--[[
	Cerebro - Config Schema (Modernized)
	
	PURPOSE:
	Single source of truth for ALL configuration options. This schema drives:
	- Blizzard Interface Options UI generation (via Core:SetupOptions)
	- Validation and normalization of all config writes
	- RBAC gating for officer-only settings
	
	DESIGN DECISIONS:
	1. DB Path Semantics: Keys use implicit "db.global" prefix (e.g., "config.epgp.baseGP")
	2. RBAC Gating: officerOnly settings are HIDDEN by default (not just disabled)
	3. Validation: min/max violations are CLAMPED (not rejected)
	4. isPercent: Display 0–100, storage format declared per-item (normalized=0–1 or raw=0–100)
	5. Output: Deterministic sorting by category→group→item order
]]--

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

local ConfigSchema = {}

--[[=============================================
	SCHEMA DEFINITION
	
	Each item:
	- id: unique stable identifier
	- key: db path (e.g., "config.epgp.baseGP")
	- label: display name
	- type: "bool" | "number" | "string" | "select"
	- category: top-level tab name
	- order: numeric sort key
	
	Optional:
	- group: subsection header
	- desc: tooltip text
	- min, max, step: numeric bounds
	- isPercent: if true, display as 0–100
	- percentStorage: "normalized" (0–1) or "raw" (0–100)
	- officerOnly: requires officer rank
	- officerOnlyMode: "hidden" (default) or "disabled"
	- namespace: "global" (default) or "profile"
	- choices: {value=label} for select type
	- advanced: hide in advanced section
===============================================]]--

