--[[
	Cerebro - Sync Scheduler
	
	PURPOSE:
	Centralises all sync-timing decisions into a single module.
	Owns encounter state, the 15-second heartbeat timer, and the
	post-combat flush logic.  Nothing in this module touches payload
	construction or data merging — that stays in Communication.lua.
	
	RESPONSIBILITIES:
	1. ShouldSync() — unified gate used by Communication and callers
	2. Encounter lifecycle (ENCOUNTER_START / ENCOUNTER_END)
	3. Post-combat flush via PLAYER_REGEN_ENABLED
	4. Heartbeat timer (15 s repeating sync trigger)
	5. Boss-kill forwarding to Session module (officer only)
	
	CONTRACTS:
	- ShouldSync()   -> boolean  (safe to broadcast right now?)
	- GetSyncStatus() -> string  (human-readable reason if blocked)
	
	DESIGN DECISIONS:
	- Direct call to Session:RecordBossKill (Option A) keeps it simple.
	- 5-second safety buffer after ENCOUNTER_END per WoW 12.0 research
	  (see knowledge: cerebro_technical_constraints / combat_restrictions_research)
	- 2-second delay on PLAYER_REGEN_ENABLED flush to let lockdown fully clear.

	@module Modules.SyncScheduler
	@version 0.12.0
	@invariant ShouldSync() is the single gate for all sync timing decisions
	@invariant Periodic heartbeats gated to Sync Leader during active raids
	@invariant Event-driven heartbeats ungated (all officers respond)
	@invariant Post-combat flush waits for PLAYER_REGEN_ENABLED + 2s delay
]]--

local AddonName, _PrivateTable = ...
local Cerebro = LibStub("AceAddon-3.0"):GetAddon(AddonName)
local SyncScheduler = Cerebro:NewModule("SyncScheduler", "AceEvent-3.0", "AceTimer-3.0")

--[[=============================================
	State
===============================================]]

SyncScheduler.encounterState = {
	inEncounter = false,
	startedAt = 0,
	endedAt = 0,
	pendingSyncFlush = false,
}

-- Sync Leader state (Fix 2)
SyncScheduler._cachedLeader = nil
SyncScheduler._cachedLeaderAt = 0

-- Session-Aware Sync state
SyncScheduler._sessionActive = false   -- Set true during active raid sessions
SyncScheduler._pendingAward = false    -- Set true between boss kill and popup response

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

function SyncScheduler:OnEnable()
	self:RegisterEvent("ENCOUNTER_START")
	self:RegisterEvent("ENCOUNTER_END")
	self:RegisterEvent("PLAYER_REGEN_ENABLED")
	-- Raid Sync v2: Read heartbeat interval from config (default 15s — Build B)
	local s = Cerebro.db.global.syncState or {}
	local cfg = s.raidSyncConfig or {}
	local interval = cfg.heartbeatInterval or 15
	self:ScheduleRepeatingTimer("OnHeartbeat", interval)
end

--[[=============================================
	Unified Sync Gate
	Replaces scattered IsEncounterActive() + InCombatLockdown() checks
===============================================]]

-- Returns true if it is safe to broadcast sync data right now.
function SyncScheduler:ShouldSync()
	-- Pending award gate: suppress sync while boss kill popup is waiting
	if self._pendingAward then
		return false
	end
	if self.encounterState.inEncounter then
		return false
	end
	if InCombatLockdown() then
		return false
	end
	return true
end

-- Returns a human-readable reason when sync is blocked, nil when clear.
function SyncScheduler:GetSyncBlockReason()
	if self.encounterState.inEncounter then
		return "encounter active"
	end
	if InCombatLockdown() then
		return "combat lockdown"
	end
	return nil
end

--[[=============================================
	Event Handlers  (moved from Core.lua)
===============================================]]

