--[[
	Cerebro - EPGP Loot System for RCLootCouncil
	Core.lua - Main addon initialization and event handling
]]--

-- Create addon using Ace3
local AddonName = "Cerebro"
Cerebro = LibStub("AceAddon-3.0"):NewAddon(AddonName, "AceEvent-3.0", "AceConsole-3.0", "AceComm-3.0", "AceSerializer-3.0", "AceTimer-3.0")

-- Addon version
Cerebro.version = "0.10.30"
Cerebro.addonName = AddonName

-- Quick reference to modules (populated on module load)
Cerebro.modules = {}

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

function Cerebro:OnInitialize()
	-- Initialize database
	self:InitDatabase()

	-- Register slash commands
	self:RegisterChatCommand("cerebro", "SlashCommand")
	self:RegisterChatCommand("cb", "SlashCommand")

	-- Register addon communication channel
	self:RegisterComm("Cerebro", "OnCommReceived")

	-- Setup configuration options
	self:SetupOptions()

	self:Print("Initialized. Type /cerebro or /cb for commands.")

	-- Suppress "No player named X is currently playing" system messages.
	-- AceComm chunks large addon messages into ~250-byte segments. If a player
	-- goes offline mid-transmission, every remaining chunk produces this error.
	-- A single snapshot push can generate 30-40 lines of spam in the chat frame.
	-- Registered in OnInitialize (once per session) to avoid double-registration on /reload.
	if not self._chatFilterRegistered then
		ChatFrame_AddMessageEventFilter("CHAT_MSG_SYSTEM", function(_, _, msg)
			if msg and msg:find("No player named") and msg:find("is currently playing") then
				return true  -- suppress
			end
			return false
		end)
		self._chatFilterRegistered = true
	end

	-- DIAGNOSTIC ERROR INTERCEPTOR: Temporarily disabled for blank error isolation.
	-- Re-enable once actual error source is identified.
	--[[ DISABLED
	if not self._errorHandlerWrapped then
		self._errorHandlerWrapped = true
		self:ScheduleTimer(function()
			local origHandler = geterrorhandler()
			seterrorhandler(function(msg)
				Cerebro._lastErrors = Cerebro._lastErrors or {}
				local entry = {
					msg = tostring(msg or "<nil>"),
					stack = debugstack(2, 12, 0),
					time = time(),
				}
				table.insert(Cerebro._lastErrors, entry)
				while #Cerebro._lastErrors > 20 do
					table.remove(Cerebro._lastErrors, 1)
				end
				if origHandler then
					return origHandler(msg)
				end
			end)
			Cerebro:Debug("Error interceptor installed (delayed)")
		end, 3)
	end
	]]

	-- Popup for Decay
	StaticPopupDialogs["CEREBRO_CONFIRM_DECAY"] = {
		text = "Are you sure you want to run Weekly Decay? This will reduce all EP/GP by the configured percentage.",
		button1 = "Yes",
		button2 = "No",
		OnAccept = function()
			local epgp = Cerebro:GetModule("EPGP")
			local count, epDecayed, gpDecayed = epgp:Decay()
			Cerebro.Ledger:AddAuditLog("DECAY_RUN", string.format("Processed %d players. -%d EP, -%d GP", count, epDecayed, gpDecayed))
			Cerebro:Print(string.format("Decay complete. Processed %d players. Removed %d EP and %d GP total.", 
				count, epDecayed, gpDecayed))
			Cerebro:GetModule("UI"):RefreshStandings()
		end,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
	}

	-- Popup for Guild Scope Lock Confirmation (Bootstrapping)
	StaticPopupDialogs["CEREBRO_CONFIRM_SCOPE_LOCK"] = {
		text = "Lock Cerebro to guild: |cff00ff00%s|r?\n\nThis cannot be undone without wiping all data.\nOnly characters in this guild will have access.",
		button1 = "Lock to This Guild",
		button2 = "Cancel",
		OnAccept = function()
			if Cerebro:SetAuthorizedGuild() then
				local scope = Cerebro:GetAuthorizedGuild()
				Cerebro:Print("Guild scope locked to: " .. (scope and scope.guildName or "Unknown"))
				-- Retry UI show
				if Cerebro.modules.UI then
					Cerebro.modules.UI:Show()
				end
			else
				Cerebro:Print("Failed to lock guild scope. Guild info may not be ready.")
			end
		end,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
	}

	-- Popup for Guild Scope Override (Wipe and Switch)
	-- Constants for backup size limits
	local BACKUP_MAX_TRANSACTIONS = 500
	local BACKUP_MAX_AUDIT = 200
	
	-- Helper: Get last N entries from a list
	local function TrimList(list, maxEntries)
		if not list or #list <= maxEntries then
			return Cerebro:CopyTable(list or {})
		end
		local trimmed = {}
		local start = #list - maxEntries + 1
		for i = start, #list do
			table.insert(trimmed, Cerebro:CopyTable(list[i]))
		end
		return trimmed
	end
	
	StaticPopupDialogs["CEREBRO_CONFIRM_SCOPE_OVERRIDE"] = {
		text = "WARNING: Switching guilds will PERMANENTLY DELETE all EPGP data.\n\nThis includes: Players, Transactions, Audit Logs, Awards.\n\nType WIPE to confirm:",
		button1 = "Wipe and Switch",
		button2 = "Cancel",
		hasEditBox = true,
		OnAccept = function(self)
			-- Wrap in pcall to catch any silent errors
			local success, err = pcall(function()
				local confirmText = strtrim(self.editBox:GetText() or ""):upper()
				if confirmText ~= "WIPE" then
					Cerebro:Print("Confirmation failed. Type WIPE exactly.")
					return
				end
				
				-- Create backup before wipe (with size limits)
				local backup = {
					timestamp = time(),
					version = Cerebro.version,
					players = Cerebro:CopyTable(Cerebro.db.global.players),
					transactions = TrimList(Cerebro.db.global.transactions, BACKUP_MAX_TRANSACTIONS),
					audit = TrimList(Cerebro.db.global.audit, BACKUP_MAX_AUDIT),
					scope = Cerebro:CopyTable(Cerebro.db.global.scope),
					reason = "SCOPE_OVERRIDE",
					trimmed = {
						transactions = #(Cerebro.db.global.transactions or {}) > BACKUP_MAX_TRANSACTIONS,
						audit = #(Cerebro.db.global.audit or {}) > BACKUP_MAX_AUDIT,
					},
				}
				Cerebro.db.global.backup = backup
				
				-- Log before wipe
				if Cerebro.Ledger and Cerebro.Ledger.AddAuditLog then
					Cerebro.Ledger:AddAuditLog("BACKUP_CREATED", "Before scope override")
				end
				
				-- Wipe data
				Cerebro.db.global.players = {}
				Cerebro.db.global.transactions = {}
				Cerebro.db.global.audit = {}
				Cerebro.db.global.awards = {}
				Cerebro.db.global.currentSession = nil
				Cerebro.db.global.scope = nil  -- Clear scope for re-locking
				
				-- Activate quarantine to prevent sync from re-injecting stale scope
				-- This is critical: without quarantine, an incoming officer sync 
				-- will immediately re-inject the old scope before user can type /cb
				Cerebro.db.global.resetQuarantine = {
					at = time(),
					reason = "SCOPE_OVERRIDE"
				}
				
				-- Log after wipe (to fresh audit table)
				if Cerebro.Ledger and Cerebro.Ledger.AddAuditLog then
					Cerebro.Ledger:AddAuditLog("SCOPE_OVERRIDE", "Data wiped for guild switch")
				end
				
				Cerebro:Print("Data wiped. Open /cb to lock to new guild.")
				
				-- Close UI
				if Cerebro.modules.UI then
					Cerebro.modules.UI:Hide()
				end
			end)
			
			if not success then
				Cerebro:Print("|cffff0000ERROR during scope override:|r " .. tostring(err))
			end
		end,
		OnShow = function(self)
			self.editBox:SetText("")
		end,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
	}

	-- Clean up corrupted "player" entries from DB
	if self.db.global.players["player"] then
		self.db.global.players["player"] = nil
		self:Print("Removed invalid 'player' entry from database.")
	end