local schema = {
	-- GENERAL CATEGORY
	{
		id = "debug_level",
		key = "config.debugLevel",
		label = "Debug Level",
		desc = "Controls debug message verbosity in chat (local only, not synced)",
		type = "select",
		choices = {off = "Off", simple = "Simple", technical = "Technical", both = "Both"},
		category = "General",
		order = 1,
		officerOnly = false,
	},
	{
		id = "use_new_ui",
		key = "config.ui.useNewUI",
		label = "Use New UI (Experimental)",
		desc = nil, -- Removed per user request
		type = "bool",
		category = "General",
		order = 2,
		officerOnly = false,
	},
	{
		id = "tooltip_mode",
		key = "config.ui.tooltipMode",
		label = "Show GP on Tooltips",
		desc = "Control when GP values appear on item tooltips.",
		type = "select",
		choices = {
			["always"] = "Always",
			["raid"] = "Raid Only",
			["never"] = "Never",
		},
		category = "General",
		order = 3,
		officerOnly = false,
	},
	
	{
		id = "show_item_ids",
		key = "config.ui.showItemIDs",
		label = "Show Item IDs on Tooltips",
		desc = "Display Item ID, Enchant ID, and Gem IDs on item tooltips (useful for prep-check debugging).",
		type = "bool",
		category = "General",
		order = 4,
		officerOnly = false,
	},
	
	-- ADMIN CATEGORY - System Configuration
	{
		id = "epgp_baseGP",
		key = "config.epgp.baseGP",
		label = "Base GP",
		desc = "Minimum GP floor for all players",
		type = "number",
		category = "Admin",
		group = "System Configuration",
		order = 90,
		min = 0,
		max = 10000,
		step = 10,
		officerOnly = true,
	},
	{
		id = "epgp_decayRate",
		key = "config.epgp.decayRate",
		label = "Weekly Decay Rate",
		desc = "Percentage to decay EP and GP each week",
		type = "number",
		category = "Admin",
		group = "System Configuration",
		order = 91,
		min = 0,
		max = 0.50,
		step = 0.01,
		isPercent = true,
		percentStorage = "normalized", -- stored as 0–1
		officerOnly = true,
	},
	
	-- EP AWARDS CATEGORY
	{
		id = "epgp_onTimeGracePeriod",
		key = "config.epgp.onTimeGracePeriod",
		label = "On-Time Grace Period",
		desc = "Minutes after session start where arrivals count as on-time",
		type = "number",
		category = "EP Awards",
		order = 10,
		min = 0,
		max = 30,
		step = 1,
		officerOnly = true,
	},
	{
		id = "epgp_epOnTime",
		key = "config.epgp.epOnTime",
		label = "Raid Start EP",
		desc = "EP awarded at session start (after grace period)",
		type = "number",
		category = "EP Awards",
		order = 11,
		min = 0,
		max = 1000,
		step = 10,
		officerOnly = true,
	},
	{
		id = "epgp_epFarmBoss",
		key = "config.epgp.epFarmBoss",
		label = "Farm Boss EP",
		desc = "EP awarded per farm boss kill",
		type = "number",
		category = "EP Awards",
		order = 12,
		min = 0,
		max = 1000,
		step = 10,
		officerOnly = true,
	},
	{
		id = "epgp_epProgBoss",
		key = "config.epgp.epProgBoss",
		label = "Prog Boss EP",
		desc = "EP awarded per progression boss kill",
		type = "number",
		category = "EP Awards",
		order = 13,
		min = 0,
		max = 1000,
		step = 10,
		officerOnly = true,
	},
	{
		id = "epgp_epFullAttendance",
		key = "config.epgp.epFullAttendance",
		label = "Raid End EP",
		desc = "EP awarded when the session ends (full attendance bonus)",
		type = "number",
		category = "EP Awards",
		order = 14,
		min = 0,
		max = 1000,
		step = 10,
		officerOnly = true,
	},
	{
		id = "epgp_epProgressionHourly",
		key = "config.epgp.epProgressionHourly",
		label = "Progression Interval EP",
		desc = "EP awarded every progression tick during a session",
		type = "number",
		category = "EP Awards",
		order = 15,
		min = 0,
		max = 1000,
		step = 10,
		officerOnly = true,
	},
	{
		id = "epgp_progressionIntervalMin",
		key = "config.epgp.progressionIntervalMin",
		label = "Progression Interval (min)",
		desc = "Minutes between each progression EP tick. Takes effect on next session start.",
		type = "number",
		category = "EP Awards",
		order = 14,
		min = 5,
		max = 120,
		step = 5,
		officerOnly = true,
	},
	{
		id = "epgp_benchMultiplier",
		key = "config.epgp.benchMultiplier",
		label = "Bench Multiplier",
		desc = "EP multiplier for benched raiders (e.g. 50 = 50%, 200 = 200%)",
		type = "number",
		category = "EP Awards",
		order = 16,
		min = 0,
		step = 0.05,
		isPercent = true,
		percentStorage = "normalized",
		officerOnly = true,
	},
	{
		id = "epgp_epSignUp",
		key = "config.epgp.epSignUp",
		label = "Sign-Up EP",
		desc = "EP awarded for qualifying calendar event sign-ups",
		type = "number",
		category = "EP Awards",
		order = 17,
		min = 0,
		max = 1000,
		step = 10,
		officerOnly = true,
	},
	
	-- GP MODIFIERS CATEGORY
	-- This is controlled in RCLC menu's
	--[[
	{
		id = "epgp_offspecMultiplier",
		key = "config.epgp.offspecMultiplier",
		label = "Off-Spec Multiplier",
		desc = "GP multiplier for off-spec items",
		type = "number",
		category = "GP Modifiers",
		order = 10,
		min = 0,
		max = 1.0,
		step = 0.05,
		isPercent = true,
		percentStorage = "normalized",
		officerOnly = true,
	},
	{
		id = "epgp_minorUpgradeMultiplier",
		key = "config.epgp.minorUpgradeMultiplier",
		label = "Minor Upgrade Multiplier",
		desc = "GP multiplier for minor upgrades",
		type = "number",
		category = "GP Modifiers",
		order = 11,
		min = 0,
		max = 1.0,
		step = 0.05,
		isPercent = true,
		percentStorage = "normalized",
		officerOnly = true,
	},
	{
		id = "epgp_minorUpgradeIlvlDelta",
		key = "config.epgp.minorUpgradeIlvlDelta",
		label = "Minor Upgrade iLvl Delta",
		desc = "Max iLvl difference to consider as minor upgrade",
		type = "number",
		category = "GP Modifiers",
		order = 12,
		min = 0,
		max = 50,
		step = 1,
		officerOnly = true,
	},
	]]--
	
	-- Difficulty Multipliers: These values scale the base GP cost based on drop difficulty.
	{
		id = "epgp_diffMult_normal",
		key = "config.epgp.difficultyMultipliers.14", -- String key "14"
		label = "Normal Multiplier",
		desc = "Percentage of Base GP for Normal items.",
		type = "number",
		category = "GP Modifiers",
		order = 20,
		min = 0,
		max = 5.0,
		step = 0.05,
		isPercent = true,
		percentStorage = "normalized",
		officerOnly = true,
	},
	{
		id = "epgp_diffMult_heroic",
		key = "config.epgp.difficultyMultipliers.15", -- String key "15"
		label = "Heroic Multiplier",
		desc = "Percentage of Base GP for Heroic items.",
		type = "number",
		category = "GP Modifiers",
		order = 21,
		min = 0,
		max = 5.0,
		step = 0.05,
		isPercent = true,
		percentStorage = "normalized",
		officerOnly = true,
	},
	{
		id = "epgp_diffMult_mythic",
		key = "config.epgp.difficultyMultipliers.16", -- String key "16"
		label = "Mythic Multiplier",
		desc = "Percentage of Base GP for Mythic items.",
		type = "number",
		category = "GP Modifiers",
		order = 22,
		min = 0,
		max = 5.0,
		step = 0.05,
		isPercent = true,
		percentStorage = "normalized",
		officerOnly = true,
	},
	
	-- MYTHIC+ CATEGORY
	{
		id = "mplus_base",
		key = "config.epgp.mplusBase",
		label = "M+ Base EP",
		desc = "Base EP for completing a Mythic+ dungeon",
		type = "number",
		category = "Mythic+",
		order = 40,
		min = 0,
		max = 1000,
		step = 10,
		officerOnly = true,
	},
	{
		id = "mplus_guildBonus2",
		key = "config.epgp.mplusGuildBonus2",
		label = "Group: 2 Guild Members",
		desc = "Enter a percentage (e.g. '10%') or a flat value (e.g. '50') for exactly 2 guild members.",
		type = "string",
		category = "Mythic+",
		order = 41,
		officerOnly = true,
	},
	{
		id = "mplus_guildBonus3",
		key = "config.epgp.mplusGuildBonus3",
		label = "Group: 3 Guild Members",
		desc = "Enter a percentage (e.g. '15%') or a flat value (e.g. '50') for exactly 3 guild members.",
		type = "string",
		category = "Mythic+",
		order = 42,
		officerOnly = true,
	},
	{
		id = "mplus_guildBonus4",
		key = "config.epgp.mplusGuildBonus4",
		label = "Group: 4 Guild Members",
		desc = "Enter a percentage (e.g. '20%') or a flat value (e.g. '50') for exactly 4 guild members.",
		type = "string",
		category = "Mythic+",
		order = 43,
		officerOnly = true,
	},
	{
		id = "mplus_guildBonus5",
		key = "config.epgp.mplusGuildBonus5",
		label = "Group: 5 Guild Members (Full)",
		desc = "Enter a percentage (e.g. '25%') or a flat value (e.g. '50') for 5 guild members.",
		type = "string",
		category = "Mythic+",
		order = 44,
		officerOnly = true,
	},
	{
		id = "mplus_perLevel",
		key = "config.epgp.mplusPerLevel",
		label = "EP per Level",
		desc = "EP awarded for each keystone level completion.",
		type = "number",
		category = "Mythic+",
		order = 45,
		min = 0,
		max = 100,
		step = 1,
		officerOnly = true,
	},
	{
		id = "mplus_minLevel",
		key = "config.epgp.mplusMinLevel",
		label = "Min M+ Level",
		desc = "Minimum keystone level required to earn EP. Runs below this level award 0 EP.",
		type = "number",
		category = "Mythic+",
		order = 46,
		min = 1,
		max = 30,
		step = 1,
		officerOnly = true,
	},
	{
		id = "mplus_maxLevel",
		key = "config.epgp.mplusMaxLevel",
		label = "Max M+ Level",
		desc = "Maximum keystone level for EP scaling. Runs above this level are capped at this value.",
		type = "number",
		category = "Mythic+",
		order = 47,
		min = 1,
		max = 50,
		step = 1,
		officerOnly = true,
	},
	{
		id = "mplus_readinessCap",
		key = "config.epgp.readinessCap",
		label = "Readiness Cap",
		desc = "Maximum readiness EP per week",
		type = "number",
		category = "Mythic+",
		order = 48,
		min = 0,
		max = 10000,
		step = 100,
		officerOnly = true,
	},
	{
		id = "season_start_override",
		key = "config.epgp.seasonStartOverride",
		label = "Season Start Override",
		desc = "Override season start date (YYYY-MM-DD). Stored as UTC timestamp.",
		type = "string",
		category = "Mythic+",
		order = 49,
		officerOnly = true,
	},
	
	-- PREPARATION CATEGORY
	-- Ordered as: master toggles, then paired checkbox → EP input per type
	{
		id = "prep_allOrNothing",
		key = "config.prep.allOrNothing",
		label = "All or Nothing",
		desc = "When checked, all other checkboxes are pass or fail. This is a master check for guilds who want perfection or you get nothing.",
		type = "bool",
		category = "Preparation",
		order = 49,
		officerOnly = true,
	},
	-- Average iLvl: toggle → threshold → EP
	{
		id = "prep_requireMinIlvl",
		key = "config.prep.requireMinIlvl",
		label = "Require Min Avg iLvl",
		desc = "When checked, players whose average equipped item level is below the threshold will be flagged and will not receive the iLvl EP bonus.",
		type = "bool",
		category = "Preparation",
		order = 50,
		officerOnly = true,
	},
	{
		id = "prep_minIlvl",
		key = "config.prep.minIlvl",
		label = "  Min Avg iLevel",
		desc = "Minimum average equipped item level threshold",
		type = "number",
		category = "Preparation",
		order = 51,
		min = 0,
		max = 1000,
		step = 1,
		officerOnly = true,
		dimmedWhen = "config.prep.requireMinIlvl",
	},
	{
		id = "prep_minIlvlEP",
		key = "config.prep.minIlvlEP",
		label = "  iLvl EP",
		desc = "EP awarded for meeting the minimum average item level",
		type = "number",
		category = "Preparation",
		order = 52,
		min = 0,
		max = 1000,
		step = 1,
		officerOnly = true,
		dimmedWhen = "config.prep.requireMinIlvl",
	},
	-- Gems: toggle → EP
	{
		id = "prep_requireGems",
		key = "config.prep.requireGems",
		label = "Require Gems",
		desc = nil,
		type = "bool",
		category = "Preparation",
		order = 55,
		officerOnly = true,
	},
	{
		id = "prep_gemEPPerSocket",
		key = "config.prep.gemEPPerSocket",
		label = "  Gem EP Per Socket",
		desc = "EP awarded per valid gem",
		type = "number",
		category = "Preparation",
		order = 56,
		min = 0,
		max = 50,
		step = 1,
		officerOnly = true,
		dimmedWhen = "config.prep.requireGems",
	},
	-- Enchants: toggle → EP
	{
		id = "prep_requireEnchants",
		key = "config.prep.requireEnchants",
		label = "Require Enchants",
		desc = nil,
		type = "bool",
		category = "Preparation",
		order = 57,
		officerOnly = true,
	},
	{
		id = "prep_enchantEPPerSlot",
		key = "config.prep.enchantEPPerSlot",
		label = "  Enchant EP Per Slot",
		desc = "EP awarded per valid enchant",
		type = "number",
		category = "Preparation",
		order = 58,
		min = 0,
		max = 50,
		step = 1,
		officerOnly = true,
		dimmedWhen = "config.prep.requireEnchants",
	},
	-- Food: toggle → EP
	{
		id = "prep_requireConsumables",
		key = "config.prep.requireFood",
		label = "Require Food",
		desc = "Require Well Fed food buff",
		type = "bool",
		category = "Preparation",
		order = 59,
		officerOnly = true,
	},
	{
		id = "prep_foodEP",
		key = "config.prep.foodEP",
		label = "  Food EP",
		desc = "EP awarded for having a valid food buff",
		type = "number",
		category = "Preparation",
		order = 60,
		min = 0,
		max = 50,
		step = 1,
		officerOnly = true,
		dimmedWhen = "config.prep.requireFood",
	},
	-- Flask: toggle → EP
	{
		id = "prep_requireFlask",
		key = "config.prep.requireFlask",
		label = "Require Flask",
		desc = "Require combat Flask buff",
		type = "bool",
		category = "Preparation",
		order = 61,
		officerOnly = true,
	},
	{
		id = "prep_flaskEP",
		key = "config.prep.flaskEP",
		label = "  Flask EP",
		desc = "EP awarded for having a valid flask",
		type = "number",
		category = "Preparation",
		order = 62,
		min = 0,
		max = 50,
		step = 1,
		officerOnly = true,
		dimmedWhen = "config.prep.requireFlask",
	},
	-- Augment Rune: toggle → EP
	{
		id = "prep_requireAugmentRune",
		key = "config.prep.requireAugmentRune",
		label = "Require Augment Rune",
		desc = "Require Augment Rune buff",
		type = "bool",
		category = "Preparation",
		order = 63,
		officerOnly = true,
	},
	{
		id = "prep_augmentRuneEP",
		key = "config.prep.augmentRuneEP",
		label = "  Augment Rune EP",
		desc = "EP awarded for having an augment rune",
		type = "number",
		category = "Preparation",
		order = 64,
		min = 0,
		max = 50,
		step = 1,
		officerOnly = true,
		dimmedWhen = "config.prep.requireAugmentRune",
	},
	-- Vantus Rune: toggle → EP
	{
		id = "prep_requireVantusRune",
		key = "config.prep.requireVantusRune",
		label = "Require Vantus Rune",
		desc = "Require Vantus Rune buff (weekly, boss-specific)",
		type = "bool",
		category = "Preparation",
		order = 65,
		officerOnly = true,
	},
	{
		id = "prep_vantusRuneEP",
		key = "config.prep.vantusRuneEP",
		label = "  Vantus Rune EP",
		desc = "EP awarded for having a vantus rune",
		type = "number",
		category = "Preparation",
		order = 66,
		min = 0,
		max = 50,
		step = 1,
		officerOnly = true,
		dimmedWhen = "config.prep.requireVantusRune",
	},
	-- Weapon Enhancement: toggle → EP
	{
		id = "prep_requireWeaponEnhancement",
		key = "config.prep.requireWeaponEnhancement",
		label = "Require Weapon Enhancement",
		desc = "Require weapon oil or stone",
		type = "bool",
		category = "Preparation",
		order = 67,
		officerOnly = true,
	},
	{
		id = "prep_weaponEnhancementEP",
		key = "config.prep.weaponEnhancementEP",
		label = "  Weapon Enhancement EP",
		desc = "EP awarded for having a weapon buff",
		type = "number",
		category = "Preparation",
		order = 68,
		min = 0,
		max = 50,
		step = 1,
		officerOnly = true,
		dimmedWhen = "config.prep.requireWeaponEnhancement",
	},
	
	-- ADMIN CATEGORY - Destructive Command Permissions
	{
		id = "admin_allowOfficerFactoryReset",
		key = "config.admin.allowOfficerFactoryReset",
		label = "Allow Officer Factory Reset",
		desc = "If enabled, officers can run /cb factoryreset (subject to 72h backup protection).",
		type = "bool",
		category = "Admin",
		group = "Destructive Commands",
		order = 100,
		officerOnly = true,
	},
}