function SyncScheduler:ENCOUNTER_START(_event, encounterID, encounterName, _difficultyID, _groupSize)
	Cerebro:Debug("Encounter started: " .. encounterName .. " (ID: " .. encounterID .. ")")

	-- Convergence checkpoint: repair stragglers before combat suppression
	-- Guard: only in raids — M+ encounters hit stale peerHashes and waste resources
	if IsInRaid() then
		self:OnEncounterStartConvergence()
	end

	-- Suppress syncs during encounter
	self.encounterState.inEncounter = true
	self.encounterState.startedAt = GetTime()
	Cerebro:Debug("Combat sync suppressed (encounter started)")

	-- Notify other modules (e.g. Session bidding lock)
	if Cerebro.modules.Session and Cerebro.modules.Session:IsActive() then
		Cerebro:SendMessage("ENCOUNTER_START", encounterID, encounterName)
	end
	-- Diagnostic trace
	local diag = Cerebro:GetModule("Diagnostics", true)
	if diag and diag.TraceEvent then
		pcall(diag.TraceEvent, diag, "ENC_START", { boss = encounterName })
	end
end

function SyncScheduler:ENCOUNTER_END(_event, encounterID, encounterName, difficultyID, _groupSize, success)
	Cerebro:Debug("Encounter ended: " .. encounterName .. " (Success: " .. tostring(success) .. ")")

	-- Replace fixed 5s buffer with dynamic combat exit polling
	self:WaitForRaidOutOfCombat()

	-- Boss-kill forwarding: Sync Leader exclusive processing
	-- Only the Sync Leader shows the boss kill popup to prevent redundant awards
	-- Guard: must be in a raid (not M+/dungeon) AND not LFR (difficultyID 17)
	local LFR_DIFFICULTY = 17
	local inRaid = IsInRaid()
	local sessionActive = Cerebro.modules.Session and Cerebro.modules.Session:IsActive()
	Cerebro:Debug(string.format("ENCOUNTER_END guard: success=%s inRaid=%s diff=%s sessionActive=%s",
		tostring(success), tostring(inRaid), tostring(difficultyID), tostring(sessionActive)))
	if success and inRaid and difficultyID ~= LFR_DIFFICULTY and sessionActive then
		if Cerebro:IsOfficer() and self:IsSyncLeader() then
			Cerebro.modules.Session:RecordBossKill(encounterID, encounterName, difficultyID)
			Cerebro:SendMessage("BOSS_KILL", encounterID, encounterName)
		end
	elseif success and sessionActive and not inRaid then
		Cerebro:Debug("Boss kill popup BLOCKED: not in raid (M+/dungeon content)")
	end
	-- Diagnostic trace
	local diag = Cerebro:GetModule("Diagnostics", true)
	if diag and diag.TraceEvent then
		pcall(diag.TraceEvent, diag, "ENC_END", {
			boss = encounterName, success = tostring(success),
			inRaid = tostring(inRaid), diff = tostring(difficultyID),
		})
	end
end

--- Dynamic combat exit polling (replaces fixed 5s buffer).
-- Polls UnitAffectingCombat for all raid members every 0.5s.
-- Clears encounter state when ALL members are out of combat.
-- Hard timeout at 15s to prevent infinite blocking from bugged combat states.
function SyncScheduler:WaitForRaidOutOfCombat()
	local startTime = GetTime()
	local maxWait = 15  -- Hard timeout (seconds)
	local pollInterval = 0.5

	-- Cancel any previous polling timer
	if self._combatPollTimer then
		Cerebro:CancelTimer(self._combatPollTimer)
	end

	local function pollCombat()
		local elapsed = GetTime() - startTime

		-- Timeout: proceed even if some members are still "in combat"
		if elapsed >= maxWait then
			Cerebro:Debug("WaitForRaidOutOfCombat: timeout (15s), proceeding")
			self._combatPollTimer = nil
			self:OnRaidCombatCleared()
			return
		end

		-- Check all raid members
		local raidSize = GetNumGroupMembers()
		local anyInCombat = false
		for i = 1, raidSize do
			if UnitAffectingCombat("raid" .. i) then
				anyInCombat = true
				break
			end
		end

		if anyInCombat then
			-- Poll again in 0.5s
			self._combatPollTimer = Cerebro:ScheduleTimer(pollCombat, pollInterval)
		else
			-- All raid members out of combat
			Cerebro:Debug(string.format("WaitForRaidOutOfCombat: all clear in %.1fs", elapsed))
			self._combatPollTimer = nil
			self:OnRaidCombatCleared()
		end
	end

	-- Start first poll immediately (0.5s delay to let the engine settle)
	self._combatPollTimer = Cerebro:ScheduleTimer(pollCombat, pollInterval)
