--[[
    Cerebro - Database Accessors
    Extracted from Database.lua (Phase 2a decomposition)

    Player data access and query functions: GetPlayerData, GetAllPlayers,
    RepairDatabase, RecalculateEligibleBossKills, Guild Scope accessors,
    Purge Ledger cleanup, and Backup/Export utilities.

    @module Database.Accessors
    @version 0.12.0
]]--

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

--[[=============================================
	Player Data Management
===============================================]]

function Cerebro:GetPlayerData(player)
	-- Scope check FIRST: prevent auto-creation of records for out-of-guild alts
	local scopeStatus = self:VerifyGuildScope()
	if scopeStatus == false then
		-- Mismatch - return frozen guest stub
		return Cerebro.GUEST_STUB
	end
	-- Note: scopeStatus == nil (pending) allows through for now; UI will handle retry

	player = player or self:GetFullPlayerName(UnitName("player")) or "Unknown"
	-- Smart realm resolution: if short name passed, resolve correct realm
	-- from existing player records or guild roster. NEVER fall back to local
	-- realm — that creates wrong-realm entries for cross-realm guild members
	-- (e.g., "Lexike-Whisperwind" instead of "Lexike-Area52").
	if player and not player:find("-") then
		local resolved = nil
		local lowerPlayer = player:lower()
		-- 1. Check existing players table for a matching short name
		for existingName in pairs(self.db.global.players) do
			local shortExisting = existingName:match("^([^-]+)")
			if shortExisting and shortExisting:lower() == lowerPlayer then
				resolved = existingName
				break
			end
		end
		-- 2. Check guild roster for the full name with correct realm
		if not resolved then
			pcall(function()
				for gi = 1, GetNumGuildMembers() do
					local gn = GetGuildRosterInfo(gi)
					if gn then
						local shortGuild = gn:match("^([^-]+)")
						if shortGuild and shortGuild:lower() == lowerPlayer then
							resolved = self:GetFullPlayerName(gn)
							return
						end
					end
				end
			end)
		end
		-- 3. If this is the local player, use local realm (always valid for self)
		if not resolved then
			local myShort = UnitName("player")
			if myShort and myShort:lower() == lowerPlayer then
				resolved = self:GetFullPlayerName(player)
			end
		end
		-- 4. No match found — cannot resolve realm safely. Return GUEST_STUB
		--    to prevent creating a wrong-realm entry.
		if not resolved then
			self:Debug("GetPlayerData: cannot resolve realm for '" .. player .. "', returning GUEST_STUB")
			return Cerebro.GUEST_STUB
		end
		player = resolved
	end

	if not self.db.global.players[player] then
		-- GUARD: Don't auto-create purged or blocked players.
		-- Session.lua iterates old session rosters and calls GetPlayerData(player)
		-- for every historical participant — without this guard, purged players
		-- silently resurrect as Pending stubs every time EndSession runs.
		-- Players who rejoin the guild get re-created via DiscoverGuildGroupMembers.
		local purged = self.db.global.purgedPlayers
		local blocked = self.db.global.blockedPlayers
		if (purged and purged[player]) or (blocked and blocked[player]) then
			-- SELF-PROTECTION: Never block your own character
			local myName = self:GetFullPlayerName(UnitName("player"))
			if player == myName then
				-- Auto-clean: remove self from purge/block lists
				if purged then purged[player] = nil end
				if blocked then blocked[player] = nil end
				self:Debug("Self-protection: removed self from purge/block list: " .. player)
			else
				return Cerebro.GUEST_STUB
			end
		end

		-- Create new player entry

		-- Try multiple sources to populate class
		-- Performance: This scan only fires on first encounter for classless
		-- players. Once class is set, it persists in SavedVariables.
		local classFile = select(2, UnitClass(player))  -- Works for unit IDs only
		if not classFile then
			-- Try guild roster (works for any guild member by name)
			-- pcall wraps anonymous fn; return exits the fn, classFile set via upvalue
			pcall(function()
				for gi = 1, GetNumGuildMembers() do
					local gn, _, _, _, _, _, _, _, _, _, gClassFile = GetGuildRosterInfo(gi)
					if gn and self:GetFullPlayerName(gn) == player then
						classFile = gClassFile
						return
					end
				end
			end)
		end
		if not classFile and IsInRaid() then
			-- Try raid roster (works for raid members by index)
			-- GetRaidRosterInfo returns: name, rank, subgroup, level, class, fileName, ...
			-- Position 6 (fileName) is the class token e.g. "WARRIOR" (not localized)
			for ri = 1, GetNumGroupMembers() do
				local rn, _, _, _, _, rClassFile = GetRaidRosterInfo(ri)
				if rn and self:GetFullPlayerName(rn) == player then
					classFile = rClassFile
					break
				end
			end
		end

		self.db.global.players[player] = {
			class = classFile,
			lastSeen = time(),
			lastUpdate = GetServerTime and GetServerTime() or time(), -- For LWW sync
			createdEpoch = self.db.global.resetEpoch or 0,  -- For stale record detection (v0.8.18)
			attendance = {
				total = 0,
				onTime = 0,
				prepared = 0,
				full = 0,            -- Sessions with full attendance
			},
			bossKillsPresent = 0,
			coreAdmittedAt = nil,  -- Timestamp when admitted to Core (set on status change)
			-- EPGP Data
			ep = 0,
			gp = 200,
			pr = 0,
			status = "Pending",
			role = nil,
		}
		self:Debug(string.format("Created new player entry: %s (status=Pending)", player))
	end

	-- Ensure bossKillsPresent exists (migration)
	if not self.db.global.players[player].bossKillsPresent then
		self.db.global.players[player].bossKillsPresent = 0
	end

	-- Ensure EPGP fields exist (migration)
	if not self.db.global.players[player].ep then
		self.db.global.players[player].ep = 0
		self.db.global.players[player].gp = self.db.global.config.epgp.baseGP or 200
		self.db.global.players[player].pr = 0
		self.db.global.players[player].status = "Pending" -- Default to Pending
	end

	-- Update last seen
	self.db.global.players[player].lastSeen = time()

	return self.db.global.players[player]