--[[=============================================
	SCHEMA HELPERS
===============================================]]--

-- Get all schema items sorted for deterministic output
function ConfigSchema:GetItemsSorted()
	local sorted = {}
	for _, item in ipairs(schema) do
		table.insert(sorted, item)
	end
	
	-- Sort by: category order, group order, item order, label
	table.sort(sorted, function(a, b)
		if a.category ~= b.category then
			return (a.category or "") < (b.category or "")
		end
		if (a.group or "") ~= (b.group or "") then
			return (a.group or "") < (b.group or "")
		end
		if a.order ~= b.order then
			return a.order < b.order
		end
		return (a.label or "") < (b.label or "")
	end)
	
	return sorted
end

-- Get unique categories sorted by first item order
function ConfigSchema:GetCategoriesSorted()
	local categories = {}
	local seen = {}
	
	for _, item in ipairs(self:GetItemsSorted()) do
		if not seen[item.category] then
			seen[item.category] = true
			table.insert(categories, item.category)
		end
	end
	
	return categories
end

-- Get items for a specific category/group
function ConfigSchema:GetItemsForCategory(category, group)
	local items = {}
	for _, item in ipairs(self:GetItemsSorted()) do
		if item.category == category then
			if not group or item.group == group then
				table.insert(items, item)
			end
		end
	end
	return items
