--[[
	Cerebro - Database Management
	Handles SavedVariables initialization, defaults, and migrations
]]--

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

-- Default database structure
local defaults = {
	global = {
		-- Version for migration tracking
		dbVersion = 1,
		
		-- RCLC Deduplication (No migration needed, AceDB backfills)
		-- Key: session|winner|itemID|response|status -> timestamp
		rclcAwardDedupe = {},

		-- RCLC Import Deduplication
		-- Key: rclcId (servertime|player|itemString) -> timestamp
		rclcImportedIds = {},
		-- Last RCLC import metadata (for /cb rclc status)
		rclcLastImport = nil,  -- { timestamp, sinceDate, imported, voided, skipped, importedBy }

		-- Sync status tracking (for /cb status)
		-- lastOfficerSync: Unix timestamp of last SYNC_DATA receipt from officer
		syncStatus = {
			lastOfficerSync = 0,
		},

		-- Factory Reset Quarantine System
		-- Epoch: Increments on every factory reset; used to reject stale sync payloads
		-- Quarantine: Blocks sync for 15 min after reset to prevent data restoration
		resetEpoch = 0,
		resetQuarantine = nil,  -- { at = timestamp }

		-- RCLC Integration (v1.0.0+)
		-- Stores per-button GP multipliers/overrides
		rclc = {
			groupButtons = {}, -- [groupName] = { [buttonIndex] = "100%" or "50" }
			responses = {},    -- Legacy/Fallback if needed
		},

		-- Guild Scope (Guild Locking)
		-- Binds database to a specific guild to prevent alt data leakage
		-- See SPEC.md §3.5 and SECURITY_MODEL.md §7
		scope = nil,  -- { guildGUID, guildName, realm, setBy, setAt, migrated }

		-- Purge Ledger (Negative Ledger pattern)
		-- Tracks permanently deleted players to prevent sync resurrection
		-- See docs/proposals/PLAYER_PURGE.md
		purgedPlayers = {},  -- { ["Name-Realm"] = timestamp }

		-- Tracks blocked entries (corrupted names, fake entries) — prevents sync resurrection
		-- Synced to ALL clients via V2 snapshot. Use /cb block to add, /cb unblock to remove.
		blockedPlayers = {},  -- { ["Name-Realm"] = timestamp }

		-- Season tracking (for M+ week calculation)
		-- Stores last seen season ID to detect season changes
		lastKnownSeasonID = 0,

		-- Attendance Tracking (per-season)
		-- Reset when season changes or factory reset
		seasonBossKills = 0,      -- Total boss kills this season
		seasonID = 0,             -- Current M+ season ID for reset detection

		-- Minimap icon position (for LibDBIcon)
		minimap = {
			hide = false,
		},

		-- Configuration
		config = {
			debug = false,
			debugLevel = "off",  -- "off" | "simple" | "technical" | "both"

			epgp = {
				baseGP = 200,
				minEP = 500,
				decayRate = 0.10, -- 10%
				epOnTime = 700,              -- Raid Start EP
				epFarmBoss = 15,             -- Farm Boss EP
				epProgBoss = 50,             -- Prog Boss EP
				epFullAttendance = 700,      -- Raid End EP
				epProgressionHourly = 15,    -- Progression Interval EP
				progressionIntervalMin = 30, -- Minutes between progression EP ticks
				benchMultiplier = 1.01,      -- 101% for standby
				offspecMultiplier = 0.5,
				minorUpgradeMultiplier = 0.5,
				minGP = 100, -- Minimum GP
				decay = 15,  -- 15% Weekly Decay
				allowTestAwards = false, -- Allow RCLC Test Mode to charge GP
				onTimeGracePeriod = 0,   -- Minutes after session start for on-time arrivals

				-- Slot Weights (Base GP)
				slotWeights = {
					["INVTYPE_HEAD"] = 1500,
					["INVTYPE_NECK"] = 840,
					["INVTYPE_SHOULDER"] = 1125,
					["INVTYPE_CLOAK"] = 500,       -- Back
					["INVTYPE_CHEST"] = 1500,
					["INVTYPE_ROBE"] = 1500,       -- Chest (Robe variant)
					["INVTYPE_WRIST"] = 840,
					["INVTYPE_HAND"] = 1125,
					["INVTYPE_WAIST"] = 1125,
					["INVTYPE_LEGS"] = 1500,
					["INVTYPE_FEET"] = 1125,
					["INVTYPE_FINGER"] = 840,      -- Ring
					["INVTYPE_TRINKET"] = 1875,
					
					["INVTYPE_WEAPON"] = 1025,     -- Weapon (1H)
					["INVTYPE_SHIELD"] = 1025,
					["INVTYPE_RANGED"] = 2050,     -- Classic ranged slot
					["INVTYPE_RANGEDRIGHT"] = 2050, -- Retail ranged (bows, guns, crossbows, wands)
					["INVTYPE_2HWEAPON"] = 2050,   -- Weapon (2H)
					["INVTYPE_WEAPONMAINHAND"] = 1025, -- Main Hand
					["INVTYPE_WEAPONOFFHAND"] = 1025,  -- Off Hand
					["INVTYPE_HOLDABLE"] = 1025,   -- Off Hand (Frill)

					-- Special "Curio" slot for Context Tokens
					["Curio"] = 1350,
					["INVTYPE_RECIPE"] = 0,
					
					-- Collectible / Non-Gear Slots (default 0 — officers enable as needed)
					["INVTYPE_TOY"] = 0,         -- Toys (collectible fun items)
					["INVTYPE_PET"] = 0,         -- Companion / Battle Pets
					["INVTYPE_MOUNT"] = 0,       -- Mounts
					["INVTYPE_COSMETIC"] = 0,    -- Cosmetics (appearance-only gear)
					["INVTYPE_DECOR"] = 0,       -- Decor / Delves decoration items
				},

				-- Duplicate Slot Multipliers
				-- When awarding an item for a slot that some players only need 1 of,
				-- a popup lets the officer charge reduced GP (e.g. 2H for non-DW classes).
				duplicateSlotMultipliers = {
					["INVTYPE_2HWEAPON"] = 0.5,   -- 50% GP for single 2H slot classes
				},
				
				-- Difficulty Multipliers
				-- Keys are STRINGS ("14", "15") to match AceGUI Select control requirements
				-- 14=Normal, 15=Heroic, 16=Mythic (Standard WoW Difficulty IDs)
				difficultyMultipliers = {
					["14"] = 1.00, -- Normal
					["15"] = 1.15, -- Heroic
					["16"] = 1.30, -- Mythic
				},
				
				-- Mythic+ Configuration
				mplusBase = 100,             -- Base EP for completing any M+ dungeon
				mplusPerLevel = 5,           -- EP per keystone level
				mplusMinLevel = 2,           -- Min keystone level for scaling (default 2)
				mplusMaxLevel = 12,          -- Cap level for EP calculation
				-- Guild Bonuses (Percentage "50%" or Flat "50")
				-- Keys: mplusGuildBonus2..5 (2 members to 5 members)
				mplusGuildBonus2 = "0%",
				mplusGuildBonus3 = "0%",
				mplusGuildBonus4 = "0%",
				mplusGuildBonus5 = "0%",
				readinessCap = 400,          -- Weekly pre-raid EP cap

				-- Season Start Override (officer-set, Unix timestamp)
				-- Used when hardcoded season start dates are missing/incorrect
				seasonStartOverride = nil,

				-- Calendar Sign-Up EP
				epSignUp = 0,                -- EP for calendar sign-up (0 = disabled by default)
				signupStatuses = {           -- Qualifying RSVP statuses
					[1] = true,              -- Accepted
					[3] = true,              -- Confirmed
				},
			},

			-- Per-section LWW timestamps for config sync
			-- Updated automatically when an officer changes any setting in that section
			configLastUpdate = {
				epgp = 0,
				prep = 0,
				sync = 0,
				admin = 0,
			},

			prep = {
				allOrNothing = false,             -- Master toggle: pass everything or get zero EP
				requireGems = true,
				requireEnchants = true,
				requireFood = true,               -- Require Well Fed food buff
				requireFlask = true,              -- Require Flask / Elixir buff
				requireAugmentRune = true,        -- Require Augment Rune buff
				requireVantusRune = false,        -- Require Vantus Rune buff
				requireWeaponEnhancement = true,  -- Require weapon oil/stone
				requireMinIlvl = false,           -- Require minimum average item level
				minIlvl = 237,                    -- Minimum item level for prep checks
				-- Per-category EP awards
				minIlvlEP = 0,                    -- EP for meeting min iLvl (flat)
				gemEPPerSocket = 1,               -- EP per valid gem (proportional)
				enchantEPPerSlot = 1,             -- EP per valid enchant (proportional)
				foodEP = 1,                       -- EP for having food buff (flat)
				flaskEP = 1,                      -- EP for having flask (flat)
				augmentRuneEP = 1,                -- EP for augment rune (flat)
				vantusRuneEP = 1,                 -- EP for vantus rune (flat)
				weaponEnhancementEP = 1,          -- EP for weapon buff (flat)
			},

			ui = {
				showMinimapIcon = true,
				lockFrames = false,
			},

			-- Admin/Governance Settings
			admin = {
				allowOfficerFactoryReset = false, -- GM-only by default
				allowOfficerScopeReset = false,   -- Officers can switch guilds (GM-only by default)
			},

			-- Sync Configuration (Fix 5 + Fix 2 merged)
			-- Synced to all clients via config payload in BuildSyncPayload
			sync = {
				-- Guild ranks visible in Standings and included in sync payload
				-- Key: rankIndex (0=GM, 1=Officer, etc.), Value: true/false
				-- Players with non-visible ranks are excluded from sync and ANN_SELF
				visibleRanks = {},  -- No defaults; officers configure on first use
				-- Guild ranks eligible for Sync Leader election (Fix 2)
				-- Only candidates with these ranks participate in leader election
				syncLeaderRanks = { [0] = true, [1] = true },
			},
		},

		-- Factory Reset Backup (Rolling single backup)
		-- Created before any factory reset; can be restored by GM only
		-- Includes resetEpoch so restore doesn't reject future syncs as stale
		backup = nil, -- { timestamp, version, players, transactions, audit, epgpConfig, resetEpoch }

		-- Player data (EP, GP, PR)
		-- Delta Sync v2 adds: rev (global revision), fieldRev (per-field revisions)
		players = {
			-- ["PlayerName-RealmName"] = {
			--     ep = 0,
			--     gp = 200,
			--     lifetimeEarned = 0,
			--     lifetimeSpent = 0,
			--     class = "WARRIOR",
			--     lastSeen = 0,
			--     attendance = { total = 0, onTime = 0, prepared = 0 },
			--     rev = 0,           -- Delta Sync v2: player revision
			--     fieldRev = {},     -- Delta Sync v2: { ep = N, gp = N, status = N, ... }
			-- }
		},

		-- Wishlists
		wishlists = {
			-- ["PlayerName-RealmName"] = {
			--     [itemID] = {
			--         priority = 1,
			--         autoBid = 50,
			--         notes = "",
			--         willPass = false,
			--         encounterID = 12345,    -- Which boss drops this
			--         bossRank = 1,           -- Rank among items from same boss (1=highest)
			--         addedTime = 0,
			--     }
			-- }
		},

		-- Current raid session
		currentSession = nil,

		-- Historical sessions
		history = {
			-- ["sessionID"] = { session data }
		},

		-- EP/GP transaction log
		transactions = {
			-- {
			--     id = "tx_001",
			--     timestamp = 0,
			--     player = "PlayerName-RealmName",
			--     amount = 0,
			--     balance = 0,
			--     reason = "BOSS_KILL",
			--     details = {},
			--     sessionID = nil,
			-- }
		},

		-- Item awards
		awards = {
			-- {
			--     timestamp = 0,
			--     itemID = 0,
			--     itemLink = "",
			--     winner = "PlayerName-RealmName",
			--     amount = 0,
			--     source = "Boss Name",
			--     sessionID = nil,
			--     allBids = {},
			--     passedTo = nil,
			-- }
		},
		-- Total boss kills tracked
		totalBossKills = 0,
		
		-- Officer Audit Log (Config changes, Admin actions)
		audit = {
			-- {
			--     timestamp = 0,
			--     admin = "Name",
			--     action = "GP_UPDATE",
			--     details = "Updated Head to 1000",
			-- }
		},
		
		-- Pending Discord Webhook payloads
		discordQueue = {
			-- [timestamp] = { payload = "json_string", sent = false }
		},

		-- Last decay timestamp
		lastDecay = 0,
		
		-- Local M+ run history (per-character)
		mplusRuns = {},
		
		-- Guild-wide M+ run history (officer-only, synced between officers)
		-- 500 cap, FIFO - stores runs pushed from all raiders
		guildMplusRuns = {},
		
		-- Delta Sync Protocol v2 State (Officer)
		-- See docs/DELTA_SYNC_IMPLEMENTATION.md for protocol details
		syncState = {
			nextRev = 0,          -- Monotonic revision generator for player fields
			nextSeq = 0,          -- Monotonic sequence for outgoing SYNC_DATA
			lastFullSyncAt = 0,   -- Timestamp of last full snapshot
			dirtyPlayers = {},    -- { [name] = { [field] = true } } dirty tracking
			dirtyPurgesAt = 0,    -- Last purge timestamp included in sync
			dirtyMplusAt = 0,     -- Last M+ receivedAt included in sync
			pendingSyncs = {},    -- { [seq] = { sentAt, target, retryCount } } for WHISPER only
			maxPendingSyncs = 5,  -- Cap to prevent unbounded memory growth
			-- Raid Sync v2: Peer Status Tracking (Officer-only)
			-- Tracks all raid members' sync freshness via SYNC_ACK responses
			lastBroadcastSeq = 0, -- seq of last RAID broadcast SYNC_DATA
			lastBroadcastAt = 0,  -- timestamp of last RAID broadcast
			peerStatus = {},      -- { [fullName] = { lastSeenAt, lastAckSeq, lastAckAt, ... } }
			-- Raid Sync v2: Configurable intervals (officer-tuneable)
			raidSyncConfig = {
				fullSyncInterval = 600,     -- 10 min safety net for forced full sync
				heartbeatInterval = 30,     -- seconds between OnHeartbeat ticks
				targetedSyncThreshold = 5,  -- stale peer count: <= this uses whisper, > uses broadcast
				gossipDigestInterval = 180, -- 3 min: officer-to-officer anti-entropy (v0.9.3+)
			},
		},
		
		-- Delta Sync Protocol v2 State (Raider)
		lastSyncSeq = 0,          -- Last SYNC_DATA.seq applied
		receiptAckedBy = {},      -- { [officerName] = { ts, seq } } cursor per officer
		staleCursors = {},        -- { [officerName] = true } marks failed receipt syncs
		
		-- Receipt Cursor Tracking (see docs/proposals/Receipt_cursor_tracking.md)
		receiptSeq = 0,           -- Monotonic sequence for locally created receipts
		receiptSyncState = {
			pendingBatches = {},  -- { [batchId] = { sentAt, target, cursor, retryCount } }
			maxBatchSize = 40,    -- Receipts per batch
			lastResetAt = 0,      -- Timestamp of last cursor state reset
		},
	},

	-- Per-character settings
	profile = {
		ui = {
			mainFrame = {
				point = "CENTER",
				x = 0,
				y = 0,
				width = 700,
				height = 500,
			},
		},

		notifications = {
			showBidConfirm = true,
			showEPGPChanges = true,
			showItemWon = true,
			playSound = true,
		},
	},
}

