--[[
	Cerebro - V2 Sync Protocol
	Extracted from CommunicationV2.lua (Phase 2a decomposition)

	Core sync state machine: lifecycle, heartbeat, fetch/snapshot exchange,
	and roster/config/selection merge logic.

	@module Sync.V2Protocol
	@version 0.12.0
	@invariant All RPC handlers require IsOfficer(sender) + epoch check + quarantine guard
	@invariant Heartbeat gated to Sync Leader during active raids; event-driven ungated
	@invariant MergeRosterData is purely additive (no deletes); purge/block handled separately
	@invariant Config merge uses per-section LWW on configLastUpdate timestamps
]]--

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

-- V2 Addon Prefix (separate from v1 "Cerebro")
local V2_PREFIX = "CerebroV2"

-- V2 Message Types (4 total)
local MSG_V2 = {
	STATE_HEARTBEAT = "HB",
	STATE_FETCH     = "FETCH",
	STATE_SNAPSHOT  = "SNAP",
	RPC_CONTROL     = "RPC",
}

-- HEARTBEAT_INTERVAL and SYNC_BARRIER_WINDOW live in Sync/RPCDispatch.lua
-- (used by CheckSyncBarrier, GetSyncStatus which are in that file)


--[[=============================================
	Module Lifecycle
===============================================]]

function CommunicationV2:OnEnable()
	-- Register v2 addon prefix
	Cerebro:RegisterComm(V2_PREFIX, "OnCommReceivedV2")

	-- Initialize v2 sync state
	self:InitSyncState()

	-- Heartbeat timer: REMOVED (v1.0.3) — SyncScheduler:OnHeartbeat() is the
	-- single owner of the 30s heartbeat timer. It includes combat-gating,
	-- quarantine guard, configurable interval, and post-combat flush logic.
	-- Keeping a second timer here caused doubled heartbeat messages.

	-- Fix 1: Listen for combat end so we can flush deferred addon-state UI refreshes.
	self:RegisterEvent("PLAYER_REGEN_ENABLED")

	-- Session-Aware Sync: detect late joiners during active sessions
	self:RegisterEvent("GROUP_ROSTER_UPDATE")

	-- Announce presence after a short delay
	self:ScheduleTimer("OnStartup", 5)

	Cerebro:Debug("CommunicationV2 enabled (prefix: " .. V2_PREFIX .. ")")
end

--- Fix 1: Post-combat flush for deferred addon-state UI refresh.
-- If a heartbeat arrived during a boss encounter and caused an addon state
-- change, we set _pendingAddonStateRefresh = true to avoid hitching mid-pull.
-- This handler fires after combat lockdown clears and delivers the refresh.
function CommunicationV2:PLAYER_REGEN_ENABLED()
	if self._pendingAddonStateRefresh then
		self._pendingAddonStateRefresh = nil
		-- Small delay to ensure lockdown is fully cleared (mirrors SyncScheduler pattern)
		self:ScheduleTimer(function()
			if not InCombatLockdown() then
				Cerebro:RefreshAllUI()
				Cerebro:Debug("V2 post-combat addon state refresh flushed")
			end
		end, 1)
	end
end

function CommunicationV2:OnDisable()
	Cerebro:Debug("CommunicationV2 disabled")
end

function CommunicationV2:InitSyncState()
	-- Initialize v2-specific sync state (separate from v1 syncState)
	local db = Cerebro.db.global
	db.syncStateV2 = db.syncStateV2 or {}

	local s = db.syncStateV2
	s.peerHashes = s.peerHashes or {}    -- { [playerName] = { rosterHash, configHash, lastSeen } }
	s.isStale = s.isStale or false       -- True when we know we're behind
	s.lastHeartbeatSent = s.lastHeartbeatSent or 0
	s.lastSnapshotReceived = s.lastSnapshotReceived or 0
	s.addonStatus = s.addonStatus or {}  -- { [playerName] = { lastSeenAt, lastKnownVersion } }

	-- Fix 10: Prune addonStatus entries older than 7 days to prevent unbounded SavedVariables growth.
	-- Runs once per login/reload (InitSyncState is called from OnEnable).
	local PRUNE_THRESHOLD = 7 * 86400  -- 7 days in seconds
	local now = time()
	for name, info in pairs(s.addonStatus) do
		if info.lastSeenAt and (now - info.lastSeenAt) > PRUNE_THRESHOLD then
			s.addonStatus[name] = nil
		end
	end

	-- Reset transient addon state cache on login/reload (in-memory only)
	self._lastKnownAddonState = {}
	-- Fix 11: Invalidate max-version cache on login/reload (peers may have updated)
	self._cachedMaxVersion = nil
	-- Fix 7: Invalidate online-member cache on login/reload
	self._onlineGuildCache = nil
	self._onlineGuildCacheAt = nil
end

--- Fix 6: Compare two semver strings numerically.
-- Returns: -1 if a < b, 0 if equal, 1 if a > b.
-- Falls back to 0 (treat as equal) if either string is nil or unparseable.
function CommunicationV2:CompareVersions(a, b)
	if not a or not b then return 0 end
	local aMaj, aMin, aPat = a:match("^(%d+)%.(%d+)%.(%d+)")
	local bMaj, bMin, bPat = b:match("^(%d+)%.(%d+)%.(%d+)")
	if not aMaj or not bMaj then return 0 end
	aMaj, aMin, aPat = tonumber(aMaj), tonumber(aMin), tonumber(aPat)
	bMaj, bMin, bPat = tonumber(bMaj), tonumber(bMin), tonumber(bPat)
	if aMaj ~= bMaj then return aMaj < bMaj and -1 or 1 end
	if aMin ~= bMin then return aMin < bMin and -1 or 1 end
	if aPat ~= bPat then return aPat < bPat and -1 or 1 end
	return 0
end

--- Fix 7: Build or return cached online guild member lookup (10s TTL).
-- Used by GetAddonState() to avoid O(N) guild scan per standings row.
function CommunicationV2:GetOnlineGuildMembers()
	local nowT = time()
	if self._onlineGuildCache and (nowT - (self._onlineGuildCacheAt or 0)) < 10 then
		return self._onlineGuildCache
	end
	local lookup = {}
	for i = 1, GetNumGuildMembers() do
		local name, _, _, _, _, _, _, _, online = GetGuildRosterInfo(i)
		if name and online then
			lookup[Cerebro:GetFullPlayerName(name)] = true
		end
	end
	self._onlineGuildCache = lookup
	self._onlineGuildCacheAt = nowT
	return lookup
end

--- Fix 11: Return the maximum non-dev addon version seen across all peers.
-- Cached; invalidated when addonStatus changes in HandleHeartbeat.
function CommunicationV2:GetMaxAddonVersion()
	if self._cachedMaxVersion then return self._cachedMaxVersion end
	local maxVersion = nil
	local s = Cerebro.db.global.syncStateV2 or {}
	for _, info in pairs(s.addonStatus or {}) do
		local v = info.lastKnownVersion
		-- Fix 12: anchor _dev$ to end of string
		if v and not v:find("_dev$") then
			if not maxVersion or self:CompareVersions(v, maxVersion) > 0 then
				maxVersion = v
			end
		end
	end
	-- Also floor max at our own version
	if Cerebro.version and not Cerebro.version:find("_dev$") then
		if not maxVersion or self:CompareVersions(Cerebro.version, maxVersion) > 0 then
			maxVersion = Cerebro.version
		end
	end
	self._cachedMaxVersion = maxVersion
	return maxVersion
end

--- Returns the current addon compliance state for a player.
-- States (in priority order):
--   "synced"   - seen within 90s AND version matches guild max
--   "outdated" - seen within 90s AND version < guild max
--   "inactive" - was seen before but >90s ago (offline/AFK)
--   "no_addon" - in guild, online, but NEVER seen in addonStatus
--   "unknown"  - guild member, never seen, cannot determine online status
-- _dev versions are excluded from max computation so dev builds
-- don't push the effective max above what live players are running.
function CommunicationV2:GetAddonState(fullName)
	-- Self-check: the local player never gets an addonStatus entry because
	-- HandleHeartbeat excludes our own messages. We know we have the addon
	-- running right now, so short-circuit to 'synced' immediately.
	if fullName == Cerebro:GetPlayerFullName() then
		return "synced"
	end

	local s = Cerebro.db.global.syncStateV2 or {}
	local addonStatus = s.addonStatus or {}
	-- Fix 4 (INACTIVE_THRESHOLD): defined once at module level via GetMaxAddonVersion.
	-- Use the module-level constant so StandingsFrame can read CommunicationV2.INACTIVE_THRESHOLD.
	local INACTIVE_THRESHOLD = CommunicationV2.INACTIVE_THRESHOLD

	-- Fix 11: Use cached max-version helper (invalidated on each HB update)
	local maxVersion = self:GetMaxAddonVersion()

	local info = addonStatus[fullName]
	if not info then
		-- Fix 7: Never-seen — use cached online lookup instead of full scan per call
		local onlineMembers = self:GetOnlineGuildMembers()
		if onlineMembers[fullName] then
			return "no_addon"  -- Online but no HB ever received
		end
		return "unknown"  -- Offline or not in guild
	end

	local now = time()
	local age = now - (info.lastSeenAt or 0)
	if age > INACTIVE_THRESHOLD then
		return "inactive"
	end

	local theirVersion = info.lastKnownVersion
	-- Fix 12: anchor _dev$ + Fix 6: use CompareVersions for semver comparison
	if theirVersion and not theirVersion:find("_dev$") and maxVersion then
		if self:CompareVersions(theirVersion, maxVersion) < 0 then
			return "outdated"
		end
	end

	return "synced"