end

-- Resolve DB path to table reference and key
function ConfigSchema:ResolveDbPath(itemOrKey)
	local item = itemOrKey
	
	-- Handle legacy string key format
	if type(itemOrKey) == "string" then
		item = {key = itemOrKey, namespace = "global"}
	end
	
	local key = item.key
	if not key then
		return nil, nil, "No key provided"
	end
	
	local namespace = item.namespace or "global"
	
	-- Split path into components
	local keys = {}
	for part in key:gmatch("[^.]+") do
		table.insert(keys, part)
	end
	
	-- Start from appropriate namespace
	local root = namespace == "profile" and Cerebro.db.profile or Cerebro.db.global
	
	-- Navigate to parent table
	local current = root
	for i = 1, #keys - 1 do
		local k = keys[i]
		if not current[k] then
			current[k] = {}
		end
		current = current[k]
	end
	
	-- Return (parentTable, finalKey)
	return current, keys[#keys]
end

-- Validate and normalize a value according to schema item
function ConfigSchema:ValidateAndNormalize(item, value)
	if item.type == "bool" then
		return true, not not value, nil
	end
	
	if item.type == "number" then
		-- Coerce to number
		local num = tonumber(value)
		if not num then
			return false, nil, "Invalid number"
		end
		
		-- Handle percent conversion (display → storage)
		if item.isPercent and item.percentStorage == "normalized" then
			-- If input is 0–100 range, normalize to 0–1
			if num > 1 then
				num = num / 100
			end
		end
		
		-- Clamp to min/max
		if item.min and num < item.min then
			num = item.min
		end
		if item.max and num > item.max then
			num = item.max
		end
		
		return true, num, nil
	end
	
	if item.type == "string" then
		-- Custom validator for season_start_override
		if item.id == "season_start_override" then
			-- Handle empty/nil (clear override)
			if not value or value == "" then
				return true, nil, nil  -- Clear the override
			end
			
			-- Parse YYYY-MM-DD
			local y, m, d = value:match("^(%d%d%d%d)-(%d%d)-(%d%d)$")
			if not y then
				return false, nil, "Invalid format (use YYYY-MM-DD)"
			end
			
			-- Convert to UTC noon (avoids DST edge cases)
			local ts = time({year=tonumber(y), month=tonumber(m), day=tonumber(d), hour=12, min=0, sec=0})
			
			-- Validate date exists (leap day check)
			local check = date("*t", ts)
			if check.day ~= tonumber(d) or check.month ~= tonumber(m) then
				return false, nil, "Invalid date (does not exist)"
			end
			
			-- Range validation
			if ts > time() then
				return false, nil, "Future dates not allowed"
			end
			if ts < 1577836800 then  -- Jan 1, 2020 UTC
				return false, nil, "Date too old (before 2020)"
			end
			
			return true, ts, nil  -- Return Unix timestamp (number)
		end
		
		return true, tostring(value), nil
	end
	
	if item.type == "select" then
		-- Validate against choices
		if item.choices and not item.choices[value] then
			return false, nil, "Invalid choice"
		end
		return true, value, nil
	end
	
	return true, value, nil
end

-- Legacy alias for ValidateAndNormalize (Weights tab uses this name)
function ConfigSchema:Validate(item, value)
	return self:ValidateAndNormalize(item, value)
end

-- Get value from DB
-- Returns the raw stored value. UI should handle display conversion separately.
function ConfigSchema:GetValue(itemOrKey)
	local item = itemOrKey
	
	-- Handle legacy string key format
	if type(itemOrKey) == "string" then
		-- Find the item by key for full metadata
		for _, schemaItem in ipairs(self:GetItemsSorted()) do
			if schemaItem.key == itemOrKey then
				item = schemaItem
				break
			end
		end
		-- If not found, create minimal item
		if type(item) == "string" then
			item = {key = itemOrKey, namespace = "global"}
		end
	end
	
	local parent, key = self:ResolveDbPath(item)
	if not parent then return nil end
	
	return parent[key]
end

-- Set value to DB (with validation)
function ConfigSchema:SetValue(itemOrKey, value)
	local item = itemOrKey
	
	-- Handle legacy string key format
	if type(itemOrKey) == "string" then
		-- Find the item by key for full metadata
		for _, schemaItem in ipairs(self:GetItemsSorted()) do
			if schemaItem.key == itemOrKey then
				item = schemaItem
				break
			end
		end
		-- If not found, create minimal item
		if type(item) == "string" then
			item = {key = itemOrKey, namespace = "global", type = "string"}
		end
	end
	
	-- RBAC check
	if item.officerOnly and not Cerebro:IsOfficerSafe() then
		Cerebro:Print("Officer permissions required to modify this setting.")
		return false
	end
	
	-- Capture old value for logging
	local oldValue = self:GetValue(item)
	
	-- Validate and normalize
	local ok, normalizedValue, err = self:ValidateAndNormalize(item, value)
	if not ok then
		Cerebro:Print("Validation error: " .. (err or "unknown"))
		return false
	end
	
	-- Skip if no change
	if oldValue == normalizedValue then
		return true -- No change, no log
	end
	
	-- Write to DB
	local parent, key = self:ResolveDbPath(item)
	parent[key] = normalizedValue

	-- Bump per-section LWW timestamp for config sync
	-- Determines which config section was modified from the key path
	local section = item.key:match("^config%.([^.]+)%.")
	if section and Cerebro.db.global.config.configLastUpdate then
		Cerebro.db.global.config.configLastUpdate[section] = GetServerTime and GetServerTime() or time()
	end
	
	Cerebro:Debug("ConfigSchema: Set " .. item.key .. " = " .. tostring(normalizedValue))
	
	-- Log to System Logs if this is an officer-only setting (Weights tab)
	-- Note: We do NOT log to History (transactions) - History is for EP/GP adjustments only
	if item.officerOnly and Cerebro.Ledger and Cerebro.Ledger.AddAuditLog then
		local label = item.label or item.key or "Unknown"
		local logDetails = string.format("Changed [%s] from %s to %s", label, tostring(oldValue), tostring(normalizedValue))
		Cerebro.Ledger:AddAuditLog("WEIGHT_CHANGE", logDetails)
		
		-- Auto-Increment Config Version
		-- Only for settings that affect EP earning or GP costs, to trigger Intro for everyone
		local triggerKeys = {
			["config.epgp.baseGP"] = true,
			["config.epgp.decayRate"] = true,
			["config.epgp.epFarmBoss"] = true,
			["config.epgp.epProgBoss"] = true,
			["config.epgp.epOnTime"] = true,
			["config.epgp.epFullAttendance"] = true,
			["config.epgp.epProgressionHourly"] = true,
			["config.epgp.progressionIntervalMin"] = true,
			["config.epgp.benchMultiplier"] = true,
			["config.epgp.offspecMultiplier"] = true,
			["config.epgp.minorUpgradeMultiplier"] = true,
			["config.epgp.mplusBase"] = true,
			["config.epgp.mplusPerLevel"] = true,
			["config.epgp.difficultyMultipliers.14"] = true,
			["config.epgp.difficultyMultipliers.15"] = true,
			["config.epgp.difficultyMultipliers.16"] = true,
			-- Prep config: EP values shown in onboarding
			["config.prep.gemEPPerSocket"] = true,
			["config.prep.enchantEPPerSlot"] = true,
			["config.prep.foodEP"] = true,
			["config.prep.flaskEP"] = true,
			["config.prep.augmentRuneEP"] = true,
			["config.prep.vantusRuneEP"] = true,
			["config.prep.weaponEnhancementEP"] = true,
			["config.prep.minIlvlEP"] = true,
		}
		
		if triggerKeys[item.key] then
			local currentVer = Cerebro.db.global.config.epgp.configVersion or 0
			Cerebro.db.global.config.epgp.configVersion = currentVer + 1
			Cerebro:Debug("Config Version bumped to " .. (currentVer + 1))
		end

		-- Fast config sync: trigger immediate heartbeat so other clients
		-- discover the config hash mismatch within ~2s instead of waiting
		-- for the next periodic heartbeat cycle (15s+ depending on context).
		local v2 = Cerebro:GetModule("CommunicationV2", true)
		if v2 then
			v2:InvalidateHashCache()
			v2:NotifyStateChange("config_change")
		end
	end
	
	return true
end

-- Legacy compatibility (for existing code that uses key strings)
function ConfigSchema:GetValueByKey(key)
	for _, item in ipairs(schema) do
		if item.key == key then
			return self:GetValue(item)
		end
	end
	return nil
end

function ConfigSchema:SetValueByKey(key, value)
	for _, item in ipairs(schema) do
		if item.key == key then
			return self:SetValue(item, value)
		end
	end
	return false
end

-- Legacy API compatibility (Weights tab expects old format)
-- Old format: [{category: "Name", items: [...]}, ...]
-- New format: flat array of items with category field
function ConfigSchema:GetCategories()
	local categories = {}
	local categoryMap = {}
	
	-- Group items by category
	for _, item in ipairs(self:GetItemsSorted()) do
		if not categoryMap[item.category] then
			categoryMap[item.category] = {
				category = item.category,
				items = {}
			}
			table.insert(categories, categoryMap[item.category])
		end
		table.insert(categoryMap[item.category].items, item)
	end
	
	return categories
end

-- Make globally accessible
_G.UI_ConfigSchema = ConfigSchema