--[[
	Cerebro - Utility Functions
	Helper functions for item parsing, player names, time formatting, etc.
]]--

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

-- Local references to frequently used functions
local tonumber = tonumber
local strmatch = string.match
local tinsert = table.insert

--[[=============================================
	UUID Generation
===============================================]]

-- Generate a UUID v4 string (random-based)
-- Used by Ledger for transaction IDs and Communication for batchIds
function Cerebro:GenerateUUID()
	local template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"
	return string.gsub(template, "[xy]", function(c)
		local v = (c == 'x') and math.random(0, 0xf) or math.random(8, 0xb)
		return string.format("%x", v)
	end)
end

--[[=============================================
	Item Link Parsing
===============================================]]

-- Extract item ID explicitly using regex (Fastest, usually correct for links)
function Cerebro:GetItemIDFromLink(itemLink)
	if not itemLink then return nil end
	local itemID = strmatch(itemLink, "item:(%d+)")
	return tonumber(itemID)
end

-- Get Reliable Item ID (Prioritizes Regex extraction)
-- C_TooltipInfo.GetHyperlink and GetItemInfo(link) can return the 'created item' ID for recipes.
-- Regex extraction from the link string accurately returns the Recipe ID itself (Class 9).
function Cerebro:GetReliableItemID(itemLink)
	if not itemLink then return nil end
	
	-- Force Regex extraction first
	-- Previous attempts showed API calls (GetItemInfo, GetHyperlink) often resolve to the created item
	return self:GetItemIDFromLink(itemLink)
end

-- Slot Definitions (Shared between Utilities and UI)
-- Maps INVTYPE to Friendly Name
Cerebro.SlotMap = {
	INVTYPE_HEAD = "Head",
	INVTYPE_NECK = "Neck",
	INVTYPE_SHOULDER = "Shoulder",
	INVTYPE_CHEST = "Chest",
	INVTYPE_ROBE = "Chest",
	INVTYPE_WAIST = "Waist",
	INVTYPE_LEGS = "Legs",
	INVTYPE_FEET = "Feet",
	INVTYPE_WRIST = "Wrist",
	INVTYPE_HAND = "Hands",
	INVTYPE_FINGER = "Ring",
	INVTYPE_TRINKET = "Trinket",
	INVTYPE_CLOAK = "Cloak",
	INVTYPE_WEAPON = "Weapon (1H)",
	INVTYPE_SHIELD = "Shield",
	INVTYPE_2HWEAPON = "Weapon (2H)",
	INVTYPE_WEAPONMAINHAND = "Main Hand",
	INVTYPE_WEAPONOFFHAND = "Off Hand",
	INVTYPE_HOLDABLE = "Off Hand",
	INVTYPE_RANGED = "Ranged",
	INVTYPE_RANGEDRIGHT = "Ranged",
	INVTYPE_THROWN = "Thrown",
	INVTYPE_RELIC = "Relic",
	INVTYPE_NON_EQUIP_IGNORE = "Curio",
	INVTYPE_RECIPE = "Recipe",
	INVTYPE_TOY = "Toy",
	INVTYPE_PET = "Pet",
	INVTYPE_MOUNT = "Mount",
	INVTYPE_COSMETIC = "Cosmetic",
	INVTYPE_DECOR = "Decor",
}

-- Ordered list for Config UI
Cerebro.SlotList = {
	{ key = "INVTYPE_HEAD", label = "Head" },
	{ key = "INVTYPE_NECK", label = "Neck" },
	{ key = "INVTYPE_SHOULDER", label = "Shoulder" },
	{ key = "INVTYPE_CLOAK", label = "Back" },
	{ key = "INVTYPE_CHEST", label = "Chest" },
	{ key = "INVTYPE_WRIST", label = "Wrist" },
	{ key = "INVTYPE_HAND", label = "Hands" },
	{ key = "INVTYPE_WAIST", label = "Waist" },
	{ key = "INVTYPE_LEGS", label = "Legs" },
	{ key = "INVTYPE_FEET", label = "Feet" },
	{ key = "INVTYPE_FINGER", label = "Ring" },
	{ key = "INVTYPE_TRINKET", label = "Trinket" },
	{ key = "INVTYPE_WEAPON", label = "Weapon (1H)" },
	{ key = "INVTYPE_SHIELD", label = "Shield" },
	{ key = "INVTYPE_RANGED", label = "Ranged" },
	{ key = "INVTYPE_2HWEAPON", label = "Weapon (2H)" },
	{ key = "INVTYPE_WEAPONMAINHAND", label = "Main Hand" },
	{ key = "INVTYPE_WEAPONOFFHAND", label = "Off Hand" },
	{ key = "INVTYPE_HOLDABLE", label = "Off Hand (Frill)" },
	{ key = "Curio", label = "Curio" },
	{ key = "INVTYPE_RECIPE", label = "Recipe" },
	{ key = "INVTYPE_TOY", label = "Toy" },
	{ key = "INVTYPE_PET", label = "Pet" },
	{ key = "INVTYPE_MOUNT", label = "Mount" },
	{ key = "INVTYPE_COSMETIC", label = "Cosmetic" },
	{ key = "INVTYPE_DECOR", label = "Decor" },
}

function Cerebro:GetFriendlySlotName(equipLoc)
	return self.SlotMap[equipLoc] or equipLoc or "Unknown"
end

-- Get item info from link
function Cerebro:GetItemInfo(itemLink)
	if not itemLink then return nil end

	local itemName, itemLinkResult, itemQuality, itemLevel, itemMinLevel, itemType, itemSubType,
		  _, itemEquipLoc, iconFileDataID, sellPrice, classID, subclassID,
		  bindType = C_Item.GetItemInfo(itemLink)

	return {
		name = itemName,
		link = itemLinkResult,
		quality = itemQuality,
		ilvl = itemLevel,
		minLevel = itemMinLevel,
		type = itemType,
		subType = itemSubType,
		equipLoc = itemEquipLoc,
		icon = iconFileDataID,
		sellPrice = sellPrice,
		classID = classID,
		subclassID = subclassID,
		bindType = bindType,
	}
end

-- Check if item is equippable
function Cerebro:IsItemEquippable(itemLink)
	local itemInfo = self:GetItemInfo(itemLink)
	if not itemInfo then return false end
	return itemInfo.equipLoc and itemInfo.equipLoc ~= ""
end

-- Check if item is a tier token
-- Refined logic: Scans tooltip for "Use: ... <Slot>" and "Classes: ..." restrictions
-- CONTEXT: Tier tokens (e.g., "Helm of the Lost Conqueror") are not equippable themselves,
-- but they create equippable gear. We must map them to their target slot to calculate
-- the correct GP cost (e.g., Head Slot GP) instead of treating them as miscellaneous items.
-- Token identification constants
local TOKEN_VALID_SLOTS = {
	["head"] = true,
	["shoulder"] = true,
	["chest"] = true,
	["robe"] = true,
	["hand"] = true,    -- Changed from hands
	["hands"] = true,   -- Variation
	["leg"] = true,     -- Changed from legs
	["legs"] = true,    -- Variation
	["wrist"] = true,
	["waist"] = true,
	["feet"] = true,
	["back"] = true,
}