end

--- Called when all raid members are confirmed out of combat.
function SyncScheduler:OnRaidCombatCleared()
	self.encounterState.inEncounter = false
	self.encounterState.endedAt = GetTime()
	self.encounterState.pendingSyncFlush = true
	Cerebro:Debug("Encounter state cleared (combat exit confirmed), sync flush pending")
end

-- Post-combat flush: broadcast pending data once lockdown is fully clear
function SyncScheduler:PLAYER_REGEN_ENABLED()
	-- 2-second delay to ensure lockdown is fully clear
	Cerebro:ScheduleTimer(function()
		if self.encounterState.pendingSyncFlush then
			self.encounterState.pendingSyncFlush = false
			Cerebro:Debug("Flushing pending sync after combat")
			if Cerebro:IsOfficer() and Cerebro.modules.CommunicationV2 then
				Cerebro.modules.CommunicationV2:SyncToRaid()
			end
			-- Diagnostic trace
			local diag = Cerebro:GetModule("Diagnostics", true)
			if diag and diag.TraceEvent then
				pcall(diag.TraceEvent, diag, "SYNC_FLUSH", nil)
			end
		end
	end, 2)
end

--[[=============================================
	Sync Mode (Raid Sync v2)
	Rich 4-state machine for sync decisions.
	ShouldSync() remains as a simple boolean gate for callers
	that don't need the full state.
===============================================]]

-- Returns the current sync mode: "OFF" | "RAID_IDLE" | "RAID_COMBAT" | "RAID_POST"
function SyncScheduler:GetSyncMode()
	if not IsInRaid() then return "OFF" end
	if self.encounterState.inEncounter then return "RAID_COMBAT" end
	if InCombatLockdown() then return "RAID_COMBAT" end
	if self.encounterState.pendingSyncFlush then return "RAID_POST" end
	return "RAID_IDLE"
end

--[[=============================================
	Heartbeat Timer  (moved from Core.lua)
	Raid Sync v2: Smart heartbeat with peer-aware decisions
===============================================]]

function SyncScheduler:OnHeartbeat()
	if not Cerebro:IsOfficer() then return end

	-- Session-Aware: suppress periodic heartbeat during active sessions.
	-- All data changes push event-driven (BatchEPGP, EPGP_UPDATE, PushAfterAward).
	-- Periodic heartbeat resumes when session ends.
	if self._sessionActive then return end

	-- Quarantine guard: suppress heartbeats during factory reset window
	-- (prevents broadcasting empty/zeroed hashes that trigger false sync)
	if Cerebro:IsQuarantined() then
		return
	end

	local mode = self:GetSyncMode()

	-- V2-only: Sync Leader broadcasts v2 heartbeats during raids.
	-- Outside raids, all officers broadcast normally.
	-- Event-driven heartbeats (NotifyStateChange) are NOT gated here —
	-- they call CommunicationV2:SendHeartbeat() directly, so all officers
	-- still react to state mutations immediately.
	if mode ~= "RAID_COMBAT" and Cerebro.modules.CommunicationV2 then
		if not IsInRaid() or self:IsSyncLeader() then
			Cerebro.modules.CommunicationV2:SendHeartbeat()
		end
	end

	-- Post-combat flush: run sync immediately after encounter ends
	if mode == "RAID_POST" then
		self:MaybeRaidSync(mode)
	end
end

-- Smart sync decision: V2-only, uses hash-based sync
function SyncScheduler:MaybeRaidSync(mode)
	-- RAID_POST: always flush (post-combat window)
	if mode == "RAID_POST" then
		Cerebro:Debug("MaybeRaidSync: RAID_POST flush")
		if Cerebro.modules.CommunicationV2 then
			Cerebro.modules.CommunicationV2:SyncToRaid()
		end
		return
	end

	-- RAID_IDLE: V2 heartbeat handles divergence detection automatically
	-- No need for periodic full broadcasts or stale peer checks — V2 heartbeat
	-- comparison triggers STATE_FETCH/STATE_SNAPSHOT only when hashes diverge
	Cerebro:Debug("MaybeRaidSync: V2 heartbeat handles sync, skipping")