--[[=============================================
	Guild Scope - Guest Stub
	
	Frozen read-only player stub returned when scope
	verification fails. Prevents Lua errors at call sites
	while blocking data writes.
===============================================]]

-- Proxy pattern: real data is hidden, proxy blocks ALL writes
local GUEST_STUB_DATA = {
	ep = 0,
	gp = 0,
	pr = 0,
	status = "Guest",
	class = nil,
	lastSeen = 0,
	attendance = { total = 0, onTime = 0, prepared = 0 },
	bossKillsPresent = 0,
	role = nil,
}
local GUEST_STUB = setmetatable({}, {
	__index = GUEST_STUB_DATA,        -- Reads proxy to real data
	__newindex = function() end,      -- ALL writes silently ignored
	__metatable = false,              -- Prevent tampering
})

-- Expose for testing
Cerebro.GUEST_STUB = GUEST_STUB

--[[=============================================
	Database Initialization
===============================================]]


function Cerebro:InitDatabase()
	-- Initialize AceDB
	self.db = LibStub("AceDB-3.0"):New("CerebroDB", defaults, true)

	-- Run migrations if needed
	self:MigrateDatabase()
	
	-- Cleanup stale purge ledger entries (30-day TTL)
	local ok1, err1 = pcall(function() self:CleanupPurgeLedger() end)
	if not ok1 then self:Print("|cffff0000[INIT ERROR] CleanupPurgeLedger: " .. tostring(err1) .. "|r") end

	-- Force signupStatuses out of AceDB's metatable proxy into raw SavedVariables.
	-- AceDB serves default subtables via proxy; writes to them don't persist unless
	-- explicitly materialized. Without this, config sync overwrites them with defaults.
	local ok2, err2 = pcall(function()
		local epgpConfig = self.db.global.config and self.db.global.config.epgp
		if epgpConfig and not rawget(epgpConfig, "signupStatuses") then
			local statusDefaults = { [1] = true, [2] = false, [3] = true, [4] = false, [5] = false, [8] = false }
			local existing = epgpConfig.signupStatuses
			if existing then
				for k, v in pairs(existing) do
					statusDefaults[k] = v
				end
			end
			epgpConfig.signupStatuses = statusDefaults
		end
	end)
	if not ok2 then self:Print("|cffff0000[INIT ERROR] signupStatuses: " .. tostring(err2) .. "|r") end

	-- Register callbacks
	self.db.RegisterCallback(self, "OnProfileChanged", "RefreshConfig")
	self.db.RegisterCallback(self, "OnProfileCopied", "RefreshConfig")
	self.db.RegisterCallback(self, "OnProfileReset", "RefreshConfig")

	self:Debug("Database initialized (version " .. self.db.global.dbVersion .. ")")
