--[[
	Cerebro - Session Module
	
	PURPOSE:
	Manages raid session lifecycle, roster snapshots, and boss kill tracking.
	This module provides the "active state" layer above the EPGP rules engine,
	capturing real-time attendance and orchestrating automated EP awards for
	raid progression.
	
	RESPONSIBILITIES:
	1. Session State Management - Start/End/Active lifecycle
	2. Roster Snapshotting - Capture raid composition at session start
	3. Boss Kill Tracking - Record encounters and award EP
	4. Session Point Tracking - Aggregate session-scoped EP/GP per player
	5. Status Promotion - Auto-promote Pending → Core on first raid attendance
	
	CONTRACTS (Derived from Code):
	
	# StartSession(raidName) -> sessionID
	  - MUST verify IsOfficer() (fail-closed RBAC)
	  - MUST fail if session already active
	  - Generates sessionID: YYYY-MM-DD-### (sequential daily counter)
	  - Auto-detects raidName from instance if not provided
	  - Creates currentSession with startTime, roster snapshot, empty bosses[]
	  - Broadcasts to raid + Communication module
	  - Returns: sessionID string
	  
	# EndSession() -> void
	  - MUST verify IsOfficer() (fail-closed RBAC)
	  - Sets endTime on currentSession
	  - Archives currentSession to db.global.history[sessionID]
	  - Clears db.global.currentSession (state transition to inactive)
	  - Broadcasts end to raid + Communication module
	  
	# RecordBossKill(encounterID, encounterName, difficultyID) -> void
	  - MUST be idempotent: duplicate encounterID in session.bosses[] is ignored
	  - Records boss data: encounterID, name, difficulty, killTime
	  - Increments db.global.totalBossKills (attendance analytics)
	  - Awards fixed EP via EPGP:MassAwardEP():
	    - Normal (14): 50 EP
	    - Heroic (15): 75 EP
	    - Mythic (16): 100 EP
	  - Filter: Only players currently in raid (Cerebro:IsPlayerInRaid())
	  
	# UpdateRoster() -> void
	  - Snapshots current raid roster via Cerebro:GetRaidRoster()
	  - For new players: creates roster entry with joinTime, class, session EP/GP = 0
	  - NOTE: No longer auto-promotes Pending → Core (officer must admit via Officer Tools)
	  - Updates player class in db (sync from roster)
	  
	# OnPointChange(player, amount, type) -> void
	  - Tracks session-scoped EP/GP deltas for analytics
	  - Creates roster entry if player not in snapshot (handles standby/late joins)
	  - Accumulates session.roster[player].sessionEP/sessionGP
	  
	SESSION SCHEMA:
	{
	    sessionID = "YYYY-MM-DD-###",
	    raidName = "Raid Instance Name",
	    startTime = 1234567890,
	    endTime = 1234567899 | nil,  -- nil while active
	    roster = {
	        ["PlayerName-Realm"] = {
	            joinTime = 1234567890,
	            class = "WARRIOR",
	            sessionEP = 150,
	            sessionGP = 200
	        }
	    },
	    bosses = {
	        {
	            encounterID = 12345,
	            name = "Boss Name",
	            difficultyID = 16,
	            killed = true,
	            killTime = 1234567891
	        }
	    }
	}
	
	INVARIANTS:
	1. Only one session can be active at a time (enforced by StartSession)
	2. Boss kills are idempotent within a session (encounterID uniqueness check)
	3. Roster entries persist through session (no removal, only addition)
	4. Session history is append-only (archived sessions never modified)
	
	DESIGN DECISIONS:
	- StartSession auto-detects raid name from GetInstanceInfo() if not provided
	- Pending → Core promotion happens on UpdateRoster (attendance-based)
	- Boss EP amounts are hardcoded per difficulty (not configurable)
	- OnPointChange creates roster entries for standby/late joins (permissive)
	
	SECURITY MODEL:
	StartSession and EndSession require IsOfficer() verification (caller enforced).
	UpdateRoster is safe to call by anyone (read-only roster snapshot).
	RecordBossKill should be called by authorized sources (Session, RCLC_Bridge).
	
	EXTERNAL DEPENDENCIES:
	- Modules/EPGP.lua (MassAwardEP)
	- Modules/CommunicationV2.lua (BroadcastSessionStart/End, optional)
	- Database.lua (GetPlayerData, GenerateSessionID)
	- Utilities.lua (GetRaidRoster, IsPlayerInRaid, CopyTable)
	- WoW API: GetInstanceInfo, GetNumGroupMembers, UnitName, UnitClass
	
	TEST COVERAGE:
	- TEST-SESSION-001: State machine, RBAC, sessionID generation, archival
	- TEST-ROSTER-001: Roster snapshots, promotion logic, point tracking
]]--

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

-- State
Session.onTimeTimer = nil  -- Timer handle for grace period
Session.progressionTimer = nil  -- Timer handle for 30-min EP
Session.progressionCount = 0  -- Track ticks for summary
Session._lastEndedSessionID = nil  -- Idempotency: prevent double EndSession
Session._lastEndedAt = 0           -- Timestamp of last EndSession
Session._rosterLeaveQueue = {}     -- Queue for players who left raid (popup system)
Session._rosterJoinQueue = {}      -- Queue for bench players who joined raid
Session._lastKnownRoster = {}      -- Snapshot of raid roster names for diff

--[[=============================================
	Roster Leave Popup
	Shown when a player leaves the raid during an active session.
	-- NOTE: Currently RL-only. Future option: show to all officers.
===============================================]]

