local parentAddonName = "EnhanceQoL"
local addon = select(2, ...)

if _G[parentAddonName] then
	addon = _G[parentAddonName]
else
	error(parentAddonName .. " is not loaded")
end

addon.Aura = addon.Aura or {}
addon.Aura.CooldownPanels = addon.Aura.CooldownPanels or {}
local CooldownPanels = addon.Aura.CooldownPanels
local Helper = CooldownPanels.helper
local Keybinds = Helper.Keybinds
local Api = Helper.Api or {}
local SettingType = addon.EditModeLib and addon.EditModeLib.SettingType
local L = LibStub("AceLocale-3.0"):GetLocale(parentAddonName)
local LSM = LibStub("LibSharedMedia-3.0", true)
local Glow = addon.Glow
local cdp = {
	EXPORT = {
		KIND = "EQOL_COOLDOWN_PANEL",
		SERIALIZER = LibStub("AceSerializer-3.0"),
		DEFLATE = LibStub("LibDeflate"),
	},
	FAKE_CURSOR = {
		FRAME_NAME = "EQOL_CooldownPanelsFakeCursor",
		ATLAS = "Cursor_Point_32",
		SIZE = 32,
		HOTSPOT_X = 0,
		HOTSPOT_Y = 0,
		DEFAULT_X = 0,
		DEFAULT_Y = 100,
	},
	FIXED_VISUAL = {
		LAYOUTS = setmetatable({}, { __mode = "k" }),
	},
	ENTRY = {},
	RUNTIME = {},
}

CooldownPanels.ENTRY_TYPE = {
	SPELL = "SPELL",
	ITEM = "ITEM",
	SLOT = "SLOT",
	STANCE = "STANCE",
	MACRO = "MACRO",
	CDM_AURA = "CDM_AURA",
}

CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE = CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE or {
	HIDE = "HIDE",
	HIDE_DESATURATE_ACTIVE = "HIDE_DESATURATE_ACTIVE",
	SHOW = "SHOW",
	DESATURATE = "DESATURATE",
	DESATURATE_ACTIVE = "DESATURATE_ACTIVE",
}

CooldownPanels.BLIZZARD_ALERT_TEXTURES = CooldownPanels.BLIZZARD_ALERT_TEXTURES
	or {
		{ id = 424570, name = "Spell Activation Overlay 0" },
		{ id = 449486, name = "Arcane Missiles" },
		{ id = 449487, name = "Blood Surge" },
		{ id = 449488, name = "Brain Freeze" },
		{ id = 449489, name = "Frozen Fingers" },
		{ id = 449490, name = "Hot Streak" },
		{ id = 449491, name = "Imp Empowerment" },
		{ id = 449492, name = "Nightfall" },
		{ id = 449493, name = "Sudden Death" },
		{ id = 449494, name = "Sword and Board" },
		{ id = 450913, name = "Art of War" },
		{ id = 450914, name = "Eclipse Moon" },
		{ id = 450915, name = "Eclipse Sun" },
		{ id = 450916, name = "Focus Fire" },
		{ id = 450917, name = "Generic Arc 1" },
		{ id = 450918, name = "Generic Arc 2" },
		{ id = 450919, name = "Generic Arc 3" },
		{ id = 450920, name = "Generic Arc 4" },
		{ id = 450921, name = "Generic Arc 5" },
		{ id = 450922, name = "Generic Arc 6" },
		{ id = 450923, name = "Generic Top 1" },
		{ id = 450924, name = "Generic Top 2" },
		{ id = 450925, name = "Grand Crusader" },
		{ id = 450926, name = "Lock and Load" },
		{ id = 450927, name = "Maelstrom Weapon" },
		{ id = 450928, name = "Master Marksman" },
		{ id = 450929, name = "Nature's Grace" },
		{ id = 450930, name = "Rime" },
		{ id = 450931, name = "Slice and Dice" },
		{ id = 450932, name = "Sudden Doom" },
		{ id = 450933, name = "Surge of Light" },
		{ id = 457658, name = "Impact" },
		{ id = 458740, name = "Killing Machine" },
		{ id = 458741, name = "Molten Core" },
		{ id = 459313, name = "Daybreak" },
		{ id = 459314, name = "Hand of Light" },
		{ id = 460830, name = "Backslash" },
		{ id = 460831, name = "Fury of Stormrage" },
		{ id = 461878, name = "Dark Transformation" },
		{ id = 463452, name = "Shooting Stars" },
		{ id = 467696, name = "Fulmination" },
		{ id = 469752, name = "Serendipity" },
		{ id = 510822, name = "Berserk" },
		{ id = 510823, name = "Omen of Clarity (Feral)" },
		{ id = 511104, name = "Blood Boil" },
		{ id = 511105, name = "Necropolis" },
		{ id = 511469, name = "Denounce" },
		{ id = 592058, name = "Surge of Darkness" },
		{ id = 603338, name = "Dark Tiger" },
		{ id = 603339, name = "White Tiger" },
		{ id = 623950, name = "Monk Ox" },
		{ id = 623951, name = "Monk Serpent" },
		{ id = 623952, name = "Monk Tiger" },
		{ id = 627609, name = "Shadow of Death" },
		{ id = 627610, name = "Ultimatum" },
		{ id = 656728, name = "Shadow Word Insanity" },
		{ id = 774420, name = "Tooth and Claw" },
		{ id = 801266, name = "Backlash Green" },
		{ id = 801267, name = "Imp Empowerment Green" },
		{ id = 801268, name = "Molten Core Green" },
		{ id = 898423, name = "Predatory Swiftness" },
		{ id = 962497, name = "Raging Blow" },
		{ id = 1001511, name = "Monk Blackout Kick" },
		{ id = 1001512, name = "Monk Tiger Palm" },
		{ id = 1027131, name = "Arcane Missiles 1" },
		{ id = 1027132, name = "Arcane Missiles 2" },
		{ id = 1027133, name = "Arcane Missiles 3" },
		{ id = 1028091, name = "Monk Ox 2" },
		{ id = 1028092, name = "Monk Ox 3" },
		{ id = 1028136, name = "Maelstrom Weapon 1" },
		{ id = 1028137, name = "Maelstrom Weapon 2" },
		{ id = 1028138, name = "Maelstrom Weapon 3" },
		{ id = 1028139, name = "Maelstrom Weapon 4" },
		{ id = 1029138, name = "Thrill of the Hunt 1" },
		{ id = 1029139, name = "Thrill of the Hunt 2" },
		{ id = 1029140, name = "Thrill of the Hunt 3" },
		{ id = 1030393, name = "Bandits Guile" },
		{ id = 1057288, name = "Echo of the Elements" },
		{ id = 1518303, name = "Predatory Swiftness Green" },
		{ id = 2851787, name = "Demonic Core" },
		{ id = 2851788, name = "High Tide" },
		{ id = 2888300, name = "Demonic Core Vertical" },
		{ id = 4699056, name = "Essence Burst" },
		{ id = 4699057, name = "Snapfire" },
		{ id = 6160020, name = "Arcane Soul" },
		{ id = 6160021, name = "Hyperthermia" },
	}
if CooldownPanels._eqolSpellCooldownIgnoreGCDSupported == nil then
	-- TODO: Remove this pre-12.0.5 compatibility gate once 12.0.5+ is the minimum supported client.
	CooldownPanels._eqolSpellCooldownIgnoreGCDSupported = (tonumber(GetBuildInfo and select(4, GetBuildInfo())) or 0) >= 120005
end

CooldownPanels.POWER_TYPE_TOKEN_BY_ID = CooldownPanels.POWER_TYPE_TOKEN_BY_ID
	or {
		[0] = "MANA",
		[1] = "RAGE",
		[2] = "FOCUS",
		[3] = "ENERGY",
		[4] = "COMBO_POINTS",
		[5] = "RUNES",
		[6] = "RUNIC_POWER",
		[7] = "SOUL_SHARDS",
		[8] = "LUNAR_POWER",
		[9] = "HOLY_POWER",
		[10] = "ALTERNATE",
		[11] = "MAELSTROM",
		[12] = "CHI",
		[13] = "INSANITY",
		[17] = "FURY",
		[18] = "PAIN",
		[19] = "ESSENCE",
	}

CooldownPanels.itemHighestRankByID = CooldownPanels.itemHighestRankByID
	or {
		-- Potion of Recklessness (normal rank1 -> rank2, fleeting rank1 -> rank2)
		[241289] = { 241289, 241288, 245903, 245902 },
		[241288] = { 241289, 241288, 245903, 245902 },
		[245903] = { 241289, 241288, 245903, 245902 },
		[245902] = { 241289, 241288, 245903, 245902 },
		-- Potion of Devoured Dreams (normal rank1 -> rank2, fleeting rank1 -> rank2)
		[241295] = { 241295, 241294, 245905, 245904 },
		[241294] = { 241295, 241294, 245905, 245904 },
		[245905] = { 241295, 241294, 245905, 245904 },
		[245904] = { 241295, 241294, 245905, 245904 },
		-- Light's Potential (normal rank1 -> rank2, fleeting rank1 -> rank2)
		[241309] = { 241309, 241308, 245897, 245898 },
		[241308] = { 241309, 241308, 245897, 245898 },
		[245897] = { 241309, 241308, 245897, 245898 },
		[245898] = { 241309, 241308, 245897, 245898 },
		-- Lightfused Mana Potion (rank1 -> rank2)
		[241301] = { 241301, 241300 },
		[241300] = { 241301, 241300 },
	}

CooldownPanels.staticSpellVariantGroupByID = CooldownPanels.staticSpellVariantGroupByID or {}
CooldownPanels.spellVariantGroupByID = CooldownPanels.staticSpellVariantGroupByID

CooldownPanels.staticSpellVariantGroups = CooldownPanels.staticSpellVariantGroups
	or {
		-- Blood Elf racial with class-specific spellIDs.
		{ 202719, 50613, 80483, 28730, 129597, 155145, 232633, 25046, 69179 },
		-- Orc racial with physical / hybrid / intellect variants.
		{ 20572, 33697, 33702 },
		-- Draenei racial with class-specific spellIDs.
		{ 59545, 59543, 59548, 121093, 59542, 59544, 59547, 28880, 370626, 416250 },
	}

CooldownPanels.playerRacialSpellIDs = CooldownPanels.playerRacialSpellIDs
	or {
		20572, 33697, 33702, 26297, 7744, 20549,
		202719, 50613, 80483, 28730, 129597, 155145, 232633, 25046, 69179,
		69070, 59752, 20594, 58984, 20589,
		59545, 59543, 59548, 121093, 59542, 59544, 59547, 28880, 370626, 416250,
		68992, 107079, 357214, 368970, 260364, 255654, 274738, 291944, 312411, 256948,
		255647, 265221, 287712, 312924, 436344, 1237885,
	}

function CooldownPanels:RegisterItemRankGroup(rankList)
	if type(rankList) ~= "table" then return false end
	local ids = {}
	for i = 1, #rankList do
		local itemID = tonumber(rankList[i])
		if itemID and itemID > 0 then ids[#ids + 1] = itemID end
	end
	if #ids < 2 then return false end
	local rankMap = self.itemHighestRankByID
	if type(rankMap) ~= "table" then
		rankMap = {}
		self.itemHighestRankByID = rankMap
	end
	for i = 1, #ids do
		rankMap[ids[i]] = ids
	end
	return true
end

function cdp.RUNTIME.RegisterSpellVariantGroupIntoMap(groupMap, variantList, options)
	if type(variantList) ~= "table" then return false end
	local ids = {}
	local seen = {}
	local mapBaseSpellIDs = type(options) == "table" and options.mapBaseSpellIDs == true
	for i = 1, #variantList do
		local spellID = tonumber(variantList[i])
		if spellID and spellID > 0 and not seen[spellID] then
			seen[spellID] = true
			ids[#ids + 1] = spellID
		end
	end
	if #ids < 2 then return false end
	if type(groupMap) ~= "table" then
		return false
	end
	for i = 1, #ids do
		local spellID = ids[i]
		groupMap[spellID] = ids
		if mapBaseSpellIDs and Api.GetBaseSpell then
			local baseSpellID = Api.GetBaseSpell(spellID)
			if type(baseSpellID) == "number" and baseSpellID > 0 then groupMap[baseSpellID] = ids end
		end
	end
	return true
end

function CooldownPanels:RegisterStaticSpellVariantGroup(variantList, options)
	local groupMap = self.staticSpellVariantGroupByID
	if type(groupMap) ~= "table" then
		groupMap = {}
		self.staticSpellVariantGroupByID = groupMap
		self.spellVariantGroupByID = groupMap
	end
	return cdp.RUNTIME.RegisterSpellVariantGroupIntoMap(groupMap, variantList, options)
end

function CooldownPanels:RegisterSpellVariantGroup(variantList, options)
	return self:RegisterStaticSpellVariantGroup(variantList, options)
end

local function shouldShowEntryStacks(entry, resolvedType)
	if not entry then return false end
	if resolvedType ~= "SPELL" and resolvedType ~= "CDM_AURA" then return false end
	local bars = CooldownPanels.Bars
	if bars and bars.ShouldEntryShowStacks then return bars.ShouldEntryShowStacks(entry, resolvedType) end
	return entry.showStacks == true
end

function CooldownPanels:IngestRankGroupsByRank(entries, keyPrefix)
	if type(entries) ~= "table" then return false end
	local groups = {}
	for typeKey, list in pairs(entries) do
		if type(list) == "table" then
			for _, entry in ipairs(list) do
				local itemID = tonumber(entry and entry.id)
				local rank = tonumber(entry and entry.rank)
				if itemID and itemID > 0 and rank and rank > 0 then
					local rawKey = type(entry.key) == "string" and entry.key or tostring(itemID)
					local baseKey = rawKey:gsub("%d+$", "")
					local requiredLevel = tonumber(entry.requiredLevel) or 0
					local groupKey = string.format("%s:%s:%d:%s", keyPrefix or "rank", baseKey, requiredLevel, tostring(typeKey))
					groups[groupKey] = groups[groupKey] or {}
					groups[groupKey][rank] = itemID
				end
			end
		end
	end
	local added = false
	for _, byRank in pairs(groups) do
		local ordered = {}
		for rank = 1, 9 do
			local itemID = byRank[rank]
			if itemID then ordered[#ordered + 1] = itemID end
		end
		if #ordered >= 2 and self:RegisterItemRankGroup(ordered) then added = true end
	end
	return added
end

function CooldownPanels:IngestHealthPotionRankGroups()
	local healthList = addon.Health and addon.Health.healthList
	if type(healthList) ~= "table" then return false end
	local groups = {}
	for _, entry in ipairs(healthList) do
		local itemID = tonumber(entry and entry.id)
		local rawKey = type(entry and entry.key) == "string" and entry.key or nil
		if itemID and itemID > 0 and rawKey then
			local baseKey = rawKey:gsub("%d+$", "")
			if baseKey == "" then baseKey = rawKey end
			local requiredLevel = tonumber(entry.requiredLevel) or 0
			local groupKey = string.format("health:%s:%d", baseKey, requiredLevel)
			groups[groupKey] = groups[groupKey] or {}
			groups[groupKey][#groups[groupKey] + 1] = {
				id = itemID,
				heal = tonumber(entry.heal),
				rankSuffix = tonumber(rawKey:match("(%d+)$")),
			}
		end
	end
	local added = false
	for _, items in pairs(groups) do
		if #items >= 2 then
			table.sort(items, function(a, b)
				local aHeal = tonumber(a and a.heal)
				local bHeal = tonumber(b and b.heal)
				if aHeal and bHeal and aHeal ~= bHeal then return aHeal < bHeal end
				if aHeal and not bHeal then return true end
				if bHeal and not aHeal then return false end
				local aRank = tonumber(a and a.rankSuffix)
				local bRank = tonumber(b and b.rankSuffix)
				if aRank and bRank and aRank ~= bRank then return aRank < bRank end
				return (tonumber(a and a.id) or 0) < (tonumber(b and b.id) or 0)
			end)
		end
		local ordered = {}
		local seen = {}
		for i = 1, #items do
			local itemID = tonumber(items[i] and items[i].id)
			if itemID and itemID > 0 and not seen[itemID] then
				seen[itemID] = true
				ordered[#ordered + 1] = itemID
			end
		end
		if #ordered >= 2 and self:RegisterItemRankGroup(ordered) then added = true end
	end
	return added
end

function CooldownPanels:IngestFlaskRankGroups()
	local flasks = addon.Flasks
	if type(flasks) ~= "table" then return false end
	local added = false
	if self:IngestRankGroupsByRank(flasks.typeFlasks, "flask") then added = true end
	if self:IngestRankGroupsByRank(flasks.fleetingTypeFlasks, "flask_fleeting") then added = true end
	return added
end

function CooldownPanels:EnsureFoodRankGroupsLoaded()
	self.runtime = self.runtime or {}
	local state = self.runtime.itemRankSourcesLoaded
	if type(state) ~= "table" then
		state = { health = false, flasks = false }
		self.runtime.itemRankSourcesLoaded = state
	end
	if not state.health and self:IngestHealthPotionRankGroups() then state.health = true end
	if not state.flasks and self:IngestFlaskRankGroups() then state.flasks = true end
end

function CooldownPanels:GetCanonicalItemRankID(itemID)
	self:EnsureFoodRankGroupsLoaded()
	local numericID = tonumber(itemID)
	if not numericID then return nil, false, nil end
	local rankMap = CooldownPanels.itemHighestRankByID
	local group = rankMap and rankMap[numericID]
	if not group or type(group) ~= "table" or #group == 0 then return numericID, false, nil end
	local lowestRankID = tonumber(group[1]) or numericID
	return lowestRankID, lowestRankID ~= numericID, group
end

function CooldownPanels.ResolveEntryItemID(entry, itemID)
	CooldownPanels:EnsureFoodRankGroupsLoaded()
	local numericID = tonumber(itemID)
	if not numericID then return nil end
	if entry and entry.type == "ITEM" and addon.Health and addon.Health.functions and addon.Health.functions.resolveTrackedHealthstoneItem then
		numericID = addon.Health.functions.resolveTrackedHealthstoneItem(numericID) or numericID
	end
	if not (entry and entry.type == "ITEM" and entry.useHighestRank == true) then return numericID end
	local rankMap = CooldownPanels.itemHighestRankByID
	local group = rankMap and rankMap[numericID]
	if not group then return numericID end
	local runtime = CooldownPanels.runtime
	local itemCountCache = runtime and runtime.itemCountCache or nil
	local missingCache = false
	if itemCountCache then
		for i = #group, 1, -1 do
			local candidateID = tonumber(group[i])
			if candidateID then
				local cached = itemCountCache[candidateID]
				if cached and cached.count ~= nil then
					if cached.count > 0 then return candidateID end
				else
					missingCache = true
				end
			end
		end
		if not missingCache then return numericID end
	end
	for i = #group, 1, -1 do
		local candidateID = group[i]
		local count = Api.GetItemCount(candidateID, false, false) or 0
		if count > 0 then return candidateID end
	end
	return numericID
end

_G["BINDING_NAME_EQOL_TOGGLE_COOLDOWN_PANELS"] = L["CooldownPanelBindingToggle"] or "Toggle Cooldown Panel Editor"

CooldownPanels.runtime = CooldownPanels.runtime or {}

local curveDesat = C_CurveUtil.CreateCurve()
curveDesat:SetType(Enum.LuaCurveType.Step)
curveDesat:AddPoint(0, 0)
curveDesat:AddPoint(0.1, 1)

local curveAlpha = C_CurveUtil.CreateCurve()
curveAlpha:SetType(Enum.LuaCurveType.Step)
curveAlpha:AddPoint(0, 1)
curveAlpha:AddPoint(0.1, 0)

function CooldownPanels:NormalizeCDMAuraAlwaysShowMode(value, fallback)
	local mode = type(value) == "string" and string.upper(value) or nil
	local values = self.CDM_AURA_ALWAYS_SHOW_MODE or {}
	if mode == values.SHOW or mode == values.DESATURATE or mode == values.DESATURATE_ACTIVE or mode == values.HIDE or mode == values.HIDE_DESATURATE_ACTIVE then return mode end
	return fallback or values.HIDE or "HIDE"
end

function CooldownPanels:IsCDMAuraAlwaysShowModeVisibleWhenInactive(value)
	local values = self.CDM_AURA_ALWAYS_SHOW_MODE or {}
	local mode = self:NormalizeCDMAuraAlwaysShowMode(value, values.HIDE or "HIDE")
	return mode ~= (values.HIDE or "HIDE") and mode ~= (values.HIDE_DESATURATE_ACTIVE or "HIDE_DESATURATE_ACTIVE")
end

function CooldownPanels:GetCDMAuraAlwaysShowOptions()
	local values = self.CDM_AURA_ALWAYS_SHOW_MODE or {}
	return {
		{
			value = values.HIDE or "HIDE",
			label = L["CooldownPanelCDMAuraAlwaysShowModeHide"] or "Only show when active",
		},
		{
			value = values.HIDE_DESATURATE_ACTIVE or "HIDE_DESATURATE_ACTIVE",
			label = L["CooldownPanelCDMAuraAlwaysShowModeHideDesaturateActive"] or "Only show when active (desaturated)",
		},
		{
			value = values.SHOW or "SHOW",
			label = L["Always show"] or (L["Always show"] or "Always show"),
		},
		{
			value = values.DESATURATE or "DESATURATE",
			label = L["CooldownPanelCDMAuraAlwaysShowModeDesaturate"] or "Always show (desaturate if inactive)",
		},
		{
			value = values.DESATURATE_ACTIVE or "DESATURATE_ACTIVE",
			label = L["CooldownPanelCDMAuraAlwaysShowModeDesaturateActive"] or "Always show (desaturate when active)",
		},
	}
end

local function normalizeId(value)
	local num = tonumber(value)
	if num then return num end
	return value
end

local function getClassInfoById(classId)
	if GetClassInfo then return GetClassInfo(classId) end
	if C_CreatureInfo and C_CreatureInfo.GetClassInfo then
		local info = C_CreatureInfo.GetClassInfo(classId)
		if info then return info.className, info.classFile, info.classID end
	end
	return nil
end

local function getClassSpecMenuData()
	local classes = {}
	local getSpecCount = C_SpecializationInfo and C_SpecializationInfo.GetNumSpecializationsForClassID
	if not getSpecCount or not GetSpecializationInfoForClassID or not GetNumClasses then return classes end
	local sex = UnitSex and UnitSex("player") or nil
	local numClasses = GetNumClasses() or 0
	for classIndex = 1, numClasses do
		local className, classTag, classID = getClassInfoById(classIndex)
		if classID then
			local specCount = getSpecCount(classID) or 0
			if specCount > 0 then
				local specs = {}
				for specIndex = 1, specCount do
					local specID, specName, _, _, role = GetSpecializationInfoForClassID(classID, specIndex, sex)
					if specID then
						local isMelee = CooldownPanels and CooldownPanels.IsSpecQuickFilterMelee and CooldownPanels:IsSpecQuickFilterMelee(specID) or false
						local isCaster = role == "DAMAGER" and isMelee ~= true and classTag ~= "HUNTER"
						specs[#specs + 1] = {
							id = specID,
							name = specName or ("Spec " .. tostring(specID)),
							role = role,
							classTag = classTag,
							isMelee = isMelee,
							isCaster = isCaster,
						}
					end
				end
				if #specs > 0 then classes[#classes + 1] = {
					id = classID,
					name = className or classTag or tostring(classID),
					classTag = classTag,
					specs = specs,
				} end
			end
		end
	end
	if #classes > 1 then
		table.sort(classes, function(a, b)
			local an = a and a.name or ""
			local bn = b and b.name or ""
			if strcmputf8i then return strcmputf8i(an, bn) < 0 end
			return tostring(an):lower() < tostring(bn):lower()
		end)
	end
	return classes
end

local function getSpecNameById(specId)
	if GetSpecializationInfoForSpecID then
		local _, specName = GetSpecializationInfoForSpecID(specId)
		if specName and specName ~= "" then return specName end
	end
	return tostring(specId or "")
end

function CooldownPanels:IsSpecQuickFilterMelee(specId)
	specId = tonumber(specId)
	if not specId then return false end
	if specId == 70 or specId == 71 or specId == 72 or specId == 103 or specId == 251 or specId == 252 or specId == 255 then return true end
	if specId == 259 or specId == 260 or specId == 261 or specId == 263 or specId == 269 or specId == 577 then return true end
	return false
end

function CooldownPanels:GetSpecQuickFilterDefinitions(classMenuData)
	local definitions = {
		{ id = "HEALER", label = L["CooldownPanelSpecAllHealers"] or "All healers", specIds = {} },
		{ id = "TANK", label = L["CooldownPanelSpecAllTanks"] or "All tanks", specIds = {} },
		{ id = "MELEE", label = L["CooldownPanelSpecAllMelee"] or "All melee", specIds = {} },
		{ id = "CASTER", label = L["CooldownPanelSpecAllCasters"] or "All casters", specIds = {} },
	}

	for _, classData in ipairs(classMenuData or getClassSpecMenuData()) do
		for _, specData in ipairs(classData.specs or {}) do
			local specId = tonumber(specData.id)
			if specId and specId > 0 then
				if specData.role == "HEALER" then definitions[1].specIds[#definitions[1].specIds + 1] = specId end
				if specData.role == "TANK" then definitions[2].specIds[#definitions[2].specIds + 1] = specId end
				if specData.role == "DAMAGER" and specData.isMelee == true then definitions[3].specIds[#definitions[3].specIds + 1] = specId end
				if specData.isCaster == true then definitions[4].specIds[#definitions[4].specIds + 1] = specId end
			end
		end
	end

	return definitions
end

function CooldownPanels:HasAllPanelSpecFilterEntries(panel, specIds)
	if not panel or type(specIds) ~= "table" or #specIds == 0 then return false end
	local filter = panel.specFilter
	if type(filter) ~= "table" then return false end
	for _, specId in ipairs(specIds) do
		if filter[specId] ~= true then return false end
	end
	return true
end

function CooldownPanels:SetPanelSpecFilterEntries(panel, specIds, enabled)
	if not panel or type(specIds) ~= "table" then return false end
	panel.specFilter = panel.specFilter or {}
	local changed = false
	for _, specId in ipairs(specIds) do
		specId = tonumber(specId)
		if specId and specId > 0 then
			if enabled then
				if panel.specFilter[specId] ~= true then
					panel.specFilter[specId] = true
					changed = true
				end
			elseif panel.specFilter[specId] ~= nil then
				panel.specFilter[specId] = nil
				changed = true
			end
		end
	end
	return changed
end

function CooldownPanels:CommitPanelSpecFilter(panelId)
	self:RebuildSpellIndex()
	self:RefreshPanel(panelId)
	self:RefreshEditor()
end

local function getEffectiveSpellId(spellId)
	local id = tonumber(spellId)
	if not id then return nil end
	if Api.GetOverrideSpell then
		local overrideId = Api.GetOverrideSpell(id)
		if type(overrideId) == "number" and overrideId > 0 then return overrideId end
	end
	return id
end

local function getBaseSpellId(spellId)
	local id = tonumber(spellId)
	if not id then return nil end
	if Api.GetBaseSpell then
		local baseId = Api.GetBaseSpell(id)
		if type(baseId) == "number" and baseId > 0 then return baseId end
	end
	return id
end

function CooldownPanels:GetReadySoundStateKey(spellId)
	local id = tonumber(spellId)
	if not id then return nil end
	local effectiveId = getEffectiveSpellId(id) or id
	return self:GetCanonicalSpellVariantID(effectiveId) or getBaseSpellId(effectiveId) or effectiveId
end

function CooldownPanels:GetReadySoundQuerySpellId(spellId)
	local id = tonumber(spellId)
	if not id then return nil end
	return self:ResolveTrackedSpellID(id)
end

function CooldownPanels:SpellIndexHasSpell(index, spellId)
	local id = tonumber(spellId)
	if not (index and id) then return false end
	local canonicalId = self:GetReadySoundStateKey(id)
	local baseId = getBaseSpellId(id) or id
	local effectiveId = getEffectiveSpellId(id) or id
	return index[id] ~= nil or index[baseId] ~= nil or index[effectiveId] ~= nil or (canonicalId and index[canonicalId] ~= nil) or false
end

function CooldownPanels:GetTrustedReadyUnavailable(spellId)
	local querySpellId = self:GetReadySoundQuerySpellId(spellId)
	if not querySpellId then return false end
	local _, _, _, _, isOnGCD, isActive = self:GetCachedSpellCooldownInfo(querySpellId)
	return isActive == true and isOnGCD == false
end

function CooldownPanels:EnsureStaticSpellVariantGroupsLoaded()
	self.runtime = self.runtime or {}
	if self.runtime.staticSpellVariantGroupsLoaded == true then return end
	local groups = self.staticSpellVariantGroups
	for i = 1, #(groups or {}) do
		self:RegisterStaticSpellVariantGroup(groups[i], { mapBaseSpellIDs = true })
	end
	self.runtime.staticSpellVariantGroupsLoaded = true
end

local function queryPlayerSpecId()
	if not (C_SpecializationInfo and C_SpecializationInfo.GetSpecialization) then return nil end
	local specIndex = C_SpecializationInfo.GetSpecialization()
	if not specIndex then return nil end
	if C_SpecializationInfo and C_SpecializationInfo.GetSpecializationInfo then
		local specId = C_SpecializationInfo.GetSpecializationInfo(specIndex)
		if type(specId) == "number" and specId > 0 then return specId end
		if type(specId) == "table" and type(specId.specID) == "number" and specId.specID > 0 then return specId.specID end
	end
	return nil
end

function cdp.RUNTIME.GetTalentNodeActiveEntryID(nodeInfo)
	if type(nodeInfo) ~= "table" then return nil end
	local activeEntry = nodeInfo.activeEntry
	if type(activeEntry) == "table" then
		local entryID = tonumber(activeEntry.entryID or activeEntry.id)
		if entryID then return entryID end
	elseif type(activeEntry) == "number" then
		return activeEntry
	end
	local activeEntryID = tonumber(nodeInfo.activeEntryID)
	if activeEntryID then return activeEntryID end
	local committed = nodeInfo.entryIDsWithCommittedRanks
	if type(committed) == "table" then
		for i = 1, #committed do
			local entryID = tonumber(committed[i])
			if entryID then return entryID end
		end
		for entryID, rank in pairs(committed) do
			local numericEntryID = tonumber(entryID)
			local numericRank = tonumber(rank)
			if numericEntryID and numericRank and numericRank > 0 then return numericEntryID end
		end
	end
	return nil
end

function cdp.RUNTIME.IsTalentChoiceNodeActive(nodeInfo, activeEntryID)
	if type(nodeInfo) ~= "table" or not activeEntryID then return false end
	local hasRankField = false
	for _, field in ipairs({ "currentRank", "activeRank", "ranksPurchased", "rank" }) do
		local rank = tonumber(nodeInfo[field])
		if rank ~= nil then
			hasRankField = true
			if rank > 0 then return true end
		end
	end
	local committed = nodeInfo.entryIDsWithCommittedRanks
	if type(committed) == "table" then
		for i = 1, #committed do
			if tonumber(committed[i]) == activeEntryID then return true end
		end
		for entryID, rank in pairs(committed) do
			local numericEntryID = tonumber(entryID)
			local numericRank = tonumber(rank)
			if numericEntryID == activeEntryID and (numericRank == nil or numericRank > 0) then return true end
		end
	end
	if hasRankField then return false end
	return true
end

function cdp.RUNTIME.GetTalentEntrySpellID(configID, entryID)
	local entryInfo = Api.GetTraitEntryInfo and Api.GetTraitEntryInfo(configID, entryID) or nil
	local definitionID = entryInfo and tonumber(entryInfo.definitionID) or nil
	if not definitionID then return nil end
	local definitionInfo = Api.GetTraitDefinitionInfo and Api.GetTraitDefinitionInfo(definitionID) or nil
	local spellID = definitionInfo and tonumber(definitionInfo.spellID) or nil
	if not spellID or spellID <= 0 then return nil end
	if Api.IsSpellPassiveFn and Api.IsSpellPassiveFn(spellID) then return nil end
	return spellID
end

function cdp.RUNTIME.AddTalentChoiceGroupLookupID(ctx, group, spellID)
	local id = tonumber(spellID)
	if not id or group.lookupSeen[id] then return end
	group.lookupSeen[id] = true
	local list = ctx.byID[id]
	if type(list) ~= "table" then
		list = {}
		ctx.byID[id] = list
	end
	list[#list + 1] = group
end

function cdp.RUNTIME.RegisterTalentChoiceRuntimeGroup(ctx, treeID, nodeID, spellIDs, selectedSpellID, activeEntryID)
	local selectedID = tonumber(selectedSpellID)
	if type(ctx) ~= "table" or type(spellIDs) ~= "table" or #spellIDs < 2 or not selectedID then return nil end
	local group = {
		kind = "talentChoice",
		treeID = treeID,
		nodeID = nodeID,
		key = tostring(treeID or "?") .. ":" .. tostring(nodeID or "?"),
		ids = spellIDs,
		idSet = {},
		canonicalID = tonumber(spellIDs[1]) or selectedID,
		selectedSpellID = selectedID,
		activeEntryID = activeEntryID,
		lookupSeen = {},
	}
	ctx.groups[#ctx.groups + 1] = group
	for i = 1, #spellIDs do
		local spellID = tonumber(spellIDs[i])
		if spellID then
			group.idSet[spellID] = true
			cdp.RUNTIME.AddTalentChoiceGroupLookupID(ctx, group, spellID)
			cdp.RUNTIME.AddTalentChoiceGroupLookupID(ctx, group, getBaseSpellId(spellID))
			cdp.RUNTIME.AddTalentChoiceGroupLookupID(ctx, group, getEffectiveSpellId(spellID))
		end
	end
	cdp.RUNTIME.AddTalentChoiceGroupLookupID(ctx, group, selectedID)
	cdp.RUNTIME.AddTalentChoiceGroupLookupID(ctx, group, getBaseSpellId(selectedID))
	cdp.RUNTIME.AddTalentChoiceGroupLookupID(ctx, group, getEffectiveSpellId(selectedID))
	group.lookupSeen = nil
	return group
end

function cdp.RUNTIME.AreEquivalentSpellIDs(firstSpellId, secondSpellId)
	local firstID = tonumber(firstSpellId)
	local secondID = tonumber(secondSpellId)
	if not (firstID and secondID) then return false end
	if firstID == secondID then return true end

	local firstBaseID = getBaseSpellId(firstID) or firstID
	local secondBaseID = getBaseSpellId(secondID) or secondID
	if firstBaseID == secondBaseID then return true end

	local firstEffectiveID = getEffectiveSpellId(firstID)
	local secondEffectiveID = getEffectiveSpellId(secondID)
	if firstEffectiveID and firstEffectiveID == secondID then return true end
	if secondEffectiveID and secondEffectiveID == firstID then return true end
	if firstEffectiveID and secondEffectiveID and firstEffectiveID == secondEffectiveID then return true end

	local firstEffectiveBaseID = firstEffectiveID and (getBaseSpellId(firstEffectiveID) or firstEffectiveID) or nil
	local secondEffectiveBaseID = secondEffectiveID and (getBaseSpellId(secondEffectiveID) or secondEffectiveID) or nil
	if firstEffectiveBaseID and firstEffectiveBaseID == secondBaseID then return true end
	if secondEffectiveBaseID and secondEffectiveBaseID == firstBaseID then return true end

	return false
end

function cdp.RUNTIME.DoesSpellMatchTalentChoiceGroup(group, spellId)
	local numericID = tonumber(spellId)
	if not (group and numericID) then return false end
	if type(group.idSet) == "table" and group.idSet[numericID] == true then return true end
	local selectedID = tonumber(group.selectedSpellID)
	if selectedID and cdp.RUNTIME.AreEquivalentSpellIDs(selectedID, numericID) then return true end
	return false
end

function cdp.RUNTIME.IsDirectTalentChoiceGroupMember(group, spellId)
	local numericID = tonumber(spellId)
	return group and group.kind == "talentChoice" and numericID and type(group.idSet) == "table" and group.idSet[numericID] == true or false
end

function cdp.RUNTIME.EnsureTalentChoiceSpellVariantGroupsLoaded(owner)
	owner.runtime = owner.runtime or {}
	local runtime = owner.runtime
	runtime.talentChoiceSpellVariantGroupsLoadedByConfig = nil
	local specID = queryPlayerSpecId()
	local configID = Api.GetActiveTalentConfigID and Api.GetActiveTalentConfigID() or nil
	if not specID or not configID then
		runtime.talentChoiceVariantContext = nil
		return nil
	end
	local generation = runtime.talentChoiceVariantGeneration or 0
	local key = tostring(specID) .. ":" .. tostring(configID) .. ":" .. tostring(generation)
	local existing = runtime.talentChoiceVariantContext
	if existing and existing.key == key then return existing end

	local ctx = {
		key = key,
		specID = specID,
		configID = configID,
		generation = generation,
		groups = {},
		byID = {},
	}

	local configInfo = Api.GetTraitConfigInfo and Api.GetTraitConfigInfo(configID) or nil
	local treeIDs = configInfo and configInfo.treeIDs
	if type(treeIDs) ~= "table" then
		runtime.talentChoiceVariantContext = ctx
		return ctx
	end

	local selectionNodeType = Api.TraitNodeTypeSelection
	local subtreeSelectionNodeType = Api.TraitNodeTypeSubTreeSelection or (Enum and Enum.TraitNodeType and Enum.TraitNodeType.SubTreeSelection)
	for _, treeID in ipairs(treeIDs) do
		local nodeIDs = Api.GetTraitTreeNodes and Api.GetTraitTreeNodes(treeID) or nil
		if type(nodeIDs) == "table" then
			for _, nodeID in ipairs(nodeIDs) do
				local nodeInfo = Api.GetTraitNodeInfo and Api.GetTraitNodeInfo(configID, nodeID) or nil
				local entryIDs = nodeInfo and nodeInfo.entryIDs
				local nodeType = nodeInfo and nodeInfo.type
				local activeEntryID = cdp.RUNTIME.GetTalentNodeActiveEntryID(nodeInfo)
				local isChoiceNode = selectionNodeType == nil
					or nodeType == selectionNodeType
					or nodeType == subtreeSelectionNodeType
					or (nodeType == nil and activeEntryID ~= nil)
				if type(entryIDs) == "table" and #entryIDs > 1 and isChoiceNode then
					local spellIDs = {}
					local seenSpellIDs = {}
					local selectedSpellID = activeEntryID and cdp.RUNTIME.GetTalentEntrySpellID(configID, activeEntryID) or nil
					for _, entryID in ipairs(entryIDs) do
						local spellID = cdp.RUNTIME.GetTalentEntrySpellID(configID, entryID)
						if spellID and not seenSpellIDs[spellID] then
							seenSpellIDs[spellID] = true
							spellIDs[#spellIDs + 1] = spellID
						end
					end
					if #spellIDs >= 2 and selectedSpellID and cdp.RUNTIME.IsTalentChoiceNodeActive(nodeInfo, activeEntryID) then
						cdp.RUNTIME.RegisterTalentChoiceRuntimeGroup(ctx, treeID, nodeID, spellIDs, selectedSpellID, activeEntryID)
					end
				end
			end
		end
	end
	runtime.talentChoiceVariantContext = ctx
	return ctx
end

function CooldownPanels:InvalidateTalentChoiceSpellVariantGroups()
	self.runtime = self.runtime or {}
	local runtime = self.runtime
	runtime.talentChoiceVariantGeneration = (runtime.talentChoiceVariantGeneration or 0) + 1
	runtime.talentChoiceVariantContext = nil
	runtime.talentChoiceSpellVariantGroupsLoadedByConfig = nil
	runtime.activeTalentChoiceGroupsForSpellCache = nil
	self.spellVariantGroupByID = self.staticSpellVariantGroupByID
end

function CooldownPanels:GetActiveTalentChoiceVariantContext()
	local ctx = cdp.RUNTIME.EnsureTalentChoiceSpellVariantGroupsLoaded(self)
	local runtime = self.runtime
	if not ctx then ctx = runtime and runtime.talentChoiceVariantContext or nil end
	if not ctx then return nil end
	local specID = queryPlayerSpecId()
	local configID = Api.GetActiveTalentConfigID and Api.GetActiveTalentConfigID() or nil
	if ctx.specID ~= specID or ctx.configID ~= configID then
		self:InvalidateTalentChoiceSpellVariantGroups()
		return nil
	end
	return ctx
end

function CooldownPanels:GetStaticSpellVariantGroupForSpell(spellId)
	self:EnsureStaticSpellVariantGroupsLoaded()
	local numericID = tonumber(spellId)
	if not numericID then return nil end
	local baseSpellID = getBaseSpellId(numericID) or numericID
	local groupMap = self.staticSpellVariantGroupByID
	return type(groupMap) == "table" and (groupMap[numericID] or groupMap[baseSpellID]) or nil
end

function CooldownPanels:GetActiveTalentChoiceGroupsForSpell(spellId)
	local numericID = tonumber(spellId)
	if not numericID then return nil end
	self.runtime = self.runtime or {}
	local runtime = self.runtime
	local generation = runtime.talentChoiceVariantGeneration or 0
	local cache = runtime.activeTalentChoiceGroupsForSpellCache
	if not cache or cache.generation ~= generation then
		cache = { generation = generation }
		runtime.activeTalentChoiceGroupsForSpellCache = cache
	end
	local cached = cache[numericID]
	if cached ~= nil then return cached ~= false and cached or nil end

	local ctx = self:GetActiveTalentChoiceVariantContext()
	if not ctx then ctx = cdp.RUNTIME.EnsureTalentChoiceSpellVariantGroupsLoaded(self) end
	if not (ctx and type(ctx.byID) == "table") then
		cache[numericID] = false
		return nil
	end
	local groups = ctx.byID[numericID]
	local baseSpellID = getBaseSpellId(numericID)
	local effectiveSpellID = getEffectiveSpellId(numericID)
	if not groups and baseSpellID and baseSpellID ~= numericID then groups = ctx.byID[baseSpellID] end
	if not groups and effectiveSpellID and effectiveSpellID ~= numericID and effectiveSpellID ~= baseSpellID then groups = ctx.byID[effectiveSpellID] end
	if type(groups) ~= "table" then
		cache[numericID] = false
		return nil
	end

	local filtered = nil
	for i = 1, #groups do
		local group = groups[i]
		if cdp.RUNTIME.DoesSpellMatchTalentChoiceGroup(group, numericID) then
			filtered = filtered or {}
			filtered[#filtered + 1] = group
		end
	end
	cache[numericID] = filtered or false
	return filtered
end

function cdp.RUNTIME.ResolveTrackedSpellID(owner, spellId)
	local numericID = tonumber(spellId)
	if not numericID then return nil, nil, nil end
	local storedBaseSpellID = getBaseSpellId(numericID) or numericID
	local resolvedSpellID, _, variantGroup = owner:ResolveRuntimeSpellVariantID(numericID)
	resolvedSpellID = resolvedSpellID or storedBaseSpellID
	local effectiveSpellID = getEffectiveSpellId(resolvedSpellID) or resolvedSpellID
	return effectiveSpellID, resolvedSpellID, storedBaseSpellID, variantGroup
end

function cdp.RUNTIME.GetSpellAliasIDs(owner, spellId, outIds, seenIds, options)
	local ids = outIds or {}
	local seen = seenIds or {}
	local includeTalentChoice = type(options) ~= "table" or options.includeTalentChoice ~= false

	local function addID(id)
		local numericID = tonumber(id)
		if not numericID or seen[numericID] then return end
		seen[numericID] = true
		ids[#ids + 1] = numericID
	end

	local function collectID(id)
		local numericID = tonumber(id)
		if not numericID then return end
		addID(numericID)
		local baseSpellID = getBaseSpellId(numericID)
		if baseSpellID and baseSpellID ~= numericID then addID(baseSpellID) end
		local effectiveSpellID = getEffectiveSpellId(numericID)
		if effectiveSpellID and effectiveSpellID ~= numericID and effectiveSpellID ~= baseSpellID then addID(effectiveSpellID) end
	end

	collectID(spellId)
	local staticGroup = owner:GetStaticSpellVariantGroupForSpell(spellId)
	if type(staticGroup) == "table" then
		for i = 1, #staticGroup do
			collectID(staticGroup[i])
		end
	end
	if includeTalentChoice then
		local dynamicGroups = owner:GetActiveTalentChoiceGroupsForSpell(spellId)
		if type(dynamicGroups) == "table" then
			for groupIndex = 1, #dynamicGroups do
				local group = dynamicGroups[groupIndex]
				local groupIDs = group and group.ids
				if type(groupIDs) == "table" then
					for i = 1, #groupIDs do
						collectID(groupIDs[i])
					end
				end
				if group and group.selectedSpellID then collectID(group.selectedSpellID) end
				if group and group.canonicalID then collectID(group.canonicalID) end
			end
		end
	end
	return ids, seen
end

function cdp.RUNTIME.AreSpellVariantsEquivalent(owner, firstSpellId, secondSpellId)
	local firstID = tonumber(firstSpellId)
	local secondID = tonumber(secondSpellId)
	if not (firstID and secondID) then return false end
	if firstID == secondID then return true end
	local firstCanonicalID = owner:GetCanonicalSpellVariantID(firstID) or getBaseSpellId(firstID) or firstID
	local secondCanonicalID = owner:GetCanonicalSpellVariantID(secondID) or getBaseSpellId(secondID) or secondID
	if firstCanonicalID == secondCanonicalID then return true end
	local firstEffectiveID = getEffectiveSpellId(firstID)
	local secondEffectiveID = getEffectiveSpellId(secondID)
	return (firstEffectiveID and firstEffectiveID == secondID)
		or (secondEffectiveID and secondEffectiveID == firstID)
		or (firstEffectiveID and secondEffectiveID and firstEffectiveID == secondEffectiveID)
		or false
end

function CooldownPanels:EnsureTalentChoiceSpellVariantGroupsLoaded() return cdp.RUNTIME.EnsureTalentChoiceSpellVariantGroupsLoaded(self) end

function CooldownPanels:ResolveTrackedSpellID(spellId) return cdp.RUNTIME.ResolveTrackedSpellID(self, spellId) end

function CooldownPanels:GetSpellAliasIDs(spellId, outIds, seenIds, options) return cdp.RUNTIME.GetSpellAliasIDs(self, spellId, outIds, seenIds, options) end

function CooldownPanels:AreSpellVariantsEquivalent(firstSpellId, secondSpellId) return cdp.RUNTIME.AreSpellVariantsEquivalent(self, firstSpellId, secondSpellId) end

function CooldownPanels:GetStaticCanonicalSpellVariantID(spellId)
	self:EnsureStaticSpellVariantGroupsLoaded()
	local numericID = tonumber(spellId)
	if not numericID then return nil, false, nil end
	local baseSpellID = getBaseSpellId(numericID) or numericID
	local group = self:GetStaticSpellVariantGroupForSpell(numericID)
	if not group or type(group) ~= "table" or #group == 0 then return baseSpellID, false, nil end
	local canonicalID = tonumber(group[1]) or baseSpellID
	return canonicalID, canonicalID ~= baseSpellID, group
end

function CooldownPanels:GetRuntimeCanonicalSpellVariantID(spellId)
	local numericID = tonumber(spellId)
	if not numericID then return nil, false, nil end
	local baseSpellID = getBaseSpellId(numericID) or numericID
	local dynamicGroups = self:GetActiveTalentChoiceGroupsForSpell(numericID)
	if type(dynamicGroups) == "table" then
		local group = dynamicGroups[1]
		if group then
			local canonicalID = tonumber(group.canonicalID) or baseSpellID
			return canonicalID, canonicalID ~= baseSpellID, group
		end
	end
	return self:GetStaticCanonicalSpellVariantID(numericID)
end

function CooldownPanels:GetCanonicalSpellVariantID(spellId, options)
	if type(options) == "table" and options.staticOnly == true then
		return self:GetStaticCanonicalSpellVariantID(spellId)
	end
	return self:GetRuntimeCanonicalSpellVariantID(spellId)
end

function CooldownPanels:ResolveStaticKnownSpellVariantID(spellId)
	self:EnsureStaticSpellVariantGroupsLoaded()
	local numericID = tonumber(spellId)
	if not numericID then return nil, false, nil end
	local baseSpellID = getBaseSpellId(numericID) or numericID
	local group = self:GetStaticSpellVariantGroupForSpell(numericID)
	if not group or type(group) ~= "table" or #group == 0 then return baseSpellID, false, nil end
	if Api.IsSpellKnown and Api.IsSpellKnown(baseSpellID, false) then return baseSpellID, false, group end
	for i = 1, #group do
		local candidateID = tonumber(group[i])
		if candidateID and candidateID > 0 and Api.IsSpellKnown and Api.IsSpellKnown(candidateID, false) then return candidateID, candidateID ~= baseSpellID, group end
	end
	return baseSpellID, false, group
end

function CooldownPanels:ResolveActiveTalentChoiceVariantID(spellId)
	local numericID = tonumber(spellId)
	if not numericID then return nil, false, nil end
	local dynamicGroups = self:GetActiveTalentChoiceGroupsForSpell(numericID)
	if type(dynamicGroups) ~= "table" then return nil, false, nil end
	for i = 1, #dynamicGroups do
		local group = dynamicGroups[i]
		local selectedID = group and tonumber(group.selectedSpellID) or nil
		if selectedID and selectedID > 0 then return selectedID, selectedID ~= numericID, group end
	end
	return nil, false, nil
end

function CooldownPanels:ResolveRuntimeSpellVariantID(spellId)
	local dynamicID, dynamicChanged, dynamicGroup = self:ResolveActiveTalentChoiceVariantID(spellId)
	if dynamicID then return dynamicID, dynamicChanged, dynamicGroup end
	return self:ResolveStaticKnownSpellVariantID(spellId)
end

function CooldownPanels:ResolveKnownSpellVariantID(spellId, options)
	if type(options) == "table" and options.staticOnly == true then
		return self:ResolveStaticKnownSpellVariantID(spellId)
	end
	return self:ResolveRuntimeSpellVariantID(spellId)
end

function CooldownPanels:NormalizePersistentSpellID(spellId, options)
	local numericID = tonumber(spellId)
	if not numericID then return spellId end
	local allowTalentChoiceCanonical = type(options) == "table" and options.allowTalentChoiceCanonical == true
	if allowTalentChoiceCanonical then
		local dynamicGroups = self:GetActiveTalentChoiceGroupsForSpell(numericID)
		if type(dynamicGroups) == "table" then
			for i = 1, #dynamicGroups do
				local canonicalID = tonumber(dynamicGroups[i] and dynamicGroups[i].canonicalID)
				if canonicalID and canonicalID > 0 then return canonicalID end
			end
		end
	end
	local canonicalID, _, staticGroup = self:GetStaticCanonicalSpellVariantID(numericID)
	if staticGroup then return canonicalID end
	return numericID
end

local function isSpellPassiveSafe(spellId, effectiveId)
	if not spellId or not Api.IsSpellPassiveFn then return false end
	local checkId = effectiveId or getEffectiveSpellId(spellId) or spellId
	if Api.IsSpellPassiveFn(checkId) then return true end
	if checkId ~= spellId and Api.IsSpellPassiveFn(spellId) then return true end
	return false
end

local function shouldTrackPassiveSpell(entry)
	return entry and entry.trackPassiveSpell == true
end

local function setPowerInsufficient(runtime, spellId, isUsable, insufficientPower)
	if not runtime or not spellId then return false end
	runtime.powerInsufficient = runtime.powerInsufficient or {}
	runtime.spellUnusable = runtime.spellUnusable or {}
	local usable = isUsable == true
	local powerValue = (insufficientPower == true) and true or nil
	local unusableValue = (not usable and insufficientPower ~= true) and true or nil
	local changed = false
	local ids = CooldownPanels:GetSpellAliasIDs(spellId, {}, {})
	for i = 1, #ids do
		local id = ids[i]
		if runtime.powerInsufficient[id] ~= powerValue then
			runtime.powerInsufficient[id] = powerValue
			changed = true
		end
		if runtime.spellUnusable[id] ~= unusableValue then
			runtime.spellUnusable[id] = unusableValue
			changed = true
		end
	end
	return changed
end

local function getSpellPowerCostNamesFromCosts(costs)
	if type(costs) ~= "table" then return nil end
	local names, seen = {}, {}
	for _, info in ipairs(costs) do
		local name = info and info.name
		if type(name) == "string" and name ~= "" then
			local upperName = string.upper(name)
			if upperName ~= "" and not seen[upperName] then
				seen[upperName] = true
				names[#names + 1] = upperName
			end
		end
		local powerType = info and tonumber(info.type)
		local token = powerType and CooldownPanels.POWER_TYPE_TOKEN_BY_ID and CooldownPanels.POWER_TYPE_TOKEN_BY_ID[powerType] or nil
		if token and token ~= "" and not seen[token] then
			seen[token] = true
			names[#names + 1] = token
		end
	end
	if #names == 0 then return nil end
	return names
end

local function getSpellPowerCostNames(spellId)
	if not spellId or not Api.GetSpellPowerCost then return nil end
	return getSpellPowerCostNamesFromCosts(Api.GetSpellPowerCost(spellId))
end

local function getRuntime(panelId)
	local runtime = CooldownPanels.runtime[panelId]
	if not runtime then
		runtime = {}
		CooldownPanels.runtime[panelId] = runtime
	end
	return runtime
end

local PanelVisibility = (function()
	local optionCache

	local function copySelectionMap(selection)
		if type(selection) ~= "table" then return nil end
		local out
		for key, value in pairs(selection) do
			if value == true then
				out = out or {}
				out[key] = true
			end
		end
		return out
	end

	local function normalizeConfig(config)
		local normalize = addon.functions and addon.functions.NormalizeActionbarVisibilityConfig
		if normalize then return normalize(config) end
		return nil
	end

	local function getRuleOptions()
		if optionCache then return optionCache end
		local getOptions = addon.functions and addon.functions.GetActionbarVisibilityRuleOptions
		optionCache = getOptions and getOptions() or {}
		return optionCache
	end

	local function shouldShow(config)
		local cfg = normalizeConfig(config)
		if not cfg then return true end
		local shouldShowConfig = addon.functions and addon.functions.ShouldShowVisibilityConfig
		if shouldShowConfig then
			return shouldShowConfig(cfg, {
				supportsPlayerTargetRule = true,
				supportsPlayerCastingRule = true,
				supportsPlayerMountedRule = true,
				supportsGroupRule = true,
			})
		end
		return true
	end

	return {
		CopySelectionMap = copySelectionMap,
		NormalizeConfig = normalizeConfig,
		GetRuleOptions = getRuleOptions,
		ShouldShow = shouldShow,
	}
end)()

local updatePowerEventRegistration
local updateRangeCheckSpells
local updateItemCountCacheForItem
local ensureRoot
local ensurePanelAnchor
local panelAllowsSpec
local ensureAssistedHighlightHook

local STRATA_INDEX = {}
for index, strata in ipairs(Helper.STRATA_ORDER or {}) do
	if type(strata) == "string" and strata ~= "" then STRATA_INDEX[strata] = index end
end

local function getLayoutEditPanelHandleStrata(strata)
	local targetStrata = Helper.NormalizeStrata(strata, Helper.PANEL_LAYOUT_DEFAULTS.strata)
	local overlayStrata = Helper.NormalizeStrata("TOOLTIP", targetStrata)
	local targetIndex = STRATA_INDEX[targetStrata] or 0
	local overlayIndex = STRATA_INDEX[overlayStrata] or targetIndex
	if overlayIndex > targetIndex then return overlayStrata end
	return targetStrata
end

local function syncLayoutSelectionStrata(frame)
	if not (frame and frame.GetFrameStrata) then return end
	local selection = frame.Selection
	local supportsSelectionStrata = selection and selection.SetFrameStrata
	if supportsSelectionStrata and not frame._eqolSelectionBaseStrata then
		local baseStrata = Helper.NormalizeStrata((selection.GetFrameStrata and selection:GetFrameStrata()) or "MEDIUM", "MEDIUM")
		frame._eqolSelectionBaseStrata = baseStrata
		frame._eqolSelectionBaseStrataIndex = STRATA_INDEX[baseStrata] or STRATA_INDEX.MEDIUM or 3
	end
	local baseStrata = frame._eqolSelectionBaseStrata
	local baseIndex = frame._eqolSelectionBaseStrataIndex
	if not (baseStrata and baseIndex) then
		baseStrata = Helper.NormalizeStrata(frame:GetFrameStrata(), Helper.PANEL_LAYOUT_DEFAULTS.strata)
		baseIndex = STRATA_INDEX[baseStrata] or STRATA_INDEX.MEDIUM or 3
	end
	local currentStrata = Helper.NormalizeStrata(frame:GetFrameStrata(), baseStrata)
	local currentIndex = STRATA_INDEX[currentStrata]
	local targetStrata = (currentIndex and currentIndex > baseIndex) and currentStrata or baseStrata
	local anchorLevel = (selection and selection.GetFrameLevel and selection:GetFrameLevel()) or frame:GetFrameLevel()
	if supportsSelectionStrata and selection.GetFrameStrata and selection:GetFrameStrata() ~= targetStrata then selection:SetFrameStrata(targetStrata) end
	if supportsSelectionStrata and frame.editMoveHandle then
		frame.editMoveHandle:SetFrameStrata(targetStrata)
		frame.editMoveHandle:SetFrameLevel(anchorLevel + 20)
	end
	if frame.editPanelHandle then
		frame.editPanelHandle:SetFrameStrata(getLayoutEditPanelHandleStrata(targetStrata))
		frame.editPanelHandle:SetFrameLevel(anchorLevel + 21)
	end
	if supportsSelectionStrata and frame.editDropZone then
		frame.editDropZone:SetFrameStrata(frame:GetFrameStrata())
		frame.editDropZone:SetFrameLevel(frame:GetFrameLevel())
	end
end

local function refreshLayoutPanelFrame(panelId)
	local runtime = panelId and getRuntime(panelId) or nil
	syncLayoutSelectionStrata(runtime and runtime.frame)
end

local function refreshStandaloneSettingValues()
	local internal = addon.EditModeLib and addon.EditModeLib.internal
	if not internal then return end
	if internal.RequestRefreshSettingValues then
		internal:RequestRefreshSettingValues()
	elseif internal.RefreshSettingValues then
		internal:RefreshSettingValues()
	end
end

local function refreshStandaloneSettings()
	local lib = addon.EditModeLib
	if not (lib and lib.internal) then return end
	if lib.internal.RequestRefreshSettings then
		lib.internal:RequestRefreshSettings()
	elseif lib.internal.RefreshSettings then
		lib.internal:RefreshSettings()
	end
	if lib.internal.RefreshSettingValues then lib.internal:RefreshSettingValues() end
end

CooldownPanels.MarkRelativeFrameEntriesDirty = function()
	CooldownPanels.runtime = CooldownPanels.runtime or {}
	local runtime = CooldownPanels.runtime
	runtime._eqolRelativeFrameEntriesVersion = (runtime._eqolRelativeFrameEntriesVersion or 0) + 1
end

local fakeCursorFrame
local fakeCursorResetOnShow = true
local cursorFollowRunner
local cursorSpecRetryPending

local function ensureFakeCursorFrame()
	if fakeCursorFrame then return fakeCursorFrame end
	local frame = CreateFrame("Frame", cdp.FAKE_CURSOR.FRAME_NAME, UIParent)
	frame:SetSize(cdp.FAKE_CURSOR.SIZE, cdp.FAKE_CURSOR.SIZE)
	frame:SetFrameStrata("TOOLTIP")
	frame:SetClampedToScreen(true)
	frame:EnableMouse(true)
	frame:SetMovable(true)
	frame:RegisterForDrag("LeftButton")
	frame:SetScript("OnDragStart", frame.StartMoving)
	frame:SetScript("OnDragStop", frame.StopMovingOrSizing)

	local tex = frame:CreateTexture(nil, "OVERLAY")
	tex:SetAtlas(cdp.FAKE_CURSOR.ATLAS, true)
	tex:ClearAllPoints()
	tex:SetSize(cdp.FAKE_CURSOR.SIZE, cdp.FAKE_CURSOR.SIZE)
	tex:SetPoint("CENTER", frame, "CENTER", (cdp.FAKE_CURSOR.SIZE * 0.5) - cdp.FAKE_CURSOR.HOTSPOT_X, cdp.FAKE_CURSOR.HOTSPOT_Y - (cdp.FAKE_CURSOR.SIZE * 0.5))
	frame.texture = tex

	frame:Hide()
	fakeCursorFrame = frame
	return frame
end

local function showFakeCursorFrame()
	local frame = ensureFakeCursorFrame()
	if fakeCursorResetOnShow or not frame._eqolHasPosition then
		frame:ClearAllPoints()
		frame:SetPoint("CENTER", UIParent, "CENTER", cdp.FAKE_CURSOR.DEFAULT_X, cdp.FAKE_CURSOR.DEFAULT_Y)
		frame._eqolHasPosition = true
		fakeCursorResetOnShow = false
	end
	frame:Show()
	return frame
end

local function hideFakeCursorFrame()
	if fakeCursorFrame then
		fakeCursorFrame:SetAlpha(0)
		if fakeCursorFrame.texture then fakeCursorFrame.texture:Hide() end
		fakeCursorFrame:EnableMouse(false)
		fakeCursorFrame:Hide()
	end
end

local function resetFakeCursorFrame()
	fakeCursorResetOnShow = true
	if fakeCursorFrame then fakeCursorFrame._eqolHasPosition = nil end
end

local function setFakeCursorMode(mode)
	-- Always re-apply the visual state because layout edit paths can manipulate
	-- the fake cursor frame directly between mode transitions.
	if mode ~= "edit" and mode ~= "follow" then mode = "hidden" end
	if mode == "hidden" then
		hideFakeCursorFrame()
		return
	end
	local frame = ensureFakeCursorFrame()
	if mode == "edit" then
		showFakeCursorFrame()
		frame:SetAlpha(1)
		if frame.texture then frame.texture:Show() end
		frame:EnableMouse(true)
		frame:Show()
	elseif mode == "follow" then
		fakeCursorResetOnShow = false
		frame:SetAlpha(0)
		if frame.texture then frame.texture:Hide() end
		frame:EnableMouse(false)
		frame:Show()
	end
end

local function updateFakeCursorToMouse()
	local frame = ensureFakeCursorFrame()
	local x, y = Api.GetCursorPosition()
	if not x or not y then return end
	local scale = UIParent:GetEffectiveScale()
	frame:ClearAllPoints()
	frame:SetPoint("CENTER", UIParent, "BOTTOMLEFT", x / scale, y / scale)
end

local function startCursorFollow()
	if cursorFollowRunner and cursorFollowRunner:GetScript("OnUpdate") then return end
	if CooldownPanels:IsAnyPanelLayoutEditActive() then return end
	local runner = cursorFollowRunner
	if not runner then
		runner = CreateFrame("Frame")
		cursorFollowRunner = runner
	end
	updateFakeCursorToMouse()
	runner:SetScript("OnUpdate", function(self)
		if CooldownPanels:IsAnyPanelLayoutEditActive() then return end
		updateFakeCursorToMouse()
	end)
end

local function stopCursorFollow()
	if cursorFollowRunner then cursorFollowRunner:SetScript("OnUpdate", nil) end
end

local function panelUsesFakeCursor(panel)
	local anchor = ensurePanelAnchor(panel)
	local rel = anchor and anchor.relativeFrame
	return rel == cdp.FAKE_CURSOR.FRAME_NAME
end

CooldownPanels.IsPanelRuntimeEnabled = function(panelId, panel, runtime)
	if not panel or panel.enabled == false then return false end
	local sharedRuntime = runtime or CooldownPanels.runtime
	local enabledPanels = sharedRuntime and sharedRuntime.enabledPanels
	if enabledPanels then return enabledPanels[panelId] == true end
	return panelAllowsSpec(panel)
end

local function hasSpecFilteredCursorPanels()
	local root = ensureRoot()
	if not root or not root.panels then return false end
	for _, panel in pairs(root.panels) do
		if panel and panelUsesFakeCursor(panel) and panel.enabled ~= false then
			local filter = panel.specFilter
			if type(filter) == "table" then
				for _, enabled in pairs(filter) do
					if enabled then return true end
				end
			end
		end
	end
	return false
end

local function scheduleCursorSpecRetry()
	if cursorSpecRetryPending then return end
	if not C_Timer or not C_Timer.After then return end
	cursorSpecRetryPending = true
	C_Timer.After(0.5, function()
		cursorSpecRetryPending = false
		if not hasSpecFilteredCursorPanels() then return end
		if queryPlayerSpecId() then
			CooldownPanels:UpdateCursorAnchorState()
			return
		end
		scheduleCursorSpecRetry()
	end)
end

local function hasFakeCursorPanels()
	local root = ensureRoot()
	if not root or not root.panels then return false end
	for _, panel in pairs(root.panels) do
		if panel and panelUsesFakeCursor(panel) then
			if panel.enabled ~= false and panelAllowsSpec(panel) then return true end
		end
	end
	return false
end

function CooldownPanels:GetLayoutEditFakeCursorPanel()
	local runtime = CooldownPanels.runtime
	local editor = runtime and runtime.editor or nil
	if not (editor and editor.frame and editor.frame:IsShown()) then return nil end
	if editor.layoutEditActive ~= true then return nil end
	local panelId = normalizeId(editor.selectedPanelId)
	if not panelId then return nil end
	local panel = CooldownPanels:GetPanel(panelId)
	if panel and panelUsesFakeCursor(panel) then return panelId, panel end
	return nil
end

function CooldownPanels:UpdateCursorAnchorState()
	local layoutEditCursorPanelId = self:GetLayoutEditFakeCursorPanel()
	local wantsCursor = layoutEditCursorPanelId ~= nil or hasFakeCursorPanels()
	if wantsCursor then
		if layoutEditCursorPanelId then
			stopCursorFollow()
			setFakeCursorMode("edit")
			local frame = showFakeCursorFrame()
			frame:SetAlpha(1)
			if frame.texture then frame.texture:Show() end
			frame:EnableMouse(false)
			frame:Show()
			local runtime = getRuntime(layoutEditCursorPanelId)
			CooldownPanels.ClearAppliedAnchorCache(runtime)
			self:ApplyPanelPosition(layoutEditCursorPanelId)
		else
			setFakeCursorMode("follow")
			startCursorFollow()
		end
		cursorSpecRetryPending = false
	else
		stopCursorFollow()
		setFakeCursorMode("hidden")
		if hasSpecFilteredCursorPanels() and not queryPlayerSpecId() then scheduleCursorSpecRetry() end
	end
end

function CooldownPanels:GetGlowStyleOptions(panelId) return Helper.GLOW_STYLE_OPTIONS or {} end

cdp.ICON_BORDER = cdp.ICON_BORDER or {
	DEFAULT = "DEFAULT",
	BLIZZARD = "BLIZZARD",
	BLIZZARD_ALIAS = "ORIGINAL_BLIZZARD",
	OVERLAY_ATLAS = "UI-HUD-CoolDownManager-IconOverlay",
	MASK_ATLAS = "UI-HUD-CoolDownManager-Mask",
	SWIPE_TEXTURE = "Interface\\HUD\\UI-HUD-CoolDownManager-Icon-Swipe",
	OVERLAY_OFFSET_X_RATIO = 0.175,
	OVERLAY_OFFSET_Y_RATIO = 0.175,
	OVERLAY_OFFSET_X_NUDGE = 0,
	COOLDOWN_INSET_RATIO = 0.055,
	ICON_BOTTOM_INSET = 2,
}

local function iconBorderOptions()
	local list = {}
	local seen = {}
	local function add(value, label)
		local lv = tostring(value or ""):lower()
		if lv == "" or seen[lv] then return end
		seen[lv] = true
		list[#list + 1] = { value = value, label = label or value }
	end
	add(cdp.ICON_BORDER.DEFAULT, _G.DEFAULT or (L and L["Default"]) or "Default")
	add(cdp.ICON_BORDER.BLIZZARD, L["CooldownPanelIconBorderBlizzard"] or "Original Blizzard")
	local names = addon.functions and addon.functions.GetLSMMediaNames and addon.functions.GetLSMMediaNames("border") or {}
	local hash = addon.functions and addon.functions.GetLSMMediaHash and addon.functions.GetLSMMediaHash("border") or {}
	for i = 1, #names do
		local name = names[i]
		local path = hash[name]
		if type(path) == "string" and path ~= "" then add(name, tostring(name)) end
	end
	return list
end

local function normalizeIconBorderTexture(value, fallback)
	if type(value) == "string" and value ~= "" then
		local upperValue = strupper(value)
		if upperValue == cdp.ICON_BORDER.BLIZZARD or upperValue == cdp.ICON_BORDER.BLIZZARD_ALIAS then return cdp.ICON_BORDER.BLIZZARD end
		return value
	end
	if type(fallback) == "string" and fallback ~= "" then return fallback end
	return cdp.ICON_BORDER.DEFAULT
end

function cdp.ENTRY.IsBlizzardIconBorderTexture(value)
	return normalizeIconBorderTexture(value, cdp.ICON_BORDER.DEFAULT) == cdp.ICON_BORDER.BLIZZARD
end

local function resolveIconBorderTexture(value)
	local key = normalizeIconBorderTexture(value, cdp.ICON_BORDER.DEFAULT)
	local ufHelper = addon.Aura and addon.Aura.UFHelper
	if ufHelper and ufHelper.resolveBorderTexture then return ufHelper.resolveBorderTexture(key) end
	if not key or key == "" or key == cdp.ICON_BORDER.DEFAULT then return "Interface\\Buttons\\WHITE8x8" end
	if LSM and LSM.Fetch then
		local tex = LSM:Fetch("border", key)
		if type(tex) == "string" and tex ~= "" then return tex end
	end
	return key
end

local getEditor
local applyEditLayout
local refreshPanelsForSpell
local refreshPanelsForCharges
local normalizedRoots = setmetatable({}, { __mode = "k" })
local normalizedPanels = setmetatable({}, { __mode = "k" })
local COOLDOWN_PANELS_STORAGE_VERSION = 2
CooldownPanels._styleCacheRoots = CooldownPanels._styleCacheRoots
	or {
		cooldownTextPanel = setmetatable({}, { __mode = "k" }),
		cooldownTextEntry = setmetatable({}, { __mode = "k" }),
		stackTextPanel = setmetatable({}, { __mode = "k" }),
		stackTextEntry = setmetatable({}, { __mode = "k" }),
		chargesTextPanel = setmetatable({}, { __mode = "k" }),
		chargesTextEntry = setmetatable({}, { __mode = "k" }),
		glowPanel = setmetatable({}, { __mode = "k" }),
		glowEntry = setmetatable({}, { __mode = "k" }),
		pandemicGlowPanel = setmetatable({}, { __mode = "k" }),
		pandemicGlowEntry = setmetatable({}, { __mode = "k" }),
		procGlowPanel = setmetatable({}, { __mode = "k" }),
		procGlowEntry = setmetatable({}, { __mode = "k" }),
		iconLayoutEntry = setmetatable({}, { __mode = "k" }),
	}
CooldownPanels.POWER_USABLE_REFRESH_DELAY = CooldownPanels.POWER_USABLE_REFRESH_DELAY or 0.05

function CooldownPanels.FillCachedColor(cache, r, g, b, a)
	cache = cache or {}
	cache[1] = r or 1
	cache[2] = g or 1
	cache[3] = b or 1
	cache[4] = a or 1
	return cache
end

local function clearRuntimeLayoutShapeCache(runtime)
	if not runtime then return end
	runtime._eqolLastLayoutCount = nil
	runtime._eqolLayoutIconSize = nil
	runtime._eqolLayoutIconSizeSeparate = nil
	runtime._eqolLayoutIconWidth = nil
	runtime._eqolLayoutIconHeight = nil
	runtime._eqolLayoutSpacing = nil
	runtime._eqolLayoutMode = nil
	runtime._eqolLayoutDirection = nil
	runtime._eqolLayoutWrapCount = nil
	runtime._eqolLayoutWrapDirection = nil
	runtime._eqolLayoutGrowthPoint = nil
	runtime._eqolLayoutRadialRadius = nil
	runtime._eqolLayoutRadialRotation = nil
	runtime._eqolLayoutRowSize1 = nil
	runtime._eqolLayoutRowSize2 = nil
	runtime._eqolLayoutRowSize3 = nil
	runtime._eqolLayoutRowSize4 = nil
	runtime._eqolLayoutRowSize5 = nil
	runtime._eqolLayoutRowSize6 = nil
	runtime._eqolLayoutIconBorderEnabled = nil
	runtime._eqolLayoutIconBorderTexture = nil
	runtime._eqolLayoutIconBorderSize = nil
	runtime._eqolLayoutIconBorderOffset = nil
	runtime._eqolLayoutIconBorderColorR = nil
	runtime._eqolLayoutIconBorderColorG = nil
	runtime._eqolLayoutIconBorderColorB = nil
	runtime._eqolLayoutIconBorderColorA = nil
end

function CooldownPanels:InvalidateAllPanelLayoutShapeCaches()
	local runtime = self.runtime
	if not runtime then return end
	for panelId, panelRuntime in pairs(runtime) do
		if type(panelId) == "number" and type(panelRuntime) == "table" then clearRuntimeLayoutShapeCache(panelRuntime) end
	end
end

CooldownPanels.ClearRuntimeResolvedLayoutCache = function(runtime)
	if not runtime then return end
	runtime._eqolResolvedRuntimeLayout = nil
end

CooldownPanels.ClearAppliedAnchorCache = function(runtime)
	if not runtime then return end
	runtime._eqolAnchorAppliedFrame = nil
	runtime._eqolAnchorPoint = nil
	runtime._eqolAnchorRelativePoint = nil
	runtime._eqolAnchorRelativeFrame = nil
	runtime._eqolAnchorX = nil
	runtime._eqolAnchorY = nil
end

local function didLayoutShapeChange(runtime, layout, layoutCount)
	if not runtime then return true end
	if not layout then
		local changed = runtime._eqolLastLayoutCount ~= layoutCount
		runtime._eqolLastLayoutCount = layoutCount
		return changed
	end
	local rowSizes = layout.rowSizes
	local rowSize1 = rowSizes and rowSizes[1] or nil
	local rowSize2 = rowSizes and rowSizes[2] or nil
	local rowSize3 = rowSizes and rowSizes[3] or nil
	local rowSize4 = rowSizes and rowSizes[4] or nil
	local rowSize5 = rowSizes and rowSizes[5] or nil
	local rowSize6 = rowSizes and rowSizes[6] or nil
	local fixedGridColumns = layout.fixedGridColumns
	local fixedGridRows = layout.fixedGridRows
	local borderColor = layout.iconBorderColor
	local borderColorR = borderColor and (borderColor.r or borderColor[1]) or nil
	local borderColorG = borderColor and (borderColor.g or borderColor[2]) or nil
	local borderColorB = borderColor and (borderColor.b or borderColor[3]) or nil
	local borderColorA = borderColor and (borderColor.a or borderColor[4]) or nil
	if
		runtime._eqolLastLayoutCount == layoutCount
		and runtime._eqolLayoutIconSize == layout.iconSize
		and runtime._eqolLayoutIconSizeSeparate == layout.iconSizeSeparate
		and runtime._eqolLayoutIconWidth == layout.iconWidth
		and runtime._eqolLayoutIconHeight == layout.iconHeight
		and runtime._eqolLayoutSpacing == layout.spacing
		and runtime._eqolLayoutMode == layout.layoutMode
		and runtime._eqolLayoutDirection == layout.direction
		and runtime._eqolLayoutWrapCount == layout.wrapCount
		and runtime._eqolLayoutWrapDirection == layout.wrapDirection
		and runtime._eqolLayoutGrowthPoint == layout.growthPoint
		and runtime._eqolLayoutRadialRadius == layout.radialRadius
		and runtime._eqolLayoutRadialRotation == layout.radialRotation
		and runtime._eqolLayoutRadialArcDegrees == layout.radialArcDegrees
		and runtime._eqolLayoutRowSize1 == rowSize1
		and runtime._eqolLayoutRowSize2 == rowSize2
		and runtime._eqolLayoutRowSize3 == rowSize3
		and runtime._eqolLayoutRowSize4 == rowSize4
		and runtime._eqolLayoutRowSize5 == rowSize5
		and runtime._eqolLayoutRowSize6 == rowSize6
		and runtime._eqolLayoutFixedGridColumns == fixedGridColumns
		and runtime._eqolLayoutFixedGridRows == fixedGridRows
		and runtime._eqolLayoutIconBorderEnabled == layout.iconBorderEnabled
		and runtime._eqolLayoutIconBorderTexture == layout.iconBorderTexture
		and runtime._eqolLayoutIconBorderSize == layout.iconBorderSize
		and runtime._eqolLayoutIconBorderOffset == layout.iconBorderOffset
		and runtime._eqolLayoutIconBorderColorR == borderColorR
		and runtime._eqolLayoutIconBorderColorG == borderColorG
		and runtime._eqolLayoutIconBorderColorB == borderColorB
		and runtime._eqolLayoutIconBorderColorA == borderColorA
	then
		return false
	end
	runtime._eqolLastLayoutCount = layoutCount
	runtime._eqolLayoutIconSize = layout.iconSize
	runtime._eqolLayoutIconSizeSeparate = layout.iconSizeSeparate
	runtime._eqolLayoutIconWidth = layout.iconWidth
	runtime._eqolLayoutIconHeight = layout.iconHeight
	runtime._eqolLayoutSpacing = layout.spacing
	runtime._eqolLayoutMode = layout.layoutMode
	runtime._eqolLayoutDirection = layout.direction
	runtime._eqolLayoutWrapCount = layout.wrapCount
	runtime._eqolLayoutWrapDirection = layout.wrapDirection
	runtime._eqolLayoutGrowthPoint = layout.growthPoint
	runtime._eqolLayoutRadialRadius = layout.radialRadius
	runtime._eqolLayoutRadialRotation = layout.radialRotation
	runtime._eqolLayoutRadialArcDegrees = layout.radialArcDegrees
	runtime._eqolLayoutRowSize1 = rowSize1
	runtime._eqolLayoutRowSize2 = rowSize2
	runtime._eqolLayoutRowSize3 = rowSize3
	runtime._eqolLayoutRowSize4 = rowSize4
	runtime._eqolLayoutRowSize5 = rowSize5
	runtime._eqolLayoutRowSize6 = rowSize6
	runtime._eqolLayoutFixedGridColumns = fixedGridColumns
	runtime._eqolLayoutFixedGridRows = fixedGridRows
	runtime._eqolLayoutIconBorderEnabled = layout.iconBorderEnabled
	runtime._eqolLayoutIconBorderTexture = layout.iconBorderTexture
	runtime._eqolLayoutIconBorderSize = layout.iconBorderSize
	runtime._eqolLayoutIconBorderOffset = layout.iconBorderOffset
	runtime._eqolLayoutIconBorderColorR = borderColorR
	runtime._eqolLayoutIconBorderColorG = borderColorG
	runtime._eqolLayoutIconBorderColorB = borderColorB
	runtime._eqolLayoutIconBorderColorA = borderColorA
	return true
end

CooldownPanels.ResolveRuntimeLayout = function(runtime, frame, layout)
	if not runtime or not frame then return nil end
	local defaults = Helper.PANEL_LAYOUT_DEFAULTS
	local cache = runtime._eqolResolvedRuntimeLayout
	local readyGlowColor = layout.readyGlowColor
	local readyGlowR = readyGlowColor and (readyGlowColor.r or readyGlowColor[1]) or nil
	local readyGlowG = readyGlowColor and (readyGlowColor.g or readyGlowColor[2]) or nil
	local readyGlowB = readyGlowColor and (readyGlowColor.b or readyGlowColor[3]) or nil
	local readyGlowA = readyGlowColor and (readyGlowColor.a or readyGlowColor[4]) or nil
	local powerTintColor = layout.powerTintColor
	local powerTintSrcR = powerTintColor and (powerTintColor.r or powerTintColor[1]) or nil
	local powerTintSrcG = powerTintColor and (powerTintColor.g or powerTintColor[2]) or nil
	local powerTintSrcB = powerTintColor and (powerTintColor.b or powerTintColor[3]) or nil
	local powerTintSrcA = powerTintColor and (powerTintColor.a or powerTintColor[4]) or nil
	local unusableTintColor = layout.unusableTintColor
	local unusableTintSrcR = unusableTintColor and (unusableTintColor.r or unusableTintColor[1]) or nil
	local unusableTintSrcG = unusableTintColor and (unusableTintColor.g or unusableTintColor[2]) or nil
	local unusableTintSrcB = unusableTintColor and (unusableTintColor.b or unusableTintColor[3]) or nil
	local unusableTintSrcA = unusableTintColor and (unusableTintColor.a or unusableTintColor[4]) or nil
	local rangeOverlayColor = layout.rangeOverlayColor
	local rangeOverlaySrcR = rangeOverlayColor and (rangeOverlayColor.r or rangeOverlayColor[1]) or nil
	local rangeOverlaySrcG = rangeOverlayColor and (rangeOverlayColor.g or rangeOverlayColor[2]) or nil
	local rangeOverlaySrcB = rangeOverlayColor and (rangeOverlayColor.b or rangeOverlayColor[3]) or nil
	local rangeOverlaySrcA = rangeOverlayColor and (rangeOverlayColor.a or rangeOverlayColor[4]) or nil

	if
		cache
		and cache.frame == frame
		and cache.showTooltipsSource == layout.showTooltips
		and cache.keybindsEnabledSource == layout.keybindsEnabled
		and cache.showIconTextureSource == layout.showIconTexture
		and cache.noDesaturationSource == layout.noDesaturation
		and cache.checkPowerSource == layout.checkPower
		and cache.readyGlowRSource == readyGlowR
		and cache.readyGlowGSource == readyGlowG
		and cache.readyGlowBSource == readyGlowB
		and cache.readyGlowASource == readyGlowA
		and cache.powerTintRSource == powerTintSrcR
		and cache.powerTintGSource == powerTintSrcG
		and cache.powerTintBSource == powerTintSrcB
		and cache.powerTintASource == powerTintSrcA
		and cache.unusableTintRSource == unusableTintSrcR
		and cache.unusableTintGSource == unusableTintSrcG
		and cache.unusableTintBSource == unusableTintSrcB
		and cache.unusableTintASource == unusableTintSrcA
		and cache.rangeOverlayEnabledSource == layout.rangeOverlayEnabled
		and cache.rangeOverlayRSource == rangeOverlaySrcR
		and cache.rangeOverlayGSource == rangeOverlaySrcG
		and cache.rangeOverlayBSource == rangeOverlaySrcB
		and cache.rangeOverlayASource == rangeOverlaySrcA
		and cache.cooldownDrawEdgeSource == layout.cooldownDrawEdge
		and cache.cooldownDrawBlingSource == layout.cooldownDrawBling
		and cache.cooldownDrawSwipeSource == layout.cooldownDrawSwipe
		and cache.hideOnCooldownSource == layout.hideOnCooldown
		and cache.showOnCooldownSource == layout.showOnCooldown
		and cache.cooldownGcdDrawEdgeSource == layout.cooldownGcdDrawEdge
		and cache.cooldownGcdDrawBlingSource == layout.cooldownGcdDrawBling
		and cache.cooldownGcdDrawSwipeSource == layout.cooldownGcdDrawSwipe
	then
		return cache
	end

	local showTooltips = layout.showTooltips == true
	local showKeybinds = layout.keybindsEnabled == true
	local showIconTexture = layout.showIconTexture ~= false
	local noDesaturation = layout.noDesaturation == true
	local checkPower = layout.checkPower == true
	local staticFontPath, staticFontSize, staticFontStyle = Helper.GetCountFontDefaults(frame)
	local normalizedReadyGlow = Helper.NormalizeColor(layout.readyGlowColor, defaults.readyGlowColor)
	local powerTintR, powerTintG, powerTintB = Helper.ResolveColor(layout.powerTintColor, defaults.powerTintColor)
	local unusableTintR, unusableTintG, unusableTintB = Helper.ResolveColor(layout.unusableTintColor, defaults.unusableTintColor)
	local rangeOverlayEnabled = layout.rangeOverlayEnabled == true
	local rangeOverlayR, rangeOverlayG, rangeOverlayB, rangeOverlayA = Helper.ResolveColor(layout.rangeOverlayColor, defaults.rangeOverlayColor)
	local drawEdge = layout.cooldownDrawEdge ~= false
	local drawBling = layout.cooldownDrawBling ~= false
	local drawSwipe = layout.cooldownDrawSwipe ~= false
	local hideOnCooldown = layout.hideOnCooldown == true
	local showOnCooldown = layout.showOnCooldown == true
	if showOnCooldown then hideOnCooldown = false end
	local gcdDrawEdge = layout.cooldownGcdDrawEdge == true
	local gcdDrawBling = layout.cooldownGcdDrawBling == true
	local gcdDrawSwipe = layout.cooldownGcdDrawSwipe == true

	cache = cache or {}
	cache.frame = frame
	cache.showTooltipsSource = layout.showTooltips
	cache.keybindsEnabledSource = layout.keybindsEnabled
	cache.showIconTextureSource = layout.showIconTexture
	cache.noDesaturationSource = layout.noDesaturation
	cache.checkPowerSource = layout.checkPower
	cache.readyGlowRSource = readyGlowR
	cache.readyGlowGSource = readyGlowG
	cache.readyGlowBSource = readyGlowB
	cache.readyGlowASource = readyGlowA
	cache.powerTintRSource = powerTintSrcR
	cache.powerTintGSource = powerTintSrcG
	cache.powerTintBSource = powerTintSrcB
	cache.powerTintASource = powerTintSrcA
	cache.unusableTintRSource = unusableTintSrcR
	cache.unusableTintGSource = unusableTintSrcG
	cache.unusableTintBSource = unusableTintSrcB
	cache.unusableTintASource = unusableTintSrcA
	cache.rangeOverlayEnabledSource = layout.rangeOverlayEnabled
	cache.rangeOverlayRSource = rangeOverlaySrcR
	cache.rangeOverlayGSource = rangeOverlaySrcG
	cache.rangeOverlayBSource = rangeOverlaySrcB
	cache.rangeOverlayASource = rangeOverlaySrcA
	cache.cooldownDrawEdgeSource = layout.cooldownDrawEdge
	cache.cooldownDrawBlingSource = layout.cooldownDrawBling
	cache.cooldownDrawSwipeSource = layout.cooldownDrawSwipe
	cache.hideOnCooldownSource = layout.hideOnCooldown
	cache.showOnCooldownSource = layout.showOnCooldown
	cache.cooldownGcdDrawEdgeSource = layout.cooldownGcdDrawEdge
	cache.cooldownGcdDrawBlingSource = layout.cooldownGcdDrawBling
	cache.cooldownGcdDrawSwipeSource = layout.cooldownGcdDrawSwipe
	cache.showTooltips = showTooltips
	cache.showKeybinds = showKeybinds
	cache.showIconTexture = showIconTexture
	cache.noDesaturation = noDesaturation
	cache.checkPower = checkPower
	cache.staticFontPath = staticFontPath
	cache.staticFontSize = staticFontSize
	cache.staticFontStyle = staticFontStyle
	cache.readyGlowColor = cache.readyGlowColor or {}
	cache.readyGlowColor[1] = normalizedReadyGlow[1]
	cache.readyGlowColor[2] = normalizedReadyGlow[2]
	cache.readyGlowColor[3] = normalizedReadyGlow[3]
	cache.readyGlowColor[4] = normalizedReadyGlow[4]
	cache.powerTintR = powerTintR
	cache.powerTintG = powerTintG
	cache.powerTintB = powerTintB
	cache.unusableTintR = unusableTintR
	cache.unusableTintG = unusableTintG
	cache.unusableTintB = unusableTintB
	cache.rangeOverlayEnabled = rangeOverlayEnabled
	cache.rangeOverlayR = rangeOverlayR
	cache.rangeOverlayG = rangeOverlayG
	cache.rangeOverlayB = rangeOverlayB
	cache.rangeOverlayA = rangeOverlayA
	cache.drawEdge = drawEdge
	cache.drawBling = drawBling
	cache.drawSwipe = drawSwipe
	cache.hideOnCooldown = hideOnCooldown
	cache.showOnCooldown = showOnCooldown
	cache.gcdDrawEdge = gcdDrawEdge
	cache.gcdDrawBling = gcdDrawBling
	cache.gcdDrawSwipe = gcdDrawSwipe
	runtime._eqolResolvedRuntimeLayout = cache
	return cache
end

ensurePanelAnchor = function(panel)
	if not panel then return nil end
	panel.anchor = panel.anchor or {}
	local anchor = panel.anchor
	local fallbackPoint = Helper.NormalizeAnchor(panel.point, "CENTER")
	anchor.point = Helper.NormalizeAnchor(anchor.point, fallbackPoint)
	anchor.relativePoint = Helper.NormalizeAnchor(anchor.relativePoint, anchor.point)
	anchor.x = tonumber(anchor.x)
	if anchor.x == nil then anchor.x = tonumber(panel.x) or 0 end
	anchor.y = tonumber(anchor.y)
	if anchor.y == nil then anchor.y = tonumber(panel.y) or 0 end
	anchor.relativeFrame = Helper.NormalizeRelativeFrameName(anchor.relativeFrame)
	panel.point = anchor.point
	panel.x = anchor.x
	panel.y = anchor.y
	return anchor
end

local function anchorUsesUIParent(anchor) return not anchor or (anchor.relativeFrame or "UIParent") == "UIParent" end

local function isMappedUnitFrameEnabled(ufKey)
	if type(ufKey) ~= "string" or ufKey == "" then return false end
	local ufCfg = addon.db and addon.db.ufFrames
	local ufEntry = ufCfg and ufCfg[ufKey]
	if ufEntry and ufEntry.enabled == true then return true end
	local groupCfg = addon.db and addon.db.ufGroupFrames
	local groupEntry = groupCfg and groupCfg[ufKey]
	return groupEntry and groupEntry.enabled == true
end

local function maybeEnsureGroupAnchorFrame(ufKey)
	if ufKey ~= "party" and ufKey ~= "raid" and ufKey ~= "mt" and ufKey ~= "ma" then return end
	if InCombatLockdown and InCombatLockdown() then return end
	local groupFrames = addon.Aura and addon.Aura.UF and addon.Aura.UF.GroupFrames
	if groupFrames and groupFrames.EnsureHeaders then groupFrames:EnsureHeaders() end
end

local function resolveAnchorFrame(anchor)
	local relativeName = Helper.NormalizeRelativeFrameName(anchor and anchor.relativeFrame)
	if relativeName == "UIParent" then return UIParent end
	if relativeName == cdp.FAKE_CURSOR.FRAME_NAME then return ensureFakeCursorFrame() end
	local generic = Helper.GENERIC_ANCHORS[relativeName]
	if generic then
		if generic.ufKey and isMappedUnitFrameEnabled(generic.ufKey) then
			maybeEnsureGroupAnchorFrame(generic.ufKey)
			local ufFrame = _G[generic.uf]
			if ufFrame then return ufFrame end
		end
		local blizzFrame = _G[generic.blizz]
		if blizzFrame then return blizzFrame end
	end
	local anchorHelper = CooldownPanels.AnchorHelper
	if anchorHelper and anchorHelper.ResolveExternalFrame then
		local externalFrame = anchorHelper:ResolveExternalFrame(relativeName)
		if externalFrame then return externalFrame end
	end
	local frame = _G[relativeName]
	if frame then return frame end
	return UIParent
end

local function panelFrameName(panelId) return "EQOL_CooldownPanel" .. tostring(panelId) end

local function frameNameToPanelId(frameName)
	if type(frameName) ~= "string" then return nil end
	local id = frameName:match("^EQOL_CooldownPanel(%d+)$")
	return id and tonumber(id) or nil
end

CooldownPanels.WouldCauseRelativeFrameLoop = function(panelKey, candidateName, root)
	local targetId = frameNameToPanelId(candidateName)
	if not targetId then return false end
	if targetId == panelKey then return true end
	local panels = root and root.panels or nil
	local seen = {}
	local currentId = targetId
	local limit = 20
	while currentId and limit > 0 do
		if seen[currentId] then break end
		seen[currentId] = true
		if currentId == panelKey then return true end
		local other = panels and panels[currentId] or nil
		if other and root and not normalizedPanels[other] then
			Helper.NormalizePanel(other, root.defaults)
			normalizedPanels[other] = true
		end
		local otherAnchor = other and ensurePanelAnchor(other)
		currentId = frameNameToPanelId(otherAnchor and otherAnchor.relativeFrame)
		limit = limit - 1
	end
	return false
end

CooldownPanels.GetRelativeFrameCache = function(runtimePanel, panel, panelKey)
	CooldownPanels.runtime = CooldownPanels.runtime or {}
	local shared = CooldownPanels.runtime
	local version = shared._eqolRelativeFrameEntriesVersion or 0
	local anchor = ensurePanelAnchor(panel)
	local cur = Helper.NormalizeRelativeFrameName(anchor and anchor.relativeFrame)
	local root = ensureRoot()
	local cache = runtimePanel and runtimePanel._eqolRelativeFrameCache or nil
	if cache and cache.version == version and cache.root == root and cache.current == cur then return cache end
	local entries = {}
	local valid = {}
	local seen = {}
	local function add(key, label)
		if not key or key == "" or seen[key] then return end
		if CooldownPanels.WouldCauseRelativeFrameLoop(panelKey, key, root) then return end
		seen[key] = true
		valid[key] = true
		entries[#entries + 1] = { key = key, label = label or key }
	end

	add("UIParent", "UIParent")
	add(cdp.FAKE_CURSOR.FRAME_NAME, _G.CURSOR or "Cursor")
	for _, key in ipairs(Helper.GENERIC_ANCHOR_ORDER) do
		local info = Helper.GENERIC_ANCHORS[key]
		if info then add(key, info.label) end
	end
	if _G and _G.EssentialCooldownViewer then add("EssentialCooldownViewer", L["cooldownViewerEssential"] or COOLDOWN_VIEWER_SETTINGS_CATEGORY_ESSENTIAL or "Essential Cooldown Viewer") end
	if _G and _G.UtilityCooldownViewer then add("UtilityCooldownViewer", L["cooldownViewerUtility"] or COOLDOWN_VIEWER_SETTINGS_CATEGORY_UTILITY or "Utility Cooldown Viewer") end
	if _G and _G.BuffIconCooldownViewer then add("BuffIconCooldownViewer", L["cooldownViewerBuffIcon"] or "Buff Icon Cooldowns") end

	local anchorHelper = CooldownPanels.AnchorHelper
	if anchorHelper and anchorHelper.CollectAnchorEntries then
		anchorHelper:CollectAnchorEntries(entries, seen)
		for i = 1, #entries do
			local entry = entries[i]
			if entry and entry.key and entry.key ~= "" then
				seen[entry.key] = true
				valid[entry.key] = true
			end
		end
	end

	if root and root.panels then
		for id, other in pairs(root.panels) do
			local otherId = normalizeId(id)
			if otherId ~= panelKey then
					local label = (L["cooldownPanelReferenceLabel"]):format(tostring(otherId), other and other.name or L["cooldownPanelDefaultName"])
				add(panelFrameName(otherId), label)
			end
		end
	end

	if cur and not seen[cur] then
		local label = anchorHelper and anchorHelper.GetAnchorLabel and anchorHelper:GetAnchorLabel(cur)
		add(cur, label or cur)
	end

	cache = { version = version, root = root, current = cur, entries = entries, valid = valid }
	if runtimePanel then runtimePanel._eqolRelativeFrameCache = cache end
	return cache
end

CooldownPanels.ValidateRelativeFrameChoice = function(panel, panelKey, runtimePanel)
	local anchor = ensurePanelAnchor(panel)
	if not anchor then return "UIParent" end
	local cur = Helper.NormalizeRelativeFrameName(anchor.relativeFrame)
	local cache = CooldownPanels.GetRelativeFrameCache(runtimePanel, panel, panelKey)
	if cache.valid and cache.valid[cur] then return cur end
	if anchor.relativeFrame ~= "UIParent" then
		anchor.relativeFrame = "UIParent"
		CooldownPanels.MarkRelativeFrameEntriesDirty()
	end
	return "UIParent"
end

local function normalizeSoundName(value)
	if type(value) ~= "string" or value == "" then return (Helper and Helper.ENTRY_DEFAULTS and Helper.ENTRY_DEFAULTS.soundReadyFile) or "None" end
	return value
end

local function getSoundLabel(value)
	local soundName = normalizeSoundName(value)
	if soundName == "None" then return _G.NONE end
	return soundName
end

local function getSoundButtonText(value) return SOUND .. ": " .. getSoundLabel(value) end

local function getSoundOptions()
	local list = {}
	local seen = {}
	local function add(name)
		if type(name) ~= "string" or name == "" then return end
		local key = string.lower(name)
		if seen[key] then return end
		seen[key] = true
		list[#list + 1] = name
	end
	local names = addon.functions and addon.functions.GetLSMMediaNames and addon.functions.GetLSMMediaNames("sound") or {}
	for i = 1, #names do
		add(names[i])
	end
	add((LSM and LSM.DefaultMedia and LSM.DefaultMedia.sound) or nil)
	add((Helper and Helper.ENTRY_DEFAULTS and Helper.ENTRY_DEFAULTS.soundReadyFile) or nil)
	for i, name in ipairs(list) do
		if name == "None" then
			table.remove(list, i)
			table.insert(list, 1, name)
			break
		end
	end
	return list
end

local function resolveSoundFile(soundName)
	local value = normalizeSoundName(soundName)
	if value == "None" then return nil end
	if LSM and LSM.Fetch then
		local file = LSM:Fetch("sound", value, true)
		if file then return file end
	end
	return value
end

local function playSoundName(soundName)
	if not soundName or soundName == "" then return end
	local numeric = tonumber(soundName)
	if numeric and PlaySound then
		PlaySound(numeric, "Master")
		return
	end
	local file = resolveSoundFile(soundName)
	if file and PlaySoundFile then PlaySoundFile(file, "Master") end
end

function CooldownPanels:GetEntrySoundConfig(entry, requestedMode)
	if type(entry) ~= "table" then return nil end
	local entryType = entry.type
	local mode = requestedMode
	if mode == nil and entryType ~= "MACRO" and entryType ~= "STANCE" and entryType ~= "CDM_AURA" then mode = "ready" end
	if mode == "ready" and entryType ~= "MACRO" and entryType ~= "STANCE" and entryType ~= "CDM_AURA" then
		return mode, "soundReady", "soundReadyFile", L["CooldownPanelSoundReady"] or "Sound when ready"
	end
	return nil
end

local function setExampleCooldown(cooldown)
	if not cooldown then return end
	local setAsPercent = _G.CooldownFrame_SetDisplayAsPercentage
	if setAsPercent then
		setAsPercent(cooldown, Helper.EXAMPLE_COOLDOWN_PERCENT)
	elseif cooldown.SetCooldown and Api.GetTime then
		local duration = 100
		cooldown:SetCooldown(Api.GetTime() - (duration * Helper.EXAMPLE_COOLDOWN_PERCENT), duration, 1)
	end
	cooldown._eqolPreviewCooldown = true
	if cooldown.Pause then cooldown:Pause() end
end

local function clearPreviewCooldown(cooldown)
	if not cooldown or not cooldown._eqolPreviewCooldown then return end
	cooldown._eqolPreviewCooldown = nil
	if cooldown.Clear then cooldown:Clear() end
	if cooldown.Resume then cooldown:Resume() end
end

local getPreviewEntryIds
local function getPreviewCount(panel)
	if not panel or type(panel.order) ~= "table" then return Helper.DEFAULT_PREVIEW_COUNT end
	if Helper.IsFixedLayout(panel.layout) then
		local count = Helper.GetFixedSlotCount(panel, true)
		if count > Helper.MAX_PREVIEW_COUNT then return Helper.MAX_PREVIEW_COUNT end
		return count
	end
	local entries = getPreviewEntryIds and getPreviewEntryIds(panel) or nil
	if not entries then
		local count = #panel.order
		if count <= 0 then return Helper.DEFAULT_PREVIEW_COUNT end
		if count > Helper.MAX_PREVIEW_COUNT then return Helper.MAX_PREVIEW_COUNT end
		return count
	end
	local count = #entries
	if count <= 0 then return 0 end
	if count > Helper.MAX_PREVIEW_COUNT then return Helper.MAX_PREVIEW_COUNT end
	return count
end

local function getEditorPreviewCount(panel, previewFrame, baseLayout, entries)
	if not panel or type(panel.order) ~= "table" then return Helper.DEFAULT_PREVIEW_COUNT end
	local layoutMode = Helper.NormalizeLayoutMode(baseLayout and baseLayout.layoutMode, Helper.PANEL_LAYOUT_DEFAULTS.layoutMode)
	local count
	if layoutMode == "FIXED" then
		count = Helper.GetFixedSlotCount(panel, true)
	elseif entries then
		count = #entries
		if count <= 0 then return 0 end
	else
		count = #panel.order
		if count <= 0 then return Helper.DEFAULT_PREVIEW_COUNT end
	end
	if not previewFrame then return count end

	if layoutMode == "RADIAL" then return count end

	local spacing = Helper.ClampInt((baseLayout and baseLayout.spacing) or 0, 0, Helper.SPACING_RANGE or 200, Helper.PANEL_LAYOUT_DEFAULTS.spacing)
	local step = Helper.PREVIEW_ICON_SIZE + spacing
	if step <= 0 then return count end
	local width = previewFrame:GetWidth() or 0
	local height = previewFrame:GetHeight() or 0
	if width <= 0 or height <= 0 then return count end

	local cols = math.floor((width + spacing) / step)
	local rows = math.floor((height + spacing) / step)
	if cols < 1 then cols = 1 end
	if rows < 1 then rows = 1 end
	local capacity = cols * rows
	if capacity <= 0 then return count end
	return math.min(count, capacity)
end

function CooldownPanels.NormalizeMacroName(name)
	if type(name) ~= "string" then return nil end
	if strtrim then name = strtrim(name) end
	if name == "" then return nil end
	return name
end

function CooldownPanels.NormalizePanelGroupName(name)
	if type(name) ~= "string" then return nil end
	if strtrim then name = strtrim(name) end
	if name == "" then return nil end
	return name
end

function CooldownPanels.GetFixedGroups(panel)
	if not panel then return {} end
	panel.layout = panel.layout or {}
	local cache = Helper.GetFixedLayoutCache and Helper.GetFixedLayoutCache(panel) or nil
	return cache and cache.groups or Helper.NormalizeFixedGroups(panel.layout)
end

function CooldownPanels.GetFixedGroupName(group)
	if type(group) ~= "table" then return nil end
	local name = type(group.name) == "string" and group.name or nil
	if name and strtrim then name = strtrim(name) end
	if name == "" then name = nil end
	return name or ("Group " .. tostring(group.id or "?"))
end

function CooldownPanels.GetFixedGroupById(panel, groupId)
	if not panel then return nil end
	return Helper.GetFixedGroupById(panel, groupId)
end

function CooldownPanels.GetFixedGroupAtCell(panel, column, row, ignoreGroupId)
	if not panel then return nil end
	return Helper.GetFixedGroupAtCell(panel, column, row, ignoreGroupId)
end

function CooldownPanels.GetFixedGroupMode(group) return Helper.GetFixedGroupMode(group) end

function CooldownPanels.IsFixedGroupStatic(group) return Helper.FixedGroupUsesStaticSlots(group) end

function CooldownPanels.GetFixedGroupModeLabel(group) return CooldownPanels.IsFixedGroupStatic(group) and (L["CooldownPanelStatic"] or "Static") or (L["CooldownPanelDynamic"] or "Dynamic") end

function CooldownPanels.GetFixedGroupDisplayLabel(group)
	local name = CooldownPanels.GetFixedGroupName(group)
	return string.format("%s [%s]", tostring(name or "Group"), CooldownPanels.GetFixedGroupModeLabel(group))
end

function CooldownPanels.BumpFixedGroupEffectiveLayoutVersion(panel)
	if type(panel) ~= "table" then return end
	panel._eqolFixedGroupEffectiveLayoutVersion = (panel._eqolFixedGroupEffectiveLayoutVersion or 0) + 1
end

function CooldownPanels:ClearFixedGroupEffectiveLayoutCache(panelId, groupId)
	panelId = normalizeId(panelId)
	if not panelId then return end
	local runtime = getRuntime(panelId)
	local cache = runtime and runtime._eqolFixedGroupEffectiveLayouts or nil
	if not cache then return end
	groupId = Helper.NormalizeFixedGroupId(groupId)
	if groupId then
		cache[groupId] = nil
	else
		for key in pairs(cache) do
			cache[key] = nil
		end
	end
end

function CooldownPanels:GetFixedGroupLayoutOverrides(panel, groupOrId, create)
	local group = type(groupOrId) == "table" and groupOrId or (panel and CooldownPanels.GetFixedGroupById(panel, groupOrId) or nil)
	if not group then return nil end
	local overrides = Helper.NormalizeFixedGroupLayoutOverrides(group.layoutOverrides)
	if overrides then
		group.layoutOverrides = overrides
	elseif create == true then
		overrides = {}
		group.layoutOverrides = overrides
	else
		group.layoutOverrides = nil
	end
	return overrides
end

function CooldownPanels:GetFixedGroupEffectiveLayout(panelId, groupOrId, buildCache, panel, layout, fixedLayoutCache)
	panelId = normalizeId(panelId)
	local group = type(groupOrId) == "table" and groupOrId or nil
	local groupId = group and Helper.NormalizeFixedGroupId(group.id) or Helper.NormalizeFixedGroupId(groupOrId)
	if buildCache and groupId and buildCache[groupId] then return buildCache[groupId] end

	panel = panel or (panelId and self:GetPanel(panelId) or nil)
	layout = layout or (panel and panel.layout or nil)
	if not (panelId and panel and layout and groupId) then return layout end

	if not group then
		fixedLayoutCache = fixedLayoutCache or (panel and Helper.GetFixedLayoutCache and Helper.GetFixedLayoutCache(panel) or nil)
		group = groupId and fixedLayoutCache and fixedLayoutCache.groupById and fixedLayoutCache.groupById[groupId] or (panel and groupId and CooldownPanels.GetFixedGroupById(panel, groupId) or nil)
	end
	if not group then return layout end

	groupId = Helper.NormalizeFixedGroupId(group.id) or groupId
	if buildCache and groupId and buildCache[groupId] then return buildCache[groupId] end

	local overrides = type(group.layoutOverrides) == "table" and group.layoutOverrides or nil
	if overrides and not next(overrides) then
		group.layoutOverrides = nil
		overrides = nil
	end
	if not overrides then
		self:ClearFixedGroupEffectiveLayoutCache(panelId, groupId)
		if buildCache then buildCache[groupId] = layout end
		return layout
	end
	local runtime = getRuntime(panelId)
	runtime._eqolFixedGroupEffectiveLayouts = runtime._eqolFixedGroupEffectiveLayouts or {}
	local effective = runtime._eqolFixedGroupEffectiveLayouts[groupId]
	if not effective then
		effective = {}
		runtime._eqolFixedGroupEffectiveLayouts[groupId] = effective
	end
	local version = panel._eqolFixedGroupEffectiveLayoutVersion or 0
	if effective._eqolVersion == version and effective._eqolLayoutRef == layout and effective._eqolOverridesRef == overrides then
		if buildCache then buildCache[groupId] = effective end
		return effective
	end
	for key in pairs(effective) do
		effective[key] = nil
	end
	for key, value in pairs(layout) do
		effective[key] = value
	end
	for key, value in pairs(overrides) do
		effective[key] = value
	end
	effective._eqolVersion = version
	effective._eqolLayoutRef = layout
	effective._eqolOverridesRef = overrides
	if buildCache then buildCache[groupId] = effective end
	return effective
end

function CooldownPanels:GetEntryEffectiveLayout(panelId, entry, buildCache, panel)
	local layout = panel and panel.layout or nil
	if not layout then
		panelId = normalizeId(panelId)
		panel = panelId and self:GetPanel(panelId) or nil
		layout = panel and panel.layout or nil
	end
	if not entry then return layout end
	local groupId = Helper.NormalizeFixedGroupId(entry.fixedGroupId)
	if not groupId then return layout end
	return self:GetFixedGroupEffectiveLayout(panelId, groupId, buildCache) or layout
end

function CooldownPanels:IsFixedCellWithinBounds(panel, column, row)
	column = Helper.NormalizeSlotCoordinate(column)
	row = Helper.NormalizeSlotCoordinate(row)
	if not (panel and column and row) then return false end
	local columns, rows = Helper.GetFixedGridBounds(panel, false)
	if columns <= 0 or rows <= 0 then return false end
	return column >= 1 and column <= columns and row >= 1 and row <= rows
end

function CooldownPanels.GetEntryFixedPlacement(panel, entryOrId)
	local entry = entryOrId
	if type(entryOrId) ~= "table" then entry = panel and panel.entries and panel.entries[entryOrId] or nil end
	if not entry then return nil end
	local column = Helper.NormalizeSlotCoordinate(entry.slotColumn)
	local row = Helper.NormalizeSlotCoordinate(entry.slotRow)
	if not (column and row) then return nil end
	local group = CooldownPanels.GetFixedGroupById(panel, entry.fixedGroupId)
	if group then
		if not CooldownPanels.IsFixedGroupStatic(group) then return nil end
		if column < group.column or column > (group.column + group.columns - 1) or row < group.row or row > (group.row + group.rows - 1) then return nil end
		return {
			groupId = group.id,
			column = column,
			row = row,
		}
	end
	return {
		groupId = nil,
		column = column,
		row = row,
	}
end

function CooldownPanels.AssignEntryFixedPlacement(entry, placement)
	if not entry then return end
	entry.fixedGroupId = placement and Helper.NormalizeFixedGroupId(placement.groupId) or nil
	entry.slotColumn = placement and Helper.NormalizeSlotCoordinate(placement.column) or nil
	entry.slotRow = placement and Helper.NormalizeSlotCoordinate(placement.row) or nil
	entry.slotIndex = nil
end

function CooldownPanels:SyncEntryFixedGroupState(panel, entry)
	if not (panel and entry) then return end
	local group = CooldownPanels.GetFixedGroupById(panel, entry.fixedGroupId)
	if group then
		entry.fixedGroupId = group.id
	else
		entry.fixedGroupId = nil
	end
	Helper.SyncEntryFixedGroupIconState(panel, entry, group)
end

function CooldownPanels:GetEntryAtUngroupedFixedCell(panel, column, row, skipEntryId)
	column = Helper.NormalizeSlotCoordinate(column)
	row = Helper.NormalizeSlotCoordinate(row)
	if not (panel and column and row) then return nil end
	local cache = Helper.GetFixedLayoutCache and Helper.GetFixedLayoutCache(panel) or nil
	local key = tostring(column) .. ":" .. tostring(row)
	local cachedId = cache and cache.entryAtUngroupedCell and cache.entryAtUngroupedCell[key] or nil
	if cachedId and cachedId ~= skipEntryId then return cachedId, panel.entries and panel.entries[cachedId] or nil end
	for _, entryId in ipairs(panel.order or {}) do
		if entryId ~= skipEntryId then
			local entry = panel.entries and panel.entries[entryId]
			if
				entry
				and Helper.NormalizeFixedGroupId(entry.fixedGroupId) == nil
				and Helper.NormalizeSlotCoordinate(entry.slotColumn) == column
				and Helper.NormalizeSlotCoordinate(entry.slotRow) == row
			then
				return entryId, entry
			end
		end
	end
	return nil
end

function CooldownPanels:GetEntryAtStaticGroupCell(panel, groupId, column, row, skipEntryId)
	groupId = Helper.NormalizeFixedGroupId(groupId)
	column = Helper.NormalizeSlotCoordinate(column)
	row = Helper.NormalizeSlotCoordinate(row)
	if not (panel and groupId and column and row) then return nil end
	local cache = Helper.GetFixedLayoutCache and Helper.GetFixedLayoutCache(panel) or nil
	local key = tostring(column) .. ":" .. tostring(row)
	local groupCells = cache and cache.entryAtStaticGroupCell and cache.entryAtStaticGroupCell[groupId] or nil
	local cachedId = groupCells and groupCells[key] or nil
	if cachedId and cachedId ~= skipEntryId then return cachedId, panel.entries and panel.entries[cachedId] or nil end
	for _, entryId in ipairs(panel.order or {}) do
		if entryId ~= skipEntryId then
			local entry = panel.entries and panel.entries[entryId]
			if
				entry
				and Helper.NormalizeFixedGroupId(entry.fixedGroupId) == groupId
				and Helper.NormalizeSlotCoordinate(entry.slotColumn) == column
				and Helper.NormalizeSlotCoordinate(entry.slotRow) == row
			then
				return entryId, entry
			end
		end
	end
	return nil
end

function CooldownPanels:FindFirstFreeUngroupedFixedCell(panel, skipEntryId, skipEntryId2)
	if not panel then return nil end
	local columns, rows = Helper.GetFixedGridBounds(panel, false)
	if columns <= 0 or rows <= 0 then return nil end
	for row = 1, rows do
		for column = 1, columns do
			if not CooldownPanels.GetFixedGroupAtCell(panel, column, row) then
				local occupantId = self:GetEntryAtUngroupedFixedCell(panel, column, row, skipEntryId)
				if not occupantId or occupantId == skipEntryId2 then return column, row end
			end
		end
	end
	return nil
end

function CooldownPanels:FindFirstFreeStaticGroupCell(panel, groupId, skipEntryId, skipEntryId2)
	if not panel then return nil end
	local group = CooldownPanels.GetFixedGroupById(panel, groupId)
	if not (group and CooldownPanels.IsFixedGroupStatic(group)) then return nil end
	for row = group.row, group.row + group.rows - 1 do
		for column = group.column, group.column + group.columns - 1 do
			if CooldownPanels:IsFixedCellWithinBounds(panel, column, row) then
				local occupantId = self:GetEntryAtStaticGroupCell(panel, group.id, column, row, skipEntryId)
				if not occupantId or occupantId == skipEntryId2 then return column, row end
			end
		end
	end
	return nil
end

function CooldownPanels:ResolveFixedEntryAddOverrides(panel, overrides)
	if not (panel and Helper.IsFixedLayout(panel.layout)) then return overrides, nil end
	local resolved = {}
	if type(overrides) == "table" then
		for key, value in pairs(overrides) do
			resolved[key] = value
		end
	end
	local slotColumn = Helper.NormalizeSlotCoordinate(resolved.slotColumn)
	local slotRow = Helper.NormalizeSlotCoordinate(resolved.slotRow)
	local targetGroup = resolved.fixedGroupId and CooldownPanels.GetFixedGroupById(panel, resolved.fixedGroupId) or nil
	if targetGroup then
		resolved.fixedGroupId = targetGroup.id
		if CooldownPanels.IsFixedGroupStatic(targetGroup) then
			if slotColumn and slotRow and not CooldownPanels.RectContainsCell(targetGroup.column, targetGroup.row, targetGroup.columns, targetGroup.rows, slotColumn, slotRow) then
				return nil, L["CooldownPanelFixedTargetInvalid"] or "Target slot is outside the selected group."
			end
			if not (slotColumn and slotRow) or self:GetEntryAtStaticGroupCell(panel, targetGroup.id, slotColumn, slotRow) then
				slotColumn, slotRow = self:FindFirstFreeStaticGroupCell(panel, targetGroup.id)
			end
			if not (slotColumn and slotRow) then return nil, L["CooldownPanelFixedGroupFull"] or "Fixed group is full." end
			resolved.slotColumn = slotColumn
			resolved.slotRow = slotRow
			resolved.slotIndex = nil
		else
			resolved.slotColumn = nil
			resolved.slotRow = nil
			resolved.slotIndex = nil
		end
		return resolved, nil
	end
	if slotColumn and slotRow and not self:IsFixedCellWithinBounds(panel, slotColumn, slotRow) then return nil, L["CooldownPanelFixedTargetInvalid"] or "Target slot is outside the panel bounds." end
	if not (slotColumn and slotRow) or CooldownPanels.GetFixedGroupAtCell(panel, slotColumn, slotRow) or self:GetEntryAtUngroupedFixedCell(panel, slotColumn, slotRow) then
		slotColumn, slotRow = self:FindFirstFreeUngroupedFixedCell(panel)
	end
	if not (slotColumn and slotRow) then return nil, L["CooldownPanelFixedPanelFull"] or "Fixed panel is full." end
	resolved.fixedGroupId = nil
	resolved.slotColumn = slotColumn
	resolved.slotRow = slotRow
	resolved.slotIndex = nil
	return resolved, nil
end

function CooldownPanels:GetFixedEntryAddError(panel, overrides)
	local _, fixedError = self:ResolveFixedEntryAddOverrides(panel, overrides)
	return fixedError
end

function CooldownPanels:GetFixedDropEntryOverrides(panelId, targetSlot)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	if not (panel and targetSlot and Helper.IsFixedLayout(panel.layout)) then return nil end
	local column = Helper.NormalizeSlotCoordinate(targetSlot.column or targetSlot.slotColumn or targetSlot.x)
	local row = Helper.NormalizeSlotCoordinate(targetSlot.row or targetSlot.slotRow or targetSlot.y)
	if not (column and row) then return nil end
	local overrides = {
		slotColumn = column,
		slotRow = row,
		slotIndex = nil,
	}
	local group = CooldownPanels.GetFixedGroupAtCell(panel, column, row)
	if group then
		overrides.fixedGroupId = group.id
		if not CooldownPanels.IsFixedGroupStatic(group) then
			overrides.slotColumn = nil
			overrides.slotRow = nil
		end
	end
	return overrides
end

function CooldownPanels.GetFixedGridColumnCount(panel)
	if not panel then return 0 end
	local columns = select(1, Helper.GetFixedGridBounds(panel, false)) or 0
	return columns
end

function CooldownPanels.GetFixedGridCellIndex(panel, column, row)
	column = Helper.NormalizeSlotCoordinate(column)
	row = Helper.NormalizeSlotCoordinate(row)
	if not (panel and column and row) then return nil, 0 end
	local columns = CooldownPanels.GetFixedGridColumnCount(panel)
	if columns <= 0 then return nil, columns end
	return ((row - 1) * columns) + column, columns
end

function CooldownPanels.GetFixedGroupCells(group)
	local cells = {}
	if type(group) ~= "table" then return cells end
	if CooldownPanels.IsFixedGroupStatic(group) ~= true and Helper.GetFixedGroupOrderedCells then return Helper.GetFixedGroupOrderedCells(group) end
	for row = 0, (group.rows or 0) - 1 do
		for column = 0, (group.columns or 0) - 1 do
			cells[#cells + 1] = {
				column = group.column + column,
				row = group.row + row,
			}
		end
	end
	return cells
end

function CooldownPanels.GetFixedGroupEntriesInOrder(panel, groupId, skipEntryId)
	local entries = {}
	groupId = Helper.NormalizeFixedGroupId(groupId)
	if not (panel and groupId) then return entries end
	local cache = Helper.GetFixedLayoutCache and Helper.GetFixedLayoutCache(panel) or nil
	local cachedEntries = cache and cache.groupEntryIds and cache.groupEntryIds[groupId] or nil
	if cachedEntries then
		for i = 1, #cachedEntries do
			local candidateId = cachedEntries[i]
			if candidateId ~= skipEntryId then entries[#entries + 1] = candidateId end
		end
		return entries
	end
	for _, candidateId in ipairs(panel.order or {}) do
		if candidateId ~= skipEntryId then
			local entry = panel.entries and panel.entries[candidateId]
			if entry and Helper.NormalizeFixedGroupId(entry.fixedGroupId) == groupId then entries[#entries + 1] = candidateId end
		end
	end
	return entries
end

function CooldownPanels:GetFixedGroupLabel(panelId, groupId)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local group = panel and CooldownPanels.GetFixedGroupById(panel, groupId) or nil
	return group and CooldownPanels.GetFixedGroupName(group) or nil
end

function CooldownPanels:FindNextFixedGroupId(panel)
	if not panel then return nil end
	local groups = CooldownPanels.GetFixedGroups(panel)
	local used = {}
	for i = 1, #groups do
		local group = groups[i]
		if group and group.id then used[group.id] = true end
	end
	local nextIndex = 1
	while used["group" .. tostring(nextIndex)] do
		nextIndex = nextIndex + 1
	end
	return "group" .. tostring(nextIndex), nextIndex
end

function CooldownPanels:DoesFixedGroupRectOverlap(panel, column, row, columns, rows, ignoreGroupId)
	if not panel then return true end
	column = Helper.NormalizeSlotCoordinate(column)
	row = Helper.NormalizeSlotCoordinate(row)
	columns = Helper.NormalizeFixedGridSize(columns, 0)
	rows = Helper.NormalizeFixedGridSize(rows, 0)
	if not (column and row) or columns <= 0 or rows <= 0 then return true end
	local targetRight = column + columns - 1
	local targetBottom = row + rows - 1
	for _, group in ipairs(CooldownPanels.GetFixedGroups(panel)) do
		if group and group.id ~= Helper.NormalizeFixedGroupId(ignoreGroupId) then
			local groupRight = group.column + group.columns - 1
			local groupBottom = group.row + group.rows - 1
			local overlaps = not (targetRight < group.column or groupRight < column or targetBottom < group.row or groupBottom < row)
			if overlaps then return true end
		end
	end
	return false
end

function CooldownPanels.RectContainsCell(column, row, columns, rows, targetColumn, targetRow)
	if not (column and row and columns and rows and targetColumn and targetRow) then return false end
	return targetColumn >= column and targetColumn <= (column + columns - 1) and targetRow >= row and targetRow <= (row + rows - 1)
end

function CooldownPanels:CanCreateFixedGroup(panelId, column, row, columns, rows)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	if not panel then return false end
	panel.layout = panel.layout or Helper.CopyTableShallow(Helper.PANEL_LAYOUT_DEFAULTS)
	if not Helper.IsFixedLayout(panel.layout) then return false end
	column = Helper.NormalizeSlotCoordinate(column)
	row = Helper.NormalizeSlotCoordinate(row)
	columns = Helper.NormalizeFixedGridSize(columns, 0)
	rows = Helper.NormalizeFixedGridSize(rows, 0)
	if not (column and row) or columns <= 0 or rows <= 0 then return false end
	local gridColumns, gridRows = Helper.GetFixedGridBounds(panel, false)
	if gridColumns <= 0 or gridRows <= 0 then return false end
	if (column + columns - 1) > gridColumns or (row + rows - 1) > gridRows then return false end
	if self:DoesFixedGroupRectOverlap(panel, column, row, columns, rows, nil) then return false end
	for _, entry in pairs(panel.entries or {}) do
		if entry and Helper.NormalizeFixedGroupId(entry.fixedGroupId) ~= nil then
			local entryGroup = CooldownPanels.GetFixedGroupById(panel, entry.fixedGroupId)
			if entryGroup and CooldownPanels.RectContainsCell(column, row, columns, rows, entryGroup.column, entryGroup.row) then return false end
		end
	end
	return true
end

function CooldownPanels:CreateFixedGroup(panelId, column, row, columns, rows, name)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	if not panel then return nil end
	panel.layout = panel.layout or Helper.CopyTableShallow(Helper.PANEL_LAYOUT_DEFAULTS)
	local layout = panel.layout
	if not Helper.IsFixedLayout(layout) then return nil end
	column = Helper.NormalizeSlotCoordinate(column)
	row = Helper.NormalizeSlotCoordinate(row)
	columns = Helper.NormalizeFixedGridSize(columns, 0)
	rows = Helper.NormalizeFixedGridSize(rows, 0)
	if not (column and row) or columns <= 0 or rows <= 0 then return nil end
	if self:DoesFixedGroupRectOverlap(panel, column, row, columns, rows, nil) then return nil end
	layout.fixedGroups = layout.fixedGroups or {}
	local groupId, nextIndex = self:FindNextFixedGroupId(panel)
	if not groupId then return nil end
	local groupName = CooldownPanels.NormalizePanelGroupName(name) or ("Group " .. tostring(nextIndex or #layout.fixedGroups + 1))
	local group = {
		id = groupId,
		name = groupName,
		column = column,
		row = row,
		columns = columns,
		rows = rows,
		mode = "DYNAMIC",
		dynamicStartPoint = "TOPLEFT",
		dynamicDirection = "RIGHT",
		iconSize = nil,
		layoutOverrides = nil,
	}
	layout.fixedGroups[#layout.fixedGroups + 1] = group
	Helper.NormalizeFixedGroups(layout)

	for _, entryId in ipairs(panel.order or {}) do
		local entry = panel.entries and panel.entries[entryId]
		if entry then
			local currentGroupId = Helper.NormalizeFixedGroupId(entry.fixedGroupId)
			if currentGroupId == nil then
				local slotColumn = Helper.NormalizeSlotCoordinate(entry.slotColumn)
				local slotRow = Helper.NormalizeSlotCoordinate(entry.slotRow)
				if CooldownPanels.RectContainsCell(column, row, columns, rows, slotColumn, slotRow) then entry.fixedGroupId = groupId end
			end
		end
	end

	Helper.InvalidateFixedLayoutCache(panel)
	Helper.EnsureFixedSlotAssignments(panel)
	self:InvalidateLayoutEditGrid(panelId)
	return groupId
end

function CooldownPanels:RenameFixedGroup(panelId, groupId, name)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local group = panel and CooldownPanels.GetFixedGroupById(panel, groupId) or nil
	local normalizedName = CooldownPanels.NormalizePanelGroupName(name)
	if not (group and normalizedName) then return false end
	if group.name == normalizedName then return false end
	group.name = normalizedName
	self:InvalidateLayoutEditGrid(panelId)
	return true
end

function CooldownPanels:DeleteFixedGroup(panelId, groupId)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	if not panel then return false end
	local layout = panel.layout or {}
	local groups = CooldownPanels.GetFixedGroups(panel)
	local group, groupIndex = CooldownPanels.GetFixedGroupById(panel, groupId)
	if not (group and groupIndex) then return false end
	local memberIds = CooldownPanels.GetFixedGroupEntriesInOrder(panel, group.id)
	local cells = CooldownPanels.GetFixedGroupCells(group)
	for i = 1, #memberIds do
		local entry = panel.entries and panel.entries[memberIds[i]]
		if entry then
			entry.fixedGroupId = nil
			local cell = cells[i]
			if cell then
				entry.slotColumn = cell.column
				entry.slotRow = cell.row
				entry.slotIndex = nil
			else
				entry.slotColumn = nil
				entry.slotRow = nil
				entry.slotIndex = nil
			end
		end
	end
	table.remove(layout.fixedGroups, groupIndex)
	Helper.NormalizeFixedGroups(layout)
	self:ClearFixedGroupEffectiveLayoutCache(panelId, group.id)
	Helper.InvalidateFixedLayoutCache(panel)
	Helper.EnsureFixedSlotAssignments(panel)
	self:InvalidateLayoutEditGrid(panelId)
	return true
end

function CooldownPanels:SetFixedGroupMode(panelId, groupId, mode)
	panelId = normalizeId(panelId)
	groupId = Helper.NormalizeFixedGroupId(groupId)
	mode = Helper.NormalizeFixedGroupMode(mode, "DYNAMIC")
	local panel = panelId and self:GetPanel(panelId) or nil
	local group = panel and CooldownPanels.GetFixedGroupById(panel, groupId) or nil
	if not (panel and group) then return false, "GROUP_NOT_FOUND" end
	if CooldownPanels.GetFixedGroupMode(group) == mode then return false, "UNCHANGED" end
	if mode == "STATIC" then
		local members = CooldownPanels.GetFixedGroupEntriesInOrder(panel, group.id)
		local capacity = Helper.GetFixedGroupCapacity(group)
		if #members > capacity then return false, "GROUP_FULL" end
	end
	group.mode = mode
	Helper.NormalizeFixedGroups(panel.layout)
	Helper.InvalidateFixedLayoutCache(panel)
	Helper.EnsureFixedSlotAssignments(panel)
	self:InvalidateLayoutEditGrid(panelId)
	return true
end

function CooldownPanels:SetFixedGroupDynamicStartPoint(panelId, groupId, startPoint)
	panelId = normalizeId(panelId)
	groupId = Helper.NormalizeFixedGroupId(groupId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local group = panel and CooldownPanels.GetFixedGroupById(panel, groupId) or nil
	if not (panel and group) then return false end
	local currentStartPoint = Helper.NormalizeFixedGroupStartPoint(group.dynamicStartPoint, "TOPLEFT")
	local currentDirection = Helper.NormalizeFixedGroupDynamicDirection(currentStartPoint, group.dynamicDirection, nil)
	local nextStartPoint = Helper.NormalizeFixedGroupStartPoint(startPoint, currentStartPoint)
	local nextDirection = Helper.NormalizeFixedGroupDynamicDirection(nextStartPoint, currentDirection, nil)
	if currentStartPoint == nextStartPoint and currentDirection == nextDirection then return false end
	group.dynamicStartPoint = nextStartPoint
	group.dynamicDirection = nextDirection
	Helper.NormalizeFixedGroups(panel.layout)
	Helper.InvalidateFixedLayoutCache(panel)
	Helper.EnsureFixedSlotAssignments(panel)
	self:InvalidateLayoutEditGrid(panelId)
	return true
end

function CooldownPanels:SetFixedGroupDynamicDirection(panelId, groupId, direction)
	panelId = normalizeId(panelId)
	groupId = Helper.NormalizeFixedGroupId(groupId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local group = panel and CooldownPanels.GetFixedGroupById(panel, groupId) or nil
	if not (panel and group) then return false end
	local startPoint = Helper.NormalizeFixedGroupStartPoint(group.dynamicStartPoint, "TOPLEFT")
	local currentDirection = Helper.NormalizeFixedGroupDynamicDirection(startPoint, group.dynamicDirection, nil)
	local nextDirection = Helper.NormalizeFixedGroupDynamicDirection(startPoint, direction, currentDirection)
	if currentDirection == nextDirection then return false end
	group.dynamicDirection = nextDirection
	Helper.NormalizeFixedGroups(panel.layout)
	Helper.InvalidateFixedLayoutCache(panel)
	Helper.EnsureFixedSlotAssignments(panel)
	self:InvalidateLayoutEditGrid(panelId)
	return true
end

function CooldownPanels:SetFixedGroupIconSize(panelId, groupId, iconSize)
	panelId = normalizeId(panelId)
	groupId = Helper.NormalizeFixedGroupId(groupId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local group = panel and CooldownPanels.GetFixedGroupById(panel, groupId) or nil
	if not (panel and group) then return false end
	local normalizedSize = Helper.NormalizeFixedGroupIconSize(iconSize)
	local currentSize = Helper.NormalizeFixedGroupIconSize(group.iconSize)
	if currentSize == normalizedSize and group.iconSizeSeparate ~= true then return false end
	group.iconSize = normalizedSize
	group.iconSizeSeparate = false
	for _, entryId in ipairs(panel.order or {}) do
		local entry = panel.entries and panel.entries[entryId]
		if entry and Helper.NormalizeFixedGroupId(entry.fixedGroupId) == group.id then self:SyncEntryFixedGroupState(panel, entry) end
	end
	Helper.NormalizeFixedGroups(panel.layout)
	self:InvalidateLayoutEditGrid(panelId)
	return true
end

function CooldownPanels:SetFixedGroupSeparateIconSize(panelId, groupId, enabled)
	panelId = normalizeId(panelId)
	groupId = Helper.NormalizeFixedGroupId(groupId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local group = panel and CooldownPanels.GetFixedGroupById(panel, groupId) or nil
	if not (panel and group) then return false end
	local normalized = enabled == true
	if group.iconSizeSeparate == normalized then return false end
	group.iconSizeSeparate = normalized
	if normalized then
		local fallback = Helper.NormalizeFixedGroupIconSize(group.iconSize) or Helper.ClampInt(panel.layout and panel.layout.iconSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
		group.iconWidth = Helper.NormalizeFixedGroupIconDimension(group.iconWidth, fallback)
		group.iconHeight = Helper.NormalizeFixedGroupIconDimension(group.iconHeight, fallback)
	end
	for _, entryId in ipairs(panel.order or {}) do
		local entry = panel.entries and panel.entries[entryId]
		if entry and Helper.NormalizeFixedGroupId(entry.fixedGroupId) == group.id then self:SyncEntryFixedGroupState(panel, entry) end
	end
	Helper.NormalizeFixedGroups(panel.layout)
	self:InvalidateLayoutEditGrid(panelId)
	return true
end

function CooldownPanels:SetFixedGroupIconDimension(panelId, groupId, field, value)
	panelId = normalizeId(panelId)
	groupId = Helper.NormalizeFixedGroupId(groupId)
	if field ~= "iconWidth" and field ~= "iconHeight" then return false end
	local panel = panelId and self:GetPanel(panelId) or nil
	local group = panel and CooldownPanels.GetFixedGroupById(panel, groupId) or nil
	if not (panel and group) then return false end
	local fallback = Helper.NormalizeFixedGroupIconSize(group.iconSize) or Helper.ClampInt(panel.layout and panel.layout.iconSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
	local nextValue = Helper.NormalizeFixedGroupIconDimension(value, fallback)
	if group[field] == nextValue then return false end
	group[field] = nextValue
	group.iconSizeSeparate = true
	for _, entryId in ipairs(panel.order or {}) do
		local entry = panel.entries and panel.entries[entryId]
		if entry and Helper.NormalizeFixedGroupId(entry.fixedGroupId) == group.id then self:SyncEntryFixedGroupState(panel, entry) end
	end
	Helper.NormalizeFixedGroups(panel.layout)
	self:InvalidateLayoutEditGrid(panelId)
	return true
end

function CooldownPanels:SetFixedGroupLayoutOverride(panelId, groupId, field, value)
	panelId = normalizeId(panelId)
	groupId = Helper.NormalizeFixedGroupId(groupId)
	if type(field) ~= "string" then return false end
	local panel = panelId and self:GetPanel(panelId) or nil
	local group = panel and CooldownPanels.GetFixedGroupById(panel, groupId) or nil
	if not (panel and group) then return false end
	local layout = panel.layout or Helper.PANEL_LAYOUT_DEFAULTS
	local currentLayout = self:GetFixedGroupEffectiveLayout(panelId, group.id)
	local overrides = self:GetFixedGroupLayoutOverrides(panel, group, true)
	local currentValue
	local baseValue
	local nextValue

	if field == "spacing" then
		baseValue = Helper.ClampInt(layout and layout.spacing, 0, Helper.SPACING_RANGE or 200, Helper.PANEL_LAYOUT_DEFAULTS.spacing)
		currentValue = Helper.ClampInt(currentLayout and currentLayout.spacing, 0, Helper.SPACING_RANGE or 200, baseValue)
		nextValue = Helper.ClampInt(value, 0, Helper.SPACING_RANGE or 200, baseValue)
	elseif field == "iconOffsetX" then
		baseValue = 0
		currentValue = Helper.ClampInt(currentLayout and currentLayout.iconOffsetX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
		nextValue = Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
	elseif field == "iconOffsetY" then
		baseValue = 0
		currentValue = Helper.ClampInt(currentLayout and currentLayout.iconOffsetY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
		nextValue = Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
	elseif field == "procGlowEnabled" then
		baseValue = self:ResolveEntryProcGlowEnabled(layout, nil)
		currentValue = self:ResolveEntryProcGlowEnabled(currentLayout, nil)
		nextValue = value ~= false
	elseif field == "hideGlowOutOfCombat" then
		baseValue = layout.hideGlowOutOfCombat == true
		currentValue = currentLayout and currentLayout.hideGlowOutOfCombat == true or false
		nextValue = value == true
	elseif field == "procGlowStyle" then
		baseValue = select(1, self:ResolveEntryProcGlowVisual(layout, nil))
		currentValue = select(1, self:ResolveEntryProcGlowVisual(currentLayout, nil))
		nextValue = Helper.NormalizeGlowStyle(value, baseValue)
	elseif field == "procGlowInset" then
		baseValue = select(2, self:ResolveEntryProcGlowVisual(layout, nil))
		currentValue = select(2, self:ResolveEntryProcGlowVisual(currentLayout, nil))
		nextValue = Helper.NormalizeGlowInset(value, baseValue)
	elseif field == "readyGlowCheckPower" then
		baseValue = self:ResolveEntryReadyGlowCheckPower(layout, nil)
		currentValue = self:ResolveEntryReadyGlowCheckPower(currentLayout, nil)
		nextValue = value == true
	elseif field == "readyGlowStyle" then
		baseValue = select(3, self:ResolveEntryGlowStyle(layout, nil))
		currentValue = select(3, self:ResolveEntryGlowStyle(currentLayout, nil))
		nextValue = Helper.NormalizeGlowStyle(value, baseValue)
	elseif field == "readyGlowInset" then
		baseValue = select(4, self:ResolveEntryGlowStyle(layout, nil))
		currentValue = select(4, self:ResolveEntryGlowStyle(currentLayout, nil))
		nextValue = Helper.NormalizeGlowInset(value, baseValue)
	elseif field == "readyGlowColor" then
		baseValue = select(2, self:ResolveEntryGlowStyle(layout, nil))
		currentValue = select(2, self:ResolveEntryGlowStyle(currentLayout, nil))
		nextValue = Helper.NormalizeColor(value, baseValue)
	elseif field == "pandemicGlowStyle" then
		baseValue = select(2, self:ResolveEntryPandemicGlowVisual(layout, nil))
		currentValue = select(2, self:ResolveEntryPandemicGlowVisual(currentLayout, nil))
		nextValue = Helper.NormalizeGlowStyle(value, baseValue)
	elseif field == "pandemicGlowInset" then
		baseValue = select(3, self:ResolveEntryPandemicGlowVisual(layout, nil))
		currentValue = select(3, self:ResolveEntryPandemicGlowVisual(currentLayout, nil))
		nextValue = Helper.NormalizeGlowInset(value, baseValue)
	elseif field == "pandemicGlowColor" then
		baseValue = select(1, self:ResolveEntryPandemicGlowVisual(layout, nil))
		currentValue = select(1, self:ResolveEntryPandemicGlowVisual(currentLayout, nil))
		nextValue = Helper.NormalizeColor(value, baseValue)
	else
		return false
	end

	local function valuesEqual(left, right)
		if type(left) == "table" or type(right) == "table" then
			local l = Helper.NormalizeColor(left, { 0, 0, 0, 0 })
			local r = Helper.NormalizeColor(right, { 0, 0, 0, 0 })
			return l[1] == r[1] and l[2] == r[2] and l[3] == r[3] and l[4] == r[4]
		end
		return left == right
	end

	if valuesEqual(currentValue, nextValue) then return false end
	if valuesEqual(nextValue, baseValue) then
		overrides[field] = nil
	else
		overrides[field] = nextValue
	end
	group.layoutOverrides = Helper.NormalizeFixedGroupLayoutOverrides(overrides)
	CooldownPanels.BumpFixedGroupEffectiveLayoutVersion(panel)
	self:ClearFixedGroupEffectiveLayoutCache(panelId, group.id)
	if field == "readyGlowCheckPower" then self:RebuildPowerIndex() end
	self:InvalidateLayoutEditGrid(panelId)
	return true
end

function CooldownPanels:MoveFixedGroup(panelId, groupId, column, row)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	if not panel then return false, "PANEL_NOT_FOUND" end
	local group = CooldownPanels.GetFixedGroupById(panel, groupId)
	if not group then return false, "GROUP_NOT_FOUND" end
	local sourceColumn = group.column
	local sourceRow = group.row
	local groupColumns = group.columns
	local groupRows = group.rows
	local staticGroup = CooldownPanels.IsFixedGroupStatic(group)
	column = Helper.NormalizeSlotCoordinate(column)
	row = Helper.NormalizeSlotCoordinate(row)
	if not (column and row) then return false, "INVALID_TARGET" end
	if column == sourceColumn and row == sourceRow then return false, "NO_CHANGE" end
	local gridColumns, gridRows = Helper.GetFixedGridBounds(panel, false)
	if gridColumns <= 0 or gridRows <= 0 then return false, "GRID_BOUNDS_INVALID" end
	if (column + groupColumns - 1) > gridColumns or (row + groupRows - 1) > gridRows then return false, "OUT_OF_BOUNDS" end
	if self:DoesFixedGroupRectOverlap(panel, column, row, groupColumns, groupRows, groupId) then return false, "OVERLAP" end
	for _, entryId in ipairs(panel.order or {}) do
		local entry = panel.entries and panel.entries[entryId]
		if entry and Helper.NormalizeFixedGroupId(entry.fixedGroupId) == nil then
			local slotColumn = Helper.NormalizeSlotCoordinate(entry.slotColumn)
			local slotRow = Helper.NormalizeSlotCoordinate(entry.slotRow)
			if CooldownPanels.RectContainsCell(column, row, groupColumns, groupRows, slotColumn, slotRow) then return false, "UNGROUPED_OCCUPIED" end
		end
	end
	local deltaColumn = column - sourceColumn
	local deltaRow = row - sourceRow
	if staticGroup and (deltaColumn ~= 0 or deltaRow ~= 0) then
		for _, entryId in ipairs(panel.order or {}) do
			local entry = panel.entries and panel.entries[entryId]
			if entry and Helper.NormalizeFixedGroupId(entry.fixedGroupId) == groupId then
				local slotColumn = Helper.NormalizeSlotCoordinate(entry.slotColumn)
				local slotRow = Helper.NormalizeSlotCoordinate(entry.slotRow)
				if slotColumn and slotRow then
					entry.slotColumn = slotColumn + deltaColumn
					entry.slotRow = slotRow + deltaRow
					entry.slotIndex = nil
				end
			end
		end
	end
	group = CooldownPanels.GetFixedGroupById(panel, groupId)
	if not group then return false, "GROUP_NOT_FOUND_REFETCH" end
	group.column = column
	group.row = row
	Helper.NormalizeFixedGroups(panel.layout)
	Helper.InvalidateFixedLayoutCache(panel)
	Helper.EnsureFixedSlotAssignments(panel)
	self:InvalidateLayoutEditGrid(panelId)
	return true, nil
end

function CooldownPanels.ResolveMacroEntry(entry)
	if not entry or entry.type ~= "MACRO" then return nil end
	local function resolveMacroSpellId(token)
		if token == nil then return nil end
		if type(token) == "number" then
			if token > 0 then return token end
			return nil
		end
		if type(token) ~= "string" or token == "" then return nil end
		local numeric = tonumber(token)
		if numeric and numeric > 0 then return numeric end
		if C_Spell and C_Spell.GetSpellIDForSpellIdentifier then
			local spellId = C_Spell.GetSpellIDForSpellIdentifier(token)
			if type(spellId) == "number" and spellId > 0 then return spellId end
		end
		if Api.GetSpellInfoFn then
			local _, _, _, _, _, _, spellId = Api.GetSpellInfoFn(token)
			if type(spellId) == "number" and spellId > 0 then return spellId end
		end
		return nil
	end
	local function resolveMacroItemId(token)
		if token == nil then return nil end
		if type(token) == "number" then
			if token > 0 then return token end
			return nil
		end
		if type(token) ~= "string" or token == "" then return nil end
		local numeric = tonumber(token)
		if numeric and numeric > 0 then return numeric end
		local fromLink = token:match("item:(%d+)")
		if fromLink then
			local itemId = tonumber(fromLink)
			if itemId and itemId > 0 then return itemId end
		end
		if Api.GetItemInfoInstantFn then
			local itemId = Api.GetItemInfoInstantFn(token)
			if type(itemId) == "number" and itemId > 0 then return itemId end
		end
		return nil
	end
	local macroID = tonumber(entry.macroID)
	local macroName = CooldownPanels.NormalizeMacroName(entry.macroName)
	local lookup = nil

	-- Prefer stored macroID. Name lookups can collide when multiple macros share a name.
	if macroID and macroID > 0 then
		if Api.GetMacroInfo then
			local infoName = Api.GetMacroInfo(macroID)
			if infoName then
				lookup = macroID
				macroName = CooldownPanels.NormalizeMacroName(infoName) or macroName
			end
		else
			lookup = macroID
		end
	end

	if not lookup and macroName and Api.GetMacroIndexByName then
		local byName = Api.GetMacroIndexByName(macroName)
		if type(byName) == "number" and byName > 0 then
			macroID = byName
			lookup = byName
		end
	end
	if not lookup and macroName then lookup = macroName end
	if not lookup then return nil end

	local resolved = {
		macroID = macroID,
		macroName = macroName,
	}
	if Api.GetMacroInfo then
		local infoName, icon = Api.GetMacroInfo(lookup)
		resolved.macroName = CooldownPanels.NormalizeMacroName(infoName) or resolved.macroName
		resolved.icon = icon
		if (not resolved.macroID) and resolved.macroName and Api.GetMacroIndexByName then
			local byName = Api.GetMacroIndexByName(resolved.macroName)
			if type(byName) == "number" and byName > 0 then resolved.macroID = byName end
		end
	end

	local macroSpellToken = nil
	if Api.GetMacroSpell then
		macroSpellToken = Api.GetMacroSpell(lookup)
		local spellId = resolveMacroSpellId(macroSpellToken)
		if spellId then
			local baseSpellId = getBaseSpellId(spellId) or spellId
			resolved.kind = "SPELL"
			resolved.spellID = baseSpellId
			return resolved
		end
	end

	local macroItemName = nil
	local macroItemLink = nil
	if Api.GetMacroItem then
		macroItemName, macroItemLink = Api.GetMacroItem(lookup)
		local itemId = resolveMacroItemId(macroItemLink) or resolveMacroItemId(macroItemName)
		if itemId then
			resolved.kind = "ITEM"
			resolved.itemID = itemId
			return resolved
		end
	end

	return resolved
end

local function getEntryIcon(entry)
	if not entry or type(entry) ~= "table" then return Helper.PREVIEW_ICON end
	if entry.type == "CDM_AURA" then
		local cdmAuras = CooldownPanels.CDMAuras
		if cdmAuras and cdmAuras.GetEntryIcon then
			local icon = cdmAuras:GetEntryIcon(entry)
			if icon then return icon end
		end
	end
	if entry.type == "MACRO" then
		local macro = CooldownPanels.ResolveMacroEntry(entry)
		if macro then
			if macro.kind == "SPELL" and macro.spellID then return getEntryIcon({ type = "SPELL", spellID = macro.spellID }) end
			if macro.kind == "ITEM" and macro.itemID then return getEntryIcon({ type = "ITEM", itemID = macro.itemID }) end
			if macro.icon then return macro.icon end
		end
		return Helper.PREVIEW_ICON
	end
	if entry.type == "SPELL" and entry.spellID then
		local spellId = CooldownPanels:ResolveTrackedSpellID(entry.spellID) or getEffectiveSpellId(entry.spellID) or entry.spellID
		local runtime = CooldownPanels.runtime
		runtime = runtime or {}
		CooldownPanels.runtime = runtime
		runtime.iconCache = runtime.iconCache or {}
		local cache = runtime.iconCache
		local cacheKey = "S:" .. tostring(spellId)
		local cached = cache[cacheKey]
		if cached then return cached end
		local icon = (C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(spellId)) or Helper.PREVIEW_ICON
		cache[cacheKey] = icon
		return icon
	end
	if entry.type == "ITEM" and entry.itemID then
		local runtime = CooldownPanels.runtime
		runtime = runtime or {}
		CooldownPanels.runtime = runtime
		runtime.iconCache = runtime.iconCache or {}
		local cache = runtime.iconCache
		local cacheKey = "I:" .. tostring(entry.itemID)
		local cached = cache[cacheKey]
		if cached then return cached end
		local icon
		if Api.GetItemIconByID then icon = Api.GetItemIconByID(entry.itemID) end
		if not icon and Api.GetItemInfoInstantFn then
			local _, _, _, _, instantIcon = Api.GetItemInfoInstantFn(entry.itemID)
			icon = instantIcon
		end
		icon = icon or Helper.PREVIEW_ICON
		cache[cacheKey] = icon
		return icon
	end
	if entry.type == "SLOT" and entry.slotID and Api.GetInventoryItemID then
		local itemID = Api.GetInventoryItemID("player", entry.slotID)
		if itemID then
			local runtime = CooldownPanels.runtime
			runtime = runtime or {}
			CooldownPanels.runtime = runtime
			runtime.iconCache = runtime.iconCache or {}
			local cache = runtime.iconCache
			local cacheKey = "I:" .. tostring(itemID)
			local cached = cache[cacheKey]
			if cached then return cached end
			local icon
			if Api.GetItemIconByID then icon = Api.GetItemIconByID(itemID) end
			if not icon and Api.GetItemInfoInstantFn then
				local _, _, _, _, instantIcon = Api.GetItemInfoInstantFn(itemID)
				icon = instantIcon
			end
			icon = icon or Helper.PREVIEW_ICON
			cache[cacheKey] = icon
			return icon
		end
	end
	if entry.type == "STANCE" and CooldownPanels.GetStanceEntryIcon then
		local icon = CooldownPanels:GetStanceEntryIcon(entry)
		if icon then return icon end
	end
	return Helper.PREVIEW_ICON
end

local SLOT_LABELS = {}
local SLOT_MENU_ENTRIES
local SLOT_DEFS = {
	{ name = "HeadSlot", label = _G.HEADSLOT or "Head" },
	{ name = "NeckSlot", label = _G.NECKSLOT or "Neck" },
	{ name = "ShoulderSlot", label = _G.SHOULDERSLOT or "Shoulder" },
	{ name = "BackSlot", label = _G.BACKSLOT or "Back" },
	{ name = "ChestSlot", label = _G.CHESTSLOT or "Chest" },
	{ name = "ShirtSlot", label = _G.SHIRTSLOT or "Shirt" },
	{ name = "TabardSlot", label = _G.TABARDSLOT or "Tabard" },
	{ name = "WristSlot", label = _G.WRISTSLOT or "Wrist" },
	{ name = "HandsSlot", label = _G.HANDSSLOT or "Hands" },
	{ name = "WaistSlot", label = _G.WAISTSLOT or "Waist" },
	{ name = "LegsSlot", label = _G.LEGSSLOT or "Legs" },
	{ name = "FeetSlot", label = _G.FEETSLOT or "Feet" },
	{ name = "Finger0Slot", label = string.format("%s 1", _G.FINGER0SLOT or "Finger") },
	{ name = "Finger1Slot", label = string.format("%s 2", _G.FINGER1SLOT or "Finger") },
	{ name = "Trinket0Slot", label = string.format("%s 1", _G.TRINKET0SLOT or "Trinket") },
	{ name = "Trinket1Slot", label = string.format("%s 2", _G.TRINKET1SLOT or "Trinket") },
	{ name = "MainHandSlot", label = _G.MAINHANDSLOT or "Main Hand" },
	{ name = "SecondaryHandSlot", label = _G.SECONDARYHANDSLOT or "Off Hand" },
	{ name = "RangedSlot", label = _G.RANGEDSLOT or "Ranged" },
}

local function getSlotMenuEntries()
	if SLOT_MENU_ENTRIES then return SLOT_MENU_ENTRIES end
	SLOT_MENU_ENTRIES = {}
	if not Api.GetInventorySlotInfo then return SLOT_MENU_ENTRIES end
	for _, def in ipairs(SLOT_DEFS) do
		local ok, slotId = pcall(Api.GetInventorySlotInfo, def.name)
		if ok and slotId then
			local label = def.label or def.name
			SLOT_LABELS[slotId] = label
			SLOT_MENU_ENTRIES[#SLOT_MENU_ENTRIES + 1] = { id = slotId, label = label }
		end
	end
	return SLOT_MENU_ENTRIES
end

local function getSlotLabel(slotId)
	if not next(SLOT_LABELS) then getSlotMenuEntries() end
	return SLOT_LABELS[slotId] or ("Slot " .. tostring(slotId))
end

local function getSpellName(spellId)
	if not spellId then return nil end
	if C_Spell and C_Spell.GetSpellInfo then
		local info = C_Spell.GetSpellInfo(spellId)
		if info and info.name then return info.name end
	end
	if GetSpellInfo then
		local name = GetSpellInfo(spellId)
		if name then return name end
	end
	return nil
end

local function getItemName(itemId)
	if not itemId then return nil end
	if C_Item and C_Item.GetItemNameByID then
		local name = C_Item.GetItemNameByID(itemId)
		if name then return name end
	end
	if C_Item and C_Item.GetItemInfo then
		local name = C_Item.GetItemInfo(itemId)
		if name then return name end
	end
	return nil
end

local function getEntryName(entry)
	if not entry then return "" end
	if entry.type == "CDM_AURA" then
		local cdmAuras = CooldownPanels.CDMAuras
		if cdmAuras and cdmAuras.GetEntryName then
			local name = cdmAuras:GetEntryName(entry)
			if name and name ~= "" then return name end
		end
	end
	if entry.type == "SPELL" then
		local numericID = tonumber(entry.spellID)
		local effectiveSpellId, resolvedSpellId, _, variantGroup = CooldownPanels:ResolveTrackedSpellID(entry.spellID)
		local spellId = effectiveSpellId or resolvedSpellId or getEffectiveSpellId(entry.spellID) or entry.spellID
		if variantGroup and variantGroup.kind == "talentChoice" and not cdp.RUNTIME.IsDirectTalentChoiceGroupMember(variantGroup, numericID) then
			spellId = numericID or entry.spellID
		end
		local name = getSpellName(spellId)
		return name or ("Spell " .. tostring(entry.spellID or ""))
	end
	if entry.type == "ITEM" then
		local name = getItemName(entry.itemID)
		return name or ("Item " .. tostring(entry.itemID or ""))
	end
	if entry.type == "SLOT" then return getSlotLabel(entry.slotID) end
	if entry.type == "STANCE" and CooldownPanels.GetStanceEntryName then
		local name = CooldownPanels:GetStanceEntryName(entry)
		if name and name ~= "" then return name end
		return (CooldownPanels.GetStanceTypeLabel and CooldownPanels:GetStanceTypeLabel()) or (_G.STANCE or "Stance")
	end
	if entry.type == "MACRO" then
		local macro = CooldownPanels.ResolveMacroEntry(entry)
		if macro and macro.macroName then return macro.macroName end
		return (_G.MACRO or "Macro") .. " " .. tostring(entry.macroID or "")
	end
	return "Entry"
end

local function getEntryTypeLabel(entryType)
	local key = entryType and tostring(entryType):upper() or nil
	if key == "SPELL" then return _G.STAT_CATEGORY_SPELL or _G.SPELLS or "Spell" end
	if key == "ITEM" then return _G.AUCTION_HOUSE_HEADER_ITEM or _G.ITEMS or "Item" end
	if key == "SLOT" then return L["CooldownPanelSlotType"] or "Slot" end
	if key == "STANCE" then return (CooldownPanels.GetStanceTypeLabel and CooldownPanels:GetStanceTypeLabel()) or (_G.STANCE or "Stance") end
	if key == "MACRO" then return _G.MACRO or "Macro" end
	if key == "CDM_AURA" then
		local cdmAuras = CooldownPanels.CDMAuras
		if cdmAuras and cdmAuras.GetEntryTypeLabel then
			local label = cdmAuras:GetEntryTypeLabel(key)
			if label and label ~= "" then return label end
		end
	end
	return entryType or ""
end

function CooldownPanels:GetEntryStandaloneTitle(entry)
	local name = getEntryName(entry)
	local typeLabel = getEntryTypeLabel(entry and entry.type)
	if name and name ~= "" and typeLabel and typeLabel ~= "" then return string.format(L["CooldownPanelStandaloneEntryTitle"] or "%s - %s", name, typeLabel) end
	return name or typeLabel or ""
end

cdp.ENTRY.STYLE_CLIPBOARD = {
	SKIP_KEYS = {
		id = true,
		type = true,
		spellID = true,
		itemID = true,
		slotID = true,
		cooldownID = true,
		buffName = true,
		iconTextureID = true,
		sourceType = true,
		sourceViewer = true,
		macroID = true,
		macroName = true,
		slotIndex = true,
		slotColumn = true,
		slotRow = true,
		fixedGroupId = true,
		iconOffsetX = true,
		iconOffsetY = true,
		customIconID = true,
		ignoreMasque = true,
		staticText = true,
		stateTextureType = true,
		stateTextureAtlas = true,
		stateTextureFileID = true,
		fixedGroupIconSizeInherited = true,
		fixedGroupIconSizePrevUseGlobal = true,
		fixedGroupIconSizePrev = true,
	},
	SPELL_ONLY_KEYS = {
		showCharges = true,
		showChargesCooldown = true,
		trackPassiveSpell = true,
		readyGlowCheckPower = true,
		checkPower = true,
		checkPowerUseGlobal = true,
		hideWhenNoResource = true,
		hideWhenNoResourceUseGlobal = true,
		procGlowEnabled = true,
		procGlowUseGlobal = true,
		procGlowStyle = true,
		procGlowInset = true,
	},
	ITEM_ONLY_KEYS = {
		showItemCount = true,
		showItemUses = true,
		showWhenEmpty = true,
		useHighestRank = true,
	},
	SLOT_ONLY_KEYS = {
		showWhenNoCooldown = true,
	},
	CDM_AURA_ONLY_KEYS = {
		pandemicGlow = true,
		pandemicGlowColor = true,
		pandemicGlowStyle = true,
		pandemicGlowInset = true,
		cdmAuraAlwaysShowUseGlobal = true,
		cdmAuraAlwaysShowMode = true,
		cdmAuraOverlayEnabled = true,
		cdmAuraOverlayReverse = true,
		cdmAuraOverlayColor = true,
	},
	STATE_TEXTURE_KEYS = {
		stateTextureInput = true,
		stateTextureShowWithoutProc = true,
		stateTextureScale = true,
		stateTextureWidth = true,
		stateTextureHeight = true,
		stateTextureAngle = true,
		stateTextureDouble = true,
		stateTextureMirror = true,
		stateTextureMirrorSecond = true,
		stateTextureMirrorVertical = true,
		stateTextureMirrorVerticalSecond = true,
		stateTextureSpacingX = true,
		stateTextureSpacingY = true,
	},
}

cdp.ENTRY.STANDALONE_COLLAPSIBLE_IDS = {
	"cooldownPanelStandaloneDisplay",
	"cooldownPanelStandaloneStacks",
	"cooldownPanelStandaloneCharges",
	"cooldownPanelStandaloneCooldownVisuals",
	"cooldownPanelStandaloneOverlays",
	"cooldownPanelStandaloneCooldownText",
	"cooldownPanelStandaloneStaticText",
	"cooldownPanelStandaloneStateTexture",
	"cooldownPanelStandaloneGlow",
	"cooldownPanelStandaloneSound",
}

cdp.ENTRY.TEXT_INFO_LAYOUTS = {
	COOLDOWN_ABOVE_TEXT_BELOW = {
		labelKey = "CooldownPanelQuickSetupCooldownAboveTextBelow",
		fallback = "Cooldown above, text below",
		cooldownTextX = 0,
		cooldownTextY = 12,
		staticTextAnchor = "CENTER",
		staticTextX = 0,
		staticTextY = -8,
	},
	TEXT_ABOVE_COOLDOWN_BELOW = {
		labelKey = "CooldownPanelQuickSetupTextAboveCooldownBelow",
		fallback = "Text above, cooldown below",
		cooldownTextX = 0,
		cooldownTextY = -8,
		staticTextAnchor = "CENTER",
		staticTextX = 0,
		staticTextY = 12,
	},
	TEXT_LEFT_COOLDOWN_RIGHT = {
		labelKey = "CooldownPanelQuickSetupTextLeftCooldownRight",
		fallback = "Text left, cooldown right",
		cooldownTextX = 28,
		cooldownTextY = 0,
		staticTextAnchor = "RIGHT",
		staticTextX = -28,
		staticTextY = 0,
	},
	COOLDOWN_LEFT_TEXT_RIGHT = {
		labelKey = "CooldownPanelQuickSetupCooldownLeftTextRight",
		fallback = "Cooldown left, text right",
		cooldownTextX = -28,
		cooldownTextY = 0,
		staticTextAnchor = "LEFT",
		staticTextX = 28,
		staticTextY = 0,
	},
}

cdp.ENTRY.TEXT_INFO_LAYOUT_ORDER = {
	"COOLDOWN_ABOVE_TEXT_BELOW",
	"TEXT_ABOVE_COOLDOWN_BELOW",
	"TEXT_LEFT_COOLDOWN_RIGHT",
	"COOLDOWN_LEFT_TEXT_RIGHT",
}

function cdp.ENTRY.IsStyleClipboardKeyAllowedForEntry(key, entry)
	local style = cdp.ENTRY.STYLE_CLIPBOARD
	if type(key) ~= "string" or key:match("^_") then return false end
	if key:match("^cdmAura") and not style.CDM_AURA_ONLY_KEYS[key] then return false end
	if style.SKIP_KEYS[key] then return false end
	local entryType = entry and entry.type
	if style.SPELL_ONLY_KEYS[key] and entryType ~= "SPELL" then return false end
	if style.ITEM_ONLY_KEYS[key] and entryType ~= "ITEM" then return false end
	if style.SLOT_ONLY_KEYS[key] and entryType ~= "SLOT" then return false end
	if style.CDM_AURA_ONLY_KEYS[key] and entryType ~= "CDM_AURA" then return false end
	if style.STATE_TEXTURE_KEYS[key] then return entryType == "SPELL" or entryType == "CDM_AURA" end
	if key == "showStacks" then return entryType == "SPELL" or entryType == "CDM_AURA" end
	if key == "activationOverlayOnly" or key == "activationOverlayGlow" or key == "activationOverlayReverse" or key == "activationOverlayColor" then
		return entryType == "SPELL" or entryType == "ITEM" or entryType == "MACRO"
	end
	if key == "autoCooldownDurationEnabled" then return entryType == "SPELL" or entryType == "MACRO" end
	if key == "customCooldownDurationEnabled" or key == "customCooldownDuration" then return entryType == "SPELL" or entryType == "ITEM" or entryType == "SLOT" or entryType == "MACRO" end
	return true
end

function cdp.ENTRY.CopyStyleValue(value) return Helper.CopyTableDeep(value) end

local function getPlayerClassSpecMap()
	local classId = UnitClass and select(3, UnitClass("player")) or nil
	if not classId then return nil end
	local getSpecCount = C_SpecializationInfo and C_SpecializationInfo.GetNumSpecializationsForClassID
	if not getSpecCount or not GetSpecializationInfoForClassID then return nil end
	local specs = {}
	local sex = UnitSex and UnitSex("player") or nil
	local specCount = getSpecCount(classId) or 0
	for specIndex = 1, specCount do
		local specID = GetSpecializationInfoForClassID(classId, specIndex, sex)
		if specID then specs[specID] = true end
	end
	return specs
end

local function panelHasSpecFilter(panel)
	local filter = panel and panel.specFilter
	if type(filter) ~= "table" then return false end
	for _, enabled in pairs(filter) do
		if enabled then return true end
	end
	return false
end

local function panelHasRuntimeEntries(panel)
	if type(panel) ~= "table" or type(panel.entries) ~= "table" then return false end
	for _, entry in pairs(panel.entries) do
		if type(entry) == "table" then return true end
	end
	return false
end

panelAllowsSpec = function(panel)
	if not panelHasSpecFilter(panel) then return true end
	local specId = queryPlayerSpecId()
	if not specId then return false end
	local filter = panel and panel.specFilter
	return filter and filter[specId] == true
end

local function panelMatchesPlayerClass(panel, classSpecs)
	if not panelHasSpecFilter(panel) then return true end
	if not classSpecs or not next(classSpecs) then return true end
	for specId, enabled in pairs(panel.specFilter) do
		if enabled and classSpecs[specId] then return true end
	end
	return false
end

local function getSpecFilterLabel(panel)
	if not panelHasSpecFilter(panel) then return L["CooldownPanelSpecAny"] or "All specs" end
	local labels = {}
	if panel and panel.specFilter then
		for specId, enabled in pairs(panel.specFilter) do
			if enabled then labels[#labels + 1] = getSpecNameById(specId) end
		end
	end
	table.sort(labels)
	if #labels == 0 then return L["CooldownPanelSpecAny"] or "All specs" end
	return table.concat(labels, ", ")
end

function CooldownPanels:SetPanelEditorName(panelId, value)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local text = type(value) == "string" and value or tostring(value or "")
	if not (panel and text ~= "" and text ~= panel.name) then return end
	panel.name = text
	CooldownPanels.MarkRelativeFrameEntriesDirty()
	refreshStandaloneSettings()
	CooldownPanels:RefreshPanel(panelId)
	CooldownPanels:RefreshEditor()
end

function CooldownPanels:SetPanelEditorEnabled(panelId, enabled)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	if not panel then return end
	local nextEnabled = enabled == true
	if (panel.enabled ~= false) == nextEnabled then return end
	panel.enabled = nextEnabled
	CooldownPanels:RebuildSpellIndex()
	Keybinds.MarkPanelsDirty()
	CooldownPanels:RefreshPanel(panelId)
	CooldownPanels:UpdateCursorAnchorState()
	CooldownPanels:RefreshEditor()
end

function CooldownPanels:GetPanelSpecFilterOptions()
	local classMenuData = getClassSpecMenuData()
	local options = {
		{ value = "__all", text = L["CooldownPanelSpecAny"] or "All specs" },
	}
	for _, quickFilter in ipairs(self:GetSpecQuickFilterDefinitions(classMenuData) or {}) do
		if type(quickFilter.specIds) == "table" and #quickFilter.specIds > 0 then
			options[#options + 1] = {
				value = "__quick:" .. tostring(quickFilter.id),
				text = quickFilter.label,
			}
		end
	end
	for _, classData in ipairs(classMenuData) do
		for _, specData in ipairs(classData.specs or {}) do
			if specData.id then
				options[#options + 1] = {
					value = specData.id,
					text = string.format("%s - %s", classData.name or "", specData.name or tostring(specData.id)),
				}
			end
		end
	end
	return options
end

function CooldownPanels:GetPanelSpecQuickFilterSpecIds(value)
	local quickId = type(value) == "string" and value:match("^__quick:(.+)$") or nil
	if not quickId then return nil end
	for _, quickFilter in ipairs(self:GetSpecQuickFilterDefinitions() or {}) do
		if quickFilter.id == quickId then return quickFilter.specIds end
	end
	return nil
end

function CooldownPanels:IsPanelSpecFilterOptionSelected(panelId, value)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	if not panel then return false end
	if value == "__all" then return not panelHasSpecFilter(panel) end
	local quickSpecIds = self:GetPanelSpecQuickFilterSpecIds(value)
	if quickSpecIds then return self:HasAllPanelSpecFilterEntries(panel, quickSpecIds) end
	local specId = tonumber(value)
	return specId and panel.specFilter and panel.specFilter[specId] == true or false
end

function CooldownPanels:SetPanelSpecFilterOptionSelected(panelId, value, selected)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	if not panel then return end
	if value == "__all" then
		if selected == true and panelHasSpecFilter(panel) then
			panel.specFilter = {}
			CooldownPanels:CommitPanelSpecFilter(panelId)
		end
		return
	end
	local quickSpecIds = self:GetPanelSpecQuickFilterSpecIds(value)
	if quickSpecIds then
		if CooldownPanels:SetPanelSpecFilterEntries(panel, quickSpecIds, selected == true) then CooldownPanels:CommitPanelSpecFilter(panelId) end
		return
	end
	local specId = tonumber(value)
	if not (specId and specId > 0) then return end
	panel.specFilter = panel.specFilter or {}
	if selected == true then
		if panel.specFilter[specId] == true then return end
		panel.specFilter[specId] = true
	elseif panel.specFilter[specId] ~= nil then
		panel.specFilter[specId] = nil
	else
		return
	end
	CooldownPanels:CommitPanelSpecFilter(panelId)
end

local function isSpellKnownSafe(spellId)
	if not spellId then return false end
	if Api and Api.IsSpellKnown then
		local known = Api.IsSpellKnown(spellId, true)
		if known then return true end
		local overrideId = getEffectiveSpellId(spellId)
		if overrideId and overrideId ~= spellId then return Api.IsSpellKnown(overrideId, true) and true or false end
		return false
	end
	return true
end

local function spellExistsSafe(spellId)
	if not spellId then return false end
	if Api.DoesSpellExist then return Api.DoesSpellExist(spellId) and true or false end
	if C_Spell and C_Spell.GetSpellInfo then return C_Spell.GetSpellInfo(spellId) ~= nil end
	if Api.GetSpellInfoFn then return Api.GetSpellInfoFn(spellId) ~= nil end
	return true
end

function CooldownPanels:GetPlayerRacialSpellEntries()
	local entries = {}
	local _, raceTag = UnitRace("player")
	local _, classTag = UnitClass("player")
	raceTag = (addon.variables and addon.variables.unitRace) or raceTag
	if not raceTag then return entries end
	local racialCooldowns = {
		Orc = 20572,
		Troll = 26297,
		Scourge = 7744,
		Tauren = 20549,
		BloodElf = {
			DEATHKNIGHT = 50613,
			DEMONHUNTER = 202719,
			HUNTER = 80483,
			MAGE = 28730,
			MONK = 129597,
			PALADIN = 155145,
			PRIEST = 232633,
			ROGUE = 25046,
			WARLOCK = 28730,
			WARRIOR = 69179,
		},
		Goblin = 69070,
		Human = 59752,
		Dwarf = 20594,
		NightElf = 58984,
		Gnome = 20589,
		Draenei = {
			DEATHKNIGHT = 59545,
			HUNTER = 59543,
			MAGE = 59548,
			MONK = 121093,
			PALADIN = 59542,
			PRIEST = 59544,
			ROGUE = 370626,
			SHAMAN = 59547,
			WARLOCK = 416250,
			WARRIOR = 28880,
		},
		Worgen = 68992,
		Pandaren = 107079,
		Dracthyr = { 357214, 368970 },
		Nightborne = 260364,
		HighmountainTauren = 255654,
		MagharOrc = 274738,
		ZandalariTroll = 291944,
		Vulpera = 312411,
		VoidElf = 256948,
		LightforgedDraenei = 255647,
		DarkIronDwarf = 265221,
		KulTiran = 287712,
		Mechagnome = 312924,
		EarthenDwarf = 436344,
		Haranir = 1237885,
		Harronir = 1237885,
	}
	local seen = {}
	local function addSpell(spellId, racialSlot)
		local numericSpellId = tonumber(spellId)
		if not numericSpellId then return end
		local canonicalSpellId = self:NormalizePersistentSpellID(numericSpellId, { allowTalentChoiceCanonical = true }) or getBaseSpellId(numericSpellId) or numericSpellId
		if canonicalSpellId and not seen[canonicalSpellId] and spellExistsSafe(canonicalSpellId) and isSpellKnownSafe(canonicalSpellId) then
			seen[canonicalSpellId] = true
			entries[#entries + 1] = {
				spellID = canonicalSpellId,
				name = getSpellName(canonicalSpellId) or tostring(canonicalSpellId),
				icon = (C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(canonicalSpellId)) or nil,
				order = #entries + 1,
				racialSlot = tonumber(racialSlot) or (#entries + 1),
			}
		end
	end
	local candidates = racialCooldowns[raceTag]
	if type(candidates) == "table" then
		if classTag and candidates[classTag] then addSpell(candidates[classTag], 1) end
		if candidates.default then addSpell(candidates.default, 1) end
		for index, spellId in ipairs(candidates) do
			addSpell(spellId, index)
		end
	else
		addSpell(candidates, 1)
	end
	table.sort(entries, function(a, b)
		return (a.order or 0) < (b.order or 0)
	end)
	if #entries == 0 then
		entries[1] = {
			spellID = 20572,
			name = L["CooldownPanelAddRacial"] or "Racial",
			icon = Helper.PREVIEW_ICON,
			isFallback = true,
			order = 1,
			racialSlot = 1,
		}
	end
	if #entries == 1 then
		entries[2] = {
			spellID = 368970,
			name = (L["CooldownPanelAddRacial"] or "Racial") .. " 2",
			icon = (C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(368970)) or Helper.PREVIEW_ICON,
			isFallback = true,
			order = 2,
			racialSlot = 2,
		}
	end
	return entries
end

function CooldownPanels:GetPlayerRacialSpellIDSet()
	local set = self.playerRacialSpellIDSet
	if type(set) == "table" then return set end
	set = {}
	for _, spellId in ipairs(self.playerRacialSpellIDs or {}) do
		local numericSpellId = tonumber(spellId)
		if numericSpellId then set[numericSpellId] = true end
	end
	self.playerRacialSpellIDSet = set
	return set
end

function CooldownPanels:IsRacialSpellID(spellId)
	local numericSpellId = tonumber(spellId)
	if not numericSpellId then return false end
	local set = self:GetPlayerRacialSpellIDSet()
	return set[numericSpellId] == true
end

function CooldownPanels:GetCurrentPlayerRacialSpellIDs()
	local ids = {}
	local seen = {}
	local racials = self:GetPlayerRacialSpellEntries()
	for i = 1, #racials do
		local racial = racials[i]
		local spellId = tonumber(racial and racial.spellID)
		if spellId and racial.isFallback ~= true and self:IsRacialSpellID(spellId) and not seen[spellId] then
			seen[spellId] = true
			ids[#ids + 1] = spellId
		end
	end
	return ids
end

function CooldownPanels:NormalizePlayerRacialSpellID(spellId, currentPlayerRacialSpellIDs, entry)
	local numericSpellId = tonumber(spellId)
	if not numericSpellId or not self:IsRacialSpellID(numericSpellId) then return spellId end
	if type(currentPlayerRacialSpellIDs) ~= "table" or #currentPlayerRacialSpellIDs == 0 then return numericSpellId end
	local racialSlot = tonumber(entry and entry.racialSlot)
	if racialSlot and racialSlot >= 1 then
		local slotSpellId = tonumber(currentPlayerRacialSpellIDs[math.floor(racialSlot)])
		return slotSpellId or numericSpellId
	end
	for i = 1, #currentPlayerRacialSpellIDs do
		if tonumber(currentPlayerRacialSpellIDs[i]) == numericSpellId then return numericSpellId end
	end
	return tonumber(currentPlayerRacialSpellIDs[1]) or numericSpellId
end

function CooldownPanels:NormalizeActivePanelPlayerRacials(root)
	root = root or ensureRoot()
	if not (root and root.panels) then return false end
	local currentPlayerRacialSpellIDs = self:GetCurrentPlayerRacialSpellIDs()
	if type(currentPlayerRacialSpellIDs) ~= "table" or #currentPlayerRacialSpellIDs == 0 then return false end
	local changed = false
	for _, panel in pairs(root.panels) do
		if panel and panel.enabled ~= false and panelAllowsSpec(panel) then
			local panelChanged = false
			for _, entry in pairs(panel.entries or {}) do
				if entry and entry.type == "SPELL" and entry.spellID then
					local normalizedSpellID = self:NormalizePersistentSpellID(entry.spellID, { allowTalentChoiceCanonical = false }) or entry.spellID
					normalizedSpellID = self:NormalizePlayerRacialSpellID(normalizedSpellID, currentPlayerRacialSpellIDs, entry)
					if normalizedSpellID ~= entry.spellID then
						entry.spellID = normalizedSpellID
						changed = true
						panelChanged = true
					end
				end
			end
			if panelChanged then Helper.InvalidateFixedLayoutCache(panel) end
		end
	end
	if self:DedupeActivePanelPlayerRacials(root) then changed = true end
	return changed
end

function CooldownPanels:DedupeActivePanelPlayerRacials(root)
	root = root or ensureRoot()
	if not (root and root.panels) then return false end
	local changed = false
	for panelId, panel in pairs(root.panels) do
		if panel and panel.enabled ~= false and panelAllowsSpec(panel) and type(panel.entries) == "table" and type(panel.order) == "table" then
			local seen = nil
			local panelChanged = false
			for _, entryId in ipairs(panel.order) do
				local entry = panel.entries[entryId]
				local spellId = entry and entry.type == "SPELL" and tonumber(entry.spellID) or nil
				if spellId and self:IsRacialSpellID(spellId) then
					seen = seen or {}
					if seen[spellId] then
						panel.entries[entryId] = nil
						local runtime = CooldownPanels.runtime
						if runtime and runtime.actionDisplayCounts then runtime.actionDisplayCounts[Helper.GetEntryKey(panelId, entryId)] = nil end
						panelChanged = true
						changed = true
					else
						seen[spellId] = entryId
					end
				end
			end
			if panelChanged then
				Helper.SyncOrder(panel.order, panel.entries)
				Helper.InvalidateFixedLayoutCache(panel)
			end
		end
	end
	return changed
end

function CooldownPanels:RunStorageMigrations(root)
	root = root or ensureRoot()
	if type(root) ~= "table" then return false end
	local version = tonumber(root.version) or 1
	local changed = false
	if version < 2 then
		if self:DedupeActivePanelPlayerRacials(root) then changed = true end
		version = 2
	end
	if version < COOLDOWN_PANELS_STORAGE_VERSION and root.version ~= COOLDOWN_PANELS_STORAGE_VERSION then
		root.version = COOLDOWN_PANELS_STORAGE_VERSION
		changed = true
	end
	return changed
end

function CooldownPanels:FindEquivalentSpellEntry(panelId, spellId)
	panelId = normalizeId(panelId)
	local panel = self:GetPanel(panelId)
	local numericSpellId = tonumber(spellId)
	if not (panel and numericSpellId) then return nil end
	for entryId, entry in pairs(panel.entries or {}) do
		if entry and entry.type == "SPELL" and entry.spellID and self:AreSpellVariantsEquivalent(entry.spellID, numericSpellId) then return entryId, entry end
	end
	return nil
end

function CooldownPanels:AddPlayerRacialEntry(panelId, spellId, racialSlot)
	panelId = normalizeId(panelId)
	local numericSpellId = tonumber(spellId)
	if not (panelId and numericSpellId) then return nil, "INVALID" end
	if self:FindEntryByValue(panelId, "SPELL", numericSpellId) or self:FindEquivalentSpellEntry(panelId, numericSpellId) then return nil, "DUPLICATE" end
	local overrides = nil
	local numericRacialSlot = tonumber(racialSlot)
	if numericRacialSlot and numericRacialSlot >= 1 then overrides = { racialSlot = math.floor(numericRacialSlot) } end
	return self:AddEntrySafe(panelId, "SPELL", numericSpellId, overrides)
end

function CooldownPanels:AddPlayerRacialEntries(panelId)
	panelId = normalizeId(panelId)
	if not panelId then return nil end
	local entries = self:GetPlayerRacialSpellEntries()
	if #entries == 0 then return { added = 0, duplicates = 0, total = 0 } end
	local stats = { added = 0, duplicates = 0, total = #entries }
	for _, racial in ipairs(entries) do
		local spellId = tonumber(racial and racial.spellID)
		if spellId then
			local entryId, reason = self:AddPlayerRacialEntry(panelId, spellId, racial.racialSlot)
			if entryId then
				stats.added = stats.added + 1
			elseif reason == "DUPLICATE" then
				stats.duplicates = stats.duplicates + 1
			end
		end
	end
	if stats.added > 0 then self:RefreshEditor() end
	return stats
end

function CooldownPanels:GetRacialExclusionDefinitions()
	return {
		{ spellID = 20572 },
		{ spellID = 26297 },
		{ spellID = 7744 },
		{ spellID = 20549 },
		{ spellID = 28730 },
		{ spellID = 69070 },
		{ spellID = 59752 },
		{ spellID = 20594 },
		{ spellID = 58984 },
		{ spellID = 20589 },
		{ spellID = 28880 },
		{ spellID = 68992 },
		{ spellID = 107079 },
		{ spellID = 357214 },
		{ spellID = 368970 },
		{ spellID = 260364 },
		{ spellID = 255654 },
		{ spellID = 274738 },
		{ spellID = 291944 },
		{ spellID = 312411 },
		{ spellID = 256948 },
		{ spellID = 255647 },
		{ spellID = 265221 },
		{ spellID = 287712 },
		{ spellID = 312924 },
		{ spellID = 436344 },
		{ spellID = 1237885 },
	}
end

function CooldownPanels:IsRacialSpellEntry(entry)
	return entry and entry.type == "SPELL" and self:IsRacialSpellID(entry.spellID) or false
end

function CooldownPanels:IsEntryRacialVariantExcluded(entry, spellId)
	local exclusions = entry and entry.racialVariantExclusions
	local numericSpellId = tonumber(spellId)
	if type(exclusions) ~= "table" or not numericSpellId then return false end
	for excludedSpellId, excluded in pairs(exclusions) do
		if excluded == true and self:AreSpellVariantsEquivalent(numericSpellId, excludedSpellId) then return true end
	end
	return false
end

function CooldownPanels:GetRacialExclusionsButtonText(entry)
	local count = 0
	if type(entry and entry.racialVariantExclusions) == "table" then
		for _, excluded in pairs(entry.racialVariantExclusions) do
			if excluded == true then count = count + 1 end
		end
	end
	local label = L["CooldownPanelRacialExclusions"] or "Excluded racials"
	if count > 0 then return string.format("%s (%d)", label, count) end
	return label
end

local function showErrorMessage(msg)
	if UIErrorsFrame and msg then UIErrorsFrame:AddMessage(msg, 1, 0.2, 0.2, 1) end
end

ensureRoot = function()
	if not addon.db then return nil end
	if type(addon.db.cooldownPanels) ~= "table" then addon.db.cooldownPanels = Helper.CreateRoot() end
	local root = addon.db.cooldownPanels
	local needsNormalize = not normalizedRoots[root] or type(root.panels) ~= "table" or type(root.order) ~= "table" or type(root.defaults) ~= "table"
	if needsNormalize then
		Helper.NormalizeRoot(root)
		root._eqolEditorGroupsReady = nil
		root._eqolEditorGroupOrderDirty = true
		local runtime = CooldownPanels.runtime
		if runtime and runtime._eqolPanelIdCacheRoot == root then
			runtime._eqolPanelIdCache = nil
			runtime._eqolPanelIdCacheRoot = nil
		end
		normalizedRoots[root] = true
	end
	if CooldownPanels.EnsureEditorGroupStorage then CooldownPanels.EnsureEditorGroupStorage(root) end
	return root
end

CooldownPanels.EnsureEditorGroupStorage = function(root)
	if not root then return nil end
	if type(root.editorGroups) ~= "table" then
		root.editorGroups = {}
		root._eqolEditorGroupsReady = nil
		root._eqolEditorGroupOrderDirty = true
	end
	if type(root.editorGroupOrder) ~= "table" then
		root.editorGroupOrder = {}
		root._eqolEditorGroupsReady = nil
		root._eqolEditorGroupOrderDirty = true
	end
	if root._eqolEditorGroupsReady == true then
		if root._eqolEditorGroupOrderDirty == true then CooldownPanels:SortEditorGroupOrder(root) end
		return root
	end

	local groups = root.editorGroups
	local order = root.editorGroupOrder
	local groupsByName = {}

	for groupId, group in pairs(groups) do
		if type(group) ~= "table" then
			groups[groupId] = { id = groupId, name = "Group " .. tostring(groupId) }
			group = groups[groupId]
		end
		group.id = normalizeId(group.id) or normalizeId(groupId) or groupId
		group.name = CooldownPanels.NormalizePanelGroupName(group.name) or ("Group " .. tostring(group.id))
		group.parentGroupId = normalizeId(group.parentGroupId)
		groupsByName[group.name] = group.id
	end

	for groupId, group in pairs(groups) do
		local parentGroupId = normalizeId(group.parentGroupId)
		if parentGroupId == groupId or not groups[parentGroupId] then parentGroupId = nil end
		group.parentGroupId = parentGroupId
	end

	for groupId, group in pairs(groups) do
		local seen = { [groupId] = true }
		local parentGroupId = normalizeId(group.parentGroupId)
		while parentGroupId do
			if seen[parentGroupId] then
				group.parentGroupId = nil
				break
			end
			seen[parentGroupId] = true
			local parentGroup = groups[parentGroupId]
			parentGroupId = parentGroup and normalizeId(parentGroup.parentGroupId) or nil
		end
	end

	Helper.SyncOrder(order, groups)

	local panels = root.panels or {}
	for panelId, panel in pairs(panels) do
		if type(panel) == "table" then
			local groupId = normalizeId(panel.editorGroupId)
			if groupId and groups[groupId] then
				panel.editorGroupId = groupId
			else
				panel.editorGroupId = nil
			end

			local legacyName = CooldownPanels.NormalizePanelGroupName(panel.editorGroup)
			if legacyName and not panel.editorGroupId then
				local existingId = groupsByName[legacyName]
				if not existingId then
					existingId = Helper.GetNextNumericId(groups)
					groups[existingId] = { id = existingId, name = legacyName }
					order[#order + 1] = existingId
					groupsByName[legacyName] = existingId
				end
				panel.editorGroupId = existingId
			end
			panel.editorGroup = nil
		end
	end

	Helper.SyncOrder(order, groups)
	root._eqolEditorGroupsReady = true
	root._eqolEditorGroupOrderDirty = true
	CooldownPanels:SortEditorGroupOrder(root)
	return root
end

function CooldownPanels.GetEditorGroup(root, groupId)
	root = CooldownPanels.EnsureEditorGroupStorage(root)
	groupId = normalizeId(groupId)
	return root and root.editorGroups and groupId and root.editorGroups[groupId] or nil
end

function CooldownPanels.GetEditorGroupName(root, groupId)
	local group = CooldownPanels.GetEditorGroup(root, groupId)
	return group and group.name or nil
end

function CooldownPanels.IsEditorGroupNameBefore(a, b)
	local left = CooldownPanels.NormalizePanelGroupName(a) or ""
	local right = CooldownPanels.NormalizePanelGroupName(b) or ""
	if strcmputf8i then
		local cmp = strcmputf8i(left, right)
		if cmp ~= 0 then return cmp < 0 end
	end
	local leftLower = tostring(left):lower()
	local rightLower = tostring(right):lower()
	if leftLower ~= rightLower then return leftLower < rightLower end
	return tostring(left) < tostring(right)
end

function CooldownPanels:SortEditorGroupOrder(root)
	if not root then return nil end
	if type(root.editorGroups) ~= "table" then root.editorGroups = {} end
	if type(root.editorGroupOrder) ~= "table" then root.editorGroupOrder = {} end
	if root._eqolEditorGroupsReady == true and root._eqolEditorGroupOrderDirty ~= true then return root.editorGroupOrder end
	Helper.SyncOrder(root.editorGroupOrder, root.editorGroups)
	table.sort(root.editorGroupOrder, function(leftId, rightId)
		local leftGroup = root.editorGroups[leftId]
		local rightGroup = root.editorGroups[rightId]
		local leftName = leftGroup and leftGroup.name or tostring(leftId or "")
		local rightName = rightGroup and rightGroup.name or tostring(rightId or "")
		if leftName == rightName then return (tonumber(leftId) or 0) < (tonumber(rightId) or 0) end
		return CooldownPanels.IsEditorGroupNameBefore(leftName, rightName)
	end)
	root._eqolEditorGroupOrderDirty = nil
	return root.editorGroupOrder
end

function CooldownPanels:CanSetEditorGroupParent(root, groupId, parentGroupId)
	root = CooldownPanels.EnsureEditorGroupStorage(root)
	groupId = normalizeId(groupId)
	parentGroupId = normalizeId(parentGroupId)
	if not (root and groupId and root.editorGroups and root.editorGroups[groupId]) then return false end
	if parentGroupId == nil then return true end
	if not root.editorGroups[parentGroupId] or parentGroupId == groupId then return false end
	while parentGroupId do
		if parentGroupId == groupId then return false end
		local parentGroup = root.editorGroups[parentGroupId]
		parentGroupId = parentGroup and normalizeId(parentGroup.parentGroupId) or nil
	end
	return true
end

function CooldownPanels:BuildEditorGroupHierarchy(root)
	root = CooldownPanels.EnsureEditorGroupStorage(root)
	local groups = root and root.editorGroups or {}
	local order = root and root.editorGroupOrder or {}
	local childrenByParent = { ["__root"] = {} }
	local depthByGroup = {}

	local function getParentKey(parentGroupId)
		parentGroupId = normalizeId(parentGroupId)
		if parentGroupId ~= nil then return tostring(parentGroupId) end
		return "__root"
	end

	for _, groupId in ipairs(order) do
		local group = groups[groupId]
		if group then
			local key = getParentKey(group.parentGroupId)
			local bucket = childrenByParent[key]
			if not bucket then
				bucket = {}
				childrenByParent[key] = bucket
			end
			bucket[#bucket + 1] = groupId
		end
	end

	local function assignDepth(parentGroupId, depth)
		local key = getParentKey(parentGroupId)
		for _, childGroupId in ipairs(childrenByParent[key] or {}) do
			if depthByGroup[childGroupId] == nil then
				depthByGroup[childGroupId] = depth
				assignDepth(childGroupId, depth + 1)
			end
		end
	end

	assignDepth(nil, 0)
	return childrenByParent, depthByGroup
end

function CooldownPanels:GetEditorGroupDescendantIdSet(root, groupId)
	local descendants = {}
	groupId = normalizeId(groupId)
	if not groupId then return descendants end
	local childrenByParent = self:BuildEditorGroupHierarchy(root)

	local function collect(parentGroupId)
		local key = parentGroupId ~= nil and tostring(parentGroupId) or "__root"
		for _, childGroupId in ipairs(childrenByParent[key] or {}) do
			descendants[childGroupId] = true
			collect(childGroupId)
		end
	end

	collect(groupId)
	return descendants
end

function CooldownPanels:PopulateEditorGroupRadioMenu(menu, root, selectedGroupId, onSelect, options)
	if not (menu and onSelect) then return false end
	root = CooldownPanels.EnsureEditorGroupStorage(root)
	selectedGroupId = normalizeId(selectedGroupId)
	local childrenByParent, depthByGroup = self:BuildEditorGroupHierarchy(root)
	local skipGroupIds = options and options.skipGroupIds or nil
	local hasGroups = false

	local function appendChoices(parentGroupId)
		local key = parentGroupId ~= nil and tostring(parentGroupId) or "__root"
		for _, groupId in ipairs(childrenByParent[key] or {}) do
			local group = root.editorGroups and root.editorGroups[groupId] or nil
			if group then
				local skip = skipGroupIds and skipGroupIds[groupId] == true
				if not skip then
					hasGroups = true
					local depth = depthByGroup[groupId] or 0
					local prefix = depth > 0 and string.rep("> ", depth) or ""
					local label = prefix .. (group.name or ("Group " .. tostring(groupId)))
					local targetGroupId = normalizeId(groupId)
					menu:CreateRadio(label, function() return selectedGroupId == targetGroupId end, function() onSelect(targetGroupId) end)
				end
				appendChoices(groupId)
			end
		end
	end

	appendChoices(nil)
	return hasGroups
end

function CooldownPanels:CreateEditorGroup(name, parentGroupId)
	local root = ensureRoot()
	if not root then return nil end
	root = CooldownPanels.EnsureEditorGroupStorage(root)
	local groups = root.editorGroups
	local order = root.editorGroupOrder
	local groupId = Helper.GetNextNumericId(groups)
	local groupName = CooldownPanels.NormalizePanelGroupName(name) or (L["CooldownPanelNewGroup"] or "New Group")
	parentGroupId = normalizeId(parentGroupId)
	if parentGroupId and not groups[parentGroupId] then parentGroupId = nil end
	groups[groupId] = { id = groupId, name = groupName, parentGroupId = parentGroupId }
	order[#order + 1] = groupId
	root._eqolEditorGroupOrderDirty = true
	self:SortEditorGroupOrder(root)
	return groupId
end

function CooldownPanels:RenameEditorGroup(groupId, name)
	local root = ensureRoot()
	local group = CooldownPanels.GetEditorGroup(root, groupId)
	if not group then return false end
	name = CooldownPanels.NormalizePanelGroupName(name)
	if not name then return false end
	group.name = name
	root._eqolEditorGroupOrderDirty = true
	self:SortEditorGroupOrder(root)
	return true
end

function CooldownPanels:DeleteEditorGroup(groupId)
	local root = ensureRoot()
	root = CooldownPanels.EnsureEditorGroupStorage(root)
	groupId = normalizeId(groupId)
	if not (root and root.editorGroups and groupId and root.editorGroups[groupId]) then return false end
	local parentGroupId = normalizeId(root.editorGroups[groupId].parentGroupId)
	root.editorGroups[groupId] = nil
	Helper.SyncOrder(root.editorGroupOrder, root.editorGroups)
	for _, group in pairs(root.editorGroups or {}) do
		if group and normalizeId(group.parentGroupId) == groupId then group.parentGroupId = parentGroupId end
	end
	for _, panel in pairs(root.panels or {}) do
		if panel and normalizeId(panel.editorGroupId) == groupId then panel.editorGroupId = parentGroupId end
	end
	root._eqolEditorGroupOrderDirty = true
	return true
end

function CooldownPanels:SetEditorGroupParent(groupId, parentGroupId)
	local root = ensureRoot()
	root = CooldownPanels.EnsureEditorGroupStorage(root)
	groupId = normalizeId(groupId)
	parentGroupId = normalizeId(parentGroupId)
	local group = root and root.editorGroups and groupId and root.editorGroups[groupId] or nil
	if not group then return false end
	if parentGroupId == nil then
		group.parentGroupId = nil
		root._eqolEditorGroupOrderDirty = true
		return true
	end
	if not self:CanSetEditorGroupParent(root, groupId, parentGroupId) then return false end
	group.parentGroupId = parentGroupId
	root._eqolEditorGroupOrderDirty = true
	return true
end

function CooldownPanels:GetEditorPanelGroupState(editor)
	if not editor then return {} end
	editor.panelGroupState = editor.panelGroupState or {}
	return editor.panelGroupState
end

function CooldownPanels:GetEditorPanelGroupStateKey(groupId)
	groupId = normalizeId(groupId)
	if groupId ~= nil then return tostring(groupId) end
	return "__ungrouped"
end

function CooldownPanels:IsEditorPanelGroupCollapsed(editor, groupId)
	local state = self:GetEditorPanelGroupState(editor)
	return state[self:GetEditorPanelGroupStateKey(groupId)] == true
end

function CooldownPanels:ToggleEditorPanelGroupCollapsed(editor, groupId)
	local state = self:GetEditorPanelGroupState(editor)
	local key = self:GetEditorPanelGroupStateKey(groupId)
	if state[key] == true then
		state[key] = nil
	else
		state[key] = true
	end
	return state[key] == true
end

function CooldownPanels:SetPanelEditorGroup(panelId, groupId)
	local root = ensureRoot()
	root = CooldownPanels.EnsureEditorGroupStorage(root)
	panelId = normalizeId(panelId)
	local panel = root and root.panels and panelId and root.panels[panelId] or nil
	if not panel then return false end
	groupId = normalizeId(groupId)
	if groupId and not (root.editorGroups and root.editorGroups[groupId]) then groupId = nil end
	panel.editorGroupId = groupId
	return true
end

local function markRootOrderDirty(root)
	if root then
		root._orderDirty = true
		CooldownPanels.MarkRelativeFrameEntriesDirty()
		local runtime = CooldownPanels.runtime
		if runtime and runtime._eqolPanelIdCacheRoot == root then
			runtime._eqolPanelIdCache = nil
			runtime._eqolPanelIdCacheRoot = nil
		end
	end
end

local function syncRootOrderIfDirty(root, force)
	if not root then return false end
	if not force then
		if not root._orderDirty then return false end
		if InCombatLockdown and InCombatLockdown() then return false end
	end
	Helper.SyncOrder(root.order, root.panels)
	root._orderDirty = nil
	local runtime = CooldownPanels.runtime
	if runtime and runtime._eqolPanelIdCacheRoot == root then
		runtime._eqolPanelIdCache = nil
		runtime._eqolPanelIdCacheRoot = nil
	end
	return true
end

CooldownPanels.GetCachedPanelIds = function(root)
	if not root then return {} end
	CooldownPanels.runtime = CooldownPanels.runtime or {}
	local runtime = CooldownPanels.runtime
	local cached = runtime._eqolPanelIdCache
	if cached and runtime._eqolPanelIdCacheRoot == root then return cached end

	local panelIds = {}
	local seen = {}
	for _, panelId in ipairs(root.order or {}) do
		if root.panels[panelId] and not seen[panelId] then
			seen[panelId] = true
			panelIds[#panelIds + 1] = panelId
		end
	end
	for panelId in pairs(root.panels or {}) do
		if not seen[panelId] then
			seen[panelId] = true
			panelIds[#panelIds + 1] = panelId
		end
	end

	runtime._eqolPanelIdCache = panelIds
	runtime._eqolPanelIdCacheRoot = root
	return panelIds
end

function CooldownPanels:EnsureDB() return ensureRoot() end

function CooldownPanels:GetRoot() return ensureRoot() end

function CooldownPanels:GetPanel(panelId)
	local root = ensureRoot()
	if not root then return nil end
	panelId = normalizeId(panelId)
	local panel = root.panels and root.panels[panelId]
	if panel and not normalizedPanels[panel] then
		Helper.NormalizePanel(panel, root.defaults)
		normalizedPanels[panel] = true
	end
	return panel
end

function CooldownPanels:GetPanelOrder()
	local root = ensureRoot()
	if not root then return nil end
	return root.order
end

function CooldownPanels:SetPanelOrder(order)
	local root = ensureRoot()
	if not root or type(order) ~= "table" then return end
	root.order = order
	markRootOrderDirty(root)
	syncRootOrderIfDirty(root, true)
end

function CooldownPanels:SetSelectedPanel(panelId)
	local root = ensureRoot()
	if not root then return end
	panelId = normalizeId(panelId)
	if root.panels and root.panels[panelId] then root.selectedPanel = panelId end
end

function CooldownPanels:GetSelectedPanel()
	local root = ensureRoot()
	if not root then return nil end
	return root.selectedPanel
end

function CooldownPanels:CreatePanel(name)
	local root = ensureRoot()
	if not root then return nil end
	local id = Helper.GetNextNumericId(root.panels)
	local panel = Helper.CreatePanel(name, root.defaults)
	panel.id = id
	root.panels[id] = panel
	root.order[#root.order + 1] = id
	markRootOrderDirty(root)
	Keybinds.MarkPanelsDirty()
	if not root.selectedPanel then root.selectedPanel = id end
	self:RebuildSpellIndex()
	self:RefreshPanel(id)
	return id, panel
end

function CooldownPanels:DuplicatePanel(panelId)
	local root = ensureRoot()
	panelId = normalizeId(panelId)
	if not root or not root.panels or not panelId then return nil end
	local source = root.panels[panelId]
	if not source then return nil end

	local id = Helper.GetNextNumericId(root.panels)
	local panel = Helper.CopyTableDeep(source)
	if type(panel) ~= "table" then return nil end
	panel.id = id

	local usedNames = {}
	for _, existingPanel in pairs(root.panels or {}) do
		local existingName = existingPanel and existingPanel.name
		if type(existingName) == "string" and existingName ~= "" then usedNames[existingName] = true end
	end
	local baseName = (type(source.name) == "string" and source.name ~= "" and source.name) or (L["CooldownPanelNewPanel"] or "New Panel")
	local copyLabel = L["Copy"] or "Copy"
	panel.name = string.format("%s %s", baseName, copyLabel)
	if usedNames[panel.name] then
		local suffix = 2
		repeat
			panel.name = string.format("%s %s %d", baseName, copyLabel, suffix)
			suffix = suffix + 1
		until not usedNames[panel.name]
	end

	local anchor = ensurePanelAnchor(panel)
	if anchor then
		anchor.x = (tonumber(anchor.x) or tonumber(panel.x) or 0) + 24
		anchor.y = (tonumber(anchor.y) or tonumber(panel.y) or 0) - 24
		panel.point = anchor.point or panel.point or "CENTER"
		panel.x = anchor.x
		panel.y = anchor.y
	else
		panel.x = (tonumber(panel.x) or 0) + 24
		panel.y = (tonumber(panel.y) or 0) - 24
	end

	Helper.NormalizePanel(panel, root.defaults)
	for _, entry in pairs(panel.entries or {}) do
		Helper.NormalizeEntry(entry, root.defaults)
	end

	root.panels[id] = panel
	local inserted = false
	for index, currentId in ipairs(root.order or {}) do
		if currentId == panelId then
			table.insert(root.order, index + 1, id)
			inserted = true
			break
		end
	end
	if not inserted then root.order[#root.order + 1] = id end
	markRootOrderDirty(root)
	Keybinds.MarkPanelsDirty()
	self:RebuildSpellIndex()
	self:UpdateCursorAnchorState()
	self:RefreshPanel(id)
	return id, panel
end

cdp.EXPORT.SERIALIZABLE_KEY_TYPES = {
	boolean = true,
	number = true,
	string = true,
}

cdp.EXPORT.SERIALIZABLE_VALUE_TYPES = {
	boolean = true,
	number = true,
	string = true,
}

function cdp.EXPORT.SanitizeValue(value, activeTables)
	local valueType = type(value)
	if cdp.EXPORT.SERIALIZABLE_VALUE_TYPES[valueType] then return value end
	if valueType ~= "table" then return nil end
	if activeTables[value] then return nil end

	activeTables[value] = true
	local clone = {}
	for key, child in pairs(value) do
		if cdp.EXPORT.SERIALIZABLE_KEY_TYPES[type(key)] then
			local sanitized = cdp.EXPORT.SanitizeValue(child, activeTables)
			if sanitized ~= nil then clone[key] = sanitized end
		end
	end
	activeTables[value] = nil
	return clone
end

function cdp.EXPORT.SanitizeTable(source)
	if type(source) ~= "table" then return {} end
	return cdp.EXPORT.SanitizeValue(source, {}) or {}
end

function cdp.EXPORT.EncodePayload(payload)
	local serializer = cdp.EXPORT.SERIALIZER
	local deflate = cdp.EXPORT.DEFLATE
	if not serializer or not deflate then return nil, "NO_LIB" end
	local ok, serialized = pcall(serializer.Serialize, serializer, payload)
	if not ok or type(serialized) ~= "string" or serialized == "" then return nil, "SERIALIZE" end
	local compressed = deflate:CompressDeflate(serialized)
	if not compressed then return nil, "COMPRESS" end
	return deflate:EncodeForPrint(compressed)
end

function cdp.EXPORT.DecodePayload(encoded)
	local serializer = cdp.EXPORT.SERIALIZER
	local deflate = cdp.EXPORT.DEFLATE
	if not serializer or not deflate then return nil, "NO_LIB" end
	encoded = tostring(encoded or "")
	encoded = encoded:gsub("^%s+", ""):gsub("%s+$", "")
	if encoded == "" then return nil, "NO_INPUT" end

	local decoded = deflate:DecodeForPrint(encoded) or deflate:DecodeForWoWChatChannel(encoded) or deflate:DecodeForWoWAddonChannel(encoded)
	if not decoded then return nil, "DECODE" end
	local decompressed = deflate:DecompressDeflate(decoded)
	if not decompressed then return nil, "DECOMPRESS" end
	local ok, payload = serializer:Deserialize(decompressed)
	if not ok or type(payload) ~= "table" then return nil, "DESERIALIZE" end
	local meta = payload.meta
	if type(meta) ~= "table" or meta.addon ~= parentAddonName or meta.kind ~= cdp.EXPORT.KIND then return nil, "INVALID" end
	return payload
end

function cdp.EXPORT.GetUniquePanelName(root, preferredName)
	local usedNames = {}
	for _, existingPanel in pairs(root and root.panels or {}) do
		local existingName = existingPanel and existingPanel.name
		if type(existingName) == "string" and existingName ~= "" then usedNames[existingName] = true end
	end
	local baseName = (type(preferredName) == "string" and preferredName ~= "" and preferredName) or (L["CooldownPanelNewPanel"] or "New Panel")
	if not usedNames[baseName] then return baseName end
	local suffix = 2
	local candidate
	repeat
		candidate = string.format("%s %d", baseName, suffix)
		suffix = suffix + 1
	until not usedNames[candidate]
	return candidate
end

function cdp.EXPORT.GetUniqueGroupName(root, preferredName)
	local usedNames = {}
	for _, group in pairs(root and root.editorGroups or {}) do
		local groupName = group and group.name
		if type(groupName) == "string" and groupName ~= "" then usedNames[groupName] = true end
	end
	local baseName = CooldownPanels.NormalizePanelGroupName(preferredName) or (L["CooldownPanelNewGroup"] or "New Group")
	if not usedNames[baseName] then return baseName end
	local suffix = 2
	local candidate
	repeat
		candidate = string.format("%s %d", baseName, suffix)
		suffix = suffix + 1
	until not usedNames[candidate]
	return candidate
end

function CooldownPanels:ExportPanel(panelId)
	local root = ensureRoot()
	panelId = normalizeId(panelId)
	local panel = root and root.panels and panelId and root.panels[panelId] or nil
	if type(panel) ~= "table" then return nil, "NO_DATA" end
	local exportPanel = Helper.CopyTableDeep(panel)
	exportPanel.editorGroupId = nil
	return cdp.EXPORT.EncodePayload({
		meta = {
			addon = parentAddonName,
			kind = cdp.EXPORT.KIND,
			version = tostring(C_AddOns.GetAddOnMetadata(parentAddonName, "Version") or ""),
			payloadVersion = 1,
			scope = "PANEL",
		},
		data = {
			panel = cdp.EXPORT.SanitizeTable(exportPanel),
		},
	})
end

function CooldownPanels:ExportEditorGroup(groupId)
	local root = ensureRoot()
	root = CooldownPanels.EnsureEditorGroupStorage(root)
	groupId = normalizeId(groupId)
	if not (root and root.editorGroups and groupId and root.editorGroups[groupId]) then return nil, "NO_DATA" end

	local groupIds = { groupId }
	local includedGroups = { [groupId] = true }
	local descendants = self:GetEditorGroupDescendantIdSet(root, groupId)
	for descendantId in pairs(descendants) do
		if root.editorGroups[descendantId] then
			includedGroups[descendantId] = true
			groupIds[#groupIds + 1] = descendantId
		end
	end
	table.sort(groupIds, function(left, right) return (tonumber(left) or 0) < (tonumber(right) or 0) end)

	local groups = {}
	for _, exportGroupId in ipairs(groupIds) do
		local group = Helper.CopyTableDeep(root.editorGroups[exportGroupId])
		group.parentGroupId = includedGroups[normalizeId(group.parentGroupId)] and normalizeId(group.parentGroupId) or nil
		groups[exportGroupId] = cdp.EXPORT.SanitizeTable(group)
	end

	local panels = {}
	local panelOrder = {}
	for _, exportPanelId in ipairs(root.order or {}) do
		local panel = root.panels and root.panels[exportPanelId] or nil
		if panel and includedGroups[normalizeId(panel.editorGroupId)] then
			panels[exportPanelId] = cdp.EXPORT.SanitizeTable(panel)
			panelOrder[#panelOrder + 1] = exportPanelId
		end
	end

	return cdp.EXPORT.EncodePayload({
		meta = {
			addon = parentAddonName,
			kind = cdp.EXPORT.KIND,
			version = tostring(C_AddOns.GetAddOnMetadata(parentAddonName, "Version") or ""),
			payloadVersion = 1,
			scope = "GROUP",
		},
		data = {
			rootGroupId = groupId,
			groups = groups,
			groupOrder = groupIds,
			panels = panels,
			panelOrder = panelOrder,
		},
	})
end

function cdp.EXPORT.ImportPanelData(data)
	local root = ensureRoot()
	if not root or type(data) ~= "table" or type(data.panel) ~= "table" then return nil, "NO_DATA" end
	local id = Helper.GetNextNumericId(root.panels)
	local panel = cdp.EXPORT.SanitizeTable(data.panel)
	panel.id = id
	panel.name = cdp.EXPORT.GetUniquePanelName(root, panel.name)
	panel.editorGroupId = nil
	Helper.NormalizePanel(panel, root.defaults)
	for _, entry in pairs(panel.entries or {}) do
		Helper.NormalizeEntry(entry, root.defaults)
	end
	root.panels[id] = panel
	root.order[#root.order + 1] = id
	root.selectedPanel = id
	markRootOrderDirty(root)
	Keybinds.MarkPanelsDirty()
	CooldownPanels:RebuildSpellIndex()
	CooldownPanels:UpdateCursorAnchorState()
	CooldownPanels:RefreshPanel(id)
	return id
end

function cdp.EXPORT.ImportGroupData(data)
	local root = ensureRoot()
	root = CooldownPanels.EnsureEditorGroupStorage(root)
	if not root or type(data) ~= "table" or type(data.groups) ~= "table" then return nil, "NO_DATA" end

	local groupIdMap = {}
	for _, oldGroupId in ipairs(data.groupOrder or {}) do
		local group = data.groups[oldGroupId]
		if type(group) == "table" then
			local newGroupId = Helper.GetNextNumericId(root.editorGroups)
			groupIdMap[oldGroupId] = newGroupId
			local groupName = oldGroupId == normalizeId(data.rootGroupId) and cdp.EXPORT.GetUniqueGroupName(root, group.name) or CooldownPanels.NormalizePanelGroupName(group.name)
			root.editorGroups[newGroupId] = {
				id = newGroupId,
				name = groupName or (L["CooldownPanelNewGroup"] or "New Group"),
				parentGroupId = nil,
			}
			root.editorGroupOrder[#root.editorGroupOrder + 1] = newGroupId
		end
	end

	for oldGroupId, newGroupId in pairs(groupIdMap) do
		local group = data.groups[oldGroupId]
		local oldParentId = group and normalizeId(group.parentGroupId)
		root.editorGroups[newGroupId].parentGroupId = oldParentId and groupIdMap[oldParentId] or nil
	end

	local firstPanelId
	for _, oldPanelId in ipairs(data.panelOrder or {}) do
		local oldPanel = data.panels and data.panels[oldPanelId]
		if type(oldPanel) == "table" then
			local newPanelId = Helper.GetNextNumericId(root.panels)
			local panel = cdp.EXPORT.SanitizeTable(oldPanel)
			panel.id = newPanelId
			panel.name = cdp.EXPORT.GetUniquePanelName(root, panel.name)
			panel.editorGroupId = groupIdMap[normalizeId(panel.editorGroupId)] or groupIdMap[normalizeId(data.rootGroupId)]
			Helper.NormalizePanel(panel, root.defaults)
			for _, entry in pairs(panel.entries or {}) do
				Helper.NormalizeEntry(entry, root.defaults)
			end
			root.panels[newPanelId] = panel
			root.order[#root.order + 1] = newPanelId
			firstPanelId = firstPanelId or newPanelId
		end
	end

	if not next(groupIdMap) and not firstPanelId then return nil, "NO_DATA" end
	root._eqolEditorGroupOrderDirty = true
	CooldownPanels:SortEditorGroupOrder(root)
	root.selectedPanel = firstPanelId or root.selectedPanel
	markRootOrderDirty(root)
	Keybinds.MarkPanelsDirty()
	CooldownPanels:RebuildSpellIndex()
	CooldownPanels:UpdateCursorAnchorState()
	if firstPanelId then CooldownPanels:RefreshPanel(firstPanelId) end
	return firstPanelId or true
end

function CooldownPanels:ImportPanelOrGroup(encoded)
	local payload, reason = cdp.EXPORT.DecodePayload(encoded)
	if not payload then return false, reason end
	local scope = payload.meta and payload.meta.scope
	local imported, importReason
	if scope == "GROUP" then
		imported, importReason = cdp.EXPORT.ImportGroupData(payload.data)
	else
		imported, importReason = cdp.EXPORT.ImportPanelData(payload.data)
	end
	if not imported then return false, importReason or "NO_DATA" end
	CooldownPanels:RefreshEditor()
	return true
end

function CooldownPanels:ReleaseDeletedPanelRuntime(panelId)
	panelId = normalizeId(panelId)
	local allRuntime = self.runtime
	local runtime = allRuntime and panelId and allRuntime[panelId] or nil
	if not runtime then return end

	runtime.visibleCount = 0
	runtime.visiblePowerSpellCount = 0
	runtime._eqolDeletedPanelRuntime = true
	if runtime.visibleEntries then
		for i = 1, #runtime.visibleEntries do
			runtime.visibleEntries[i] = nil
		end
	end
	if runtime.visiblePowerSpells then
		for i = 1, #runtime.visiblePowerSpells do
			runtime.visiblePowerSpells[i] = nil
		end
	end

	local frame = runtime.frame
	if frame then
		local detachedDriver = self:ApplyVisibilityDriverToFrame(frame, nil)
		if allRuntime.pendingVisibilityDriverUpdates then
			if detachedDriver then
				allRuntime.pendingVisibilityDriverUpdates[frame] = nil
			else
				allRuntime.pendingVisibilityDriverUpdates[frame] = false
			end
		end
		frame._eqolDeletedPanelRuntime = true
		frame._eqolDeletedPanelId = panelId
		frame.panelId = nil
		if frame.EnableMouse then frame:EnableMouse(false) end
		if frame.SetAlpha then frame:SetAlpha(0) end
		if frame.bg then frame.bg:Hide() end
		if frame.label then
			frame.label:SetText("")
			frame.label:Hide()
		end
		if frame.editDropZone then
			frame.editDropZone:Hide()
			frame.editDropZone:EnableMouse(false)
			frame.editDropZone.panelId = nil
		end
		if frame.editPanelHandle then
			frame.editPanelHandle:Hide()
			frame.editPanelHandle:EnableMouse(false)
			frame.editPanelHandle.panelId = nil
		end
		if frame.editMoveHandle then
			frame.editMoveHandle:Hide()
			frame.editMoveHandle:EnableMouse(false)
			frame.editMoveHandle.panelId = nil
		end
		if frame.icons then
			for i = 1, #frame.icons do
				local icon = frame.icons[i]
				if icon then
					icon.entryId = nil
					icon._eqolRuntimeData = nil
					icon._eqolRuntimeSnapshot = nil
					if CooldownPanels.StopAllIconGlows then CooldownPanels.StopAllIconGlows(icon) end
					if icon.cooldown then
						if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
						if icon.cooldown.Clear then icon.cooldown:Clear() end
						icon.cooldown._eqolPanelId = nil
						icon.cooldown._eqolEntryId = nil
						icon.cooldown._eqolCustomCooldownKey = nil
					end
					if icon.count then icon.count:Hide() end
					if icon.charges then icon.charges:Hide() end
					if icon.rangeOverlay then icon.rangeOverlay:Hide() end
					if icon.keybind then icon.keybind:Hide() end
					if icon.staticText then icon.staticText:Hide() end
					if icon.stateTexture then icon.stateTexture:Hide() end
					if icon.stateTextureSecond then icon.stateTextureSecond:Hide() end
					icon:Hide()
				end
			end
		end
		local inCombat = InCombatLockdown and InCombatLockdown()
		local protected = frame.IsProtected and frame:IsProtected()
		if not (inCombat and protected) then
			frame:Hide()
			if frame.ClearAllPoints then frame:ClearAllPoints() end
			if frame.SetParent then pcall(frame.SetParent, frame, nil) end
		end
		runtime.frame = nil
	end

	self:ClearPanelCustomCooldownDurations(panelId)
	allRuntime[panelId] = nil
end

function CooldownPanels:ClearAnchorsToDeletedPanel(root, deletedPanelId)
	deletedPanelId = normalizeId(deletedPanelId)
	local deletedFrameName = deletedPanelId and panelFrameName(deletedPanelId) or nil
	if not (root and root.panels and deletedFrameName) then return end

	local changed = false
	for otherPanelId, otherPanel in pairs(root.panels) do
		local anchor = ensurePanelAnchor(otherPanel)
		if anchor and Helper.NormalizeRelativeFrameName(anchor.relativeFrame) == deletedFrameName then
			anchor.relativeFrame = "UIParent"
			changed = true
			local runtime = self.runtime and self.runtime[normalizeId(otherPanelId)] or nil
			if runtime then
				runtime._eqolRelativeFrameCache = nil
				if self.ClearAppliedAnchorCache then self.ClearAppliedAnchorCache(runtime) end
			end
		end
	end

	if changed then self.MarkRelativeFrameEntriesDirty() end
end

function CooldownPanels:DeletePanel(panelId)
	local root = ensureRoot()
	panelId = normalizeId(panelId)
	if not root or not root.panels or not root.panels[panelId] then return end
	self:HideLayoutEntryStandaloneMenu(panelId)
	self:HideLayoutPanelStandaloneMenu(panelId)
	self:HideLayoutFixedGroupStandaloneMenu(panelId)
	self:ReleaseDeletedPanelRuntime(panelId)
	root.panels[panelId] = nil
	markRootOrderDirty(root)
	syncRootOrderIfDirty(root, true)
	self:ClearAnchorsToDeletedPanel(root, panelId)
	Keybinds.MarkPanelsDirty()
	if root.selectedPanel == panelId then root.selectedPanel = root.order[1] end
	self:RebuildSpellIndex()
	self:UpdateCursorAnchorState()
end

function CooldownPanels:AddEntry(panelId, entryType, idValue, overrides)
	local root = ensureRoot()
	if not root then return nil end
	panelId = normalizeId(panelId)
	local panel = self:GetPanel(panelId)
	if not panel then return nil end
	local typeKey = entryType and tostring(entryType):upper() or nil
	if typeKey ~= "SPELL" and typeKey ~= "ITEM" and typeKey ~= "SLOT" and typeKey ~= "STANCE" and typeKey ~= "MACRO" and typeKey ~= "CDM_AURA" then return nil end
	local entryValue = idValue
	local numericValue = tonumber(idValue)
	local itemWasHigherRank = false
	if typeKey == "SPELL" or typeKey == "ITEM" or typeKey == "SLOT" then
		if not numericValue then return nil end
		if typeKey == "SPELL" then
			numericValue = self:NormalizePersistentSpellID(numericValue, { allowTalentChoiceCanonical = true }) or numericValue
		elseif typeKey == "ITEM" then
			local canonicalItemID, wasHigherRank = self:GetCanonicalItemRankID(numericValue)
			numericValue = canonicalItemID
			itemWasHigherRank = wasHigherRank == true
		end
		entryValue = numericValue
	elseif typeKey == "STANCE" then
		local stanceDef = CooldownPanels.GetStanceDefinition and CooldownPanels:GetStanceDefinition(idValue) or nil
		if not stanceDef then return nil end
		entryValue = stanceDef.id
	elseif typeKey == "MACRO" then
		if numericValue then
			entryValue = numericValue
		elseif type(entryValue) ~= "string" then
			return nil
		end
	elseif typeKey == "CDM_AURA" then
		entryValue = idValue
	end
	if Helper.IsFixedLayout(panel.layout) then
		local resolvedOverrides, fixedError = self:ResolveFixedEntryAddOverrides(panel, overrides)
		if fixedError then return nil end
		overrides = resolvedOverrides
	end
	local entryId = Helper.GetNextNumericId(panel.entries)
	local entry = Helper.CreateEntry(typeKey, entryValue, root.defaults)
	if typeKey == "CDM_AURA" and not (entry and entry.cooldownID) then return nil end
	entry.id = entryId
	if type(overrides) == "table" then
		for key, value in pairs(overrides) do
			entry[key] = value
		end
	end
	if entry.type == "ITEM" and itemWasHigherRank then entry.useHighestRank = true end
	if entry.type == "MACRO" then
		entry.macroID = tonumber(entry.macroID)
		entry.macroName = CooldownPanels.NormalizeMacroName(entry.macroName)
	elseif entry.type == "STANCE" and CooldownPanels.NormalizeStanceEntry then
		CooldownPanels:NormalizeStanceEntry(entry)
	elseif entry.type == "CDM_AURA" then
		local cdmAuras = CooldownPanels.CDMAuras
		if cdmAuras and cdmAuras.NormalizeEntry then cdmAuras:NormalizeEntry(entry, root.defaults) end
	end
	panel.entries[entryId] = entry
	panel.order[#panel.order + 1] = entryId
	Helper.InvalidateFixedLayoutCache(panel)
	if Helper.IsFixedLayout(panel.layout) then Helper.EnsureFixedSlotAssignments(panel) end
	if entry.type == "ITEM" and entry.itemID then
		updateItemCountCacheForItem(entry.itemID)
	elseif entry.type == "MACRO" then
		local macro = CooldownPanels.ResolveMacroEntry(entry)
		if macro and macro.kind == "ITEM" and macro.itemID then updateItemCountCacheForItem(macro.itemID) end
	end
	self:RebuildSpellIndex()
	self:RefreshPanel(panelId)
	return entryId, entry
end

function CooldownPanels:FindEntryByValue(panelId, entryType, idValue)
	panelId = normalizeId(panelId)
	local panel = self:GetPanel(panelId)
	if not panel then return nil end
	local typeKey = entryType and tostring(entryType):upper() or nil
	if typeKey == "CDM_AURA" then
		local cdmAuras = CooldownPanels.CDMAuras
		if cdmAuras and cdmAuras.FindEntryByValue then return cdmAuras:FindEntryByValue(panel, idValue) end
		return nil
	end
	local numericValue = tonumber(idValue)
	local canonicalSpellValue = nil
	if typeKey == "SPELL" and numericValue then canonicalSpellValue = self:NormalizePersistentSpellID(numericValue, { allowTalentChoiceCanonical = true }) or numericValue end
	if typeKey == "ITEM" and numericValue then
		local canonicalItemID = self:GetCanonicalItemRankID(numericValue)
		numericValue = canonicalItemID or numericValue
	end
	local macroName = CooldownPanels.NormalizeMacroName(type(idValue) == "string" and idValue or nil)
	local stanceDef = typeKey == "STANCE" and CooldownPanels.GetStanceDefinition and CooldownPanels:GetStanceDefinition(idValue) or nil
	local stanceID = stanceDef and stanceDef.id or nil
	if typeKey ~= "SPELL" and typeKey ~= "ITEM" and typeKey ~= "SLOT" and typeKey ~= "STANCE" and typeKey ~= "MACRO" then return nil end
	for entryId, entry in pairs(panel.entries or {}) do
		if entry and entry.type == typeKey then
			if typeKey == "SPELL" then
				local entrySpellID = tonumber(entry.spellID)
				local canonicalEntrySpellID = entrySpellID and (self:NormalizePersistentSpellID(entrySpellID, { allowTalentChoiceCanonical = true }) or entrySpellID) or nil
				if canonicalEntrySpellID and canonicalSpellValue and canonicalEntrySpellID == canonicalSpellValue then return entryId, entry end
			end
			if typeKey == "ITEM" then
				local entryItemID = tonumber(entry.itemID)
				if entryItemID then
					local canonicalEntryItemID = self:GetCanonicalItemRankID(entryItemID) or entryItemID
					if canonicalEntryItemID == numericValue then return entryId, entry end
				end
			end
			if typeKey == "SLOT" and entry.slotID == numericValue then return entryId, entry end
			if typeKey == "STANCE" and stanceID and entry.stanceID == stanceID then return entryId, entry end
			if typeKey == "MACRO" then
				local entryMacroID = tonumber(entry.macroID)
				local entryMacroName = CooldownPanels.NormalizeMacroName(entry.macroName)
				if numericValue and entryMacroID == numericValue then return entryId, entry end
				if macroName and entryMacroName and macroName == entryMacroName then return entryId, entry end
			end
		end
	end
	return nil
end

function CooldownPanels:RemoveEntry(panelId, entryId)
	panelId = normalizeId(panelId)
	entryId = normalizeId(entryId)
	local panel = self:GetPanel(panelId)
	if not panel or not panel.entries or not panel.entries[entryId] then return end
	local state = CooldownPanels.runtime and CooldownPanels.runtime.layoutEntryStandaloneMenu or nil
	if state and normalizeId(state.panelId) == panelId and normalizeId(state.entryId) == entryId then self:HideLayoutEntryStandaloneMenu(panelId) end
	panel.entries[entryId] = nil
	local runtime = CooldownPanels.runtime
	if runtime and runtime.actionDisplayCounts then runtime.actionDisplayCounts[Helper.GetEntryKey(panelId, entryId)] = nil end
	self:ClearEntryCustomCooldownDuration(panelId, entryId, true)
	Helper.SyncOrder(panel.order, panel.entries)
	Helper.InvalidateFixedLayoutCache(panel)
	self:RebuildSpellIndex()
	self:RefreshPanel(panelId)
end

function cdp.ENTRY.EnsureSpellEntryMeta(metaByPanel, panelId, entryId)
	metaByPanel[panelId] = metaByPanel[panelId] or {}
	local meta = metaByPanel[panelId][entryId]
	if meta then return meta end
	meta = {}
	metaByPanel[panelId][entryId] = meta
	return meta
end

function cdp.ENTRY.RegisterSpellAlias(index, aliasId, panelId, entryId, meta)
	local numericAliasId = tonumber(aliasId)
	if not numericAliasId then return end
	index[numericAliasId] = index[numericAliasId] or {}
	index[numericAliasId][panelId] = index[numericAliasId][panelId] or {}
	index[numericAliasId][panelId][entryId] = meta
end

function cdp.ENTRY.EnsureItemEntryMeta(metaByPanel, panelId, entryId)
	metaByPanel[panelId] = metaByPanel[panelId] or {}
	local meta = metaByPanel[panelId][entryId]
	if meta then return meta end
	meta = {}
	metaByPanel[panelId][entryId] = meta
	return meta
end

function cdp.ENTRY.RegisterItemAlias(index, aliasId, panelId, entryId, meta)
	local numericAliasId = tonumber(aliasId)
	if not numericAliasId then return end
	index[numericAliasId] = index[numericAliasId] or {}
	index[numericAliasId][panelId] = index[numericAliasId][panelId] or {}
	index[numericAliasId][panelId][entryId] = meta
end

function cdp.ENTRY.EnsureSlotEntryMeta(metaByPanel, panelId, entryId)
	metaByPanel[panelId] = metaByPanel[panelId] or {}
	local meta = metaByPanel[panelId][entryId]
	if meta then return meta end
	meta = {}
	metaByPanel[panelId][entryId] = meta
	return meta
end

function cdp.ENTRY.RegisterSlotAlias(index, aliasId, panelId, entryId, meta)
	local numericAliasId = tonumber(aliasId)
	if not numericAliasId then return end
	index[numericAliasId] = index[numericAliasId] or {}
	index[numericAliasId][panelId] = index[numericAliasId][panelId] or {}
	index[numericAliasId][panelId][entryId] = meta
end

function CooldownPanels:RebuildSpellIndex()
	self:InvalidateSpellQueryCaches()
	local root = ensureRoot()
	local runtime = self.runtime or {}
	local index = {}
	local readySoundQueryByStateKey = {}
	local readySoundSoundByStateKey = {}
	local readySoundSweepIds = {}
	local readySoundEventDrivenByStateKey = {}
	local spellEntryIndex = {}
	local spellEntryMeta = {}
	local itemEntryIndex = {}
	local itemEntryMeta = {}
	local slotEntryIndex = {}
	local slotEntryMeta = {}
	local enabledPanels = {}
	local enabledPanelIds = {}
	local cdmAuraPanels = {}
	local cdmAuraPanelIds = {}
	local cdmAuraEntryIdsByPanel = {}
	local cdmAuraCooldownKeys = {}
	local cdmAuraSpellIds = {}
	local cdmAuraHasSpellOnlyEntries = false
	local cdmAuraEntryCount = 0
	local barPanels = {}
	local barPanelIds = {}
	local barEntryIdsByPanel = {}
	local barEntryCount = 0
	local enabledPanelsBySpec = {}
	local enabledPanelIdsBySpec = {}
	local itemPanels = {}
	local itemUsesPanels = {}
	local itemTrackedIds = {}
	local itemUsesTrackedIds = {}
	local rangeCheckSpells = {}
	self:EnsureFoodRankGroupsLoaded()
	local activeSpecId = queryPlayerSpecId()
	local classSpecs = getPlayerClassSpecMap()
	if classSpecs then
		for specId in pairs(classSpecs) do
			enabledPanelsBySpec[specId] = {}
			enabledPanelIdsBySpec[specId] = {}
		end
	end
	if activeSpecId and not enabledPanelsBySpec[activeSpecId] then
		enabledPanelsBySpec[activeSpecId] = {}
		enabledPanelIdsBySpec[activeSpecId] = {}
	end
	if root and root.panels then
		syncRootOrderIfDirty(root)
		for _, panelId in ipairs(CooldownPanels.GetCachedPanelIds(root)) do
			local panel = root.panels[panelId]
			local hasRuntimeEntries = panelHasRuntimeEntries(panel)
			if panel and panel.enabled ~= false and hasRuntimeEntries then
				if panelHasSpecFilter(panel) then
					local filter = panel.specFilter
					for specId, bucket in pairs(enabledPanelsBySpec) do
						if filter and filter[specId] == true then
							bucket[panelId] = true
							enabledPanelIdsBySpec[specId][#enabledPanelIdsBySpec[specId] + 1] = panelId
						end
					end
				else
					for specId, bucket in pairs(enabledPanelsBySpec) do
						bucket[panelId] = true
						enabledPanelIdsBySpec[specId][#enabledPanelIdsBySpec[specId] + 1] = panelId
					end
				end
			end
			if panel and panel.enabled ~= false and hasRuntimeEntries and panelAllowsSpec(panel) then
				enabledPanels[panelId] = true
				enabledPanelIds[#enabledPanelIds + 1] = panelId
				local layout = panel.layout
				local wantsRangeCheck = layout and layout.rangeOverlayEnabled == true
				for entryId, entry in pairs(panel.entries or {}) do
					local spellId
					local trackedItemId
					local trackedSlotId
					local spellEntryMetaData
					local itemEntryMetaData
					local slotEntryMetaData
					if entry and entry.displayMode == "BAR" then
						local entryIds = barEntryIdsByPanel[panelId]
						if not entryIds then
							barPanels[panelId] = true
							barPanelIds[#barPanelIds + 1] = panelId
							entryIds = {}
							barEntryIdsByPanel[panelId] = entryIds
						end
						entryIds[#entryIds + 1] = entryId
						barEntryCount = barEntryCount + 1
					end
					if entry and entry.type == "CDM_AURA" then
						local entryIds = cdmAuraEntryIdsByPanel[panelId]
						if not entryIds then
							cdmAuraPanels[panelId] = true
							cdmAuraPanelIds[#cdmAuraPanelIds + 1] = panelId
							entryIds = {}
							cdmAuraEntryIdsByPanel[panelId] = entryIds
							end
							entryIds[#entryIds + 1] = entryId
							cdmAuraEntryCount = cdmAuraEntryCount + 1
							local cooldownID = entry.cooldownID
							if type(cooldownID) == "number" and cooldownID > 0 then
								cdmAuraCooldownKeys[cooldownID] = true
							elseif type(cooldownID) == "string" and cooldownID ~= "" then
								cdmAuraCooldownKeys[cooldownID] = true
							end
							local cdmAuraSpellID = tonumber(entry.spellID)
							if cdmAuraSpellID and cdmAuraSpellID > 0 then
								cdmAuraSpellIds[cdmAuraSpellID] = true
								if not cooldownID then cdmAuraHasSpellOnlyEntries = true end
							end
						end
					if entry and entry.type == "SPELL" and entry.spellID then
						spellId = tonumber(entry.spellID)
						if self:IsEntryCDMAuraOverlayEnabled(panel.layout, entry, "SPELL") and spellId and spellId > 0 then
							if not cdmAuraEntryIdsByPanel[panelId] then
								cdmAuraPanels[panelId] = true
								cdmAuraPanelIds[#cdmAuraPanelIds + 1] = panelId
								cdmAuraEntryIdsByPanel[panelId] = {}
							end
							for _, aliasId in ipairs(self:GetSpellAliasIDs(spellId)) do
								cdmAuraSpellIds[aliasId] = true
							end
							cdmAuraEntryCount = cdmAuraEntryCount + 1
						end
					elseif entry and entry.type == "MACRO" then
						local macro = CooldownPanels.ResolveMacroEntry(entry)
						if macro and macro.kind == "SPELL" and macro.spellID then
							spellId = tonumber(macro.spellID)
						elseif macro and macro.kind == "ITEM" and entry.showCooldown ~= false then
							itemPanels[panelId] = true
						end
						if macro and macro.kind == "ITEM" then
							trackedItemId = tonumber(macro.itemID)
							if entry.showItemUses == true then itemUsesPanels[panelId] = true end
						end
					end
					if spellId then
						local effectiveId, resolvedSpellId, _, variantGroup = self:ResolveTrackedSpellID(spellId)
						effectiveId = effectiveId or getEffectiveSpellId(spellId) or spellId
						resolvedSpellId = resolvedSpellId or spellId
						if shouldTrackPassiveSpell(entry) or not isSpellPassiveSafe(resolvedSpellId, effectiveId) then
							local showCooldown = entry.showCooldown ~= false
							local staticTextShowOnCooldown = entry.staticTextShowOnCooldown == true
							local showCharges = entry.showCharges == true
							local alwaysShow = entry.alwaysShow ~= false
							spellEntryMetaData = cdp.ENTRY.EnsureSpellEntryMeta(spellEntryMeta, panelId, entryId)
							spellEntryMetaData.panelId = panelId
							spellEntryMetaData.entryId = entryId
							spellEntryMetaData.baseSpellId = spellId
							spellEntryMetaData.resolvedSpellId = resolvedSpellId
							spellEntryMetaData.effectiveSpellId = effectiveId
							spellEntryMetaData.variantGroupKind = variantGroup and variantGroup.kind or nil
							spellEntryMetaData.variantGroupKey = variantGroup and variantGroup.key or nil
							spellEntryMetaData.trackCooldown = showCooldown or staticTextShowOnCooldown
							spellEntryMetaData.trackCharges = showCharges
							spellEntryMetaData.alwaysShow = alwaysShow
							spellEntryMetaData.visibilityOnCooldown = showCooldown and not alwaysShow
							spellEntryMetaData.visibilityOnCharges = showCharges and not alwaysShow
							spellEntryMetaData.localCooldownSafe = spellEntryMetaData.trackCooldown == true and spellEntryMetaData.visibilityOnCooldown ~= true
							spellEntryMetaData.localChargesSafe = spellEntryMetaData.trackCharges == true and spellEntryMetaData.visibilityOnCharges ~= true
							for _, aliasId in ipairs(self:GetSpellAliasIDs(spellId)) do
								index[aliasId] = index[aliasId] or {}
								index[aliasId][panelId] = true
								cdp.ENTRY.RegisterSpellAlias(spellEntryIndex, aliasId, panelId, entryId, spellEntryMetaData)
							end
							if wantsRangeCheck and effectiveId then rangeCheckSpells[effectiveId] = true end
						end
					end
					if entry and (entry.type == "ITEM" or entry.type == "SLOT") and entry.showCooldown ~= false then itemPanels[panelId] = true end
					if entry and entry.type == "ITEM" and entry.showItemUses == true then itemUsesPanels[panelId] = true end
					if entry and entry.type == "ITEM" then trackedItemId = tonumber(entry.itemID) or trackedItemId end
					if entry and entry.type == "SLOT" then trackedSlotId = tonumber(entry.slotID) end
					if trackedItemId then
						local trackUses = entry and entry.showItemUses == true
						itemEntryMetaData = cdp.ENTRY.EnsureItemEntryMeta(itemEntryMeta, panelId, entryId)
						itemEntryMetaData.panelId = panelId
						itemEntryMetaData.entryId = entryId
						itemEntryMetaData.baseItemId = trackedItemId
						itemEntryMetaData.trackUses = trackUses == true
						itemEntryMetaData.showWhenEmpty = entry and entry.showWhenEmpty == true or false
						itemEntryMetaData.alwaysShow = entry and entry.alwaysShow ~= false or false
						local tracked = false
						if entry and entry.type == "ITEM" and entry.useHighestRank == true then
							local rankMap = self.itemHighestRankByID
							local group = rankMap and rankMap[trackedItemId] or nil
							if type(group) == "table" then
								for i = 1, #group do
									local candidateId = tonumber(group[i])
									if candidateId then
										itemTrackedIds[candidateId] = true
										if trackUses then itemUsesTrackedIds[candidateId] = true end
										cdp.ENTRY.RegisterItemAlias(itemEntryIndex, candidateId, panelId, entryId, itemEntryMetaData)
										tracked = true
									end
								end
							end
						end
						if not tracked then
							itemTrackedIds[trackedItemId] = true
							if trackUses then itemUsesTrackedIds[trackedItemId] = true end
							cdp.ENTRY.RegisterItemAlias(itemEntryIndex, trackedItemId, panelId, entryId, itemEntryMetaData)
						end
					end
					if trackedSlotId and entry and entry.type == "SLOT" and entry.showCooldown ~= false then
						slotEntryMetaData = cdp.ENTRY.EnsureSlotEntryMeta(slotEntryMeta, panelId, entryId)
						slotEntryMetaData.panelId = panelId
						slotEntryMetaData.entryId = entryId
						slotEntryMetaData.slotId = trackedSlotId
						slotEntryMetaData.showWhenNoCooldown = entry.showWhenNoCooldown == true
						slotEntryMetaData.alwaysShow = entry.alwaysShow ~= false
						cdp.ENTRY.RegisterSlotAlias(slotEntryIndex, trackedSlotId, panelId, entryId, slotEntryMetaData)
					end
				end
				for i = 1, #(panel.order or {}) do
					local orderedEntryId = panel.order[i]
					local orderedEntry = panel.entries and panel.entries[orderedEntryId] or nil
					local orderedSpellId = orderedEntry and orderedEntry.type == "SPELL" and tonumber(orderedEntry.spellID) or nil
					if orderedSpellId then
						local _, enabledField, soundField = self:GetEntrySoundConfig(orderedEntry)
						if enabledField and soundField and orderedEntry[enabledField] == true then
							local soundName = normalizeSoundName(orderedEntry[soundField])
							if soundName and soundName ~= "None" then
								local stateKey = self:GetReadySoundStateKey(orderedSpellId)
								local querySpellId = self:GetReadySoundQuerySpellId(orderedSpellId)
								if stateKey and querySpellId and readySoundQueryByStateKey[stateKey] == nil then
									local chargesInfo = Api.GetSpellChargesInfo and Api.GetSpellChargesInfo(querySpellId) or nil
									readySoundQueryByStateKey[stateKey] = querySpellId
									readySoundSoundByStateKey[stateKey] = soundName
									readySoundSweepIds[#readySoundSweepIds + 1] = querySpellId
									readySoundEventDrivenByStateKey[stateKey] = type(chargesInfo) == "table" and chargesInfo.maxCharges ~= nil
								end
							end
						end
					end
				end
			end
		end
	end
	self.runtime = runtime
	runtime.disabledPanelIds = runtime.disabledPanelIds or {}
	for i = 1, #runtime.disabledPanelIds do
		runtime.disabledPanelIds[i] = nil
	end
	for panelId in pairs(runtime.enabledPanels or {}) do
		if not enabledPanels[panelId] then runtime.disabledPanelIds[#runtime.disabledPanelIds + 1] = panelId end
	end
	runtime.activeSpecId = activeSpecId
	runtime.enabledPanelsBySpec = enabledPanelsBySpec
	runtime.enabledPanelIdsBySpec = enabledPanelIdsBySpec
	runtime.spellIndex = index
	runtime.readySoundQueryByStateKey = readySoundQueryByStateKey
	runtime.readySoundSoundByStateKey = readySoundSoundByStateKey
	runtime.readySoundSweepIds = readySoundSweepIds
	runtime.readySoundEventDrivenByStateKey = readySoundEventDrivenByStateKey
	runtime.spellEntryIndex = spellEntryIndex
	runtime.spellEntryMeta = spellEntryMeta
	runtime.itemEntryIndex = itemEntryIndex
	runtime.itemEntryMeta = itemEntryMeta
	runtime.slotEntryIndex = slotEntryIndex
	runtime.slotEntryMeta = slotEntryMeta
	runtime.enabledPanels = enabledPanels
	runtime.enabledPanelIds = enabledPanelIds
	runtime.cdmAuraPanels = cdmAuraPanels
	runtime.cdmAuraPanelIds = cdmAuraPanelIds
	runtime.cdmAuraEntryIdsByPanel = cdmAuraEntryIdsByPanel
	runtime.cdmAuraCooldownKeys = cdmAuraCooldownKeys
	runtime.cdmAuraSpellIds = cdmAuraSpellIds
	runtime.cdmAuraHasSpellOnlyEntries = cdmAuraHasSpellOnlyEntries
	runtime.cdmAuraEntryCount = cdmAuraEntryCount
	runtime.barPanels = barPanels
	runtime.barPanelIds = barPanelIds
	runtime.barEntryIdsByPanel = barEntryIdsByPanel
	runtime.barEntryCount = barEntryCount
	runtime.barStaticGeneration = (runtime.barStaticGeneration or 0) + 1
	runtime.barLabelGeneration = (runtime.barLabelGeneration or 0) + 1
	runtime.itemPanels = itemPanels
	runtime.itemUsesPanels = itemUsesPanels
	runtime.itemTrackedIds = itemTrackedIds
	runtime.itemUsesTrackedIds = itemUsesTrackedIds
	if updateRangeCheckSpells then updateRangeCheckSpells(rangeCheckSpells) end
	self:RebuildPowerIndex()
	self:RebuildChargesIndex()
	self:PrimeReadySoundStates()
	local cdmAuras = self.CDMAuras
	if cdmAuras and cdmAuras.HandleRuntimeIndexChanged then
		cdmAuras:HandleRuntimeIndexChanged("RebuildSpellIndex", false)
	elseif cdmAuras and cdmAuras.UpdateEventRegistration then
		cdmAuras:UpdateEventRegistration()
	end
	if self.UpdateEventRegistration then self:UpdateEventRegistration() end
	if self.CheckCDMAuraQuickSetup then self:CheckCDMAuraQuickSetup("RebuildSpellIndex") end
	return index
end

function CooldownPanels:RebuildPowerIndex()
	local root = ensureRoot()
	local runtime = self.runtime
	local enabledPanels = runtime and runtime.enabledPanels or nil
	local enabledPanelIds = runtime and runtime.enabledPanelIds or nil
	local powerIndex = {}
	local powerCostNames = {}
	local powerCheckSpells = {}
	local powerPanelsBySpell = {}
	local powerCheckActive = false
	if root and root.panels then
		if enabledPanelIds then
			for i = 1, #enabledPanelIds do
				local panelId = enabledPanelIds[i]
				local panel = root.panels[panelId]
				local layout = panel and panel.layout or {}
				if panel and (not enabledPanels or enabledPanels[panelId]) then
					local effectiveLayoutCache = {}
					for _, entry in pairs(panel.entries or {}) do
						local trackEntryPower = false
						if entry then
							local entryLayout = self:GetEntryEffectiveLayout(panelId, entry, effectiveLayoutCache, panel) or layout
							trackEntryPower = self:ResolveEntryCheckPower(entryLayout, entry)
								or self:ResolveEntryHideWhenNoResource(entryLayout, entry)
								or (entry.type == "SPELL" and entry.glowReady == true and self:ResolveEntryReadyGlowCheckPower(entryLayout, entry))
						end
						if entry and trackEntryPower then
							local baseId
							if entry.type == "SPELL" and entry.spellID then
								baseId = tonumber(entry.spellID)
							elseif entry.type == "MACRO" then
								local macro = CooldownPanels.ResolveMacroEntry(entry)
								if macro and macro.kind == "SPELL" and macro.spellID then baseId = tonumber(macro.spellID) end
							end
							if baseId then
								powerCheckActive = true
								local effectiveId, resolvedSpellId = self:ResolveTrackedSpellID(baseId)
								effectiveId = effectiveId or getEffectiveSpellId(baseId) or baseId
								resolvedSpellId = resolvedSpellId or baseId
								if shouldTrackPassiveSpell(entry) or not isSpellPassiveSafe(resolvedSpellId, effectiveId) then
									powerCheckSpells[effectiveId] = true
									powerPanelsBySpell[effectiveId] = powerPanelsBySpell[effectiveId] or {}
									powerPanelsBySpell[effectiveId][panelId] = true
									local costs = Api.GetSpellPowerCost and Api.GetSpellPowerCost(effectiveId)
									if type(costs) == "table" then
										local names = getSpellPowerCostNamesFromCosts(costs)
										if names then
											powerCostNames[baseId] = names
											for _, name in ipairs(names) do
												local key = string.upper(name)
												if key ~= "" then
													powerIndex[key] = powerIndex[key] or {}
													powerIndex[key][effectiveId] = true
												end
											end
										end
									end
								end
							end
						end
					end
				end
			end
		else
			for panelId, panel in pairs(root.panels) do
				local layout = panel.layout or {}
				if panel.enabled ~= false and panelAllowsSpec(panel) then
					local normalizedPanelId = normalizeId(panelId)
					local effectiveLayoutCache = {}
					for _, entry in pairs(panel.entries or {}) do
						local trackEntryPower = false
						if entry then
							local entryLayout = self:GetEntryEffectiveLayout(normalizedPanelId, entry, effectiveLayoutCache, panel) or layout
							trackEntryPower = self:ResolveEntryCheckPower(entryLayout, entry)
								or self:ResolveEntryHideWhenNoResource(entryLayout, entry)
								or (entry.type == "SPELL" and entry.glowReady == true and self:ResolveEntryReadyGlowCheckPower(entryLayout, entry))
						end
						if entry and trackEntryPower then
							local baseId
							if entry.type == "SPELL" and entry.spellID then
								baseId = tonumber(entry.spellID)
							elseif entry.type == "MACRO" then
								local macro = CooldownPanels.ResolveMacroEntry(entry)
								if macro and macro.kind == "SPELL" and macro.spellID then baseId = tonumber(macro.spellID) end
							end
							if baseId then
								powerCheckActive = true
								local effectiveId, resolvedSpellId = self:ResolveTrackedSpellID(baseId)
								effectiveId = effectiveId or getEffectiveSpellId(baseId) or baseId
								resolvedSpellId = resolvedSpellId or baseId
								if shouldTrackPassiveSpell(entry) or not isSpellPassiveSafe(resolvedSpellId, effectiveId) then
									powerCheckSpells[effectiveId] = true
									powerPanelsBySpell[effectiveId] = powerPanelsBySpell[effectiveId] or {}
									powerPanelsBySpell[effectiveId][normalizedPanelId] = true
									local costs = Api.GetSpellPowerCost and Api.GetSpellPowerCost(effectiveId)
									if type(costs) == "table" then
										local names = getSpellPowerCostNamesFromCosts(costs)
										if names then
											powerCostNames[baseId] = names
											for _, name in ipairs(names) do
												local key = string.upper(name)
												if key ~= "" then
													powerIndex[key] = powerIndex[key] or {}
													powerIndex[key][effectiveId] = true
												end
											end
										end
									end
								end
							end
						end
					end
				end
			end
		end
	end
	if powerCheckActive and not next(powerCheckSpells) then powerCheckActive = false end
	self.runtime = self.runtime or {}
	runtime = self.runtime
	runtime.powerIndex = powerIndex
	runtime.powerCostNames = powerCostNames
	runtime.powerCheckSpells = powerCheckSpells
	runtime.powerPanelsBySpell = powerPanelsBySpell
	runtime.powerCheckActive = powerCheckActive == true
	runtime.powerInsufficient = runtime.powerInsufficient or {}
	wipe(runtime.powerInsufficient)
	runtime.spellUnusable = runtime.spellUnusable or {}
	wipe(runtime.spellUnusable)
	updatePowerEventRegistration()
	if Api.IsSpellUsableFn then
		for spellId in pairs(powerCheckSpells) do
			local checkId = getEffectiveSpellId(spellId) or spellId
			local isUsable, insufficientPower = Api.IsSpellUsableFn(checkId)
			setPowerInsufficient(runtime, checkId, isUsable, insufficientPower)
		end
	end
end

function CooldownPanels:RebuildChargesIndex()
	local root = ensureRoot()
	local runtime = self.runtime
	local enabledPanels = runtime and runtime.enabledPanels or nil
	local enabledPanelIds = runtime and runtime.enabledPanelIds or nil
	local chargesIndex = {}
	local chargesPanels = {}
	if root and root.panels then
		if enabledPanelIds then
			for i = 1, #enabledPanelIds do
				local panelId = enabledPanelIds[i]
				local panel = root.panels[panelId]
				if panel and (not enabledPanels or enabledPanels[panelId]) then
					for _, entry in pairs(panel.entries or {}) do
						local baseId
						if entry and entry.showCharges == true then
							if entry.type == "SPELL" and entry.spellID then
								baseId = tonumber(entry.spellID)
							elseif entry.type == "MACRO" then
								local macro = CooldownPanels.ResolveMacroEntry(entry)
								if macro and macro.kind == "SPELL" and macro.spellID then baseId = tonumber(macro.spellID) end
							end
						end
						if baseId then
							local effectiveId, resolvedSpellId = self:ResolveTrackedSpellID(baseId)
							effectiveId = effectiveId or getEffectiveSpellId(baseId) or baseId
							resolvedSpellId = resolvedSpellId or baseId
							if shouldTrackPassiveSpell(entry) or not isSpellPassiveSafe(resolvedSpellId, effectiveId) then
								chargesPanels[panelId] = true
								if Api.GetSpellChargesInfo then
									local info = Api.GetSpellChargesInfo(effectiveId)
									if type(info) == "table" then
										chargesIndex[effectiveId] = chargesIndex[effectiveId] or {}
										chargesIndex[effectiveId][panelId] = true
									end
								end
							end
						end
					end
				end
			end
		else
			for panelId, panel in pairs(root.panels) do
				if panel and panel.enabled ~= false and panelAllowsSpec(panel) then
					for _, entry in pairs(panel.entries or {}) do
						local baseId
						if entry and entry.showCharges == true then
							if entry.type == "SPELL" and entry.spellID then
								baseId = tonumber(entry.spellID)
							elseif entry.type == "MACRO" then
								local macro = CooldownPanels.ResolveMacroEntry(entry)
								if macro and macro.kind == "SPELL" and macro.spellID then baseId = tonumber(macro.spellID) end
							end
						end
						if baseId then
							local effectiveId, resolvedSpellId = self:ResolveTrackedSpellID(baseId)
							effectiveId = effectiveId or getEffectiveSpellId(baseId) or baseId
							resolvedSpellId = resolvedSpellId or baseId
							if shouldTrackPassiveSpell(entry) or not isSpellPassiveSafe(resolvedSpellId, effectiveId) then
								chargesPanels[panelId] = true
								if Api.GetSpellChargesInfo then
									local info = Api.GetSpellChargesInfo(effectiveId)
									if type(info) == "table" then
										chargesIndex[effectiveId] = chargesIndex[effectiveId] or {}
										chargesIndex[effectiveId][panelId] = true
									end
								end
							end
						end
					end
				end
			end
		end
	end
	self.runtime = self.runtime or {}
	runtime = self.runtime
	runtime.chargesIndex = chargesIndex
	runtime.chargesPanels = chargesPanels
	runtime.chargesActive = next(chargesIndex) and true or false
	runtime.chargesState = runtime.chargesState or {}
	for spellId in pairs(runtime.chargesState) do
		if not chargesIndex[spellId] then runtime.chargesState[spellId] = nil end
	end
end

function CooldownPanels:NormalizeAll()
	local root = ensureRoot()
	if not root then return end
	Helper.NormalizeRoot(root)
	root._eqolEditorGroupsReady = nil
	root._eqolEditorGroupOrderDirty = true
	normalizedRoots[root] = true
	Helper.SyncOrder(root.order, root.panels)
	root._orderDirty = nil
	local currentPlayerRacialSpellIDs = self:GetCurrentPlayerRacialSpellIDs()
	for panelId, panel in pairs(root.panels) do
		if panel and panel.id == nil then panel.id = panelId end
		Helper.NormalizePanel(panel, root.defaults)
		Helper.InvalidateFixedLayoutCache(panel)
		normalizedPanels[panel] = true
		Helper.SyncOrder(panel.order, panel.entries)
		local normalizePlayerRacials = panel and panel.enabled ~= false and panelAllowsSpec(panel)
		for entryId, entry in pairs(panel.entries) do
			if entry and entry.id == nil then entry.id = entryId end
			Helper.NormalizeEntry(entry, root.defaults)
			if entry and entry.type == "SPELL" and entry.spellID then
				local normalizedSpellID = self:NormalizePersistentSpellID(entry.spellID, { allowTalentChoiceCanonical = false }) or entry.spellID
				if normalizePlayerRacials then normalizedSpellID = self:NormalizePlayerRacialSpellID(normalizedSpellID, currentPlayerRacialSpellIDs, entry) end
				entry.spellID = normalizedSpellID
			end
			if entry and entry.type == "ITEM" then
				local canonicalItemID, wasHigherRank = self:GetCanonicalItemRankID(entry.itemID)
				if canonicalItemID then entry.itemID = canonicalItemID end
				if wasHigherRank then entry.useHighestRank = true end
			end
		end
		local seenSpellVariantEntries = nil
		local removedDuplicateVariantEntry = false
		for _, entryId in ipairs(panel.order or {}) do
			local entry = panel.entries and panel.entries[entryId]
			if entry and entry.type == "SPELL" and entry.spellID then
				local canonicalSpellID, _, spellGroup = self:GetCanonicalSpellVariantID(entry.spellID, { staticOnly = true })
				if canonicalSpellID and spellGroup then
					seenSpellVariantEntries = seenSpellVariantEntries or {}
					if seenSpellVariantEntries[canonicalSpellID] then
						panel.entries[entryId] = nil
						local runtime = CooldownPanels.runtime
						if runtime and runtime.actionDisplayCounts then runtime.actionDisplayCounts[Helper.GetEntryKey(panelId, entryId)] = nil end
						removedDuplicateVariantEntry = true
					else
						seenSpellVariantEntries[canonicalSpellID] = entryId
					end
				end
			end
		end
		if removedDuplicateVariantEntry then
			Helper.SyncOrder(panel.order, panel.entries)
			Helper.InvalidateFixedLayoutCache(panel)
		end
		if Helper.IsFixedLayout(panel.layout) then Helper.EnsureFixedSlotAssignments(panel) end
	end
	self:DedupeActivePanelPlayerRacials(root)
	self:RunStorageMigrations(root)
	self:RebuildSpellIndex()
end

function CooldownPanels:AddEntrySafe(panelId, entryType, idValue, overrides)
	local typeKey = entryType and tostring(entryType):upper() or nil
	local function addWithFixedChecks(finalType, finalValue, finalOverrides)
		local panel = self:GetPanel(panelId)
		local resolvedOverrides = finalOverrides
		local fixedError = nil
		if panel and Helper.IsFixedLayout(panel.layout) then
			resolvedOverrides, fixedError = self:ResolveFixedEntryAddOverrides(panel, finalOverrides)
		end
		if fixedError then
			showErrorMessage(fixedError)
			return nil
		end
		return self:AddEntry(panelId, finalType, finalValue, resolvedOverrides)
	end
	if typeKey == "CDM_AURA" then
		local cdmAuras = CooldownPanels.CDMAuras
		if cdmAuras and cdmAuras.AddEntrySafe then return cdmAuras:AddEntrySafe(panelId, idValue, overrides) end
		return nil
	end
	local numericValue = tonumber(idValue)
	local baseValue = numericValue
	if typeKey == "SPELL" and numericValue then
		baseValue = getBaseSpellId(numericValue) or numericValue
		if not spellExistsSafe(numericValue) and not spellExistsSafe(baseValue) then
			showErrorMessage(L["CooldownPanelSpellInvalid"] or "Spell does not exist.")
			return nil
		end
		baseValue = self:NormalizePersistentSpellID(numericValue, { allowTalentChoiceCanonical = true }) or baseValue
	end
	if typeKey == "STANCE" then
		local stanceDef = CooldownPanels.GetStanceDefinition and CooldownPanels:GetStanceDefinition(idValue) or nil
		if not stanceDef then
			local stanceLabel = (CooldownPanels.GetStanceTypeLabel and CooldownPanels:GetStanceTypeLabel()) or (_G.STANCE or "Stance")
			showErrorMessage((stanceLabel or (_G.STANCE or "Stance")) .. " does not exist.")
			return nil
		end
		if self:FindEntryByValue(panelId, typeKey, stanceDef.id) then
			showErrorMessage(L["CooldownPanelEntry"] and (L["CooldownPanelEntry"] .. " already exists.") or "Entry already exists.")
			return nil
		end
		local finalOverrides = {}
		if type(overrides) == "table" then
			for key, value in pairs(overrides) do
				finalOverrides[key] = value
			end
		end
		local defaults = CooldownPanels.GetStanceDefaultOverrides and CooldownPanels:GetStanceDefaultOverrides() or nil
		if type(defaults) == "table" then
			for key, value in pairs(defaults) do
				if finalOverrides[key] == nil then finalOverrides[key] = value end
			end
		end
		return addWithFixedChecks(typeKey, stanceDef.id, finalOverrides)
	end
	if typeKey == "MACRO" then
		local macroName = nil
		if type(overrides) == "table" then macroName = CooldownPanels.NormalizeMacroName(overrides.macroName) end
		if not macroName then macroName = CooldownPanels.NormalizeMacroName(type(idValue) == "string" and idValue or nil) end
		if not macroName and numericValue and Api.GetMacroInfo then
			local infoName = Api.GetMacroInfo(numericValue)
			macroName = CooldownPanels.NormalizeMacroName(infoName)
		end
		if numericValue and not macroName then
			showErrorMessage((_G.MACRO or "Macro") .. " does not exist.")
			return nil
		end
		if not numericValue and macroName and Api.GetMacroIndexByName then
			local macroId = Api.GetMacroIndexByName(macroName)
			if type(macroId) == "number" and macroId > 0 then
				numericValue = macroId
				baseValue = macroId
			end
		end
		if not numericValue and not macroName then
			showErrorMessage((_G.MACRO or "Macro") .. " does not exist.")
			return nil
		end
		if self:FindEntryByValue(panelId, typeKey, numericValue or macroName) then
			showErrorMessage(L["CooldownPanelEntry"] and (L["CooldownPanelEntry"] .. " already exists.") or "Entry already exists.")
			return nil
		end
		local finalOverrides = {}
		if type(overrides) == "table" then
			for key, value in pairs(overrides) do
				finalOverrides[key] = value
			end
		end
		finalOverrides.macroName = macroName
		return addWithFixedChecks(typeKey, numericValue or idValue, finalOverrides)
	end
	local finalOverrides = overrides
	if typeKey == "ITEM" and numericValue then
		local canonicalItemID, wasHigherRank = self:GetCanonicalItemRankID(baseValue)
		if canonicalItemID then baseValue = canonicalItemID end
		if wasHigherRank then
			finalOverrides = {}
			if type(overrides) == "table" then
				for key, value in pairs(overrides) do
					finalOverrides[key] = value
				end
			end
			finalOverrides.useHighestRank = true
		end
	end
	if self:FindEntryByValue(panelId, typeKey, baseValue) then
		showErrorMessage(L["CooldownPanelEntry"] and (L["CooldownPanelEntry"] .. " already exists.") or "Entry already exists.")
		return nil
	end
	return addWithFixedChecks(typeKey, baseValue, finalOverrides)
end

function CooldownPanels:HandleCursorDrop(panelId, targetSlot)
	panelId = normalizeId(panelId or self:GetSelectedPanel())
	if not panelId then return false end
	local cursorType, cursorId, _, cursorSpellId = Api.GetCursorInfo()
	if not cursorType then return false end
	local dropOverrides = targetSlot and self:GetFixedDropEntryOverrides(panelId, targetSlot) or nil
	local requiresPostAddMove = false
	if dropOverrides and dropOverrides.fixedGroupId then
		local panel = self:GetPanel(panelId)
		local targetGroup = panel and CooldownPanels.GetFixedGroupById(panel, dropOverrides.fixedGroupId) or nil
		requiresPostAddMove = targetGroup and not CooldownPanels.IsFixedGroupStatic(targetGroup) or false
	end
	local function mergeDropOverrides(baseOverrides)
		if not dropOverrides then return baseOverrides end
		local merged = {}
		if type(baseOverrides) == "table" then
			for key, value in pairs(baseOverrides) do
				merged[key] = value
			end
		end
		for key, value in pairs(dropOverrides) do
			merged[key] = value
		end
		return merged
	end

	local added = false
	local addedEntryId
	if cursorType == "spell" then
		local spellId = cursorSpellId or cursorId
		if spellId then addedEntryId = self:AddEntrySafe(panelId, "SPELL", spellId, mergeDropOverrides()) end
	elseif cursorType == "item" then
		if cursorId then addedEntryId = self:AddEntrySafe(panelId, "ITEM", cursorId, mergeDropOverrides()) end
	elseif cursorType == "macro" then
		if cursorId then
			local macroName = Api.GetMacroInfo and Api.GetMacroInfo(cursorId) or nil
			addedEntryId = self:AddEntrySafe(panelId, "MACRO", cursorId, mergeDropOverrides({ macroName = macroName }))
		end
	elseif cursorType == "action" and Api.GetActionInfo then
		local actionType, actionId = Api.GetActionInfo(cursorId)
		if actionType == "spell" then
			addedEntryId = self:AddEntrySafe(panelId, "SPELL", actionId, mergeDropOverrides())
		elseif actionType == "item" then
			addedEntryId = self:AddEntrySafe(panelId, "ITEM", actionId, mergeDropOverrides())
		elseif actionType == "macro" then
			local macroID = tonumber(actionId)
			local macroName = Api.GetActionText and CooldownPanels.NormalizeMacroName(Api.GetActionText(cursorId)) or nil
			if macroName and Api.GetMacroIndexByName then
				local byName = Api.GetMacroIndexByName(macroName)
				if type(byName) == "number" and byName > 0 then macroID = byName end
			end
			if not macroName and macroID and Api.GetMacroInfo then macroName = CooldownPanels.NormalizeMacroName(Api.GetMacroInfo(macroID)) end
			addedEntryId = self:AddEntrySafe(panelId, "MACRO", macroID or macroName, mergeDropOverrides({ macroName = macroName }))
		end
	end

	added = addedEntryId ~= nil
	if added and targetSlot and requiresPostAddMove then self:MoveEntryToFixedSlot(panelId, addedEntryId, targetSlot) end
	if added then Api.ClearCursor() end
	return added
end

function CooldownPanels:SelectPanel(panelId)
	local root = ensureRoot()
	if not root then return end
	panelId = normalizeId(panelId)
	if not root.panels or not root.panels[panelId] then return end
	local previousPanelId = normalizeId(root.selectedPanel)
	local editor = getEditor()
	local layoutEditActive = editor and editor.layoutEditActive == true
	local previousLayoutPanelId = layoutEditActive and normalizeId(editor._eqolLayoutPanelId or previousPanelId) or nil
	if previousPanelId and previousPanelId ~= panelId then self:HideLayoutEntryStandaloneMenu(previousPanelId) end
	if previousPanelId and previousPanelId ~= panelId then self:HideLayoutPanelStandaloneMenu(previousPanelId) end
	if previousPanelId and previousPanelId ~= panelId then self:HideLayoutFixedGroupStandaloneMenu(previousPanelId) end
	root.selectedPanel = panelId
	if editor then
		editor.selectedPanelId = panelId
		editor.selectedEntryId = nil
		if layoutEditActive then editor._eqolLayoutPanelId = panelId end
	end
	local openLayoutPanelDialog = layoutEditActive and previousPanelId ~= panelId
	local needsLiveRefresh = self:IsAnyPanelLayoutEditActive()
	if needsLiveRefresh then
		if previousLayoutPanelId and previousLayoutPanelId ~= panelId and self:GetPanel(previousLayoutPanelId) then self:RefreshPanel(previousLayoutPanelId) end
		if previousPanelId and previousPanelId ~= panelId and previousPanelId ~= previousLayoutPanelId and self:GetPanel(previousPanelId) then self:RefreshPanel(previousPanelId) end
		self:RefreshPanel(panelId)
	end
	self:UpdateCursorAnchorState()
	self:RefreshEditor()
	if openLayoutPanelDialog then self:OpenLayoutPanelStandaloneMenu(panelId) end
end

function CooldownPanels:SelectEntry(entryId)
	entryId = normalizeId(entryId)
	local editor = getEditor()
	if not editor then return end
	editor.selectedEntryId = entryId
	local panelId = editor.selectedPanelId
	if panelId then
		local runtime = getRuntime(panelId)
		runtime.layoutEditEntryId = entryId
	end
	refreshStandaloneSettingValues()
	self:RefreshEditor()
end

function CooldownPanels:IsPanelLayoutEditAvailable(panelId)
	panelId = normalizeId(panelId)
	if not panelId then return false end
	local panel = self:GetPanel(panelId)
	if not panel then return false end
	return true
end

function CooldownPanels:IsPanelLayoutEditActive(panelId)
	panelId = normalizeId(panelId)
	if not panelId then return false end
	local editor = getEditor()
	if not (editor and editor.frame and editor.frame:IsShown()) then return false end
	if editor.layoutEditActive ~= true then return false end
	if normalizeId(editor.selectedPanelId) ~= panelId then return false end
	return self:IsPanelLayoutEditAvailable(panelId)
end

function CooldownPanels:IsAnyPanelLayoutEditActive()
	local editor = getEditor()
	return editor and editor.frame and editor.frame:IsShown() and editor.layoutEditActive == true
end

function CooldownPanels:SetEditorLayoutEditEnabled(enabled)
	local editor = getEditor()
	if not editor then return end
	enabled = enabled == true
	if enabled and not self:IsPanelLayoutEditAvailable(editor.selectedPanelId) then
		self:RefreshEditor()
		return
	end
	if editor.layoutEditActive == enabled then
		self:RefreshEditor()
		return
	end
	local previousPanelId = normalizeId(editor._eqolLayoutPanelId)
	editor.layoutEditActive = enabled
	local nextPanelId = enabled and normalizeId(editor.selectedPanelId) or nil
	editor._eqolLayoutPanelId = nextPanelId
	if not enabled then
		stopCursorFollow()
		setFakeCursorMode("hidden")
	end
	if not enabled then self:HideLayoutEntryStandaloneMenu(previousPanelId or editor.selectedPanelId) end
	if not enabled then self:HideLayoutPanelStandaloneMenu(previousPanelId or editor.selectedPanelId) end
	if not enabled then self:HideLayoutFixedGroupStandaloneMenu(previousPanelId or editor.selectedPanelId) end
	if previousPanelId and self:GetPanel(previousPanelId) then self:RefreshPanel(previousPanelId) end
	if enabled and nextPanelId then
		local nextPanel = self:GetPanel(nextPanelId)
		if nextPanel and panelUsesFakeCursor(nextPanel) then
			stopCursorFollow()
			resetFakeCursorFrame()
			local frame = showFakeCursorFrame()
			frame:SetAlpha(1)
			if frame.texture then frame.texture:Show() end
			frame:EnableMouse(false)
		end
	end
	if nextPanelId and self:GetPanel(nextPanelId) then self:RefreshPanel(nextPanelId) end
	self:UpdateCursorAnchorState()
	self:RefreshEditor()
	if enabled and nextPanelId then self:OpenLayoutPanelStandaloneMenu(nextPanelId) end
end

function CooldownPanels:PreparePanelForFixedLayoutEdit(panelId)
	panelId = normalizeId(panelId)
	local panel = self:GetPanel(panelId)
	if not panel then return false end
	panel.layout = panel.layout or Helper.CopyTableShallow(Helper.PANEL_LAYOUT_DEFAULTS)
	local layout = panel.layout
	if Helper.NormalizeLayoutMode(layout.layoutMode, Helper.PANEL_LAYOUT_DEFAULTS.layoutMode) == "RADIAL" then return false end
	local changed = false
	if not Helper.IsFixedLayout(layout) then
		layout.layoutMode = "FIXED"
		if Helper.NormalizeFixedGridSize(layout.fixedGridColumns, 0) <= 0 then
			local gridColumns = Helper.ClampInt(layout.wrapCount, 0, 40, Helper.PANEL_LAYOUT_DEFAULTS.wrapCount or 0)
			if gridColumns <= 0 then gridColumns = math.min(math.max(type(panel.order) == "table" and #panel.order or 0, 4), 12) end
			layout.fixedGridColumns = gridColumns
		end
		changed = true
	end
	local maxColumn, maxRow = Helper.EnsureFixedSlotAssignments(panel)
	local nextColumns = Helper.NormalizeFixedGridSize(layout.fixedGridColumns, 0)
	local nextRows = Helper.NormalizeFixedGridSize(layout.fixedGridRows, 0)
	if nextColumns <= 0 then nextColumns = math.max(maxColumn or 0, 1) end
	if nextRows <= 0 then nextRows = math.max(maxRow or 0, 1) end
	if layout.fixedGridColumns ~= nextColumns then
		layout.fixedGridColumns = nextColumns
		changed = true
	end
	if layout.fixedGridRows ~= nextRows then
		layout.fixedGridRows = nextRows
		changed = true
	end
	if changed then
		refreshStandaloneSettingValues()
	end
	return changed
end

function CooldownPanels:BeginStandalonePanelDrag(panelId)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local runtime = panelId and getRuntime(panelId) or nil
	local frame = runtime and runtime.frame
	if not (panel and frame and self:IsPanelLayoutEditActive(panelId)) then return false end
	local anchor = ensurePanelAnchor(panel)
	local usesFakeCursor = panelUsesFakeCursor(panel)
	if usesFakeCursor or not anchorUsesUIParent(anchor) then return false end
	if InCombatLockdown and InCombatLockdown() then return false end
	if frame.SetMovable then frame:SetMovable(true) end
	if frame.EnableMouse then
		runtime._eqolStandalonePanelDragMouseEnabled = true
		frame:EnableMouse(true)
	end
	runtime._eqolStandalonePanelDragging = true
	frame:StartMoving()
	return true
end

function CooldownPanels:FinishStandalonePanelDrag(panelId)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local runtime = panelId and getRuntime(panelId) or nil
	local frame = runtime and runtime.frame
	if not (panel and frame and runtime and runtime._eqolStandalonePanelDragging) then return false end
	runtime._eqolStandalonePanelDragging = nil
	frame:StopMovingOrSizing()
	if runtime._eqolStandalonePanelDragMouseEnabled then
		runtime._eqolStandalonePanelDragMouseEnabled = nil
		frame:EnableMouse(false)
	end
	local anchor = ensurePanelAnchor(panel)
	local point, _, relativePoint, x, y = frame:GetPoint(1)
	point = Helper.NormalizeAnchor(point, anchor and anchor.point or "CENTER")
	relativePoint = Helper.NormalizeAnchor(relativePoint, anchor and anchor.relativePoint or point)
	x = tonumber(x) or 0
	y = tonumber(y) or 0
	self:HandlePositionChanged(panelId, {
		point = point,
		relativePoint = relativePoint,
		x = x,
		y = y,
	})
	CooldownPanels.ClearAppliedAnchorCache(runtime)
	self:ApplyPanelPosition(panelId)
	refreshStandaloneSettingValues()
	return true
end

function CooldownPanels.ShowIconTooltip(self)
	if not self or not self._eqolTooltipEnabled then return end
	local entry = self._eqolTooltipEntry
	if not entry or not GameTooltip then return end

	GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
	local resolvedEntry = entry
	local runtimeData = self._eqolRuntimeData
	if entry.type == "MACRO" then
		local macro = CooldownPanels.ResolveMacroEntry(entry)
		if macro and macro.kind == "SPELL" and macro.spellID then
			resolvedEntry = { type = "SPELL", spellID = macro.spellID }
		elseif macro and macro.kind == "ITEM" and macro.itemID then
			resolvedEntry = { type = "ITEM", itemID = macro.itemID }
		end
	end
	if resolvedEntry.type == "SPELL" and resolvedEntry.spellID and GameTooltip.SetSpellByID then
		local tooltipSpellID = runtimeData and runtimeData.resolvedType == "SPELL" and (runtimeData.effectiveSpellId or runtimeData.spellId or runtimeData.resolvedSpellId) or nil
		if not tooltipSpellID then tooltipSpellID = CooldownPanels:ResolveTrackedSpellID(resolvedEntry.spellID) end
		GameTooltip:SetSpellByID(tooltipSpellID or getEffectiveSpellId(resolvedEntry.spellID) or resolvedEntry.spellID)
	elseif resolvedEntry.type == "CDM_AURA" and resolvedEntry.spellID and GameTooltip.SetSpellByID then
		GameTooltip:SetSpellByID(getEffectiveSpellId(resolvedEntry.spellID) or resolvedEntry.spellID)
	elseif resolvedEntry.type == "ITEM" and resolvedEntry.itemID and GameTooltip.SetItemByID then
		GameTooltip:SetItemByID(resolvedEntry.itemID)
	elseif resolvedEntry.type == "MACRO" then
		local macro = CooldownPanels.ResolveMacroEntry(entry)
		if macro and macro.macroName then
			GameTooltip:SetText(macro.macroName)
		else
			GameTooltip:SetText(_G.MACRO or "Macro")
		end
	elseif entry.type == "SLOT" and entry.slotID then
		local shown = false
		if GameTooltip.SetInventoryItem then shown = GameTooltip:SetInventoryItem("player", entry.slotID) end
		if not shown then GameTooltip:SetText(getSlotLabel(entry.slotID)) end
	elseif entry.type == "STANCE" or entry.type == "CDM_AURA" then
		GameTooltip:SetText(getEntryName(entry))
	else
		return
	end
	GameTooltip:Show()
end

function CooldownPanels.HideIconTooltip()
	if GameTooltip then GameTooltip:Hide() end
end

cdp.RUNTIME.CreateCDMAuraHelpButton = function(parent)
	if not parent then return nil end
	local button = CreateFrame("Button", nil, parent)
	button:SetSize(32, 32)
	button:SetNormalTexture("Interface\\common\\help-i")
	button:SetHighlightTexture("Interface\\common\\help-i")
	if button:GetHighlightTexture() then button:GetHighlightTexture():SetAlpha(0.7) end
	button.ring = button:CreateTexture(nil, "BORDER")
	button.ring:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
	button.ring:SetSize(44, 44)
	button.ring:SetPoint("CENTER", button, "CENTER", 8, -9)
	button:SetScript("OnEnter", function(self)
		if not GameTooltip then return end
		GameTooltip:SetOwner(self, "ANCHOR_RIGHT", 6, 0)
		GameTooltip:ClearLines()
		GameTooltip:SetText(L["CooldownPanelCDMAuraHelpTitle"] or "Tracked Auras")
		GameTooltip:AddLine(L["CooldownPanelCDMAuraHelpTooltip"] or "Auras must be enabled in WoW's Cooldown Manager before EnhanceQoL can display them.", 1, 1, 1, true)
		GameTooltip:Show()
	end)
	button:SetScript("OnLeave", function()
		if GameTooltip then GameTooltip:Hide() end
	end)
	return button
end

function CooldownPanels.SetIconTooltipMouseState(icon, enabled)
	if not icon then return end
	local mouseEnabled = enabled == true
	if icon.SetMouseClickEnabled then
		if icon._eqolTooltipMouseClickEnabled ~= mouseEnabled then
			icon:SetMouseClickEnabled(mouseEnabled)
			icon._eqolTooltipMouseClickEnabled = mouseEnabled
		end
	end
	if icon.SetMouseMotionEnabled and icon._eqolTooltipMouseMotionEnabled ~= mouseEnabled then
		icon:SetMouseMotionEnabled(mouseEnabled)
		icon._eqolTooltipMouseMotionEnabled = mouseEnabled
	end
	if icon.EnableMouse and icon._eqolTooltipMouseEnabled ~= mouseEnabled then
		icon:EnableMouse(mouseEnabled)
		icon._eqolTooltipMouseEnabled = mouseEnabled
	end
	if not mouseEnabled and GameTooltip and GameTooltip.IsOwned and GameTooltip.Hide and GameTooltip:IsOwned(icon) then GameTooltip:Hide() end
end

function CooldownPanels.ApplyIconTooltip(icon, entry, enabled)
	if not icon then return end
	local tooltipEnabled = enabled == true and entry ~= nil
	if icon._eqolTooltipEntry == entry and icon._eqolTooltipEnabled == tooltipEnabled then return end
	icon._eqolTooltipEntry = entry
	icon._eqolTooltipEnabled = tooltipEnabled
	CooldownPanels.SetIconTooltipMouseState(icon, tooltipEnabled)
end

function CooldownPanels:GetCooldownFontDefaults(frame)
	local icon = frame and frame.icons and frame.icons[1]
	local fontString = icon and icon.cooldown and icon.cooldown.GetCountdownFontString and icon.cooldown:GetCountdownFontString()
	if fontString and fontString.GetFont then
		local fontPath, fontSize, fontStyle = fontString:GetFont()
		if fontPath then return fontPath, fontSize, fontStyle end
	end
	return Helper.GetCountFontDefaults(frame)
end

function CooldownPanels:GetGlobalFontConfigKey()
	if addon.functions and addon.functions.GetGlobalFontConfigKey then return addon.functions.GetGlobalFontConfigKey() end
	return "__EQOL_GLOBAL_FONT__"
end

function CooldownPanels:GetFontDropdownValue(value)
	if addon.functions and addon.functions.IsGlobalFontConfigValue and addon.functions.IsGlobalFontConfigValue(value) then return self:GetGlobalFontConfigKey() end
	if type(value) == "string" and value ~= "" then return value end
	return self:GetGlobalFontConfigKey()
end

function CooldownPanels:ResolveEntryCooldownTextStyle(layout, entry, fallbackFontPath, fallbackFontSize, fallbackFontStyle)
	local panelCache = CooldownPanels._styleCacheRoots.cooldownTextPanel[layout]
	local globalFontStateVersion = addon.functions and addon.functions.GetGlobalFontStateVersion and addon.functions.GetGlobalFontStateVersion() or 0
	local srcFont = layout and layout.cooldownTextFont or nil
	local srcSize = layout and layout.cooldownTextSize or nil
	local srcStyle = layout and layout.cooldownTextStyle or nil
	local srcColor = layout and layout.cooldownTextColor or nil
	local srcX = layout and layout.cooldownTextX or nil
	local srcY = layout and layout.cooldownTextY or nil
	if
		not panelCache
		or panelCache.globalFontStateVersion ~= globalFontStateVersion
		or panelCache.fallbackFontPath ~= fallbackFontPath
		or panelCache.fallbackFontSize ~= fallbackFontSize
		or panelCache.fallbackFontStyle ~= fallbackFontStyle
		or panelCache.srcFont ~= srcFont
		or panelCache.srcSize ~= srcSize
		or panelCache.srcStyle ~= srcStyle
		or panelCache.srcColor ~= srcColor
		or panelCache.srcX ~= srcX
		or panelCache.srcY ~= srcY
	then
		panelCache = panelCache or {}
		panelCache.globalFontStateVersion = globalFontStateVersion
		panelCache.fallbackFontPath = fallbackFontPath
		panelCache.fallbackFontSize = fallbackFontSize
		panelCache.fallbackFontStyle = fallbackFontStyle
		panelCache.srcFont = srcFont
		panelCache.srcSize = srcSize
		panelCache.srcStyle = srcStyle
		panelCache.srcColor = srcColor
		panelCache.srcX = srcX
		panelCache.srcY = srcY
		panelCache.fontPath = Helper.ResolveFontPath(srcFont, fallbackFontPath)
		panelCache.fontSize = Helper.ClampInt(srcSize, 6, 64, fallbackFontSize or 12)
		panelCache.fontStyleChoice = Helper.NormalizeFontStyleChoice(srcStyle, fallbackFontStyle)
		panelCache.fontStyle = Helper.NormalizeFontStyle(panelCache.fontStyleChoice, fallbackFontStyle) or ""
		local r, g, b, a = Helper.ResolveColor(srcColor, Helper.PANEL_LAYOUT_DEFAULTS.cooldownTextColor)
		panelCache.fontColor = CooldownPanels.FillCachedColor(panelCache.fontColor, r, g, b, a)
		panelCache.fontX = Helper.ClampInt(srcX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
		panelCache.fontY = Helper.ClampInt(srcY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
		panelCache.version = (panelCache.version or 0) + 1
		CooldownPanels._styleCacheRoots.cooldownTextPanel[layout] = panelCache
	end
	if not entry or entry.cooldownTextUseGlobal ~= false then
		return panelCache.fontPath, panelCache.fontSize, panelCache.fontStyleChoice, panelCache.fontStyle, panelCache.fontColor, panelCache.fontX, panelCache.fontY
	end
	local cache = CooldownPanels._styleCacheRoots.cooldownTextEntry[entry]
	if
		not cache
		or cache.panelVersion ~= panelCache.version
		or cache.srcFont ~= entry.cooldownTextFont
		or cache.srcSize ~= entry.cooldownTextSize
		or cache.srcStyle ~= entry.cooldownTextStyle
		or cache.srcColor ~= entry.cooldownTextColor
		or cache.srcX ~= entry.cooldownTextX
		or cache.srcY ~= entry.cooldownTextY
	then
		cache = cache or {}
		cache.panelVersion = panelCache.version
		cache.srcFont = entry.cooldownTextFont
		cache.srcSize = entry.cooldownTextSize
		cache.srcStyle = entry.cooldownTextStyle
		cache.srcColor = entry.cooldownTextColor
		cache.srcX = entry.cooldownTextX
		cache.srcY = entry.cooldownTextY
		cache.fontPath = Helper.ResolveFontPath(entry.cooldownTextFont, panelCache.fontPath)
		cache.fontSize = Helper.ClampInt(entry.cooldownTextSize, 6, 64, panelCache.fontSize)
		cache.fontStyleChoice = Helper.NormalizeFontStyleChoice(entry.cooldownTextStyle, panelCache.fontStyleChoice)
		cache.fontStyle = Helper.NormalizeFontStyle(cache.fontStyleChoice, panelCache.fontStyle) or ""
		local r, g, b, a = Helper.ResolveColor(entry.cooldownTextColor, panelCache.fontColor)
		cache.fontColor = CooldownPanels.FillCachedColor(cache.fontColor, r, g, b, a)
		cache.fontX = Helper.ClampInt(entry.cooldownTextX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, panelCache.fontX)
		cache.fontY = Helper.ClampInt(entry.cooldownTextY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, panelCache.fontY)
		CooldownPanels._styleCacheRoots.cooldownTextEntry[entry] = cache
	end
	return cache.fontPath, cache.fontSize, cache.fontStyleChoice, cache.fontStyle, cache.fontColor, cache.fontX, cache.fontY
end

function CooldownPanels:ApplyEntryCooldownTextStyle(icon, layout, entry)
	if not (icon and icon.cooldown and icon.cooldown.GetCountdownFontString) then return end
	local fontString = icon.cooldown:GetCountdownFontString()
	if not fontString then return end
	if not icon.cooldown._eqolCooldownTextDefaults then
		local fontPath, fontSize, fontStyle = fontString:GetFont()
		icon.cooldown._eqolCooldownTextDefaults = {
			font = fontPath,
			size = fontSize,
			style = fontStyle,
		}
	end
	local defaults = icon.cooldown._eqolCooldownTextDefaults
	local fontPath, fontSize, fontStyleChoice, fontStyle, fontColor, fontX, fontY =
		self:ResolveEntryCooldownTextStyle(layout, entry, defaults and defaults.font, defaults and defaults.size, defaults and defaults.style)
	if
		fontString._eqolCooldownFont ~= fontPath
		or fontString._eqolCooldownFontSize ~= fontSize
		or fontString._eqolCooldownFontStyle ~= fontStyle
		or fontString._eqolCooldownFontStyleChoice ~= fontStyleChoice
	then
		if Helper.SetFont then
			Helper.SetFont(fontString, fontPath, fontSize, fontStyle, defaults and defaults.font)
		else
			fontString:SetFont(fontPath, fontSize, fontStyle)
		end
		if addon.functions and addon.functions.ApplyFontStyleShadow then
			addon.functions.ApplyFontStyleShadow(fontString, fontStyleChoice, defaults and defaults.style)
		end
		fontString._eqolCooldownFont = fontPath
		fontString._eqolCooldownFontSize = fontSize
		fontString._eqolCooldownFontStyle = fontStyle
		fontString._eqolCooldownFontStyleChoice = fontStyleChoice
	end
	if fontString._eqolCooldownAnchor ~= "CENTER" or fontString._eqolCooldownX ~= fontX or fontString._eqolCooldownY ~= fontY then
		fontString:ClearAllPoints()
		fontString:SetPoint("CENTER", icon.cooldown, "CENTER", fontX, fontY)
		fontString._eqolCooldownAnchor = "CENTER"
		fontString._eqolCooldownX = fontX
		fontString._eqolCooldownY = fontY
	end
	local r = fontColor[1] or 1
	local g = fontColor[2] or 1
	local b = fontColor[3] or 1
	local a = fontColor[4] or 1
	if fontString._eqolCooldownColorR ~= r or fontString._eqolCooldownColorG ~= g or fontString._eqolCooldownColorB ~= b or fontString._eqolCooldownColorA ~= a then
		fontString:SetTextColor(r, g, b, a)
		fontString._eqolCooldownColorR = r
		fontString._eqolCooldownColorG = g
		fontString._eqolCooldownColorB = b
		fontString._eqolCooldownColorA = a
	end
end

function CooldownPanels:ResolveEntryStackTextStyle(layout, entry, fallbackFontPath, fallbackFontSize, fallbackFontStyle)
	local panelCache = CooldownPanels._styleCacheRoots.stackTextPanel[layout]
	local globalFontStateVersion = addon.functions and addon.functions.GetGlobalFontStateVersion and addon.functions.GetGlobalFontStateVersion() or 0
	local srcFont = layout and layout.stackFont or nil
	local srcSize = layout and layout.stackFontSize or nil
	local srcStyle = layout and layout.stackFontStyle or nil
	local srcColor = layout and layout.stackColor or nil
	local srcAnchor = layout and layout.stackAnchor or nil
	local srcX = layout and layout.stackX or nil
	local srcY = layout and layout.stackY or nil
	if
		not panelCache
		or panelCache.globalFontStateVersion ~= globalFontStateVersion
		or panelCache.fallbackFontPath ~= fallbackFontPath
		or panelCache.fallbackFontSize ~= fallbackFontSize
		or panelCache.fallbackFontStyle ~= fallbackFontStyle
		or panelCache.srcFont ~= srcFont
		or panelCache.srcSize ~= srcSize
		or panelCache.srcStyle ~= srcStyle
		or panelCache.srcColor ~= srcColor
		or panelCache.srcAnchor ~= srcAnchor
		or panelCache.srcX ~= srcX
		or panelCache.srcY ~= srcY
	then
		panelCache = panelCache or {}
		panelCache.globalFontStateVersion = globalFontStateVersion
		panelCache.fallbackFontPath = fallbackFontPath
		panelCache.fallbackFontSize = fallbackFontSize
		panelCache.fallbackFontStyle = fallbackFontStyle
		panelCache.srcFont = srcFont
		panelCache.srcSize = srcSize
		panelCache.srcStyle = srcStyle
		panelCache.srcColor = srcColor
		panelCache.srcAnchor = srcAnchor
		panelCache.srcX = srcX
		panelCache.srcY = srcY
		panelCache.fontPath = Helper.ResolveFontPath(srcFont, fallbackFontPath)
		panelCache.fontSize = Helper.ClampInt(srcSize, 6, 64, fallbackFontSize or 12)
		panelCache.fontStyleChoice = Helper.NormalizeFontStyleChoice(srcStyle, fallbackFontStyle)
		panelCache.fontStyle = Helper.NormalizeFontStyle(panelCache.fontStyleChoice, fallbackFontStyle) or ""
		local r, g, b, a = Helper.ResolveColor(srcColor, Helper.PANEL_LAYOUT_DEFAULTS.stackColor or { 1, 1, 1, 1 })
		panelCache.fontColor = CooldownPanels.FillCachedColor(panelCache.fontColor, r, g, b, a)
		panelCache.anchor = Helper.NormalizeAnchor(srcAnchor, Helper.PANEL_LAYOUT_DEFAULTS.stackAnchor or "BOTTOMRIGHT")
		panelCache.x = Helper.ClampInt(srcX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, Helper.PANEL_LAYOUT_DEFAULTS.stackX or 0)
		panelCache.y = Helper.ClampInt(srcY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, Helper.PANEL_LAYOUT_DEFAULTS.stackY or 0)
		panelCache.version = (panelCache.version or 0) + 1
		CooldownPanels._styleCacheRoots.stackTextPanel[layout] = panelCache
	end
	if not entry or entry.stackStyleUseGlobal ~= false then
		return panelCache.fontPath, panelCache.fontSize, panelCache.fontStyleChoice, panelCache.fontStyle, panelCache.fontColor, panelCache.anchor, panelCache.x, panelCache.y
	end
	local cache = CooldownPanels._styleCacheRoots.stackTextEntry[entry]
	if
		not cache
		or cache.panelVersion ~= panelCache.version
		or cache.srcFont ~= entry.stackFont
		or cache.srcSize ~= entry.stackFontSize
		or cache.srcStyle ~= entry.stackFontStyle
		or cache.srcColor ~= entry.stackColor
		or cache.srcAnchor ~= entry.stackAnchor
		or cache.srcX ~= entry.stackX
		or cache.srcY ~= entry.stackY
	then
		cache = cache or {}
		cache.panelVersion = panelCache.version
		cache.srcFont = entry.stackFont
		cache.srcSize = entry.stackFontSize
		cache.srcStyle = entry.stackFontStyle
		cache.srcColor = entry.stackColor
		cache.srcAnchor = entry.stackAnchor
		cache.srcX = entry.stackX
		cache.srcY = entry.stackY
		cache.fontPath = Helper.ResolveFontPath(entry.stackFont, panelCache.fontPath)
		cache.fontSize = Helper.ClampInt(entry.stackFontSize, 6, 64, panelCache.fontSize)
		cache.fontStyleChoice = Helper.NormalizeFontStyleChoice(entry.stackFontStyle, panelCache.fontStyleChoice)
		cache.fontStyle = Helper.NormalizeFontStyle(cache.fontStyleChoice, panelCache.fontStyle) or ""
		local r, g, b, a = Helper.ResolveColor(entry.stackColor, panelCache.fontColor)
		cache.fontColor = CooldownPanels.FillCachedColor(cache.fontColor, r, g, b, a)
		cache.anchor = Helper.NormalizeAnchor(entry.stackAnchor, panelCache.anchor)
		cache.x = Helper.ClampInt(entry.stackX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, panelCache.x)
		cache.y = Helper.ClampInt(entry.stackY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, panelCache.y)
		CooldownPanels._styleCacheRoots.stackTextEntry[entry] = cache
	end
	return cache.fontPath, cache.fontSize, cache.fontStyleChoice, cache.fontStyle, cache.fontColor, cache.anchor, cache.x, cache.y
end

function CooldownPanels:ApplyEntryStackTextStyle(icon, layout, entry, fallbackFontPath, fallbackFontSize, fallbackFontStyle)
	if not (icon and icon.count) then return end
	local fontPath, fontSize, fontStyleChoice, fontStyle, fontColor, anchor, x, y =
		self:ResolveEntryStackTextStyle(layout, entry, fallbackFontPath, fallbackFontSize, fallbackFontStyle)
	if icon.count._eqolStackAnchor ~= anchor or icon.count._eqolStackX ~= x or icon.count._eqolStackY ~= y then
		icon.count:ClearAllPoints()
		icon.count:SetPoint(anchor, icon, anchor, x, y)
		icon.count._eqolStackAnchor = anchor
		icon.count._eqolStackX = x
		icon.count._eqolStackY = y
	end
	if
		icon.count._eqolStackFont ~= fontPath
		or icon.count._eqolStackFontSize ~= fontSize
		or icon.count._eqolStackFontStyle ~= fontStyle
		or icon.count._eqolStackFontStyleChoice ~= fontStyleChoice
	then
		if Helper.SetFont then
			Helper.SetFont(icon.count, fontPath, fontSize, fontStyle, fallbackFontPath)
		else
			icon.count:SetFont(fontPath, fontSize, fontStyle)
		end
		if addon.functions and addon.functions.ApplyFontStyleShadow then addon.functions.ApplyFontStyleShadow(icon.count, fontStyleChoice, fallbackFontStyle) end
		icon.count._eqolStackFont = fontPath
		icon.count._eqolStackFontSize = fontSize
		icon.count._eqolStackFontStyle = fontStyle
		icon.count._eqolStackFontStyleChoice = fontStyleChoice
	end
	local r = fontColor[1] or 1
	local g = fontColor[2] or 1
	local b = fontColor[3] or 1
	local a = fontColor[4] or 1
	if icon.count._eqolStackColorR ~= r or icon.count._eqolStackColorG ~= g or icon.count._eqolStackColorB ~= b or icon.count._eqolStackColorA ~= a then
		icon.count:SetTextColor(r, g, b, a)
		icon.count._eqolStackColorR = r
		icon.count._eqolStackColorG = g
		icon.count._eqolStackColorB = b
		icon.count._eqolStackColorA = a
	end
end

function CooldownPanels:ResolveEntryChargesTextStyle(layout, entry, fallbackFontPath, fallbackFontSize, fallbackFontStyle)
	local panelCache = CooldownPanels._styleCacheRoots.chargesTextPanel[layout]
	local globalFontStateVersion = addon.functions and addon.functions.GetGlobalFontStateVersion and addon.functions.GetGlobalFontStateVersion() or 0
	local srcFont = layout and layout.chargesFont or nil
	local srcSize = layout and layout.chargesFontSize or nil
	local srcStyle = layout and layout.chargesFontStyle or nil
	local srcColor = layout and layout.chargesColor or nil
	local srcAnchor = layout and layout.chargesAnchor or nil
	local srcX = layout and layout.chargesX or nil
	local srcY = layout and layout.chargesY or nil
	if
		not panelCache
		or panelCache.globalFontStateVersion ~= globalFontStateVersion
		or panelCache.fallbackFontPath ~= fallbackFontPath
		or panelCache.fallbackFontSize ~= fallbackFontSize
		or panelCache.fallbackFontStyle ~= fallbackFontStyle
		or panelCache.srcFont ~= srcFont
		or panelCache.srcSize ~= srcSize
		or panelCache.srcStyle ~= srcStyle
		or panelCache.srcColor ~= srcColor
		or panelCache.srcAnchor ~= srcAnchor
		or panelCache.srcX ~= srcX
		or panelCache.srcY ~= srcY
	then
		panelCache = panelCache or {}
		panelCache.globalFontStateVersion = globalFontStateVersion
		panelCache.fallbackFontPath = fallbackFontPath
		panelCache.fallbackFontSize = fallbackFontSize
		panelCache.fallbackFontStyle = fallbackFontStyle
		panelCache.srcFont = srcFont
		panelCache.srcSize = srcSize
		panelCache.srcStyle = srcStyle
		panelCache.srcColor = srcColor
		panelCache.srcAnchor = srcAnchor
		panelCache.srcX = srcX
		panelCache.srcY = srcY
		panelCache.fontPath = Helper.ResolveFontPath(srcFont, fallbackFontPath)
		panelCache.fontSize = Helper.ClampInt(srcSize, 6, 64, fallbackFontSize or 12)
		panelCache.fontStyleChoice = Helper.NormalizeFontStyleChoice(srcStyle, fallbackFontStyle)
		panelCache.fontStyle = Helper.NormalizeFontStyle(panelCache.fontStyleChoice, fallbackFontStyle) or ""
		local r, g, b, a = Helper.ResolveColor(srcColor, Helper.PANEL_LAYOUT_DEFAULTS.chargesColor or { 1, 1, 1, 1 })
		panelCache.fontColor = CooldownPanels.FillCachedColor(panelCache.fontColor, r, g, b, a)
		panelCache.anchor = Helper.NormalizeAnchor(srcAnchor, Helper.PANEL_LAYOUT_DEFAULTS.chargesAnchor or "TOP")
		panelCache.x = Helper.ClampInt(srcX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, Helper.PANEL_LAYOUT_DEFAULTS.chargesX or 0)
		panelCache.y = Helper.ClampInt(srcY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, Helper.PANEL_LAYOUT_DEFAULTS.chargesY or 0)
		panelCache.version = (panelCache.version or 0) + 1
		CooldownPanels._styleCacheRoots.chargesTextPanel[layout] = panelCache
	end
	if not entry or entry.chargesStyleUseGlobal ~= false then
		return panelCache.fontPath, panelCache.fontSize, panelCache.fontStyleChoice, panelCache.fontStyle, panelCache.fontColor, panelCache.anchor, panelCache.x, panelCache.y
	end
	local cache = CooldownPanels._styleCacheRoots.chargesTextEntry[entry]
	if
		not cache
		or cache.panelVersion ~= panelCache.version
		or cache.srcFont ~= entry.chargesFont
		or cache.srcSize ~= entry.chargesFontSize
		or cache.srcStyle ~= entry.chargesFontStyle
		or cache.srcColor ~= entry.chargesColor
		or cache.srcAnchor ~= entry.chargesAnchor
		or cache.srcX ~= entry.chargesX
		or cache.srcY ~= entry.chargesY
	then
		cache = cache or {}
		cache.panelVersion = panelCache.version
		cache.srcFont = entry.chargesFont
		cache.srcSize = entry.chargesFontSize
		cache.srcStyle = entry.chargesFontStyle
		cache.srcColor = entry.chargesColor
		cache.srcAnchor = entry.chargesAnchor
		cache.srcX = entry.chargesX
		cache.srcY = entry.chargesY
		cache.fontPath = Helper.ResolveFontPath(entry.chargesFont, panelCache.fontPath)
		cache.fontSize = Helper.ClampInt(entry.chargesFontSize, 6, 64, panelCache.fontSize)
		cache.fontStyleChoice = Helper.NormalizeFontStyleChoice(entry.chargesFontStyle, panelCache.fontStyleChoice)
		cache.fontStyle = Helper.NormalizeFontStyle(cache.fontStyleChoice, panelCache.fontStyle) or ""
		local r, g, b, a = Helper.ResolveColor(entry.chargesColor, panelCache.fontColor)
		cache.fontColor = CooldownPanels.FillCachedColor(cache.fontColor, r, g, b, a)
		cache.anchor = Helper.NormalizeAnchor(entry.chargesAnchor, panelCache.anchor)
		cache.x = Helper.ClampInt(entry.chargesX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, panelCache.x)
		cache.y = Helper.ClampInt(entry.chargesY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, panelCache.y)
		CooldownPanels._styleCacheRoots.chargesTextEntry[entry] = cache
	end
	return cache.fontPath, cache.fontSize, cache.fontStyleChoice, cache.fontStyle, cache.fontColor, cache.anchor, cache.x, cache.y
end

function CooldownPanels:ApplyEntryChargesTextStyle(icon, layout, entry, fallbackFontPath, fallbackFontSize, fallbackFontStyle)
	if not (icon and icon.charges) then return end
	local fontPath, fontSize, fontStyleChoice, fontStyle, fontColor, anchor, x, y =
		self:ResolveEntryChargesTextStyle(layout, entry, fallbackFontPath, fallbackFontSize, fallbackFontStyle)
	if icon.charges._eqolChargesAnchor ~= anchor or icon.charges._eqolChargesX ~= x or icon.charges._eqolChargesY ~= y then
		icon.charges:ClearAllPoints()
		icon.charges:SetPoint(anchor, icon, anchor, x, y)
		icon.charges._eqolChargesAnchor = anchor
		icon.charges._eqolChargesX = x
		icon.charges._eqolChargesY = y
	end
	if
		icon.charges._eqolChargesFont ~= fontPath
		or icon.charges._eqolChargesFontSize ~= fontSize
		or icon.charges._eqolChargesFontStyle ~= fontStyle
		or icon.charges._eqolChargesFontStyleChoice ~= fontStyleChoice
	then
		if Helper.SetFont then
			Helper.SetFont(icon.charges, fontPath, fontSize, fontStyle, fallbackFontPath)
		else
			icon.charges:SetFont(fontPath, fontSize, fontStyle)
		end
		if addon.functions and addon.functions.ApplyFontStyleShadow then addon.functions.ApplyFontStyleShadow(icon.charges, fontStyleChoice, fallbackFontStyle) end
		icon.charges._eqolChargesFont = fontPath
		icon.charges._eqolChargesFontSize = fontSize
		icon.charges._eqolChargesFontStyle = fontStyle
		icon.charges._eqolChargesFontStyleChoice = fontStyleChoice
	end
	local r = fontColor[1] or 1
	local g = fontColor[2] or 1
	local b = fontColor[3] or 1
	local a = fontColor[4] or 1
	if icon.charges._eqolChargesColorR ~= r or icon.charges._eqolChargesColorG ~= g or icon.charges._eqolChargesColorB ~= b or icon.charges._eqolChargesColorA ~= a then
		icon.charges:SetTextColor(r, g, b, a)
		icon.charges._eqolChargesColorR = r
		icon.charges._eqolChargesColorG = g
		icon.charges._eqolChargesColorB = b
		icon.charges._eqolChargesColorA = a
	end
end

function CooldownPanels:ResolveEntryStaticTextStyle(layout, entry, fallbackFontPath, fallbackFontSize, fallbackFontStyle)
	local panelFontPath = Helper.ResolveFontPath(layout and layout.staticTextFont, fallbackFontPath)
	local panelFontSize = Helper.ClampInt(layout and layout.staticTextSize, 6, 64, (layout and layout.staticTextSize) or Helper.PANEL_LAYOUT_DEFAULTS.staticTextSize or fallbackFontSize or 12)
	local panelFontStyleChoice =
		Helper.NormalizeFontStyleChoice(layout and layout.staticTextStyle, layout and layout.staticTextStyle or Helper.PANEL_LAYOUT_DEFAULTS.staticTextStyle or fallbackFontStyle)
	local panelFontStyle = Helper.NormalizeFontStyle(panelFontStyleChoice, fallbackFontStyle) or ""
	local panelFontColor = Helper.NormalizeColor(layout and layout.staticTextColor, Helper.PANEL_LAYOUT_DEFAULTS.staticTextColor or { 1, 1, 1, 1 })
	local panelAnchor = Helper.NormalizeAnchor(layout and layout.staticTextAnchor, Helper.PANEL_LAYOUT_DEFAULTS.staticTextAnchor or "CENTER")
	local panelX = Helper.ClampInt(layout and layout.staticTextX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, Helper.PANEL_LAYOUT_DEFAULTS.staticTextX or 0)
	local panelY = Helper.ClampInt(layout and layout.staticTextY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, Helper.PANEL_LAYOUT_DEFAULTS.staticTextY or 0)
	if not entry or entry.staticTextUseGlobal ~= false then
		return panelFontPath, panelFontSize, panelFontStyleChoice, panelFontStyle, panelFontColor, panelAnchor, panelX, panelY
	end
	local fontPath = Helper.ResolveFontPath(entry.staticTextFont, panelFontPath)
	local fontSize = Helper.ClampInt(entry.staticTextSize, 6, 64, panelFontSize)
	local fontStyleChoice = Helper.NormalizeFontStyleChoice(entry.staticTextStyle, panelFontStyleChoice)
	local fontStyle = Helper.NormalizeFontStyle(fontStyleChoice, panelFontStyle) or ""
	local fontColor = Helper.NormalizeColor(entry.staticTextColor, panelFontColor)
	local anchor = Helper.NormalizeAnchor(entry.staticTextAnchor, panelAnchor)
	local x = Helper.ClampInt(entry.staticTextX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, panelX)
	local y = Helper.ClampInt(entry.staticTextY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, panelY)
	return fontPath, fontSize, fontStyleChoice, fontStyle, fontColor, anchor, x, y
end

function CooldownPanels:ResolveEntryGlowStyle(layout, entry)
	local panelCache = CooldownPanels._styleCacheRoots.glowPanel[layout]
	local srcColor = layout and layout.readyGlowColor or nil
	local srcStyle = layout and layout.readyGlowStyle or nil
	local srcInset = layout and layout.readyGlowInset or nil
	if not panelCache or panelCache.srcColor ~= srcColor or panelCache.srcStyle ~= srcStyle or panelCache.srcInset ~= srcInset then
		panelCache = panelCache or {}
		panelCache.srcColor = srcColor
		panelCache.srcStyle = srcStyle
		panelCache.srcInset = srcInset
		local r, g, b, a = Helper.ResolveColor(srcColor, Helper.PANEL_LAYOUT_DEFAULTS.readyGlowColor)
		panelCache.color = CooldownPanels.FillCachedColor(panelCache.color, r, g, b, a)
		panelCache.style = Helper.NormalizeGlowStyle(srcStyle, Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle)
		panelCache.inset = Helper.NormalizeGlowInset(srcInset, Helper.PANEL_LAYOUT_DEFAULTS.readyGlowInset or 0)
		panelCache.version = (panelCache.version or 0) + 1
		CooldownPanels._styleCacheRoots.glowPanel[layout] = panelCache
	end
	if not entry or entry.glowUseGlobal ~= false then return 0, panelCache.color, panelCache.style, panelCache.inset end
	local cache = CooldownPanels._styleCacheRoots.glowEntry[entry]
	if not cache or cache.panelVersion ~= panelCache.version or cache.srcColor ~= entry.glowColor or cache.srcStyle ~= entry.glowStyle or cache.srcInset ~= entry.glowInset then
		cache = cache or {}
		cache.panelVersion = panelCache.version
		cache.srcColor = entry.glowColor
		cache.srcStyle = entry.glowStyle
		cache.srcInset = entry.glowInset
		local r, g, b, a = Helper.ResolveColor(entry.glowColor, panelCache.color)
		cache.color = CooldownPanels.FillCachedColor(cache.color, r, g, b, a)
		cache.style = Helper.NormalizeGlowStyle(entry.glowStyle, panelCache.style)
		cache.inset = Helper.NormalizeGlowInset(entry.glowInset, panelCache.inset)
		CooldownPanels._styleCacheRoots.glowEntry[entry] = cache
	end
	return 0, cache.color, cache.style, cache.inset
end

function CooldownPanels:ResolveEntryReadyGlowCheckPower(layout, entry)
	local panelValue = layout and layout.readyGlowCheckPower == true
	if not entry or entry.glowUseGlobal ~= false then return panelValue end
	return entry.readyGlowCheckPower == true
end

function CooldownPanels:ResolveEntryPandemicGlowColor(layout, entry)
	local color = self:ResolveEntryPandemicGlowVisual(layout, entry)
	return color
end

function CooldownPanels:ResolveEntryPandemicGlowVisual(layout, entry)
	local _, panelReadyColor, panelReadyStyle, panelReadyInset = self:ResolveEntryGlowStyle(layout, nil)
	local panelCache = CooldownPanels._styleCacheRoots.pandemicGlowPanel[layout]
	local srcColor = layout and layout.pandemicGlowColor or nil
	local srcStyle = layout and layout.pandemicGlowStyle or nil
	local srcInset = layout and layout.pandemicGlowInset or nil
	if
		not panelCache
		or panelCache.srcColor ~= srcColor
		or panelCache.srcStyle ~= srcStyle
		or panelCache.srcInset ~= srcInset
		or panelCache.readyColor ~= panelReadyColor
		or panelCache.readyStyle ~= panelReadyStyle
		or panelCache.readyInset ~= panelReadyInset
	then
		panelCache = panelCache or {}
		panelCache.srcColor = srcColor
		panelCache.srcStyle = srcStyle
		panelCache.srcInset = srcInset
		panelCache.readyColor = panelReadyColor
		panelCache.readyStyle = panelReadyStyle
		panelCache.readyInset = panelReadyInset
		local r, g, b, a = Helper.ResolveColor(srcColor, panelReadyColor)
		panelCache.color = CooldownPanels.FillCachedColor(panelCache.color, r, g, b, a)
		panelCache.style = Helper.NormalizeGlowStyle(srcStyle, panelReadyStyle)
		panelCache.inset = Helper.NormalizeGlowInset(srcInset, panelReadyInset)
		panelCache.version = (panelCache.version or 0) + 1
		CooldownPanels._styleCacheRoots.pandemicGlowPanel[layout] = panelCache
	end
	if not entry or entry.glowUseGlobal ~= false then return panelCache.color, panelCache.style, panelCache.inset end
	local cache = CooldownPanels._styleCacheRoots.pandemicGlowEntry[entry]
	if not cache or cache.panelVersion ~= panelCache.version or cache.srcColor ~= entry.pandemicGlowColor or cache.srcStyle ~= entry.pandemicGlowStyle or cache.srcInset ~= entry.pandemicGlowInset then
		cache = cache or {}
		cache.panelVersion = panelCache.version
		cache.srcColor = entry.pandemicGlowColor
		cache.srcStyle = entry.pandemicGlowStyle
		cache.srcInset = entry.pandemicGlowInset
		local r, g, b, a = Helper.ResolveColor(entry.pandemicGlowColor, panelCache.color)
		cache.color = CooldownPanels.FillCachedColor(cache.color, r, g, b, a)
		cache.style = Helper.NormalizeGlowStyle(entry.pandemicGlowStyle, panelCache.style)
		cache.inset = Helper.NormalizeGlowInset(entry.pandemicGlowInset, panelCache.inset)
		CooldownPanels._styleCacheRoots.pandemicGlowEntry[entry] = cache
	end
	return cache.color, cache.style, cache.inset
end

function CooldownPanels:ResolveEntryProcGlowVisual(layout, entry)
	local _, _, panelReadyStyle, panelReadyInset = self:ResolveEntryGlowStyle(layout, nil)
	local panelCache = CooldownPanels._styleCacheRoots.procGlowPanel[layout]
	local srcStyle = layout and layout.procGlowStyle or nil
	local srcInset = layout and layout.procGlowInset or nil
	if not panelCache or panelCache.srcStyle ~= srcStyle or panelCache.srcInset ~= srcInset or panelCache.readyStyle ~= panelReadyStyle or panelCache.readyInset ~= panelReadyInset then
		panelCache = panelCache or {}
		panelCache.srcStyle = srcStyle
		panelCache.srcInset = srcInset
		panelCache.readyStyle = panelReadyStyle
		panelCache.readyInset = panelReadyInset
		panelCache.style = Helper.NormalizeGlowStyle(srcStyle, panelReadyStyle)
		panelCache.inset = Helper.NormalizeGlowInset(srcInset, panelReadyInset)
		panelCache.version = (panelCache.version or 0) + 1
		CooldownPanels._styleCacheRoots.procGlowPanel[layout] = panelCache
	end
	if not entry or entry.procGlowUseGlobal ~= false then return panelCache.style, panelCache.inset end
	local cache = CooldownPanels._styleCacheRoots.procGlowEntry[entry]
	if not cache or cache.panelVersion ~= panelCache.version or cache.srcStyle ~= entry.procGlowStyle or cache.srcInset ~= entry.procGlowInset then
		cache = cache or {}
		cache.panelVersion = panelCache.version
		cache.srcStyle = entry.procGlowStyle
		cache.srcInset = entry.procGlowInset
		cache.style = Helper.NormalizeGlowStyle(entry.procGlowStyle, panelCache.style)
		cache.inset = Helper.NormalizeGlowInset(entry.procGlowInset, panelCache.inset)
		CooldownPanels._styleCacheRoots.procGlowEntry[entry] = cache
	end
	return cache.style, cache.inset
end

function CooldownPanels:ResolveEntryProcGlowEnabled(layout, entry)
	local panelValue = not (layout and layout.procGlowEnabled == false)
	if not entry or entry.procGlowUseGlobal ~= false then return panelValue end
	return entry.procGlowEnabled ~= false
end

function CooldownPanels:ClearEntryStateTexture(entry)
	if type(entry) ~= "table" then return end
	entry.stateTextureInput = ""
	entry.stateTextureType = nil
	entry.stateTextureAtlas = nil
	entry.stateTextureFileID = nil
end

function CooldownPanels:ApplyEntryStateTextureInput(entry, value)
	if type(entry) ~= "table" then return false, nil end
	local input = Helper.NormalizeTextureInput(value)
	if input == "" then
		self:ClearEntryStateTexture(entry)
		return true, nil
	end

	local resolvedType, resolvedValue = Helper.ResolveTextureInput(input)
	if not resolvedType then return false, string.format(L["CooldownPanelStateTextureInvalid"] or "Texture '%s' was not found. Use a valid atlas name or FileDataID.", input) end

	entry.stateTextureInput = input
	entry.stateTextureType = resolvedType
	if resolvedType == "ATLAS" then
		entry.stateTextureAtlas = resolvedValue
		entry.stateTextureFileID = nil
	else
		entry.stateTextureFileID = resolvedValue
		entry.stateTextureAtlas = nil
	end
	return true, nil
end

function CooldownPanels:ResolveEntryStateTexture(entry)
	if type(entry) ~= "table" then return nil end
	local input = Helper.NormalizeTextureInput(entry.stateTextureInput)
	if input == "" then return nil end

	local textureType = type(entry.stateTextureType) == "string" and strupper(entry.stateTextureType) or nil
	local textureValue
	if textureType == "ATLAS" and type(entry.stateTextureAtlas) == "string" and entry.stateTextureAtlas ~= "" then
		textureValue = entry.stateTextureAtlas
	elseif textureType == "FILEID" then
		local fileID = tonumber(entry.stateTextureFileID)
		if fileID and fileID > 0 then textureValue = fileID end
	end

	if not textureValue then
		local resolvedType, resolvedValue = Helper.ResolveTextureInput(input)
		if not resolvedType then
			self:ClearEntryStateTexture(entry)
			return nil
		end
		textureType = resolvedType
		textureValue = resolvedValue
		entry.stateTextureInput = input
		entry.stateTextureType = resolvedType
		if resolvedType == "ATLAS" then
			entry.stateTextureAtlas = resolvedValue
			entry.stateTextureFileID = nil
		else
			entry.stateTextureFileID = resolvedValue
			entry.stateTextureAtlas = nil
		end
	end

	local scale = Helper.ClampNumber(entry.stateTextureScale, 0.1, 8, 1)
	local width = Helper.ClampNumber(entry.stateTextureWidth, 0.1, 8, 1)
	local height = Helper.ClampNumber(entry.stateTextureHeight, 0.1, 8, 1)
	local angle = Helper.ClampNumber(entry.stateTextureAngle, 0, 360, 0)
	local doubleTexture = entry.stateTextureDouble == true
	local mirror = entry.stateTextureMirror == true
	local mirrorSecond = entry.stateTextureMirrorSecond == true
	local mirrorVertical = entry.stateTextureMirrorVertical == true
	local mirrorVerticalSecond = entry.stateTextureMirrorVerticalSecond == true
	local spacingX = Helper.ClampInt(entry.stateTextureSpacingX, 0, Helper.STATE_TEXTURE_SPACING_RANGE or 2000, 0)
	local spacingY = Helper.ClampInt(entry.stateTextureSpacingY, 0, Helper.STATE_TEXTURE_SPACING_RANGE or 2000, 0)
	return textureType, textureValue, width, height, scale, angle, doubleTexture, mirror, mirrorSecond, mirrorVertical, mirrorVerticalSecond, spacingX, spacingY
end

function CooldownPanels:HasConfiguredStateTexture(entry)
	if type(entry) ~= "table" then return false end
	local input = Helper.NormalizeTextureInput(entry.stateTextureInput)
	if input == "" then return false end
	local textureType = type(entry.stateTextureType) == "string" and strupper(entry.stateTextureType) or nil
	if textureType == "ATLAS" then return type(entry.stateTextureAtlas) == "string" and entry.stateTextureAtlas ~= "" end
	if textureType == "FILEID" then
		local fileID = tonumber(entry.stateTextureFileID)
		return fileID ~= nil and fileID > 0
	end
	local resolvedType = self:ResolveEntryStateTexture(entry)
	return resolvedType ~= nil
end

function CooldownPanels:ResolveEntryNoDesaturation(layout, entry)
	local panelValue = layout and layout.noDesaturation == true
	if not entry or entry.noDesaturationUseGlobal ~= false then return panelValue end
	return entry.noDesaturation == true
end

function CooldownPanels:ResolveEntryCooldownVisibility(layout, entry)
	local hideOnCooldown = layout and layout.hideOnCooldown == true
	local showOnCooldown = layout and layout.showOnCooldown == true
	if showOnCooldown then hideOnCooldown = false end
	if not entry or entry.cooldownVisibilityUseGlobal ~= false then return hideOnCooldown, showOnCooldown end
	hideOnCooldown = entry.hideOnCooldown == true
	showOnCooldown = entry.showOnCooldown == true
	if showOnCooldown then hideOnCooldown = false end
	return hideOnCooldown, showOnCooldown
end

function CooldownPanels:ResolveEntryCDMAuraAlwaysShowMode(layout, entry)
	local values = self.CDM_AURA_ALWAYS_SHOW_MODE or {}
	local panelValue = self:NormalizeCDMAuraAlwaysShowMode(layout and layout.cdmAuraAlwaysShowMode, Helper.PANEL_LAYOUT_DEFAULTS.cdmAuraAlwaysShowMode or values.HIDE or "HIDE")
	if not entry or entry.type ~= "CDM_AURA" or entry.cdmAuraAlwaysShowUseGlobal ~= false then return panelValue end
	return self:NormalizeCDMAuraAlwaysShowMode(entry.cdmAuraAlwaysShowMode, panelValue)
end

function CooldownPanels:ResolveEntryCheckPower(layout, entry)
	local panelValue = layout and layout.checkPower == true
	if not entry or entry.checkPowerUseGlobal ~= false then return panelValue end
	return entry.checkPower == true
end

function CooldownPanels:ResolveEntryHideWhenNoResource(layout, entry)
	local panelValue = layout and layout.hideWhenNoResource == true
	if not entry then return panelValue end
	local entryType = entry.type
	if entryType == "MACRO" then
		local macro = CooldownPanels.ResolveMacroEntry(entry)
		entryType = (macro and macro.kind) or entryType
	end
	if entryType ~= "SPELL" then return false end
	if entry.hideWhenNoResourceUseGlobal ~= false then return panelValue end
	return entry.hideWhenNoResource == true
end

function CooldownPanels:ResolveEntryShowIconTexture(layout, entry)
	local panelValue = not (layout and layout.showIconTexture == false)
	if not entry then return panelValue end
	if self:HasConfiguredStateTexture(entry) then return false end
	if entry.hideIcon == true then return false end
	if entry.showIconTextureUseGlobal == false then return true end
	return panelValue
end

function CooldownPanels:ShouldShowEditorGhostIcon(layout, entry, showIconTexture, editorContext)
	if editorContext ~= true or not entry then return false end
	if showIconTexture == false then return true end
	return Helper.ClampInt(layout and layout.iconOffsetX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0) ~= 0
		or Helper.ClampInt(layout and layout.iconOffsetY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0) ~= 0
		or Helper.ClampInt(entry.iconOffsetX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0) ~= 0
		or Helper.ClampInt(entry.iconOffsetY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0) ~= 0
end

function CooldownPanels:ResolveEntryCooldownVisuals(layout, entry)
	local panelShowChargesCooldown = layout and layout.showChargesCooldown == true
	local panelDrawEdge = not (layout and layout.cooldownDrawEdge == false)
	local panelDrawBling = not (layout and layout.cooldownDrawBling == false)
	local panelDrawSwipe = not (layout and layout.cooldownDrawSwipe == false)
	local panelGcdDrawEdge = layout and layout.cooldownGcdDrawEdge == true
	local panelGcdDrawBling = layout and layout.cooldownGcdDrawBling == true
	local panelGcdDrawSwipe = layout and layout.cooldownGcdDrawSwipe == true
	if not entry or entry.cooldownVisualsUseGlobal ~= false then
		return panelShowChargesCooldown, panelDrawEdge, panelDrawBling, panelDrawSwipe, panelGcdDrawEdge, panelGcdDrawBling, panelGcdDrawSwipe
	end
	return entry.showChargesCooldown == true,
		entry.cooldownDrawEdge == true,
		entry.cooldownDrawBling == true,
		entry.cooldownDrawSwipe == true,
		entry.cooldownGcdDrawEdge == true,
		entry.cooldownGcdDrawBling == true,
		entry.cooldownGcdDrawSwipe == true
end

function CooldownPanels:ShouldIgnoreEntryCooldownGCD(layout, entry)
	local _, _, _, _, gcdDrawEdge, gcdDrawBling, gcdDrawSwipe = self:ResolveEntryCooldownVisuals(layout, entry)
	return gcdDrawEdge ~= true and gcdDrawBling ~= true and gcdDrawSwipe ~= true
end

function CooldownPanels:ResolveEntryIconVisualLayout(layout, entry, baseSize)
	if type(entry) == "number" and baseSize == nil and type(layout) == "table" and layout.id ~= nil and layout.type ~= nil then
		baseSize = entry
		entry = layout
		layout = nil
	end
	local fallbackSize = Helper.ClampInt(baseSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
	local fallbackWidth = Helper.ClampInt(layout and layout.iconSizeSeparate == true and layout.iconWidth, 12, 128, fallbackSize)
	local fallbackHeight = Helper.ClampInt(layout and layout.iconSizeSeparate == true and layout.iconHeight, 12, 128, fallbackSize)
	local layoutOffsetX = Helper.ClampInt(layout and layout.iconOffsetX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
	local layoutOffsetY = Helper.ClampInt(layout and layout.iconOffsetY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
	if not entry then return fallbackSize, layoutOffsetX, layoutOffsetY, fallbackWidth, fallbackHeight end
	local cache = CooldownPanels._styleCacheRoots.iconLayoutEntry[entry]
	if
		not cache
		or cache.baseSize ~= fallbackSize
		or cache.baseWidth ~= fallbackWidth
		or cache.baseHeight ~= fallbackHeight
		or cache.layoutOffsetX ~= layoutOffsetX
		or cache.layoutOffsetY ~= layoutOffsetY
		or cache.iconSizeUseGlobal ~= entry.iconSizeUseGlobal
		or cache.iconSize ~= entry.iconSize
		or cache.iconSizeSeparate ~= entry.iconSizeSeparate
		or cache.iconWidth ~= entry.iconWidth
		or cache.iconHeight ~= entry.iconHeight
		or cache.iconOffsetX ~= entry.iconOffsetX
		or cache.iconOffsetY ~= entry.iconOffsetY
	then
		cache = cache or {}
		cache.baseSize = fallbackSize
		cache.baseWidth = fallbackWidth
		cache.baseHeight = fallbackHeight
		cache.layoutOffsetX = layoutOffsetX
		cache.layoutOffsetY = layoutOffsetY
		cache.iconSizeUseGlobal = entry.iconSizeUseGlobal
		cache.iconSize = entry.iconSize
		cache.iconSizeSeparate = entry.iconSizeSeparate
		cache.iconWidth = entry.iconWidth
		cache.iconHeight = entry.iconHeight
		cache.iconOffsetX = entry.iconOffsetX
		cache.iconOffsetY = entry.iconOffsetY
		if entry.iconSizeUseGlobal == false and entry.iconSizeSeparate == true then
			cache.width = Helper.ClampInt(entry.iconWidth, 12, 128, fallbackWidth)
			cache.height = Helper.ClampInt(entry.iconHeight, 12, 128, fallbackHeight)
			cache.size = math.max(cache.width, cache.height)
		else
			cache.size = entry.iconSizeUseGlobal == false and Helper.ClampInt(entry.iconSize, 12, 128, fallbackSize) or fallbackSize
			cache.width = entry.iconSizeUseGlobal == false and cache.size or fallbackWidth
			cache.height = entry.iconSizeUseGlobal == false and cache.size or fallbackHeight
		end
		cache.offsetX = layoutOffsetX + Helper.ClampInt(entry.iconOffsetX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
		cache.offsetY = layoutOffsetY + Helper.ClampInt(entry.iconOffsetY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
		CooldownPanels._styleCacheRoots.iconLayoutEntry[entry] = cache
	end
	return cache.size, cache.offsetX, cache.offsetY, cache.width, cache.height
end

CooldownPanels._eqolFixedVisualCacheUtil = CooldownPanels._eqolFixedVisualCacheUtil or {}

function CooldownPanels._eqolFixedVisualCacheUtil.ClearSparseTable(tbl)
	if type(tbl) ~= "table" then return end
	for key in pairs(tbl) do
		tbl[key] = nil
	end
end

function CooldownPanels._eqolFixedVisualCacheUtil.GetSharedLayoutCache(fixedLayoutCache)
	if type(fixedLayoutCache) ~= "table" then return nil end
	local layouts = cdp.FIXED_VISUAL and cdp.FIXED_VISUAL.LAYOUTS or nil
	if type(layouts) ~= "table" then return nil end
	local shared = layouts[fixedLayoutCache]
	if type(shared) ~= "table" then
		shared = {
			orderedCellsByGroupId = {},
			dynamicPlacementsByGroupId = {},
			groupStateById = {},
		}
		layouts[fixedLayoutCache] = shared
	end
	return shared
end

function CooldownPanels._eqolFixedVisualCacheUtil.SyncSharedGroupState(shared, group)
	if type(shared) ~= "table" or type(group) ~= "table" then return nil end
	local groupId = type(group.id) == "string" and group.id or tostring(group.id or "")
	local state = shared.groupStateById and shared.groupStateById[groupId] or nil
	if type(state) ~= "table" then
		state = {}
		shared.groupStateById[groupId] = state
	end
	local mode = group.mode
	local columns = group.columns
	local rows = group.rows
	local column = group.column
	local row = group.row
	local startPoint = group.dynamicStartPoint
	local direction = group.dynamicDirection
	if
		state.groupRef ~= group
		or state.mode ~= mode
		or state.columns ~= columns
		or state.rows ~= rows
		or state.column ~= column
		or state.row ~= row
		or state.startPoint ~= startPoint
		or state.direction ~= direction
	then
		shared.orderedCellsByGroupId[groupId] = nil
		shared.dynamicPlacementsByGroupId[groupId] = nil
		state.groupRef = group
		state.mode = mode
		state.columns = columns
		state.rows = rows
		state.column = column
		state.row = row
		state.startPoint = startPoint
		state.direction = direction
	end
	return groupId
end

function CooldownPanels._eqolFixedVisualCacheUtil.Prepare(frame, panel, fixedGridColumns, fixedLayoutCache)
	if not frame then return nil end
	local cache = frame._eqolFixedVisualCache
	if type(cache) ~= "table" then
		cache = {}
		frame._eqolFixedVisualCache = cache
	end
	local sharedLayoutCache = CooldownPanels._eqolFixedVisualCacheUtil.GetSharedLayoutCache(fixedLayoutCache)
	cache.panel = panel
	cache.fixedGridColumns = fixedGridColumns
	cache.fixedLayoutCacheRef = fixedLayoutCache
	cache.sharedLayoutCache = sharedLayoutCache
	cache.slotCenterX = cache.slotCenterX or {}
	cache.slotCenterY = cache.slotCenterY or {}
	cache.slotCenterMissing = cache.slotCenterMissing or {}
	cache.orderedCellsByGroupId = cache.orderedCellsByGroupId or {}
	cache.dynamicPlacementsByGroupId = cache.dynamicPlacementsByGroupId or {}
	CooldownPanels._eqolFixedVisualCacheUtil.ClearSparseTable(cache.slotCenterX)
	CooldownPanels._eqolFixedVisualCacheUtil.ClearSparseTable(cache.slotCenterY)
	CooldownPanels._eqolFixedVisualCacheUtil.ClearSparseTable(cache.slotCenterMissing)
	if not sharedLayoutCache then
		CooldownPanels._eqolFixedVisualCacheUtil.ClearSparseTable(cache.orderedCellsByGroupId)
		CooldownPanels._eqolFixedVisualCacheUtil.ClearSparseTable(cache.dynamicPlacementsByGroupId)
	end
	return cache
end

function CooldownPanels._eqolFixedVisualCacheUtil.GetSlotCenter(cache, icons, fixedGridColumns, column, row)
	column = Helper.NormalizeSlotCoordinate(column)
	row = Helper.NormalizeSlotCoordinate(row)
	if not (icons and fixedGridColumns > 0 and column and row) then return nil, nil end
	local index = ((row - 1) * fixedGridColumns) + column

	if cache and cache.slotCenterX and cache.slotCenterY and cache.slotCenterMissing then
		if cache.slotCenterMissing[index] then return nil, nil end
		local cachedX = cache.slotCenterX[index]
		local cachedY = cache.slotCenterY[index]
		if cachedX ~= nil and cachedY ~= nil then return cachedX, cachedY end
		local icon = icons[index]
		local slot = icon and (icon.slotAnchor or icon) or nil
		if not (slot and slot.GetCenter) then
			cache.slotCenterMissing[index] = true
			return nil, nil
		end
		local x, y = slot:GetCenter()
		if not (x and y) then
			cache.slotCenterMissing[index] = true
			return nil, nil
		end
		cache.slotCenterX[index] = x
		cache.slotCenterY[index] = y
		return x, y
	end

	local icon = icons[index]
	local slot = icon and (icon.slotAnchor or icon) or nil
	if not (slot and slot.GetCenter) then return nil, nil end
	local x, y = slot:GetCenter()
	if not (x and y) then return nil, nil end
	return x, y
end

function CooldownPanels._eqolFixedVisualCacheUtil.GetOrderedCells(cache, group)
	if type(group) ~= "table" then return nil end
	local store = nil
	local key = nil
	if cache and cache.sharedLayoutCache then
		key = CooldownPanels._eqolFixedVisualCacheUtil.SyncSharedGroupState(cache.sharedLayoutCache, group)
		store = cache.sharedLayoutCache.orderedCellsByGroupId
	elseif cache and cache.orderedCellsByGroupId then
		key = type(group.id) == "string" and group.id or tostring(group.id or "")
		store = cache.orderedCellsByGroupId
	end
	if not store then return Helper.GetFixedGroupOrderedCells and Helper.GetFixedGroupOrderedCells(group) or nil end
	local cached = store[key]
	if cached ~= nil then
		if cached == false then return nil end
		return cached
	end
	cached = Helper.GetFixedGroupOrderedCells and Helper.GetFixedGroupOrderedCells(group) or nil
	store[key] = cached or false
	return cached
end

function CooldownPanels._eqolFixedVisualCacheUtil.GetDynamicPlacement(cache, group, index, count)
	if type(group) ~= "table" then return nil end
	local placementsByGroupId = nil
	local groupKey = nil
	if cache and cache.sharedLayoutCache then
		groupKey = CooldownPanels._eqolFixedVisualCacheUtil.SyncSharedGroupState(cache.sharedLayoutCache, group)
		placementsByGroupId = cache.sharedLayoutCache.dynamicPlacementsByGroupId
	elseif cache and cache.dynamicPlacementsByGroupId then
		groupKey = type(group.id) == "string" and group.id or tostring(group.id or "")
		placementsByGroupId = cache.dynamicPlacementsByGroupId
	end
	if not placementsByGroupId then return Helper.GetFixedGroupDynamicPlacement and Helper.GetFixedGroupDynamicPlacement(group, index, count) or nil end
	local groupCache = placementsByGroupId[groupKey]
	if type(groupCache) ~= "table" then
		groupCache = {}
		placementsByGroupId[groupKey] = groupCache
	end
	local countKey = tostring(count or 0)
	local countCache = groupCache[countKey]
	if type(countCache) ~= "table" then
		countCache = {}
		groupCache[countKey] = countCache
	end
	local cached = countCache[index]
	if cached ~= nil then
		if cached == false then return nil end
		return cached
	end
	cached = Helper.GetFixedGroupDynamicPlacement and Helper.GetFixedGroupDynamicPlacement(group, index, count) or nil
	countCache[index] = cached or false
	return cached
end

function CooldownPanels:ResolveFixedGroupSlotSpacingOffset(panel, frame, entry, layout, fixedGridColumns, slotColumn, slotRow, visualSize, fixedContext, fixedLayoutCache)
	if not (panel and frame and entry and layout) then return 0, 0 end
	if not fixedLayoutCache and not Helper.IsFixedLayout(panel.layout) then return 0, 0 end
	fixedGridColumns = Helper.NormalizeFixedGridSize(fixedGridColumns, 0)
	slotColumn = Helper.NormalizeSlotCoordinate(slotColumn)
	slotRow = Helper.NormalizeSlotCoordinate(slotRow)
	visualSize = Helper.ClampInt(visualSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
	if fixedGridColumns <= 0 or not (slotColumn and slotRow and visualSize) then return 0, 0 end

	local groupId = Helper.NormalizeFixedGroupId(entry.fixedGroupId)
	if not groupId then return 0, 0 end
	local group = fixedLayoutCache and fixedLayoutCache.groupById and fixedLayoutCache.groupById[groupId] or nil
	if not group then group = CooldownPanels.GetFixedGroupById(panel, groupId) end
	if not group then return 0, 0 end
	local frameCache = frame and frame._eqolFixedVisualCache or nil
	local cacheUtil = CooldownPanels._eqolFixedVisualCacheUtil

	local baseSpacing = Helper.ClampInt(panel.layout and panel.layout.spacing, 0, Helper.SPACING_RANGE or 200, Helper.PANEL_LAYOUT_DEFAULTS.spacing)
	local targetSpacing = Helper.ClampInt(layout and layout.spacing, 0, Helper.SPACING_RANGE or 200, baseSpacing)
	local desiredStep = visualSize + targetSpacing
	local centerOffsetX, centerOffsetY = 0, 0
	local placementCount = nil
	local placementIndex = nil
	local placement = nil
	local centerGrowth = false
	if not CooldownPanels.IsFixedGroupStatic(group) and Helper.GetFixedGroupDynamicPlacement then
		centerGrowth = Helper.IsFixedGroupCenterGrowth and Helper.IsFixedGroupCenterGrowth(group) == true
		if type(fixedContext) == "table" then
			placementCount = math.floor(tonumber(fixedContext.dynamicCount) or 0)
			placementIndex = math.floor(tonumber(fixedContext.dynamicLocalIndex) or 0)
		end
		if placementCount == nil or placementCount < 1 or placementIndex == nil or placementIndex < 1 then
			local cache = fixedLayoutCache or (Helper.GetFixedLayoutCache and Helper.GetFixedLayoutCache(panel) or nil)
			local groupEntryIds = cache and cache.groupEntryIds and cache.groupEntryIds[group.id] or nil
			if groupEntryIds then
				placementCount = #groupEntryIds
				for index = 1, placementCount do
					if groupEntryIds[index] == entry.id then
						placementIndex = index
						break
					end
				end
			end
		end
		if placementCount and placementCount > 0 and placementIndex and placementIndex > 0 then
			placement = cacheUtil.GetDynamicPlacement(frameCache, group, placementIndex, placementCount)
			if placement then
				slotColumn = Helper.NormalizeSlotCoordinate(placement.column, slotColumn)
				slotRow = Helper.NormalizeSlotCoordinate(placement.row, slotRow)
				if not centerGrowth then
					centerOffsetX = (tonumber(placement.offsetSlotsX) or 0) * desiredStep
					centerOffsetY = (tonumber(placement.offsetSlotsY) or 0) * desiredStep
				end
			end
		end
	end

	local icons = frame.icons or nil

	if centerGrowth and placement then
		local rowCount = math.floor(tonumber(placement.rowCount) or 0)
		local columnIndex = tonumber(placement.columnIndex)
		local rowIndex = tonumber(placement.rowIndex)
		local currentX, currentY = cacheUtil.GetSlotCenter(frameCache, icons, fixedGridColumns, slotColumn, slotRow)
		local leftX = select(1, cacheUtil.GetSlotCenter(frameCache, icons, fixedGridColumns, group.column, placement.row))
		local rightX = select(1, cacheUtil.GetSlotCenter(frameCache, icons, fixedGridColumns, group.column + group.columns - 1, placement.row))
		local startPoint = Helper.NormalizeFixedGroupStartPoint(group.dynamicStartPoint, "TOPLEFT")
		local startRow = startPoint == "BOTTOM" and (group.row + group.rows - 1) or group.row
		local _, startY = cacheUtil.GetSlotCenter(frameCache, icons, fixedGridColumns, group.column, startRow)
		if currentX and currentY and leftX and rightX and startY and rowCount > 0 and columnIndex ~= nil and rowIndex ~= nil then
			local desiredCenterX = ((leftX + rightX) / 2) + ((columnIndex - ((rowCount - 1) / 2)) * desiredStep)
			local desiredCenterY = startY + ((startPoint == "BOTTOM" and rowIndex or -rowIndex) * desiredStep)
			return desiredCenterX - currentX, desiredCenterY - currentY
		end
	end

	if targetSpacing == baseSpacing and centerOffsetX == 0 and centerOffsetY == 0 then return 0, 0 end

	local anchorColumn = group.column
	local anchorRow = group.row
	if not CooldownPanels.IsFixedGroupStatic(group) and Helper.GetFixedGroupDynamicPlacement and placementCount and placementCount > 0 then
		local anchorPlacement = cacheUtil.GetDynamicPlacement(frameCache, group, 1, placementCount)
		anchorColumn = anchorPlacement and anchorPlacement.column or anchorColumn
		anchorRow = anchorPlacement and anchorPlacement.row or anchorRow
	elseif not CooldownPanels.IsFixedGroupStatic(group) then
		local orderedCells = cacheUtil.GetOrderedCells(frameCache, group)
		local anchorCell = orderedCells and orderedCells[1] or nil
		anchorColumn = anchorCell and anchorCell.column or anchorColumn
		anchorRow = anchorCell and anchorCell.row or anchorRow
	end
	anchorColumn = Helper.NormalizeSlotCoordinate(anchorColumn)
	anchorRow = Helper.NormalizeSlotCoordinate(anchorRow)
	if not (anchorColumn and anchorRow) then return 0, 0 end

	local anchorX, anchorY = cacheUtil.GetSlotCenter(frameCache, icons, fixedGridColumns, anchorColumn, anchorRow)
	local currentX, currentY = cacheUtil.GetSlotCenter(frameCache, icons, fixedGridColumns, slotColumn, slotRow)
	if not (anchorX and anchorY and currentX and currentY) then return 0, 0 end

	local desiredDeltaX = (slotColumn - anchorColumn) * desiredStep
	local desiredDeltaY = (anchorRow - slotRow) * desiredStep
	local actualDeltaX = currentX - anchorX
	local actualDeltaY = currentY - anchorY
	return (desiredDeltaX - actualDeltaX) + centerOffsetX, (desiredDeltaY - actualDeltaY) + centerOffsetY
end

function CooldownPanels:ApplyEntryIconVisualLayout(icon, layout, entry, panel, fixedGridColumns, slotColumn, slotRow, fixedContext, fixedLayoutCache)
	if not icon then return end
	local slotAnchor = icon.slotAnchor
	local baseSize = Helper.ClampInt(icon._eqolBaseSlotSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
	local size, offsetX, offsetY, width, height = self:ResolveEntryIconVisualLayout(layout, entry, baseSize)
	width = width or size
	height = height or size
	local fixedOffsetX, fixedOffsetY = 0, 0
	if panel and entry then
		fixedOffsetX, fixedOffsetY = self:ResolveFixedGroupSlotSpacingOffset(
			panel,
			icon:GetParent(),
			entry,
			layout,
			fixedGridColumns,
			slotColumn or icon._eqolPreviewCellColumn or icon._eqolLayoutSlotColumn,
			slotRow or icon._eqolPreviewCellRow or icon._eqolLayoutSlotRow,
			math.max(width, height),
			fixedContext,
			fixedLayoutCache
		)
	end
	offsetX = offsetX + (fixedOffsetX or 0)
	offsetY = offsetY + (fixedOffsetY or 0)
	local currentWidth, currentHeight = icon:GetSize()
	local previewBlingSize = icon.previewBling and (size * 1.5) or nil
	if
		icon._eqolVisualSize == size
		and icon._eqolVisualWidth == width
		and icon._eqolVisualHeight == height
		and currentWidth == width
		and currentHeight == height
		and icon._eqolVisualAnchor == slotAnchor
		and icon._eqolVisualOffsetX == offsetX
		and icon._eqolVisualOffsetY == offsetY
		and ((not icon.previewGlowBorder) or icon.previewGlowBorder._eqolSize == size)
		and ((not icon.previewBling) or (icon.previewBling._eqolVisualAnchor == icon and icon.previewBling._eqolVisualSize == previewBlingSize))
	then
		return
	end
	if icon._eqolVisualWidth ~= width or icon._eqolVisualHeight ~= height or currentWidth ~= width or currentHeight ~= height then
		icon:SetSize(width, height)
		icon._eqolVisualSize = size
		icon._eqolVisualWidth = width
		icon._eqolVisualHeight = height
	end
	if slotAnchor then
		local point, relativeTo, relativePoint, currentX, currentY = icon:GetPoint(1)
		local needsAnchorUpdate = icon:GetNumPoints() ~= 1
			or icon._eqolVisualAnchor ~= slotAnchor
			or icon._eqolVisualOffsetX ~= offsetX
			or icon._eqolVisualOffsetY ~= offsetY
			or point ~= "CENTER"
			or relativeTo ~= slotAnchor
			or relativePoint ~= "CENTER"
			or currentX ~= offsetX
			or currentY ~= offsetY
		if needsAnchorUpdate then
			icon:ClearAllPoints()
			icon:SetPoint("CENTER", slotAnchor, "CENTER", offsetX, offsetY)
			icon._eqolVisualAnchor = slotAnchor
			icon._eqolVisualOffsetX = offsetX
			icon._eqolVisualOffsetY = offsetY
		end
	end
	CooldownPanels.UpdatePreviewGlowBorderLayout(icon, size)
	if icon.previewBling then
		if icon.previewBling._eqolVisualAnchor ~= icon then
			icon.previewBling:ClearAllPoints()
			icon.previewBling:SetPoint("CENTER", icon, "CENTER", 0, 0)
			icon.previewBling._eqolVisualAnchor = icon
		end
		if icon.previewBling._eqolVisualSize ~= previewBlingSize then
			icon.previewBling:SetSize(previewBlingSize, previewBlingSize)
			icon.previewBling._eqolVisualSize = previewBlingSize
		end
	end
end

function CooldownPanels:ApplyEditorGhostIcon(icon)
	local texture = icon and icon.editorGhostTexture or nil
	local source = icon and icon.texture or nil
	if not texture or not source then return end
	texture:SetTexture(source:GetTexture())
	texture:SetTexCoord(source:GetTexCoord())
	texture:SetShown(true)
	texture:SetDesaturated(true)
	texture:SetAlpha(0.32)
	texture._eqolGhostShown = true
end

function CooldownPanels:HideEditorGhostIcon(icon)
	local texture = icon and icon.editorGhostTexture or nil
	if not texture then return end
	texture:SetTexture(nil)
	texture:Hide()
	texture._eqolGhostShown = false
end

local function applyStaticText(icon, layout, entry, defaultFontPath, defaultFontSize, defaultFontStyle, cooldownActive)
	if not icon or not icon.staticText then return end
	if not entry or type(entry.staticText) ~= "string" or entry.staticText == "" then
		icon.staticText:Hide()
		return
	end
	if entry.staticTextShowOnCooldown == true and not cooldownActive then
		icon.staticText:Hide()
		return
	end
	local text = entry.staticText
	if text:find("\\n", 1, true) then text = text:gsub("\\n", "\n") end
	if text:find("|n", 1, true) then text = text:gsub("|n", "\n") end
	local fontPath, fontSize, fontStyleChoice, fontStyle, fontColor, anchor, x, y =
		CooldownPanels:ResolveEntryStaticTextStyle(layout, entry, defaultFontPath, defaultFontSize, defaultFontStyle)
	if Helper.SetFont then
		Helper.SetFont(icon.staticText, fontPath, fontSize, fontStyle, defaultFontPath)
	else
		icon.staticText:SetFont(fontPath, fontSize, fontStyle)
	end
	if addon.functions and addon.functions.ApplyFontStyleShadow then
		addon.functions.ApplyFontStyleShadow(icon.staticText, fontStyleChoice, defaultFontStyle)
	end
	icon.staticText:SetTextColor(fontColor[1] or 1, fontColor[2] or 1, fontColor[3] or 1, fontColor[4] or 1)
	if icon.staticText.SetJustifyH then
		icon.staticText:SetJustifyH(anchor == "LEFT" and "LEFT" or anchor == "RIGHT" and "RIGHT" or "CENTER")
	end
	icon.staticText:ClearAllPoints()
	icon.staticText:SetPoint(anchor, icon.overlay, anchor, x, y)
	icon.staticText:SetText(text)
	icon.staticText:Show()
end

local function applyStateTexture(icon, data)
	local texture = icon and icon.stateTexture or nil
	local textureSecond = icon and icon.stateTextureSecond or nil
	if not texture then return end
	local desaturated = data and data.stateTextureDesaturated == true
	if not (data and data.stateTextureShown == true and data.stateTextureType and data.stateTextureValue) then
		if texture.SetDesaturated then texture:SetDesaturated(false) end
		texture:Hide()
		if textureSecond then
			if textureSecond.SetDesaturated then textureSecond:SetDesaturated(false) end
			textureSecond:Hide()
		end
		return
	end

	local scale = Helper.ClampNumber(data.stateTextureScale, 0.1, 8, 1)
	local widthScale = Helper.ClampNumber(data.stateTextureWidth, 0.1, 8, 1)
	local heightScale = Helper.ClampNumber(data.stateTextureHeight, 0.1, 8, 1)
	local angle = Helper.ClampNumber(data.stateTextureAngle, 0, 360, 0)
	local doubleTexture = data.stateTextureDouble == true
	local mirror = data.stateTextureMirror == true
	local mirrorSecond = data.stateTextureMirrorSecond == true
	local mirrorVertical = data.stateTextureMirrorVertical == true
	local mirrorVerticalSecond = data.stateTextureMirrorVerticalSecond == true
	local spacingX = Helper.ClampInt(data.stateTextureSpacingX, 0, Helper.STATE_TEXTURE_SPACING_RANGE or 2000, 0)
	local spacingY = Helper.ClampInt(data.stateTextureSpacingY, 0, Helper.STATE_TEXTURE_SPACING_RANGE or 2000, 0)
	local atlasInfo = data.stateTextureType == "ATLAS" and Api.GetAtlasInfo and Api.GetAtlasInfo(data.stateTextureValue) or nil
	local baseWidth, baseHeight
	if atlasInfo and atlasInfo.width and atlasInfo.height then
		baseWidth = atlasInfo.width
		baseHeight = atlasInfo.height
	else
		baseWidth, baseHeight = icon:GetSize()
		baseWidth = baseWidth or 36
		baseHeight = baseHeight or 36
	end
	local width = baseWidth * scale * widthScale
	local height = baseHeight * scale * heightScale

	local function setRegionTexCoord(region, left, right, top, bottom, mirroredHorizontal, mirroredVertical)
		if mirroredHorizontal then
			left, right = right, left
		end
		if mirroredVertical then
			top, bottom = bottom, top
		end
		region:SetTexCoord(left, right, top, bottom)
	end

	local function applyRegion(region, offsetX, offsetY, mirroredHorizontal, mirroredVertical)
		if not region then return end
		region:ClearAllPoints()
		region:SetPoint("CENTER", icon, "CENTER", offsetX or 0, offsetY or 0)
		region:SetSize(width, height)
		if data.stateTextureType == "ATLAS" then
			local atlas = data.stateTextureValue
			region:SetTexture(nil)
			region:SetAtlas(atlas, true)
			if atlasInfo then
				local left = atlasInfo.leftTexCoord or 0
				local right = atlasInfo.rightTexCoord or 1
				local top = atlasInfo.topTexCoord or 0
				local bottom = atlasInfo.bottomTexCoord or 1
				setRegionTexCoord(region, left, right, top, bottom, mirroredHorizontal, mirroredVertical)
			end
		else
			local fileID = tonumber(data.stateTextureValue)
			region:SetTexture(nil)
			region:SetTexture(fileID)
			setRegionTexCoord(region, 0, 1, 0, 1, mirroredHorizontal, mirroredVertical)
		end
		if region.SetDesaturated then region:SetDesaturated(desaturated) end
		if region.SetRotation then region:SetRotation(math.rad(angle)) end
		region:Show()
	end

	if doubleTexture and textureSecond then
		local halfX = spacingX / 2
		local halfY = spacingY / 2
		local secondMirrored = (mirrorSecond and not mirror) or ((not mirrorSecond) and mirror)
		local secondMirroredVertical = (mirrorVerticalSecond and not mirrorVertical) or ((not mirrorVerticalSecond) and mirrorVertical)
		applyRegion(texture, -halfX, -halfY, mirror, mirrorVertical)
		applyRegion(textureSecond, halfX, halfY, secondMirrored, secondMirroredVertical)
	else
		applyRegion(texture, 0, 0, mirror, mirrorVertical)
		if textureSecond then
			if textureSecond.SetDesaturated then textureSecond:SetDesaturated(false) end
			textureSecond:Hide()
		end
	end
end

function cdp.RUNTIME.ClearIconSnapshot(icon)
	if not icon then return end
	icon._eqolRuntimeSnapshot = nil
end

function cdp.RUNTIME.EnsureIconSnapshot(icon)
	if not icon then return nil end
	local snapshot = icon._eqolRuntimeSnapshot
	if type(snapshot) ~= "table" then
		snapshot = {}
		icon._eqolRuntimeSnapshot = snapshot
	end
	return snapshot
end

function cdp.RUNTIME.GetFixedContextSignature(fixedContext)
	if type(fixedContext) ~= "table" then return nil, nil end
	return tonumber(fixedContext.dynamicLocalIndex) or 0, tonumber(fixedContext.dynamicCount) or 0
end

function cdp.RUNTIME.HasPlacementChange(icon, snapshot, data, fixedLayoutCache, fixedGridColumns, slotColumn, slotRow, layoutEditActive)
	if not (icon and snapshot and data) then return true end
	if icon._eqolVisualSize == nil or icon._eqolVisualAnchor == nil or icon._eqolVisualOffsetX == nil or icon._eqolVisualOffsetY == nil then return true end
	local entry = data.entry
	local layout = data.layout
	local fixedLocalIndex, fixedCount = cdp.RUNTIME.GetFixedContextSignature(data.fixedContext)
	return snapshot.entryId ~= data.entryId
		or snapshot.baseSlotSize ~= icon._eqolBaseSlotSize
		or snapshot.fixedLayoutCache ~= fixedLayoutCache
		or snapshot.fixedGridColumns ~= fixedGridColumns
		or snapshot.slotColumn ~= slotColumn
		or snapshot.slotRow ~= slotRow
		or snapshot.layoutEditActive ~= (layoutEditActive == true)
		or snapshot.entryIconSizeUseGlobal ~= (entry and entry.iconSizeUseGlobal)
		or snapshot.entryIconSize ~= (entry and entry.iconSize)
		or snapshot.entryIconSizeSeparate ~= (entry and entry.iconSizeSeparate)
		or snapshot.entryIconWidth ~= (entry and entry.iconWidth)
		or snapshot.entryIconHeight ~= (entry and entry.iconHeight)
		or snapshot.entryIconOffsetX ~= (entry and entry.iconOffsetX)
		or snapshot.entryIconOffsetY ~= (entry and entry.iconOffsetY)
		or snapshot.entryFixedGroupId ~= (entry and entry.fixedGroupId)
		or snapshot.layoutIconOffsetX ~= (layout and layout.iconOffsetX)
		or snapshot.layoutIconOffsetY ~= (layout and layout.iconOffsetY)
		or snapshot.layoutIconSizeSeparate ~= (layout and layout.iconSizeSeparate)
		or snapshot.layoutIconWidth ~= (layout and layout.iconWidth)
		or snapshot.layoutIconHeight ~= (layout and layout.iconHeight)
		or snapshot.layoutSpacing ~= (layout and layout.spacing)
		or snapshot.fixedLocalIndex ~= fixedLocalIndex
		or snapshot.fixedCount ~= fixedCount
end

function cdp.RUNTIME.WritePlacementSnapshot(icon, snapshot, data, fixedLayoutCache, fixedGridColumns, slotColumn, slotRow, layoutEditActive)
	if not (icon and snapshot and data) then return end
	local entry = data.entry
	local layout = data.layout
	local fixedLocalIndex, fixedCount = cdp.RUNTIME.GetFixedContextSignature(data.fixedContext)
	snapshot.entryId = data.entryId
	snapshot.baseSlotSize = icon._eqolBaseSlotSize
	snapshot.fixedLayoutCache = fixedLayoutCache
	snapshot.fixedGridColumns = fixedGridColumns
	snapshot.slotColumn = slotColumn
	snapshot.slotRow = slotRow
	snapshot.layoutEditActive = layoutEditActive == true
	snapshot.entryIconSizeUseGlobal = entry and entry.iconSizeUseGlobal or nil
	snapshot.entryIconSize = entry and entry.iconSize or nil
	snapshot.entryIconSizeSeparate = entry and entry.iconSizeSeparate or nil
	snapshot.entryIconWidth = entry and entry.iconWidth or nil
	snapshot.entryIconHeight = entry and entry.iconHeight or nil
	snapshot.entryIconOffsetX = entry and entry.iconOffsetX or nil
	snapshot.entryIconOffsetY = entry and entry.iconOffsetY or nil
	snapshot.entryFixedGroupId = entry and entry.fixedGroupId or nil
	snapshot.layoutIconOffsetX = layout and layout.iconOffsetX or nil
	snapshot.layoutIconOffsetY = layout and layout.iconOffsetY or nil
	snapshot.layoutIconSizeSeparate = layout and layout.iconSizeSeparate or nil
	snapshot.layoutIconWidth = layout and layout.iconWidth or nil
	snapshot.layoutIconHeight = layout and layout.iconHeight or nil
	snapshot.layoutSpacing = layout and layout.spacing or nil
	snapshot.fixedLocalIndex = fixedLocalIndex
	snapshot.fixedCount = fixedCount
end

function cdp.RUNTIME.HasBaseDecorChange(snapshot, data, showTooltips)
	if not (snapshot and data) then return true end
	local keybindVisible = data.showKeybinds == true and data.keybindText ~= nil
	return snapshot.entryId ~= data.entryId
		or snapshot.decorEntry ~= data.entry
		or snapshot.showIconTexture ~= (data.showIconTexture ~= false)
		or snapshot.tooltipEnabled ~= (showTooltips == true)
		or snapshot.keybindVisible ~= keybindVisible
		or snapshot.keybindText ~= data.keybindText
end

function cdp.RUNTIME.WriteBaseDecorSnapshot(snapshot, data, showTooltips)
	if not (snapshot and data) then return end
	snapshot.entryId = data.entryId
	snapshot.decorEntry = data.entry
	snapshot.showIconTexture = data.showIconTexture ~= false
	snapshot.tooltipEnabled = showTooltips == true
	snapshot.keybindVisible = data.showKeybinds == true and data.keybindText ~= nil
	snapshot.keybindText = data.keybindText
end

function cdp.RUNTIME.HasTextureChange(snapshot, data)
	if not data then return true end
	local iconTexture = data.icon or Helper.PREVIEW_ICON
	local issecretvalue = Api and Api.issecretvalue or nil
	if issecretvalue and issecretvalue(iconTexture) then return true end
	if not snapshot or snapshot.iconTextureComparable ~= true then return true end
	return snapshot.iconTexture ~= iconTexture
end

function cdp.RUNTIME.WriteTextureSnapshot(snapshot, data)
	if not (snapshot and data) then return end
	local iconTexture = data.icon or Helper.PREVIEW_ICON
	local issecretvalue = Api and Api.issecretvalue or nil
	if issecretvalue and issecretvalue(iconTexture) then
		snapshot.iconTextureComparable = false
		snapshot.iconTexture = nil
		return
	end
	snapshot.iconTextureComparable = true
	snapshot.iconTexture = iconTexture
end

function cdp.RUNTIME.EnsureCooldownTextDefaults(icon)
	if not (icon and icon.cooldown and icon.cooldown.GetCountdownFontString) then return nil, nil, nil end
	local defaults = icon.cooldown._eqolCooldownTextDefaults
	if defaults then return defaults.font, defaults.size, defaults.style end
	local fontString = icon.cooldown:GetCountdownFontString()
	if not (fontString and fontString.GetFont) then return nil, nil, nil end
	local fontPath, fontSize, fontStyle = fontString:GetFont()
	icon.cooldown._eqolCooldownTextDefaults = {
		font = fontPath,
		size = fontSize,
		style = fontStyle,
	}
	return fontPath, fontSize, fontStyle
end

function cdp.RUNTIME.HasCooldownWidgetConfigChange(snapshot, data, cooldownUsesAuraDisplay)
	if not (snapshot and data) then return true end
	local color = data.activationOverlayColor
	return snapshot.entryId ~= data.entryId
		or snapshot.cooldownHideCountdownNumbers ~= not data.showCooldownText
		or snapshot.cooldownUsesAuraDisplay ~= (cooldownUsesAuraDisplay == true)
		or snapshot.cooldownReverse ~= (data.cooldownReverse == true)
		or snapshot.activationOverlayColorR ~= (color and color[1] or nil)
		or snapshot.activationOverlayColorG ~= (color and color[2] or nil)
		or snapshot.activationOverlayColorB ~= (color and color[3] or nil)
		or snapshot.activationOverlayColorA ~= (color and color[4] or nil)
end

function cdp.RUNTIME.WriteCooldownWidgetConfigSnapshot(snapshot, data, cooldownUsesAuraDisplay)
	if not (snapshot and data) then return end
	local color = data.activationOverlayColor
	snapshot.entryId = data.entryId
	snapshot.cooldownHideCountdownNumbers = not data.showCooldownText
	snapshot.cooldownUsesAuraDisplay = cooldownUsesAuraDisplay == true
	snapshot.cooldownReverse = data.cooldownReverse == true
	snapshot.activationOverlayColorR = color and color[1] or nil
	snapshot.activationOverlayColorG = color and color[2] or nil
	snapshot.activationOverlayColorB = color and color[3] or nil
	snapshot.activationOverlayColorA = color and color[4] or nil
end

function cdp.RUNTIME.HasCooldownTextStyleChange(snapshot, data, defaultFontPath, defaultFontSize, defaultFontStyle)
	if not (snapshot and data) then return true end
	local entry = data.entry
	local layout = data.layout
	return snapshot.entryId ~= data.entryId
		or snapshot.cooldownTextDefaultFontPath ~= defaultFontPath
		or snapshot.cooldownTextDefaultFontSize ~= defaultFontSize
		or snapshot.cooldownTextDefaultFontStyle ~= defaultFontStyle
		or snapshot.entryCooldownTextUseGlobal ~= (entry and entry.cooldownTextUseGlobal ~= false or true)
		or snapshot.entryCooldownTextFont ~= (entry and entry.cooldownTextFont)
		or snapshot.entryCooldownTextSize ~= (entry and entry.cooldownTextSize)
		or snapshot.entryCooldownTextStyle ~= (entry and entry.cooldownTextStyle)
		or snapshot.entryCooldownTextColor ~= (entry and entry.cooldownTextColor)
		or snapshot.entryCooldownTextX ~= (entry and entry.cooldownTextX)
		or snapshot.entryCooldownTextY ~= (entry and entry.cooldownTextY)
		or snapshot.layoutCooldownTextFont ~= (layout and layout.cooldownTextFont)
		or snapshot.layoutCooldownTextSize ~= (layout and layout.cooldownTextSize)
		or snapshot.layoutCooldownTextStyle ~= (layout and layout.cooldownTextStyle)
		or snapshot.layoutCooldownTextColor ~= (layout and layout.cooldownTextColor)
		or snapshot.layoutCooldownTextX ~= (layout and layout.cooldownTextX)
		or snapshot.layoutCooldownTextY ~= (layout and layout.cooldownTextY)
end

function cdp.RUNTIME.WriteCooldownTextStyleSnapshot(snapshot, data, defaultFontPath, defaultFontSize, defaultFontStyle)
	if not (snapshot and data) then return end
	local entry = data.entry
	local layout = data.layout
	snapshot.entryId = data.entryId
	snapshot.cooldownTextDefaultFontPath = defaultFontPath
	snapshot.cooldownTextDefaultFontSize = defaultFontSize
	snapshot.cooldownTextDefaultFontStyle = defaultFontStyle
	snapshot.entryCooldownTextUseGlobal = entry and entry.cooldownTextUseGlobal ~= false or true
	snapshot.entryCooldownTextFont = entry and entry.cooldownTextFont or nil
	snapshot.entryCooldownTextSize = entry and entry.cooldownTextSize or nil
	snapshot.entryCooldownTextStyle = entry and entry.cooldownTextStyle or nil
	snapshot.entryCooldownTextColor = entry and entry.cooldownTextColor or nil
	snapshot.entryCooldownTextX = entry and entry.cooldownTextX or nil
	snapshot.entryCooldownTextY = entry and entry.cooldownTextY or nil
	snapshot.layoutCooldownTextFont = layout and layout.cooldownTextFont or nil
	snapshot.layoutCooldownTextSize = layout and layout.cooldownTextSize or nil
	snapshot.layoutCooldownTextStyle = layout and layout.cooldownTextStyle or nil
	snapshot.layoutCooldownTextColor = layout and layout.cooldownTextColor or nil
	snapshot.layoutCooldownTextX = layout and layout.cooldownTextX or nil
	snapshot.layoutCooldownTextY = layout and layout.cooldownTextY or nil
end

function cdp.RUNTIME.HasStackTextStyleChange(snapshot, data, fallbackFontPath, fallbackFontSize, fallbackFontStyle)
	if not (snapshot and data) then return true end
	local entry = data.entry
	local layout = data.layout
	return snapshot.entryId ~= data.entryId
		or snapshot.stackTextFallbackFontPath ~= fallbackFontPath
		or snapshot.stackTextFallbackFontSize ~= fallbackFontSize
		or snapshot.stackTextFallbackFontStyle ~= fallbackFontStyle
		or snapshot.entryStackUseGlobal ~= (entry and entry.stackStyleUseGlobal ~= false or true)
		or snapshot.entryStackFont ~= (entry and entry.stackFont)
		or snapshot.entryStackSize ~= (entry and entry.stackFontSize)
		or snapshot.entryStackStyle ~= (entry and entry.stackFontStyle)
		or snapshot.entryStackColor ~= (entry and entry.stackColor)
		or snapshot.entryStackAnchor ~= (entry and entry.stackAnchor)
		or snapshot.entryStackX ~= (entry and entry.stackX)
		or snapshot.entryStackY ~= (entry and entry.stackY)
		or snapshot.layoutStackFont ~= (layout and layout.stackFont)
		or snapshot.layoutStackSize ~= (layout and layout.stackFontSize)
		or snapshot.layoutStackStyle ~= (layout and layout.stackFontStyle)
		or snapshot.layoutStackColor ~= (layout and layout.stackColor)
		or snapshot.layoutStackAnchor ~= (layout and layout.stackAnchor)
		or snapshot.layoutStackX ~= (layout and layout.stackX)
		or snapshot.layoutStackY ~= (layout and layout.stackY)
end

function cdp.RUNTIME.WriteStackTextStyleSnapshot(snapshot, data, fallbackFontPath, fallbackFontSize, fallbackFontStyle)
	if not (snapshot and data) then return end
	local entry = data.entry
	local layout = data.layout
	snapshot.entryId = data.entryId
	snapshot.stackTextFallbackFontPath = fallbackFontPath
	snapshot.stackTextFallbackFontSize = fallbackFontSize
	snapshot.stackTextFallbackFontStyle = fallbackFontStyle
	snapshot.entryStackUseGlobal = entry and entry.stackStyleUseGlobal ~= false or true
	snapshot.entryStackFont = entry and entry.stackFont or nil
	snapshot.entryStackSize = entry and entry.stackFontSize or nil
	snapshot.entryStackStyle = entry and entry.stackFontStyle or nil
	snapshot.entryStackColor = entry and entry.stackColor or nil
	snapshot.entryStackAnchor = entry and entry.stackAnchor or nil
	snapshot.entryStackX = entry and entry.stackX or nil
	snapshot.entryStackY = entry and entry.stackY or nil
	snapshot.layoutStackFont = layout and layout.stackFont or nil
	snapshot.layoutStackSize = layout and layout.stackFontSize or nil
	snapshot.layoutStackStyle = layout and layout.stackFontStyle or nil
	snapshot.layoutStackColor = layout and layout.stackColor or nil
	snapshot.layoutStackAnchor = layout and layout.stackAnchor or nil
	snapshot.layoutStackX = layout and layout.stackX or nil
	snapshot.layoutStackY = layout and layout.stackY or nil
end

function cdp.RUNTIME.HasChargesTextStyleChange(snapshot, data, fallbackFontPath, fallbackFontSize, fallbackFontStyle)
	if not (snapshot and data) then return true end
	local entry = data.entry
	local layout = data.layout
	return snapshot.entryId ~= data.entryId
		or snapshot.chargesTextFallbackFontPath ~= fallbackFontPath
		or snapshot.chargesTextFallbackFontSize ~= fallbackFontSize
		or snapshot.chargesTextFallbackFontStyle ~= fallbackFontStyle
		or snapshot.entryChargesUseGlobal ~= (entry and entry.chargesStyleUseGlobal ~= false or true)
		or snapshot.entryChargesFont ~= (entry and entry.chargesFont)
		or snapshot.entryChargesSize ~= (entry and entry.chargesFontSize)
		or snapshot.entryChargesStyle ~= (entry and entry.chargesFontStyle)
		or snapshot.entryChargesColor ~= (entry and entry.chargesColor)
		or snapshot.entryChargesAnchor ~= (entry and entry.chargesAnchor)
		or snapshot.entryChargesX ~= (entry and entry.chargesX)
		or snapshot.entryChargesY ~= (entry and entry.chargesY)
		or snapshot.layoutChargesFont ~= (layout and layout.chargesFont)
		or snapshot.layoutChargesSize ~= (layout and layout.chargesFontSize)
		or snapshot.layoutChargesStyle ~= (layout and layout.chargesFontStyle)
		or snapshot.layoutChargesColor ~= (layout and layout.chargesColor)
		or snapshot.layoutChargesAnchor ~= (layout and layout.chargesAnchor)
		or snapshot.layoutChargesX ~= (layout and layout.chargesX)
		or snapshot.layoutChargesY ~= (layout and layout.chargesY)
end

function cdp.RUNTIME.WriteChargesTextStyleSnapshot(snapshot, data, fallbackFontPath, fallbackFontSize, fallbackFontStyle)
	if not (snapshot and data) then return end
	local entry = data.entry
	local layout = data.layout
	snapshot.entryId = data.entryId
	snapshot.chargesTextFallbackFontPath = fallbackFontPath
	snapshot.chargesTextFallbackFontSize = fallbackFontSize
	snapshot.chargesTextFallbackFontStyle = fallbackFontStyle
	snapshot.entryChargesUseGlobal = entry and entry.chargesStyleUseGlobal ~= false or true
	snapshot.entryChargesFont = entry and entry.chargesFont or nil
	snapshot.entryChargesSize = entry and entry.chargesFontSize or nil
	snapshot.entryChargesStyle = entry and entry.chargesFontStyle or nil
	snapshot.entryChargesColor = entry and entry.chargesColor or nil
	snapshot.entryChargesAnchor = entry and entry.chargesAnchor or nil
	snapshot.entryChargesX = entry and entry.chargesX or nil
	snapshot.entryChargesY = entry and entry.chargesY or nil
	snapshot.layoutChargesFont = layout and layout.chargesFont or nil
	snapshot.layoutChargesSize = layout and layout.chargesFontSize or nil
	snapshot.layoutChargesStyle = layout and layout.chargesFontStyle or nil
	snapshot.layoutChargesColor = layout and layout.chargesColor or nil
	snapshot.layoutChargesAnchor = layout and layout.chargesAnchor or nil
	snapshot.layoutChargesX = layout and layout.chargesX or nil
	snapshot.layoutChargesY = layout and layout.chargesY or nil
end

function cdp.RUNTIME.HasStaticTextChange(snapshot, data, staticFontPath, staticFontSize, staticFontStyle, staticTextCooldown)
	if not (snapshot and data) then return true end
	local entry = data.entry
	local layout = data.layout
	return snapshot.entryId ~= data.entryId
		or snapshot.staticFontPath ~= staticFontPath
		or snapshot.staticFontSize ~= staticFontSize
		or snapshot.staticFontStyle ~= staticFontStyle
		or snapshot.staticTextCooldown ~= (staticTextCooldown == true)
		or snapshot.entryStaticText ~= (entry and entry.staticText)
		or snapshot.entryStaticTextShowOnCooldown ~= (entry and entry.staticTextShowOnCooldown)
		or snapshot.entryStaticTextUseGlobal ~= (entry and entry.staticTextUseGlobal)
		or snapshot.entryStaticTextFont ~= (entry and entry.staticTextFont)
		or snapshot.entryStaticTextSize ~= (entry and entry.staticTextSize)
		or snapshot.entryStaticTextStyle ~= (entry and entry.staticTextStyle)
		or snapshot.entryStaticTextColor ~= (entry and entry.staticTextColor)
		or snapshot.entryStaticTextAnchor ~= (entry and entry.staticTextAnchor)
		or snapshot.entryStaticTextX ~= (entry and entry.staticTextX)
		or snapshot.entryStaticTextY ~= (entry and entry.staticTextY)
		or snapshot.layoutStaticTextFont ~= (layout and layout.staticTextFont)
		or snapshot.layoutStaticTextSize ~= (layout and layout.staticTextSize)
		or snapshot.layoutStaticTextStyle ~= (layout and layout.staticTextStyle)
		or snapshot.layoutStaticTextColor ~= (layout and layout.staticTextColor)
		or snapshot.layoutStaticTextAnchor ~= (layout and layout.staticTextAnchor)
		or snapshot.layoutStaticTextX ~= (layout and layout.staticTextX)
		or snapshot.layoutStaticTextY ~= (layout and layout.staticTextY)
end

function cdp.RUNTIME.WriteStaticTextSnapshot(snapshot, data, staticFontPath, staticFontSize, staticFontStyle, staticTextCooldown)
	if not (snapshot and data) then return end
	local entry = data.entry
	local layout = data.layout
	snapshot.entryId = data.entryId
	snapshot.staticFontPath = staticFontPath
	snapshot.staticFontSize = staticFontSize
	snapshot.staticFontStyle = staticFontStyle
	snapshot.staticTextCooldown = staticTextCooldown == true
	snapshot.entryStaticText = entry and entry.staticText or nil
	snapshot.entryStaticTextShowOnCooldown = entry and entry.staticTextShowOnCooldown or nil
	snapshot.entryStaticTextUseGlobal = entry and entry.staticTextUseGlobal or nil
	snapshot.entryStaticTextFont = entry and entry.staticTextFont or nil
	snapshot.entryStaticTextSize = entry and entry.staticTextSize or nil
	snapshot.entryStaticTextStyle = entry and entry.staticTextStyle or nil
	snapshot.entryStaticTextColor = entry and entry.staticTextColor or nil
	snapshot.entryStaticTextAnchor = entry and entry.staticTextAnchor or nil
	snapshot.entryStaticTextX = entry and entry.staticTextX or nil
	snapshot.entryStaticTextY = entry and entry.staticTextY or nil
	snapshot.layoutStaticTextFont = layout and layout.staticTextFont or nil
	snapshot.layoutStaticTextSize = layout and layout.staticTextSize or nil
	snapshot.layoutStaticTextStyle = layout and layout.staticTextStyle or nil
	snapshot.layoutStaticTextColor = layout and layout.staticTextColor or nil
	snapshot.layoutStaticTextAnchor = layout and layout.staticTextAnchor or nil
	snapshot.layoutStaticTextX = layout and layout.staticTextX or nil
	snapshot.layoutStaticTextY = layout and layout.staticTextY or nil
end

function cdp.RUNTIME.HasStateTextureChange(snapshot, data)
	if not (snapshot and data) then return true end
	return snapshot.entryId ~= data.entryId
		or snapshot.stateTextureShown ~= (data.stateTextureShown == true)
		or snapshot.stateTextureType ~= data.stateTextureType
		or snapshot.stateTextureValue ~= data.stateTextureValue
		or snapshot.stateTextureWidth ~= data.stateTextureWidth
		or snapshot.stateTextureHeight ~= data.stateTextureHeight
		or snapshot.stateTextureScale ~= data.stateTextureScale
		or snapshot.stateTextureAngle ~= data.stateTextureAngle
		or snapshot.stateTextureDouble ~= (data.stateTextureDouble == true)
		or snapshot.stateTextureMirror ~= (data.stateTextureMirror == true)
		or snapshot.stateTextureMirrorSecond ~= (data.stateTextureMirrorSecond == true)
		or snapshot.stateTextureMirrorVertical ~= (data.stateTextureMirrorVertical == true)
		or snapshot.stateTextureMirrorVerticalSecond ~= (data.stateTextureMirrorVerticalSecond == true)
		or snapshot.stateTextureSpacingX ~= data.stateTextureSpacingX
		or snapshot.stateTextureSpacingY ~= data.stateTextureSpacingY
		or snapshot.stateTextureDesaturated ~= (data.stateTextureDesaturated == true)
end

function cdp.RUNTIME.WriteStateTextureSnapshot(snapshot, data)
	if not (snapshot and data) then return end
	snapshot.entryId = data.entryId
	snapshot.stateTextureShown = data.stateTextureShown == true
	snapshot.stateTextureType = data.stateTextureType
	snapshot.stateTextureValue = data.stateTextureValue
	snapshot.stateTextureWidth = data.stateTextureWidth
	snapshot.stateTextureHeight = data.stateTextureHeight
	snapshot.stateTextureScale = data.stateTextureScale
	snapshot.stateTextureAngle = data.stateTextureAngle
	snapshot.stateTextureDouble = data.stateTextureDouble == true
	snapshot.stateTextureMirror = data.stateTextureMirror == true
	snapshot.stateTextureMirrorSecond = data.stateTextureMirrorSecond == true
	snapshot.stateTextureMirrorVertical = data.stateTextureMirrorVertical == true
	snapshot.stateTextureMirrorVerticalSecond = data.stateTextureMirrorVerticalSecond == true
	snapshot.stateTextureSpacingX = data.stateTextureSpacingX
	snapshot.stateTextureSpacingY = data.stateTextureSpacingY
	snapshot.stateTextureDesaturated = data.stateTextureDesaturated == true
end

local function createIconFrame(parent)
	local icon = CreateFrame("Frame", nil, parent)
	icon:Hide()
	icon:EnableMouse(false)
	icon:SetScript("OnEnter", CooldownPanels.ShowIconTooltip)
	icon:SetScript("OnLeave", CooldownPanels.HideIconTooltip)

	icon.slotAnchor = CreateFrame("Frame", nil, parent)
	icon.slotAnchor:EnableMouse(false)
	icon.slotAnchorHandle = CreateFrame("Button", nil, icon.slotAnchor)
	icon.slotAnchorHandle:SetAllPoints(icon.slotAnchor)
	icon.slotAnchorHandle:EnableMouse(false)
	icon.slotAnchorHandle:Hide()

	icon.editorGhostTexture = icon.slotAnchor:CreateTexture(nil, "ARTWORK")
	icon.editorGhostTexture:SetAllPoints(icon.slotAnchor)
	icon.editorGhostTexture:Hide()

	icon.texture = icon:CreateTexture(nil, "ARTWORK")
	icon.texture:SetAllPoints(icon)

	icon.cooldown = CreateFrame("Cooldown", nil, icon, "CooldownFrameTemplate")
	icon.cooldown:SetAllPoints(icon)
	icon.cooldown:SetHideCountdownNumbers(true)

	icon.overlay = CreateFrame("Frame", nil, icon)
	icon.overlay:SetAllPoints(icon)
	icon.overlay:SetFrameStrata(icon.cooldown:GetFrameStrata() or icon:GetFrameStrata())
	icon.overlay:SetFrameLevel((icon.cooldown:GetFrameLevel() or icon:GetFrameLevel()) + 5)
	icon.overlay:EnableMouse(false)

	icon.layoutHandle = CreateFrame("Button", nil, icon)
	icon.layoutHandle:SetAllPoints(icon)
	icon.layoutHandle:SetFrameStrata(icon.overlay:GetFrameStrata() or icon:GetFrameStrata())
	icon.layoutHandle:SetFrameLevel((icon.overlay:GetFrameLevel() or icon:GetFrameLevel()) + 20)
	icon.layoutHandle:EnableMouse(false)
	icon.layoutHandle:Hide()

	icon.border = CreateFrame("Frame", nil, icon, "BackdropTemplate")
	icon.border:SetFrameStrata(icon.cooldown:GetFrameStrata() or icon:GetFrameStrata())
	icon.border:SetFrameLevel((icon.cooldown:GetFrameLevel() or icon:GetFrameLevel()) + 1)
	icon.border:EnableMouse(false)
	icon.border:Hide()

	icon.count = icon.overlay:CreateFontString(nil, "OVERLAY", "NumberFontNormalSmall")
	icon.count:SetPoint("BOTTOMRIGHT", icon.overlay, "BOTTOMRIGHT", -1, 1)
	icon.count:Hide()

	icon.charges = icon.overlay:CreateFontString(nil, "OVERLAY", "NumberFontNormalSmall")
	icon.charges:SetPoint("TOP", icon.overlay, "TOP", 0, -1)
	icon.charges:Hide()

	icon.rangeOverlay = icon.overlay:CreateTexture(nil, "BACKGROUND")
	icon.rangeOverlay:SetAllPoints(icon.overlay)
	icon.rangeOverlay:Hide()

	icon.stateTexture = icon:CreateTexture(nil, "ARTWORK", nil, 1)
	icon.stateTexture:SetBlendMode("BLEND")
	icon.stateTexture:Hide()

	icon.stateTextureSecond = icon:CreateTexture(nil, "ARTWORK", nil, 1)
	icon.stateTextureSecond:SetBlendMode("BLEND")
	icon.stateTextureSecond:Hide()

	icon.keybind = icon.overlay:CreateFontString(nil, "OVERLAY", "NumberFontNormalSmall")
	icon.keybind:SetPoint("TOPLEFT", icon.overlay, "TOPLEFT", 2, -2)
	icon.keybind:Hide()

	icon.staticText = icon.overlay:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
	icon.staticText:SetPoint("CENTER", icon.overlay, "CENTER", 0, 0)
	icon.staticText:SetJustifyH("CENTER")
	icon.staticText:SetJustifyV("MIDDLE")
	if icon.staticText.SetWordWrap then icon.staticText:SetWordWrap(true) end
	icon.staticText:Hide()

	icon.previewGlowBorder = CreateFrame("Frame", nil, icon, "BackdropTemplate")
	icon.previewGlowBorder:SetFrameStrata(icon.cooldown:GetFrameStrata() or icon:GetFrameStrata())
	icon.previewGlowBorder:SetFrameLevel((icon.cooldown:GetFrameLevel() or icon:GetFrameLevel()) + 2)
	icon.previewGlowBorder:EnableMouse(false)
	icon.previewGlowBorder:SetBackdrop({
		edgeFile = "Interface\\Buttons\\WHITE8x8",
		edgeSize = 2,
		insets = { left = 0, right = 0, top = 0, bottom = 0 },
	})
	icon.previewGlowBorder:SetBackdropColor(0, 0, 0, 0)
	icon.previewGlowBorder:Hide()

	icon.previewBling = icon:CreateTexture(nil, "OVERLAY")
	icon.previewBling:SetTexture("Interface\\Cooldown\\star4")
	icon.previewBling:SetVertexColor(0.3, 0.6, 1, 0.9)
	icon.previewBling:SetBlendMode("ADD")
	icon.previewBling:Hide()

	icon.previewSoundBorder = CreateFrame("Frame", nil, icon.overlay, "BackdropTemplate")
	icon.previewSoundBorder:SetSize(14, 14)
	icon.previewSoundBorder:SetPoint("TOPRIGHT", icon.overlay, "TOPRIGHT", -1, -1)
	icon.previewSoundBorder:SetBackdrop({
		bgFile = "Interface\\Buttons\\WHITE8x8",
		edgeFile = "Interface\\Buttons\\WHITE8x8",
		edgeSize = 1,
	})
	icon.previewSoundBorder:SetBackdropColor(0, 0, 0, 0.55)
	icon.previewSoundBorder:SetBackdropBorderColor(0.9, 0.9, 0.9, 0.9)
	icon.previewSoundBorder:Hide()

	icon.previewSound = icon.previewSoundBorder:CreateTexture(nil, "OVERLAY")
	icon.previewSound:SetTexture("Interface\\Common\\VoiceChat-Speaker")
	icon.previewSound:SetSize(12, 12)
	icon.previewSound:SetPoint("CENTER", icon.previewSoundBorder, "CENTER", 0, 0)
	icon.previewSound:SetAlpha(0.95)

	return icon
end

function CooldownPanels.HidePreviewGlowBorder(icon)
	local border = icon and icon.previewGlowBorder
	if border then border:Hide() end
end

function CooldownPanels.UpdatePreviewGlowBorderLayout(icon, rowSize)
	local border = icon and icon.previewGlowBorder
	if not border then return end

	local size = tonumber(rowSize) or 24
	local edgeSize = math.min(6, math.max(2, math.floor((size * 0.08) + 0.5)))
	local offset = math.min(6, math.max(1, math.floor((size * 0.1) + 0.5)))

	if border._eqolEdgeSize ~= edgeSize then
		border:SetBackdrop({
			edgeFile = "Interface\\Buttons\\WHITE8x8",
			edgeSize = edgeSize,
			insets = { left = 0, right = 0, top = 0, bottom = 0 },
		})
		border:SetBackdropColor(0, 0, 0, 0)
		border._eqolEdgeSize = edgeSize
	end

	if border._eqolOffset ~= offset or border._eqolSize ~= size then
		border:ClearAllPoints()
		border:SetPoint("TOPLEFT", icon, "TOPLEFT", -offset, offset)
		border:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", offset, -offset)
		border._eqolOffset = offset
		border._eqolSize = size
	end
end

function CooldownPanels.ShowPreviewGlowBorder(icon, glowColor)
	local border = icon and icon.previewGlowBorder
	if not border then return end

	local fallbackColor = (Helper and Helper.PANEL_LAYOUT_DEFAULTS and Helper.PANEL_LAYOUT_DEFAULTS.readyGlowColor) or { 1, 0.82, 0.2, 1 }
	local color = Helper.NormalizeColor(glowColor, fallbackColor)
	CooldownPanels.UpdatePreviewGlowBorderLayout(icon, icon:GetWidth())
	border:SetBackdropBorderColor(color[1] or 1, color[2] or 0.82, color[3] or 0.2, color[4] or 1)
	border:Show()
end

local function ensureIconCount(frame, count)
	frame.icons = frame.icons or {}
	local icons = frame.icons
	local previousCount = tonumber(frame._eqolEnsuredIconCount) or 0
	for i = 1, count do
		if not icons[i] then
			icons[i] = createIconFrame(frame)
			icons[i]:Hide()
		end
	end
	if count < previousCount then
		for i = count + 1, previousCount do
			local icon = icons[i]
			if icon then icon:Hide() end
		end
	end
	frame._eqolEnsuredIconCount = count
end

local ASSISTED_HIGHLIGHT_CONFIG = {
	atlas = "RotationHelper_Ants_Flipbook_2x",
	rows = 6,
	columns = 5,
	frames = 30,
	duration = 1.0,
	scale = 1.45,
	fallbackTexture = "Interface\\Buttons\\UI-ActionButton-Border",
}

local function resizeAssistedHighlight(frame)
	if not frame then return end
	local highlight = frame._eqolAssistedHighlight
	if not (highlight and highlight.Texture) then return end
	local width, height = frame:GetSize()
	if not width or width <= 0 or not height or height <= 0 then return end
	highlight.Texture:SetSize(width * ASSISTED_HIGHLIGHT_CONFIG.scale, height * ASSISTED_HIGHLIGHT_CONFIG.scale)
end

local function ensureAssistedHighlight(frame)
	if not frame then return nil end
	local highlight = frame._eqolAssistedHighlight
	if highlight then
		resizeAssistedHighlight(frame)
		return highlight
	end

	highlight = CreateFrame("Frame", nil, frame.overlay or frame)
	highlight:SetAllPoints(frame)
	highlight:EnableMouse(false)
	highlight:SetFrameStrata((frame.overlay and frame.overlay:GetFrameStrata()) or frame:GetFrameStrata())
	highlight:SetFrameLevel(((frame.overlay and frame.overlay:GetFrameLevel()) or frame:GetFrameLevel()) + 8)

	local tex = highlight:CreateTexture(nil, "OVERLAY")
	tex:SetPoint("CENTER", frame, "CENTER", 0, 0)
	if tex.SetBlendMode then tex:SetBlendMode("ADD") end
	local atlasApplied = tex.SetAtlas and tex:SetAtlas(ASSISTED_HIGHLIGHT_CONFIG.atlas, true)
	if not atlasApplied then
		tex:SetTexture(ASSISTED_HIGHLIGHT_CONFIG.fallbackTexture)
		tex:SetVertexColor(0.35, 0.75, 1, 0.95)
	end
	highlight.Texture = tex
	resizeAssistedHighlight(frame)

	local anim = highlight:CreateAnimationGroup()
	anim:SetLooping("REPEAT")
	anim:SetToFinalAlpha(true)
	highlight.Anim = anim

	local alphaAnim = anim:CreateAnimation("Alpha")
	alphaAnim:SetChildKey("Texture")
	alphaAnim:SetFromAlpha(1)
	alphaAnim:SetToAlpha(1)
	alphaAnim:SetDuration(0.001)
	alphaAnim:SetOrder(0)

	local flipAnim
	if anim.CreateAnimation then
		local ok, created = pcall(anim.CreateAnimation, anim, "FlipBook")
		if ok then flipAnim = created end
	end
	if flipAnim and flipAnim.SetFlipBookRows then
		flipAnim:SetChildKey("Texture")
		flipAnim:SetDuration(ASSISTED_HIGHLIGHT_CONFIG.duration)
		flipAnim:SetOrder(0)
		flipAnim:SetFlipBookRows(ASSISTED_HIGHLIGHT_CONFIG.rows)
		flipAnim:SetFlipBookColumns(ASSISTED_HIGHLIGHT_CONFIG.columns)
		flipAnim:SetFlipBookFrames(ASSISTED_HIGHLIGHT_CONFIG.frames)
		flipAnim:SetFlipBookFrameWidth(0)
		flipAnim:SetFlipBookFrameHeight(0)
		highlight.FlipAnim = flipAnim
	end

	highlight:SetAlpha(0)
	highlight:Show()
	frame._eqolAssistedHighlight = highlight
	return highlight
end

local function setAssistedHighlight(frame, enabled)
	if not frame then return end
	if enabled ~= true then
		frame._eqolAssistedHighlightShown = nil
		local existing = frame._eqolAssistedHighlight
		if existing then
			existing:SetAlpha(0)
			if existing.Anim and existing.Anim.IsPlaying and existing.Anim:IsPlaying() then existing.Anim:Stop() end
		end
		return
	end

	local highlight = ensureAssistedHighlight(frame)
	if not highlight then return end
	resizeAssistedHighlight(frame)
	if frame._eqolAssistedHighlightShown == true then
		highlight:SetAlpha(1)
		if highlight.Anim and highlight.Anim.IsPlaying and not highlight.Anim:IsPlaying() then highlight.Anim:Play() end
		return
	end
	frame._eqolAssistedHighlightShown = true
	highlight:SetAlpha(1)
	if highlight.Anim and highlight.Anim.IsPlaying and not highlight.Anim:IsPlaying() then highlight.Anim:Play() end
end

local function setGlow(frame, enabled, glowColor, glowKey, glowCondition, glowAlphaOn, glowAlphaOff, glowStyle, glowInset)
	if not frame then return end
	glowKey = glowKey or "EQOL_SIMPLE"
	local alphaOn = glowAlphaOn == nil and 1 or glowAlphaOn
	local alphaOff = glowAlphaOff == nil and 0 or glowAlphaOff
	frame._eqolGlowState = frame._eqolGlowState or {}
	local state = frame._eqolGlowState[glowKey]
	if not state then
		state = {}
		frame._eqolGlowState[glowKey] = state
	end
	local wasEnabled = state.enabled == true
	if not enabled then
		if not wasEnabled then return end
		state.enabled = false
		state.color = nil
		state.style = nil
		state.inset = nil
		state.requestedColorR = nil
		state.requestedColorG = nil
		state.requestedColorB = nil
		state.requestedColorA = nil
		state.requestedStyle = nil
		state.requestedInset = nil
		state.condition = nil
		state.alphaOn = nil
		state.alphaOff = nil
		if Glow then Glow.Stop(frame, glowKey) end
		return
	end
	local requestedColorR = glowColor and (glowColor[1] or glowColor.r) or nil
	local requestedColorG = glowColor and (glowColor[2] or glowColor.g) or nil
	local requestedColorB = glowColor and (glowColor[3] or glowColor.b) or nil
	local requestedColorA = glowColor and (glowColor[4] or glowColor.a) or nil
	local issecretvalue = Api and Api.issecretvalue
	local hasSecretCondition = issecretvalue and ((glowCondition ~= nil and issecretvalue(glowCondition)) or (state.condition ~= nil and issecretvalue(state.condition)))
	if
		wasEnabled
		and state.requestedColorR == requestedColorR
		and state.requestedColorG == requestedColorG
		and state.requestedColorB == requestedColorB
		and state.requestedColorA == requestedColorA
		and state.requestedStyle == glowStyle
		and state.requestedInset == glowInset
		and not hasSecretCondition
		and state.condition == glowCondition
		and state.alphaOn == alphaOn
		and state.alphaOff == alphaOff
	then
		return
	end
	local fallbackColor = (Helper and Helper.PANEL_LAYOUT_DEFAULTS and Helper.PANEL_LAYOUT_DEFAULTS.readyGlowColor) or { 1, 0.82, 0.2, 1 }
	local normalizedGlowColor = Helper.NormalizeColor(glowColor, fallbackColor)
	local normalizedGlowStyle = Helper.NormalizeGlowStyle(glowStyle, "BLIZZARD")
	local normalizedGlowInset = Helper.NormalizeGlowInset(glowInset, 0)
	local colorChanged = false
	local styleChanged = state.style ~= normalizedGlowStyle
	local insetChanged = state.inset ~= normalizedGlowInset
	local currentGlowColor = state.color
	colorChanged = not currentGlowColor
		or currentGlowColor[1] ~= normalizedGlowColor[1]
		or currentGlowColor[2] ~= normalizedGlowColor[2]
		or currentGlowColor[3] ~= normalizedGlowColor[3]
		or currentGlowColor[4] ~= normalizedGlowColor[4]
	if Glow and (not wasEnabled or colorChanged or styleChanged or insetChanged) then
		Glow.Start(frame, glowKey, normalizedGlowStyle, { color = normalizedGlowColor, cooldown = frame.cooldown, inset = normalizedGlowInset })
	end
	state.enabled = true
	state.style = normalizedGlowStyle
	state.inset = normalizedGlowInset
	state.requestedColorR = requestedColorR
	state.requestedColorG = requestedColorG
	state.requestedColorB = requestedColorB
	state.requestedColorA = requestedColorA
	state.requestedStyle = glowStyle
	state.requestedInset = glowInset
	state.condition = glowCondition
	state.alphaOn = alphaOn
	state.alphaOff = alphaOff
	if normalizedGlowColor then
		state.color = state.color or {}
		state.color[1] = normalizedGlowColor[1]
		state.color[2] = normalizedGlowColor[2]
		state.color[3] = normalizedGlowColor[3]
		state.color[4] = normalizedGlowColor[4]
	else
		state.color = nil
	end
	if Glow then
		if glowCondition ~= nil then
			Glow.SetAlphaFromBoolean(frame, glowKey, glowCondition, alphaOn, alphaOff)
		else
			Glow.SetAlpha(frame, glowKey, alphaOn)
		end
	end
end

local PREVIEW_GLOW_KEY = "EQOL_PREVIEW"

local function stopGlowKeys(frame, ...)
	if not frame then return end
	for i = 1, select("#", ...) do
		local glowKey = select(i, ...)
		if glowKey then setGlow(frame, false, nil, glowKey) end
	end
end

local function setPreviewGlow(frame, enabled, glowColor, glowStyle, glowInset)
	if not frame then return end
	if not enabled then
		setGlow(frame, false, nil, PREVIEW_GLOW_KEY)
		CooldownPanels.HidePreviewGlowBorder(frame)
		return
	end
	if Glow then
		CooldownPanels.HidePreviewGlowBorder(frame)
		setGlow(frame, true, glowColor, PREVIEW_GLOW_KEY, nil, nil, nil, glowStyle, glowInset)
		return
	end
	setGlow(frame, false, nil, PREVIEW_GLOW_KEY)
	CooldownPanels.ShowPreviewGlowBorder(frame, glowColor)
end

function CooldownPanels.StopAllIconGlows(frame)
	if not frame then return end
	frame._eqolGlowState = nil
	if Glow then Glow.StopAll(frame) end
end

function CooldownPanels.GetReadyGlowPrimedState(runtime)
	if type(runtime) ~= "table" then return nil end
	local primed = runtime.readyGlowPrimed or runtime.itemReadyPrimed
	if type(primed) ~= "table" then primed = {} end
	runtime.readyGlowPrimed = primed
	runtime.itemReadyPrimed = primed
	return primed
end

function CooldownPanels.ClearReadyGlowEntryState(panelId, entryId, clearPrimed)
	if not panelId or not entryId then return end
	local runtime = getRuntime(panelId)
	runtime.readyAt = runtime.readyAt or {}
	runtime.glowTimers = runtime.glowTimers or {}
	runtime.readyAt[entryId] = nil
	local timer = runtime.glowTimers[entryId]
	if timer and timer.Cancel then timer:Cancel() end
	runtime.glowTimers[entryId] = nil
	if clearPrimed then
		local primed = CooldownPanels.GetReadyGlowPrimedState(runtime)
		if primed then primed[entryId] = nil end
	end
end

local function triggerReadyGlow(panelId, entryId, glowDuration)
	if not panelId or not entryId then return end
	local runtime = getRuntime(panelId)
	runtime.readyAt = runtime.readyAt or {}
	runtime.glowTimers = runtime.glowTimers or {}
	CooldownPanels.ClearReadyGlowEntryState(panelId, entryId, false)

	local now = Api.GetTime and Api.GetTime() or 0
	runtime.readyAt[entryId] = now
end

local function shouldArmReadySoundOnCooldownDone(data)
	if type(data) ~= "table" or data.soundReady ~= true or data.cooldownGCD == true then return false end
	if data.resolvedType == "SPELL" then
		if data.chargesInfo and data.chargesInfo.maxCharges ~= nil then return false end
		return CooldownPanels.IsSpellCooldownInfoActive(data.cooldownIsActive, data.cooldownEnabled, data.cooldownStart, data.cooldownDuration)
	end
	if data.resolvedType == "ITEM" or data.resolvedType == "SLOT" then return CooldownPanels.IsSpellCooldownInfoActive(nil, true, data.cooldownStart, data.cooldownDuration) end
	return false
end

local function updateCooldownDoneContext(cooldown, panelId, entryId, data)
	if not (cooldown and panelId and entryId and type(data) == "table") then return end

	local sameEntry = cooldown._eqolPanelId == panelId and cooldown._eqolEntryId == entryId
	local armReadySound = shouldArmReadySoundOnCooldownDone(data)
	local preserveReadySound = not armReadySound and data.cooldownGCD == true and sameEntry and cooldown._eqolSoundReady == true and cooldown._eqolSoundName ~= nil

	cooldown._eqolPanelId = panelId
	cooldown._eqolEntryId = entryId
	cooldown._eqolCooldownIsGCD = data.cooldownGCD == true
	cooldown._eqolSpellId = data.spellId
	cooldown._eqolBaseSpellId = data.baseSpellId
	cooldown._eqolEffectiveSpellId = data.effectiveSpellId
	cooldown._eqolCustomCooldownKey = data.customCooldownDurationActive == true and data.customCooldownDurationKey or nil
	cooldown._eqolSoundName = data.soundName
	if armReadySound then
		cooldown._eqolSoundReady = true
		cooldown._eqolSoundReadyIgnoreGCD = nil
	elseif preserveReadySound then
		cooldown._eqolSoundReady = true
		cooldown._eqolSoundReadyIgnoreGCD = true
	else
		cooldown._eqolSoundReady = nil
		cooldown._eqolSoundReadyIgnoreGCD = nil
	end
	cooldown._eqolGlowReady = data.glowReady
	cooldown._eqolGlowDuration = data.glowDuration
end

local function clearCooldownDoneState(cooldown)
	if not cooldown then return end
	cooldown._eqolSoundReady = nil
	cooldown._eqolSoundReadyIgnoreGCD = nil
	cooldown._eqolGlowReady = nil
	cooldown._eqolCustomCooldownKey = nil
	if cooldown.SetScript then cooldown:SetScript("OnCooldownDone", nil) end
end

local function isCooldownPanelRuntimeActive(panelId)
	local runtime = CooldownPanels and CooldownPanels.runtime
	local enabledPanels = runtime and runtime.enabledPanels
	if enabledPanels then return panelId and enabledPanels[panelId] == true end
	return true
end

local function onCooldownDone(self)
	if not self then return end
	if not isCooldownPanelRuntimeActive(self._eqolPanelId) then
		clearCooldownDoneState(self)
		return
	end

	-- Never trigger sound/glow for GCD-only cooldowns.
	local isGCD = self._eqolCooldownIsGCD == true
	local allowSoundDuringGCD = self._eqolSoundReadyIgnoreGCD == true

	if not isGCD or allowSoundDuringGCD then
		-- Sound should only fire once per displayed cooldown.
		if self._eqolSoundReady then
			playSoundName(self._eqolSoundName)
			self._eqolSoundReady = nil
			self._eqolSoundReadyIgnoreGCD = nil
		end
	end

	if not isGCD then
		if self._eqolCustomCooldownKey and CooldownPanels and CooldownPanels.ClearEntryCustomCooldownDuration then
			CooldownPanels:ClearEntryCustomCooldownDuration(self._eqolPanelId, self._eqolEntryId, true)
			self._eqolCustomCooldownKey = nil
		end

		if CooldownPanels and CooldownPanels.InvalidateSpellCooldownCachesForAliases then
			CooldownPanels:InvalidateSpellCooldownCachesForAliases(self._eqolSpellId)
			CooldownPanels:InvalidateSpellCooldownCachesForAliases(self._eqolBaseSpellId)
			CooldownPanels:InvalidateSpellCooldownCachesForAliases(self._eqolEffectiveSpellId)
		end

		-- Glow trigger is purely event-driven (robust in secret environments).
		if self._eqolGlowReady then triggerReadyGlow(self._eqolPanelId, self._eqolEntryId, self._eqolGlowDuration) end
	end

	if CooldownPanels and CooldownPanels.RequestPanelRefresh then
		CooldownPanels:RequestPanelRefresh(self._eqolPanelId)
	elseif CooldownPanels and CooldownPanels.RefreshPanel and self._eqolPanelId then
		CooldownPanels:RefreshPanel(self._eqolPanelId)
	end
	-- if CooldownPanels and CooldownPanels.RequestUpdate then CooldownPanels:RequestUpdate() end
end

local function isSafeNumber(value) return type(value) == "number" and (not Api.issecretvalue or not Api.issecretvalue(value)) end

local function isSafeGreaterThan(value, threshold)
	if not isSafeNumber(value) or not isSafeNumber(threshold) then return false end
	return value > threshold
end

local function isSafeLessThan(a, b)
	if not isSafeNumber(a) or not isSafeNumber(b) then return false end
	return a < b
end

local function isSafeNotFalse(value)
	if Api.issecretvalue and Api.issecretvalue(value) then return true end
	return value ~= false
end

local function isCooldownActive(startTime, duration)
	if not isSafeNumber(startTime) or not isSafeNumber(duration) then return false end
	if duration <= 0 or startTime <= 0 then return false end
	if not Api.GetTime then return false end
	return (startTime + duration) > Api.GetTime()
end

function CooldownPanels:GetDurationNow()
	local durationUtil = _G.C_DurationUtil
	if durationUtil and durationUtil.GetCurrentTime then return durationUtil.GetCurrentTime() end
	return (Api.GetTime and Api.GetTime()) or GetTime()
end

function CooldownPanels:SupportsEntryCustomCooldownDuration(entry, resolvedType)
	if not entry then return false end
	if entry.type == "CDM_AURA" or entry.type == "STANCE" then return false end
	local typeKey = resolvedType
	if not typeKey then
		if entry.type == "MACRO" then
			local macro = CooldownPanels.ResolveMacroEntry and CooldownPanels.ResolveMacroEntry(entry) or nil
			typeKey = macro and macro.kind or entry.type
		else
			typeKey = entry.type
		end
	end
	return typeKey == "SPELL" or typeKey == "ITEM" or typeKey == "SLOT"
end

function CooldownPanels:GetAutoCooldownDurationInfoForItemID(itemID)
	itemID = tonumber(itemID)
	if not itemID then return nil end
	local map = self.autoCooldownDurationByItemID
	local info = map and map[itemID] or nil
	local duration = info and tonumber(info.duration) or nil
	if not (duration and duration > 0) then return nil end
	local maxDuration = Helper.CUSTOM_COOLDOWN_DURATION_MAX or 300
	if duration > maxDuration then duration = maxDuration end
	return info, duration
end

function CooldownPanels:GetEntryAutoCooldownDurationInfo(entry, resolvedType)
	if not entry then return nil end
	local typeKey = resolvedType or entry.type
	if typeKey == "ITEM" then
		local itemID = tonumber(entry.itemID)
		if itemID and entry.type == "ITEM" then itemID = self.ResolveEntryItemID(entry, itemID) end
		return self:GetAutoCooldownDurationInfoForItemID(itemID)
	elseif typeKey == "SLOT" then
		local slotID = tonumber(entry.slotID)
		local itemID = slotID and Api.GetInventoryItemID and Api.GetInventoryItemID("player", slotID) or nil
		return self:GetAutoCooldownDurationInfoForItemID(itemID)
	end
	return nil
end

function CooldownPanels:IsEntryTrinketSlot(entry, resolvedType)
	if not entry then return false end
	local typeKey = resolvedType or entry.type
	if typeKey ~= "SLOT" then return false end
	local slotID = tonumber(entry.slotID)
	if not slotID then return false end
	return slotID == 13 or slotID == 14
end

function CooldownPanels:SupportsEntryAutoCooldownDuration(entry, resolvedType)
	if self:IsEntryTrinketSlot(entry, resolvedType) then return true end
	local _, duration = self:GetEntryAutoCooldownDurationInfo(entry, resolvedType)
	return duration ~= nil
end

function CooldownPanels:SupportsEntryCDMAuraOverlay(entry, resolvedType)
	if not entry then return false end
	local typeKey = resolvedType or entry.type
	if typeKey ~= "SPELL" or entry.type ~= "SPELL" then return false end
	local spellID = tonumber(entry.spellID)
	local cdmAuras = self.CDMAuras
	if not (spellID and cdmAuras and cdmAuras.SupportsSpellAuraOverlay) then return false end
	local candidates, seen = {}, {}
	candidates, seen = self:GetSpellAliasIDs(spellID, candidates, seen)
	local effectiveSpellID, resolvedSpellID = self:ResolveTrackedSpellID(spellID)
	if effectiveSpellID then candidates, seen = self:GetSpellAliasIDs(effectiveSpellID, candidates, seen) end
	if resolvedSpellID then candidates, seen = self:GetSpellAliasIDs(resolvedSpellID, candidates, seen) end
	for i = 1, #candidates do
		if cdmAuras:SupportsSpellAuraOverlay(candidates[i]) then return true end
	end
	return false
end

function CooldownPanels:IsEntryCDMAuraOverlayEnabled(layout, entry, resolvedType)
	if not entry then return false end
	local typeKey = resolvedType or entry.type
	if typeKey ~= "SPELL" or entry.type ~= "SPELL" then return false end
	if entry.customCooldownDurationEnabled == true then return false end
	return entry.cdmAuraOverlayEnabled == true or (layout and layout.cdmAuraOverlayEnabled == true)
end

function CooldownPanels:GetEntryCustomCooldownDuration(entry, resolvedType)
	if not self:SupportsEntryCustomCooldownDuration(entry, resolvedType) then return nil end
	if entry.autoCooldownDurationEnabled == true then
		local _, autoDuration = self:GetEntryAutoCooldownDurationInfo(entry, resolvedType)
		if autoDuration then return autoDuration end
	end
	if entry.customCooldownDurationEnabled ~= true then return nil end
	local duration = tonumber(entry.customCooldownDuration)
	if not duration or duration <= 0 then return nil end
	local maxDuration = Helper.CUSTOM_COOLDOWN_DURATION_MAX or 300
	if duration > maxDuration then duration = maxDuration end
	return duration
end

function CooldownPanels:GetCustomCooldownDurationStore()
	self.runtime = self.runtime or {}
	local runtime = self.runtime
	runtime.customCooldownDurations = runtime.customCooldownDurations or {}
	runtime.customCooldownDurationTimers = runtime.customCooldownDurationTimers or {}
	return runtime.customCooldownDurations, runtime.customCooldownDurationTimers
end

function CooldownPanels:ClearEntryCustomCooldownDuration(panelId, entryId, suppressRefresh)
	panelId = normalizeId(panelId)
	entryId = normalizeId(entryId)
	if not (panelId and entryId and self.runtime) then return false end
	local key = Helper.GetEntryKey(panelId, entryId)
	local store = self.runtime.customCooldownDurations
	local timers = self.runtime.customCooldownDurationTimers
	local hadRecord = store and store[key] ~= nil
	if store then store[key] = nil end
	local timer = timers and timers[key] or nil
	if timer and timer.Cancel then timer:Cancel() end
	if timers then timers[key] = nil end
	if hadRecord and not suppressRefresh then
		if self.RequestPanelRefresh then
			self:RequestPanelRefresh(panelId)
		elseif self:GetPanel(panelId) then
			self:RefreshPanel(panelId)
		end
	end
	return hadRecord
end

function CooldownPanels:ClearPanelCustomCooldownDurations(panelId)
	panelId = normalizeId(panelId)
	if not (panelId and self.runtime) then return false end
	local store = self.runtime.customCooldownDurations
	local timers = self.runtime.customCooldownDurationTimers
	if not store then return false end
	local prefix = tostring(panelId) .. ":"
	local changed = false
	for key in pairs(store) do
		if tostring(key):sub(1, #prefix) == prefix then
			store[key] = nil
			local timer = timers and timers[key] or nil
			if timer and timer.Cancel then timer:Cancel() end
			if timers then timers[key] = nil end
			changed = true
		end
	end
	return changed
end

function CooldownPanels:GetActiveEntryCustomCooldownDuration(panelId, entryId, entry, resolvedType)
	local duration = self:GetEntryCustomCooldownDuration(entry, resolvedType)
	if not duration then return nil end
	panelId = normalizeId(panelId)
	entryId = normalizeId(entryId)
	if not (panelId and entryId and self.runtime) then return nil end
	local store = self.runtime.customCooldownDurations
	local key = Helper.GetEntryKey(panelId, entryId)
	local record = store and store[key] or nil
	if not record then return nil end
	local now = self:GetDurationNow()
	local startTime = tonumber(record.startTime)
	local endTime = tonumber(record.endTime)
	local recordDuration = tonumber(record.duration) or duration
	if not (startTime and endTime and recordDuration and recordDuration > 0) or now >= endTime then
		self:ClearEntryCustomCooldownDuration(panelId, entryId, true)
		return nil
	end
	local durationObject = record.durationObject
	if durationObject and durationObject.GetRemainingDuration then
		local remaining = durationObject.GetRemainingDuration(durationObject, Api.DurationModifierRealTime)
		if isSafeNumber(remaining) and remaining <= 0 then
			self:ClearEntryCustomCooldownDuration(panelId, entryId, true)
			return nil
		end
	end
	return {
		key = key,
		durationObject = durationObject,
		startTime = startTime,
		duration = recordDuration,
		endTime = endTime,
		rate = 1,
	}
end

function CooldownPanels:StartEntryCustomCooldownDuration(panelId, entryId, entry, resolvedType)
	local duration = self:GetEntryCustomCooldownDuration(entry, resolvedType)
	if not duration then return false end
	panelId = normalizeId(panelId)
	entryId = normalizeId(entryId)
	if not (panelId and entryId) then return false end
	local now = self:GetDurationNow()
	local durationObject
	local durationUtil = _G.C_DurationUtil
	if durationUtil and durationUtil.CreateDuration then
		durationObject = durationUtil.CreateDuration()
		if durationObject and durationObject.SetTimeFromStart then durationObject:SetTimeFromStart(now, duration, 1) end
	end
	local store, timers = self:GetCustomCooldownDurationStore()
	local key = Helper.GetEntryKey(panelId, entryId)
	local oldTimer = timers[key]
	if oldTimer and oldTimer.Cancel then oldTimer:Cancel() end
	store[key] = {
		durationObject = durationObject,
		startTime = now,
		duration = duration,
		endTime = now + duration,
	}
	if C_Timer and C_Timer.NewTimer then
		timers[key] = C_Timer.NewTimer(duration, function()
			local currentStore = CooldownPanels.runtime and CooldownPanels.runtime.customCooldownDurations
			local current = currentStore and currentStore[key] or nil
			if current and current.startTime == now and current.duration == duration then CooldownPanels:ClearEntryCustomCooldownDuration(panelId, entryId) end
		end)
	end
	return true
end

function CooldownPanels:ApplyCustomCooldownDurationState(data, customState)
	if not data then return end
	if not customState then
		data.customCooldownDurationActive = false
		data.customCooldownDurationKey = nil
		data.customCooldownDurationObject = nil
		data.customCooldownStart = nil
		data.customCooldownDuration = nil
		return
	end
	data.customCooldownDurationActive = true
	data.customCooldownDurationKey = customState.key
	data.customCooldownDurationObject = customState.durationObject
	data.customCooldownStart = customState.startTime
	data.customCooldownDuration = customState.duration
	data.cooldownStart = customState.startTime or data.cooldownStart or 0
	data.cooldownDuration = customState.duration or data.cooldownDuration or 0
	data.cooldownEnabled = true
	data.cooldownIsActive = true
	data.cooldownRate = customState.rate or 1
	data.cooldownGCD = false
	data.cooldownDurationObject = customState.durationObject
end

function CooldownPanels:ApplyActivationOverlayVisualState(data, entry)
	if not data then return end
	local active = data.customCooldownDurationActive == true or data.spellAuraOverlayActive == true
	data.activationOverlayActive = active
	data.activationOverlayReverse = entry and entry.activationOverlayReverse ~= false or true
	data.activationOverlayColor = Helper.NormalizeColor(entry and entry.activationOverlayColor, Helper.ACTIVATION_OVERLAY_COLOR_DEFAULT)
	data.activationOverlayOnly = entry and entry.activationOverlayOnly == true or false
	data.activationOverlayGlow = entry and entry.activationOverlayGlow == true or false
	data.cooldownReverse = data.resolvedType == "CDM_AURA" or (active == true and data.activationOverlayReverse ~= false)
end

function CooldownPanels:EntryUsesActivationOverlay(entry, resolvedType)
	if not entry then return false end
	return self:SupportsEntryCustomCooldownDuration(entry, resolvedType) or self:SupportsEntryCDMAuraOverlay(entry, resolvedType)
end

function CooldownPanels:ShouldTriggerEntryCustomCooldownDuration(entry, castSpellId)
	local castId = tonumber(castSpellId)
	if not (entry and castId) then return false end
	local macro = entry.type == "MACRO" and CooldownPanels.ResolveMacroEntry and CooldownPanels.ResolveMacroEntry(entry) or nil
	local resolvedType = (macro and macro.kind) or entry.type
	if not self:GetEntryCustomCooldownDuration(entry, resolvedType) then return false end
	if resolvedType == "SPELL" then
		local spellId = tonumber((macro and macro.spellID) or entry.spellID)
		if not spellId then return false end
		local aliases = self:GetSpellAliasIDs(spellId)
		for i = 1, #aliases do
			if aliases[i] == castId then return true end
		end
		return self:AreSpellVariantsEquivalent(spellId, castId)
	elseif resolvedType == "ITEM" then
		local itemId = tonumber((macro and macro.itemID) or entry.itemID)
		if itemId and entry.type == "ITEM" then itemId = self.ResolveEntryItemID(entry, itemId) end
		local itemSpellId = itemId and self:GetItemUseSpellID(itemId) or nil
		return itemSpellId and self:AreSpellVariantsEquivalent(itemSpellId, castId) or false
	elseif resolvedType == "SLOT" then
		local slotId = tonumber(entry.slotID)
		local itemId = slotId and Api.GetInventoryItemID and Api.GetInventoryItemID("player", slotId) or nil
		local itemSpellId = itemId and self:GetItemUseSpellID(itemId) or nil
		return itemSpellId and self:AreSpellVariantsEquivalent(itemSpellId, castId) or false
	end
	return false
end

function CooldownPanels:HandleCustomCooldownActivation(castSpellId)
	local spellId = tonumber(castSpellId)
	if not spellId then return false end
	local root = ensureRoot()
	local runtime = self.runtime
	local enabledPanels = runtime and runtime.enabledPanels
	if not (root and root.panels and enabledPanels and next(enabledPanels)) then return false end
	local panelsToRefresh = {}
	local started = false
	local panelIds = runtime and runtime.enabledPanelIds
	if panelIds and #panelIds > 0 then
		for i = 1, #panelIds do
			local panelId = panelIds[i]
			local panel = enabledPanels[panelId] and root.panels[panelId] or nil
			if panel and panel.entries then
				for _, entryId in ipairs(panel.order or {}) do
					local entry = panel.entries[entryId]
					if self:ShouldTriggerEntryCustomCooldownDuration(entry, spellId) then
						local macro = entry.type == "MACRO" and CooldownPanels.ResolveMacroEntry and CooldownPanels.ResolveMacroEntry(entry) or nil
						local resolvedType = (macro and macro.kind) or entry.type
						if self:StartEntryCustomCooldownDuration(panelId, entryId, entry, resolvedType) then
							panelsToRefresh[panelId] = true
							started = true
						end
					end
				end
			end
		end
	end
	for panelId in pairs(panelsToRefresh) do
		if self.RequestPanelRefresh then
			self:RequestPanelRefresh(panelId)
		elseif self:GetPanel(panelId) then
			self:RefreshPanel(panelId)
		end
	end
	return started
end

function CooldownPanels.IsSpellCooldownInfoActive(cooldownIsActive, cooldownEnabled, startTime, duration)
	if not (Api.issecretvalue and Api.issecretvalue(cooldownIsActive)) and type(cooldownIsActive) == "boolean" then return cooldownIsActive end
	if not isSafeNotFalse(cooldownEnabled) then return false end
	return isCooldownActive(startTime, duration)
end

function CooldownPanels.IsChargeInfoActive(chargesInfo)
	if type(chargesInfo) ~= "table" then return false end
	if not (Api.issecretvalue and Api.issecretvalue(chargesInfo.isActive)) and type(chargesInfo.isActive) == "boolean" then return chargesInfo.isActive end
	if Api.issecretvalue and (Api.issecretvalue(chargesInfo.currentCharges) or Api.issecretvalue(chargesInfo.maxCharges)) then return true end
	return isSafeLessThan(chargesInfo.currentCharges, chargesInfo.maxCharges)
end

local function getDurationRemaining(duration)
	if not duration then return nil end
	local remaining = duration.GetRemainingDuration(duration, Api.DurationModifierRealTime)
	if isSafeNumber(remaining) then return remaining end
	return nil
end

local function getSpellCooldownInfo(spellID)
	if not spellID or not Api.GetSpellCooldownInfo then return 0, 0, false, 1, nil, false end
	local a, b, c, d = Api.GetSpellCooldownInfo(spellID)
	if type(a) == "table" then
		local startTime = a.startTime or 0
		local duration = a.duration or 0
		local isEnabled = a.isEnabled
		local isOnGCD = type(a.isOnGCD) == "boolean" and a.isOnGCD or nil
		local isActive = type(a.isActive) == "boolean" and a.isActive or CooldownPanels.IsSpellCooldownInfoActive(nil, isEnabled, startTime, duration)
		return startTime, duration, isEnabled, a.modRate or 1, isOnGCD, isActive
	end
	local startTime = a or 0
	local duration = b or 0
	local isEnabled = c
	return startTime, duration, isEnabled, d or 1, nil, CooldownPanels.IsSpellCooldownInfoActive(nil, isEnabled, startTime, duration)
end

local function getGCDStateKey()
	local startTime, duration, enabled, _, isOnGCD, isActive = getSpellCooldownInfo(61304)
	local active = CooldownPanels.IsSpellCooldownInfoActive(isActive, enabled, startTime, duration)
	local comparableStart = isSafeNumber(startTime) and startTime or false
	local comparableDuration = isSafeNumber(duration) and duration or false
	return table.concat({
		tostring(active == true),
		tostring(isOnGCD == true),
		tostring(comparableStart),
		tostring(comparableDuration),
	}, "\031")
end

function CooldownPanels:UpdateGlobalGCDState()
	self.runtime = self.runtime or {}
	local runtime = self.runtime
	local nextKey = getGCDStateKey()
	local changed = runtime._eqolGlobalGCDStateKey == nil or runtime._eqolGlobalGCDStateKey ~= nextKey
	runtime._eqolGlobalGCDStateKey = nextKey
	return changed
end

local getSpellCooldownDurationObject

function CooldownPanels:EnsureSpellQueryCaches()
	self.runtime = self.runtime or {}
	local runtime = self.runtime
	runtime.spellCooldownDurationCache = runtime.spellCooldownDurationCache or {}
	runtime.spellCooldownInfoCache = runtime.spellCooldownInfoCache or {}
	runtime.spellChargesInfoCache = runtime.spellChargesInfoCache or {}
	runtime.spellPassStateCache = runtime.spellPassStateCache or {}
	return runtime
end

function CooldownPanels:BeginSpellQueryBatch()
	local runtime = self:EnsureSpellQueryCaches()
	local depth = (runtime.spellQueryBatchDepth or 0) + 1
	runtime.spellQueryBatchDepth = depth
	if depth == 1 then runtime.spellQueryPass = (runtime.spellQueryPass or 0) + 1 end
	return runtime.spellQueryPass
end

function CooldownPanels:EndSpellQueryBatch()
	local runtime = self.runtime
	if not runtime then return end
	local depth = tonumber(runtime.spellQueryBatchDepth) or 0
	if depth <= 1 then
		runtime.spellQueryBatchDepth = nil
	else
		runtime.spellQueryBatchDepth = depth - 1
	end
end

function CooldownPanels:EnsureSpellQueryPass()
	local runtime = self:EnsureSpellQueryCaches()
	if (runtime.spellQueryBatchDepth or 0) <= 0 then runtime.spellQueryPass = (runtime.spellQueryPass or 0) + 1 end
	return runtime.spellQueryPass
end

function CooldownPanels:BeginSpellQueryPass() return self:EnsureSpellQueryPass() end

function CooldownPanels:GetSpellPassState(spellId)
	if not spellId then return nil end
	local runtime = self:EnsureSpellQueryCaches()
	local pass = runtime.spellQueryPass
	if not pass then return nil end
	local cache = runtime.spellPassStateCache
	local state = cache[spellId]
	if state and state.pass == pass then return state end
	state = state or {}
	state.pass = pass
	state.infoLoaded = nil
	state.durationLoaded = nil
	state.chargesLoaded = nil
	state.chargeDurationLoaded = nil
	state.cooldownStart = nil
	state.cooldownDuration = nil
	state.cooldownEnabled = nil
	state.cooldownRate = nil
	state.cooldownGCD = nil
	state.cooldownIsActive = nil
	state.cooldownDurationObject = nil
	state.chargesInfo = nil
	state.chargeDurationObject = nil
	cache[spellId] = state
	return state
end

function CooldownPanels:BeginRuntimeQueryBatch()
	self:BeginSpellQueryBatch()
	local cdmAuras = self.CDMAuras
	if cdmAuras and cdmAuras.BeginRuntimePass then cdmAuras:BeginRuntimePass() end
end

function CooldownPanels:EndRuntimeQueryBatch()
	local cdmAuras = self.CDMAuras
	if cdmAuras and cdmAuras.EndRuntimePass then cdmAuras:EndRuntimePass() end
	self:EndSpellQueryBatch()
end

function CooldownPanels:IsRuntimeQueryBatchActive()
	local runtime = self.runtime
	return runtime and (runtime.spellQueryBatchDepth or 0) > 0 or false
end

function CooldownPanels:InvalidateSpellQueryCaches(kind, spellId)
	local runtime = self.runtime
	if not runtime then return end
	if kind == "duration" or kind == nil then
		if spellId ~= nil then
			runtime.spellCooldownDurationCache = runtime.spellCooldownDurationCache or {}
			runtime.spellCooldownDurationCache[spellId] = nil
		else
			runtime.spellCooldownDurationCache = {}
		end
	end
	if kind == "info" or kind == nil then
		if spellId ~= nil then
			runtime.spellCooldownInfoCache = runtime.spellCooldownInfoCache or {}
			runtime.spellCooldownInfoCache[spellId] = nil
		else
			runtime.spellCooldownInfoCache = {}
		end
	end
	if kind == "charges" or kind == nil then
		if spellId ~= nil then
			runtime.spellChargesInfoCache = runtime.spellChargesInfoCache or {}
			runtime.spellChargesInfoCache[spellId] = nil
		else
			runtime.spellChargesInfoCache = {}
		end
	end
	if kind == "chargeDuration" or kind == nil then
		if spellId ~= nil then
			runtime.spellChargeDurationCache = runtime.spellChargeDurationCache or {}
			runtime.spellChargeDurationCache[spellId] = nil
		else
			runtime.spellChargeDurationCache = {}
		end
	end
	if runtime.spellPassStateCache then
		if spellId ~= nil then
			runtime.spellPassStateCache[spellId] = nil
		elseif kind == nil or kind == "info" or kind == "duration" then
			runtime.spellPassStateCache = {}
		end
	end
end

function CooldownPanels:InvalidateSpellCooldownCachesForAliases(spellId)
	local id = tonumber(spellId)
	if not (id and self.runtime) then return false end
	local ids = self:GetSpellAliasIDs(id, {}, {})
	if not ids or #ids == 0 then return false end
	for i = 1, #ids do
		local aliasId = ids[i]
		self:InvalidateSpellQueryCaches("duration", aliasId)
		self:InvalidateSpellQueryCaches("info", aliasId)
		self:InvalidateSpellQueryCaches("charges", aliasId)
		self:InvalidateSpellQueryCaches("chargeDuration", aliasId)
	end
	return true
end

function CooldownPanels:GetCachedSpellCooldownDurationObject(spellId, ignoreGCD)
	if not spellId then return nil end
	local runtime = self:EnsureSpellQueryCaches()
	local pass = runtime.spellQueryPass
	if not pass then return getSpellCooldownDurationObject(spellId, ignoreGCD) end
	local cache = runtime.spellCooldownDurationCache
	local mode = ignoreGCD == true and "ignoreGCD" or "default"
	local cachedByMode = cache[spellId]
	if type(cachedByMode) ~= "table" then
		cachedByMode = {}
		cache[spellId] = cachedByMode
	end
	local cached = cachedByMode[mode]
	if cached and cached.pass == pass then return cached.value end
	cached = cached or {}
	cachedByMode[mode] = cached
	cached.pass = pass
	cached.value = getSpellCooldownDurationObject(spellId, ignoreGCD)
	return cached.value
end

function CooldownPanels:GetCachedSpellCooldownInfo(spellId, ignoreGCD)
	if not spellId then return 0, 0, false, 1, nil, false end
	local runtime = self:EnsureSpellQueryCaches()
	local pass = runtime.spellQueryPass
	if not pass then return getSpellCooldownInfo(spellId) end
	local cache = runtime.spellCooldownInfoCache
	local useIgnoreGCD = ignoreGCD == true and CooldownPanels._eqolSpellCooldownIgnoreGCDSupported == true
	local mode = useIgnoreGCD == true and "ignoreGCD" or "default"
	local cachedByMode = cache[spellId]
	if type(cachedByMode) ~= "table" then
		cachedByMode = {}
		cache[spellId] = cachedByMode
	end
	local cached = cachedByMode[mode]
	if cached and cached.pass == pass then return cached.startTime, cached.duration, cached.enabled, cached.modRate, cached.isOnGCD, cached.isActive end
	cached = cached or {}
	cachedByMode[mode] = cached
	cached.pass = pass
	if useIgnoreGCD == true then
		cached.startTime, cached.duration, cached.enabled, cached.modRate, cached.isOnGCD, cached.isActive =
			self:GetCachedSpellCooldownInfo(spellId, false)
		if cached.isOnGCD == true then
			cached.startTime = 0
			cached.duration = 0
			cached.isActive = false
			cached.isOnGCD = false
		end
	else
		cached.startTime, cached.duration, cached.enabled, cached.modRate, cached.isOnGCD, cached.isActive = getSpellCooldownInfo(spellId)
	end
	return cached.startTime, cached.duration, cached.enabled, cached.modRate, cached.isOnGCD, cached.isActive
end

function CooldownPanels:GetCachedSpellChargesInfo(spellId)
	if not spellId or not Api.GetSpellChargesInfo then return nil end
	local runtime = self:EnsureSpellQueryCaches()
	local pass = runtime.spellQueryPass
	if not pass then return Api.GetSpellChargesInfo(spellId) end
	local cache = runtime.spellChargesInfoCache
	local cached = cache[spellId]
	if cached and cached.pass == pass then return cached.value end
	cached = cached or {}
	cache[spellId] = cached
	cached.pass = pass
	cached.value = Api.GetSpellChargesInfo(spellId)
	return cached.value
end

function CooldownPanels:GetCachedSpellChargeDurationObject(spellId)
	if not spellId or not (C_Spell and C_Spell.GetSpellChargeDuration) then return nil end
	local runtime = self:EnsureSpellQueryCaches()
	local pass = runtime.spellQueryPass
	if not pass then return C_Spell.GetSpellChargeDuration(spellId) end
	local cache = runtime.spellChargeDurationCache
	if not cache then
		cache = {}
		runtime.spellChargeDurationCache = cache
	end
	local cached = cache[spellId]
	if cached and cached.pass == pass then return cached.value end
	cached = cached or {}
	cache[spellId] = cached
	cached.pass = pass
	cached.value = C_Spell.GetSpellChargeDuration(spellId)
	return cached.value
end

local function getItemCooldownInfo(itemID, slotID)
	local start, duration, enabled
	if slotID and Api.GetInventoryItemCooldown then
		start, duration, enabled = Api.GetInventoryItemCooldown("player", slotID)
		if not (start and duration) then
			start, duration, enabled = nil, nil, nil
		end
	end
	if (start == nil or duration == nil) and itemID and Api.GetItemCooldownFn then
		start, duration, enabled = Api.GetItemCooldownFn(itemID)
	end
	start = start or 0
	duration = duration or 0

	-- Item/slot cooldown queries can briefly collapse to the player's GCD.
	-- If the linked use spell has its own real cooldown running, prefer that timer.
	if itemID and CooldownPanels:IsCooldownMatchingGlobalCooldown(start, duration) then
		local spellId = CooldownPanels:GetItemUseSpellID(itemID)
		if spellId then
			local spellStart, spellDuration = getSpellCooldownInfo(spellId)
			if isCooldownActive(spellStart, spellDuration) and not CooldownPanels:IsCooldownMatchingGlobalCooldown(spellStart, spellDuration) then return spellStart or 0, spellDuration or 0, true end
		end
	end

	return start, duration, enabled
end

function CooldownPanels:GetItemUseSpellID(itemID)
	if not itemID then return nil end
	self.runtime = self.runtime or {}
	local runtime = self.runtime
	runtime.itemUseSpellCache = runtime.itemUseSpellCache or {}
	local cached = runtime.itemUseSpellCache[itemID]
	if cached ~= nil then return cached or nil end
	if not Api.GetItemSpell then return nil end
	local _, spellId = Api.GetItemSpell(itemID)
	spellId = tonumber(spellId)
	if spellId then runtime.itemUseSpellCache[itemID] = spellId end
	return spellId
end

function CooldownPanels:IsCooldownMatchingGlobalCooldown(cooldownStart, cooldownDuration)
	if not isCooldownActive(cooldownStart, cooldownDuration) then return false end
	local gcdStart, gcdDuration = getSpellCooldownInfo(61304)
	if not isCooldownActive(gcdStart, gcdDuration) then return false end
	return cooldownStart == gcdStart and cooldownDuration == gcdDuration
end

function CooldownPanels:IsItemCooldownOnGCD(itemID, cooldownStart, cooldownDuration)
	if not self:IsCooldownMatchingGlobalCooldown(cooldownStart, cooldownDuration) then return false end
	local spellId = self:GetItemUseSpellID(itemID)
	if not spellId then return true end

	local spellStart, spellDuration = getSpellCooldownInfo(spellId)

	-- A linked use spell being on cooldown is only relevant if its timer is separate from the GCD.
	if isCooldownActive(spellStart, spellDuration) and not self:IsCooldownMatchingGlobalCooldown(spellStart, spellDuration) then return false end

	return true
end

getSpellCooldownDurationObject = function(spellID, ignoreGCD)
	if not spellID or not Api.GetSpellCooldownDuration then return nil end
	if ignoreGCD == true and CooldownPanels._eqolSpellCooldownIgnoreGCDSupported == true then return Api.GetSpellCooldownDuration(spellID, true) end
	return Api.GetSpellCooldownDuration(spellID)
end

local function hasItem(itemID)
	if not itemID then return false end
	if Api.IsEquippedItem and Api.IsEquippedItem(itemID) then return true end
	local count = Api.GetItemCount(itemID, false, false)
	if count and count > 0 then return true end
	return false
end

local function itemHasUseSpell(itemID) return CooldownPanels.GetItemUseSpellID and CooldownPanels:GetItemUseSpellID(itemID) ~= nil or false end

local function createPanelFrame(panelId, panel)
	local frame = CreateFrame("Button", "EQOL_CooldownPanel" .. tostring(panelId), UIParent)
	frame:SetClampedToScreen(true)
	frame:SetMovable(true)
	frame:EnableMouse(false)
	frame.panelId = panelId
	frame.icons = {}
	frame._eqolPanelFrame = true

	local bg = frame:CreateTexture(nil, "BACKGROUND")
	bg:SetAllPoints(frame)
	bg:SetColorTexture(0.1, 0.6, 0.6, 0.2)
	bg:Hide()
	frame.bg = bg

	local label = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
	label:SetPoint("CENTER")
	label:SetText(panel and panel.name or L["cooldownPanelDefaultName"])
	label:Hide()
	frame.label = label

	local editDropZone = CreateFrame("Button", nil, frame)
	editDropZone:SetAllPoints(frame)
	editDropZone:SetFrameLevel(frame:GetFrameLevel())
	editDropZone:EnableMouse(false)
	editDropZone:RegisterForClicks("LeftButtonUp")
	editDropZone:SetScript("OnReceiveDrag", function(self)
		if not (CooldownPanels and CooldownPanels:IsPanelLayoutEditActive(self.panelId)) then return end
		if CooldownPanels:HandleCursorDrop(self.panelId) then
			CooldownPanels:RefreshPanel(self.panelId)
			if CooldownPanels:IsEditorOpen() then CooldownPanels:RefreshEditor() end
		end
	end)
	editDropZone:SetScript("OnMouseUp", function(self, btn)
		if btn ~= "LeftButton" then return end
		if not (CooldownPanels and CooldownPanels:IsPanelLayoutEditActive(self.panelId)) then return end
		if CooldownPanels:HandleCursorDrop(self.panelId) then
			CooldownPanels:RefreshPanel(self.panelId)
			if CooldownPanels:IsEditorOpen() then CooldownPanels:RefreshEditor() end
			return
		end
		CooldownPanels:SelectPanel(self.panelId)
	end)
	editDropZone:Hide()
	editDropZone.panelId = panelId
	frame.editDropZone = editDropZone

	local editGrid = CreateFrame("Frame", nil, frame)
	editGrid:SetAllPoints(frame)
	editGrid:SetFrameLevel((frame:GetFrameLevel() or 0) + 1)
	editGrid:EnableMouse(false)
	editGrid.cells = {}
	editGrid:Hide()
	frame.editGrid = editGrid

	local editPanelHandle = CreateFrame("Button", nil, frame, "BackdropTemplate")
	editPanelHandle:SetSize(72, 16)
	editPanelHandle:SetPoint("BOTTOM", frame, "TOP", 0, 4)
	editPanelHandle:SetBackdrop({
		bgFile = "Interface\\Buttons\\WHITE8x8",
		edgeFile = "Interface\\Buttons\\WHITE8x8",
		edgeSize = 1,
	})
	editPanelHandle:SetBackdropColor(0, 0, 0, 0.72)
	editPanelHandle:SetBackdropBorderColor(0.95, 0.82, 0.25, 0.95)
	editPanelHandle:RegisterForClicks("LeftButtonUp")
	editPanelHandle:RegisterForDrag("LeftButton")
	editPanelHandle:EnableMouse(false)
	editPanelHandle:Hide()
	editPanelHandle.panelId = panelId
	editPanelHandle.label = editPanelHandle:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
	editPanelHandle.label:SetPoint("CENTER")
	editPanelHandle.label:SetText(L["Panel"] or "Panel")
	editPanelHandle.label:SetTextColor(1, 0.86, 0.24, 1)
	editPanelHandle:SetScript("OnMouseDown", function(self, btn)
		if btn ~= "LeftButton" then return end
		self._eqolDragged = nil
		CooldownPanels:SelectPanel(self.panelId)
	end)
	editPanelHandle:SetScript("OnClick", function(self, btn)
		if btn ~= "LeftButton" then return end
		if self._eqolDragged then
			self._eqolDragged = nil
			return
		end
		if not (CooldownPanels and CooldownPanels:IsPanelLayoutEditActive(self.panelId)) then return end
		CooldownPanels:SelectPanel(self.panelId)
		CooldownPanels:OpenLayoutPanelStandaloneMenu(self.panelId, self)
	end)
	editPanelHandle:SetScript("OnDragStart", function(self)
		if not (CooldownPanels and CooldownPanels:IsPanelLayoutEditActive(self.panelId)) then return end
		self._eqolDragged = CooldownPanels:BeginStandalonePanelDrag(self.panelId) and true or nil
		CooldownPanels:SelectPanel(self.panelId)
	end)
	editPanelHandle:SetScript("OnDragStop", function(self)
		if not (CooldownPanels and CooldownPanels:IsPanelLayoutEditActive(self.panelId)) then return end
		CooldownPanels:FinishStandalonePanelDrag(self.panelId)
	end)
	frame.editPanelHandle = editPanelHandle

	return frame
end

function CooldownPanels.PanelHasUngroupedEntryInRect(panel, column, row, columns, rows)
	if not panel then return false end
	for _, entry in pairs(panel.entries or {}) do
		if entry and Helper.NormalizeFixedGroupId(entry.fixedGroupId) == nil then
			local slotColumn = Helper.NormalizeSlotCoordinate(entry.slotColumn)
			local slotRow = Helper.NormalizeSlotCoordinate(entry.slotRow)
			if CooldownPanels.RectContainsCell(column, row, columns, rows, slotColumn, slotRow) then return true end
		end
	end
	return false
end

function CooldownPanels.ClearFixedGroupDragState(runtime)
	if not runtime then return end
	local frame = runtime.frame
	local grid = frame and frame.editGrid or nil
	if grid and grid:GetScript("OnUpdate") then grid:SetScript("OnUpdate", nil) end
	runtime._eqolFixedGroupDrag = nil
end

function CooldownPanels:GetFixedGroupDragRect(panelId)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local runtime = panelId and getRuntime(panelId) or nil
	local drag = runtime and runtime._eqolFixedGroupDrag or nil
	if not (panel and drag and Helper.IsFixedLayout(panel.layout)) then return nil end
	local currentColumn = Helper.NormalizeSlotCoordinate(drag.currentColumn or drag.startColumn)
	local currentRow = Helper.NormalizeSlotCoordinate(drag.currentRow or drag.startRow)
	if drag.mode == "create" then
		local startColumn = Helper.NormalizeSlotCoordinate(drag.startColumn)
		local startRow = Helper.NormalizeSlotCoordinate(drag.startRow)
		if not (startColumn and startRow and currentColumn and currentRow) then return nil end
		local column = math.min(startColumn, currentColumn)
		local row = math.min(startRow, currentRow)
		local columns = math.abs(currentColumn - startColumn) + 1
		local rows = math.abs(currentRow - startRow) + 1
		return {
			mode = "create",
			column = column,
			row = row,
			columns = columns,
			rows = rows,
			valid = self:CanCreateFixedGroup(panelId, column, row, columns, rows),
		}
	elseif drag.mode == "move" then
		local group = CooldownPanels.GetFixedGroupById(panel, drag.groupId)
		if not (group and currentColumn and currentRow) then return nil end
		local column = currentColumn - ((drag.anchorColumn or 1) - 1)
		local row = currentRow - ((drag.anchorRow or 1) - 1)
		local gridColumns, gridRows = Helper.GetFixedGridBounds(panel, false)
		local valid = column >= 1
			and row >= 1
			and gridColumns > 0
			and gridRows > 0
			and (column + group.columns - 1) <= gridColumns
			and (row + group.rows - 1) <= gridRows
			and not self:DoesFixedGroupRectOverlap(panel, column, row, group.columns, group.rows, group.id)
		if valid and CooldownPanels.PanelHasUngroupedEntryInRect(panel, column, row, group.columns, group.rows) then valid = false end
		return {
			mode = "move",
			groupId = group.id,
			column = column,
			row = row,
			columns = group.columns,
			rows = group.rows,
			valid = valid,
		}
	end
	return nil
end

function CooldownPanels:CancelFixedGroupDrag(panelId)
	panelId = normalizeId(panelId)
	local runtime = panelId and getRuntime(panelId) or nil
	if not runtime or not runtime._eqolFixedGroupDrag then return false end
	CooldownPanels.ClearFixedGroupDragState(runtime)
	local panel = self:GetPanel(panelId)
	local slotCount = panel and Helper.GetFixedSlotCount(panel, false) or 0
	self:UpdateLayoutEditGrid(panelId, slotCount)
	return true
end

function CooldownPanels:EnableFixedGroupDragTracking(panelId)
	panelId = normalizeId(panelId)
	local runtime = panelId and getRuntime(panelId) or nil
	local frame = runtime and runtime.frame or nil
	local grid = frame and frame.editGrid or nil
	if not (runtime and grid) then return false end
	grid:SetScript("OnUpdate", function()
		local activeRuntime = getRuntime(panelId)
		if not (activeRuntime and activeRuntime._eqolFixedGroupDrag) then
			grid:SetScript("OnUpdate", nil)
			return
		end
		local targetSlot = CooldownPanels:GetLayoutEditCursorSlot(panelId)
		if targetSlot then CooldownPanels:UpdateFixedGroupDragTarget(panelId, targetSlot.column, targetSlot.row) end
	end)
	return true
end

function CooldownPanels:StartFixedGroupDraw(panelId, column, row)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local runtime = panelId and getRuntime(panelId) or nil
	column = Helper.NormalizeSlotCoordinate(column)
	row = Helper.NormalizeSlotCoordinate(row)
	if not (panel and runtime and column and row and Helper.IsFixedLayout(panel.layout)) then return false end
	runtime._eqolFixedGroupDrag = {
		mode = "create",
		startColumn = column,
		startRow = row,
		currentColumn = column,
		currentRow = row,
	}
	self:EnableFixedGroupDragTracking(panelId)
	self:UpdateLayoutEditGrid(panelId, Helper.GetFixedSlotCount(panel, false))
	return true
end

function CooldownPanels:StartFixedGroupMove(panelId, groupId, column, row)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local runtime = panelId and getRuntime(panelId) or nil
	column = Helper.NormalizeSlotCoordinate(column)
	row = Helper.NormalizeSlotCoordinate(row)
	local group = panel and CooldownPanels.GetFixedGroupById(panel, groupId) or nil
	if not (panel and runtime and group and column and row and Helper.IsFixedLayout(panel.layout)) then return false end
	local anchorColumn = math.max(1, math.min(group.columns, column - group.column + 1))
	local anchorRow = math.max(1, math.min(group.rows, row - group.row + 1))
	runtime._eqolFixedGroupDrag = {
		mode = "move",
		groupId = group.id,
		startColumn = column,
		startRow = row,
		currentColumn = column,
		currentRow = row,
		anchorColumn = anchorColumn,
		anchorRow = anchorRow,
	}
	self:EnableFixedGroupDragTracking(panelId)
	self:UpdateLayoutEditGrid(panelId, Helper.GetFixedSlotCount(panel, false))
	return true
end

function CooldownPanels:InvalidateLayoutEditGrid(panelId)
	panelId = normalizeId(panelId)
	local runtime = panelId and getRuntime(panelId) or nil
	if not runtime then return end
	runtime._eqolLayoutEditGridCount = nil
	runtime._eqolLayoutEditGridActive = nil
	runtime._eqolLayoutEditGridDirty = true
end

function CooldownPanels:UpdateFixedGroupDragTarget(panelId, column, row)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local runtime = panelId and getRuntime(panelId) or nil
	local drag = runtime and runtime._eqolFixedGroupDrag or nil
	column = Helper.NormalizeSlotCoordinate(column)
	row = Helper.NormalizeSlotCoordinate(row)
	if not (panel and drag and column and row) then return false end
	if drag.currentColumn == column and drag.currentRow == row then return false end
	drag.currentColumn = column
	drag.currentRow = row
	self:UpdateLayoutEditGrid(panelId, Helper.GetFixedSlotCount(panel, false))
	return true
end

function CooldownPanels:FinishFixedGroupDrag(panelId)
	panelId = normalizeId(panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local runtime = panelId and getRuntime(panelId) or nil
	local drag = runtime and runtime._eqolFixedGroupDrag or nil
	if not (panel and runtime and drag) then return false end
	local rect = self:GetFixedGroupDragRect(panelId)
	CooldownPanels.ClearFixedGroupDragState(runtime)
	self:UpdateLayoutEditGrid(panelId, Helper.GetFixedSlotCount(panel, false))
	if not (rect and rect.valid) then return false end
	if drag.mode == "create" then
		local groupId = self:CreateFixedGroup(panelId, rect.column, rect.row, rect.columns, rect.rows)
		if groupId then
			clearRuntimeLayoutShapeCache(runtime)
			self:RefreshPanelForCurrentEditContext(panelId, true)
			return true
		end
	elseif drag.mode == "move" then
		local moved, reason = self:MoveFixedGroup(panelId, drag.groupId, rect.column, rect.row)
		if moved then
			clearRuntimeLayoutShapeCache(runtime)
			self:RefreshPanelForCurrentEditContext(panelId, true)
			return true
		end
	end
	return false
end

function CooldownPanels:UpdateLayoutEditGrid(panelId, slotCount)
	local runtime = getRuntime(panelId)
	local frame = runtime and runtime.frame
	local panel = self:GetPanel(panelId)
	local grid = frame and frame.editGrid
	local showGrid = grid and panel and self:IsPanelLayoutEditActive(panelId) and Helper.IsFixedLayout(panel.layout)
	if not grid then return end
	slotCount = tonumber(slotCount) or 0
	if not showGrid or slotCount < 1 then
		grid:Hide()
		for i = 1, #(grid.cells or {}) do
			local cell = grid.cells[i]
			if cell then cell:Hide() end
		end
		return
	end
	grid:Show()
	grid.cells = grid.cells or {}
	local fixedLayoutCache = Helper.GetFixedLayoutCache and Helper.GetFixedLayoutCache(panel) or nil
	local gridColumns = fixedLayoutCache and fixedLayoutCache.boundsColumns or select(1, Helper.GetFixedGridBounds(panel, false))
	local groups = CooldownPanels.GetFixedGroups(panel)
	for i = 1, slotCount do
		local icon = frame.icons and frame.icons[i]
		local cell = grid.cells[i]
		if not cell then
			cell = CreateFrame("Frame", nil, grid, "BackdropTemplate")
			cell:SetBackdrop({
				bgFile = "Interface\\Buttons\\WHITE8x8",
				edgeFile = "Interface\\Buttons\\WHITE8x8",
				edgeSize = 1,
			})
			cell:SetBackdropColor(0.08, 0.2, 0.24, 0.08)
			cell:SetBackdropBorderColor(1, 1, 1, 0.14)
			cell:EnableMouse(false)
			grid.cells[i] = cell
		end
		if icon then
			local cellAnchor = icon.slotAnchor or icon
			local cellColumn = gridColumns and gridColumns > 0 and (((i - 1) % gridColumns) + 1) or i
			local cellRow = gridColumns and gridColumns > 0 and (math.floor((i - 1) / gridColumns) + 1) or 1
			local group = CooldownPanels.GetFixedGroupAtCell(panel, cellColumn, cellRow)
			cell:ClearAllPoints()
			cell:SetPoint("TOPLEFT", cellAnchor, "TOPLEFT", 0, 0)
			cell:SetPoint("BOTTOMRIGHT", cellAnchor, "BOTTOMRIGHT", 0, 0)
			cell._eqolLayoutSlotColumn = cellColumn
			cell._eqolLayoutSlotRow = cellRow
			if group then
				if CooldownPanels.IsFixedGroupStatic(group) then
					cell:SetBackdropColor(0.15, 0.22, 0.34, 0.18)
					cell:SetBackdropBorderColor(0.42, 0.62, 0.92, 0.32)
				else
					cell:SetBackdropColor(0.18, 0.28, 0.34, 0.16)
					cell:SetBackdropBorderColor(0.8, 0.68, 0.22, 0.28)
				end
			else
				cell:SetBackdropColor(0.08, 0.2, 0.24, 0.08)
				cell:SetBackdropBorderColor(1, 1, 1, 0.14)
			end
			cell:Show()
		else
			cell._eqolLayoutSlotColumn = nil
			cell._eqolLayoutSlotRow = nil
			cell:Hide()
		end
	end
	for i = slotCount + 1, #grid.cells do
		local cell = grid.cells[i]
		if cell then
			cell._eqolLayoutSlotColumn = nil
			cell._eqolLayoutSlotRow = nil
			cell:Hide()
		end
	end

	grid.groupOverlays = grid.groupOverlays or {}
	for i = 1, #groups do
		local group = groups[i]
		local overlay = grid.groupOverlays[i]
		if not overlay then
			overlay = CreateFrame("Frame", nil, grid, "BackdropTemplate")
			overlay:SetBackdrop({
				bgFile = "Interface\\Buttons\\WHITE8x8",
				edgeFile = "Interface\\Buttons\\WHITE8x8",
				edgeSize = 1,
			})
			overlay:SetBackdropColor(0.85, 0.72, 0.22, 0.06)
			overlay:SetBackdropBorderColor(0.9, 0.78, 0.24, 0.55)
			overlay:EnableMouse(false)
			overlay.label = overlay:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
			overlay.label:SetPoint("TOPLEFT", overlay, "TOPLEFT", 4, -3)
			overlay.label:SetTextColor(1, 0.88, 0.34, 0.9)
			grid.groupOverlays[i] = overlay
		end
		local startIndex = CooldownPanels.GetFixedGridCellIndex(panel, group.column, group.row)
		local endIndex = CooldownPanels.GetFixedGridCellIndex(panel, group.column + group.columns - 1, group.row + group.rows - 1)
		local startIcon = startIndex and frame.icons and frame.icons[startIndex] or nil
		local endIcon = endIndex and frame.icons and frame.icons[endIndex] or nil
		if startIcon and endIcon then
			local startAnchor = startIcon.slotAnchor or startIcon
			local endAnchor = endIcon.slotAnchor or endIcon
			overlay:ClearAllPoints()
			overlay:SetPoint("TOPLEFT", startAnchor, "TOPLEFT", 1, -1)
			overlay:SetPoint("BOTTOMRIGHT", endAnchor, "BOTTOMRIGHT", -1, 1)
			if CooldownPanels.IsFixedGroupStatic(group) then
				overlay:SetBackdropColor(0.35, 0.52, 0.92, 0.08)
				overlay:SetBackdropBorderColor(0.45, 0.64, 0.95, 0.55)
				overlay.label:SetTextColor(0.78, 0.86, 1, 0.95)
			else
				overlay:SetBackdropColor(0.85, 0.72, 0.22, 0.06)
				overlay:SetBackdropBorderColor(0.9, 0.78, 0.24, 0.55)
				overlay.label:SetTextColor(1, 0.88, 0.34, 0.9)
			end
			overlay.label:SetText(CooldownPanels.GetFixedGroupDisplayLabel(group))
			overlay:Show()
		else
			overlay:Hide()
		end
	end
	for i = #groups + 1, #(grid.groupOverlays or {}) do
		local overlay = grid.groupOverlays[i]
		if overlay then overlay:Hide() end
	end

	if not grid.selectionOverlay then
		local overlay = CreateFrame("Frame", nil, grid, "BackdropTemplate")
		overlay:SetBackdrop({
			bgFile = "Interface\\Buttons\\WHITE8x8",
			edgeFile = "Interface\\Buttons\\WHITE8x8",
			edgeSize = 1,
		})
		overlay:EnableMouse(false)
		overlay:Hide()
		grid.selectionOverlay = overlay
	end
	local dragRect = CooldownPanels:GetFixedGroupDragRect(panelId)
	if dragRect then
		local startIndex = CooldownPanels.GetFixedGridCellIndex(panel, dragRect.column, dragRect.row)
		local endIndex = CooldownPanels.GetFixedGridCellIndex(panel, dragRect.column + dragRect.columns - 1, dragRect.row + dragRect.rows - 1)
		local startIcon = startIndex and frame.icons and frame.icons[startIndex] or nil
		local endIcon = endIndex and frame.icons and frame.icons[endIndex] or nil
		if startIcon and endIcon then
			local startAnchor = startIcon.slotAnchor or startIcon
			local endAnchor = endIcon.slotAnchor or endIcon
			grid.selectionOverlay:ClearAllPoints()
			grid.selectionOverlay:SetPoint("TOPLEFT", startAnchor, "TOPLEFT", 1, -1)
			grid.selectionOverlay:SetPoint("BOTTOMRIGHT", endAnchor, "BOTTOMRIGHT", -1, 1)
			if dragRect.valid then
				grid.selectionOverlay:SetBackdropColor(0.2, 0.7, 0.3, 0.14)
				grid.selectionOverlay:SetBackdropBorderColor(0.2, 0.85, 0.35, 0.9)
			else
				grid.selectionOverlay:SetBackdropColor(0.8, 0.15, 0.15, 0.12)
				grid.selectionOverlay:SetBackdropBorderColor(0.9, 0.2, 0.2, 0.9)
			end
			grid.selectionOverlay:Show()
		else
			grid.selectionOverlay:Hide()
		end
	else
		grid.selectionOverlay:Hide()
	end
end

local function getGridDimensions(count, wrapCount, primaryHorizontal)
	if count < 1 then count = 1 end
	if wrapCount and wrapCount > 0 then
		if primaryHorizontal then
			local cols = math.min(count, wrapCount)
			local rows = math.floor((count + wrapCount - 1) / wrapCount)
			return cols, rows
		end
		local rows = math.min(count, wrapCount)
		local cols = math.floor((count + wrapCount - 1) / wrapCount)
		return cols, rows
	end
	if primaryHorizontal then return count, 1 end
	return 1, count
end

local function getPanelRowCount(panel, layout)
	if not panel or not layout then return 1, true end
	local count
	if Helper.IsFixedLayout(layout) then
		local _, rows = Helper.GetFixedGridBounds(panel, false)
		return rows > 0 and rows or 1, true
	else
		count = panel.order and #panel.order or 0
	end
	if count < 1 then count = 1 end
	local wrapCount = Helper.ClampInt(layout.wrapCount, 0, 40, Helper.PANEL_LAYOUT_DEFAULTS.wrapCount or 0)
	local direction = Helper.NormalizeDirection(layout.direction, Helper.PANEL_LAYOUT_DEFAULTS.direction)
	local primaryHorizontal = direction == "LEFT" or direction == "RIGHT"
	local _, rows = getGridDimensions(count, wrapCount, primaryHorizontal)
	return rows, primaryHorizontal
end

local function containsId(list, id)
	if type(list) ~= "table" then return false end
	for _, value in ipairs(list) do
		if value == id then return true end
	end
	return false
end

local function setCooldownDrawState(cooldown, drawEdge, drawBling, drawSwipe)
	if not cooldown then return end
	if cooldown.SetDrawEdge and cooldown._eqolDrawEdge ~= drawEdge then
		cooldown:SetDrawEdge(drawEdge)
		cooldown._eqolDrawEdge = drawEdge
	end
	if cooldown.SetDrawBling and cooldown._eqolDrawBling ~= drawBling then
		cooldown:SetDrawBling(drawBling)
		cooldown._eqolDrawBling = drawBling
	end
	if cooldown.SetDrawSwipe and cooldown._eqolDrawSwipe ~= drawSwipe then
		cooldown:SetDrawSwipe(drawSwipe)
		cooldown._eqolDrawSwipe = drawSwipe
	end
end

function cdp.ENTRY.GetCooldownSwipeColor(data)
	if data and data.activationOverlayActive == true then
		local color = Helper.NormalizeColor(data.activationOverlayColor, Helper.ACTIVATION_OVERLAY_COLOR_DEFAULT)
		return color[1], color[2], color[3], color[4]
	end
	if data and data.resolvedType == "CDM_AURA" then return 0, 0, 0, 0.7 end
	return 1, 1, 1, 1
end

function cdp.ENTRY.ApplyCooldownSwipeVisual(icon, data)
	if not (icon and icon.cooldown) then return end
	local r, g, b, a = cdp.ENTRY.GetCooldownSwipeColor(data)
	local needsCustomSwipeColor = data and (data.activationOverlayActive == true or data.resolvedType == "CDM_AURA") or false
	if icon.cooldown.SetSwipeTexture and icon._eqolBlizzardSwipeTextureApplied then
		if icon._eqolSwipeTextureR ~= r or icon._eqolSwipeTextureG ~= g or icon._eqolSwipeTextureB ~= b or icon._eqolSwipeTextureA ~= a then
			local ok = pcall(icon.cooldown.SetSwipeTexture, icon.cooldown, cdp.ICON_BORDER.SWIPE_TEXTURE, r, g, b, a)
			if ok then
				icon._eqolSwipeTextureR, icon._eqolSwipeTextureG, icon._eqolSwipeTextureB, icon._eqolSwipeTextureA = r, g, b, a
			end
		end
	end
	if needsCustomSwipeColor and icon.cooldown.SetSwipeColor then
		if icon._eqolSwipeColorR ~= r or icon._eqolSwipeColorG ~= g or icon._eqolSwipeColorB ~= b or icon._eqolSwipeColorA ~= a then
			icon.cooldown:SetSwipeColor(r, g, b, a)
			icon._eqolSwipeColorR, icon._eqolSwipeColorG, icon._eqolSwipeColorB, icon._eqolSwipeColorA = r, g, b, a
		end
	elseif not needsCustomSwipeColor then
		if icon.cooldown.SetSwipeColor and icon._eqolSwipeColorR ~= nil then icon.cooldown:SetSwipeColor(0, 0, 0, 0.8) end
		icon._eqolSwipeColorR, icon._eqolSwipeColorG, icon._eqolSwipeColorB, icon._eqolSwipeColorA = nil, nil, nil, nil
	end
	if needsCustomSwipeColor and icon.cooldown.SetSwipeTexture and not icon._eqolBlizzardSwipeTextureApplied then
		local ok = pcall(icon.cooldown.SetSwipeTexture, icon.cooldown, cdp.ICON_BORDER.SWIPE_TEXTURE, r, g, b, a)
		if ok then
			icon._eqolBlizzardSwipeTextureApplied = true
			icon._eqolSwipeTextureR, icon._eqolSwipeTextureG, icon._eqolSwipeTextureB, icon._eqolSwipeTextureA = r, g, b, a
		end
	end
end

function cdp.ENTRY.EnsureBlizzardIconOverlay(icon)
	if not icon then return nil end
	local overlay = icon.blizzardIconOverlay
	if not overlay then
		overlay = icon:CreateTexture(nil, "OVERLAY", nil, 2)
		overlay:SetAtlas(cdp.ICON_BORDER.OVERLAY_ATLAS, false)
		overlay:Hide()
		icon.blizzardIconOverlay = overlay
	end
	return overlay
end

function cdp.ENTRY.ClearBlizzardIconSkin(icon)
	if not icon then return end
	if icon.blizzardIconOverlay then icon.blizzardIconOverlay:Hide() end
	if icon._eqolBlizzardMaskApplied and icon.texture and icon.blizzardIconMask and icon.texture.RemoveMaskTexture then
		pcall(icon.texture.RemoveMaskTexture, icon.texture, icon.blizzardIconMask)
	end
	icon._eqolBlizzardMaskApplied = nil
	if (icon._eqolBlizzardIconTextureInsetApplied or icon._eqolBlizzardIconTextureAnchored) and icon.texture then
		icon.texture:ClearAllPoints()
		icon.texture:SetAllPoints(icon)
	end
	icon._eqolBlizzardIconTextureInsetApplied = nil
	icon._eqolBlizzardIconTextureAnchored = nil
	icon._eqolBlizzardIconBottomInset = nil
	if (icon._eqolBlizzardCooldownInsetApplied or icon._eqolBlizzardCooldownAnchored) and icon.cooldown then
		icon.cooldown:ClearAllPoints()
		icon.cooldown:SetAllPoints(icon)
	end
	icon._eqolBlizzardCooldownInsetApplied = nil
	icon._eqolBlizzardCooldownAnchored = nil
	icon._eqolBlizzardCooldownInset = nil
	icon._eqolBlizzardCooldownWidth = nil
	icon._eqolBlizzardCooldownHeight = nil
	icon._eqolBlizzardSwipeTextureApplied = nil
end

function cdp.ENTRY.ApplyBlizzardIconSkin(icon)
	if not icon then return end
	local overlay = cdp.ENTRY.EnsureBlizzardIconOverlay(icon)
	local width = tonumber(icon:GetWidth()) or 0
	local height = tonumber(icon:GetHeight()) or 0
	if width <= 0 then width = tonumber(icon._eqolBaseSlotSize) or Helper.PANEL_LAYOUT_DEFAULTS.iconSize or 36 end
	if height <= 0 then height = width end
	if overlay then
		local offsetX = math.max(1, math.floor((width * cdp.ICON_BORDER.OVERLAY_OFFSET_X_RATIO) + 0.5))
		local offsetY = math.max(1, math.floor((height * cdp.ICON_BORDER.OVERLAY_OFFSET_Y_RATIO) + 0.5))
		local offsetXNudge = cdp.ICON_BORDER.OVERLAY_OFFSET_X_NUDGE or 0
		overlay:ClearAllPoints()
		overlay:SetPoint("TOPLEFT", icon, "TOPLEFT", -offsetX + offsetXNudge, offsetY)
		overlay:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", offsetX + offsetXNudge, -offsetY)
		overlay:SetAlpha(1)
		overlay:Show()
	end

	if icon.texture then
		local iconBottomInset = cdp.ICON_BORDER.ICON_BOTTOM_INSET
		if icon._eqolBlizzardIconBottomInset ~= iconBottomInset then
			icon.texture:ClearAllPoints()
			icon.texture:SetPoint("TOPLEFT", icon, "TOPLEFT", 0, 0)
			icon.texture:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", 0, iconBottomInset)
			icon._eqolBlizzardIconBottomInset = iconBottomInset
			icon._eqolBlizzardIconTextureInsetApplied = true
			icon._eqolBlizzardIconTextureAnchored = nil
		end
	end

	if icon.texture and icon.texture.AddMaskTexture and icon.CreateMaskTexture then
		if not icon.blizzardIconMask then
			local mask = icon:CreateMaskTexture(nil, "ARTWORK")
			mask:SetAtlas(cdp.ICON_BORDER.MASK_ATLAS, false)
			mask:SetAllPoints(icon)
			icon.blizzardIconMask = mask
		end
		if not icon._eqolBlizzardMaskApplied then
			local ok = pcall(icon.texture.AddMaskTexture, icon.texture, icon.blizzardIconMask)
			icon._eqolBlizzardMaskApplied = ok == true
		end
	end

	if icon.cooldown then
		local size = math.min(width, height)
		local inset = math.max(1, math.floor((size * cdp.ICON_BORDER.COOLDOWN_INSET_RATIO) + 0.5))
		if icon._eqolBlizzardCooldownInset ~= inset then
			icon.cooldown:ClearAllPoints()
			icon.cooldown:SetPoint("TOPLEFT", icon, "TOPLEFT", inset, -inset)
			icon.cooldown:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -inset, inset)
			icon._eqolBlizzardCooldownInset = inset
			icon._eqolBlizzardCooldownInsetApplied = true
			icon._eqolBlizzardCooldownAnchored = nil
			icon._eqolBlizzardCooldownWidth = nil
			icon._eqolBlizzardCooldownHeight = nil
		end
		if icon.cooldown.SetSwipeTexture then
			local runtimeData = icon._eqolRuntimeData
			local r, g, b, a = cdp.ENTRY.GetCooldownSwipeColor(runtimeData)
			local ok = pcall(icon.cooldown.SetSwipeTexture, icon.cooldown, cdp.ICON_BORDER.SWIPE_TEXTURE, r, g, b, a)
			if ok then
				icon._eqolBlizzardSwipeTextureApplied = true
				icon._eqolSwipeTextureR, icon._eqolSwipeTextureG, icon._eqolSwipeTextureB, icon._eqolSwipeTextureA = r, g, b, a
			end
		end
		cdp.ENTRY.ApplyCooldownSwipeVisual(icon, icon._eqolRuntimeData)
	end
end

local function applyIconBorder(icon, layout)
	if not icon or not icon.border then return end
	local border = icon.border
	local defaults = Helper.PANEL_LAYOUT_DEFAULTS
	local enabled = layout and layout.iconBorderEnabled == true
	if not enabled then
		cdp.ENTRY.ClearBlizzardIconSkin(icon)
		border:Hide()
		return
	end

	local textureKey = normalizeIconBorderTexture(layout.iconBorderTexture, defaults.iconBorderTexture)
	if cdp.ENTRY.IsBlizzardIconBorderTexture(textureKey) then
		border:Hide()
		cdp.ENTRY.ApplyBlizzardIconSkin(icon)
		return
	end

	cdp.ENTRY.ClearBlizzardIconSkin(icon)
	local edgeSize = Helper.ClampInt(layout.iconBorderSize, 1, 64, defaults.iconBorderSize or 1)
	local offset = Helper.ClampInt(layout.iconBorderOffset, -64, 64, defaults.iconBorderOffset or 0)
	local edgeFile = resolveIconBorderTexture(textureKey)
	local color = Helper.NormalizeColor(layout.iconBorderColor, defaults.iconBorderColor)

	if border._eqolBorderEdgeFile ~= edgeFile or border._eqolBorderEdgeSize ~= edgeSize then
		border:SetBackdrop({
			edgeFile = edgeFile,
			edgeSize = edgeSize,
			insets = { left = 0, right = 0, top = 0, bottom = 0 },
		})
		border:SetBackdropColor(0, 0, 0, 0)
		border._eqolBorderEdgeFile = edgeFile
		border._eqolBorderEdgeSize = edgeSize
	end

	if border._eqolBorderOffset ~= offset then
		border:ClearAllPoints()
		border:SetPoint("TOPLEFT", icon, "TOPLEFT", -offset, offset)
		border:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", offset, -offset)
		border._eqolBorderOffset = offset
	end

	border:SetBackdropBorderColor(color[1] or 0, color[2] or 0, color[3] or 0, color[4] or 1)
	border:Show()
end

local function applyIconLayout(frame, count, layout)
	if not frame then return end
	local iconSize = Helper.ClampInt(layout.iconSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
	local iconWidth = layout.iconSizeSeparate == true and Helper.ClampInt(layout.iconWidth, 12, 128, iconSize) or iconSize
	local iconHeight = layout.iconSizeSeparate == true and Helper.ClampInt(layout.iconHeight, 12, 128, iconSize) or iconSize
	local spacing = Helper.ClampInt(layout.spacing, 0, Helper.SPACING_RANGE or 200, Helper.PANEL_LAYOUT_DEFAULTS.spacing)
	local layoutMode = Helper.NormalizeLayoutMode(layout.layoutMode, Helper.PANEL_LAYOUT_DEFAULTS.layoutMode)
	local direction = Helper.NormalizeDirection(layout.direction, Helper.PANEL_LAYOUT_DEFAULTS.direction)
	local wrapCount = Helper.ClampInt(layout.wrapCount, 0, 40, Helper.PANEL_LAYOUT_DEFAULTS.wrapCount or 0)
	local wrapDirection = Helper.NormalizeDirection(layout.wrapDirection, Helper.PANEL_LAYOUT_DEFAULTS.wrapDirection or "DOWN")
	local growthPoint = Helper.NormalizeGrowthPoint(layout.growthPoint, Helper.PANEL_LAYOUT_DEFAULTS.growthPoint)
	local primaryHorizontal = direction == "LEFT" or direction == "RIGHT"
	local stackAnchor = Helper.NormalizeAnchor(layout.stackAnchor, Helper.PANEL_LAYOUT_DEFAULTS.stackAnchor)
	local stackX = Helper.ClampInt(layout.stackX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, Helper.PANEL_LAYOUT_DEFAULTS.stackX)
	local stackY = Helper.ClampInt(layout.stackY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, Helper.PANEL_LAYOUT_DEFAULTS.stackY)
	local chargesAnchor = Helper.NormalizeAnchor(layout.chargesAnchor, Helper.PANEL_LAYOUT_DEFAULTS.chargesAnchor)
	local chargesX = Helper.ClampInt(layout.chargesX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, Helper.PANEL_LAYOUT_DEFAULTS.chargesX)
	local chargesY = Helper.ClampInt(layout.chargesY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, Helper.PANEL_LAYOUT_DEFAULTS.chargesY)
	local keybindAnchor = Helper.NormalizeAnchor(layout.keybindAnchor, Helper.PANEL_LAYOUT_DEFAULTS.keybindAnchor)
	local keybindX = Helper.ClampInt(layout.keybindX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, Helper.PANEL_LAYOUT_DEFAULTS.keybindX)
	local keybindY = Helper.ClampInt(layout.keybindY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, Helper.PANEL_LAYOUT_DEFAULTS.keybindY)
	local drawEdge = layout.cooldownDrawEdge ~= false
	local drawBling = layout.cooldownDrawBling ~= false
	local drawSwipe = layout.cooldownDrawSwipe ~= false
	local cooldownTextFont = layout.cooldownTextFont
	local cooldownTextSize = layout.cooldownTextSize
	local cooldownTextStyle = layout.cooldownTextStyle
	local cooldownTextColor = Helper.NormalizeColor(layout.cooldownTextColor, Helper.PANEL_LAYOUT_DEFAULTS.cooldownTextColor)
	local cooldownTextX = Helper.ClampInt(layout.cooldownTextX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
	local cooldownTextY = Helper.ClampInt(layout.cooldownTextY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)

	local cols, rows = 1, 1
	local baseIconSize = iconSize
	local baseIconWidth = iconWidth
	local baseIconHeight = iconHeight
	local rowSizes = {}
	local rowOffsets = {}
	local rowWidths = {}
	local width = 0
	local height = 0
	local radialRadius = nil
	local radialRotation = nil
	local radialArcDegrees = nil
	local radialStep = nil
	local radialBaseAngle = nil

	if layoutMode == "RADIAL" then
		radialRadius = Helper.ClampInt(layout.radialRadius, 0, Helper.RADIAL_RADIUS_RANGE or 600, Helper.PANEL_LAYOUT_DEFAULTS.radialRadius)
		radialRotation = Helper.ClampNumber(layout.radialRotation, -Helper.RADIAL_ROTATION_RANGE, Helper.RADIAL_ROTATION_RANGE, Helper.PANEL_LAYOUT_DEFAULTS.radialRotation)
		radialArcDegrees = Helper.ClampInt(layout.radialArcDegrees, Helper.RADIAL_ARC_DEGREES_MIN or 15, Helper.RADIAL_ARC_DEGREES_MAX or 360, Helper.PANEL_LAYOUT_DEFAULTS.radialArcDegrees or 360)
		if radialArcDegrees >= (Helper.RADIAL_ARC_DEGREES_MAX or 360) then
			radialStep = count > 0 and ((2 * math.pi) / count) or 0
		else
			radialStep = count > 1 and (math.rad(radialArcDegrees) / (count - 1)) or 0
		end
		radialBaseAngle = (math.pi / 2) - math.rad(radialRotation or 0)
		width = math.max(baseIconSize, radialRadius * 2)
		height = width
	else
		cols, rows = getGridDimensions(count, wrapCount, primaryHorizontal)
		if primaryHorizontal then
			local totalHeight = 0
			for rowIndex = 1, rows do
				local rowSize = baseIconSize
				if type(layout.rowSizes) == "table" then
					local override = tonumber(layout.rowSizes[rowIndex])
					if override then rowSize = Helper.ClampInt(override, 12, 128, baseIconSize) end
				end
				rowSizes[rowIndex] = rowSize
				rowOffsets[rowIndex] = totalHeight
				local rowIconWidth = layout.iconSizeSeparate == true and baseIconWidth or rowSize
				local rowIconHeight = layout.iconSizeSeparate == true and baseIconHeight or rowSize
				local rowCols = cols
				if wrapCount and wrapCount > 0 then
					local fillIndex = rowIndex
					if wrapDirection == "UP" then fillIndex = rows - rowIndex + 1 end
					rowCols = math.min(wrapCount, count - ((fillIndex - 1) * wrapCount))
					if rowCols < 1 then rowCols = 1 end
				end
				local rowWidth = (rowCols * rowIconWidth) + ((rowCols - 1) * spacing)
				rowWidths[rowIndex] = rowWidth
				if rowWidth > width then width = rowWidth end
				totalHeight = totalHeight + rowIconHeight + spacing
			end
			if rows > 0 then height = totalHeight - spacing end
		else
			local step = baseIconHeight + spacing
			width = (cols * baseIconWidth) + ((cols - 1) * spacing)
			height = (rows * baseIconHeight) + ((rows - 1) * spacing)
			for rowIndex = 1, rows do
				rowSizes[rowIndex] = baseIconSize
				rowOffsets[rowIndex] = (rowIndex - 1) * step
				rowWidths[rowIndex] = width
			end
		end
	end

	if width <= 0 then width = baseIconWidth end
	if height <= 0 then height = baseIconHeight end

	frame:SetSize(width, height)
	ensureIconCount(frame, count)
	local fontPath, fontSize, fontStyle = Helper.GetCountFontDefaults(frame)
	local countFontPath = Helper.ResolveFontPath(layout.stackFont, fontPath)
	local countFontSize = Helper.ClampInt(layout.stackFontSize, 6, 64, fontSize or 12)
	local countFontStyle = Helper.NormalizeFontStyle(layout.stackFontStyle, fontStyle)
	local countFontColor = Helper.NormalizeColor(layout.stackColor, Helper.PANEL_LAYOUT_DEFAULTS.stackColor or { 1, 1, 1, 1 })
	local chargesFontPath, chargesFontSize, chargesFontStyle = Helper.GetChargesFontDefaults(frame)
	local chargesPath = Helper.ResolveFontPath(layout.chargesFont, chargesFontPath)
	local chargesSize = Helper.ClampInt(layout.chargesFontSize, 6, 64, chargesFontSize or 12)
	local chargesStyle = Helper.NormalizeFontStyle(layout.chargesFontStyle, chargesFontStyle)
	local chargesFontColor = Helper.NormalizeColor(layout.chargesColor, Helper.PANEL_LAYOUT_DEFAULTS.chargesColor or { 1, 1, 1, 1 })
	local keybindFontPath = Helper.ResolveFontPath(layout.keybindFont, countFontPath)
	local keybindFontSize = Helper.ClampInt(layout.keybindFontSize, 6, 64, Helper.PANEL_LAYOUT_DEFAULTS.keybindFontSize or math.min(countFontSize, 10))
	local keybindFontStyle = Helper.NormalizeFontStyle(layout.keybindFontStyle, countFontStyle)

	local function getAnchorComponents(point)
		local h = "CENTER"
		if point and point:find("LEFT") then
			h = "LEFT"
		elseif point and point:find("RIGHT") then
			h = "RIGHT"
		end
		local v = "CENTER"
		if point and point:find("TOP") then
			v = "TOP"
		elseif point and point:find("BOTTOM") then
			v = "BOTTOM"
		end
		return h, v
	end
	local function getGrowthOffset(point, gridWidth, gridHeight)
		local h, v = getAnchorComponents(point)
		local x = 0
		if h == "CENTER" then x = -(gridWidth / 2) end
		if h == "RIGHT" then x = -gridWidth end
		local y = 0
		if v == "CENTER" then
			y = (gridHeight / 2)
		elseif v == "BOTTOM" then
			y = gridHeight
		end
		return x, y, h, v
	end
	local growthOffsetX, growthOffsetY, anchorH, anchorV = getGrowthOffset(growthPoint, width, height)

	local function applyIconCommon(icon, rowSize)
		local slotAnchor = icon.slotAnchor or icon
		local rowWidth = layout.iconSizeSeparate == true and baseIconWidth or rowSize
		local rowHeight = layout.iconSizeSeparate == true and baseIconHeight or rowSize
		slotAnchor:SetSize(rowWidth, rowHeight)
		icon._eqolBaseSlotSize = rowSize
		icon:SetSize(rowWidth, rowHeight)
		if slotAnchor ~= icon then
			icon:ClearAllPoints()
			icon:SetPoint("CENTER", slotAnchor, "CENTER", 0, 0)
		end
		icon._eqolVisualSize = nil
		icon._eqolVisualWidth = nil
		icon._eqolVisualHeight = nil
		icon._eqolVisualAnchor = nil
		icon._eqolVisualOffsetX = nil
		icon._eqolVisualOffsetY = nil
		applyIconBorder(icon, layout)
		if icon.count then
			local r = countFontColor[1] or 1
			local g = countFontColor[2] or 1
			local b = countFontColor[3] or 1
			local a = countFontColor[4] or 1
			icon.count:ClearAllPoints()
			icon.count:SetPoint(stackAnchor, icon, stackAnchor, stackX, stackY)
			if Helper.SetFont then
				Helper.SetFont(icon.count, countFontPath, countFontSize, countFontStyle, fontPath)
			else
				icon.count:SetFont(countFontPath, countFontSize, countFontStyle)
			end
			if addon.functions and addon.functions.ApplyFontStyleShadow then
				addon.functions.ApplyFontStyleShadow(icon.count, Helper.NormalizeFontStyleChoice(layout and layout.stackFontStyle, fontStyle), fontStyle)
			end
			icon.count:SetTextColor(r, g, b, a)
			icon.count._eqolStackAnchor = stackAnchor
			icon.count._eqolStackX = stackX
			icon.count._eqolStackY = stackY
			icon.count._eqolStackFont = countFontPath
			icon.count._eqolStackFontSize = countFontSize
			icon.count._eqolStackFontStyle = countFontStyle
			icon.count._eqolStackColorR = r
			icon.count._eqolStackColorG = g
			icon.count._eqolStackColorB = b
			icon.count._eqolStackColorA = a
		end
		if icon.charges then
			local r = chargesFontColor[1] or 1
			local g = chargesFontColor[2] or 1
			local b = chargesFontColor[3] or 1
			local a = chargesFontColor[4] or 1
			icon.charges:ClearAllPoints()
			icon.charges:SetPoint(chargesAnchor, icon, chargesAnchor, chargesX, chargesY)
			if Helper.SetFont then
				Helper.SetFont(icon.charges, chargesPath, chargesSize, chargesStyle, chargesFontPath)
			else
				icon.charges:SetFont(chargesPath, chargesSize, chargesStyle)
			end
			if addon.functions and addon.functions.ApplyFontStyleShadow then
				addon.functions.ApplyFontStyleShadow(icon.charges, Helper.NormalizeFontStyleChoice(layout and layout.chargesFontStyle, chargesFontStyle), chargesFontStyle)
			end
			icon.charges:SetTextColor(r, g, b, a)
			icon.charges._eqolChargesAnchor = chargesAnchor
			icon.charges._eqolChargesX = chargesX
			icon.charges._eqolChargesY = chargesY
			icon.charges._eqolChargesFont = chargesPath
			icon.charges._eqolChargesFontSize = chargesSize
			icon.charges._eqolChargesFontStyle = chargesStyle
			icon.charges._eqolChargesColorR = r
			icon.charges._eqolChargesColorG = g
			icon.charges._eqolChargesColorB = b
			icon.charges._eqolChargesColorA = a
		end
		if icon.keybind then
			icon.keybind:ClearAllPoints()
			icon.keybind:SetPoint(keybindAnchor, icon, keybindAnchor, keybindX, keybindY)
			if Helper.SetFont then
				Helper.SetFont(icon.keybind, keybindFontPath, keybindFontSize, keybindFontStyle, countFontPath)
			else
				icon.keybind:SetFont(keybindFontPath, keybindFontSize, keybindFontStyle)
			end
			if addon.functions and addon.functions.ApplyFontStyleShadow then
				addon.functions.ApplyFontStyleShadow(icon.keybind, Helper.NormalizeFontStyleChoice(layout and layout.keybindFontStyle, countFontStyle), countFontStyle)
			end
		end
		setCooldownDrawState(icon.cooldown, drawEdge, drawBling, drawSwipe)
		if icon.cooldown and icon.cooldown.GetCountdownFontString then
			local fontString = icon.cooldown:GetCountdownFontString()
			if fontString then
				if not icon.cooldown._eqolCooldownTextDefaults then
					local fontPath, fontSize, fontStyle = fontString:GetFont()
					icon.cooldown._eqolCooldownTextDefaults = {
						font = fontPath,
						size = fontSize,
						style = fontStyle,
					}
				end
				local defaults = icon.cooldown._eqolCooldownTextDefaults
				local fontPath = Helper.ResolveFontPath(cooldownTextFont, defaults and defaults.font)
				local fontSize = Helper.ClampInt(cooldownTextSize, 6, 64, defaults and defaults.size or 12)
				local fontStyle = Helper.NormalizeFontStyle(cooldownTextStyle, defaults and defaults.style) or ""
				local r = cooldownTextColor[1] or 1
				local g = cooldownTextColor[2] or 1
				local b = cooldownTextColor[3] or 1
				local a = cooldownTextColor[4] or 1
				if Helper.SetFont then
					Helper.SetFont(fontString, fontPath, fontSize, fontStyle, defaults and defaults.font)
				else
					fontString:SetFont(fontPath, fontSize, fontStyle)
				end
				if addon.functions and addon.functions.ApplyFontStyleShadow then
					addon.functions.ApplyFontStyleShadow(fontString, Helper.NormalizeFontStyleChoice(cooldownTextStyle, defaults and defaults.style), defaults and defaults.style)
				end
				fontString:ClearAllPoints()
				fontString:SetPoint("CENTER", icon.cooldown, "CENTER", cooldownTextX, cooldownTextY)
				fontString:SetTextColor(r, g, b, a)
				fontString._eqolCooldownFont = fontPath
				fontString._eqolCooldownFontSize = fontSize
				fontString._eqolCooldownFontStyle = fontStyle
				fontString._eqolCooldownAnchor = "CENTER"
				fontString._eqolCooldownX = cooldownTextX
				fontString._eqolCooldownY = cooldownTextY
				fontString._eqolCooldownColorR = r
				fontString._eqolCooldownColorG = g
				fontString._eqolCooldownColorB = b
				fontString._eqolCooldownColorA = a
			end
		end
		CooldownPanels.UpdatePreviewGlowBorderLayout(icon, rowSize)
		if icon.previewBling then
			icon.previewBling:ClearAllPoints()
			icon.previewBling:SetPoint("CENTER", icon, "CENTER", 0, 0)
			icon.previewBling:SetSize(rowSize * 1.5, rowSize * 1.5)
		end
		resizeAssistedHighlight(icon)
	end

	if layoutMode == "RADIAL" then
		local centerRadius = (radialRadius or 0) - (baseIconSize / 2)
		if centerRadius < 0 then centerRadius = 0 end
		for i = 1, count do
			local icon = frame.icons[i]
			local slotAnchor = icon.slotAnchor or icon
			applyIconCommon(icon, baseIconSize)
			local x, y = 0, 0
			if count > 1 then
				local angle = radialBaseAngle - ((i - 1) * (radialStep or 0))
				x = math.cos(angle) * centerRadius
				y = math.sin(angle) * centerRadius
			end
			slotAnchor:ClearAllPoints()
			slotAnchor:SetPoint("CENTER", frame, "CENTER", x, y)
			if slotAnchor ~= icon then
				icon:ClearAllPoints()
				icon:SetPoint("CENTER", slotAnchor, "CENTER", 0, 0)
			end
		end
		return
	end

	for i = 1, count do
		local icon = frame.icons[i]
		local slotAnchor = icon.slotAnchor or icon
		local primaryIndex = i - 1
		local secondaryIndex = 0
		if wrapCount and wrapCount > 0 then
			primaryIndex = (i - 1) % wrapCount
			secondaryIndex = math.floor((i - 1) / wrapCount)
		end

		local col, row
		if primaryHorizontal then
			col = primaryIndex
			row = secondaryIndex
		else
			local colCount = wrapCount and wrapCount > 0 and math.min(wrapCount, count - (secondaryIndex * wrapCount)) or count
			local rowOffset = anchorV == "CENTER" and ((rows - colCount) / 2) or 0
			row = primaryIndex + rowOffset
			col = secondaryIndex
		end

		if primaryHorizontal and direction == "LEFT" then
			col = (cols - 1) - col
		elseif (not primaryHorizontal) and direction == "UP" then
			row = (rows - 1) - row
		end

		if primaryHorizontal then
			if wrapDirection == "UP" then row = (rows - 1) - row end
		else
			if wrapDirection == "LEFT" then col = (cols - 1) - col end
		end

		local rowIndex = row + 1
		local rowSize = rowSizes[rowIndex] or baseIconSize
		local rowIconWidth = layout.iconSizeSeparate == true and baseIconWidth or rowSize
		local rowHeight = layout.iconSizeSeparate == true and baseIconHeight or rowSize
		local rowOffset = rowOffsets[rowIndex] or (row * (baseIconSize + spacing))
		local rowWidth = rowWidths[rowIndex] or width
		local rowAlignOffset = 0
		if primaryHorizontal then
			if anchorH == "CENTER" then
				rowAlignOffset = (width - rowWidth) / 2
			elseif anchorH == "RIGHT" then
				rowAlignOffset = width - rowWidth
			end
		end
		local anchorXAdjust = 0
		if anchorH == "CENTER" then
			anchorXAdjust = rowIconWidth / 2
		elseif anchorH == "RIGHT" then
			anchorXAdjust = rowIconWidth
		end
		local anchorYAdjust = 0
		if anchorV == "CENTER" then
			anchorYAdjust = -(rowHeight / 2)
		elseif anchorV == "BOTTOM" then
			anchorYAdjust = -rowHeight
		end
		local stepX = primaryHorizontal and (rowIconWidth + spacing) or (baseIconWidth + spacing)

		applyIconCommon(icon, rowSize)
		slotAnchor:ClearAllPoints()
		slotAnchor:SetPoint(growthPoint, frame, growthPoint, growthOffsetX + anchorXAdjust + rowAlignOffset + (col * stepX), growthOffsetY + anchorYAdjust - rowOffset)
		if slotAnchor ~= icon then
			icon:ClearAllPoints()
			icon:SetPoint("CENTER", slotAnchor, "CENTER", 0, 0)
		end
	end
end

local function applyPanelBorder(frame)
	local borderLayer, borderSubLevel = "BORDER", 0
	local borderPath = "Interface\\AddOns\\EnhanceQoL\\Assets\\PanelBorder_"
	local cornerSize = 70
	local edgeThickness = 70
	local cornerOffsets = 13

	local function makeTex(key, layer, subLevel)
		local tex = frame:CreateTexture(nil, layer or borderLayer, nil, subLevel or borderSubLevel)
		tex:SetTexture(borderPath .. key .. ".tga")
		tex:SetAlpha(0.95)
		return tex
	end

	local tl = makeTex("tl", borderLayer, borderSubLevel + 1)
	tl:SetSize(cornerSize, cornerSize)
	tl:SetPoint("TOPLEFT", frame, "TOPLEFT", -cornerOffsets, cornerOffsets)

	local tr = makeTex("tr", borderLayer, borderSubLevel + 1)
	tr:SetSize(cornerSize, cornerSize)
	tr:SetPoint("TOPRIGHT", frame, "TOPRIGHT", cornerOffsets + 8, cornerOffsets)

	local bl = makeTex("bl", borderLayer, borderSubLevel + 1)
	bl:SetSize(cornerSize, cornerSize)
	bl:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", -cornerOffsets, -cornerOffsets)

	local br = makeTex("br", borderLayer, borderSubLevel + 1)
	br:SetSize(cornerSize, cornerSize)
	br:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", cornerOffsets + 8, -cornerOffsets)

	local top = makeTex("t", borderLayer, borderSubLevel)
	top:SetPoint("TOPLEFT", tl, "TOPRIGHT", 0, 0)
	top:SetPoint("TOPRIGHT", tr, "TOPLEFT", 0, 0)
	top:SetHeight(edgeThickness)
	top:SetHorizTile(true)

	local bottom = makeTex("b", borderLayer, borderSubLevel)
	bottom:SetPoint("BOTTOMLEFT", bl, "BOTTOMRIGHT", 0, 0)
	bottom:SetPoint("BOTTOMRIGHT", br, "BOTTOMLEFT", 0, 0)
	bottom:SetHeight(edgeThickness)
	bottom:SetHorizTile(true)

	local left = makeTex("l", borderLayer, borderSubLevel)
	left:SetPoint("TOPLEFT", tl, "BOTTOMLEFT", 0, 0)
	left:SetPoint("BOTTOMLEFT", bl, "TOPLEFT", 0, 0)
	left:SetWidth(edgeThickness)
	left:SetVertTile(true)

	local right = makeTex("r", borderLayer, borderSubLevel)
	right:SetPoint("TOPRIGHT", tr, "BOTTOMRIGHT", 0, 0)
	right:SetPoint("BOTTOMRIGHT", br, "TOPRIGHT", 0, 0)
	right:SetWidth(edgeThickness)
	right:SetVertTile(true)
end

local function applyInsetBorder(frame, offset)
	if not frame then return end
	offset = offset or 10

	local layer, subLevel = "BORDER", 2
	local path = "Interface\\AddOns\\EnhanceQoL\\Assets\\border_round_"
	local cornerSize = 36
	local edgeSize = 36

	frame.eqolInsetParts = frame.eqolInsetParts or {}
	local parts = frame.eqolInsetParts

	local function tex(name)
		if not parts[name] then parts[name] = frame:CreateTexture(nil, layer, nil, subLevel) end
		local t = parts[name]
		t:SetAlpha(0.7)
		t:SetTexture(path .. name .. ".tga")
		t:SetDrawLayer(layer, subLevel)
		return t
	end

	local tl = tex("tl")
	tl:SetSize(cornerSize, cornerSize)
	tl:ClearAllPoints()
	tl:SetPoint("TOPLEFT", frame, "TOPLEFT", offset, -offset)

	local tr = tex("tr")
	tr:SetSize(cornerSize, cornerSize)
	tr:ClearAllPoints()
	tr:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -offset, -offset)

	local bl = tex("bl")
	bl:SetSize(cornerSize, cornerSize)
	bl:ClearAllPoints()
	bl:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", offset, offset)

	local br = tex("br")
	br:SetSize(cornerSize, cornerSize)
	br:ClearAllPoints()
	br:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -offset, offset)

	local top = tex("t")
	top:ClearAllPoints()
	top:SetPoint("TOPLEFT", tl, "TOPRIGHT", 0, 0)
	top:SetPoint("TOPRIGHT", tr, "TOPLEFT", 0, 0)
	top:SetHeight(edgeSize)
	top:SetHorizTile(true)

	local bottom = tex("b")
	bottom:ClearAllPoints()
	bottom:SetPoint("BOTTOMLEFT", bl, "BOTTOMRIGHT", 0, 0)
	bottom:SetPoint("BOTTOMRIGHT", br, "BOTTOMLEFT", 0, 0)
	bottom:SetHeight(edgeSize)
	bottom:SetHorizTile(true)

	local left = tex("l")
	left:ClearAllPoints()
	left:SetPoint("TOPLEFT", tl, "BOTTOMLEFT", 0, 0)
	left:SetPoint("BOTTOMLEFT", bl, "TOPLEFT", 0, 0)
	left:SetWidth(edgeSize)
	left:SetVertTile(true)

	local right = tex("r")
	right:ClearAllPoints()
	right:SetPoint("TOPRIGHT", tr, "BOTTOMRIGHT", 0, 0)
	right:SetPoint("BOTTOMRIGHT", br, "TOPRIGHT", 0, 0)
	right:SetWidth(edgeSize)
	right:SetVertTile(true)
end

local function showEditorDragIcon(editor, texture)
	if not editor then return end
	if not editor.dragIcon then
		local frame = CreateFrame("Frame", nil, UIParent)
		frame:SetSize(34, 34)
		frame:SetFrameStrata("TOOLTIP")
		frame.texture = frame:CreateTexture(nil, "OVERLAY")
		frame.texture:SetAllPoints()
		editor.dragIcon = frame
	end
	editor.dragIcon.texture:SetTexture(texture or Helper.PREVIEW_ICON)
	editor.dragIcon:SetScript("OnUpdate", function(f)
		local x, y = Api.GetCursorPosition()
		local scale = UIParent:GetEffectiveScale()
		f:ClearAllPoints()
		f:SetPoint("CENTER", UIParent, "BOTTOMLEFT", x / scale, y / scale)
	end)
	editor.dragIcon:Show()
end

local function hideEditorDragIcon(editor)
	if not editor or not editor.dragIcon then return end
	editor.dragIcon:SetScript("OnUpdate", nil)
	editor.dragIcon:Hide()
end

function CooldownPanels:GetCursorPositionOnUIParent()
	if not (Api.GetCursorPosition and UIParent and UIParent.GetEffectiveScale) then return nil, nil end
	local x, y = Api.GetCursorPosition()
	local scale = UIParent:GetEffectiveScale()
	if not (x and y and scale and scale > 0) then return nil, nil end
	return x / scale, y / scale
end

function CooldownPanels:GetLayoutEntryCandidatesAtCursor(panelId)
	panelId = normalizeId(panelId)
	if not panelId then return nil end
	local cursorX, cursorY = self:GetCursorPositionOnUIParent()
	if not (cursorX and cursorY) then return nil end
	local runtime = getRuntime(panelId)
	local frame = runtime and runtime.frame or nil
	local icons = frame and frame.icons or nil
	if not icons then return nil end

	local candidates = {}
	local seen = {}
	for i = #icons, 1, -1 do
		local icon = icons[i]
		local candidateEntryId = normalizeId(icon and (icon.entryId or (icon.cooldown and icon.cooldown._eqolEntryId)) or nil)
		if candidateEntryId and not seen[candidateEntryId] and icon and icon.IsShown and icon:IsShown() then
			local left = icon.GetLeft and icon:GetLeft() or nil
			local right = icon.GetRight and icon:GetRight() or nil
			local top = icon.GetTop and icon:GetTop() or nil
			local bottom = icon.GetBottom and icon:GetBottom() or nil
			if left and right and top and bottom and cursorX >= left and cursorX <= right and cursorY <= top and cursorY >= bottom then
				candidates[#candidates + 1] = {
					entryId = candidateEntryId,
					icon = icon,
					anchorFrame = (icon.layoutHandle and icon.layoutHandle.IsShown and icon.layoutHandle:IsShown()) and icon.layoutHandle or icon,
					frameLevel = icon.GetFrameLevel and icon:GetFrameLevel() or 0,
					index = i,
				}
				seen[candidateEntryId] = true
			end
		end
	end

	table.sort(candidates, function(a, b)
		if (a.frameLevel or 0) ~= (b.frameLevel or 0) then return (a.frameLevel or 0) > (b.frameLevel or 0) end
		return (a.index or 0) > (b.index or 0)
	end)

	return candidates
end

function CooldownPanels:ShowLayoutEntryChooserMenu(owner, panelId, candidates)
	if not (owner and panelId and candidates and #candidates > 1 and Api.MenuUtil and Api.MenuUtil.CreateContextMenu) then return false end
	local panel = self:GetPanel(panelId)
	if not panel then return false end

	Api.MenuUtil.CreateContextMenu(owner, function(_, rootDescription)
		rootDescription:SetTag("MENU_EQOL_COOLDOWN_PANEL_LAYOUT_ENTRY_PICKER")
		if rootDescription.SetScrollMode then rootDescription:SetScrollMode(260) end
		rootDescription:CreateTitle("Select entry")
		for _, candidate in ipairs(candidates) do
			local entryId = normalizeId(candidate and candidate.entryId)
			local entry = entryId and panel.entries and panel.entries[entryId] or nil
			if entry then
				local label = CooldownPanels:GetEntryStandaloneTitle(entry)
				local slotColumn = Helper.NormalizeSlotCoordinate(entry.slotColumn)
				local slotRow = Helper.NormalizeSlotCoordinate(entry.slotRow)
				local iconToken = getEntryIcon(entry)
				local iconType = type(iconToken)
				if slotColumn and slotRow then label = string.format("%s [%d,%d]", label, slotColumn, slotRow) end
				if (iconType == "string" and iconToken ~= "") or iconType == "number" then label = string.format("|T%s:14:14:0:0:64:64:4:60:4:60|t %s", tostring(iconToken), label) end
				rootDescription:CreateButton(label, function()
					CooldownPanels:SelectPanel(panelId)
					CooldownPanels:SelectEntry(entryId)
					CooldownPanels:OpenLayoutEntryStandaloneMenu(panelId, entryId, candidate.anchorFrame or candidate.icon or owner)
				end)
			end
		end
	end)

	return true
end

function CooldownPanels:ShowEntryQuickSetupMenu(owner, panelId, entryId)
	panelId = normalizeId(panelId)
	entryId = normalizeId(entryId)
	if not (owner and panelId and entryId and Api.MenuUtil and Api.MenuUtil.CreateContextMenu) then return false end
	local panel = self:GetPanel(panelId)
	local entry = panel and panel.entries and panel.entries[entryId] or nil
	if not entry then return false end
	Api.MenuUtil.CreateContextMenu(owner, function(_, rootDescription)
		rootDescription:SetTag("MENU_EQOL_COOLDOWN_PANEL_ENTRY_QUICK_SETUP")
		rootDescription:CreateTitle(L["CooldownPanelQuickSetups"] or "Quick setups")
		local bars = self.Bars
		if bars and bars.IsBarDisplayModeValue and bars.IsBarDisplayModeValue(entry.displayMode) then
			if bars.AppendQuickSetupMenu then bars.AppendQuickSetupMenu(rootDescription, panelId, entryId) end
			return
		end
		local textInfoMenu = rootDescription:CreateButton(L["CooldownPanelQuickSetupTextInfoOnCooldown"] or "Text info on cooldown")
		for _, layoutKey in ipairs(cdp.ENTRY.TEXT_INFO_LAYOUT_ORDER) do
			local layout = cdp.ENTRY.TEXT_INFO_LAYOUTS[layoutKey]
			if layout then
				textInfoMenu:CreateButton(L[layout.labelKey] or layout.fallback, function()
					CooldownPanels:ApplyTextInfoOnCooldownQuickSetup(panelId, entryId, true, layoutKey)
				end)
			end
		end
		local textAlwaysMenu = rootDescription:CreateButton(L["CooldownPanelQuickSetupTextAlwaysWithCooldown"] or "Text always with cooldown")
		for _, layoutKey in ipairs(cdp.ENTRY.TEXT_INFO_LAYOUT_ORDER) do
			local layout = cdp.ENTRY.TEXT_INFO_LAYOUTS[layoutKey]
			if layout then
				textAlwaysMenu:CreateButton(L[layout.labelKey] or layout.fallback, function()
					CooldownPanels:ApplyTextAlwaysWithCooldownQuickSetup(panelId, entryId, true, layoutKey)
				end)
			end
		end
		if entry.type ~= "CDM_AURA" then
			rootDescription:CreateButton(L["CooldownPanelQuickSetupTextInfoWhenReady"] or "Text info when ready", function()
				CooldownPanels:ApplyTextInfoWhenReadyQuickSetup(panelId, entryId, true)
			end)
		end
	end)
	return true
end

local function showSlotMenu(owner, panelId)
	if not panelId or not Api.MenuUtil or not Api.MenuUtil.CreateContextMenu then return end
	local slotEntries = getSlotMenuEntries()
	local stanceEntries = CooldownPanels.GetStanceMenuEntries and CooldownPanels:GetStanceMenuEntries() or nil
	local racialEntries = CooldownPanels.GetPlayerRacialSpellEntries and CooldownPanels:GetPlayerRacialSpellEntries() or nil
	local cdmAuras = CooldownPanels.CDMAuras
	local hasCDMMenu = cdmAuras and cdmAuras.AppendAddMenu
	if ((not slotEntries) or #slotEntries == 0) and ((not stanceEntries) or #stanceEntries == 0) and ((not racialEntries) or #racialEntries == 0) and not hasCDMMenu then return end
	Api.MenuUtil.CreateContextMenu(owner, function(_, rootDescription)
		rootDescription:SetTag("MENU_EQOL_COOLDOWN_PANEL_ENTRY_ADD")
		rootDescription:CreateTitle(L["CooldownPanelAddSlot"] or "Add more")
		if slotEntries and #slotEntries > 0 then
			local slotMenu = rootDescription:CreateButton(L["CooldownPanelSlotType"] or _G.SLOT or "Slot")
			for _, slot in ipairs(slotEntries) do
				slotMenu:CreateButton(slot.label, function()
					CooldownPanels:AddEntrySafe(panelId, "SLOT", slot.id)
					CooldownPanels:RefreshEditor()
				end)
			end
		end
		if stanceEntries and #stanceEntries > 0 then
			local stanceMenu = rootDescription:CreateButton((CooldownPanels.GetStanceTypeLabel and CooldownPanels:GetStanceTypeLabel()) or (_G.STANCE or "Stance"))
			for _, classData in ipairs(stanceEntries) do
				local classMenu = stanceMenu:CreateButton(classData.label or tostring(classData.classTag or "Class"))
				for _, stance in ipairs(classData.entries or {}) do
					local menuLabel = stance.label or (_G.STEALTH or "Stealth")
					local iconToken = stance.icon
					local iconType = type(iconToken)
					if (iconType == "string" and iconToken ~= "") or iconType == "number" then menuLabel = string.format("|T%s:14:14:0:0:64:64:4:60:4:60|t %s", tostring(iconToken), menuLabel) end
					classMenu:CreateButton(menuLabel, function()
						local overrides = CooldownPanels.GetStanceDefaultOverrides and CooldownPanels:GetStanceDefaultOverrides() or nil
						CooldownPanels:AddEntrySafe(panelId, "STANCE", stance.id, overrides)
						CooldownPanels:RefreshEditor()
					end)
				end
			end
		end
		if racialEntries and #racialEntries > 0 then
			local racialMenu = rootDescription:CreateButton(L["CooldownPanelAddRacials"] or "Racials")
			if #racialEntries > 1 then
				racialMenu:CreateButton(string.format("%s: %s", _G.ADD or "Add", _G.ALL or "All"), function()
					CooldownPanels:AddPlayerRacialEntries(panelId)
				end)
				racialMenu:CreateDivider()
			end
			for _, racial in ipairs(racialEntries) do
				local label = L["CooldownPanelAddRacial"] or "Racial"
				if #racialEntries > 1 then label = string.format("%s %d", label, _) end
				local iconToken = racial.icon or Helper.PREVIEW_ICON
				local iconType = type(iconToken)
				if (iconType == "string" and iconToken ~= "") or iconType == "number" then label = string.format("|T%s:14:14:0:0:64:64:4:60:4:60|t %s", tostring(iconToken), label) end
				racialMenu:CreateButton(label, function()
					CooldownPanels:AddPlayerRacialEntry(panelId, racial.spellID, racial.racialSlot)
					CooldownPanels:RefreshEditor()
				end)
			end
		end
		if hasCDMMenu then cdmAuras:AppendAddMenu(rootDescription, panelId) end
	end)
end

function CooldownPanels:ShowRacialExclusionsMenu(owner, panelId, entryId)
	if not (owner and panelId and entryId and Api.MenuUtil and Api.MenuUtil.CreateContextMenu) then return end
	local panel = CooldownPanels:GetPanel(panelId)
	local entry = panel and panel.entries and panel.entries[entryId] or nil
	if not CooldownPanels:IsRacialSpellEntry(entry) then return end
	Api.MenuUtil.CreateContextMenu(owner, function(_, rootDescription)
		rootDescription:SetTag("MENU_EQOL_COOLDOWN_PANEL_RACIAL_EXCLUSIONS")
		rootDescription:CreateTitle(L["CooldownPanelRacialExclusions"] or "Excluded racials")
		for _, definition in ipairs(CooldownPanels:GetRacialExclusionDefinitions()) do
			local spellId = tonumber(definition and definition.spellID)
			if spellId then
				local label = getSpellName(spellId) or tostring(spellId)
				local iconToken = C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(spellId) or nil
				local iconType = type(iconToken)
				if (iconType == "string" and iconToken ~= "") or iconType == "number" then label = string.format("|T%s:14:14:0:0:64:64:4:60:4:60|t %s", tostring(iconToken), label) end
				rootDescription:CreateCheckbox(label, function()
					return type(entry.racialVariantExclusions) == "table" and entry.racialVariantExclusions[spellId] == true or false
				end, function()
					entry.racialVariantExclusions = entry.racialVariantExclusions or {}
					if entry.racialVariantExclusions[spellId] == true then
						entry.racialVariantExclusions[spellId] = nil
					else
						entry.racialVariantExclusions[spellId] = true
					end
					if not next(entry.racialVariantExclusions) then entry.racialVariantExclusions = nil end
					CooldownPanels:RebuildSpellIndex()
					CooldownPanels:RefreshPanel(panelId)
					CooldownPanels:RefreshEditor()
				end)
			end
		end
	end)
end

local function getSpellIdFromCooldownManagerChild(child)
	if not child then return nil end
	local spellId
	if type(child.GetSpellID) == "function" then
		local ok, value = pcall(child.GetSpellID, child)
		if ok then spellId = tonumber(value) end
	end
	if not spellId and type(child.GetSpellId) == "function" then
		local ok, value = pcall(child.GetSpellId, child)
		if ok then spellId = tonumber(value) end
	end
	if not spellId and type(child.GetSpell) == "function" then
		local ok, value = pcall(child.GetSpell, child)
		if ok then spellId = tonumber(value) end
	end
	if not spellId and type(child.GetData) == "function" then
		local ok, data = pcall(child.GetData, child)
		if ok and type(data) == "table" then spellId = tonumber(data.spellID or data.spellId or data.spell) end
	end
	if not spellId and type(child) == "table" then spellId = tonumber(child.spellID or child.spellId or child.spell) end
	return spellId
end

local ensureImportCDMPopup

local function getCooldownManagerSourceLabel(sourceKind)
	local cdmAuras = CooldownPanels.CDMAuras
	if cdmAuras and cdmAuras.GetImportSourceLabel then
		local label = cdmAuras:GetImportSourceLabel(sourceKind)
		if label then return label end
	end
	if sourceKind == "UTILITY" then return L["CooldownPanelImportCDMUtility"] or "Utility Cooldowns" end
	return L["CooldownPanelImportCDMEssential"] or COOLDOWN_VIEWER_SETTINGS_CATEGORY_ESSENTIAL
end

local function getCooldownManagerLayoutChildren(sourceKind)
	local viewerName = sourceKind == "UTILITY" and "UtilityCooldownViewer" or "EssentialCooldownViewer"
	local sourceLabel = getCooldownManagerSourceLabel(sourceKind)
	local viewer = _G[viewerName]
	if type(viewer) ~= "table" then return nil, sourceLabel, "SOURCE_NOT_FOUND" end
	local containers = {
		viewer.oldGridSettings,
		viewer.gridSettings,
		viewer.currentGridSettings,
		viewer.settings,
		viewer,
	}
	for i = 1, #containers do
		local container = containers[i]
		local layoutChildren = container and container.layoutChildren
		if type(layoutChildren) == "table" then return layoutChildren, sourceLabel end
	end
	return nil, sourceLabel, "SOURCE_NOT_FOUND"
end

local function importCooldownManagerSpells(panelId, sourceKind)
	panelId = normalizeId(panelId)
	local panel = CooldownPanels:GetPanel(panelId)
	if not panel then return nil, "PANEL_NOT_FOUND" end
	local layoutChildren, sourceLabel, sourceErr = getCooldownManagerLayoutChildren(sourceKind)
	if sourceErr then return nil, sourceErr, sourceLabel end
	local root = ensureRoot()
	if not root then return nil, "NO_DB" end
	panel.entries = panel.entries or {}
	panel.order = panel.order or {}

	local existingBySpellId = {}
	for _, entry in pairs(panel.entries) do
		if entry and entry.type == "SPELL" and entry.spellID then
			local canonicalSpellID = CooldownPanels:NormalizePersistentSpellID(entry.spellID, { allowTalentChoiceCanonical = true }) or tonumber(entry.spellID)
			if canonicalSpellID then existingBySpellId[canonicalSpellID] = true end
		end
	end

	local function importChild(child, stats)
		local spellId = getSpellIdFromCooldownManagerChild(child)
		if not spellId then
			stats.invalid = stats.invalid + 1
			return
		end
		local baseSpellId = getBaseSpellId(spellId) or spellId
		if not spellExistsSafe(baseSpellId) then
			stats.invalid = stats.invalid + 1
			return
		end
		local resolvedSpellId = CooldownPanels:NormalizePersistentSpellID(spellId, { allowTalentChoiceCanonical = true }) or baseSpellId
		local canonicalSpellID = CooldownPanels:NormalizePersistentSpellID(resolvedSpellId, { allowTalentChoiceCanonical = true }) or resolvedSpellId
		if existingBySpellId[canonicalSpellID] then
			stats.duplicates = stats.duplicates + 1
			return
		end
		if CooldownPanels:GetFixedEntryAddError(panel, nil) then
			stats.invalid = stats.invalid + 1
			return
		end
		local entryId = Helper.GetNextNumericId(panel.entries)
		local entry = Helper.CreateEntry("SPELL", resolvedSpellId, root.defaults)
		entry.id = entryId
		panel.entries[entryId] = entry
		panel.order[#panel.order + 1] = entryId
		existingBySpellId[canonicalSpellID] = true
		stats.added = stats.added + 1
	end

	local stats = { added = 0, duplicates = 0, invalid = 0, seen = 0 }
	if #layoutChildren > 0 then
		for i = 1, #layoutChildren do
			stats.seen = stats.seen + 1
			importChild(layoutChildren[i], stats)
		end
	else
		local numericKeys = {}
		for key in pairs(layoutChildren) do
			if type(key) == "number" then numericKeys[#numericKeys + 1] = key end
		end
		table.sort(numericKeys)
		for _, key in ipairs(numericKeys) do
			stats.seen = stats.seen + 1
			importChild(layoutChildren[key], stats)
		end
	end

	if stats.added > 0 then
		Helper.SyncOrder(panel.order, panel.entries)
		CooldownPanels:RebuildSpellIndex()
		CooldownPanels:RefreshPanel(panelId)
	end
	stats.sourceLabel = sourceLabel
	return stats
end

local function showImportCDMMenu(owner, panelId)
	if not panelId or not Api.MenuUtil or not Api.MenuUtil.CreateContextMenu then return end
	Api.MenuUtil.CreateContextMenu(owner, function(_, rootDescription)
		rootDescription:SetTag("MENU_EQOL_COOLDOWN_PANEL_IMPORT_CDM")
		rootDescription:CreateTitle(L["CooldownPanelImportCDMMenuTitle"] or "Import from Cooldown Manager")
		rootDescription:CreateButton(getCooldownManagerSourceLabel("ESSENTIAL"), function()
			ensureImportCDMPopup()
			StaticPopup_Show("EQOL_COOLDOWN_PANEL_IMPORT_CDM", getCooldownManagerSourceLabel("ESSENTIAL"), nil, {
				panelId = panelId,
				sourceKind = "ESSENTIAL",
				sourceLabel = getCooldownManagerSourceLabel("ESSENTIAL"),
			})
		end)
		rootDescription:CreateButton(getCooldownManagerSourceLabel("UTILITY"), function()
			ensureImportCDMPopup()
			StaticPopup_Show("EQOL_COOLDOWN_PANEL_IMPORT_CDM", getCooldownManagerSourceLabel("UTILITY"), nil, {
				panelId = panelId,
				sourceKind = "UTILITY",
				sourceLabel = getCooldownManagerSourceLabel("UTILITY"),
			})
		end)
		local cdmAuras = CooldownPanels.CDMAuras
		if cdmAuras and cdmAuras.ImportEntries then
			rootDescription:CreateButton(getCooldownManagerSourceLabel("BUFF_ICON"), function()
				ensureImportCDMPopup()
				StaticPopup_Show("EQOL_COOLDOWN_PANEL_IMPORT_CDM", getCooldownManagerSourceLabel("BUFF_ICON"), nil, {
					panelId = panelId,
					sourceKind = "BUFF_ICON",
					sourceLabel = getCooldownManagerSourceLabel("BUFF_ICON"),
				})
			end)
			rootDescription:CreateButton(getCooldownManagerSourceLabel("BUFF_BAR"), function()
				ensureImportCDMPopup()
				StaticPopup_Show("EQOL_COOLDOWN_PANEL_IMPORT_CDM", getCooldownManagerSourceLabel("BUFF_BAR"), nil, {
					panelId = panelId,
					sourceKind = "BUFF_BAR",
					sourceLabel = getCooldownManagerSourceLabel("BUFF_BAR"),
				})
			end)
		end
	end)
end

local function showSpecMenu(owner, panelId)
	if not panelId or not Api.MenuUtil or not Api.MenuUtil.CreateContextMenu then return end
	local panel = CooldownPanels:GetPanel(panelId)
	if not panel then return end
	local classMenuData = getClassSpecMenuData()
	local quickFilterDefinitions = CooldownPanels:GetSpecQuickFilterDefinitions(classMenuData)
	Api.MenuUtil.CreateContextMenu(owner, function(_, rootDescription)
		rootDescription:SetTag("MENU_EQOL_COOLDOWN_PANEL_SPECS")
		rootDescription:CreateTitle(L["CooldownPanelSpecFilter"] or "Show only for spec")
		rootDescription:CreateCheckbox(L["CooldownPanelSpecAny"] or "All specs", function() return not panelHasSpecFilter(panel) end, function()
			panel.specFilter = {}
			CooldownPanels:CommitPanelSpecFilter(panelId)
		end)

		local hasQuickFilters = false
		for _, quickFilter in ipairs(quickFilterDefinitions or {}) do
			if type(quickFilter.specIds) == "table" and #quickFilter.specIds > 0 then
				hasQuickFilters = true
				break
			end
		end
		if hasQuickFilters then
			rootDescription:CreateDivider()
			rootDescription:CreateTitle(L["CooldownPanelSpecQuickSelect"] or "Quick select")
			for _, quickFilter in ipairs(quickFilterDefinitions or {}) do
				if type(quickFilter.specIds) == "table" and #quickFilter.specIds > 0 then
					rootDescription:CreateCheckbox(quickFilter.label, function() return CooldownPanels:HasAllPanelSpecFilterEntries(panel, quickFilter.specIds) end, function()
						local enable = not CooldownPanels:HasAllPanelSpecFilterEntries(panel, quickFilter.specIds)
						if CooldownPanels:SetPanelSpecFilterEntries(panel, quickFilter.specIds, enable) then CooldownPanels:CommitPanelSpecFilter(panelId) end
					end)
				end
			end
			rootDescription:CreateDivider()
		end

		for _, classData in ipairs(classMenuData) do
			local classMenu = rootDescription:CreateButton(classData.name)
			for _, specData in ipairs(classData.specs or {}) do
				classMenu:CreateCheckbox(specData.name, function() return panel.specFilter and panel.specFilter[specData.id] == true end, function()
					panel.specFilter = panel.specFilter or {}
					if panel.specFilter[specData.id] then
						panel.specFilter[specData.id] = nil
					else
						panel.specFilter[specData.id] = true
					end
					CooldownPanels:CommitPanelSpecFilter(panelId)
				end)
			end
		end
	end)
end

local function showPanelFilterMenu(owner)
	if not Api.MenuUtil or not Api.MenuUtil.CreateContextMenu then return end
	Api.MenuUtil.CreateContextMenu(owner, function(_, rootDescription)
		rootDescription:SetTag("MENU_EQOL_COOLDOWN_PANEL_FILTERS")
		rootDescription:CreateTitle(L["CooldownPanelPanelFilters"] or "Panel filters")
		rootDescription:CreateCheckbox(L["CooldownPanelOnlyMyClass"] or "Only show Panels of my Class", function() return addon.db and addon.db.cooldownPanelsFilterClass == true end, function()
			if addon.db then addon.db.cooldownPanelsFilterClass = addon.db.cooldownPanelsFilterClass ~= true end
			CooldownPanels:RefreshEditor()
		end)
		rootDescription:CreateCheckbox(L["CooldownPanelOnlyMySpec"] or "Only show Panels of my Spec", function() return addon.db and addon.db.cooldownPanelsFilterSpec == true end, function()
			if addon.db then addon.db.cooldownPanelsFilterSpec = addon.db.cooldownPanelsFilterSpec ~= true end
			CooldownPanels:RefreshEditor()
		end)
		rootDescription:CreateCheckbox(L["CooldownPanelHideEmptyGroups"] or "Hide empty groups", function() return addon.db and addon.db.cooldownPanelsHideEmptyGroups == true end, function()
			if addon.db then addon.db.cooldownPanelsHideEmptyGroups = addon.db.cooldownPanelsHideEmptyGroups ~= true end
			CooldownPanels:RefreshEditor()
		end)
	end)
end

local function showSoundMenu(owner, panelId, entryId)
	if not panelId or not entryId or not Api.MenuUtil or not Api.MenuUtil.CreateContextMenu then return end
	local panel = CooldownPanels:GetPanel(panelId)
	local entry = panel and panel.entries and panel.entries[entryId]
	if not entry then return end
	local _, _, soundField, title = CooldownPanels:GetEntrySoundConfig(entry)
	if not soundField then return end
	local options = getSoundOptions()
	if not options or #options == 0 then return end
	Api.MenuUtil.CreateContextMenu(owner, function(_, rootDescription)
		rootDescription:SetTag("MENU_EQOL_COOLDOWN_PANEL_SOUND")
		if rootDescription.SetScrollMode then rootDescription:SetScrollMode(260) end
		rootDescription:CreateTitle(title or (L["CooldownPanelSoundReady"] or "Sound when ready"))
		for _, soundName in ipairs(options) do
			local label = getSoundLabel(soundName)
			rootDescription:CreateRadio(label, function() return normalizeSoundName(entry[soundField]) == soundName end, function()
				entry[soundField] = soundName
				playSoundName(soundName)
				CooldownPanels:RefreshPanel(panelId)
				CooldownPanels:RefreshEditor()
			end)
		end
	end)
end

function CooldownPanels:GetLayoutEntryStandaloneMenuState(create)
	CooldownPanels.runtime = CooldownPanels.runtime or {}
	local state = CooldownPanels.runtime.layoutEntryStandaloneMenu
	if state == nil and create ~= false then
		state = {}
		CooldownPanels.runtime.layoutEntryStandaloneMenu = state
	end
	return state
end

function CooldownPanels:ClearLayoutEntryStandaloneMenuState()
	local state = self:GetLayoutEntryStandaloneMenuState(false)
	if not state then return end
	for key in pairs(state) do
		state[key] = nil
	end
end

function CooldownPanels:GetLayoutEntryStandaloneDialogEntry(panelId, entryId)
	panelId = normalizeId(panelId)
	entryId = normalizeId(entryId)
	local panel = self:GetPanel(panelId)
	local entry = panel and panel.entries and panel.entries[entryId] or nil
	return panel, entry
end

function CooldownPanels:GetLayoutEntryStandaloneEffectiveType(entry)
	local effectiveType = entry and entry.type or nil
	if effectiveType == "MACRO" then
		local macro = CooldownPanels.ResolveMacroEntry(entry)
		effectiveType = (macro and macro.kind) or "MACRO"
	end
	return effectiveType
end

function CooldownPanels:HideLayoutEntryStandaloneMenu(panelId)
	local lib = addon.EditModeLib
	local state = self:GetLayoutEntryStandaloneMenuState(false)
	local trackedPanelId = normalizeId(state and state.panelId)
	if panelId and trackedPanelId and trackedPanelId ~= normalizeId(panelId) then return false end
	local hostFrame = state and state.hostFrame or (trackedPanelId and getRuntime(trackedPanelId).frame) or nil
	local hidden = false
	if lib and lib.HideStandaloneSettingsDialog then hidden = lib:HideStandaloneSettingsDialog(hostFrame) end
	self:ClearLayoutEntryStandaloneMenuState()
	return hidden
end

function CooldownPanels:RefreshLayoutEntryStandaloneMenu(rebuild)
	local lib = addon.EditModeLib
	local state = self:GetLayoutEntryStandaloneMenuState(false)
	if not state or not state.hostFrame then return end
	if not (lib and lib.IsStandaloneSettingsDialogShown and lib:IsStandaloneSettingsDialogShown(state.hostFrame)) then
		self:ClearLayoutEntryStandaloneMenuState()
		return
	end
	local panelId = normalizeId(state.panelId)
	local entryId = normalizeId(state.entryId)
	local panel, entry = self:GetLayoutEntryStandaloneDialogEntry(panelId, entryId)
	local editor = getEditor()
	local selectedPanelId = normalizeId(editor and editor.selectedPanelId)
	local selectedEntryId = normalizeId(editor and editor.selectedEntryId)
	if not panel or not entry or not self:IsPanelLayoutEditActive(panelId) or selectedPanelId ~= panelId or selectedEntryId ~= entryId then
		self:HideLayoutEntryStandaloneMenu(panelId)
		return
	end
	if rebuild == true then self:OpenLayoutEntryStandaloneMenu(panelId, entryId, state.anchorFrame or state.dialog or state.hostFrame) end
end

function CooldownPanels:FocusEntryStaticTextStandaloneSettings(panelId)
	panelId = normalizeId(panelId)
	local lib = addon.EditModeLib
	local setter = lib and lib.internal and lib.internal.SetCollapseState or nil
	local state = self:GetLayoutEntryStandaloneMenuState(false)
	if not (panelId and setter and state and normalizeId(state.panelId) == panelId and state.hostFrame) then return false end
	for _, groupId in ipairs(cdp.ENTRY.STANDALONE_COLLAPSIBLE_IDS) do
		setter(lib.internal, state.hostFrame, groupId, groupId ~= "cooldownPanelStandaloneStaticText")
	end
	return true
end

function CooldownPanels:GetEditorStandaloneDialogAnchor()
	local editor = getEditor()
	local frame = editor and editor.frame or nil
	if frame and frame.IsShown and frame:IsShown() then return frame end
	return nil
end

function CooldownPanels:GetEditorStandaloneDialogSpawnPosition()
	local frame = self:GetEditorStandaloneDialogAnchor()
	if not frame then return nil end
	return {
		point = "TOPLEFT",
		relativePoint = "TOPRIGHT",
		relativeTo = frame,
		x = 8,
		y = 0,
	}
end

function CooldownPanels:DockStandaloneDialogToEditor(dialog)
	local frame = self:GetEditorStandaloneDialogAnchor()
	if not (dialog and frame) then return end
	if dialog.SetClampedToScreen then dialog:SetClampedToScreen(false) end
	dialog:ClearAllPoints()
	dialog:SetPoint("TOPLEFT", frame, "TOPRIGHT", 8, 0)
end

function CooldownPanels:GetStandaloneDialogSpawnPosition(anchorFrame, fallbackFrame, offsetX, offsetY)
	local editorSpawnPosition = self:GetEditorStandaloneDialogSpawnPosition()
	if editorSpawnPosition then return editorSpawnPosition end

	local source = anchorFrame or fallbackFrame or UIParent
	local xOffset = tonumber(offsetX) or 0
	local yOffset = tonumber(offsetY) or 0
	if not source then return {
		point = "TOPLEFT",
		relativePoint = "TOPLEFT",
		relativeTo = UIParent,
		x = xOffset,
		y = yOffset,
	} end

	local right = source.GetRight and source:GetRight() or nil
	local top = source.GetTop and source:GetTop() or nil
	local sourceScale = (source.GetEffectiveScale and source:GetEffectiveScale()) or 1
	local parentScale = (UIParent and UIParent.GetEffectiveScale and UIParent:GetEffectiveScale()) or 1

	if right and top then
		return {
			point = "TOPLEFT",
			relativePoint = "BOTTOMLEFT",
			relativeTo = UIParent,
			x = (right * sourceScale / parentScale) + xOffset,
			y = (top * sourceScale / parentScale) + yOffset,
		}
	end

	return {
		point = "TOPLEFT",
		relativePoint = "TOPRIGHT",
		relativeTo = source,
		x = xOffset,
		y = yOffset,
	}
end

function CooldownPanels:OpenLayoutEntryStandaloneMenu(panelId, entryId, anchorFrame)
	local lib = addon.EditModeLib
	if not (lib and lib.ShowStandaloneSettingsDialog and SettingType) then return end
	panelId = normalizeId(panelId)
	entryId = normalizeId(entryId)
	if not (panelId and entryId) then return end
	if not self:IsPanelLayoutEditActive(panelId) then return end
	self:HideLayoutPanelStandaloneMenu(panelId)
	self:HideLayoutFixedGroupStandaloneMenu(panelId)

	local panel, entry = self:GetLayoutEntryStandaloneDialogEntry(panelId, entryId)
	local runtime = getRuntime(panelId)
	local hostFrame = runtime and runtime.frame or nil
	if not (panel and entry and hostFrame) then return end
	local spawnPosition = self:GetStandaloneDialogSpawnPosition(anchorFrame, hostFrame, 12, 0)
	local defaultStaticFontPath, defaultStaticFontSize, defaultStaticFontStyle = Helper.GetCountFontDefaults(hostFrame)
	local defaultCooldownFontPath, defaultCooldownFontSize, defaultCooldownFontStyle = self:GetCooldownFontDefaults(hostFrame)
	local defaultCountFontPath, defaultCountFontSize, defaultCountFontStyle = defaultStaticFontPath, defaultStaticFontSize, defaultStaticFontStyle
	local defaultChargesFontPath, defaultChargesFontSize, defaultChargesFontStyle = Helper.GetChargesFontDefaults(hostFrame)

	local function allowsStandaloneEntryReadySound(effectiveType) return effectiveType and effectiveType ~= "MACRO" and effectiveType ~= "STANCE" and effectiveType ~= "CDM_AURA" end

	local function getPanel() return CooldownPanels:GetPanel(panelId) end

	local function getLayout()
		local currentPanel = getPanel()
		return currentPanel and currentPanel.layout or nil
	end

	local function getEntry()
		local currentPanel, currentEntry = CooldownPanels:GetLayoutEntryStandaloneDialogEntry(panelId, entryId)
		return currentPanel, currentEntry
	end

	local function getEffectiveType()
		local _, currentEntry = getEntry()
		return CooldownPanels:GetLayoutEntryStandaloneEffectiveType(currentEntry)
	end

	local refreshEntryDialogPending = false

	local function isStandaloneDialogDragActive()
		if type(IsMouseButtonDown) ~= "function" then return false end
		return IsMouseButtonDown("LeftButton") == true
	end

	local function updateEntryDialog()
		local state = CooldownPanels:GetLayoutEntryStandaloneMenuState(false)
		local activeDialog = state and state.dialog
		if state and normalizeId(state.panelId) == panelId and normalizeId(state.entryId) == entryId and activeDialog then
			local _, currentEntry = getEntry()
			local title = currentEntry and CooldownPanels:GetEntryStandaloneTitle(currentEntry) or nil
			if activeDialog.context then activeDialog.context.title = title end
			if activeDialog.Title and title then activeDialog.Title:SetText(title) end
			if activeDialog.UpdateSettings then activeDialog:UpdateSettings() end
			if activeDialog.UpdateButtons then activeDialog:UpdateButtons() end
			if activeDialog.Layout then activeDialog:Layout() end
		end
	end

	local function scheduleEntryDialogRefresh()
		if refreshEntryDialogPending then return end
		refreshEntryDialogPending = true
		RunNextFrame(function()
			refreshEntryDialogPending = false
			local state = CooldownPanels:GetLayoutEntryStandaloneMenuState(false)
			if not state or normalizeId(state.panelId) ~= panelId or normalizeId(state.entryId) ~= entryId then return end
			if isStandaloneDialogDragActive() then
				scheduleEntryDialogRefresh()
				return
			end
			updateEntryDialog()
		end)
	end

	local function refreshEntryViews()
		CooldownPanels:RefreshPanel(panelId)
		if CooldownPanels.IsEditorOpen and CooldownPanels:IsEditorOpen() then CooldownPanels:RefreshEditor() end
		if isStandaloneDialogDragActive() then
			scheduleEntryDialogRefresh()
			return
		end
		updateEntryDialog()
	end

	local function refreshEntryPreview()
		CooldownPanels:RefreshPanel(panelId)
		if CooldownPanels.IsEditorOpen and CooldownPanels:IsEditorOpen() then CooldownPanels:RefreshEditor() end
	end

	local function setEntryField(field, value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		if currentEntry[field] == value then return end
		currentEntry[field] = value
		refreshEntryViews()
	end

	local function setEntryBoolean(field, value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local normalized = value == true
		if (currentEntry[field] == true) == normalized then return end
		currentEntry[field] = normalized and true or false
		CooldownPanels:HandleEntryBooleanMutation(panelId, entryId, currentEntry, field)
		refreshEntryViews()
	end

	local function setAlwaysShowLike(value)
		local layout = getLayout()
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local normalized = value == true
		if currentEntry.type == "STANCE" then
			if (currentEntry.showWhenMissing == true) == normalized then return end
			currentEntry.showWhenMissing = normalized and true or false
		elseif currentEntry.type == "CDM_AURA" then
			local mode = normalized and (CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE and CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE.SHOW or "SHOW")
				or (CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE and CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE.HIDE or "HIDE")
			local resolvedMode = CooldownPanels:ResolveEntryCDMAuraAlwaysShowMode(layout, currentEntry)
			if currentEntry.cdmAuraAlwaysShowUseGlobal == false and resolvedMode == mode then return end
			currentEntry.cdmAuraAlwaysShowUseGlobal = false
			currentEntry.cdmAuraAlwaysShowMode = mode
			currentEntry.alwaysShow = normalized and true or false
		else
			if (currentEntry.alwaysShow == true) == normalized then return end
			currentEntry.alwaysShow = normalized and true or false
		end
		refreshEntryViews()
	end

	local function refreshReadyGlowState(currentEntry)
		if not currentEntry then return end
		local runtimeState = getRuntime(panelId)
		local hadReady = runtimeState and runtimeState.readyAt and runtimeState.readyAt[entryId] ~= nil
		CooldownPanels.ClearReadyGlowEntryState(panelId, entryId, false)
		local primed = CooldownPanels.GetReadyGlowPrimedState(runtimeState)
		if primed then primed[entryId] = nil end
		if currentEntry.glowReady and hadReady then
			local resolvedDuration = CooldownPanels:ResolveEntryGlowStyle(getLayout(), currentEntry)
			triggerReadyGlow(panelId, entryId, resolvedDuration)
			if primed then primed[entryId] = true end
		end
	end

	local function setGlowOverrideEnabled(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local useGlobal = value ~= true
		if currentEntry.glowUseGlobal == useGlobal then return end
		currentEntry.glowUseGlobal = useGlobal
		CooldownPanels:RebuildPowerIndex()
		refreshReadyGlowState(currentEntry)
		refreshEntryViews()
	end

	local function setProcGlowOverrideEnabled(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local useGlobal = value ~= true
		if currentEntry.procGlowUseGlobal == useGlobal then return end
		currentEntry.procGlowUseGlobal = useGlobal
		refreshEntryViews()
	end

	local function setProcGlowEnabled(_, value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local normalized = value == true
		if currentEntry.procGlowEnabled == normalized then return end
		currentEntry.procGlowEnabled = normalized
		refreshEntryViews()
	end

	local function setProcGlowStyle(_, value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local layout = getLayout()
		local panelStyle = CooldownPanels:ResolveEntryProcGlowVisual(layout, nil)
		local normalized = Helper.NormalizeGlowStyle(value, panelStyle)
		currentEntry.procGlowStyle = normalized == panelStyle and nil or normalized
		refreshEntryViews()
	end

	local function setProcGlowInset(_, value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local layout = getLayout()
		local _, panelInset = CooldownPanels:ResolveEntryProcGlowVisual(layout, nil)
		local normalized = Helper.NormalizeGlowInset(value, panelInset)
		currentEntry.procGlowInset = normalized == panelInset and nil or normalized
		refreshEntryViews()
	end

	local function setGlowStyle(_, value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local layout = getLayout()
		local _, _, panelStyle = CooldownPanels:ResolveEntryGlowStyle(layout, nil)
		local normalized = Helper.NormalizeGlowStyle(value, panelStyle)
		currentEntry.glowStyle = normalized == panelStyle and nil or normalized
		refreshEntryViews()
	end

	local function setPandemicGlowStyle(_, value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local layout = getLayout()
		local _, panelStyle = CooldownPanels:ResolveEntryPandemicGlowVisual(layout, nil)
		local normalized = Helper.NormalizeGlowStyle(value, panelStyle)
		currentEntry.pandemicGlowStyle = normalized == panelStyle and nil or normalized
		refreshEntryViews()
	end

	local function setGlowInset(_, value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local layout = getLayout()
		local _, _, _, panelInset = CooldownPanels:ResolveEntryGlowStyle(layout, nil)
		local normalized = Helper.NormalizeGlowInset(value, panelInset)
		currentEntry.glowInset = normalized == panelInset and nil or normalized
		refreshEntryViews()
	end

	local function setPandemicGlowInset(_, value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local layout = getLayout()
		local _, _, panelInset = CooldownPanels:ResolveEntryPandemicGlowVisual(layout, nil)
		local normalized = Helper.NormalizeGlowInset(value, panelInset)
		currentEntry.pandemicGlowInset = normalized == panelInset and nil or normalized
		refreshEntryViews()
	end

	local function setGlowColor(_, value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		currentEntry.glowColor = Helper.NormalizeColor(value, Helper.PANEL_LAYOUT_DEFAULTS.readyGlowColor)
		refreshEntryViews()
	end

	local function setStaticText(_, value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local text = value or ""
		if currentEntry.staticText == text then return end
		currentEntry.staticText = text
		refreshEntryViews()
	end

	local function isStateTextureSupported()
		local effectiveType = getEffectiveType()
		return effectiveType == "SPELL" or effectiveType == "CDM_AURA"
	end

	local function entryHasStateTexture()
		local _, currentEntry = getEntry()
		return CooldownPanels:HasConfiguredStateTexture(currentEntry)
	end

	local function entryUsesDoubleStateTexture()
		local _, currentEntry = getEntry()
		return currentEntry and currentEntry.stateTextureDouble == true or false
	end

	local function setStateTextureInput(_, value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local ok, err = CooldownPanels:ApplyEntryStateTextureInput(currentEntry, value)
		if not ok then
			showErrorMessage(err)
			refreshEntryViews()
			refreshStandaloneSettingValues()
			return
		end
		refreshEntryPreview()
		refreshStandaloneSettingValues()
	end

	local function setStateTextureField(field, value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		if currentEntry[field] == value then return end
		currentEntry[field] = value
		refreshEntryPreview()
	end

	local function setCooldownTextOverrideEnabled(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local useGlobal = value ~= true
		if currentEntry.cooldownTextUseGlobal == useGlobal then return end
		currentEntry.cooldownTextUseGlobal = useGlobal
		refreshEntryViews()
	end

	local function setStaticTextOverrideEnabled(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local useGlobal = value ~= true
		if currentEntry.staticTextUseGlobal == useGlobal then return end
		currentEntry.staticTextUseGlobal = useGlobal
		refreshEntryViews()
	end

	local function setShowIconOverrideEnabled(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local useGlobal = value ~= true
		if currentEntry.showIconTextureUseGlobal == useGlobal then return end
		currentEntry.showIconTextureUseGlobal = useGlobal
		refreshEntryViews()
	end

	local function setCooldownVisualsOverrideEnabled(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local useGlobal = value ~= true
		if currentEntry.cooldownVisualsUseGlobal == useGlobal then return end
		currentEntry.cooldownVisualsUseGlobal = useGlobal
		refreshEntryViews()
	end

	local function setCustomCooldownDurationEnabled(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local normalized = value == true
		if normalized and not ((tonumber(currentEntry.customCooldownDuration) or 0) > 0) then
			currentEntry.customCooldownDuration = 60
		end
		if currentEntry.customCooldownDurationEnabled == normalized then return end
		currentEntry.customCooldownDurationEnabled = normalized
		if normalized then currentEntry.autoCooldownDurationEnabled = false end
		if normalized then currentEntry.cdmAuraOverlayEnabled = false end
		CooldownPanels:ClearEntryCustomCooldownDuration(panelId, entryId, true)
		refreshEntryViews()
	end

	local function setCDMAuraOverlayEnabled(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local normalized = value == true
		if normalized and not CooldownPanels:SupportsEntryCDMAuraOverlay(currentEntry, getEffectiveType()) then return end
		if currentEntry.cdmAuraOverlayEnabled == normalized then return end
		currentEntry.cdmAuraOverlayEnabled = normalized
		if normalized then
			currentEntry.customCooldownDurationEnabled = false
			currentEntry.autoCooldownDurationEnabled = false
		end
		CooldownPanels:ClearEntryCustomCooldownDuration(panelId, entryId, true)
		CooldownPanels:RebuildSpellIndex()
		refreshEntryViews()
	end

	local function setAutoCooldownDurationEnabled(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local normalized = value == true
		if normalized and not CooldownPanels:SupportsEntryAutoCooldownDuration(currentEntry, getEffectiveType()) then return end
		if currentEntry.autoCooldownDurationEnabled == normalized then return end
		currentEntry.autoCooldownDurationEnabled = normalized
		if normalized then currentEntry.customCooldownDurationEnabled = false end
		if normalized then currentEntry.cdmAuraOverlayEnabled = false end
		CooldownPanels:ClearEntryCustomCooldownDuration(panelId, entryId, true)
		refreshEntryViews()
	end

	local function setCustomCooldownDuration(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local duration = Helper.ClampNumber(value, 0, Helper.CUSTOM_COOLDOWN_DURATION_MAX or 300, currentEntry.customCooldownDuration or 0) or 0
		local enabledChanged = false
		if duration <= 0 then
			duration = 0
			if currentEntry.customCooldownDurationEnabled ~= false then
				currentEntry.customCooldownDurationEnabled = false
				enabledChanged = true
			end
		end
		if currentEntry.customCooldownDuration == duration and not enabledChanged then return end
		currentEntry.customCooldownDuration = duration
		CooldownPanels:ClearEntryCustomCooldownDuration(panelId, entryId, true)
		refreshEntryViews()
	end

	local function setCooldownVisibilityOverrideEnabled(value)
		local layout = getLayout()
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local useGlobal = value ~= true
		if currentEntry.cooldownVisibilityUseGlobal == useGlobal then return end
		if not useGlobal then
			currentEntry.hideOnCooldown, currentEntry.showOnCooldown = CooldownPanels:ResolveEntryCooldownVisibility(layout, currentEntry)
		end
		currentEntry.cooldownVisibilityUseGlobal = useGlobal
		refreshEntryViews()
	end

	local function setCooldownVisibility(field, value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local hideOnCooldown = currentEntry.hideOnCooldown == true
		local showOnCooldown = currentEntry.showOnCooldown == true
		if field == "hideOnCooldown" then
			hideOnCooldown = value == true
			if hideOnCooldown then showOnCooldown = false end
		elseif field == "showOnCooldown" then
			showOnCooldown = value == true
			if showOnCooldown then hideOnCooldown = false end
		else
			return
		end
		if currentEntry.hideOnCooldown == hideOnCooldown and currentEntry.showOnCooldown == showOnCooldown then return end
		currentEntry.hideOnCooldown = hideOnCooldown
		currentEntry.showOnCooldown = showOnCooldown
		refreshEntryViews()
	end

	local function setIconSizeOverrideEnabled(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local useGlobal = value ~= true
		if currentEntry.iconSizeUseGlobal == useGlobal then return end
		currentEntry.iconSizeUseGlobal = useGlobal
		if not useGlobal then
			currentEntry.iconWidth = Helper.ClampInt(currentEntry.iconWidth, 12, 128, currentEntry.iconSize or Helper.ENTRY_DEFAULTS.iconSize)
			currentEntry.iconHeight = Helper.ClampInt(currentEntry.iconHeight, 12, 128, currentEntry.iconSize or Helper.ENTRY_DEFAULTS.iconSize)
		end
		refreshEntryViews()
	end

	local function setEntrySeparateIconSizeEnabled(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local enabled = value == true
		if currentEntry.iconSizeSeparate == enabled then return end
		currentEntry.iconSizeSeparate = enabled
		if enabled then
			currentEntry.iconSizeUseGlobal = false
			currentEntry.iconWidth = Helper.ClampInt(currentEntry.iconWidth, 12, 128, currentEntry.iconSize or Helper.ENTRY_DEFAULTS.iconSize)
			currentEntry.iconHeight = Helper.ClampInt(currentEntry.iconHeight, 12, 128, currentEntry.iconSize or Helper.ENTRY_DEFAULTS.iconSize)
		end
		refreshEntryViews()
	end

	local function setNoDesaturationOverrideEnabled(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local useGlobal = value ~= true
		if currentEntry.noDesaturationUseGlobal == useGlobal then return end
		currentEntry.noDesaturationUseGlobal = useGlobal
		refreshEntryViews()
	end

	local function setHideWhenNoResourceOverrideEnabled(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local useGlobal = value ~= true
		if currentEntry.hideWhenNoResourceUseGlobal == useGlobal then return end
		currentEntry.hideWhenNoResourceUseGlobal = useGlobal
		CooldownPanels:RebuildPowerIndex()
		refreshEntryViews()
	end

	local function setCDMAuraAlwaysShowOverrideEnabled(value)
		local layout = getLayout()
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local useGlobal = value ~= true
		if currentEntry.cdmAuraAlwaysShowUseGlobal == useGlobal then return end
		if not useGlobal then currentEntry.cdmAuraAlwaysShowMode = CooldownPanels:ResolveEntryCDMAuraAlwaysShowMode(layout, currentEntry) end
		currentEntry.cdmAuraAlwaysShowUseGlobal = useGlobal
		currentEntry.alwaysShow = CooldownPanels:IsCDMAuraAlwaysShowModeVisibleWhenInactive(CooldownPanels:ResolveEntryCDMAuraAlwaysShowMode(layout, currentEntry))
		refreshEntryViews()
	end

	local function setCDMAuraAlwaysShowMode(_, value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local normalized = CooldownPanels:NormalizeCDMAuraAlwaysShowMode(value, CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE and CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE.HIDE or "HIDE")
		if currentEntry.cdmAuraAlwaysShowMode == normalized then return end
		currentEntry.cdmAuraAlwaysShowMode = normalized
		currentEntry.alwaysShow = CooldownPanels:IsCDMAuraAlwaysShowModeVisibleWhenInactive(normalized)
		refreshEntryViews()
	end

	local function setStackStyleOverrideEnabled(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local useGlobal = value ~= true
		if currentEntry.stackStyleUseGlobal == useGlobal then return end
		currentEntry.stackStyleUseGlobal = useGlobal
		refreshEntryViews()
	end

	local function setChargesStyleOverrideEnabled(value)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local useGlobal = value ~= true
		if currentEntry.chargesStyleUseGlobal == useGlobal then return end
		currentEntry.chargesStyleUseGlobal = useGlobal
		refreshEntryViews()
	end

	local function setSoundReadyFile(soundName)
		local _, currentEntry = getEntry()
		if not currentEntry then return end
		local _, _, soundField = CooldownPanels:GetEntrySoundConfig(currentEntry)
		if not soundField then return end
		local normalized = normalizeSoundName(soundName)
		if normalizeSoundName(currentEntry[soundField]) == normalized then return end
		currentEntry[soundField] = normalized
		playSoundName(normalized)
		refreshEntryViews()
	end

	local function getCooldownTextFontSelection()
		local _, currentEntry = getEntry()
		return CooldownPanels:GetFontDropdownValue(currentEntry and currentEntry.cooldownTextFont)
	end

	local function getResolvedCooldownTextStyleChoice()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		local panelChoice = Helper.NormalizeFontStyleChoice(layout and layout.cooldownTextStyle, defaultCooldownFontStyle)
		if currentEntry and currentEntry.cooldownTextUseGlobal == false then return Helper.NormalizeFontStyleChoice(currentEntry.cooldownTextStyle, panelChoice) end
		return panelChoice
	end

	local function getResolvedCooldownTextColor()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		local _, _, _, _, color =
			CooldownPanels:ResolveEntryCooldownTextStyle(layout, currentEntry, defaultCooldownFontPath, defaultCooldownFontSize, defaultCooldownFontStyle)
		return color
	end

	local function getStaticTextFontSelection()
		local _, currentEntry = getEntry()
		return CooldownPanels:GetFontDropdownValue(currentEntry and currentEntry.staticTextFont)
	end

	local function getResolvedStaticTextStyleChoice()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		local panelChoice = Helper.NormalizeFontStyleChoice(layout and layout.staticTextStyle, Helper.PANEL_LAYOUT_DEFAULTS.staticTextStyle or defaultStaticFontStyle)
		if currentEntry and currentEntry.staticTextUseGlobal == false then return Helper.NormalizeFontStyleChoice(currentEntry.staticTextStyle, panelChoice) end
		return panelChoice
	end

	local function getResolvedStaticTextColor()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		local _, _, _, _, color =
			CooldownPanels:ResolveEntryStaticTextStyle(layout, currentEntry, defaultStaticFontPath, defaultStaticFontSize, defaultStaticFontStyle)
		return color
	end

	local function getStackFontSelection()
		local _, currentEntry = getEntry()
		return CooldownPanels:GetFontDropdownValue(currentEntry and currentEntry.stackFont)
	end

	local function getResolvedStackStyleChoice()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		local panelChoice = Helper.NormalizeFontStyleChoice(layout and layout.stackFontStyle, defaultCountFontStyle)
		if currentEntry and currentEntry.stackStyleUseGlobal == false then return Helper.NormalizeFontStyleChoice(currentEntry.stackFontStyle, panelChoice) end
		return panelChoice
	end

	local function getResolvedStackColor()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		local _, _, _, _, color =
			CooldownPanels:ResolveEntryStackTextStyle(layout, currentEntry, defaultCountFontPath, defaultCountFontSize, defaultCountFontStyle)
		return color
	end

	local function getChargesFontSelection()
		local _, currentEntry = getEntry()
		return CooldownPanels:GetFontDropdownValue(currentEntry and currentEntry.chargesFont)
	end

	local function getResolvedChargesStyleChoice()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		local panelChoice = Helper.NormalizeFontStyleChoice(layout and layout.chargesFontStyle, defaultChargesFontStyle)
		if currentEntry and currentEntry.chargesStyleUseGlobal == false then return Helper.NormalizeFontStyleChoice(currentEntry.chargesFontStyle, panelChoice) end
		return panelChoice
	end

	local function getResolvedChargesColor()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		local _, _, _, _, color =
			CooldownPanels:ResolveEntryChargesTextStyle(layout, currentEntry, defaultChargesFontPath, defaultChargesFontSize, defaultChargesFontStyle)
		return color
	end

	local function getResolvedGlowColor()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		local _, color = CooldownPanels:ResolveEntryGlowStyle(layout, currentEntry)
		return color
	end

	local function getResolvedGlowStyle()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		local _, _, style = CooldownPanels:ResolveEntryGlowStyle(layout, currentEntry)
		return Helper.NormalizeGlowStyle(style, Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle)
	end

	local function getResolvedGlowInset()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		local _, _, _, inset = CooldownPanels:ResolveEntryGlowStyle(layout, currentEntry)
		return inset
	end

	local function getResolvedPandemicGlowColor()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		return CooldownPanels:ResolveEntryPandemicGlowColor(layout, currentEntry)
	end

	local function getResolvedPandemicGlowStyle()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		local _, style = CooldownPanels:ResolveEntryPandemicGlowVisual(layout, currentEntry)
		return Helper.NormalizeGlowStyle(style, layout and layout.readyGlowStyle or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle)
	end

	local function getResolvedPandemicGlowInset()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		local _, _, inset = CooldownPanels:ResolveEntryPandemicGlowVisual(layout, currentEntry)
		return inset
	end

	local function getResolvedProcGlowEnabled()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		return CooldownPanels:ResolveEntryProcGlowEnabled(layout, currentEntry)
	end

	local function getResolvedProcGlowStyle()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		local style = CooldownPanels:ResolveEntryProcGlowVisual(layout, currentEntry)
		return Helper.NormalizeGlowStyle(style, layout and layout.readyGlowStyle or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle)
	end

	local function getResolvedProcGlowInset()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		local _, inset = CooldownPanels:ResolveEntryProcGlowVisual(layout, currentEntry)
		return inset
	end

	local function getResolvedReadyGlowCheckPower()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		return CooldownPanels:ResolveEntryReadyGlowCheckPower(layout, currentEntry)
	end

	local function entryUsesConfiguredGlow(currentEntry)
		if not currentEntry or currentEntry.type == "MACRO" then return false end
		if currentEntry.type == "SPELL" then return true end
		if currentEntry.type == "CDM_AURA" then return currentEntry.glowReady == true or currentEntry.pandemicGlow == true end
		return currentEntry.glowReady == true
	end

	local function getResolvedNoDesaturation()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		return CooldownPanels:ResolveEntryNoDesaturation(layout, currentEntry)
	end

	local function getResolvedHideOnCooldown()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		return select(1, CooldownPanels:ResolveEntryCooldownVisibility(layout, currentEntry))
	end

	local function getResolvedShowOnCooldown()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		return select(2, CooldownPanels:ResolveEntryCooldownVisibility(layout, currentEntry))
	end

	local function getResolvedHideWhenNoResource()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		return CooldownPanels:ResolveEntryHideWhenNoResource(layout, currentEntry)
	end

	local function getResolvedCDMAuraAlwaysShowMode()
		local layout = getLayout()
		local _, currentEntry = getEntry()
		return CooldownPanels:ResolveEntryCDMAuraAlwaysShowMode(layout, currentEntry)
	end

	local function getRacialExclusionOptions()
		local options = {}
		for _, definition in ipairs(CooldownPanels:GetRacialExclusionDefinitions()) do
			local spellId = tonumber(definition and definition.spellID)
			if spellId then
				local sortLabel = getSpellName(spellId) or tostring(spellId)
				local label = sortLabel
				local iconToken = C_Spell and C_Spell.GetSpellTexture and C_Spell.GetSpellTexture(spellId) or nil
				local iconType = type(iconToken)
				if (iconType == "string" and iconToken ~= "") or iconType == "number" then label = string.format("|T%s:14:14:0:0:64:64:4:60:4:60|t %s", tostring(iconToken), label) end
				options[#options + 1] = { value = spellId, label = label, sortLabel = sortLabel }
			end
		end
		table.sort(options, function(a, b)
			local left = string.lower(tostring(a and a.sortLabel or ""))
			local right = string.lower(tostring(b and b.sortLabel or ""))
			if left == right then return (tonumber(a and a.value) or 0) < (tonumber(b and b.value) or 0) end
			return left < right
		end)
		return options
	end

	local function isRacialEntrySelected()
		local _, currentEntry = getEntry()
		return CooldownPanels:IsRacialSpellEntry(currentEntry)
	end

	local function isRacialVariantExcluded(_, spellId)
		local _, currentEntry = getEntry()
		spellId = tonumber(spellId)
		return spellId and type(currentEntry and currentEntry.racialVariantExclusions) == "table" and currentEntry.racialVariantExclusions[spellId] == true or false
	end

	local function setRacialVariantExcluded(_, spellId, selected)
		local _, currentEntry = getEntry()
		spellId = tonumber(spellId)
		if not (currentEntry and spellId) then return end
		currentEntry.racialVariantExclusions = currentEntry.racialVariantExclusions or {}
		currentEntry.racialVariantExclusions[spellId] = selected and true or nil
		if not next(currentEntry.racialVariantExclusions) then currentEntry.racialVariantExclusions = nil end
		CooldownPanels:RebuildSpellIndex()
		refreshEntryPreview()
	end

	local function getResolvedIconSize()
		local layout = getLayout()
		local runtimeState = getRuntime(panelId)
		local currentIcon = runtimeState and runtimeState.entryToIcon and runtimeState.entryToIcon[entryId] or nil
		local baseSize = currentIcon and currentIcon._eqolBaseSlotSize or Helper.ClampInt(layout and layout.iconSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
		local _, currentEntry = getEntry()
		local size = CooldownPanels:ResolveEntryIconVisualLayout(layout, currentEntry, baseSize)
		return size
	end
	local function getResolvedIconDimensions()
		local layout = getLayout()
		local runtimeState = getRuntime(panelId)
		local currentIcon = runtimeState and runtimeState.entryToIcon and runtimeState.entryToIcon[entryId] or nil
		local baseSize = currentIcon and currentIcon._eqolBaseSlotSize or Helper.ClampInt(layout and layout.iconSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
		local _, currentEntry = getEntry()
		local size, _, _, width, height = CooldownPanels:ResolveEntryIconVisualLayout(layout, currentEntry, baseSize)
		return width or size, height or size
	end

	local initialEffectiveType = getEffectiveType()
	local settings = {
		{
			name = L["Display"] or "Display",
			kind = SettingType.Collapsible,
			id = "cooldownPanelStandaloneDisplay",
			defaultCollapsed = false,
		},
		{
			name = (initialEffectiveType == "STANCE" and (L["CooldownPanelShowWhenMissing"] or "Show when missing")) or (L["Always show"] or "Always show"),
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneDisplay",
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "ITEM" or effectiveType == "STANCE"
			end,
			get = function()
				local _, currentEntry = getEntry()
				if not currentEntry then return false end
				if currentEntry.type == "STANCE" then return currentEntry.showWhenMissing == true end
				return currentEntry.alwaysShow ~= false
			end,
			set = function(_, value) setAlwaysShowLike(value) end,
		},
		{
			name = L["CooldownPanelOverwritePanelCDMAuraAlwaysShow"] or "Overwrite panel tracked aura display",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneDisplay",
			isShown = function() return getEffectiveType() == "CDM_AURA" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.cdmAuraAlwaysShowUseGlobal == false or false
			end,
			set = function(_, value) setCDMAuraAlwaysShowOverrideEnabled(value) end,
		},
		{
			name = L["CooldownPanelCDMAuraAlwaysShowMode"] or "Tracked aura display",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneDisplay",
			height = 180,
			isShown = function() return getEffectiveType() == "CDM_AURA" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.cdmAuraAlwaysShowUseGlobal == false)
			end,
			get = function() return getResolvedCDMAuraAlwaysShowMode() end,
			set = function(_, value) setCDMAuraAlwaysShowMode(nil, value) end,
			generator = function(_, root)
				for _, option in ipairs(CooldownPanels:GetCDMAuraAlwaysShowOptions()) do
					root:CreateRadio(option.label, function() return getResolvedCDMAuraAlwaysShowMode() == option.value end, function() setCDMAuraAlwaysShowMode(nil, option.value) end)
				end
			end,
		},
		{
			name = L["CooldownPanelOverwriteGlobalDefault"] or "Overwrite global default",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneDisplay",
			isShown = function() return getEffectiveType() ~= "CDM_AURA" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.cooldownVisibilityUseGlobal == false or false
			end,
			set = function(_, value) setCooldownVisibilityOverrideEnabled(value) end,
		},
		{
			name = L["CooldownPanelHideOnCooldown"] or "Hide on cooldown",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneDisplay",
			isShown = function() return getEffectiveType() ~= "CDM_AURA" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.cooldownVisibilityUseGlobal == false)
			end,
			get = function() return getResolvedHideOnCooldown() end,
			set = function(_, value) setCooldownVisibility("hideOnCooldown", value) end,
		},
		{
			name = L["CooldownPanelShowOnCooldown"] or "Show on cooldown",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneDisplay",
			isShown = function() return getEffectiveType() ~= "CDM_AURA" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.cooldownVisibilityUseGlobal == false)
			end,
			get = function() return getResolvedShowOnCooldown() end,
			set = function(_, value) setCooldownVisibility("showOnCooldown", value) end,
		},
		{
			name = L["CooldownPanelTrackPassiveSpell"] or "Track passive spell",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneDisplay",
			isShown = function() return getEffectiveType() == "SPELL" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.trackPassiveSpell == true or false
			end,
			set = function(_, value) setEntryBoolean("trackPassiveSpell", value) end,
		},
		{
			name = L["CooldownPanelRacialExclusions"] or "Excluded racials",
			kind = SettingType.MultiDropdown,
			field = "racialVariantExclusions",
			parentId = "cooldownPanelStandaloneDisplay",
			height = 260,
			values = getRacialExclusionOptions(),
			isShown = isRacialEntrySelected,
			isSelected = isRacialVariantExcluded,
			setSelected = setRacialVariantExcluded,
		},
		{
			name = L["CooldownPanelShowItemCount"] or "Show item count",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneDisplay",
			isShown = function() return getEffectiveType() == "ITEM" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.showItemCount ~= false or false
			end,
			set = function(_, value) setEntryBoolean("showItemCount", value) end,
		},
		{
			name = L["CooldownPanelShowItemUses"] or "Show item uses",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneDisplay",
			isShown = function() return getEffectiveType() == "ITEM" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.showItemUses == true or false
			end,
			set = function(_, value) setEntryBoolean("showItemUses", value) end,
		},
		{
			name = L["CooldownPanelUseHighestRank"] or "Use highest rank",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneDisplay",
			isShown = function()
				local _, currentEntry = getEntry()
				if not currentEntry or currentEntry.type ~= "ITEM" then return false end
				CooldownPanels:EnsureFoodRankGroupsLoaded()
				local itemID = tonumber(currentEntry.itemID)
				local rankMap = CooldownPanels.itemHighestRankByID
				return itemID and rankMap and rankMap[itemID] ~= nil
			end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.useHighestRank == true or false
			end,
			set = function(_, value) setEntryBoolean("useHighestRank", value) end,
		},
		{
			name = L["CooldownPanelShowWhenEmpty"] or "Show when empty",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneDisplay",
			isShown = function() return getEffectiveType() == "ITEM" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.showWhenEmpty == true or false
			end,
			set = function(_, value) setEntryBoolean("showWhenEmpty", value) end,
		},
		{
			name = L["CooldownPanelShowWhenNoCooldown"] or "Show even without cooldown",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneDisplay",
			isShown = function() return getEffectiveType() == "SLOT" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.showWhenNoCooldown == true or false
			end,
			set = function(_, value) setEntryBoolean("showWhenNoCooldown", value) end,
		},
		{
			name = L["Hide icon"] or "Hide icon",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneDisplay",
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and (currentEntry.hideIcon == true or CooldownPanels:HasConfiguredStateTexture(currentEntry)) or false
			end,
			disabled = function() return entryHasStateTexture() end,
			set = function(_, value) setEntryBoolean("hideIcon", value) end,
		},
		{
			name = L["CooldownPanelOverwriteGlobalDefault"] or "Overwrite global default",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneDisplay",
			disabled = function() return entryHasStateTexture() end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.showIconTextureUseGlobal == false or false
			end,
			set = function(_, value) setShowIconOverrideEnabled(value) end,
		},
		{
			name = L["CooldownPanelOverwriteGlobalSize"] or "Overwrite global size",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneDisplay",
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.iconSizeUseGlobal == false or false
			end,
			set = function(_, value) setIconSizeOverrideEnabled(value) end,
		},
		{
			name = L["Icon size"] or "Icon size",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneDisplay",
			minValue = 12,
			maxValue = 128,
			valueStep = 1,
			allowInput = true,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.iconSizeUseGlobal == false) or (currentEntry and currentEntry.iconSizeSeparate == true)
			end,
			get = function() return getResolvedIconSize() end,
			set = function(_, value)
				local _, currentEntry = getEntry()
				if not currentEntry then return end
				local size = Helper.ClampInt(value, 12, 128, getResolvedIconSize())
				if currentEntry.iconSize == size then return end
				currentEntry.iconSize = size
				refreshEntryViews()
			end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelSeparateIconSize"] or "Separate icon width/height",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneDisplay",
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.iconSizeUseGlobal == false)
			end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.iconSizeSeparate == true or false
			end,
			set = function(_, value) setEntrySeparateIconSizeEnabled(value) end,
		},
		{
			name = L["CooldownPanelIconWidth"] or "Icon width",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneDisplay",
			minValue = 12,
			maxValue = 128,
			valueStep = 1,
			allowInput = true,
			isShown = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.iconSizeUseGlobal == false and currentEntry.iconSizeSeparate == true or false
			end,
			get = function()
				local width = getResolvedIconDimensions()
				return width
			end,
			set = function(_, value)
				local _, currentEntry = getEntry()
				if not currentEntry then return end
				local width = Helper.ClampInt(value, 12, 128, currentEntry.iconWidth or currentEntry.iconSize or Helper.ENTRY_DEFAULTS.iconWidth)
				if currentEntry.iconWidth == width then return end
				currentEntry.iconWidth = width
				refreshEntryViews()
			end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelIconHeight"] or "Icon height",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneDisplay",
			minValue = 12,
			maxValue = 128,
			valueStep = 1,
			allowInput = true,
			isShown = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.iconSizeUseGlobal == false and currentEntry.iconSizeSeparate == true or false
			end,
			get = function()
				local _, height = getResolvedIconDimensions()
				return height
			end,
			set = function(_, value)
				local _, currentEntry = getEntry()
				if not currentEntry then return end
				local height = Helper.ClampInt(value, 12, 128, currentEntry.iconHeight or currentEntry.iconSize or Helper.ENTRY_DEFAULTS.iconHeight)
				if currentEntry.iconHeight == height then return end
				currentEntry.iconHeight = height
				refreshEntryViews()
			end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["Icon X"] or "Icon X",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneDisplay",
			minValue = -Helper.OFFSET_RANGE,
			maxValue = Helper.STATE_TEXTURE_SPACING_RANGE or 2000,
			valueStep = 1,
			allowInput = true,
			get = function()
				local _, currentEntry = getEntry()
				return Helper.ClampInt(currentEntry and currentEntry.iconOffsetX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
			end,
			set = function(_, value) setEntryField("iconOffsetX", Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["Icon Y"] or "Icon Y",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneDisplay",
			minValue = -Helper.OFFSET_RANGE,
			maxValue = Helper.STATE_TEXTURE_SPACING_RANGE or 2000,
			valueStep = 1,
			allowInput = true,
			get = function()
				local _, currentEntry = getEntry()
				return Helper.ClampInt(currentEntry and currentEntry.iconOffsetY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
			end,
			set = function(_, value) setEntryField("iconOffsetY", Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelStacksHeader"] or "Stacks / Item Count",
			kind = SettingType.Collapsible,
			id = "cooldownPanelStandaloneStacks",
			defaultCollapsed = true,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "CDM_AURA" or effectiveType == "ITEM"
			end,
		},
		{
			name = L["CooldownPanelShowStacks"] or "Show stack count",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneStacks",
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "CDM_AURA"
			end,
			get = function()
				local _, currentEntry = getEntry()
				local effectiveType = getEffectiveType()
				return shouldShowEntryStacks(currentEntry, effectiveType)
			end,
			set = function(_, value) setEntryBoolean("showStacks", value) end,
		},
		{
			name = L["CooldownPanelOverwriteGlobalDefault"] or "Overwrite global default",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneStacks",
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "CDM_AURA" or effectiveType == "ITEM"
			end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.stackStyleUseGlobal == false or false
			end,
			set = function(_, value) setStackStyleOverrideEnabled(value) end,
		},
		{
			name = L["CooldownPanelCountAnchor"] or "Count anchor",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneStacks",
			height = 160,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "CDM_AURA" or effectiveType == "ITEM"
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.stackStyleUseGlobal == false)
			end,
			get = function()
				local _, _, _, _, _, anchor =
					CooldownPanels:ResolveEntryStackTextStyle(getLayout(), select(2, getEntry()), defaultCountFontPath, defaultCountFontSize, defaultCountFontStyle)
				return anchor
			end,
			set = function(_, value) setEntryField("stackAnchor", Helper.NormalizeAnchor(value, Helper.PANEL_LAYOUT_DEFAULTS.stackAnchor or "BOTTOMRIGHT")) end,
			generator = function(_, root)
				for _, option in ipairs(Helper.AnchorOptions) do
					root:CreateRadio(option.label, function()
						local _, _, _, _, _, anchor =
							CooldownPanels:ResolveEntryStackTextStyle(getLayout(), select(2, getEntry()), defaultCountFontPath, defaultCountFontSize, defaultCountFontStyle)
						return anchor == option.value
					end, function() setEntryField("stackAnchor", option.value) end)
				end
			end,
		},
		{
			name = L["CooldownPanelCountOffsetX"] or "Count X",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneStacks",
			minValue = -Helper.OFFSET_RANGE,
			maxValue = Helper.OFFSET_RANGE,
			valueStep = 1,
			allowInput = true,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "CDM_AURA" or effectiveType == "ITEM"
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.stackStyleUseGlobal == false)
			end,
			get = function()
				local _, _, _, _, _, _, x =
					CooldownPanels:ResolveEntryStackTextStyle(getLayout(), select(2, getEntry()), defaultCountFontPath, defaultCountFontSize, defaultCountFontStyle)
				return x
			end,
			set = function(_, value) setEntryField("stackX", Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelCountOffsetY"] or "Count Y",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneStacks",
			minValue = -Helper.OFFSET_RANGE,
			maxValue = Helper.OFFSET_RANGE,
			valueStep = 1,
			allowInput = true,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "CDM_AURA" or effectiveType == "ITEM"
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.stackStyleUseGlobal == false)
			end,
			get = function()
				local _, _, _, _, _, _, _, y =
					CooldownPanels:ResolveEntryStackTextStyle(getLayout(), select(2, getEntry()), defaultCountFontPath, defaultCountFontSize, defaultCountFontStyle)
				return y
			end,
			set = function(_, value) setEntryField("stackY", Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["Font"] or "Font",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneStacks",
			height = 220,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "CDM_AURA" or effectiveType == "ITEM"
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.stackStyleUseGlobal == false)
			end,
			get = function() return getStackFontSelection() end,
			set = function(_, value) setEntryField("stackFont", value) end,
			generator = function(_, root)
				for _, option in ipairs(Helper.GetFontOptions(defaultCountFontPath)) do
					root:CreateRadio(option.label, function() return getStackFontSelection() == option.value end, function() setEntryField("stackFont", option.value) end)
				end
			end,
		},
		{
			name = L["CooldownPanelFontStyle"] or "Font style",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneStacks",
			height = 120,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "CDM_AURA" or effectiveType == "ITEM"
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.stackStyleUseGlobal == false)
			end,
			get = function() return getResolvedStackStyleChoice() end,
			set = function(_, value) setEntryField("stackFontStyle", Helper.NormalizeFontStyleChoice(value, Helper.PANEL_LAYOUT_DEFAULTS.stackFontStyle or "OUTLINE")) end,
			generator = function(_, root)
				for _, option in ipairs(Helper.FontStyleOptions) do
					root:CreateRadio(option.label, function() return getResolvedStackStyleChoice() == option.value end, function() setEntryField("stackFontStyle", option.value) end)
				end
			end,
		},
		{
			name = _G.COLOR or "Color",
			kind = SettingType.Color,
			parentId = "cooldownPanelStandaloneStacks",
			hasOpacity = true,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "CDM_AURA" or effectiveType == "ITEM"
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.stackStyleUseGlobal == false)
			end,
			get = function()
				local color = getResolvedStackColor()
				return { r = color[1], g = color[2], b = color[3], a = color[4] }
			end,
			set = function(_, value) setEntryField("stackColor", Helper.NormalizeColor(value, Helper.PANEL_LAYOUT_DEFAULTS.stackColor or { 1, 1, 1, 1 })) end,
		},
		{
			name = FONT_SIZE,
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneStacks",
			minValue = 6,
			maxValue = 64,
			valueStep = 1,
			allowInput = true,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "CDM_AURA" or effectiveType == "ITEM"
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.stackStyleUseGlobal == false)
			end,
			get = function()
				local _, size = CooldownPanels:ResolveEntryStackTextStyle(getLayout(), select(2, getEntry()), defaultCountFontPath, defaultCountFontSize, defaultCountFontStyle)
				return size
			end,
			set = function(_, value) setEntryField("stackFontSize", Helper.ClampInt(value, 6, 64, defaultCountFontSize or 12)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["Charges"] or "Charges",
			kind = SettingType.Collapsible,
			id = "cooldownPanelStandaloneCharges",
			defaultCollapsed = true,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "ITEM"
			end,
		},
		{
			name = L["Show charges"] or "Show charges",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCharges",
			isShown = function() return getEffectiveType() == "SPELL" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.showCharges == true or false
			end,
			set = function(_, value) setEntryBoolean("showCharges", value) end,
		},
		{
			name = L["CooldownPanelOverwriteGlobalDefault"] or "Overwrite global default",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCharges",
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "ITEM"
			end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.chargesStyleUseGlobal == false or false
			end,
			set = function(_, value) setChargesStyleOverrideEnabled(value) end,
		},
		{
			name = L["CooldownPanelChargesAnchor"] or "Charges anchor",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneCharges",
			height = 160,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "ITEM"
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.chargesStyleUseGlobal == false)
			end,
			get = function()
				local _, _, _, _, _, anchor =
					CooldownPanels:ResolveEntryChargesTextStyle(getLayout(), select(2, getEntry()), defaultChargesFontPath, defaultChargesFontSize, defaultChargesFontStyle)
				return anchor
			end,
			set = function(_, value) setEntryField("chargesAnchor", Helper.NormalizeAnchor(value, Helper.PANEL_LAYOUT_DEFAULTS.chargesAnchor or "TOP")) end,
			generator = function(_, root)
				for _, option in ipairs(Helper.AnchorOptions) do
					root:CreateRadio(option.label, function()
						local _, _, _, _, _, anchor =
							CooldownPanels:ResolveEntryChargesTextStyle(getLayout(), select(2, getEntry()), defaultChargesFontPath, defaultChargesFontSize, defaultChargesFontStyle)
						return anchor == option.value
					end, function() setEntryField("chargesAnchor", option.value) end)
				end
			end,
		},
		{
			name = L["CooldownPanelChargesOffsetX"] or "Charges X",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneCharges",
			minValue = -Helper.OFFSET_RANGE,
			maxValue = Helper.OFFSET_RANGE,
			valueStep = 1,
			allowInput = true,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "ITEM"
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.chargesStyleUseGlobal == false)
			end,
			get = function()
				local _, _, _, _, _, _, x =
					CooldownPanels:ResolveEntryChargesTextStyle(getLayout(), select(2, getEntry()), defaultChargesFontPath, defaultChargesFontSize, defaultChargesFontStyle)
				return x
			end,
			set = function(_, value) setEntryField("chargesX", Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelChargesOffsetY"] or "Charges Y",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneCharges",
			minValue = -Helper.OFFSET_RANGE,
			maxValue = Helper.OFFSET_RANGE,
			valueStep = 1,
			allowInput = true,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "ITEM"
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.chargesStyleUseGlobal == false)
			end,
			get = function()
				local _, _, _, _, _, _, _, y =
					CooldownPanels:ResolveEntryChargesTextStyle(getLayout(), select(2, getEntry()), defaultChargesFontPath, defaultChargesFontSize, defaultChargesFontStyle)
				return y
			end,
			set = function(_, value) setEntryField("chargesY", Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["Font"] or "Font",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneCharges",
			height = 220,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "ITEM"
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.chargesStyleUseGlobal == false)
			end,
			get = function() return getChargesFontSelection() end,
			set = function(_, value) setEntryField("chargesFont", value) end,
			generator = function(_, root)
				for _, option in ipairs(Helper.GetFontOptions(defaultChargesFontPath)) do
					root:CreateRadio(option.label, function() return getChargesFontSelection() == option.value end, function() setEntryField("chargesFont", option.value) end)
				end
			end,
		},
		{
			name = L["CooldownPanelFontStyle"] or "Font style",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneCharges",
			height = 120,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "ITEM"
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.chargesStyleUseGlobal == false)
			end,
			get = function() return getResolvedChargesStyleChoice() end,
			set = function(_, value) setEntryField("chargesFontStyle", Helper.NormalizeFontStyleChoice(value, Helper.PANEL_LAYOUT_DEFAULTS.chargesFontStyle or "OUTLINE")) end,
			generator = function(_, root)
				for _, option in ipairs(Helper.FontStyleOptions) do
					root:CreateRadio(option.label, function() return getResolvedChargesStyleChoice() == option.value end, function() setEntryField("chargesFontStyle", option.value) end)
				end
			end,
		},
		{
			name = _G.COLOR or "Color",
			kind = SettingType.Color,
			parentId = "cooldownPanelStandaloneCharges",
			hasOpacity = true,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "ITEM"
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.chargesStyleUseGlobal == false)
			end,
			get = function()
				local color = getResolvedChargesColor()
				return { r = color[1], g = color[2], b = color[3], a = color[4] }
			end,
			set = function(_, value) setEntryField("chargesColor", Helper.NormalizeColor(value, Helper.PANEL_LAYOUT_DEFAULTS.chargesColor or { 1, 1, 1, 1 })) end,
		},
		{
			name = FONT_SIZE,
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneCharges",
			minValue = 6,
			maxValue = 64,
			valueStep = 1,
			allowInput = true,
			isShown = function()
				local effectiveType = getEffectiveType()
				return effectiveType == "SPELL" or effectiveType == "ITEM"
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.chargesStyleUseGlobal == false)
			end,
			get = function()
				local _, size = CooldownPanels:ResolveEntryChargesTextStyle(getLayout(), select(2, getEntry()), defaultChargesFontPath, defaultChargesFontSize, defaultChargesFontStyle)
				return size
			end,
			set = function(_, value) setEntryField("chargesFontSize", Helper.ClampInt(value, 6, 64, defaultChargesFontSize or 12)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["Cooldown"] or "Cooldown",
			kind = SettingType.Collapsible,
			id = "cooldownPanelStandaloneCooldownVisuals",
			defaultCollapsed = true,
			isShown = function() return getEffectiveType() ~= "STANCE" end,
		},
		{
			name = L["CooldownPanelCDMAuraOverlay"] or "Show aura overlay",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			isShown = function()
				local _, currentEntry = getEntry()
				return CooldownPanels:SupportsEntryCDMAuraOverlay(currentEntry, getEffectiveType())
			end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.cdmAuraOverlayEnabled == true or false
			end,
			set = function(_, value) setCDMAuraOverlayEnabled(value) end,
		},
		{
			name = L["CooldownPanelActivationOverlayColor"] or "Activation overlay color",
			kind = SettingType.Color,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			hasOpacity = true,
			isShown = function()
				local _, currentEntry = getEntry()
				return CooldownPanels:SupportsEntryCustomCooldownDuration(currentEntry, getEffectiveType()) or CooldownPanels:SupportsEntryCDMAuraOverlay(currentEntry, getEffectiveType())
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				local panelOverlay = getLayout() and getLayout().cdmAuraOverlayEnabled == true and CooldownPanels:SupportsEntryCDMAuraOverlay(currentEntry, getEffectiveType())
				return not (currentEntry and (currentEntry.customCooldownDurationEnabled == true or currentEntry.autoCooldownDurationEnabled == true or currentEntry.cdmAuraOverlayEnabled == true or panelOverlay))
			end,
			default = Helper.NormalizeColor(Helper.ACTIVATION_OVERLAY_COLOR_DEFAULT, Helper.ACTIVATION_OVERLAY_COLOR_DEFAULT),
			get = function()
				local _, currentEntry = getEntry()
				local color = Helper.NormalizeColor(currentEntry and currentEntry.activationOverlayColor, Helper.ACTIVATION_OVERLAY_COLOR_DEFAULT)
				return { r = color[1], g = color[2], b = color[3], a = color[4] }
			end,
			set = function(_, value) setEntryField("activationOverlayColor", Helper.NormalizeColor(value, Helper.ACTIVATION_OVERLAY_COLOR_DEFAULT)) end,
		},
		{
			name = L["CooldownPanelActivationOverlayReverse"] or "Reverse activation swipe",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			isShown = function()
				local _, currentEntry = getEntry()
				return CooldownPanels:SupportsEntryCustomCooldownDuration(currentEntry, getEffectiveType()) or CooldownPanels:SupportsEntryCDMAuraOverlay(currentEntry, getEffectiveType())
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				local panelOverlay = getLayout() and getLayout().cdmAuraOverlayEnabled == true and CooldownPanels:SupportsEntryCDMAuraOverlay(currentEntry, getEffectiveType())
				return not (currentEntry and (currentEntry.customCooldownDurationEnabled == true or currentEntry.autoCooldownDurationEnabled == true or currentEntry.cdmAuraOverlayEnabled == true or panelOverlay))
			end,
			get = function()
				local _, currentEntry = getEntry()
				return not currentEntry or currentEntry.activationOverlayReverse ~= false
			end,
			set = function(_, value) setEntryField("activationOverlayReverse", value == true) end,
		},
		{
			name = L["CooldownPanelActivationOverlayOnly"] or "Only show during activation",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			isShown = function()
				local _, currentEntry = getEntry()
				return CooldownPanels:EntryUsesActivationOverlay(currentEntry, getEffectiveType())
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				local panelOverlay = getLayout() and getLayout().cdmAuraOverlayEnabled == true and CooldownPanels:SupportsEntryCDMAuraOverlay(currentEntry, getEffectiveType())
				return not (currentEntry and (currentEntry.customCooldownDurationEnabled == true or currentEntry.autoCooldownDurationEnabled == true or currentEntry.cdmAuraOverlayEnabled == true or panelOverlay))
			end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.activationOverlayOnly == true or false
			end,
			set = function(_, value) setEntryField("activationOverlayOnly", value == true) end,
		},
		{
			name = L["CooldownPanelActivationOverlayGlow"] or "Glow during activation",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			isShown = function()
				local _, currentEntry = getEntry()
				return CooldownPanels:EntryUsesActivationOverlay(currentEntry, getEffectiveType())
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				local panelOverlay = getLayout() and getLayout().cdmAuraOverlayEnabled == true and CooldownPanels:SupportsEntryCDMAuraOverlay(currentEntry, getEffectiveType())
				return not (currentEntry and (currentEntry.customCooldownDurationEnabled == true or currentEntry.autoCooldownDurationEnabled == true or currentEntry.cdmAuraOverlayEnabled == true or panelOverlay))
			end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.activationOverlayGlow == true or false
			end,
			set = function(_, value) setEntryField("activationOverlayGlow", value == true) end,
		},
		{
			name = L["CooldownPanelAutoDuration"] or "Automatic duration on activation",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			isShown = function()
				local _, currentEntry = getEntry()
				return CooldownPanels:SupportsEntryAutoCooldownDuration(currentEntry, getEffectiveType())
			end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.autoCooldownDurationEnabled == true or false
			end,
			set = function(_, value) setAutoCooldownDurationEnabled(value) end,
		},
		{
			name = L["CooldownPanelCustomDuration"] or "Custom duration on activation",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			isShown = function()
				local _, currentEntry = getEntry()
				return CooldownPanels:SupportsEntryCustomCooldownDuration(currentEntry, getEffectiveType())
			end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.customCooldownDurationEnabled == true or false
			end,
			set = function(_, value) setCustomCooldownDurationEnabled(value) end,
		},
		{
			name = L["CooldownPanelCustomDurationSeconds"] or "Custom duration (sec)",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			minValue = 1,
			maxValue = Helper.CUSTOM_COOLDOWN_DURATION_MAX or 300,
			valueStep = 1,
			allowInput = true,
			isShown = function()
				local _, currentEntry = getEntry()
				return CooldownPanels:SupportsEntryCustomCooldownDuration(currentEntry, getEffectiveType())
			end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.customCooldownDurationEnabled == true)
			end,
			get = function()
				local _, currentEntry = getEntry()
				return Helper.ClampNumber(currentEntry and currentEntry.customCooldownDuration, 1, Helper.CUSTOM_COOLDOWN_DURATION_MAX or 300, 60)
			end,
			set = function(_, value) setCustomCooldownDuration(value) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelOverwriteGlobalDefault"] or "Overwrite global default",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			isShown = function() return getEffectiveType() ~= "STANCE" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.cooldownVisualsUseGlobal == false or false
			end,
			set = function(_, value) setCooldownVisualsOverrideEnabled(value) end,
		},
		{
			name = L["CooldownPanelShowChargesCooldown"] or "Show charges cooldown",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			isShown = function() return getEffectiveType() == "SPELL" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.cooldownVisualsUseGlobal == false)
			end,
			get = function()
				local _, currentEntry = getEntry()
				local value = CooldownPanels:ResolveEntryCooldownVisuals(getLayout(), currentEntry)
				return value
			end,
			set = function(_, value) setEntryBoolean("showChargesCooldown", value) end,
		},
		{
			name = L["CooldownPanelDrawEdge"] or "Draw edge",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			isShown = function() return getEffectiveType() ~= "STANCE" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.cooldownVisualsUseGlobal == false)
			end,
			get = function()
				local _, currentEntry = getEntry()
				local _, value = CooldownPanels:ResolveEntryCooldownVisuals(getLayout(), currentEntry)
				return value
			end,
			set = function(_, value) setEntryBoolean("cooldownDrawEdge", value) end,
		},
		{
			name = L["CooldownPanelDrawBling"] or "Draw bling",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			isShown = function() return getEffectiveType() ~= "STANCE" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.cooldownVisualsUseGlobal == false)
			end,
			get = function()
				local _, currentEntry = getEntry()
				local _, _, value = CooldownPanels:ResolveEntryCooldownVisuals(getLayout(), currentEntry)
				return value
			end,
			set = function(_, value) setEntryBoolean("cooldownDrawBling", value) end,
		},
		{
			name = L["CooldownPanelDrawSwipe"] or "Draw swipe",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			isShown = function() return getEffectiveType() ~= "STANCE" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.cooldownVisualsUseGlobal == false)
			end,
			get = function()
				local _, currentEntry = getEntry()
				local _, _, _, value = CooldownPanels:ResolveEntryCooldownVisuals(getLayout(), currentEntry)
				return value
			end,
			set = function(_, value) setEntryBoolean("cooldownDrawSwipe", value) end,
		},
		{
			name = L["CooldownPanelDrawEdgeGcd"] or "Draw edge on GCD",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			isShown = function() return getEffectiveType() ~= "STANCE" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.cooldownVisualsUseGlobal == false)
			end,
			get = function()
				local _, currentEntry = getEntry()
				local _, _, _, _, value = CooldownPanels:ResolveEntryCooldownVisuals(getLayout(), currentEntry)
				return value
			end,
			set = function(_, value) setEntryBoolean("cooldownGcdDrawEdge", value) end,
		},
		{
			name = L["CooldownPanelDrawBlingGcd"] or "Draw bling on GCD",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			isShown = function() return getEffectiveType() ~= "STANCE" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.cooldownVisualsUseGlobal == false)
			end,
			get = function()
				local _, currentEntry = getEntry()
				local _, _, _, _, _, value = CooldownPanels:ResolveEntryCooldownVisuals(getLayout(), currentEntry)
				return value
			end,
			set = function(_, value) setEntryBoolean("cooldownGcdDrawBling", value) end,
		},
		{
			name = L["CooldownPanelDrawSwipeGcd"] or "Draw swipe on GCD",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownVisuals",
			isShown = function() return getEffectiveType() ~= "STANCE" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.cooldownVisualsUseGlobal == false)
			end,
			get = function()
				local _, currentEntry = getEntry()
				local _, _, _, _, _, _, value = CooldownPanels:ResolveEntryCooldownVisuals(getLayout(), currentEntry)
				return value
			end,
			set = function(_, value) setEntryBoolean("cooldownGcdDrawSwipe", value) end,
		},
		{
			name = L["CooldownPanelOverlaysHeader"] or "Overlays",
			kind = SettingType.Collapsible,
			id = "cooldownPanelStandaloneOverlays",
			defaultCollapsed = true,
		},
		{
			name = L["CooldownPanelOverwriteGlobalDefault"] or "Overwrite global default",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneOverlays",
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.noDesaturationUseGlobal == false or false
			end,
			set = function(_, value) setNoDesaturationOverrideEnabled(value) end,
		},
		{
			name = L["CooldownPanelNoDesaturation"] or "No desaturation",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneOverlays",
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.noDesaturationUseGlobal == false)
			end,
			get = function() return getResolvedNoDesaturation() end,
			set = function(_, value) setEntryBoolean("noDesaturation", value) end,
		},
		{
			name = L["CooldownPanelOverwritePanelCheckPower"] or "Overwrite panel power check",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneOverlays",
			isShown = function() return getEffectiveType() == "SPELL" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.checkPowerUseGlobal == false or false
			end,
			set = function(_, value)
				local _, currentEntry = getEntry()
				if not currentEntry then return end
				local useGlobal = value ~= true
				if currentEntry.checkPowerUseGlobal == useGlobal then return end
				currentEntry.checkPowerUseGlobal = useGlobal
				CooldownPanels:RebuildPowerIndex()
				refreshEntryViews()
			end,
		},
		{
			name = L["Check power"] or "Check power",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneOverlays",
			isShown = function() return getEffectiveType() == "SPELL" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.checkPowerUseGlobal == false)
			end,
			get = function() return CooldownPanels:ResolveEntryCheckPower(getLayout(), select(2, getEntry())) == true end,
			set = function(_, value) setEntryBoolean("checkPower", value) end,
		},
		{
			name = L["CooldownPanelOverwritePanelHideWhenNoResource"] or "Overwrite panel hide when no resource",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneOverlays",
			isShown = function() return getEffectiveType() == "SPELL" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.hideWhenNoResourceUseGlobal == false or false
			end,
			set = function(_, value) setHideWhenNoResourceOverrideEnabled(value) end,
		},
		{
			name = L["CooldownPanelHideWhenNoResource"] or "Hide when no resource",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneOverlays",
			isShown = function() return getEffectiveType() == "SPELL" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.hideWhenNoResourceUseGlobal == false)
			end,
			get = function() return getResolvedHideWhenNoResource() == true end,
			set = function(_, value) setEntryBoolean("hideWhenNoResource", value) end,
		},
		{
			name = L["Cooldown text"] or "Cooldown text",
			kind = SettingType.Collapsible,
			id = "cooldownPanelStandaloneCooldownText",
			defaultCollapsed = true,
			isShown = function() return getEffectiveType() ~= "STANCE" end,
		},
		{
			name = L["Show cooldown text"] or "Show cooldown text",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownText",
			isShown = function() return getEffectiveType() ~= "STANCE" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.showCooldownText ~= false or false
			end,
			set = function(_, value) setEntryBoolean("showCooldownText", value) end,
		},
		{
			name = L["CooldownPanelOverwriteGlobalDefault"] or "Overwrite global default",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneCooldownText",
			isShown = function() return getEffectiveType() ~= "STANCE" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.cooldownTextUseGlobal == false or false
			end,
			set = function(_, value) setCooldownTextOverrideEnabled(value) end,
		},
		{
			name = L["Font"] or "Font",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneCooldownText",
			height = 220,
			isShown = function() return getEffectiveType() ~= "STANCE" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.showCooldownText ~= false and currentEntry.cooldownTextUseGlobal == false)
			end,
			get = function() return getCooldownTextFontSelection() end,
			set = function(_, value) setEntryField("cooldownTextFont", value) end,
			generator = function(_, root)
				for _, option in ipairs(Helper.GetFontOptions(defaultCooldownFontPath)) do
					root:CreateRadio(option.label, function() return getCooldownTextFontSelection() == option.value end, function() setEntryField("cooldownTextFont", option.value) end)
				end
			end,
		},
		{
			name = L["Cooldown text outline"] or "Cooldown text outline",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneCooldownText",
			height = 120,
			isShown = function() return getEffectiveType() ~= "STANCE" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.showCooldownText ~= false and currentEntry.cooldownTextUseGlobal == false)
			end,
			get = function() return getResolvedCooldownTextStyleChoice() end,
			set = function(_, value)
				setEntryField(
					"cooldownTextStyle",
					Helper.NormalizeFontStyleChoice(value, Helper.ENTRY_DEFAULTS.cooldownTextStyle or Helper.PANEL_LAYOUT_DEFAULTS.cooldownTextStyle or "NONE")
				)
			end,
			generator = function(_, root)
				for _, option in ipairs(Helper.FontStyleOptions) do
					root:CreateRadio(option.label, function() return getResolvedCooldownTextStyleChoice() == option.value end, function() setEntryField("cooldownTextStyle", option.value) end)
				end
			end,
		},
		{
			name = L["Cooldown text color"] or "Cooldown text color",
			kind = SettingType.Color,
			parentId = "cooldownPanelStandaloneCooldownText",
			hasOpacity = true,
			isShown = function() return getEffectiveType() ~= "STANCE" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.showCooldownText ~= false and currentEntry.cooldownTextUseGlobal == false)
			end,
			get = function()
				local color = getResolvedCooldownTextColor()
				return { r = color[1], g = color[2], b = color[3], a = color[4] }
			end,
			set = function(_, value) setEntryField("cooldownTextColor", Helper.NormalizeColor(value, Helper.PANEL_LAYOUT_DEFAULTS.cooldownTextColor)) end,
		},
		{
			name = FONT_SIZE,
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneCooldownText",
			minValue = 6,
			maxValue = 64,
			valueStep = 1,
			allowInput = true,
			isShown = function() return getEffectiveType() ~= "STANCE" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.showCooldownText ~= false and currentEntry.cooldownTextUseGlobal == false)
			end,
			get = function()
				local _, size = CooldownPanels:ResolveEntryCooldownTextStyle(getLayout(), select(2, getEntry()), defaultCooldownFontPath, defaultCooldownFontSize, defaultCooldownFontStyle)
				return size
			end,
			set = function(_, value) setEntryField("cooldownTextSize", Helper.ClampInt(value, 6, 64, defaultCooldownFontSize or 12)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["Cooldown text offset X"] or "Cooldown text offset X",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneCooldownText",
			minValue = -Helper.OFFSET_RANGE,
			maxValue = Helper.OFFSET_RANGE,
			valueStep = 1,
			allowInput = true,
			isShown = function() return getEffectiveType() ~= "STANCE" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.showCooldownText ~= false and currentEntry.cooldownTextUseGlobal == false)
			end,
			get = function()
				local _, _, _, _, _, x =
					CooldownPanels:ResolveEntryCooldownTextStyle(getLayout(), select(2, getEntry()), defaultCooldownFontPath, defaultCooldownFontSize, defaultCooldownFontStyle)
				return x
			end,
			set = function(_, value) setEntryField("cooldownTextX", Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["Cooldown text offset Y"] or "Cooldown text offset Y",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneCooldownText",
			minValue = -Helper.OFFSET_RANGE,
			maxValue = Helper.OFFSET_RANGE,
			valueStep = 1,
			allowInput = true,
			isShown = function() return getEffectiveType() ~= "STANCE" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.showCooldownText ~= false and currentEntry.cooldownTextUseGlobal == false)
			end,
			get = function()
				local _, _, _, _, _, _, y =
					CooldownPanels:ResolveEntryCooldownTextStyle(getLayout(), select(2, getEntry()), defaultCooldownFontPath, defaultCooldownFontSize, defaultCooldownFontStyle)
				return y
			end,
			set = function(_, value) setEntryField("cooldownTextY", Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelStaticText"] or "Static text",
			kind = SettingType.Collapsible,
			id = "cooldownPanelStandaloneStaticText",
			defaultCollapsed = true,
			isShown = function() return true end,
		},
		{
			name = L["CooldownPanelStaticText"] or "Static text",
			kind = SettingType.Input,
			parentId = "cooldownPanelStandaloneStaticText",
			inputWidth = 220,
			isShown = function() return true end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.staticText or ""
			end,
			set = function(_, value) setStaticText(_, value) end,
			default = "",
			maxChars = 32,
		},
		{
			name = L["CooldownPanelOverwriteGlobalDefault"] or "Overwrite global default",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneStaticText",
			isShown = function() return true end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.staticTextUseGlobal == false or false
			end,
			set = function(_, value) setStaticTextOverrideEnabled(value) end,
		},
		{
			name = L["CooldownPanelStaticTextDuringCD"] or "Show text during CD",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneStaticText",
			isShown = function() return true end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.staticTextShowOnCooldown == true or false
			end,
			set = function(_, value) setEntryBoolean("staticTextShowOnCooldown", value) end,
		},
		{
			name = L["Font"] or "Font",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneStaticText",
			height = 220,
			isShown = function() return true end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.staticTextUseGlobal == false)
			end,
			get = function() return getStaticTextFontSelection() end,
			set = function(_, value) setEntryField("staticTextFont", value) end,
			generator = function(_, root)
				for _, option in ipairs(Helper.GetFontOptions(defaultStaticFontPath)) do
					root:CreateRadio(option.label, function() return getStaticTextFontSelection() == option.value end, function() setEntryField("staticTextFont", option.value) end)
				end
			end,
		},
		{
			name = L["CooldownPanelFontStyle"] or "Font style",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneStaticText",
			height = 120,
			isShown = function() return true end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.staticTextUseGlobal == false)
			end,
			get = function() return getResolvedStaticTextStyleChoice() end,
			set = function(_, value) setEntryField("staticTextStyle", Helper.NormalizeFontStyleChoice(value, Helper.PANEL_LAYOUT_DEFAULTS.staticTextStyle or "OUTLINE")) end,
			generator = function(_, root)
				for _, option in ipairs(Helper.FontStyleOptions) do
					root:CreateRadio(option.label, function() return getResolvedStaticTextStyleChoice() == option.value end, function() setEntryField("staticTextStyle", option.value) end)
				end
			end,
		},
		{
			name = L["CooldownPanelStaticTextColor"] or _G.COLOR or "Color",
			kind = SettingType.Color,
			parentId = "cooldownPanelStandaloneStaticText",
			hasOpacity = true,
			isShown = function() return true end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.staticTextUseGlobal == false)
			end,
			get = function()
				local color = getResolvedStaticTextColor()
				return { r = color[1], g = color[2], b = color[3], a = color[4] }
			end,
			set = function(_, value) setEntryField("staticTextColor", Helper.NormalizeColor(value, Helper.PANEL_LAYOUT_DEFAULTS.staticTextColor or { 1, 1, 1, 1 })) end,
		},
		{
			name = FONT_SIZE,
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneStaticText",
			minValue = 6,
			maxValue = 64,
			valueStep = 1,
			allowInput = true,
			isShown = function() return true end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.staticTextUseGlobal == false)
			end,
			get = function()
				local _, size = CooldownPanels:ResolveEntryStaticTextStyle(getLayout(), select(2, getEntry()), defaultStaticFontPath, defaultStaticFontSize, defaultStaticFontStyle)
				return size
			end,
			set = function(_, value) setEntryField("staticTextSize", Helper.ClampInt(value, 6, 64, defaultStaticFontSize or 12)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["Anchor"] or "Anchor",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneStaticText",
			height = 160,
			isShown = function() return true end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.staticTextUseGlobal == false)
			end,
			get = function()
				local _, _, _, _, _, anchor =
					CooldownPanels:ResolveEntryStaticTextStyle(getLayout(), select(2, getEntry()), defaultStaticFontPath, defaultStaticFontSize, defaultStaticFontStyle)
				return anchor
			end,
			set = function(_, value) setEntryField("staticTextAnchor", Helper.NormalizeAnchor(value, Helper.PANEL_LAYOUT_DEFAULTS.staticTextAnchor or "CENTER")) end,
			generator = function(_, root)
				for _, option in ipairs(Helper.AnchorOptions) do
					root:CreateRadio(option.label, function()
						local _, _, _, _, _, anchor =
							CooldownPanels:ResolveEntryStaticTextStyle(getLayout(), select(2, getEntry()), defaultStaticFontPath, defaultStaticFontSize, defaultStaticFontStyle)
						return anchor == option.value
					end, function() setEntryField("staticTextAnchor", option.value) end)
				end
			end,
		},
		{
			name = L["Text X Offset"] or "Text X Offset",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneStaticText",
			minValue = -Helper.OFFSET_RANGE,
			maxValue = Helper.OFFSET_RANGE,
			valueStep = 1,
			allowInput = true,
			isShown = function() return true end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.staticTextUseGlobal == false)
			end,
			get = function()
				local _, _, _, _, _, _, x =
					CooldownPanels:ResolveEntryStaticTextStyle(getLayout(), select(2, getEntry()), defaultStaticFontPath, defaultStaticFontSize, defaultStaticFontStyle)
				return x
			end,
			set = function(_, value) setEntryField("staticTextX", Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["Text Y Offset"] or "Text Y Offset",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneStaticText",
			minValue = -Helper.OFFSET_RANGE,
			maxValue = Helper.OFFSET_RANGE,
			valueStep = 1,
			allowInput = true,
			isShown = function() return true end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.staticTextUseGlobal == false)
			end,
			get = function()
				local _, _, _, _, _, _, _, y =
					CooldownPanels:ResolveEntryStaticTextStyle(getLayout(), select(2, getEntry()), defaultStaticFontPath, defaultStaticFontSize, defaultStaticFontStyle)
				return y
			end,
			set = function(_, value) setEntryField("staticTextY", Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelStateTexture"] or "State texture",
			kind = SettingType.Collapsible,
			id = "cooldownPanelStandaloneStateTexture",
			defaultCollapsed = true,
			isShown = function() return isStateTextureSupported() end,
		},
		{
			name = L["CooldownPanelStateTextureInput"] or "Texture ID / Atlas",
			kind = SettingType.Input,
			parentId = "cooldownPanelStandaloneStateTexture",
			inputWidth = 220,
			isShown = function() return isStateTextureSupported() end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.stateTextureInput or ""
			end,
			set = setStateTextureInput,
			default = "",
			maxChars = 128,
		},
		{
			name = L["CooldownPanelBlizzardAlertTexture"] or "Blizzard alert texture",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneStateTexture",
			height = 120,
			isShown = function() return isStateTextureSupported() end,
			get = function()
				local _, currentEntry = getEntry()
				local alert = CooldownPanels:GetBlizzardAlertTextureByID(currentEntry and currentEntry.stateTextureInput)
				return alert and CooldownPanels:FormatBlizzardAlertTextureLabel(alert) or (L["CooldownPanelBrowseBlizzardAlertTextures"] or "Browse Blizzard alerts")
			end,
			set = function() end,
			generator = function(_, root)
				root:CreateButton(L["CooldownPanelBrowseBlizzardAlertTextures"] or "Browse Blizzard alerts", function()
					local _, currentEntry = getEntry()
					CooldownPanels:ShowBlizzardAlertTexturePicker(currentEntry and currentEntry.stateTextureInput, function(fileDataID)
						setStateTextureInput(nil, tostring(fileDataID or ""))
					end)
				end)
				root:CreateButton(_G.CLEAR or "Clear", function()
					setStateTextureInput(nil, "")
				end)
			end,
		},
		{
			name = L["CooldownPanelStateTextureShowWithoutProc"] or "Show without proc",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneStateTexture",
			tooltip = L["CooldownPanelStateTextureShowWithoutProcTooltip"] or "Shows the configured Spell State texture even when the spell has no active proc overlay. Leave this disabled to keep the texture proc-only.",
			isShown = function() return getEffectiveType() == "SPELL" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.stateTextureShowWithoutProc == true or false
			end,
			set = function(_, value) setStateTextureField("stateTextureShowWithoutProc", value == true) end,
		},
		{
			name = L["CooldownPanelStateTextureDouble"] or "Double texture",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneStateTexture",
			isShown = function() return isStateTextureSupported() end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.stateTextureDouble == true or false
			end,
			set = function(_, value) setStateTextureField("stateTextureDouble", value == true) end,
		},
		{
			name = L["CooldownPanelStateTextureMirror"] or "Mirror texture",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneStateTexture",
			isShown = function() return isStateTextureSupported() end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.stateTextureMirror == true or false
			end,
			set = function(_, value) setStateTextureField("stateTextureMirror", value == true) end,
		},
		{
			name = L["CooldownPanelStateTextureMirrorSecond"] or "Mirror second texture",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneStateTexture",
			isShown = function() return isStateTextureSupported() and entryUsesDoubleStateTexture() end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.stateTextureMirrorSecond ~= false or false
			end,
			set = function(_, value) setStateTextureField("stateTextureMirrorSecond", value == true) end,
		},
		{
			name = L["CooldownPanelStateTextureMirrorVertical"] or "Mirror texture vertically",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneStateTexture",
			isShown = function() return isStateTextureSupported() end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.stateTextureMirrorVertical == true or false
			end,
			set = function(_, value) setStateTextureField("stateTextureMirrorVertical", value == true) end,
		},
		{
			name = L["CooldownPanelStateTextureMirrorVerticalSecond"] or "Mirror second texture vertically",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneStateTexture",
			isShown = function() return isStateTextureSupported() and entryUsesDoubleStateTexture() end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.stateTextureMirrorVerticalSecond == true or false
			end,
			set = function(_, value) setStateTextureField("stateTextureMirrorVerticalSecond", value == true) end,
		},
		{
			name = L["CooldownPanelStateTextureScale"] or "Texture scale",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneStateTexture",
			minValue = 0.1,
			maxValue = 8,
			valueStep = 0.05,
			allowInput = true,
			isShown = function() return isStateTextureSupported() end,
			get = function()
				local _, currentEntry = getEntry()
				return Helper.ClampNumber(currentEntry and currentEntry.stateTextureScale, 0.1, 8, 1)
			end,
			set = function(_, value) setStateTextureField("stateTextureScale", Helper.ClampNumber(value, 0.1, 8, 1)) end,
			formatter = function(value)
				local num = tonumber(value) or 1
				return string.format("%.2fx", num)
			end,
		},
		{
			name = L["CooldownPanelStateTextureWidth"] or "Texture width",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneStateTexture",
			minValue = 0.1,
			maxValue = 8,
			valueStep = 0.05,
			allowInput = true,
			isShown = function() return isStateTextureSupported() end,
			get = function()
				local _, currentEntry = getEntry()
				return Helper.ClampNumber(currentEntry and currentEntry.stateTextureWidth, 0.1, 8, 1)
			end,
			set = function(_, value) setStateTextureField("stateTextureWidth", Helper.ClampNumber(value, 0.1, 8, 1)) end,
			formatter = function(value)
				local num = tonumber(value) or 1
				return string.format("%.2fx", num)
			end,
		},
		{
			name = L["CooldownPanelStateTextureHeight"] or "Texture height",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneStateTexture",
			minValue = 0.1,
			maxValue = 8,
			valueStep = 0.05,
			allowInput = true,
			isShown = function() return isStateTextureSupported() end,
			get = function()
				local _, currentEntry = getEntry()
				return Helper.ClampNumber(currentEntry and currentEntry.stateTextureHeight, 0.1, 8, 1)
			end,
			set = function(_, value) setStateTextureField("stateTextureHeight", Helper.ClampNumber(value, 0.1, 8, 1)) end,
			formatter = function(value)
				local num = tonumber(value) or 1
				return string.format("%.2fx", num)
			end,
		},
		{
			name = L["CooldownPanelStateTextureAngle"] or "Texture angle",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneStateTexture",
			minValue = 0,
			maxValue = 360,
			valueStep = 1,
			allowInput = true,
			isShown = function() return isStateTextureSupported() end,
			get = function()
				local _, currentEntry = getEntry()
				return Helper.ClampNumber(currentEntry and currentEntry.stateTextureAngle, 0, 360, 0)
			end,
			set = function(_, value) setStateTextureField("stateTextureAngle", Helper.ClampNumber(value, 0, 360, 0)) end,
			formatter = function(value) return string.format("%d°", math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelStateTextureSpacingX"] or "Texture spacing X",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneStateTexture",
			minValue = 0,
			maxValue = Helper.STATE_TEXTURE_SPACING_RANGE or 2000,
			valueStep = 1,
			allowInput = true,
			isShown = function() return isStateTextureSupported() and entryUsesDoubleStateTexture() end,
			get = function()
				local _, currentEntry = getEntry()
				return Helper.ClampInt(currentEntry and currentEntry.stateTextureSpacingX, 0, Helper.STATE_TEXTURE_SPACING_RANGE or 2000, 0)
			end,
			set = function(_, value) setStateTextureField("stateTextureSpacingX", Helper.ClampInt(value, 0, Helper.STATE_TEXTURE_SPACING_RANGE or 2000, 0)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelStateTextureSpacingY"] or "Texture spacing Y",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneStateTexture",
			minValue = 0,
			maxValue = Helper.STATE_TEXTURE_SPACING_RANGE or 2000,
			valueStep = 1,
			allowInput = true,
			isShown = function() return isStateTextureSupported() and entryUsesDoubleStateTexture() end,
			get = function()
				local _, currentEntry = getEntry()
				return Helper.ClampInt(currentEntry and currentEntry.stateTextureSpacingY, 0, Helper.STATE_TEXTURE_SPACING_RANGE or 2000, 0)
			end,
			set = function(_, value) setStateTextureField("stateTextureSpacingY", Helper.ClampInt(value, 0, Helper.STATE_TEXTURE_SPACING_RANGE or 2000, 0)) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = _G.GLOW or "Glow",
			kind = SettingType.Collapsible,
			id = "cooldownPanelStandaloneGlow",
			defaultCollapsed = true,
			isShown = function() return getEffectiveType() ~= "MACRO" end,
		},
		{
			name = (initialEffectiveType == "STANCE" and (_G.GLOW or "Glow"))
				or (initialEffectiveType == "CDM_AURA" and (L["CooldownPanelGlowActive"] or "Glow when active"))
				or (L["CooldownPanelGlowReady"] or "Glow when ready"),
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneGlow",
			isShown = function() return getEffectiveType() ~= "MACRO" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.glowReady == true or false
			end,
			set = function(_, value) setEntryBoolean("glowReady", value) end,
		},
		{
			name = L["CooldownPanelGlowPandemic"] or "Pandemic glow",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneGlow",
			isShown = function() return getEffectiveType() == "CDM_AURA" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.pandemicGlow == true or false
			end,
			set = function(_, value) setEntryBoolean("pandemicGlow", value) end,
		},
		{
			name = L["CooldownPanelOverwriteGlobalDefault"] or "Overwrite global default",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneGlow",
			isShown = function() return getEffectiveType() ~= "MACRO" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.glowUseGlobal == false or false
			end,
			set = function(_, value) setGlowOverrideEnabled(value) end,
		},
		{
			name = L["CooldownPanelReadyGlowCheckPower"] or "Require resource for ready glow",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneGlow",
			isShown = function() return getEffectiveType() == "SPELL" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.glowReady == true and currentEntry.glowUseGlobal == false)
			end,
			get = function() return getResolvedReadyGlowCheckPower() == true end,
			set = function(_, value) setEntryBoolean("readyGlowCheckPower", value) end,
		},
		{
			name = L["CooldownPanelOverwritePanelProcGlow"] or "Overwrite panel proc glow",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneGlow",
			isShown = function() return getEffectiveType() == "SPELL" end,
			get = function()
				local _, currentEntry = getEntry()
				return currentEntry and currentEntry.procGlowUseGlobal == false or false
			end,
			set = function(_, value) setProcGlowOverrideEnabled(value) end,
		},
		{
			name = L["CooldownPanelProcGlow"] or "Proc glow",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneGlow",
			isShown = function() return getEffectiveType() == "SPELL" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.procGlowUseGlobal == false)
			end,
			get = function() return getResolvedProcGlowEnabled() == true end,
			set = setProcGlowEnabled,
		},
		{
			name = L["CooldownPanelProcGlowStyle"] or "Proc glow style",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneGlow",
			height = 180,
			isShown = function() return getEffectiveType() == "SPELL" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.procGlowUseGlobal == false)
			end,
			get = function() return getResolvedProcGlowStyle() end,
			set = setProcGlowStyle,
			generator = function(_, root)
				for _, option in ipairs(CooldownPanels:GetGlowStyleOptions(panelId)) do
					local label = L[option.labelKey] or option.fallback
					root:CreateRadio(label, function() return getResolvedProcGlowStyle() == option.value end, function() setProcGlowStyle(nil, option.value) end)
				end
			end,
		},
		{
			name = L["CooldownPanelProcGlowInset"] or "Proc glow inset",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneGlow",
			minValue = -(Helper.GLOW_INSET_RANGE or 20),
			maxValue = Helper.GLOW_INSET_RANGE or 20,
			valueStep = 1,
			allowInput = true,
			isShown = function() return getEffectiveType() == "SPELL" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.procGlowUseGlobal == false)
			end,
			get = function() return getResolvedProcGlowInset() end,
			set = setProcGlowInset,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["Glow style"] or "Glow style",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneGlow",
			height = 180,
			isShown = function() return getEffectiveType() ~= "MACRO" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and entryUsesConfiguredGlow(currentEntry) and currentEntry.glowUseGlobal == false)
			end,
			get = function() return getResolvedGlowStyle() end,
			set = setGlowStyle,
			generator = function(_, root)
				for _, option in ipairs(CooldownPanels:GetGlowStyleOptions(panelId)) do
					local label = L[option.labelKey] or option.fallback
					root:CreateRadio(label, function() return getResolvedGlowStyle() == option.value end, function() setGlowStyle(nil, option.value) end)
				end
			end,
		},
		{
			name = L["CooldownPanelGlowStylePandemic"] or "Pandemic glow style",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneGlow",
			height = 180,
			isShown = function() return getEffectiveType() == "CDM_AURA" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.pandemicGlow == true and currentEntry.glowUseGlobal == false)
			end,
			get = function() return getResolvedPandemicGlowStyle() end,
			set = setPandemicGlowStyle,
			generator = function(_, root)
				for _, option in ipairs(CooldownPanels:GetGlowStyleOptions(panelId)) do
					local label = L[option.labelKey] or option.fallback
					root:CreateRadio(label, function() return getResolvedPandemicGlowStyle() == option.value end, function() setPandemicGlowStyle(nil, option.value) end)
				end
			end,
		},
		{
			name = L["Glow inset"] or "Glow inset",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneGlow",
			minValue = -(Helper.GLOW_INSET_RANGE or 20),
			maxValue = Helper.GLOW_INSET_RANGE or 20,
			valueStep = 1,
			allowInput = true,
			isShown = function() return getEffectiveType() ~= "MACRO" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and entryUsesConfiguredGlow(currentEntry) and currentEntry.glowUseGlobal == false)
			end,
			get = function() return getResolvedGlowInset() end,
			set = setGlowInset,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelGlowInsetPandemic"] or "Pandemic glow inset",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneGlow",
			minValue = -(Helper.GLOW_INSET_RANGE or 20),
			maxValue = Helper.GLOW_INSET_RANGE or 20,
			valueStep = 1,
			allowInput = true,
			isShown = function() return getEffectiveType() == "CDM_AURA" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.pandemicGlow == true and currentEntry.glowUseGlobal == false)
			end,
			get = function() return getResolvedPandemicGlowInset() end,
			set = setPandemicGlowInset,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["Glow color"] or "Glow color",
			kind = SettingType.Color,
			parentId = "cooldownPanelStandaloneGlow",
			hasOpacity = true,
			isShown = function() return getEffectiveType() ~= "MACRO" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and entryUsesConfiguredGlow(currentEntry) and currentEntry.glowUseGlobal == false)
			end,
			get = function()
				local color = getResolvedGlowColor()
				return { r = color[1], g = color[2], b = color[3], a = color[4] }
			end,
			set = setGlowColor,
		},
		{
			name = L["CooldownPanelGlowColorPandemic"] or "Pandemic glow color",
			kind = SettingType.Color,
			parentId = "cooldownPanelStandaloneGlow",
			hasOpacity = true,
			isShown = function() return getEffectiveType() == "CDM_AURA" end,
			disabled = function()
				local _, currentEntry = getEntry()
				return not (currentEntry and currentEntry.pandemicGlow == true and currentEntry.glowUseGlobal == false)
			end,
			get = function()
				local color = getResolvedPandemicGlowColor()
				return { r = color[1], g = color[2], b = color[3], a = color[4] }
			end,
			set = function(_, value)
				local _, currentEntry = getEntry()
				if not currentEntry then return end
				local fallbackColor = getResolvedGlowColor()
				local normalized = Helper.NormalizeColor(value, fallbackColor)
				if normalized[1] == fallbackColor[1] and normalized[2] == fallbackColor[2] and normalized[3] == fallbackColor[3] and normalized[4] == fallbackColor[4] then
					currentEntry.pandemicGlowColor = nil
				else
					currentEntry.pandemicGlowColor = normalized
				end
				refreshEntryViews()
			end,
		},
		{
			name = SOUND,
			kind = SettingType.Collapsible,
			id = "cooldownPanelStandaloneSound",
			defaultCollapsed = true,
			isShown = function() return allowsStandaloneEntryReadySound(getEffectiveType()) end,
		},
		{
			name = L["CooldownPanelSoundReady"] or "Sound when ready",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneSound",
			isShown = function() return allowsStandaloneEntryReadySound(getEffectiveType()) end,
			get = function()
				local _, currentEntry = getEntry()
				local _, enabledField = CooldownPanels:GetEntrySoundConfig(currentEntry)
				return currentEntry and enabledField and currentEntry[enabledField] == true or false
			end,
			set = function(_, value)
				local _, currentEntry = getEntry()
				local _, enabledField = CooldownPanels:GetEntrySoundConfig(currentEntry)
				if enabledField then setEntryBoolean(enabledField, value) end
			end,
		},
		{
			name = SOUND,
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneSound",
			height = 260,
			isShown = function() return allowsStandaloneEntryReadySound(getEffectiveType()) end,
			disabled = function()
				local _, currentEntry = getEntry()
				local _, enabledField = CooldownPanels:GetEntrySoundConfig(currentEntry)
				return not (currentEntry and enabledField and currentEntry[enabledField] == true)
			end,
			get = function()
				local _, currentEntry = getEntry()
				local _, _, soundField = CooldownPanels:GetEntrySoundConfig(currentEntry)
				return normalizeSoundName(currentEntry and soundField and currentEntry[soundField])
			end,
			set = function(_, value) setSoundReadyFile(value) end,
			generator = function(_, root)
				for _, soundName in ipairs(getSoundOptions()) do
					local label = getSoundLabel(soundName)
					root:CreateRadio(label, function()
						local _, currentEntry = getEntry()
						local _, _, soundField = CooldownPanels:GetEntrySoundConfig(currentEntry)
						return normalizeSoundName(currentEntry and soundField and currentEntry[soundField]) == soundName
					end, function() setSoundReadyFile(soundName) end)
				end
			end,
		},
	}

	local buttons = {}
	local bars = self.Bars
	local isBarEntry = bars and bars.IsBarDisplayModeValue and bars.IsBarDisplayModeValue(entry.displayMode) or false
	if not isBarEntry or (bars and bars.EntryHasQuickSetups and bars.EntryHasQuickSetups(panelId, entryId)) then
		buttons[#buttons + 1] = {
			text = L["CooldownPanelQuickSetups"] or "Quick setups",
			layout = "compact",
			click = function(button) CooldownPanels:ShowEntryQuickSetupMenu(button or hostFrame, panelId, entryId) end,
		}
	end
	buttons[#buttons + 1] = {
		text = L["CooldownPanelCopyEntryStyle"] or "Copy entry style",
		layout = "compact",
		click = function() CooldownPanels:CopyEntryStyle(panelId, entryId) end,
	}
	buttons[#buttons + 1] = {
		text = L["CooldownPanelPasteEntryStyle"] or "Paste entry style",
		layout = "compact",
		click = function() CooldownPanels:PasteEntryStyle(panelId, entryId) end,
	}
	buttons[#buttons + 1] = {
		id = "removeEntry",
		text = L["CooldownPanelRemoveEntry"] or "Remove entry",
		layout = "compact",
		click = function()
			CooldownPanels:HideLayoutEntryStandaloneMenu(panelId)
			CooldownPanels:RemoveEntry(panelId, entryId)
			local editor = getEditor()
			if editor and normalizeId(editor.selectedPanelId) == panelId and normalizeId(editor.selectedEntryId) == entryId then editor.selectedEntryId = nil end
			CooldownPanels:RefreshEditor()
		end,
	}

	local dialog = lib:ShowStandaloneSettingsDialog(hostFrame, {
		title = self:GetEntryStandaloneTitle(entry),
		settings = settings,
		buttons = buttons,
		showReset = false,
		showSettingsReset = false,
		settingsMaxHeight = 520,
		point = spawnPosition.point,
		relativePoint = spawnPosition.relativePoint,
		relativeTo = spawnPosition.relativeTo,
		x = spawnPosition.x,
		y = spawnPosition.y,
		onHide = function() CooldownPanels:ClearLayoutEntryStandaloneMenuState() end,
	})
	if dialog then
		self:DockStandaloneDialogToEditor(dialog)
		local state = self:GetLayoutEntryStandaloneMenuState()
		state.panelId = panelId
		state.entryId = entryId
		state.hostFrame = hostFrame
		state.anchorFrame = anchorFrame
		state.dialog = dialog
	end
end

function CooldownPanels:GetLayoutPanelStandaloneMenuState(create)
	CooldownPanels.runtime = CooldownPanels.runtime or {}
	local state = CooldownPanels.runtime.layoutPanelStandaloneMenu
	if state == nil and create ~= false then
		state = {}
		CooldownPanels.runtime.layoutPanelStandaloneMenu = state
	end
	return state
end

function CooldownPanels:ClearLayoutPanelStandaloneMenuState()
	local state = self:GetLayoutPanelStandaloneMenuState(false)
	if not state then return end
	for key in pairs(state) do
		state[key] = nil
	end
end

function CooldownPanels:IsLayoutPanelStandaloneMenuAvailable(panelId)
	panelId = normalizeId(panelId)
	local lib = addon.EditModeLib
	return panelId and self:IsPanelLayoutEditActive(panelId) and lib and lib.ShowStandaloneSettingsDialog and SettingType ~= nil or false
end

function CooldownPanels:HideLayoutPanelStandaloneMenu(panelId)
	local lib = addon.EditModeLib
	local state = self:GetLayoutPanelStandaloneMenuState(false)
	local trackedPanelId = normalizeId(state and state.panelId)
	if panelId and trackedPanelId and trackedPanelId ~= normalizeId(panelId) then return false end
	local hostFrame = state and state.hostFrame or (trackedPanelId and getRuntime(trackedPanelId).frame) or nil
	local hidden = false
	if lib and lib.HideStandaloneSettingsDialog then hidden = lib:HideStandaloneSettingsDialog(hostFrame) end
	self:ClearLayoutPanelStandaloneMenuState()
	return hidden
end

function CooldownPanels:RefreshLayoutPanelStandaloneMenu()
	local lib = addon.EditModeLib
	local state = self:GetLayoutPanelStandaloneMenuState(false)
	if not state or not state.hostFrame then return end
	if not (lib and lib.IsStandaloneSettingsDialogShown and lib:IsStandaloneSettingsDialogShown(state.hostFrame)) then
		self:ClearLayoutPanelStandaloneMenuState()
		return
	end
	local panelId = normalizeId(state.panelId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local editor = getEditor()
	local selectedPanelId = normalizeId(editor and editor.selectedPanelId)
	if not panel or not self:IsLayoutPanelStandaloneMenuAvailable(panelId) or selectedPanelId ~= panelId then
		self:HideLayoutPanelStandaloneMenu(panelId)
		return
	end
	local dialog = state.dialog
	if dialog then
			local title = panel.name or L["cooldownPanelDefaultName"]
		if dialog.context then dialog.context.title = title end
		if dialog.Title and title then dialog.Title:SetText(title) end
	end
end

function CooldownPanels:OpenLayoutPanelStandaloneMenu(panelId, anchorFrame)
	local lib = addon.EditModeLib
	if not (lib and lib.ShowStandaloneSettingsDialog and SettingType) then return end
	panelId = normalizeId(panelId)
	if not (panelId and self:IsLayoutPanelStandaloneMenuAvailable(panelId)) then return end
	self:HideLayoutEntryStandaloneMenu(panelId)
	self:HideLayoutFixedGroupStandaloneMenu(panelId)

	self:PrepareLayoutPanelStandaloneSettings(panelId)
	local registeredRuntime = getRuntime(panelId)
	local registeredPanel = self:GetPanel(panelId)
	local registeredHostFrame = registeredRuntime and registeredRuntime.frame or nil
	local registeredSettings = registeredRuntime and registeredRuntime.layoutPanelSettings or nil
	if not (registeredPanel and registeredHostFrame and registeredSettings) then return end

	local spawnPosition = self:GetStandaloneDialogSpawnPosition(anchorFrame, registeredHostFrame, 12, 0)
	local dialog = lib:ShowStandaloneSettingsDialog(registeredHostFrame, {
			title = registeredPanel.name or L["cooldownPanelDefaultName"],
		settings = registeredSettings,
		showReset = false,
		showSettingsReset = false,
		settingsMaxHeight = registeredRuntime.layoutPanelSettingsMaxHeight or 620,
		point = spawnPosition.point,
		relativePoint = spawnPosition.relativePoint,
		relativeTo = spawnPosition.relativeTo,
		x = spawnPosition.x,
		y = spawnPosition.y,
		onHide = function() CooldownPanels:ClearLayoutPanelStandaloneMenuState() end,
	})
	if dialog then
		self:DockStandaloneDialogToEditor(dialog)
		local state = self:GetLayoutPanelStandaloneMenuState()
		state.panelId = panelId
		state.hostFrame = registeredHostFrame
		state.dialog = dialog
	end
end

function CooldownPanels:GetLayoutFixedGroupStandaloneMenuState(create)
	CooldownPanels.runtime = CooldownPanels.runtime or {}
	local state = CooldownPanels.runtime.layoutFixedGroupStandaloneMenu
	if state == nil and create ~= false then
		state = {}
		CooldownPanels.runtime.layoutFixedGroupStandaloneMenu = state
	end
	return state
end

function CooldownPanels:ClearLayoutFixedGroupStandaloneMenuState()
	local state = self:GetLayoutFixedGroupStandaloneMenuState(false)
	if not state then return end
	for key in pairs(state) do
		state[key] = nil
	end
end

function CooldownPanels:IsLayoutFixedGroupStandaloneMenuAvailable(panelId, groupId)
	panelId = normalizeId(panelId)
	groupId = Helper.NormalizeFixedGroupId(groupId)
	if not (panelId and groupId and self:IsPanelLayoutEditActive(panelId)) then return false end
	local panel = self:GetPanel(panelId)
	local group = panel and CooldownPanels.GetFixedGroupById(panel, groupId) or nil
	return group ~= nil
end

function CooldownPanels:HideLayoutFixedGroupStandaloneMenu(panelId)
	local lib = addon.EditModeLib
	local state = self:GetLayoutFixedGroupStandaloneMenuState(false)
	local trackedPanelId = normalizeId(state and state.panelId)
	if panelId and trackedPanelId and trackedPanelId ~= normalizeId(panelId) then return false end
	local hostFrame = state and state.hostFrame or (trackedPanelId and getRuntime(trackedPanelId).frame) or nil
	local hidden = false
	if lib and lib.HideStandaloneSettingsDialog then hidden = lib:HideStandaloneSettingsDialog(hostFrame) end
	self:ClearLayoutFixedGroupStandaloneMenuState()
	return hidden
end

function CooldownPanels:RefreshLayoutFixedGroupStandaloneMenu()
	local lib = addon.EditModeLib
	local state = self:GetLayoutFixedGroupStandaloneMenuState(false)
	if not state or not state.hostFrame then return end
	if not (lib and lib.IsStandaloneSettingsDialogShown and lib:IsStandaloneSettingsDialogShown(state.hostFrame)) then
		self:ClearLayoutFixedGroupStandaloneMenuState()
		return
	end
	local panelId = normalizeId(state.panelId)
	local groupId = Helper.NormalizeFixedGroupId(state.groupId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local group = panel and groupId and CooldownPanels.GetFixedGroupById(panel, groupId) or nil
	local editor = getEditor()
	local selectedPanelId = normalizeId(editor and editor.selectedPanelId)
	if not group or not self:IsPanelLayoutEditActive(panelId) or selectedPanelId ~= panelId then
		self:HideLayoutFixedGroupStandaloneMenu(panelId)
		return
	end
	local dialog = state.dialog
	if dialog then
		local title = CooldownPanels.GetFixedGroupDisplayLabel(group)
		if dialog.context then dialog.context.title = title end
		if dialog.Title and title then dialog.Title:SetText(title) end
		if dialog.UpdateSettings then dialog:UpdateSettings() end
		if dialog.UpdateButtons then dialog:UpdateButtons() end
		if dialog.Layout then dialog:Layout() end
	end
end

function CooldownPanels:ScheduleLayoutFixedGroupStandaloneMenuRefresh(panelId, groupId)
	local state = self:GetLayoutFixedGroupStandaloneMenuState(false)
	if not state then return end
	if state.refreshPending then return end
	state.refreshPending = true
	panelId = normalizeId(panelId)
	groupId = Helper.NormalizeFixedGroupId(groupId)
	RunNextFrame(function()
		local currentState = CooldownPanels:GetLayoutFixedGroupStandaloneMenuState(false)
		if currentState then currentState.refreshPending = nil end
		if not currentState then return end
		if normalizeId(currentState.panelId) ~= panelId then return end
		if Helper.NormalizeFixedGroupId(currentState.groupId) ~= groupId then return end
		CooldownPanels:RefreshLayoutFixedGroupStandaloneMenu()
	end)
end

function CooldownPanels:RefreshLayoutFixedGroupStandaloneLivePreview(panelId)
	panelId = normalizeId(panelId)
	if not panelId then return end
	local runtime = getRuntime(panelId)
	if self:IsPanelLayoutEditActive(panelId) and runtime and runtime.frame then
		self:UpdateRuntimeIcons(panelId)
		return
	end
	self:RefreshPanelForCurrentEditContext(panelId, false)
end

function CooldownPanels:ScheduleLayoutFixedGroupStandaloneLivePreview(panelId)
	panelId = normalizeId(panelId)
	if not panelId then return end
	local state = self:GetLayoutFixedGroupStandaloneMenuState(false)
	if not (state and C_Timer and C_Timer.After) then
		self:RefreshLayoutFixedGroupStandaloneLivePreview(panelId)
		return
	end
	state.livePreviewPanelId = panelId
	if state.livePreviewPending then return end
	state.livePreviewPending = true
	C_Timer.After(0.05, function()
		local currentState = CooldownPanels:GetLayoutFixedGroupStandaloneMenuState(false)
		if currentState then currentState.livePreviewPending = nil end
		local queuedPanelId = normalizeId(currentState and currentState.livePreviewPanelId)
		if currentState then currentState.livePreviewPanelId = nil end
		if not queuedPanelId then return end
		CooldownPanels:RefreshLayoutFixedGroupStandaloneLivePreview(queuedPanelId)
	end)
end

function CooldownPanels:BuildLayoutFixedGroupStandaloneSettings(panelId, groupId)
	panelId = normalizeId(panelId)
	groupId = Helper.NormalizeFixedGroupId(groupId)
	if not (panelId and groupId and SettingType) then return nil end

	local function getPanelAndGroup()
		local panel = CooldownPanels:GetPanel(panelId)
		local group = panel and CooldownPanels.GetFixedGroupById(panel, groupId) or nil
		return panel, group
	end
	local function getLayout()
		local panel = CooldownPanels:GetPanel(panelId)
		if not panel then return Helper.PANEL_LAYOUT_DEFAULTS end
		return CooldownPanels:GetFixedGroupEffectiveLayout(panelId, groupId) or panel.layout or Helper.PANEL_LAYOUT_DEFAULTS
	end
	local function refresh()
		CooldownPanels:RefreshPanelForCurrentEditContext(panelId, true)
		CooldownPanels:RefreshLayoutFixedGroupStandaloneMenu()
	end
	local function refreshLivePreview() CooldownPanels:ScheduleLayoutFixedGroupStandaloneLivePreview(panelId) end
	local function setMode(mode)
		local changed, reason = CooldownPanels:SetFixedGroupMode(panelId, groupId, mode)
		if changed then
			refresh()
		elseif reason == "GROUP_FULL" then
			showErrorMessage(L["CooldownPanelFixedGroupFull"] or "Fixed group is full.")
		end
	end
	local function setOverride(field, value)
		if not CooldownPanels:SetFixedGroupLayoutOverride(panelId, groupId, field, value) then return end
		refreshLivePreview()
	end

	return {
		{
			name = L["Group"] or "Group",
			kind = SettingType.Collapsible,
			id = "cooldownPanelStandaloneFixedGroupGeneral",
			defaultCollapsed = false,
		},
		{
			name = L["CooldownPanelRename"] or "Name",
			kind = SettingType.Input,
			parentId = "cooldownPanelStandaloneFixedGroupGeneral",
			labelWidth = 80,
			inputWidth = 220,
			get = function()
				local _, group = getPanelAndGroup()
				return group and CooldownPanels.GetFixedGroupName(group) or ""
			end,
			set = function(_, value)
				local normalized = CooldownPanels.NormalizePanelGroupName(value)
				if normalized and CooldownPanels:RenameFixedGroup(panelId, groupId, normalized) then refresh() end
			end,
			default = "",
			maxChars = 32,
		},
		{
			name = L["Mode"] or "Mode",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneFixedGroupGeneral",
			height = 80,
			get = function()
				local _, group = getPanelAndGroup()
				return group and CooldownPanels.GetFixedGroupMode(group) or "DYNAMIC"
			end,
			set = function(_, value) setMode(value) end,
			generator = function(_, root)
				root:CreateRadio(L["CooldownPanelDynamic"] or "Dynamic", function()
					local _, group = getPanelAndGroup()
					return not CooldownPanels.IsFixedGroupStatic(group)
				end, function() setMode("DYNAMIC") end)
				root:CreateRadio(L["CooldownPanelStatic"] or "Static", function()
					local _, group = getPanelAndGroup()
					return CooldownPanels.IsFixedGroupStatic(group)
				end, function() setMode("STATIC") end)
			end,
		},
		{
			name = L["CooldownPanelStartPoint"] or "Start point",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneFixedGroupGeneral",
			height = 160,
			disabled = function()
				local _, group = getPanelAndGroup()
				return group == nil or CooldownPanels.IsFixedGroupStatic(group)
			end,
			get = function()
				local _, group = getPanelAndGroup()
				return Helper.NormalizeFixedGroupStartPoint(group and group.dynamicStartPoint, "TOPLEFT")
			end,
			set = function(_, value)
				if CooldownPanels:SetFixedGroupDynamicStartPoint(panelId, groupId, value) then refresh() end
			end,
			generator = function(_, root)
				local _, group = getPanelAndGroup()
				local current = Helper.NormalizeFixedGroupStartPoint(group and group.dynamicStartPoint, "TOPLEFT")
				for _, option in ipairs(Helper.FixedGroupStartPointOptions or {}) do
					root:CreateRadio(option.label, function() return current == option.value end, function()
						if CooldownPanels:SetFixedGroupDynamicStartPoint(panelId, groupId, option.value) then refresh() end
					end)
				end
			end,
		},
		{
			name = L["Growth direction"] or "Growth direction",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneFixedGroupGeneral",
			height = 90,
			disabled = function()
				local _, group = getPanelAndGroup()
				return group == nil or CooldownPanels.IsFixedGroupStatic(group)
			end,
			get = function()
				local _, group = getPanelAndGroup()
				local startPoint = Helper.NormalizeFixedGroupStartPoint(group and group.dynamicStartPoint, "TOPLEFT")
				return Helper.NormalizeFixedGroupDynamicDirection(startPoint, group and group.dynamicDirection, nil)
			end,
			set = function(_, value)
				if CooldownPanels:SetFixedGroupDynamicDirection(panelId, groupId, value) then refresh() end
			end,
			generator = function(_, root)
				local _, group = getPanelAndGroup()
				local startPoint = Helper.NormalizeFixedGroupStartPoint(group and group.dynamicStartPoint, "TOPLEFT")
				local current = Helper.NormalizeFixedGroupDynamicDirection(startPoint, group and group.dynamicDirection, nil)
				for _, option in ipairs(Helper.GetFixedGroupDynamicDirectionOptions(startPoint)) do
					root:CreateRadio(option.label, function() return current == option.value end, function()
						if CooldownPanels:SetFixedGroupDynamicDirection(panelId, groupId, option.value) then refresh() end
					end)
				end
			end,
		},
		{
			name = L["Icon X"] or "Icon X",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneFixedGroupGeneral",
			minValue = -(Helper.OFFSET_RANGE or 200),
			maxValue = Helper.OFFSET_RANGE or 200,
			valueStep = 1,
			allowInput = true,
			get = function()
				local layout = getLayout()
				return Helper.ClampInt(layout and layout.iconOffsetX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
			end,
			set = function(_, value) setOverride("iconOffsetX", value) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["Icon Y"] or "Icon Y",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneFixedGroupGeneral",
			minValue = -(Helper.OFFSET_RANGE or 200),
			maxValue = Helper.OFFSET_RANGE or 200,
			valueStep = 1,
			allowInput = true,
			get = function()
				local layout = getLayout()
				return Helper.ClampInt(layout and layout.iconOffsetY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
			end,
			set = function(_, value) setOverride("iconOffsetY", value) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelUseCustomIconSize"] or "Use custom icon size",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneFixedGroupGeneral",
			get = function()
				local _, group = getPanelAndGroup()
				return group and (Helper.NormalizeFixedGroupIconSize(group.iconSize) ~= nil or group.iconSizeSeparate == true) or false
			end,
			set = function(_, value)
				local panel, group = getPanelAndGroup()
				if not (panel and group) then return end
				local nextSize = nil
				if value == true then
					nextSize = Helper.NormalizeFixedGroupIconSize(group.iconSize) or Helper.ClampInt(panel.layout and panel.layout.iconSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
				end
				if CooldownPanels:SetFixedGroupIconSize(panelId, groupId, nextSize) then refresh() end
			end,
		},
		{
			name = L["Icon size"] or "Icon size",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneFixedGroupGeneral",
			minValue = 12,
			maxValue = 128,
			valueStep = 1,
			allowInput = true,
			disabled = function()
				local _, group = getPanelAndGroup()
				return not (group and Helper.NormalizeFixedGroupIconSize(group.iconSize) ~= nil) or (group and group.iconSizeSeparate == true)
			end,
			get = function()
				local panel, group = getPanelAndGroup()
				local fallback = Helper.ClampInt(panel and panel.layout and panel.layout.iconSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
				return group and (Helper.NormalizeFixedGroupIconSize(group.iconSize) or fallback) or fallback
			end,
			set = function(_, value)
				if CooldownPanels:SetFixedGroupIconSize(panelId, groupId, value) then refreshLivePreview() end
			end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelSeparateIconSize"] or "Separate icon width/height",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneFixedGroupGeneral",
			disabled = function()
				local _, group = getPanelAndGroup()
				return group == nil or (Helper.NormalizeFixedGroupIconSize(group.iconSize) == nil and group.iconSizeSeparate ~= true)
			end,
			get = function()
				local _, group = getPanelAndGroup()
				return group and group.iconSizeSeparate == true or false
			end,
			set = function(_, value)
				if CooldownPanels:SetFixedGroupSeparateIconSize(panelId, groupId, value) then refresh() end
			end,
		},
		{
			name = L["CooldownPanelIconWidth"] or "Icon width",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneFixedGroupGeneral",
			minValue = 12,
			maxValue = 128,
			valueStep = 1,
			allowInput = true,
			isShown = function()
				local _, group = getPanelAndGroup()
				return group and group.iconSizeSeparate == true or false
			end,
			get = function()
				local panel, group = getPanelAndGroup()
				local fallback = Helper.NormalizeFixedGroupIconSize(group and group.iconSize) or Helper.ClampInt(panel and panel.layout and panel.layout.iconSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
				return Helper.NormalizeFixedGroupIconDimension(group and group.iconWidth, fallback)
			end,
			set = function(_, value)
				if CooldownPanels:SetFixedGroupIconDimension(panelId, groupId, "iconWidth", value) then refreshLivePreview() end
			end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelIconHeight"] or "Icon height",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneFixedGroupGeneral",
			minValue = 12,
			maxValue = 128,
			valueStep = 1,
			allowInput = true,
			isShown = function()
				local _, group = getPanelAndGroup()
				return group and group.iconSizeSeparate == true or false
			end,
			get = function()
				local panel, group = getPanelAndGroup()
				local fallback = Helper.NormalizeFixedGroupIconSize(group and group.iconSize) or Helper.ClampInt(panel and panel.layout and panel.layout.iconSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
				return Helper.NormalizeFixedGroupIconDimension(group and group.iconHeight, fallback)
			end,
			set = function(_, value)
				if CooldownPanels:SetFixedGroupIconDimension(panelId, groupId, "iconHeight", value) then refreshLivePreview() end
			end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["Spacing"] or "Spacing",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneFixedGroupGeneral",
			minValue = 0,
			maxValue = Helper.SPACING_RANGE or 200,
			valueStep = 1,
			allowInput = true,
			get = function()
				local layout = getLayout()
				return Helper.ClampInt(layout and layout.spacing, 0, Helper.SPACING_RANGE or 200, Helper.PANEL_LAYOUT_DEFAULTS.spacing)
			end,
			set = function(_, value) setOverride("spacing", value) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = _G.GLOW or "Glow",
			kind = SettingType.Collapsible,
			id = "cooldownPanelStandaloneFixedGroupGlow",
			defaultCollapsed = false,
		},
		{
			name = L["CooldownPanelProcGlow"] or "Proc glow",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneFixedGroupGlow",
			get = function()
				local layout = getLayout()
				return CooldownPanels:ResolveEntryProcGlowEnabled(layout, nil)
			end,
			set = function(_, value) setOverride("procGlowEnabled", value) end,
		},
		{
			name = L["CooldownPanelHideGlowOutOfCombat"] or "Hide glow out of combat",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneFixedGroupGlow",
			get = function()
				local layout = getLayout()
				return layout and layout.hideGlowOutOfCombat == true or false
			end,
			set = function(_, value) setOverride("hideGlowOutOfCombat", value) end,
		},
		{
			name = L["CooldownPanelProcGlowStyle"] or "Proc glow style",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneFixedGroupGlow",
			height = 180,
			get = function()
				local layout = getLayout()
				return Helper.NormalizeGlowStyle(select(1, CooldownPanels:ResolveEntryProcGlowVisual(layout, nil)), layout and layout.readyGlowStyle or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle)
			end,
			set = function(_, value) setOverride("procGlowStyle", value) end,
			generator = function(_, root)
				for _, option in ipairs(CooldownPanels:GetGlowStyleOptions(panelId)) do
					local label = L[option.labelKey] or option.fallback
					root:CreateRadio(label, function()
						local layout = getLayout()
						return Helper.NormalizeGlowStyle(
							select(1, CooldownPanels:ResolveEntryProcGlowVisual(layout, nil)),
							layout and layout.readyGlowStyle or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle
						) == option.value
					end, function() setOverride("procGlowStyle", option.value) end)
				end
			end,
		},
		{
			name = L["CooldownPanelProcGlowInset"] or "Proc glow inset",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneFixedGroupGlow",
			minValue = -(Helper.GLOW_INSET_RANGE or 20),
			maxValue = Helper.GLOW_INSET_RANGE or 20,
			valueStep = 1,
			allowInput = true,
			get = function()
				local layout = getLayout()
				return select(2, CooldownPanels:ResolveEntryProcGlowVisual(layout, nil))
			end,
			set = function(_, value) setOverride("procGlowInset", value) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelReadyGlowCheckPower"] or "Require resource for ready glow",
			kind = SettingType.Checkbox,
			parentId = "cooldownPanelStandaloneFixedGroupGlow",
			get = function()
				local layout = getLayout()
				return CooldownPanels:ResolveEntryReadyGlowCheckPower(layout, nil)
			end,
			set = function(_, value) setOverride("readyGlowCheckPower", value) end,
		},
		{
			name = L["Glow style"] or "Glow style",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneFixedGroupGlow",
			height = 180,
			get = function()
				local layout = getLayout()
				return Helper.NormalizeGlowStyle(select(3, CooldownPanels:ResolveEntryGlowStyle(layout, nil)), Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle)
			end,
			set = function(_, value) setOverride("readyGlowStyle", value) end,
			generator = function(_, root)
				for _, option in ipairs(CooldownPanels:GetGlowStyleOptions(panelId)) do
					local label = L[option.labelKey] or option.fallback
					root:CreateRadio(label, function()
						local layout = getLayout()
						return Helper.NormalizeGlowStyle(select(3, CooldownPanels:ResolveEntryGlowStyle(layout, nil)), Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle) == option.value
					end, function() setOverride("readyGlowStyle", option.value) end)
				end
			end,
		},
		{
			name = L["Glow inset"] or "Glow inset",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneFixedGroupGlow",
			minValue = -(Helper.GLOW_INSET_RANGE or 20),
			maxValue = Helper.GLOW_INSET_RANGE or 20,
			valueStep = 1,
			allowInput = true,
			get = function()
				local layout = getLayout()
				return select(4, CooldownPanels:ResolveEntryGlowStyle(layout, nil))
			end,
			set = function(_, value) setOverride("readyGlowInset", value) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelGlowColor"] or "Ready glow color",
			kind = SettingType.Color,
			parentId = "cooldownPanelStandaloneFixedGroupGlow",
			hasOpacity = true,
			get = function()
				local layout = getLayout()
				local color = select(2, CooldownPanels:ResolveEntryGlowStyle(layout, nil))
				return { r = color[1], g = color[2], b = color[3], a = color[4] }
			end,
			set = function(_, value) setOverride("readyGlowColor", value) end,
		},
		{
			name = L["CooldownPanelGlowStylePandemic"] or "Pandemic glow style",
			kind = SettingType.Dropdown,
			parentId = "cooldownPanelStandaloneFixedGroupGlow",
			height = 180,
			get = function()
				local layout = getLayout()
				return Helper.NormalizeGlowStyle(select(2, CooldownPanels:ResolveEntryPandemicGlowVisual(layout, nil)), layout and layout.readyGlowStyle or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle)
			end,
			set = function(_, value) setOverride("pandemicGlowStyle", value) end,
			generator = function(_, root)
				for _, option in ipairs(CooldownPanels:GetGlowStyleOptions(panelId)) do
					local label = L[option.labelKey] or option.fallback
					root:CreateRadio(label, function()
						local layout = getLayout()
						return Helper.NormalizeGlowStyle(
							select(2, CooldownPanels:ResolveEntryPandemicGlowVisual(layout, nil)),
							layout and layout.readyGlowStyle or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle
						) == option.value
					end, function() setOverride("pandemicGlowStyle", option.value) end)
				end
			end,
		},
		{
			name = L["CooldownPanelGlowInsetPandemic"] or "Pandemic glow inset",
			kind = SettingType.Slider,
			parentId = "cooldownPanelStandaloneFixedGroupGlow",
			minValue = -(Helper.GLOW_INSET_RANGE or 20),
			maxValue = Helper.GLOW_INSET_RANGE or 20,
			valueStep = 1,
			allowInput = true,
			get = function()
				local layout = getLayout()
				return select(3, CooldownPanels:ResolveEntryPandemicGlowVisual(layout, nil))
			end,
			set = function(_, value) setOverride("pandemicGlowInset", value) end,
			formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
		},
		{
			name = L["CooldownPanelGlowColorPandemic"] or "Pandemic glow color",
			kind = SettingType.Color,
			parentId = "cooldownPanelStandaloneFixedGroupGlow",
			hasOpacity = true,
			get = function()
				local layout = getLayout()
				local color = select(1, CooldownPanels:ResolveEntryPandemicGlowVisual(layout, nil))
				return { r = color[1], g = color[2], b = color[3], a = color[4] }
			end,
			set = function(_, value) setOverride("pandemicGlowColor", value) end,
		},
	}
end

function CooldownPanels:OpenLayoutFixedGroupStandaloneMenu(panelId, groupId, anchorFrame)
	local lib = addon.EditModeLib
	if not (lib and lib.ShowStandaloneSettingsDialog and SettingType) then return end
	panelId = normalizeId(panelId)
	groupId = Helper.NormalizeFixedGroupId(groupId)
	if not self:IsLayoutFixedGroupStandaloneMenuAvailable(panelId, groupId) then return end
	self:HideLayoutEntryStandaloneMenu(panelId)
	self:HideLayoutPanelStandaloneMenu(panelId)
	self:HideLayoutFixedGroupStandaloneMenu(panelId)

	local panel = self:GetPanel(panelId)
	local group = panel and CooldownPanels.GetFixedGroupById(panel, groupId) or nil
	local runtime = getRuntime(panelId)
	local hostFrame = runtime and runtime.frame or nil
	local settings = self:BuildLayoutFixedGroupStandaloneSettings(panelId, groupId)
	if not (group and hostFrame and settings) then return end

	local spawnPosition = self:GetStandaloneDialogSpawnPosition(anchorFrame, hostFrame, 12, 0)
	local buttons = {
		{
			text = DELETE or "Delete",
			click = function()
				local currentPanel = CooldownPanels:GetPanel(panelId)
				local currentGroup = currentPanel and CooldownPanels.GetFixedGroupById(currentPanel, groupId) or nil
				local groupName = currentGroup and CooldownPanels.GetFixedGroupName(currentGroup) or nil
				CooldownPanels:HideLayoutFixedGroupStandaloneMenu(panelId)
				CooldownPanels:EnsureFixedGroupDeletePopup()
				StaticPopup_Show("EQOL_COOLDOWN_PANEL_FIXED_GROUP_DELETE", groupName, nil, { panelId = panelId, groupId = groupId })
			end,
		},
	}
	local dialog = lib:ShowStandaloneSettingsDialog(hostFrame, {
		title = CooldownPanels.GetFixedGroupDisplayLabel(group),
		settings = settings,
		buttons = buttons,
		showReset = false,
		showSettingsReset = false,
		settingsMaxHeight = 560,
		point = spawnPosition.point,
		relativePoint = spawnPosition.relativePoint,
		relativeTo = spawnPosition.relativeTo,
		x = spawnPosition.x,
		y = spawnPosition.y,
		onHide = function() CooldownPanels:ClearLayoutFixedGroupStandaloneMenuState() end,
	})
	if dialog then
		self:DockStandaloneDialogToEditor(dialog)
		local state = self:GetLayoutFixedGroupStandaloneMenuState()
		state.panelId = panelId
		state.groupId = groupId
		state.hostFrame = hostFrame
		state.dialog = dialog
		self:ScheduleLayoutFixedGroupStandaloneMenuRefresh(panelId, groupId)
	end
end

getEditor = function()
	local runtime = CooldownPanels.runtime and CooldownPanels.runtime["editor"]
	return runtime and runtime.editor or nil
end

local function applyEditorPosition(frame)
	if not frame or not addon or not addon.db then return end
	local point = addon.db.cooldownPanelsEditorPoint
	local x = addon.db.cooldownPanelsEditorX
	local y = addon.db.cooldownPanelsEditorY
	if not point or x == nil or y == nil then return end
	frame:ClearAllPoints()
	frame:SetPoint(point, UIParent, point, x, y)
end

local function saveEditorPosition(frame)
	if not frame or not addon or not addon.db then return end
	local point, _, _, x, y = frame:GetPoint()
	if not point or x == nil or y == nil then return end
	addon.db.cooldownPanelsEditorPoint = point
	addon.db.cooldownPanelsEditorX = x
	addon.db.cooldownPanelsEditorY = y
end

function CooldownPanels:IsEditorFrameTooFarOffscreen(frame)
	if not (frame and UIParent and frame.GetLeft and frame.GetRight and frame.GetTop and frame.GetBottom) then return false end
	local left = frame:GetLeft()
	local right = frame:GetRight()
	local top = frame:GetTop()
	local bottom = frame:GetBottom()
	if not (left and right and top and bottom) then return false end
	local screenWidth = UIParent:GetWidth() or 0
	local screenHeight = UIParent:GetHeight() or 0
	if screenWidth <= 0 or screenHeight <= 0 then return false end
	local visibleLeft = math.max(left, 0)
	local visibleRight = math.min(right, screenWidth)
	local visibleBottom = math.max(bottom, 0)
	local visibleTop = math.min(top, screenHeight)
	local visibleWidth = visibleRight - visibleLeft
	local visibleHeight = visibleTop - visibleBottom
	local frameWidth = frame:GetWidth() or 0
	local frameHeight = frame:GetHeight() or 0
	local minVisibleWidth = math.min(frameWidth, math.max(160, math.floor(frameWidth * 0.25)))
	local minVisibleHeight = math.min(frameHeight, math.max(60, math.floor(frameHeight * 0.15)))
	return visibleWidth < minVisibleWidth or visibleHeight < minVisibleHeight
end

function CooldownPanels:ResetEditorPosition(frame)
	if not (frame and UIParent) then return end
	frame:ClearAllPoints()
	frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
	saveEditorPosition(frame)
end

function CooldownPanels:EnsureEditorFramePosition(frame)
	if not (frame and UIParent) then return end
	if frame:GetNumPoints() == 0 then frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0) end
	if self:IsEditorFrameTooFarOffscreen(frame) then self:ResetEditorPosition(frame) end
end

local ensureDeletePopup
local ensureCopyPopup

local function copyPanelSettings(targetPanelId, sourcePanelId)
	local root = ensureRoot()
	if not root or not root.panels then return false end
	local target = root.panels[targetPanelId]
	local source = root.panels[sourcePanelId]
	if not target or not source then return false end

	local function replaceTableContents(dst, src)
		if type(dst) ~= "table" then dst = {} end
		if wipe then
			wipe(dst)
		else
			for key in pairs(dst) do
				dst[key] = nil
			end
		end
		if type(src) == "table" then
			for key, value in pairs(src) do
				dst[key] = value
			end
		end
		return dst
	end

	local copier = CopyTable or Helper.CopyTableShallow
	target.layout = replaceTableContents(target.layout, copier(source.layout or {}))
	target.anchor = replaceTableContents(target.anchor, copier(source.anchor or {}))
	target.point = source.point
	target.x = source.x
	target.y = source.y

	Helper.NormalizePanel(target, root.defaults)
	CooldownPanels.MarkRelativeFrameEntriesDirty()
	CooldownPanels:RebuildSpellIndex()
	CooldownPanels:ApplyPanelPosition(targetPanelId)
	refreshStandaloneSettings()
	refreshStandaloneSettingValues()
	CooldownPanels:RefreshPanel(targetPanelId)
	CooldownPanels:RefreshEditor()
	return true
end

local function applySettingsIcon(texture)
	if not texture then return end
	local source = QuestScrollFrame and QuestScrollFrame.SettingsDropdown and QuestScrollFrame.SettingsDropdown.icon
	if source then
		local atlas = source.GetAtlas and source:GetAtlas()
		if atlas and atlas ~= "" then
			if texture.SetAtlas then texture:SetAtlas(atlas, true) end
			return
		end
		local tex = source.GetTexture and source:GetTexture()
		if tex then
			texture:SetTexture(tex)
			return
		end
	end
	texture:SetTexture("Interface\\Buttons\\UI-OptionsButton")
end

function CooldownPanels:FormatBlizzardAlertTextureLabel(entry)
	if not entry then return _G.NONE or "None" end
	return string.format("%s (%d)", tostring(entry.name or entry.id), tonumber(entry.id) or 0)
end

function CooldownPanels:GetBlizzardAlertTextureByID(fileDataID)
	local id = tonumber(fileDataID)
	if not id then return nil end
	for _, entry in ipairs(self.BLIZZARD_ALERT_TEXTURES or {}) do
		if tonumber(entry.id) == id then return entry end
	end
	return nil
end

function CooldownPanels:ShowBlizzardAlertTexturePicker(currentValue, onSelect)
	local runtime = getRuntime("blizzardAlertTexturePicker")
	local frame = runtime.frame
	if not frame then
		frame = CreateFrame("Frame", "EQOL_CooldownPanelsBlizzardAlertTexturePicker", UIParent, "BackdropTemplate")
		runtime.frame = frame
		frame:SetSize(700, 520)
		frame:SetPoint("CENTER")
		frame:SetFrameStrata("DIALOG")
		frame:SetClampedToScreen(true)
		frame:SetMovable(true)
		frame:EnableMouse(true)
		frame:RegisterForDrag("LeftButton")
		frame:SetScript("OnDragStart", frame.StartMoving)
		frame:SetScript("OnDragStop", frame.StopMovingOrSizing)
		frame:Hide()

		frame.bg = frame:CreateTexture(nil, "BACKGROUND")
		frame.bg:SetPoint("TOPLEFT", frame, "TOPLEFT", 8, -8)
		frame.bg:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 8)
		frame.bg:SetTexture("Interface\\AddOns\\EnhanceQoL\\Assets\\background_dark.tga")
		frame.bg:SetAlpha(0.95)
		applyPanelBorder(frame)

		frame.title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
		frame.title:SetPoint("TOPLEFT", frame, "TOPLEFT", 20, -16)
		frame.title:SetText(L["CooldownPanelBlizzardAlertTexturePicker"] or "Blizzard alert textures")
		frame.title:SetFont((addon.variables and addon.variables.defaultFont) or frame.title:GetFont(), 16, "OUTLINE")

		frame.close = CreateFrame("Button", nil, frame, "UIPanelCloseButtonNoScripts")
		frame.close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 20, 12)
		frame.close:SetScript("OnClick", function(self) self:GetParent():Hide() end)

		frame.searchLabel = Helper.CreateLabel(frame, SEARCH or "Search", 11, "OUTLINE")
		frame.searchLabel:SetPoint("TOPLEFT", frame.title, "BOTTOMLEFT", 0, -12)

		frame.searchBox = Helper.CreateEditBox(frame, 240, 20)
		frame.searchBox:SetPoint("LEFT", frame.searchLabel, "RIGHT", 8, 0)
		frame.searchBox:SetScript("OnTextChanged", function(self)
			local owner = self:GetParent()
			if owner and owner.Refresh then owner:Refresh() end
		end)

		frame.clearButton = Helper.CreateButton(frame, _G.CLEAR or "Clear", 70, 20)
		frame.clearButton:SetPoint("LEFT", frame.searchBox, "RIGHT", 8, 0)
		frame.clearButton:SetScript("OnClick", function(self)
			local owner = self:GetParent()
			if owner and owner.searchBox then owner.searchBox:SetText("") end
		end)

		frame.okButton = Helper.CreateButton(frame, OKAY or "OK", 80, 22)
		frame.okButton:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -32, 18)
		frame.okButton:SetScript("OnClick", function(self)
			self:GetParent():Hide()
		end)

		frame.scroll = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
		frame.scroll:SetPoint("TOPLEFT", frame, "TOPLEFT", 18, -76)
		frame.scroll:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -34, 50)
		frame.content = CreateFrame("Frame", nil, frame.scroll)
		frame.content:SetSize(1, 1)
		frame.scroll:SetScrollChild(frame.content)
		frame.scroll:SetScript("OnSizeChanged", function(self)
			if frame.content then frame.content:SetWidth(self:GetWidth() or 1) end
			if frame.Refresh then frame:Refresh() end
		end)

		frame.noResults = frame.content:CreateFontString(nil, "OVERLAY", "GameFontDisable")
		frame.noResults:SetPoint("TOPLEFT", frame.content, "TOPLEFT", 8, -12)
		frame.noResults:SetText(L["CooldownPanelBlizzardAlertTextureNoResults"] or "No matching alert textures.")
		frame.noResults:Hide()
		frame.tiles = {}

		frame.Refresh = function(self)
			local search = string.lower(tostring(self.searchBox and self.searchBox:GetText() or ""))
			local selectedID = tonumber(self.currentValue)
			local tileWidth, tileHeight = 146, 112
			local paddingX, paddingY = 8, 10
			local scrollWidth = self.scroll and self.scroll:GetWidth() or 560
			local columns = math.max(1, math.floor((scrollWidth + paddingX) / (tileWidth + paddingX)))
			tileWidth = math.max(tileWidth, math.floor((scrollWidth - ((columns - 1) * paddingX)) / columns))
			local visibleIndex = 0

			for _, entry in ipairs(CooldownPanels.BLIZZARD_ALERT_TEXTURES or {}) do
				local name = string.lower(tostring(entry and entry.name or ""))
				local id = tostring(entry and entry.id or "")
				local show = search == "" or string.find(name, search, 1, true) ~= nil or string.find(id, search, 1, true) ~= nil
				visibleIndex = show and (visibleIndex + 1) or visibleIndex
				local tile = self.tiles[visibleIndex]
				if show and not tile then
					tile = CreateFrame("Button", nil, self.content, "BackdropTemplate")
					self.tiles[visibleIndex] = tile
					tile:SetSize(tileWidth, tileHeight)
					tile:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8", edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 10, insets = { left = 1, right = 1, top = 1, bottom = 1 } })
					tile:SetBackdropColor(0, 0, 0, 0.35)
					tile:SetBackdropBorderColor(0.45, 0.45, 0.45, 0.85)
					tile.icon = tile:CreateTexture(nil, "ARTWORK")
					tile.icon:SetSize(62, 62)
					tile.icon:SetPoint("TOP", tile, "TOP", 0, -8)
					tile.icon:SetTexCoord(0, 1, 0, 1)
					tile.name = tile:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
					tile.name:SetPoint("TOPLEFT", tile.icon, "BOTTOMLEFT", -22, -4)
					tile.name:SetPoint("TOPRIGHT", tile.icon, "BOTTOMRIGHT", 22, -4)
					tile.name:SetJustifyH("CENTER")
					if tile.name.SetWordWrap then tile.name:SetWordWrap(false) end
					if tile.name.SetMaxLines then tile.name:SetMaxLines(1) end
					tile.id = tile:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
					tile.id:SetPoint("TOP", tile.name, "BOTTOM", 0, -2)
					tile.selected = tile:CreateTexture(nil, "OVERLAY")
					tile.selected:SetAllPoints(tile)
					tile.selected:SetColorTexture(1, 0.82, 0, 0.18)
					tile.selected:Hide()
					tile:SetScript("OnClick", function(button)
						local data = button.entry
						if data and self.onSelect then
							self.currentValue = tostring(data.id or "")
							self.onSelect(data.id, data)
							if self.Refresh then self:Refresh() end
						end
					end)
					tile:SetScript("OnEnter", function(button)
						button:SetBackdropBorderColor(1, 0.82, 0, 1)
						GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
						GameTooltip:SetText(CooldownPanels:FormatBlizzardAlertTextureLabel(button.entry), 1, 1, 1)
						GameTooltip:Show()
					end)
					tile:SetScript("OnLeave", function(button)
						local data = button.entry
						local activeID = tonumber(self.currentValue)
						button:SetBackdropBorderColor(tonumber(data and data.id) == activeID and 1 or 0.45, tonumber(data and data.id) == activeID and 0.82 or 0.45, tonumber(data and data.id) == activeID and 0 or 0.45, tonumber(data and data.id) == activeID and 1 or 0.85)
						GameTooltip:Hide()
					end)
				end
				if show and tile then
					local row = math.floor((visibleIndex - 1) / columns)
					local column = (visibleIndex - 1) % columns
					local selected = tonumber(entry.id) == selectedID
					tile.entry = entry
					tile:ClearAllPoints()
					tile:SetPoint("TOPLEFT", self.content, "TOPLEFT", column * (tileWidth + paddingX), -(row * (tileHeight + paddingY)))
					tile.icon:SetTexture(entry.id)
					tile.name:SetText(entry.name or tostring(entry.id))
					tile.id:SetText(tostring(entry.id))
					tile.selected:SetShown(selected)
					tile:SetBackdropBorderColor(selected and 1 or 0.45, selected and 0.82 or 0.45, selected and 0 or 0.45, selected and 1 or 0.85)
					tile:Show()
				end
			end

			for index = visibleIndex + 1, #self.tiles do
				local tile = self.tiles[index]
				if tile then
					tile.entry = nil
					tile:Hide()
				end
			end

			self.noResults:SetShown(visibleIndex == 0)
			local rows = visibleIndex > 0 and math.ceil(visibleIndex / columns) or 1
			self.content:SetSize(scrollWidth, math.max(1, rows * (tileHeight + paddingY)))
		end
	end

	frame.currentValue = currentValue
	frame.onSelect = onSelect
	if frame.searchBox then frame.searchBox:SetText("") end
	if frame.Refresh then frame:Refresh() end
	frame:Show()
	frame:Raise()
	return frame
end

local function ensureEditor()
	local runtime = getRuntime("editor")
	if runtime.editor then return runtime.editor end

	local frame = CreateFrame("Frame", "EQOL_CooldownPanelsEditor", UIParent, "BackdropTemplate")
	frame:SetSize(660, 560)
	frame:SetPoint("CENTER")
	applyEditorPosition(frame)
	frame:SetClampedToScreen(false)
	frame:SetMovable(true)
	frame:EnableMouse(true)
	frame:RegisterForDrag("LeftButton")
	frame:SetFrameStrata("DIALOG")
	frame:SetScript("OnDragStart", frame.StartMoving)
	frame:SetScript("OnDragStop", function(self)
		self:StopMovingOrSizing()
		saveEditorPosition(self)
	end)
	frame:Hide()

	frame.bg = frame:CreateTexture(nil, "BACKGROUND")
	frame.bg:SetPoint("TOPLEFT", frame, "TOPLEFT", 8, -8)
	frame.bg:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 10)
	frame.bg:SetTexture("Interface\\AddOns\\EnhanceQoL\\Assets\\background_dark.tga")
	frame.bg:SetAlpha(0.9)
	applyPanelBorder(frame)

	frame.title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
	frame.title:SetPoint("TOPLEFT", frame, "TOPLEFT", 20, -12)
	frame.title:SetText(L["CooldownPanelEditor"] or "Cooldown Panel Editor")
	frame.title:SetFont((addon.variables and addon.variables.defaultFont) or frame.title:GetFont(), 16, "OUTLINE")

	frame.cdmAuraHelp = cdp.RUNTIME.CreateCDMAuraHelpButton(frame)
	if frame.cdmAuraHelp then frame.cdmAuraHelp:SetPoint("TOPLEFT", frame, "TOPLEFT", -13, 13) end

	frame.close = CreateFrame("Button", nil, frame, "UIPanelCloseButtonNoScripts")
	frame.close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 20, 13)
	frame.close:SetScript("OnClick", function() frame:Hide() end)

	local left = CreateFrame("Frame", nil, frame, "BackdropTemplate")
	left:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -44)
	left:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 16, 16)
	left:SetWidth(220)
	left.bg = left:CreateTexture(nil, "BACKGROUND")
	left.bg:SetAllPoints(left)
	left.bg:SetTexture("Interface\\AddOns\\EnhanceQoL\\Assets\\background_gray.tga")
	left.bg:SetAlpha(0.85)
	applyInsetBorder(left, -4)
	frame.left = left

	local panelTitle = Helper.CreateLabel(left, L["Panels"] or "Panels", 12, "OUTLINE")
	panelTitle:SetPoint("TOPLEFT", left, "TOPLEFT", 12, -12)

	if addon.db and addon.db.cooldownPanelsFilterClass == nil then addon.db.cooldownPanelsFilterClass = false end
	if addon.db and addon.db.cooldownPanelsFilterSpec == nil then addon.db.cooldownPanelsFilterSpec = false end
	if addon.db and addon.db.cooldownPanelsHideEmptyGroups == nil then addon.db.cooldownPanelsHideEmptyGroups = false end
	local filterButton = CreateFrame("Button", nil, left)
	filterButton:SetSize(18, 18)
	filterButton:SetPoint("TOPRIGHT", left, "TOPRIGHT", -10, -10)
	filterButton.icon = filterButton:CreateTexture(nil, "ARTWORK")
	filterButton.icon:SetAllPoints(filterButton)
	filterButton.icon:SetAlpha(0.9)
	applySettingsIcon(filterButton.icon)
	filterButton.highlight = filterButton:CreateTexture(nil, "HIGHLIGHT")
	filterButton.highlight:SetAllPoints(filterButton)
	filterButton.highlight:SetColorTexture(1, 1, 1, 0.12)
	filterButton:SetScript("OnClick", function(self) showPanelFilterMenu(self) end)

	local panelScroll = CreateFrame("ScrollFrame", nil, left, "UIPanelScrollFrameTemplate")
	panelScroll:SetPoint("TOPLEFT", panelTitle, "BOTTOMLEFT", 0, -8)
	panelScroll:SetPoint("BOTTOMRIGHT", left, "BOTTOMRIGHT", -26, 72)
	local panelContent = CreateFrame("Frame", nil, panelScroll)
	panelContent:SetSize(1, 1)
	panelScroll:SetScrollChild(panelContent)
	panelContent:SetWidth(panelScroll:GetWidth() or 1)
	panelScroll:SetScript("OnSizeChanged", function(self) panelContent:SetWidth(self:GetWidth() or 1) end)

	local addGroup = Helper.CreateButton(left, L["CooldownPanelAddGroup"] or "Add Group", 96, 22)
	addGroup:SetPoint("BOTTOMLEFT", left, "BOTTOMLEFT", 12, 40)

	local importPanel = Helper.CreateButton(left, L["CooldownPanelImportPanel"] or "Import Panel", 96, 22)
	importPanel:SetPoint("BOTTOMRIGHT", left, "BOTTOMRIGHT", -12, 40)

	local addPanel = Helper.CreateButton(left, L["Add Panel"] or "Add Panel", 96, 22)
	addPanel:SetPoint("BOTTOMLEFT", left, "BOTTOMLEFT", 12, 12)

	local deletePanel = Helper.CreateButton(left, L["CooldownPanelDeletePanel"] or "Delete Panel", 96, 22)
	deletePanel:SetPoint("BOTTOMRIGHT", left, "BOTTOMRIGHT", -12, 12)

	local right = CreateFrame("Frame", nil, frame, "BackdropTemplate")
	right:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -16, -44)
	right:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -16, 16)
	right:SetWidth(260)
	right.bg = right:CreateTexture(nil, "BACKGROUND")
	right.bg:SetAllPoints(right)
	right.bg:SetTexture("Interface\\AddOns\\EnhanceQoL\\Assets\\background_gray.tga")
	right.bg:SetAlpha(0.85)
	applyInsetBorder(right, -4)
	frame.right = right
	right:Hide()
	right:EnableMouse(false)

	local rightScroll = CreateFrame("ScrollFrame", nil, right, "UIPanelScrollFrameTemplate")
	rightScroll:SetPoint("TOPLEFT", right, "TOPLEFT", 10, -10)
	rightScroll:SetPoint("BOTTOMRIGHT", right, "BOTTOMRIGHT", -28, 12)
	local rightContent = CreateFrame("Frame", nil, rightScroll)
	rightContent:SetSize(1, 1)
	rightScroll:SetScrollChild(rightContent)
	rightContent:SetWidth(rightScroll:GetWidth() or 1)
	rightScroll:SetScript("OnSizeChanged", function(self) rightContent:SetWidth(self:GetWidth() or 1) end)

	local panelHeader = Helper.CreateLabel(rightContent, L["Panels"] or "Panels", 12, "OUTLINE")
	panelHeader:SetPoint("TOPLEFT", rightContent, "TOPLEFT", 2, -2)
	panelHeader:SetTextColor(0.9, 0.9, 0.9, 1)

	local panelNameLabel = Helper.CreateLabel(rightContent, L["CooldownPanelPanelName"] or "Panel name", 11, "OUTLINE")
	panelNameLabel:SetPoint("TOPLEFT", panelHeader, "BOTTOMLEFT", 0, -8)
	panelNameLabel:SetTextColor(0.9, 0.9, 0.9, 1)

	local panelNameBox = Helper.CreateEditBox(rightContent, 200, 20)
	panelNameBox:SetPoint("TOPLEFT", panelNameLabel, "BOTTOMLEFT", 0, -4)

	local panelEnabled = Helper.CreateCheck(rightContent, L["Enabled"] or "Enabled")
	panelEnabled:SetPoint("TOPLEFT", panelNameBox, "BOTTOMLEFT", -2, -6)

	local panelSpecLabel = Helper.CreateLabel(rightContent, L["CooldownPanelSpecFilter"] or "Show only for spec", 11, "OUTLINE")
	panelSpecLabel:SetPoint("TOPLEFT", panelEnabled, "BOTTOMLEFT", 2, -8)
	panelSpecLabel:SetTextColor(0.9, 0.9, 0.9, 1)

	local panelSpecButton = Helper.CreateButton(rightContent, L["CooldownPanelSpecAny"] or "All specs", 200, 20)
	panelSpecButton:SetPoint("TOPLEFT", panelSpecLabel, "BOTTOMLEFT", 0, -4)

	local entryHeader = Helper.CreateLabel(rightContent, L["CooldownPanelEntry"] or "Entry", 12, "OUTLINE")
	entryHeader:SetPoint("TOPLEFT", panelSpecButton, "BOTTOMLEFT", 2, -16)
	entryHeader:SetTextColor(0.9, 0.9, 0.9, 1)

	local entryEmptyHint = rightContent:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
	entryEmptyHint:SetPoint("TOPLEFT", entryHeader, "BOTTOMLEFT", 2, -8)
	entryEmptyHint:SetWidth(200)
	entryEmptyHint:SetJustifyH("LEFT")
	entryEmptyHint:SetJustifyV("TOP")
	if entryEmptyHint.SetWordWrap then entryEmptyHint:SetWordWrap(true) end
	entryEmptyHint:SetText(L["CooldownPanelSelectEntryHint"] or "Click a spell/item/macro/slot/stance to modify")
	entryEmptyHint:SetTextColor(0.75, 0.75, 0.75, 1)
	entryEmptyHint:Hide()

	local entryIcon = rightContent:CreateTexture(nil, "ARTWORK")
	entryIcon:SetSize(36, 36)
	entryIcon:SetPoint("TOPLEFT", entryHeader, "BOTTOMLEFT", 0, -6)
	entryIcon:SetTexture(Helper.PREVIEW_ICON)

	local entryName = rightContent:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
	entryName:SetPoint("LEFT", entryIcon, "RIGHT", 8, 8)
	entryName:SetWidth(180)
	entryName:SetJustifyH("LEFT")
	entryName:SetTextColor(1, 1, 1, 1)

	local entryType = rightContent:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
	entryType:SetPoint("TOPLEFT", entryName, "BOTTOMLEFT", 0, -2)
	entryType:SetJustifyH("LEFT")

	local entryIdBox = Helper.CreateEditBox(rightContent, 120, 20)
	entryIdBox:SetPoint("TOPLEFT", entryIcon, "BOTTOMLEFT", 0, -8)
	entryIdBox:SetNumeric(true)

	local cbCooldownText = Helper.CreateCheck(rightContent, L["Show cooldown text"] or "Show cooldown text")
	cbCooldownText:SetPoint("TOPLEFT", entryIdBox, "BOTTOMLEFT", -2, -6)

	local cbTrackPassiveSpell = Helper.CreateCheck(rightContent, L["CooldownPanelTrackPassiveSpell"] or "Track passive spell")
	cbTrackPassiveSpell:SetPoint("TOPLEFT", cbCooldownText, "BOTTOMLEFT", 0, -4)

	local racialExclusionsButton = Helper.CreateButton(rightContent, L["CooldownPanelRacialExclusions"] or "Excluded racials", 180, 20)
	racialExclusionsButton:SetPoint("TOPLEFT", cbTrackPassiveSpell, "BOTTOMLEFT", 18, -4)

	local cbAlwaysShow = Helper.CreateCheck(rightContent, L["Always show"] or "Always show")
	cbAlwaysShow:SetPoint("TOPLEFT", racialExclusionsButton, "BOTTOMLEFT", -18, -4)

	local cbCharges = Helper.CreateCheck(rightContent, L["Show charges"] or "Show charges")
	cbCharges:SetPoint("TOPLEFT", cbTrackPassiveSpell, "BOTTOMLEFT", 0, -4)

	local cbStacks = Helper.CreateCheck(rightContent, L["CooldownPanelShowStacks"] or "Show stack count")
	cbStacks:SetPoint("TOPLEFT", cbCharges, "BOTTOMLEFT", 0, -4)

	local cbItemCount = Helper.CreateCheck(rightContent, L["CooldownPanelShowItemCount"] or "Show item count")
	cbItemCount:SetPoint("TOPLEFT", cbCooldownText, "BOTTOMLEFT", 0, -4)

	local cbItemUses = Helper.CreateCheck(rightContent, L["CooldownPanelShowItemUses"] or "Show item uses")
	cbItemUses:SetPoint("TOPLEFT", cbItemCount, "BOTTOMLEFT", 0, -4)

	local cbUseHighestRank = Helper.CreateCheck(rightContent, L["CooldownPanelUseHighestRank"] or "Use highest rank")
	cbUseHighestRank:SetPoint("TOPLEFT", cbItemUses, "BOTTOMLEFT", 0, -4)

	local cbShowWhenEmpty = Helper.CreateCheck(rightContent, L["CooldownPanelShowWhenEmpty"] or "Show when empty")
	cbShowWhenEmpty:SetPoint("TOPLEFT", cbUseHighestRank, "BOTTOMLEFT", 0, -4)

	local cbShowWhenNoCooldown = Helper.CreateCheck(rightContent, L["CooldownPanelShowWhenNoCooldown"] or "Show even without cooldown")
	cbShowWhenNoCooldown:SetPoint("TOPLEFT", cbShowWhenEmpty, "BOTTOMLEFT", 0, -4)

	local cbCDMAuraOverlay = Helper.CreateCheck(rightContent, L["CooldownPanelCDMAuraOverlay"] or "Show aura overlay")
	cbCDMAuraOverlay:SetPoint("TOPLEFT", cbShowWhenNoCooldown, "BOTTOMLEFT", 0, -4)

	local cbCDMAuraOverlayReverse = Helper.CreateCheck(rightContent, L["CooldownPanelActivationOverlayReverse"] or "Reverse activation swipe")
	cbCDMAuraOverlayReverse:SetPoint("TOPLEFT", cbCDMAuraOverlay, "BOTTOMLEFT", 0, -4)

	local cbActivationOverlayOnly = Helper.CreateCheck(rightContent, L["CooldownPanelActivationOverlayOnly"] or "Only show during activation")
	cbActivationOverlayOnly:SetPoint("TOPLEFT", cbCDMAuraOverlayReverse, "BOTTOMLEFT", 0, -4)

	local cbActivationOverlayGlow = Helper.CreateCheck(rightContent, L["CooldownPanelActivationOverlayGlow"] or "Glow during activation")
	cbActivationOverlayGlow:SetPoint("TOPLEFT", cbActivationOverlayOnly, "BOTTOMLEFT", 0, -4)

	local cbAutoDuration = Helper.CreateCheck(rightContent, L["CooldownPanelAutoDuration"] or "Automatic duration on activation")
	cbAutoDuration:SetPoint("TOPLEFT", cbActivationOverlayGlow, "BOTTOMLEFT", 0, -4)

	local cbCustomDuration = Helper.CreateCheck(rightContent, L["CooldownPanelCustomDuration"] or "Custom duration on activation")
	cbCustomDuration:SetPoint("TOPLEFT", cbAutoDuration, "BOTTOMLEFT", 0, -4)

	local customDurationBox = Helper.CreateEditBox(rightContent, 70, 20)
	customDurationBox:SetPoint("TOPLEFT", cbCustomDuration, "BOTTOMLEFT", 18, -4)
	customDurationBox:SetNumeric(true)

	local customDurationLabel = Helper.CreateLabel(rightContent, L["CooldownPanelCustomDurationSeconds"] or "Custom duration (sec)", 10, "OUTLINE")
	customDurationLabel:SetPoint("LEFT", customDurationBox, "RIGHT", 6, 0)
	customDurationLabel:SetTextColor(0.9, 0.9, 0.9, 1)

	local staticTextLabel = Helper.CreateLabel(rightContent, L["CooldownPanelStaticText"] or "Static text", 11, "OUTLINE")
	staticTextLabel:SetPoint("TOPLEFT", customDurationBox, "BOTTOMLEFT", -16, -8)
	staticTextLabel:SetTextColor(0.9, 0.9, 0.9, 1)

	local staticTextBox = Helper.CreateEditBox(rightContent, 180, 20)
	staticTextBox:SetPoint("TOPLEFT", staticTextLabel, "BOTTOMLEFT", -2, -4)

	local cbStaticTextDuringCD = Helper.CreateCheck(rightContent, L["CooldownPanelStaticTextDuringCD"] or "Show text during CD")
	cbStaticTextDuringCD:SetPoint("TOPLEFT", staticTextBox, "BOTTOMLEFT", -2, -6)

	local cbGlow = Helper.CreateCheck(rightContent, L["CooldownPanelGlowReady"] or "Glow when ready")
	cbGlow:SetPoint("TOPLEFT", cbStacks, "BOTTOMLEFT", 0, -4)

	local cbPandemicGlow = Helper.CreateCheck(rightContent, L["CooldownPanelGlowPandemic"] or "Pandemic glow")
	cbPandemicGlow:SetPoint("TOPLEFT", cbGlow, "BOTTOMLEFT", 0, -4)

	local cbSound = Helper.CreateCheck(rightContent, L["CooldownPanelSoundReady"] or "Sound when ready")
	cbSound:SetPoint("TOPLEFT", cbPandemicGlow, "BOTTOMLEFT", 0, -6)

	local soundButton = Helper.CreateButton(rightContent, "", 180, 20)
	soundButton:SetPoint("TOPLEFT", cbSound, "BOTTOMLEFT", 18, -6)

	local removeEntry = Helper.CreateButton(rightContent, L["CooldownPanelRemoveEntry"] or "Remove entry", 180, 22)
	removeEntry:SetPoint("TOP", cbSound, "BOTTOM", 0, -12)

	local middle = CreateFrame("Frame", nil, frame, "BackdropTemplate")
	middle:SetPoint("TOPLEFT", left, "TOPRIGHT", 16, 0)
	middle:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -16, 16)
	middle.bg = middle:CreateTexture(nil, "BACKGROUND")
	middle.bg:SetAllPoints(middle)
	middle.bg:SetTexture("Interface\\AddOns\\EnhanceQoL\\Assets\\background_gray.tga")
	middle.bg:SetAlpha(0.85)
	applyInsetBorder(middle, -4)
	frame.middle = middle

	local previewTitle = Helper.CreateLabel(middle, L["CooldownPanelDropZone"] or "Add entries", 12, "OUTLINE")
	previewTitle:SetPoint("TOPLEFT", middle, "TOPLEFT", 12, -12)

	local manualAddRow = CreateFrame("Frame", nil, middle)
	manualAddRow:SetPoint("TOPLEFT", previewTitle, "BOTTOMLEFT", 0, -6)
	manualAddRow:SetPoint("RIGHT", middle, "RIGHT", -12, 0)
	manualAddRow:SetHeight(22)

	local addSpellButton = Helper.CreateButton(manualAddRow, _G.SPELL or "Spell", 66, 20)
	addSpellButton:SetPoint("LEFT", manualAddRow, "LEFT", 0, 0)

	local addItemButton = Helper.CreateButton(manualAddRow, _G.ITEM or "Item", 76, 20)
	addItemButton:SetPoint("LEFT", addSpellButton, "RIGHT", 6, 0)

	local addIdBox = Helper.CreateEditBox(manualAddRow, 84, 20)
	addIdBox:SetPoint("LEFT", addItemButton, "RIGHT", 10, 0)
	addIdBox:SetNumeric(true)

	local previewFrame = CreateFrame("Frame", nil, middle, "BackdropTemplate")
	previewFrame:SetPoint("TOPLEFT", middle, "TOPLEFT", 12, -64)
	previewFrame:SetPoint("TOPRIGHT", middle, "TOPRIGHT", -12, -64)
	previewFrame:SetHeight(48)
	previewFrame:SetClipsChildren(true)
	previewFrame.bg = previewFrame:CreateTexture(nil, "BACKGROUND")
	previewFrame.bg:SetAllPoints(previewFrame)
	previewFrame.bg:SetColorTexture(0, 0, 0, 0.3)
	applyInsetBorder(previewFrame, -6)

	local previewCanvas = CreateFrame("Frame", nil, previewFrame)
	previewCanvas._eqolIsPreview = true
	previewCanvas:SetPoint("CENTER", previewFrame, "CENTER")
	previewCanvas:SetFrameLevel((previewFrame:GetFrameLevel() or 0) + 2)
	previewFrame.canvas = previewCanvas

	local previewHint = previewFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
	previewHint:SetPoint("CENTER", previewFrame, "CENTER")
	previewHint:SetText(L["CooldownPanelDropHint"] or "Drop spells, items, or macros here")
	previewHint:SetTextColor(1, 0.86, 0.24, 1)
	previewFrame.dropHint = previewHint

	local dropZone = CreateFrame("Button", nil, previewFrame)
	dropZone:SetAllPoints(previewFrame)
	dropZone:SetFrameLevel((previewFrame:GetFrameLevel() or 0) + 1)
	dropZone:RegisterForClicks("LeftButtonUp")
	dropZone:SetScript("OnReceiveDrag", function()
		if CooldownPanels:HandleCursorDrop(runtime.editor and runtime.editor.selectedPanelId) then CooldownPanels:RefreshEditor() end
	end)
	dropZone:SetScript("OnMouseUp", function(_, btn)
		if btn == "LeftButton" then
			if CooldownPanels:HandleCursorDrop(runtime.editor and runtime.editor.selectedPanelId) then CooldownPanels:RefreshEditor() end
		end
	end)
	dropZone.highlight = dropZone:CreateTexture(nil, "HIGHLIGHT")
	dropZone.highlight:SetAllPoints(dropZone)
	dropZone.highlight:SetColorTexture(0.2, 0.6, 0.6, 0.15)
	previewFrame.dropZone = dropZone
	local previewHintLabel = middle:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
	previewHintLabel:SetPoint("BOTTOMRIGHT", previewFrame, "TOPRIGHT", -2, 6)
	previewHintLabel:SetJustifyH("RIGHT")
	previewHintLabel:SetText(L["CooldownPanelPreviewHint"] or "Drag spells/items/macros here to add")
	previewHintLabel:Hide()

	local entryTitle = Helper.CreateLabel(middle, L["CooldownPanelEntries"] or "Entries", 12, "OUTLINE")
	entryTitle:SetPoint("TOPLEFT", previewFrame, "BOTTOMLEFT", 0, -12)

	local entryScroll = CreateFrame("ScrollFrame", nil, middle, "UIPanelScrollFrameTemplate")
	entryScroll:SetPoint("TOPLEFT", entryTitle, "BOTTOMLEFT", 0, -8)
	entryScroll:SetPoint("BOTTOMRIGHT", middle, "BOTTOMRIGHT", -26, 48)
	local entryContent = CreateFrame("Frame", nil, entryScroll)
	entryContent:SetSize(1, 1)
	entryScroll:SetScrollChild(entryContent)
	entryContent:SetWidth(entryScroll:GetWidth() or 1)
	entryScroll:SetScript("OnSizeChanged", function(self) entryContent:SetWidth(self:GetWidth() or 1) end)

	local entryHint = middle:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
	entryHint:SetPoint("BOTTOMRIGHT", entryScroll, "TOPRIGHT", -2, 6)
	entryHint:SetJustifyH("RIGHT")
	entryHint:SetText(L["CooldownPanelEntriesHint"] or "Drag entries to reorder")

	local bottomActionButtonWidth = 112
	local bottomActionButtonHeight = 20

	local layoutEditButton = Helper.CreateButton(middle, L["CooldownPanelLayoutEdit"] or "Layout edit", bottomActionButtonWidth, bottomActionButtonHeight)
	layoutEditButton:SetPoint("BOTTOMLEFT", middle, "BOTTOMLEFT", 12, 18)

	local importCDMButton = Helper.CreateButton(middle, L["CooldownPanelImportCDM"] or "Import CDM", bottomActionButtonWidth, bottomActionButtonHeight)
	importCDMButton:SetPoint("LEFT", layoutEditButton, "RIGHT", 8, 0)

	local slotButton = Helper.CreateButton(middle, L["CooldownPanelAddSlot"] or "Add more", bottomActionButtonWidth, bottomActionButtonHeight)
	slotButton:SetPoint("LEFT", importCDMButton, "RIGHT", 8, 0)

	frame:SetScript("OnShow", function()
		CooldownPanels:EnsureEditorFramePosition(frame)
		CooldownPanels:RefreshEditor()
	end)
	frame:SetScript("OnHide", function()
		saveEditorPosition(frame)
		CooldownPanels:HideLayoutEntryStandaloneMenu()
		CooldownPanels:HideLayoutPanelStandaloneMenu()
		CooldownPanels:HideLayoutFixedGroupStandaloneMenu()
		if runtime and runtime.editor then
			local previousLayoutPanelId = normalizeId(runtime.editor._eqolLayoutPanelId or runtime.editor.selectedPanelId)
			hideEditorDragIcon(runtime.editor)
			runtime.editor.draggingEntry = nil
			runtime.editor.dragEntryId = nil
			runtime.editor.dragTargetId = nil
			runtime.editor.dragPreviewSlot = nil
			runtime.editor.layoutEditActive = nil
			runtime.editor._eqolLayoutPanelId = nil
			if previousLayoutPanelId and CooldownPanels:GetPanel(previousLayoutPanelId) then CooldownPanels:RefreshPanel(previousLayoutPanelId) end
			CooldownPanels:UpdateCursorAnchorState()
		end
	end)
	runtime.editor = {
		frame = frame,
		layoutEditActive = nil,
		selectedPanelId = nil,
		selectedEntryId = nil,
		panelRows = {},
		entryRows = {},
		panelList = { scroll = panelScroll, content = panelContent },
		entryList = { scroll = entryScroll, content = entryContent },
		previewFrame = previewFrame,
		previewHintLabel = previewHintLabel,
		entryHint = entryHint,
		addGroup = addGroup,
		importPanel = importPanel,
		addPanel = addPanel,
		deletePanel = deletePanel,
		addEntryType = "SPELL",
		addSpellButton = addSpellButton,
		addItemButton = addItemButton,
		addIdBox = addIdBox,
		layoutEditButton = layoutEditButton,
		slotButton = slotButton,
		importCDMButton = importCDMButton,
		filterButton = filterButton,
		inspector = {
			scroll = rightScroll,
			content = rightContent,
			panelHeader = panelHeader,
			panelName = panelNameBox,
			panelEnabled = panelEnabled,
			panelSpecLabel = panelSpecLabel,
			panelSpecButton = panelSpecButton,
			entryHeader = entryHeader,
			entryEmptyHint = entryEmptyHint,
			entryIcon = entryIcon,
			entryName = entryName,
			entryType = entryType,
			entryId = entryIdBox,
			cbCooldownText = cbCooldownText,
			cbTrackPassiveSpell = cbTrackPassiveSpell,
			racialExclusionsButton = racialExclusionsButton,
			cbAlwaysShow = cbAlwaysShow,
			cbCharges = cbCharges,
			cbStacks = cbStacks,
			cbItemCount = cbItemCount,
			cbItemUses = cbItemUses,
			cbUseHighestRank = cbUseHighestRank,
			cbShowWhenEmpty = cbShowWhenEmpty,
			cbShowWhenNoCooldown = cbShowWhenNoCooldown,
			cbCDMAuraOverlay = cbCDMAuraOverlay,
			cbCDMAuraOverlayReverse = cbCDMAuraOverlayReverse,
			cbActivationOverlayOnly = cbActivationOverlayOnly,
			cbActivationOverlayGlow = cbActivationOverlayGlow,
			cbAutoDuration = cbAutoDuration,
			cbCustomDuration = cbCustomDuration,
			customDurationBox = customDurationBox,
			customDurationLabel = customDurationLabel,
			staticTextLabel = staticTextLabel,
			staticTextBox = staticTextBox,
			cbStaticTextDuringCD = cbStaticTextDuringCD,
			cbGlow = cbGlow,
			cbPandemicGlow = cbPandemicGlow,
			cbSound = cbSound,
			soundButton = soundButton,
			removeEntry = removeEntry,
		},
	}

	local editor = runtime.editor

	local function refreshAddEntryTypeButtons()
		local entryType = editor.addEntryType == "ITEM" and "ITEM" or "SPELL"
		if editor.addSpellButton then
			if entryType == "SPELL" then
				editor.addSpellButton:LockHighlight()
			else
				editor.addSpellButton:UnlockHighlight()
			end
		end
		if editor.addItemButton then
			if entryType == "ITEM" then
				editor.addItemButton:LockHighlight()
			else
				editor.addItemButton:UnlockHighlight()
			end
		end
	end

	addGroup:SetScript("OnClick", function()
		if CooldownPanels.ShowEditorGroupCreatePopup then CooldownPanels:ShowEditorGroupCreatePopup() end
	end)

	addPanel:SetScript("OnClick", function()
		local newName = L["CooldownPanelNewPanel"] or "New Panel"
		local panelId = CooldownPanels:CreatePanel(newName)
		if panelId then CooldownPanels:SelectPanel(panelId) end
	end)

	importPanel:SetScript("OnClick", function()
		CooldownPanels.EnsureImportPanelPopup()
		StaticPopup_Show("EQOL_COOLDOWN_PANEL_IMPORT")
	end)

	deletePanel:SetScript("OnClick", function()
		local panelId = editor.selectedPanelId
		if not panelId then return end
		local panel = CooldownPanels:GetPanel(panelId)
		ensureDeletePopup()
		StaticPopup_Show("EQOL_COOLDOWN_PANEL_DELETE", panel and panel.name or nil, nil, { panelId = panelId })
	end)

	addSpellButton:SetScript("OnClick", function()
		editor.addEntryType = "SPELL"
		refreshAddEntryTypeButtons()
		if editor.addIdBox then editor.addIdBox:SetFocus() end
	end)

	addItemButton:SetScript("OnClick", function()
		editor.addEntryType = "ITEM"
		refreshAddEntryTypeButtons()
		if editor.addIdBox then editor.addIdBox:SetFocus() end
	end)

	addIdBox:SetScript("OnEnterPressed", function(self)
		local panelId = editor.selectedPanelId
		local value = tonumber(self:GetText())
		local entryType = editor.addEntryType == "ITEM" and "ITEM" or "SPELL"
		if panelId and value then CooldownPanels:AddEntrySafe(panelId, entryType, value) end
		self:SetText("")
		self:ClearFocus()
		CooldownPanels:RefreshEditor()
	end)
	addIdBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
	refreshAddEntryTypeButtons()

	layoutEditButton:SetScript("OnClick", function() CooldownPanels:SetEditorLayoutEditEnabled(editor.layoutEditActive ~= true) end)

	slotButton:SetScript("OnClick", function(self) showSlotMenu(self, editor.selectedPanelId) end)
	racialExclusionsButton:SetScript("OnClick", function(self) CooldownPanels:ShowRacialExclusionsMenu(self, editor.selectedPanelId, editor.selectedEntryId) end)
	importCDMButton:SetScript("OnClick", function(self)
		local panelId = editor.selectedPanelId
		if not panelId then return end
		showImportCDMMenu(self, panelId)
	end)

	local function commitPanelNameChange(self)
		local panelId = editor.selectedPanelId
		local panel = panelId and CooldownPanels:GetPanel(panelId)
		local text = self:GetText()
		if panel and text and text ~= "" and text ~= panel.name then
			panel.name = text
			CooldownPanels.MarkRelativeFrameEntriesDirty()
			refreshStandaloneSettings()
			CooldownPanels:RefreshPanel(panelId)
		end
	end

	panelNameBox:SetScript("OnEnterPressed", function(self)
		commitPanelNameChange(self)
		self:ClearFocus()
		CooldownPanels:RefreshEditor()
	end)
	panelNameBox:SetScript("OnEditFocusLost", function(self)
		commitPanelNameChange(self)
		CooldownPanels:RefreshEditor()
	end)
	panelNameBox:SetScript("OnEscapePressed", function(self)
		self:ClearFocus()
		CooldownPanels:RefreshEditor()
	end)

	panelEnabled:SetScript("OnClick", function(self)
		local panelId = editor.selectedPanelId
		local panel = panelId and CooldownPanels:GetPanel(panelId)
		if panel then
			panel.enabled = self:GetChecked() and true or false
			CooldownPanels:RebuildSpellIndex()
			Keybinds.MarkPanelsDirty()
			CooldownPanels:RefreshPanel(panelId)
			CooldownPanels:UpdateCursorAnchorState()
			CooldownPanels:RefreshEditor()
		end
	end)

	panelSpecButton:SetScript("OnClick", function(self) showSpecMenu(self, editor.selectedPanelId) end)

	entryIdBox:SetScript("OnEnterPressed", function(self)
		local panelId = editor.selectedPanelId
		local entryId = editor.selectedEntryId
		local panel = panelId and CooldownPanels:GetPanel(panelId)
		local entry = panel and panel.entries and panel.entries[entryId]
		local value = tonumber(self:GetText())
		if not panel or not entry then
			self:ClearFocus()
			CooldownPanels:RefreshEditor()
			return
		end
		if entry.type == "STANCE" or entry.type == "CDM_AURA" then
			self:ClearFocus()
			CooldownPanels:RefreshEditor()
			return
		end
		if not value then
			self:ClearFocus()
			CooldownPanels:RefreshEditor()
			return
		end
		local newValue = value
		local enableHighestRank = false
		if entry.type == "SPELL" then
			local baseValue = getBaseSpellId(value) or value
			if not spellExistsSafe(value) and not spellExistsSafe(baseValue) then
				showErrorMessage(L["CooldownPanelSpellInvalid"] or "Spell does not exist.")
				self:ClearFocus()
				CooldownPanels:RefreshEditor()
				return
			end
			newValue = CooldownPanels:NormalizePersistentSpellID(value, { allowTalentChoiceCanonical = true }) or baseValue
		elseif entry.type == "ITEM" then
			local canonicalItemID, wasHigherRank = CooldownPanels:GetCanonicalItemRankID(newValue)
			if canonicalItemID then newValue = canonicalItemID end
			enableHighestRank = wasHigherRank == true
		end
		local existingId = CooldownPanels:FindEntryByValue(panelId, entry.type, newValue)
		if existingId and existingId ~= entryId then
			showErrorMessage("Entry already exists.")
			self:ClearFocus()
			CooldownPanels:RefreshEditor()
			return
		end
		if entry.type == "SPELL" then
			entry.spellID = newValue
		elseif entry.type == "ITEM" then
			entry.itemID = newValue
			if enableHighestRank then entry.useHighestRank = true end
		elseif entry.type == "SLOT" then
			entry.slotID = newValue
		elseif entry.type == "MACRO" then
			entry.macroID = newValue
			local macroName = Api.GetMacroInfo and Api.GetMacroInfo(newValue) or nil
			entry.macroName = CooldownPanels.NormalizeMacroName(macroName) or entry.macroName
		end
		self:ClearFocus()
		CooldownPanels:ClearEntryCustomCooldownDuration(panelId, entryId, true)
		CooldownPanels:RebuildSpellIndex()
		CooldownPanels:RefreshPanel(panelId)
		CooldownPanels:RefreshEditor()
	end)
	entryIdBox:SetScript("OnEscapePressed", function(self)
		self:ClearFocus()
		CooldownPanels:RefreshEditor()
	end)

	local function bindEntryToggle(cb, field)
		cb:SetScript("OnClick", function(self)
			local panelId = editor.selectedPanelId
			local entryId = editor.selectedEntryId
			local panel = panelId and CooldownPanels:GetPanel(panelId)
			local entry = panel and panel.entries and panel.entries[entryId]
			if not entry then return end
			entry[field] = self:GetChecked() and true or false
			CooldownPanels:HandleEntryBooleanMutation(panelId, entryId, entry, field)
			CooldownPanels:RefreshPanel(panelId)
			CooldownPanels:RefreshEditor()
		end)
	end

	bindEntryToggle(cbCharges, "showCharges")
	bindEntryToggle(cbStacks, "showStacks")
	bindEntryToggle(cbCooldownText, "showCooldownText")
	bindEntryToggle(cbTrackPassiveSpell, "trackPassiveSpell")
	cbAlwaysShow:SetScript("OnClick", function(self)
		local panelId = editor.selectedPanelId
		local entryId = editor.selectedEntryId
		local panel = panelId and CooldownPanels:GetPanel(panelId)
		local entry = panel and panel.entries and panel.entries[entryId]
		if not entry then return end
		if entry.type == "STANCE" then
			entry.showWhenMissing = self:GetChecked() and true or false
		elseif entry.type == "CDM_AURA" then
			local layout = panel and panel.layout or nil
			local mode = self:GetChecked() and (CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE and CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE.SHOW or "SHOW")
				or (CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE and CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE.HIDE or "HIDE")
			local resolvedMode = CooldownPanels:ResolveEntryCDMAuraAlwaysShowMode(layout, entry)
			if entry.cdmAuraAlwaysShowUseGlobal == false and resolvedMode == mode then return end
			entry.cdmAuraAlwaysShowUseGlobal = false
			entry.cdmAuraAlwaysShowMode = mode
			entry.alwaysShow = self:GetChecked() and true or false
		else
			entry.alwaysShow = self:GetChecked() and true or false
		end
		CooldownPanels:RefreshPanel(panelId)
		CooldownPanels:RefreshEditor()
	end)
	bindEntryToggle(cbItemCount, "showItemCount")
	bindEntryToggle(cbItemUses, "showItemUses")
	bindEntryToggle(cbUseHighestRank, "useHighestRank")
	bindEntryToggle(cbShowWhenEmpty, "showWhenEmpty")
	bindEntryToggle(cbShowWhenNoCooldown, "showWhenNoCooldown")
	cbCDMAuraOverlay:SetScript("OnClick", function(self)
		local panelId = editor.selectedPanelId
		local entryId = editor.selectedEntryId
		local panel = panelId and CooldownPanels:GetPanel(panelId)
		local entry = panel and panel.entries and panel.entries[entryId]
		if not entry then return end
		local enabled = self:GetChecked() == true
		if enabled and not CooldownPanels:SupportsEntryCDMAuraOverlay(entry, entry.type) then
			self:SetChecked(false)
			return
		end
		entry.cdmAuraOverlayEnabled = enabled
		if enabled then
			entry.customCooldownDurationEnabled = false
			entry.autoCooldownDurationEnabled = false
		end
		CooldownPanels:ClearEntryCustomCooldownDuration(panelId, entryId, true)
		CooldownPanels:RebuildSpellIndex()
		CooldownPanels:RefreshPanel(panelId)
		CooldownPanels:RefreshEditor()
	end)
	cbCDMAuraOverlayReverse:SetScript("OnClick", function(self)
		local panelId = editor.selectedPanelId
		local entryId = editor.selectedEntryId
		local panel = panelId and CooldownPanels:GetPanel(panelId)
		local entry = panel and panel.entries and panel.entries[entryId]
		if not entry then return end
		entry.activationOverlayReverse = self:GetChecked() == true
		CooldownPanels:RefreshPanel(panelId)
		CooldownPanels:RefreshEditor()
	end)
	cbActivationOverlayOnly:SetScript("OnClick", function(self)
		local panelId = editor.selectedPanelId
		local entryId = editor.selectedEntryId
		local panel = panelId and CooldownPanels:GetPanel(panelId)
		local entry = panel and panel.entries and panel.entries[entryId]
		if not entry then return end
		entry.activationOverlayOnly = self:GetChecked() == true
		CooldownPanels:RefreshPanel(panelId)
		CooldownPanels:RefreshEditor()
	end)
	cbActivationOverlayGlow:SetScript("OnClick", function(self)
		local panelId = editor.selectedPanelId
		local entryId = editor.selectedEntryId
		local panel = panelId and CooldownPanels:GetPanel(panelId)
		local entry = panel and panel.entries and panel.entries[entryId]
		if not entry then return end
		entry.activationOverlayGlow = self:GetChecked() == true
		CooldownPanels:RefreshPanel(panelId)
		CooldownPanels:RefreshEditor()
	end)
	cbAutoDuration:SetScript("OnClick", function(self)
		local panelId = editor.selectedPanelId
		local entryId = editor.selectedEntryId
		local panel = panelId and CooldownPanels:GetPanel(panelId)
		local entry = panel and panel.entries and panel.entries[entryId]
		if not entry then return end
		local enabled = self:GetChecked() == true
		if enabled and not CooldownPanels:SupportsEntryAutoCooldownDuration(entry, entry.type) then
			self:SetChecked(false)
			return
		end
		entry.autoCooldownDurationEnabled = enabled
		if enabled then entry.customCooldownDurationEnabled = false end
		if enabled then entry.cdmAuraOverlayEnabled = false end
		CooldownPanels:ClearEntryCustomCooldownDuration(panelId, entryId, true)
		CooldownPanels:RefreshPanel(panelId)
		CooldownPanels:RefreshEditor()
	end)
	cbCustomDuration:SetScript("OnClick", function(self)
		local panelId = editor.selectedPanelId
		local entryId = editor.selectedEntryId
		local panel = panelId and CooldownPanels:GetPanel(panelId)
		local entry = panel and panel.entries and panel.entries[entryId]
		if not entry then return end
		local enabled = self:GetChecked() == true
		if enabled and not ((tonumber(entry.customCooldownDuration) or 0) > 0) then entry.customCooldownDuration = 60 end
		entry.customCooldownDurationEnabled = enabled
		if enabled then entry.autoCooldownDurationEnabled = false end
		if enabled then entry.cdmAuraOverlayEnabled = false end
		CooldownPanels:ClearEntryCustomCooldownDuration(panelId, entryId, true)
		CooldownPanels:RefreshPanel(panelId)
		CooldownPanels:RefreshEditor()
	end)
	bindEntryToggle(cbStaticTextDuringCD, "staticTextShowOnCooldown")
	bindEntryToggle(cbGlow, "glowReady")
	bindEntryToggle(cbPandemicGlow, "pandemicGlow")
	cbSound:SetScript("OnClick", function(self)
		local panelId = editor.selectedPanelId
		local entryId = editor.selectedEntryId
		local panel = panelId and CooldownPanels:GetPanel(panelId)
		local entry = panel and panel.entries and panel.entries[entryId]
		local _, enabledField = CooldownPanels:GetEntrySoundConfig(entry)
		if not (entry and enabledField) then return end
		entry[enabledField] = self:GetChecked() and true or false
		CooldownPanels:RefreshPanel(panelId)
		CooldownPanels:RefreshEditor()
	end)

	local function applyStaticTextValue(self)
		local panelId = editor.selectedPanelId
		local entryId = editor.selectedEntryId
		local panel = panelId and CooldownPanels:GetPanel(panelId)
		local entry = panel and panel.entries and panel.entries[entryId]
		if not entry then
			self:ClearFocus()
			return
		end
		local text = self:GetText() or ""
		if entry.staticText == text then
			self:ClearFocus()
			return
		end
		entry.staticText = text
		self:ClearFocus()
		CooldownPanels:RefreshPanel(panelId)
		CooldownPanels:RefreshEditor()
	end

	staticTextBox:SetScript("OnEnterPressed", applyStaticTextValue)
	staticTextBox:SetScript("OnEditFocusLost", applyStaticTextValue)
	staticTextBox:SetScript("OnEscapePressed", function(self)
		self:ClearFocus()
		CooldownPanels:RefreshEditor()
	end)

	local function applyCustomDurationValue(self)
		local panelId = editor.selectedPanelId
		local entryId = editor.selectedEntryId
		local panel = panelId and CooldownPanels:GetPanel(panelId)
		local entry = panel and panel.entries and panel.entries[entryId]
		if not entry then
			self:ClearFocus()
			return
		end
		local duration = Helper.ClampNumber(tonumber(self:GetText()), 0, Helper.CUSTOM_COOLDOWN_DURATION_MAX or 300, entry.customCooldownDuration or 0) or 0
		if duration <= 0 then
			duration = 0
			entry.customCooldownDurationEnabled = false
		end
		entry.customCooldownDuration = duration
		CooldownPanels:ClearEntryCustomCooldownDuration(panelId, entryId, true)
		self:ClearFocus()
		CooldownPanels:RefreshPanel(panelId)
		CooldownPanels:RefreshEditor()
	end

	customDurationBox:SetScript("OnEnterPressed", applyCustomDurationValue)
	customDurationBox:SetScript("OnEditFocusLost", applyCustomDurationValue)
	customDurationBox:SetScript("OnEscapePressed", function(self)
		self:ClearFocus()
		CooldownPanels:RefreshEditor()
	end)

	soundButton:SetScript("OnClick", function(self)
		local panelId = editor.selectedPanelId
		local entryId = editor.selectedEntryId
		if panelId and entryId then showSoundMenu(self, panelId, entryId) end
	end)

	removeEntry:SetScript("OnClick", function()
		local panelId = editor.selectedPanelId
		local entryId = editor.selectedEntryId
		if panelId and entryId then
			CooldownPanels:RemoveEntry(panelId, entryId)
			editor.selectedEntryId = nil
			CooldownPanels:RefreshEditor()
		end
	end)

	local cdmAuras = CooldownPanels.CDMAuras
	if cdmAuras and cdmAuras.AttachEditor then cdmAuras:AttachEditor(runtime.editor) end

	return runtime.editor
end

ensureDeletePopup = function()
	if StaticPopupDialogs["EQOL_COOLDOWN_PANEL_DELETE"] then return end
	StaticPopupDialogs["EQOL_COOLDOWN_PANEL_DELETE"] = {
		text = L["CooldownPanelDeletePanel"] or "Delete Panel?",
		button1 = YES,
		button2 = CANCEL,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
		OnAccept = function(self, data)
			if not data or not data.panelId then return end
			CooldownPanels:DeletePanel(data.panelId)
			CooldownPanels:RefreshEditor()
		end,
	}
end

function cdp.EXPORT.ImportErrorMessage(reason)
	if reason == "NO_INPUT" then return L["ProfileImportEmpty"] or "Please paste a code to import." end
	if reason == "INVALID" or reason == "DECODE" or reason == "DECOMPRESS" or reason == "DESERIALIZE" then return L["The code could not be read."] or "The code could not be read." end
	return L["CooldownPanelImportFailed"] or "Cooldown panel import failed."
end

function cdp.EXPORT.ShowDialog(title, code)
	CooldownPanels.EnsureExportPanelPopup()
	local dialog = StaticPopupDialogs["EQOL_COOLDOWN_PANEL_EXPORT"]
	dialog.text = title or (L["CooldownPanelExportPanel"] or "Export Panel")
	dialog._eqolExportCode = code or ""
	StaticPopup_Show("EQOL_COOLDOWN_PANEL_EXPORT")
end

function CooldownPanels.EnsureExportPanelPopup()
	if StaticPopupDialogs["EQOL_COOLDOWN_PANEL_EXPORT"] then return end
	StaticPopupDialogs["EQOL_COOLDOWN_PANEL_EXPORT"] = {
		text = L["CooldownPanelExportPanel"] or "Export Panel",
		button1 = CLOSE,
		hasEditBox = true,
		editBoxWidth = 320,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
		OnShow = function(self)
			self:SetFrameStrata("TOOLTIP")
			local editBox = self.editBox or self:GetEditBox()
			editBox:SetText(StaticPopupDialogs["EQOL_COOLDOWN_PANEL_EXPORT"]._eqolExportCode or "")
			editBox:HighlightText()
			editBox:SetFocus()
		end,
	}
end

function CooldownPanels.EnsureImportPanelPopup()
	if StaticPopupDialogs["EQOL_COOLDOWN_PANEL_IMPORT"] then return end
	StaticPopupDialogs["EQOL_COOLDOWN_PANEL_IMPORT"] = {
		text = L["CooldownPanelImportConfirm"] or "Import a cooldown panel or group?",
		button1 = L["Import"] or OKAY,
		button2 = CANCEL,
		hasEditBox = true,
		editBoxWidth = 320,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
		OnShow = function(self)
			self:SetFrameStrata("TOOLTIP")
			local editBox = self.editBox or self:GetEditBox()
			editBox:SetText("")
			editBox:SetFocus()
		end,
		EditBoxOnEnterPressed = function(editBox)
			local parent = editBox:GetParent()
			if parent and parent.button1 then parent.button1:Click() end
		end,
		OnAccept = function(self)
			local editBox = self.editBox or self:GetEditBox()
			local ok, reason = CooldownPanels:ImportPanelOrGroup(editBox and editBox:GetText() or "")
			if not ok then
				print("|cff00ff98Enhance QoL|r: " .. tostring(cdp.EXPORT.ImportErrorMessage(reason)))
				return
			end
			print("|cff00ff98Enhance QoL|r: " .. (L["CooldownPanelImportSuccess"] or "Cooldown panel imported."))
		end,
	}
end

ensureImportCDMPopup = function()
	if StaticPopupDialogs["EQOL_COOLDOWN_PANEL_IMPORT_CDM"] then return end
	StaticPopupDialogs["EQOL_COOLDOWN_PANEL_IMPORT_CDM"] = {
		text = L["CooldownPanelImportCDMConfirm"] or "Import all entries from %s?",
		button1 = YES,
		button2 = NO,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
		OnAccept = function(_, data)
			if not data or not data.panelId then return end
			local sourceKind = data.sourceKind or "ESSENTIAL"
			local sourceLabel = data.sourceLabel or getCooldownManagerSourceLabel(sourceKind)
			local result, err, resolvedSourceLabel
			local cdmAuras = CooldownPanels.CDMAuras
			if (sourceKind == "BUFF_ICON" or sourceKind == "BUFF_BAR") and cdmAuras and cdmAuras.ImportEntries then
				result, err, resolvedSourceLabel = cdmAuras:ImportEntries(data.panelId, sourceKind)
			else
				result, err, resolvedSourceLabel = importCooldownManagerSpells(data.panelId, sourceKind)
			end
			local errorSourceLabel = resolvedSourceLabel or sourceLabel
			if not result then
				if err == "SOURCE_NOT_FOUND" then
					showErrorMessage(string.format(L["CooldownPanelImportCDMNoSource"] or "Cooldown Manager data not found for %s.", tostring(errorSourceLabel or sourceLabel)))
				elseif err == "PANEL_NOT_FOUND" then
					showErrorMessage(L["CooldownPanelImportCDMNoPanel"] or "No panel selected.")
				end
			else
				print(
					string.format(
						"[EnhanceQoL] " .. (L["CooldownPanelImportCDMResult"] or "Imported %d entries from %s (%d duplicates, %d skipped)."),
						result.added or 0,
						tostring(result.sourceLabel or sourceLabel),
						result.duplicates or 0,
						result.invalid or 0
					)
				)
			end
			CooldownPanels:RefreshEditor()
		end,
	}
end

ensureCopyPopup = function()
	if StaticPopupDialogs["EQOL_COOLDOWN_PANEL_COPY_SETTINGS"] then return end
	StaticPopupDialogs["EQOL_COOLDOWN_PANEL_COPY_SETTINGS"] = {
		text = L["CooldownPanelCopySettingsConfirm"] or "Copy settings from %s to this panel?",
		button1 = YES,
		button2 = CANCEL,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
		OnAccept = function(self, data)
			if not data or not data.targetPanelId or not data.sourcePanelId then return end
			copyPanelSettings(data.targetPanelId, data.sourcePanelId)
		end,
	}
end

function CooldownPanels:EnsureEditorGroupCreatePopup()
	if StaticPopupDialogs["EQOL_COOLDOWN_PANEL_GROUP_CREATE"] then return end
	StaticPopupDialogs["EQOL_COOLDOWN_PANEL_GROUP_CREATE"] = {
		text = L["CooldownPanelCreateGroupPrompt"] or "Create group",
		button1 = OKAY,
		button2 = CANCEL,
		hasEditBox = true,
		editBoxWidth = 240,
		enterClicksFirstButton = true,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
		OnShow = function(self)
			local editBox = self.editBox or self.GetEditBox and self:GetEditBox()
			if editBox then
				editBox:SetText("")
				editBox:SetFocus()
				if editBox.HighlightText then editBox:HighlightText() end
			end
		end,
		OnAccept = function(self, data)
			local editBox = self.editBox or self.GetEditBox and self:GetEditBox()
			local text = editBox and editBox:GetText()
			local parentGroupId = data and normalizeId(data.parentGroupId) or nil
			local groupId = CooldownPanels:CreateEditorGroup(text, parentGroupId)
			if groupId and CooldownPanels.IsEditorOpen and CooldownPanels:IsEditorOpen() then
				local editor = getEditor()
				if editor and parentGroupId ~= nil then CooldownPanels:GetEditorPanelGroupState(editor)[CooldownPanels:GetEditorPanelGroupStateKey(parentGroupId)] = nil end
				CooldownPanels:RefreshEditor()
			end
		end,
	}
	StaticPopupDialogs["EQOL_COOLDOWN_PANEL_GROUP_CREATE"].EditBoxOnEnterPressed = function(editBox)
		local parent = editBox and editBox.GetParent and editBox:GetParent()
		if parent and parent.button1 and parent.button1:IsEnabled() then parent.button1:Click() end
	end
end

function CooldownPanels:EnsureEditorGroupRenamePopup()
	if StaticPopupDialogs["EQOL_COOLDOWN_PANEL_GROUP_RENAME"] then return end
	StaticPopupDialogs["EQOL_COOLDOWN_PANEL_GROUP_RENAME"] = {
		text = L["CooldownPanelRenameGroupPrompt"] or "Rename group",
		button1 = OKAY,
		button2 = CANCEL,
		hasEditBox = true,
		editBoxWidth = 240,
		enterClicksFirstButton = true,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
		OnShow = function(self, data)
			local editBox = self.editBox or self.GetEditBox and self:GetEditBox()
			local root = ensureRoot()
			local currentName = data and CooldownPanels.GetEditorGroupName(root, data.groupId) or ""
			if editBox then
				editBox:SetText(currentName or "")
				editBox:SetFocus()
				if editBox.HighlightText then editBox:HighlightText() end
			end
		end,
		OnAccept = function(self, data)
			if not (data and data.groupId) then return end
			local editBox = self.editBox or self.GetEditBox and self:GetEditBox()
			local text = editBox and editBox:GetText()
			if CooldownPanels:RenameEditorGroup(data.groupId, text) and CooldownPanels.IsEditorOpen and CooldownPanels:IsEditorOpen() then CooldownPanels:RefreshEditor() end
		end,
	}
	StaticPopupDialogs["EQOL_COOLDOWN_PANEL_GROUP_RENAME"].EditBoxOnEnterPressed = function(editBox)
		local parent = editBox and editBox.GetParent and editBox:GetParent()
		if parent and parent.button1 and parent.button1:IsEnabled() then parent.button1:Click() end
	end
end

function CooldownPanels:EnsureEditorGroupDeletePopup()
	if StaticPopupDialogs["EQOL_COOLDOWN_PANEL_GROUP_DELETE"] then return end
	StaticPopupDialogs["EQOL_COOLDOWN_PANEL_GROUP_DELETE"] = {
		text = L["CooldownPanelDeleteGroupPrompt"] or "Delete group %s? Panels will stay ungrouped.",
		button1 = YES,
		button2 = CANCEL,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
		OnAccept = function(_, data)
			if not (data and data.groupId) then return end
			if CooldownPanels:DeleteEditorGroup(data.groupId) and CooldownPanels.IsEditorOpen and CooldownPanels:IsEditorOpen() then CooldownPanels:RefreshEditor() end
		end,
	}
end

function CooldownPanels:ShowEditorGroupCreatePopup(parentGroupId)
	parentGroupId = normalizeId(parentGroupId)
	self:EnsureEditorGroupCreatePopup()
	StaticPopup_Show("EQOL_COOLDOWN_PANEL_GROUP_CREATE", nil, nil, { parentGroupId = parentGroupId })
end

function CooldownPanels:ShowEditorGroupRenamePopup(groupId)
	groupId = normalizeId(groupId)
	if not groupId then return end
	self:EnsureEditorGroupRenamePopup()
	StaticPopup_Show("EQOL_COOLDOWN_PANEL_GROUP_RENAME", nil, nil, { groupId = groupId })
end

function CooldownPanels:EnsureFixedGroupRenamePopup()
	if StaticPopupDialogs["EQOL_COOLDOWN_PANEL_FIXED_GROUP_RENAME"] then return end
	StaticPopupDialogs["EQOL_COOLDOWN_PANEL_FIXED_GROUP_RENAME"] = {
		text = L["CooldownPanelRenameGroupPrompt"] or "Rename group",
		button1 = OKAY,
		button2 = CANCEL,
		hasEditBox = true,
		editBoxWidth = 240,
		enterClicksFirstButton = true,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
		OnShow = function(self, data)
			local editBox = self.editBox or self.GetEditBox and self:GetEditBox()
			local currentName = data and CooldownPanels:GetFixedGroupLabel(data.panelId, data.groupId) or ""
			if editBox then
				editBox:SetText(currentName or "")
				editBox:SetFocus()
				if editBox.HighlightText then editBox:HighlightText() end
			end
		end,
		OnAccept = function(self, data)
			if not (data and data.panelId and data.groupId) then return end
			local editBox = self.editBox or self.GetEditBox and self:GetEditBox()
			local text = editBox and editBox:GetText()
			if CooldownPanels:RenameFixedGroup(data.panelId, data.groupId, text) then
				CooldownPanels:RefreshPanel(data.panelId)
				CooldownPanels:RefreshEditor()
			end
		end,
	}
	StaticPopupDialogs["EQOL_COOLDOWN_PANEL_FIXED_GROUP_RENAME"].EditBoxOnEnterPressed = function(editBox)
		local parent = editBox and editBox.GetParent and editBox:GetParent()
		if parent and parent.button1 and parent.button1:IsEnabled() then parent.button1:Click() end
	end
end

function CooldownPanels:EnsureFixedGroupDeletePopup()
	if StaticPopupDialogs["EQOL_COOLDOWN_PANEL_FIXED_GROUP_DELETE"] then return end
	StaticPopupDialogs["EQOL_COOLDOWN_PANEL_FIXED_GROUP_DELETE"] = {
		text = L["CooldownPanelDeleteGroupPrompt"] or "Delete group %s? Entries will become static fixed slots.",
		button1 = YES,
		button2 = CANCEL,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
		OnAccept = function(_, data)
			if not (data and data.panelId and data.groupId) then return end
			CooldownPanels:HideLayoutFixedGroupStandaloneMenu(data.panelId)
			if CooldownPanels:DeleteFixedGroup(data.panelId, data.groupId) then
				local runtime = getRuntime(data.panelId)
				if runtime then clearRuntimeLayoutShapeCache(runtime) end
				CooldownPanels:RefreshPanelForCurrentEditContext(data.panelId, true)
			end
		end,
	}
end

function CooldownPanels:EnsureFixedGroupIconSizePopup()
	if StaticPopupDialogs["EQOL_COOLDOWN_PANEL_FIXED_GROUP_ICON_SIZE"] then return end
	StaticPopupDialogs["EQOL_COOLDOWN_PANEL_FIXED_GROUP_ICON_SIZE"] = {
		text = L["CooldownPanelFixedGroupIconSizePrompt"] or "Set group icon size (leave empty for panel size)",
		button1 = OKAY,
		button2 = CANCEL,
		hasEditBox = true,
		editBoxWidth = 180,
		enterClicksFirstButton = true,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
		OnShow = function(self, data)
			local editBox = self.editBox or self.GetEditBox and self:GetEditBox()
			local panel = data and data.panelId and CooldownPanels:GetPanel(data.panelId) or nil
			local group = panel and CooldownPanels.GetFixedGroupById(panel, data.groupId) or nil
			local size = group and Helper.NormalizeFixedGroupIconSize(group.iconSize) or nil
			if editBox then
				editBox:SetText(size and tostring(size) or "")
				editBox:SetFocus()
				if editBox.HighlightText then editBox:HighlightText() end
			end
		end,
		OnAccept = function(self, data)
			if not (data and data.panelId and data.groupId) then return end
			local editBox = self.editBox or self.GetEditBox and self:GetEditBox()
			local text = editBox and editBox:GetText() or ""
			if strtrim then text = strtrim(text) end
			local nextSize = text ~= "" and Helper.NormalizeFixedGroupIconSize(tonumber(text)) or nil
			if text ~= "" and nextSize == nil then
				showErrorMessage(L["CooldownPanelFixedGroupIconSizeInvalid"] or "Icon size must be between 12 and 128.")
				return
			end
			if CooldownPanels:SetFixedGroupIconSize(data.panelId, data.groupId, nextSize) then
				CooldownPanels:RefreshPanel(data.panelId)
				CooldownPanels:RefreshEditor()
			end
		end,
	}
	StaticPopupDialogs["EQOL_COOLDOWN_PANEL_FIXED_GROUP_ICON_SIZE"].EditBoxOnEnterPressed = function(editBox)
		local parent = editBox and editBox.GetParent and editBox:GetParent()
		if parent and parent.button1 and parent.button1:IsEnabled() then parent.button1:Click() end
	end
end

function CooldownPanels:ShowFixedGroupRenamePopup(panelId, groupId)
	panelId = normalizeId(panelId)
	groupId = Helper.NormalizeFixedGroupId(groupId)
	if not (panelId and groupId) then return end
	self:EnsureFixedGroupRenamePopup()
	StaticPopup_Show("EQOL_COOLDOWN_PANEL_FIXED_GROUP_RENAME", nil, nil, { panelId = panelId, groupId = groupId })
end

function CooldownPanels:ShowFixedGroupIconSizePopup(panelId, groupId)
	panelId = normalizeId(panelId)
	groupId = Helper.NormalizeFixedGroupId(groupId)
	if not (panelId and groupId) then return end
	self:EnsureFixedGroupIconSizePopup()
	StaticPopup_Show("EQOL_COOLDOWN_PANEL_FIXED_GROUP_ICON_SIZE", nil, nil, { panelId = panelId, groupId = groupId })
end

function CooldownPanels:ShowFixedGroupMenu(owner, panelId, groupId)
	panelId = normalizeId(panelId)
	groupId = Helper.NormalizeFixedGroupId(groupId)
	if not (owner and panelId and groupId and Api.MenuUtil and Api.MenuUtil.CreateContextMenu) then return end
	local panel = self:GetPanel(panelId)
	local group = panel and CooldownPanels.GetFixedGroupById(panel, groupId) or nil
	if not group then return end
	local groupName = CooldownPanels.GetFixedGroupName(group)
	Api.MenuUtil.CreateContextMenu(owner, function(_, rootDescription)
		rootDescription:CreateTitle(CooldownPanels.GetFixedGroupDisplayLabel(group))
		rootDescription:CreateDivider()
		local modeMenu = rootDescription:CreateButton(L["Mode"] or "Mode")
		modeMenu:CreateRadio(L["CooldownPanelDynamic"] or "Dynamic", function() return not CooldownPanels.IsFixedGroupStatic(group) end, function()
			if CooldownPanels:SetFixedGroupMode(panelId, groupId, "DYNAMIC") then
				CooldownPanels:RefreshPanel(panelId)
				CooldownPanels:RefreshEditor()
			end
		end)
		modeMenu:CreateRadio(L["CooldownPanelStatic"] or "Static", function() return CooldownPanels.IsFixedGroupStatic(group) end, function()
			local changed, reason = CooldownPanels:SetFixedGroupMode(panelId, groupId, "STATIC")
			if changed then
				CooldownPanels:RefreshPanel(panelId)
				CooldownPanels:RefreshEditor()
			elseif reason == "GROUP_FULL" then
				showErrorMessage(L["CooldownPanelFixedGroupFull"] or "Fixed group is full.")
			end
		end)
		local sizeMenu = rootDescription:CreateButton(L["Icon size"] or "Icon size")
		sizeMenu:CreateRadio(L["CooldownPanelUsePanelSize"] or "Use panel size", function() return Helper.NormalizeFixedGroupIconSize(group.iconSize) == nil and group.iconSizeSeparate ~= true end, function()
			if CooldownPanels:SetFixedGroupIconSize(panelId, groupId, nil) then
				CooldownPanels:RefreshPanel(panelId)
				CooldownPanels:RefreshEditor()
			end
		end)
		for _, preset in ipairs({ 24, 30, 36, 42, 48, 56 }) do
			local size = preset
			sizeMenu:CreateRadio(tostring(size), function() return Helper.NormalizeFixedGroupIconSize(group.iconSize) == size and group.iconSizeSeparate ~= true end, function()
				if CooldownPanels:SetFixedGroupIconSize(panelId, groupId, size) then
					CooldownPanels:RefreshPanel(panelId)
					CooldownPanels:RefreshEditor()
				end
			end)
		end
		sizeMenu:CreateButton(CUSTOM or "Custom", function() CooldownPanels:ShowFixedGroupIconSizePopup(panelId, groupId) end)
		rootDescription:CreateDivider()
		rootDescription:CreateButton(L["CooldownPanelRename"] or "Rename", function() CooldownPanels:ShowFixedGroupRenamePopup(panelId, groupId) end)
		rootDescription:CreateButton(DELETE or "Delete", function()
			CooldownPanels:EnsureFixedGroupDeletePopup()
			StaticPopup_Show("EQOL_COOLDOWN_PANEL_FIXED_GROUP_DELETE", groupName, nil, { panelId = panelId, groupId = groupId })
		end)
	end)
end

function CooldownPanels:ShowEditorGroupMenu(owner, groupId)
	groupId = normalizeId(groupId)
	if not (owner and groupId and Api.MenuUtil and Api.MenuUtil.CreateContextMenu) then return end
	local root = ensureRoot()
	root = CooldownPanels.EnsureEditorGroupStorage(root)
	local group = root and root.editorGroups and root.editorGroups[groupId] or nil
	local groupName = group and group.name or ("Group " .. tostring(groupId))
	local currentParentId = group and normalizeId(group.parentGroupId) or nil
	local blockedGroupIds = CooldownPanels:GetEditorGroupDescendantIdSet(root, groupId)
	blockedGroupIds[groupId] = true
	Api.MenuUtil.CreateContextMenu(owner, function(_, rootDescription)
		rootDescription:CreateTitle(groupName)
		rootDescription:CreateDivider()
		rootDescription:CreateButton(L["CooldownPanelAddSubgroup"] or "Add Subgroup", function() CooldownPanels:ShowEditorGroupCreatePopup(groupId) end)
		local parentMenu = rootDescription:CreateButton(L["CooldownPanelMoveToGroup"] or "Move to group")
		parentMenu:CreateRadio(L["CooldownPanelTopLevel"] or "Top level", function() return currentParentId == nil end, function()
			if CooldownPanels:SetEditorGroupParent(groupId, nil) then CooldownPanels:RefreshEditor() end
		end)
		CooldownPanels:PopulateEditorGroupRadioMenu(parentMenu, root, currentParentId, function(targetGroupId)
			if CooldownPanels:SetEditorGroupParent(groupId, targetGroupId) then
				local editor = getEditor()
				if editor then CooldownPanels:GetEditorPanelGroupState(editor)[CooldownPanels:GetEditorPanelGroupStateKey(targetGroupId)] = nil end
				CooldownPanels:RefreshEditor()
			end
		end, { skipGroupIds = blockedGroupIds })
		rootDescription:CreateDivider()
		rootDescription:CreateButton(L["CooldownPanelRename"] or "Rename", function() CooldownPanels:ShowEditorGroupRenamePopup(groupId) end)
		rootDescription:CreateButton(L["CooldownPanelExportGroup"] or "Export Group", function()
			local code = CooldownPanels:ExportEditorGroup(groupId)
			if not code then
				showErrorMessage(L["DataExportFailed"] or "Export failed.")
				return
			end
			cdp.EXPORT.ShowDialog(L["CooldownPanelExportGroup"] or "Export Group", code)
		end)
		rootDescription:CreateButton(DELETE or "Delete", function()
			CooldownPanels:EnsureEditorGroupDeletePopup()
			StaticPopup_Show("EQOL_COOLDOWN_PANEL_GROUP_DELETE", groupName, nil, { groupId = groupId })
		end)
	end)
end

function CooldownPanels:ShowPanelGroupAssignMenu(owner, panelId)
	panelId = normalizeId(panelId)
	if not (owner and panelId and Api.MenuUtil and Api.MenuUtil.CreateContextMenu) then return end
	local root = ensureRoot()
	root = CooldownPanels.EnsureEditorGroupStorage(root)
	local panel = root and root.panels and root.panels[panelId] or nil
	if not panel then return end
	self:SortEditorGroupOrder(root)

	Api.MenuUtil.CreateContextMenu(owner, function(_, rootDescription)
		rootDescription:CreateTitle(panel.name or ("Panel " .. tostring(panelId)))
		rootDescription:CreateDivider()
		rootDescription:CreateButton(L["CooldownPanelDuplicate"] or "Duplicate", function()
			local duplicatedPanelId = CooldownPanels:DuplicatePanel(panelId)
			if duplicatedPanelId then CooldownPanels:SelectPanel(duplicatedPanelId) end
		end)
		rootDescription:CreateButton(L["CooldownPanelExportPanel"] or "Export Panel", function()
			local code = CooldownPanels:ExportPanel(panelId)
			if not code then
				showErrorMessage(L["DataExportFailed"] or "Export failed.")
				return
			end
			cdp.EXPORT.ShowDialog(L["CooldownPanelExportPanel"] or "Export Panel", code)
		end)

		local currentGroupId = normalizeId(panel.editorGroupId)
		local groupMenu = rootDescription:CreateButton(L["CooldownPanelAddToGroup"] or "Add to group")
		groupMenu:CreateRadio(L["CooldownPanelUngrouped"] or "Ungrouped", function() return currentGroupId == nil end, function()
			if CooldownPanels:SetPanelEditorGroup(panelId, nil) then CooldownPanels:RefreshEditor() end
		end)

		local hasGroups = CooldownPanels:PopulateEditorGroupRadioMenu(groupMenu, root, currentGroupId, function(targetGroupId)
			if CooldownPanels:SetPanelEditorGroup(panelId, targetGroupId) then CooldownPanels:RefreshEditor() end
		end)

		if not hasGroups then
			groupMenu:CreateButton(L["CooldownPanelNoGroups"] or "No groups", function() end)
			rootDescription:CreateButton(L["CooldownPanelAddGroup"] or "Add Group", function() CooldownPanels:ShowEditorGroupCreatePopup() end)
		end
	end)
end

local function updateRowVisual(row, selected)
	if not row or not row.bg then return end
	if row._eqolPanelListKind == "bucket" then
		row.bg:SetColorTexture(0, 0, 0, 0.3)
		return
	end
	if selected then
		row.bg:SetColorTexture(0.1, 0.6, 0.6, 0.35)
	else
		row.bg:SetColorTexture(0, 0, 0, 0.2)
	end
end

local function movePanelInOrder(root, panelId, targetPanelId)
	if not root or not root.order then return false end
	if not panelId or not targetPanelId or panelId == targetPanelId then return false end
	local fromIndex, toIndex
	for i, id in ipairs(root.order) do
		if id == panelId then fromIndex = i end
		if id == targetPanelId then toIndex = i end
	end
	if not fromIndex or not toIndex then return false end
	table.remove(root.order, fromIndex)
	if fromIndex < toIndex then toIndex = toIndex - 1 end
	table.insert(root.order, toIndex, panelId)
	markRootOrderDirty(root)
	return true
end

local function findFirstPanelForFilters(root, classSpecs, filterByClass, filterBySpec)
	if not root or not root.order then return nil end
	for _, panelId in ipairs(root.order) do
		local panel = root.panels and root.panels[panelId]
		if panel then
			local matchesClass = not filterByClass or panelMatchesPlayerClass(panel, classSpecs)
			local matchesSpec = not filterBySpec or panelAllowsSpec(panel)
			if matchesClass and matchesSpec then return panelId end
		end
	end
	return nil
end

local function refreshPanelList(editor, root, classSpecs)
	local list = editor.panelList
	if not list then return end
	root = CooldownPanels.EnsureEditorGroupStorage(root)
	local content = list.content
	local rowHeight = 28
	local spacing = 4
	local index = 0
	local filterByClass = addon.db and addon.db.cooldownPanelsFilterClass == true
	local filterBySpec = addon.db and addon.db.cooldownPanelsFilterSpec == true
	local hideEmptyGroups = addon.db and addon.db.cooldownPanelsHideEmptyGroups == true
	local groups = root.editorGroups or {}
	local groupedPanelIds = {}
	local ungroupedPanelIds = {}
	local entries = {}
	local showUngroupedBucket = false

	CooldownPanels:SortEditorGroupOrder(root)
	local childrenByParent = CooldownPanels:BuildEditorGroupHierarchy(root)

	for _, groupId in ipairs(root.editorGroupOrder or {}) do
		if groups[groupId] then groupedPanelIds[groupId] = {} end
	end

	for _, panelId in ipairs(root.order or {}) do
		local panel = root.panels and root.panels[panelId]
		if panel then
			local matchesClass = not filterByClass or panelMatchesPlayerClass(panel, classSpecs)
			local matchesSpec = not filterBySpec or panelAllowsSpec(panel)
			if matchesClass and matchesSpec then
				local groupId = normalizeId(panel.editorGroupId)
				if groupId and groups[groupId] then
					local bucket = groupedPanelIds[groupId]
					if not bucket then
						bucket = {}
						groupedPanelIds[groupId] = bucket
					end
					bucket[#bucket + 1] = panelId
				else
					ungroupedPanelIds[#ungroupedPanelIds + 1] = panelId
				end
			end
		end
	end

	showUngroupedBucket = #ungroupedPanelIds > 0 or editor.draggingPanel == true

	local function getHierarchyKey(groupId)
		groupId = normalizeId(groupId)
		if groupId ~= nil then return tostring(groupId) end
		return "__root"
	end

	local visibleGroupCounts = {}

	local function getVisibleGroupCount(groupId)
		if visibleGroupCounts[groupId] ~= nil then return visibleGroupCounts[groupId] end
		local count = #(groupedPanelIds[groupId] or {})
		for _, childGroupId in ipairs(childrenByParent[getHierarchyKey(groupId)] or {}) do
			count = count + getVisibleGroupCount(childGroupId)
		end
		visibleGroupCounts[groupId] = count
		return count
	end

	local function appendBucket(groupId, label, count, depth)
		local collapsed = CooldownPanels:IsEditorPanelGroupCollapsed(editor, groupId)
		entries[#entries + 1] = {
			kind = "bucket",
			groupId = groupId,
			label = label,
			count = count or 0,
			collapsed = collapsed,
			depth = depth or 0,
		}
		return collapsed ~= true
	end

	local function appendGroup(groupId, depth)
		local group = groups[groupId]
		if not group then return end
		local count = getVisibleGroupCount(groupId)
		if hideEmptyGroups and count <= 0 and editor.draggingPanel ~= true then return end
		if not appendBucket(groupId, group.name or ("Group " .. tostring(groupId)), count, depth) then return end
		for _, childGroupId in ipairs(childrenByParent[getHierarchyKey(groupId)] or {}) do
			appendGroup(childGroupId, depth + 1)
		end
		for _, panelId in ipairs(groupedPanelIds[groupId] or {}) do
			entries[#entries + 1] = {
				kind = "panel",
				groupId = groupId,
				panelId = panelId,
				panel = root.panels and root.panels[panelId] or nil,
				depth = (depth or 0) + 1,
			}
		end
	end

	if showUngroupedBucket and appendBucket(nil, L["CooldownPanelUngrouped"] or "Ungrouped", #ungroupedPanelIds, 0) then
		for _, panelId in ipairs(ungroupedPanelIds) do
			entries[#entries + 1] = {
				kind = "panel",
				groupId = nil,
				panelId = panelId,
				panel = root.panels and root.panels[panelId] or nil,
				depth = 1,
			}
		end
	end
	for _, groupId in ipairs(childrenByParent["__root"] or {}) do
		appendGroup(groupId, 0)
	end

	for _, entry in ipairs(entries) do
		index = index + 1
		local row = editor.panelRows[index]
		if not row then
			row = Helper.CreateRowButton(content, rowHeight)
			row.label = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
			row.label:SetTextColor(1, 1, 1, 1)

			row.count = row:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
			row.count:SetPoint("RIGHT", row, "RIGHT", -8, 0)

			row.toggle = row:CreateTexture(nil, "ARTWORK")
			row.toggle:SetSize(12, 12)
			row.toggle:Hide()

			row.toggleFrame = CreateFrame("Button", nil, row)
			row.toggleFrame:SetSize(18, 18)
			row.toggleFrame:SetPoint("LEFT", row, "LEFT", 4, 0)
			row.toggleFrame:Hide()
			row.toggleFrame:SetScript("OnClick", function(self)
				local parent = self:GetParent()
				if not (parent and parent._eqolPanelListKind == "bucket") then return end
				CooldownPanels:ToggleEditorPanelGroupCollapsed(editor, parent.groupId)
				CooldownPanels:RefreshEditor()
			end)
			row.toggleFrame:SetScript("OnEnter", function(self)
				local parent = self:GetParent()
				if parent and parent.highlight then parent.highlight:Show() end
			end)
			row.toggleFrame:SetScript("OnLeave", function(self)
				local parent = self:GetParent()
				if parent and parent.highlight then parent.highlight:Hide() end
			end)

			row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
			row:RegisterForDrag("LeftButton")
			row:SetScript("OnDragStart", function(self)
				if self._eqolPanelListKind ~= "panel" then return end
				editor.dragPanelId = self.panelId
				editor.dragTargetPanelId = nil
				editor.dragTargetHasBucket = nil
				editor.dragTargetBucketId = nil
				editor.draggingPanel = true
				CooldownPanels:RefreshEditor()
				showEditorDragIcon(editor, Helper.PREVIEW_ICON)
				self:SetAlpha(0.6)
			end)
			row:SetScript("OnDragStop", function(self)
				self:SetAlpha(1)
				if not editor.draggingPanel then return end
				editor.draggingPanel = nil
				hideEditorDragIcon(editor)
				local fromId = editor.dragPanelId
				local targetPanelId = editor.dragTargetPanelId
				local hasBucketTarget = editor.dragTargetHasBucket == true
				local targetBucketId = editor.dragTargetBucketId
				editor.dragPanelId = nil
				editor.dragTargetPanelId = nil
				editor.dragTargetHasBucket = nil
				editor.dragTargetBucketId = nil
				if not fromId then
					CooldownPanels:RefreshEditor()
					return
				end
				local sourcePanel = root.panels and root.panels[fromId] or nil
				if sourcePanel then
					if targetPanelId and targetPanelId ~= fromId then
						local targetPanel = root.panels and root.panels[targetPanelId] or nil
						if targetPanel then
							sourcePanel.editorGroupId = normalizeId(targetPanel.editorGroupId)
							movePanelInOrder(root, fromId, targetPanelId)
						end
					elseif hasBucketTarget then
						sourcePanel.editorGroupId = normalizeId(targetBucketId)
						for _, candidateId in ipairs(root.order or {}) do
							if candidateId ~= fromId then
								local candidatePanel = root.panels and root.panels[candidateId] or nil
								if candidatePanel and normalizeId(candidatePanel.editorGroupId) == normalizeId(targetBucketId) then
									movePanelInOrder(root, fromId, candidateId)
									break
								end
							end
						end
					end
				end
				CooldownPanels:RefreshEditor()
			end)
			row:SetScript("OnEnter", function(self)
				if not editor.draggingPanel then return end
				if self._eqolPanelListKind == "panel" then
					editor.dragTargetPanelId = self.panelId
					editor.dragTargetHasBucket = nil
					editor.dragTargetBucketId = nil
				else
					editor.dragTargetPanelId = nil
					editor.dragTargetHasBucket = true
					editor.dragTargetBucketId = self.groupId
				end
				if self.bg then self.bg:SetColorTexture(0.2, 0.7, 0.2, 0.35) end
			end)
			row:SetScript("OnLeave", function(self)
				if not editor.draggingPanel then return end
				if self._eqolPanelListKind == "panel" then
					if editor.dragTargetPanelId == self.panelId then editor.dragTargetPanelId = nil end
					updateRowVisual(self, self.panelId == editor.selectedPanelId)
				else
					editor.dragTargetHasBucket = nil
					editor.dragTargetBucketId = nil
					updateRowVisual(self, false)
				end
			end)
			row:SetScript("OnClick", function(self, button)
				if self._eqolPanelListKind == "bucket" then
					if button == "RightButton" then
						if self.groupId then CooldownPanels:ShowEditorGroupMenu(self, self.groupId) end
						return
					end
					CooldownPanels:ToggleEditorPanelGroupCollapsed(editor, self.groupId)
					CooldownPanels:RefreshEditor()
					return
				end
				if button == "RightButton" then
					if self.panelId then CooldownPanels:ShowPanelGroupAssignMenu(self, self.panelId) end
					return
				end
				if self.panelId then CooldownPanels:SelectPanel(self.panelId) end
			end)
			editor.panelRows[index] = row
		end

		row:ClearAllPoints()
		row:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -((index - 1) * (rowHeight + spacing)))
		row:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, -((index - 1) * (rowHeight + spacing)))
		row:SetAlpha(1)
		row:Show()

		if entry.kind == "bucket" then
			local depth = entry.depth or 0
			row._eqolPanelListKind = "bucket"
			row.panelId = nil
			row.groupId = entry.groupId
			row.toggle:Show()
			row.toggleFrame:Show()
			row.toggleFrame:ClearAllPoints()
			row.toggleFrame:SetPoint("LEFT", row, "LEFT", 4 + (depth * 14), 0)
			row.toggle:ClearAllPoints()
			row.toggle:SetPoint("CENTER", row.toggleFrame, "CENTER", 0, 0)
			row.toggle:SetAtlas(entry.collapsed and "NPE_ArrowRight" or "NPE_ArrowDown")
			row.label:ClearAllPoints()
			row.label:SetPoint("LEFT", row, "LEFT", 24 + (depth * 14), 0)
			row.label:SetText(entry.label or (L["CooldownPanelUngrouped"] or "Ungrouped"))
			row.label:SetTextColor(1, 0.9, 0.6, 1)
			row.count:SetText(tostring(entry.count or 0))
			row.count:SetTextColor(0.9, 0.9, 0.9, 1)
			updateRowVisual(row, false)
		else
			local depth = entry.depth or 1
			local panelId = entry.panelId
			local panel = entry.panel
			row._eqolPanelListKind = "panel"
			row.panelId = panelId
			row.groupId = entry.groupId
			row.toggle:Hide()
			row.toggleFrame:Hide()
			row.label:ClearAllPoints()
			row.label:SetPoint("LEFT", row, "LEFT", 22 + (depth * 14), 0)
			row.label:SetText((panel and panel.name) or ("Panel " .. tostring(panelId)))
			row.label:SetTextColor(1, 1, 1, 1)
			row.count:SetText(tostring(panel and panel.order and #panel.order or 0))
			row.count:SetTextColor(0.7, 0.7, 0.7, 1)
			updateRowVisual(row, panelId == editor.selectedPanelId)
		end
	end

	for i = index + 1, #editor.panelRows do
		editor.panelRows[i]:Hide()
	end

	local totalHeight = index * (rowHeight + spacing)
	content:SetHeight(totalHeight > 1 and totalHeight or 1)
end

local function moveEntryInOrder(panel, entryId, targetEntryId)
	if not panel or not panel.order then return false end
	if not entryId or not targetEntryId or entryId == targetEntryId then return false end
	local fromIndex, toIndex
	for i, id in ipairs(panel.order) do
		if id == entryId then fromIndex = i end
		if id == targetEntryId then toIndex = i end
	end
	if not fromIndex or not toIndex then return false end
	table.remove(panel.order, fromIndex)
	if fromIndex < toIndex then toIndex = toIndex - 1 end
	table.insert(panel.order, toIndex, entryId)
	Helper.InvalidateFixedLayoutCache(panel)
	return true
end

function CooldownPanels.MoveEntryToOrderIndex(panel, entryId, targetIndex)
	if not panel or not panel.order then return false end
	targetIndex = tonumber(targetIndex)
	if not targetIndex then return false end
	local fromIndex
	for i, id in ipairs(panel.order) do
		if id == entryId then
			fromIndex = i
			break
		end
	end
	if not fromIndex then return false end
	local maxIndex = #panel.order + 1
	if targetIndex < 1 then
		targetIndex = 1
	elseif targetIndex > maxIndex then
		targetIndex = maxIndex
	end
	if fromIndex == targetIndex or fromIndex + 1 == targetIndex then return false end
	table.remove(panel.order, fromIndex)
	if fromIndex < targetIndex then targetIndex = targetIndex - 1 end
	if targetIndex > #panel.order + 1 then targetIndex = #panel.order + 1 end
	table.insert(panel.order, targetIndex, entryId)
	Helper.InvalidateFixedLayoutCache(panel)
	return true
end

function CooldownPanels:MoveEntryToFixedSlot(panelId, entryId, targetSlot)
	panelId = normalizeId(panelId)
	entryId = normalizeId(entryId)
	local panel = self:GetPanel(panelId)
	if not panel or not panel.entries then return false end
	local targetColumn, targetRow
	if type(targetSlot) == "table" then
		targetColumn = Helper.NormalizeSlotCoordinate(targetSlot.column or targetSlot.slotColumn or targetSlot.x)
		targetRow = Helper.NormalizeSlotCoordinate(targetSlot.row or targetSlot.slotRow or targetSlot.y)
	else
		local normalizedTargetSlot = Helper.NormalizeSlotIndex(targetSlot)
		if normalizedTargetSlot then
			local gridColumns = Helper.NormalizeFixedGridSize(panel.layout and panel.layout.fixedGridColumns, 0)
			if gridColumns <= 0 then
				local _, _, builtColumns = Helper.BuildFixedSlotEntryIds(panel, nil, false)
				gridColumns = builtColumns or 0
			end
			if gridColumns > 0 then
				targetColumn = ((normalizedTargetSlot - 1) % gridColumns) + 1
				targetRow = math.floor((normalizedTargetSlot - 1) / gridColumns) + 1
			end
		end
	end
	if not (targetColumn and targetRow) then return false end
	panel.layout = panel.layout or Helper.CopyTableShallow(Helper.PANEL_LAYOUT_DEFAULTS)
	local layout = panel.layout
	if Helper.NormalizeLayoutMode(layout.layoutMode, Helper.PANEL_LAYOUT_DEFAULTS.layoutMode) == "RADIAL" then return false end
	if not Helper.IsFixedLayout(layout) then
		layout.layoutMode = "FIXED"
		Helper.InvalidateFixedLayoutCache(panel)
		local gridColumns = Helper.NormalizeFixedGridSize(layout.fixedGridColumns, 0)
		if gridColumns <= 0 then
			gridColumns = Helper.ClampInt(layout.wrapCount, 0, 40, Helper.PANEL_LAYOUT_DEFAULTS.wrapCount or 0)
			if not gridColumns or gridColumns <= 0 then gridColumns = math.min(math.max(type(panel.order) == "table" and #panel.order or 0, 4), 12) end
			layout.fixedGridColumns = gridColumns
		end
		Helper.EnsureFixedSlotAssignments(panel)
		if Helper.NormalizeFixedGridSize(layout.fixedGridRows, 0) <= 0 then
			local _, maxRow = Helper.EnsureFixedSlotAssignments(panel)
			layout.fixedGridRows = math.max(maxRow or 0, 1)
		end
	end
	Helper.EnsureFixedSlotAssignments(panel)
	if not self:IsFixedCellWithinBounds(panel, targetColumn, targetRow) then return false end
	local fromEntry = panel.entries[entryId]
	if not fromEntry then return false end
	local fromPlacement = CooldownPanels.GetEntryFixedPlacement(panel, fromEntry)
	local fromGroupId = Helper.NormalizeFixedGroupId(fromEntry.fixedGroupId)
	local targetGroup = CooldownPanels.GetFixedGroupAtCell(panel, targetColumn, targetRow)
	if targetGroup then
		if CooldownPanels.IsFixedGroupStatic(targetGroup) then
			if fromPlacement and fromPlacement.groupId == targetGroup.id and fromPlacement.column == targetColumn and fromPlacement.row == targetRow then return false end
			local targetOccupantId, targetOccupant = self:GetEntryAtStaticGroupCell(panel, targetGroup.id, targetColumn, targetRow, entryId)
			if targetOccupant then
				local swapPlacement = fromPlacement
				if not swapPlacement or (swapPlacement.groupId == targetGroup.id and swapPlacement.column == targetColumn and swapPlacement.row == targetRow) then
					local freeColumn, freeRow = self:FindFirstFreeStaticGroupCell(panel, targetGroup.id, entryId)
					if not (freeColumn and freeRow) then return false end
					swapPlacement = {
						groupId = targetGroup.id,
						column = freeColumn,
						row = freeRow,
					}
				end
				CooldownPanels.AssignEntryFixedPlacement(targetOccupant, swapPlacement)
				self:SyncEntryFixedGroupState(panel, targetOccupant)
			end
			CooldownPanels.AssignEntryFixedPlacement(fromEntry, {
				groupId = targetGroup.id,
				column = targetColumn,
				row = targetRow,
			})
			self:SyncEntryFixedGroupState(panel, fromEntry)
			Helper.InvalidateFixedLayoutCache(panel)
			Helper.EnsureFixedSlotAssignments(panel)
			return true
		end
		local targetLocalIndex = Helper.GetFixedGroupLocalIndex(targetGroup, targetColumn, targetRow) or (#CooldownPanels.GetFixedGroupEntriesInOrder(panel, targetGroup.id, entryId) + 1)
		local changed = false
		if fromGroupId ~= targetGroup.id then
			fromEntry.fixedGroupId = targetGroup.id
			changed = true
		end
		fromEntry.slotColumn = nil
		fromEntry.slotRow = nil
		fromEntry.slotIndex = nil
		self:SyncEntryFixedGroupState(panel, fromEntry)
		local groupMemberIds = CooldownPanels.GetFixedGroupEntriesInOrder(panel, targetGroup.id, entryId)
		local desiredIndex = math.max(1, math.min(targetLocalIndex, #groupMemberIds + 1))
		if desiredIndex <= #groupMemberIds then
			if moveEntryInOrder(panel, entryId, groupMemberIds[desiredIndex]) then changed = true end
		else
			local lastIndex
			for i, id in ipairs(panel.order or {}) do
				if id ~= entryId then
					local candidate = panel.entries and panel.entries[id]
					if candidate and Helper.NormalizeFixedGroupId(candidate.fixedGroupId) == targetGroup.id then lastIndex = i end
				end
			end
			if CooldownPanels.MoveEntryToOrderIndex(panel, entryId, (lastIndex or #panel.order) + 1) then changed = true end
		end
		if changed then Helper.InvalidateFixedLayoutCache(panel) end
		Helper.EnsureFixedSlotAssignments(panel)
		return changed
	end
	if fromPlacement and fromPlacement.groupId == nil and fromPlacement.column == targetColumn and fromPlacement.row == targetRow then return false end
	local swapId, swapEntry = self:GetEntryAtUngroupedFixedCell(panel, targetColumn, targetRow, entryId)
	if swapEntry then
		local swapPlacement = fromPlacement
		if not swapPlacement or (swapPlacement.groupId == nil and swapPlacement.column == targetColumn and swapPlacement.row == targetRow) then
			local freeColumn, freeRow = self:FindFirstFreeUngroupedFixedCell(panel, entryId)
			if not (freeColumn and freeRow) then return false end
			swapPlacement = {
				groupId = nil,
				column = freeColumn,
				row = freeRow,
			}
		end
		CooldownPanels.AssignEntryFixedPlacement(swapEntry, swapPlacement)
		self:SyncEntryFixedGroupState(panel, swapEntry)
	end
	CooldownPanels.AssignEntryFixedPlacement(fromEntry, {
		groupId = nil,
		column = targetColumn,
		row = targetRow,
	})
	self:SyncEntryFixedGroupState(panel, fromEntry)
	Helper.InvalidateFixedLayoutCache(panel)
	Helper.EnsureFixedSlotAssignments(panel)
	return true
end

function CooldownPanels:GetLayoutEditCursorSlot(panelId)
	panelId = normalizeId(panelId)
	if not panelId then return nil end
	local runtime = getRuntime(panelId)
	local frame = runtime and runtime.frame
	if not frame or not frame.icons then return nil end
	local cursorX, cursorY = self:GetCursorPositionOnUIParent()
	if not (cursorX and cursorY) then return nil end
	local grid = frame.editGrid
	if grid and grid.cells then
		for i = 1, #grid.cells do
			local cell = grid.cells[i]
			if cell and cell.IsShown and cell:IsShown() then
				local left = cell.GetLeft and cell:GetLeft()
				local right = cell.GetRight and cell:GetRight()
				local top = cell.GetTop and cell:GetTop()
				local bottom = cell.GetBottom and cell:GetBottom()
				if left and right and top and bottom and cursorX >= left and cursorX <= right and cursorY <= top and cursorY >= bottom then
					local column = Helper.NormalizeSlotCoordinate(cell._eqolLayoutSlotColumn)
					local row = Helper.NormalizeSlotCoordinate(cell._eqolLayoutSlotRow)
					if column and row then return { column = column, row = row } end
				end
			end
		end
	end
	for i = 1, #frame.icons do
		local icon = frame.icons[i]
		local handle = icon and icon.layoutHandle
		if handle and handle.IsShown and handle:IsShown() then
			local bounds = icon or handle
			local left = bounds and bounds.GetLeft and bounds:GetLeft()
			local right = bounds and bounds.GetRight and bounds:GetRight()
			local top = bounds and bounds.GetTop and bounds:GetTop()
			local bottom = bounds and bounds.GetBottom and bounds:GetBottom()
			if left and right and top and bottom and cursorX >= left and cursorX <= right and cursorY <= top and cursorY >= bottom then
				local column = Helper.NormalizeSlotCoordinate(handle._eqolLayoutSlotColumn)
				local row = Helper.NormalizeSlotCoordinate(handle._eqolLayoutSlotRow)
				if column and row then return { column = column, row = row } end
			end
		end
	end
	return nil
end

local function refreshEntryList(editor, panel)
	local list = editor.entryList
	if not list then return end
	local content = list.content
	local rowHeight = 30
	local spacing = 4
	local index = 0

	if panel and panel.order then
		for _, entryId in ipairs(panel.order or {}) do
			local entry = panel.entries and panel.entries[entryId]
			if entry then
				index = index + 1
				local row = editor.entryRows[index]
				if not row then
					row = Helper.CreateRowButton(content, rowHeight)
					row.icon = row:CreateTexture(nil, "ARTWORK")
					row.icon:SetSize(22, 22)
					row.icon:SetPoint("LEFT", row, "LEFT", 6, 0)

					row.label = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
					row.label:SetPoint("LEFT", row.icon, "RIGHT", 6, 0)
					row.label:SetTextColor(1, 1, 1, 1)

					row.kind = row:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
					row.kind:SetPoint("RIGHT", row, "RIGHT", -6, 0)
					row:RegisterForDrag("LeftButton")
					row:SetScript("OnDragStart", function(self)
						if not editor.selectedPanelId then return end
						editor.dragEntryId = self.entryId
						editor.dragTargetId = nil
						editor.dragPreviewSlot = nil
						editor.draggingEntry = true
						showEditorDragIcon(editor, self.icon and self.icon:GetTexture())
						self:SetAlpha(0.6)
					end)
					row:SetScript("OnDragStop", function(self)
						self:SetAlpha(1)
						if not editor.draggingEntry then return end
						editor.draggingEntry = nil
						hideEditorDragIcon(editor)
						local fromId = editor.dragEntryId
						local targetId = editor.dragTargetId
						local panelId = editor.selectedPanelId
						local targetSlot = editor.dragPreviewSlot or CooldownPanels:GetLayoutEditCursorSlot(panelId)
						editor.dragEntryId = nil
						editor.dragTargetId = nil
						editor.dragPreviewSlot = nil
						if not fromId then
							CooldownPanels:RefreshEditor()
							return
						end
						local activePanel = panelId and CooldownPanels:GetPanel(panelId) or nil
						if activePanel and targetSlot then
							if CooldownPanels:MoveEntryToFixedSlot(panelId, fromId, targetSlot) then CooldownPanels:RefreshPanel(panelId) end
						elseif activePanel and targetId and fromId ~= targetId and moveEntryInOrder(activePanel, fromId, targetId) then
							CooldownPanels:RefreshPanel(panelId)
						end
						CooldownPanels:RefreshEditor()
					end)
					row:SetScript("OnEnter", function(self)
						if editor.draggingEntry then
							editor.dragTargetId = self.entryId
							if self.bg then self.bg:SetColorTexture(0.2, 0.7, 0.2, 0.35) end
						end
					end)
					row:SetScript("OnLeave", function(self)
						if editor.draggingEntry then updateRowVisual(self, self.entryId == editor.selectedEntryId) end
					end)
					editor.entryRows[index] = row
				end
				row:ClearAllPoints()
				row:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -((index - 1) * (rowHeight + spacing)))
				row:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, -((index - 1) * (rowHeight + spacing)))

				row.entryId = entryId
				row.icon:SetTexture(getEntryIcon(entry))
				local label = getEntryName(entry)
				row.label:SetText(label)
				row.kind:SetText(getEntryTypeLabel(entry.type))
				row:Show()

				updateRowVisual(row, entryId == editor.selectedEntryId)
				row:SetScript("OnClick", function(self)
					CooldownPanels:SelectEntry(entryId)
					local panelId = editor.selectedPanelId
					if panelId and CooldownPanels:IsPanelLayoutEditActive(panelId) then CooldownPanels:OpenLayoutEntryStandaloneMenu(panelId, entryId, self) end
				end)
			end
		end
	end

	for i = index + 1, #editor.entryRows do
		editor.entryRows[i]:Hide()
	end

	local totalHeight = index * (rowHeight + spacing)
	content:SetHeight(totalHeight > 1 and totalHeight or 1)
end

local function entryIsAvailableForPreview(entry)
	if not entry or type(entry) ~= "table" then return false end
	if entry.type == "CDM_AURA" then
		local cdmAuras = CooldownPanels.CDMAuras
		if cdmAuras and cdmAuras.EntryIsAvailableForPreview then return cdmAuras:EntryIsAvailableForPreview(entry) end
		return false
	end
	if entry.type == "SPELL" then
		if not entry.spellID then return false end
		local _, resolvedSpellId = CooldownPanels:ResolveTrackedSpellID(entry.spellID)
		if CooldownPanels:IsEntryRacialVariantExcluded(entry, resolvedSpellId or entry.spellID) then return false end
		return true
	elseif entry.type == "ITEM" then
		local itemID = CooldownPanels.ResolveEntryItemID(entry, entry.itemID)
		if not itemID then return false end
		if itemHasUseSpell and not itemHasUseSpell(itemID) then return false end
		if entry.showWhenEmpty == true then return true end
		return hasItem(itemID)
	elseif entry.type == "SLOT" then
		if entry.slotID and Api.GetInventoryItemID then
			local itemId = Api.GetInventoryItemID("player", entry.slotID)
			if not itemId then return entry.showWhenNoCooldown == true end
			if itemHasUseSpell and itemHasUseSpell(itemId) then return true end
			return entry.showWhenNoCooldown == true
		end
		return false
	elseif entry.type == "MACRO" then
		local macro = CooldownPanels.ResolveMacroEntry(entry)
		if not macro then return false end
		if macro.kind == "SPELL" and macro.spellID then return true end
		if macro.kind == "ITEM" and macro.itemID then
			if itemHasUseSpell and not itemHasUseSpell(macro.itemID) then return false end
			return true
		end
		return false
	end
	return true
end

getPreviewEntryIds = function(panel)
	if not panel or type(panel.order) ~= "table" then return nil end
	if #panel.order == 0 then return nil end
	if Helper.IsFixedLayout(panel.layout) then
		local slots = Helper.BuildFixedSlotEntryIds(panel)
		return slots
	end
	local list = {}
	for _, entryId in ipairs(panel.order) do
		local entry = panel.entries and panel.entries[entryId]
		if entry and entryIsAvailableForPreview(entry) then list[#list + 1] = entryId end
	end
	return list
end

local function getPreviewLayout(panel, previewFrame, count)
	local baseLayout = (panel and panel.layout) or Helper.PANEL_LAYOUT_DEFAULTS
	local previewLayout = Helper.CopyTableShallow(baseLayout)
	local layoutMode = Helper.NormalizeLayoutMode(baseLayout.layoutMode, Helper.PANEL_LAYOUT_DEFAULTS.layoutMode)
	local baseIconSize = Helper.ClampInt(baseLayout.iconSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
	local scale = baseIconSize > 0 and (Helper.PREVIEW_ICON_SIZE / baseIconSize) or 1
	previewLayout.iconSize = Helper.PREVIEW_ICON_SIZE
	if type(baseLayout.rowSizes) == "table" then
		previewLayout.rowSizes = {}
		for index, size in pairs(baseLayout.rowSizes) do
			local num = tonumber(size)
			if num then previewLayout.rowSizes[index] = Helper.ClampInt(num * scale, 12, 128, Helper.PREVIEW_ICON_SIZE) end
		end
	end
	local baseRadius = Helper.ClampInt(baseLayout.radialRadius, 0, Helper.RADIAL_RADIUS_RANGE or 600, Helper.PANEL_LAYOUT_DEFAULTS.radialRadius)
	previewLayout.radialRadius = Helper.ClampInt(baseRadius * scale, 0, Helper.RADIAL_RADIUS_RANGE or 600, baseRadius)
	local stackSize = tonumber(previewLayout.stackFontSize or Helper.PANEL_LAYOUT_DEFAULTS.stackFontSize) or Helper.PANEL_LAYOUT_DEFAULTS.stackFontSize
	previewLayout.stackFontSize = math.max(stackSize, Helper.PREVIEW_COUNT_FONT_MIN)
	local chargesSize = tonumber(previewLayout.chargesFontSize or Helper.PANEL_LAYOUT_DEFAULTS.chargesFontSize) or Helper.PANEL_LAYOUT_DEFAULTS.chargesFontSize
	previewLayout.chargesFontSize = math.max(chargesSize, Helper.PREVIEW_COUNT_FONT_MIN)
	local keybindSize = tonumber(previewLayout.keybindFontSize or Helper.PANEL_LAYOUT_DEFAULTS.keybindFontSize) or Helper.PANEL_LAYOUT_DEFAULTS.keybindFontSize
	previewLayout.keybindFontSize = math.max(keybindSize, Helper.PREVIEW_COUNT_FONT_MIN)

	if not previewFrame or not count or count < 1 then return previewLayout end

	local iconSize = Helper.PREVIEW_ICON_SIZE
	local spacing = Helper.ClampInt(baseLayout.spacing, 0, Helper.SPACING_RANGE or 200, Helper.PANEL_LAYOUT_DEFAULTS.spacing)

	local width = previewFrame:GetWidth() or 0
	local height = previewFrame:GetHeight() or 0
	local step = iconSize + spacing
	if width <= 0 or height <= 0 or step <= 0 then return previewLayout end

	if layoutMode == "RADIAL" then
		local padding = 8
		local maxRadius = math.floor((math.min(width, height) - padding) / 2)
		if maxRadius < 0 then maxRadius = 0 end
		previewLayout.radialRadius = Helper.ClampInt(previewLayout.radialRadius, 0, maxRadius, previewLayout.radialRadius)
		return previewLayout
	end

	local direction = Helper.NormalizeDirection(baseLayout.direction, Helper.PANEL_LAYOUT_DEFAULTS.direction)
	local wrapCount = Helper.ClampInt(baseLayout.wrapCount, 0, 40, Helper.PANEL_LAYOUT_DEFAULTS.wrapCount or 0)

	local primaryHorizontal = direction == "LEFT" or direction == "RIGHT"
	local available = primaryHorizontal and width or height
	local maxPrimary = math.floor((available + spacing) / step)
	if maxPrimary < 1 then maxPrimary = 1 end

	if wrapCount == 0 then
		if count > maxPrimary then previewLayout.wrapCount = maxPrimary end
	else
		previewLayout.wrapCount = math.min(wrapCount, maxPrimary)
	end

	return previewLayout
end

local function refreshPreview(editor, panel)
	if not editor.previewFrame then return end
	local preview = editor.previewFrame
	local canvas = preview.canvas or preview
	if editor.previewHintLabel then editor.previewHintLabel:Hide() end
	ensureIconCount(canvas, 0)
	canvas:SetSize(1, 1)
	canvas:ClearAllPoints()
	canvas:SetPoint("CENTER", preview, "CENTER")
	if preview.dropHint then
		local root = CooldownPanels:GetRoot()
		local hasPanels = root and ((root.order and #root.order > 0) or (root.panels and next(root.panels)))
		if panel then
			preview.dropHint:SetText(L["CooldownPanelDropHint"] or "Drop spells, items, or macros here")
		else
			preview.dropHint:SetText(hasPanels and (L["CooldownPanelSelectPanel"] or "Select a panel to edit.") or (L["CooldownPanelCreatePanel"] or "Create a Panel"))
		end
		preview.dropHint:Show()
	end
	if editor.entryHint then editor.entryHint:SetText(L["CooldownPanelEntriesHint"] or "Drag entries to reorder") end
end

local function layoutInspectorToggles(inspector, entry)
	if not inspector then return end
	local function hideToggle(cb)
		if not cb then return end
		cb:Hide()
		cb:Disable()
		cb:SetChecked(false)
	end
	local function hideControl(control)
		if not control then return end
		control:Hide()
		if control.Disable then control:Disable() end
	end
	if not entry then
		hideToggle(inspector.cbCooldownText)
		hideToggle(inspector.cbTrackPassiveSpell)
		hideControl(inspector.racialExclusionsButton)
		hideToggle(inspector.cbAlwaysShow)
		hideToggle(inspector.cbCharges)
		hideToggle(inspector.cbStacks)
		hideToggle(inspector.cbItemCount)
		hideToggle(inspector.cbItemUses)
		hideToggle(inspector.cbUseHighestRank)
		hideToggle(inspector.cbShowWhenEmpty)
		hideToggle(inspector.cbShowWhenNoCooldown)
		hideToggle(inspector.cbCDMAuraOverlay)
		hideToggle(inspector.cbCDMAuraOverlayReverse)
		hideToggle(inspector.cbActivationOverlayOnly)
		hideToggle(inspector.cbActivationOverlayGlow)
		hideToggle(inspector.cbAutoDuration)
		hideToggle(inspector.cbCustomDuration)
		hideControl(inspector.customDurationBox)
		hideControl(inspector.customDurationLabel)
		hideControl(inspector.staticTextLabel)
		hideControl(inspector.staticTextBox)
		hideToggle(inspector.cbStaticTextDuringCD)
		hideToggle(inspector.cbGlow)
		hideToggle(inspector.cbPandemicGlow)
		hideToggle(inspector.cbSound)
		hideControl(inspector.soundButton)
		if inspector.content and inspector.scroll then
			local height = inspector.scroll:GetHeight() or 1
			inspector.content:SetHeight(height)
		end
		return
	end

	local prev = inspector.entryId
	local effectiveType = entry and entry.type or nil
	local entryAnchor = inspector.entryIcon or inspector.entryId or inspector.entryType
	if effectiveType == "MACRO" then
		local macro = CooldownPanels.ResolveMacroEntry(entry)
		effectiveType = (macro and macro.kind) or "MACRO"
	end
	local cdmAuras = CooldownPanels.CDMAuras
	if effectiveType == "CDM_AURA" then
		prev = (cdmAuras and cdmAuras.LayoutInspector and cdmAuras:LayoutInspector(inspector, entry, entryAnchor)) or entryAnchor
	elseif effectiveType == "STANCE" or effectiveType == "SLOT" then
		prev = entryAnchor
		if cdmAuras and cdmAuras.LayoutInspector then cdmAuras:LayoutInspector(inspector, nil, prev) end
	else
		if cdmAuras and cdmAuras.LayoutInspector then cdmAuras:LayoutInspector(inspector, nil, prev) end
	end
	local function place(control, show, offsetX, offsetY)
		if not control then return end
		control:ClearAllPoints()
		control:SetPoint("TOPLEFT", prev, "BOTTOMLEFT", offsetX or 0, offsetY or -6)
		if show then
			control:Show()
			if control.Enable then control:Enable() end
			prev = control
		else
			control:Hide()
			if control.Disable then control:Disable() end
		end
	end

	place(inspector.cbCooldownText, effectiveType ~= "STANCE", -2)
	place(inspector.cbTrackPassiveSpell, effectiveType == "SPELL")
	if inspector.racialExclusionsButton then
		inspector.racialExclusionsButton:SetText(CooldownPanels:GetRacialExclusionsButtonText(entry))
	end
	place(inspector.racialExclusionsButton, effectiveType == "SPELL" and CooldownPanels:IsRacialSpellEntry(entry), 18, -4)
	if effectiveType == "SPELL" then
		place(inspector.cbAlwaysShow, false)
		place(inspector.cbCharges, true)
		place(inspector.cbStacks, true)
		place(inspector.cbItemCount, false)
		place(inspector.cbItemUses, false)
		place(inspector.cbUseHighestRank, false)
		place(inspector.cbShowWhenEmpty, false)
		place(inspector.cbShowWhenNoCooldown, false)
	elseif effectiveType == "ITEM" then
		place(inspector.cbAlwaysShow, true)
		place(inspector.cbCharges, false)
		place(inspector.cbStacks, false)
		place(inspector.cbItemCount, true)
		place(inspector.cbItemUses, true)
		CooldownPanels:EnsureFoodRankGroupsLoaded()
		local itemID = tonumber(entry and entry.itemID)
		local rankMap = CooldownPanels.itemHighestRankByID
		place(inspector.cbUseHighestRank, entry and entry.type == "ITEM" and itemID and rankMap and rankMap[itemID] ~= nil)
		place(inspector.cbShowWhenEmpty, true)
		place(inspector.cbShowWhenNoCooldown, false)
	elseif effectiveType == "SLOT" then
		place(inspector.cbAlwaysShow, false)
		place(inspector.cbCharges, false)
		place(inspector.cbStacks, false)
		place(inspector.cbItemCount, false)
		place(inspector.cbItemUses, false)
		place(inspector.cbUseHighestRank, false)
		place(inspector.cbShowWhenEmpty, false)
		place(inspector.cbShowWhenNoCooldown, true)
	elseif effectiveType == "CDM_AURA" then
		place(inspector.cbAlwaysShow, true)
		place(inspector.cbCharges, false)
		place(inspector.cbStacks, true)
		place(inspector.cbItemCount, false)
		place(inspector.cbItemUses, false)
		place(inspector.cbUseHighestRank, false)
		place(inspector.cbShowWhenEmpty, false)
		place(inspector.cbShowWhenNoCooldown, false)
	elseif effectiveType == "STANCE" then
		place(inspector.cbAlwaysShow, true)
		place(inspector.cbCharges, false)
		place(inspector.cbStacks, false)
		place(inspector.cbItemCount, false)
		place(inspector.cbItemUses, false)
		place(inspector.cbUseHighestRank, false)
		place(inspector.cbShowWhenEmpty, false)
		place(inspector.cbShowWhenNoCooldown, false)
	else
		place(inspector.cbAlwaysShow, false)
		place(inspector.cbCharges, false)
		place(inspector.cbStacks, false)
		place(inspector.cbItemCount, false)
		place(inspector.cbItemUses, false)
		place(inspector.cbUseHighestRank, false)
		place(inspector.cbShowWhenEmpty, false)
		place(inspector.cbShowWhenNoCooldown, false)
	end
	local showCustomDuration = CooldownPanels:SupportsEntryCustomCooldownDuration(entry, effectiveType)
	local showCDMAuraOverlay = CooldownPanels:SupportsEntryCDMAuraOverlay(entry, effectiveType)
	local showAutoDuration = CooldownPanels:SupportsEntryAutoCooldownDuration(entry, effectiveType)
	local showActivationOverlayVisuals = showCustomDuration or showCDMAuraOverlay
	place(inspector.cbCDMAuraOverlay, showCDMAuraOverlay)
	place(inspector.cbCDMAuraOverlayReverse, showActivationOverlayVisuals)
	place(inspector.cbActivationOverlayOnly, showActivationOverlayVisuals)
	place(inspector.cbActivationOverlayGlow, showActivationOverlayVisuals)
	if inspector.cbCDMAuraOverlayReverse then
		if entry.cdmAuraOverlayEnabled == true or entry.autoCooldownDurationEnabled == true or entry.customCooldownDurationEnabled == true then
			inspector.cbCDMAuraOverlayReverse:Enable()
		else
			inspector.cbCDMAuraOverlayReverse:Disable()
		end
	end
	if inspector.cbActivationOverlayOnly then
		if entry.cdmAuraOverlayEnabled == true or entry.autoCooldownDurationEnabled == true or entry.customCooldownDurationEnabled == true then
			inspector.cbActivationOverlayOnly:Enable()
		else
			inspector.cbActivationOverlayOnly:Disable()
		end
	end
	if inspector.cbActivationOverlayGlow then
		if entry.cdmAuraOverlayEnabled == true or entry.autoCooldownDurationEnabled == true or entry.customCooldownDurationEnabled == true then
			inspector.cbActivationOverlayGlow:Enable()
		else
			inspector.cbActivationOverlayGlow:Disable()
		end
	end
	place(inspector.cbAutoDuration, showAutoDuration)
	place(inspector.cbCustomDuration, showCustomDuration)
	if showCustomDuration and inspector.customDurationBox and inspector.customDurationLabel then
		inspector.customDurationBox:ClearAllPoints()
		inspector.customDurationBox:SetPoint("TOPLEFT", inspector.cbCustomDuration, "BOTTOMLEFT", 18, -4)
		inspector.customDurationBox:Show()
		if entry.customCooldownDurationEnabled == true then
			inspector.customDurationBox:Enable()
		else
			inspector.customDurationBox:Disable()
		end
		inspector.customDurationLabel:ClearAllPoints()
		inspector.customDurationLabel:SetPoint("LEFT", inspector.customDurationBox, "RIGHT", 6, 0)
		inspector.customDurationLabel:Show()
		prev = inspector.customDurationBox
	else
		hideControl(inspector.customDurationBox)
		hideControl(inspector.customDurationLabel)
	end
	local allowStaticText = true
	place(inspector.staticTextLabel, allowStaticText, 2, -8)
	place(inspector.staticTextBox, allowStaticText, -2, -4)
	place(inspector.cbStaticTextDuringCD, allowStaticText, -2, -6)
	local showGlowToggle = entry.type ~= "MACRO"
	local showPandemicGlowToggle = effectiveType == "CDM_AURA"
	local showReadyEffects = entry.type ~= "MACRO" and entry.type ~= "STANCE" and entry.type ~= "CDM_AURA"
	local _, soundEnabledField = CooldownPanels:GetEntrySoundConfig(entry)
	local showSoundEffects = soundEnabledField ~= nil
	place(inspector.cbGlow, showGlowToggle)
	place(inspector.cbPandemicGlow, showPandemicGlowToggle)
	place(inspector.cbSound, showSoundEffects, 0, -6)
	if showSoundEffects and inspector.soundButton then
		inspector.soundButton:ClearAllPoints()
		inspector.soundButton:SetPoint("TOPLEFT", inspector.cbSound, "BOTTOMLEFT", 18, -6)
		if entry[soundEnabledField] == true then
			inspector.soundButton:Show()
			inspector.soundButton:Enable()
			prev = inspector.soundButton
		else
			inspector.soundButton:Hide()
			inspector.soundButton:Disable()
		end
	elseif inspector.soundButton then
		inspector.soundButton:Hide()
		inspector.soundButton:Disable()
	end

	if inspector.soundButton and inspector.soundButton:IsShown() then prev = inspector.soundButton end
	if inspector.removeEntry then
		inspector.removeEntry:ClearAllPoints()
		if inspector.content and inspector.content.GetTop and prev and prev.GetBottom then
			local top = inspector.content:GetTop()
			local bottom = prev:GetBottom()
			if top and bottom then
				inspector.removeEntry:SetPoint("TOP", inspector.content, "TOP", 0, (bottom - top) - 12)
			else
				inspector.removeEntry:SetPoint("TOP", prev, "BOTTOM", 0, -12)
			end
		else
			inspector.removeEntry:SetPoint("TOP", prev, "BOTTOM", 0, -12)
		end
		inspector.removeEntry:Show()
		if inspector.removeEntry.Enable then inspector.removeEntry:Enable() end
	end

	if inspector.content and inspector.panelHeader and inspector.removeEntry then
		local top = inspector.panelHeader:GetTop()
		local bottom = inspector.removeEntry:GetBottom()
		if top and bottom then
			local height = (top - bottom) + 20
			local minHeight = inspector.scroll and inspector.scroll:GetHeight() or 1
			if height < minHeight then height = minHeight end
			if height < 1 then height = 1 end
			inspector.content:SetHeight(height)
		end
	end
end

local function refreshInspector(editor, panel, entry)
	local inspector = editor.inspector
	if not inspector then return end

	if panel then
		inspector.panelName:SetText(panel.name or "")
		inspector.panelEnabled:SetChecked(panel.enabled ~= false)
		inspector.panelName:Enable()
		inspector.panelEnabled:Enable()
		if inspector.panelSpecButton then
			Helper.SetButtonTextEllipsized(inspector.panelSpecButton, getSpecFilterLabel(panel))
			inspector.panelSpecButton:Enable()
		end
		if inspector.panelSpecLabel then inspector.panelSpecLabel:Show() end
	else
		inspector.panelName:SetText("")
		inspector.panelName:Disable()
		inspector.panelEnabled:SetChecked(false)
		inspector.panelEnabled:Disable()
		if inspector.panelSpecButton then
			Helper.SetButtonTextEllipsized(inspector.panelSpecButton, L["CooldownPanelSpecAny"] or "All specs")
			inspector.panelSpecButton:Disable()
		end
		if inspector.panelSpecLabel then inspector.panelSpecLabel:Hide() end
	end

	if entry then
		if inspector.entryHeader then inspector.entryHeader:Show() end
		if inspector.entryEmptyHint then inspector.entryEmptyHint:Hide() end
		if inspector.entryIcon then inspector.entryIcon:Show() end
		if inspector.entryName then inspector.entryName:Show() end
		if inspector.entryType then inspector.entryType:Show() end

		inspector.entryIcon:SetTexture(getEntryIcon(entry))
		inspector.entryName:SetText(getEntryName(entry))
		inspector.entryType:SetText(getEntryTypeLabel(entry.type))
		local entryIdText = tostring(entry.spellID or entry.itemID or entry.slotID or entry.stanceID or entry.macroID or "")
		local cdmAuras = CooldownPanels.CDMAuras
		if entry.type == "CDM_AURA" and cdmAuras and cdmAuras.GetEntryIdText then entryIdText = tostring(cdmAuras:GetEntryIdText(entry) or "") end
		inspector.entryId:SetText(entryIdText)
		local effectiveType = entry and entry.type or nil
		if effectiveType == "MACRO" then
			local macro = CooldownPanels.ResolveMacroEntry(entry)
			effectiveType = (macro and macro.kind) or "MACRO"
		end
		local showEntryIdBox = effectiveType ~= "STANCE" and effectiveType ~= "SLOT" and effectiveType ~= "CDM_AURA"
		if inspector.entryId then
			if showEntryIdBox then
				inspector.entryId:Show()
				inspector.entryId:Enable()
			else
				inspector.entryId:Hide()
				inspector.entryId:Disable()
			end
			if inspector.entryId.SetNumeric then inspector.entryId:SetNumeric(showEntryIdBox) end
		end
		if inspector.cbAlwaysShow and inspector.cbAlwaysShow.Text then
			if effectiveType == "STANCE" then
				inspector.cbAlwaysShow.Text:SetText(L["CooldownPanelShowWhenMissing"] or "Show when missing")
			else
				inspector.cbAlwaysShow.Text:SetText(L["Always show"] or "Always show")
			end
		end
		if inspector.cbGlow and inspector.cbGlow.Text then
			if effectiveType == "STANCE" then
				inspector.cbGlow.Text:SetText(_G.GLOW or "Glow")
			elseif effectiveType == "CDM_AURA" then
				inspector.cbGlow.Text:SetText(L["CooldownPanelGlowActive"] or "Glow when active")
			else
				inspector.cbGlow.Text:SetText(L["CooldownPanelGlowReady"] or "Glow when ready")
			end
		end
		if inspector.cbPandemicGlow and inspector.cbPandemicGlow.Text then inspector.cbPandemicGlow.Text:SetText(L["CooldownPanelGlowPandemic"] or "Pandemic glow") end
		if inspector.cbSound and inspector.cbSound.Text then
			local _, _, _, soundLabel = CooldownPanels:GetEntrySoundConfig(entry)
			inspector.cbSound.Text:SetText(soundLabel or (L["CooldownPanelSoundReady"] or "Sound when ready"))
		end

		inspector.cbCooldownText:SetChecked(entry.showCooldownText ~= false)
		if inspector.cbTrackPassiveSpell then inspector.cbTrackPassiveSpell:SetChecked(entry.trackPassiveSpell == true) end
		if inspector.racialExclusionsButton then inspector.racialExclusionsButton:SetText(CooldownPanels:GetRacialExclusionsButtonText(entry)) end
		if effectiveType == "STANCE" then
			inspector.cbAlwaysShow:SetChecked(entry.showWhenMissing == true)
		else
			local alwaysShowChecked = effectiveType == "ITEM" and entry.alwaysShow ~= false
			if effectiveType == "CDM_AURA" then
				alwaysShowChecked = CooldownPanels:IsCDMAuraAlwaysShowModeVisibleWhenInactive(CooldownPanels:ResolveEntryCDMAuraAlwaysShowMode(panel and panel.layout or nil, entry))
			end
			inspector.cbAlwaysShow:SetChecked(alwaysShowChecked)
		end
		inspector.cbCharges:SetChecked(entry.showCharges and true or false)
		inspector.cbStacks:SetChecked(shouldShowEntryStacks(entry, effectiveType))
		inspector.cbItemCount:SetChecked(effectiveType == "ITEM" and entry.showItemCount ~= false)
		inspector.cbItemUses:SetChecked(effectiveType == "ITEM" and entry.showItemUses == true)
		if inspector.cbUseHighestRank then inspector.cbUseHighestRank:SetChecked(effectiveType == "ITEM" and entry.type == "ITEM" and entry.useHighestRank == true) end
		inspector.cbShowWhenEmpty:SetChecked(effectiveType == "ITEM" and entry.showWhenEmpty == true)
		inspector.cbShowWhenNoCooldown:SetChecked(effectiveType == "SLOT" and entry.showWhenNoCooldown == true)
		if inspector.cbCDMAuraOverlay then inspector.cbCDMAuraOverlay:SetChecked(entry.cdmAuraOverlayEnabled == true and CooldownPanels:SupportsEntryCDMAuraOverlay(entry, effectiveType)) end
		if inspector.cbCDMAuraOverlayReverse then inspector.cbCDMAuraOverlayReverse:SetChecked(entry.activationOverlayReverse ~= false) end
		if inspector.cbActivationOverlayOnly then inspector.cbActivationOverlayOnly:SetChecked(entry.activationOverlayOnly == true) end
		if inspector.cbActivationOverlayGlow then inspector.cbActivationOverlayGlow:SetChecked(entry.activationOverlayGlow == true) end
		if inspector.cbAutoDuration then inspector.cbAutoDuration:SetChecked(entry.autoCooldownDurationEnabled == true and CooldownPanels:SupportsEntryAutoCooldownDuration(entry, effectiveType)) end
		if inspector.cbCustomDuration then inspector.cbCustomDuration:SetChecked(entry.customCooldownDurationEnabled == true) end
		if inspector.customDurationBox then inspector.customDurationBox:SetText(tostring(math.floor((tonumber(entry.customCooldownDuration) or 0) + 0.5))) end
		inspector.cbGlow:SetChecked(entry.type ~= "MACRO" and entry.glowReady and true or false)
		if inspector.cbPandemicGlow then inspector.cbPandemicGlow:SetChecked(effectiveType == "CDM_AURA" and entry.pandemicGlow == true) end
		do
			local _, soundEnabledField, soundField = CooldownPanels:GetEntrySoundConfig(entry)
			inspector.cbSound:SetChecked(soundEnabledField and entry[soundEnabledField] == true or false)
			if inspector.soundButton then inspector.soundButton:SetText(getSoundButtonText(soundField and entry[soundField] or nil)) end
		end
		if inspector.staticTextBox then inspector.staticTextBox:SetText(entry.staticText or "") end
		if inspector.cbStaticTextDuringCD then inspector.cbStaticTextDuringCD:SetChecked(entry.staticTextShowOnCooldown == true) end

		if cdmAuras and cdmAuras.RefreshInspector then cdmAuras:RefreshInspector(editor, panel, entry) end
		inspector.removeEntry:Enable()
		layoutInspectorToggles(inspector, entry)
	else
		if inspector.entryHeader then inspector.entryHeader:Hide() end
		if inspector.entryEmptyHint then
			inspector.entryEmptyHint:SetText(L["CooldownPanelSelectEntryHint"] or "Click a spell/item/macro/slot/stance to modify")
			inspector.entryEmptyHint:Show()
		end
		if inspector.entryIcon then inspector.entryIcon:Hide() end
		if inspector.entryName then inspector.entryName:Hide() end
		if inspector.entryType then inspector.entryType:Hide() end
		if inspector.entryId then
			inspector.entryId:SetText("")
			if inspector.entryId.SetNumeric then inspector.entryId:SetNumeric(true) end
			inspector.entryId:Hide()
		end
		if inspector.cbAlwaysShow and inspector.cbAlwaysShow.Text then inspector.cbAlwaysShow.Text:SetText(L["Always show"] or "Always show") end
		if inspector.cbGlow and inspector.cbGlow.Text then inspector.cbGlow.Text:SetText(L["CooldownPanelGlowReady"] or "Glow when ready") end
		if inspector.cbPandemicGlow and inspector.cbPandemicGlow.Text then inspector.cbPandemicGlow.Text:SetText(L["CooldownPanelGlowPandemic"] or "Pandemic glow") end
		if inspector.cbSound and inspector.cbSound.Text then inspector.cbSound.Text:SetText(L["CooldownPanelSoundReady"] or "Sound when ready") end

		if inspector.staticTextBox then inspector.staticTextBox:SetText("") end
		if inspector.cbCDMAuraOverlay then inspector.cbCDMAuraOverlay:SetChecked(false) end
		if inspector.cbCDMAuraOverlayReverse then inspector.cbCDMAuraOverlayReverse:SetChecked(false) end
		if inspector.cbActivationOverlayOnly then inspector.cbActivationOverlayOnly:SetChecked(false) end
		if inspector.cbActivationOverlayGlow then inspector.cbActivationOverlayGlow:SetChecked(false) end
		if inspector.cbAutoDuration then inspector.cbAutoDuration:SetChecked(false) end
		if inspector.cbCustomDuration then inspector.cbCustomDuration:SetChecked(false) end
		if inspector.customDurationBox then inspector.customDurationBox:SetText("") end
		if inspector.cbTrackPassiveSpell then inspector.cbTrackPassiveSpell:SetChecked(false) end
		if inspector.racialExclusionsButton then inspector.racialExclusionsButton:SetText(L["CooldownPanelRacialExclusions"] or "Excluded racials") end
		if inspector.cbStaticTextDuringCD then inspector.cbStaticTextDuringCD:SetChecked(false) end

		inspector.entryId:Disable()
		inspector.removeEntry:Disable()
		if inspector.removeEntry then inspector.removeEntry:Hide() end
		if inspector.soundButton then inspector.soundButton:SetText(getSoundButtonText(nil)) end
		local cdmAuras = CooldownPanels.CDMAuras
		if cdmAuras and cdmAuras.RefreshInspector then cdmAuras:RefreshInspector(editor, panel, nil) end
		layoutInspectorToggles(inspector, nil)
	end
end

function CooldownPanels:RefreshEditor()
	local editor = getEditor()
	if not editor or not editor.frame or not editor.frame:IsShown() then return end
	local root = ensureRoot()
	if not root then return end
	local previousLayoutPanelId = normalizeId(editor._eqolLayoutPanelId)

	Helper.SyncOrder(root.order, root.panels)
	root._orderDirty = nil

	local panelId = editor.selectedPanelId or root.selectedPanel or (root.order and root.order[1])
	if panelId and (not root.panels or not root.panels[panelId]) then panelId = root.order and root.order[1] or nil end

	local filterByClass = addon.db and addon.db.cooldownPanelsFilterClass == true
	local filterBySpec = addon.db and addon.db.cooldownPanelsFilterSpec == true
	local hideEmptyGroups = addon.db and addon.db.cooldownPanelsHideEmptyGroups == true
	local classSpecs = filterByClass and getPlayerClassSpecMap() or nil
	local keepSelectedPanelForDialog = false
	do
		local panelMenuState = self:GetLayoutPanelStandaloneMenuState(false)
		keepSelectedPanelForDialog = panelMenuState and normalizeId(panelMenuState.panelId) == normalizeId(panelId) and editor.layoutEditActive == true or false
	end
	if (filterByClass or filterBySpec) and panelId and not keepSelectedPanelForDialog then
		local selectedPanel = root.panels and root.panels[panelId]
		if selectedPanel then
			local matchesClass = not filterByClass or panelMatchesPlayerClass(selectedPanel, classSpecs)
			local matchesSpec = not filterBySpec or panelAllowsSpec(selectedPanel)
			if not (matchesClass and matchesSpec) then panelId = findFirstPanelForFilters(root, classSpecs, filterByClass, filterBySpec) end
		end
	end
	editor.selectedPanelId = panelId
	root.selectedPanel = panelId

	local panel = panelId and self:GetPanel(panelId) or nil

	if editor.filterButton and editor.filterButton.icon then
		if filterByClass or filterBySpec or hideEmptyGroups then
			editor.filterButton.icon:SetVertexColor(1, 0.82, 0.2, 1)
		else
			editor.filterButton.icon:SetVertexColor(1, 1, 1, 0.9)
		end
	end
	refreshPanelList(editor, root, classSpecs)
	refreshEntryList(editor, panel)
	refreshPreview(editor, panel)

	local panelActive = panel ~= nil
	if not panelActive then editor.layoutEditActive = nil end
	local layoutEditAvailable = panelActive and CooldownPanels:IsPanelLayoutEditAvailable(panelId)
	if not layoutEditAvailable then editor.layoutEditActive = nil end
	if editor.deletePanel then
		if panelActive then
			editor.deletePanel:Enable()
		else
			editor.deletePanel:Disable()
		end
	end
	if editor.addSpellButton then
		if panelActive then
			editor.addSpellButton:Enable()
		else
			editor.addSpellButton:Disable()
		end
	end
	if editor.addItemButton then
		if panelActive then
			editor.addItemButton:Enable()
		else
			editor.addItemButton:Disable()
		end
	end
	if editor.addIdBox then
		if panelActive then
			editor.addIdBox:Enable()
		else
			editor.addIdBox:Disable()
		end
	end
	if editor.slotButton then
		if panelActive then
			editor.slotButton:Enable()
		else
			editor.slotButton:Disable()
		end
	end
	if editor.importCDMButton then
		if panelActive then
			editor.importCDMButton:Enable()
		else
			editor.importCDMButton:Disable()
		end
	end
	if editor.layoutEditButton then
		if layoutEditAvailable then
			editor.layoutEditButton:Enable()
		else
			editor.layoutEditButton:Disable()
		end
		editor.layoutEditButton:SetText(editor.layoutEditActive == true and (L["CooldownPanelLayoutEditDone"] or "Done") or (L["CooldownPanelLayoutEdit"] or "Layout edit"))
	end

	local entryId = editor.selectedEntryId
	if panel and entryId and not (panel.entries and panel.entries[entryId]) then entryId = nil end
	editor.selectedEntryId = entryId
	local entry = panel and entryId and panel.entries and panel.entries[entryId] or nil
	refreshInspector(editor, panel, entry)

	local currentLayoutPanelId = editor.layoutEditActive == true and normalizeId(panelId) or nil
	editor._eqolLayoutPanelId = currentLayoutPanelId
	if previousLayoutPanelId ~= currentLayoutPanelId then
		if previousLayoutPanelId and self:GetPanel(previousLayoutPanelId) then self:RefreshPanel(previousLayoutPanelId) end
		if currentLayoutPanelId and self:GetPanel(currentLayoutPanelId) then self:RefreshPanel(currentLayoutPanelId) end
	elseif currentLayoutPanelId and self:GetPanel(currentLayoutPanelId) then
		self:UpdatePanelMouseState(currentLayoutPanelId)
	end
	self:RefreshLayoutEntryStandaloneMenu()
	self:RefreshLayoutPanelStandaloneMenu()
	self:RefreshLayoutFixedGroupStandaloneMenu()
end

function CooldownPanels:OpenEditor()
	local editor = ensureEditor()
	if not editor then return end
	editor.frame:Show()
	return editor.frame
end

function CooldownPanels:CloseEditor()
	local editor = getEditor()
	if not editor then return end
	local panelId = normalizeId(editor._eqolLayoutPanelId or editor.selectedPanelId)
	self:HideLayoutEntryStandaloneMenu(panelId)
	self:HideLayoutPanelStandaloneMenu(panelId)
	self:HideLayoutFixedGroupStandaloneMenu(panelId)
	editor.frame:Hide()
	editor.layoutEditActive = nil
	editor._eqolLayoutPanelId = nil
	if panelId and self:GetPanel(panelId) then self:RefreshPanel(panelId) end
	self:UpdateCursorAnchorState()
end

function CooldownPanels:ToggleEditor()
	local editor = getEditor()
	if not editor then
		self:OpenEditor()
		return
	end
	if editor.frame:IsShown() then
		self:CloseEditor()
	else
		self:OpenEditor()
	end
end

function CooldownPanels:IsEditorOpen()
	local editor = getEditor()
	return editor and editor.frame and editor.frame:IsShown()
end

function CooldownPanels:EnsurePanelFrame(panelId)
	local panel = self:GetPanel(panelId)
	if not panel then return nil end
	local runtime = getRuntime(panelId)
	if runtime.frame then return runtime.frame end
	local frame = createPanelFrame(panelId, panel)
	runtime.frame = frame
	self:ApplyPanelPosition(panelId)
	local showPreviewLayout = self:IsPanelLayoutEditActive(panelId) == true
	if showPreviewLayout then
		self:ApplyLayout(panelId)
		self:UpdatePreviewIcons(panelId)
	else
		-- Normal startup/runtime will rebuild the real icon layout on demand in UpdateRuntimeIcons.
		self:ApplyLayout(panelId, 1)
	end
	return frame
end

function CooldownPanels:ApplyLayout(panelId, countOverride)
	local panel = self:GetPanel(panelId)
	if not panel then return end
	local runtime = getRuntime(panelId)
	local frame = runtime.frame
	if not frame then return end
	CooldownPanels.ClearRuntimeResolvedLayoutCache(runtime)
	panel.layout = panel.layout or Helper.CopyTableShallow(Helper.PANEL_LAYOUT_DEFAULTS)
	local layout = panel.layout
	local appliedLayout = layout
	local fixedLayoutCount
	if Helper.IsFixedLayout(layout) then
		local _, slotCount, gridColumns = Helper.BuildFixedSlotEntryIds(panel, nil, false)
		fixedLayoutCount = slotCount
		appliedLayout = Helper.CopyTableShallow(layout)
		appliedLayout.wrapCount = gridColumns or 0
		appliedLayout.direction = "RIGHT"
		appliedLayout.wrapDirection = "DOWN"
	end

	local count = countOverride or fixedLayoutCount or getPreviewCount(panel)
	applyIconLayout(frame, count, appliedLayout)

	frame:SetFrameStrata(Helper.NormalizeStrata(layout.strata, Helper.PANEL_LAYOUT_DEFAULTS.strata))
	syncLayoutSelectionStrata(frame)
	if frame.label then frame.label:SetText(panel.name or L["cooldownPanelDefaultName"]) end
	CooldownPanels.ClearAppliedAnchorCache(runtime)
	self:ApplyPanelPosition(panelId)
end

function CooldownPanels.LayoutSlotAnchorHandleOnEnter(self)
	local handle = self and self._eqolForwardHandle or nil
	local script = handle and handle.GetScript and handle:GetScript("OnEnter") or nil
	if script then script(handle) end
end

function CooldownPanels.LayoutSlotAnchorHandleOnLeave(self)
	local handle = self and self._eqolForwardHandle or nil
	local script = handle and handle.GetScript and handle:GetScript("OnLeave") or nil
	if script then script(handle) end
end

function CooldownPanels.LayoutSlotAnchorHandleOnDragStart(self)
	local handle = self and self._eqolForwardHandle or nil
	local script = handle and handle.GetScript and handle:GetScript("OnDragStart") or nil
	if script then script(handle) end
end

function CooldownPanels.LayoutSlotAnchorHandleOnDragStop(self)
	local handle = self and self._eqolForwardHandle or nil
	local script = handle and handle.GetScript and handle:GetScript("OnDragStop") or nil
	if script then script(handle) end
end

function CooldownPanels.LayoutSlotAnchorHandleOnReceiveDrag(self)
	local handle = self and self._eqolForwardHandle or nil
	local script = handle and handle.GetScript and handle:GetScript("OnReceiveDrag") or nil
	if script then script(handle) end
end

function CooldownPanels.LayoutSlotAnchorHandleOnMouseUp(self, btn)
	local handle = self and self._eqolForwardHandle or nil
	local script = handle and handle.GetScript and handle:GetScript("OnMouseUp") or nil
	if script then script(handle, btn) end
end

function CooldownPanels:ConfigureLayoutEditPanelIcon(panelId, icon, entryId, slotColumn, slotRow)
	if not icon then return end
	local handle = icon.layoutHandle
	local slotAnchorHandle = icon.slotAnchorHandle
	entryId = normalizeId(entryId)
	slotColumn = Helper.NormalizeSlotCoordinate(slotColumn)
	slotRow = Helper.NormalizeSlotCoordinate(slotRow)
	icon._eqolLayoutEntryId = entryId
	icon._eqolLayoutSlotColumn = nil
	icon._eqolLayoutSlotRow = nil
	local active = self:IsPanelLayoutEditActive(panelId)
	CooldownPanels.SetIconTooltipMouseState(icon, icon._eqolTooltipEnabled == true and not active)
	icon:SetScript("OnDragStart", nil)
	icon:SetScript("OnDragStop", nil)
	icon:SetScript("OnReceiveDrag", nil)
	icon:SetScript("OnMouseUp", nil)
	if not handle then return end
	if not active then
		if handle._eqolLayoutConfigured ~= true then return end
		handle:Hide()
		handle:EnableMouse(false)
		if slotAnchorHandle then
			slotAnchorHandle:Hide()
			slotAnchorHandle:EnableMouse(false)
			slotAnchorHandle:SetScript("OnEnter", nil)
			slotAnchorHandle:SetScript("OnLeave", nil)
			slotAnchorHandle:SetScript("OnDragStart", nil)
			slotAnchorHandle:SetScript("OnDragStop", nil)
			slotAnchorHandle:SetScript("OnReceiveDrag", nil)
			slotAnchorHandle:SetScript("OnMouseUp", nil)
			slotAnchorHandle._eqolForwardHandle = nil
			slotAnchorHandle._eqolLayoutEntryId = nil
			slotAnchorHandle._eqolLayoutSlotColumn = nil
			slotAnchorHandle._eqolLayoutSlotRow = nil
		end
		handle:SetScript("OnEnter", nil)
		handle:SetScript("OnLeave", nil)
		handle:SetScript("OnDragStart", nil)
		handle:SetScript("OnDragStop", nil)
		handle:SetScript("OnReceiveDrag", nil)
		handle:SetScript("OnMouseUp", nil)
		handle._eqolLayoutEntryId = nil
		handle._eqolLayoutSlotColumn = nil
		handle._eqolLayoutSlotRow = nil
		handle._eqolLayoutConfigured = nil
		return
	end
	if handle._eqolLayoutConfigured == true then
		handle:Show()
		handle:EnableMouse(true)
		handle:RegisterForDrag("LeftButton")
		handle:ClearAllPoints()
		handle:SetAllPoints(icon)
		if slotAnchorHandle then
			slotAnchorHandle:Show()
			slotAnchorHandle:EnableMouse(true)
			slotAnchorHandle:RegisterForClicks("LeftButtonUp", "RightButtonUp")
			slotAnchorHandle:RegisterForDrag("LeftButton")
			slotAnchorHandle._eqolForwardHandle = handle
			slotAnchorHandle._eqolLayoutEntryId = entryId
			slotAnchorHandle._eqolLayoutSlotColumn = slotColumn
			slotAnchorHandle._eqolLayoutSlotRow = slotRow
		end
		handle._eqolLayoutEntryId = entryId
		handle._eqolLayoutSlotColumn = slotColumn
		handle._eqolLayoutSlotRow = slotRow
		return
	end
	handle:Show()
	handle:EnableMouse(true)
	handle:RegisterForClicks("LeftButtonUp", "RightButtonUp")
	handle:RegisterForDrag("LeftButton")
	handle:ClearAllPoints()
	handle:SetAllPoints(icon)
	if slotAnchorHandle then
		slotAnchorHandle:Show()
		slotAnchorHandle:EnableMouse(true)
		slotAnchorHandle:RegisterForClicks("LeftButtonUp", "RightButtonUp")
		slotAnchorHandle:RegisterForDrag("LeftButton")
		slotAnchorHandle._eqolForwardHandle = handle
		slotAnchorHandle._eqolLayoutEntryId = entryId
		slotAnchorHandle._eqolLayoutSlotColumn = slotColumn
		slotAnchorHandle._eqolLayoutSlotRow = slotRow
	end
	handle._eqolLayoutEntryId = entryId
	handle._eqolLayoutSlotColumn = slotColumn
	handle._eqolLayoutSlotRow = slotRow
	handle._eqolLayoutConfigured = true
	handle:SetScript("OnEnter", function(self)
		local editor = getEditor()
		if editor and editor.draggingEntry then
			editor.dragPreviewSlot = {
				column = self._eqolLayoutSlotColumn,
				row = self._eqolLayoutSlotRow,
			}
			if icon.rangeOverlay then
				icon._eqolLayoutDragRangePreview = true
				icon.rangeOverlay:SetColorTexture(0.2, 0.7, 0.2, 0.28)
				icon.rangeOverlay:Show()
			end
			return
		end
		local runtimePanel = getRuntime(panelId)
		if runtimePanel and runtimePanel._eqolFixedGroupDrag then
			CooldownPanels:UpdateFixedGroupDragTarget(panelId, self._eqolLayoutSlotColumn, self._eqolLayoutSlotRow)
			return
		end
		CooldownPanels.ShowIconTooltip(icon)
	end)
	handle:SetScript("OnLeave", function(self)
		local editor = getEditor()
		local dragSlot = editor and editor.dragPreviewSlot or nil
		if type(dragSlot) == "table" and dragSlot.column == self._eqolLayoutSlotColumn and dragSlot.row == self._eqolLayoutSlotRow then editor.dragPreviewSlot = nil end
		if icon.rangeOverlay and icon._eqolLayoutDragRangePreview then
			icon._eqolLayoutDragRangePreview = nil
			icon.rangeOverlay:Hide()
		end
		icon:SetAlpha(1)
		CooldownPanels.HideIconTooltip(icon)
	end)
	handle:SetScript("OnDragStart", function(self)
		local currentEntryId = normalizeId(self._eqolLayoutEntryId)
		local currentColumn = Helper.NormalizeSlotCoordinate(self._eqolLayoutSlotColumn)
		local currentRow = Helper.NormalizeSlotCoordinate(self._eqolLayoutSlotRow)
		if IsShiftKeyDown and IsShiftKeyDown() then
			local currentPanel = CooldownPanels:GetPanel(panelId)
			local group = currentPanel and CooldownPanels.GetFixedGroupAtCell(currentPanel, currentColumn, currentRow) or nil
			if group then
				if CooldownPanels:StartFixedGroupMove(panelId, group.id, currentColumn, currentRow) then icon:SetAlpha(0.8) end
			else
				CooldownPanels:StartFixedGroupDraw(panelId, currentColumn, currentRow)
			end
			return
		end
		if not currentEntryId then return end
		local editor = getEditor()
		if not editor then return end
		if CooldownPanels:PreparePanelForFixedLayoutEdit(panelId) then
			CooldownPanels:RefreshPanel(panelId)
			CooldownPanels:RefreshEditor()
		end
		editor.dragEntryId = currentEntryId
		editor.dragTargetId = nil
		editor.dragPreviewSlot = nil
		editor.draggingEntry = true
		showEditorDragIcon(editor, icon.texture and icon.texture:GetTexture())
		icon:SetAlpha(0.6)
	end)
	handle:SetScript("OnDragStop", function()
		icon:SetAlpha(1)
		local runtimePanel = getRuntime(panelId)
		if runtimePanel and runtimePanel._eqolFixedGroupDrag then
			local targetSlot = CooldownPanels:GetLayoutEditCursorSlot(panelId)
			if targetSlot then CooldownPanels:UpdateFixedGroupDragTarget(panelId, targetSlot.column, targetSlot.row) end
			CooldownPanels:FinishFixedGroupDrag(panelId)
			return
		end
		local editor = getEditor()
		if not (editor and editor.draggingEntry) then return end
		editor.draggingEntry = nil
		hideEditorDragIcon(editor)
		local fromId = editor.dragEntryId
		local targetSlot = editor.dragPreviewSlot or CooldownPanels:GetLayoutEditCursorSlot(panelId)
		editor.dragEntryId = nil
		editor.dragTargetId = nil
		editor.dragPreviewSlot = nil
		if panelId and fromId and targetSlot and CooldownPanels:MoveEntryToFixedSlot(panelId, fromId, targetSlot) then CooldownPanels:RefreshPanel(panelId) end
		CooldownPanels:RefreshEditor()
	end)
	handle:SetScript("OnReceiveDrag", function(self)
		local currentColumn = Helper.NormalizeSlotCoordinate(self._eqolLayoutSlotColumn)
		local currentRow = Helper.NormalizeSlotCoordinate(self._eqolLayoutSlotRow)
		local editor = getEditor()
		if editor and editor.draggingEntry then return end
		if CooldownPanels:HandleCursorDrop(panelId, { column = currentColumn, row = currentRow }) then
			CooldownPanels:RefreshPanel(panelId)
			CooldownPanels:RefreshEditor()
		end
	end)
	handle:SetScript("OnMouseUp", function(self, btn)
		local currentEntryId = normalizeId(self._eqolLayoutEntryId)
		local currentColumn = Helper.NormalizeSlotCoordinate(self._eqolLayoutSlotColumn)
		local currentRow = Helper.NormalizeSlotCoordinate(self._eqolLayoutSlotRow)
		if btn == "RightButton" then
			local currentPanel = CooldownPanels:GetPanel(panelId)
			local group = currentPanel and CooldownPanels.GetFixedGroupAtCell(currentPanel, currentColumn, currentRow) or nil
			if group then
				if IsControlKeyDown and IsControlKeyDown() then
					CooldownPanels:ShowFixedGroupMenu(handle or icon, panelId, group.id)
				else
					CooldownPanels:OpenLayoutFixedGroupStandaloneMenu(panelId, group.id, handle or icon)
				end
				return
			end
			return
		end
		if btn ~= "LeftButton" then return end
		local editor = getEditor()
		if editor and editor.draggingEntry then return end
		local runtimePanel = getRuntime(panelId)
		if runtimePanel and runtimePanel._eqolFixedGroupDrag then return end
		if CooldownPanels:HandleCursorDrop(panelId, { column = currentColumn, row = currentRow }) then
			CooldownPanels:RefreshPanel(panelId)
			CooldownPanels:RefreshEditor()
			return
		end
		CooldownPanels:SelectPanel(panelId)
		local cursorCandidates = CooldownPanels:GetLayoutEntryCandidatesAtCursor(panelId)
		if cursorCandidates and #cursorCandidates > 1 and CooldownPanels:ShowLayoutEntryChooserMenu(handle or icon, panelId, cursorCandidates) then return end
		local selectedCandidate = cursorCandidates and cursorCandidates[1] or nil
		local targetEntryId = selectedCandidate and selectedCandidate.entryId or currentEntryId
		local targetAnchor = selectedCandidate and (selectedCandidate.anchorFrame or selectedCandidate.icon) or (handle or icon)
		if targetEntryId then
			CooldownPanels:SelectEntry(targetEntryId)
			CooldownPanels:OpenLayoutEntryStandaloneMenu(panelId, targetEntryId, targetAnchor)
		else
			CooldownPanels:HideLayoutEntryStandaloneMenu(panelId)
			CooldownPanels:RefreshEditor()
		end
	end)
	if slotAnchorHandle then
		slotAnchorHandle:SetScript("OnEnter", CooldownPanels.LayoutSlotAnchorHandleOnEnter)
		slotAnchorHandle:SetScript("OnLeave", CooldownPanels.LayoutSlotAnchorHandleOnLeave)
		slotAnchorHandle:SetScript("OnDragStart", CooldownPanels.LayoutSlotAnchorHandleOnDragStart)
		slotAnchorHandle:SetScript("OnDragStop", CooldownPanels.LayoutSlotAnchorHandleOnDragStop)
		slotAnchorHandle:SetScript("OnReceiveDrag", CooldownPanels.LayoutSlotAnchorHandleOnReceiveDrag)
		slotAnchorHandle:SetScript("OnMouseUp", CooldownPanels.LayoutSlotAnchorHandleOnMouseUp)
	end
end

function CooldownPanels:UpdatePreviewIcons(panelId, countOverride)
	local panel = self:GetPanel(panelId)
	if not panel then return end
	local runtime = getRuntime(panelId)
	local frame = runtime.frame
	if not frame then return end
	panel.layout = panel.layout or Helper.CopyTableShallow(Helper.PANEL_LAYOUT_DEFAULTS)
	local layout = panel.layout
	local fixedLayout = Helper.IsFixedLayout(layout)
	local layoutEditActive = self:IsPanelLayoutEditActive(panelId)
	local showTooltips = layout.showTooltips == true
	local showKeybinds = layout.keybindsEnabled == true
	local showIconTexture = layout.showIconTexture ~= false
	local staticFontPath, staticFontSize, staticFontStyle = Helper.GetCountFontDefaults(frame)
	local defaultCountFontPath, defaultCountFontSize, defaultCountFontStyle = staticFontPath, staticFontSize, staticFontStyle
	local defaultChargesFontPath, defaultChargesFontSize, defaultChargesFontStyle = Helper.GetChargesFontDefaults(frame)
	local previewEntryIds
	local count = countOverride
	local previewGridColumns
	if fixedLayout then
		previewEntryIds, count, previewGridColumns = Helper.BuildFixedSlotEntryIds(panel, nil, false)
	end
	if not count then
		previewEntryIds = previewEntryIds or (getPreviewEntryIds and getPreviewEntryIds(panel) or nil)
		count = getPreviewCount(panel)
	end
	local editGridColumns
	if layoutEditActive and not fixedLayout then
		editGridColumns = Helper.ClampInt(layout.wrapCount, 0, 40, Helper.PANEL_LAYOUT_DEFAULTS.wrapCount or 0)
		if editGridColumns <= 0 then editGridColumns = math.min(math.max(type(panel.order) == "table" and #panel.order or 0, 4), 12) end
	end
	ensureIconCount(frame, count)
	local effectiveLayoutCache = {}

	for i = 1, count do
		local entryId
		if fixedLayout then
			entryId = previewEntryIds and previewEntryIds[i] or nil
		else
			entryId = (previewEntryIds and previewEntryIds[i]) or (panel.order and panel.order[i])
		end
		local entry = entryId and panel.entries and panel.entries[entryId] or nil
		local entryLayout = entry and CooldownPanels:GetEntryEffectiveLayout(panelId, entry, effectiveLayoutCache, panel) or layout
		local macro = entry and entry.type == "MACRO" and CooldownPanels.ResolveMacroEntry(entry) or nil
		local resolvedType = entry and ((macro and macro.kind) or entry.type) or nil
		local previewBaseItemId = resolvedType == "ITEM" and ((macro and macro.itemID) or entry.itemID) or nil
		local previewItemId = resolvedType == "ITEM" and CooldownPanels.ResolveEntryItemID(entry, previewBaseItemId) or nil
		local icon = frame.icons[i]
		local showCooldown = entry and entry.showCooldown ~= false
		local staticCooldown = entry and entry.staticTextShowOnCooldown == true or false
		local showCooldownText = entry and entry.showCooldownText ~= false
		local showCharges = entry and resolvedType == "SPELL" and entry.showCharges == true
		local showStacks = shouldShowEntryStacks(entry, resolvedType)
		local showItemCount = entry and resolvedType == "ITEM" and entry.showItemCount ~= false
		local showItemUses = entry and resolvedType == "ITEM" and entry.showItemUses == true
		local showEntryIconTexture = entry and CooldownPanels:ResolveEntryShowIconTexture(entryLayout, entry) or showIconTexture
		local showGhostIcon = entry and CooldownPanels:ShouldShowEditorGhostIcon(entryLayout, entry, showEntryIconTexture, true) or false
		local stateTextureType, stateTextureValue, stateTextureWidth, stateTextureHeight, stateTextureScale, stateTextureAngle, stateTextureDouble, stateTextureMirror, stateTextureMirrorSecond, stateTextureMirrorVertical, stateTextureMirrorVerticalSecond, stateTextureSpacingX, stateTextureSpacingY
		if entry then
			stateTextureType, stateTextureValue, stateTextureWidth, stateTextureHeight, stateTextureScale, stateTextureAngle, stateTextureDouble, stateTextureMirror, stateTextureMirrorSecond, stateTextureMirrorVertical, stateTextureMirrorVerticalSecond, stateTextureSpacingX, stateTextureSpacingY =
				CooldownPanels:ResolveEntryStateTexture(entry)
		end
		local slotColumn = previewGridColumns and (((i - 1) % previewGridColumns) + 1) or (editGridColumns and (((i - 1) % editGridColumns) + 1) or i)
		local slotRow = previewGridColumns and (math.floor((i - 1) / previewGridColumns) + 1) or (editGridColumns and (math.floor((i - 1) / editGridColumns) + 1) or 1)
		icon:Show()
		icon._eqolPreviewCellColumn = slotColumn
		icon._eqolPreviewCellRow = slotRow
		CooldownPanels:ApplyEntryIconVisualLayout(icon, entryLayout, entry, fixedLayout and panel or nil, fixedLayout and previewGridColumns or nil, slotColumn, slotRow)
		CooldownPanels:HideEditorGhostIcon(icon)
		icon.texture:SetTexture(entry and getEntryIcon(entry) or Helper.PREVIEW_ICON)
		icon.texture:SetVertexColor(1, 1, 1)
		icon.texture:SetShown(showEntryIconTexture or not entry)
		local entryAuraOverlayEnabled = CooldownPanels:IsEntryCDMAuraOverlayEnabled(entryLayout, entry, resolvedType) and CooldownPanels:SupportsEntryCDMAuraOverlay(entry, resolvedType)
		local cooldownUsesAuraDisplay = resolvedType == "CDM_AURA" or CooldownPanels:GetEntryCustomCooldownDuration(entry, resolvedType) ~= nil or entryAuraOverlayEnabled
		local cooldownReverse = resolvedType == "CDM_AURA" or CooldownPanels:GetEntryCustomCooldownDuration(entry, resolvedType) ~= nil or (entryAuraOverlayEnabled and (not entry or entry.activationOverlayReverse ~= false))
		if icon.cooldown.SetReverse then icon.cooldown:SetReverse(cooldownReverse) end
		if icon.cooldown.SetUseAuraDisplayTime then icon.cooldown:SetUseAuraDisplayTime(cooldownUsesAuraDisplay) end
		icon.cooldown:SetHideCountdownNumbers(not showCooldownText)
		CooldownPanels:ApplyEntryCooldownTextStyle(icon, entryLayout, entry)
		CooldownPanels:ApplyEntryStackTextStyle(icon, entryLayout, entry, defaultCountFontPath, defaultCountFontSize, defaultCountFontStyle)
		CooldownPanels:ApplyEntryChargesTextStyle(icon, entryLayout, entry, defaultChargesFontPath, defaultChargesFontSize, defaultChargesFontStyle)
		icon.cooldown:Clear()
		if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
		icon.count:Hide()
		icon.charges:SetAlpha(1)
		icon.charges:Hide()
		if icon.rangeOverlay then icon.rangeOverlay:Hide() end
		if icon.keybind then icon.keybind:Hide() end
		if icon.stateTexture then
			applyStateTexture(icon, {
				stateTextureShown = entry ~= nil and stateTextureType ~= nil,
				stateTextureType = stateTextureType,
				stateTextureValue = stateTextureValue,
				stateTextureWidth = stateTextureWidth,
				stateTextureHeight = stateTextureHeight,
				stateTextureScale = stateTextureScale,
				stateTextureAngle = stateTextureAngle,
				stateTextureDouble = stateTextureDouble,
				stateTextureMirror = stateTextureMirror,
				stateTextureMirrorSecond = stateTextureMirrorSecond,
				stateTextureMirrorVertical = stateTextureMirrorVertical,
				stateTextureMirrorVerticalSecond = stateTextureMirrorVerticalSecond,
				stateTextureSpacingX = stateTextureSpacingX,
				stateTextureSpacingY = stateTextureSpacingY,
			})
		end
		CooldownPanels.HidePreviewGlowBorder(icon)
		if icon.previewBling then icon.previewBling:Hide() end
		CooldownPanels.StopAllIconGlows(icon)
		setAssistedHighlight(icon, false)
		if not entry then
			if icon.staticText then icon.staticText:Hide() end
			icon.texture:SetDesaturated(false)
			icon.texture:SetAlpha(0)
			icon.texture:SetShown(false)
			CooldownPanels:HideEditorGhostIcon(icon)
			CooldownPanels.ApplyIconTooltip(icon, nil, false)
		else
			applyStaticText(icon, entryLayout, entry, staticFontPath, staticFontSize, staticFontStyle, staticCooldown)
			icon.texture:SetShown(showEntryIconTexture)
			icon.texture:SetDesaturated(false)
			icon.texture:SetAlpha(1)
			if showGhostIcon then CooldownPanels:ApplyEditorGhostIcon(icon) end
			if showItemUses and previewItemId then
				local usesValue = Api.GetItemCount(previewItemId, false, true)
				if isSafeGreaterThan(usesValue, 0) then
					icon.charges:SetText(usesValue)
					icon.charges:SetAlpha(1)
					icon.charges:Show()
				end
			end
			if showItemCount and previewItemId then
				local countValue = Api.GetItemCount(previewItemId, false, false)
				if isSafeGreaterThan(countValue, 0) then
					icon.count:SetText(countValue)
					icon.count:Show()
				end
			end
			if entryLayout.keybindsEnabled == true and entry and icon.keybind then
				local keyText = Keybinds.GetEntryKeybindText(entry, entryLayout)
				if keyText then
					icon.keybind:SetText(keyText)
					icon.keybind:Show()
				else
					icon.keybind:Hide()
				end
			end
			CooldownPanels.ApplyIconTooltip(icon, entry, showTooltips)
		end
		self:ConfigureLayoutEditPanelIcon(panelId, icon, entryId, slotColumn, slotRow)
	end
	for i = count + 1, #(frame.icons or {}) do
		local icon = frame.icons[i]
		if icon then
			icon:Hide()
			CooldownPanels:HideEditorGhostIcon(icon)
			if icon.stateTexture then icon.stateTexture:Hide() end
			if icon.stateTextureSecond then icon.stateTextureSecond:Hide() end
			setAssistedHighlight(icon, false)
		end
	end
	self:UpdateLayoutEditGrid(panelId, fixedLayout and count or 0)
end

local function isSpellFlagged(map, baseId, effectiveId)
	if not map then return false end
	if (effectiveId and map[effectiveId]) or (baseId and map[baseId]) then return true end
	local canonicalID, _, variantGroup = CooldownPanels:GetCanonicalSpellVariantID(effectiveId or baseId)
	if canonicalID and map[canonicalID] then return true end
	if type(variantGroup) == "table" then
		for i = 1, #variantGroup do
			local candidateID = tonumber(variantGroup[i])
			if candidateID and map[candidateID] then return true end
			local candidateEffectiveID = candidateID and getEffectiveSpellId(candidateID) or nil
			if candidateEffectiveID and map[candidateEffectiveID] then return true end
		end
	end
	return false
end

CooldownPanels.SetIconDesaturatedRuntime = function(texture, enabled, skipDesaturation)
	if skipDesaturation then
		texture:SetDesaturated(false)
	else
		texture:SetDesaturated(enabled == true)
	end
end

CooldownPanels.SetIconDesaturationRuntime = function(texture, value, skipDesaturation)
	if skipDesaturation then
		texture:SetDesaturation(0)
	else
		texture:SetDesaturation(value or 0)
	end
end

CooldownPanels.IsAssistedSuggestedSpell = function(assistedSuggestedSpellId, assistedSuggestedBaseId, assistedSuggestedEffectiveId, baseSpellId, effectiveSpellId)
	if not (assistedSuggestedSpellId and baseSpellId) then return false end
	if baseSpellId == assistedSuggestedSpellId or baseSpellId == assistedSuggestedBaseId or baseSpellId == assistedSuggestedEffectiveId then return true end
	if effectiveSpellId and (effectiveSpellId == assistedSuggestedSpellId or effectiveSpellId == assistedSuggestedBaseId or effectiveSpellId == assistedSuggestedEffectiveId) then return true end
	return false
end

local function updateItemCountCache(usesOnly)
	CooldownPanels.runtime = CooldownPanels.runtime or {}
	local runtime = CooldownPanels.runtime
	runtime.itemCountCache = runtime.itemCountCache or {}
	local cache = runtime.itemCountCache
	local trackedIds = usesOnly == true and runtime.itemUsesTrackedIds or runtime.itemTrackedIds
	local trackedUsesIds = runtime.itemUsesTrackedIds
	if not trackedIds or not next(trackedIds) then
		if usesOnly == true then return false end
		for id in pairs(cache) do
			cache[id] = nil
		end
		return false
	end
	for id in pairs(cache) do
		if not trackedIds[id] then cache[id] = nil end
	end
	for id in pairs(trackedIds) do
		local slot = cache[id] or {}
		if usesOnly ~= true then slot.count = Api.GetItemCount(id, false, false) or 0 end
		if trackedUsesIds and trackedUsesIds[id] then
			slot.uses = Api.GetItemCount(id, false, true) or 0
		elseif usesOnly ~= true then
			slot.uses = nil
		end
		cache[id] = slot
	end
	return true
end

updateItemCountCacheForItem = function(itemID, includeUses)
	if not itemID then return end
	CooldownPanels.runtime = CooldownPanels.runtime or {}
	local runtime = CooldownPanels.runtime
	runtime.itemCountCache = runtime.itemCountCache or {}
	local slot = runtime.itemCountCache[itemID] or {}
	slot.count = Api.GetItemCount(itemID, false, false) or 0
	if includeUses == nil then includeUses = runtime.itemUsesTrackedIds and runtime.itemUsesTrackedIds[itemID] end
	if includeUses then
		slot.uses = Api.GetItemCount(itemID, false, true) or 0
	else
		slot.uses = nil
	end
	runtime.itemCountCache[itemID] = slot
end

function CooldownPanels:HandleEntryBooleanMutation(panelId, entryId, entry, field)
	if not (panelId and entry and field) then return end
	if field == "glowReady" then CooldownPanels.ClearReadyGlowEntryState(panelId, entryId, true) end
	if field == "trackPassiveSpell" then self:RebuildSpellIndex() end
	if field == "glowReady" or field == "checkPower" or field == "hideWhenNoResource" or field == "readyGlowCheckPower" then self:RebuildPowerIndex() end
	if field == "showCharges" then self:RebuildChargesIndex() end
	if field == "showItemUses" or field == "useHighestRank" then
		self:RebuildSpellIndex()
		updateItemCountCache()
		if entry.type == "ITEM" then
			local resolvedItemId = CooldownPanels.ResolveEntryItemID(entry, entry.itemID)
			if resolvedItemId then updateItemCountCacheForItem(resolvedItemId, entry.showItemUses == true) end
		elseif entry.type == "MACRO" then
			local macro = CooldownPanels.ResolveMacroEntry(entry)
			if macro and macro.kind == "ITEM" and macro.itemID then updateItemCountCacheForItem(macro.itemID, entry.showItemUses == true) end
		end
	end
end

function CooldownPanels:RefreshEntryStyleMutation(panelId, entryId, entry, refreshDialog)
	panelId = normalizeId(panelId)
	entryId = normalizeId(entryId)
	if not (panelId and entry) then return end
	local root = ensureRoot()
	if root and Helper.NormalizeEntry then Helper.NormalizeEntry(entry, root.defaults) end
	self:ClearEntryCustomCooldownDuration(panelId, entryId, true)
	self:RebuildSpellIndex()
	self:RebuildChargesIndex()
	self:RebuildPowerIndex()
	updateItemCountCache()
	self:RefreshPanel(panelId)
	if self.IsEditorOpen and self:IsEditorOpen() then self:RefreshEditor() end
	if refreshDialog and self.RefreshLayoutEntryStandaloneMenu then self:RefreshLayoutEntryStandaloneMenu(true) end
end

function CooldownPanels:DisableEntryGlowForQuickSetup(panelId, entryId, entry)
	if not entry then return end
	entry.glowUseGlobal = false
	entry.glowReady = false
	entry.pandemicGlow = false
	entry.procGlowUseGlobal = false
	entry.procGlowEnabled = false
	entry.activationOverlayGlow = false
	self.ClearReadyGlowEntryState(panelId, entryId, true)
end

function CooldownPanels:DisableEntryCountersForQuickSetup(entry)
	if not entry then return end
	entry.showCharges = false
	entry.showChargesCooldown = false
	entry.showStacks = false
	if entry.type == "ITEM" then
		entry.showItemCount = false
		entry.showItemUses = false
	end
end

function CooldownPanels:SetEntryAlwaysShowForQuickSetup(entry)
	if not entry then return end
	entry.alwaysShow = true
	if entry.type == "CDM_AURA" then
		entry.cdmAuraAlwaysShowUseGlobal = false
		entry.cdmAuraAlwaysShowMode = CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE and CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE.SHOW or "SHOW"
	end
end

function CooldownPanels:ApplyTextInfoOnCooldownQuickSetup(panelId, entryId, refreshDialog, layoutKey)
	panelId = normalizeId(panelId)
	entryId = normalizeId(entryId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local entry = panel and panel.entries and panel.entries[entryId] or nil
	if not entry then return false end
	local textInfoLayout = cdp.ENTRY.TEXT_INFO_LAYOUTS[layoutKey] or cdp.ENTRY.TEXT_INFO_LAYOUTS.COOLDOWN_ABOVE_TEXT_BELOW
	entry.hideIcon = true
	entry.cooldownVisibilityUseGlobal = false
	entry.hideOnCooldown = false
	entry.showOnCooldown = true
	entry.showCooldownText = true
	entry.cooldownTextUseGlobal = false
	entry.cooldownTextX = textInfoLayout.cooldownTextX
	entry.cooldownTextY = textInfoLayout.cooldownTextY
	entry.cooldownVisualsUseGlobal = false
	entry.cooldownDrawSwipe = false
	entry.cooldownDrawBling = false
	entry.cooldownDrawEdge = false
	self:DisableEntryCountersForQuickSetup(entry)
	self:DisableEntryGlowForQuickSetup(panelId, entryId, entry)
	entry.staticText = getEntryName(entry)
	entry.staticTextShowOnCooldown = true
	entry.staticTextUseGlobal = false
	entry.staticTextAnchor = textInfoLayout.staticTextAnchor
	entry.staticTextX = textInfoLayout.staticTextX
	entry.staticTextY = textInfoLayout.staticTextY
	if refreshDialog then self:FocusEntryStaticTextStandaloneSettings(panelId) end
	self:RefreshEntryStyleMutation(panelId, entryId, entry, refreshDialog)
	return true
end

function CooldownPanels:ApplyTextAlwaysWithCooldownQuickSetup(panelId, entryId, refreshDialog, layoutKey)
	panelId = normalizeId(panelId)
	entryId = normalizeId(entryId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local entry = panel and panel.entries and panel.entries[entryId] or nil
	if not entry then return false end
	local textInfoLayout = cdp.ENTRY.TEXT_INFO_LAYOUTS[layoutKey] or cdp.ENTRY.TEXT_INFO_LAYOUTS.COOLDOWN_ABOVE_TEXT_BELOW
	entry.hideIcon = true
	self:SetEntryAlwaysShowForQuickSetup(entry)
	entry.cooldownVisibilityUseGlobal = false
	entry.hideOnCooldown = false
	entry.showOnCooldown = false
	entry.showCooldownText = true
	entry.cooldownTextUseGlobal = false
	entry.cooldownTextX = textInfoLayout.cooldownTextX
	entry.cooldownTextY = textInfoLayout.cooldownTextY
	entry.cooldownVisualsUseGlobal = false
	entry.cooldownDrawSwipe = false
	entry.cooldownDrawBling = false
	entry.cooldownDrawEdge = false
	self:DisableEntryCountersForQuickSetup(entry)
	self:DisableEntryGlowForQuickSetup(panelId, entryId, entry)
	entry.staticText = getEntryName(entry)
	entry.staticTextShowOnCooldown = false
	entry.staticTextUseGlobal = false
	entry.staticTextAnchor = textInfoLayout.staticTextAnchor
	entry.staticTextX = textInfoLayout.staticTextX
	entry.staticTextY = textInfoLayout.staticTextY
	if refreshDialog then self:FocusEntryStaticTextStandaloneSettings(panelId) end
	self:RefreshEntryStyleMutation(panelId, entryId, entry, refreshDialog)
	return true
end

function CooldownPanels:ApplyTextInfoWhenReadyQuickSetup(panelId, entryId, refreshDialog)
	panelId = normalizeId(panelId)
	entryId = normalizeId(entryId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local entry = panel and panel.entries and panel.entries[entryId] or nil
	if not entry then return false end
	entry.hideIcon = true
	entry.cooldownVisibilityUseGlobal = false
	entry.hideOnCooldown = true
	entry.showOnCooldown = false
	entry.showCooldownText = false
	entry.cooldownVisualsUseGlobal = false
	entry.cooldownDrawSwipe = false
	entry.cooldownDrawBling = false
	entry.cooldownDrawEdge = false
	self:DisableEntryCountersForQuickSetup(entry)
	self:DisableEntryGlowForQuickSetup(panelId, entryId, entry)
	entry.staticText = getEntryName(entry)
	entry.staticTextShowOnCooldown = false
	entry.staticTextUseGlobal = false
	entry.staticTextAnchor = "CENTER"
	entry.staticTextX = 0
	entry.staticTextY = 0
	if refreshDialog then self:FocusEntryStaticTextStandaloneSettings(panelId) end
	self:RefreshEntryStyleMutation(panelId, entryId, entry, refreshDialog)
	return true
end

function CooldownPanels:CopyEntryStyle(panelId, entryId)
	panelId = normalizeId(panelId)
	entryId = normalizeId(entryId)
	local panel = panelId and self:GetPanel(panelId) or nil
	local entry = panel and panel.entries and panel.entries[entryId] or nil
	if not entry then return false end
	CooldownPanels.runtime = CooldownPanels.runtime or {}
	local fields = {}
	for key, value in pairs(entry) do
		if cdp.ENTRY.IsStyleClipboardKeyAllowedForEntry(key, entry) then fields[key] = cdp.ENTRY.CopyStyleValue(value) end
	end
	CooldownPanels.runtime.entryStyleClipboard = {
		sourcePanelId = panelId,
		sourceEntryId = entryId,
		sourceType = entry.type,
		sourceName = getEntryName(entry),
		fields = fields,
	}
	print("|cff00ff98Enhance QoL|r: " .. (L["CooldownPanelEntryStyleCopied"] or "Entry style copied."))
	return true
end

function CooldownPanels:CanPasteEntryStyle()
	local clipboard = CooldownPanels.runtime and CooldownPanels.runtime.entryStyleClipboard
	return type(clipboard) == "table" and type(clipboard.fields) == "table" and next(clipboard.fields) ~= nil
end

function CooldownPanels:PasteEntryStyle(panelId, entryId)
	panelId = normalizeId(panelId)
	entryId = normalizeId(entryId)
	local clipboard = CooldownPanels.runtime and CooldownPanels.runtime.entryStyleClipboard or nil
	if not (type(clipboard) == "table" and type(clipboard.fields) == "table") then
		print("|cff00ff98Enhance QoL|r: " .. (L["CooldownPanelEntryStyleClipboardEmpty"] or "No entry style copied."))
		return false
	end
	local panel = panelId and self:GetPanel(panelId) or nil
	local entry = panel and panel.entries and panel.entries[entryId] or nil
	if not entry then return false end
	local bars = self.Bars
	local wasBarDisplayMode = bars and bars.IsBarDisplayModeValue and bars.IsBarDisplayModeValue(entry.displayMode) or false
	local changed = false
	for key, value in pairs(clipboard.fields) do
		if cdp.ENTRY.IsStyleClipboardKeyAllowedForEntry(key, entry) then
			entry[key] = cdp.ENTRY.CopyStyleValue(value)
			changed = true
		end
	end
	if not changed then return false end
	if bars and bars.HandleEntryStylePaste then bars.HandleEntryStylePaste(panelId, entryId, entry, wasBarDisplayMode) end
	self:RefreshEntryStyleMutation(panelId, entryId, entry, true)
	print("|cff00ff98Enhance QoL|r: " .. (L["CooldownPanelEntryStylePasted"] or "Entry style pasted."))
	return true
end

function CooldownPanels:UpdateRuntimeIcons(panelId)
	local panel = self:GetPanel(panelId)
	if not panel then return end
	local runtime = getRuntime(panelId)
	local frame = runtime.frame
	if not frame then return end
	local layoutEditActive = self:IsPanelLayoutEditActive(panelId)
	local shared = CooldownPanels.runtime
	local enabledPanels = shared and shared.enabledPanels
	local eligible = layoutEditActive or enabledPanels and enabledPanels[panelId] or (not enabledPanels and panel.enabled ~= false and panelAllowsSpec(panel))
	if not eligible then
		if runtime._eqolHiddenByEligibility then return end
		runtime._eqolHiddenByEligibility = true
		runtime.visibleCount = 0
		runtime.visiblePowerSpellCount = 0
		if runtime.visibleEntries then
			for i = 1, #runtime.visibleEntries do
				runtime.visibleEntries[i] = nil
			end
		end
		if runtime.visiblePowerSpells then
			for i = 1, #runtime.visiblePowerSpells do
				runtime.visiblePowerSpells[i] = nil
			end
		end
		if frame.icons then
			for i = 1, #frame.icons do
				local icon = frame.icons[i]
				cdp.RUNTIME.ClearIconSnapshot(icon)
				CooldownPanels:HideEditorGhostIcon(icon)
				if icon.stateTexture then icon.stateTexture:Hide() end
				if icon.stateTextureSecond then icon.stateTextureSecond:Hide() end
				setAssistedHighlight(icon, false)
			end
		end
		ensureIconCount(frame, 0)
		return
	end
	runtime._eqolHiddenByEligibility = nil
	panel.layout = panel.layout or Helper.CopyTableShallow(Helper.PANEL_LAYOUT_DEFAULTS)
	local layout = panel.layout
	local fixedLayout = Helper.IsFixedLayout(layout)
	local resolvedLayout = CooldownPanels.ResolveRuntimeLayout(runtime, frame, layout)
	local showTooltips = resolvedLayout and resolvedLayout.showTooltips
	local showKeybinds = resolvedLayout and resolvedLayout.showKeybinds
	local staticFontPath = resolvedLayout and resolvedLayout.staticFontPath
	local staticFontSize = resolvedLayout and resolvedLayout.staticFontSize
	local staticFontStyle = resolvedLayout and resolvedLayout.staticFontStyle
	local defaultCountFontPath, defaultCountFontSize, defaultCountFontStyle = Helper.GetCountFontDefaults(frame)
	local defaultChargesFontPath, defaultChargesFontSize, defaultChargesFontStyle = Helper.GetChargesFontDefaults(frame)
	local powerTintR = resolvedLayout and resolvedLayout.powerTintR
	local powerTintG = resolvedLayout and resolvedLayout.powerTintG
	local powerTintB = resolvedLayout and resolvedLayout.powerTintB
	local unusableTintR = resolvedLayout and resolvedLayout.unusableTintR
	local unusableTintG = resolvedLayout and resolvedLayout.unusableTintG
	local unusableTintB = resolvedLayout and resolvedLayout.unusableTintB
	local rangeOverlayEnabled = resolvedLayout and resolvedLayout.rangeOverlayEnabled
	local rangeOverlayR = resolvedLayout and resolvedLayout.rangeOverlayR
	local rangeOverlayG = resolvedLayout and resolvedLayout.rangeOverlayG
	local rangeOverlayB = resolvedLayout and resolvedLayout.rangeOverlayB
	local rangeOverlayA = resolvedLayout and resolvedLayout.rangeOverlayA
	local drawEdge = resolvedLayout and resolvedLayout.drawEdge
	local drawBling = resolvedLayout and resolvedLayout.drawBling
	local drawSwipe = resolvedLayout and resolvedLayout.drawSwipe
	local gcdDrawEdge = resolvedLayout and resolvedLayout.gcdDrawEdge
	local gcdDrawBling = resolvedLayout and resolvedLayout.gcdDrawBling
	local gcdDrawSwipe = resolvedLayout and resolvedLayout.gcdDrawSwipe
	local assistedHighlightEnabled = shared and shared.assistedHighlightEnabled
	if assistedHighlightEnabled == nil then
		if CooldownPanels.refreshAssistedHighlightCVarState then CooldownPanels.refreshAssistedHighlightCVarState(nil, true) end
		assistedHighlightEnabled = shared and shared.assistedHighlightEnabled == true
	end
	local assistedSuggestedSpellId = assistedHighlightEnabled and Api.GetAssistedCombatNextSpell and tonumber(Api.GetAssistedCombatNextSpell()) or nil
	local assistedSuggestedBaseId = assistedSuggestedSpellId and getBaseSpellId(assistedSuggestedSpellId) or nil
	local assistedSuggestedEffectiveId = assistedSuggestedSpellId and getEffectiveSpellId(assistedSuggestedSpellId) or nil

	local visible = runtime.visibleEntries
	if not visible then
		visible = {}
		runtime.visibleEntries = visible
	end
	local lastLayoutEditActive = runtime._eqolLastLayoutEditActive == true
	self:EnsureSpellQueryPass()
	local visibleCount = 0
	local previousOccupiedSlots = fixedLayout and runtime._eqolOccupiedSlots or nil
	local previousOccupiedSlotIndices = fixedLayout and runtime._eqolOccupiedSlotIndices or nil
	local visibleSlotsUsed = fixedLayout and (runtime._eqolScratchOccupiedSlots or {}) or nil
	if visibleSlotsUsed then
		for slotIndex in pairs(visibleSlotsUsed) do
			visibleSlotsUsed[slotIndex] = nil
		end
	end
	local visibleSlotIndices = fixedLayout and (runtime._eqolScratchOccupiedSlotIndices or {}) or nil
	local visibleSlotCount = 0
	local order = panel.order or {}
	local fixedSlotCount = 0
	local fixedGridColumns = 0
	local fixedLayoutCache = fixedLayout and Helper.GetFixedLayoutCache and Helper.GetFixedLayoutCache(panel) or nil
	local fixedGroups = fixedLayout and (fixedLayoutCache and fixedLayoutCache.groups or CooldownPanels.GetFixedGroups(panel)) or nil
	local fixedGroupById = fixedLayout and (fixedLayoutCache and fixedLayoutCache.groupById or {}) or nil
	local fixedStaticTargetIndices = fixedLayout and fixedLayoutCache and fixedLayoutCache.staticTargetIndexByEntryId or nil
	local fixedGroupVisibleCounts = fixedLayout and {} or nil
	local fixedCenterGroupVisibleData = fixedLayout and {} or nil
	local effectiveLayoutCache = {}
	local fixedVisualCache = nil
	if fixedLayout then
		if fixedLayoutCache then
			fixedSlotCount = fixedLayoutCache.slotCount or 0
			fixedGridColumns = fixedLayoutCache.boundsColumns or 0
		else
			_, fixedSlotCount, fixedGridColumns = Helper.BuildFixedSlotEntryIds(panel, nil, false)
		end
		if fixedGroups and fixedGroupById and not fixedLayoutCache then
			for i = 1, #fixedGroups do
				local fixedGroup = fixedGroups[i]
				if fixedGroup then fixedGroupById[fixedGroup.id] = fixedGroup end
			end
		end
		if fixedGroups then
			for i = 1, #fixedGroups do
				local fixedGroup = fixedGroups[i]
				if fixedGroup then
					if fixedGroup.layoutOverrides then
						effectiveLayoutCache[fixedGroup.id] = self:GetFixedGroupEffectiveLayout(panelId, fixedGroup, effectiveLayoutCache, panel, layout, fixedLayoutCache) or layout
					else
						effectiveLayoutCache[fixedGroup.id] = layout
					end
				end
			end
		end
		fixedVisualCache = CooldownPanels._eqolFixedVisualCacheUtil.Prepare(frame, panel, fixedGridColumns, fixedLayoutCache)
	end
	local editGridColumns
	if layoutEditActive and not fixedLayout then
		editGridColumns = Helper.ClampInt(layout.wrapCount, 0, 40, Helper.PANEL_LAYOUT_DEFAULTS.wrapCount or 0)
		if editGridColumns <= 0 then editGridColumns = math.min(math.max(type(panel.order) == "table" and #panel.order or 0, 4), 12) end
	end
	local layoutEditGridCount = layoutEditActive and fixedLayout and fixedSlotCount or 0
	if runtime._eqolLayoutEditGridDirty == true or runtime._eqolLayoutEditGridCount ~= layoutEditGridCount or runtime._eqolLayoutEditGridActive ~= (layoutEditActive == true) then
		self:UpdateLayoutEditGrid(panelId, layoutEditGridCount)
		runtime._eqolLayoutEditGridCount = layoutEditGridCount
		runtime._eqolLayoutEditGridActive = layoutEditActive == true
		runtime._eqolLayoutEditGridDirty = nil
	end
	runtime.readyAt = runtime.readyAt or {}
	runtime.glowTimers = runtime.glowTimers or {}
	local readyGlowPrimed = CooldownPanels.GetReadyGlowPrimedState(runtime)
	local overlayGlowSpells = shared and shared.overlayGlowSpells
	local powerInsufficientSpells = shared and shared.powerInsufficient
	local spellUnusableSpells = shared and shared.spellUnusable
	local rangeOverlaySpells = shared and shared.rangeOverlaySpells
	local powerCheckSpells = shared and shared.powerCheckSpells or nil
	local cdmAuras = CooldownPanels.CDMAuras
	local playerInCombat = (InCombatLockdown and InCombatLockdown()) or (UnitAffectingCombat and UnitAffectingCombat("player")) or false
	local directTalentChoiceEntriesByGroupKey = {}
	for _, candidateEntryId in ipairs(order) do
		local candidateEntry = panel.entries and panel.entries[candidateEntryId]
		if candidateEntry then
			local candidateMacro = candidateEntry.type == "MACRO" and CooldownPanels.ResolveMacroEntry(candidateEntry) or nil
			local candidateResolvedType = (candidateMacro and candidateMacro.kind) or candidateEntry.type
			local candidateBaseSpellId = candidateResolvedType == "SPELL" and ((candidateMacro and candidateMacro.spellID) or candidateEntry.spellID) or nil
			if candidateBaseSpellId then
				local candidateGroups = self:GetActiveTalentChoiceGroupsForSpell(candidateBaseSpellId)
				if type(candidateGroups) == "table" then
					for groupIndex = 1, #candidateGroups do
						local candidateGroup = candidateGroups[groupIndex]
						if cdp.RUNTIME.IsDirectTalentChoiceGroupMember(candidateGroup, candidateBaseSpellId) then
							local key = candidateGroup.key
							if key then
								directTalentChoiceEntriesByGroupKey[key] = directTalentChoiceEntriesByGroupKey[key] or {}
								directTalentChoiceEntriesByGroupKey[key][candidateEntryId] = true
							end
						end
					end
				end
			end
		end
	end
	for _, entryId in ipairs(order) do
		local entry = panel.entries and panel.entries[entryId]
		if entry then
			local entryLayout = layout
			if fixedLayout then
				local groupId = entry.fixedGroupId
				if groupId and fixedGroupById and fixedGroupById[groupId] then entryLayout = effectiveLayoutCache[groupId] or layout end
			else
				entryLayout = self:GetEntryEffectiveLayout(panelId, entry, effectiveLayoutCache, panel) or layout
			end
			local macro = entry.type == "MACRO" and CooldownPanels.ResolveMacroEntry(entry) or nil
			local resolvedType = (macro and macro.kind) or entry.type
			local resolvedBaseItemId = resolvedType == "ITEM" and ((macro and macro.itemID) or entry.itemID) or nil
			local resolvedItemId = resolvedType == "ITEM" and CooldownPanels.ResolveEntryItemID(entry, resolvedBaseItemId) or nil
			local resolvedSlotId = resolvedType == "SLOT" and entry.slotID or nil
			local showCooldown = entry.showCooldown ~= false
			local showCooldownText = entry.showCooldownText ~= false
			local staticTextShowOnCooldown = entry.staticTextShowOnCooldown == true
			local trackCooldown = showCooldown or staticTextShowOnCooldown
			local showCharges = entry.showCharges == true and resolvedType == "SPELL"
			local showStacks = shouldShowEntryStacks(entry, resolvedType)
			local showItemCount = resolvedType == "ITEM" and entry.showItemCount ~= false
			local showItemUses = resolvedType == "ITEM" and entry.showItemUses == true
			local showWhenEmpty = resolvedType == "ITEM" and entry.showWhenEmpty == true
			local showWhenNoCooldown = resolvedType == "SLOT" and entry.showWhenNoCooldown == true
			local showWhenMissing = resolvedType == "STANCE" and entry.showWhenMissing == true
			local alwaysShow = entry.alwaysShow ~= false
			local cdmAuraAlwaysShowMode = resolvedType == "CDM_AURA" and CooldownPanels:ResolveEntryCDMAuraAlwaysShowMode(entryLayout, entry) or nil
			local glowReady = entry.type ~= "MACRO" and entry.type ~= "CDM_AURA" and entry.glowReady ~= false
			local baseSpellId = resolvedType == "SPELL" and ((macro and macro.spellID) or entry.spellID) or nil
			local effectiveSpellId, resolvedSpellId, storedBaseSpellId, variantGroup
			if baseSpellId then effectiveSpellId, resolvedSpellId, storedBaseSpellId, variantGroup = CooldownPanels:ResolveTrackedSpellID(baseSpellId) end
			local talentChoiceResolved = variantGroup and variantGroup.kind == "talentChoice"
			local stanceRelevant = resolvedType == "STANCE" and CooldownPanels.IsStanceEntryRelevant and CooldownPanels:IsStanceEntryRelevant(entry) or false
			local stanceActive = stanceRelevant and CooldownPanels.IsStanceEntryActive and CooldownPanels:IsStanceEntryActive(entry) or false
			local spellPassive = resolvedSpellId and not shouldTrackPassiveSpell(entry) and isSpellPassiveSafe(resolvedSpellId, effectiveSpellId) or false
			local cdmAuraData
			local spellAuraOverlayData
			local stackCount
			local itemCount
			local itemUses
			local chargesInfo
			local chargesInfoActive = false
			local chargeDurationObject
			local cooldownDurationObject
			local cooldownStart, cooldownDuration, cooldownEnabled, cooldownRate, cooldownGCD, cooldownIsActive
			local show = false
			local cooldownEnabledOk = true
			local emptyItem = false
			local canTriggerReadyGlow = false
			local spellId
			local spellPassState
			local customCooldownState = self:GetActiveEntryCustomCooldownDuration(panelId, entryId, entry, resolvedType)

			if resolvedType == "SPELL" and baseSpellId then
				spellId = effectiveSpellId or baseSpellId
				spellPassState = self:GetSpellPassState(spellId)
				if self:IsEntryRacialVariantExcluded(entry, resolvedSpellId or spellId) then
					show = false
					CooldownPanels.ClearReadyGlowEntryState(panelId, entryId, true)
				elseif spellPassive then
					show = false
				elseif Api.IsSpellKnown and not talentChoiceResolved and not Api.IsSpellKnown(spellId, true) then
					show = false
				else
					canTriggerReadyGlow = true
					if showCharges then
						if spellPassState and spellPassState.chargesLoaded == nil then
							spellPassState.chargesInfo = self:GetCachedSpellChargesInfo(spellId)
							spellPassState.chargesLoaded = true
						end
						chargesInfo = spellPassState and spellPassState.chargesInfo or self:GetCachedSpellChargesInfo(spellId)
						chargesInfoActive = CooldownPanels.IsChargeInfoActive(chargesInfo)
					end
					if trackCooldown or (showCooldown and chargesInfoActive) then
						cooldownStart, cooldownDuration, cooldownEnabled, cooldownRate, cooldownGCD, cooldownIsActive =
							self:GetCachedSpellCooldownInfo(spellId, self:ShouldIgnoreEntryCooldownGCD(entryLayout, entry))
					end
					cooldownIsActive = CooldownPanels.IsSpellCooldownInfoActive(cooldownIsActive, cooldownEnabled, cooldownStart, cooldownDuration)
					if showStacks then
						local entryKey = Helper.GetEntryKey(panelId, entryId)
						local displayCount = Helper.GetActionDisplayCountForSpell and Helper.GetActionDisplayCountForSpell(spellId) or nil
						if displayCount == nil and baseSpellId and baseSpellId ~= spellId and Helper.GetActionDisplayCountForSpell then
							displayCount = Helper.GetActionDisplayCountForSpell(baseSpellId)
						end
						stackCount = displayCount
						shared = shared or CooldownPanels.runtime
						if shared then
							shared.actionDisplayCounts = shared.actionDisplayCounts or {}
							shared.actionDisplayCounts[entryKey] = displayCount
						end
					end
					cooldownEnabledOk = isSafeNotFalse(cooldownEnabled)
					show = alwaysShow
					if not show and showCooldown and cooldownIsActive then show = true end
					if not show and showCharges and chargesInfoActive then show = true end
					if not show and showStacks and Helper.HasDisplayCount(stackCount) then show = true end
					if self:IsEntryCDMAuraOverlayEnabled(entryLayout, entry, resolvedType) and cdmAuras and cdmAuras.BuildSpellAuraOverlayData then
						spellAuraOverlayData = cdmAuras:BuildSpellAuraOverlayData(panelId, entryId, entry, spellId, entryLayout)
						if spellAuraOverlayData and spellAuraOverlayData.active == true then
							cooldownStart = spellAuraOverlayData.cooldownStart
							cooldownDuration = spellAuraOverlayData.cooldownDuration
							cooldownEnabled = spellAuraOverlayData.cooldownEnabled
							cooldownRate = spellAuraOverlayData.cooldownRate
							cooldownGCD = false
							cooldownIsActive = true
							cooldownDurationObject = spellAuraOverlayData.cooldownDurationObject
							cooldownEnabledOk = true
							if showCooldown then show = true end
						end
					end
				end
			elseif resolvedType == "ITEM" and resolvedItemId then
				local itemCache = shared and shared.itemCountCache
				local cached = itemCache and itemCache[resolvedItemId]
				if not cached then
					updateItemCountCacheForItem(resolvedItemId, showItemUses)
					itemCache = shared and shared.itemCountCache
					cached = itemCache and itemCache[resolvedItemId]
				end
				local cachedCount = cached and cached.count
				local ownsItem
				if cachedCount ~= nil then
					ownsItem = cachedCount > 0 or (Api.IsEquippedItem and Api.IsEquippedItem(resolvedItemId))
				else
					ownsItem = hasItem(resolvedItemId)
				end
				emptyItem = showWhenEmpty and not ownsItem
				if (ownsItem or showWhenEmpty) and itemHasUseSpell(resolvedItemId) then
					canTriggerReadyGlow = ownsItem == true
					if trackCooldown and ownsItem then
						cooldownStart, cooldownDuration, cooldownEnabled = getItemCooldownInfo(resolvedItemId)
						if CooldownPanels:IsItemCooldownOnGCD(resolvedItemId, cooldownStart, cooldownDuration) then
							cooldownStart, cooldownDuration, cooldownEnabled = 0, 0, true
							cooldownGCD = true
						end
					end
					cooldownEnabledOk = cooldownEnabled ~= false and cooldownEnabled ~= 0
					show = alwaysShow or showWhenEmpty
					if not show and showCooldown and cooldownEnabledOk and isCooldownActive(cooldownStart, cooldownDuration) then show = true end
				end
			elseif resolvedType == "SLOT" and resolvedSlotId then
				local itemId = Api.GetInventoryItemID and Api.GetInventoryItemID("player", resolvedSlotId) or nil
				if itemId then
					if itemHasUseSpell(itemId) then
						canTriggerReadyGlow = true
						if trackCooldown then
							cooldownStart, cooldownDuration, cooldownEnabled = getItemCooldownInfo(itemId, resolvedSlotId)
							if CooldownPanels:IsItemCooldownOnGCD(itemId, cooldownStart, cooldownDuration) then
								cooldownStart, cooldownDuration, cooldownEnabled = 0, 0, true
								cooldownGCD = true
							end
						end
						cooldownEnabledOk = cooldownEnabled ~= false and cooldownEnabled ~= 0
						show = alwaysShow or showWhenNoCooldown
						if not show and showCooldown and cooldownEnabledOk and isCooldownActive(cooldownStart, cooldownDuration) then show = true end
					elseif showWhenNoCooldown then
						show = true
					end
				end
			elseif resolvedType == "STANCE" then
				if not stanceRelevant then
					show = false
				elseif showWhenMissing then
					show = not stanceActive
				else
					show = stanceActive
				end
			elseif resolvedType == "CDM_AURA" and cdmAuras and cdmAuras.BuildRuntimeData then
				cdmAuraData = cdmAuras:BuildRuntimeData(panelId, entryId, entry, entryLayout, cdmAuraAlwaysShowMode)
				if cdmAuraData then
					stackCount = cdmAuraData.stackCount
					cooldownStart = cdmAuraData.cooldownStart
					cooldownDuration = cdmAuraData.cooldownDuration
					cooldownEnabled = cdmAuraData.cooldownEnabled
					cooldownRate = cdmAuraData.cooldownRate
					show = cdmAuraData.show == true
				end
			end
			if customCooldownState then
				cooldownStart = customCooldownState.startTime
				cooldownDuration = customCooldownState.duration
				cooldownEnabled = true
				cooldownRate = customCooldownState.rate or 1
				cooldownGCD = false
				cooldownIsActive = true
				cooldownDurationObject = customCooldownState.durationObject
				cooldownEnabledOk = true
				if showCooldown then show = true end
				canTriggerReadyGlow = canTriggerReadyGlow or showCooldown
			end
			if entry.activationOverlayOnly == true and self:EntryUsesActivationOverlay(entry, resolvedType) then
				show = customCooldownState ~= nil or (spellAuraOverlayData and spellAuraOverlayData.active == true) or false
			end
			if show and resolvedType == "SPELL" and baseSpellId and self:ResolveEntryHideWhenNoResource(entryLayout, entry) then
				if isSpellFlagged(powerInsufficientSpells, baseSpellId, effectiveSpellId) then show = false end
			end
			if show and resolvedType == "SPELL" and cdp.RUNTIME.IsDirectTalentChoiceGroupMember(variantGroup, baseSpellId) ~= true then
				local directEntries = variantGroup and directTalentChoiceEntriesByGroupKey[variantGroup.key] or nil
				if type(directEntries) == "table" then
					for directEntryId in pairs(directEntries) do
						if directEntryId ~= entryId then
							show = false
							CooldownPanels.ClearReadyGlowEntryState(panelId, entryId, true)
							break
						end
					end
				end
			end
			if layoutEditActive then show = true end

			if show then
				-- Phase 2: only hydrate expensive visual/runtime details for entries that will actually render.
				local entryHideOnCooldown, entryShowOnCooldown = self:ResolveEntryCooldownVisibility(entryLayout, entry)
				if layoutEditActive or resolvedType == "CDM_AURA" then
					entryHideOnCooldown = false
					entryShowOnCooldown = false
				end
				local showEntryIconTexture = self:ResolveEntryShowIconTexture(entryLayout, entry)
				local showGhostIcon = self:ShouldShowEditorGhostIcon(entryLayout, entry, showEntryIconTexture, layoutEditActive)
				local entryNoDesaturation = self:ResolveEntryNoDesaturation(entryLayout, entry)
				local entryShowChargesCooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe, entryGcdDrawEdge, entryGcdDrawBling, entryGcdDrawSwipe =
					self:ResolveEntryCooldownVisuals(entryLayout, entry)
				local cdmAuraActiveGlow = entry.type == "CDM_AURA" and entry.glowReady == true
				local cdmAuraPandemicGlow = entry.type == "CDM_AURA" and entry.pandemicGlow == true
				local glowDuration, glowColor, glowStyle, glowInset = CooldownPanels:ResolveEntryGlowStyle(entryLayout, entry)
				local procGlowStyle, procGlowInset = CooldownPanels:ResolveEntryProcGlowVisual(entryLayout, entry)
				local stateTextureType, stateTextureValue, stateTextureWidth, stateTextureHeight, stateTextureScale, stateTextureAngle, stateTextureDouble, stateTextureMirror, stateTextureMirrorSecond, stateTextureMirrorVertical, stateTextureMirrorVerticalSecond, stateTextureSpacingX, stateTextureSpacingY =
					CooldownPanels:ResolveEntryStateTexture(entry)
				local pandemicGlowColor, pandemicGlowStyle, pandemicGlowInset = glowColor, glowStyle, glowInset
				if resolvedType == "CDM_AURA" then
					pandemicGlowColor, pandemicGlowStyle, pandemicGlowInset = CooldownPanels:ResolveEntryPandemicGlowVisual(entryLayout, entry)
				end
				local soundReady = false
				local soundName = normalizeSoundName(nil)
				local previewSound = false
				do
					local _, soundEnabledField, soundField = self:GetEntrySoundConfig(entry)
					if soundEnabledField and soundField then
						previewSound = entry[soundEnabledField] == true
						soundName = normalizeSoundName(entry[soundField])
						soundReady = resolvedType ~= "CDM_AURA" and previewSound
					end
				end
				local entryCheckPower = resolvedType == "SPELL" and self:ResolveEntryCheckPower(entryLayout, entry)
				local readyGlowCheckPower = resolvedType == "SPELL" and glowReady and self:ResolveEntryReadyGlowCheckPower(entryLayout, entry)
				local procGlowEnabled = resolvedType == "SPELL" and CooldownPanels:ResolveEntryProcGlowEnabled(entryLayout, entry)
				local procActive = resolvedType == "SPELL" and isSpellFlagged(overlayGlowSpells, baseSpellId, effectiveSpellId)
				local overlayGlow = procActive and procGlowEnabled
				local resourceInsufficient = resolvedType == "SPELL" and isSpellFlagged(powerInsufficientSpells, baseSpellId, effectiveSpellId)
				local unusableForReadyGlow = resolvedType == "SPELL" and isSpellFlagged(spellUnusableSpells, baseSpellId, effectiveSpellId)
				local readyGlowResourceBlocked = resolvedType == "SPELL" and readyGlowCheckPower and (resourceInsufficient or unusableForReadyGlow)
				local powerInsufficient = resolvedType == "SPELL" and entryCheckPower and resourceInsufficient
				local spellUnusable = resolvedType == "SPELL" and entryCheckPower and isSpellFlagged(spellUnusableSpells, baseSpellId, effectiveSpellId)
				local rangeOverlay = rangeOverlayEnabled and resolvedType == "SPELL" and isSpellFlagged(rangeOverlaySpells, baseSpellId, effectiveSpellId)
				local assistedSuggested = resolvedType == "SPELL"
					and CooldownPanels.IsAssistedSuggestedSpell(assistedSuggestedSpellId, assistedSuggestedBaseId, assistedSuggestedEffectiveId, baseSpellId, effectiveSpellId)
				if rangeOverlay and Api.IsSpellUsableFn then
					local checkId = effectiveSpellId or baseSpellId
					if checkId then
						local isUsable = Api.IsSpellUsableFn(checkId)
						if not isUsable then rangeOverlay = false end
					end
				end
				local spellReadyCondition
				if resolvedType == "SPELL" and spellId then
					if showCooldown and entryShowChargesCooldown and chargesInfoActive then
						if spellPassState and spellPassState.chargeDurationLoaded == nil then
							spellPassState.chargeDurationObject = self:GetCachedSpellChargeDurationObject(spellId)
							spellPassState.chargeDurationLoaded = true
						end
						chargeDurationObject = spellPassState and spellPassState.chargeDurationObject or self:GetCachedSpellChargeDurationObject(spellId)
					end
					if trackCooldown and cooldownIsActive then
						cooldownDurationObject = self:GetCachedSpellCooldownDurationObject(spellId, self:ShouldIgnoreEntryCooldownGCD(entryLayout, entry))
					end
					if glowReady and showCooldown then
						if cooldownGCD then
							spellReadyCondition = true
						elseif type(cooldownIsActive) == "boolean" then
							spellReadyCondition = not cooldownIsActive
						end
					end
				end
				if customCooldownState then
					cooldownStart = customCooldownState.startTime
					cooldownDuration = customCooldownState.duration
					cooldownEnabled = true
					cooldownRate = customCooldownState.rate or 1
					cooldownGCD = false
					cooldownIsActive = true
					cooldownDurationObject = customCooldownState.durationObject
				end
				if not customCooldownState and spellAuraOverlayData and spellAuraOverlayData.active == true then
					cooldownStart = spellAuraOverlayData.cooldownStart
					cooldownDuration = spellAuraOverlayData.cooldownDuration
					cooldownEnabled = spellAuraOverlayData.cooldownEnabled
					cooldownRate = spellAuraOverlayData.cooldownRate or 1
					cooldownGCD = false
					cooldownIsActive = true
					cooldownDurationObject = spellAuraOverlayData.cooldownDurationObject
				end
				if resolvedType == "ITEM" and resolvedItemId then
					local itemCache = shared and shared.itemCountCache
					local cached = itemCache and itemCache[resolvedItemId]
					if not cached then
						updateItemCountCacheForItem(resolvedItemId, showItemUses)
						itemCache = shared and shared.itemCountCache
						cached = itemCache and itemCache[resolvedItemId]
					end
					if showItemCount then
						local count = cached and cached.count
						if count == nil then
							updateItemCountCacheForItem(resolvedItemId, showItemUses)
							itemCache = shared and shared.itemCountCache
							cached = itemCache and itemCache[resolvedItemId]
							count = cached and cached.count or 0
						end
						if isSafeGreaterThan(count, 0) then
							itemCount = count
						elseif showWhenEmpty then
							itemCount = 0
						end
					end
					if showItemUses then
						local uses = cached and cached.uses
						if uses == nil then
							updateItemCountCacheForItem(resolvedItemId, true)
							itemCache = shared and shared.itemCountCache
							cached = itemCache and itemCache[resolvedItemId]
							uses = cached and cached.uses or 0
						end
						if isSafeGreaterThan(uses, 0) then
							itemUses = uses
						elseif showWhenEmpty then
							itemUses = 0
						end
					end
				end
				local iconTexture = cdmAuraData and cdmAuraData.iconTextureID or getEntryIcon(entry)
				visibleCount = visibleCount + 1
				local targetIndex = visibleCount
				local data = nil
				local fixedGroup = nil
				local fixedGroupCenterGrowth = false
				local fixedGroupDynamicRuntimeIndex = nil
				if fixedLayout then
					fixedGroup = entry.fixedGroupId and fixedGroupById and fixedGroupById[entry.fixedGroupId] or nil
					if fixedGroup then
						if Helper.FixedGroupUsesStaticSlots and Helper.FixedGroupUsesStaticSlots(fixedGroup) == true then
							targetIndex = fixedStaticTargetIndices and fixedStaticTargetIndices[entryId] or nil
						else
							local groupVisibleCount = (fixedGroupVisibleCounts[fixedGroup.id] or 0) + 1
							fixedGroupVisibleCounts[fixedGroup.id] = groupVisibleCount
							fixedGroupDynamicRuntimeIndex = groupVisibleCount
							fixedGroupCenterGrowth = Helper.IsFixedGroupCenterGrowth and Helper.IsFixedGroupCenterGrowth(fixedGroup) == true
							if not fixedGroupCenterGrowth then
								local dynamicTargetIndices = Helper.GetFixedGroupDynamicTargetIndices and Helper.GetFixedGroupDynamicTargetIndices(fixedGroup) or nil
								targetIndex = dynamicTargetIndices and dynamicTargetIndices[groupVisibleCount] or nil
								if not (targetIndex and targetIndex <= fixedSlotCount) then targetIndex = nil end
							else
								targetIndex = nil
							end
						end
					else
						targetIndex = fixedStaticTargetIndices and fixedStaticTargetIndices[entryId] or nil
					end
					if targetIndex and targetIndex > fixedSlotCount then targetIndex = nil end
				end
				if fixedLayout and targetIndex and visibleSlotsUsed and not visibleSlotsUsed[targetIndex] then
					visibleSlotsUsed[targetIndex] = true
					visibleSlotCount = visibleSlotCount + 1
					visibleSlotIndices[visibleSlotCount] = targetIndex
				end
				if not data then data = targetIndex and visible[targetIndex] or nil end
				if not data then
					data = {}
					if targetIndex then visible[targetIndex] = data end
				end
				data.icon = iconTexture or Helper.PREVIEW_ICON
				data.showCooldown = showCooldown
				data.showCooldownText = showCooldownText
				data.showIconTexture = showEntryIconTexture
				data.showGhostIcon = showGhostIcon
				data.showCharges = showCharges
				data.showChargesCooldown = showCharges and entryShowChargesCooldown
				data.showStacks = showStacks
				data.showItemCount = showItemCount
				data.showItemUses = showItemUses
				data.chargesHideWhenZero = entryLayout.chargesHideWhenZero == true
				data.showKeybinds = entryLayout.keybindsEnabled == true
				data.keybindText = data.showKeybinds and Keybinds.GetEntryKeybindText(entry, entryLayout, effectiveSpellId, resolvedSpellId, storedBaseSpellId, entry.type == "SPELL" and resolvedType == "SPELL") or nil
				data.layout = entryLayout
				data.liveGlowAllowed = entryLayout.hideGlowOutOfCombat ~= true or playerInCombat == true
				data.entry = entry
				data.entryId = entryId
				data.fixedContext = fixedGroupDynamicRuntimeIndex and {
					dynamicLocalIndex = fixedGroupDynamicRuntimeIndex,
					dynamicCount = fixedGroupDynamicRuntimeIndex,
				} or nil
				data.hideOnCooldown = entryHideOnCooldown == true
				data.showOnCooldown = entryShowOnCooldown == true
				data.resolvedType = resolvedType
				data.spellId = spellId
				data.baseSpellId = baseSpellId
				data.effectiveSpellId = effectiveSpellId
				data.resolvedSpellId = resolvedSpellId
				data.variantGroupKind = variantGroup and variantGroup.kind or nil
				data.variantGroupKey = variantGroup and variantGroup.key or nil
				data.overlayGlow = overlayGlow
				data.overlayGlowColor = nil
				data.overlayGlowStyle = procGlowStyle
				data.overlayGlowInset = procGlowInset
				data.stateTextureShown = false
				data.stateTextureType = stateTextureType
				data.stateTextureValue = stateTextureValue
				data.stateTextureWidth = stateTextureWidth
				data.stateTextureHeight = stateTextureHeight
				data.stateTextureScale = stateTextureScale
				data.stateTextureAngle = stateTextureAngle
				data.stateTextureDouble = stateTextureDouble
				data.stateTextureMirror = stateTextureMirror
				data.stateTextureMirrorSecond = stateTextureMirrorSecond
				data.stateTextureMirrorVertical = stateTextureMirrorVertical
				data.stateTextureMirrorVerticalSecond = stateTextureMirrorVerticalSecond
				data.stateTextureSpacingX = stateTextureSpacingX
				data.stateTextureSpacingY = stateTextureSpacingY
				if resolvedType == "STANCE" and glowReady then
					data.overlayGlow = true
					data.overlayGlowColor = glowColor
					data.overlayGlowStyle = glowStyle
					data.overlayGlowInset = glowInset
				end
				if resolvedType == "CDM_AURA" and cdmAuraPandemicGlow and cdmAuraData and cdmAuraData.pandemicActive == true then
					data.overlayGlow = true
					data.overlayGlowColor = pandemicGlowColor
					data.overlayGlowStyle = pandemicGlowStyle
					data.overlayGlowInset = pandemicGlowInset
				elseif resolvedType == "CDM_AURA" and cdmAuraActiveGlow and cdmAuraData and cdmAuraData.active == true then
					data.overlayGlow = true
					data.overlayGlowColor = glowColor
					data.overlayGlowStyle = glowStyle
					data.overlayGlowInset = glowInset
				end
				if stateTextureType then
					if layoutEditActive then
						data.stateTextureShown = true
					elseif resolvedType == "SPELL" then
						data.stateTextureShown = procActive == true or entry.stateTextureShowWithoutProc == true
					elseif resolvedType == "CDM_AURA" then
						data.stateTextureShown = cdmAuraData and cdmAuraData.show == true or false
					end
				end
				data.powerInsufficient = powerInsufficient
				data.spellUnusable = spellUnusable
				data.rangeOverlay = rangeOverlay
				data.assistedSuggested = assistedSuggested == true
				data.noDesaturation = entryNoDesaturation
				data.cooldownDrawEdge = entryDrawEdge
				data.cooldownDrawBling = entryDrawBling
				data.cooldownDrawSwipe = entryDrawSwipe
				data.cooldownGcdDrawEdge = entryGcdDrawEdge
				data.cooldownGcdDrawBling = entryGcdDrawBling
				data.cooldownGcdDrawSwipe = entryGcdDrawSwipe
				data.glowReady = glowReady
				data.glowDuration = glowDuration
				data.readyGlowColor = glowColor
				data.readyGlowStyle = glowStyle
				data.readyGlowInset = glowInset
				data.readyGlowCheckPower = readyGlowCheckPower == true
				data.readyGlowResourceBlocked = readyGlowResourceBlocked == true
				data.spellReadyCondition = spellReadyCondition
				data.canTriggerReadyGlow = canTriggerReadyGlow
				data.soundReady = soundReady
				data.soundName = soundName
				data.previewSound = previewSound
				data.readyAt = runtime.readyAt[entryId]
				data.stanceActive = stanceActive == true
				data.stackCount = Helper.NormalizeDisplayCount(stackCount)
				data.itemCount = itemCount
				data.itemUses = itemUses
				data.emptyItem = emptyItem
				data.chargesInfo = chargesInfo
				data.chargeDurationObject = chargeDurationObject
				data.cooldownDurationObject = cooldownDurationObject
				data.customCooldownDurationActive = customCooldownState ~= nil
				data.customCooldownDurationKey = customCooldownState and customCooldownState.key or nil
				data.customCooldownDurationObject = customCooldownState and customCooldownState.durationObject or nil
				data.customCooldownStart = customCooldownState and customCooldownState.startTime or nil
				data.customCooldownDuration = customCooldownState and customCooldownState.duration or nil
				data.spellAuraOverlayActive = spellAuraOverlayData and spellAuraOverlayData.active == true or false
				data.spellAuraOverlayDurationObject = spellAuraOverlayData and spellAuraOverlayData.cooldownDurationObject or nil
				data.spellAuraOverlayLabel = spellAuraOverlayData and spellAuraOverlayData.buffName or nil
				self:ApplyActivationOverlayVisualState(data, entry)
				data.cooldownIgnoreGCD = resolvedType == "SPELL" and self:ShouldIgnoreEntryCooldownGCD(entryLayout, entry) or false
				data.cooldownStart = cooldownStart or 0
				data.cooldownDuration = cooldownDuration or 0
				data.cooldownEnabled = cooldownEnabled
				data.cooldownIsActive = cooldownIsActive
				data.cooldownRate = cooldownRate or 1
				data.cooldownGCD = cooldownGCD == true
				data.cdmAuraLabel = cdmAuraData and cdmAuraData.buffName or nil
				data.cdmAuraRawApplications = cdmAuraData and cdmAuraData.rawApplications or nil
				data.cdmAuraActive = cdmAuraData and cdmAuraData.active == true
				data.cdmAuraInactiveDesaturate = cdmAuraData and cdmAuraData.inactiveDesaturate == true
					or cdmAuraAlwaysShowMode == (CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE and CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE.DESATURATE or "DESATURATE")
				data.cdmAuraActiveDesaturate = cdmAuraData and cdmAuraData.activeDesaturate == true
					or cdmAuraAlwaysShowMode == (CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE and CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE.DESATURATE_ACTIVE or "DESATURATE_ACTIVE")
					or cdmAuraAlwaysShowMode == (CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE and CooldownPanels.CDM_AURA_ALWAYS_SHOW_MODE.HIDE_DESATURATE_ACTIVE or "HIDE_DESATURATE_ACTIVE")
				data.cdmAuraDurationObject = cdmAuraData and cdmAuraData.cooldownDurationObject or nil
				if fixedGroupCenterGrowth and fixedGroup then
					local centerList = fixedCenterGroupVisibleData[fixedGroup.id]
					if not centerList then
						centerList = {}
						fixedCenterGroupVisibleData[fixedGroup.id] = centerList
					end
					data.fixedContext = {
						dynamicLocalIndex = fixedGroupVisibleCounts[fixedGroup.id] or #centerList + 1,
					}
					centerList[#centerList + 1] = data
				end
			end
		end
	end

	if fixedLayout and fixedCenterGroupVisibleData then
		for groupId, centerList in pairs(fixedCenterGroupVisibleData) do
			local fixedGroup = fixedGroupById and fixedGroupById[groupId] or nil
			local totalCount = centerList and #centerList or 0
			if fixedGroup and totalCount > 0 then
				for localIndex = 1, totalCount do
					local centerData = centerList[localIndex]
					local placement = CooldownPanels._eqolFixedVisualCacheUtil.GetDynamicPlacement(fixedVisualCache, fixedGroup, localIndex, totalCount)
					local column = Helper.NormalizeSlotCoordinate(placement and placement.column)
					local row = Helper.NormalizeSlotCoordinate(placement and placement.row)
					local centerTargetIndex = column and row and fixedGridColumns > 0 and (((row - 1) * fixedGridColumns) + column) or nil
					if centerTargetIndex and centerTargetIndex <= fixedSlotCount then
						visible[centerTargetIndex] = centerData
						if visibleSlotsUsed and not visibleSlotsUsed[centerTargetIndex] then
							visibleSlotsUsed[centerTargetIndex] = true
							visibleSlotCount = visibleSlotCount + 1
							visibleSlotIndices[visibleSlotCount] = centerTargetIndex
						end
						centerData.fixedContext = centerData.fixedContext or {}
						centerData.fixedContext.dynamicLocalIndex = localIndex
						centerData.fixedContext.dynamicCount = totalCount
					end
				end
			end
		end
	end

	if fixedLayout then
		for i = 1, fixedSlotCount do
			if not visibleSlotsUsed[i] then visible[i] = nil end
		end
		for i = fixedSlotCount + 1, #visible do
			visible[i] = nil
		end
	else
		for i = visibleCount + 1, #visible do
			visible[i] = nil
		end
	end
	local count = fixedLayout and fixedSlotCount or visibleCount
	local layoutCount = count > 0 and count or 1
	local layoutShapeChanged = didLayoutShapeChange(runtime, layout, layoutCount)
	if layoutShapeChanged then self:ApplyLayout(panelId, layoutCount) end
	ensureIconCount(frame, count)
	if not layoutEditActive and frame.icons then
		-- Preview ghosts live on slot anchors, so clear them before sparse fixed updates can skip empty slots.
		for i = 1, #frame.icons do
			local icon = frame.icons[i]
			if icon then CooldownPanels:HideEditorGhostIcon(icon) end
		end
	end

	runtime.entryToIcon = runtime.entryToIcon or {}
	local entryToIcon = runtime.entryToIcon
	for key in pairs(entryToIcon) do
		entryToIcon[key] = nil
	end

	local sparseFixedRuntime = fixedLayout
		and not layoutEditActive
		and not lastLayoutEditActive
		and not layoutShapeChanged
		and runtime.initialized == true
		and previousOccupiedSlots ~= nil
		and previousOccupiedSlotIndices ~= nil
	local function updateRuntimeIconAtIndex(i)
		local data = visible[i]
		local icon = frame.icons[i]
		local slotColumn = fixedLayout and fixedGridColumns > 0 and (((i - 1) % fixedGridColumns) + 1) or (editGridColumns and (((i - 1) % editGridColumns) + 1) or nil)
		local slotRow = fixedLayout and fixedGridColumns > 0 and (math.floor((i - 1) / fixedGridColumns) + 1) or (editGridColumns and (math.floor((i - 1) / editGridColumns) + 1) or nil)
		if layoutEditActive or (icon and icon.layoutHandle and icon.layoutHandle._eqolLayoutConfigured == true) then
			self:ConfigureLayoutEditPanelIcon(panelId, icon, data and data.entryId or nil, slotColumn, slotRow)
		end
		if not data then
			cdp.RUNTIME.ClearIconSnapshot(icon)
			icon.entryId = nil
			icon._eqolRuntimeData = nil
			if not layoutEditActive and icon._eqolRuntimeEmpty == true then
				CooldownPanels:HideEditorGhostIcon(icon)
				icon:Hide()
			else
				CooldownPanels:ApplyEntryIconVisualLayout(icon, layout, nil)
				CooldownPanels:HideEditorGhostIcon(icon)
				clearPreviewCooldown(icon.cooldown)
				icon.cooldown:Clear()
				icon.cooldown._eqolPanelId = nil
				icon.cooldown._eqolEntryId = nil
				icon.cooldown._eqolCooldownIsGCD = nil
				icon.cooldown._eqolSoundReady = nil
				icon.cooldown._eqolSoundReadyIgnoreGCD = nil
				icon.cooldown._eqolSoundName = nil
				icon.cooldown._eqolGlowReady = nil
				icon.cooldown._eqolGlowDuration = nil
				icon.cooldown._eqolSpellId = nil
				icon.cooldown._eqolBaseSpellId = nil
				icon.cooldown._eqolEffectiveSpellId = nil
				icon.cooldown._eqolCustomCooldownKey = nil
				if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
				if icon.cooldown.Resume then icon.cooldown:Resume() end
				icon.count:Hide()
				icon.charges:Hide()
				if icon.rangeOverlay then icon.rangeOverlay:Hide() end
				if icon.keybind then icon.keybind:Hide() end
				if icon.staticText then icon.staticText:Hide() end
				if icon.stateTexture then icon.stateTexture:Hide() end
				if icon.stateTextureSecond then icon.stateTextureSecond:Hide() end
				CooldownPanels.HidePreviewGlowBorder(icon)
				if icon.previewBling then icon.previewBling:Hide() end
				if icon.previewSoundBorder then icon.previewSoundBorder:Hide() end
				icon._eqolLayoutDragRangePreview = nil
				icon.texture:SetDesaturated(false)
				icon.texture:SetAlpha(1)
				CooldownPanels.ApplyIconTooltip(icon, nil, false)
				setAssistedHighlight(icon, false)
				CooldownPanels.StopAllIconGlows(icon)
				if layoutEditActive and fixedLayout then
					icon:Show()
					icon:SetAlpha(1)
					icon._eqolPreviewCellColumn = slotColumn
					icon._eqolPreviewCellRow = slotRow
					icon.texture:SetTexture(Helper.PREVIEW_ICON)
					icon.texture:SetShown(false)
					icon.texture:SetAlpha(0)
				else
					icon:Hide()
				end
			end
			icon._eqolRuntimeEmpty = not layoutEditActive
		else
			icon._eqolRuntimeEmpty = nil
			icon:Show()
			icon.entryId = data.entryId
			icon._eqolRuntimeData = data
			icon._eqolPreviewCellColumn = slotColumn
			icon._eqolPreviewCellRow = slotRow
			if layoutEditActive then
				CooldownPanels.HidePreviewGlowBorder(icon)
				stopGlowKeys(icon, "EQOL_SIMPLE", "EQOL_OVERLAY", "EQOL_READY")
			end
			cdp.RUNTIME.EnsureIconSnapshot(icon)
			data._eqolRuntimePlacementDirty = cdp.RUNTIME.HasPlacementChange(
				icon,
				icon._eqolRuntimeSnapshot,
				data,
				fixedLayout and fixedLayoutCache or nil,
				fixedLayout and fixedGridColumns or nil,
				slotColumn,
				slotRow,
				layoutEditActive
			)
			data._eqolRuntimeBaseDecorDirty = data._eqolRuntimePlacementDirty or cdp.RUNTIME.HasBaseDecorChange(icon._eqolRuntimeSnapshot, data, showTooltips)
			local showGhostIcon = layoutEditActive and data.showGhostIcon == true
			local hideOnCooldown = data.hideOnCooldown == true
			local showOnCooldown = data.showOnCooldown == true
			if data._eqolRuntimePlacementDirty then
				CooldownPanels:ApplyEntryIconVisualLayout(icon, data.layout, data.entry, fixedLayout and panel or nil, fixedLayout and fixedGridColumns or nil, slotColumn, slotRow, data.fixedContext, fixedLayout and fixedLayoutCache or nil)
				cdp.RUNTIME.WritePlacementSnapshot(
					icon,
					icon._eqolRuntimeSnapshot,
					data,
					fixedLayout and fixedLayoutCache or nil,
					fixedLayout and fixedGridColumns or nil,
					slotColumn,
					slotRow,
					layoutEditActive
				)
			end
			if icon.editorGhostTexture and (layoutEditActive or icon.editorGhostTexture._eqolGhostShown == true) then CooldownPanels:HideEditorGhostIcon(icon) end
			if showOnCooldown then
				icon:SetAlpha(0)
			elseif not hideOnCooldown then
				icon:SetAlpha(1)
			end
			clearPreviewCooldown(icon.cooldown)
			entryToIcon[data.entryId] = icon
			data._eqolRuntimeTextureDirty = cdp.RUNTIME.HasTextureChange(icon._eqolRuntimeSnapshot, data)
			if data._eqolRuntimeTextureDirty then
				icon.texture:SetTexture(data.icon or Helper.PREVIEW_ICON)
				cdp.RUNTIME.WriteTextureSnapshot(icon._eqolRuntimeSnapshot, data)
			end
			icon.texture:SetAlpha(1)
			if data._eqolRuntimeBaseDecorDirty then
				icon.texture:SetShown(data.showIconTexture ~= false)
				CooldownPanels.ApplyIconTooltip(icon, data.entry, showTooltips)
			end
			local cooldownUsesAuraDisplay = data.resolvedType == "CDM_AURA" or data.customCooldownDurationActive == true or data.spellAuraOverlayActive == true
			if data._eqolRuntimePlacementDirty or cdp.RUNTIME.HasCooldownWidgetConfigChange(icon._eqolRuntimeSnapshot, data, cooldownUsesAuraDisplay) then
				icon.cooldown:SetHideCountdownNumbers(not data.showCooldownText)
				if icon.cooldown.SetReverse then icon.cooldown:SetReverse(data.cooldownReverse == true) end
				if icon.cooldown.SetUseAuraDisplayTime then icon.cooldown:SetUseAuraDisplayTime(cooldownUsesAuraDisplay) end
				cdp.ENTRY.ApplyCooldownSwipeVisual(icon, data)
				cdp.RUNTIME.WriteCooldownWidgetConfigSnapshot(icon._eqolRuntimeSnapshot, data, cooldownUsesAuraDisplay)
			end
			if data._eqolRuntimePlacementDirty or cdp.RUNTIME.HasStackTextStyleChange(icon._eqolRuntimeSnapshot, data, defaultCountFontPath, defaultCountFontSize, defaultCountFontStyle) then
				CooldownPanels:ApplyEntryStackTextStyle(icon, data.layout, data.entry, defaultCountFontPath, defaultCountFontSize, defaultCountFontStyle)
				cdp.RUNTIME.WriteStackTextStyleSnapshot(icon._eqolRuntimeSnapshot, data, defaultCountFontPath, defaultCountFontSize, defaultCountFontStyle)
			end
			if data._eqolRuntimePlacementDirty or cdp.RUNTIME.HasChargesTextStyleChange(icon._eqolRuntimeSnapshot, data, defaultChargesFontPath, defaultChargesFontSize, defaultChargesFontStyle) then
				CooldownPanels:ApplyEntryChargesTextStyle(icon, data.layout, data.entry, defaultChargesFontPath, defaultChargesFontSize, defaultChargesFontStyle)
				cdp.RUNTIME.WriteChargesTextStyleSnapshot(icon._eqolRuntimeSnapshot, data, defaultChargesFontPath, defaultChargesFontSize, defaultChargesFontStyle)
			end

			-- Context for OnCooldownDone (sound/glow) - keep this in sync every update.
			updateCooldownDoneContext(icon.cooldown, panelId, data.entryId, data)
			if icon.cooldown.Resume then icon.cooldown:Resume() end
			if icon.cooldown.SetAlpha then icon.cooldown:SetAlpha(1) end
			CooldownPanels.HidePreviewGlowBorder(icon)
			if icon.previewBling then icon.previewBling:Hide() end
			if icon.previewSoundBorder then icon.previewSoundBorder:Hide() end
			icon._eqolLayoutDragRangePreview = nil

			local cooldownStart = data.cooldownStart or 0
			local cooldownDuration = data.cooldownDuration or 0
			local cooldownRate = data.cooldownRate or 1
			local chargeDurationObject = data.chargeDurationObject
			local cooldownDurationObject = data.cooldownDurationObject
			local cooldownEnabledOk = isSafeNotFalse(data.cooldownEnabled)
			if data.resolvedType == "ITEM" or data.resolvedType == "SLOT" then cooldownEnabledOk = data.cooldownEnabled ~= false and data.cooldownEnabled ~= 0 end
			local spellCooldownActive = CooldownPanels.IsSpellCooldownInfoActive(data.cooldownIsActive, data.cooldownEnabled, cooldownStart, cooldownDuration)
			local durationActive = cooldownDurationObject ~= nil
			if data.resolvedType == "SPELL" then durationActive = durationActive and spellCooldownActive end
			local cdmAuraActive = data.cdmAuraActive == true
			local cdmAuraDurationObject = data.cdmAuraDurationObject
			local cdmAuraDurationActive = cdmAuraDurationObject ~= nil
			local cooldownActive = data.showCooldown and (durationActive or spellCooldownActive)
			local usingCooldown = false
			local desaturate = false
			local hidden = false
			local chargeCooldownHasAvailableCharge = false
			local chargeInfoActive = false
			local entryNoDesaturation = data.noDesaturation == true and not (data.resolvedType == "ITEM" and data.emptyItem == true)
			local entryDrawEdge = data.cooldownDrawEdge ~= false
			local entryDrawBling = data.cooldownDrawBling ~= false
			local entryDrawSwipe = data.cooldownDrawSwipe ~= false
			local entryGcdDrawEdge = data.cooldownGcdDrawEdge == true
			local entryGcdDrawBling = data.cooldownGcdDrawBling == true
			local entryGcdDrawSwipe = data.cooldownGcdDrawSwipe == true

			local chargesAlpha = 1
			if data.showCharges and data.chargesInfo and data.chargesInfo.maxCharges ~= nil then
				if data.chargesInfo.currentCharges ~= nil then
					icon.charges:SetText(data.chargesInfo.currentCharges)
					if data.chargesHideWhenZero == true then chargesAlpha = data.chargesInfo.currentCharges end
					icon.charges:SetAlpha(chargesAlpha)
					icon.charges:Show()
				else
					icon.charges:SetAlpha(1)
					icon.charges:Hide()
				end
				if data.showCooldown then
					chargeInfoActive = CooldownPanels.IsChargeInfoActive(data.chargesInfo)
					if chargeInfoActive then
						cooldownStart = data.chargesInfo.cooldownStartTime or cooldownStart
						cooldownDuration = data.chargesInfo.cooldownDuration or cooldownDuration
						cooldownRate = data.chargesInfo.chargeModRate or cooldownRate
						cooldownActive = true
						usingCooldown = true
					end
				end
				if usingCooldown then
					if isSafeNumber(data.chargesInfo.currentCharges) then
						chargeCooldownHasAvailableCharge = data.chargesInfo.currentCharges > 0 and isSafeLessThan(data.chargesInfo.currentCharges, data.chargesInfo.maxCharges)
						desaturate = data.chargesInfo.currentCharges == 0
						if hideOnCooldown or showOnCooldown then hidden = desaturate end
					else
						-- local CCD = C_Spell.GetSpellChargeDuration(data.entry.spellID)
						-- local SCD = C_Spell.GetSpellCooldownDuration(data.entry.spellID)
						-- if CCD and SCD then
						-- 	-- desaturate = true
						-- 	-- icon.texture:SetDesaturation(SCD:GetRemainingDuration())
						-- else
						-- 	-- desaturate = false
						-- 	-- icon.texture:SetDesaturated(false)
						-- end
					end
				end
			else
				icon.charges:SetAlpha(1)
				icon.charges:Hide()
			end

			if data.showItemUses then
				if data.itemUses ~= nil then
					icon.charges:SetText(data.itemUses)
					icon.charges:SetAlpha(1)
					icon.charges:Show()
				else
					icon.charges:SetAlpha(1)
					icon.charges:Hide()
				end
			end
			if layoutEditActive and not (icon.charges and icon.charges.IsShown and icon.charges:IsShown()) then
				if data.resolvedType == "SPELL" and data.showCharges then
					icon.charges:SetText("2")
					icon.charges:SetAlpha(1)
					icon.charges:Show()
				elseif data.resolvedType == "ITEM" and data.showItemUses then
					icon.charges:SetText("2")
					icon.charges:SetAlpha(1)
					icon.charges:Show()
				end
			end

			if data.customCooldownDurationActive == true then
				usingCooldown = false
				chargeCooldownHasAvailableCharge = false
				chargeInfoActive = false
				cooldownStart = data.customCooldownStart or cooldownStart
				cooldownDuration = data.customCooldownDuration or cooldownDuration
				cooldownRate = 1
				cooldownEnabledOk = true
				spellCooldownActive = true
				durationActive = cooldownDurationObject ~= nil
				cooldownActive = data.showCooldown == true
				desaturate = false
				if hideOnCooldown or showOnCooldown then hidden = true end
			end
			if data.spellAuraOverlayActive == true then
				usingCooldown = false
				chargeCooldownHasAvailableCharge = false
				chargeInfoActive = false
				cooldownEnabledOk = true
				spellCooldownActive = true
				durationActive = cooldownDurationObject ~= nil
				cooldownActive = data.showCooldown == true
				desaturate = false
				if hideOnCooldown or showOnCooldown then hidden = true end
			end

			if data.emptyItem and data.customCooldownDurationActive ~= true then desaturate = true end
			if data.resolvedType == "CDM_AURA" and data.cdmAuraInactiveDesaturate == true and not cdmAuraActive and not cdmAuraDurationActive then desaturate = true end
			if data.resolvedType == "CDM_AURA" and data.cdmAuraActiveDesaturate == true and cdmAuraActive then desaturate = true end

			if not isSafeNumber(cooldownRate) then cooldownRate = 1 end
			CooldownPanels.SetIconDesaturatedRuntime(icon.texture, desaturate, entryNoDesaturation)
			if hideOnCooldown then
				icon:SetAlphaFromBoolean(hidden, 0, 1)
			elseif showOnCooldown then
				icon:SetAlphaFromBoolean(hidden, 1, 0)
			end
			if data.showCooldown then
				if usingCooldown then
					setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)
					if chargeCooldownHasAvailableCharge then
						-- Match Blizzard action-button behavior: charge recharge takes precedence while at least one charge remains.
						if data.showChargesCooldown then
							if chargeDurationObject and icon.cooldown.SetCooldownFromDurationObject then
								icon.cooldown:Clear()
								icon.cooldown:SetCooldownFromDurationObject(chargeDurationObject)
							elseif isSafeNumber(cooldownStart) and isSafeNumber(cooldownDuration) then
								icon.cooldown:Clear()
								icon.cooldown:SetCooldown(cooldownStart, cooldownDuration, cooldownRate)
							else
								icon.cooldown:Clear()
							end
							if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", onCooldownDone) end
						else
							icon.cooldown:Clear()
							if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
						end
					else
						if data.showChargesCooldown then
							if chargeDurationObject and icon.cooldown.SetCooldownFromDurationObject then
								icon.cooldown:Clear()
								icon.cooldown:SetCooldownFromDurationObject(chargeDurationObject)
							elseif isSafeNumber(cooldownStart) and isSafeNumber(cooldownDuration) then
								icon.cooldown:Clear()
								icon.cooldown:SetCooldown(cooldownStart, cooldownDuration, cooldownRate)
							else
								icon.cooldown:Clear()
							end
							if not data.cooldownGCD then
								if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", onCooldownDone) end
							end
						else
							icon.cooldown:Clear()
							if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
						end

						-- local SCD = effectiveId and C_Spell.GetSpellCooldownDuration(effectiveId)
						-- only when you have zero charges SCD will be true CCD is always true when one charge is missing
						if cooldownDurationObject then
							if data.cooldownGCD then
							-- icon.texture:SetDesaturation(0)
							-- desaturate = false
							-- setCooldownDrawState(icon.cooldown, entryGcdDrawEdge, entryGcdDrawBling, entryGcdDrawSwipe)
							else
								if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", onCooldownDone) end
								setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)
								CooldownPanels.SetIconDesaturationRuntime(icon.texture, cooldownDurationObject:EvaluateRemainingDuration(curveDesat), entryNoDesaturation)
								if hideOnCooldown then
									icon:SetAlpha(cooldownDurationObject:EvaluateRemainingDuration(curveAlpha))
								elseif showOnCooldown then
									icon:SetAlpha(cooldownDurationObject:EvaluateRemainingDuration(curveDesat))
								end
								if data.showChargesCooldown and chargeDurationObject then
									icon.cooldown:SetCooldownFromDurationObject(chargeDurationObject)
								else
									icon.cooldown:SetCooldownFromDurationObject(cooldownDurationObject)
								end
							end
						end
					end
				elseif durationActive then
					icon.cooldown:SetCooldownFromDurationObject(cooldownDurationObject)
					if data.cooldownGCD then
						CooldownPanels.SetIconDesaturationRuntime(icon.texture, 0, entryNoDesaturation)
						if hideOnCooldown then
							icon:SetAlpha(1)
						elseif showOnCooldown then
							icon:SetAlpha(0)
						end
						desaturate = false
						hidden = false
						setCooldownDrawState(icon.cooldown, entryGcdDrawEdge, entryGcdDrawBling, entryGcdDrawSwipe)
					else
						setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)

						local auraDisplayDurationActive = data.customCooldownDurationActive == true or data.spellAuraOverlayActive == true
						local desat = auraDisplayDurationActive and 0 or cooldownDurationObject:EvaluateRemainingDuration(curveDesat)
						CooldownPanels.SetIconDesaturationRuntime(icon.texture, desat, entryNoDesaturation)
						if hideOnCooldown then
							icon:SetAlpha(cooldownDurationObject:EvaluateRemainingDuration(curveAlpha))
						elseif showOnCooldown then
							icon:SetAlpha(auraDisplayDurationActive and 1 or cooldownDurationObject:EvaluateRemainingDuration(curveDesat))
						end
					end
					if data.cooldownGCD then
					else
						if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", onCooldownDone) end
					end
				elseif cdmAuraActive then
					setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)
					icon.cooldown:Clear()
					if cdmAuraDurationActive and icon.cooldown.SetCooldownFromDurationObject then
						icon.cooldown:SetCooldownFromDurationObject(cdmAuraDurationObject)
						if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", onCooldownDone) end
					else
						if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
					end
					CooldownPanels.SetIconDesaturationRuntime(icon.texture, 0, entryNoDesaturation)
					desaturate = false
					if hideOnCooldown then
						icon:SetAlpha(0)
						hidden = true
					elseif showOnCooldown then
						icon:SetAlpha(1)
					end
				elseif cooldownActive then
					icon.cooldown:SetCooldown(cooldownStart, cooldownDuration, cooldownRate)
					desaturate = true
					CooldownPanels.SetIconDesaturatedRuntime(icon.texture, desaturate, entryNoDesaturation)
					if hideOnCooldown then
						icon:SetAlpha(0)
					elseif showOnCooldown then
						icon:SetAlpha(1)
					end
					setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)
					if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", onCooldownDone) end
				elseif data.resolvedType == "CDM_AURA" and cdmAuraActive then
					setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)
					icon.cooldown:Clear()
					CooldownPanels.SetIconDesaturationRuntime(icon.texture, 0, entryNoDesaturation)
					desaturate = false
					if hideOnCooldown then
						icon:SetAlpha(0)
						hidden = true
					elseif showOnCooldown then
						icon:SetAlpha(1)
					end
					if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
				else
					setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)
					icon.cooldown:Clear()
					if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
				end
			else
				setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)
				icon.cooldown:Clear()
				if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
			end
			if layoutEditActive and data.showCooldown and data.showCooldownText then
				hidden = false
				if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
				setExampleCooldown(icon.cooldown)
				icon:SetAlpha(1)
			end
			if data.resolvedType == "CDM_AURA" and data.cdmAuraActiveDesaturate == true and cdmAuraActive then
				desaturate = true
				CooldownPanels.SetIconDesaturatedRuntime(icon.texture, true, entryNoDesaturation)
			end
			local cooldownDefaultFontPath, cooldownDefaultFontSize, cooldownDefaultFontStyle = cdp.RUNTIME.EnsureCooldownTextDefaults(icon)
			if
				data._eqolRuntimePlacementDirty
				or cdp.RUNTIME.HasCooldownTextStyleChange(icon._eqolRuntimeSnapshot, data, cooldownDefaultFontPath, cooldownDefaultFontSize, cooldownDefaultFontStyle)
			then
				CooldownPanels:ApplyEntryCooldownTextStyle(icon, data.layout, data.entry)
				cdp.RUNTIME.WriteCooldownTextStyleSnapshot(icon._eqolRuntimeSnapshot, data, cooldownDefaultFontPath, cooldownDefaultFontSize, cooldownDefaultFontStyle)
			end
			if data.spellUnusable then
				icon.texture:SetVertexColor(unusableTintR or 0.6, unusableTintG or 0.6, unusableTintB or 0.6)
			elseif data.powerInsufficient then
				icon.texture:SetVertexColor(powerTintR or 0.5, powerTintG or 0.5, powerTintB or 1)
			elseif
				((data.resolvedType == "ITEM" or data.resolvedType == "SLOT") and not cooldownEnabledOk and isSafeGreaterThan(cooldownDuration, 0))
				or (data.resolvedType == "SPELL" and data.cooldownEnabled == false and data.cooldownIsActive == false and data.cooldownGCD ~= true)
			then
				icon.texture:SetVertexColor(0.4, 0.4, 0.4)
			else
				icon.texture:SetVertexColor(1, 1, 1)
			end

			if data.showItemCount and data.itemCount ~= nil then
				icon.count:SetText(data.itemCount)
				icon.count:Show()
			elseif data.showStacks and data.stackCount then
				icon.count:SetText(data.stackCount)
				icon.count:Show()
			else
				icon.count:Hide()
			end
			if layoutEditActive and not (icon.count and icon.count.IsShown and icon.count:IsShown()) then
				if data.resolvedType == "ITEM" and data.showItemCount then
					icon.count:SetText("20")
					icon.count:Show()
				elseif data.showStacks then
					icon.count:SetText(data.resolvedType == "CDM_AURA" and "2" or "3")
					icon.count:Show()
				end
			end
			if data._eqolRuntimeBaseDecorDirty and icon.keybind then
				if data.showKeybinds and data.keybindText then
					icon.keybind:SetText(data.keybindText)
					icon.keybind:Show()
				else
					icon.keybind:Hide()
				end
				cdp.RUNTIME.WriteBaseDecorSnapshot(icon._eqolRuntimeSnapshot, data, showTooltips)
			end
			local staticTextCooldown = false
			if data.entry and data.entry.staticTextShowOnCooldown == true then
				staticTextCooldown = layoutEditActive == true or data.stanceActive == true or cdmAuraActive or durationActive or (cooldownEnabledOk and isCooldownActive(cooldownStart, cooldownDuration))
			end
			if data._eqolRuntimeBaseDecorDirty and not icon.keybind then cdp.RUNTIME.WriteBaseDecorSnapshot(icon._eqolRuntimeSnapshot, data, showTooltips) end
			if data._eqolRuntimePlacementDirty or cdp.RUNTIME.HasStaticTextChange(icon._eqolRuntimeSnapshot, data, staticFontPath, staticFontSize, staticFontStyle, staticTextCooldown) then
				applyStaticText(icon, data.layout, data.entry, staticFontPath, staticFontSize, staticFontStyle, staticTextCooldown)
				cdp.RUNTIME.WriteStaticTextSnapshot(icon._eqolRuntimeSnapshot, data, staticFontPath, staticFontSize, staticFontStyle, staticTextCooldown)
			end
			if durationActive and showOnCooldown and data.entry.staticTextShowOnCooldown == true and staticTextCooldown then
				if data.entry.spellID then
					icon.staticText:SetAlpha(cooldownDurationObject:EvaluateRemainingDuration(Helper.FakeCurve))
					icon.cooldown:SetAlpha(cooldownDurationObject:EvaluateRemainingDuration(Helper.FakeCurve))
				end
			end

			data.stateTextureDesaturated = data.stateTextureShown == true and desaturate == true and entryNoDesaturation ~= true
			if data._eqolRuntimePlacementDirty or cdp.RUNTIME.HasStateTextureChange(icon._eqolRuntimeSnapshot, data) then
				applyStateTexture(icon, data)
				cdp.RUNTIME.WriteStateTextureSnapshot(icon._eqolRuntimeSnapshot, data)
			end
			if layoutEditActive and icon.previewSoundBorder and data.previewSound then icon.previewSoundBorder:Show() end
			if icon.rangeOverlay then
				if data.rangeOverlay then
					icon.rangeOverlay:SetColorTexture(rangeOverlayR or 1, rangeOverlayG or 0.1, rangeOverlayB or 0.1, rangeOverlayA or 0.35)
					icon.rangeOverlay:Show()
				else
					icon.rangeOverlay:Hide()
				end
			end
			setAssistedHighlight(icon, data.assistedSuggested == true)

			local readyGlowCooldownRunning
			local useSecretReadyGlow = data.resolvedType == "SPELL" and data.spellReadyCondition ~= nil and data.glowReady and data.showCooldown and data.canTriggerReadyGlow
			local secretReadyGlowAllowed = useSecretReadyGlow and data.readyGlowResourceBlocked ~= true
			if useSecretReadyGlow then
				if data.readyAt or (readyGlowPrimed and readyGlowPrimed[data.entryId]) then
					CooldownPanels.ClearReadyGlowEntryState(panelId, data.entryId, true)
					data.readyAt = nil
				end
			elseif data.canTriggerReadyGlow then
				readyGlowCooldownRunning = durationActive or usingCooldown or (cooldownEnabledOk and isCooldownActive(cooldownStart, cooldownDuration))
				if readyGlowCooldownRunning then
					CooldownPanels.ClearReadyGlowEntryState(panelId, data.entryId, true)
					data.readyAt = nil
				end
			end
			if not useSecretReadyGlow and data.glowReady and data.showCooldown and data.canTriggerReadyGlow then
				if data.readyAt then
					if readyGlowPrimed then readyGlowPrimed[data.entryId] = true end
				elseif not readyGlowCooldownRunning and not data.cooldownGCD and readyGlowPrimed and not readyGlowPrimed[data.entryId] then
					triggerReadyGlow(panelId, data.entryId, data.glowDuration)
					readyGlowPrimed[data.entryId] = true
					data.readyAt = runtime.readyAt[data.entryId]
				end
			end

			local overlayGlow = data.overlayGlow == true
			local overlayGlowColor = overlayGlow and data.overlayGlowColor or nil
			local simpleGlowEnabled = overlayGlow
			local simpleGlowColor = overlayGlowColor
			local simpleGlowStyle = data.overlayGlowStyle or data.readyGlowStyle
			local simpleGlowInset = data.overlayGlowInset or data.readyGlowInset
			if data.glowReady and not useSecretReadyGlow then
				local ready = data.readyAt ~= nil
				if ready and data.readyGlowCheckPower == true and data.readyGlowResourceBlocked == true then ready = false end

				simpleGlowEnabled = overlayGlow or ready
				simpleGlowColor = ready and data.readyGlowColor or overlayGlowColor
				simpleGlowStyle = ready and data.readyGlowStyle or (data.overlayGlowStyle or data.readyGlowStyle)
				simpleGlowInset = ready and data.readyGlowInset or (data.overlayGlowInset or data.readyGlowInset)
			end
			if data.activationOverlayActive == true and data.activationOverlayGlow == true then
				simpleGlowEnabled = true
				simpleGlowColor = data.readyGlowColor or overlayGlowColor
				simpleGlowStyle = data.readyGlowStyle or simpleGlowStyle
				simpleGlowInset = data.readyGlowInset or simpleGlowInset
			end
			if layoutEditActive and data.entry and data.entry.type ~= "MACRO" and (data.entry.glowReady == true or data.entry.pandemicGlow == true or data.entry.activationOverlayGlow == true) then
				simpleGlowEnabled = true
				simpleGlowColor = (
					data.entry.type == "CDM_AURA"
					and data.entry.pandemicGlow == true
					and data.entry.glowReady ~= true
					and CooldownPanels:ResolveEntryPandemicGlowColor(data.layout, data.entry)
				)
					or data.readyGlowColor
					or overlayGlowColor
				simpleGlowStyle = (
					data.entry.type == "CDM_AURA"
					and data.entry.pandemicGlow == true
					and data.entry.glowReady ~= true
					and select(2, CooldownPanels:ResolveEntryPandemicGlowVisual(data.layout, data.entry))
				) or data.readyGlowStyle
				simpleGlowInset = (
					data.entry.type == "CDM_AURA"
					and data.entry.pandemicGlow == true
					and data.entry.glowReady ~= true
					and select(3, CooldownPanels:ResolveEntryPandemicGlowVisual(data.layout, data.entry))
				) or data.readyGlowInset
			end
			if layoutEditActive then
				stopGlowKeys(icon, "EQOL_SIMPLE", "EQOL_OVERLAY", "EQOL_READY")
				setPreviewGlow(icon, simpleGlowEnabled, simpleGlowColor, simpleGlowStyle, simpleGlowInset)
			else
				setPreviewGlow(icon, false)
				if data.liveGlowAllowed == false then
					setGlow(icon, false, nil, "EQOL_SIMPLE")
					setGlow(icon, false, nil, "EQOL_OVERLAY")
					setGlow(icon, false, nil, "EQOL_READY")
				elseif useSecretReadyGlow then
					setGlow(icon, false, nil, "EQOL_SIMPLE")
					if secretReadyGlowAllowed then
						setGlow(
							icon,
							overlayGlow,
							overlayGlowColor,
							"EQOL_OVERLAY",
							data.spellReadyCondition,
							0,
							1,
							data.overlayGlowStyle or data.readyGlowStyle,
							data.overlayGlowInset or data.readyGlowInset
						)
						setGlow(icon, true, data.readyGlowColor, "EQOL_READY", data.spellReadyCondition, 1, 0, data.readyGlowStyle, data.readyGlowInset)
					else
						setGlow(icon, overlayGlow, overlayGlowColor, "EQOL_OVERLAY", nil, nil, nil, data.overlayGlowStyle or data.readyGlowStyle, data.overlayGlowInset or data.readyGlowInset)
						setGlow(icon, false, nil, "EQOL_READY")
					end
				else
					setGlow(icon, false, nil, "EQOL_OVERLAY")
					setGlow(icon, false, nil, "EQOL_READY")
					setGlow(icon, simpleGlowEnabled, simpleGlowColor, "EQOL_SIMPLE", nil, nil, nil, simpleGlowStyle, simpleGlowInset)
				end
			end
			if showGhostIcon then
				icon:SetAlpha(1)
				CooldownPanels:ApplyEditorGhostIcon(icon)
			end
		end
	end

	if sparseFixedRuntime and visibleSlotIndices then
		for listIndex = 1, visibleSlotCount do
			updateRuntimeIconAtIndex(visibleSlotIndices[listIndex])
		end
		if previousOccupiedSlotIndices and previousOccupiedSlots then
			for listIndex = 1, #previousOccupiedSlotIndices do
				local slotIndex = previousOccupiedSlotIndices[listIndex]
				if slotIndex and not visibleSlotsUsed[slotIndex] and previousOccupiedSlots[slotIndex] then updateRuntimeIconAtIndex(slotIndex) end
			end
		end
	else
		for i = 1, count do
			updateRuntimeIconAtIndex(i)
		end
	end

	for i = count + 1, #frame.icons do
		local icon = frame.icons[i]
		if icon then
			cdp.RUNTIME.ClearIconSnapshot(icon)
			self:ConfigureLayoutEditPanelIcon(panelId, icon, nil, nil, nil)
			icon.entryId = nil
			clearPreviewCooldown(icon.cooldown)
			icon.cooldown:Clear()
			icon.cooldown._eqolSoundReady = nil
			icon.cooldown._eqolSoundReadyIgnoreGCD = nil
			icon.cooldown._eqolSoundName = nil
			if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
			if icon.cooldown.Resume then icon.cooldown:Resume() end
			icon.count:Hide()
			icon.charges:Hide()
			if icon.rangeOverlay then icon.rangeOverlay:Hide() end
			if icon.keybind then icon.keybind:Hide() end
			if icon.staticText then icon.staticText:Hide() end
			if icon.stateTexture then icon.stateTexture:Hide() end
			if icon.stateTextureSecond then icon.stateTextureSecond:Hide() end
			CooldownPanels:HideEditorGhostIcon(icon)
			CooldownPanels.HidePreviewGlowBorder(icon)
			if icon.previewBling then icon.previewBling:Hide() end
			if icon.previewSoundBorder then icon.previewSoundBorder:Hide() end
			icon._eqolLayoutDragRangePreview = nil
			icon.texture:SetDesaturated(false)
			icon.texture:SetAlpha(1)
			setAssistedHighlight(icon, false)
			CooldownPanels.StopAllIconGlows(icon)
		end
	end

	if fixedLayout then
		local nextScratchOccupiedSlots = previousOccupiedSlots or {}
		local nextScratchOccupiedSlotIndices = previousOccupiedSlotIndices or {}
		for i = visibleSlotCount + 1, #(visibleSlotIndices or {}) do
			visibleSlotIndices[i] = nil
		end
		runtime._eqolScratchOccupiedSlots = nextScratchOccupiedSlots
		runtime._eqolScratchOccupiedSlotIndices = nextScratchOccupiedSlotIndices
		runtime._eqolOccupiedSlots = visibleSlotsUsed
		runtime._eqolOccupiedSlotIndices = visibleSlotIndices
	end
	runtime._eqolLastLayoutEditActive = layoutEditActive == true
	runtime.visibleCount = visibleCount
	runtime.initialized = true
end

function CooldownPanels:ApplyPanelPosition(panelId)
	local panel = self:GetPanel(panelId)
	if not panel then return end
	local runtime = getRuntime(panelId)
	local frame = runtime.frame
	if not frame then return end
	local anchor = ensurePanelAnchor(panel)
	local panelKey = normalizeId(panelId)
	if panelKey then CooldownPanels.ValidateRelativeFrameChoice(panel, panelKey, runtime) end
	local point = Helper.NormalizeAnchor(anchor and anchor.point, panel.point or "CENTER")
	local relativePoint = Helper.NormalizeAnchor(anchor and anchor.relativePoint, point)
	local x = tonumber(anchor and anchor.x) or 0
	local y = tonumber(anchor and anchor.y) or 0
	local relativeFrame = resolveAnchorFrame(anchor)
	if relativeFrame == frame then
		anchor.relativeFrame = "UIParent"
		CooldownPanels.MarkRelativeFrameEntriesDirty()
		relativeFrame = UIParent
	end
	if
		runtime._eqolAnchorAppliedFrame == frame
		and runtime._eqolAnchorPoint == point
		and runtime._eqolAnchorRelativePoint == relativePoint
		and runtime._eqolAnchorRelativeFrame == relativeFrame
		and runtime._eqolAnchorX == x
		and runtime._eqolAnchorY == y
	then
		return
	end
	runtime._eqolAnchorAppliedFrame = frame
	runtime._eqolAnchorPoint = point
	runtime._eqolAnchorRelativePoint = relativePoint
	runtime._eqolAnchorRelativeFrame = relativeFrame
	runtime._eqolAnchorX = x
	runtime._eqolAnchorY = y
	frame:ClearAllPoints()
	frame:SetPoint(point, relativeFrame, relativePoint, x, y)

	local mythicPlus = addon and addon.MythicPlus
	local reapplyTrackerAnchorsForTarget = mythicPlus and mythicPlus.functions and mythicPlus.functions.ReapplyTrackerAnchorsForTarget
	if reapplyTrackerAnchorsForTarget and frame.GetName then reapplyTrackerAnchorsForTarget(frame:GetName()) end
end

function CooldownPanels:HandlePositionChanged(panelId, data)
	local panel = self:GetPanel(panelId)
	if not panel or type(data) ~= "table" then return end
	local runtime = getRuntime(panelId)
	local anchor = ensurePanelAnchor(panel)
	if not anchor or not anchorUsesUIParent(anchor) then return end
	anchor.point = data.point or anchor.point or "CENTER"
	anchor.relativePoint = data.relativePoint or anchor.relativePoint or anchor.point
	if data.x ~= nil then anchor.x = data.x end
	if data.y ~= nil then anchor.y = data.y end
	panel.point = anchor.point or panel.point or "CENTER"
	panel.x = anchor.x or panel.x or 0
	panel.y = anchor.y or panel.y or 0
end

local function playerHasVehicleUI()
	if UnitHasVehicleUI then return UnitHasVehicleUI("player") == true end
	if UnitInVehicle then return UnitInVehicle("player") == true end
	return false
end

function CooldownPanels:BuildVisibilityDriverSignature(layout)
	local source = layout and layout.visibility
	local signature = 0
	local weight = 1
	if layout and layout.hideInPetBattle == true then signature = signature + weight end
	weight = weight * 2
	if layout and layout.hideInVehicle == true then signature = signature + weight end
	weight = weight * 2
	if addon.variables and addon.variables.unitClass == "DRUID" then signature = signature + weight end
	weight = weight * 2
	if addon.functions and addon.functions.GetDruidTravelStanceIndexes then signature = signature + weight end
	weight = weight * 2
	if addon.functions and addon.functions.BuildUnitFrameDriverExpression then signature = signature + weight end
	weight = weight * 2
	if addon.functions and addon.functions.VisibilityConfigUsesManualEvaluation then signature = signature + weight end
	weight = weight * 2
	if type(source) == "table" then
		signature = signature + weight
		weight = weight * 2
		if source.MOUSEOVER then signature = signature + weight end
		weight = weight * 2
		if source.ALWAYS_IN_COMBAT then signature = signature + weight end
		weight = weight * 2
		if source.ALWAYS_OUT_OF_COMBAT then signature = signature + weight end
		weight = weight * 2
		if source.PLAYER_CASTING then signature = signature + weight end
		weight = weight * 2
		if source.PLAYER_MOUNTED then signature = signature + weight end
		weight = weight * 2
		if source.PLAYER_NOT_MOUNTED then signature = signature + weight end
		weight = weight * 2
		if source.PLAYER_HAS_TARGET then signature = signature + weight end
		weight = weight * 2
		if source.PLAYER_IN_GROUP then signature = signature + weight end
		weight = weight * 2
		if source.SKYRIDING_ACTIVE then signature = signature + weight end
		weight = weight * 2
		if source.SKYRIDING_INACTIVE then signature = signature + weight end
		weight = weight * 2
		if source.FLYING_ACTIVE then signature = signature + weight end
		weight = weight * 2
		if source.FLYING_INACTIVE then signature = signature + weight end
		weight = weight * 2
		if source.ALWAYS_HIDDEN then signature = signature + weight end
		return signature, nil
	else
		return signature, source
	end
end

local function isPetBattleActive() return C_PetBattles and C_PetBattles.IsInBattle and C_PetBattles.IsInBattle() == true end
local function isClientSceneActive()
	local runtime = CooldownPanels.runtime
	return runtime and runtime.clientSceneActive == true
end

function CooldownPanels:EnsureVisibilityDriverWatcher()
	self.runtime = self.runtime or {}
	if self.runtime.visibilityDriverWatcher then return end
	local watcher = CreateFrame("Frame")
	watcher:RegisterEvent("PLAYER_REGEN_ENABLED")
	watcher:SetScript("OnEvent", function()
		local runtime = CooldownPanels.runtime
		local pending = runtime and runtime.pendingVisibilityDriverUpdates
		if not pending then return end
		runtime.pendingVisibilityDriverUpdates = nil
		for frame, expr in pairs(pending) do
			if frame then
				local desired = expr
				if desired == false then desired = nil end
				CooldownPanels:ApplyVisibilityDriverToFrame(frame, desired)
				local panelId = frame.panelId
				if panelId and CooldownPanels.GetPanel and CooldownPanels:GetPanel(panelId) then CooldownPanels:UpdateVisibility(panelId) end
			end
		end
	end)
	self.runtime.visibilityDriverWatcher = watcher
end

function CooldownPanels:ApplyVisibilityDriverToFrame(frame, expression)
	self.runtime = self.runtime or {}
	if not frame then return false end
	if InCombatLockdown and InCombatLockdown() then
		self.runtime.pendingVisibilityDriverUpdates = self.runtime.pendingVisibilityDriverUpdates or {}
		self.runtime.pendingVisibilityDriverUpdates[frame] = expression == nil and false or expression
		self:EnsureVisibilityDriverWatcher()
		return false
	end
	if not expression then
		if frame._eqolVisibilityDriver then
			if UnregisterStateDriver then pcall(UnregisterStateDriver, frame, "visibility") end
			frame._eqolVisibilityDriver = nil
		end
		return true
	end
	if frame._eqolVisibilityDriver == expression then return true end
	if RegisterStateDriver then
		if frame._eqolVisibilityDriver and UnregisterStateDriver then
			pcall(UnregisterStateDriver, frame, "visibility")
			frame._eqolVisibilityDriver = nil
		end
		local ok = pcall(RegisterStateDriver, frame, "visibility", expression)
		if ok then
			frame._eqolVisibilityDriver = expression
			return true
		end
	end
	return false
end

function CooldownPanels:BuildVisibilityDriver(panel, panelId)
	if not panel then return nil, false, nil end
	panel.layout = panel.layout or Helper.CopyTableShallow(Helper.PANEL_LAYOUT_DEFAULTS)
	local layout = panel.layout
	local signature, signatureSource = self:BuildVisibilityDriverSignature(layout)
	panelId = panelId or panel.id
	local runtime = panelId and getRuntime(panelId) or nil
	local cache = runtime and runtime.visibilityDriverCache
	if cache and cache.signature == signature and cache.signatureSource == signatureSource then return cache.expression, cache.usesManualVisibility, cache.visibilityCfg end

	local visibilityCfg = PanelVisibility.NormalizeConfig(layout.visibility)
	local hideInPetBattle = layout.hideInPetBattle == true
	local hideInVehicle = layout.hideInVehicle == true
	local expression
	local usesManual = false
	if visibilityCfg and visibilityCfg.ALWAYS_HIDDEN then
		expression = "hide"
		if runtime then runtime.visibilityDriverCache = { signature = signature, signatureSource = signatureSource, expression = expression, usesManualVisibility = false, visibilityCfg = visibilityCfg } end
		return expression, false, visibilityCfg
	end
	local usesManualVisibility = addon.functions and addon.functions.VisibilityConfigUsesManualEvaluation
	if usesManualVisibility and usesManualVisibility(visibilityCfg, { allowMouseover = false }) then
		usesManual = true
		if runtime then runtime.visibilityDriverCache = { signature = signature, signatureSource = signatureSource, expression = nil, usesManualVisibility = true, visibilityCfg = visibilityCfg } end
		return nil, true, visibilityCfg
	end
	if not visibilityCfg and not hideInPetBattle and not hideInVehicle then
		if runtime then runtime.visibilityDriverCache = { signature = signature, signatureSource = signatureSource, expression = nil, usesManualVisibility = false, visibilityCfg = nil } end
		return nil, false, nil
	end

	local prependHideClauses = {}
	if hideInPetBattle then prependHideClauses[#prependHideClauses + 1] = "petbattle" end
	if hideInVehicle then prependHideClauses[#prependHideClauses + 1] = "vehicleui" end

	local buildDriverExpression = addon.functions and addon.functions.BuildUnitFrameDriverExpression
	if visibilityCfg and next(visibilityCfg) and buildDriverExpression then expression = buildDriverExpression(visibilityCfg, { prependHideClauses = prependHideClauses }) end
	if not expression and #prependHideClauses > 0 then
		local clauses = {}
		for i = 1, #prependHideClauses do
			clauses[#clauses + 1] = ("[%s] hide"):format(prependHideClauses[i])
		end
		clauses[#clauses + 1] = "show"
		expression = table.concat(clauses, "; ")
	end
	if runtime then runtime.visibilityDriverCache = { signature = signature, signatureSource = signatureSource, expression = expression, usesManualVisibility = usesManual, visibilityCfg = visibilityCfg } end
	return expression, usesManual, visibilityCfg
end

function CooldownPanels:ShouldShowPanel(panelId)
	local panel = self:GetPanel(panelId)
	if not panel then return false end
	if self:IsPanelLayoutEditActive(panelId) then return true end
	if panel.enabled == false then return false end
	if not panelAllowsSpec(panel) then return false end
	panel.layout = panel.layout or Helper.CopyTableShallow(Helper.PANEL_LAYOUT_DEFAULTS)
	if panel.layout.hideInPetBattle == true and isPetBattleActive() then return false end
	if panel.layout.hideInVehicle == true and playerHasVehicleUI() then return false end
	local hideInClientScene = panel.layout.hideInClientScene
	if hideInClientScene == nil then hideInClientScene = Helper.PANEL_LAYOUT_DEFAULTS.hideInClientScene == true end
	if hideInClientScene and isClientSceneActive() then return false end
	if not PanelVisibility.ShouldShow(panel.layout.visibility) then return false end
	local runtime = getRuntime(panelId)
	return runtime.visibleCount and runtime.visibleCount > 0
end

function CooldownPanels:UpdatePanelOpacity(panelId, forcedAlpha)
	local panel = self:GetPanel(panelId)
	if not panel then return end
	local runtime = getRuntime(panelId)
	local frame = runtime.frame
	if not frame then return end
	panel.layout = panel.layout or Helper.CopyTableShallow(Helper.PANEL_LAYOUT_DEFAULTS)
	local layout = panel.layout
	local fallbackOut = Helper.PANEL_LAYOUT_DEFAULTS.opacityOutOfCombat
	local fallbackIn = Helper.PANEL_LAYOUT_DEFAULTS.opacityInCombat
	local outAlpha = Helper.NormalizeOpacity(layout.opacityOutOfCombat, fallbackOut)
	local inAlpha = Helper.NormalizeOpacity(layout.opacityInCombat, fallbackIn)
	local alpha
	if forcedAlpha ~= nil then
		alpha = forcedAlpha
	elseif self:IsPanelLayoutEditActive(panelId) then
		alpha = 1
	else
		local inCombat = (InCombatLockdown and InCombatLockdown()) or (UnitAffectingCombat and UnitAffectingCombat("player")) or false
		alpha = inCombat and inAlpha or outAlpha
	end
	if alpha == nil then alpha = 1 end
	if frame._eqolAlpha ~= alpha then
		frame._eqolAlpha = alpha
		frame:SetAlpha(alpha)
	end
end

function CooldownPanels:UpdateVisibility(panelId)
	local panel = self:GetPanel(panelId)
	local runtime = getRuntime(panelId)
	local frame = runtime.frame
	if not frame or not panel then return end
	panel.layout = panel.layout or Helper.CopyTableShallow(Helper.PANEL_LAYOUT_DEFAULTS)
	local layout = panel.layout
	local layoutEditActive = self:IsPanelLayoutEditActive(panelId)
	local visibleCount = runtime.visibleCount or 0
	local contentVisible = layoutEditActive or visibleCount > 0
	local hideInClientScene = layout.hideInClientScene
	if hideInClientScene == nil then hideInClientScene = Helper.PANEL_LAYOUT_DEFAULTS.hideInClientScene == true end
	local clientSceneHidden = not layoutEditActive and hideInClientScene and isClientSceneActive()
	local canUseDriver = not layoutEditActive and panel.enabled ~= false and panelAllowsSpec(panel) and contentVisible and not clientSceneHidden
	local driverExpression
	local usesManualVisibility = false
	if canUseDriver then
		driverExpression, usesManualVisibility = self:BuildVisibilityDriver(panel, panelId)
	end
	if canUseDriver and driverExpression and not usesManualVisibility then
		if self:ApplyVisibilityDriverToFrame(frame, driverExpression) then
			self:UpdatePanelOpacity(panelId, nil)
			self:UpdatePanelMouseState(panelId)
			return
		end
	end
	self:ApplyVisibilityDriverToFrame(frame, nil)
	local shouldShow = self:ShouldShowPanel(panelId)
	local forceAlphaHidden = false
	local pendingVisibilityUpdates = self.runtime and self.runtime.pendingVisibilityDriverUpdates
	local driverDetachPending = pendingVisibilityUpdates and pendingVisibilityUpdates[frame] == false
	local driverStillControlsVisibility = frame._eqolVisibilityDriver ~= nil or driverDetachPending
	if frame:IsShown() ~= shouldShow then
		local inCombat = (InCombatLockdown and InCombatLockdown()) or false
		local isProtected = frame.IsProtected and frame:IsProtected()
		if not shouldShow and inCombat and driverStillControlsVisibility then
			forceAlphaHidden = true
		elseif not (inCombat and isProtected) then
			frame:SetShown(shouldShow)
		elseif not shouldShow then
			-- Protected frames cannot be shown/hidden in combat; use alpha fallback.
			forceAlphaHidden = true
		end
	elseif not shouldShow then
		local inCombat = (InCombatLockdown and InCombatLockdown()) or false
		local isProtected = frame.IsProtected and frame:IsProtected()
		if inCombat and (isProtected or driverStillControlsVisibility) then forceAlphaHidden = true end
	end
	self:UpdatePanelOpacity(panelId, forceAlphaHidden and 0 or nil)
	self:UpdatePanelMouseState(panelId)
end

function CooldownPanels:UpdatePanelMouseState(panelId)
	local runtime = getRuntime(panelId)
	local frame = runtime.frame
	if not frame then return end
	local layoutEditActive = self:IsPanelLayoutEditActive(panelId)
	if frame._mouseEnabled ~= false then
		frame._mouseEnabled = false
		frame:EnableMouse(false)
	end
	if frame.Selection and frame.Selection.EnableMouse then
		local enableSelection = false
		if frame._eqolSelectionMouseEnabled ~= enableSelection then
			frame._eqolSelectionMouseEnabled = enableSelection
			frame.Selection:EnableMouse(enableSelection)
		end
	end
	if frame.editDropZone then
		local showDropZone = layoutEditActive
		frame.editDropZone:SetShown(showDropZone)
		if frame._eqolDropZoneMouseEnabled ~= showDropZone then
			frame._eqolDropZoneMouseEnabled = showDropZone
			frame.editDropZone:EnableMouse(showDropZone)
		end
	end
	local showMoveHandle = false
	local showPanelHandle = layoutEditActive and self:IsLayoutPanelStandaloneMenuAvailable(panelId)
	local handleLayoutKey
	if showMoveHandle then
		handleLayoutKey = "MOVE"
	elseif showPanelHandle then
		handleLayoutKey = "PANEL"
	else
		handleLayoutKey = "NONE"
	end
	if frame._eqolPanelHandleLayout ~= handleLayoutKey then
		frame._eqolPanelHandleLayout = handleLayoutKey
		if frame.editMoveHandle then
			frame.editMoveHandle:ClearAllPoints()
			frame.editMoveHandle:SetPoint("BOTTOM", frame, "TOP", 0, 4)
		end
		if frame.editPanelHandle then
			frame.editPanelHandle:ClearAllPoints()
			frame.editPanelHandle:SetPoint("BOTTOM", frame, "TOP", 0, 4)
		end
	end
	if frame.editMoveHandle then
		frame.editMoveHandle:SetShown(showMoveHandle == true)
		if frame._eqolMoveHandleMouseEnabled ~= showMoveHandle then
			frame._eqolMoveHandleMouseEnabled = showMoveHandle
			frame.editMoveHandle:EnableMouse(showMoveHandle == true)
		end
	end
	if frame.editPanelHandle then
		frame.editPanelHandle:SetShown(showPanelHandle == true)
		if frame._eqolPanelHandleMouseEnabled ~= showPanelHandle then
			frame._eqolPanelHandleMouseEnabled = showPanelHandle
			frame.editPanelHandle:EnableMouse(showPanelHandle == true)
		end
	end
end

function CooldownPanels:ShowLayoutEditHint(panelId, show)
	local runtime = getRuntime(panelId)
	local frame = runtime.frame
	if not frame then return end
	if show then
		if frame.bg then frame.bg:Show() end
		if frame.label then frame.label:Show() end
	else
		if frame.bg then frame.bg:Hide() end
		if frame.label then frame.label:Hide() end
	end
end

function CooldownPanels:RefreshPanel(panelId)
	local panel = self:GetPanel(panelId)
	if not panel then return end
	local layoutEditActive = self:IsPanelLayoutEditActive(panelId)
	if panel.enabled == false and not layoutEditActive then
		local runtime = self.runtime and self.runtime[panelId]
		local frame = runtime and runtime.frame
		if runtime then runtime.visibleCount = 0 end
		if frame then
			if frame.icons then
				for i = 1, #frame.icons do
					local cooldown = frame.icons[i] and frame.icons[i].cooldown
					if cooldown then clearCooldownDoneState(cooldown) end
				end
			end
			self:ApplyVisibilityDriverToFrame(frame, nil)
			if InCombatLockdown and InCombatLockdown() then
				self:UpdatePanelOpacity(panelId, 0)
			else
				frame:Hide()
				self:UpdatePanelOpacity(panelId, nil)
			end
		end
		return
	end
	local startedRuntimeQueryBatch = false
	if not self:IsRuntimeQueryBatchActive() then
		self:BeginRuntimeQueryBatch()
		startedRuntimeQueryBatch = true
	end
	self:EnsurePanelFrame(panelId)
	self:ApplyPanelPosition(panelId)
	local runtime = getRuntime(panelId)
	if layoutEditActive then
		clearRuntimeLayoutShapeCache(runtime)
		self:ApplyLayout(panelId)
		self:UpdateRuntimeIcons(panelId)
	else
		if ensureAssistedHighlightHook then ensureAssistedHighlightHook() end
		self:UpdateRuntimeIcons(panelId)
	end
	self:UpdateVisibility(panelId)
	self:ShowLayoutEditHint(panelId, layoutEditActive)
	if startedRuntimeQueryBatch then self:EndRuntimeQueryBatch() end
end

function CooldownPanels:HideAllRuntimePanels()
	local root = ensureRoot()
	if not root or not root.panels then return end
	local allRuntime = self.runtime
	if not allRuntime then return end
	for panelId in pairs(root.panels) do
		local runtime = allRuntime[panelId]
		if runtime then
			runtime.visibleCount = 0
			runtime.visiblePowerSpellCount = 0
			runtime._eqolHiddenByEligibility = true
			if runtime.visibleEntries then
				for i = 1, #runtime.visibleEntries do
					runtime.visibleEntries[i] = nil
				end
			end
			if runtime.visiblePowerSpells then
				for i = 1, #runtime.visiblePowerSpells do
					runtime.visiblePowerSpells[i] = nil
				end
			end
			if runtime.frame then self:UpdateVisibility(panelId) end
		end
	end
end

CooldownPanels.GetLoadedPanelIdsForRefresh = function(root, runtime)
	if runtime then
		local enabledPanels = runtime.enabledPanels
		if enabledPanels then
			if not next(enabledPanels) then return {} end
			local enabledPanelIds = runtime.enabledPanelIds
			if enabledPanelIds and #enabledPanelIds > 0 then return enabledPanelIds end
			local panelIds = {}
			for _, panelId in ipairs(CooldownPanels.GetCachedPanelIds(root)) do
				if enabledPanels[panelId] then panelIds[#panelIds + 1] = panelId end
			end
			return panelIds
		end
	end
	return CooldownPanels.GetCachedPanelIds(root)
end

CooldownPanels.HideDisabledPanelRuntime = function(panelId)
	local panel = CooldownPanels:GetPanel(panelId)
	if not panel then return end
	local runtime = getRuntime(panelId)
	if not runtime then return end
	runtime.visibleCount = 0
	runtime.visiblePowerSpellCount = 0
	if not runtime.frame then
		runtime._eqolHiddenByEligibility = true
		return
	end
	runtime._eqolHiddenByEligibility = nil
	CooldownPanels:UpdateRuntimeIcons(panelId)
	CooldownPanels:UpdateVisibility(panelId)
	CooldownPanels:ShowLayoutEditHint(panelId, false)
end

function CooldownPanels:RefreshAllPanels(forceAll)
	local root = ensureRoot()
	if not root then return end
	local runtime = self.runtime
	local layoutEditActive = self:IsAnyPanelLayoutEditActive()
	local panelIds = nil
	if runtime and runtime.disabledPanelIds then
		for i = 1, #runtime.disabledPanelIds do
			local panelId = runtime.disabledPanelIds[i]
			if panelId and root.panels and root.panels[panelId] then
				if layoutEditActive then
					CooldownPanels.HideDisabledPanelRuntime(panelId)
				else
					self:RefreshPanel(panelId)
				end
			end
		end
		for i = 1, #runtime.disabledPanelIds do
			runtime.disabledPanelIds[i] = nil
		end
	end
	if runtime and runtime.enabledPanels and not next(runtime.enabledPanels) then
		self:HideAllRuntimePanels()
		self:UpdateCursorAnchorState()
		return
	end
	if forceAll ~= true and not layoutEditActive then
		local enabledPanels = runtime and runtime.enabledPanels
		if not enabledPanels or not next(enabledPanels) then
			self:HideAllRuntimePanels()
			self:UpdateCursorAnchorState()
			return
		end
		panelIds = runtime and runtime.enabledPanelIds or nil
	end
	syncRootOrderIfDirty(root)
	panelIds = panelIds or CooldownPanels.GetLoadedPanelIdsForRefresh(root, runtime)
	self:BeginRuntimeQueryBatch()
	for _, panelId in ipairs(panelIds) do
		self:EnsurePanelFrame(panelId)
	end
	for _, panelId in ipairs(panelIds) do
		self:ApplyPanelPosition(panelId)
	end
	for _, panelId in ipairs(panelIds) do
		self:RefreshPanel(panelId)
	end
	self:EndRuntimeQueryBatch()
	self:UpdateCursorAnchorState()
end

function CooldownPanels:RefreshPanelForCurrentEditContext(panelId, refreshEditor)
	local runtime = getRuntime(panelId)
	if CooldownPanels:IsPanelLayoutEditActive(panelId) then
		if runtime then clearRuntimeLayoutShapeCache(runtime) end
		CooldownPanels:ApplyLayout(panelId)
		CooldownPanels:UpdateRuntimeIcons(panelId)
		CooldownPanels:UpdateVisibility(panelId)
	elseif CooldownPanels:IsEditorOpen() then
		if runtime then clearRuntimeLayoutShapeCache(runtime) end
		CooldownPanels:ApplyLayout(panelId)
		CooldownPanels:ApplyPanelPosition(panelId)
		CooldownPanels:UpdateVisibility(panelId)
	else
		CooldownPanels:RefreshPanel(panelId)
	end
	if refreshEditor and CooldownPanels:IsEditorOpen() then CooldownPanels:RefreshEditor() end
end

applyEditLayout = function(panelId, field, value, skipRefresh)
	local panel = CooldownPanels:GetPanel(panelId)
	if not panel then return end
	panel.layout = panel.layout or {}
	local layout = panel.layout
	local rowSizeIndex = field and field:match("^rowSize(%d+)$")

	if field == "iconSize" then
		layout.iconSize = Helper.ClampInt(value, 12, 128, layout.iconSize)
	elseif field == "iconSizeSeparate" then
		layout.iconSizeSeparate = value == true
		if layout.iconSizeSeparate then
			layout.iconWidth = Helper.ClampInt(layout.iconWidth, 12, 128, layout.iconSize or Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
			layout.iconHeight = Helper.ClampInt(layout.iconHeight, 12, 128, layout.iconSize or Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
		end
	elseif field == "iconWidth" then
		layout.iconWidth = Helper.ClampInt(value, 12, 128, layout.iconWidth or layout.iconSize or Helper.PANEL_LAYOUT_DEFAULTS.iconWidth)
	elseif field == "iconHeight" then
		layout.iconHeight = Helper.ClampInt(value, 12, 128, layout.iconHeight or layout.iconSize or Helper.PANEL_LAYOUT_DEFAULTS.iconHeight)
	elseif field == "spacing" then
		layout.spacing = Helper.ClampInt(value, 0, Helper.SPACING_RANGE or 200, layout.spacing)
	elseif field == "layoutMode" then
		layout.layoutMode = Helper.NormalizeLayoutMode(value, layout.layoutMode or Helper.PANEL_LAYOUT_DEFAULTS.layoutMode)
		Helper.InvalidateFixedLayoutCache(panel)
		if Helper.IsFixedLayout(layout) then
			local maxColumn, maxRow = Helper.EnsureFixedSlotAssignments(panel)
			if Helper.NormalizeFixedGridSize(layout.fixedGridColumns, 0) <= 0 then layout.fixedGridColumns = math.max(maxColumn or 0, 1) end
			if Helper.NormalizeFixedGridSize(layout.fixedGridRows, 0) <= 0 then layout.fixedGridRows = math.max(maxRow or 0, 1) end
		end
	elseif field == "fixedSlotCount" then
		local minimum = 0
		Helper.InvalidateFixedLayoutCache(panel)
		if Helper.IsFixedLayout(layout) then
			local maxColumn = Helper.EnsureFixedSlotAssignments(panel)
			minimum = maxColumn or 0
		end
		layout.fixedGridColumns = math.max(Helper.NormalizeFixedGridSize(value, layout.fixedGridColumns or Helper.PANEL_LAYOUT_DEFAULTS.fixedGridColumns or 0), minimum)
	elseif field == "fixedGridRows" then
		local minimum = 0
		Helper.InvalidateFixedLayoutCache(panel)
		if Helper.IsFixedLayout(layout) then
			local _, maxRow = Helper.EnsureFixedSlotAssignments(panel)
			minimum = maxRow or 0
		end
		layout.fixedGridRows = math.max(Helper.NormalizeFixedGridSize(value, layout.fixedGridRows or Helper.PANEL_LAYOUT_DEFAULTS.fixedGridRows or 0), minimum)
	elseif field == "direction" then
		layout.direction = Helper.NormalizeDirection(value, layout.direction)
	elseif field == "wrapCount" then
		layout.wrapCount = Helper.ClampInt(value, 0, 40, layout.wrapCount)
		if Helper.IsFixedLayout(layout) then Helper.InvalidateFixedLayoutCache(panel) end
	elseif field == "wrapDirection" then
		layout.wrapDirection = Helper.NormalizeDirection(value, layout.wrapDirection)
	elseif field == "growthPoint" then
		layout.growthPoint = Helper.NormalizeGrowthPoint(value, layout.growthPoint or Helper.PANEL_LAYOUT_DEFAULTS.growthPoint)
	elseif field == "radialRadius" then
		layout.radialRadius = Helper.ClampInt(value, 0, Helper.RADIAL_RADIUS_RANGE or 600, layout.radialRadius or Helper.PANEL_LAYOUT_DEFAULTS.radialRadius)
	elseif field == "radialRotation" then
		layout.radialRotation =
			Helper.ClampNumber(value, -(Helper.RADIAL_ROTATION_RANGE or 360), Helper.RADIAL_ROTATION_RANGE or 360, layout.radialRotation or Helper.PANEL_LAYOUT_DEFAULTS.radialRotation)
	elseif field == "radialArcDegrees" then
		layout.radialArcDegrees =
			Helper.ClampInt(value, Helper.RADIAL_ARC_DEGREES_MIN or 15, Helper.RADIAL_ARC_DEGREES_MAX or 360, layout.radialArcDegrees or Helper.PANEL_LAYOUT_DEFAULTS.radialArcDegrees or 360)
	elseif field == "rangeOverlayEnabled" then
		layout.rangeOverlayEnabled = value == true
		if updateRangeCheckSpells then updateRangeCheckSpells() end
	elseif field == "rangeOverlayColor" then
		layout.rangeOverlayColor = Helper.NormalizeColor(value, Helper.PANEL_LAYOUT_DEFAULTS.rangeOverlayColor)
	elseif field == "procGlowEnabled" then
		layout.procGlowEnabled = value ~= false
	elseif field == "hideGlowOutOfCombat" then
		layout.hideGlowOutOfCombat = value == true
	elseif field == "procGlowStyle" then
		layout.procGlowStyle = Helper.NormalizeGlowStyle(value, layout.procGlowStyle or layout.readyGlowStyle or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle)
	elseif field == "procGlowInset" then
		layout.procGlowInset = Helper.NormalizeGlowInset(value, layout.procGlowInset or layout.readyGlowInset or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowInset or 0)
	elseif field == "readyGlowStyle" then
		layout.readyGlowStyle = Helper.NormalizeGlowStyle(value, layout.readyGlowStyle or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle)
	elseif field == "pandemicGlowStyle" then
		layout.pandemicGlowStyle = Helper.NormalizeGlowStyle(value, layout.pandemicGlowStyle or layout.readyGlowStyle or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle)
	elseif field == "readyGlowInset" then
		layout.readyGlowInset = Helper.NormalizeGlowInset(value, layout.readyGlowInset or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowInset or 0)
	elseif field == "readyGlowColor" then
		layout.readyGlowColor = Helper.NormalizeColor(value, Helper.PANEL_LAYOUT_DEFAULTS.readyGlowColor)
	elseif field == "pandemicGlowInset" then
		layout.pandemicGlowInset = Helper.NormalizeGlowInset(value, layout.pandemicGlowInset or layout.readyGlowInset or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowInset or 0)
	elseif field == "pandemicGlowColor" then
		layout.pandemicGlowColor = Helper.NormalizeColor(value, layout.readyGlowColor or Helper.PANEL_LAYOUT_DEFAULTS.pandemicGlowColor or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowColor)
	elseif field == "readyGlowDuration" then
		layout.readyGlowDuration = 0
	elseif field == "readyGlowCheckPower" then
		layout.readyGlowCheckPower = value == true
		CooldownPanels:RebuildPowerIndex()
	elseif field == "noDesaturation" then
		layout.noDesaturation = value == true
	elseif field == "hideWhenNoResource" then
		layout.hideWhenNoResource = value == true
		CooldownPanels:RebuildPowerIndex()
	elseif field == "cdmAuraAlwaysShowMode" then
		layout.cdmAuraAlwaysShowMode = CooldownPanels:NormalizeCDMAuraAlwaysShowMode(value, layout.cdmAuraAlwaysShowMode or Helper.PANEL_LAYOUT_DEFAULTS.cdmAuraAlwaysShowMode)
	elseif field == "cdmAuraOverlayEnabled" then
		layout.cdmAuraOverlayEnabled = value == true
		CooldownPanels:RebuildSpellIndex()
	elseif field == "checkPower" then
		layout.checkPower = value == true
		CooldownPanels:RebuildPowerIndex()
	elseif field == "powerTintColor" then
		layout.powerTintColor = Helper.NormalizeColor(value, Helper.PANEL_LAYOUT_DEFAULTS.powerTintColor)
	elseif field == "strata" then
		layout.strata = Helper.NormalizeStrata(value, layout.strata)
	elseif field == "stackAnchor" then
		layout.stackAnchor = Helper.NormalizeAnchor(value, layout.stackAnchor or Helper.PANEL_LAYOUT_DEFAULTS.stackAnchor)
	elseif field == "stackX" then
		layout.stackX = Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, layout.stackX or Helper.PANEL_LAYOUT_DEFAULTS.stackX)
	elseif field == "stackY" then
		layout.stackY = Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, layout.stackY or Helper.PANEL_LAYOUT_DEFAULTS.stackY)
	elseif field == "stackFont" then
		if type(value) == "string" and value ~= "" then layout.stackFont = value end
	elseif field == "stackFontSize" then
		layout.stackFontSize = Helper.ClampInt(value, 6, 64, layout.stackFontSize or Helper.PANEL_LAYOUT_DEFAULTS.stackFontSize)
	elseif field == "stackFontStyle" then
		layout.stackFontStyle = Helper.NormalizeFontStyleChoice(value, layout.stackFontStyle or Helper.PANEL_LAYOUT_DEFAULTS.stackFontStyle)
	elseif field == "stackColor" then
		layout.stackColor = Helper.NormalizeColor(value, Helper.PANEL_LAYOUT_DEFAULTS.stackColor or { 1, 1, 1, 1 })
	elseif field == "chargesAnchor" then
		layout.chargesAnchor = Helper.NormalizeAnchor(value, layout.chargesAnchor or Helper.PANEL_LAYOUT_DEFAULTS.chargesAnchor)
	elseif field == "chargesX" then
		layout.chargesX = Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, layout.chargesX or Helper.PANEL_LAYOUT_DEFAULTS.chargesX)
	elseif field == "chargesY" then
		layout.chargesY = Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, layout.chargesY or Helper.PANEL_LAYOUT_DEFAULTS.chargesY)
	elseif field == "chargesFont" then
		if type(value) == "string" and value ~= "" then layout.chargesFont = value end
	elseif field == "chargesFontSize" then
		layout.chargesFontSize = Helper.ClampInt(value, 6, 64, layout.chargesFontSize or Helper.PANEL_LAYOUT_DEFAULTS.chargesFontSize)
	elseif field == "chargesFontStyle" then
		layout.chargesFontStyle = Helper.NormalizeFontStyleChoice(value, layout.chargesFontStyle or Helper.PANEL_LAYOUT_DEFAULTS.chargesFontStyle)
	elseif field == "chargesColor" then
		layout.chargesColor = Helper.NormalizeColor(value, Helper.PANEL_LAYOUT_DEFAULTS.chargesColor or { 1, 1, 1, 1 })
	elseif field == "chargesHideWhenZero" then
		layout.chargesHideWhenZero = value == true
	elseif field == "keybindsEnabled" then
		layout.keybindsEnabled = value == true
		Keybinds.MarkPanelsDirty()
	elseif field == "keybindsIgnoreItems" then
		layout.keybindsIgnoreItems = value == true
	elseif field == "keybindAnchor" then
		layout.keybindAnchor = Helper.NormalizeAnchor(value, layout.keybindAnchor or Helper.PANEL_LAYOUT_DEFAULTS.keybindAnchor)
	elseif field == "keybindX" then
		layout.keybindX = Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, layout.keybindX or Helper.PANEL_LAYOUT_DEFAULTS.keybindX)
	elseif field == "keybindY" then
		layout.keybindY = Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, layout.keybindY or Helper.PANEL_LAYOUT_DEFAULTS.keybindY)
	elseif field == "keybindFont" then
		if type(value) == "string" and value ~= "" then layout.keybindFont = value end
	elseif field == "keybindFontSize" then
		layout.keybindFontSize = Helper.ClampInt(value, 6, 64, layout.keybindFontSize or Helper.PANEL_LAYOUT_DEFAULTS.keybindFontSize)
	elseif field == "keybindFontStyle" then
		layout.keybindFontStyle = Helper.NormalizeFontStyleChoice(value, layout.keybindFontStyle or Helper.PANEL_LAYOUT_DEFAULTS.keybindFontStyle)
	elseif field == "cooldownDrawEdge" then
		layout.cooldownDrawEdge = value ~= false
	elseif field == "cooldownDrawBling" then
		layout.cooldownDrawBling = value ~= false
	elseif field == "cooldownDrawSwipe" then
		layout.cooldownDrawSwipe = value ~= false
	elseif field == "showChargesCooldown" then
		layout.showChargesCooldown = value == true
	elseif field == "cooldownGcdDrawEdge" then
		layout.cooldownGcdDrawEdge = value == true
	elseif field == "cooldownGcdDrawBling" then
		layout.cooldownGcdDrawBling = value == true
	elseif field == "cooldownGcdDrawSwipe" then
		layout.cooldownGcdDrawSwipe = value == true
	elseif field == "showTooltips" then
		layout.showTooltips = value == true
	elseif field == "showIconTexture" then
		layout.showIconTexture = value ~= false
	elseif field == "iconBorderEnabled" then
		layout.iconBorderEnabled = value == true
	elseif field == "iconBorderTexture" then
		layout.iconBorderTexture = normalizeIconBorderTexture(value, layout.iconBorderTexture or Helper.PANEL_LAYOUT_DEFAULTS.iconBorderTexture)
	elseif field == "iconBorderSize" then
		layout.iconBorderSize = Helper.ClampInt(value, 1, 64, layout.iconBorderSize or Helper.PANEL_LAYOUT_DEFAULTS.iconBorderSize)
	elseif field == "iconBorderOffset" then
		layout.iconBorderOffset = Helper.ClampInt(value, -64, 64, layout.iconBorderOffset or Helper.PANEL_LAYOUT_DEFAULTS.iconBorderOffset)
	elseif field == "iconBorderColor" then
		layout.iconBorderColor = Helper.NormalizeColor(value, Helper.PANEL_LAYOUT_DEFAULTS.iconBorderColor)
	elseif field == "hideOnCooldown" then
		layout.hideOnCooldown = value == true
		if layout.hideOnCooldown then layout.showOnCooldown = false end
	elseif field == "showOnCooldown" then
		layout.showOnCooldown = value == true
		if layout.showOnCooldown then layout.hideOnCooldown = false end
	elseif field == "hideInVehicle" then
		layout.hideInVehicle = value == true
	elseif field == "hideInPetBattle" then
		layout.hideInPetBattle = value == true
	elseif field == "hideInClientScene" then
		layout.hideInClientScene = value == true
	elseif field == "visibility" then
		layout.visibility = PanelVisibility.NormalizeConfig(value)
	elseif field == "cooldownTextFont" then
		if type(value) == "string" and value ~= "" then layout.cooldownTextFont = value end
	elseif field == "cooldownTextSize" then
		layout.cooldownTextSize = Helper.ClampInt(value, 6, 64, layout.cooldownTextSize or 12)
	elseif field == "cooldownTextStyle" then
		layout.cooldownTextStyle = Helper.NormalizeFontStyleChoice(value, layout.cooldownTextStyle or Helper.PANEL_LAYOUT_DEFAULTS.cooldownTextStyle)
	elseif field == "cooldownTextColor" then
		layout.cooldownTextColor = Helper.NormalizeColor(value, Helper.PANEL_LAYOUT_DEFAULTS.cooldownTextColor)
	elseif field == "cooldownTextX" then
		layout.cooldownTextX = Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, layout.cooldownTextX or 0)
	elseif field == "cooldownTextY" then
		layout.cooldownTextY = Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, layout.cooldownTextY or 0)
	elseif field == "staticTextFont" then
		if type(value) == "string" then layout.staticTextFont = value end
	elseif field == "staticTextSize" then
		layout.staticTextSize = Helper.ClampInt(value, 6, 64, layout.staticTextSize or Helper.PANEL_LAYOUT_DEFAULTS.staticTextSize or 12)
	elseif field == "staticTextStyle" then
		layout.staticTextStyle = Helper.NormalizeFontStyleChoice(value, layout.staticTextStyle or Helper.PANEL_LAYOUT_DEFAULTS.staticTextStyle or "OUTLINE")
	elseif field == "staticTextColor" then
		layout.staticTextColor = Helper.NormalizeColor(value, Helper.PANEL_LAYOUT_DEFAULTS.staticTextColor or { 1, 1, 1, 1 })
	elseif field == "staticTextAnchor" then
		layout.staticTextAnchor = Helper.NormalizeAnchor(value, layout.staticTextAnchor or Helper.PANEL_LAYOUT_DEFAULTS.staticTextAnchor or "CENTER")
	elseif field == "staticTextX" then
		layout.staticTextX = Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, layout.staticTextX or Helper.PANEL_LAYOUT_DEFAULTS.staticTextX or 0)
	elseif field == "staticTextY" then
		layout.staticTextY = Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, layout.staticTextY or Helper.PANEL_LAYOUT_DEFAULTS.staticTextY or 0)
	elseif field == "opacityOutOfCombat" then
		layout.opacityOutOfCombat = Helper.NormalizeOpacity(value, layout.opacityOutOfCombat or Helper.PANEL_LAYOUT_DEFAULTS.opacityOutOfCombat)
	elseif field == "opacityInCombat" then
		layout.opacityInCombat = Helper.NormalizeOpacity(value, layout.opacityInCombat or Helper.PANEL_LAYOUT_DEFAULTS.opacityInCombat)
	elseif rowSizeIndex then
		local index = tonumber(rowSizeIndex)
		local base = Helper.ClampInt(layout.iconSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
		local newSize = Helper.ClampInt(value, 12, 128, base)
		layout.rowSizes = layout.rowSizes or {}
		if newSize == base then
			layout.rowSizes[index] = nil
		else
			layout.rowSizes[index] = newSize
		end
		if not next(layout.rowSizes) then layout.rowSizes = nil end
	end

	if Helper.IsFixedLayout(layout) then CooldownPanels.BumpFixedGroupEffectiveLayoutVersion(panel) end

	if not skipRefresh then CooldownPanels:RefreshPanelForCurrentEditContext(panelId, false) end
	if field == "layoutMode" and not skipRefresh then refreshStandaloneSettings() end
end

local function getCopySettingsEntries(panelKey)
	local root = CooldownPanels:GetRoot()
	if not root or not root.panels then return {} end
	local entries = {}
	local seen = {}
	if type(root.order) == "table" then
		for _, id in ipairs(root.order) do
			local otherId = normalizeId(id)
			if otherId ~= panelKey then
				local other = root.panels[otherId]
				if other then
						local label = (L["cooldownPanelReferenceLabel"]):format(tostring(otherId), other.name or L["cooldownPanelDefaultName"])
					entries[#entries + 1] = { id = otherId, label = label }
					seen[otherId] = true
				end
			end
		end
	end
	for id, other in pairs(root.panels) do
		local otherId = normalizeId(id)
		if other and otherId ~= panelKey and not seen[otherId] then
				local label = (L["cooldownPanelReferenceLabel"]):format(tostring(otherId), other.name or L["cooldownPanelDefaultName"])
			entries[#entries + 1] = { id = otherId, label = label }
		end
	end
	return entries
end

function CooldownPanels:PrepareLayoutPanelStandaloneSettings(panelId)
	local panel = self:GetPanel(panelId)
	if not panel then return end
	local runtime = getRuntime(panelId)

	local frame = self:EnsurePanelFrame(panelId)
	if not frame then return end

	panel.layout = panel.layout or Helper.CopyTableShallow(Helper.PANEL_LAYOUT_DEFAULTS)
	local layout = panel.layout
	local baseIconSize = Helper.ClampInt(layout.iconSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
	local anchor = ensurePanelAnchor(panel)
	local panelKey = normalizeId(panelId)
	local countFontPath, countFontSize, countFontStyle = Helper.GetCountFontDefaults(frame)
	local chargesFontPath, chargesFontSize, chargesFontStyle = Helper.GetChargesFontDefaults(frame)
	local function fontOptions() return Helper.GetFontOptions(countFontPath) end
	local function chargesFontOptions() return Helper.GetFontOptions(chargesFontPath) end
	local function hasStaticTextEntries() return panel and panel.entries and next(panel.entries) ~= nil end
	local function hasCDMAuraEntries()
		if not (panel and panel.entries) then return false end
		for _, entry in pairs(panel.entries) do
			if entry and entry.type == "CDM_AURA" then return true end
		end
		return false
	end
	local function setStaticTextEntryId(entryId)
		local runtimePanel = getRuntime(panelId)
		if runtimePanel then runtimePanel.layoutEditEntryId = normalizeId(entryId) end
	end
	local function getStaticTextEntryId()
		if not hasStaticTextEntries() then return nil end
		local editor = getEditor()
		if editor and editor.selectedPanelId == panelId then
			local selected = normalizeId(editor.selectedEntryId)
			if selected and panel.entries and panel.entries[selected] then
				setStaticTextEntryId(selected)
				return selected
			end
		end
		local runtimePanel = getRuntime(panelId)
		local entryId = normalizeId(runtimePanel and runtimePanel.layoutEditEntryId)
		if entryId and panel.entries and panel.entries[entryId] then return entryId end
		local order = panel.order or {}
		for _, id in ipairs(order) do
			if panel.entries and panel.entries[id] then
				setStaticTextEntryId(id)
				return id
			end
		end
		for id in pairs(panel.entries) do
			if panel.entries[id] then
				setStaticTextEntryId(id)
				return id
			end
		end
		return nil
	end
	local function getStaticTextEntry()
		local entryId = getStaticTextEntryId()
		return entryId and panel.entries and panel.entries[entryId] or nil, entryId
	end
	local staticTextSharedFields = {
		staticTextFont = true,
		staticTextSize = true,
		staticTextStyle = true,
		staticTextColor = true,
		staticTextAnchor = true,
		staticTextX = true,
		staticTextY = true,
	}
	local function updateStaticTextEntry(entry, field, value)
		if not entry then return end
		local changed = false
		if staticTextSharedFields[field] then
			for _, other in pairs(panel.entries or {}) do
				if other then
					if other.staticTextUseGlobal ~= false then
						other.staticTextUseGlobal = false
						changed = true
					end
					if other[field] ~= value then
						other[field] = value
						changed = true
					end
				end
			end
		else
			if entry[field] == value then return end
			entry[field] = value
			changed = true
		end
		if not changed then return end
		CooldownPanels:RefreshPanel(panelId)
		CooldownPanels:RefreshEditor()
	end
	local function ensureAnchorTable() return ensurePanelAnchor(panel) end
	local function syncPanelPositionFromAnchor()
		local a = ensureAnchorTable()
		if not a then return end
		panel.point = a.point or panel.point or "CENTER"
		panel.x = a.x or panel.x or 0
		panel.y = a.y or panel.y or 0
	end
	local function applyAnchorPosition(skipFrameRefresh, skipSettingValuesRefresh)
		syncPanelPositionFromAnchor()
		CooldownPanels:ApplyPanelPosition(panelId)
		CooldownPanels:UpdateVisibility(panelId)
		if skipFrameRefresh ~= true then refreshLayoutPanelFrame(panelId) end
		if skipSettingValuesRefresh ~= true then refreshStandaloneSettingValues() end
	end
	local function applyAnchorDefaults(a, target)
		if not a then return end
		if target == "UIParent" then
			a.point = "CENTER"
			a.relativePoint = "CENTER"
			a.x = 0
			a.y = 0
		else
			a.point = "TOPLEFT"
			a.relativePoint = "BOTTOMLEFT"
			a.x = 0
			a.y = 0
		end
	end
	local function getRowSizeValue(index)
		local base = Helper.ClampInt(layout.iconSize, 12, 128, Helper.PANEL_LAYOUT_DEFAULTS.iconSize)
		local rowSizes = layout.rowSizes
		local value = rowSizes and tonumber(rowSizes[index]) or nil
		return Helper.ClampInt(value, 12, 128, base)
	end
	local function isRadialLayout() return Helper.NormalizeLayoutMode(layout.layoutMode, Helper.PANEL_LAYOUT_DEFAULTS.layoutMode) == "RADIAL" end
	local function isFixedLayout() return Helper.NormalizeLayoutMode(layout.layoutMode, Helper.PANEL_LAYOUT_DEFAULTS.layoutMode) == "FIXED" end
	local function usesSeparateIconSize() return layout.iconSizeSeparate == true end
	local function shouldShowRowSize(index)
		if isRadialLayout() or usesSeparateIconSize() then return false end
		local rows, primaryHorizontal = getPanelRowCount(panel, layout)
		return primaryHorizontal and rows >= index
	end
	local visibilityRuleOptions = PanelVisibility.GetRuleOptions()
	local settings
	if SettingType then
		settings = {
			{
				name = _G.GENERAL or "General",
				kind = SettingType.Collapsible,
				id = "cooldownPanelGeneral",
				defaultCollapsed = false,
			},
			{
				name = L["CooldownPanelPanelName"] or "Panel name",
				kind = SettingType.Input,
				parentId = "cooldownPanelGeneral",
				labelWidth = 100,
				inputWidth = 220,
				get = function()
					local currentPanel = CooldownPanels:GetPanel(panelId)
					return currentPanel and currentPanel.name or ""
				end,
				set = function(_, value) CooldownPanels:SetPanelEditorName(panelId, value) end,
				default = "",
				maxChars = 64,
			},
			{
				name = L["Enabled"] or "Enabled",
				kind = SettingType.Checkbox,
				parentId = "cooldownPanelGeneral",
				get = function()
					local currentPanel = CooldownPanels:GetPanel(panelId)
					return currentPanel and currentPanel.enabled ~= false or false
				end,
				set = function(_, value) CooldownPanels:SetPanelEditorEnabled(panelId, value) end,
			},
			{
				name = L["CooldownPanelSpecFilter"] or "Show only for spec",
				kind = SettingType.MultiDropdown,
				parentId = "cooldownPanelGeneral",
				customText = L["CooldownPanelSpecAny"] or "All specs",
				height = 260,
				get = function() return nil end,
				set = function() end,
				optionfunc = function() return CooldownPanels:GetPanelSpecFilterOptions() end,
				isSelected = function(_, value) return CooldownPanels:IsPanelSpecFilterOptionSelected(panelId, value) end,
				setSelected = function(_, value, selected) CooldownPanels:SetPanelSpecFilterOptionSelected(panelId, value, selected) end,
			},
			{
				name = L["Copy Settings"] or "Copy Settings",
				kind = SettingType.Collapsible,
				id = "cooldownPanelCopySettings",
				defaultCollapsed = true,
			},
			{
				name = L["Copy Settings"] or "Copy Settings",
				kind = SettingType.Dropdown,
				field = "copySettingsFrom",
				parentId = "cooldownPanelCopySettings",
				height = 140,
				get = function() return nil end,
				set = function() end,
				generator = function(_, root)
					local entries = getCopySettingsEntries(panelKey)
					if not entries or #entries == 0 then
						if root.CreateTitle then root:CreateTitle(L["CooldownPanelCopySettingsNone"] or "No other panels") end
						return
					end
					for _, entry in ipairs(entries) do
						root:CreateRadio(entry.label, function() return false end, function()
							ensureCopyPopup()
							StaticPopup_Show("EQOL_COOLDOWN_PANEL_COPY_SETTINGS", entry.label, nil, { targetPanelId = panelId, sourcePanelId = entry.id })
						end)
					end
				end,
			},
			{
				name = "Anchor",
				kind = SettingType.Collapsible,
				id = "cooldownPanelAnchor",
				defaultCollapsed = false,
			},
			{
				name = "Relative frame",
				kind = SettingType.Dropdown,
				field = "anchorRelativeFrame",
				parentId = "cooldownPanelAnchor",
				height = 200,
				get = function() return CooldownPanels.ValidateRelativeFrameChoice(panel, panelKey, runtime) end,
				set = function(_, value)
					local a = ensureAnchorTable()
					if not a then return end
					local target = Helper.NormalizeRelativeFrameName(value)
					local cache = CooldownPanels.GetRelativeFrameCache(runtime, panel, panelKey)
					if not (cache.valid and cache.valid[target]) then target = "UIParent" end
					if target == cdp.FAKE_CURSOR.FRAME_NAME then
						CooldownPanels:AttachFakeCursor(panelId)
						return
					end
					if a.relativeFrame ~= target then CooldownPanels.MarkRelativeFrameEntriesDirty() end
					a.relativeFrame = target
					applyAnchorDefaults(a, target)
					applyAnchorPosition()
					CooldownPanels:UpdateCursorAnchorState()
					local anchorHelper = CooldownPanels.AnchorHelper
					if anchorHelper and anchorHelper.MaybeScheduleRefresh then anchorHelper:MaybeScheduleRefresh(target) end
				end,
				generator = function(_, root)
					local entries = CooldownPanels.GetRelativeFrameCache(runtime, panel, panelKey).entries
					local current = CooldownPanels.ValidateRelativeFrameChoice(panel, panelKey, runtime)
					for _, entry in ipairs(entries) do
						root:CreateRadio(entry.label, function() return current == entry.key end, function()
							local a = ensureAnchorTable()
							if not a then return end
							local target = entry.key
							local cache = CooldownPanels.GetRelativeFrameCache(runtime, panel, panelKey)
							if not (cache.valid and cache.valid[target]) then target = "UIParent" end
							if target == cdp.FAKE_CURSOR.FRAME_NAME then
								CooldownPanels:AttachFakeCursor(panelId)
								return
							end
							if a.relativeFrame ~= target then CooldownPanels.MarkRelativeFrameEntriesDirty() end
							a.relativeFrame = target
							applyAnchorDefaults(a, target)
							applyAnchorPosition()
							CooldownPanels:UpdateCursorAnchorState()
							local anchorHelper = CooldownPanels.AnchorHelper
							if anchorHelper and anchorHelper.MaybeScheduleRefresh then anchorHelper:MaybeScheduleRefresh(target) end
						end)
					end
				end,
				default = "UIParent",
			},
			{
				name = "Anchor point",
				kind = SettingType.Dropdown,
				field = "anchorPoint",
				parentId = "cooldownPanelAnchor",
				height = 160,
				get = function()
					local a = ensureAnchorTable()
					return Helper.NormalizeAnchor(a and a.point, "CENTER")
				end,
				set = function(_, value)
					local a = ensureAnchorTable()
					if not a then return end
					a.point = Helper.NormalizeAnchor(value, a.point or "CENTER")
					if not a.relativePoint then a.relativePoint = a.point end
					applyAnchorPosition(true, true)
				end,
				generator = function(_, root)
					for _, option in ipairs(Helper.AnchorOptions) do
						root:CreateRadio(option.label, function()
							local a = ensureAnchorTable()
							return Helper.NormalizeAnchor(a and a.point, "CENTER") == option.value
						end, function()
							local a = ensureAnchorTable()
							if not a then return end
							a.point = option.value
							if not a.relativePoint then a.relativePoint = option.value end
							applyAnchorPosition(true, true)
						end)
					end
				end,
			},
			{
				name = "Relative point",
				kind = SettingType.Dropdown,
				field = "anchorRelativePoint",
				parentId = "cooldownPanelAnchor",
				height = 160,
				get = function()
					local a = ensureAnchorTable()
					return Helper.NormalizeAnchor(a and a.relativePoint, a and a.point or "CENTER")
				end,
				set = function(_, value)
					local a = ensureAnchorTable()
					if not a then return end
					a.relativePoint = Helper.NormalizeAnchor(value, a.relativePoint or "CENTER")
					applyAnchorPosition(true, true)
				end,
				generator = function(_, root)
					for _, option in ipairs(Helper.AnchorOptions) do
						root:CreateRadio(option.label, function()
							local a = ensureAnchorTable()
							return Helper.NormalizeAnchor(a and a.relativePoint, a and a.point or "CENTER") == option.value
						end, function()
							local a = ensureAnchorTable()
							if not a then return end
							a.relativePoint = option.value
							applyAnchorPosition(true, true)
						end)
					end
				end,
			},
			{
				name = "X Offset",
				kind = SettingType.Slider,
				allowInput = true,
				field = "anchorOffsetX",
				parentId = "cooldownPanelAnchor",
				minValue = -1000,
				maxValue = 1000,
				valueStep = 1,
				get = function()
					local a = ensureAnchorTable()
					return a and a.x or 0
				end,
				set = function(_, value)
					local a = ensureAnchorTable()
					if not a then return end
					local new = tonumber(value) or 0
					if a.x == new then return end
					a.x = new
					applyAnchorPosition(true, true)
				end,
				default = 0,
			},
			{
				name = "Y Offset",
				kind = SettingType.Slider,
				allowInput = true,
				field = "anchorOffsetY",
				parentId = "cooldownPanelAnchor",
				minValue = -1000,
				maxValue = 1000,
				valueStep = 1,
				get = function()
					local a = ensureAnchorTable()
					return a and a.y or 0
				end,
				set = function(_, value)
					local a = ensureAnchorTable()
					if not a then return end
					local new = tonumber(value) or 0
					if a.y == new then return end
					a.y = new
					applyAnchorPosition(true, true)
				end,
				default = 0,
			},
			{
				name = L["Layout"] or "Layout",
				kind = SettingType.Collapsible,
				id = "cooldownPanelLayout",
				defaultCollapsed = false,
			},
			{
				name = L["CooldownPanelLayoutMode"] or "Layout mode",
				kind = SettingType.Dropdown,
				field = "layoutMode",
				parentId = "cooldownPanelLayout",
				height = 80,
				get = function() return Helper.NormalizeLayoutMode(layout.layoutMode, Helper.PANEL_LAYOUT_DEFAULTS.layoutMode) end,
				set = function(_, value) applyEditLayout(panelId, "layoutMode", value) end,
				generator = function(_, root)
					for _, option in ipairs(Helper.LayoutModeOptions) do
						root:CreateRadio(
							option.label,
							function() return Helper.NormalizeLayoutMode(layout.layoutMode, Helper.PANEL_LAYOUT_DEFAULTS.layoutMode) == option.value end,
							function() applyEditLayout(panelId, "layoutMode", option.value) end
						)
					end
				end,
			},
			{
				name = "Icon size",
				kind = SettingType.Slider,
				field = "iconSize",
				parentId = "cooldownPanelLayout",
				default = layout.iconSize,
				minValue = 12,
				maxValue = 128,
				valueStep = 1,
				disabled = usesSeparateIconSize,
				get = function() return layout.iconSize end,
				set = function(_, value) applyEditLayout(panelId, "iconSize", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["CooldownPanelSeparateIconSize"] or "Separate icon width/height",
				kind = SettingType.Checkbox,
				field = "iconSizeSeparate",
				parentId = "cooldownPanelLayout",
				get = usesSeparateIconSize,
				set = function(_, value) applyEditLayout(panelId, "iconSizeSeparate", value) end,
			},
			{
				name = L["CooldownPanelIconWidth"] or "Icon width",
				kind = SettingType.Slider,
				field = "iconWidth",
				parentId = "cooldownPanelLayout",
				default = layout.iconWidth or layout.iconSize,
				minValue = 12,
				maxValue = 128,
				valueStep = 1,
				allowInput = true,
				isShown = usesSeparateIconSize,
				disabled = function() return not usesSeparateIconSize() end,
				get = function() return Helper.ClampInt(layout.iconWidth, 12, 128, layout.iconSize or Helper.PANEL_LAYOUT_DEFAULTS.iconSize) end,
				set = function(_, value) applyEditLayout(panelId, "iconWidth", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["CooldownPanelIconHeight"] or "Icon height",
				kind = SettingType.Slider,
				field = "iconHeight",
				parentId = "cooldownPanelLayout",
				default = layout.iconHeight or layout.iconSize,
				minValue = 12,
				maxValue = 128,
				valueStep = 1,
				allowInput = true,
				isShown = usesSeparateIconSize,
				disabled = function() return not usesSeparateIconSize() end,
				get = function() return Helper.ClampInt(layout.iconHeight, 12, 128, layout.iconSize or Helper.PANEL_LAYOUT_DEFAULTS.iconSize) end,
				set = function(_, value) applyEditLayout(panelId, "iconHeight", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = "Spacing",
				kind = SettingType.Slider,
				field = "spacing",
				parentId = "cooldownPanelLayout",
				default = layout.spacing,
				minValue = 0,
				maxValue = Helper.SPACING_RANGE or 200,
				valueStep = 1,
				allowInput = true,
				isShown = function() return not isRadialLayout() end,
				disabled = function() return isRadialLayout() end,
				get = function() return Helper.ClampInt(layout.spacing, 0, Helper.SPACING_RANGE or 200, Helper.PANEL_LAYOUT_DEFAULTS.spacing) end,
				set = function(_, value) applyEditLayout(panelId, "spacing", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = "Direction",
				kind = SettingType.Dropdown,
				field = "direction",
				parentId = "cooldownPanelLayout",
				height = 120,
				isShown = function() return not isRadialLayout() end,
				disabled = function() return isRadialLayout() end,
				get = function() return Helper.NormalizeDirection(layout.direction, Helper.PANEL_LAYOUT_DEFAULTS.direction) end,
				set = function(_, value) applyEditLayout(panelId, "direction", value) end,
				generator = function(_, root)
					for _, option in ipairs(Helper.DirectionOptions) do
						root:CreateRadio(
							option.label,
							function() return Helper.NormalizeDirection(layout.direction, Helper.PANEL_LAYOUT_DEFAULTS.direction) == option.value end,
							function() applyEditLayout(panelId, "direction", option.value) end
						)
					end
				end,
			},
			{
				name = L["CooldownPanelFixedSlotCount"] or "Fixed slots",
				kind = SettingType.Slider,
				field = "fixedSlotCount",
				parentId = "cooldownPanelLayout",
				default = Helper.NormalizeFixedGridSize(layout.fixedGridColumns, Helper.PANEL_LAYOUT_DEFAULTS.fixedGridColumns or 0),
				minValue = 0,
				maxValue = 40,
				valueStep = 1,
				allowInput = true,
				isShown = function() return not isRadialLayout() end,
				disabled = function() return not isFixedLayout() end,
				get = function() return Helper.NormalizeFixedGridSize(layout.fixedGridColumns, Helper.PANEL_LAYOUT_DEFAULTS.fixedGridColumns or 0) end,
				set = function(_, value) applyEditLayout(panelId, "fixedSlotCount", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["CooldownPanelFixedGridRows"] or "Grid rows",
				kind = SettingType.Slider,
				field = "fixedGridRows",
				parentId = "cooldownPanelLayout",
				default = Helper.NormalizeFixedGridSize(layout.fixedGridRows, Helper.PANEL_LAYOUT_DEFAULTS.fixedGridRows or 0),
				minValue = 0,
				maxValue = 40,
				valueStep = 1,
				allowInput = true,
				isShown = function() return not isRadialLayout() end,
				disabled = function() return not isFixedLayout() end,
				get = function() return Helper.NormalizeFixedGridSize(layout.fixedGridRows, Helper.PANEL_LAYOUT_DEFAULTS.fixedGridRows or 0) end,
				set = function(_, value) applyEditLayout(panelId, "fixedGridRows", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = "Wrap",
				kind = SettingType.Slider,
				field = "wrapCount",
				parentId = "cooldownPanelLayout",
				default = layout.wrapCount or 0,
				minValue = 0,
				maxValue = 40,
				valueStep = 1,
				isShown = function() return not isRadialLayout() end,
				disabled = function() return isRadialLayout() end,
				get = function() return layout.wrapCount or 0 end,
				set = function(_, value) applyEditLayout(panelId, "wrapCount", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = "Wrap direction",
				kind = SettingType.Dropdown,
				field = "wrapDirection",
				parentId = "cooldownPanelLayout",
				height = 120,
				isShown = function() return not isRadialLayout() end,
				disabled = function() return isRadialLayout() or (layout.wrapCount or 0) == 0 end,
				get = function() return Helper.NormalizeDirection(layout.wrapDirection, Helper.PANEL_LAYOUT_DEFAULTS.wrapDirection or "DOWN") end,
				set = function(_, value) applyEditLayout(panelId, "wrapDirection", value) end,
				generator = function(_, root)
					for _, option in ipairs(Helper.DirectionOptions) do
						root:CreateRadio(
							option.label,
							function() return Helper.NormalizeDirection(layout.wrapDirection, Helper.PANEL_LAYOUT_DEFAULTS.wrapDirection or "DOWN") == option.value end,
							function() applyEditLayout(panelId, "wrapDirection", option.value) end
						)
					end
				end,
			},
			{
				name = L["CooldownPanelGrowthPoint"] or "Growth point",
				kind = SettingType.Dropdown,
				field = "growthPoint",
				parentId = "cooldownPanelLayout",
				height = 90,
				isShown = function() return not isRadialLayout() end,
				disabled = function() return isRadialLayout() or (layout.wrapCount or 0) == 0 end,
				get = function() return Helper.NormalizeGrowthPoint(layout.growthPoint, Helper.PANEL_LAYOUT_DEFAULTS.growthPoint) end,
				set = function(_, value) applyEditLayout(panelId, "growthPoint", value) end,
				generator = function(_, root)
					for _, option in ipairs(Helper.GrowthPointOptions) do
						root:CreateRadio(
							option.label,
							function() return Helper.NormalizeGrowthPoint(layout.growthPoint, Helper.PANEL_LAYOUT_DEFAULTS.growthPoint) == option.value end,
							function() applyEditLayout(panelId, "growthPoint", option.value) end
						)
					end
				end,
			},
			{
				name = L["CooldownPanelRadialRadius"] or "Radius",
				kind = SettingType.Slider,
				field = "radialRadius",
				parentId = "cooldownPanelLayout",
				default = layout.radialRadius or Helper.PANEL_LAYOUT_DEFAULTS.radialRadius,
				minValue = 0,
				maxValue = Helper.RADIAL_RADIUS_RANGE or 600,
				valueStep = 1,
				allowInput = true,
				isShown = function() return isRadialLayout() end,
				disabled = function() return not isRadialLayout() end,
				get = function() return layout.radialRadius or Helper.PANEL_LAYOUT_DEFAULTS.radialRadius end,
				set = function(_, value) applyEditLayout(panelId, "radialRadius", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["CooldownPanelRadialRotation"] or "Rotation",
				kind = SettingType.Slider,
				field = "radialRotation",
				parentId = "cooldownPanelLayout",
				default = layout.radialRotation or Helper.PANEL_LAYOUT_DEFAULTS.radialRotation,
				minValue = -(Helper.RADIAL_ROTATION_RANGE or 360),
				maxValue = Helper.RADIAL_ROTATION_RANGE or 360,
				valueStep = 1,
				allowInput = true,
				isShown = function() return isRadialLayout() end,
				disabled = function() return not isRadialLayout() end,
				get = function() return layout.radialRotation or Helper.PANEL_LAYOUT_DEFAULTS.radialRotation end,
				set = function(_, value) applyEditLayout(panelId, "radialRotation", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["CooldownPanelRadialArcDegrees"] or "Arc degrees",
				kind = SettingType.Slider,
				field = "radialArcDegrees",
				parentId = "cooldownPanelLayout",
				default = layout.radialArcDegrees or Helper.PANEL_LAYOUT_DEFAULTS.radialArcDegrees or 360,
				minValue = Helper.RADIAL_ARC_DEGREES_MIN or 15,
				maxValue = Helper.RADIAL_ARC_DEGREES_MAX or 360,
				valueStep = 1,
				allowInput = true,
				isShown = function() return isRadialLayout() end,
				disabled = function() return not isRadialLayout() end,
				get = function() return layout.radialArcDegrees or Helper.PANEL_LAYOUT_DEFAULTS.radialArcDegrees or 360 end,
				set = function(_, value) applyEditLayout(panelId, "radialArcDegrees", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = "Strata",
				kind = SettingType.Dropdown,
				field = "strata",
				parentId = "cooldownPanelLayout",
				height = 200,
				get = function() return Helper.NormalizeStrata(layout.strata, Helper.PANEL_LAYOUT_DEFAULTS.strata) end,
				set = function(_, value) applyEditLayout(panelId, "strata", value) end,
				generator = function(_, root)
					for _, option in ipairs(Helper.STRATA_ORDER) do
						root:CreateRadio(
							option,
							function() return Helper.NormalizeStrata(layout.strata, Helper.PANEL_LAYOUT_DEFAULTS.strata) == option end,
							function() applyEditLayout(panelId, "strata", option) end
						)
					end
				end,
			},
			{
				name = L["CooldownPanelRowSizesHeader"] or "Row sizes",
				kind = SettingType.Collapsible,
				id = "cooldownPanelRowSizes",
				parentId = "cooldownPanelLayout",
				defaultCollapsed = true,
				isShown = function() return not isRadialLayout() end,
			},
			{
				name = (L["CooldownPanelRowSize"] or "Row %d size"):format(1),
				kind = SettingType.Slider,
				field = "rowSize1",
				parentId = "cooldownPanelRowSizes",
				default = getRowSizeValue(1),
				minValue = 12,
				maxValue = 128,
				valueStep = 1,
				allowInput = true,
				get = function() return getRowSizeValue(1) end,
				set = function(_, value) applyEditLayout(panelId, "rowSize1", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
				isShown = function() return shouldShowRowSize(1) end,
			},
			{
				name = (L["CooldownPanelRowSize"] or "Row %d size"):format(2),
				kind = SettingType.Slider,
				field = "rowSize2",
				parentId = "cooldownPanelRowSizes",
				default = getRowSizeValue(2),
				minValue = 12,
				maxValue = 128,
				valueStep = 1,
				allowInput = true,
				get = function() return getRowSizeValue(2) end,
				set = function(_, value) applyEditLayout(panelId, "rowSize2", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
				isShown = function() return shouldShowRowSize(2) end,
			},
			{
				name = (L["CooldownPanelRowSize"] or "Row %d size"):format(3),
				kind = SettingType.Slider,
				field = "rowSize3",
				parentId = "cooldownPanelRowSizes",
				default = getRowSizeValue(3),
				minValue = 12,
				maxValue = 128,
				valueStep = 1,
				allowInput = true,
				get = function() return getRowSizeValue(3) end,
				set = function(_, value) applyEditLayout(panelId, "rowSize3", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
				isShown = function() return shouldShowRowSize(3) end,
			},
			{
				name = (L["CooldownPanelRowSize"] or "Row %d size"):format(4),
				kind = SettingType.Slider,
				field = "rowSize4",
				parentId = "cooldownPanelRowSizes",
				default = getRowSizeValue(4),
				minValue = 12,
				maxValue = 128,
				valueStep = 1,
				allowInput = true,
				get = function() return getRowSizeValue(4) end,
				set = function(_, value) applyEditLayout(panelId, "rowSize4", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
				isShown = function() return shouldShowRowSize(4) end,
			},
			{
				name = (L["CooldownPanelRowSize"] or "Row %d size"):format(5),
				kind = SettingType.Slider,
				field = "rowSize5",
				parentId = "cooldownPanelRowSizes",
				default = getRowSizeValue(5),
				minValue = 12,
				maxValue = 128,
				valueStep = 1,
				allowInput = true,
				get = function() return getRowSizeValue(5) end,
				set = function(_, value) applyEditLayout(panelId, "rowSize5", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
				isShown = function() return shouldShowRowSize(5) end,
			},
			{
				name = (L["CooldownPanelRowSize"] or "Row %d size"):format(6),
				kind = SettingType.Slider,
				field = "rowSize6",
				parentId = "cooldownPanelRowSizes",
				default = getRowSizeValue(6),
				minValue = 12,
				maxValue = 128,
				valueStep = 1,
				allowInput = true,
				get = function() return getRowSizeValue(6) end,
				set = function(_, value) applyEditLayout(panelId, "rowSize6", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
				isShown = function() return shouldShowRowSize(6) end,
			},
			{
				name = L["Display"] or "Display",
				kind = SettingType.Collapsible,
				id = "cooldownPanelDisplay",
				defaultCollapsed = true,
			},
			{
				name = L["CooldownPanelShowTooltips"] or "Show tooltips",
				kind = SettingType.Checkbox,
				field = "showTooltips",
				parentId = "cooldownPanelDisplay",
				default = layout.showTooltips == true,
				get = function() return layout.showTooltips == true end,
				set = function(_, value) applyEditLayout(panelId, "showTooltips", value) end,
			},
			{
				name = L["CooldownPanelShowIconTexture"] or "Show icon texture",
				kind = SettingType.Checkbox,
				field = "showIconTexture",
				parentId = "cooldownPanelDisplay",
				default = layout.showIconTexture ~= false,
				get = function() return layout.showIconTexture ~= false end,
				set = function(_, value) applyEditLayout(panelId, "showIconTexture", value) end,
			},
			{
				name = L["CooldownPanelIconBorder"] or "Icon border",
				kind = SettingType.CheckboxColor,
				field = "iconBorderEnabled",
				parentId = "cooldownPanelDisplay",
				default = layout.iconBorderEnabled == true,
				get = function() return layout.iconBorderEnabled == true end,
				set = function(_, value) applyEditLayout(panelId, "iconBorderEnabled", value) end,
				colorDefault = Helper.NormalizeColor(layout.iconBorderColor, Helper.PANEL_LAYOUT_DEFAULTS.iconBorderColor),
				colorGet = function() return layout.iconBorderColor or Helper.PANEL_LAYOUT_DEFAULTS.iconBorderColor end,
				colorSet = function(_, value) applyEditLayout(panelId, "iconBorderColor", value) end,
				hasOpacity = true,
			},
			{
				name = L["Border texture"] or "Border texture",
				kind = SettingType.Dropdown,
				field = "iconBorderTexture",
				parentId = "cooldownPanelDisplay",
				height = 180,
				disabled = function() return layout.iconBorderEnabled ~= true end,
				default = normalizeIconBorderTexture(layout.iconBorderTexture, Helper.PANEL_LAYOUT_DEFAULTS.iconBorderTexture),
				get = function() return normalizeIconBorderTexture(layout.iconBorderTexture, Helper.PANEL_LAYOUT_DEFAULTS.iconBorderTexture) end,
				set = function(_, value) applyEditLayout(panelId, "iconBorderTexture", value) end,
				generator = function(_, root)
					for _, option in ipairs(iconBorderOptions()) do
						root:CreateRadio(
							option.label,
							function() return normalizeIconBorderTexture(layout.iconBorderTexture, Helper.PANEL_LAYOUT_DEFAULTS.iconBorderTexture) == option.value end,
							function() applyEditLayout(panelId, "iconBorderTexture", option.value) end
						)
					end
				end,
			},
			{
				name = L["Border size"] or "Border size",
				kind = SettingType.Slider,
				field = "iconBorderSize",
				parentId = "cooldownPanelDisplay",
				default = Helper.ClampInt(layout.iconBorderSize, 1, 64, Helper.PANEL_LAYOUT_DEFAULTS.iconBorderSize),
				minValue = 1,
				maxValue = 64,
				valueStep = 1,
				allowInput = true,
				disabled = function() return layout.iconBorderEnabled ~= true or cdp.ENTRY.IsBlizzardIconBorderTexture(layout.iconBorderTexture) end,
				get = function() return Helper.ClampInt(layout.iconBorderSize, 1, 64, Helper.PANEL_LAYOUT_DEFAULTS.iconBorderSize) end,
				set = function(_, value) applyEditLayout(panelId, "iconBorderSize", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["Border offset"] or "Border offset",
				kind = SettingType.Slider,
				field = "iconBorderOffset",
				parentId = "cooldownPanelDisplay",
				default = Helper.ClampInt(layout.iconBorderOffset, -64, 64, Helper.PANEL_LAYOUT_DEFAULTS.iconBorderOffset),
				minValue = -64,
				maxValue = 64,
				valueStep = 1,
				allowInput = true,
				disabled = function() return layout.iconBorderEnabled ~= true or cdp.ENTRY.IsBlizzardIconBorderTexture(layout.iconBorderTexture) end,
				get = function() return Helper.ClampInt(layout.iconBorderOffset, -64, 64, Helper.PANEL_LAYOUT_DEFAULTS.iconBorderOffset) end,
				set = function(_, value) applyEditLayout(panelId, "iconBorderOffset", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["CooldownPanelHideOnCooldown"] or "Hide on cooldown",
				kind = SettingType.Checkbox,
				field = "hideOnCooldown",
				parentId = "cooldownPanelDisplay",
				default = layout.hideOnCooldown == true,
				get = function() return layout.hideOnCooldown == true end,
				set = function(_, value) applyEditLayout(panelId, "hideOnCooldown", value) end,
			},
			{
				name = L["CooldownPanelShowOnCooldown"] or "Show on cooldown",
				kind = SettingType.Checkbox,
				field = "showOnCooldown",
				parentId = "cooldownPanelDisplay",
				default = layout.showOnCooldown == true,
				get = function() return layout.showOnCooldown == true end,
				set = function(_, value) applyEditLayout(panelId, "showOnCooldown", value) end,
			},
			{
				name = L["CooldownPanelCDMAuraAlwaysShowMode"] or "Tracked aura display",
				kind = SettingType.Dropdown,
				field = "cdmAuraAlwaysShowMode",
				parentId = "cooldownPanelDisplay",
				height = 180,
				isShown = function() return hasCDMAuraEntries() end,
				default = CooldownPanels:ResolveEntryCDMAuraAlwaysShowMode(layout, nil),
				get = function() return CooldownPanels:ResolveEntryCDMAuraAlwaysShowMode(layout, nil) end,
				set = function(_, value) applyEditLayout(panelId, "cdmAuraAlwaysShowMode", value) end,
				generator = function(_, root)
					for _, option in ipairs(CooldownPanels:GetCDMAuraAlwaysShowOptions()) do
						root:CreateRadio(
							option.label,
							function() return CooldownPanels:ResolveEntryCDMAuraAlwaysShowMode(layout, nil) == option.value end,
							function() applyEditLayout(panelId, "cdmAuraAlwaysShowMode", option.value) end
						)
					end
				end,
			},
			{
				name = L["Show when"] or "Show when",
				kind = SettingType.MultiDropdown,
				field = "visibility",
				parentId = "cooldownPanelDisplay",
				height = 220,
				values = visibilityRuleOptions,
				hideSummary = true,
				default = PanelVisibility.CopySelectionMap(PanelVisibility.NormalizeConfig(layout.visibility)),
				isSelected = function(_, value)
					local cfg = PanelVisibility.NormalizeConfig(layout.visibility)
					return cfg and cfg[value] == true or false
				end,
				setSelected = function(_, value, state)
					local cfg = PanelVisibility.NormalizeConfig(layout.visibility) or {}
					if value == "ALWAYS_HIDDEN" and state then
						cfg = { ALWAYS_HIDDEN = true }
					elseif state then
						cfg[value] = true
						cfg.ALWAYS_HIDDEN = nil
					else
						cfg[value] = nil
					end
					if not next(cfg) then cfg = nil end
					applyEditLayout(panelId, "visibility", cfg)
				end,
				isShown = function() return visibilityRuleOptions and #visibilityRuleOptions > 0 end,
				isEnabled = function() return visibilityRuleOptions and #visibilityRuleOptions > 0 end,
			},
			{
				name = L["Hide in vehicles"] or "Hide in vehicles",
				kind = SettingType.Checkbox,
				field = "hideInVehicle",
				parentId = "cooldownPanelDisplay",
				default = layout.hideInVehicle == true,
				get = function() return layout.hideInVehicle == true end,
				set = function(_, value) applyEditLayout(panelId, "hideInVehicle", value) end,
			},
			{
				name = L["Hide in pet battles"] or "Hide in pet battles",
				kind = SettingType.Checkbox,
				field = "hideInPetBattle",
				parentId = "cooldownPanelDisplay",
				default = layout.hideInPetBattle == true,
				get = function() return layout.hideInPetBattle == true end,
				set = function(_, value) applyEditLayout(panelId, "hideInPetBattle", value) end,
			},
			{
				name = L["Hide in client scenes"] or "Hide in client scenes",
				kind = SettingType.Checkbox,
				field = "hideInClientScene",
				parentId = "cooldownPanelDisplay",
				default = layout.hideInClientScene ~= false,
				get = function() return layout.hideInClientScene ~= false end,
				set = function(_, value) applyEditLayout(panelId, "hideInClientScene", value) end,
			},
			{
				name = L["CooldownPanelOpacityOutOfCombat"] or "Opacity (out of combat)",
				kind = SettingType.Slider,
				field = "opacityOutOfCombat",
				parentId = "cooldownPanelDisplay",
				default = Helper.NormalizeOpacity(layout.opacityOutOfCombat, Helper.PANEL_LAYOUT_DEFAULTS.opacityOutOfCombat),
				minValue = 0,
				maxValue = 1,
				valueStep = 0.05,
				allowInput = true,
				get = function() return Helper.NormalizeOpacity(layout.opacityOutOfCombat, Helper.PANEL_LAYOUT_DEFAULTS.opacityOutOfCombat) end,
				set = function(_, value) applyEditLayout(panelId, "opacityOutOfCombat", value) end,
				formatter = function(value)
					local num = tonumber(value) or 0
					return tostring(math.floor((num * 100) + 0.5)) .. "%"
				end,
			},
			{
				name = L["CooldownPanelOpacityInCombat"] or "Opacity (in combat)",
				kind = SettingType.Slider,
				field = "opacityInCombat",
				parentId = "cooldownPanelDisplay",
				default = Helper.NormalizeOpacity(layout.opacityInCombat, Helper.PANEL_LAYOUT_DEFAULTS.opacityInCombat),
				minValue = 0,
				maxValue = 1,
				valueStep = 0.05,
				allowInput = true,
				get = function() return Helper.NormalizeOpacity(layout.opacityInCombat, Helper.PANEL_LAYOUT_DEFAULTS.opacityInCombat) end,
				set = function(_, value) applyEditLayout(panelId, "opacityInCombat", value) end,
				formatter = function(value)
					local num = tonumber(value) or 0
					return tostring(math.floor((num * 100) + 0.5)) .. "%"
				end,
			},
			{
				name = L["Cooldown text"] or "Cooldown text",
				kind = SettingType.Collapsible,
				id = "cooldownPanelCooldownText",
				defaultCollapsed = true,
			},
			{
				name = L["Cooldown text font"] or "Cooldown text font",
				kind = SettingType.Dropdown,
				field = "cooldownTextFont",
				parentId = "cooldownPanelCooldownText",
				height = 160,
				default = layout.cooldownTextFont or countFontPath,
				get = function() return layout.cooldownTextFont or countFontPath end,
				set = function(_, value) applyEditLayout(panelId, "cooldownTextFont", value) end,
				generator = function(_, root)
					for _, option in ipairs(fontOptions()) do
						root:CreateRadio(
							option.label,
							function() return (layout.cooldownTextFont or countFontPath) == option.value end,
							function() applyEditLayout(panelId, "cooldownTextFont", option.value) end
						)
					end
				end,
			},
			{
				name = L["Cooldown text size"] or "Cooldown text size",
				kind = SettingType.Slider,
				field = "cooldownTextSize",
				parentId = "cooldownPanelCooldownText",
				default = layout.cooldownTextSize or countFontSize or 12,
				minValue = 6,
				maxValue = 64,
				valueStep = 1,
				allowInput = true,
				get = function() return layout.cooldownTextSize or countFontSize or 12 end,
				set = function(_, value) applyEditLayout(panelId, "cooldownTextSize", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["Cooldown text outline"] or "Cooldown text outline",
				kind = SettingType.Dropdown,
				field = "cooldownTextStyle",
				parentId = "cooldownPanelCooldownText",
				height = 120,
				default = Helper.NormalizeFontStyleChoice(layout.cooldownTextStyle, Helper.PANEL_LAYOUT_DEFAULTS.cooldownTextStyle or "NONE"),
				get = function() return Helper.NormalizeFontStyleChoice(layout.cooldownTextStyle, Helper.PANEL_LAYOUT_DEFAULTS.cooldownTextStyle or "NONE") end,
				set = function(_, value) applyEditLayout(panelId, "cooldownTextStyle", value) end,
				generator = function(_, root)
					for _, option in ipairs(Helper.FontStyleOptions) do
						root:CreateRadio(
							option.label,
							function() return Helper.NormalizeFontStyleChoice(layout.cooldownTextStyle, Helper.PANEL_LAYOUT_DEFAULTS.cooldownTextStyle or "NONE") == option.value end,
							function() applyEditLayout(panelId, "cooldownTextStyle", option.value) end
						)
					end
				end,
			},
			{
				name = L["Cooldown text color"] or "Cooldown text color",
				kind = SettingType.Color,
				parentId = "cooldownPanelCooldownText",
				hasOpacity = true,
				default = Helper.NormalizeColor(layout.cooldownTextColor, Helper.PANEL_LAYOUT_DEFAULTS.cooldownTextColor),
				get = function()
					local color = Helper.NormalizeColor(layout.cooldownTextColor, Helper.PANEL_LAYOUT_DEFAULTS.cooldownTextColor)
					return { r = color[1], g = color[2], b = color[3], a = color[4] }
				end,
				set = function(_, value) applyEditLayout(panelId, "cooldownTextColor", value) end,
			},
			{
				name = L["Cooldown text offset X"] or "Cooldown text offset X",
				kind = SettingType.Slider,
				field = "cooldownTextX",
				parentId = "cooldownPanelCooldownText",
				default = layout.cooldownTextX or 0,
				minValue = -Helper.OFFSET_RANGE,
				maxValue = Helper.OFFSET_RANGE,
				valueStep = 1,
				allowInput = true,
				get = function() return layout.cooldownTextX or 0 end,
				set = function(_, value) applyEditLayout(panelId, "cooldownTextX", value) end,
			},
			{
				name = L["Cooldown text offset Y"] or "Cooldown text offset Y",
				kind = SettingType.Slider,
				field = "cooldownTextY",
				parentId = "cooldownPanelCooldownText",
				default = layout.cooldownTextY or 0,
				minValue = -Helper.OFFSET_RANGE,
				maxValue = Helper.OFFSET_RANGE,
				valueStep = 1,
				allowInput = true,
				get = function() return layout.cooldownTextY or 0 end,
				set = function(_, value) applyEditLayout(panelId, "cooldownTextY", value) end,
			},
			{
				name = L["CooldownPanelStaticText"] or "Static text",
				kind = SettingType.Collapsible,
				id = "cooldownPanelStaticText",
				defaultCollapsed = true,
				isShown = function() return hasStaticTextEntries() end,
			},
			{
				name = L["Font"] or "Font",
				kind = SettingType.Dropdown,
				parentId = "cooldownPanelStaticText",
				height = 220,
				isShown = function() return hasStaticTextEntries() end,
				get = function()
					local entry = getStaticTextEntry()
					local configured = entry and entry.staticTextFont
					if type(configured) == "string" and configured ~= "" then return configured end
					return countFontPath
				end,
				set = function(_, value)
					local entry = getStaticTextEntry()
					if not entry then return end
					updateStaticTextEntry(entry, "staticTextFont", value)
				end,
				generator = function(_, root)
					for _, option in ipairs(fontOptions()) do
						root:CreateRadio(option.label, function()
							local entry = getStaticTextEntry()
							if not entry then return false end
							local configured = entry.staticTextFont
							if type(configured) == "string" and configured ~= "" then return configured == option.value end
							return countFontPath == option.value
						end, function()
							local entry = getStaticTextEntry()
							if not entry then return end
							updateStaticTextEntry(entry, "staticTextFont", option.value)
						end)
					end
				end,
			},
			{
				name = L["CooldownPanelFontStyle"] or "Font style",
				kind = SettingType.Dropdown,
				parentId = "cooldownPanelStaticText",
				height = 120,
				isShown = function() return hasStaticTextEntries() end,
				get = function()
					local entry = getStaticTextEntry()
					return Helper.NormalizeFontStyleChoice(entry and entry.staticTextStyle, Helper.ENTRY_DEFAULTS.staticTextStyle or "OUTLINE")
				end,
				set = function(_, value)
					local entry = getStaticTextEntry()
					if not entry then return end
					updateStaticTextEntry(entry, "staticTextStyle", Helper.NormalizeFontStyleChoice(value, Helper.ENTRY_DEFAULTS.staticTextStyle or "OUTLINE"))
				end,
				generator = function(_, root)
					for _, option in ipairs(Helper.FontStyleOptions) do
						root:CreateRadio(option.label, function()
							local entry = getStaticTextEntry()
							return Helper.NormalizeFontStyleChoice(entry and entry.staticTextStyle, Helper.ENTRY_DEFAULTS.staticTextStyle or "OUTLINE") == option.value
						end, function()
							local entry = getStaticTextEntry()
							if not entry then return end
							updateStaticTextEntry(entry, "staticTextStyle", option.value)
						end)
					end
				end,
			},
			{
				name = L["CooldownPanelStaticTextColor"] or _G.COLOR or "Color",
				kind = SettingType.Color,
				parentId = "cooldownPanelStaticText",
				hasOpacity = true,
				isShown = function() return hasStaticTextEntries() end,
				default = Helper.NormalizeColor(Helper.ENTRY_DEFAULTS.staticTextColor, { 1, 1, 1, 1 }),
				get = function()
					local entry = getStaticTextEntry()
					local color = Helper.NormalizeColor(entry and entry.staticTextColor, Helper.ENTRY_DEFAULTS.staticTextColor or { 1, 1, 1, 1 })
					return { r = color[1], g = color[2], b = color[3], a = color[4] }
				end,
				set = function(_, value)
					local entry = getStaticTextEntry()
					if not entry then return end
					updateStaticTextEntry(entry, "staticTextColor", Helper.NormalizeColor(value, Helper.ENTRY_DEFAULTS.staticTextColor or { 1, 1, 1, 1 }))
				end,
			},
			{
				name = FONT_SIZE,
				kind = SettingType.Slider,
				parentId = "cooldownPanelStaticText",
				minValue = 6,
				maxValue = 64,
				valueStep = 1,
				allowInput = true,
				isShown = function() return hasStaticTextEntries() end,
				get = function()
					local entry = getStaticTextEntry()
					return Helper.ClampInt(entry and entry.staticTextSize, 6, 64, Helper.ENTRY_DEFAULTS.staticTextSize or countFontSize or 12)
				end,
				set = function(_, value)
					local entry = getStaticTextEntry()
					if not entry then return end
					local size = Helper.ClampInt(value, 6, 64, Helper.ENTRY_DEFAULTS.staticTextSize or countFontSize or 12)
					updateStaticTextEntry(entry, "staticTextSize", size)
				end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["Anchor"] or "Anchor",
				kind = SettingType.Dropdown,
				parentId = "cooldownPanelStaticText",
				height = 160,
				isShown = function() return hasStaticTextEntries() end,
				get = function()
					local entry = getStaticTextEntry()
					return Helper.NormalizeAnchor(entry and entry.staticTextAnchor, Helper.ENTRY_DEFAULTS.staticTextAnchor or "CENTER")
				end,
				set = function(_, value)
					local entry = getStaticTextEntry()
					if not entry then return end
					updateStaticTextEntry(entry, "staticTextAnchor", Helper.NormalizeAnchor(value, Helper.ENTRY_DEFAULTS.staticTextAnchor or "CENTER"))
				end,
				generator = function(_, root)
					for _, option in ipairs(Helper.AnchorOptions) do
						root:CreateRadio(option.label, function()
							local entry = getStaticTextEntry()
							return Helper.NormalizeAnchor(entry and entry.staticTextAnchor, Helper.ENTRY_DEFAULTS.staticTextAnchor or "CENTER") == option.value
						end, function()
							local entry = getStaticTextEntry()
							if not entry then return end
							updateStaticTextEntry(entry, "staticTextAnchor", option.value)
						end)
					end
				end,
			},
			{
				name = L["Text X Offset"] or "Text X Offset",
				kind = SettingType.Slider,
				parentId = "cooldownPanelStaticText",
				minValue = -Helper.OFFSET_RANGE,
				maxValue = Helper.OFFSET_RANGE,
				valueStep = 1,
				allowInput = true,
				isShown = function() return hasStaticTextEntries() end,
				get = function()
					local entry = getStaticTextEntry()
					return Helper.ClampInt(entry and entry.staticTextX, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
				end,
				set = function(_, value)
					local entry = getStaticTextEntry()
					if not entry then return end
					local offset = Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
					updateStaticTextEntry(entry, "staticTextX", offset)
				end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["Text Y Offset"] or "Text Y Offset",
				kind = SettingType.Slider,
				parentId = "cooldownPanelStaticText",
				minValue = -Helper.OFFSET_RANGE,
				maxValue = Helper.OFFSET_RANGE,
				valueStep = 1,
				allowInput = true,
				isShown = function() return hasStaticTextEntries() end,
				get = function()
					local entry = getStaticTextEntry()
					return Helper.ClampInt(entry and entry.staticTextY, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
				end,
				set = function(_, value)
					local entry = getStaticTextEntry()
					if not entry then return end
					local offset = Helper.ClampInt(value, -Helper.OFFSET_RANGE, Helper.OFFSET_RANGE, 0)
					updateStaticTextEntry(entry, "staticTextY", offset)
				end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["CooldownPanelOverlaysHeader"] or "Overlays",
				kind = SettingType.Collapsible,
				id = "cooldownPanelOverlays",
				defaultCollapsed = true,
			},
			{
				name = L["CooldownPanelRangeOverlay"] or "Range overlay",
				kind = SettingType.CheckboxColor,
				parentId = "cooldownPanelOverlays",
				default = layout.rangeOverlayEnabled == true,
				get = function() return layout.rangeOverlayEnabled == true end,
				set = function(_, value) applyEditLayout(panelId, "rangeOverlayEnabled", value) end,
				colorDefault = Helper.NormalizeColor(layout.rangeOverlayColor, Helper.PANEL_LAYOUT_DEFAULTS.rangeOverlayColor),
				colorGet = function() return layout.rangeOverlayColor or Helper.PANEL_LAYOUT_DEFAULTS.rangeOverlayColor end,
				colorSet = function(_, value) applyEditLayout(panelId, "rangeOverlayColor", value) end,
				hasOpacity = true,
			},
			{
				name = L["CooldownPanelNoDesaturation"] or "No desaturation",
				kind = SettingType.Checkbox,
				parentId = "cooldownPanelOverlays",
				default = layout.noDesaturation == true,
				get = function() return layout.noDesaturation == true end,
				set = function(_, value) applyEditLayout(panelId, "noDesaturation", value) end,
			},
			{
				name = L["Check power"] or "Check power",
				kind = SettingType.CheckboxColor,
				parentId = "cooldownPanelOverlays",
				default = layout.checkPower == true,
				get = function() return layout.checkPower == true end,
				set = function(_, value) applyEditLayout(panelId, "checkPower", value) end,
				colorDefault = Helper.NormalizeColor(layout.powerTintColor, Helper.PANEL_LAYOUT_DEFAULTS.powerTintColor),
				colorGet = function() return layout.powerTintColor or Helper.PANEL_LAYOUT_DEFAULTS.powerTintColor end,
				colorSet = function(_, value) applyEditLayout(panelId, "powerTintColor", value) end,
			},
			{
				name = L["CooldownPanelHideWhenNoResource"] or "Hide when no resource",
				kind = SettingType.Checkbox,
				parentId = "cooldownPanelOverlays",
				default = layout.hideWhenNoResource == true,
				get = function() return layout.hideWhenNoResource == true end,
				set = function(_, value) applyEditLayout(panelId, "hideWhenNoResource", value) end,
			},
			{
				name = _G.GLOW or "Glow",
				kind = SettingType.Collapsible,
				id = "cooldownPanelGlow",
				defaultCollapsed = true,
			},
			{
				name = L["CooldownPanelProcGlow"] or "Proc glow",
				kind = SettingType.Checkbox,
				parentId = "cooldownPanelGlow",
				default = layout.procGlowEnabled ~= false,
				get = function() return layout.procGlowEnabled ~= false end,
				set = function(_, value) applyEditLayout(panelId, "procGlowEnabled", value) end,
			},
			{
				name = L["CooldownPanelHideGlowOutOfCombat"] or "Hide glow out of combat",
				kind = SettingType.Checkbox,
				parentId = "cooldownPanelGlow",
				default = layout.hideGlowOutOfCombat == true,
				get = function() return layout.hideGlowOutOfCombat == true end,
				set = function(_, value) applyEditLayout(panelId, "hideGlowOutOfCombat", value) end,
			},
			{
				name = L["CooldownPanelProcGlowStyle"] or "Proc glow style",
				kind = SettingType.Dropdown,
				parentId = "cooldownPanelGlow",
				height = 180,
				default = Helper.NormalizeGlowStyle(select(1, CooldownPanels:ResolveEntryProcGlowVisual(layout, nil)), layout.readyGlowStyle or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle),
				get = function()
					return Helper.NormalizeGlowStyle(select(1, CooldownPanels:ResolveEntryProcGlowVisual(layout, nil)), layout.readyGlowStyle or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle)
				end,
				set = function(_, value) applyEditLayout(panelId, "procGlowStyle", value) end,
				generator = function(_, root)
					for _, option in ipairs(CooldownPanels:GetGlowStyleOptions(panelId)) do
						local label = L[option.labelKey] or option.fallback
						root:CreateRadio(
							label,
							function()
								return Helper.NormalizeGlowStyle(
									select(1, CooldownPanels:ResolveEntryProcGlowVisual(layout, nil)),
									layout.readyGlowStyle or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle
								) == option.value
							end,
							function() applyEditLayout(panelId, "procGlowStyle", option.value) end
						)
					end
				end,
			},
			{
				name = L["CooldownPanelProcGlowInset"] or "Proc glow inset",
				kind = SettingType.Slider,
				parentId = "cooldownPanelGlow",
				minValue = -(Helper.GLOW_INSET_RANGE or 20),
				maxValue = Helper.GLOW_INSET_RANGE or 20,
				valueStep = 1,
				allowInput = true,
				default = select(2, CooldownPanels:ResolveEntryProcGlowVisual(layout, nil)),
				get = function() return select(2, CooldownPanels:ResolveEntryProcGlowVisual(layout, nil)) end,
				set = function(_, value) applyEditLayout(panelId, "procGlowInset", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["CooldownPanelReadyGlowCheckPower"] or "Require resource for ready glow",
				kind = SettingType.Checkbox,
				parentId = "cooldownPanelGlow",
				default = layout.readyGlowCheckPower == true,
				get = function() return layout.readyGlowCheckPower == true end,
				set = function(_, value) applyEditLayout(panelId, "readyGlowCheckPower", value) end,
			},
			{
				name = L["Glow style"] or "Glow style",
				kind = SettingType.Dropdown,
				parentId = "cooldownPanelGlow",
				height = 180,
				default = Helper.NormalizeGlowStyle(layout.readyGlowStyle, Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle),
				get = function() return Helper.NormalizeGlowStyle(layout.readyGlowStyle, Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle) end,
				set = function(_, value) applyEditLayout(panelId, "readyGlowStyle", value) end,
				generator = function(_, root)
					for _, option in ipairs(CooldownPanels:GetGlowStyleOptions(panelId)) do
						local label = L[option.labelKey] or option.fallback
						root:CreateRadio(
							label,
							function() return Helper.NormalizeGlowStyle(layout.readyGlowStyle, Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle) == option.value end,
							function() applyEditLayout(panelId, "readyGlowStyle", option.value) end
						)
					end
				end,
			},
			{
				name = L["CooldownPanelGlowStylePandemic"] or "Pandemic glow style",
				kind = SettingType.Dropdown,
				parentId = "cooldownPanelGlow",
				height = 180,
				default = Helper.NormalizeGlowStyle(layout.pandemicGlowStyle, layout.readyGlowStyle or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle),
				get = function() return Helper.NormalizeGlowStyle(layout.pandemicGlowStyle, layout.readyGlowStyle or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle) end,
				set = function(_, value) applyEditLayout(panelId, "pandemicGlowStyle", value) end,
				generator = function(_, root)
					for _, option in ipairs(CooldownPanels:GetGlowStyleOptions(panelId)) do
						local label = L[option.labelKey] or option.fallback
						root:CreateRadio(
							label,
							function() return Helper.NormalizeGlowStyle(layout.pandemicGlowStyle, layout.readyGlowStyle or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowStyle) == option.value end,
							function() applyEditLayout(panelId, "pandemicGlowStyle", option.value) end
						)
					end
				end,
			},
			{
				name = L["Glow inset"] or "Glow inset",
				kind = SettingType.Slider,
				parentId = "cooldownPanelGlow",
				minValue = -(Helper.GLOW_INSET_RANGE or 20),
				maxValue = Helper.GLOW_INSET_RANGE or 20,
				valueStep = 1,
				allowInput = true,
				default = Helper.NormalizeGlowInset(layout.readyGlowInset, Helper.PANEL_LAYOUT_DEFAULTS.readyGlowInset or 0),
				get = function() return Helper.NormalizeGlowInset(layout.readyGlowInset, Helper.PANEL_LAYOUT_DEFAULTS.readyGlowInset or 0) end,
				set = function(_, value) applyEditLayout(panelId, "readyGlowInset", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["CooldownPanelGlowColor"] or "Ready glow color",
				kind = SettingType.Color,
				parentId = "cooldownPanelGlow",
				hasOpacity = true,
				default = Helper.NormalizeColor(layout.readyGlowColor, Helper.PANEL_LAYOUT_DEFAULTS.readyGlowColor),
				get = function()
					local color = Helper.NormalizeColor(layout.readyGlowColor, Helper.PANEL_LAYOUT_DEFAULTS.readyGlowColor)
					return { r = color[1], g = color[2], b = color[3], a = color[4] }
				end,
				set = function(_, value) applyEditLayout(panelId, "readyGlowColor", value) end,
			},
			{
				name = L["CooldownPanelGlowColorPandemic"] or "Pandemic glow color",
				kind = SettingType.Color,
				parentId = "cooldownPanelGlow",
				hasOpacity = true,
				default = Helper.NormalizeColor(layout.pandemicGlowColor, layout.readyGlowColor or Helper.PANEL_LAYOUT_DEFAULTS.pandemicGlowColor or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowColor),
				get = function()
					local color =
						Helper.NormalizeColor(layout.pandemicGlowColor, layout.readyGlowColor or Helper.PANEL_LAYOUT_DEFAULTS.pandemicGlowColor or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowColor)
					return { r = color[1], g = color[2], b = color[3], a = color[4] }
				end,
				set = function(_, value) applyEditLayout(panelId, "pandemicGlowColor", value) end,
			},
			{
				name = L["CooldownPanelGlowInsetPandemic"] or "Pandemic glow inset",
				kind = SettingType.Slider,
				parentId = "cooldownPanelGlow",
				minValue = -(Helper.GLOW_INSET_RANGE or 20),
				maxValue = Helper.GLOW_INSET_RANGE or 20,
				valueStep = 1,
				allowInput = true,
				default = Helper.NormalizeGlowInset(layout.pandemicGlowInset, layout.readyGlowInset or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowInset or 0),
				get = function() return Helper.NormalizeGlowInset(layout.pandemicGlowInset, layout.readyGlowInset or Helper.PANEL_LAYOUT_DEFAULTS.readyGlowInset or 0) end,
				set = function(_, value) applyEditLayout(panelId, "pandemicGlowInset", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["CooldownPanelStacksHeader"] or "Stacks / Item Count",
				kind = SettingType.Collapsible,
				id = "cooldownPanelStacks",
				defaultCollapsed = true,
			},
			{
				name = L["CooldownPanelCountAnchor"] or "Count anchor",
				kind = SettingType.Dropdown,
				field = "stackAnchor",
				parentId = "cooldownPanelStacks",
				height = 160,
				get = function() return Helper.NormalizeAnchor(layout.stackAnchor, Helper.PANEL_LAYOUT_DEFAULTS.stackAnchor) end,
				set = function(_, value) applyEditLayout(panelId, "stackAnchor", value) end,
				generator = function(_, root)
					for _, option in ipairs(Helper.AnchorOptions) do
						root:CreateRadio(
							option.label,
							function() return Helper.NormalizeAnchor(layout.stackAnchor, Helper.PANEL_LAYOUT_DEFAULTS.stackAnchor) == option.value end,
							function() applyEditLayout(panelId, "stackAnchor", option.value) end
						)
					end
				end,
			},
			{
				name = L["CooldownPanelCountOffsetX"] or "Count X",
				kind = SettingType.Slider,
				field = "stackX",
				parentId = "cooldownPanelStacks",
				default = layout.stackX or Helper.PANEL_LAYOUT_DEFAULTS.stackX,
				minValue = -Helper.OFFSET_RANGE,
				maxValue = Helper.OFFSET_RANGE,
				valueStep = 1,
				get = function() return layout.stackX or Helper.PANEL_LAYOUT_DEFAULTS.stackX end,
				set = function(_, value) applyEditLayout(panelId, "stackX", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["CooldownPanelCountOffsetY"] or "Count Y",
				kind = SettingType.Slider,
				field = "stackY",
				parentId = "cooldownPanelStacks",
				default = layout.stackY or Helper.PANEL_LAYOUT_DEFAULTS.stackY,
				minValue = -Helper.OFFSET_RANGE,
				maxValue = Helper.OFFSET_RANGE,
				valueStep = 1,
				get = function() return layout.stackY or Helper.PANEL_LAYOUT_DEFAULTS.stackY end,
				set = function(_, value) applyEditLayout(panelId, "stackY", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["Font"] or "Font",
				kind = SettingType.Dropdown,
				field = "stackFont",
				parentId = "cooldownPanelStacks",
				height = 220,
				get = function() return layout.stackFont or countFontPath end,
				set = function(_, value) applyEditLayout(panelId, "stackFont", value) end,
				generator = function(_, root)
					for _, option in ipairs(fontOptions()) do
						root:CreateRadio(option.label, function() return (layout.stackFont or countFontPath) == option.value end, function() applyEditLayout(panelId, "stackFont", option.value) end)
					end
				end,
			},
			{
				name = L["CooldownPanelFontStyle"] or "Font style",
				kind = SettingType.Dropdown,
				field = "stackFontStyle",
				parentId = "cooldownPanelStacks",
				height = 120,
				get = function() return Helper.NormalizeFontStyleChoice(layout.stackFontStyle, countFontStyle) end,
				set = function(_, value) applyEditLayout(panelId, "stackFontStyle", value) end,
				generator = function(_, root)
					for _, option in ipairs(Helper.FontStyleOptions) do
						root:CreateRadio(
							option.label,
							function() return Helper.NormalizeFontStyleChoice(layout.stackFontStyle, countFontStyle) == option.value end,
							function() applyEditLayout(panelId, "stackFontStyle", option.value) end
						)
					end
				end,
			},
			{
				name = FONT_SIZE,
				kind = SettingType.Slider,
				field = "stackFontSize",
				parentId = "cooldownPanelStacks",
				default = layout.stackFontSize or countFontSize or 12,
				minValue = 6,
				maxValue = 64,
				valueStep = 1,
				get = function() return layout.stackFontSize or countFontSize or 12 end,
				set = function(_, value) applyEditLayout(panelId, "stackFontSize", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = _G.COLOR or "Color",
				kind = SettingType.Color,
				field = "stackColor",
				parentId = "cooldownPanelStacks",
				hasOpacity = true,
				default = Helper.NormalizeColor(layout.stackColor, Helper.PANEL_LAYOUT_DEFAULTS.stackColor or { 1, 1, 1, 1 }),
				get = function()
					local color = Helper.NormalizeColor(layout.stackColor, Helper.PANEL_LAYOUT_DEFAULTS.stackColor or { 1, 1, 1, 1 })
					return { r = color[1], g = color[2], b = color[3], a = color[4] }
				end,
				set = function(_, value) applyEditLayout(panelId, "stackColor", value) end,
			},
			{
				name = L["Charges"] or "Charges",
				kind = SettingType.Collapsible,
				id = "cooldownPanelCharges",
				defaultCollapsed = true,
			},
			{
				name = L["CooldownPanelChargesAnchor"] or "Charges anchor",
				kind = SettingType.Dropdown,
				field = "chargesAnchor",
				parentId = "cooldownPanelCharges",
				height = 160,
				get = function() return Helper.NormalizeAnchor(layout.chargesAnchor, Helper.PANEL_LAYOUT_DEFAULTS.chargesAnchor) end,
				set = function(_, value) applyEditLayout(panelId, "chargesAnchor", value) end,
				generator = function(_, root)
					for _, option in ipairs(Helper.AnchorOptions) do
						root:CreateRadio(
							option.label,
							function() return Helper.NormalizeAnchor(layout.chargesAnchor, Helper.PANEL_LAYOUT_DEFAULTS.chargesAnchor) == option.value end,
							function() applyEditLayout(panelId, "chargesAnchor", option.value) end
						)
					end
				end,
			},
			{
				name = L["CooldownPanelChargesOffsetX"] or "Charges X",
				kind = SettingType.Slider,
				field = "chargesX",
				parentId = "cooldownPanelCharges",
				default = layout.chargesX or Helper.PANEL_LAYOUT_DEFAULTS.chargesX,
				minValue = -Helper.OFFSET_RANGE,
				maxValue = Helper.OFFSET_RANGE,
				valueStep = 1,
				get = function() return layout.chargesX or Helper.PANEL_LAYOUT_DEFAULTS.chargesX end,
				set = function(_, value) applyEditLayout(panelId, "chargesX", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["CooldownPanelChargesOffsetY"] or "Charges Y",
				kind = SettingType.Slider,
				field = "chargesY",
				parentId = "cooldownPanelCharges",
				default = layout.chargesY or Helper.PANEL_LAYOUT_DEFAULTS.chargesY,
				minValue = -Helper.OFFSET_RANGE,
				maxValue = Helper.OFFSET_RANGE,
				valueStep = 1,
				get = function() return layout.chargesY or Helper.PANEL_LAYOUT_DEFAULTS.chargesY end,
				set = function(_, value) applyEditLayout(panelId, "chargesY", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["Font"] or "Font",
				kind = SettingType.Dropdown,
				field = "chargesFont",
				parentId = "cooldownPanelCharges",
				height = 220,
				get = function() return layout.chargesFont or chargesFontPath end,
				set = function(_, value) applyEditLayout(panelId, "chargesFont", value) end,
				generator = function(_, root)
					for _, option in ipairs(chargesFontOptions()) do
						root:CreateRadio(
							option.label,
							function() return (layout.chargesFont or chargesFontPath) == option.value end,
							function() applyEditLayout(panelId, "chargesFont", option.value) end
						)
					end
				end,
			},
			{
				name = L["CooldownPanelFontStyle"] or "Font style",
				kind = SettingType.Dropdown,
				field = "chargesFontStyle",
				parentId = "cooldownPanelCharges",
				height = 120,
				get = function() return Helper.NormalizeFontStyleChoice(layout.chargesFontStyle, chargesFontStyle) end,
				set = function(_, value) applyEditLayout(panelId, "chargesFontStyle", value) end,
				generator = function(_, root)
					for _, option in ipairs(Helper.FontStyleOptions) do
						root:CreateRadio(
							option.label,
							function() return Helper.NormalizeFontStyleChoice(layout.chargesFontStyle, chargesFontStyle) == option.value end,
							function() applyEditLayout(panelId, "chargesFontStyle", option.value) end
						)
					end
				end,
			},
			{
				name = FONT_SIZE,
				kind = SettingType.Slider,
				field = "chargesFontSize",
				parentId = "cooldownPanelCharges",
				default = layout.chargesFontSize or chargesFontSize or 12,
				minValue = 6,
				maxValue = 64,
				valueStep = 1,
				get = function() return layout.chargesFontSize or chargesFontSize or 12 end,
				set = function(_, value) applyEditLayout(panelId, "chargesFontSize", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = _G.COLOR or "Color",
				kind = SettingType.Color,
				field = "chargesColor",
				parentId = "cooldownPanelCharges",
				hasOpacity = true,
				default = Helper.NormalizeColor(layout.chargesColor, Helper.PANEL_LAYOUT_DEFAULTS.chargesColor or { 1, 1, 1, 1 }),
				get = function()
					local color = Helper.NormalizeColor(layout.chargesColor, Helper.PANEL_LAYOUT_DEFAULTS.chargesColor or { 1, 1, 1, 1 })
					return { r = color[1], g = color[2], b = color[3], a = color[4] }
				end,
				set = function(_, value) applyEditLayout(panelId, "chargesColor", value) end,
			},
			{
				name = L["CooldownPanelHideWhenZero"] or "Hide when 0",
				kind = SettingType.Checkbox,
				field = "chargesHideWhenZero",
				parentId = "cooldownPanelCharges",
				default = layout.chargesHideWhenZero == true,
				get = function() return layout.chargesHideWhenZero == true end,
				set = function(_, value) applyEditLayout(panelId, "chargesHideWhenZero", value) end,
			},
			{
				name = L["CooldownPanelKeybindsHeader"] or "Keybinds",
				kind = SettingType.Collapsible,
				id = "cooldownPanelKeybinds",
				defaultCollapsed = true,
			},
			{
				name = L["CooldownPanelShowKeybinds"] or "Show keybinds",
				kind = SettingType.Checkbox,
				field = "keybindsEnabled",
				parentId = "cooldownPanelKeybinds",
				default = layout.keybindsEnabled == true,
				get = function() return layout.keybindsEnabled == true end,
				set = function(_, value) applyEditLayout(panelId, "keybindsEnabled", value) end,
			},
			{
				name = L["CooldownPanelKeybindsIgnoreItems"] or "Ignore items",
				kind = SettingType.Checkbox,
				field = "keybindsIgnoreItems",
				parentId = "cooldownPanelKeybinds",
				default = layout.keybindsIgnoreItems == true,
				get = function() return layout.keybindsIgnoreItems == true end,
				set = function(_, value) applyEditLayout(panelId, "keybindsIgnoreItems", value) end,
			},
			{
				name = L["CooldownPanelKeybindsAnchor"] or "Keybind anchor",
				kind = SettingType.Dropdown,
				field = "keybindAnchor",
				parentId = "cooldownPanelKeybinds",
				height = 160,
				get = function() return Helper.NormalizeAnchor(layout.keybindAnchor, Helper.PANEL_LAYOUT_DEFAULTS.keybindAnchor) end,
				set = function(_, value) applyEditLayout(panelId, "keybindAnchor", value) end,
				generator = function(_, root)
					for _, option in ipairs(Helper.AnchorOptions) do
						root:CreateRadio(
							option.label,
							function() return Helper.NormalizeAnchor(layout.keybindAnchor, Helper.PANEL_LAYOUT_DEFAULTS.keybindAnchor) == option.value end,
							function() applyEditLayout(panelId, "keybindAnchor", option.value) end
						)
					end
				end,
			},
			{
				name = L["CooldownPanelKeybindsOffsetX"] or "Keybind X",
				kind = SettingType.Slider,
				field = "keybindX",
				parentId = "cooldownPanelKeybinds",
				default = layout.keybindX or Helper.PANEL_LAYOUT_DEFAULTS.keybindX,
				minValue = -Helper.OFFSET_RANGE,
				maxValue = Helper.OFFSET_RANGE,
				valueStep = 1,
				get = function() return layout.keybindX or Helper.PANEL_LAYOUT_DEFAULTS.keybindX end,
				set = function(_, value) applyEditLayout(panelId, "keybindX", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["CooldownPanelKeybindsOffsetY"] or "Keybind Y",
				kind = SettingType.Slider,
				field = "keybindY",
				parentId = "cooldownPanelKeybinds",
				default = layout.keybindY or Helper.PANEL_LAYOUT_DEFAULTS.keybindY,
				minValue = -Helper.OFFSET_RANGE,
				maxValue = Helper.OFFSET_RANGE,
				valueStep = 1,
				get = function() return layout.keybindY or Helper.PANEL_LAYOUT_DEFAULTS.keybindY end,
				set = function(_, value) applyEditLayout(panelId, "keybindY", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["Font"] or "Font",
				kind = SettingType.Dropdown,
				field = "keybindFont",
				parentId = "cooldownPanelKeybinds",
				height = 220,
				get = function() return layout.keybindFont or countFontPath end,
				set = function(_, value) applyEditLayout(panelId, "keybindFont", value) end,
				generator = function(_, root)
					for _, option in ipairs(fontOptions()) do
						root:CreateRadio(
							option.label,
							function() return (layout.keybindFont or countFontPath) == option.value end,
							function() applyEditLayout(panelId, "keybindFont", option.value) end
						)
					end
				end,
			},
			{
				name = L["CooldownPanelFontStyle"] or "Font style",
				kind = SettingType.Dropdown,
				field = "keybindFontStyle",
				parentId = "cooldownPanelKeybinds",
				height = 120,
				get = function() return Helper.NormalizeFontStyleChoice(layout.keybindFontStyle, countFontStyle) end,
				set = function(_, value) applyEditLayout(panelId, "keybindFontStyle", value) end,
				generator = function(_, root)
					for _, option in ipairs(Helper.FontStyleOptions) do
						root:CreateRadio(
							option.label,
							function() return Helper.NormalizeFontStyleChoice(layout.keybindFontStyle, countFontStyle) == option.value end,
							function() applyEditLayout(panelId, "keybindFontStyle", option.value) end
						)
					end
				end,
			},
			{
				name = FONT_SIZE,
				kind = SettingType.Slider,
				field = "keybindFontSize",
				parentId = "cooldownPanelKeybinds",
				default = layout.keybindFontSize or Helper.PANEL_LAYOUT_DEFAULTS.keybindFontSize or 10,
				minValue = 6,
				maxValue = 64,
				valueStep = 1,
				get = function() return layout.keybindFontSize or Helper.PANEL_LAYOUT_DEFAULTS.keybindFontSize or 10 end,
				set = function(_, value) applyEditLayout(panelId, "keybindFontSize", value) end,
				formatter = function(value) return tostring(math.floor((tonumber(value) or 0) + 0.5)) end,
			},
			{
				name = L["Cooldown"] or "Cooldown",
				kind = SettingType.Collapsible,
				id = "cooldownPanelCooldown",
				defaultCollapsed = true,
			},
			{
				name = L["CooldownPanelShowChargesCooldown"] or "Show charges cooldown",
				kind = SettingType.Checkbox,
				field = "showChargesCooldown",
				parentId = "cooldownPanelCooldown",
				default = layout.showChargesCooldown == true,
				get = function() return layout.showChargesCooldown == true end,
				set = function(_, value) applyEditLayout(panelId, "showChargesCooldown", value) end,
			},
			{
				name = L["CooldownPanelCDMAuraOverlayPanel"] or "Show aura overlay for supported spells",
				kind = SettingType.Checkbox,
				field = "cdmAuraOverlayEnabled",
				parentId = "cooldownPanelCooldown",
				default = layout.cdmAuraOverlayEnabled == true,
				get = function() return layout.cdmAuraOverlayEnabled == true end,
				set = function(_, value) applyEditLayout(panelId, "cdmAuraOverlayEnabled", value) end,
			},
			{
				name = L["CooldownPanelDrawEdge"] or "Draw edge",
				kind = SettingType.Checkbox,
				field = "cooldownDrawEdge",
				parentId = "cooldownPanelCooldown",
				default = layout.cooldownDrawEdge ~= false,
				get = function() return layout.cooldownDrawEdge ~= false end,
				set = function(_, value) applyEditLayout(panelId, "cooldownDrawEdge", value) end,
			},
			{
				name = L["CooldownPanelDrawBling"] or "Draw bling",
				kind = SettingType.Checkbox,
				field = "cooldownDrawBling",
				parentId = "cooldownPanelCooldown",
				default = layout.cooldownDrawBling ~= false,
				get = function() return layout.cooldownDrawBling ~= false end,
				set = function(_, value) applyEditLayout(panelId, "cooldownDrawBling", value) end,
			},
			{
				name = L["CooldownPanelDrawSwipe"] or "Draw swipe",
				kind = SettingType.Checkbox,
				field = "cooldownDrawSwipe",
				parentId = "cooldownPanelCooldown",
				default = layout.cooldownDrawSwipe ~= false,
				get = function() return layout.cooldownDrawSwipe ~= false end,
				set = function(_, value) applyEditLayout(panelId, "cooldownDrawSwipe", value) end,
			},
			{
				name = L["CooldownPanelDrawEdgeGcd"] or "Draw edge on GCD",
				kind = SettingType.Checkbox,
				field = "cooldownGcdDrawEdge",
				parentId = "cooldownPanelCooldown",
				default = layout.cooldownGcdDrawEdge == true,
				get = function() return layout.cooldownGcdDrawEdge == true end,
				set = function(_, value) applyEditLayout(panelId, "cooldownGcdDrawEdge", value) end,
			},
			{
				name = L["CooldownPanelDrawBlingGcd"] or "Draw bling on GCD",
				kind = SettingType.Checkbox,
				field = "cooldownGcdDrawBling",
				parentId = "cooldownPanelCooldown",
				default = layout.cooldownGcdDrawBling == true,
				get = function() return layout.cooldownGcdDrawBling == true end,
				set = function(_, value) applyEditLayout(panelId, "cooldownGcdDrawBling", value) end,
			},
			{
				name = L["CooldownPanelDrawSwipeGcd"] or "Draw swipe on GCD",
				kind = SettingType.Checkbox,
				field = "cooldownGcdDrawSwipe",
				parentId = "cooldownPanelCooldown",
				default = layout.cooldownGcdDrawSwipe == true,
				get = function() return layout.cooldownGcdDrawSwipe == true end,
				set = function(_, value) applyEditLayout(panelId, "cooldownGcdDrawSwipe", value) end,
			},
		}
	end
	runtime.layoutPanelSettings = settings
	runtime.layoutPanelSettingsMaxHeight = 620
	self:UpdateVisibility(panelId)
end

function CooldownPanels:AttachFakeCursor(panelId)
	if not panelId then return end
	local panel = self:GetPanel(panelId)
	if not panel then return end
	local anchor = ensurePanelAnchor(panel)
	if not anchor then return end

	anchor.point = "CENTER"
	anchor.relativePoint = "CENTER"
	CooldownPanels.MarkRelativeFrameEntriesDirty()
	anchor.relativeFrame = cdp.FAKE_CURSOR.FRAME_NAME
	anchor.x = 0
	anchor.y = 0
	panel.point = anchor.point or panel.point or "CENTER"
	panel.x = anchor.x or panel.x or 0
	panel.y = anchor.y or panel.y or 0
	self:ApplyPanelPosition(panelId)
	refreshStandaloneSettingValues()
	resetFakeCursorFrame()
	self:UpdateCursorAnchorState()
end

local function isSlashCommandRegistered(command)
	if addon.functions and addon.functions.IsSlashCommandRegistered then return addon.functions.IsSlashCommandRegistered(command) end
	if not command then return false end
	command = command:lower()
	for key, value in pairs(_G) do
		if type(key) == "string" and key:match("^SLASH_") and type(value) == "string" then
			if value:lower() == command then return true end
		end
	end
	return false
end

local function setSlashCommandAlias(slot, command)
	if addon.functions and addon.functions.SetSlashCommandAlias then
		return addon.functions.SetSlashCommandAlias("EQOLCP", slot, command)
	end
	command = type(command) == "string" and command:lower() or nil
	_G["SLASH_EQOLCP" .. slot] = command
	return command
end

local function registerCooldownPanelsSlashCommand()
	if not SlashCmdList then return end
	local commands = { "/ecd", "/cpe" }
	local assigned = false
	local slot = 1
	for _, command in ipairs(commands) do
		local lower = command:lower()
		if isSlashCommandRegistered(lower) then
			local owned = false
			if SlashCmdList["EQOLCP"] then
				for i = 1, #commands do
					local key = _G["SLASH_EQOLCP" .. i]
					if type(key) == "string" and key:lower() == lower then
						owned = true
						break
					end
				end
			end
			if not owned then command = nil end
		end
		if command then
			setSlashCommandAlias(slot, lower)
			slot = slot + 1
			assigned = true
		end
	end
	for clearSlot = slot, #commands do
		setSlashCommandAlias(clearSlot, nil)
	end
	if not assigned then return end
	SlashCmdList["EQOLCP"] = function()
		local panels = addon.Aura and addon.Aura.CooldownPanels
		if not panels then return end
		if panels.ToggleEditor then
			panels:ToggleEditor()
		elseif panels.OpenEditor then
			panels:OpenEditor()
		end
	end
end

function cdp.ENTRY.GetSpellTargets(runtime, spellId, baseSpellId)
	local index = runtime and runtime.spellEntryIndex
	if not index then return nil end
	runtime._eqolSpellEntryTargets = runtime._eqolSpellEntryTargets or {}
	local targets = runtime._eqolSpellEntryTargets
	for panelId, entries in pairs(targets) do
		if entries then
			for entryId in pairs(entries) do
				entries[entryId] = nil
			end
		end
		targets[panelId] = nil
	end

	local function collect(id)
		local numericId = tonumber(id)
		if not numericId then return end
		local panels = index[numericId]
		if not panels then return end
		for panelId, entries in pairs(panels) do
			targets[panelId] = targets[panelId] or {}
			for entryId, meta in pairs(entries) do
				targets[panelId][entryId] = meta
			end
		end
	end

	local seenIDs = {}
	for _, sourceId in ipairs({ spellId, baseSpellId }) do
		if sourceId then
			local aliasIDs = CooldownPanels:GetSpellAliasIDs(sourceId)
			for i = 1, #aliasIDs do
				local aliasId = aliasIDs[i]
				if not seenIDs[aliasId] then
					seenIDs[aliasId] = true
					collect(aliasId)
				end
			end
		end
	end

	return next(targets) and targets or nil
end

function cdp.ENTRY.GetItemTargets(runtime, itemId)
	local index = runtime and runtime.itemEntryIndex
	if not index then return nil end
	runtime._eqolItemEntryTargets = runtime._eqolItemEntryTargets or {}
	local targets = runtime._eqolItemEntryTargets
	for panelId, entries in pairs(targets) do
		if entries then
			for entryId in pairs(entries) do
				entries[entryId] = nil
			end
		end
		targets[panelId] = nil
	end

	local numericItemId = tonumber(itemId)
	if not numericItemId then return nil end
	local panels = index[numericItemId]
	if not panels then return nil end
	for panelId, entries in pairs(panels) do
		targets[panelId] = targets[panelId] or {}
		for entryId, meta in pairs(entries) do
			targets[panelId][entryId] = meta
		end
	end

	return next(targets) and targets or nil
end

function cdp.ENTRY.QueuePanelRefresh(panelsToRefresh, panelId)
	if not (panelsToRefresh and panelId) then return end
	panelsToRefresh[panelId] = true
end

function cdp.ENTRY.GetPanelRefreshScratch(runtime, scratchKey)
	if not runtime then return nil end
	scratchKey = scratchKey or "_eqolEntryPanelRefreshScratch"
	local scratch = runtime[scratchKey]
	if not scratch then
		scratch = {}
		runtime[scratchKey] = scratch
	else
		for panelId in pairs(scratch) do
			scratch[panelId] = nil
		end
	end
	return scratch
end

function cdp.ENTRY.FlushPanelRefreshes(panelsToRefresh)
	if not panelsToRefresh then return false end
	local refreshed = false
	for panelId in pairs(panelsToRefresh) do
		panelsToRefresh[panelId] = nil
		if CooldownPanels.RequestPanelRefresh then
			CooldownPanels:RequestPanelRefresh(panelId)
			refreshed = true
		elseif CooldownPanels:GetPanel(panelId) then
			CooldownPanels:RefreshPanel(panelId)
			refreshed = true
		end
	end
	return refreshed
end

function cdp.ENTRY.ApplyVisibleSpellRuntime(panelId, runtime, icon, data, resolvedLayout)
	if not (runtime and icon and data) then return false end
	local staticFontPath = resolvedLayout and resolvedLayout.staticFontPath
	local staticFontSize = resolvedLayout and resolvedLayout.staticFontSize
	local staticFontStyle = resolvedLayout and resolvedLayout.staticFontStyle
	local powerTintR = resolvedLayout and resolvedLayout.powerTintR
	local powerTintG = resolvedLayout and resolvedLayout.powerTintG
	local powerTintB = resolvedLayout and resolvedLayout.powerTintB
	local unusableTintR = resolvedLayout and resolvedLayout.unusableTintR
	local unusableTintG = resolvedLayout and resolvedLayout.unusableTintG
	local unusableTintB = resolvedLayout and resolvedLayout.unusableTintB
	local readyGlowPrimed = CooldownPanels.GetReadyGlowPrimedState(runtime)

	icon:Show()
	updateCooldownDoneContext(icon.cooldown, panelId, data.entryId, data)
	if icon.cooldown.Resume then icon.cooldown:Resume() end
	if icon.cooldown.SetAlpha then icon.cooldown:SetAlpha(1) end
	icon.cooldown:SetHideCountdownNumbers(not data.showCooldownText)
	local auraDisplayActive = data.customCooldownDurationActive == true or data.spellAuraOverlayActive == true
	if icon.cooldown.SetReverse then icon.cooldown:SetReverse(data.cooldownReverse == true) end
	if icon.cooldown.SetUseAuraDisplayTime then icon.cooldown:SetUseAuraDisplayTime(auraDisplayActive) end
	cdp.ENTRY.ApplyCooldownSwipeVisual(icon, data)

	local cooldownStart = data.cooldownStart or 0
	local cooldownDuration = data.cooldownDuration or 0
	local cooldownRate = data.cooldownRate or 1
	local chargeDurationObject = data.chargeDurationObject
	local cooldownDurationObject = data.cooldownDurationObject
	local cooldownEnabledOk = isSafeNotFalse(data.cooldownEnabled)
	local spellCooldownActive = CooldownPanels.IsSpellCooldownInfoActive(data.cooldownIsActive, data.cooldownEnabled, cooldownStart, cooldownDuration)
	local durationActive = cooldownDurationObject ~= nil and spellCooldownActive
	local cooldownActive = data.showCooldown and (durationActive or spellCooldownActive)
	local usingCooldown = false
	local desaturate = false
	local hidden = false
	local chargeCooldownHasAvailableCharge = false
	local chargeInfoActive = false
	local entryNoDesaturation = data.noDesaturation == true
	local entryDrawEdge = data.cooldownDrawEdge ~= false
	local entryDrawBling = data.cooldownDrawBling ~= false
	local entryDrawSwipe = data.cooldownDrawSwipe ~= false
	local entryGcdDrawEdge = data.cooldownGcdDrawEdge == true
	local entryGcdDrawBling = data.cooldownGcdDrawBling == true
	local entryGcdDrawSwipe = data.cooldownGcdDrawSwipe == true
	local hideOnCooldown = data.hideOnCooldown == true
	local showOnCooldown = data.showOnCooldown == true

	local chargesAlpha = 1
	if data.showCharges and data.chargesInfo and data.chargesInfo.maxCharges ~= nil then
		if data.chargesInfo.currentCharges ~= nil then
			icon.charges:SetText(data.chargesInfo.currentCharges)
			if data.chargesHideWhenZero == true then chargesAlpha = data.chargesInfo.currentCharges end
			icon.charges:SetAlpha(chargesAlpha)
			icon.charges:Show()
		else
			icon.charges:SetAlpha(1)
			icon.charges:Hide()
		end
		if data.showCooldown then
			chargeInfoActive = CooldownPanels.IsChargeInfoActive(data.chargesInfo)
			if chargeInfoActive then
				cooldownStart = data.chargesInfo.cooldownStartTime or cooldownStart
				cooldownDuration = data.chargesInfo.cooldownDuration or cooldownDuration
				cooldownRate = data.chargesInfo.chargeModRate or cooldownRate
				cooldownActive = true
				usingCooldown = true
			end
		end
		if usingCooldown and isSafeNumber(data.chargesInfo.currentCharges) then
			chargeCooldownHasAvailableCharge = data.chargesInfo.currentCharges > 0 and isSafeLessThan(data.chargesInfo.currentCharges, data.chargesInfo.maxCharges)
			desaturate = data.chargesInfo.currentCharges == 0
			if hideOnCooldown or showOnCooldown then hidden = desaturate end
		end
	else
		icon.charges:SetAlpha(1)
		icon.charges:Hide()
	end

	if data.customCooldownDurationActive == true then
		usingCooldown = false
		chargeCooldownHasAvailableCharge = false
		chargeInfoActive = false
		cooldownStart = data.customCooldownStart or cooldownStart
		cooldownDuration = data.customCooldownDuration or cooldownDuration
		cooldownRate = 1
		cooldownEnabledOk = true
		spellCooldownActive = true
		durationActive = cooldownDurationObject ~= nil
		cooldownActive = data.showCooldown == true
		desaturate = false
		if hideOnCooldown or showOnCooldown then hidden = true end
	end
	if data.spellAuraOverlayActive == true then
		usingCooldown = false
		chargeCooldownHasAvailableCharge = false
		chargeInfoActive = false
		cooldownEnabledOk = true
		spellCooldownActive = true
		durationActive = cooldownDurationObject ~= nil
		cooldownActive = data.showCooldown == true
		desaturate = false
		if hideOnCooldown or showOnCooldown then hidden = true end
	end

	CooldownPanels.SetIconDesaturatedRuntime(icon.texture, desaturate, entryNoDesaturation)
	if hideOnCooldown then
		icon:SetAlphaFromBoolean(hidden, 0, 1)
	elseif showOnCooldown then
		icon:SetAlphaFromBoolean(hidden, 1, 0)
	else
		icon:SetAlpha(1)
	end

	if data.showCooldown then
		if usingCooldown then
			setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)
			if chargeCooldownHasAvailableCharge then
				if data.showChargesCooldown then
					if chargeDurationObject and icon.cooldown.SetCooldownFromDurationObject then
						icon.cooldown:Clear()
						icon.cooldown:SetCooldownFromDurationObject(chargeDurationObject)
					elseif isSafeNumber(cooldownStart) and isSafeNumber(cooldownDuration) then
						icon.cooldown:Clear()
						icon.cooldown:SetCooldown(cooldownStart, cooldownDuration, cooldownRate)
					else
						icon.cooldown:Clear()
					end
					if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", onCooldownDone) end
				else
					icon.cooldown:Clear()
					if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
				end
			else
				if data.showChargesCooldown then
					if chargeDurationObject and icon.cooldown.SetCooldownFromDurationObject then
						icon.cooldown:Clear()
						icon.cooldown:SetCooldownFromDurationObject(chargeDurationObject)
					elseif isSafeNumber(cooldownStart) and isSafeNumber(cooldownDuration) then
						icon.cooldown:Clear()
						icon.cooldown:SetCooldown(cooldownStart, cooldownDuration, cooldownRate)
					else
						icon.cooldown:Clear()
					end
					if not data.cooldownGCD and icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", onCooldownDone) end
				else
					icon.cooldown:Clear()
					if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
				end

				if cooldownDurationObject then
					if data.cooldownGCD then
					else
						if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", onCooldownDone) end
						setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)
						CooldownPanels.SetIconDesaturationRuntime(icon.texture, cooldownDurationObject:EvaluateRemainingDuration(curveDesat), entryNoDesaturation)
						if hideOnCooldown then
							icon:SetAlpha(cooldownDurationObject:EvaluateRemainingDuration(curveAlpha))
						elseif showOnCooldown then
							icon:SetAlpha(cooldownDurationObject:EvaluateRemainingDuration(curveDesat))
						end
						if data.showChargesCooldown and chargeDurationObject then
							icon.cooldown:SetCooldownFromDurationObject(chargeDurationObject)
						else
							icon.cooldown:SetCooldownFromDurationObject(cooldownDurationObject)
						end
					end
				end
			end
		elseif durationActive then
			icon.cooldown:SetCooldownFromDurationObject(cooldownDurationObject)
			if data.cooldownGCD then
				CooldownPanels.SetIconDesaturationRuntime(icon.texture, 0, entryNoDesaturation)
				if hideOnCooldown then
					icon:SetAlpha(1)
				elseif showOnCooldown then
					icon:SetAlpha(0)
				end
				setCooldownDrawState(icon.cooldown, entryGcdDrawEdge, entryGcdDrawBling, entryGcdDrawSwipe)
			else
				setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)
				local auraDisplayDurationActive = data.customCooldownDurationActive == true or data.spellAuraOverlayActive == true
				local desat = auraDisplayDurationActive and 0 or cooldownDurationObject:EvaluateRemainingDuration(curveDesat)
				CooldownPanels.SetIconDesaturationRuntime(icon.texture, desat, entryNoDesaturation)
				if hideOnCooldown then
					icon:SetAlpha(cooldownDurationObject:EvaluateRemainingDuration(curveAlpha))
				elseif showOnCooldown then
					icon:SetAlpha(auraDisplayDurationActive and 1 or cooldownDurationObject:EvaluateRemainingDuration(curveDesat))
				end
				if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", onCooldownDone) end
			end
		elseif cooldownActive then
			icon.cooldown:SetCooldown(cooldownStart, cooldownDuration, cooldownRate)
			if data.cooldownGCD then
				CooldownPanels.SetIconDesaturationRuntime(icon.texture, 0, entryNoDesaturation)
				if hideOnCooldown then
					icon:SetAlpha(1)
				elseif showOnCooldown then
					icon:SetAlpha(0)
				end
				setCooldownDrawState(icon.cooldown, entryGcdDrawEdge, entryGcdDrawBling, entryGcdDrawSwipe)
				if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
			else
				CooldownPanels.SetIconDesaturatedRuntime(icon.texture, true, entryNoDesaturation)
				if hideOnCooldown then
					icon:SetAlpha(0)
				elseif showOnCooldown then
					icon:SetAlpha(1)
				end
				setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)
				if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", onCooldownDone) end
			end
		else
			setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)
			icon.cooldown:Clear()
			if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
		end
	else
		setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)
		icon.cooldown:Clear()
		if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
	end

	if data.spellUnusable then
		icon.texture:SetVertexColor(unusableTintR or 0.6, unusableTintG or 0.6, unusableTintB or 0.6)
	elseif data.powerInsufficient then
		icon.texture:SetVertexColor(powerTintR or 0.5, powerTintG or 0.5, powerTintB or 1)
	elseif data.cooldownEnabled == false and data.cooldownIsActive == false and data.cooldownGCD ~= true then
		icon.texture:SetVertexColor(0.4, 0.4, 0.4)
	else
		icon.texture:SetVertexColor(1, 1, 1)
	end

	local staticTextCooldown = false
	if data.entry and data.entry.staticTextShowOnCooldown == true then staticTextCooldown = durationActive or (cooldownEnabledOk and isCooldownActive(cooldownStart, cooldownDuration)) end
	applyStaticText(icon, data.layout, data.entry, staticFontPath, staticFontSize, staticFontStyle, staticTextCooldown)
	if durationActive and showOnCooldown and data.entry and data.entry.staticTextShowOnCooldown == true and staticTextCooldown and data.entry.spellID then
		icon.staticText:SetAlpha(cooldownDurationObject:EvaluateRemainingDuration(Helper.FakeCurve))
		icon.cooldown:SetAlpha(cooldownDurationObject:EvaluateRemainingDuration(Helper.FakeCurve))
	end

	data.readyAt = runtime.readyAt and runtime.readyAt[data.entryId] or nil
	local readyGlowCooldownRunning
	local useSecretReadyGlow = data.spellReadyCondition ~= nil and data.glowReady and data.showCooldown and data.canTriggerReadyGlow
	local secretReadyGlowAllowed = useSecretReadyGlow and data.readyGlowResourceBlocked ~= true
	if useSecretReadyGlow then
		if data.readyAt or (readyGlowPrimed and readyGlowPrimed[data.entryId]) then
			CooldownPanels.ClearReadyGlowEntryState(panelId, data.entryId, true)
			data.readyAt = nil
		end
	elseif data.canTriggerReadyGlow then
		readyGlowCooldownRunning = durationActive or usingCooldown or (cooldownEnabledOk and isCooldownActive(cooldownStart, cooldownDuration))
		if readyGlowCooldownRunning then
			CooldownPanels.ClearReadyGlowEntryState(panelId, data.entryId, true)
			data.readyAt = nil
		end
	end
	if not useSecretReadyGlow and data.glowReady and data.showCooldown and data.canTriggerReadyGlow then
		if data.readyAt then
			if readyGlowPrimed then readyGlowPrimed[data.entryId] = true end
		elseif not readyGlowCooldownRunning and not data.cooldownGCD and readyGlowPrimed and not readyGlowPrimed[data.entryId] then
			triggerReadyGlow(panelId, data.entryId, data.glowDuration)
			readyGlowPrimed[data.entryId] = true
			data.readyAt = runtime.readyAt[data.entryId]
		end
	end

	local overlayGlow = data.overlayGlow == true
	local overlayGlowColor = overlayGlow and data.overlayGlowColor or nil
	local simpleGlowEnabled = overlayGlow
	local simpleGlowColor = overlayGlowColor
	local simpleGlowStyle = data.overlayGlowStyle or data.readyGlowStyle
	local simpleGlowInset = data.overlayGlowInset or data.readyGlowInset
	if data.glowReady and not useSecretReadyGlow then
		local ready = data.readyAt ~= nil
		if ready and data.readyGlowCheckPower == true and data.readyGlowResourceBlocked == true then ready = false end
		simpleGlowEnabled = overlayGlow or ready
		simpleGlowColor = ready and data.readyGlowColor or overlayGlowColor
		simpleGlowStyle = ready and data.readyGlowStyle or (data.overlayGlowStyle or data.readyGlowStyle)
		simpleGlowInset = ready and data.readyGlowInset or (data.overlayGlowInset or data.readyGlowInset)
	end
	setPreviewGlow(icon, false)
	if data.liveGlowAllowed == false then
		setGlow(icon, false, nil, "EQOL_SIMPLE")
		setGlow(icon, false, nil, "EQOL_OVERLAY")
		setGlow(icon, false, nil, "EQOL_READY")
	elseif useSecretReadyGlow then
		setGlow(icon, false, nil, "EQOL_SIMPLE")
		if secretReadyGlowAllowed then
			setGlow(icon, overlayGlow, overlayGlowColor, "EQOL_OVERLAY", data.spellReadyCondition, 0, 1, data.overlayGlowStyle or data.readyGlowStyle, data.overlayGlowInset or data.readyGlowInset)
			setGlow(icon, true, data.readyGlowColor, "EQOL_READY", data.spellReadyCondition, 1, 0, data.readyGlowStyle, data.readyGlowInset)
		else
			setGlow(icon, overlayGlow, overlayGlowColor, "EQOL_OVERLAY", nil, nil, nil, data.overlayGlowStyle or data.readyGlowStyle, data.overlayGlowInset or data.readyGlowInset)
			setGlow(icon, false, nil, "EQOL_READY")
		end
	else
		setGlow(icon, false, nil, "EQOL_OVERLAY")
		setGlow(icon, false, nil, "EQOL_READY")
		setGlow(icon, simpleGlowEnabled, simpleGlowColor, "EQOL_SIMPLE", nil, nil, nil, simpleGlowStyle, simpleGlowInset)
	end

	return true
end

function cdp.ENTRY.ApplyVisibleItemRuntime(panelId, runtime, icon, data, resolvedLayout)
	if not (runtime and icon and data) then return false end
	local staticFontPath = resolvedLayout and resolvedLayout.staticFontPath
	local staticFontSize = resolvedLayout and resolvedLayout.staticFontSize
	local staticFontStyle = resolvedLayout and resolvedLayout.staticFontStyle
	local readyGlowPrimed = CooldownPanels.GetReadyGlowPrimedState(runtime)
	local cooldownStart = data.cooldownStart or 0
	local cooldownDuration = data.cooldownDuration or 0
	local cooldownEnabledOk = data.cooldownEnabled ~= false and data.cooldownEnabled ~= 0
	local cooldownDurationObject = data.cooldownDurationObject
	local customCooldownActive = data.customCooldownDurationActive == true
	if customCooldownActive then
		cooldownStart = data.customCooldownStart or cooldownStart
		cooldownDuration = data.customCooldownDuration or cooldownDuration
		cooldownEnabledOk = true
	end
	local cooldownRunning = (customCooldownActive and (cooldownDurationObject ~= nil or isCooldownActive(cooldownStart, cooldownDuration))) or (cooldownEnabledOk and isCooldownActive(cooldownStart, cooldownDuration))
	local cooldownActive = data.showCooldown and cooldownRunning
	local entryNoDesaturation = data.noDesaturation == true and data.emptyItem ~= true
	local entryDrawEdge = data.cooldownDrawEdge ~= false
	local entryDrawBling = data.cooldownDrawBling ~= false
	local entryDrawSwipe = data.cooldownDrawSwipe ~= false
	local hidden = false
	local desaturate = data.emptyItem == true

	icon:Show()
	updateCooldownDoneContext(icon.cooldown, panelId, data.entryId, data)
	if icon.cooldown.Resume then icon.cooldown:Resume() end
	if icon.cooldown.SetAlpha then icon.cooldown:SetAlpha(1) end
	icon.cooldown:SetHideCountdownNumbers(not data.showCooldownText)
	if icon.cooldown.SetReverse then icon.cooldown:SetReverse(data.cooldownReverse == true) end
	if icon.cooldown.SetUseAuraDisplayTime then icon.cooldown:SetUseAuraDisplayTime(customCooldownActive) end
	cdp.ENTRY.ApplyCooldownSwipeVisual(icon, data)

	if data.showItemUses then
		if data.itemUses ~= nil then
			icon.charges:SetText(data.itemUses)
			icon.charges:SetAlpha(1)
			icon.charges:Show()
		else
			icon.charges:SetAlpha(1)
			icon.charges:Hide()
		end
	else
		icon.charges:SetAlpha(1)
		icon.charges:Hide()
	end

	if data.showCooldown and cooldownActive then
		if cooldownDurationObject and icon.cooldown.SetCooldownFromDurationObject then
			icon.cooldown:Clear()
			icon.cooldown:SetCooldownFromDurationObject(cooldownDurationObject)
		else
			icon.cooldown:SetCooldown(cooldownStart, cooldownDuration, 1)
		end
		setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)
		if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", onCooldownDone) end
		desaturate = customCooldownActive ~= true
		if data.hideOnCooldown == true then
			icon:SetAlpha(0)
			hidden = true
		elseif data.showOnCooldown == true then
			icon:SetAlpha(1)
		end
	else
		setCooldownDrawState(icon.cooldown, entryDrawEdge, entryDrawBling, entryDrawSwipe)
		icon.cooldown:Clear()
		if icon.cooldown.SetScript then icon.cooldown:SetScript("OnCooldownDone", nil) end
		if data.hideOnCooldown == true then
			icon:SetAlpha(1)
		elseif data.showOnCooldown == true then
			icon:SetAlpha(0)
			hidden = true
		else
			icon:SetAlpha(1)
		end
	end

	CooldownPanels.SetIconDesaturatedRuntime(icon.texture, desaturate, entryNoDesaturation)
	if not hidden and data.showOnCooldown ~= true and data.hideOnCooldown ~= true then icon:SetAlpha(1) end

	if (data.resolvedType == "ITEM" or data.resolvedType == "SLOT") and not cooldownEnabledOk and isSafeGreaterThan(cooldownDuration, 0) then
		icon.texture:SetVertexColor(0.4, 0.4, 0.4)
	else
		icon.texture:SetVertexColor(1, 1, 1)
	end

	if data.showItemCount and data.itemCount ~= nil then
		icon.count:SetText(data.itemCount)
		icon.count:Show()
	else
		icon.count:Hide()
	end

	local staticTextCooldown = false
	if data.entry and data.entry.staticTextShowOnCooldown == true then staticTextCooldown = cooldownRunning == true end
	applyStaticText(icon, data.layout, data.entry, staticFontPath, staticFontSize, staticFontStyle, staticTextCooldown)

	data.readyAt = runtime.readyAt and runtime.readyAt[data.entryId] or nil
	local readyGlowCooldownRunning = cooldownRunning == true
	if data.canTriggerReadyGlow then
		if readyGlowCooldownRunning then
			CooldownPanels.ClearReadyGlowEntryState(panelId, data.entryId, true)
			data.readyAt = nil
		end
	end
	if data.glowReady and data.showCooldown and data.canTriggerReadyGlow then
		if data.readyAt then
			if readyGlowPrimed then readyGlowPrimed[data.entryId] = true end
		elseif not readyGlowCooldownRunning and not data.cooldownGCD and readyGlowPrimed and not readyGlowPrimed[data.entryId] then
			triggerReadyGlow(panelId, data.entryId, data.glowDuration)
			readyGlowPrimed[data.entryId] = true
			data.readyAt = runtime.readyAt[data.entryId]
		end
	end

	local overlayGlow = data.overlayGlow == true
	local overlayGlowColor = overlayGlow and data.overlayGlowColor or nil
	local simpleGlowEnabled = overlayGlow
	local simpleGlowColor = overlayGlowColor
	local simpleGlowStyle = data.overlayGlowStyle or data.readyGlowStyle
	local simpleGlowInset = data.overlayGlowInset or data.readyGlowInset
	if data.glowReady then
		local ready = data.readyAt ~= nil
		if ready and data.readyGlowCheckPower == true and data.readyGlowResourceBlocked == true then ready = false end
		simpleGlowEnabled = overlayGlow or ready
		simpleGlowColor = ready and data.readyGlowColor or overlayGlowColor
		simpleGlowStyle = ready and data.readyGlowStyle or (data.overlayGlowStyle or data.readyGlowStyle)
		simpleGlowInset = ready and data.readyGlowInset or (data.overlayGlowInset or data.readyGlowInset)
	end
	if data.activationOverlayActive == true and data.activationOverlayGlow == true then
		simpleGlowEnabled = true
		simpleGlowColor = data.readyGlowColor or overlayGlowColor
		simpleGlowStyle = data.readyGlowStyle or simpleGlowStyle
		simpleGlowInset = data.readyGlowInset or simpleGlowInset
	end
	setPreviewGlow(icon, false)
	if data.liveGlowAllowed == false then
		setGlow(icon, false, nil, "EQOL_SIMPLE")
		setGlow(icon, false, nil, "EQOL_OVERLAY")
		setGlow(icon, false, nil, "EQOL_READY")
	else
		setGlow(icon, false, nil, "EQOL_OVERLAY")
		setGlow(icon, false, nil, "EQOL_READY")
		setGlow(icon, simpleGlowEnabled, simpleGlowColor, "EQOL_SIMPLE", nil, nil, nil, simpleGlowStyle, simpleGlowInset)
	end

	return true
end

function cdp.ENTRY.TryRefreshVisibleSpellEntry(panelId, entryId, mode)
	local panel = CooldownPanels:GetPanel(panelId)
	local runtime = panel and getRuntime(panelId) or nil
	if not (panel and runtime and runtime.frame) then return false end
	if CooldownPanels:IsPanelLayoutEditActive(panelId) then return false end

	local metaByPanel = CooldownPanels.runtime and CooldownPanels.runtime.spellEntryMeta
	local meta = metaByPanel and metaByPanel[panelId] and metaByPanel[panelId][entryId] or nil
	if not meta then return false end
	if mode == "charges" then
		if meta.localChargesSafe ~= true then return false end
	else
		if meta.localCooldownSafe ~= true then return false end
	end

	local icon = runtime.entryToIcon and runtime.entryToIcon[entryId] or nil
	local data = icon and icon._eqolRuntimeData or nil
	if not (icon and data and data.entryId == entryId and data.resolvedType == "SPELL") then return false end

	local entry = panel.entries and panel.entries[entryId] or nil
	if not entry then return false end
	if entry.displayMode == "BAR" then return false end
	if CooldownPanels:IsEntryCDMAuraOverlayEnabled(panel.layout, entry, "SPELL") then return false end
	if entry.activationOverlayOnly == true and CooldownPanels:EntryUsesActivationOverlay(entry, "SPELL") then return false end
	local macro = entry.type == "MACRO" and CooldownPanels.ResolveMacroEntry(entry) or nil
	local baseSpellId = entry.type == "SPELL" and tonumber(entry.spellID) or (macro and macro.kind == "SPELL" and tonumber(macro.spellID)) or nil
	if not baseSpellId then return false end
	local effectiveSpellId, resolvedSpellId, _, variantGroup = CooldownPanels:ResolveTrackedSpellID(baseSpellId)
	effectiveSpellId = effectiveSpellId or getEffectiveSpellId(baseSpellId) or baseSpellId
	local spellId = effectiveSpellId or baseSpellId
	local spellPassive = not shouldTrackPassiveSpell(entry) and isSpellPassiveSafe(resolvedSpellId or baseSpellId, effectiveSpellId) or false
	local talentChoiceResolved = variantGroup and variantGroup.kind == "talentChoice"
	if spellPassive or (Api.IsSpellKnown and not talentChoiceResolved and not Api.IsSpellKnown(spellId, true)) then return false end

	local showCooldown = entry.showCooldown ~= false
	local staticTextShowOnCooldown = entry.staticTextShowOnCooldown == true
	local trackCooldown = showCooldown or staticTextShowOnCooldown
	local showCharges = entry.showCharges == true
	local spellPassState = CooldownPanels:GetSpellPassState(spellId)
	local ignoreCooldownGCD = CooldownPanels:ShouldIgnoreEntryCooldownGCD(data.layout, entry)
	local chargesInfo
	local chargesInfoActive = false
	local chargeDurationObject
	local cooldownDurationObject
	local cooldownStart, cooldownDuration, cooldownEnabled, cooldownRate, cooldownGCD, cooldownIsActive

	if showCharges then
		if spellPassState and spellPassState.chargesLoaded == nil then
			spellPassState.chargesInfo = CooldownPanels:GetCachedSpellChargesInfo(spellId)
			spellPassState.chargesLoaded = true
		end
		chargesInfo = spellPassState and spellPassState.chargesInfo or CooldownPanels:GetCachedSpellChargesInfo(spellId)
		chargesInfoActive = CooldownPanels.IsChargeInfoActive(chargesInfo)
	end
	if trackCooldown or (showCooldown and chargesInfoActive) then
		cooldownStart, cooldownDuration, cooldownEnabled, cooldownRate, cooldownGCD, cooldownIsActive =
			CooldownPanels:GetCachedSpellCooldownInfo(spellId, ignoreCooldownGCD)
	end
	cooldownIsActive = CooldownPanels.IsSpellCooldownInfoActive(cooldownIsActive, cooldownEnabled, cooldownStart, cooldownDuration)
	if showCooldown and data.showChargesCooldown and chargesInfoActive then
		if spellPassState and spellPassState.chargeDurationLoaded == nil then
			spellPassState.chargeDurationObject = CooldownPanels:GetCachedSpellChargeDurationObject(spellId)
			spellPassState.chargeDurationLoaded = true
		end
		chargeDurationObject = spellPassState and spellPassState.chargeDurationObject or CooldownPanels:GetCachedSpellChargeDurationObject(spellId)
	end
	if trackCooldown and cooldownIsActive then
		cooldownDurationObject = CooldownPanels:GetCachedSpellCooldownDurationObject(spellId, ignoreCooldownGCD)
	end

	data.cooldownStart = cooldownStart or 0
	data.cooldownDuration = cooldownDuration or 0
	data.cooldownEnabled = cooldownEnabled
	data.cooldownIsActive = cooldownIsActive
	data.cooldownRate = cooldownRate or 1
	data.cooldownGCD = cooldownGCD == true
	data.cooldownIgnoreGCD = ignoreCooldownGCD == true
	data.chargesInfo = chargesInfo
	data.chargeDurationObject = chargeDurationObject
	data.cooldownDurationObject = cooldownDurationObject
	data.spellId = spellId
	data.baseSpellId = baseSpellId
	data.effectiveSpellId = effectiveSpellId
	data.resolvedSpellId = resolvedSpellId
	data.variantGroupKind = variantGroup and variantGroup.kind or nil
	data.variantGroupKey = variantGroup and variantGroup.key or nil
	if data.stateTextureType then
		local overlayGlowSpells = CooldownPanels.runtime and CooldownPanels.runtime.overlayGlowSpells
		data.stateTextureShown = entry.stateTextureShowWithoutProc == true or isSpellFlagged(overlayGlowSpells, baseSpellId, effectiveSpellId) == true
	end
	local customCooldownState = CooldownPanels:GetActiveEntryCustomCooldownDuration(panelId, entryId, entry, "SPELL")
	CooldownPanels:ApplyCustomCooldownDurationState(data, customCooldownState)
	CooldownPanels:ApplyActivationOverlayVisualState(data, entry)
	if customCooldownState then
		cooldownGCD = false
		cooldownIsActive = true
	end
	if data.glowReady and showCooldown then
		if cooldownGCD then
			data.spellReadyCondition = true
		elseif type(cooldownIsActive) == "boolean" then
			data.spellReadyCondition = not cooldownIsActive
		else
			data.spellReadyCondition = nil
		end
	else
		data.spellReadyCondition = nil
	end

	local resolvedLayout = CooldownPanels.ResolveRuntimeLayout(runtime, runtime.frame, panel.layout)
	return cdp.ENTRY.ApplyVisibleSpellRuntime(panelId, runtime, icon, data, resolvedLayout)
end

function cdp.ENTRY.TryRefreshVisibleItemEntry(panelId, entryId)
	local panel = CooldownPanels:GetPanel(panelId)
	local runtime = panel and getRuntime(panelId) or nil
	if not (panel and runtime and runtime.frame) then return false end
	if CooldownPanels:IsPanelLayoutEditActive(panelId) then return false end

	local icon = runtime.entryToIcon and runtime.entryToIcon[entryId] or nil
	local data = icon and icon._eqolRuntimeData or nil
	local entry = panel.entries and panel.entries[entryId] or nil
	if not entry then return false end
	if entry.displayMode == "BAR" then return false end
	local macro = entry.type == "MACRO" and CooldownPanels.ResolveMacroEntry(entry) or nil
	local resolvedType = (macro and macro.kind) or entry.type
	if resolvedType ~= "ITEM" then return false end

	local baseItemId = tonumber((macro and macro.itemID) or entry.itemID)
	if not baseItemId then return false end
	local resolvedItemId = CooldownPanels.ResolveEntryItemID(entry, baseItemId)
	if not (resolvedItemId and itemHasUseSpell(resolvedItemId)) then return false end

	local showCooldown = entry.showCooldown ~= false
	local showWhenEmpty = entry.showWhenEmpty == true
	local showItemCount = entry.showItemCount ~= false
	local showItemUses = entry.showItemUses == true
	local alwaysShow = entry.alwaysShow ~= false
	local itemCache = CooldownPanels.runtime and CooldownPanels.runtime.itemCountCache or nil
	local cached = itemCache and itemCache[resolvedItemId] or nil
	if not cached then
		updateItemCountCacheForItem(resolvedItemId, showItemUses)
		itemCache = CooldownPanels.runtime and CooldownPanels.runtime.itemCountCache or nil
		cached = itemCache and itemCache[resolvedItemId] or nil
	end

	local cachedCount = cached and cached.count
	local ownsItem
	if cachedCount ~= nil then
		ownsItem = cachedCount > 0 or (Api.IsEquippedItem and Api.IsEquippedItem(resolvedItemId))
	else
		ownsItem = hasItem(resolvedItemId)
	end
	local emptyItem = showWhenEmpty and not ownsItem
	local cooldownStart, cooldownDuration, cooldownEnabled
	local cooldownGCD = false
	local newVisible = false
	if (ownsItem or showWhenEmpty) and itemHasUseSpell(resolvedItemId) then
		if (showCooldown or entry.staticTextShowOnCooldown == true) and ownsItem then
			cooldownStart, cooldownDuration, cooldownEnabled = getItemCooldownInfo(resolvedItemId)
			if CooldownPanels:IsItemCooldownOnGCD(resolvedItemId, cooldownStart, cooldownDuration) then
				cooldownStart, cooldownDuration, cooldownEnabled = 0, 0, true
				cooldownGCD = true
			end
		end
		local cooldownEnabledOk = cooldownEnabled ~= false and cooldownEnabled ~= 0
		newVisible = alwaysShow or showWhenEmpty
		if not newVisible and showCooldown and cooldownEnabledOk and isCooldownActive(cooldownStart, cooldownDuration) then newVisible = true end
	end
	local customCooldownState = CooldownPanels:GetActiveEntryCustomCooldownDuration(panelId, entryId, entry, "ITEM")
	if customCooldownState then
		cooldownStart = customCooldownState.startTime
		cooldownDuration = customCooldownState.duration
		cooldownEnabled = true
		cooldownGCD = false
		if showCooldown then newVisible = true end
	end
	if entry.activationOverlayOnly == true and CooldownPanels:EntryUsesActivationOverlay(entry, "ITEM") then newVisible = customCooldownState ~= nil end
	if not icon then return newVisible ~= true end
	if not (data and data.entryId == entryId and data.resolvedType == "ITEM") then return false end
	if not newVisible then return false end

	local itemCount = nil
	if showItemCount then
		if cachedCount == nil then
			updateItemCountCacheForItem(resolvedItemId, showItemUses)
			itemCache = CooldownPanels.runtime and CooldownPanels.runtime.itemCountCache or nil
			cached = itemCache and itemCache[resolvedItemId] or nil
			cachedCount = cached and cached.count or 0
		end
		if isSafeGreaterThan(cachedCount, 0) then
			itemCount = cachedCount
		elseif showWhenEmpty then
			itemCount = 0
		end
	end

	local itemUses = nil
	if showItemUses then
		local uses = cached and cached.uses
		if uses == nil then
			updateItemCountCacheForItem(resolvedItemId, true)
			itemCache = CooldownPanels.runtime and CooldownPanels.runtime.itemCountCache or nil
			cached = itemCache and itemCache[resolvedItemId] or nil
			uses = cached and cached.uses or 0
		end
		if isSafeGreaterThan(uses, 0) then
			itemUses = uses
		elseif showWhenEmpty then
			itemUses = 0
		end
	end

	data.itemCount = itemCount
	data.itemUses = itemUses
	data.emptyItem = emptyItem
	data.cooldownStart = cooldownStart or 0
	data.cooldownDuration = cooldownDuration or 0
	data.cooldownEnabled = cooldownEnabled
	data.cooldownIsActive = nil
	data.cooldownRate = 1
	data.cooldownGCD = cooldownGCD == true
	data.cooldownDurationObject = nil
	data.chargeDurationObject = nil
	data.chargesInfo = nil
	data.canTriggerReadyGlow = ownsItem == true
	data.resolvedItemId = resolvedItemId
	data.customCooldownDurationActive = customCooldownState ~= nil
	data.customCooldownDurationKey = customCooldownState and customCooldownState.key or nil
	data.customCooldownDurationObject = customCooldownState and customCooldownState.durationObject or nil
	data.customCooldownStart = customCooldownState and customCooldownState.startTime or nil
	data.customCooldownDuration = customCooldownState and customCooldownState.duration or nil
	CooldownPanels:ApplyCustomCooldownDurationState(data, customCooldownState)
	CooldownPanels:ApplyActivationOverlayVisualState(data, entry)
	if customCooldownState then data.canTriggerReadyGlow = true end

	local resolvedLayout = CooldownPanels.ResolveRuntimeLayout(runtime, runtime.frame, panel.layout)
	return cdp.ENTRY.ApplyVisibleItemRuntime(panelId, runtime, icon, data, resolvedLayout)
end

function cdp.ENTRY.TryRefreshVisibleSlotEntry(panelId, entryId)
	local panel = CooldownPanels:GetPanel(panelId)
	local runtime = panel and getRuntime(panelId) or nil
	if not (panel and runtime and runtime.frame) then return false end
	if CooldownPanels:IsPanelLayoutEditActive(panelId) then return false end

	local icon = runtime.entryToIcon and runtime.entryToIcon[entryId] or nil
	local data = icon and icon._eqolRuntimeData or nil
	local entry = panel.entries and panel.entries[entryId] or nil
	if not (entry and entry.type == "SLOT") then return false end
	if entry.displayMode == "BAR" then return false end

	local slotId = tonumber(entry.slotID)
	if not slotId then return false end
	local showCooldown = entry.showCooldown ~= false
	local alwaysShow = entry.alwaysShow ~= false
	local showWhenNoCooldown = entry.showWhenNoCooldown == true
	local itemId = Api.GetInventoryItemID and Api.GetInventoryItemID("player", slotId) or nil
	local hasUsableItem = itemId and itemHasUseSpell(itemId) or false
	local cooldownStart, cooldownDuration, cooldownEnabled
	local cooldownGCD = false
	local newVisible = false
	if hasUsableItem then
		if showCooldown or entry.staticTextShowOnCooldown == true then
			cooldownStart, cooldownDuration, cooldownEnabled = getItemCooldownInfo(itemId, slotId)
			if CooldownPanels:IsItemCooldownOnGCD(itemId, cooldownStart, cooldownDuration) then
				cooldownStart, cooldownDuration, cooldownEnabled = 0, 0, true
				cooldownGCD = true
			end
		end
		local cooldownEnabledOk = cooldownEnabled ~= false and cooldownEnabled ~= 0
		newVisible = alwaysShow or showWhenNoCooldown
		if not newVisible and showCooldown and cooldownEnabledOk and isCooldownActive(cooldownStart, cooldownDuration) then newVisible = true end
	elseif showWhenNoCooldown then
		newVisible = true
	end
	local customCooldownState = CooldownPanels:GetActiveEntryCustomCooldownDuration(panelId, entryId, entry, "SLOT")
	if customCooldownState then
		cooldownStart = customCooldownState.startTime
		cooldownDuration = customCooldownState.duration
		cooldownEnabled = true
		cooldownGCD = false
		if showCooldown then newVisible = true end
	end
	if entry.activationOverlayOnly == true and CooldownPanels:EntryUsesActivationOverlay(entry, "SLOT") then newVisible = customCooldownState ~= nil end
	if not icon then return newVisible ~= true end
	if not (data and data.entryId == entryId and data.resolvedType == "SLOT") then return false end
	if not newVisible then return false end

	data.cooldownStart = cooldownStart or 0
	data.cooldownDuration = cooldownDuration or 0
	data.cooldownEnabled = cooldownEnabled
	data.cooldownIsActive = nil
	data.cooldownRate = 1
	data.cooldownGCD = cooldownGCD == true
	data.cooldownDurationObject = nil
	data.chargeDurationObject = nil
	data.chargesInfo = nil
	data.canTriggerReadyGlow = hasUsableItem == true
	data.customCooldownDurationActive = customCooldownState ~= nil
	data.customCooldownDurationKey = customCooldownState and customCooldownState.key or nil
	data.customCooldownDurationObject = customCooldownState and customCooldownState.durationObject or nil
	data.customCooldownStart = customCooldownState and customCooldownState.startTime or nil
	data.customCooldownDuration = customCooldownState and customCooldownState.duration or nil
	CooldownPanels:ApplyCustomCooldownDurationState(data, customCooldownState)
	CooldownPanels:ApplyActivationOverlayVisualState(data, entry)
	if customCooldownState then data.canTriggerReadyGlow = true end

	local resolvedLayout = CooldownPanels.ResolveRuntimeLayout(runtime, runtime.frame, panel.layout)
	return cdp.ENTRY.ApplyVisibleItemRuntime(panelId, runtime, icon, data, resolvedLayout)
end

function cdp.ENTRY.RefreshSpellEntries(spellId, baseSpellId, mode, panelsToRefresh)
	local runtime = CooldownPanels.runtime
	local targets = cdp.ENTRY.GetSpellTargets(runtime, spellId, baseSpellId)
	if not targets then return false end
	local startedBatch = false
	if CooldownPanels.BeginRuntimeQueryBatch and CooldownPanels.IsRuntimeQueryBatchActive and not CooldownPanels:IsRuntimeQueryBatchActive() then
		CooldownPanels:BeginRuntimeQueryBatch()
		startedBatch = true
	end

	local refreshed = false
	for panelId, entries in pairs(targets) do
		local panelNeedsRefresh = false
		for entryId, meta in pairs(entries) do
			local shouldProcess = false
			if mode == "charges" then
				shouldProcess = meta.trackCharges == true
			else
				shouldProcess = meta.trackCooldown == true
			end
			if shouldProcess then
				if cdp.ENTRY.TryRefreshVisibleSpellEntry(panelId, entryId, mode) then
					refreshed = true
				else
					panelNeedsRefresh = true
					refreshed = true
					break
				end
			end
		end
		if panelNeedsRefresh then cdp.ENTRY.QueuePanelRefresh(panelsToRefresh, panelId) end
	end

	if startedBatch and CooldownPanels.EndRuntimeQueryBatch then CooldownPanels:EndRuntimeQueryBatch() end
	return refreshed
end

function cdp.ENTRY.RefreshItemEntries(itemId, panelsToRefresh)
	local runtime = CooldownPanels.runtime
	local targets = cdp.ENTRY.GetItemTargets(runtime, itemId)
	if not targets then return false end

	local refreshed = false
	for panelId, entries in pairs(targets) do
		local panelNeedsRefresh = false
		for entryId in pairs(entries) do
			if cdp.ENTRY.TryRefreshVisibleItemEntry(panelId, entryId) then
				refreshed = true
			else
				panelNeedsRefresh = true
				refreshed = true
				break
			end
		end
		if panelNeedsRefresh then cdp.ENTRY.QueuePanelRefresh(panelsToRefresh, panelId) end
	end

	return refreshed
end

function cdp.ENTRY.RefreshAllTrackedBagCooldownEntries()
	local runtime = CooldownPanels.runtime
	if not runtime then return false end
	local panelsToRefresh = cdp.ENTRY.GetPanelRefreshScratch(runtime, "_eqolBagCooldownEntryRefreshScratch")
	local refreshed = false

	local itemMeta = runtime.itemEntryMeta
	if itemMeta then
		for panelId, entries in pairs(itemMeta) do
			local panelNeedsRefresh = false
			for entryId in pairs(entries) do
				if cdp.ENTRY.TryRefreshVisibleItemEntry(panelId, entryId) then
					refreshed = true
				else
					panelNeedsRefresh = true
				end
			end
			if panelNeedsRefresh then
				cdp.ENTRY.QueuePanelRefresh(panelsToRefresh, panelId)
				refreshed = true
			end
		end
	end

	local slotMeta = runtime.slotEntryMeta
	if slotMeta then
		for panelId, entries in pairs(slotMeta) do
			local panelNeedsRefresh = false
			for entryId in pairs(entries) do
				if cdp.ENTRY.TryRefreshVisibleSlotEntry(panelId, entryId) then
					refreshed = true
				else
					panelNeedsRefresh = true
				end
			end
			if panelNeedsRefresh then
				cdp.ENTRY.QueuePanelRefresh(panelsToRefresh, panelId)
				refreshed = true
			end
		end
	end

	if cdp.ENTRY.FlushPanelRefreshes(panelsToRefresh) then refreshed = true end
	return refreshed
end

function cdp.ENTRY.RefreshChangedTrackedItemEntries()
	local runtime = CooldownPanels.runtime
	if not runtime then return false end
	runtime.itemCountCache = runtime.itemCountCache or {}
	local cache = runtime.itemCountCache
	local trackedIds = runtime.itemTrackedIds
	local trackedUsesIds = runtime.itemUsesTrackedIds
	if not trackedIds or not next(trackedIds) then return false end

	local changedItemIds = runtime._eqolChangedTrackedItemIds
	if not changedItemIds then
		changedItemIds = {}
		runtime._eqolChangedTrackedItemIds = changedItemIds
	else
		for itemId in pairs(changedItemIds) do
			changedItemIds[itemId] = nil
		end
	end

	for itemId in pairs(cache) do
		if not trackedIds[itemId] then cache[itemId] = nil end
	end
	for itemId in pairs(trackedIds) do
		local slot = cache[itemId] or {}
		local oldCount = slot.count
		local oldUses = slot.uses
		local newCount = Api.GetItemCount(itemId, false, false) or 0
		local newUses = trackedUsesIds and trackedUsesIds[itemId] and (Api.GetItemCount(itemId, false, true) or 0) or nil
		if oldCount ~= newCount or oldUses ~= newUses then changedItemIds[itemId] = true end
		slot.count = newCount
		slot.uses = newUses
		cache[itemId] = slot
	end
	if not next(changedItemIds) then return false end

	local panelsToRefresh = cdp.ENTRY.GetPanelRefreshScratch(runtime, "_eqolItemEntryRefreshScratch")
	local refreshed = false
	for itemId in pairs(changedItemIds) do
		if cdp.ENTRY.RefreshItemEntries(itemId, panelsToRefresh) then refreshed = true end
	end
	if cdp.ENTRY.FlushPanelRefreshes(panelsToRefresh) then refreshed = true end
	return refreshed
end

local function refreshTrackedSpellEntries(spellId, baseSpellId, mode, panelsToRefresh) return cdp.ENTRY.RefreshSpellEntries(spellId, baseSpellId, mode, panelsToRefresh) == true end

refreshPanelsForSpell = function(spellId)
	local id = tonumber(spellId)
	if not id then return false end
	local runtime = CooldownPanels.runtime
	local index = runtime and runtime.spellIndex
	if not runtime or not index then return false end
	runtime._eqolSpellRefreshScratch = runtime._eqolSpellRefreshScratch or {}
	local panelsToRefresh = runtime._eqolSpellRefreshScratch
	for panelId in pairs(panelsToRefresh) do
		panelsToRefresh[panelId] = nil
	end
	local refreshed = false
	local function collectPanels(lookupId)
		if not lookupId then return end
		local panels = index[lookupId]
		if not panels then return end
		for panelId in pairs(panels) do
			panelsToRefresh[panelId] = true
			refreshed = true
		end
	end
	local aliasIDs = CooldownPanels:GetSpellAliasIDs(id)
	for i = 1, #aliasIDs do
		collectPanels(aliasIDs[i])
	end
	if not refreshed then return false end
	for panelId in pairs(panelsToRefresh) do
		panelsToRefresh[panelId] = nil
		if CooldownPanels.RequestPanelRefresh then
			CooldownPanels:RequestPanelRefresh(panelId)
		elseif CooldownPanels:GetPanel(panelId) then
			CooldownPanels:RefreshPanel(panelId)
		end
	end
	return refreshed
end

local function invalidateSpellIconCacheForSpell(spellId)
	local numericSpellId = tonumber(spellId)
	local runtime = CooldownPanels.runtime
	local cache = runtime and runtime.iconCache
	if not (numericSpellId and cache) then return false end

	local changed = false
	local function clearForId(id)
		if not id then return end
		local cacheKey = "S:" .. tostring(id)
		if cache[cacheKey] ~= nil then
			cache[cacheKey] = nil
			changed = true
		end
	end

	clearForId(numericSpellId)

	local baseId = getBaseSpellId(numericSpellId)
	if baseId and baseId ~= numericSpellId then clearForId(baseId) end

	local effectiveId = getEffectiveSpellId(numericSpellId)
	if effectiveId and effectiveId ~= numericSpellId and effectiveId ~= baseId then clearForId(effectiveId) end

	local canonicalId, _, variantGroup = CooldownPanels:GetCanonicalSpellVariantID(numericSpellId)
	if canonicalId and canonicalId ~= numericSpellId and canonicalId ~= baseId and canonicalId ~= effectiveId then clearForId(canonicalId) end
	if type(variantGroup) == "table" then
		for i = 1, #variantGroup do
			clearForId(variantGroup[i])
		end
	end

	return changed
end

local setOverlayGlowForSpell

local function handleSpellUpdateIcon(spellId)
	if spellId ~= nil then
		invalidateSpellIconCacheForSpell(spellId)
		if setOverlayGlowForSpell(spellId, true) then return true end
		return refreshPanelsForSpell(spellId) == true
	end

	if CooldownPanels.runtime then CooldownPanels.runtime.iconCache = nil end
	if CooldownPanels.RefreshAllOverlayGlowStates then CooldownPanels:RefreshAllOverlayGlowStates(true) end
	CooldownPanels:RequestUpdate({
		cause = "Event:SPELL_UPDATE_ICON",
		fullRefresh = true,
	})
	return true
end

function CooldownPanels:GetReadySoundState(spellId, create)
	local runtime = self.runtime
	if not runtime then return nil end
	local stateKey = self:GetReadySoundStateKey(spellId)
	if not stateKey then return nil end
	runtime.readySoundStates = runtime.readySoundStates or {}
	local states = runtime.readySoundStates
	local state = states[stateKey]
	if not state and create ~= false then
		state = {}
		states[stateKey] = state
	end
	return state
end

function CooldownPanels:PrimeReadySoundStateForSpell(spellId)
	local runtime = self.runtime
	local stateKey = self:GetReadySoundStateKey(spellId)
	local queryByStateKey = runtime and runtime.readySoundQueryByStateKey
	local eventDrivenByStateKey = runtime and runtime.readySoundEventDrivenByStateKey
	local querySpellId = stateKey and queryByStateKey and queryByStateKey[stateKey] or nil
	if not (runtime and stateKey and querySpellId and eventDrivenByStateKey and eventDrivenByStateKey[stateKey] == true) then return end
	local state = self:GetReadySoundState(stateKey, true)
	if not state then return end
	state.initialized = true
	state.unavailable = self:GetTrustedReadyUnavailable(querySpellId) == true
end

function CooldownPanels:PrimeReadySoundStates()
	local runtime = self.runtime
	if not runtime then return end
	runtime.readySoundStates = {}
	local sweepIds = runtime.readySoundSweepIds
	if not sweepIds then return end
	for i = 1, #sweepIds do
		self:PrimeReadySoundStateForSpell(sweepIds[i])
	end
end

function CooldownPanels:EvaluateReadySoundTransitionForSpell(spellId)
	local runtime = self.runtime
	local stateKey = self:GetReadySoundStateKey(spellId)
	local queryByStateKey = runtime and runtime.readySoundQueryByStateKey
	local eventDrivenByStateKey = runtime and runtime.readySoundEventDrivenByStateKey
	local querySpellId = stateKey and queryByStateKey and queryByStateKey[stateKey] or nil
	if not (runtime and stateKey and querySpellId and eventDrivenByStateKey and eventDrivenByStateKey[stateKey] == true) then return false end
	local state = self:GetReadySoundState(stateKey, true)
	if not state then return false end
	local unavailable = self:GetTrustedReadyUnavailable(querySpellId) == true
	local shouldPlay = state.initialized == true and state.unavailable == true and unavailable ~= true
	state.initialized = true
	state.unavailable = unavailable
	return shouldPlay
end

function CooldownPanels:TriggerReadySoundForSpell(spellId)
	local id = tonumber(spellId)
	if not id then return end
	local runtime = self.runtime
	local stateKey = self:GetReadySoundStateKey(id)
	local soundByStateKey = runtime and runtime.readySoundSoundByStateKey
	local soundName = stateKey and soundByStateKey and soundByStateKey[stateKey] or nil
	if soundName and soundName ~= "None" then playSoundName(soundName) end
end

function CooldownPanels:SweepReadySoundTransitions()
	local runtime = self.runtime
	local sweepIds = runtime and runtime.readySoundSweepIds
	if not sweepIds then return false end
	local played = false
	for i = 1, #sweepIds do
		local spellId = sweepIds[i]
		if self:EvaluateReadySoundTransitionForSpell(spellId) then
			self:TriggerReadySoundForSpell(spellId)
			played = true
		end
	end
	return played
end

function CooldownPanels:HandleReadySoundSpellEvent(spellId, baseSpellId, fallbackSweep)
	local runtime = self.runtime
	local queryByStateKey = runtime and runtime.readySoundQueryByStateKey
	local eventDrivenByStateKey = runtime and runtime.readySoundEventDrivenByStateKey
	if not (queryByStateKey and next(queryByStateKey)) then return false end
	local seenKeys = {}
	local handled = false
	local played = false

	local function process(id)
		id = tonumber(id)
		if not id then return end
		local stateKey = self:GetReadySoundStateKey(id)
		local querySpellId = stateKey and queryByStateKey[stateKey] or nil
		if not (stateKey and querySpellId and eventDrivenByStateKey and eventDrivenByStateKey[stateKey] == true) then return end
		if seenKeys[stateKey] then return end
		seenKeys[stateKey] = true
		handled = true
		if self:EvaluateReadySoundTransitionForSpell(id) then
			self:TriggerReadySoundForSpell(id)
			played = true
		end
	end

	process(spellId)
	process(baseSpellId)

	if not handled and fallbackSweep == true then played = self:SweepReadySoundTransitions() or played end
	return played
end

CooldownPanels.InvalidateChargeCachesForSpell = function(spellId)
	return CooldownPanels:InvalidateSpellCooldownCachesForAliases(spellId)
end

refreshPanelsForCharges = function()
	local runtime = CooldownPanels.runtime
	local chargesIndex = runtime and runtime.chargesIndex
	if not chargesIndex or not Api.GetSpellChargesInfo then return false end
	runtime.chargesState = runtime.chargesState or {}
	local chargesState = runtime.chargesState
	local panelsToRefresh = cdp.ENTRY.GetPanelRefreshScratch(runtime, "_eqolChargeEntryRefreshScratch")
	local changedSpellIds = runtime._eqolChargeChangedSpellScratch
	if not changedSpellIds then
		changedSpellIds = {}
		runtime._eqolChargeChangedSpellScratch = changedSpellIds
	else
		for trackedSpellId in pairs(changedSpellIds) do
			changedSpellIds[trackedSpellId] = nil
		end
	end

	for spellId, panels in pairs(chargesIndex) do
		local info = Api.GetSpellChargesInfo(spellId)
		local function safeNumber(value)
			if Api.issecretvalue and Api.issecretvalue(value) then return nil, true end
			return type(value) == "number" and value or nil, false
		end
		local function safeBoolean(value)
			if Api.issecretvalue and Api.issecretvalue(value) then return nil, true end
			return type(value) == "boolean" and value or nil, false
		end
		local cur, curSecret = safeNumber(info and info.currentCharges)
		local max, maxSecret = safeNumber(info and info.maxCharges)
		local start, startSecret = safeNumber(info and info.cooldownStartTime)
		local duration, durationSecret = safeNumber(info and info.cooldownDuration)
		local rate, rateSecret = safeNumber(info and info.chargeModRate)
		local active, activeSecret = safeBoolean(info and info.isActive)

		local state = chargesState[spellId]
		local changed = false
		if not state then
			state = {}
			chargesState[spellId] = state
			changed = true
		end

		local function updateStateField(field, value, isSecret)
			local secretField = field .. "Secret"
			local normalizedSecret = isSecret or nil
			if state[field] ~= value or state[secretField] ~= normalizedSecret then changed = true end
			state[field] = value
			state[secretField] = normalizedSecret
		end
		updateStateField("cur", cur, curSecret)
		updateStateField("max", max, maxSecret)
		updateStateField("start", start, startSecret)
		updateStateField("duration", duration, durationSecret)
		updateStateField("rate", rate, rateSecret)
		updateStateField("active", active, activeSecret)
		if changed and panels then
			local refreshSpellId = getEffectiveSpellId(spellId) or tonumber(spellId)
			if refreshSpellId then
				changedSpellIds[refreshSpellId] = true
			else
				for panelId in pairs(panels) do
					panelsToRefresh[panelId] = true
				end
			end
		end
	end

	local refreshed = false
	for trackedSpellId in pairs(changedSpellIds) do
		local trackedBaseSpellId = getBaseSpellId(trackedSpellId)
		if refreshTrackedSpellEntries(trackedSpellId, trackedBaseSpellId, "charges", panelsToRefresh) then
			refreshed = true
		else
			local panels = chargesIndex[trackedSpellId]
			if not panels and trackedBaseSpellId and trackedBaseSpellId ~= trackedSpellId then panels = chargesIndex[trackedBaseSpellId] end
			if panels then
				for panelId in pairs(panels) do
					panelsToRefresh[panelId] = true
				end
				refreshed = true
			end
		end
	end
	if cdp.ENTRY.FlushPanelRefreshes(panelsToRefresh) then refreshed = true end
	return refreshed
end

local function updatePowerStatesVisible(powerTokens)
	if not Api.IsSpellUsableFn then return false end
	local runtime = CooldownPanels.runtime
	local powerIndex = runtime and runtime.powerIndex
	local powerCheckSpells = runtime and runtime.powerCheckSpells
	local powerPanelsBySpell = runtime and runtime.powerPanelsBySpell
	if not powerCheckSpells or not runtime.powerCheckActive then return false end
	runtime.powerInsufficient = runtime.powerInsufficient or {}
	runtime.spellUnusable = runtime.spellUnusable or {}
	runtime._eqolPowerCheckSpellsScratch = runtime._eqolPowerCheckSpellsScratch or {}
	runtime._eqolPowerChangedScratch = runtime._eqolPowerChangedScratch or {}
	local spellsToCheck = runtime._eqolPowerCheckSpellsScratch
	local changedBySpell = runtime._eqolPowerChangedScratch
	for spellId in pairs(spellsToCheck) do
		spellsToCheck[spellId] = nil
	end
	for spellId in pairs(changedBySpell) do
		changedBySpell[spellId] = nil
	end
	local checkAll = true
	if powerTokens and next(powerTokens) and powerIndex and next(powerIndex) then
		checkAll = false
		for token in pairs(powerTokens) do
			local bucket = token and powerIndex[token] or nil
			if bucket then
				for spellId in pairs(bucket) do
					spellsToCheck[spellId] = true
				end
			else
				checkAll = true
				break
			end
		end
		if not checkAll and not next(spellsToCheck) then checkAll = true end
	end
	local panelsToRefresh
	local spellMap = checkAll and powerCheckSpells or spellsToCheck
	for effectiveId in pairs(spellMap) do
		if powerCheckSpells[effectiveId] then
			local isUsable, insufficientPower = Api.IsSpellUsableFn(effectiveId)
			changedBySpell[effectiveId] = setPowerInsufficient(runtime, effectiveId, isUsable, insufficientPower) == true
			if changedBySpell[effectiveId] then
				local panels = powerPanelsBySpell and powerPanelsBySpell[effectiveId] or nil
				if panels then
					panelsToRefresh = panelsToRefresh or {}
					for panelId in pairs(panels) do
						panelsToRefresh[panelId] = true
					end
				end
			end
		end
	end
	if panelsToRefresh then
		for panelId in pairs(panelsToRefresh) do
			if CooldownPanels.RequestPanelRefresh then
				CooldownPanels:RequestPanelRefresh(panelId)
			elseif CooldownPanels:GetPanel(panelId) then
				CooldownPanels:RefreshPanel(panelId)
			end
		end
	end
	return true
end

local function schedulePowerUsableRefresh(powerToken)
	local runtime = CooldownPanels.runtime
	if not runtime or not runtime.powerCheckActive then return end
	runtime._eqolPendingPowerTokens = runtime._eqolPendingPowerTokens or {}
	local pendingTokens = runtime._eqolPendingPowerTokens
	if type(powerToken) == "string" and powerToken ~= "" then
		pendingTokens[string.upper(powerToken)] = true
	else
		runtime._eqolPendingPowerFull = true
	end
	if runtime.powerRefreshPending then return end
	runtime.powerRefreshPending = true
	C_Timer.After(CooldownPanels.POWER_USABLE_REFRESH_DELAY, function()
		local rt = CooldownPanels.runtime
		if not rt then return end
		local pending = rt._eqolPendingPowerTokens
		local pendingFull = rt._eqolPendingPowerFull == true
		rt.powerRefreshPending = nil
		rt._eqolPendingPowerFull = nil
		updatePowerStatesVisible(pendingFull and nil or pending)
		rt._eqolPendingPowerTokens = rt._eqolPendingPowerTokens or {}
		if pending and next(pending) then
			for token in pairs(pending) do
				rt._eqolPendingPowerTokens[token] = nil
			end
		end
	end)
end

updatePowerEventRegistration = function()
	local runtime = CooldownPanels.runtime
	local frame = runtime and runtime.updateFrame
	if not frame or not frame.RegisterUnitEvent then return end
	local enable = runtime and runtime.powerCheckActive
	if enable and not runtime.powerEventRegistered then
		frame:RegisterUnitEvent("UNIT_POWER_UPDATE", "player")
		frame:RegisterEvent("SPELL_UPDATE_USABLE")
		runtime.powerEventRegistered = true
	elseif not enable and runtime.powerEventRegistered then
		frame:UnregisterEvent("UNIT_POWER_UPDATE")
		frame:UnregisterEvent("SPELL_UPDATE_USABLE")
		runtime.powerEventRegistered = nil
	end
end

updateRangeCheckSpells = function(rangeCheckSpells)
	if not Api.EnableSpellRangeCheck then return end
	local wanted = rangeCheckSpells
	if not wanted then
		wanted = {}
		local root = ensureRoot()
		if root and root.panels then
			for _, panel in pairs(root.panels) do
				if panel and panel.enabled ~= false and panelAllowsSpec(panel) then
					local layout = panel.layout
					if layout and layout.rangeOverlayEnabled == true then
						for _, entry in pairs(panel.entries or {}) do
							local spellId
							if entry and entry.type == "SPELL" and entry.spellID then
								spellId = tonumber(entry.spellID)
							elseif entry and entry.type == "MACRO" then
								local macro = CooldownPanels.ResolveMacroEntry(entry)
								if macro and macro.kind == "SPELL" and macro.spellID then spellId = tonumber(macro.spellID) end
							end
							if spellId then
								local effectiveId, resolvedSpellId = CooldownPanels:ResolveTrackedSpellID(spellId)
								effectiveId = effectiveId or getEffectiveSpellId(spellId) or spellId
								resolvedSpellId = resolvedSpellId or spellId
								if effectiveId and (shouldTrackPassiveSpell(entry) or not isSpellPassiveSafe(resolvedSpellId, effectiveId)) then wanted[effectiveId] = true end
							end
						end
					end
				end
			end
		end
	end

	CooldownPanels.runtime = CooldownPanels.runtime or {}
	local runtime = CooldownPanels.runtime
	runtime.rangeCheckSpells = runtime.rangeCheckSpells or {}
	local active = runtime.rangeCheckSpells

	for spellId in pairs(active) do
		if not wanted[spellId] then
			Api.EnableSpellRangeCheck(spellId, false)
			active[spellId] = nil
			if runtime.rangeOverlaySpells then runtime.rangeOverlaySpells[spellId] = nil end
		end
	end
	for spellId in pairs(wanted) do
		if not active[spellId] then
			Api.EnableSpellRangeCheck(spellId, true)
			active[spellId] = true
		end
	end
end
local function clearReadyGlowForSpell(spellId)
	local id = tonumber(spellId)
	if not id then return false end
	local index = CooldownPanels.runtime and CooldownPanels.runtime.spellIndex
	if not index then return false end
	local panels
	local aliasIDs = CooldownPanels:GetSpellAliasIDs(id, {}, {})
	for i = 1, #aliasIDs do
		local aliasPanels = index[aliasIDs[i]]
		if aliasPanels then
			panels = panels or {}
			for panelId in pairs(aliasPanels) do
				panels[panelId] = true
			end
		end
	end
	if not panels then return false end
	for panelId in pairs(panels) do
		local runtime = getRuntime(panelId)
		local panel = CooldownPanels:GetPanel(panelId)
		if runtime and panel and panel.entries then
			runtime.readyAt = runtime.readyAt or {}
			runtime.glowTimers = runtime.glowTimers or {}
			local primed = CooldownPanels.GetReadyGlowPrimedState(runtime)
			for entryId, entry in pairs(panel.entries) do
				if entry then
					local entrySpellId
					if entry.type == "SPELL" and entry.spellID then
						entrySpellId = tonumber(entry.spellID)
					elseif entry.type == "MACRO" then
						local macro = CooldownPanels.ResolveMacroEntry(entry)
						if macro and macro.kind == "SPELL" and macro.spellID then entrySpellId = tonumber(macro.spellID) end
					end
					if CooldownPanels:AreSpellVariantsEquivalent(entrySpellId, id) then
						runtime.readyAt[entryId] = nil
						local t = runtime.glowTimers[entryId]
						if t and t.Cancel then t:Cancel() end
						runtime.glowTimers[entryId] = nil
						if primed then primed[entryId] = nil end
					end
				end
			end
		end
	end
	return true
end

local function triggerProcSoundForSpell(spellId)
	local id = tonumber(spellId)
	if not id then return end

	local index = CooldownPanels.runtime and CooldownPanels.runtime.spellIndex
	if not index then return end

	local panels
	local function mergePanels(map)
		if not map then return end
		panels = panels or {}
		for panelId in pairs(map) do
			panels[panelId] = true
		end
	end

	local aliasIDs = CooldownPanels:GetSpellAliasIDs(id)
	for i = 1, #aliasIDs do
		mergePanels(index[aliasIDs[i]])
	end
	if not panels then return end

	-- EINMAL spielen, auch wenn der Spell in mehreren Panels vorkommt.
	local root = ensureRoot()
	local panelOrder = root and root.order

	local function scanPanel(panelId)
		local panel = CooldownPanels:GetPanel(panelId)
		if not (panel and panel.order and panel.entries) then return false end

		for _, entryId in ipairs(panel.order) do
			local entry = panel.entries[entryId]
			if entry then
				local entryBaseId
				if entry.type == "SPELL" and entry.spellID then
					entryBaseId = tonumber(entry.spellID)
				elseif entry.type == "MACRO" then
					local macro = CooldownPanels.ResolveMacroEntry(entry)
					if macro and macro.kind == "SPELL" and macro.spellID then entryBaseId = tonumber(macro.spellID) end
				end
				if entryBaseId and CooldownPanels:AreSpellVariantsEquivalent(entryBaseId, id) and entry.type ~= "MACRO" and entry.soundReady == true then
					local soundName = normalizeSoundName(entry.soundReadyFile)
					if soundName and soundName ~= "None" then
						playSoundName(soundName)
						return true
					end
				end
			end
		end

		return false
	end

	-- deterministisch: erst Root-Panel-Reihenfolge
	if panelOrder then
		for _, panelId in ipairs(panelOrder) do
			if panels[panelId] and scanPanel(panelId) then return end
		end
	end

	-- fallback: irgend eins
	for panelId in pairs(panels) do
		if scanPanel(panelId) then return end
	end
end

setOverlayGlowForSpell = function(spellId, enabled)
	local id = tonumber(spellId)
	if not id then return false end
	CooldownPanels.runtime = CooldownPanels.runtime or {}
	local runtime = CooldownPanels.runtime
	runtime.overlayGlowSpells = runtime.overlayGlowSpells or {}
	local overlayGlowSpells = runtime.overlayGlowSpells
	if Api.IsSpellOverlayed then
		local aliasIds = CooldownPanels:GetSpellAliasIDs(id, {}, {})
		local wasEnabled = false
		for i = 1, #aliasIds do
			local aliasId = aliasIds[i]
			if aliasId and overlayGlowSpells[aliasId] == true then
				wasEnabled = true
				break
			end
		end
		local isEnabled = false
		local changed = false
		for i = 1, #aliasIds do
			local aliasId = aliasIds[i]
			local value = (aliasId and Api.IsSpellOverlayed(aliasId) == true) and true or nil
			if value then isEnabled = true end
			if aliasId and overlayGlowSpells[aliasId] ~= value then
				overlayGlowSpells[aliasId] = value
				changed = true
			end
		end
		if enabled and isEnabled and not wasEnabled then triggerProcSoundForSpell(id) end
		if not changed then return false end
	else
		local baseId = getBaseSpellId(id)
		local effectiveId = getEffectiveSpellId(id)
		local wasEnabled = overlayGlowSpells[id] == true
		if not wasEnabled and baseId then wasEnabled = overlayGlowSpells[baseId] == true end
		if not wasEnabled and effectiveId then wasEnabled = overlayGlowSpells[effectiveId] == true end
		local function setFlag(spellIdentifier, value)
			if spellIdentifier then overlayGlowSpells[spellIdentifier] = value end
		end
		if enabled then
			setFlag(id, true)
			if baseId and baseId ~= id then setFlag(baseId, true) end
			if effectiveId and effectiveId ~= id then setFlag(effectiveId, true) end
		else
			setFlag(id, nil)
			if baseId and baseId ~= id then setFlag(baseId, nil) end
			if effectiveId and effectiveId ~= id then setFlag(effectiveId, nil) end
		end
		-- Sound nur wenn es frisch "an" ging.
		if enabled and not wasEnabled then triggerProcSoundForSpell(id) end
	end
	if refreshPanelsForSpell and refreshPanelsForSpell(id) then return true end
	if CooldownPanels and CooldownPanels.RequestUpdate then CooldownPanels:RequestUpdate("OverlayGlow") end
	return true
end

function CooldownPanels:RefreshAllOverlayGlowStates(suppressRefresh)
	if not Api.IsSpellOverlayed then return false end
	local runtime = self.runtime
	if not runtime then return false end
	runtime.overlayGlowSpells = runtime.overlayGlowSpells or {}
	local overlayGlowSpells = runtime.overlayGlowSpells
	local candidateIds = runtime.overlayGlowCandidateIds
	if not candidateIds then
		candidateIds = {}
		runtime.overlayGlowCandidateIds = candidateIds
	else
		for i = 1, #candidateIds do
			candidateIds[i] = nil
		end
	end
	local candidateSeen = runtime.overlayGlowCandidateSeen
	if not candidateSeen then
		candidateSeen = {}
		runtime.overlayGlowCandidateSeen = candidateSeen
	else
		wipe(candidateSeen)
	end
	local spellIndex = runtime.spellIndex
	if spellIndex then
		for spellId in pairs(spellIndex) do
			self:GetSpellAliasIDs(spellId, candidateIds, candidateSeen)
		end
	end
	for spellId in pairs(overlayGlowSpells) do
		self:GetSpellAliasIDs(spellId, candidateIds, candidateSeen)
	end
	local changed = false
	for i = 1, #candidateIds do
		local candidateId = candidateIds[i]
		local value = (candidateId and Api.IsSpellOverlayed(candidateId) == true) and true or nil
		if candidateId and overlayGlowSpells[candidateId] ~= value then
			overlayGlowSpells[candidateId] = value
			changed = true
		end
	end
	if not changed then return false end
	if suppressRefresh then return true end
	if self.RequestEnabledPanelRefreshes and self:RequestEnabledPanelRefreshes() then return true end
	if self.RequestUpdate then self:RequestUpdate("OverlayGlowSync") end
	return true
end

local function setRangeOverlayForSpell(spellIdentifier, isInRange, checksRange)
	local id = tonumber(spellIdentifier)
	if not id and type(spellIdentifier) == "string" then id = C_Spell.GetSpellIDForSpellIdentifier(spellIdentifier) end
	if not id then return false end
	local index = CooldownPanels.runtime and CooldownPanels.runtime.spellIndex
	local panels = index and index[id]
	if not panels then return false end
	CooldownPanels.runtime = CooldownPanels.runtime or {}
	local runtime = CooldownPanels.runtime
	runtime.rangeOverlaySpells = runtime.rangeOverlaySpells or {}
	local shouldShow = checksRange and isInRange == false
	local wasShown = runtime.rangeOverlaySpells[id] == true
	if shouldShow == wasShown then return false end
	if shouldShow then
		runtime.rangeOverlaySpells[id] = true
	else
		runtime.rangeOverlaySpells[id] = nil
	end
	if refreshPanelsForSpell and refreshPanelsForSpell(id) then return true end
	if CooldownPanels and CooldownPanels.RequestUpdate then CooldownPanels:RequestUpdate("RangeOverlay") end
	return true
end

local function refreshPanelsDisabledBySpecChange(previousEnabledPanels)
	if type(previousEnabledPanels) ~= "table" then return false end
	local runtime = CooldownPanels.runtime
	local enabledPanels = runtime and runtime.enabledPanels
	local root = ensureRoot()
	if not root or not root.panels then return false end

	local refreshed = false
	CooldownPanels:BeginRuntimeQueryBatch()
	for panelId in pairs(previousEnabledPanels) do
		if panelId and root.panels[panelId] and not (enabledPanels and enabledPanels[panelId]) then
			CooldownPanels:RefreshPanel(panelId)
			refreshed = true
		end
	end
	CooldownPanels:EndRuntimeQueryBatch()

	local disabledPanelIds = runtime and runtime.disabledPanelIds
	if disabledPanelIds then
		for i = 1, #disabledPanelIds do
			disabledPanelIds[i] = nil
		end
	end
	return refreshed
end

local function performSpecAwareRebuild(cause)
	local runtime = CooldownPanels.runtime
	local previousSpecId = runtime and runtime.activeSpecId
	local previousEnabledPanels = runtime and runtime.enabledPanels
	local racialsChanged = CooldownPanels:NormalizeActivePanelPlayerRacials()
	if racialsChanged then Keybinds.MarkPanelsDirty() end
	CooldownPanels:RebuildSpellIndex()
	local currentSpecId = CooldownPanels.runtime and CooldownPanels.runtime.activeSpecId
	if previousSpecId ~= nil and previousSpecId ~= currentSpecId then refreshPanelsDisabledBySpecChange(previousEnabledPanels) end
	Keybinds.InvalidateCache()
	CooldownPanels:RequestUpdate({
		cause = cause,
		fullRefresh = true,
	})
	if CooldownPanels.IsEditorOpen and CooldownPanels:IsEditorOpen() then CooldownPanels:RefreshEditor() end
end

local function runDelayedSpecAwareRebuild()
	local rt = CooldownPanels.runtime
	if not rt then return end
	rt.specRebuildPending = nil
	rt.specRebuildImmediateDone = nil
	local cause = rt.specRebuildCause
	rt.specRebuildCause = nil
	performSpecAwareRebuild(cause)
end

local function scheduleSpecAwareRebuild(event, immediate)
	CooldownPanels.runtime = CooldownPanels.runtime or {}
	local runtime = CooldownPanels.runtime
	runtime.specRebuildCause = "Event:" .. tostring(event or "Unknown")

	if immediate == true and runtime.specRebuildImmediateDone ~= true then
		runtime.specRebuildImmediateDone = true
		performSpecAwareRebuild(runtime.specRebuildCause .. ":Immediate")
	end

	if runtime.specRebuildPending then return end
	runtime.specRebuildPending = true
	C_Timer.After(1, runDelayedSpecAwareRebuild)
end

CooldownPanels.UPDATE_FRAME_EVENTS = {
	"PLAYER_ENTERING_WORLD",
	"PLAYER_LOGIN",
	"ADDON_LOADED",
	"CVAR_UPDATE",
	"SPELL_UPDATE_COOLDOWN",
	"SPELL_UPDATE_ICON",
	"SPELL_UPDATE_CHARGES",
	"SPELL_UPDATE_USES",
	"SPELLS_CHANGED",
	"ACTIVE_PLAYER_SPECIALIZATION_CHANGED",
	"ACTIVE_TALENT_GROUP_CHANGED",
	"PLAYER_TALENT_UPDATE",
	"TRAIT_CONFIG_UPDATED",
	"TRAIT_CONFIG_LIST_UPDATED",
	"PLAYER_EQUIPMENT_CHANGED",
	"PLAYER_MOUNT_DISPLAY_CHANGED",
	"UPDATE_SHAPESHIFT_FORM",
	"PLAYER_CAN_GLIDE_CHANGED",
	"PLAYER_IS_GLIDING_CHANGED",
	"GROUP_ROSTER_UPDATE",
	"PLAYER_TARGET_CHANGED",
	"BAG_UPDATE_DELAYED",
	"BAG_UPDATE_COOLDOWN",
	"UPDATE_BINDINGS",
	"UPDATE_MACROS",
	"ACTIONBAR_SLOT_CHANGED",
	"ACTIONBAR_PAGE_CHANGED",
	"ACTIONBAR_HIDEGRID",
	"SPELL_ACTIVATION_OVERLAY_GLOW_SHOW",
	"SPELL_ACTIVATION_OVERLAY_GLOW_HIDE",
	"SPELL_RANGE_CHECK_UPDATE",
	"PLAYER_REGEN_DISABLED",
	"PLAYER_REGEN_ENABLED",
	"PET_BATTLE_OPENING_START",
	"PET_BATTLE_CLOSE",
	"CLIENT_SCENE_OPENED",
	"CLIENT_SCENE_CLOSED",
}

CooldownPanels.UPDATE_FRAME_PASSIVE_EVENTS = {
	"PLAYER_ENTERING_WORLD",
	"PLAYER_LOGIN",
	"ADDON_LOADED",
	"SPELLS_CHANGED",
	"ACTIVE_PLAYER_SPECIALIZATION_CHANGED",
	"ACTIVE_TALENT_GROUP_CHANGED",
	"PLAYER_TALENT_UPDATE",
	"TRAIT_CONFIG_UPDATED",
	"TRAIT_CONFIG_LIST_UPDATED",
}

local function hasEnabledPanels()
	local runtime = CooldownPanels and CooldownPanels.runtime
	local enabledPanels = runtime and runtime.enabledPanels
	return enabledPanels and next(enabledPanels) ~= nil
end

local function hasConfiguredEnabledPanels()
	local root = ensureRoot()
	if not root or not root.panels then return false end
	local classSpecs = getPlayerClassSpecMap()
	for _, panel in pairs(root.panels) do
		if panel and panel.enabled ~= false and panelHasRuntimeEntries(panel) and panelMatchesPlayerClass(panel, classSpecs) then return true end
	end
	return false
end

local function getUpdateFrameRegistrationMode()
	if hasEnabledPanels() then return "active" end
	if hasConfiguredEnabledPanels() then return "passive" end
	return nil
end

CooldownPanels.RequestEnabledPanelRefreshes = function()
	local root = ensureRoot()
	local runtime = CooldownPanels and CooldownPanels.runtime
	local enabledPanels = runtime and runtime.enabledPanels
	local enabledPanelIds = runtime and runtime.enabledPanelIds
	if not (root and root.panels and enabledPanels and next(enabledPanels)) then return false end
	local panelIds = enabledPanelIds
	local queued = false
	local queueRefresh = type(CooldownPanels.RequestPanelRefresh) == "function"
	if panelIds and #panelIds > 0 then
		if queueRefresh then
			for i = 1, #panelIds do
				local panelId = panelIds[i]
				if enabledPanels[panelId] then
					CooldownPanels:RequestPanelRefresh(panelId)
					queued = true
				end
			end
			return queued
		end
		CooldownPanels:BeginRuntimeQueryBatch()
		for i = 1, #panelIds do
			CooldownPanels:RefreshPanel(panelIds[i])
			queued = true
		end
		CooldownPanels:EndRuntimeQueryBatch()
		return queued
	end
	if queueRefresh then
		for _, panelId in ipairs(CooldownPanels.GetCachedPanelIds(root)) do
			if enabledPanels[panelId] then
				CooldownPanels:RequestPanelRefresh(panelId)
				queued = true
			end
		end
		return queued
	end
	CooldownPanels:BeginRuntimeQueryBatch()
	for _, panelId in ipairs(CooldownPanels.GetCachedPanelIds(root)) do
		if enabledPanels[panelId] then
			CooldownPanels:RefreshPanel(panelId)
			queued = true
		end
	end
	CooldownPanels:EndRuntimeQueryBatch()
	return queued
end

cdp.RUNTIME.CDM_SETUP_VISIBLE_SETTING = Enum and Enum.EditModeCooldownViewerSetting and Enum.EditModeCooldownViewerSetting.VisibleSetting or 6
cdp.RUNTIME.CDM_SETUP_VISIBLE_ALWAYS = Enum and Enum.CooldownViewerVisibleSetting and Enum.CooldownViewerVisibleSetting.Always or 0
cdp.RUNTIME.CDM_SETUP_BUFF_VIEWERS = {
	"BuffIconCooldownViewer",
}
cdp.RUNTIME.CDM_SETUP_POPUP = "EQOL_COOLDOWN_PANELS_CDM_QUICK_SETUP"
cdp.RUNTIME.CDM_SETUP_PROMPT_DELAY = 1.5

cdp.RUNTIME.AddCDMSetupIssue = function(issues, key, detail)
	issues = issues or {}
	issues[key] = detail or true
	return issues
end

cdp.RUNTIME.GetCDMSetupIssueSignature = function(issues)
	if not issues then return nil end
	local parts = {}
	for _, key in ipairs({ "cdmDisabled", "buffViewerNotAlwaysVisible" }) do
		local value = issues[key]
		if value ~= nil then parts[#parts + 1] = key .. (value ~= true and (":" .. tostring(value)) or "") end
	end
	if #parts == 0 then return nil end
	return table.concat(parts, "|")
end

cdp.RUNTIME.ShouldSuppressCDMSetupPrompt = function(root, issues)
	if not root then return true end
	local signature = cdp.RUNTIME.GetCDMSetupIssueSignature(issues)
	if not signature then return false end
	if root.cdmAuraQuickSetupIgnored == true then root.cdmAuraQuickSetupIgnored = nil end
	if root.cdmAuraQuickSetupSuppressedAfterApply == true then root.cdmAuraQuickSetupSuppressedAfterApply = nil end
	if root.cdmAuraQuickSetupIgnored ~= nil and root.cdmAuraQuickSetupIgnored ~= signature then root.cdmAuraQuickSetupIgnored = nil end
	if root.cdmAuraQuickSetupSuppressedAfterApply ~= nil and root.cdmAuraQuickSetupSuppressedAfterApply ~= signature then root.cdmAuraQuickSetupSuppressedAfterApply = nil end
	return root.cdmAuraQuickSetupIgnored == signature or root.cdmAuraQuickSetupSuppressedAfterApply == signature
end

cdp.RUNTIME.CooldownPanelsHasActiveCDMAuraEntries = function()
	local root = ensureRoot()
	local runtime = CooldownPanels and CooldownPanels.runtime
	local enabledPanels = runtime and runtime.enabledPanels
	if not (root and root.panels and enabledPanels and next(enabledPanels)) then return false end
	for panelId in pairs(enabledPanels) do
		local panel = root.panels[panelId]
		for _, entry in pairs((panel and panel.entries) or {}) do
			if entry and entry.type == "CDM_AURA" then return true end
		end
	end
	return false
end

cdp.RUNTIME.GetCDMSetupIssues = function()
	if not cdp.RUNTIME.CooldownPanelsHasActiveCDMAuraEntries() then return nil end
	local issues = nil
	if GetCVarBool and GetCVarBool("cooldownViewerEnabled") ~= true then
		issues = cdp.RUNTIME.AddCDMSetupIssue(issues, "cdmDisabled", "GetCVarBool")
	elseif C_CVar and C_CVar.GetCVar and C_CVar.GetCVar("cooldownViewerEnabled") ~= "1" then
		issues = cdp.RUNTIME.AddCDMSetupIssue(issues, "cdmDisabled", "C_CVar")
	end
	for _, viewerName in ipairs(cdp.RUNTIME.CDM_SETUP_BUFF_VIEWERS) do
		local viewer = _G[viewerName]
		if viewer and viewer.visibleSetting ~= nil and viewer.visibleSetting ~= cdp.RUNTIME.CDM_SETUP_VISIBLE_ALWAYS then
			issues = cdp.RUNTIME.AddCDMSetupIssue(issues, "buffViewerNotAlwaysVisible", viewerName .. "=" .. tostring(viewer.visibleSetting))
			break
		end
	end
	return issues
end

cdp.RUNTIME.SetCDMSetupViewerSetting = function(viewer, setting, value)
	if not viewer then return false end
	local manager = EditModeManagerFrame
	if manager and manager.OnSystemSettingChange then
		local ok = pcall(manager.OnSystemSettingChange, manager, viewer, setting, value)
		if ok then return true end
	end
	return false
end

cdp.RUNTIME.SetEQOLBuffIconCooldownViewerHidden = function()
	addon.db = addon.db or {}
	addon.db.cooldownViewerVisibility = addon.db.cooldownViewerVisibility or {}
	addon.db.cooldownViewerVisibility.BuffIconCooldownViewer = { ALWAYS_HIDDEN = true }
	if addon.functions and addon.functions.EnsureCooldownViewerWatcher then addon.functions.EnsureCooldownViewerWatcher() end
	if addon.functions and addon.functions.ApplyCooldownViewerVisibility then addon.functions.ApplyCooldownViewerVisibility() end
end

function CooldownPanels:ApplyCDMAuraQuickSetup()
	if InCombatLockdown and InCombatLockdown() then
		showErrorMessage(L["CooldownPanelCDMQuickSetupCombat"] or "Cooldown Manager Quick Setup cannot run in combat.")
		return false
	end
	if SetCVar then SetCVar("cooldownViewerEnabled", "1") end
	local currentIssues = cdp.RUNTIME.GetCDMSetupIssues()
	local viewerChanged = false
	for _, viewerName in ipairs(cdp.RUNTIME.CDM_SETUP_BUFF_VIEWERS) do
		local viewer = _G[viewerName]
		if viewer and viewer.visibleSetting ~= cdp.RUNTIME.CDM_SETUP_VISIBLE_ALWAYS then
			viewerChanged = cdp.RUNTIME.SetCDMSetupViewerSetting(viewer, cdp.RUNTIME.CDM_SETUP_VISIBLE_SETTING, cdp.RUNTIME.CDM_SETUP_VISIBLE_ALWAYS) or viewerChanged
		end
	end
	if viewerChanged and EditModeManagerFrame and EditModeManagerFrame.SaveLayouts then pcall(EditModeManagerFrame.SaveLayouts, EditModeManagerFrame) end

	local root = ensureRoot()
	if root then root.cdmAuraQuickSetupSuppressedAfterApply = cdp.RUNTIME.GetCDMSetupIssueSignature(currentIssues) end
	cdp.RUNTIME.SetEQOLBuffIconCooldownViewerHidden()
	showErrorMessage(L["CooldownPanelCDMQuickSetupApplied"] or "Cooldown Manager Quick Setup applied.")
	ReloadUI()
	return true
end

function CooldownPanels:IgnoreCDMAuraQuickSetup()
	local root = ensureRoot()
	if root then root.cdmAuraQuickSetupIgnored = cdp.RUNTIME.GetCDMSetupIssueSignature(cdp.RUNTIME.GetCDMSetupIssues()) end
end

StaticPopupDialogs[cdp.RUNTIME.CDM_SETUP_POPUP] = StaticPopupDialogs[cdp.RUNTIME.CDM_SETUP_POPUP]
	or {
		text = L["CooldownPanelCDMQuickSetupPopup"]
			or "EnhanceQoL needs WoW's Cooldown Manager for tracked auras.\n\nQuick Setup will adjust the required settings and reload the UI.",
		button1 = L["CooldownPanelCDMQuickSetupAccept"] or "Quick Setup & Reload",
		button2 = L["CooldownPanelCDMQuickSetupLater"] or "Later",
		button3 = L["CooldownPanelCDMQuickSetupIgnore"] or IGNORE or "Ignore",
		OnAccept = function() CooldownPanels:ApplyCDMAuraQuickSetup() end,
		OnAlt = function() CooldownPanels:IgnoreCDMAuraQuickSetup() end,
		timeout = 0,
		whileDead = true,
		hideOnEscape = true,
		preferredIndex = 3,
	}

function CooldownPanels:ShowCDMAuraQuickSetupAfterRecheck(specKey, cause)
	local root = ensureRoot()
	self.runtime = self.runtime or {}
	local runtime = self.runtime
	runtime.cdmAuraQuickSetupPromptPending = runtime.cdmAuraQuickSetupPromptPending or {}
	runtime.cdmAuraQuickSetupPromptShown = runtime.cdmAuraQuickSetupPromptShown or {}
	runtime.cdmAuraQuickSetupPromptPending[specKey] = nil
	if not root then return false end
	local issues = cdp.RUNTIME.GetCDMSetupIssues()
	if not issues then
		root.cdmAuraQuickSetupIgnored = nil
		root.cdmAuraQuickSetupSuppressedAfterApply = nil
		runtime.cdmAuraQuickSetupPromptShown[specKey] = nil
		return false
	end
	if cdp.RUNTIME.ShouldSuppressCDMSetupPrompt(root, issues) then return false end
	if StaticPopup_Visible and StaticPopup_Visible(cdp.RUNTIME.CDM_SETUP_POPUP) then return false end
	runtime.cdmAuraQuickSetupPromptShown[specKey] = cause or true
	StaticPopup_Show(cdp.RUNTIME.CDM_SETUP_POPUP)
	return true
end

function CooldownPanels:CheckCDMAuraQuickSetup(cause)
	local root = ensureRoot()
	if not root then return false end
	self.runtime = self.runtime or {}
	local runtime = self.runtime
	if runtime.cdmAuraQuickSetupLoginReady ~= true then return false end
	local issues = cdp.RUNTIME.GetCDMSetupIssues()
	if not issues then
		root.cdmAuraQuickSetupIgnored = nil
		root.cdmAuraQuickSetupSuppressedAfterApply = nil
		return false
	end
	if cdp.RUNTIME.ShouldSuppressCDMSetupPrompt(root, issues) then return false end
	runtime.cdmAuraQuickSetupPromptShown = runtime.cdmAuraQuickSetupPromptShown or {}
	runtime.cdmAuraQuickSetupPromptPending = runtime.cdmAuraQuickSetupPromptPending or {}
	local specKey = tostring(queryPlayerSpecId() or "nospec")
	if runtime.cdmAuraQuickSetupPromptShown[specKey] then return false end
	if runtime.cdmAuraQuickSetupPromptPending[specKey] then return false end
	if StaticPopup_Visible and StaticPopup_Visible(cdp.RUNTIME.CDM_SETUP_POPUP) then return false end
	runtime.cdmAuraQuickSetupPromptPending[specKey] = cause or true
	if C_Timer and C_Timer.After then
		C_Timer.After(cdp.RUNTIME.CDM_SETUP_PROMPT_DELAY, function()
			if CooldownPanels and CooldownPanels.ShowCDMAuraQuickSetupAfterRecheck then CooldownPanels:ShowCDMAuraQuickSetupAfterRecheck(specKey, cause) end
		end)
	else
		return self:ShowCDMAuraQuickSetupAfterRecheck(specKey, cause)
	end
	return true
end

CooldownPanels.assistedHighlightHooked = false

CooldownPanels.refreshAssistedHighlightCVarState = function(cause, suppressRefresh)
	local enabled = false
	if GetCVarBool then
		enabled = GetCVarBool("assistedCombatHighlight") == true
	elseif C_CVar and C_CVar.GetCVar then
		enabled = C_CVar.GetCVar("assistedCombatHighlight") == "1"
	end
	CooldownPanels.runtime = CooldownPanels.runtime or {}
	local runtime = CooldownPanels.runtime
	if runtime.assistedHighlightEnabled == enabled then return false end
	runtime.assistedHighlightEnabled = enabled
	if not suppressRefresh and CooldownPanels and CooldownPanels.RequestUpdate then CooldownPanels:RequestUpdate(cause or "CVar:assistedCombatHighlight") end
	return true
end

CooldownPanels.ensureAssistedHighlightCVarListener = function()
	CooldownPanels.runtime = CooldownPanels.runtime or {}
	local runtime = CooldownPanels.runtime
	if runtime.assistedHighlightCVarListenerRegistered then return true end
	CooldownPanels.refreshAssistedHighlightCVarState(nil, true)
	if CVarCallbackRegistry and CVarCallbackRegistry.RegisterCallback then
		CVarCallbackRegistry:RegisterCallback("assistedCombatHighlight", function() CooldownPanels.refreshAssistedHighlightCVarState("CVar:assistedCombatHighlight") end, CooldownPanels)
		runtime.assistedHighlightCVarListenerRegistered = true
		return true
	end
	return false
end

ensureAssistedHighlightHook = function()
	if CooldownPanels.assistedHighlightHooked then return true end
	if not (hooksecurefunc and Api.GetAssistedCombatNextSpell) then return false end
	local manager = _G.AssistedCombatManager
	if not manager then return false end
	if type(manager.UpdateAllAssistedHighlightFramesForSpell) ~= "function" then return false end

	hooksecurefunc(manager, "UpdateAllAssistedHighlightFramesForSpell", function(_, spellId)
		local refreshed = false
		if spellId and refreshPanelsForSpell then
			refreshed = refreshPanelsForSpell(spellId) == true
			local baseId = getBaseSpellId(spellId)
			if baseId and baseId ~= spellId then refreshed = refreshPanelsForSpell(baseId) == true or refreshed end
			local effectiveId = getEffectiveSpellId(spellId)
			if effectiveId and effectiveId ~= spellId and effectiveId ~= baseId then refreshed = refreshPanelsForSpell(effectiveId) == true or refreshed end
		end
		if not refreshed and CooldownPanels and CooldownPanels.RequestUpdate then CooldownPanels:RequestUpdate("AssistedCombatHighlight") end
	end)
	CooldownPanels.assistedHighlightHooked = true
	return true
end

function CooldownPanels.SetUpdateFrameEnabled(frame, enabled)
	if not frame then return end
	local mode = enabled
	if mode == true then
		mode = "active"
	elseif mode == false then
		mode = nil
	end
	if mode ~= "active" and mode ~= "passive" then mode = nil end
	if frame._eqolEventMode == mode then return end

	if frame._eqolEventsRegistered then
		frame:UnregisterAllEvents()
		frame._eqolEventsRegistered = false
		local runtime = CooldownPanels.runtime
		if runtime then runtime.powerEventRegistered = nil end
	end

	if mode then
		local events = mode == "active" and CooldownPanels.UPDATE_FRAME_EVENTS or CooldownPanels.UPDATE_FRAME_PASSIVE_EVENTS
		for _, event in ipairs(events or {}) do
			frame:RegisterEvent(event)
		end
		frame:RegisterUnitEvent("PLAYER_SPECIALIZATION_CHANGED", "player")
		if mode == "active" then
			frame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player")
			-- frame:RegisterUnitEvent("UNIT_SPELLCAST_START", "player")
			-- frame:RegisterUnitEvent("UNIT_SPELLCAST_STOP", "player")
			-- frame:RegisterUnitEvent("UNIT_SPELLCAST_FAILED", "player")
			-- frame:RegisterUnitEvent("UNIT_SPELLCAST_INTERRUPTED", "player")
			-- frame:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_START", "player")
			-- frame:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_STOP", "player")
			frame:RegisterUnitEvent("UNIT_ENTERED_VEHICLE", "player")
			frame:RegisterUnitEvent("UNIT_EXITED_VEHICLE", "player")
		end
		frame._eqolEventsRegistered = true
		frame._eqolEventMode = mode
		if mode == "active" and updatePowerEventRegistration then updatePowerEventRegistration() end
	end
	frame._eqolEventMode = mode
end

function CooldownPanels.IsAssistedCombatActionSlot(slot)
	local actionSlot = tonumber(slot)
	if not actionSlot or actionSlot <= 0 then return false end
	if not Api.IsAssistedCombatAction then return false end
	return Api.IsAssistedCombatAction(actionSlot) == true
end

function CooldownPanels:UpdateEventRegistration()
	local frame = self.runtime and self.runtime.updateFrame
	if not frame then return end
	CooldownPanels.SetUpdateFrameEnabled(frame, getUpdateFrameRegistrationMode())
end

function CooldownPanels.EnsureUpdateFrame()
	if CooldownPanels.runtime and CooldownPanels.runtime.updateFrame then return end
	if ensureAssistedHighlightHook then ensureAssistedHighlightHook() end
	if CooldownPanels.ensureAssistedHighlightCVarListener then CooldownPanels.ensureAssistedHighlightCVarListener() end
	local frame = CreateFrame("Frame")
	frame:SetScript("OnEvent", function(_, event, ...)
		if not CooldownPanels.assistedHighlightHooked and ensureAssistedHighlightHook then ensureAssistedHighlightHook() end
		if CooldownPanels.ensureAssistedHighlightCVarListener then CooldownPanels.ensureAssistedHighlightCVarListener() end
		if event == "ADDON_LOADED" then
			local name = ...
			local anchorHelper = CooldownPanels.AnchorHelper
			if anchorHelper and anchorHelper.HandleAddonLoaded then anchorHelper:HandleAddonLoaded(name) end
			if type(name) == "string" and (name == "Dominos" or name == "Bartender4" or name == "ElvUI" or name:match("^Dominos_") or name:match("^Bartender4_")) then
				if Keybinds.InvalidateButtonList then Keybinds.InvalidateButtonList() end
				Keybinds.RequestRefresh("Event:ADDON_LOADED:" .. name)
			end
			return
		end
		if event == "PLAYER_LOGIN" then
			CooldownPanels.runtime = CooldownPanels.runtime or {}
			CooldownPanels.runtime.cdmAuraQuickSetupLoginReady = true
			local anchorHelper = CooldownPanels.AnchorHelper
			if anchorHelper and anchorHelper.HandlePlayerLogin then anchorHelper:HandlePlayerLogin() end
			CooldownPanels:InvalidateTalentChoiceSpellVariantGroups()
			if Keybinds.InvalidateButtonList then Keybinds.InvalidateButtonList() end
			Keybinds.InvalidateCache()
			Keybinds.RequestRefresh("Event:PLAYER_LOGIN", false)
			if CooldownPanels.refreshAssistedHighlightCVarState then CooldownPanels.refreshAssistedHighlightCVarState("Event:PLAYER_LOGIN", true) end
			refreshPanelsForCharges()
			scheduleSpecAwareRebuild(event, false)
			return
		end
		if event == "CVAR_UPDATE" then
			local cvarName = ...
			if type(cvarName) == "string" and string.lower(cvarName) == "assistedcombathighlight" then
				if CooldownPanels.refreshAssistedHighlightCVarState then CooldownPanels.refreshAssistedHighlightCVarState("Event:CVAR_UPDATE") end
			end
			return
		end
		if event == "SPELL_ACTIVATION_OVERLAY_GLOW_SHOW" then
			local spellId = ...
			setOverlayGlowForSpell(spellId, true)
			return
		end
		if event == "SPELL_ACTIVATION_OVERLAY_GLOW_HIDE" then
			local spellId = ...
			setOverlayGlowForSpell(spellId, false)
			return
		end
		if event == "SPELL_RANGE_CHECK_UPDATE" then
			local spellId, inRange, checksRange = ...
			setRangeOverlayForSpell(spellId, inRange, checksRange)
			return
		end
		if event == "SPELL_UPDATE_USABLE" then
			schedulePowerUsableRefresh()
			return
		end
		if event == "UNIT_POWER_UPDATE" then
			local unit, powerType = ...
			if unit ~= "player" then return end
			schedulePowerUsableRefresh(powerType)
			return
		end
		if event == "SPELL_UPDATE_ICON" then
			if handleSpellUpdateIcon(...) then return end
		end
		if event == "BAG_UPDATE_COOLDOWN" then
			local runtime = CooldownPanels.runtime
			local itemPanels = runtime and runtime.itemPanels
			if not itemPanels or not next(itemPanels) then return end
			local itemUsesPanels = runtime and runtime.itemUsesPanels
			if itemUsesPanels and next(itemUsesPanels) then updateItemCountCache(true) end
			cdp.ENTRY.RefreshAllTrackedBagCooldownEntries()
			return
		end
		if event == "BAG_UPDATE_DELAYED" then
			cdp.ENTRY.RefreshChangedTrackedItemEntries()
			return
		end
		if event == "ACTIONBAR_SLOT_CHANGED" then
			local slot = tonumber((...))
			-- The Single Button Assistant rotates the assisted-combat slot without changing its binding.
			if CooldownPanels.IsAssistedCombatActionSlot(slot) then return end
			local root = ensureRoot()
			if not (root and root.panels) then
				Keybinds.RequestRefresh("Event:" .. event)
				return
			end

			local trackedMacroIDs = {}
			local trackedMacroNames = {}
			for _, panel in pairs(root.panels) do
				if panel and panel.enabled ~= false and panelAllowsSpec(panel) and panel.entries then
					for _, entry in pairs(panel.entries) do
						if entry and entry.type == "MACRO" then
							local macroID = tonumber(entry.macroID)
							if macroID and macroID > 0 then trackedMacroIDs[macroID] = true end

							local macroName = CooldownPanels.NormalizeMacroName(entry.macroName)
							if not macroName and macroID and Api.GetMacroInfo then
								local infoName = Api.GetMacroInfo(macroID)
								macroName = CooldownPanels.NormalizeMacroName(infoName)
							end
							if macroName then trackedMacroNames[macroName] = true end
						end
					end
				end
			end

			if not next(trackedMacroIDs) and not next(trackedMacroNames) then
				Keybinds.RequestRefresh("Event:" .. event)
				return
			end

			CooldownPanels.runtime = CooldownPanels.runtime or {}
			local runtime = CooldownPanels.runtime
			runtime.actionSlotMacroCache = runtime.actionSlotMacroCache or {}
			local slotCache = runtime.actionSlotMacroCache
			local firstSlot, lastSlot = slot, slot
			if not firstSlot or firstSlot <= 0 then
				firstSlot = 1
				lastSlot = _G.NUM_ACTIONBAR_SLOTS or 180
			end

			local shouldRefresh = false
			for actionSlot = firstSlot, lastSlot do
				local previous = slotCache[actionSlot]
				local previousID = previous and previous.id or nil
				local previousName = previous and previous.name or nil
				local previousSig = previous and previous.sig or nil
				local wasTracked = previous and ((previousID and trackedMacroIDs[previousID]) or (previousName and trackedMacroNames[previousName]))

				local currentID, currentName, currentSig
				if Api.GetActionInfo then
					local actionType, actionID = Api.GetActionInfo(actionSlot)
					if actionType == "macro" then
						currentID = tonumber(actionID)
						if Api.GetActionText then currentName = CooldownPanels.NormalizeMacroName(Api.GetActionText(actionSlot)) end
						if not currentName and currentID and Api.GetMacroInfo then currentName = CooldownPanels.NormalizeMacroName(Api.GetMacroInfo(currentID)) end
						if currentName and Api.GetMacroIndexByName then
							local byName = Api.GetMacroIndexByName(currentName)
							if type(byName) == "number" and byName > 0 then currentID = byName end
						end

						local resolved = CooldownPanels.ResolveMacroEntry({
							type = "MACRO",
							macroID = currentID,
							macroName = currentName,
						})
						if resolved then
							if resolved.kind == "SPELL" and resolved.spellID then
								currentSig = "S:" .. tostring(resolved.spellID)
							elseif resolved.kind == "ITEM" and resolved.itemID then
								currentSig = "I:" .. tostring(resolved.itemID)
							else
								currentSig = "M"
							end
						end
					end
				end

				if currentID or currentName then
					slotCache[actionSlot] = { id = currentID, name = currentName, sig = currentSig }
				else
					slotCache[actionSlot] = nil
				end

				local isTracked = (currentID and trackedMacroIDs[currentID]) or (currentName and trackedMacroNames[currentName]) or false
				if wasTracked or isTracked then
					local slotChanged = (previousID ~= currentID) or (previousName ~= currentName) or (wasTracked ~= isTracked)
					local signatureChanged = previousSig ~= currentSig
					if slotChanged or signatureChanged then
						shouldRefresh = true
						if firstSlot == lastSlot then break end
					end
				end
			end

			if shouldRefresh then
				Keybinds.InvalidateCache()
				runtime.iconCache = nil
				updateItemCountCache()
				CooldownPanels:RebuildSpellIndex()
				CooldownPanels:RequestUpdate("Event:" .. event)
			else
				Keybinds.RequestRefresh("Event:" .. event)
			end
			return
		end
			if event == "ACTIONBAR_HIDEGRID" then
				Keybinds.RequestRefresh("Event:ACTIONBAR_HIDEGRID")
				return
			end
			if event == "UPDATE_BINDINGS" or event == "ACTIONBAR_PAGE_CHANGED" then
				Keybinds.RequestRefresh("Event:" .. event)
				return
			end
			if event == "UPDATE_MACROS" then
				Keybinds.InvalidateCache()
				if CooldownPanels.runtime then CooldownPanels.runtime.iconCache = nil end
				CooldownPanels:InvalidateSpellQueryCaches()
				updateItemCountCache()
				CooldownPanels:RebuildSpellIndex()
				CooldownPanels:RequestUpdate("Event:" .. event)
				return
			end
			if event == "SPELLS_CHANGED" then
				CooldownPanels:InvalidateSpellQueryCaches()
				if CooldownPanels.RefreshAllOverlayGlowStates then CooldownPanels:RefreshAllOverlayGlowStates() end
				scheduleSpecAwareRebuild(event, false)
				return
			end
		if event == "PLAYER_SPECIALIZATION_CHANGED" then
			local unit = ...
			if unit and unit ~= "player" then return end
			CooldownPanels:InvalidateTalentChoiceSpellVariantGroups()
			CooldownPanels:InvalidateSpellQueryCaches()
			scheduleSpecAwareRebuild(event, true)
			return
		end
		if event == "ACTIVE_PLAYER_SPECIALIZATION_CHANGED" or event == "PLAYER_TALENT_UPDATE" or event == "ACTIVE_TALENT_GROUP_CHANGED" or event == "TRAIT_CONFIG_UPDATED" or event == "TRAIT_CONFIG_LIST_UPDATED" then
			CooldownPanels:InvalidateTalentChoiceSpellVariantGroups()
			CooldownPanels:InvalidateSpellQueryCaches()
			if CooldownPanels.runtime then CooldownPanels.runtime.iconCache = nil end
			scheduleSpecAwareRebuild(event, true)
			return
		end
		if event == "UNIT_SPELLCAST_SUCCEEDED" then
			local unit, _, spellId = ...
			if unit ~= "player" then return end
			if not spellId then return end
			local runtime = CooldownPanels.runtime
			local enabledPanels = runtime and runtime.enabledPanels
			if enabledPanels and not next(enabledPanels) then return end
			CooldownPanels:HandleCustomCooldownActivation(spellId)
			CooldownPanels:InvalidateSpellCooldownCachesForAliases(spellId)
			clearReadyGlowForSpell(spellId)
			refreshPanelsForSpell(spellId)
			return
		end
		if event == "SPELL_UPDATE_COOLDOWN" then
			local spellId, baseSpellId = ...
			if spellId ~= nil or baseSpellId ~= nil then
				CooldownPanels:InvalidateSpellCooldownCachesForAliases(spellId)
				if baseSpellId ~= spellId then CooldownPanels:InvalidateSpellCooldownCachesForAliases(baseSpellId) end
				local gcdChanged = CooldownPanels:UpdateGlobalGCDState()
				if gcdChanged then
					CooldownPanels:InvalidateSpellQueryCaches("duration")
					CooldownPanels:InvalidateSpellQueryCaches("info")
				end
				CooldownPanels:HandleReadySoundSpellEvent(spellId, baseSpellId, true)
				if gcdChanged then
					if not CooldownPanels.RequestEnabledPanelRefreshes() then CooldownPanels:RefreshAllPanels() end
				elseif spellId ~= nil then
					refreshPanelsForSpell(spellId)
				elseif baseSpellId ~= nil then
					refreshPanelsForSpell(baseSpellId)
				end
				return
			end
			CooldownPanels:InvalidateSpellQueryCaches()
			CooldownPanels:HandleReadySoundSpellEvent(nil, nil, true)
			if not CooldownPanels.RequestEnabledPanelRefreshes() then CooldownPanels:RefreshAllPanels() end
			return
		end
		if event == "SPELL_UPDATE_USES" then
			local spellId, baseSpellId = ...
			if Helper and Helper.UpdateActionDisplayCountsForSpell then Helper.UpdateActionDisplayCountsForSpell(spellId, baseSpellId) end
			return
		end
		if event == "SPELL_UPDATE_CHARGES" then
			local spellId, baseSpellId = ...
			if spellId ~= nil or baseSpellId ~= nil then
				CooldownPanels.InvalidateChargeCachesForSpell(spellId)
				if baseSpellId ~= spellId then CooldownPanels.InvalidateChargeCachesForSpell(baseSpellId) end
				CooldownPanels:HandleReadySoundSpellEvent(spellId, baseSpellId, true)
			else
				CooldownPanels:InvalidateSpellQueryCaches("charges")
				CooldownPanels:InvalidateSpellQueryCaches("chargeDuration")
				CooldownPanels:InvalidateSpellQueryCaches("duration")
				CooldownPanels:InvalidateSpellQueryCaches("info")
				CooldownPanels:HandleReadySoundSpellEvent(nil, nil, true)
			end
			refreshPanelsForCharges()
			return
		end
		if event == "PLAYER_ENTERING_WORLD" then
			CooldownPanels:InvalidateTalentChoiceSpellVariantGroups()
			CooldownPanels:InvalidateSpellQueryCaches()
			updateItemCountCache()
			scheduleSpecAwareRebuild(event)
			return
		end
		if event == "UNIT_ENTERED_VEHICLE" or event == "UNIT_EXITED_VEHICLE" then
			local unit = ...
			if unit and unit ~= "player" then return end
		end
		if event == "CLIENT_SCENE_OPENED" then
			local sceneType = ...
			CooldownPanels.runtime = CooldownPanels.runtime or {}
			CooldownPanels.runtime.clientSceneActive = (sceneType == 1)
		elseif event == "CLIENT_SCENE_CLOSED" then
			CooldownPanels.runtime = CooldownPanels.runtime or {}
			CooldownPanels.runtime.clientSceneActive = false
		end
		if event == "BAG_UPDATE_DELAYED" or event == "PLAYER_EQUIPMENT_CHANGED" or event == "PLAYER_ENTERING_WORLD" then
			if event == "PLAYER_EQUIPMENT_CHANGED" then CooldownPanels:InvalidateSpellQueryCaches() end
			updateItemCountCache()
		end
		CooldownPanels:RequestUpdate("Event:" .. event)
	end)
	CooldownPanels.runtime = CooldownPanels.runtime or {}
	CooldownPanels.runtime.updateFrame = frame
	if CooldownPanels.UpdateEventRegistration then CooldownPanels:UpdateEventRegistration() end
end

function CooldownPanels:RequestUpdate(cause)
	self.runtime = self.runtime or {}
	local runtime = self.runtime
	local fullRefresh = false
	if type(cause) == "table" then
		fullRefresh = cause.fullRefresh == true
		cause = cause.cause
	end
	local enabledPanels = self.runtime.enabledPanels
	if not enabledPanels or not next(enabledPanels) then
		self:HideAllRuntimePanels()
		return
	end
	if runtime.updateDispatching then
		if cause then runtime.updateCause = cause end
		if fullRefresh then runtime.updateFullRefresh = true end
		runtime.updateReschedule = true
		return
	end
	if runtime.updatePending then
		if cause then runtime.updateCause = cause end
		if fullRefresh then runtime.updateFullRefresh = true end
		return
	end
	runtime.updatePending = true
	runtime.updateCause = cause
	runtime.updateFullRefresh = fullRefresh
	RunNextFrame(function()
		local currentRuntime = self.runtime
		if not currentRuntime then return end
		local shouldFullRefresh = currentRuntime.updateFullRefresh == true
		currentRuntime.updateDispatching = true
		currentRuntime.updateCause = nil
		currentRuntime.updateFullRefresh = nil
		currentRuntime.updateReschedule = nil
		if shouldFullRefresh then
			CooldownPanels:RefreshAllPanels(true)
		else
			if not CooldownPanels.RequestEnabledPanelRefreshes() then CooldownPanels:RefreshAllPanels() end
		end

		currentRuntime = self.runtime
		if not currentRuntime then return end
		currentRuntime.updateDispatching = nil
		currentRuntime.updatePending = nil

		if currentRuntime.updateReschedule == true then
			local nextCause = currentRuntime.updateCause
			local nextFullRefresh = currentRuntime.updateFullRefresh == true
			currentRuntime.updateCause = nil
			currentRuntime.updateFullRefresh = nil
			currentRuntime.updateReschedule = nil
			self:RequestUpdate(nextFullRefresh and {
				cause = nextCause,
				fullRefresh = true,
			} or nextCause)
			return
		end

		currentRuntime.updateCause = nil
		currentRuntime.updateFullRefresh = nil
		currentRuntime.updateReschedule = nil
	end)
end

function CooldownPanels:Init()
	if self.InitStanceTracker then self:InitStanceTracker() end
	self.runtime = self.runtime or {}
	if IsLoggedIn and IsLoggedIn() then self.runtime.cdmAuraQuickSetupLoginReady = true end
	self:NormalizeAll()
	self:RebuildSpellIndex()
	updateItemCountCache()
	if CooldownPanels.refreshAssistedHighlightCVarState then CooldownPanels.refreshAssistedHighlightCVarState(nil, true) end
	Keybinds.RebuildPanels()
	self:RefreshAllPanels()
	self:UpdateCursorAnchorState()
	CooldownPanels.EnsureUpdateFrame()
	registerCooldownPanelsSlashCommand()
end

function addon.Aura.functions.InitCooldownPanels()
	if CooldownPanels and CooldownPanels.Init then CooldownPanels:Init() end
end