end


function Cerebro:GetAllPlayers()
	local players = {}

	-- Calculate total boss kills
	local totalBosses = self.db.global.totalBossKills or 0
    local isOfficer = self:IsOfficer()

	-- Rank visibility filter (officers only, configured in Settings tab)
	local visibleRanks = isOfficer
		and self.db.global.config
		and self.db.global.config.sync
		and self.db.global.config.sync.visibleRanks or nil
	local hasRankFilter = visibleRanks and next(visibleRanks) ~= nil

	-- Non-guildie filter (officers only, configured in Settings tab)
	local hideNonGuildies = isOfficer
		and self.db.global.config
		and self.db.global.config.sync
		and self.db.global.config.sync.hideNonGuildies

	-- Build guild roster lookup if needed for non-guildie filtering
	local guildSet = nil
	if hideNonGuildies then
		guildSet = {}
		for i = 1, GetNumGuildMembers() do
			local gName = GetGuildRosterInfo(i)
			if gName then
				guildSet[self:GetFullPlayerName(gName)] = true
			end
		end
	end

	for name, data in pairs(self.db.global.players) do
        -- Filtering: Standard users do NOT see Pending players in the list
        -- Officers see everyone.
        local status = data.status or "Core"
        local isPending = (status == "Pending")

        if isOfficer or not isPending then
			-- Rank filter: skip GUILD players whose rank is not in visibleRanks
			-- Non-guild players (rankIndex == nil) always pass — they're controlled by hideNonGuildies
			local passRank = true
			if hasRankFilter then
				local rankIndex = self:GetPlayerGuildRankIndex(name)
				if rankIndex ~= nil and not visibleRanks[rankIndex] then
					passRank = false
				end
			end

			-- Non-guildie filter: skip players not currently in the guild roster
			-- Normalize name to handle realm format differences (e.g., "Area 52" vs "Area52")
			local passGuild = true
			if guildSet and not guildSet[name] and not guildSet[self:GetFullPlayerName(name)] then
				passGuild = false
			end

			-- Auto-uncheck hidden players so they don't receive EP via CustomEP/Session
			if not passRank or not passGuild then
				local sel = self.db.global.customEP and self.db.global.customEP.selectedPlayers
				if sel and sel[name] then
					sel[name] = nil
					-- Also update StandingsFrame in-memory selection
					local sf = self:GetModule("StandingsFrame", true)
					if sf and sf.selected then
						sf.selected[name] = nil
					end
					self:Debug("Filter: auto-unchecked hidden player " .. name)
				end
			end

			if passRank and passGuild then
				-- Calculate percentage
				local pct = 0
				if totalBosses > 0 then
					pct = math.floor(((data.bossKillsPresent or 0) / totalBosses) * 100)
				end

				table.insert(players, {
					name = name,
					lifetimeEarned = data.lifetimeEarned or 0,
					lifetimeSpent = data.lifetimeSpent or 0,
					class = data.class,
					lastSeen = data.lastSeen,
					attendance = data.attendance,
					attendancePct = pct, -- 0-100 based on boss kills
					isMain = not data.main,
					-- EPGP Data
					ep = data.ep or 0,
					gp = data.gp or (self.db.global.config.epgp and self.db.global.config.epgp.baseGP) or 200,
					pr = data.pr or 0,
					status = status,
					role = data.role,
					-- Attendance tracking
					bossKillsPresent = data.bossKillsPresent or 0,
					coreAdmittedAt = data.coreAdmittedAt,
					eligibleBossKills = data.eligibleBossKills or 0,
				})
			end
        end
	end

	-- Sort by PR descending
	table.sort(players, function(a, b) return (a.pr or 0) > (b.pr or 0) end)

	return players