StaticPopupDialogs["CEREBRO_PLAYER_LEFT_RAID"] = {
	text = "|cff00ccff%s|r has left the raid.\nKeep on bench (bench EP rate) or remove from session?",
	button1 = "Bench",
	button2 = "Remove",
	OnAccept = function(_, data)
		-- Bench: keep in selection (they get bench EP multiplier)
		if data and data.name then
			Cerebro:Print(string.format("|cff00ff00%s|r moved to bench.", Cerebro:GetShortPlayerName(data.name)))
		end
		Cerebro:RefreshAllUI()
		Session:ProcessNextRosterPopup()
	end,
	OnCancel = function(_, data)
		-- Remove: deselect from session (no EP)
		if data and data.name then
			local standings = Cerebro:GetModule("StandingsFrame", true)
			if standings then
				standings.selected[data.name] = nil
				standings:SaveSelection()
			end
			Cerebro:Print(string.format("|cffff4444%s|r removed from session.", Cerebro:GetShortPlayerName(data.name)))
		end
		Cerebro:RefreshAllUI()
		Session:ProcessNextRosterPopup()
	end,
	timeout = 0,
	whileDead = true,
	hideOnEscape = false,
	preferredIndex = 4,
	hasItemFrame = false,
}

--[[=============================================
	Bench-to-Active Popup
	Shown when a bench player joins the raid during an active session.
	-- NOTE: Currently RL-only. Future option: show to all officers.
===============================================]]

StaticPopupDialogs["CEREBRO_BENCH_PLAYER_JOINED"] = {
	text = "|cff00ccff%s|r (bench) has joined the raid.\nMove to active roster or keep on bench?",
	button1 = "Active",
	button2 = "Keep Bench",
	OnAccept = function(_, data)
		-- Active: they're already selected and now in raid, so full EP applies automatically
		if data and data.name then
			Cerebro:Print(string.format("|cff00ff00%s|r moved to active roster.", Cerebro:GetShortPlayerName(data.name)))
		end
		Cerebro:RefreshAllUI()
		Session:ProcessNextRosterPopup()
	end,
	OnCancel = function(_, data)
		-- Keep bench: deselect so they don't get full EP, then re-add as bench
		-- Actually: just leave selection as-is. IsPlayerInRaid will return true now,
		-- so they'd get full EP. To KEEP bench rate, we'd need to deselect and re-add.
		-- Simplest: just inform the RL. The EP system uses IsPlayerInRaid() live,
		-- so if they're in the raid they get full rate regardless.
		if data and data.name then
			Cerebro:Print(string.format("|cff00ccff%s|r stays on bench (note: in-raid players get full EP rate).",
				Cerebro:GetShortPlayerName(data.name)))
		end
		Cerebro:RefreshAllUI()
		Session:ProcessNextRosterPopup()
	end,
	timeout = 0,
	whileDead = true,
	hideOnEscape = false,
	preferredIndex = 4,
	hasItemFrame = false,
}

--[[=============================================
	Boss Kill EP Popups (Two-Step Flow)
	Step 1: Confirm whether to award EP at all
	Step 2: If yes, choose Farm Boss or Prog Boss
===============================================]]

StaticPopupDialogs["CEREBRO_BOSS_KILL_CONFIRM"] = {
	text = "|cffFFD700%s|r killed!\nAward Boss Kill EP?",
	button1 = "Yes",
	button2 = "No",
	OnAccept = function(_, data)
		-- Show type selection popup
		if data then
			local dialog = StaticPopup_Show("CEREBRO_BOSS_KILL_TYPE", data.encounterName)
			if dialog then
				dialog.data = data
			end
		end
	end,
	OnCancel = function()
		Cerebro:Print("|cffff8800Boss kill EP skipped by officer.|r")
	end,
	timeout = 0,
	whileDead = true,
	hideOnEscape = false,
	preferredIndex = 4,
	hasItemFrame = false,
}

StaticPopupDialogs["CEREBRO_BOSS_KILL_TYPE"] = {
	text = "|cffFFD700%s|r — Select boss type:",
	button1 = "Farm Boss",
	button2 = "Prog Boss",
	OnAccept = function(_, data)
		-- Farm Boss: use epFarmBoss config
		if data then
			local ep = Cerebro.db.global.config.epgp.epFarmBoss or 15
			local fullBoss, benchBoss = Session:AwardEPToSelected(ep, "FARM_BOSS: " .. (data.encounterName or "Unknown"))
			Cerebro:SendRaidMessage(string.format("[Cerebro] Farm Boss %s down! %d EP (%d raid + %d bench).",
				data.encounterName or "Unknown", ep, fullBoss, benchBoss))
			Cerebro:RefreshAllUI()
		end
	end,
	OnCancel = function(_, data)
		-- Prog Boss: use epProgBoss config
		if data then
			local ep = Cerebro.db.global.config.epgp.epProgBoss or 50
			local fullBoss, benchBoss = Session:AwardEPToSelected(ep, "PROG_BOSS: " .. (data.encounterName or "Unknown"))
			Cerebro:SendRaidMessage(string.format("[Cerebro] Prog Boss %s down! %d EP (%d raid + %d bench).",
				data.encounterName or "Unknown", ep, fullBoss, benchBoss))
			Cerebro:RefreshAllUI()
		end
	end,
	timeout = 0,
	whileDead = true,
	hideOnEscape = false,
	preferredIndex = 4,
	hasItemFrame = false,
}

-- Process the next queued roster popup (leave or join)
function Session:ProcessNextRosterPopup()
	-- Try leave queue first
	if #self._rosterLeaveQueue > 0 then
		local nextName = table.remove(self._rosterLeaveQueue, 1)
		local dialog = StaticPopup_Show("CEREBRO_PLAYER_LEFT_RAID",
			Cerebro:GetShortPlayerName(nextName))
		if dialog then dialog.data = { name = nextName } end
		return
	end
	-- Then join queue
	if #self._rosterJoinQueue > 0 then
		local nextName = table.remove(self._rosterJoinQueue, 1)
		local dialog = StaticPopup_Show("CEREBRO_BENCH_PLAYER_JOINED",
			Cerebro:GetShortPlayerName(nextName))
		if dialog then dialog.data = { name = nextName } end
		return
	end