end

function Cerebro:RefreshConfig()
	-- Reload UI or settings when profile changes
	self:Print("Settings reloaded.")
end

--[[=============================================
	Database Migrations
===============================================]]

function Cerebro:MigrateDatabase()
	local currentVersion = self.db.global.dbVersion or 0

	if currentVersion < 1 then
		self:Debug("Migrating database to version 1...")
		-- Initial version, nothing to migrate
		self.db.global.dbVersion = 1
	end
	
	if currentVersion < 2 then
		self:Debug("Migrating database to version 2 (Guild Scope)...")
		
		-- Infer scope from existing player data if possible
		local inferredScope = self:InferGuildFromPlayers()
		if inferredScope and inferredScope.realm then
			self.db.global.scope = {
				guildGUID = nil,  -- Cannot infer GUID from player names
				guildName = inferredScope.guildName,
				realm = inferredScope.realm,
				setBy = "Migration-v2",
				setAt = time(),
				migrated = true,  -- Flag for audit trail
			}
			-- Note: Ledger may not be initialized yet during migration
			-- Audit log will be added in Core.lua after initialization
			self:Debug(string.format("Scope inferred: %s-%s", 
				inferredScope.guildName or "?", inferredScope.realm or "?"))
		end
		
		self.db.global.dbVersion = 2
	end
	
	if currentVersion < 3 then
		self:Debug("Migrating database to version 3 (LWW lastUpdate)...")
		
		-- Add lastUpdate to all existing players for Last-Write-Wins sync
		-- Use GetServerTime if available, fallback to time()
		local serverTime = GetServerTime and GetServerTime() or time()
		local migratedCount = 0
		
		for name, data in pairs(self.db.global.players or {}) do
			if not data.lastUpdate then
				data.lastUpdate = serverTime
				migratedCount = migratedCount + 1
			end
		end
		
		if migratedCount > 0 then
			self:Debug(string.format("Added lastUpdate to %d player records", migratedCount))
		end
		
		self.db.global.dbVersion = 3
	end
	
	if currentVersion < 4 then
		-- v0.8.4: Reset debug mode to false for all users
		-- Previously, officer's debug setting was being synced to raiders accidentally
		-- This migration ensures everyone starts clean with debug OFF
		if self.db.global.config and self.db.global.config.debug then
			self.db.global.config.debug = false
			self:Print("Debug mode has been turned OFF (use /cb debug to re-enable)")
		end
		
		self.db.global.dbVersion = 4
	end
	
	if currentVersion < 5 then
		self:Debug("Migrating database to version 5 (Attendance Tracking)...")
		
		-- Initialize seasonBossKills and seasonID if not present
		if not self.db.global.seasonBossKills then
			self.db.global.seasonBossKills = 0
		end
		if not self.db.global.seasonID then
			self.db.global.seasonID = 0
		end
		
		-- Add coreAdmittedAt to existing Core players
		-- Set to current time so they aren't penalized for pre-feature bosses
		local migratedCount = 0
		for name, data in pairs(self.db.global.players or {}) do
			if not data.coreAdmittedAt and data.status == "Core" then
				data.coreAdmittedAt = time()
				migratedCount = migratedCount + 1
			end
			-- Ensure attendance.full exists
			if data.attendance and not data.attendance.full then
				data.attendance.full = 0
			end
		end
		
		if migratedCount > 0 then
			self:Debug(string.format("Added coreAdmittedAt to %d Core players", migratedCount))
		end
		
		self.db.global.dbVersion = 5
	end
	
	if currentVersion < 6 then
		self:Debug("Migrating database to version 6 (Epoch Tagging)...")
		
		-- Add createdEpoch to all existing players for stale record detection
		-- Records pre-migration are assigned current epoch since they're known-good
		local currentEpoch = self.db.global.resetEpoch or 0
		local migratedCount = 0
		
		for name, data in pairs(self.db.global.players or {}) do
			if not data.createdEpoch then
				data.createdEpoch = currentEpoch
				migratedCount = migratedCount + 1
			end
		end
		
		if migratedCount > 0 then
			self:Debug(string.format("Added createdEpoch to %d player records", migratedCount))
		end
		
		self.db.global.dbVersion = 6
	end
	
	if currentVersion < 7 then
		self:Debug("Migrating database to version 7 (Delta Sync v2)...")
		
		-- Add rev and fieldRev to existing players for field-level sync
		-- rev = lastUpdate (or 0) to seed initial revision
		-- fieldRev = {} (empty, will be populated on first mutation)
		local migratedCount = 0
		
		for name, data in pairs(self.db.global.players or {}) do
			if not data.rev then
				data.rev = data.lastUpdate or 0
				data.fieldRev = {}
				migratedCount = migratedCount + 1
			end
		end
		
		-- Initialize syncState if not present (AceDB handles defaults, but be explicit)
		if not self.db.global.syncState then
			self.db.global.syncState = {
				nextRev = 0,
				nextSeq = 0,
				lastFullSyncAt = 0,
				dirtyPlayers = {},
				dirtyPurgesAt = 0,
				dirtyMplusAt = 0,
				pendingSyncs = {},
				maxPendingSyncs = 5,
			}
		end
		
		if migratedCount > 0 then
			self:Debug(string.format("Added rev/fieldRev to %d player records", migratedCount))
		end
		
		self.db.global.dbVersion = 7
	end
	
	if currentVersion < 8 then
		self:Debug("Migrating database to version 8 (Communication V2)...")
		
		-- Reset v1 sync protocol metadata (preserve all player data)
		-- This prevents stale v1 sync state from interfering with v2
		local ss = self.db.global.syncState or {}
		ss.peerStatus = {}
		ss.lastBroadcastSeq = 0
		ss.lastBroadcastAt = 0
		ss.dirtyPlayers = {}
		ss.pendingSyncs = {}
		self.db.global.syncState = ss
		
		-- Initialize v2 sync state
		self.db.global.syncStateV2 = {
			peerHashes = {},      -- { [playerName] = { rosterHash, configHash, lastSeen } }
			isStale = false,      -- True when local state diverges from officer
			lastHeartbeatSent = 0,
			lastSnapshotReceived = 0,
		}
		
		self:Debug("Communication V2 sync state initialized, v1 metadata reset")
		
		self.db.global.dbVersion = 8
	end

	-- TODO(v0.10.25+): Remove this migration once all users have updated past v0.10.24.
	-- After that point, no installs will have the old requireConsumables key.
	if currentVersion < 9 then
		self:Debug("Migrating database to version 9 (Prep schema cleanup)...")

		-- Remove orphaned requireConsumables key from prep config.
		-- v0.10.24 replaced this single toggle with per-category toggles:
		-- requireFood, requireFlask, requireAugmentRune, requireVantusRune, requireWeaponEnhancement
		local prepCfg = self.db.global.config and self.db.global.config.prep
		if prepCfg and prepCfg.requireConsumables ~= nil then
			prepCfg.requireConsumables = nil
			self:Debug("Removed orphaned requireConsumables key from prep config")
		end

		-- Remove orphaned prepAwardAmount (replaced by per-category EP values)
		if prepCfg and prepCfg.prepAwardAmount ~= nil then
			prepCfg.prepAwardAmount = nil
			self:Debug("Removed orphaned prepAwardAmount key from prep config")
		end

		self.db.global.dbVersion = 9
	end
end

-- Infer most common realm from existing player keys
function Cerebro:InferGuildFromPlayers()
	local realmCounts = {}
	local totalPlayers = 0
	
	for playerName, _ in pairs(self.db.global.players or {}) do
		local _, realm = strsplit("-", playerName)
		if realm then
			-- Normalize realm for consistent counting
			local normalizedRealm = self:NormalizeRealmString(realm)
			if normalizedRealm then
				realmCounts[normalizedRealm] = (realmCounts[normalizedRealm] or 0) + 1
				totalPlayers = totalPlayers + 1
			end
		end
	end
	
	if totalPlayers == 0 then return nil end
	
	-- Find most common realm
	local topRealm, topCount = nil, 0
	for realm, count in pairs(realmCounts) do
		if count > topCount then
			topRealm = realm
			topCount = count
		end
	end
	
	-- Get guild name from current character if in guild
	local guildName = nil
	if IsInGuild and IsInGuild() then
		guildName = GetGuildInfo("player")
	end
	
	return {
		guildName = guildName,  -- May be nil if not in guild during migration
		realm = topRealm,       -- Already normalized
	}
end

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