end

--[[=============================================
	Sync Leader Election (Fix 2)
	Only the highest-ranking eligible online officer in the raid
	broadcasts heartbeat data. Ties broken by alphabetical name.
	Other officers still RECEIVE and MERGE data, but don't broadcast.
===============================================]]

--- Determines if the current player is the Sync Leader.
-- Uses a 5-second cache to avoid rescanning the raid roster every heartbeat.
-- Outside of raid, always returns true (all officers can sync normally).
function SyncScheduler:IsSyncLeader()
	if not IsInRaid() then return true end
	if not Cerebro:IsOfficer() then return false end

	-- Use cached result if recent (5-second TTL)
	local now = GetTime()
	if self._cachedLeader and (now - self._cachedLeaderAt) < 5 then
		return self._cachedLeader == Cerebro:GetPlayerFullName()
	end

	-- Elect: scan raid roster for the highest-ranking eligible officer
	local leader = self:ElectSyncLeader()
	self._cachedLeader = leader
	self._cachedLeaderAt = now

	return leader == Cerebro:GetPlayerFullName()
end

--- Returns the full name of the current Sync Leader.
-- Used by Fix 2c for EPGP forwarding.
function SyncScheduler:GetSyncLeaderName()
	if not IsInRaid() then return nil end

	-- Refresh cache if stale
	local now = GetTime()
	if not self._cachedLeader or (now - self._cachedLeaderAt) >= 5 then
		self._cachedLeader = self:ElectSyncLeader()
		self._cachedLeaderAt = now
	end

	return self._cachedLeader
end