end

function Cerebro:SetupOptions()
	--[[
		SCHEMA-DRIVEN OPTIONS GENERATOR
		
		This function generates the entire Blizzard Interface Options UI
		dynamically from UI/ConfigSchema.lua. No hardcoded option tables.
		
		The schema is the SINGLE source of truth for all settings.
	]]--
	
	if not _G.UI_ConfigSchema then
		self:Print("ERROR: ConfigSchema not loaded. Cannot setup options.")
		return
	end
	
	local schema = _G.UI_ConfigSchema
	
	-- Build base options table
	local options = {
		type = "group",
		name = "Cerebro",
		args = {},
	}
	
	-- Generate "Commands" category (static, not from schema)
	options.args.commands = {
		type = "group",
		name = "Commands",
		order = 0,
		args = {
			header_gen = { type = "header", name = "General", order = 1 },
			show_ui = {
				type = "execute",
				name = "Show Main UI",
				desc = "Toggle the main window (/cb show)",
				func = function() self.modules.UI:Toggle() end,
				order = 2,
			},
			wishlist = {
				type = "execute",
				name = "Wishlist",
				desc = "Manage Wishlist (/cb wishlist)",
				func = function() self.modules.UI:ShowWishlist() end,
				order = 4,
			},
			standings = {
				type = "execute",
				name = "Standings",
				desc = "View EPGP Standings (/cb standings)",
				func = function() self.modules.UI:ShowStandings() end,
				order = 5,
			},
			history = {
				type = "execute",
				name = "History",
				desc = "View Loot History (/cb history)",
				func = function() self.modules.UI:ShowHistory() end,
				order = 6,
			},
			
			header_discord = { 
				type = "header", 
				name = "Discord", 
				order = 10,
				hidden = function() return not self.modules.DiscordExport end 
			},
			discord = {
				type = "execute",
				name = "Discord Export",
				desc = "Show JSON for Discord Webhook (/cb discord)",
				func = function() 
					if self.modules.DiscordExport then 
						self.modules.DiscordExport:ShowExportWindow() 
					end
				end,
				hidden = function() return not self.modules.DiscordExport end,
				order = 11,
			},

			header_off = { type = "header", name = "Officer Tools", order = 20 },
			start_session = {
				type = "execute",
				name = "Start Session",
				desc = "Start a new raid session (/cb start)",
				func = function() self.modules.Session:StartSession() end,
				disabled = function() return not self:IsOfficerSafe() end,
				order = 21,
			},
			end_session = {
				type = "execute",
				name = "End Session",
				desc = "End current session (/cb end)",
				func = function() self.modules.Session:EndSession() end,
				disabled = function() return not self:IsOfficerSafe() end,
				order = 22,
			},
			force_end_session = {
				type = "execute",
				name = "Force End Session",
				desc = "Nuclear cleanup: wipe session with no EP awards (/cb forceend)",
				func = function() self.modules.Session:ForceEndSession() end,
				disabled = function() return not self:IsOfficerSafe() end,
				order = 23,
			},
			prep_check = {
				type = "execute",
				name = "Prep Check",
				desc = "Run Preparation Check (/cb check)",
				func = function() self.modules.Preparation:CheckRaid() end,
				disabled = function() return not self:IsOfficerSafe() end,
				order = 24,
			},
			sync = {
				type = "execute",
				name = "Sync to Raid",
				desc = "Force data sync (/cb sync)",
				func = function()
					local sent = self.modules.CommunicationV2:SyncToRaid()
					if sent then
						self:Print("Data synced to raid (Officer push).")
					else
						self:Print("|cffff8800Sync deferred until combat ends.|r")
					end
				end,
				disabled = function() return not self:IsOfficerSafe() end,
				order = 24,
			},
		},
	}
	
	-- Generate categories from schema (deterministically sorted)
	local categories = schema:GetCategoriesSorted()
	local categoryOrder = 1
	
	for _, categoryName in ipairs(categories) do
		-- Create category group
		local categoryKey = categoryName:gsub("[^%w]", "_"):lower()
		
		options.args[categoryKey] = {
			type = "group",
			name = categoryName,
			order = categoryOrder,
			args = {},
		}
		
		-- Group items by group field
		local groups = {}
		local groupOrder = {}
		
		for _, item in ipairs(schema:GetItemsForCategory(categoryName)) do
			local groupName = item.group or "General"
			if not groups[groupName] then
				groups[groupName] = {}
				table.insert(groupOrder, groupName)
			end
			table.insert(groups[groupName], item)
		end
		
		-- Generate options for each group
		local currentOrder = 1
		for _, groupName in ipairs(groupOrder) do
			-- Add header for group
			if groupName ~= "General" or #groupOrder > 1 then
				local headerKey = "header_" .. groupName:gsub("[^%w]", "_"):lower()
				options.args[categoryKey].args[headerKey] = {
					type = "header",
					name = groupName,
					order = currentOrder,
				}
				currentOrder = currentOrder + 1
			end
			
			-- Add items in group
			for _, item in ipairs(groups[groupName]) do
				local optionDef = self:GenerateOption(item)
				if optionDef then
					options.args[categoryKey].args[item.id] = optionDef
					optionDef.order = currentOrder
					currentOrder = currentOrder + 1
				end
			end
		end
		
		categoryOrder = categoryOrder + 1
	end
	
	-- Register with AceConfig
	LibStub("AceConfig-3.0"):RegisterOptionsTable("Cerebro", options)
	
	-- Add to Blizzard options
	self.optionsFrame = LibStub("AceConfigDialog-3.0"):AddToBlizOptions("Cerebro", "Cerebro")
end

-- Generate a single AceConfig option from a schema item
function Cerebro:GenerateOption(item)
	local option = {
		name = item.label,
		desc = item.desc or "",
	}
	
	-- Type mapping
	if item.type == "bool" then
		option.type = "toggle"
	elseif item.type == "number" then
		if item.min and item.max then
			option.type = "range"
			option.min = item.min
			option.max = item.max
			option.step = item.step or 1
			option.isPercent = item.isPercent
		else
			option.type = "input"
		end
	elseif item.type == "string" then
		option.type = "input"
	elseif item.type == "select" then
		option.type = "select"
		option.values = item.choices or {}
	else
		return nil -- Unknown type
	end
	
	-- Getter
	option.get = function(info)
		local value = _G.UI_ConfigSchema:GetValue(item)
		
		-- For input fields (non-range numbers/strings), convert to string
		if option.type == "input" and item.type == "number" then
			return tostring(value)
		end
		
		return value
	end
	
	-- Setter
	option.set = function(info, val)
		_G.UI_ConfigSchema:SetValue(item, val)
		-- Force AceConfig refresh so dimmedWhen dependencies re-evaluate
		LibStub("AceConfigRegistry-3.0"):NotifyChange("Cerebro")
	end
	
	-- RBAC gating
	if item.officerOnly then
		local mode = item.officerOnlyMode or "hidden"
		if mode == "hidden" then
			option.hidden = function() return not self:IsOfficerSafe() end
		elseif mode == "disabled" then
			option.disabled = function() return not self:IsOfficerSafe() end
		end
	end

	-- dimmedWhen: disable this option when the referenced config toggle is unchecked.
	-- Used for prep EP inputs that should dim when their parent toggle (e.g. requireGems) is off.
	if item.dimmedWhen then
		local parentDisabled = option.disabled  -- Preserve existing disabled (RBAC)
		option.disabled = function()
			-- Check RBAC first
			if parentDisabled and parentDisabled() then return true end
			-- Resolve the config path (e.g. "config.prep.requireGems" → db.global.config.prep.requireGems)
			local current = Cerebro.db.global
			for segment in item.dimmedWhen:gmatch("[^.]+") do
				if type(current) ~= "table" then return true end
				current = current[segment]
			end
			return not current
		end
	end

	return option