end

-- Fix 4: Module-level INACTIVE_THRESHOLD constant.
-- Single source of truth: StandingsFrame reads CommunicationV2.INACTIVE_THRESHOLD.
CommunicationV2.INACTIVE_THRESHOLD = 90  -- seconds (15s heartbeat × 6 misses)

-- Hash functions (ComputeRosterHash, ComputeConfigHash, SimpleHash, InvalidateHashCache)
-- have been extracted to Modules/Sync/HashEngine.lua (Phase 2a decomposition).
-- They are loaded via TOC before this file and attach to this module via GetModule.

--[[=============================================
	Startup & Heartbeat
===============================================]]

function CommunicationV2:OnStartup()
	-- On login/reload, if officer, send an initial heartbeat
	if Cerebro:IsOfficerSafe() then
		self:SendHeartbeat()
	else
		-- Non-officers: listen passively. If no heartbeat received after 15s,
		-- request sync via STATE_FETCH to guild
		self:ScheduleTimer("MaybeRequestInitialSync", 15)
	end
end

function CommunicationV2:MaybeRequestInitialSync()
	local s = Cerebro.db.global.syncStateV2 or {}
	if s.lastSnapshotReceived and s.lastSnapshotReceived > 0 then
		return  -- Already received data from an officer
	end

	-- No officer heartbeat received — try to fetch
	if IsInGuild() then
		Cerebro:Debug("V2: No heartbeat received, broadcasting STATE_FETCH to guild")
		self:SendFetch(nil, "GUILD")
	end
end

-- RETIRED (v1.0.3): OnHeartbeatTick removed — SyncScheduler:OnHeartbeat() is the
-- single heartbeat timer owner with combat-gating, quarantine guard, and post-combat flush.
-- function CommunicationV2:OnHeartbeatTick()
-- 	if not Cerebro:IsOfficerSafe() then return end
-- 	if Cerebro.modules.SyncScheduler and not Cerebro.modules.SyncScheduler:ShouldSync() then
-- 		return
-- 	end
-- 	if Cerebro:IsQuarantined() then
-- 		return
-- 	end
-- 	self:SendHeartbeat()
-- end

--[[=============================================
	STATE_HEARTBEAT — Hash broadcast

	Officers broadcast their state hashes periodically.
	All clients compare and trigger STATE_FETCH if divergent.
===============================================]]

function CommunicationV2:SendHeartbeat()
	local rosterHash, playerCount = self:ComputeRosterHash()
	local configHash = self:ComputeConfigHash()

	local payload = {
		epoch = Cerebro.db.global.resetEpoch or 0,
		rosterHash = rosterHash,
		configHash = configHash,
		playerCount = playerCount,
		totalBossKills = Cerebro.db.global.totalBossKills or 0,
		version = Cerebro.version,
		timestamp = GetServerTime and GetServerTime() or time(),
	}

	-- Send to appropriate channel
	-- LE_PARTY_CATEGORY_HOME excludes LFR/instance groups where the RAID addon channel is blocked
	if IsInRaid(LE_PARTY_CATEGORY_HOME) then
		self:SendV2Message(MSG_V2.STATE_HEARTBEAT, payload, "RAID")
	elseif IsInGuild() then
		self:SendV2Message(MSG_V2.STATE_HEARTBEAT, payload, "GUILD")
	end

	local s = Cerebro.db.global.syncStateV2 or {}
	s.lastHeartbeatSent = time()
	Cerebro.db.global.syncStateV2 = s

	Cerebro:Debug(string.format("V2 HB sent: roster=%s config=%s players=%d",
		rosterHash, configHash, playerCount))

	-- Diagnostic trace
	local diag = Cerebro:GetModule("Diagnostics", true)
	if diag and diag.TraceEvent then
		pcall(diag.TraceEvent, diag, "HB_SENT", { rHash = rosterHash, cHash = configHash, peers = playerCount })
	end
end

--- Build C: Fire an immediate heartbeat after an officer state mutation.
-- Debounced (2s) to coalesce rapid sequential changes (e.g., batch EP award)
-- into a single heartbeat. Much cheaper than SyncToRaid (full snapshot).
-- Call this after: admit, purge, block, EP/GP award, session start/end, decay, reset.
function CommunicationV2:NotifyStateChange(reason)
	if not Cerebro:IsOfficerSafe() then return end

	-- Debounce: cancel any pending notification and schedule a new one in 2s
	if self._notifyTimer then
		self:CancelTimer(self._notifyTimer)
	end
	self._notifyTimer = self:ScheduleTimer(function()
		self._notifyTimer = nil
		self:SendHeartbeat()
		Cerebro:Debug("V2 immediate HB (state change: " .. (reason or "?") .. ")")
	end, 2)
end