local TOKEN_VALID_CLASSES = {
	["Paladin"] = true, ["Priest"] = true, ["Shaman"] = true,
	["Warrior"] = true, ["Mage"] = true, ["Warlock"] = true,
	["Hunter"] = true, ["Druid"] = true, ["Rogue"] = true,
	["Monk"] = true, ["Demon Hunter"] = true,
	["Death Knight"] = true, ["Evoker"] = true
}

-- Mapping from Token Slot ID (found in tooltip) to standard INVTYPE
-- This allows us to treat a "Head Token" as a "Head" item for GP pricing.
Cerebro.TokenSlotToEquipLoc = {
	["head"] = "INVTYPE_HEAD",
	["shoulder"] = "INVTYPE_SHOULDER",
	["chest"] = "INVTYPE_CHEST",
	["robe"] = "INVTYPE_CHEST",
	["hand"] = "INVTYPE_HAND",
	["hands"] = "INVTYPE_HAND",
	["leg"] = "INVTYPE_LEGS",
	["legs"] = "INVTYPE_LEGS",
	["wrist"] = "INVTYPE_WRIST",
	["waist"] = "INVTYPE_WAIST",
	["feet"] = "INVTYPE_FEET",
	["back"] = "INVTYPE_CLOAK",
}

-- Check if item is a tier token
-- Refined logic: Scans tooltip for "Use: ... <Slot>" and "Classes: ..." restrictions
function Cerebro:IsItemToken(itemLink)
	if not itemLink then return false end
	
	-- Check Item Class first (Hard Filter)
	-- Use GetReliableItemID to ensure we get the actual item ID (resolves Recipe vs Created Item ambiguity)
	local itemID = self:GetReliableItemID(itemLink)
	
	-- Guard: Early return if itemID cannot be resolved (prevents nil argument errors)
	if not itemID then return false end
	
	local _, _, _, _, _, _, _, _, itemEquipLoc, _, _, classID = C_Item.GetItemInfo(itemID)
	
	-- Guard: Early return if item info cannot be retrieved
	if not classID then return false end
	
	-- DEBUG:
	-- print("Cerebro Debug:", itemLink, "Class:", classID)

	-- 1. Recipe/Pattern/Plan (ClassID 9): NEVER a token.
	if classID == 9 then 
		-- print("Cerebro: Blocked Recipe", itemLink)
		return false 
	end
	
	-- 2. Weapon (ClassID 2): NEVER a token.
	if classID == 2 then return false end
	
	-- 3. Armor (ClassID 4): Only block if it is actual equipable gear.
	-- Some Tier Tokens are technically Class 4 (Misc) but have no EquipLoc.
	if classID == 4 and itemEquipLoc and itemEquipLoc ~= "" then
		return false
	end
	
	-- Verify C_TooltipInfo is available (Retail only usually)
	if not C_TooltipInfo or not C_TooltipInfo.GetHyperlink then
		-- Fallback to old heuristic if API missing
		local itemInfo = self:GetItemInfo(itemLink)
		if not itemInfo then return false end
		if itemInfo.equipLoc and itemInfo.equipLoc ~= "" and itemInfo.equipLoc ~= "INVTYPE_NON_EQUIP" then return false end
		if itemInfo.quality < 3 then return false end
		local c = itemInfo.classID
		return (c == 15 or c == 12 or c == 7)
	end
	
	local tooltipData = C_TooltipInfo.GetHyperlink(itemLink)
	if not tooltipData or not tooltipData.lines then return false end
	
	local foundUse = false
	local foundClass = false
	local foundSlot = nil
	
	for _, line in ipairs(tooltipData.lines) do
		local text = line.leftText
		
		if text then
			-- Check for "Use:" line combined with a valid slot name
			-- Example: "Use: Create a piece of ... Head ... armor"
			if string.find(text, "^Use:") then
				local lower = string.lower(text)
				for slot in pairs(TOKEN_VALID_SLOTS) do
					if string.find(lower, slot) then
						foundUse = true
						foundSlot = slot
						break
					end
				end
			end
			
			-- Check for "Classes:" line
			-- Example: "Classes: Priest, Paladin, Shaman"
			if string.find(text, "^Classes:") then
				for class in pairs(TOKEN_VALID_CLASSES) do
					if string.find(text, class) then
						foundClass = true
						break
					end
				end
			end
		end
	end
	
	return (foundUse and foundClass), foundSlot
end

-- Check if item is a "Curio" (Context Token)
-- Logic provided by user: Class 8 (Reagent), Subclass 2 (Context Token), Non-Equip
function Cerebro:IsCurio(itemLink)
	if not itemLink then return false end

	-- Use GetItemInfo to check properties
	local _,_,_,_,_,_,_,_,equipSlot,_,_,classID,subclassID,_,_,setID,isCraftingReagent = C_Item.GetItemInfo(itemLink)

	return
		classID == Enum.ItemClass.Reagent and
		subclassID == 2 and -- Context Token
		equipSlot == "INVTYPE_NON_EQUIP_IGNORE" and
		not _G.GetItemSpell(itemLink) and
		not setID and
		isCraftingReagent == false
end

-- Get slot ID from equipment location
function Cerebro:GetSlotIDFromEquipLoc(equipLoc)
	local slots = {
		INVTYPE_HEAD = 1,
		INVTYPE_NECK = 2,
		INVTYPE_SHOULDER = 3,
		INVTYPE_BODY = 4,
		INVTYPE_CHEST = 5,
		INVTYPE_ROBE = 5,
		INVTYPE_WAIST = 6,
		INVTYPE_LEGS = 7,
		INVTYPE_FEET = 8,
		INVTYPE_WRIST = 9,
		INVTYPE_HAND = 10,
		INVTYPE_FINGER = {11, 12}, -- Two ring slots
		INVTYPE_TRINKET = {13, 14}, -- Two trinket slots
		INVTYPE_CLOAK = 15,
		INVTYPE_WEAPON = 16,
		INVTYPE_SHIELD = 17,
		INVTYPE_2HWEAPON = 16,
		INVTYPE_WEAPONMAINHAND = 16,
		INVTYPE_WEAPONOFFHAND = 17,
		INVTYPE_HOLDABLE = 17,
		INVTYPE_RANGED = 16,
		INVTYPE_THROWN = 16,
		INVTYPE_RANGEDRIGHT = 16,
		INVTYPE_RELIC = 16,
		INVTYPE_TABARD = 19,
	}

	return slots[equipLoc]
end

--[[=============================================
	Player Name Formatting
===============================================]]