end


-- NOTE: Encounter state and sync heartbeat moved to Modules/SyncScheduler.lua
-- SyncScheduler owns: _encounterState, ENCOUNTER_START/END, PLAYER_REGEN_ENABLED, OnHeartbeat

function Cerebro:OnEnable()
	-- Register events (encounter events handled by SyncScheduler module)
	self:RegisterEvent("GROUP_ROSTER_UPDATE")
	self:RegisterEvent("PLAYER_ENTERING_WORLD")

	self:Print("Enabled. Version " .. self.version)

	-- Check if we're in a raid
	if IsInRaid() then
		self:CheckRaidStatus()
	end
end

function Cerebro:OnDisable()
	self:Print("Disabled.")
end

--[[=============================================
	Event Handlers
===============================================]]

-- ENCOUNTER_START, ENCOUNTER_END, PLAYER_REGEN_ENABLED moved to SyncScheduler

function Cerebro:BOSS_KILL(event, ...)
	-- Alternative boss kill detection
	self:Debug("BOSS_KILL event fired")
end

function Cerebro:CHAT_MSG_LOOT(event, text, playerName, ...)
	-- RCLC handles loot, so we don't need to do much here unless we want to log it
end

function Cerebro:GROUP_ROSTER_UPDATE(event)
	if IsInRaid() then
		-- Sync on raid join (non-officers only, once per raid)
		if not self:IsOfficer() and not self._hasRaidSynced then
			-- Guard: skip if quarantined
			if self.modules.Communication and self.modules.Communication:IsQuarantined() then
				self:Debug("GROUP_ROSTER_UPDATE sync skipped (quarantine)")
			else
				self._hasRaidSynced = true
				self:ScheduleTimer(function()
					self.modules.CommunicationV2:RequestSync()
				end, 2)
			end
		end
		
		-- Proactive WHISPER: Officers auto-sync to new raid joiners
		if self:IsOfficer() and self.modules.Communication then
			self:DetectAndSyncNewRaidMembers()
		end
		
		-- Update raid roster
		if self.modules.Session and self.modules.Session:IsActive() then
			self.modules.Session:UpdateRoster()
		end
	else
		self._hasRaidSynced = nil  -- Reset when leaving raid
		self._lastKnownRaidRoster = nil  -- Reset roster tracking
		self._proactiveWhisperSent = nil  -- Reset dedup set
		-- Flush queued M+ pushes that were suppressed during raid
		if self.modules.MythicPlus and self.modules.MythicPlus.FlushPendingPushes then
			self.modules.MythicPlus:FlushPendingPushes()
		end
		-- Left raid - only auto-end session if WE started it (session owner)
		-- Other clients should not end the session just because they left the raid
		if self.modules.Session and self.modules.Session:IsActive() then
			local session = self.modules.Session:GetCurrentSession()
			local myName = self:GetPlayerFullName()
			if session and session.startedBy == myName and self:IsOfficer() then
				self:Debug("Auto-ending session: session owner left raid")
				self.modules.Session:EndSession()
			else
				self:Debug("Left raid but not session owner — session remains active")
			end
		end
	end

	-- Auto-discover guild members in group (officers only, works in party or raid)
	if self:IsOfficer() and IsInGroup() then
		self:DiscoverGuildGroupMembers()
	end

	-- Full guild roster scan (officers only, throttled to 60s, not during combat)
	-- Fix 2: Skip during InCombatLockdown() — this triggers RecalculateAllPR()
	-- and RefreshAllUI() which are illegal in lockdown and cause hitching mid-pull.
	if self:IsOfficer() and not InCombatLockdown() then
		self:DiscoverFullGuildRoster()
	end
end