--- Print a detailed hash breakdown for cross-client debugging.
-- Strictly read-only diagnostic — no mutations, no side effects.
function CommunicationV2:DebugHash()
	-- Roster hash breakdown
	local rosterHash, playerCount = self:ComputeRosterHash()
	local configHash = self:ComputeConfigHash()
	local sessionID = Cerebro.db.global.currentSession
		and Cerebro.db.global.currentSession.sessionID or "NONE"

	Cerebro:Print("=== V2 Hash Diagnostic ===")
	Cerebro:Print(string.format("  |cff00ccffRoster Hash:|r %s  |cff00ccffConfig Hash:|r %s", rosterHash, configHash))
	Cerebro:Print(string.format("  |cff00ccffPlayers:|r %d  |cff00ccffSession:|r %s", playerCount, sessionID))
	Cerebro:Print(string.format("  |cff00ccffEpoch:|r %d  |cff00ccffBossKills:|r %d",
		Cerebro.db.global.resetEpoch or 0, Cerebro.db.global.totalBossKills or 0))

	-- Player canonical lines (first 5 + last 5 for comparison)
	local players = Cerebro:GetFilteredPlayersForSync()
	local names = {}
	for name in pairs(players) do
		names[#names + 1] = name
	end
	table.sort(names)

	local function formatLine(idx)
		local name = names[idx]
		local p = players[name]
		return string.format("  [%d] %s: ep=%.0f gp=%.0f status=%s lastUpdate=%.0f",
			idx, Cerebro:GetShortPlayerName(name),
			p.ep or 0, p.gp or 0, p.status or "Pending", p.lastUpdate or 0)
	end

	local shown = math.min(5, #names)
	Cerebro:Print(string.format("  --- First %d players ---", shown))
	for i = 1, shown do
		Cerebro:Print(formatLine(i))
	end

	if #names > 10 then
		Cerebro:Print(string.format("  ... (%d more) ...", #names - 10))
	end

	if #names > 5 then
		local lastStart = math.max(shown + 1, #names - 4)
		Cerebro:Print(string.format("  --- Last %d players ---", #names - lastStart + 1))
		for i = lastStart, #names do
			Cerebro:Print(formatLine(i))
		end
	end

	-- Peer hash comparison (from last heartbeat exchange)
	local s = Cerebro.db.global.syncStateV2 or {}
	local peerHashes = s.peerHashes or {}
	local peerCount = 0
	for _ in pairs(peerHashes) do peerCount = peerCount + 1 end

	if peerCount > 0 then
		Cerebro:Print(string.format("  --- Known Peers (%d) ---", peerCount))
		for peer, info in pairs(peerHashes) do
			local rMatch = (info.rosterHash == rosterHash) and "|cff00ff00MATCH|r" or "|cffff4444DIFFER|r"
			local cMatch = (info.configHash == configHash) and "|cff00ff00MATCH|r" or "|cffff4444DIFFER|r"
			Cerebro:Print(string.format("  %s: roster=%s (%s) config=%s (%s) players=%s",
				Cerebro:GetShortPlayerName(peer),
				info.rosterHash or "?", rMatch,
				info.configHash or "?", cMatch,
				tostring(info.playerCount or "?")))
		end
	end

	Cerebro:Print("=========================")
end

--[[=============================================
	EPOCH MISMATCH HANDLER

	Shared helper for HandleHeartbeat, HandleSnapshot, HandleFetch.
	Mirrors V1 HandleSyncData epoch logic:
	  - payload < local: reject (stale)
	  - payload > local + officer: backup, wipe, accept
	  - payload > local + non-officer: quarantine
	  - payload == local: proceed
===============================================]]

function CommunicationV2:HandleEpochMismatch(sender, payloadEpoch, context)
	local localEpoch = Cerebro.db.global.resetEpoch or 0
	if payloadEpoch == localEpoch then return true end

	if payloadEpoch < localEpoch then
		Cerebro:Debug(string.format("V2 %s: stale epoch from %s (theirs=%d < mine=%d)",
			context, sender, payloadEpoch, localEpoch))
		return false
	end

	-- payloadEpoch > localEpoch: epoch upgrade needed
	local senderIsOfficer = Cerebro:IsOfficer(sender)
	if senderIsOfficer then
		-- Auto-backup before wipe (same pattern as V1 HandleSyncData)
		Cerebro.db.global.backup = {
			timestamp = time(),
			version = Cerebro.version,
			players = Cerebro:CopyTable(Cerebro.db.global.players),
			transactions = Cerebro:CopyTable(Cerebro.db.global.transactions),
			resetEpoch = localEpoch,
		}

		-- Accept epoch upgrade + wipe local data
		Cerebro.db.global.players = {}
		Cerebro.db.global.transactions = {}
		Cerebro.db.global.awards = {}
		Cerebro.db.global.history = {}
		Cerebro.db.global.seasonBossKills = 0
		Cerebro.db.global.totalBossKills = 0
		Cerebro.db.global.currentSession = nil
		Cerebro.db.global.resetEpoch = payloadEpoch
		Cerebro.db.global.epochQuarantine = nil

		-- Reset receipt cursor state
		Cerebro:ResetReceiptCursorState("V2 epoch upgrade")

		if Cerebro.Ledger then
			Cerebro.Ledger:AddAuditLog("EPOCH_UPGRADE",
				string.format("V2 %s from %s: epoch %d -> %d",
					context, sender, localEpoch, payloadEpoch))
		end

		Cerebro:Print(string.format(
			"|cffff8800[Epoch Upgrade]|r Accepted epoch %d from %s (was %d). Data reset.",
			payloadEpoch, sender, localEpoch))

		-- Immediately request full data from the officer who triggered the upgrade.
		-- Without this, the client has empty player tables and shows "Pending"
		-- until the next heartbeat cycle detects hash mismatch (up to 3+ minutes).
		Cerebro:ScheduleTimer(function()
			if self.SendFetch then
				local whisperTarget = Cerebro:GetShortPlayerName(sender)
				self:SendFetch(whisperTarget, "WHISPER")
				Cerebro:Debug("V2 epoch upgrade: auto-fetch sent to " .. whisperTarget)
			end
		end, 1)  -- 1s delay: let the officer's reset finish before requesting

		-- Refresh UI to reflect the epoch wipe. This may trigger GetPlayerData()
		-- auto-creation with status="Pending" for the local player, but the
		-- post-merge self-correction in HandleSnapshot (line ~720) fixes that
		-- when the officer's snapshot arrives with the correct status.
		Cerebro:RefreshAllUI()
		return true
	else
		-- Non-officer: enter quarantine until officer confirms
		Cerebro.db.global.epochQuarantine = {
			at = time(),
			expectedEpoch = payloadEpoch,
			triggeredBy = sender,
		}
		Cerebro:Debug(string.format("V2 %s: epoch quarantine (raider %s has epoch %d, mine=%d)",
			context, sender, payloadEpoch, localEpoch))
		return false
	end
end

function CommunicationV2:HandleHeartbeat(sender, data)
	-- Receive-side dedup: skip if we processed a HB from this sender in the last 5 seconds.
	-- Multiple SendHeartbeat() callers (timer, SyncToRaid, session-start, force broadcast)
	-- can fire close together, creating duplicate messages with identical hash data.
	local HB_DEDUP_WINDOW = 5
	self._lastHBReceived = self._lastHBReceived or {}
	local dedupNow = time()
	local lastSeen = self._lastHBReceived[sender] or 0
	if (dedupNow - lastSeen) < HB_DEDUP_WINDOW then
		return -- Already processed recently, hash data is identical
	end
	self._lastHBReceived[sender] = dedupNow
	if not data then return end

	-- Self-exclusion
	local myName = Cerebro:GetPlayerFullName()
	if sender == myName or Cerebro:GetShortPlayerName(sender) == Cerebro:GetShortPlayerName(myName) then
		return
	end

	-- Epoch check: upgrade or reject via shared handler
	if data.epoch then
		if not self:HandleEpochMismatch(sender, data.epoch, "HB") then return end
	end

	-- Track peer hash (Fix 4: also store epoch so CheckSeasonChange can detect
	-- if a peer officer already performed the season reset before we fire ours)
	local s = Cerebro.db.global.syncStateV2 or {}
	s.peerHashes = s.peerHashes or {}
	s.peerHashes[sender] = {
		rosterHash = data.rosterHash,
		configHash = data.configHash,
		playerCount = data.playerCount,
		epoch = data.epoch or 0,
		lastSeen = time(),
	}

	-- Addon status tracking: record last seen timestamp and version per peer.
	-- Stored in syncStateV2.addonStatus (NOT in db.global.players) so it
	-- cannot affect the roster/config hash by design.
	-- IMPORTANT: AceComm passes `sender` as short name on same-realm (e.g. "Mysterthunt").
	-- Normalize to full Name-Realm format so addonStatus keys match db.global.players keys.
	local fullSender = Cerebro:GetFullPlayerName(sender) or sender
	s.addonStatus = s.addonStatus or {}
	s.addonStatus[fullSender] = {
		lastSeenAt = time(),
		lastKnownVersion = data.version,
	}
	-- Fix 11: Invalidate max-version cache whenever addonStatus changes
	self._cachedMaxVersion = nil
	-- Fix 7: Invalidate online-member cache on any heartbeat (peer may have come online)
	self._onlineGuildCache = nil
	self._onlineGuildCacheAt = nil

	-- Fix 1: Real-time reactivity with combat guard.
	-- Detect addon state transitions and refresh UI, but NEVER during combat lockdown
	-- (heartbeats arrive mid-encounter; RefreshAllUI() causes hitching on boss pulls).
	if Cerebro:IsOfficerSafe() then
		self._lastKnownAddonState = self._lastKnownAddonState or {}
		local newState = self:GetAddonState(fullSender)
		if self._lastKnownAddonState[fullSender] ~= newState then
			self._lastKnownAddonState[fullSender] = newState
			-- Debounce: coalesce rapid multi-peer HB bursts into a single redraw
			if self._addonStateRefreshTimer then
				self:CancelTimer(self._addonStateRefreshTimer)
			end
			self._addonStateRefreshTimer = self:ScheduleTimer(function()
				self._addonStateRefreshTimer = nil
				-- Fix 1: If in combat, defer refresh until post-combat flush
				if InCombatLockdown() then
					self._pendingAddonStateRefresh = true
					Cerebro:Debug("V2 addon state change for " .. fullSender .. " deferred (combat lockdown)")
					return
				end
				Cerebro:RefreshAllUI()
				Cerebro:Debug("V2 addon state change for " .. fullSender .. ": " .. newState .. " → UI refresh")
			end, 1)
		end
	end

	Cerebro.db.global.syncStateV2 = s

	-- Echo our hashes back to the sender (so synccheck and GetSyncStatus work)
	-- Only echo if this is NOT already an echo (prevent infinite loop)
	-- Rate-limited: max once per peer per 90 seconds to avoid O(N²) message storms
	local ECHO_COOLDOWN = 90
	if not data.isEcho then
		self._lastEchoSent = self._lastEchoSent or {}
		local now = time()
		local lastEcho = self._lastEchoSent[sender] or 0
		if (now - lastEcho) >= ECHO_COOLDOWN then
			self._lastEchoSent[sender] = now
			local myRosterHash, myPlayerCount = self:ComputeRosterHash()
			local myConfigHash = self:ComputeConfigHash()
			local echoPayload = {
				epoch = Cerebro.db.global.resetEpoch or 0,
				rosterHash = myRosterHash,
				configHash = myConfigHash,
				playerCount = myPlayerCount,
				version = Cerebro.version,
				timestamp = GetServerTime and GetServerTime() or time(),
				isEcho = true,
			}
			-- CRITICAL FIX: Retain full name for SendV2Message target (so IsPlayerReachable works)
			-- SendV2Message will strip it to short name right before handing off to AceComm WHISPER
			local printTarget = Cerebro:GetShortPlayerName(sender)
			self:SendV2Message(MSG_V2.STATE_HEARTBEAT, echoPayload, "WHISPER", sender)
			-- Diagnostic trace: user-visible with /cb debug simple
			Cerebro:DebugSimple(string.format("V2 echo → %s", printTarget))
			Cerebro:Debug(string.format("V2 HB echo sent to %s (roster=%s config=%s)",
				printTarget, myRosterHash, myConfigHash))
		end
	else
		-- Diagnostic trace: received an echo from a peer
		Cerebro:DebugSimple(string.format("V2 echo ← %s", Cerebro:GetShortPlayerName(sender)))
	end

	-- Compare hashes
	local myRosterHash = self:ComputeRosterHash()
	local myConfigHash = self:ComputeConfigHash()

	local rosterMatch = (data.rosterHash == myRosterHash)
	local configMatch = (data.configHash == myConfigHash)

	if rosterMatch and configMatch then
		-- Hashes match — we're in sync
		s.isStale = false
		-- Reset backoff counter on convergence
		self._fetchRetries = self._fetchRetries or {}
		self._fetchRetries[sender] = nil
		Cerebro:Debug(string.format("V2 HB from %s: MATCH (roster=%s config=%s)",
			sender, data.rosterHash, data.configHash))
		-- Diagnostic trace
		local diag = Cerebro:GetModule("Diagnostics", true)
		if diag and diag.TraceEvent then
			pcall(diag.TraceEvent, diag, "HB_MATCH", { peer = Cerebro:GetShortPlayerName(sender) })
		end
	else
		-- Hashes differ — request full snapshot (with backoff)
		s.isStale = true

		-- Backoff: track consecutive mismatches per peer
		-- Prevent rapid retries from echo storms (e.g., getting 3 mismatched HBs in 5 seconds)
		-- by silently dropping mismatches if we already sent a fetch/push within 15 seconds.
		local now = time()
		local lastFetch = self._lastFetchTo and self._lastFetchTo[sender] or 0
		local lastPush = self._lastSnapshotTo and self._lastSnapshotTo[sender] or 0
		if (now - lastFetch < 15) or (now - lastPush < 15) then
			-- Diagnostic trace (optional): Cerebro:Debug("V2 HB MISMATCH: retries paused for cooldown")
			return
		end

		self._fetchRetries = self._fetchRetries or {}
		self._fetchRetryHashes = self._fetchRetryHashes or {}
		local retries = self._fetchRetries[sender] or 0
		local prevHash = self._fetchRetryHashes[sender]

		-- Reset retries if peer's hash changed (they got a partial update)
		if prevHash and prevHash ~= data.rosterHash then
			retries = 0
		end
		self._fetchRetryHashes[sender] = data.rosterHash

		-- After 3 consecutive failures, back off — but auto-reset after 5 minutes
		local MAX_RETRIES_BEFORE_BACKOFF = 3
		local BACKOFF_RESET_SECONDS = 300 -- 5 minutes
		if retries >= MAX_RETRIES_BEFORE_BACKOFF then
			-- Check if backoff TTL has expired → auto-reset
			self._fetchRetryBackoffAt = self._fetchRetryBackoffAt or {}
			if not self._fetchRetryBackoffAt[sender] then
				self._fetchRetryBackoffAt[sender] = now
			end
			if (now - self._fetchRetryBackoffAt[sender]) >= BACKOFF_RESET_SECONDS then
				-- TTL expired — reset and allow re-attempt
				self._fetchRetries[sender] = 0
				self._fetchRetryBackoffAt[sender] = nil
				self._fetchRetryHashes[sender] = nil
				Cerebro:Debug(string.format("V2 HB from %s: backoff reset (TTL expired after %ds)",
					sender, BACKOFF_RESET_SECONDS))
				-- Fall through to normal mismatch handling below
				retries = 0
			else
				Cerebro:Debug(string.format("V2 HB from %s: MISMATCH (suppressed, %d retries — retry in %ds)",
					sender, retries, BACKOFF_RESET_SECONDS - (now - self._fetchRetryBackoffAt[sender])))
				return
			end
		end

		self._fetchRetries[sender] = retries + 1
		Cerebro:Debug(string.format("V2 HB from %s: MISMATCH (roster:%s/%s config:%s/%s) → %s (attempt %d/%d)",
			sender,
			data.rosterHash, myRosterHash,
			data.configHash, myConfigHash,
			Cerebro:IsOfficer(sender) and "FETCH" or "PUSH",
			retries + 1, MAX_RETRIES_BEFORE_BACKOFF))
		Cerebro:DebugSimple(string.format("Sync: data mismatch with %s, %s...",
			Cerebro:GetShortPlayerName(sender) or sender,
			Cerebro:IsOfficer(sender) and "requesting update" or "pushing data"))
		-- Diagnostic trace
		local diag = Cerebro:GetModule("Diagnostics", true)
		if diag and diag.TraceEvent then
			local action = Cerebro:IsOfficer(sender) and "FETCH" or "PUSH"
			pcall(diag.TraceEvent, diag, "HB_MISMATCH", { peer = Cerebro:GetShortPlayerName(sender), rHash = data.rosterHash, action = action })
		end

		if Cerebro:IsOfficer(sender) then
			-- Officer-to-officer: bidirectional exchange
			-- FETCH their data (they may have updates we lack)
			-- Option B: Jitter 0-8s to stagger simultaneous fetches from multiple officers
			local jitterB1 = math.random(0, 8)
			Cerebro:ScheduleTimer(function()
				self:SendFetch(sender, "WHISPER")
			end, jitterB1)
			-- PUSH our data (we may have updates they lack, e.g. season reset)
			now = time()
			self._lastSnapshotTo = self._lastSnapshotTo or {}
			if not self._lastSnapshotTo[sender] or (now - self._lastSnapshotTo[sender]) >= 30 then
				self._lastSnapshotTo[sender] = now
				local snapshot = self:BuildSnapshot(true, true)
				snapshot.epoch = Cerebro.db.global.resetEpoch or 0
				snapshot.version = Cerebro.version
				snapshot.sender = Cerebro:GetPlayerFullName()
				snapshot.timestamp = GetServerTime and GetServerTime() or time()
				self:SendV2Message(MSG_V2.STATE_SNAPSHOT, snapshot, "WHISPER", sender)
				Cerebro:Debug(string.format("V2 officer exchange: PUSH snapshot to %s",
					Cerebro:GetShortPlayerName(sender)))
			end
		elseif Cerebro:IsOfficerSafe() then
			-- OPTION A: Officer-to-raider — whisper only the divergent peer.
			-- Previously this was a GUILD/RAID broadcast, which disrupted all other
			-- already-synced officers every time any single stale raider logged in.
			-- A targeted WHISPER is sufficient: the raider gets the same data,
			-- and synced peers are left completely undisturbed.
			now = time()
			self._lastSnapshotTo = self._lastSnapshotTo or {}
			if not self._lastSnapshotTo[sender] or (now - self._lastSnapshotTo[sender]) >= 30 then
				self._lastSnapshotTo[sender] = now
				local snapshot = self:BuildSnapshot(true, true)
				snapshot.epoch = Cerebro.db.global.resetEpoch or 0
				snapshot.version = Cerebro.version
				snapshot.sender = Cerebro:GetPlayerFullName()
				snapshot.timestamp = GetServerTime and GetServerTime() or time()
				self:SendV2Message(MSG_V2.STATE_SNAPSHOT, snapshot, "WHISPER", sender)
				Cerebro:Debug(string.format("V2 PUSH whisper to %s (triggered by mismatch, targeted)",
					Cerebro:GetShortPlayerName(sender)))
			else
				Cerebro:Debug(string.format("V2 PUSH to %s skipped (cooldown, %ds ago)",
					Cerebro:GetShortPlayerName(sender), now - self._lastSnapshotTo[sender]))
			end
		else
			-- Non-officer receiving mismatch from non-officer: request from officers.
			-- Option B: Jitter 0-8s to stagger the thundering-herd of simultaneous FETCHes
			-- from 20 raiders all detecting the same hash mismatch at the same moment.
			local jitterB2 = math.random(0, 8)
			Cerebro:ScheduleTimer(function()
				self:SendFetch(sender, "WHISPER")
			end, jitterB2)
		end
	end
end

--[[=============================================
	STATE_FETCH — Request full state from officer
===============================================]]

function CommunicationV2:SendFetch(target, channel)
	-- Per-peer cooldown: avoid sending multiple FETCHes to the same peer within 30s
	-- Only applies to targeted (WHISPER) fetches, not broadcast fetches (target=nil)
	if target then
		local now = time()
		self._lastFetchTo = self._lastFetchTo or {}
		if self._lastFetchTo[target] and (now - self._lastFetchTo[target]) < 30 then
			Cerebro:Debug(string.format("V2 FETCH to %s skipped (cooldown, %ds ago)",
				target, now - self._lastFetchTo[target]))
			return
		end
		self._lastFetchTo[target] = now
	end

	local myRosterHash = self:ComputeRosterHash()
	local myConfigHash = self:ComputeConfigHash()

	local payload = {
		epoch = Cerebro.db.global.resetEpoch or 0,
		rosterHash = myRosterHash,
		configHash = myConfigHash,
		version = Cerebro.version,
	}

	channel = channel or "WHISPER"
	self:SendV2Message(MSG_V2.STATE_FETCH, payload, channel, target)

	Cerebro:Debug(string.format("V2 FETCH sent to %s via %s",
		target or "all", channel))
	-- Diagnostic trace
	local diag = Cerebro:GetModule("Diagnostics", true)
	if diag and diag.TraceEvent then
		pcall(diag.TraceEvent, diag, "FETCH_SENT", { peer = target or "all" })
	end
end

function CommunicationV2:HandleFetch(sender, data)
	if not data then return end

	-- Only officers respond to fetch requests
	if not Cerebro:IsOfficerSafe() then return end

	-- OPTION E: Sync Leader gate — only the elected leader responds when inside a raid.
	-- Other officers passively receive and merge the resulting snapshot.
	-- Outside of a raid (guild-only, solo officer), IsSyncLeader() always returns true,
	-- so all officers respond normally with no change in behavior.
	if IsInRaid() and Cerebro.modules.SyncScheduler
		and not Cerebro.modules.SyncScheduler:IsSyncLeader() then
		Cerebro:Debug("V2 FETCH from " .. sender .. ": deferring to Sync Leader")
		return
	end

	-- Self-exclusion
	local myName = Cerebro:GetPlayerFullName()
	if sender == myName or Cerebro:GetShortPlayerName(sender) == Cerebro:GetShortPlayerName(myName) then return end

	-- Fresh-install suppression: don't send authoritative snapshots with empty roster
	-- This prevents a fresh-install officer from wiping raider data
	local myPlayerCount = select(2, self:ComputeRosterHash())
	if myPlayerCount == 0 then
		Cerebro:Debug("V2 FETCH: skipping response (empty roster, likely fresh install)")
		return
	end

	-- Per-peer cooldown: avoid sending duplicate SNAPSHOTs to the same peer within 30s
	local now = time()
	self._lastSnapshotTo = self._lastSnapshotTo or {}
	if self._lastSnapshotTo[sender] and (now - self._lastSnapshotTo[sender]) < 30 then
		Cerebro:Debug(string.format("V2 SNAPSHOT to %s skipped (cooldown, %ds ago)",
			sender, now - self._lastSnapshotTo[sender]))
		return
	end

	-- Epoch check: upgrade or reject via shared handler
	if data.epoch then
		if not self:HandleEpochMismatch(sender, data.epoch, "FETCH") then return end
	end

	-- Quarantine guard
	if Cerebro:IsQuarantined() then
		Cerebro:Debug("V2 FETCH blocked: quarantine active")
		return
	end

	-- Determine what to send (both, roster only, or config only)
	local myRosterHash = self:ComputeRosterHash()
	local myConfigHash = self:ComputeConfigHash()

	local includeRoster = (data.rosterHash ~= myRosterHash)
	-- Always include config when sending roster to ensure receiver has matching
	-- configuration for consistent behavior.
	local includeConfig = (data.configHash ~= myConfigHash) or includeRoster

	-- If both match, send nothing (peer is already in sync)
	if not includeRoster and not includeConfig then
		Cerebro:Debug("V2 FETCH from " .. sender .. ": already in sync, no snapshot needed")
		return
	end

	-- Record cooldown timestamp
	self._lastSnapshotTo[sender] = now

	-- Build and send snapshot
	local snapshot = self:BuildSnapshot(includeRoster, includeConfig)
	self:SendV2Message(MSG_V2.STATE_SNAPSHOT, snapshot, "WHISPER", sender)

	Cerebro:Debug(string.format("V2 SNAPSHOT sent to %s (roster=%s config=%s)",
		sender, tostring(includeRoster), tostring(includeConfig)))
end

--[[=============================================
	STATE_SNAPSHOT — Full state payload
===============================================]]

function CommunicationV2:BuildSnapshot(includeRoster, includeConfig)
	local snapshot = {
		version = Cerebro.version,
		epoch = Cerebro.db.global.resetEpoch or 0,
		scope = Cerebro.db.global.scope,
		totalBossKills = Cerebro.db.global.totalBossKills or 0,
		timestamp = GetServerTime and GetServerTime() or time(),
		-- Fix 2: carry the sender's lastKnownSeasonID so the receiver can update
		-- db.global.lastKnownSeasonID before CheckSeasonChange() fires, preventing
		-- a duplicate season reset when the 60s timer eventually fires.
		lastKnownSeasonID = Cerebro.db.global.lastKnownSeasonID,
		includes = {
			roster = includeRoster or false,
			config = includeConfig or false,
		},
	}

	if includeRoster then
		-- Filter out synthetic test players
		snapshot.players = Cerebro:GetFilteredPlayersForSync()
		snapshot.purgedPlayers = Cerebro.db.global.purgedPlayers or {}
		snapshot.blockedPlayers = Cerebro.db.global.blockedPlayers or {}
		snapshot.currentSession = Cerebro.db.global.currentSession
		-- Normalize: session uses startTime but merge guard historically checked startedAt.
		-- Include both fields so peers on any version compare correctly.
		if snapshot.currentSession and snapshot.currentSession.startTime
			and not snapshot.currentSession.startedAt then
			snapshot.currentSession.startedAt = snapshot.currentSession.startTime
		end
		snapshot.customEP = Cerebro.db.global.customEP
		snapshot.signupAwards = Cerebro:CopyTable(Cerebro.db.global.signupAwards or {})

		-- Include hash for verification on receive side
		snapshot.rosterHash = self:ComputeRosterHash()
	end

	if includeConfig then
		snapshot.config = Cerebro:CopyTable(Cerebro.db.global.config)
		snapshot.configHash = self:ComputeConfigHash()
	end

	return snapshot
end

function CommunicationV2:HandleSnapshot(sender, data)
	if not data then return end

	-- Self-exclusion
	local myName = Cerebro:GetPlayerFullName()
	if sender == myName or Cerebro:GetShortPlayerName(sender) == Cerebro:GetShortPlayerName(myName) then return end

	-- Epoch check: upgrade or reject via shared handler
	if data.epoch then
		if not self:HandleEpochMismatch(sender, data.epoch, "SNAP") then return end
	end

	-- Quarantine guard
	if Cerebro:IsQuarantined() then
		Cerebro:Debug("V2 SNAPSHOT blocked: quarantine active")
		return
	end

	Cerebro:Debug(string.format("V2 SNAPSHOT received from %s (roster=%s config=%s)",
		sender,
		tostring(data.includes and data.includes.roster),
		tostring(data.includes and data.includes.config)))

	-- CRITICAL ORDER: Purge/block ledger merges FIRST (zombie prevention)
	-- SECURITY: Only accept purge/block data from officers. Non-officers should
	-- never send snapshots (all send paths are officer-guarded), but this is
	-- defense-in-depth to prevent stale purge data from non-officer clients.
	local senderIsOfficer = Cerebro:IsOfficer(sender)

	if senderIsOfficer and data.purgedPlayers then
		Cerebro.db.global.purgedPlayers = Cerebro.db.global.purgedPlayers or {}
		for name, purgeTime in pairs(data.purgedPlayers) do
			-- Normalize realm name (strip spaces to match API format)
			local normalized = Cerebro:GetFullPlayerName(name) or name
			local localTime = Cerebro.db.global.purgedPlayers[normalized] or 0
			if purgeTime > localTime then
				Cerebro.db.global.purgedPlayers[normalized] = purgeTime
			end
		end
	elseif data.purgedPlayers then
		Cerebro:Debug("V2 SNAPSHOT: ignoring purge data from non-officer " .. tostring(sender))
	end

	-- Merge blocked players (union — keep latest timestamp per entry)
	if senderIsOfficer and data.blockedPlayers then
		Cerebro.db.global.blockedPlayers = Cerebro.db.global.blockedPlayers or {}
		for name, blockTime in pairs(data.blockedPlayers) do
			-- Normalize realm name (strip spaces to match API format)
			local normalized = Cerebro:GetFullPlayerName(name) or name
			local localTime = Cerebro.db.global.blockedPlayers[normalized] or 0
			if blockTime > localTime then
				Cerebro.db.global.blockedPlayers[normalized] = blockTime
			end
		end
	elseif data.blockedPlayers then
		Cerebro:Debug("V2 SNAPSHOT: ignoring block data from non-officer " .. tostring(sender))
	end

	-- Zombie cleanup: delete local player records now covered by purge/block directives.
	-- Without this, a purge directive arrives but the existing local record persists,
	-- causing permanent participant count divergence between officers.
	for name in pairs(Cerebro.db.global.purgedPlayers or {}) do
		if Cerebro.db.global.players[name] then
			Cerebro.db.global.players[name] = nil
			Cerebro:Debug("Zombie cleanup: removed purged player " .. name)
		end
	end
	for name in pairs(Cerebro.db.global.blockedPlayers or {}) do
		if Cerebro.db.global.players[name] then
			Cerebro.db.global.players[name] = nil
			Cerebro:Debug("Zombie cleanup: removed blocked player " .. name)
		end
	end

	-- Merge roster data (LWW on lastUpdate)
	local rosterMerged = 0
	if data.includes and data.includes.roster and data.players then
		rosterMerged = self:MergeRosterData(data.players, sender)
		-- Also merge selection state (LWW on customEP.lastUpdate) — officer only
		if senderIsOfficer and data.customEP then
			self:MergeSelectionData(data.customEP, sender)
		elseif data.customEP then
			Cerebro:Debug("V2 SNAPSHOT: ignoring selection data from non-officer " .. tostring(sender))
		end
	end

	-- Merge config data — officer only
	if senderIsOfficer and data.includes and data.includes.config and data.config then
		self:MergeConfigData(data.config, sender)
	elseif data.includes and data.includes.config and data.config then
		Cerebro:Debug("V2 SNAPSHOT: ignoring config data from non-officer " .. tostring(sender))
	end

	-- Merge signupAwards (union merge: keep latest per eventKey) — officer only
	if senderIsOfficer and data.signupAwards then
		self:MergeSignupAwards(data.signupAwards, sender)
	elseif data.signupAwards then
		Cerebro:Debug("V2 SNAPSHOT: ignoring signupAwards from non-officer " .. tostring(sender))
	end

	-- Update sync metadata
	if data.totalBossKills ~= nil then
		local localKills = Cerebro.db.global.totalBossKills or 0
		-- Officers are authoritative: always accept their totalBossKills
		-- Non-officers: only accept higher values (monotonic)
		if Cerebro:IsOfficer(sender) then
			Cerebro.db.global.totalBossKills = data.totalBossKills
		elseif data.totalBossKills > localKills then
			Cerebro.db.global.totalBossKills = data.totalBossKills
		end
	end

	-- Update session state from snapshot
	-- Sessions are SHARED STATE between officers — all officers should see the same
	-- active session. When sessions disagree, use LWW (startedAt) to pick the newer one.
	-- RULES:
	--   0. Non-officers ignore session data entirely.
	--   1. Only accept session state from officers.
	--   2. Don't re-inject a session we just ended (stale snapshot race, 60s window).
	--   3. When sessions differ, newer startedAt wins (LWW).
	--   4. If peer has nil session and we have one, clear ours (session was ended).
	if data.includes and data.includes.roster and Cerebro:IsOfficerSafe() then
		local incomingSession = data.currentSession
		local localSession = Cerebro.db.global.currentSession
		local sessionMod = Cerebro:GetModule("Session", true)
		local justEnded = sessionMod and sessionMod._lastEndedAt
			and (time() - sessionMod._lastEndedAt) < 60

		if not Cerebro:IsOfficer(sender) then
			Cerebro:Debug("V2 SNAPSHOT: ignoring session from non-officer " .. tostring(sender))
		elseif justEnded and incomingSession
			and sessionMod._lastEndedSessionID == incomingSession.sessionID then
			-- Stale snapshot: peer still had the session we just ended. Ignore.
			Cerebro:Debug("V2 SNAPSHOT: ignoring stale session re-injection ("
				.. tostring(incomingSession.sessionID) .. ") - ended " .. (time() - sessionMod._lastEndedAt) .. "s ago")
		elseif incomingSession == nil and localSession then
			-- Officer peer has no session but we do — session was ended.
			-- Guard: don't clear if WE just started (our session is newer than peer's snapshot)
			-- FIX: StartSession() uses 'startTime', not 'startedAt'. The old code
			-- always read nil → 0, so any peer snapshot beat it and killed the session.
			local localStart = localSession.startTime or localSession.startedAt or 0
			local snapshotTime = data.timestamp or 0
			if snapshotTime > localStart then
				Cerebro:Debug("V2 SNAPSHOT: clearing local session (peer " .. tostring(sender) .. " has no session)")
				Cerebro.db.global.currentSession = nil
				Cerebro:RefreshAllUI()
			else
				Cerebro:Debug("V2 SNAPSHOT: keeping local session (started after peer's snapshot)")
			end
		elseif incomingSession and incomingSession.sessionID then
			if not localSession or not localSession.sessionID then
				-- No local session — adopt incoming
				Cerebro.db.global.currentSession = incomingSession
				Cerebro:Debug("V2 SNAPSHOT: adopted session from officer " .. tostring(sender))
				Cerebro:RefreshAllUI()
			elseif incomingSession.sessionID ~= localSession.sessionID then
				-- Different sessions — LWW by startedAt
				local incomingStart = incomingSession.startedAt or 0
				local localStart = localSession.startedAt or 0
				if incomingStart > localStart then
					Cerebro.db.global.currentSession = incomingSession
					Cerebro:Debug("V2 SNAPSHOT: replaced session (incoming newer: " .. incomingStart .. " > " .. localStart .. ")")
					Cerebro:RefreshAllUI()
				else
					Cerebro:Debug("V2 SNAPSHOT: keeping local session (local newer: " .. localStart .. " >= " .. incomingStart .. ")")
				end
			-- else: same sessionID — already in sync, do nothing
			end
		end
	end

	-- Scope propagation (bootstrapping)
	if data.scope and not Cerebro.db.global.scope then
		Cerebro.db.global.scope = data.scope
		Cerebro:Debug("V2: Scope received from " .. sender)
	end

	-- Fix 2: Adopt lastKnownSeasonID from officer snapshot (monotonic merge).
	-- This ensures that when CheckSeasonChange() fires (either via early cancel
	-- or the 60s fallback), the currentID == storedID guard fires correctly and
	-- suppresses a duplicate season reset.
	if senderIsOfficer and data.lastKnownSeasonID and
	   data.lastKnownSeasonID > (Cerebro.db.global.lastKnownSeasonID or 0) then
		Cerebro.db.global.lastKnownSeasonID = data.lastKnownSeasonID
		Cerebro:Debug(string.format("V2 SNAPSHOT: adopted lastKnownSeasonID %d from %s",
			data.lastKnownSeasonID, sender))
	end

	-- Fix 1 cancel: snapshot arrived — cancel the 60s season check timer and
	-- fire CheckSeasonChange() immediately. Since lastKnownSeasonID is now
	-- current (adopted above), the change-detection guard returns early safely.
	if Cerebro._seasonCheckTimer then
		Cerebro:CancelTimer(Cerebro._seasonCheckTimer, true)
		Cerebro._seasonCheckTimer = nil
		Cerebro:Debug("V2 SNAPSHOT: cancelled season check timer, firing immediately")
		Cerebro:CheckSeasonChange()
	end

	-- Recalculate derived fields
	Cerebro:RecalculateAllPR()

	-- Invalidate hash cache: merge mutated db.global.players and config,
	-- so any hashes cached earlier in this frame (e.g. from a heartbeat
	-- processed moments before) are stale. Without this, the integrity
	-- check below and SNAPSHOT_ACK may report wrong hashes when a heartbeat
	-- and snapshot are deserialized in the same WoW frame.
	self:InvalidateHashCache()

	-- Clear stale flag
	local s = Cerebro.db.global.syncStateV2 or {}
	s.isStale = false
	s.lastSnapshotReceived = time()
	Cerebro.db.global.syncStateV2 = s

	-- Update v1 sync status for /cb status compatibility
	Cerebro.db.global.syncStatus = Cerebro.db.global.syncStatus or {}
	Cerebro.db.global.syncStatus.lastOfficerSync = time()

	-- Post-merge self-correction: if our record was in the incoming snapshot
	-- with a non-Pending status, trust the officer's status over any locally
	-- auto-created Pending record (race condition during epoch upgrades).
	local me = Cerebro:GetPlayerFullName()
	local myData = Cerebro.db.global.players[me]
	if myData and myData.status == "Pending" and data.players and data.players[me] then
		local officerStatus = data.players[me].status
		if officerStatus and officerStatus ~= "Pending" then
			myData.status = officerStatus
			myData.lastUpdate = data.players[me].lastUpdate or myData.lastUpdate
			Cerebro:Debug("V2 post-merge self-correction: " .. me .. " status -> " .. officerStatus)
		end
	end

	-- Refresh UI
	Cerebro:RefreshAllUI()

	-- Post-merge integrity check: verify local state matches snapshot's expected hashes
	-- If the merge produced different state (LWW race, partial delivery, etc.),
	-- immediately schedule a re-fetch from the sender to converge.
	local integrityOk = true
	if data.rosterHash then
		local postMergeRosterHash = self:ComputeRosterHash()
		if postMergeRosterHash ~= data.rosterHash then
			Cerebro:Debug(string.format("V2 SNAPSHOT integrity FAILED: roster expected=%s got=%s (from %s)",
				data.rosterHash, postMergeRosterHash, sender))
			integrityOk = false
		end
	end
	-- NOTE: Config hash intentionally excluded from integrity check.
	-- MergeConfigData uses per-section LWW — the receiver legitimately keeps
	-- its own newer values, making the sender's hash an unreliable baseline.
	-- Config convergence is detected by the heartbeat system instead.
	if not integrityOk then
		-- Only re-fetch if LWW rejected everything (merged=0, skipped>0).
		-- This means the receiver already has newer data for all players AND
		-- may be missing data the sender has in other fields — pulling is useful.
		--
		-- When data was accepted (merged>0), the hash mismatch is structural:
		-- version difference, participant filter, or session state — none of which
		-- re-fetching can resolve. The heartbeat will detect and repair persistent
		-- divergence on its next cycle.
		local incomingPlayerCount = data.players and Cerebro:TableCount(data.players) or 0
		if rosterMerged == 0 and incomingPlayerCount > 0 then
			local fetchSender = sender  -- capture for closure
			Cerebro:ScheduleTimer(function()
				self:SendFetch(fetchSender, "WHISPER")
			end, 1)
		else
			Cerebro:Debug(string.format("V2 integrity FAILED but %d merged — heartbeat will reconcile", rosterMerged))
		end
	end

	-- SNAPSHOT_ACK: notify sender of our post-merge state so they can detect delivery failure
	local postRosterHash = self:ComputeRosterHash()
	local postConfigHash = self:ComputeConfigHash()
	-- CRITICAL FIX: Pass full sender name (not short name) for cross-realm compatibility.
	-- SendV2Message strips to short name AFTER the IsPlayerReachable check.
	self:SendRPC("SNAPSHOT_ACK", {
		rosterHash = postRosterHash,
		configHash = postConfigHash,
		converged = integrityOk,
	}, "WHISPER", sender)

	Cerebro:Debug("V2 SNAPSHOT merged from " .. sender .. " (integrity: " .. tostring(integrityOk) .. ")")
	Cerebro:DebugSimple(string.format("Sync complete from %s",
		Cerebro:GetShortPlayerName(sender) or sender))

	-- RVR: If this snapshot was tagged as a repair attempt, send REPAIR_ACK
	if data.repairAttempt then
		self:SendRPC("REPAIR_ACK", {
			rosterHash = postRosterHash,
			attempt = data.repairAttempt,
		}, "WHISPER", sender)
		Cerebro:Debug(string.format("RVR: sent REPAIR_ACK (attempt %d) to %s",
			data.repairAttempt, Cerebro:GetShortPlayerName(sender) or sender))
	end

	-- Diagnostic trace
	local diag = Cerebro:GetModule("Diagnostics", true)
	if diag and diag.TraceEvent then
		pcall(diag.TraceEvent, diag, "SNAP_RECV", { peer = Cerebro:GetShortPlayerName(sender), merged = rosterMerged, integrity = tostring(integrityOk) })
	end
end

--[[=============================================
	Roster & Config Merge (LWW)
===============================================]]

--- Valid player data fields for sync merge whitelist.
-- IMPORTANT: If you add a new field to player data, you MUST add it here
-- or it will be silently dropped during sync merge.
local VALID_PLAYER_FIELDS = {
	ep = true, gp = true, pr = true, class = true, status = true,
	lastUpdate = true, lastSeen = true, coreAdmittedAt = true,
	bossKillsPresent = true, eligibleBossKills = true,
	attendance = true, fieldRev = true, rev = true, createdEpoch = true,
}

--- Merge incoming player data using Last-Write-Wins on lastUpdate.
function CommunicationV2:MergeRosterData(incomingPlayers, sender)
	local localPlayers = Cerebro.db.global.players
	local purged = Cerebro.db.global.purgedPlayers or {}
	local blocked = Cerebro.db.global.blockedPlayers or {}
	local mergedCount = 0
	local skippedCount = 0
	local senderIsOfficer = Cerebro:IsOfficer(sender)
	local localIsOfficer = Cerebro:IsOfficerSafe()
	local baseGP = (Cerebro.db.global.config.epgp and Cerebro.db.global.config.epgp.baseGP) or 200

	for rawName, incoming in pairs(incomingPlayers) do
		-- Normalize realm name (strip spaces to match API format)
		local name = Cerebro:GetFullPlayerName(rawName) or rawName
		-- Skip non-table entries (data corruption guard)
		if type(incoming) ~= "table" then
			Cerebro:Debug("MergeRosterData: skipping corrupt entry for " .. tostring(name) .. " (type=" .. type(incoming) .. ")")
		-- Skip entries that aren't valid Name-Realm format (fake entries from corruption)
		elseif not name:find("-", 1, true) then
			Cerebro:Debug("MergeRosterData: skipping non-player key: " .. tostring(name))
		-- Skip purged players (zombie prevention)
		elseif purged[name] then
			skippedCount = skippedCount + 1
		-- Skip blocked players (corruption prevention)
		elseif blocked[name] then
			skippedCount = skippedCount + 1
		else
			local local_ = localPlayers[name]
			if not local_ then
				-- New player — accept from officers always.
				-- Non-officers can only inject Core players (not Pending stubs).
				-- This lets raiders propagate real Core data while preventing
				-- Pending stub pollution from random Cerebro clients.
				local incomingStatus = incoming.status or "Pending"
				if not senderIsOfficer and incomingStatus ~= "Core" then
					skippedCount = skippedCount + 1
				else
					local clean = {}
					for k, v in pairs(incoming) do
						if VALID_PLAYER_FIELDS[k] then clean[k] = v end
					end
					-- Ensure class is populated (try guild roster if missing)
					if not clean.class then
						pcall(function()
							for gi = 1, GetNumGuildMembers() do
								local gn, _, _, _, _, _, _, _, _, _, classFileName = GetGuildRosterInfo(gi)
								if gn and Cerebro:GetFullPlayerName(gn) == name then
									clean.class = classFileName
									break
								end
							end
						end)
					end
					localPlayers[name] = clean
					mergedCount = mergedCount + 1
				end
			else
				-- Existing player — LWW on lastUpdate with deterministic tie-breaking.
				--
				-- HISTORY: Original `>=` for officers caused oscillation (each exchange
				-- overwrote the other's data, producing cascading hash changes).
				-- Strict `>` fixed oscillation but caused permanent divergence when
				-- two officers had identical timestamps (common after sync merges).
				--
				-- SOLUTION: Deterministic value-based tie-breaking. When timestamps
				-- are equal, compare actual data values so BOTH sides independently
				-- pick the SAME winner:
				--   1. Higher EP wins (more EP = more recent awards absorbed)
				--   2. Tiebreak: lower GP wins (less GP = earlier in loot cycle)
				--   3. If both EP and GP are equal, data is effectively identical
				--      for hash purposes — no merge needed (skip).
				--
				-- This is convergent (ties get broken), idempotent (same comparison
				-- on both sides), and non-oscillating (no direction-dependent bias).
				local incomingTime = incoming.lastUpdate or 0
				local localTime = local_.lastUpdate or 0
				local accepted = false

				if incomingTime > localTime then
					accepted = true
				elseif senderIsOfficer and incomingTime == localTime then
					-- Deterministic tie-breaker: compare EP, then GP
					local incomingEP = incoming.ep or 0
					local localEP = local_.ep or 0
					if incomingEP > localEP then
						accepted = true
					elseif incomingEP == localEP then
						local incomingGP = incoming.gp or 0
						local localGP = local_.gp or 0
						if incomingGP < localGP then
							accepted = true
						end
						-- If EP and GP are both equal, skip (data matches for hash)
					end
				elseif senderIsOfficer and not localIsOfficer then
					-- RAIDER STUB OVERRIDE: if the local record was auto-created
					-- by GetPlayerData() (Pending with default EP/GP), it has a
					-- newer lastUpdate than the officer's real data. Accept the
					-- officer's version regardless of timestamp to fix the stale stub.
					-- Records with real EP/GP or non-Pending status are preserved
					-- under normal LWW to protect multi-officer scenarios.
					local isStub = (local_.status == "Pending" and (local_.ep or 0) == 0
						and (local_.gp or baseGP) == baseGP)
					if isStub then
						accepted = true
						Cerebro:Debug(string.format(
							"V2 merge: overriding stub for %s (local=%d officer=%d)",
							name, localTime, incomingTime))
					end
				end

				if accepted then
					for k, v in pairs(incoming) do
						if VALID_PLAYER_FIELDS[k] then
							-- PROTECT: never downgrade Core→Pending via sync.
							-- Status downgrades should only happen via explicit
							-- officer action (purge/block), not stale sync data.
							if k == "status" and local_[k] == "Core" and v == "Pending" then
								Cerebro:Debug("V2 merge: blocked Core→Pending downgrade for " .. name)
							-- PROTECT: preserve coreAdmittedAt if local has it
							elseif k == "coreAdmittedAt" and local_[k] and not v then
								Cerebro:Debug("V2 merge: preserved local coreAdmittedAt for " .. name)
							else
								local_[k] = v
							end
						end
					end
					mergedCount = mergedCount + 1
				else
					skippedCount = skippedCount + 1
				end
			end
		end
	end

	Cerebro:Debug(string.format("V2 roster merge from %s: %d merged, %d skipped",
		sender, mergedCount, skippedCount))

	-- Reconciliation: remove local players not in the officer's snapshot.
	-- Two paths based on local client role:
	--   RAIDER receiving from OFFICER: officer is authoritative — adopt their
	--     player set exactly (remove any local extra, except self).
	--   OFFICER receiving from OFFICER: both sides authoritative — only remove
	--     players NOT in snapshot AND NOT in guild roster (safety net).
	--
	-- DISABLED (v0.11.34): Reconciliation was deleting local Core players when
	-- the incoming officer's snapshot didn't include them. In multi-officer setups
	-- with different auto-discovery results, this caused catastrophic data loss.
	-- Players should only be removed via explicit purge/block, never via sync.
	-- Sync is now purely additive: merge updates, never delete.
	--
	-- Original behavior:
	--   RAIDER from OFFICER: remove participants not in snapshot
	--   OFFICER from OFFICER: remove only if also not in guild roster
	-- Both paths caused legitimate Core players to vanish.
	if false and Cerebro:IsOfficer(sender) then
		local myName = Cerebro:GetPlayerFullName()
		local removedCount = 0
		-- baseGP already declared at function scope (line 965) — reused here.

		if not localIsOfficer then
			-- RAIDER PATH: officer's snapshot is authoritative.
			-- Remove participants absent from snapshot; leave non-participants alone.
			for name, pData in pairs(localPlayers) do
				if not incomingPlayers[name] and name ~= myName
					and Cerebro:IsParticipant(pData, baseGP)
				then
					localPlayers[name] = nil
					removedCount = removedCount + 1
					Cerebro:Debug("V2 reconcile (raider): removed participant " .. name)
				end
			end
		else
			-- OFFICER PATH: guild roster safety net (both sides authoritative).
			-- Only run when guild data is available AND only delete participants.
			if GetNumGuildMembers() > 0 then
				local guildNames = {}
				for i = 1, GetNumGuildMembers() do
					local gn = GetGuildRosterInfo(i)
					if gn then guildNames[Cerebro:GetFullPlayerName(gn)] = true end
				end

				for name, pData in pairs(localPlayers) do
					if not incomingPlayers[name]
						and not guildNames[name]
						and name ~= myName
						and not Cerebro:IsPlayerInRaid(name)
						and Cerebro:IsParticipant(pData, baseGP)
					then
						localPlayers[name] = nil
						removedCount = removedCount + 1
						Cerebro:Debug("V2 reconcile (officer): removed phantom participant " .. name)
					end
				end
			end
		end

		if removedCount > 0 then
			Cerebro:Debug(string.format(
				"V2 reconcile: removed %d players after merge from %s (%s path)",
				removedCount, sender, localIsOfficer and "officer" or "raider"))
		end
	end

	return mergedCount, skippedCount
end

--- Merge incoming config data.
-- Config uses a simple "officer wins" model — always accept from officer.
function CommunicationV2:MergeConfigData(incomingConfig, sender)
	if not incomingConfig then return end

	-- Officers are authoritative for config
	-- Simply overwrite local config with incoming
	local localConfig = Cerebro.db.global.config

	-- Preserve local-only settings (debug, debugLevel, ui preferences)
	local preserveDebug = localConfig.debug
	local preserveDebugLevel = localConfig.debugLevel
	local preserveUI = localConfig.ui and Cerebro:CopyTable(localConfig.ui)

	-- COMPAT: v0.10.23 sent configLastUpdate as a single number (Unix timestamp).
	-- v0.10.24+ uses a table {epgp=ts, prep=ts, sync=ts, admin=ts} for per-section LWW.
	-- Convert legacy number to per-section table so config from upgrading officers propagates.
	-- Sanitize timestamps: detect millisecond-precision values (> 10 billion)
	-- and convert back to seconds. Prevents integer overflow in string.format
	-- and stops corrupted ms-timestamps from propagating via sync.
	local function sanitizeTimestamp(ts)
		if type(ts) ~= "number" then return ts end
		if ts > 10000000000 then  -- > year 2286 in seconds, but normal range for ms
			return math.floor(ts / 1000)
		end
		return ts
	end

	local incomingTimes
	if type(incomingConfig.configLastUpdate) == "table" then
		incomingTimes = incomingConfig.configLastUpdate
		-- Sanitize each section timestamp
		for k, v in pairs(incomingTimes) do
			incomingTimes[k] = sanitizeTimestamp(v)
		end
	elseif type(incomingConfig.configLastUpdate) == "number" then
		-- Legacy: spread single timestamp across all sections
		local ts = sanitizeTimestamp(incomingConfig.configLastUpdate)
		incomingTimes = { epgp = ts, prep = ts, sync = ts, admin = ts }
	else
		incomingTimes = {}
	end
	local localTimes
	if type(localConfig.configLastUpdate) == "table" then
		localTimes = localConfig.configLastUpdate
		for k, v in pairs(localTimes) do
			localTimes[k] = sanitizeTimestamp(v)
		end
	elseif type(localConfig.configLastUpdate) == "number" then
		local ts = sanitizeTimestamp(localConfig.configLastUpdate)
		localTimes = { epgp = ts, prep = ts, sync = ts, admin = ts }
	else
		localTimes = {}
	end

	-- Merge EPGP section
	local inEpgpTime = incomingTimes.epgp or 0
	local myEpgpTime = localTimes.epgp or 0
	if inEpgpTime > myEpgpTime or (inEpgpTime == 0 and myEpgpTime == 0) then
		if incomingConfig.epgp then localConfig.epgp = incomingConfig.epgp end
		localTimes.epgp = inEpgpTime
		Cerebro:Debug(string.format("V2 config merge: accepted epgp (theirs=%.0f, mine=%.0f)", inEpgpTime, myEpgpTime))
	else
		Cerebro:Debug(string.format("V2 config merge: kept local epgp (mine=%.0f >= theirs=%.0f)", myEpgpTime, inEpgpTime))
	end

	-- Merge Prep section
	local inPrepTime = incomingTimes.prep or 0
	local myPrepTime = localTimes.prep or 0
	if inPrepTime > myPrepTime or (inPrepTime == 0 and myPrepTime == 0) then
		if incomingConfig.prep then localConfig.prep = incomingConfig.prep end
		localTimes.prep = inPrepTime
		Cerebro:Debug(string.format("V2 config merge: accepted prep (theirs=%.0f, mine=%.0f)", inPrepTime, myPrepTime))
	else
		Cerebro:Debug(string.format("V2 config merge: kept local prep (mine=%.0f >= theirs=%.0f)", myPrepTime, inPrepTime))
	end

	localConfig.configLastUpdate = localTimes

	-- FIX A (merge portion): Merge sync settings using LWW on config.sync.lastUpdate.
	-- The previous "preserve if empty" guard caused permanent oscillation when
	-- two officers had different visibleRanks — neither would accept the other's.
	-- Now the most recently modified sync config wins unconditionally.
	if incomingConfig.sync then
		local incomingTime = incomingConfig.sync.lastUpdate or 0
		local localTime = (localConfig.sync and localConfig.sync.lastUpdate) or 0

		if incomingTime > localTime then
			-- Incoming is newer — accept wholesale (includes visibleRanks, syncLeaderRanks)
			localConfig.sync = incomingConfig.sync
			Cerebro:Debug(string.format("V2 config merge: accepted incoming sync config (theirs=%.0f > mine=%.0f)",
				incomingTime, localTime))
		elseif incomingTime == 0 and localTime == 0 then
			-- Legacy fallback: neither side has a timestamp (pre-LWW clients).
			-- Accept incoming (officer-authoritative) to bootstrap convergence.
			localConfig.sync = incomingConfig.sync
			Cerebro:Debug("V2 config merge: legacy sync config accepted (no timestamps)")
		else
			-- Local is newer or equal — keep our sync config
			Cerebro:Debug(string.format("V2 config merge: kept local sync config (mine=%.0f >= theirs=%.0f)",
				localTime, incomingTime))
		end
	end

	-- Merge admin settings
	if incomingConfig.admin then
		localConfig.admin = incomingConfig.admin
	end

	-- Restore local-only settings
	localConfig.debug = preserveDebug
	localConfig.debugLevel = preserveDebugLevel
	if preserveUI then
		localConfig.ui = preserveUI
	end

	Cerebro:Debug("V2 config merged from " .. sender)
end

--- Merge incoming selection state (LWW on lastUpdate).
function CommunicationV2:MergeSelectionData(incoming, sender)
	if not incoming or not incoming.lastUpdate then return end

	Cerebro.db.global.customEP = Cerebro.db.global.customEP or { selectedPlayers = {}, lastUpdate = 0 }
	local local_ = Cerebro.db.global.customEP
	local incomingTime = incoming.lastUpdate or 0
	local localTime = local_.lastUpdate or 0

	-- LWW: Officer wins timestamp ties
	local senderIsOfficer = Cerebro:IsOfficer(sender)
	if incomingTime > localTime or (senderIsOfficer and incomingTime >= localTime) then
		-- Only merge selection state — preserve local templates
		local localTemplates = local_.templates
		local_.selectedPlayers = Cerebro:CopyTable(incoming.selectedPlayers or {})
		local_.lastUpdate = incoming.lastUpdate
		local_.templates = localTemplates  -- Keep our templates intact
		Cerebro:Debug(string.format("V2 selection merge from %s: ACCEPTED (theirs=%.0f, mine=%.0f)",
			sender, incomingTime, localTime))

		-- Notification: Ensure StandingsFrame updates its in-memory 'selected' map
		local sf = Cerebro:GetModule("StandingsFrame", true)
		if sf and sf.SetSelection then
			local names = {}
			for nm in pairs(incoming.selectedPlayers or {}) do
				names[#names + 1] = nm
			end
			sf:SetSelection(names)
			-- FIX: SetSelection() calls SaveSelection() which bumps lastUpdate = time(),
			-- ratcheting our timestamp forward on every accepted merge and permanently
			-- blocking future peers from being accepted (theirs < mine forever).
			-- Restore the correct incoming timestamp that was already written to local_.
			Cerebro.db.global.customEP.lastUpdate = incoming.lastUpdate
		end
	else
		Cerebro:Debug(string.format("V2 selection merge from %s: REJECTED (theirs=%.0f, mine=%.0f)",
			sender, incomingTime, localTime))
	end
end


--- Merge signupAwards deduplication state (union merge).
-- Each key is an event identifier (e.g. "2026-03-10|RaidName").
-- Value is a table { awardedAt = timestamp, awardedBy = fullName }.
-- We keep the entry with the latest awardedAt timestamp per key.
function CommunicationV2:MergeSignupAwards(incoming, sender)
	if not incoming or type(incoming) ~= "table" then return end

	Cerebro.db.global.signupAwards = Cerebro.db.global.signupAwards or {}
	local local_ = Cerebro.db.global.signupAwards
	local mergeCount = 0

	for eventKey, award in pairs(incoming) do
		if type(award) == "table" then
			local localAward = local_[eventKey]
			local incomingAt = award.awardedAt or 0
			local localAt = (localAward and localAward.awardedAt) or 0

			if incomingAt > localAt then
				local_[eventKey] = Cerebro:CopyTable(award)
				mergeCount = mergeCount + 1
			end
		end
	end

	if mergeCount > 0 then
		Cerebro:Debug(string.format("V2 signupAwards merge from %s: %d entries updated", sender, mergeCount))
	end
end