end


--[[=============================================
	Backup/Export
===============================================]]

function Cerebro:ExportData()
	-- Serialize entire database for export
	local serialized = self:Serialize(self.db.global)
	return serialized
end

function Cerebro:ImportData(data)
	local success, importedData = self:Deserialize(data)

	if not success then
		self:Print("Failed to import data: Invalid format.")
		return false
	end

	-- Backup current data
	local backup = self:CopyTable(self.db.global)

	-- Import
	self.db.global = importedData

	self:Print("Data imported successfully.")

	return true, backup
end

function Cerebro:BackupDatabase()
	local backup = self:CopyTable(self.db.global)
	local timestamp = date("%Y-%m-%d_%H-%M-%S")
	local backupName = "Cerebro_Backup_" .. timestamp

	-- Store in a separate saved variable (would need to add to TOC)
	if not CerebroBackups then
		CerebroBackups = {}
	end

	CerebroBackups[backupName] = backup

	self:Print("Database backed up as: " .. backupName)

	return backupName
end

--[[=============================================
	Cleanup
===============================================]]

function Cerebro:CleanupInactivePlayers(days)
	days = days or 30
	local threshold = time() - (days * 24 * 60 * 60)
	local removed = 0

	for player, data in pairs(self.db.global.players) do
		if data.lastSeen < threshold then
			self.db.global.players[player] = nil
			removed = removed + 1
		end
	end

	if removed > 0 then
		self:Print("Removed " .. removed .. " inactive players (>30 days).")
	else
		self:Print("No inactive players to remove.")
	end
end

--[[=============================================
	Database Repair
===============================================]]