-- Auto-discover guild members in the current group/raid and add as Pending
-- Only runs for officers. Throttled to once per 30 seconds.
function Cerebro:DiscoverGuildGroupMembers()
	-- Throttle: avoid running on every roster update
	local now = time()
	if self._lastGroupDiscovery and (now - self._lastGroupDiscovery) < 30 then return end
	self._lastGroupDiscovery = now

	-- Build guild member lookup set
	local guildMembers = {}
	for i = 1, GetNumGuildMembers() do
		local name, _, _, _, _, _, _, _, _, _, classFileName = GetGuildRosterInfo(i)
		if name then
			guildMembers[self:GetFullPlayerName(name)] = classFileName
		end
	end

	-- Scan current group/raid for members not in our DB
	local added = 0
	local isRaid = IsInRaid()
	local numMembers = GetNumGroupMembers()
	local blocked = self.db.global.blockedPlayers or {}

	for i = 1, numMembers do
		-- Use unit IDs + UnitFullName to get the player's ACTUAL realm.
		-- GetRaidRosterInfo omits realm for connected-realm players (e.g.
		-- returns "Lecxik" instead of "Lecxik-Sargeras"), which causes
		-- GetFullPlayerName to wrongly append the officer's own realm.
		local unit
		if isRaid then
			unit = "raid" .. i
		else
			unit = (i < numMembers) and ("party" .. i) or "player"
		end
		local unitName, unitRealm = UnitFullName(unit)
		local name
		if unitName then
			if unitRealm and unitRealm ~= "" then
				name = unitName .. "-" .. unitRealm
			else
				name = unitName
			end
		end

		if name then
			local fullName = self:GetFullPlayerName(name)
			local myName = self:GetPlayerFullName()

			-- Skip self, already-known players, and blocked players
			if fullName ~= myName
				and not self.db.global.players[fullName]
				and not blocked[fullName] then

				-- If player was previously purged but is physically present in
				-- the guild roster AND the group, un-purge them. Purge is only
				-- meant to prevent sync zombies, not block returning players.
				local purged = self.db.global.purgedPlayers or {}
				if purged[fullName] and guildMembers[fullName] then
					self.db.global.purgedPlayers[fullName] = nil
					self:Debug("Un-purged returning guild member: " .. fullName)
				elseif purged[fullName] then
					-- Purged non-guild member still in group — skip
					-- (they'll be un-purged if they rejoin the guild)
					fullName = nil  -- skip auto-creation below
				end

				if fullName then
					-- Only auto-create for GUILD members in the group.
					-- Non-guild group members are intentionally skipped —
					-- they clutter standings with classless Pending entries.
					local classFile = guildMembers[fullName]
					if not classFile then
						-- Not in guild — skip entirely
						self:Debug("DiscoverGuildGroupMembers: skipping non-guild member " .. fullName)
					else
						-- Auto-create guild member as Pending with class info
						local baseGP = (self.db.global.config.epgp and self.db.global.config.epgp.baseGP) or 200
						self.db.global.players[fullName] = {
							ep = 0,
							gp = baseGP,
							pr = 0,
							status = "Pending",
							class = classFile,
							lastUpdate = 0,  -- Sentinel: never wins LWW vs officer actions (GetServerTime)
						}
						added = added + 1
						self:Debug("Auto-discovered guild member in group: " .. fullName)
					end
				end
			end
		end
	end

	if added > 0 then
		self:Debug(string.format("Discovered %d player(s) in group.", added))
		self:RefreshAllUI()
	end
end

-- Scan the full guild roster and auto-create Pending stubs for visible-rank members
-- not yet in the DB. This runs on login (10s delay via PLAYER_ENTERING_WORLD) and
-- periodically via GROUP_ROSTER_UPDATE (throttled to 60s, skipped during combat).
-- Does NOT register for GUILD_ROSTER_UPDATE to avoid excess scans from guild
-- online/offline churn.
--
-- Guards (same as DiscoverGuildGroupMembers):
--   - visibleRanks: only include ranks the officer has opted into
--   - purgedPlayers: purge is permanent and beats discovery
--   - blockedPlayers: block is permanent and beats discovery
--
-- Fix 5: Creates records as status="Pending" with lastUpdate=0 (LWW sentinel).
-- This guarantees auto-discovered stubs always lose LWW to any real officer action
-- (which uses GetServerTime()). Officers promote via /cb admit or the context menu.
-- Officer-only. Throttled to once per 60 seconds.
function Cerebro:DiscoverFullGuildRoster()
	if not self:IsOfficer() then return end

	-- Throttle: reuse the existing group discovery timestamp (same 60s window)
	local now = time()
	if self._lastFullRosterDiscovery and (now - self._lastFullRosterDiscovery) < 60 then return end
	self._lastFullRosterDiscovery = now

	-- visibleRanks must be configured; if empty/nil, skip to avoid creating everyone
	local visibleRanks = self.db.global.config and
		self.db.global.config.sync and
		self.db.global.config.sync.visibleRanks
	if not visibleRanks or not next(visibleRanks) then
		self:Debug("DiscoverFullGuildRoster: visibleRanks not configured, skipping")
		return
	end

	local baseGP = (self.db.global.config.epgp and self.db.global.config.epgp.baseGP) or 200
	local purged = self.db.global.purgedPlayers or {}
	local blocked = self.db.global.blockedPlayers or {}
	local added = 0

	for i = 1, GetNumGuildMembers() do
		local name, _, rankIndex, _, _, _, _, _, _, _, classFileName = GetGuildRosterInfo(i)
		if name and rankIndex ~= nil then
			-- Only process ranks that the officer has opted into
			if visibleRanks[rankIndex] then
				local fullName = self:GetFullPlayerName(name)
				local myName = self:GetPlayerFullName()

				-- Skip self, purged, and blocked.
				-- For existing records: only skip players with real data (lastUpdate > 0).
				-- Pending stubs created by DiscoverGuildGroupMembers use lastUpdate=0 as
				-- a sentinel — upgrade them to Core here since visibleRanks IS the
				-- admission gate and these players clearly belong in standings.
				local existing = self.db.global.players[fullName]
				local isStub = existing
					and existing.status == "Pending"
					and (existing.lastUpdate or 0) == 0

				if fullName ~= myName
					and not purged[fullName]
					and not blocked[fullName]
					and (not existing or isStub) then

					-- Fix 5: Create as Pending with lastUpdate=0 (LWW sentinel).
					-- status=Pending + lastUpdate=0 is the established sentinel pattern:
					-- any real officer action (AwardEP, Admit, etc.) uses GetServerTime()
					-- and will always win LWW over these stubs. Officers promote via
					-- /cb admit or right-click → Admit to Core.
					self.db.global.players[fullName] = {
						ep = existing and existing.ep or 0,
						gp = existing and existing.gp or baseGP,
						pr = 0,
						status = "Pending",
						class = classFileName or (existing and existing.class),
						lastUpdate = 0,  -- Sentinel: always loses LWW to real officer data
					}
					added = added + 1
					if isStub then
						self:Debug("DiscoverFullGuildRoster: refreshed Pending stub for " .. fullName)
					else
						self:Debug("DiscoverFullGuildRoster: created Pending stub for " .. fullName)
					end
				end
			end
		end
	end

	if added > 0 then
		self:Debug(string.format("DiscoverFullGuildRoster: created %d new Pending stub(s).", added))
		-- No RecalculateAllPR() or RefreshAllUI() here: Pending stubs with
		-- lastUpdate=0 are excluded from the participant filter and don't affect
		-- PR calculations. UI will update on the next natural refresh cycle.
		-- NotifyStateChange is also skipped: stubs don't change the roster hash
		-- (IsParticipant filter excludes them), so peers don't need a heartbeat.
	end
end

-- Proactive WHISPER: Detect new raid members and send them a full sync snapshot
function Cerebro:DetectAndSyncNewRaidMembers()
	-- Build current roster snapshot
	local currentRoster = {}
	for i = 1, GetNumGroupMembers() do
		local name = GetRaidRosterInfo(i)
		if name then
			local fullName = self:GetFullPlayerName(name)
			currentRoster[fullName] = true
		end
	end
	
	-- First call: seed the roster, don't whisper everyone already here
	if not self._lastKnownRaidRoster then
		self._lastKnownRaidRoster = currentRoster
		self._proactiveWhisperSent = {}
		return
	end
	
	-- Find new members (in current but not in last known)
	local myName = self:GetPlayerFullName()
	for fullName in pairs(currentRoster) do
		if not self._lastKnownRaidRoster[fullName] 
		   and fullName ~= myName
		   and not (self._proactiveWhisperSent and self._proactiveWhisperSent[fullName]) then
			-- New member detected — schedule proactive whisper after 2s delay
			self._proactiveWhisperSent = self._proactiveWhisperSent or {}
			self._proactiveWhisperSent[fullName] = true
			-- DEPRECATED: V1 ProactiveWhisperSync disabled — triggers HandleSyncData
			-- epoch wipe on receiver, destroying data V2 already populated.
			-- V2 heartbeat + snapshot handles new raid member sync.
			-- Will be removed in a future update once V1 Communication is fully retired.
		end
	end
	
	-- Update last known roster
	self._lastKnownRaidRoster = currentRoster
end

function Cerebro:PLAYER_ENTERING_WORLD(event, isInitialLogin, isReloadingUi)
	if isInitialLogin or isReloadingUi then
		self:CheckRaidStatus()

		-- Request sync from officers if we're not one
		if not self:IsOfficer() then
			-- Guard: skip if quarantined
			if self.modules.Communication and self.modules.Communication:IsQuarantined() then
				self:Debug("PLAYER_ENTERING_WORLD sync skipped (quarantine)")
			else
				self:ScheduleTimer(function()
					self.modules.CommunicationV2:RequestSync()
				end, 5)
			end
		end
		
		-- Check for season changes (delay to ensure API is ready).
		-- 60s fallback gives the V2 heartbeat exchange time to deliver an existing
		-- officer's snapshot (T+5-13s). HandleSnapshot cancels this timer early when
		-- a snapshot is received, so there is no unnecessary 60s wait when peers are online.
		self._seasonCheckTimer = self:ScheduleTimer(function()
			self._seasonCheckTimer = nil
			self:CheckSeasonChange()
		end, 60)
		
		-- Delta Sync v2: Clear pendingSyncs on login to avoid stale sequences
		if self.db.global.syncState and self.db.global.syncState.pendingSyncs then
			self.db.global.syncState.pendingSyncs = {}
			self:Debug("Cleared pendingSyncs on login")
		end
		
		-- Receipt Cursor Tracking: Clear pendingBatches on login to avoid stale in-flight batches
		if self.db.global.receiptSyncState and self.db.global.receiptSyncState.pendingBatches then
			self.db.global.receiptSyncState.pendingBatches = {}
			self:Debug("Cleared pendingBatches on login")
		end
		
		-- Receipt Cursor Tracking: Reset cursor state if helper restore happened
		if self.db.global.restoredAt then
			local lastResetAt = self.db.global.receiptSyncState and self.db.global.receiptSyncState.lastResetAt or 0
			if self.db.global.restoredAt > lastResetAt then
				self:ResetReceiptCursorState("helper restore")
			end
		end

		-- Auto-Intro: Check if config version has bumped (e.g. officer changed weights/EP values)
		local currentConfigVer = self.db.global.config.epgp.configVersion or 0
		local lastSeenConfigVer = self.db.global.config.lastSeenConfigVersion or 0
		
		if currentConfigVer > lastSeenConfigVer then
			-- Don't show immediately on login, wait for things to settle
			self:ScheduleTimer(function()
				if not InCombatLockdown() then
					self:Print("|cffFFD700Configuration Update:|r Guild settings have changed. Reviewing the intro...")
					local Onboarding = self:GetModule("Onboarding", true)
					if Onboarding then
						Onboarding:Show(true) -- force show
					end
					-- Update version so we don't show again until next bump
					self.db.global.config.lastSeenConfigVersion = currentConfigVer
				end
			end, 8) -- 8s delay to avoid login spam clashing
		end

		-- Full guild roster discovery: auto-create Core records for visible-rank members.
		-- 10s delay gives the guild roster API time to fully populate after login.
		-- Only runs for officers (DiscoverFullGuildRoster has its own IsOfficer guard).
		if self:IsOfficer() then
			self:ScheduleTimer(function()
				self:DiscoverFullGuildRoster()
			end, 10)
		end
	end
end

-- Periodic Tasks (OnHeartbeat) moved to SyncScheduler

--[[=============================================
	Reset Functions
===============================================]]

--- Core EP/GP/attendance reset with epoch increment and sync dispatch.
-- Both seasonreset and standingsreset call this, then do their own extra cleanup.
-- See docs/COMMAND_DATA_IMPACT.md for the full data impact matrix.
-- @param reason string  Audit reason (e.g., "STANDINGS_RESET" or "SEASON_RESET")
-- @return number playerCount  Number of players reset
function Cerebro:ResetStandings(reason)
	local baseGP = self.db.global.config.epgp.baseGP or 1000
	local playerCount = 0

	-- Zero EP/GP/attendance for all players
	if self.db.global.players then
		for name, data in pairs(self.db.global.players) do
			data.ep = 0
			data.gp = baseGP
			data.pr = 0
			data.bossKillsPresent = 0
			data.eligibleBossKills = 0
			data.lastUpdate = time()
			self:MarkPlayerDirty(name, {"ep", "gp", "pr"})
			playerCount = playerCount + 1
		end
	end

	-- Clear global counters
	self.db.global.seasonBossKills = 0
	self.db.global.totalBossKills = 0
	self.db.global.history = {}

	-- INCREMENT EPOCH — prevents stale data resurrection via LWW
	self.db.global.resetEpoch = (self.db.global.resetEpoch or 0) + 1

	-- Recalculate PR (should all be 0 now)
	self:RecalculateAllPR()
	self:RefreshAllUI()

	-- Audit log
	if self.Ledger and self.Ledger.AddAuditLog then
		self.Ledger:AddAuditLog(reason,
			string.format("Reset %d players (EP=0, GP=%d). Epoch=%d",
				playerCount, baseGP, self.db.global.resetEpoch))
	end

	-- V2 sync: BroadcastReset (GUILD) + targeted WHISPER snapshots for immediate propagation
	-- GUILD channel is lossy/throttled — WHISPERs give reliable per-player delivery
	if self.modules.CommunicationV2 then
		local v2 = self.modules.CommunicationV2

		-- 1. Guild broadcast (best-effort, reaches everyone listening)
		v2:BroadcastReset()

		-- 2. Targeted WHISPER snapshots to each online guild member
		--    This ensures immediate epoch propagation + data delivery
		local numMembers = GetNumGuildMembers and GetNumGuildMembers() or 0
		local myName = self:GetPlayerFullName()
		local whisperCount = 0

		for i = 1, numMembers do
			local name, _, _, _, _, _, _, _, online = GetGuildRosterInfo(i)
			if name and online then
				local fullName = self:GetFullPlayerName(name)
				if fullName ~= myName then
					local delay = whisperCount * 0.5  -- Stagger to avoid WoW throttling
					self:ScheduleTimer(function()
						if v2.BuildSnapshot and v2.SendV2Message then
							local snapshot = v2:BuildSnapshot(true, true)
							local whisperTarget = self:GetShortPlayerName(name)
							v2:SendV2Message("SNAP", snapshot, "WHISPER", whisperTarget)
							self:Debug("Reset snapshot whispered to " .. whisperTarget)
						end
					end, delay)
					whisperCount = whisperCount + 1
				end
			end
		end

		-- 3. Delayed heartbeat for hash convergence after snapshots
		self:ScheduleTimer(function()
			if v2.SendHeartbeat then
				v2:SendHeartbeat()
			end
		end, math.max(2, whisperCount * 0.5 + 1))

		if whisperCount > 0 then
			self:Print(string.format("  - Syncing to %d online members via targeted whispers", whisperCount))
		end
	end

	-- DEPRECATED: V1 SyncToRaid disabled — causes double epoch wipe on receivers,
	-- destroying data that V2 WHISPER snapshots already populated.
	-- Will be removed in a future update once V1 Communication is fully retired.
	-- local comm = self:GetModule("Communication", true)
	-- if comm and comm.SyncToRaid then
	-- 	comm:SyncToRaid()
	-- end

	return playerCount
end

--[[=============================================
	Utility Functions
===============================================]]

function Cerebro:CheckRaidStatus()
	if IsInRaid() then
		local numMembers = GetNumGroupMembers()
		self:Debug("In raid with " .. numMembers .. " members")

		-- Auto-start session if we're an officer
		if self:IsOfficer() and not self.modules.Session:IsActive() then
			self:Print("You're in a raid. Type /pb start to begin a session.")
		end
	end
end

-- TTL-based Command Execution Guard (prevent double-execution)
Cerebro.CommandExecutionGuard = {
	recent = {}, -- { [hash] = timestamp }
	duplicateWindow = 1.0, -- seconds
}

function Cerebro.CommandExecutionGuard:GenerateHash(cmdId, argv)
	-- Normalize args to stable string
	local argStr = table.concat(argv or {}, "|")
	return cmdId .. "||" .. argStr
end

function Cerebro.CommandExecutionGuard:IsDuplicate(cmdId, argv)
	local hash = self:GenerateHash(cmdId, argv)
	local now = GetTime()
	
	-- Prune old entries (older than 5 seconds)
	for h, ts in pairs(self.recent) do
		if now - ts > 5.0 then
			self.recent[h] = nil
		end
	end
	
	-- Check if recent
	if self.recent[hash] and (now - self.recent[hash] < self.duplicateWindow) then
		return true
	end
	
	-- Record this execution
	self.recent[hash] = now
	return false
end

-- Unified Command Executor (Idempotency + RBAC + Registry dispatch)
function Cerebro:ExecuteCommand(cmdId, argv, source)
	source = source or "slash"
	argv = argv or {}
	
	-- Idempotency check
	if self.CommandExecutionGuard:IsDuplicate(cmdId, argv) then
		self:Debug("Command ignored (idempotency guard): " .. cmdId)
		return
	end
	
	-- Build execution context
	local ctx = {
		source = source, -- "slash" or "ui"
		sender = self:GetFullPlayerName("player"),
		isOfficer = self:IsOfficerSafe(),
		now = GetTime(),
		print = function(msg) self:Print(msg) end,
		debug = function(msg) self:Debug(msg) end,
	}
	
	-- Dispatch to CommandRegistry
	if not _G.UI_CommandRegistry then
		self:Print("Command system not available.")
		return
	end
	
	local success, error = _G.UI_CommandRegistry:Execute(cmdId, argv, ctx)
	if not success then
		self:Print("Error executing command: " .. tostring(error))
	end
end

function Cerebro:IsOfficerSafe(player)
	-- Wrapper around IsOfficer that never errors
	local success, result = pcall(function()
		return self:IsOfficer(player)
	end)
	
	if not success then
		return false -- Fail closed
	end
	
	return result
end

-- STRICT Guild Master Check (Rank 0 ONLY)
-- NO solo fallback, NO raid leader fallback for destructive commands
-- Per SECURITY_MODEL: Factory Reset and Restore are GM-only by default
function Cerebro:IsGuildMaster(player)
	local target = player or "player"
	
	-- MUST be in a guild - no guild = no GM = no access
	if not IsInGuild() then
		return false
	end
	
	local fullName = self:GetFullPlayerName(target)
	local _, _, rankIndex = GetGuildInfo(target)
	
	-- Fallback to roster scan if rankIndex is nil
	if not rankIndex then
		for i = 1, GetNumGuildMembers() do
			local name, _, rIdx = GetGuildRosterInfo(i)
			if name and self:GetFullPlayerName(name) == fullName then
				rankIndex = rIdx
				break
			end
		end
	end
	
	-- Rank 0 is Guild Master - the ONLY condition that matters
	return rankIndex == 0
end

-- Safe wrapper for IsGuildMaster (fail closed)
function Cerebro:IsGuildMasterSafe(player)
	local success, result = pcall(function()
		return self:IsGuildMaster(player)
	end)
	
	if not success then
		return false -- Fail closed
	end
	
	return result
end

--[[=============================================
	Guild Scope Verification
	
	Verifies if current character is in the authorized guild.
	See SECURITY_MODEL.md §7 for full documentation.
	
	Returns:
		true  = Access granted (scope matches or bootstrapping)
		false = Access denied (scope mismatch)
		nil   = Pending (API not ready, caller should retry)
===============================================]]

function Cerebro:VerifyGuildScope()
	local scope = self.db.global.scope
	
	-- Bootstrapping: no scope set yet = allow (officer will set it)
	if not scope then
		return true
	end
	
	-- Not in a guild = mismatch
	if not IsInGuild() then
		return false
	end
	
	-- API not ready yet = defer (return nil to indicate "pending")
	local guildName, _, _, _ = GetGuildInfo("player")
	if not guildName then
		return nil -- Caller should retry or show "Loading..."
	end
	
	-- Primary check: GUID (most stable identifier)
	if scope.guildGUID and GetGuildGUID then
		local currentGUID = GetGuildGUID()
		if currentGUID and currentGUID == scope.guildGUID then
			return true
		end
	end
	
	-- Fallback: Name + Realm match
	local currentRealm = self:GetNormalizedRealm()
	if scope.guildName == guildName and scope.realm == currentRealm then
		return true
	end
	
	-- GUID mismatch but name/realm match (guild rename case)
	-- If we have GUID but it doesn't match, and name/realm do match,
	-- allow and auto-heal the GUID to prevent future mismatches
	if scope.guildName == guildName then
		-- Auto-heal: update stored GUID to current (guild rename/transfer)
		if GetGuildGUID then
			local currentGUID = GetGuildGUID()
			if currentGUID and scope.guildGUID ~= currentGUID then
				self:Debug("Scope GUID auto-healed: " .. (scope.guildGUID or "nil") .. " -> " .. currentGUID)
				scope.guildGUID = currentGUID
			end
		end
		return true
	end
	
	return false
end


-- Checks if a guild rank has officer permissions using the WoW guild permission system.
-- Uses officerchat_listen (flag 3) as the canonical "is officer" indicator.
-- rankIndex: 0-based guild rank index (0 = Guild Master)
function Cerebro:IsRankOfficer(rankIndex)
	if rankIndex == nil then return false end
	if rankIndex == 0 then return true end  -- GM is always officer

	-- Retail 8.0+: C_GuildInfo.GuildControlGetRankFlags returns a boolean array
	if C_GuildInfo and C_GuildInfo.GuildControlGetRankFlags then
		local flags = C_GuildInfo.GuildControlGetRankFlags(rankIndex + 1)  -- 1-based rankOrder
		-- Flag index 3 = officerchat_listen
		return flags and flags[3] or false
	end

	-- Classic fallback: requires GuildControlSetRank before reading flags
	if GuildControlSetRank and GuildControlGetRankFlags then
		GuildControlSetRank(rankIndex + 1)
		-- Return values: guildchat_listen, guildchat_speak, officerchat_listen, ...
		local _, _, officerchat_listen = GuildControlGetRankFlags()
		return officerchat_listen or false
	end

	return false
end

function Cerebro:IsOfficer(player)
	local target = player or "player"
	local fullName = self:GetFullPlayerName(target)

	-- 0. Scope guard: reject if guild doesn't match the locked scope
	-- Prevents officers from a different guild being treated as officers
	if IsInGuild() and self.VerifyGuildScope then
		local scopeResult = self:VerifyGuildScope()
		if scopeResult == false then
			return false  -- Wrong guild, fail closed
		end
	end

	-- 1. Guild Security (Highest Priority)
	-- Uses the guild's own permission system to determine officer status
	if IsInGuild() then
		-- Fast path for current player: use CanViewOfficerNote API directly
		if target == "player" and not player then
			-- Retail
			if C_GuildInfo and C_GuildInfo.CanViewOfficerNote then
				return C_GuildInfo.CanViewOfficerNote()
			end
			-- Classic
			if CanViewOfficerNote then
				return CanViewOfficerNote()
			end
		end

		local _, rankName, rankIndex = GetGuildInfo(target)

		-- FIX: GetGuildInfo only works with unit tokens ("player", "target", "raid1", etc.)
		-- When passed a player NAME, it returns garbage (rankIndex=0, rankName=nil)
		-- Only trust the result if BOTH rankName and rankIndex are valid
		if not rankName or rankIndex == nil then
			-- Fallback to cache which handles both roster loading AND cross-realm short names
			-- We use fullName to ensure unit tokens are evaluated before passing to cache
			rankName = self:GetPlayerGuildRank(fullName) -- luacheck: ignore 311
			rankIndex = self:GetPlayerGuildRankIndex(fullName)
		end

		if rankIndex then
			-- Rank 0 is ALWAYS Guild Master
			if rankIndex == 0 then
				return true
			end

			-- Check if this rank has officer permissions (officerchat_listen flag)
			if self:IsRankOfficer(rankIndex) then
				return true
			end

			-- If they are in the guild and we FOUND their rank, but it's not an officer rank,
			-- we STRICTLY reject them. This prevents random raiders from being promoted to
			-- raid assistant and "hacking" the system.
			return false
		end
	end

	-- 2. Fallback for non-guild groups (PUGs) - raid/party leader/assistant only
	-- ONLY apply this if the host player is not in a guild. If we are in a guild,
	-- we must strictly rely on guild ranks to prevent privilege escalation.
	if not IsInGuild() then
		if IsInRaid() then
			for i = 1, GetNumGroupMembers() do
				local name, rank = GetRaidRosterInfo(i)
				if name and self:GetFullPlayerName(name) == fullName then
					return (rank >= 1) -- 1=Assistant, 2=Leader
				end
			end
		elseif IsInGroup() then
			return UnitIsGroupLeader(target) or UnitIsGroupAssistant(target)
		end
	end

	-- No officer status detected - fail closed
	-- NOTE: Solo testing no longer auto-grants officer status
	-- Use /cb debug or join a test guild with officer rank for testing
	return false
end

-- Checks if a player has a specific guild rank (by name)
function Cerebro:IsGuildRank(player, rankName)
	if not IsInGuild() then return false end
	
	-- Ensure guild roster is loaded
	if GetNumGuildMembers() == 0 then
		if C_GuildInfo and C_GuildInfo.GuildRoster then
			C_GuildInfo.GuildRoster()
		end
		return false
	end
	
	local targetRank = self:GetPlayerGuildRank(player)
	return targetRank and targetRank:lower() == rankName:lower()
end

-- Cache for guild ranks to avoid constant rescanning
Cerebro.guildRankCache = {
	data = {},
	lastUpdate = 0,
}

-- Returns the guild rank name for a given player
function Cerebro:GetPlayerGuildRank(player)
	if not IsInGuild() then return nil end

	-- Use short player name for cache key
	local shortPlayerName = self:GetShortPlayerName(player)

	-- Check cache, refresh every 5 minutes (300s)
	if self.guildRankCache.data[shortPlayerName] and (time() - self.guildRankCache.lastUpdate < 300) then
		return self.guildRankCache.data[shortPlayerName]
	end

	-- Update cache if stale
	self.guildRankCache.data = {}
	self.guildRankCache.lastUpdate = time()
	
	if GetNumGuildMembers() == 0 then
		if C_GuildInfo and C_GuildInfo.GuildRoster then
			C_GuildInfo.GuildRoster()
		end
	end
	
	for i = 1, GetNumGuildMembers() do
		local name, rankName = GetGuildRosterInfo(i)
		if name then
			self.guildRankCache.data[self:GetShortPlayerName(name)] = rankName
		end
	end

	return self.guildRankCache.data[shortPlayerName]
end

-- Cache for guild rank indices (parallel to guildRankCache)
Cerebro.guildRankIndexCache = {
	data = {},
	lastUpdate = 0,
}

-- Returns the guild rank INDEX (0=GM, 1=Officer, etc.) for a given player
-- Fix 5: Used by visibleRanks filter and Sync Leader election
function Cerebro:GetPlayerGuildRankIndex(player)
	if not IsInGuild() then return nil end

	local shortPlayerName = self:GetShortPlayerName(player)

	-- Check cache, refresh every 5 minutes (300s)
	if self.guildRankIndexCache.data[shortPlayerName] and (time() - self.guildRankIndexCache.lastUpdate < 300) then
		return self.guildRankIndexCache.data[shortPlayerName]
	end

	-- Update cache if stale
	self.guildRankIndexCache.data = {}
	self.guildRankIndexCache.lastUpdate = time()

	if GetNumGuildMembers() == 0 then
		if C_GuildInfo and C_GuildInfo.GuildRoster then
			C_GuildInfo.GuildRoster()
		end
	end

	for i = 1, GetNumGuildMembers() do
		local name, _, rankIndex = GetGuildRosterInfo(i)
		if name then
			self.guildRankIndexCache.data[self:GetShortPlayerName(name)] = rankIndex
		end
	end

	return self.guildRankIndexCache.data[shortPlayerName]
end

function Cerebro:IsMasterLooter()
	local lootMethod, _, raidIndex = GetLootMethod()
	return lootMethod == "master" and raidIndex == 0
end

function Cerebro:GetPlayerFullName(unit)
	unit = unit or "player"
	local name, realm = UnitName(unit)
	realm = realm or GetRealmName()
	-- Strip spaces from realm for consistency with GetFullPlayerName()
	-- WoW API inconsistency: GetRealmName() returns "Area 52" but
	-- GetGuildRosterInfo returns "Area52". We normalize to no spaces.
	realm = realm:gsub("%s", "")
	return name .. "-" .. realm
end

-- Tiered Debug System
-- debugLevel: "off" | "simple" | "technical" | "both"
-- Debug() = technical messages (implementation detail, hashes, internal state)
-- DebugSimple() = user-friendly messages ("sync complete", "player added")

function Cerebro:GetDebugLevel()
	local level = self.db.global.config.debugLevel
	if level then return level end
	-- Backward compat: boolean config.debug maps to "both" if true
	if self.db.global.config.debug then return "both" end
	return "off"
end

function Cerebro:Debug(msg)
	local level = self:GetDebugLevel()
	if level == "technical" or level == "both" then
		self:Print("|cff888888[DEBUG]|r " .. msg)
	end
end

function Cerebro:DebugSimple(msg)
	local level = self:GetDebugLevel()
	if level == "simple" or level == "both" then
		self:Print("|cff66bbff[INFO]|r " .. msg)
	end
end



--[[=============================================
	Slash Commands
===============================================]]

function Cerebro:SlashCommand(input)
	local args = {}
	
	-- Quote-aware argument parsing
	-- Handles both "quoted strings" for arguments with spaces (e.g., "PlayerName-Area 52")
	-- and regular space-separated arguments
	local i = 1
	local len = #input
	
	while i <= len do
		-- Skip leading whitespace
		while i <= len and input:sub(i, i):match("%s") do
			i = i + 1
		end
		
		if i > len then break end
		
		local char = input:sub(i, i)
		
		if char == '"' then
			-- Quoted string - find closing quote
			local startPos = i + 1
			local endPos = input:find('"', startPos, true)
			
			if endPos then
				table.insert(args, input:sub(startPos, endPos - 1))
				i = endPos + 1
			else
				-- No closing quote, treat rest as single arg
				table.insert(args, input:sub(startPos))
				break
			end
		else
			-- Unquoted - find next whitespace
			local startPos = i
			while i <= len and not input:sub(i, i):match("%s") do
				i = i + 1
			end
			table.insert(args, input:sub(startPos, i - 1))
		end
	end

	local cmd = args[1] and args[1]:lower() or ""
	table.remove(args, 1) -- Remove command token, keep rest as argv

	-- Command mapping table (alias -> registry ID)
	local cmdMap = {
		[""] = "toggle_ui",
		["help"] = "help",
		["show"] = "toggle_ui",
		["ui"] = "toggle_ui",
		["wishlist"] = "show_wishlist",
		["wl"] = "show_wishlist",
		["standings"] = "show_standings",
		["history"] = "show_history",
		["config"] = "show_config",
		["options"] = "show_config",
		["verify"] = "run_verification",
		["debug"] = "toggle_debug",
		["discord"] = "discord_export",
		["start"] = "start_session",
		["end"] = "end_session",
		["forceend"] = "force_end_session",
		["check"] = "prep_check",
		["prep"] = "prep_check",
		["add"] = "award_points",
		["sub"] = "subtract_points",
		["subtract"] = "subtract_points",
		["readiness"] = "award_readiness",
		["admit"] = "admit_player",
		["fixdb"] = "fix_database",
		["delete"] = "delete_player",
		["factoryreset"] = "factory_reset",
		["restore"] = "restore_backup",
		["backup"] = "backup_status",
		["sync"] = "sync_data",
		["syncreset"] = "sync_reset",
		["status"] = "show_status",
		["selftest"] = "self_test",
		["testmplus"] = "test_mplus",
		["testenv"] = "test_env",
		["quarantine"] = "quarantine_status",
		["versioncheck"] = "version_check",
		["vercheck"] = "version_check",
		["purge"] = "purge_player",
		["block"] = "block_player",
		["unblock"] = "unblock_player",
		["blockedlist"] = "blocked_list",
		["purgetest"] = "purge_test_players",
		["purgeghosted"] = "purge_ghosted",
		["purgeghosts"] = "purge_ghosted",
		["attendance"] = "attendance_summary",
		["testseasonreset"] = "test_season_reset",
		["freshstart"] = "fresh_start",
		["seasonreset"] = "season_reset",
		["standingsreset"] = "standings_reset",
		["intro"] = "show_intro",
		["testui"] = "show_test_ui",
		["purgepending"] = "purge_pending",
		["setepoch"] = "set_epoch",
		["auditofficers"] = "audit_officers",
		["syncstatus"] = "sync_status",
		["syncaudit"] = "sync_audit",
		["synccheck"] = "sync_check",
		["synchistory"] = "sync_history",
		["debughash"] = "debug_hash",
	}

	local cmdId = cmdMap[cmd]
	if not cmdId then
		self:Print("Unknown command. Type /cb help for available commands.")
		return
	end

	-- Execute via unified dispatcher
	self:ExecuteCommand(cmdId, args, "slash")
end

function Cerebro:ShowHelp()
	self:Print("=== Cerebro Commands ===")
	self:Print("/cb show - Toggle main UI")

	self:Print("/cb wishlist - Manage your wishlist")
	self:Print("/cb standings - View EP/GP Standings")
	self:Print("/cb history - View history")
	if self.modules.DiscordExport then
		self:Print("/cb discord - Export data for Discord")
	end
	self:Print("/cb config - Open options")
	self:Print("/cb debug [simple|technical|both|off] - Debug output level")
	self:Print("/cb status - Show addon status")
	self:Print("/cb selftest - Run health checks")
	self:Print("/cb auditofficers - Audit officer detection for all players")
	self:Print("/cb intro - Re-show onboarding wizard")
	self:Print("/cb freshstart YES - Reset local data and re-sync")
	self:Print("/cb testui - Open UI development sandbox")
	self:Print(" ")
	self:Print("|cffff8800Officer Commands:|r")
	self:Print("/cb start [name] - Start raid session")
	self:Print("/cb end - End session")
	self:Print("/cb check - Run prep check")
	self:Print("/cb add [p] [amt] (ep/gp) - Add EP/GP")
	self:Print("/cb sub [p] [amt] (ep/gp) - Subtract EP/GP")
	self:Print("/cb delete [p] - Delete player data")
	self:Print("/cb admit [p] - Admit player to Core")
	self:Print("/cb readiness award [p] [amt] - Award Readiness")
	self:Print("/cb sync - Force data sync")
	self:Print("/cb syncstatus - Show raid sync health (Officer)")
	self:Print("/cb syncaudit - Compare standings data across online players (Officer)")
	self:Print("/cb synccheck - V2 hash-based sync check with auto-repair (Officer)")
	self:Print("/cb synchistory - Push full transaction history to online officers (Officer)")
	self:Print("/cb debughash - Dump V2 sync hash inputs for cross-client debugging")
	self:Print("/cb fixdb - Run database cleanup")
	self:Print("/cb backup - View backup status")
	self:Print("/cb purge [p] YES - Reset player (zero EP/GP, set Pending)")
	self:Print("/cb block [p] YES - Block player permanently from sync")
	self:Print("/cb unblock [p] - Unblock a previously blocked player")
	self:Print("/cb purgeghosted - Remove non-guild ghost players")
	self:Print("/cb standingsreset YES - Zero EP/GP/attendance (no epoch change)")
	self:Print(" ")
	self:Print("|cffff0000GM Only Commands:|r")
	self:Print("/cb factoryreset YES - Wipe database (creates backup)")
	self:Print("/cb restore YES - Restore from backup")
end



--[[=============================================
	Communication Handler
===============================================]]

function Cerebro:OnCommReceived(prefix, message, distribution, sender)
	-- Deserialize message
	local success, msgType, data = self:Deserialize(message)

	if not success then
		self:Debug("Failed to deserialize message from " .. sender)
		return
	end

	-- Route to Communication module
	if self.modules.Communication then
		self.modules.Communication:HandleMessage(msgType, data, sender)
	end
end

--[[=============================================
	Custom Events (for module communication)
===============================================]]

function Cerebro:SendMessage(event, ...)
	-- Fire custom event for modules to listen to
	-- This uses Ace3's message system, not to be confused with addon comms
	self:Debug("Event: " .. event)

	-- Notify all modules
	for name, module in pairs(self.modules) do
		if module.OnEvent then
			module:OnEvent(event, ...)
		end
	end
end

--[[=============================================
	Verification / Tests
===============================================]]

function Cerebro:RunVerification()
	self:Print("Running EPGP Logic Verification...")
	
	-- 1. Setup Test Player
	local testName = "TestPlayer-Realm"
	local p = self.Player:New(testName)
	p:GetData().ep = 0
	p:GetData().gp = 0
	
	-- 2. Test EP Award
	p:AddEP(100)
	local epEnd = p:GetEP()
	if epEnd == 100 then self:Print("PASS: EP Award (0->100)") else self:Print("FAIL: EP Award") end
	
	-- 3. Test GP Charge
	p:GetData().gp = 0 -- Reset
	p:AddGP(50)
	local rawGP = p:GetRawGP()
	
	if rawGP == 50 then self:Print("PASS: GP Charge (0->50)") else self:Print("FAIL: GP Charge") end
	
	-- 4. Test PR
	-- PR = EP / GP. (Using effective GP = max(Raw, Base))
	p:GetData().ep = 1000
	p:GetData().gp = 100 -- Assuming BaseGP < 100
	p:UpdatePR()
	local pr = p:GetPR()
	if math.abs(pr - 10.0) < 0.01 then self:Print("PASS: PR Calc (1000/100=10)") else self:Print("FAIL: PR Calc got "..pr) end
	
	-- 5. Test Decay
	p:GetData().ep = 1000
	p:GetData().gp = 1000
	p:Decay(0.1) -- 10%
	
	local decayedEP = p:GetEP()
	local decayedGP = p:GetRawGP()
	
	if decayedEP == 900 and decayedGP == 900 then 
		self:Print("PASS: Decay 10%") 
	else 
		self:Print("FAIL: Decay (Got "..decayedEP.."/"..decayedGP..")") 
	end
	
	self:Print("Verification Complete.")
end
