--[[
	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 from item link
function Cerebro:GetItemIDFromLink(itemLink)
	if not itemLink then return nil end
	local itemID = strmatch(itemLink, "item:(%d+)")
	return tonumber(itemID)
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 = "Token",
}

-- 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)" },
}

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

-- 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
		return s:sub(1,1):upper() .. s:sub(2):lower()
	end

	-- Check for existing realm
	local n, r = strsplit("-", name)
	
	if r then
		-- Already has realm, normalize both parts
		return TitleCase(n) .. "-" .. r -- Keep realm as provided (usually usually safe, or could TitleCase r too)
	else
		-- Add current realm
		return TitleCase(name) .. "-" .. GetRealmName()
	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
		local ep = pData.ep or 0
		local gp = math.max(pData.gp or baseGP, baseGP)
		pData.pr = tonumber(string.format("%.4f", ep / gp))
	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)
-- Used before sending sync data to prevent polluting other clients
function Cerebro:GetFilteredPlayersForSync()
	local filtered = {}
	for name, pData in pairs(self.db.global.players) do
		if not self:IsSyntheticTestPlayer(name) then
			filtered[name] = pData
		end
	end
	return filtered
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

function Cerebro:SendChatMessageChunked(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

--[[=============================================
	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
	[16] = 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 (<=15),
	-- we're in the Midnight pre-patch and the API hasn't updated yet.
	-- Treat this as pre-patch until Midnight Season 1 (16) starts.
	local _, _, _, toc = GetBuildInfo()
	if toc and toc >= 120000 and seasonID <= 15 then
		-- Midnight launched but M+ season hasn't started (Season 16 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 16 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
		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
	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,
}

-- 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: 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