function Cerebro:RepairDatabase()
	self:Debug("Repairing database...")
	local fixedClasses = 0
	local guildRosterLoaded = (GetNumGuildMembers() > 0)

	-- Phase -1: Realm name normalization migration.
	-- WoW's GetGuildRosterInfo returns "Area52" but GetRealmName() returns "Area 52".
	-- This migrates any player/purge/block keys with spaces in realm names to match the
	-- API's no-space format, and merges duplicates via LWW (highest lastUpdate wins).
	local realmFixed = 0
	-- Migrate players
	local playerRenames = {}
	for name, data in pairs(self.db.global.players) do
		if type(data) == "table" then
			-- Check if realm portion has spaces (e.g., "Area 52" in "Frixell-Area 52")
			local n, r = name:match("^([^-]+)-(.+)$")
			if r and r:find(" ") then
				local cleanRealm = r:gsub("%s", "")
				local newName = n .. "-" .. cleanRealm
				playerRenames[name] = { newName = newName, data = data }
			end
		end
	end
	for oldName, info in pairs(playerRenames) do
		local existing = self.db.global.players[info.newName]
		if existing then
			-- Merge: keep the one with higher lastUpdate
			local oldUpdate = (info.data.lastUpdate or 0)
			local newUpdate = (existing.lastUpdate or 0)
			if oldUpdate > newUpdate then
				self.db.global.players[info.newName] = info.data
			end
		else
			self.db.global.players[info.newName] = info.data
		end
		self.db.global.players[oldName] = nil
		realmFixed = realmFixed + 1
		self:Debug("RealmFix: " .. oldName .. " -> " .. info.newName)
	end
	-- Migrate purgedPlayers: normalize via GetFullPlayerName (TitleCase + realm spaces)
	-- Also removes junk entries without hyphens and self-entries
	local myName = self:GetFullPlayerName(UnitName("player"))
	if self.db.global.purgedPlayers then
		local purgeNorm = {}
		for name, ts in pairs(self.db.global.purgedPlayers) do
			-- Skip junk entries (no hyphen = not a valid player name)
			if not name:find("-", 1, true) then
				self.db.global.purgedPlayers[name] = nil
				self:Debug("PurgeClean: removed junk entry: " .. name)
			else
				local normalized = self:GetFullPlayerName(name) or name
				-- Self-protection: never purge yourself
				if normalized == myName then
					self.db.global.purgedPlayers[name] = nil
					self:Debug("PurgeClean: removed self from purge list: " .. name)
				elseif normalized ~= name then
					purgeNorm[name] = { newName = normalized, ts = ts }
				end
			end
		end
		for oldName, info in pairs(purgeNorm) do
			local existingTs = self.db.global.purgedPlayers[info.newName] or 0
			self.db.global.purgedPlayers[info.newName] = math.max(info.ts, existingTs)
			self.db.global.purgedPlayers[oldName] = nil
			self:Debug("PurgeNorm: " .. oldName .. " -> " .. info.newName)
		end
	end
	-- Migrate blockedPlayers: same normalization
	if self.db.global.blockedPlayers then
		local blockNorm = {}
		for name, ts in pairs(self.db.global.blockedPlayers) do
			if not name:find("-", 1, true) then
				self.db.global.blockedPlayers[name] = nil
				self:Debug("BlockClean: removed junk entry: " .. name)
			else
				local normalized = self:GetFullPlayerName(name) or name
				if normalized == myName then
					self.db.global.blockedPlayers[name] = nil
					self:Debug("BlockClean: removed self from block list: " .. name)
				elseif normalized ~= name then
					blockNorm[name] = { newName = normalized, ts = ts }
				end
			end
		end
		for oldName, info in pairs(blockNorm) do
			local existingTs = self.db.global.blockedPlayers[info.newName] or 0
			self.db.global.blockedPlayers[info.newName] = math.max(info.ts, existingTs)
			self.db.global.blockedPlayers[oldName] = nil
			self:Debug("BlockNorm: " .. oldName .. " -> " .. info.newName)
		end
	end

	-- Valid player data fields — must match VALID_PLAYER_FIELDS in CommunicationV2.lua
	-- IMPORTANT: If you add a new field to player data, update BOTH lists.
	local VALID_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,
	}

	-- Phase 0: Remove fake player entries (config objects leaked as players)
	-- A valid player key must contain "-" (Name-Realm format)
	-- Auto-block them into blockedPlayers so they can't be re-synced
	local removedFakes = 0
	local fakesToRemove = {}
	self.db.global.blockedPlayers = self.db.global.blockedPlayers or {}
	for name, data in pairs(self.db.global.players) do
		local isFake = false
		-- Non-table entries are always fake
		if type(data) ~= "table" then
			isFake = true
		-- Entries without "-" in the key are not Name-Realm format
		elseif not name:find("-", 1, true) then
			isFake = true
		end
		if isFake then
			fakesToRemove[name] = true
		end
	end
	for name in pairs(fakesToRemove) do
		self.db.global.players[name] = nil
		self.db.global.blockedPlayers[name] = self.db.global.blockedPlayers[name] or time()
		removedFakes = removedFakes + 1
		self:Debug("RepairDB: auto-blocked fake player entry: " .. tostring(name))
	end

	-- Phase 0.5: Strip junk field names from real player entries
	local strippedFields = 0
	for name, data in pairs(self.db.global.players) do
		if type(data) == "table" then
			local junkKeys = {}
			for k in pairs(data) do
				if not VALID_FIELDS[k] then
					junkKeys[#junkKeys + 1] = k
				end
			end
			for _, k in ipairs(junkKeys) do
				data[k] = nil
				strippedFields = strippedFields + 1
				self:Debug("RepairDB: stripped junk field '" .. k .. "' from " .. name)
			end
		end
	end

	-- Phase 1.5: Corruption validation — auto-purge malformed player records.
	-- Corrupt records spread via sync: one officer's stale/broken data infects everyone.
	-- By adding corrupted entries to purgedPlayers, the purge propagates via V2 snapshots,
	-- ensuring ALL officers delete the corrupt record when they next sync.
	local VALID_STATUSES = {Core = true, Pending = true, Trial = true, Inactive = true}
	local corruptPurged = 0
	local corruptToRemove = {}
	self.db.global.purgedPlayers = self.db.global.purgedPlayers or {}
	for name, data in pairs(self.db.global.players) do
		local isCorrupt = false
		local reason = ""

		if type(data) ~= "table" then
			isCorrupt = true
			reason = "non-table data"
		elseif not data.status then
			isCorrupt = true
			reason = "missing status"
		elseif not VALID_STATUSES[data.status] then
			isCorrupt = true
			reason = "invalid status: " .. tostring(data.status)
		elseif data.ep and type(data.ep) ~= "number" then
			isCorrupt = true
			reason = "ep is " .. type(data.ep)
		elseif data.gp and type(data.gp) ~= "number" then
			isCorrupt = true
			reason = "gp is " .. type(data.gp)
		end

		if isCorrupt then
			corruptToRemove[name] = reason
		end
	end
	for name, reason in pairs(corruptToRemove) do
		self.db.global.players[name] = nil
		self.db.global.purgedPlayers[name] = self.db.global.purgedPlayers[name] or time()
		corruptPurged = corruptPurged + 1
		self:Debug("RepairDB: auto-purged corrupt player: " .. name .. " (" .. reason .. ")")
	end
	if corruptPurged > 0 then
		self:Print(string.format("|cffff8800[RepairDB]|r Auto-purged %d corrupt player record(s).", corruptPurged))
	end

	-- Phase 1.6: Clean up customEP.selectedPlayers — remove purged/blocked entries.
	-- Without this, purged players stay in the checklist and get prep-checked.
	local selectedPlayers = self.db.global.customEP
		and self.db.global.customEP.selectedPlayers
	if selectedPlayers then
		local cleanedSelections = 0
		for name in pairs(selectedPlayers) do
			if (self.db.global.purgedPlayers and self.db.global.purgedPlayers[name])
				or (self.db.global.blockedPlayers and self.db.global.blockedPlayers[name])
				or not self.db.global.players[name] then
				selectedPlayers[name] = nil
				cleanedSelections = cleanedSelections + 1
			end
		end
		if cleanedSelections > 0 then
			self:Debug("RepairDB: cleaned " .. cleanedSelections .. " stale entries from selectedPlayers")
		end
	end

	-- Pre-scan guild roster for lookup map if available
	local guildMap = {}
	if guildRosterLoaded then
		for i = 1, GetNumGuildMembers() do
			local name, _, _, _, _, _, _, _, _, _, classFileName = GetGuildRosterInfo(i)
			if name then
				-- Normalize name-realm
				guildMap[self:GetFullPlayerName(name)] = classFileName
			end
		end
	end

	-- Phase 0.5: Clean up stale purge/block entries for guild members.
	-- Guild members should never be in the purge list — they belong in standings
	-- as Pending (for officer review) or Core. Stale purge entries from old versions
	-- or sync bugs cause guild members to be invisible in standings.
	local cleanedPurges = 0
	if guildRosterLoaded and next(guildMap) then
		for name in pairs(self.db.global.purgedPlayers or {}) do
			local inGuild = guildMap[name] or guildMap[self:GetFullPlayerName(name)]
			if inGuild then
				self.db.global.purgedPlayers[name] = nil
				cleanedPurges = cleanedPurges + 1
				self:Debug("RepairDB: removed stale purge entry for guild member: " .. name)
			end
		end
		for name in pairs(self.db.global.blockedPlayers or {}) do
			local inGuild = guildMap[name] or guildMap[self:GetFullPlayerName(name)]
			if inGuild then
				self.db.global.blockedPlayers[name] = nil
				cleanedPurges = cleanedPurges + 1
				self:Debug("RepairDB: removed stale block entry for guild member: " .. name)
			end
		end
	end

	-- Phase 0.9: Auto-demote non-guild Core players to Pending.
	-- If a player is Core but NOT in the guild roster, they were synced from
	-- another officer's different roster. The guild roster is the source of truth
	-- for who can be Core. Demoting to Pending lets Phase 1 clean them up
	-- automatically if they also have 0 EP. Players with EP keep their data as
	-- Pending for officer review. This runs on every client automatically —
	-- no slash commands needed.
	local demotedCore = 0
	if guildRosterLoaded and next(guildMap) then
		for name, data in pairs(self.db.global.players) do
			if type(data) == "table" and data.status == "Core" then
				-- Check both raw key and normalized name for cross-realm compatibility
				local inGuild = guildMap[name] or guildMap[self:GetFullPlayerName(name)]
				if not inGuild and name ~= myName then
					data.status = "Pending"
					demotedCore = demotedCore + 1
					self:Debug("RepairDB: demoted non-guild Core player to Pending: " .. name)
				end
			end
		end
	end

	-- Phase 1: Smart cleanup of ghost Pending stubs.
	-- Only removes Pending+0EP players who are NOT in the guild roster.
	-- Guild members stay as Pending for officer review/admission.
	local removedPending = 0
	if guildRosterLoaded and next(guildMap) then
		local pendingToRemove = {}
		for name, data in pairs(self.db.global.players) do
			if type(data) == "table" and data.status == "Pending" and (data.ep or 0) == 0 then
				-- Check both raw key and normalized name for cross-realm compatibility
				local inGuild = guildMap[name] or guildMap[self:GetFullPlayerName(name)]
				if not inGuild then
					pendingToRemove[#pendingToRemove + 1] = name
				end
			end
		end
		for _, name in ipairs(pendingToRemove) do
			self.db.global.players[name] = nil
			removedPending = removedPending + 1
			self:Debug("RepairDB: cleaned up non-guild Pending stub: " .. name)
		end
	end

	-- Scan database
	for player, data in pairs(self.db.global.players) do
		if not data.class then
			local newClass = nil

			-- 1. Try UnitClass (if online/visible)
			if UnitName(player) then -- Only works if in range/party
				newClass = select(2, UnitClass(player))
			end

			-- 2. Try Guild Roster Map
			if not newClass and guildMap[player] then
				newClass = guildMap[player]
			end

			-- 3. Try Raid Roster
			if not newClass and IsInRaid() then
				for i = 1, GetNumGroupMembers() do
					local name, _, _, _, _, fileName = GetRaidRosterInfo(i)
					if name and self:GetFullPlayerName(name) == player then
						newClass = fileName
						break
					end
				end
			end

			if newClass then
				data.class = newClass
				fixedClasses = fixedClasses + 1
				self:Debug("Repaired class for " .. player .. ": " .. newClass)
			end
		end

		-- Fix: Ensure Core/Survivor/Officer players have a coreAdmittedAt timestamp
		-- If missing, they show as "(NEW)" in attendance. Default to 0 to count all history.
		if not data.coreAdmittedAt and (data.status == "Core" or data.status == "Officer" or data.status == "Survivor" or data.status == "Veteran Member" or data.status == "Veteran" or data.status == "Alt") then
			-- Backfill with 1 (Epoch start) to ensure they are eligible for all historical bosses
			data.coreAdmittedAt = 1
			-- NOTE: Do NOT bump data.lastUpdate here. This is a schema migration (not a data
			-- change) that runs on every officer's login at different wall-clock seconds.
			-- Bumping lastUpdate causes permanent roster hash divergence between officers even
			-- when EP/GP/status are identical. Every officer applies this migration independently,
			-- so LWW propagation is unnecessary.
			self:Debug("Repaired coreAdmittedAt for " .. player)
		end
	end

	-- Report results
	local totalFixes = fixedClasses + removedFakes + strippedFields + removedPending + realmFixed + demotedCore
	if totalFixes > 0 then
		local parts = {}
		if realmFixed > 0 then parts[#parts + 1] = realmFixed .. " realm names normalized" end
		if demotedCore > 0 then parts[#parts + 1] = demotedCore .. " non-guild Core demoted" end
		if removedFakes > 0 then parts[#parts + 1] = removedFakes .. " fake entries removed" end
		if strippedFields > 0 then parts[#parts + 1] = strippedFields .. " junk fields stripped" end
		if fixedClasses > 0 then parts[#parts + 1] = fixedClasses .. " classes repaired" end
		if removedPending > 0 then parts[#parts + 1] = removedPending .. " non-guild ghosts cleaned" end
		self:Print("DB Repair: " .. table.concat(parts, ", ") .. ".")
	else
		self:Debug("Database repair complete. No issues found.")
	end

	-- Recalculate eligible boss kills for attendance (officer-side)
	self:RecalculateEligibleBossKills()
end

--[[=============================================
	Eligible Boss Kills Calculation

	Pre-computes eligibleBossKills per player by iterating
	db.global.history + currentSession. This value is synced
	to raiders so they can display accurate attendance without
	needing the full session history.
===============================================]]

function Cerebro:RecalculateEligibleBossKills()
	-- Only meaningful on officer clients (they have history)
	local history = self.db.global.history
	if not history then return end

	local currentSession = self.db.global.currentSession
	local recalculated = 0

	for name, data in pairs(self.db.global.players) do
		local coreAdmittedAt = data.coreAdmittedAt
		if not coreAdmittedAt then
			-- Non-core players have no eligibility
			data.eligibleBossKills = 0
		else
			local count = 0

			-- Count from archived sessions
			for _, session in pairs(history) do
				for _, boss in ipairs(session.bosses or {}) do
					if boss.killTime and boss.killTime >= coreAdmittedAt then
						count = count + 1
					end
				end
			end

			-- Count from current session
			if currentSession then
				for _, boss in ipairs(currentSession.bosses or {}) do
					if boss.killTime and boss.killTime >= coreAdmittedAt then
						count = count + 1
					end
				end
			end

			data.eligibleBossKills = count
			recalculated = recalculated + 1
		end
	end

	if recalculated > 0 then
		self:Debug(string.format("Recalculated eligibleBossKills for %d players", recalculated))
	end
end

--[[=============================================
	Guild Scope Functions
===============================================]]

-- Get the current authorized guild scope
function Cerebro:GetAuthorizedGuild()
	return self.db.global.scope
end

-- Set the authorized guild scope (officer-only action)
-- Returns true on success, false on failure
function Cerebro:SetAuthorizedGuild()
	if not IsInGuild() then
		self:Debug("SetAuthorizedGuild failed: not in guild")
		return false
	end

	local guildName, _, _, _ = GetGuildInfo("player")
	if not guildName then
		self:Debug("SetAuthorizedGuild failed: GetGuildInfo returned nil")
		return false
	end

	-- Use GetGuildGUID if available (more stable than name)
	local guildGUID = nil
	if GetGuildGUID then
		guildGUID = GetGuildGUID()
	end

	-- Use normalized realm
	local normalizedRealm = self:GetNormalizedRealm()

	self.db.global.scope = {
		guildGUID = guildGUID,
		guildName = guildName,
		realm = normalizedRealm,
		setBy = self:GetFullPlayerName(UnitName("player")),
		setAt = time(),
		migrated = false,
	}

	-- Add audit log
	if self.Ledger and self.Ledger.AddAuditLog then
		self.Ledger:AddAuditLog("SCOPE_SET",
			string.format("Guild locked to %s-%s", guildName, normalizedRealm))
	end

	self:Debug(string.format("Guild scope set: %s-%s", guildName, normalizedRealm))
	return true
end

--[[=============================================
	Purge Ledger Cleanup
===============================================]]

function Cerebro:CleanupPurgeLedger()
	local purgedPlayers = self.db.global.purgedPlayers
	if not purgedPlayers then return end

	local now = time()
	local cutoff = now - (30 * 24 * 60 * 60)  -- 30 days
	local removed = 0

	for name, purgeTime in pairs(purgedPlayers) do
		if purgeTime < cutoff or purgeTime > now then
			purgedPlayers[name] = nil
			removed = removed + 1
		end
	end

	if removed > 0 then
		self:Debug("Cleaned " .. removed .. " stale purge entries")
	end
end