end

--[[=============================================
	GROUP_ROSTER_UPDATE Handler
	During active sessions: auto-adds new joiners,
	prompts bench→active for bench players who join,
	and queues bench/remove popups for leavers.
	-- NOTE: Currently RL-only. Future option: show to all officers.
===============================================]]

function Session:GROUP_ROSTER_UPDATE()
	-- Only process during active sessions
	if not self:IsActive() then return end

	local session = self:GetCurrentSession()
	if not session then return end

	-- RL-only: only the session starter sees popups
	-- NOTE: Future option: show to all officers instead of just session starter
	local isSessionStarter = Cerebro:GetPlayerFullName() == session.startedBy

	-- Build current roster set
	local currentNames = {}
	local roster = Cerebro:GetRaidRoster()
	for _, member in ipairs(roster) do
		currentNames[member.name] = true
	end

	local standings = Cerebro:GetModule("StandingsFrame", true)
	if not standings then return end

	local selectedPlayers = Cerebro.db.global.customEP
		and Cerebro.db.global.customEP.selectedPlayers or {}

	-- Detect NEW members joining the raid
	for name in pairs(currentNames) do
		if not self._lastKnownRoster[name] then
			local playerData = Cerebro.db.global.players[name]
			-- Only process Core players
			if playerData and playerData.status == "Core" then
				if selectedPlayers[name] then
					-- Already selected (was on bench, now in raid)
					if isSessionStarter then
						table.insert(self._rosterJoinQueue, name)
					end
				else
					-- New joiner, not yet selected — auto-add
					standings.selected[name] = true
					standings:SaveSelection()
					Cerebro:Print(string.format("|cff00ff00%s|r joined — auto-added to session.",
						Cerebro:GetShortPlayerName(name)))
				end
			end
		end
	end

	-- Detect LEAVERS (were in last known roster, no longer in raid)
	if isSessionStarter then
		for name in pairs(self._lastKnownRoster) do
			if not currentNames[name] and selectedPlayers[name] then
				table.insert(self._rosterLeaveQueue, name)
			end
		end
	end

	-- Start processing popups if queue has entries and none showing
	if (#self._rosterLeaveQueue > 0 or #self._rosterJoinQueue > 0) and isSessionStarter then
		if not StaticPopup_Visible("CEREBRO_PLAYER_LEFT_RAID")
			and not StaticPopup_Visible("CEREBRO_BENCH_PLAYER_JOINED") then
			self:ProcessNextRosterPopup()
		end
	end

	-- Update snapshot
	self._lastKnownRoster = currentNames

	-- Refresh UI
	Cerebro:RefreshAllUI()
end

--[[=============================================
	Selection-Based EP Award Helper
	
	Gates EP awards on standings checkbox selection:
	  - Checked + in raid  → full EP
	  - Checked + NOT in raid → EP × benchMultiplier
	  - Not checked → nothing
	Always requires status == "Core" as a safety net.
===============================================]]

function Session:AwardEPToSelected(amount, reason, extraFilter, opts)
	local selectedPlayers = Cerebro.db.global.customEP
		and Cerebro.db.global.customEP.selectedPlayers or {}
	local benchMult = Cerebro.db.global.config.epgp.benchMultiplier or 0.5
	local EPGP = Cerebro:GetModule("EPGP")
	local fullCount, benchCount = 0, 0
	local skipBench = opts and opts.skipBench or false

	local selCount = 0
	for _ in pairs(selectedPlayers) do selCount = selCount + 1 end
	Cerebro:Debug(string.format("AwardEPToSelected: amount=%d, reason=%s, benchMult=%.2f, selectedCount=%d, skipBench=%s",
		amount, reason, benchMult, selCount, tostring(skipBench)))

	-- Collect batch awards for single broadcast (sync efficiency)
	local batchAwards = {}

	for name, data in pairs(Cerebro.db.global.players) do
		-- Must be: checked in standings, Core status, passes extra filter
		if selectedPlayers[name]
			and data.status == "Core"
			and (not extraFilter or extraFilter(name, data))
		then
			-- Determine full vs bench rate
			if Cerebro:IsPlayerInRaid(name) then
				local newEP, txId = EPGP:AwardEP(name, amount, reason, {mass = true, silent = true})
				fullCount = fullCount + 1
				table.insert(batchAwards, {
					player = name, newBalance = newEP, amount = amount,
					reason = reason, currency = "EP", txId = txId,
				})
			elseif not skipBench then
				local benchAmount = math.floor(amount * benchMult)
				if benchAmount > 0 then
					local newEP, txId = EPGP:AwardEP(name, benchAmount, reason .. " (Bench)", {mass = true, silent = true})
					benchCount = benchCount + 1
					table.insert(batchAwards, {
						player = name, newBalance = newEP, amount = benchAmount,
						reason = reason .. " (Bench)", currency = "EP", txId = txId,
					})
				end
			end
		end
	end

	-- Fire ONE batch broadcast + snapshot push instead of N individual RPCs
	if #batchAwards > 0 and Cerebro.modules.CommunicationV2 then
		Cerebro.modules.CommunicationV2:BroadcastBatchEPGPUpdate(batchAwards)
	end

	-- Single UI refresh after all awards applied
	Cerebro:RefreshAllUI()

	-- Diagnostic trace
	local diag = Cerebro:GetModule("Diagnostics", true)
	if diag and diag.TraceEvent then
		local skipped = selCount - fullCount - benchCount
		pcall(diag.TraceEvent, diag, "AWARD_EP", { reason = reason, full = fullCount, bench = benchCount, skipped = skipped })
	end

	return fullCount, benchCount
end

--[[=============================================
	Session Management
===============================================]]

function Session:StartSession(raidName)
	if not Cerebro:IsOfficer() then
		Cerebro:Print("Only officers can start a raid session.")
		return
	end

	if self:IsActive() then
		Cerebro:Print("A raid session is already active. End it first.")
		return
	end

	-- Always clear existing selection and auto-select all current group members.
	-- This ensures a clean slate at the start of each session.
	local roster = Cerebro:GetRaidRoster()
	local names = {}
	for _, member in ipairs(roster) do
		table.insert(names, member.name)
	end
	if #names > 0 then
		local standings = Cerebro:GetModule("StandingsFrame", true)
		if standings then standings:SetSelection(names) end
		Cerebro:Print(string.format("Session starting — auto-selected %d group members.", #names))
	else
		Cerebro:Print("|cffff0000[Cerebro] Cannot start session: Not in a raid group.|r")
		return
	end

	-- Auto-detect raid name
	if not raidName then
		local name, instanceType = GetInstanceInfo()
		if instanceType == "raid" then
			raidName = name
		else
			raidName = "Raid Night"
		end
	end

	-- Create new session
	local sessionID = Cerebro:GenerateSessionID(raidName)

	Cerebro.db.global.currentSession = {
		sessionID = sessionID,
		raidName = raidName,
		startTime = time(),
		startedBy = Cerebro:GetPlayerFullName(),
		endTime = nil,
		roster = {},
		bosses = {},
	}

	self:UpdateRoster()

	Cerebro:Print("Raid session started: " .. raidName)
	Cerebro:SendRaidMessage("[Cerebro] EP/GP Session Started.")
	
	-- Add to History tab (as special transaction)
	if Cerebro.Ledger and Cerebro.Ledger.AddTransaction then
		Cerebro.Ledger:AddTransaction(
			"SESSION", -- player field (used for filtering, set to "SESSION")
			0,         -- amount (zero for informational entry)
			"EP",      -- currency
			"SESSION_START", -- reason
			{ sessionInfo = string.format("[%s_%s] Session Started.", raidName, sessionID) },
			false,     -- not import
			{ silent = false } -- broadcast this
		)
	end
	
	-- Add to System Logs tab
	if Cerebro.Ledger and Cerebro.Ledger.AddAuditLog then
		Cerebro.Ledger:AddAuditLog("SESSION_START", 
			string.format("[%s_%s] Session Started.", raidName, sessionID))
	end

	if Cerebro.modules.CommunicationV2 then
		Cerebro.modules.CommunicationV2:BroadcastSessionStart(sessionID, raidName)
	end

	-- Diagnostic trace
	local diag = Cerebro:GetModule("Diagnostics", true)
	if diag and diag.TraceEvent then
		pcall(diag.TraceEvent, diag, "SESSION_START", { id = sessionID, raid = raidName })
	end
	
	-- Schedule on-time bonus timer
	local gracePeriod = Cerebro.db.global.config.epgp.onTimeGracePeriod or 5
	if gracePeriod > 0 then
		self.onTimeTimer = self:ScheduleTimer("OnGraceExpiry", gracePeriod * 60)
		Cerebro:Debug(string.format("On-time timer scheduled for %d minutes", gracePeriod))
	else
		-- Award immediately if grace period is 0
		self:OnGraceExpiry()
	end
	
	-- Schedule progression EP timer at configurable interval
	local epProgression = Cerebro.db.global.config.epgp.epProgressionHourly or 50
	local intervalMin = Cerebro.db.global.config.epgp.progressionIntervalMin or 30
	if epProgression > 0 and intervalMin > 0 then
		local intervalSec = intervalMin * 60
		self.progressionTimer = self:ScheduleRepeatingTimer("AwardProgressionEP", intervalSec)
		self.progressionCount = 0
		Cerebro:Debug(string.format("Progression timer started: %d EP every %d minutes", epProgression, intervalMin))
	end

	-- Register GROUP_ROSTER_UPDATE for auto-add/leave tracking
	self._lastKnownRoster = {}
	local rosterSnapshot = Cerebro:GetRaidRoster()
	for _, member in ipairs(rosterSnapshot) do
		self._lastKnownRoster[member.name] = true
	end
	self._rosterLeaveQueue = {}
	self._rosterJoinQueue = {}
	self:RegisterEvent("GROUP_ROSTER_UPDATE")
	Cerebro:Debug("Registered GROUP_ROSTER_UPDATE for roster tracking.")

	return sessionID
end

--[[=============================================
	On-Time Bonus
===============================================]]

function Session:OnGraceExpiry()
	-- Guard: session may have ended before timer fires
	if not self:IsActive() then
		Cerebro:Debug("OnGraceExpiry: Session not active, skipping")
		return
	end
	
	local session = self:GetCurrentSession()
	if not session then return end
	
	-- Snapshot current roster as on-time
	self:UpdateRoster()
	local onTimeCount = 0
	local epOnTime = Cerebro.db.global.config.epgp.epOnTime or 50
	
	for player, entry in pairs(session.roster or {}) do
		if entry.onTime == nil then
			-- Mark everyone currently in roster as on-time
			entry.onTime = true
			onTimeCount = onTimeCount + 1
			Cerebro:Debug("OnGraceExpiry: Marked " .. player .. " as on-time")
		end
	end
	Cerebro:Debug("OnGraceExpiry: Total on-time count = " .. onTimeCount)
	
	-- Award on-time EP to Core players who are on-time
	if epOnTime > 0 and onTimeCount > 0 then
		local fullOn, benchOn = self:AwardEPToSelected(epOnTime, "RAID_START", function(name, _data)
			local rosterEntry = session.roster[name]
			return rosterEntry and rosterEntry.onTime
		end, { skipBench = true })
		
		-- Update attendance.onTime counter for each player
		for player, entry in pairs(session.roster or {}) do
			if entry.onTime then
				local playerData = Cerebro:GetPlayerData(player)
				if playerData and playerData.attendance and playerData.status == "Core" then
					playerData.attendance.onTime = (playerData.attendance.onTime or 0) + 1
				end
			end
		end
		
		Cerebro:SendRaidMessage(string.format("[Cerebro] On-Time bonus: %d raid + %d bench.", fullOn, benchOn))
	end
	
	self.onTimeTimer = nil
end

function Session:EndSession()
	if not Cerebro:IsOfficer() then return end

	local session = self:GetCurrentSession()
	if not session then return end

	-- Idempotency guard: prevent double EndSession on the same sessionID
	-- This can happen when V2 sync re-injects a stale session from a peer's snapshot
	-- (built before the officer ended it), causing GROUP_ROSTER_UPDATE to re-trigger EndSession.
	local sessionID = session.sessionID
	if sessionID and self._lastEndedSessionID == sessionID then
		Cerebro:Debug("EndSession: IDEMPOTENCY GUARD — session " .. sessionID .. " already ended")
		-- Still clear the re-injected session to prevent it from persisting
		Cerebro.db.global.currentSession = nil
		return
	end

	-- Zombie session guard: discard sessions that have been "active" for an absurd duration.
	-- This prevents stale EP awards when an officer logs back in after going offline mid-raid
	-- (e.g., GROUP_ROSTER_UPDATE fires "left raid" → EndSession on a 12+ hour old session).
	local MAX_SESSION_DURATION = 12 * 3600  -- 12 hours
	local sessionAge = time() - (session.startTime or time())
	if sessionAge > MAX_SESSION_DURATION then
		Cerebro:Debug(string.format("EndSession: ZOMBIE detected (%dm old) — discarding without awards",
			math.floor(sessionAge / 60)))
		if Cerebro.Ledger and Cerebro.Ledger.AddAuditLog then
			Cerebro.Ledger:AddAuditLog("SESSION_ZOMBIE",
				string.format("[%s_%s] Zombie session discarded (%dm). No EP awarded.",
					session.raidName or "?", session.sessionID or "?", math.floor(sessionAge / 60)))
		end
		-- Cancel timers
		if self.onTimeTimer then self:CancelTimer(self.onTimeTimer); self.onTimeTimer = nil end
		if self.progressionTimer then self:CancelTimer(self.progressionTimer); self.progressionTimer = nil end
		-- Archive with zombie flag (no EP awards)
		session.endTime = time()
		session.zombie = true
		Cerebro.db.global.history[session.sessionID] = Cerebro:CopyTable(session)
		Cerebro.db.global.currentSession = nil
		return
	end

	-- Unregister roster tracking event
	self:UnregisterEvent("GROUP_ROSTER_UPDATE")
	self._rosterLeaveQueue = {}
	self._rosterJoinQueue = {}
	self._lastKnownRoster = {}

	-- Cancel on-time timer if still pending
	if self.onTimeTimer then
		self:CancelTimer(self.onTimeTimer)
		self.onTimeTimer = nil
	end
	
	-- Cancel progression timer if still running
	if self.progressionTimer then
		self:CancelTimer(self.progressionTimer)
		self.progressionTimer = nil
	end

	-- Award Full Attendance bonus
	self:AwardFullAttendance(session)

	session.endTime = time()
	
	-- Update attendance.total for all participants
	for player, entry in pairs(session.roster or {}) do
		local playerData = Cerebro:GetPlayerData(player)
		if playerData and playerData.attendance and playerData.status == "Core" then
			playerData.attendance.total = (playerData.attendance.total or 0) + 1
		end
	end
	
	-- Print session summaries
	self:PrintRaidSummary(session)
	self:ScheduleTimer("PrintPersonalSummary", 3)  -- 3-second delay
	
	-- Debug: Dump roster flags BEFORE copy
	local myName = Cerebro:GetFullPlayerName(UnitName("player"))
	Cerebro:Debug("EndSession: Before copy - checking roster for " .. myName)
	for player, entry in pairs(session.roster or {}) do
		Cerebro:Debug(string.format("  %s: onTime=%s fullAttendance=%s sessionEP=%s",
			player, tostring(entry.onTime), tostring(entry.fullAttendance), tostring(entry.sessionEP)))
	end
	
	-- Move to history
	Cerebro.db.global.history[session.sessionID] = Cerebro:CopyTable(session)
	Cerebro.db.global.currentSession = nil

	-- Mark this session as ended (idempotency guard)
	self._lastEndedSessionID = session.sessionID

	-- Diagnostic trace
	local diag = Cerebro:GetModule("Diagnostics", true)
	if diag and diag.TraceEvent then
		local duration = (session.endTime or time()) - (session.startTime or time())
		pcall(diag.TraceEvent, diag, "SESSION_END", { id = session.sessionID, duration = math.floor(duration / 60) .. "m" })
	end
	self._lastEndedAt = time()

	Cerebro:Print("Raid session ended.")
	Cerebro:SendRaidMessage("[Cerebro] Session Ended.")
	
	-- Add to History tab (as special transaction)
	if Cerebro.Ledger and Cerebro.Ledger.AddTransaction then
		Cerebro.Ledger:AddTransaction(
			"SESSION", -- player field
			0,         -- amount (zero for informational entry)
			"EP",      -- currency
			"SESSION_END", -- reason
			{ sessionInfo = string.format("[%s_%s] Session Ended. (Duration: %dm)", 
				session.raidName or "?", session.sessionID,
				math.floor((session.endTime - session.startTime) / 60)) },
			false,     -- not import
			{ silent = false } -- broadcast this
		)
	end
	
	-- Add to System Logs tab
	if Cerebro.Ledger and Cerebro.Ledger.AddAuditLog then
		Cerebro.Ledger:AddAuditLog("SESSION_END", 
			string.format("[%s_%s] Session Ended. (Duration: %dm)", 
				session.raidName or "?", session.sessionID,
				math.floor((session.endTime - session.startTime) / 60)))
	end

	if Cerebro.modules.CommunicationV2 then
		Cerebro.modules.CommunicationV2:BroadcastSessionEnd(session.sessionID)
		-- NOTE: BroadcastSessionEnd already schedules a SyncToRaid with 3s delay.
		-- Do NOT call SyncToRaid here — double-push was causing sync race conditions.
	end
end

--[[=============================================
	Force End Session (Nuclear Cleanup)
	Wipes currentSession with NO EP awards.
	Use when a session is stuck or corrupted.
	Officer-only. Skips all guards/awards.
===============================================]]

function Session:ForceEndSession()
	if not Cerebro:IsOfficer() then
		Cerebro:Print("Only officers can force-end a session.")
		return
	end

	local session = self:GetCurrentSession()
	local sessionInfo = "no active session"
	if session then
		sessionInfo = string.format("[%s_%s] age=%dm",
			session.raidName or "?",
			session.sessionID or "?",
			math.floor((time() - (session.startTime or time())) / 60))
	end

	-- Cancel all timers
	if self.onTimeTimer then self:CancelTimer(self.onTimeTimer); self.onTimeTimer = nil end
	if self.progressionTimer then self:CancelTimer(self.progressionTimer); self.progressionTimer = nil end

	-- Unregister events
	pcall(function() self:UnregisterEvent("GROUP_ROSTER_UPDATE") end)
	self._rosterLeaveQueue = {}
	self._rosterJoinQueue = {}
	self._lastKnownRoster = {}

	-- Nuke the session — no awards, no history archival
	Cerebro.db.global.currentSession = nil

	-- Audit log
	if Cerebro.Ledger and Cerebro.Ledger.AddAuditLog then
		Cerebro.Ledger:AddAuditLog("SESSION_FORCE_END",
			string.format("Force-ended session: %s. No EP awarded.", sessionInfo))
	end

	Cerebro:Print("|cffff4444Session force-ended.|r No EP was awarded. " .. sessionInfo)
	Cerebro:RefreshAllUI()
end

--[[=============================================
	Full Attendance Award
===============================================]]

function Session:AwardFullAttendance(session)
	local epFullAttendance = Cerebro.db.global.config.epgp.epFullAttendance or 100
	if epFullAttendance <= 0 then return end
	
	local bossCount = #(session.bosses or {})
	local currentRoster = Cerebro:GetRaidRoster()
	local currentRosterNames = {}
	for _, member in ipairs(currentRoster) do
		currentRosterNames[member.name] = true
	end
	
	local qualifyingPlayers = 0
	
	-- Full attendance requires:
	-- 1. onTime = true
	-- 2. Present at end (in current raid roster)
	-- 3. If bosses killed: 100% boss attendance
	for player, entry in pairs(session.roster or {}) do
		local playerData = Cerebro:GetPlayerData(player)
		local status = playerData and playerData.status or "NONE"
		local isOnTime = entry.onTime == true
		local isPresentNow = currentRosterNames[player] == true
		local fullBossAttendance = (bossCount == 0) or ((entry.bossesPresent or 0) >= bossCount)
		
		Cerebro:Debug(string.format("FullAttendance check: %s status=%s onTime=%s present=%s bossOk=%s", 
			player, status, tostring(isOnTime), tostring(isPresentNow), tostring(fullBossAttendance)))
		
		if playerData and playerData.status == "Core" then
			if isOnTime and isPresentNow and fullBossAttendance then
				entry.fullAttendance = true
				qualifyingPlayers = qualifyingPlayers + 1
				Cerebro:Debug("  -> QUALIFIED for full attendance")
			else
				Cerebro:Debug("  -> NOT qualified")
			end
		else
			Cerebro:Debug("  -> SKIPPED (not Core)")
		end
	end
	
	if qualifyingPlayers > 0 then
		local fullFA, benchFA = self:AwardEPToSelected(epFullAttendance, "RAID_END", function(name, _data)
			local rosterEntry = session.roster[name]
			return rosterEntry and rosterEntry.fullAttendance
		end, { skipBench = true })
		
		-- Update attendance.full counter
		for player, entry in pairs(session.roster or {}) do
			if entry.fullAttendance then
				local playerData = Cerebro:GetPlayerData(player)
				if playerData and playerData.attendance then
					playerData.attendance.full = (playerData.attendance.full or 0) + 1
				end
			end
		end
		
		Cerebro:SendRaidMessage(string.format("[Cerebro] Full Attendance bonus: %d raid + %d bench.", fullFA, benchFA))
	end
end

function Session:IsActive()
	return Cerebro.db.global.currentSession ~= nil
end

function Session:GetCurrentSession()
	return Cerebro.db.global.currentSession
end

--[[=============================================
	Roster Management
===============================================]]

function Session:UpdateRoster()
	local session = self:GetCurrentSession()
	if not session then return end

	local currentTime = time()
	local gracePeriod = Cerebro.db.global.config.epgp.onTimeGracePeriod or 5
	local graceEndTime = session.startTime + (gracePeriod * 60)
	local roster = Cerebro:GetRaidRoster()

	for _, member in ipairs(roster) do
		local player = member.name
		if not session.roster[player] then
			-- Determine if this is a late join (after grace period)
			local isLate = currentTime > graceEndTime and self.onTimeTimer == nil
			
			session.roster[player] = {
				joinTime = currentTime,
				class = member.class,
				sessionEP = 0,
				sessionGP = 0,
				onTime = isLate and false or nil,  -- nil = pending grace check, false = late
				bossesPresent = 0,
			}
			local playerData = Cerebro:GetPlayerData(player)
            playerData.class = member.class
            
            -- Bug Fix: Removed auto-promotion from Pending to Core
            -- Raiders stay Pending until officers explicitly admit them via Officer Tools
		end
	end
end

function Session:OnPointChange(player, amount, type)
	local session = self:GetCurrentSession()
	if not session then return end
	
	-- Ensure player in roster
	if not session.roster[player] then
		-- If they aren't in roster (e.g. standby), add them purely for tracking?
		-- Or ignore. Let's add them to track standby gains.
		session.roster[player] = {
			joinTime = time(),
			class = "Unknown", -- Will be fixed if they zone in or we query
			sessionEP = 0,
			sessionGP = 0
		}
	end
	
	if type == "EP" then
		session.roster[player].sessionEP = (session.roster[player].sessionEP or 0) + amount
	elseif type == "GP" then
		session.roster[player].sessionGP = (session.roster[player].sessionGP or 0) + amount
	end
end

-- Helper for UI
function Session:GetSessionStats(player)
	local session = self:GetCurrentSession()
	if not session or not session.roster[player] then
		return 0, 0
	end
	return session.roster[player].sessionEP or 0, session.roster[player].sessionGP or 0
end

--[[=============================================
	Boss Tracking
===============================================]]

function Session:RecordBossKill(encounterID, encounterName, difficultyID)
	local session = self:GetCurrentSession()
	if not session then return end

	for _, boss in ipairs(session.bosses or {}) do
		if boss.encounterID == encounterID and boss.killed then
			return
		end
	end
	
	-- Snapshot attendees before creating boss record
	self:UpdateRoster()
	local attendees = {}
	for player, entry in pairs(session.roster or {}) do
		if Cerebro:IsPlayerInRaid(player) then
			table.insert(attendees, player)
			-- Increment per-player boss attendance
			entry.bossesPresent = (entry.bossesPresent or 0) + 1
			
			-- Increment global player boss kill counter
			local playerData = Cerebro:GetPlayerData(player)
			if playerData and playerData.status == "Core" then
				playerData.bossKillsPresent = (playerData.bossKillsPresent or 0) + 1
			end
		end
	end

	local bossData = {
		encounterID = encounterID,
		name = encounterName,
		difficultyID = difficultyID,
		killed = true,
		killTime = time(),
		attendees = attendees,  -- Who was present for this kill
	}

	table.insert(session.bosses, bossData)
	Cerebro.db.global.totalBossKills = (Cerebro.db.global.totalBossKills or 0) + 1
	Cerebro.db.global.seasonBossKills = (Cerebro.db.global.seasonBossKills or 0) + 1
	
	-- Update eligibleBossKills for all Core players admitted before this kill
	local killTime = bossData.killTime
	for name, pData in pairs(Cerebro.db.global.players) do
		if pData.coreAdmittedAt and pData.coreAdmittedAt <= killTime then
			pData.eligibleBossKills = (pData.eligibleBossKills or 0) + 1
		end
	end
	
	-- Show confirmation popup (Step 1: Award EP? → Step 2: Farm or Prog?)
	local dialog = StaticPopup_Show("CEREBRO_BOSS_KILL_CONFIRM", encounterName)
	if dialog then
		dialog.data = {
			encounterName = encounterName,
			encounterID = encounterID,
			difficultyID = difficultyID,
		}
	end

	-- Diagnostic trace
	local diagMod = Cerebro:GetModule("Diagnostics", true)
	if diagMod and diagMod.TraceEvent then
		local syncSched = Cerebro:GetModule("SyncScheduler", true)
		local isSyncLeader = syncSched and syncSched.IsSyncLeader and syncSched:IsSyncLeader() or false
		pcall(diagMod.TraceEvent, diagMod, "BOSS_POPUP", { boss = encounterName, syncLeader = tostring(isSyncLeader) })
	end
end

--[[=============================================
	Progression EP (30-minute)
===============================================]]

function Session:AwardProgressionEP()
	if not self:IsActive() then return end
	
	local session = self:GetCurrentSession()
	if not session then return end
	
	local ep = Cerebro.db.global.config.epgp.epProgressionHourly or 50
	if ep <= 0 then return end
	
	self.progressionCount = (self.progressionCount or 0) + 1
	self:UpdateRoster()
	
	local fullProg, benchProg = self:AwardEPToSelected(ep, "PROGRESSION")
	
	Cerebro:SendRaidMessage(string.format("[Cerebro] Progression EP: %d EP (%d raid + %d bench).", ep, fullProg, benchProg))
end

--[[=============================================
	Session Summary
===============================================]]

function Session:PrintRaidSummary(session)
	local cfg = Cerebro.db.global.config.epgp
	local bossCount = #(session.bosses or {})
	local duration = (session.endTime or time()) - session.startTime
	local hours = math.floor(duration / 3600)
	local mins = math.floor((duration % 3600) / 60)
	
	-- Calculate max obtainable EP
	local epOnTime = cfg.epOnTime or 50
	local epProgression = cfg.epProgressionHourly or 50
	local epFullAttendance = cfg.epFullAttendance or 100
	local progressionTicks = self.progressionCount or 0
	
	-- Boss EP (difficulty-based)
	local bossEP = 0
	for _, boss in ipairs(session.bosses or {}) do
		local ep = 50 -- Normal
		if boss.difficultyID == 15 then ep = 75 end -- Heroic
		if boss.difficultyID == 16 then ep = 100 end -- Mythic
		bossEP = bossEP + ep
	end
	
	local maxEP = epOnTime + (epProgression * progressionTicks) + bossEP + epFullAttendance
	
	Cerebro:SendRaidMessage("----------------------------------")
	Cerebro:SendRaidMessage("SESSION SUMMARY")
	Cerebro:SendRaidMessage(string.format("  Duration: %dh %dm - Bosses: %d", hours, mins, bossCount))
	Cerebro:SendRaidMessage("  ---------------------------")
	Cerebro:SendRaidMessage(string.format("  On-Time Bonus: %d EP", epOnTime))
	Cerebro:SendRaidMessage(string.format("  Progression (%dx): %d EP", progressionTicks, epProgression * progressionTicks))
	Cerebro:SendRaidMessage(string.format("  Boss Kills (%dx): %d EP", bossCount, bossEP))
	Cerebro:SendRaidMessage(string.format("  Full Attendance: %d EP", epFullAttendance))
	Cerebro:SendRaidMessage("  ---------------------------")
	Cerebro:SendRaidMessage(string.format("  Max Obtainable: %d EP (Raid EP, not Readiness)", maxEP))
	Cerebro:SendRaidMessage("----------------------------------")
end

function Session:PrintPersonalSummary()
	-- Get session from history (already archived at this point)
	local session = nil
	local latestTime = 0
	for sessionID, s in pairs(Cerebro.db.global.history) do
		if s.endTime and s.endTime > latestTime then
			latestTime = s.endTime
			session = s
			Cerebro:Debug("PrintPersonalSummary: Candidate session " .. sessionID .. " endTime=" .. s.endTime)
		end
	end
	if not session then 
		Cerebro:Debug("PrintPersonalSummary: No session found in history!")
		return 
	end
	Cerebro:Debug("PrintPersonalSummary: Using session " .. (session.sessionID or "?"))
	
	local cfg = Cerebro.db.global.config.epgp
	local playerName = Cerebro:GetFullPlayerName(UnitName("player"))
	Cerebro:Debug("PrintPersonalSummary: Looking up player " .. playerName)
	
	-- Debug: List all roster keys
	for rosterName, _ in pairs(session.roster or {}) do
		Cerebro:Debug("  Roster entry: " .. rosterName)
	end
	
	local entry = session.roster[playerName]
	if not entry then 
		-- Fallback: session was adopted via V2 RPC (roster is empty on non-starter clients).
		-- Build a minimal entry from the player's DB data so the summary still displays.
		Cerebro:Debug("PrintPersonalSummary: Player NOT found in roster — using DB fallback (remote session)")
		local playerData = Cerebro.db.global.players[playerName]
		if not playerData then return end
		entry = { sessionEP = 0, sessionGP = 0, onTime = false, fullAttendance = false }
	end
	Cerebro:Debug(string.format("PrintPersonalSummary: Found entry - onTime=%s fullAttendance=%s sessionEP=%s",
		tostring(entry.onTime), tostring(entry.fullAttendance), tostring(entry.sessionEP)))
	
	local epOnTime = cfg.epOnTime or 50
	local epProgression = cfg.epProgressionHourly or 50
	local epFullAttendance = cfg.epFullAttendance or 100
	
	-- Calculate what this player earned
	local earnedOnTime = entry.onTime and epOnTime or 0
	local earnedFullAttendance = entry.fullAttendance and epFullAttendance or 0
	local earnedProgression = (entry.progressionTicks or self.progressionCount or 0) * epProgression
	
	-- Calculate boss EP from session tracking
	local earnedBoss = (entry.sessionEP or 0) - earnedOnTime - earnedProgression - earnedFullAttendance
	if earnedBoss < 0 then earnedBoss = 0 end
	
	local total = entry.sessionEP or 0
	
	Cerebro:Print("Your Session Summary:")
	Cerebro:Print(string.format("  On-Time Bonus: %s", entry.onTime and ("+" .. epOnTime .. " EP") or "MISSED"))
	Cerebro:Print(string.format("  Progression: +%d EP", earnedProgression))
	Cerebro:Print(string.format("  Boss Kills: +%d EP", earnedBoss))
	Cerebro:Print(string.format("  Full Attendance: %s", entry.fullAttendance and ("+" .. epFullAttendance .. " EP") or "MISSED"))
	Cerebro:Print("  ---------------------------")
	Cerebro:Print(string.format("  Your Total: +%d EP (Raid EP, not Readiness)", total))
end

--[[=============================================
	Health Checks (Diagnostics Integration)
	
	Per CONTRIBUTING.md §10:
	- Pure, deterministic, nil-safe, side-effect free
	- Registered via DescribeHealthChecks() pattern
===============================================]]

function Session:DescribeHealthChecks()
	return {
		{
			id = "SESSION_STATE",
			name = "Session State Consistency",
			officerOnly = false,
			fn = function()
				local session = Cerebro.db.global.currentSession
				if session then
					-- Active session: verify required fields
					if not session.sessionID then
						return false, "Active session missing sessionID"
					end
					if not session.startedAt then
						return false, "Active session missing startedAt"
					end
					if not session.startedBy then
						return false, "Active session missing startedBy"
					end
					return true, "Active (ID=" .. session.sessionID .. ")"
				else
					-- No session: ensure no dangling roster state
					if self._rosterLeaveQueue and #self._rosterLeaveQueue > 0 then
						return false, "No session but _rosterLeaveQueue not empty"
					end
					return true, "Inactive"
				end
			end,
		},
	}
end