--- Internal: Elect the sync leader from raid members.
--
-- 3-tier priority (v0.10.30+):
--   Tier 1: WoW Raid Leader (GetRaidRosterInfo rank==2), if online + IsOfficer()
--   Tier 2: WoW Assistant Raid Leader(s) (rank==1), filtered by eligibleSet, sorted alphabetically
--   Tier 3: Existing guild-rank sort fallback (unchanged from pre-v0.10.30)
--
-- All tiers require IsOfficer() — a non-officer raid leader cannot become sync leader.
-- The 5-second TTL cache on _cachedLeader is preserved; failover on DC happens within ≤5s.
function SyncScheduler:ElectSyncLeader()
	local syncConfig = (Cerebro.db.global.config.sync or {})
	local eligibleSet = syncConfig.syncLeaderRanks or { [0] = true, [1] = true }

	-- WoW raid rank constants
	local RAID_RANK_LEADER    = 2
	local RAID_RANK_ASSISTANT = 1

	local tier2candidates = {}  -- Assistant raid leaders (eligible officers)
	local tier3candidates = {}  -- Rank-sort fallback

	for i = 1, GetNumGroupMembers() do
		local name, raidRank, _, _, _, _, _, online = GetRaidRosterInfo(i)
		if name and online and Cerebro:IsOfficer(name) then
			local fullName = Cerebro:GetFullPlayerName(name)

			-- Tier 1: WoW Raid Leader — wins immediately if they are an officer
			if raidRank == RAID_RANK_LEADER then
				Cerebro:Debug("ElectSyncLeader: Tier1 raid leader elected: " .. fullName)
				return fullName
			end

			local guildRankIndex = Cerebro:GetPlayerGuildRankIndex(fullName)

			-- Tier 2: WoW Assistant Raid Leader — must also be in eligible guild ranks
			if raidRank == RAID_RANK_ASSISTANT then
				if not guildRankIndex or eligibleSet[guildRankIndex] then
					table.insert(tier2candidates, {
						name = fullName,
						rankIndex = guildRankIndex or 999,
					})
				end
			else
				-- Tier 3 pool: all remaining eligible officers (non-leaders in raid)
				if guildRankIndex and eligibleSet[guildRankIndex] then
					table.insert(tier3candidates, { name = fullName, rankIndex = guildRankIndex })
				elseif not guildRankIndex then
					table.insert(tier3candidates, { name = fullName, rankIndex = 999 })
				end
			end
		end
	end

	-- Tier 2: Assistant raid leaders — alphabetical tiebreak
	if #tier2candidates > 0 then
		table.sort(tier2candidates, function(a, b) return a.name < b.name end)
		Cerebro:Debug("ElectSyncLeader: Tier2 assistant elected: " .. tier2candidates[1].name)
		return tier2candidates[1].name
	end

	-- Tier 3: Fallback — lowest guild rank index, alphabetical tiebreak
	if #tier3candidates > 0 then
		table.sort(tier3candidates, function(a, b)
			if a.rankIndex ~= b.rankIndex then return a.rankIndex < b.rankIndex end
			return a.name < b.name
		end)
		Cerebro:Debug("ElectSyncLeader: Tier3 rank-sort elected: " .. tier3candidates[1].name)
		return tier3candidates[1].name
	end

	-- Last resort: current player (shouldn't happen if they are an officer in raid)
	return Cerebro:GetPlayerFullName()
end

--[[=============================================
	Session-Aware Sync: Lifecycle Hooks
	Called by Session module on session start/end.
===============================================]]

--- Activate session mode: suppresses periodic heartbeat, schedules convergence check.
function SyncScheduler:SetSessionActive()
	self._sessionActive = true
	Cerebro:Debug("SyncScheduler: session active — periodic heartbeat suppressed")

	-- Schedule session-start convergence 15s after session start.
	-- Allows time for: snapshot delivery (1s) + merge (1s) + SYNC_ACK debounce (3s) + buffer.
	if self:IsSyncLeader() then
		if self._sessionStartTimer then
			Cerebro:CancelTimer(self._sessionStartTimer)
		end
		self._sessionStartTimer = Cerebro:ScheduleTimer(function()
			self._sessionStartTimer = nil
			self:OnSessionStartConvergence()
		end, 15)
	end
end

--- Deactivate session mode: resumes periodic heartbeat.
function SyncScheduler:SetSessionInactive()
	self._sessionActive = false
	self._pendingAward = false
	Cerebro:Debug("SyncScheduler: session ended — periodic heartbeat resumed")

	-- Cancel any pending convergence timer
	if self._sessionStartTimer then
		Cerebro:CancelTimer(self._sessionStartTimer)
		self._sessionStartTimer = nil
	end
end

--[[=============================================
	Convergence Gates (Session-Aware Sync)
	Use CommunicationV2:StartRepairCycle() for RVR protocol.
===============================================]]

--- Session Start convergence: 15s after BroadcastSessionStart, verify all peers converged.
function SyncScheduler:OnSessionStartConvergence()
	if not self:IsSyncLeader() then return end
	local v2 = Cerebro.modules.CommunicationV2
	if not v2 then return end

	v2:InvalidateHashCache()
	local myHash = v2:ComputeRosterHash()

	local s = Cerebro.db.global.syncStateV2 or {}
	local stragglers = {}
	for name, peer in pairs(s.peerHashes or {}) do
		if peer.rosterHash ~= myHash then
			table.insert(stragglers, name)
		end
	end

	if #stragglers == 0 then
		Cerebro:Print("|cff00ff00[SYNC]|r Session start: all clients converged")
		return
	end

	Cerebro:Print(string.format("|cffff8800[SYNC]|r Session start: %d client(s) divergent, repairing...", #stragglers))
	v2:StartRepairCycle(stragglers, myHash, "session_start")
end

--- Encounter Start convergence: verify peers before combat suppression.
function SyncScheduler:OnEncounterStartConvergence()
	if not self:IsSyncLeader() then return end
	local v2 = Cerebro.modules.CommunicationV2
	if not v2 then return end

	v2:InvalidateHashCache()
	local myHash = v2:ComputeRosterHash()

	local s = Cerebro.db.global.syncStateV2 or {}
	local stragglers = {}
	for name, peer in pairs(s.peerHashes or {}) do
		if peer.rosterHash ~= myHash then
			table.insert(stragglers, name)
		end
	end

	if #stragglers == 0 then
		Cerebro:Debug("Encounter gate: ALL CONVERGED")
		return
	end

	Cerebro:Debug(string.format("Encounter gate: %d stragglers, initiating RVR repair", #stragglers))
	v2:StartRepairCycle(stragglers, myHash, "encounter_gate")
end