-- Get full player name with realm
function Cerebro:GetFullPlayerName(name)
	if not name then return nil end

	local function TitleCase(s)
		if not s then return "" end
		-- UTF-8 aware: detect multi-byte first character before applying case transforms
		-- UTF-8 lead bytes: 192-223 = 2-byte, 224-239 = 3-byte, 240-247 = 4-byte
		-- Continuation bytes: 128-191
		-- Uses decimal escapes (Lua 5.1 compatible; \xNN requires 5.2+)
		local firstChar = s:match("^([\192-\247][\128-\191]+)") or s:sub(1,1)
		local rest = s:sub(#firstChar + 1)
		return firstChar:upper() .. rest:lower()
	end

	-- Check for existing realm
	local n, r = strsplit("-", name)
	
	if r then
		-- Already has realm — normalize: strip spaces to match WoW API format
		-- GetGuildRosterInfo/GetRaidRosterInfo return "Area52", not "Area 52"
		r = r:gsub("%s", "")
		return TitleCase(n) .. "-" .. r
	else
		-- Add current realm (strip spaces from GetRealmName() for consistency)
		local realm = GetRealmName():gsub("%s", "")
		return TitleCase(name) .. "-" .. realm
	end
end

-- Get short player name (without realm)
function Cerebro:GetShortPlayerName(name)
	if not name then return nil end

	local shortName = strmatch(name, "^([^-]+)")
	return shortName or name
end

-- Get class color for player
function Cerebro:GetClassColor(class)
	if not class then return {r=1, g=1, b=1} end

	local color = RAID_CLASS_COLORS[class]
	return color or {r=1, g=1, b=1}
end

-- Colorize player name by class
function Cerebro:ColorPlayerName(name, class)
	if not class then
		-- Try to get class from database
		local playerData = self:GetPlayerData(name)
		class = playerData and playerData.class
	end

	if not class then
		return name
	end

	local color = self:GetClassColor(class)
	local shortName = self:GetShortPlayerName(name)

	return string.format("|cff%02x%02x%02x%s|r",
		color.r * 255, color.g * 255, color.b * 255,
		shortName
	)
end

-- Get class color format string (e.g. "|cffff0000")
function Cerebro:GetClassColorString(name)
	local playerData = self:GetPlayerData(name or "player")
	local class = playerData and playerData.class
	
	if not class then
		-- Fallback to API if Unit exists
		if UnitExists(name or "player") then
			local _, cls = UnitClass(name or "player")
			class = cls
		end
	end

	local color = self:GetClassColor(class)
	return string.format("|cff%02x%02x%02x", color.r * 255, color.g * 255, color.b * 255)
end

--[[=============================================
	Time Formatting
===============================================]]

-- Format timestamp as date/time string
function Cerebro:FormatTimestamp(timestamp)
	if not timestamp or timestamp == 0 then
		return "Never"
	end

	return date("%Y-%m-%d %H:%M:%S", timestamp)
end

-- Format timestamp as relative time (e.g., "5 minutes ago")
function Cerebro:FormatRelativeTime(timestamp)
	if not timestamp or timestamp == 0 then
		return "Never"
	end

	local diff = time() - timestamp

	if diff < 60 then
		return "Just now"
	elseif diff < 3600 then
		local mins = math.floor(diff / 60)
		return mins .. " minute" .. (mins > 1 and "s" or "") .. " ago"
	elseif diff < 86400 then
		local hours = math.floor(diff / 3600)
		return hours .. " hour" .. (hours > 1 and "s" or "") .. " ago"
	else
		local days = math.floor(diff / 86400)
		return days .. " day" .. (days > 1 and "s" or "") .. " ago"
	end
end

-- Format duration in seconds as human-readable
function Cerebro:FormatDuration(seconds)
	if not seconds or seconds == 0 then
		return "0s"
	end

	local hours = math.floor(seconds / 3600)
	local mins = math.floor((seconds % 3600) / 60)
	local secs = seconds % 60

	if hours > 0 then
		return string.format("%dh %dm", hours, mins)
	elseif mins > 0 then
		return string.format("%dm %ds", mins, secs)
	else
		return string.format("%ds", secs)
	end
end

--[[=============================================
	Number Formatting
===============================================]]

-- Format number with commas (e.g., 1000 -> 1,000)
function Cerebro:FormatNumber(num)
	if not num then return "0" end

	local formatted = tostring(num)
	local k

	while true do
		formatted, k = string.gsub(formatted, "^(-?%d+)(%d%d%d)", '%1,%2')
		if k == 0 then break end
	end

	return formatted
end

-- Format EPGP value with sign
function Cerebro:FormatEPGP(amount, showSign)
	local formatted = self:FormatNumber(math.abs(amount))

	if showSign then
		if amount > 0 then
			return "|cff00ff00+" .. formatted .. "|r"
		elseif amount < 0 then
			return "|cffff0000-" .. formatted .. "|r"
		end
	end

	return formatted
end

--[[=============================================
	Table Utilities
===============================================]]

-- Deep copy table
function Cerebro:CopyTable(src, dest)
	if type(src) ~= "table" then return src end

	dest = dest or {}

	for k, v in pairs(src) do
		if type(v) == "table" then
			dest[k] = self:CopyTable(v)
		else
			dest[k] = v
		end
	end

	return dest
end

-- Check if table contains value
function Cerebro:TableContains(tbl, value)
	for _, v in pairs(tbl) do
		if v == value then
			return true
		end
	end
	return false
end

-- Count table entries
function Cerebro:TableCount(tbl)
	local count = 0
	for _ in pairs(tbl) do
		count = count + 1
	end
	return count
end

-- Recalculate Priority Ratio (PR) for all players
-- Used during sync operations to ensure cached PR matches current EP/GP
-- PR = EP / max(GP, BaseGP), rounded to 4 decimal places
function Cerebro:RecalculateAllPR()
	local baseGP = self.db.global.config.epgp.baseGP or 200
	for name, pData in pairs(self.db.global.players) do
		if type(pData) == "table" then
			local ep = pData.ep or 0
			local gp = math.max(pData.gp or baseGP, baseGP)
			pData.pr = tonumber(string.format("%.4f", ep / gp))
		else
			self:Debug("RecalculateAllPR: corrupt entry for " .. tostring(name) .. " (type=" .. type(pData) .. "), removing")
			self.db.global.players[name] = nil
		end
	end
end

-- Refresh all UI components (Classic UI and Sandbox UI)
-- Use this instead of checking modules.UI/UISandbox directly throughout the codebase
-- Both checks are safe no-ops if the module isn't loaded
function Cerebro:RefreshAllUI()
	if self.modules.UI and self.modules.UI.RefreshStandings then
		self.modules.UI:RefreshStandings()
	end
	if self.modules.UISandbox and self.modules.UISandbox.RefreshStandingsRows then
		self.modules.UISandbox:RefreshStandingsRows()
	end
end

-- Get players filtered for sync (excludes synthetic test players and ghost stubs)
-- Used before sending sync data to prevent polluting other clients
function Cerebro:GetFilteredPlayersForSync()
	local filtered = {}
	local baseGP = (self.db.global.config.epgp and self.db.global.config.epgp.baseGP) or 200
	for name, pData in pairs(self.db.global.players) do
		if type(pData) ~= "table" then
			self:Debug("GetFilteredPlayersForSync: corrupt entry for " .. tostring(name) .. " (type=" .. type(pData) .. "), skipping")
		elseif self:IsSyntheticTestPlayer(name) then
			Cerebro:Debug("GetFilteredPlayersForSync: skipping synthetic player " .. name)
		elseif self:IsParticipant(pData, baseGP) then
			-- Ghost stubs (EP=0, GP=baseGP, no status) are silently excluded
			filtered[name] = pData
		end
	end
	return filtered
end

--- Determine whether a player record is a "participant" for hash computation.
-- Participants drive ComputeRosterHash() and the reconciliation deletion sweep.
-- Non-participants (ghost Pending stubs: EP=0, GP=baseGP, status=Pending)
-- are invisible to the hash — they cannot cause divergence between clients
-- that hold different ghost records.
--
-- A player is a participant if ANY of the following are true:
--   • status == "Core"   → explicitly admitted by an officer
--   • ep > 0             → has earned at least 1 EP (attended a raid)
--   • gp > baseGP        → has received at least 1 item (GP was charged)
--
-- Callers MUST pass baseGP (from config.epgp.baseGP or 200) to avoid
-- a DB lookup on every iteration.
--
-- @param pData   Player data table from db.global.players
-- @param baseGP  Numeric base GP floor (e.g. Cerebro.db.global.config.epgp.baseGP or 200)
-- @return boolean
function Cerebro:IsParticipant(pData, baseGP)
	if not pData then return false end
	if pData.status == "Core" then return true end
	if (pData.ep or 0) > 0 then return true end
	if (pData.gp or baseGP) > baseGP then return true end
	return false
end

--[[=============================================
	Player Removal Helpers
	Shared logic for purge and block actions.
	All UI and command entry points should call these
	instead of duplicating the ledger/sync/audit logic.
===============================================]]

--- Purge a player: reset EP/GP, remove from roster, add to purge ledger.
-- Player will be re-discoverable if they rejoin a guild/group.
-- @param playerName  Full player name (e.g., "Player-Realm")
-- @return boolean    true if player was found and purged
function Cerebro:PurgePlayer(playerName)
	if not playerName then return false end

	-- Add to purge ledger
	self.db.global.purgedPlayers = self.db.global.purgedPlayers or {}
	self.db.global.purgedPlayers[playerName] = time()

	-- Remove from roster
	self.db.global.players[playerName] = nil

	-- Audit log
	if self.Ledger then
		self.Ledger:AddAuditLog("PLAYER_PURGE", "Purged: " .. playerName)
	end

	-- Broadcast purge RPC to other officers
	if self.modules.CommunicationV2 then
		self.modules.CommunicationV2:SendRPC("PURGE", {
			player = playerName,
			timestamp = time(),
		}, IsInRaid() and "RAID" or "GUILD")
		-- Also trigger a snapshot sync for purge ledger propagation
		self.modules.CommunicationV2:SyncToRaid()
	end

	-- Refresh UI
	self:RefreshAllUI()

	self:Print("|cffff8800Purged|r " .. playerName .. " — removed from roster. Will reappear if rediscovered in guild/group.")
	return true
end

--- Block a player: permanently remove and prevent re-sync/re-discovery.
-- Only reversible via /cb unblock.
-- @param playerName  Full player name (e.g., "Player-Realm")
-- @return boolean    true if player was blocked
function Cerebro:BlockPlayer(playerName)
	if not playerName then return false end

	-- Add to block ledger
	self.db.global.blockedPlayers = self.db.global.blockedPlayers or {}
	self.db.global.blockedPlayers[playerName] = time()

	-- Remove from roster
	if self.db.global.players[playerName] then
		self.db.global.players[playerName] = nil
	end

	-- Audit log
	if self.Ledger then
		self.Ledger:AddAuditLog("PLAYER_BLOCK", "Blocked: " .. playerName)
	end

	-- Refresh UI
	self:RefreshAllUI()

	self:Print("|cffff0000Blocked|r " .. playerName .. " — removed from database, sync will reject.")
	return true
end

--[[=============================================
	Delta Sync v2 - Dirty Tracking
	See docs/DELTA_SYNC_IMPLEMENTATION.md
===============================================]]

-- Mark player fields as dirty for delta sync
-- Only officers track dirties (raiders receive full snapshots from officers)
-- @param name: Full player name (e.g., "Player-Realm")
-- @param fields: Table of field names that changed (e.g., {"ep", "gp"})
function Cerebro:MarkPlayerDirty(name, fields)
	-- Only officers track dirty state
	if not self:IsOfficer() then return end
	
	local p = self.db.global.players[name]
	if not p then return end
	
	local s = self.db.global.syncState
	if not s then return end
	
	-- Increment global revision
	s.nextRev = (s.nextRev or 0) + 1
	p.rev = s.nextRev
	p.fieldRev = p.fieldRev or {}
	
	-- Mark each field with its own revision
	for _, f in ipairs(fields) do
		s.nextRev = s.nextRev + 1
		p.fieldRev[f] = s.nextRev
		
		-- Track dirty fields for next sync
		s.dirtyPlayers[name] = s.dirtyPlayers[name] or {}
		s.dirtyPlayers[name][f] = true
	end
end

-- Check if there are dirty entries waiting to sync
function Cerebro:HasDirtyPlayers()
	local s = self.db.global.syncState
	if not s or not s.dirtyPlayers then return false end
	return next(s.dirtyPlayers) ~= nil
end

-- Clear dirty entries for a player (after successful sync)
function Cerebro:ClearPlayerDirty(name)
	local s = self.db.global.syncState
	if s and s.dirtyPlayers then
		s.dirtyPlayers[name] = nil
	end
end

-- Clear all dirty entries (after full sync)
function Cerebro:ClearAllDirty()
	local s = self.db.global.syncState
	if s then
		s.dirtyPlayers = {}
	end
end

-- Receipt Cursor Tracking: Centralized reset for cursor state
-- Call on epoch upgrade, factory reset, helper restore, or scope change
-- See docs/proposals/Receipt_cursor_tracking_followup.md
function Cerebro:ResetReceiptCursorState(reason)
	self.db.global.receiptAckedBy = {}
	self.db.global.staleCursors = {}
	self.db.global.receiptSeq = 0
	if self.db.global.receiptSyncState then
		self.db.global.receiptSyncState.pendingBatches = {}
		self.db.global.receiptSyncState.lastResetAt = time()
	end
	self:Debug("Receipt cursor state reset: " .. (reason or "unknown"))
end

--[[=============================================
	Raid/Group Utilities
===============================================]]

-- Get all raid members
function Cerebro:GetRaidRoster()
	local roster = {}

	if not IsInRaid() then
		return roster
	end

	for i = 1, GetNumGroupMembers() do
		local name, rank, subgroup, level, _, fileName, zone, online, isDead, role, isML = GetRaidRosterInfo(i)

		if name then
			tinsert(roster, {
				name = self:GetFullPlayerName(name),
				rank = rank, -- 0 = member, 1 = assistant, 2 = leader
				subgroup = subgroup,
				level = level,
				class = fileName,
				zone = zone,
				online = online,
				isDead = isDead,
				role = role,
				isML = isML,
			})
		end
	end

	return roster
end

-- Check if player is in current raid
function Cerebro:IsPlayerInRaid(player)
	if not IsInRaid() then return false end

	player = self:GetFullPlayerName(player)

	for i = 1, GetNumGroupMembers() do
		local name = GetRaidRosterInfo(i)
		if name and self:GetFullPlayerName(name) == player then
			return true
		end
	end

	return false
end

--[[=============================================
	Item Upgrade Calculations
===============================================]]

-- Calculate if item is an upgrade
function Cerebro:CalculateUpgrade(player, itemLink)
	local itemInfo = self:GetItemInfo(itemLink)
	if not itemInfo then return nil end

	local slotID = self:GetSlotIDFromEquipLoc(itemInfo.equipLoc)
	if not slotID then return nil end

	-- Handle items with multiple slots (rings, trinkets)
	local slots = type(slotID) == "table" and slotID or {slotID}

	local bestUpgrade = {
		isUpgrade = false,
		ilvlDiff = 0,
		currentItem = nil,
		slot = nil,
	}

	for _, slot in ipairs(slots) do
		local currentItemLink = GetInventoryItemLink(player, slot)

		if not currentItemLink then
			-- Empty slot = definite upgrade
			bestUpgrade.isUpgrade = true
			bestUpgrade.ilvlDiff = itemInfo.ilvl
			bestUpgrade.slot = slot
			break
		else
			local currentItemInfo = self:GetItemInfo(currentItemLink)

			if currentItemInfo then
				local ilvlDiff = itemInfo.ilvl - currentItemInfo.ilvl

				if ilvlDiff > bestUpgrade.ilvlDiff then
					bestUpgrade.isUpgrade = ilvlDiff > 0
					bestUpgrade.ilvlDiff = ilvlDiff
					bestUpgrade.currentItem = currentItemLink
					bestUpgrade.slot = slot
				end
			end
		end
	end

	return bestUpgrade
end

--[[=============================================
	Color Utilities
===============================================]]

-- Quality color codes
local QUALITY_COLORS = {
	[0] = {r=0.62, g=0.62, b=0.62}, -- Poor (gray)
	[1] = {r=1.00, g=1.00, b=1.00}, -- Common (white)
	[2] = {r=0.12, g=1.00, b=0.00}, -- Uncommon (green)
	[3] = {r=0.00, g=0.44, b=0.87}, -- Rare (blue)
	[4] = {r=0.64, g=0.21, b=0.93}, -- Epic (purple)
	[5] = {r=1.00, g=0.50, b=0.00}, -- Legendary (orange)
	[6] = {r=0.90, g=0.80, b=0.50}, -- Artifact (gold)
	[7] = {r=0.00, g=0.80, b=1.00}, -- Heirloom (cyan)
}

function Cerebro:GetQualityColor(quality)
	return QUALITY_COLORS[quality] or QUALITY_COLORS[1]
end

function Cerebro:ColorizeText(text, r, g, b)
	if type(r) == "table" then
		b = r.b
		g = r.g
		r = r.r
	end

	return string.format("|cff%02x%02x%02x%s|r",
		(r or 1) * 255,
		(g or 1) * 255,
		(b or 1) * 255,
		text
	)
end

--[[=============================================
	Session ID Generation
===============================================]]

function Cerebro:GenerateSessionID(raidName)
	local timestamp = date("%Y-%m-%d")
	local counter = 1

	-- Find next available session ID for today
	while self.db.global.history[timestamp .. "-" .. string.format("%03d", counter)] do
		counter = counter + 1
	end

	return timestamp .. "-" .. string.format("%03d", counter)
end

--[[=============================================
	Sound Utilities
===============================================]]

function Cerebro:PlaySound(soundFile)
	if self.db.profile.notifications.playSound then
		PlaySound(soundFile or SOUNDKIT.RAID_WARNING)
	end
end

--[[=============================================
	Chat Output
===============================================]]

function Cerebro:SendRaidMessage(msg)
	if IsInRaid(LE_PARTY_CATEGORY_HOME) then
		self:SendChatMessageChunked(msg, "RAID")
	elseif IsInGroup(LE_PARTY_CATEGORY_HOME) then
		self:SendChatMessageChunked(msg, "PARTY")
	end
end

function Cerebro:SendOfficerMessage(msg)
	if IsInRaid(LE_PARTY_CATEGORY_HOME) then
		self:SendChatMessageChunked(msg, "OFFICER")
	end
end

-- Queue for messages that need to be sent after combat ends
Cerebro.pendingChatMessages = Cerebro.pendingChatMessages or {}
Cerebro._combatEndRegistered = false

function Cerebro:SendChatMessageChunked(msg, chatType)
	-- Check if we're in combat lockdown - SendChatMessage is protected
	if InCombatLockdown() then
		table.insert(self.pendingChatMessages, {msg = msg, chatType = chatType})
		self:Debug("Queued chat message (in combat): " .. msg:sub(1, 50))
		
		-- Register for combat end event if not already
		if not self._combatEndRegistered then
			self._combatEndRegistered = true
			self:RegisterEvent("PLAYER_REGEN_ENABLED", "FlushPendingChatMessages")
		end
		return
	end
	
	-- Not in combat, send immediately
	self:SendChatMessageDirect(msg, chatType)
end

-- Internal: Actually send the message (chunks if needed)
function Cerebro:SendChatMessageDirect(msg, chatType)
	local len = string.len(msg)
	local limit = 250
	
	if len <= limit then
		SendChatMessage(msg, chatType)
	else
		local subStart = 1
		while subStart <= len do
			local subEnd = subStart + limit - 1
			if subEnd > len then subEnd = len end
			
			local chunk = string.sub(msg, subStart, subEnd)
			SendChatMessage(chunk, chatType)
			subStart = subEnd + 1
		end
	end
end

-- Flush pending messages after combat ends
function Cerebro:FlushPendingChatMessages()
	self:UnregisterEvent("PLAYER_REGEN_ENABLED")
	self._combatEndRegistered = false
	
	local count = #self.pendingChatMessages
	if count > 0 then
		self:Debug("Flushing " .. count .. " queued chat messages")
		for _, entry in ipairs(self.pendingChatMessages) do
			self:SendChatMessageDirect(entry.msg, entry.chatType)
		end
		self.pendingChatMessages = {}
	end
end

--[[=============================================
	Season & Environment Tracking
	
	Provides utilities for tracking WoW Mythic+ seasons,
	calculating the current week of a season, and
	retrieving game version information.
===============================================]]

-- Hardcoded season start dates (UTC Unix timestamps at noon)
-- These are fallbacks when officer override is not set
-- Season IDs from C_MythicPlus.GetCurrentSeason()
--
-- EXPANSION CYCLE NOTE:
-- Between seasons (typically a few weeks), there's a pre-patch period before the 
-- new expansion releases. During this time, C_MythicPlus.GetCurrentSeason() returns 0.
-- After expansion launch (e.g., 12.0), Season 1 starts ~1 week later.
-- When a new season ID appears without a hardcoded date, CheckSeasonChange() will
-- prompt officers to set an override via /cb config.
--
Cerebro.SeasonStartDates = {
	-- The War Within (TWW) Seasons
	[13] = 1725969600,  -- TWW Season 1: Sep 10, 2024 12:00 UTC
	[14] = 1741089600,  -- TWW Season 2: Mar 4, 2025 12:00 UTC
	[15] = 1755000000,  -- TWW Season 3: Aug 12, 2025 12:00 UTC (ended Jan 2026 pre-patch)
	-- Midnight Expansion Seasons
	[17] = 1773993600,  -- Midnight Season 1: Mar 17, 2026 12:00 UTC
	-- Add future seasons here
}

-- Check if running on a live (release) realm vs PTR/Beta
function Cerebro:IsReleaseBuild()
	-- Primary check: WoW's native API (added in 6.0.2)
	-- This is the authoritative source - Blizzard sets this flag on PTR/Beta realms
	if IsTestBuild and IsTestBuild() then
		return false
	end
	
	-- NOTE: Removed hardcoded version/timestamp check (was for 12.0 Midnight)
	-- The 12.0 pre-patch launched Jan 2026, making the timestamp check invalid.
	-- Blizzard's IsTestBuild() API is the reliable source for PTR/Beta detection.
	
	-- Fallback: Check realm name patterns for PTR/Beta realms
	-- This catches edge cases where IsTestBuild might not be available
	local realmName = GetRealmName() or ""
	local testPatterns = {
		"^ptr",
		"^beta",
		"test realm",
		"development",
	}
	
	local lowerRealm = realmName:lower()
	for _, pattern in ipairs(testPatterns) do
		if lowerRealm:match(pattern) then
			return false
		end
	end
	
	return true
end

-- Resolve the season start timestamp using priority: override → hardcoded → nil
function Cerebro:ResolveSeasonStart()
	-- Skip on non-release builds
	if not self:IsReleaseBuild() then
		self:Debug("PTR/Beta detected — season tracking disabled")
		return nil
	end
	
	-- Priority 1: Officer override
	local override = self.db.global.config.epgp.seasonStartOverride
	if override and override > 0 then
		return override
	end
	
	-- Priority 2: Hardcoded lookup
	local seasonID = self:GetCurrentSeasonID()
	if seasonID and seasonID > 0 and self.SeasonStartDates[seasonID] then
		return self.SeasonStartDates[seasonID]
	end
	
	-- No start date available
	return nil
end

-- Get the current season ID with API initialization handling
function Cerebro:GetCurrentSeasonID()
	-- Ensure the API is initialized
	if C_MythicPlus and C_MythicPlus.RequestMapInfo then
		C_MythicPlus.RequestMapInfo()
	end
	
	if C_MythicPlus and C_MythicPlus.GetCurrentSeason then
		local seasonID = C_MythicPlus.GetCurrentSeason()
		-- -1 means API not ready yet
		if seasonID == -1 then
			return nil
		end
		return seasonID
	end
	
	return nil
end

-- Calculate the current week of the season (1-indexed)
function Cerebro:GetCurrentSeasonWeek()
	local seasonStart = self:ResolveSeasonStart()
	if not seasonStart then return nil end
	
	local now = time()
	if now < seasonStart then
		return nil  -- Before season start
	end
	
	-- Weeks are 1-indexed: Day 1-7 = Week 1
	local elapsed = now - seasonStart
	local week = math.floor(elapsed / 604800) + 1  -- 604800 = 7 * 24 * 60 * 60
	
	return week
end

-- Get comprehensive season information
-- Returns a table with seasonID, week, affixes, active status, etc.
function Cerebro:GetSeasonInfo()
	-- Handle PTR/Beta
	if not self:IsReleaseBuild() then
		return {
			seasonID = -1,
			week = nil,
			affixes = {},
			active = false,
			isPTR = true
		}
	end
	
	local seasonID = self:GetCurrentSeasonID()
	
	-- Handle API not ready
	if seasonID == nil then
		return {
			seasonID = -1,
			week = nil,
			affixes = {},
			active = false
		}
	end
	
	-- Handle pre-patch period (no active season, between expansions)
	-- seasonID = 0 means M+ season has ended but new one hasn't started
	if seasonID == 0 then
		return {
			seasonID = 0,
			week = nil,
			affixes = {},
			active = false,
			isPrepatch = true
		}
	end
	
	-- PRE-PATCH DETECTION: TOC-based override
	-- If running WoW 12.0+ (TOC >= 120000) but season API returns a TWW season (<=16),
	-- we're in the Midnight pre-patch and the API hasn't updated yet.
	-- Treat this as pre-patch until Midnight Season 1 (17) starts.
	local _, _, _, toc = GetBuildInfo()
	if toc and toc >= 120000 and seasonID <= 16 then
		-- Midnight launched but M+ season hasn't started (Season 17 starts Mar 17, 2026)
		return {
			seasonID = seasonID,  -- Keep actual seasonID for tracking
			week = nil,
			affixes = {},
			active = false,
			isPrepatch = true,
			prepatchReason = "WoW 12.0 detected but Season 17 not yet active"
		}
	end
	
	-- Get affixes
	local affixes = {}
	if C_MythicPlus and C_MythicPlus.GetCurrentAffixes then
		local affixInfo = C_MythicPlus.GetCurrentAffixes()
		if affixInfo then
			for _, affix in ipairs(affixInfo) do
				table.insert(affixes, affix.id)
			end
		end
	end
	
	-- Resolve season start and calculate week
	local seasonStart = self:ResolveSeasonStart()
	local now = time()
	
	-- Before season start
	if seasonStart and now < seasonStart then
		return {
			seasonID = seasonID,
			week = nil,
			affixes = affixes,
			active = false,
			beforeStart = true,
			startsIn = seasonStart - now
		}
	end
	
	-- Active season
	local week = self:GetCurrentSeasonWeek()
	
	return {
		seasonID = seasonID,
		week = week,
		affixes = affixes,
		active = true
	}
end

-- Get game version and build information
function Cerebro:GetGameVersion()
	local version, build, buildDate, toc = GetBuildInfo()
	
	return {
		version = version or "Unknown",
		build = build or "0",
		date = buildDate or "Unknown",
		toc = toc or 0,
		isRelease = self:IsReleaseBuild()
	}
end

-- Check for season changes and handle override clearing
-- Should be called on PLAYER_ENTERING_WORLD or periodically
function Cerebro:CheckSeasonChange()
	-- Ensure API is ready
	if C_MythicPlus and C_MythicPlus.RequestMapInfo then
		C_MythicPlus.RequestMapInfo()
	end
	
	local currentID = self:GetCurrentSeasonID()
	local _, _, _, toc = GetBuildInfo()
	
	-- Guard: API not ready
	if currentID == nil then
		return  -- API not ready
	end
	
	-- PRE-PATCH RESET: TOC-based detection
	-- If WoW 12.0+ (TOC >= 120000) and we haven't processed the pre-patch reset yet,
	-- trigger the reset now regardless of what season the API reports.
	local storedTOC = self.db.global.lastKnownTOC or 0
	if toc and toc >= 120000 and storedTOC < 120000 then
		-- First login after Midnight pre-patch!
		self:Debug("Pre-patch detected via TOC upgrade: " .. storedTOC .. " -> " .. toc)
		self.db.global.lastKnownTOC = toc
		
		-- Force treat this as a season transition to trigger reset
		local storedID = self.db.global.lastKnownSeasonID or 0
		if storedID > 0 then
			-- We had a valid season before, trigger reset
			self:Print("|cffff8800[Pre-Patch Detected]|r WoW 12.0 detected, triggering season reset...")
			-- Continue to season reset logic below by NOT returning
			currentID = 0  -- Force pre-patch state for reset logic
		else
			-- No previous season data, just record and return
			self.db.global.lastKnownSeasonID = currentID
			return
		end
	else
		-- Update stored TOC
		if toc then
			self.db.global.lastKnownTOC = toc
		end
	end
	
	-- Handle pre-patch: seasonID = 0 means M+ season ended
	-- This is a valid state transition that should trigger reset
	local storedID = self.db.global.lastKnownSeasonID or 0
	
	-- Guard: only log on actual change, not repeated logins
	if currentID == storedID then
		return  -- No change
	end
	
	-- Guard: skip if going from unknown (-1 or nil stored) to 0
	-- This prevents false resets on first login during pre-patch
	if storedID <= 0 and currentID == 0 then
		self.db.global.lastKnownSeasonID = currentID
		return
	end
	
	-- Season changed!
	self.db.global.lastKnownSeasonID = currentID
	
	-- ============================================
	-- SEASON RESET: Clear local data (ALL clients)
	-- ============================================
	
	-- 1. Clear personal M+ history
	local mplusCount = self.db.global.mplusRuns and #self.db.global.mplusRuns or 0
	self.db.global.mplusRuns = {}
	
	-- 2. Clear readiness tracking
	if self.db.global.readiness then
		self.db.global.readiness.players = {}
		self.db.global.readiness.week = nil
	end
	
	self:Debug(string.format("Season reset: cleared %d local M+ runs + readiness", mplusCount))
	
	-- ============================================
	-- OFFICER ONLY: Epoch reset + EP/GP wipe + Attendance reset
	-- ============================================
	if self:IsOfficer() then
		-- Fix 3: Sentinel guard — prevents re-running the reset on /reload or
		-- any login where the snapshot hasn't had time to propagate lastKnownSeasonID.
		-- Type-guarded to be safe against corrupt SavedVar values.
		if type(self.db.global.lastSeasonReset) == "table" and
		   self.db.global.lastSeasonReset.seasonID == currentID then
			self:Debug(string.format("CheckSeasonChange: already reset for season %d (sentinel fired)", currentID))
			return
		end

		-- Fix 4b: Peer-epoch collision guard — if any known officer peer already sits
		-- at (localEpoch + 1), they performed the season reset before us. Doing a
		-- second independent reset on the same epoch would trigger HandleEpochMismatch's
		-- wipe+FETCH path on peers still at N, delivering an empty roster.
		-- Skip the EP/GP wipe and epoch bump; let heartbeat deliver their state.
		local localEpoch = self.db.global.resetEpoch or 0
		local peerHashes = ((self.db.global.syncStateV2 or {}).peerHashes) or {}
		for peerName, peerData in pairs(peerHashes) do
			if (peerData.epoch or 0) == (localEpoch + 1) and self:IsOfficer(peerName) then
				self:Debug(string.format(
					"CheckSeasonChange: peer %s already at epoch %d — skipping independent reset, heartbeat will sync",
					peerName, peerData.epoch))
				-- Still write sentinel so we don't re-enter on next /reload
				self.db.global.lastSeasonReset = { seasonID = currentID, resetAt = time() }
				return
			end
		end

		local baseGP = self.db.global.config.epgp.baseGP or 1000
		local playerCount = 0
		
		-- Reset EP/GP and 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.bossKillsPresent = 0  -- Reset attendance counter
				data.lastUpdate = time()  -- Mark as updated for LWW sync
				playerCount = playerCount + 1
			end
		end
		
		-- Reset global season attendance counters
		self.db.global.seasonBossKills = 0
		self.db.global.seasonID = currentID
		
		-- Clear guild M+ history
		local guildMplusCount = self.db.global.guildMplusRuns and #self.db.global.guildMplusRuns or 0
		self.db.global.guildMplusRuns = {}
		
		-- Increment epoch (authoritative reset)
		self.db.global.resetEpoch = (self.db.global.resetEpoch or 0) + 1
		
		-- Audit log
		if self.Ledger and self.Ledger.AddAuditLog then
			self.Ledger:AddAuditLog("SEASON_RESET",
				string.format("Season %d = %d. Reset %d players (EP=0, GP=%d, attendance=0), cleared %d guild M+ runs. Epoch=%d",
					storedID, currentID, playerCount, baseGP, guildMplusCount, self.db.global.resetEpoch))
			self.Ledger:AddAuditLog("ATTENDANCE_RESET", "Season changed - all attendance counters reset to 0")
		end
		
		self:Print(string.format("|cffff8800[Season Reset]|r Season %d = %d", storedID, currentID))
		self:Print(string.format("  - Reset %d players (EP = 0, GP = %d, Attendance = 0)", playerCount, baseGP))
		self:Print(string.format("  - Cleared %d M+ runs, Epoch = %d", mplusCount + guildMplusCount, self.db.global.resetEpoch))
		
		-- Trigger sync to broadcast new epoch
		local comm = self:GetModule("Communication", true)
		if comm and comm.SyncToRaid then
			comm:SyncToRaid()
		end

		-- Fix 3: Write sentinel AFTER reset so subsequent logins/reloads skip it
		self.db.global.lastSeasonReset = { seasonID = currentID, resetAt = time() }
	else
		-- Non-officer message
		self:Print(string.format("|cff00ff00[Season Change]|r Season %d = %d. Cleared %d local M+ runs.", storedID, currentID, mplusCount))
		self:Print("|cff888888Waiting for officer sync to reset standings.|r")
	end
	
	-- Clear and log override removal
	if self.db.global.config.epgp.seasonStartOverride then
		local oldOverride = self.db.global.config.epgp.seasonStartOverride
		self.db.global.config.epgp.seasonStartOverride = nil
		
		-- Audit log entry
		if self.Ledger and self.Ledger.AddAuditLog then
			local oldDate = date("%Y-%m-%d", oldOverride)
			self.Ledger:AddAuditLog(
				"SEASON_CHANGE",
				string.format("Season changed %d → %d. Override cleared (was %s).", storedID, currentID, oldDate)
			)
		end
	end
	
	-- Check if hardcoded exists for the new season
	if not self.SeasonStartDates[currentID] then
		self:Print("|cffff8800No start date known for Season " .. currentID .. ".|r")
		self:Print("Officers: Set override via /cb config → Mythic+ → Season Start Override")
	end
end

--[[=============================================
	Realm Normalization (Guild Scope Support)
	
	These functions provide consistent realm name handling
	for Guild Scope verification and player name parsing.
===============================================]]

-- Get normalized realm name for current character
-- Used when setting scope or verifying against scope
function Cerebro:GetNormalizedRealm()
	-- Prefer the WoW API normalized version (handles connected realms)
	if GetNormalizedRealmName then
		local normalized = GetNormalizedRealmName()
		if normalized and normalized ~= "" then
			return normalized
		end
	end
	
	-- Fallback: strip spaces and hyphens from standard realm name
	local realm = GetRealmName()
	if realm then
		return realm:gsub(" ", ""):gsub("-", "")
	end
	
	return "Unknown"
end

-- Normalize a realm string (for consistency when parsing player keys)
-- This applies the same logic as GetNormalizedRealm() but to an arbitrary string
function Cerebro:NormalizeRealmString(realmStr)
	if not realmStr then return nil end
	-- Strip spaces and hyphens (same as GetNormalizedRealm fallback)
	return realmStr:gsub(" ", ""):gsub("-", "")
end

-- Extract and normalize realm from "Name-Realm" format
function Cerebro:ExtractRealmFromName(fullName)
	if not fullName then return nil end
	local _, realm = strsplit("-", fullName)
	if realm then
		return self:NormalizeRealmString(realm)
	end
	return nil
end

--[[=============================================
	Version Comparison Utilities
	
	Used for minimum version enforcement and
	transaction logicVersion validation.
===============================================]]

-- Compare two semantic version strings
-- Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
-- Handles: "0.7.15", "v0.7.15", "0.7.15_dev", nil
function Cerebro:CompareVersions(v1, v2)
	-- Handle nil values (nil is always "less than" a real version)
	if not v1 and not v2 then return 0 end
	if not v1 then return -1 end
	if not v2 then return 1 end
	
	-- Strip 'v' prefix if present
	v1 = tostring(v1):gsub("^v", "")
	v2 = tostring(v2):gsub("^v", "")
	
	-- Strip suffixes like "_dev", "-beta", etc. for comparison
	v1 = v1:gsub("[_-].*$", "")
	v2 = v2:gsub("[_-].*$", "")
	
	-- Parse into parts
	local parts1 = {strsplit(".", v1)}
	local parts2 = {strsplit(".", v2)}
	
	-- Compare each part numerically
	local maxParts = math.max(#parts1, #parts2)
	for i = 1, maxParts do
		local n1 = tonumber(parts1[i]) or 0
		local n2 = tonumber(parts2[i]) or 0
		
		if n1 < n2 then return -1 end
		if n1 > n2 then return 1 end
	end
	
	return 0  -- Equal
end

--[[=============================================
	Synthetic Test Player Detection
	
	Detects test/synthetic player entries that should not
	exist in production databases. Used by sync filtering
	and purgetest command.
===============================================]]

-- Standard test patterns (prefix-based)
Cerebro.SyntheticPlayerPrefixes = {
	"^Test",
	"^Testdps",
	"^TestDPS",
	"^TestTank",
	"^TestHealer",
}

-- WoW class names (these can NEVER be valid character names)
-- If a player's name is exactly a class name, it's synthetic test data
Cerebro.WoWClassNames = {
	["Druid"] = true,
	["Hunter"] = true,
	["Mage"] = true,
	["Paladin"] = true,
	["Priest"] = true,
	["Rogue"] = true,
	["Shaman"] = true,
	["Warlock"] = true,
	["Warrior"] = true,
	["Monk"] = true,
	["Deathknight"] = true,  -- Some test data uses this format
	["DeathKnight"] = true,  -- Alternative casing
	["Demonhunter"] = true,  -- Some test data uses this format
	["DemonHunter"] = true,  -- Alternative casing
	["Evoker"] = true,
}

-- WoW reserved system words (cannot be character names)
-- Additive to WoWClassNames (classes already covered above)
Cerebro.WoWReservedNames = {
	-- System/UI terms blocked by Blizzard
	["Session"] = true,
	["Unknown"] = true,
	["Player"] = true,
	["Admin"] = true,
	["Moderator"] = true,
	-- Authority titles
	["GM"] = true,
	["King"] = true,
	["Queen"] = true,
	-- Major lore NPCs (always blocked by Blizzard)
	["Thrall"] = true,
	["Jaina"] = true,
	["Arthas"] = true,
	["Illidan"] = true,
	["Sylvanas"] = true,
	["Garrosh"] = true,
	["Anduin"] = true,
	["Bolvar"] = true,
}

-- Check if a player name matches synthetic test patterns
-- @param name Full player name (Name-Realm format)
-- @return boolean True if this is a synthetic test player
function Cerebro:IsSyntheticTestPlayer(name)
	if not name then return false end
	
	local shortName = self:GetShortPlayerName(name)
	if not shortName then return false end
	
	-- Check 1: Is the name exactly a WoW class name? (e.g., "Warrior-Stormrage")
	-- WoW doesn't allow class names as character names, so this is always synthetic
	if self.WoWClassNames[shortName] then
		return true
	end
	
	-- Check 2: Is the name a WoW-reserved word? (e.g., "Session-Stormrage")
	-- WoW blocks system terms, authority titles, and major lore NPCs
	if self.WoWReservedNames and self.WoWReservedNames[shortName] then
		return true
	end
	
	-- Check 3: Does the name match a test prefix pattern?
	for _, pattern in ipairs(self.SyntheticPlayerPrefixes) do
		if shortName:match(pattern) then
			return true
		end
	end
	
	return false
end


