-- luacheck: globals C_GameRules GetIconForRole
local parentAddonName = "EnhanceQoL"
local addonName, addon = ...

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

addon.Aura = addon.Aura or {}
addon.Aura.UFHelper = addon.Aura.UFHelper or {}
local H = addon.Aura.UFHelper

addon.variables = addon.variables or {}

local LSM = LibStub("LibSharedMedia-3.0")
local EnumPowerType = Enum and Enum.PowerType
local BLIZZARD_TEX = "Interface\\TargetingFrame\\UI-StatusBar"
H.BLIZZARD_RAID_FRAME_TEX_KEY = "EQOL_BLIZZARD_RAID_FRAME_HP_FILL"
H.BLIZZARD_RAID_FRAME_TEX = "RaidFrame-Hp-Fill"
local BLIZZARD_CAST_STANDARD_TEX = "ui-castingbar-full-standard"
local BLIZZARD_CAST_INTERRUPTED_TEX = "ui-castingbar-interrupted"
local BLIZZARD_CAST_ICON_FALLBACK_TEX = 134400 -- Interface\\Icons\\INV_Misc_QuestionMark
local DEFAULT_AURA_BORDER_TEX = "Interface\\Buttons\\UI-Debuff-Overlays"
local DEFAULT_AURA_BORDER_COORDS = { 0.296875, 0.5703125, 0, 0.515625 }
local CombatFeedback_Initialize = _G.CombatFeedback_Initialize
local CombatFeedback_OnCombatEvent = _G.CombatFeedback_OnCombatEvent
local CombatFeedback_OnUpdate = _G.CombatFeedback_OnUpdate
local NewTicker = C_Timer and C_Timer.NewTicker
local GetTime = _G.GetTime
local COMBAT_FEEDBACK_THROTTLE = 0.1
local abs = math.abs
local floor = math.floor
local UnitThreatSituation = UnitThreatSituation
local UnitExists = UnitExists
local UnitGetTotalAbsorbs = UnitGetTotalAbsorbs
local UnitHealthPercent = UnitHealthPercent
local UnitHealthMax = UnitHealthMax
local UnitPower = UnitPower
local UnitPowerMax = UnitPowerMax
local UnitStagger = UnitStagger
local C_UnitAuras = C_UnitAuras
local UIParent = UIParent
local DispelOverlayOrientation = EnumUtil and EnumUtil.MakeEnum("VerticalTopToBottom", "VerticalBottomToTop", "HorizontalLeftToRight")

function H.PrivateAurasDisabledForClient()
	return addon.AuraCompat ~= nil
end

function H.UnsecretBool(value)
	if issecretvalue and issecretvalue(value) then return nil end
	return value
end

do
local FILTER_HELPFUL = "HELPFUL|INCLUDE_NAME_PLATE_ONLY"
local FILTER_HELPFUL_GROUP_RAID = "HELPFUL|INCLUDE_NAME_PLATE_ONLY|RAID|PLAYER"
local FILTER_HELPFUL_GROUP_RAID_IN_COMBAT = "HELPFUL|INCLUDE_NAME_PLATE_ONLY|RAID_IN_COMBAT|PLAYER"
local FILTER_HARMFUL = "HARMFUL|INCLUDE_NAME_PLATE_ONLY"
local FILTER_HARMFUL_PLAYER = "HARMFUL|PLAYER|INCLUDE_NAME_PLATE_ONLY"
local FILTER_HARMFUL_RAID = "HARMFUL|INCLUDE_NAME_PLATE_ONLY|RAID"
local FILTER_HARMFUL_RAID_IN_COMBAT = "HARMFUL|INCLUDE_NAME_PLATE_ONLY|RAID_IN_COMBAT"
local FILTER_HARMFUL_DISPELLABLE = "HARMFUL|INCLUDE_NAME_PLATE_ONLY|RAID_PLAYER_DISPELLABLE"
local FILTER_HARMFUL_CROWD_CONTROL = "HARMFUL|INCLUDE_NAME_PLATE_ONLY|CROWD_CONTROL"
local FILTER_BIG_DEFENSIVE = "HELPFUL|BIG_DEFENSIVE"

local function allowNameplateOnly(aura, includeNameplateOnly)
	return includeNameplateOnly == true or aura.isNameplateOnly ~= true
end

function H.IsHelpfulAura(aura, includeNameplateOnly)
	return aura and aura.isHelpful == true and allowNameplateOnly(aura, includeNameplateOnly) or false
end

function H.IsHarmfulAura(aura, includeNameplateOnly)
	return aura and aura.isHarmful == true and allowNameplateOnly(aura, includeNameplateOnly) or false
end

function H.IsRaidAura(aura)
	return aura and aura.isRaid == true or false
end

function H.IsFromPlayerOrPlayerPetAura(aura)
	return aura and aura.isFromPlayerOrPlayerPet == true or false
end

local function isApiMatch(unit, aura, specialFilter)
	return unit
		and aura
		and aura.auraInstanceID
		and C_UnitAuras
		and C_UnitAuras.IsAuraFilteredOutByInstanceID
		and not C_UnitAuras.IsAuraFilteredOutByInstanceID(unit, aura.auraInstanceID, specialFilter)
		or false
end

local function isGroupHelpfulAura(unit, aura, filter)
	if not H.IsHelpfulAura(aura, true) then return false end
	if aura.isRaid ~= true and aura.isFromPlayerOrPlayerPet ~= true then return false end
	return isApiMatch(unit, aura, filter)
end

function H.IsAuraFilteredIn(unit, aura, filter)
	if not (aura and aura.auraInstanceID) then return false end
	if type(filter) == "table" then
		if #filter > 0 then
			for _, auraFilter in ipairs(filter) do
				if type(auraFilter) == "string" and H.IsAuraFilteredIn(unit, aura, auraFilter) then return true end
			end
			return false
		end
		for _, auraFilter in pairs(filter) do
			if type(auraFilter) == "string" and H.IsAuraFilteredIn(unit, aura, auraFilter) then return true end
		end
		return false
	end
	if type(filter) ~= "string" then return false end

	if filter == FILTER_HELPFUL then return H.IsHelpfulAura(aura, true) end
	if filter == FILTER_HARMFUL then return H.IsHarmfulAura(aura, true) end
	if filter == FILTER_HARMFUL_PLAYER then return H.IsHarmfulAura(aura, true) and H.IsFromPlayerOrPlayerPetAura(aura) and isApiMatch(unit, aura, filter) end
	if filter == FILTER_HELPFUL_GROUP_RAID or filter == FILTER_HELPFUL_GROUP_RAID_IN_COMBAT then return isGroupHelpfulAura(unit, aura, filter) end
	if filter == FILTER_HARMFUL_RAID or filter == FILTER_HARMFUL_RAID_IN_COMBAT then return H.IsHarmfulAura(aura, true) and H.IsRaidAura(aura) and isApiMatch(unit, aura, filter) end
	if filter == FILTER_HARMFUL_DISPELLABLE then return H.IsHarmfulAura(aura, true) and isApiMatch(unit, aura, filter) end
	if filter == FILTER_HARMFUL_CROWD_CONTROL then return H.IsHarmfulAura(aura, true) and isApiMatch(unit, aura, filter) end
	if filter == FILTER_BIG_DEFENSIVE then return H.IsHelpfulAura(aura, false) and isApiMatch(unit, aura, filter) end

	return isApiMatch(unit, aura, filter)
end
end

local atlasByPower = {
	LUNAR_POWER = "Unit_Druid_AstralPower_Fill",
	MAELSTROM = "Unit_Shaman_Maelstrom_Fill",
	INSANITY = "Unit_Priest_Insanity_Fill",
	FURY = "Unit_DemonHunter_Fury_Fill",
	RUNIC_POWER = "UI-HUD-UnitFrame-Player-PortraitOn-Bar-RunicPower",
	ENERGY = "UI-HUD-UnitFrame-Player-PortraitOn-ClassResource-Bar-Energy",
	FOCUS = "UI-HUD-UnitFrame-Player-PortraitOn-Bar-Focus",
	RAGE = "UI-HUD-UnitFrame-Player-PortraitOn-Bar-Rage",
	MANA = "UI-HUD-UnitFrame-Player-PortraitOn-Bar-Mana",
	HEALTH = "UI-HUD-UnitFrame-Player-PortraitOn-Bar-Health",
}

local npcColorDefaults = {
	enemy = { 0.95, 0.15, 0.15, 1 },
	neutral = { 1, 1, 0, 1 },
	friendly = { 0.2, 1, 0.2, 1 },
}

local debuffinfo = {
	[1] = DEBUFF_TYPE_MAGIC_COLOR,
	[2] = DEBUFF_TYPE_CURSE_COLOR,
	[3] = DEBUFF_TYPE_DISEASE_COLOR,
	[4] = DEBUFF_TYPE_POISON_COLOR,
	[5] = DEBUFF_TYPE_BLEED_COLOR,
	[0] = DEBUFF_TYPE_NONE_COLOR,
}
local dispelIndexByName = {
	Magic = 1,
	Curse = 2,
	Disease = 3,
	Poison = 4,
	Bleed = 5,
	None = 0,
}

local function getDebuffColorFromName(name)
	local idx = dispelIndexByName[name] or 0
	local col = debuffinfo[idx] or debuffinfo[0]
	if not col then return nil end
	if col.GetRGBA then return col:GetRGBA() end
	if col.GetRGB then return col:GetRGB() end
	if col.r then return col.r, col.g, col.b, col.a end
	return col[1], col[2], col[3], col[4]
end

H.getDebuffColorFromName = getDebuffColorFromName

local function getDispelOverlayOrientationToken(value)
	if DispelOverlayOrientation then
		if value == DispelOverlayOrientation.VerticalBottomToTop then return "VerticalBottomToTop" end
		if value == DispelOverlayOrientation.HorizontalLeftToRight then return "HorizontalLeftToRight" end
		if value == DispelOverlayOrientation.VerticalTopToBottom then return "VerticalTopToBottom" end
	end
	if type(value) == "string" then
		local token = value:gsub("[%s_]", ""):lower()
		if token == "verticalbottomtotop" then return "VerticalBottomToTop" end
		if token == "horizontallefttoright" then return "HorizontalLeftToRight" end
	end
	return "VerticalTopToBottom"
end

local function setDispelOverlayAtlas(texture, atlas)
	if not (texture and texture.SetAtlas and atlas) then return end
	local currentAtlas = texture.GetAtlas and texture:GetAtlas()
	if currentAtlas ~= atlas then texture:SetAtlas(atlas, false) end
end

function H.CreateDispelOverlay(parent)
	if not parent then return nil end

	local overlay = CreateFrame("Frame", nil, parent)
	overlay:EnableMouse(false)

	local background = overlay:CreateTexture(nil, "ARTWORK", nil, -6)
	background:SetAllPoints(overlay)
	setDispelOverlayAtlas(background, "RaidFrame-Dispel-Fill")
	overlay.Background = background

	local gradient = overlay:CreateTexture(nil, "ARTWORK", nil, -5)
	gradient:SetAllPoints(overlay)
	overlay.Gradient = gradient

	local border = overlay:CreateTexture(nil, "ARTWORK", nil, -5)
	border:SetAllPoints(overlay)
	setDispelOverlayAtlas(border, "RaidFrame-DispelHighlight")
	overlay.Border = border

	function overlay:SetOrientation(orientationOrOwner, orientation, xOffset, yOffset)
		local resolvedOrientation = orientationOrOwner
		local resolvedX = orientation
		local resolvedY = xOffset
		if type(orientationOrOwner) == "table" and orientation ~= nil then
			resolvedOrientation = orientation
			resolvedX = xOffset
			resolvedY = yOffset
		end

		local orientationToken = getDispelOverlayOrientationToken(resolvedOrientation)
		if orientationToken == "HorizontalLeftToRight" then
			setDispelOverlayAtlas(self.Gradient, "!RaidFrame-Dispel-Vertical")
			self.Gradient:SetTexCoord(0, 1, 0, 1)
		else
			setDispelOverlayAtlas(self.Gradient, "_RaidFrame-Dispel-Highlight-Horizontal")
			if orientationToken == "VerticalBottomToTop" then
				self.Gradient:SetTexCoord(0, 1, 1, 0)
			else
				self.Gradient:SetTexCoord(0, 1, 0, 1)
			end
		end

		self.Border:ClearAllPoints()
		self.Border:SetPoint("TOPLEFT")
		self.Border:SetPoint("BOTTOMRIGHT", self, "BOTTOMRIGHT", resolvedX or 0, resolvedY or 0)
	end

	overlay:SetOrientation(DispelOverlayOrientation and DispelOverlayOrientation.VerticalTopToBottom or "VerticalTopToBottom", 0, 0)
	overlay:Hide()
	return overlay
end

local debuffColorCurve = C_CurveUtil and C_CurveUtil.CreateColorCurve() or nil
if debuffColorCurve and Enum.LuaCurveType and Enum.LuaCurveType.Step then
	debuffColorCurve:SetType(Enum.LuaCurveType.Step)
	for dispeltype, v in pairs(debuffinfo) do
		debuffColorCurve:AddPoint(dispeltype, v)
	end
end

H.debuffColorCurve = debuffColorCurve

local absorbFullCurve = C_CurveUtil and C_CurveUtil.CreateCurve() or nil
if absorbFullCurve and Enum and Enum.LuaCurveType and Enum.LuaCurveType.Step then
	absorbFullCurve:SetType(Enum.LuaCurveType.Step)
	absorbFullCurve:AddPoint(1.0, 1)
	absorbFullCurve:AddPoint(0.9, 0)
end

local absorbNotFullCurve = C_CurveUtil and C_CurveUtil.CreateCurve() or nil
if absorbNotFullCurve and Enum and Enum.LuaCurveType and Enum.LuaCurveType.Step then
	absorbNotFullCurve:SetType(Enum.LuaCurveType.Step)
	absorbNotFullCurve:AddPoint(1.0, 0)
	absorbNotFullCurve:AddPoint(0.9, 1)
end

H.absorbFullCurve = absorbFullCurve
H.absorbNotFullCurve = absorbNotFullCurve

local npcColorUnits = {
	target = true,
	targettarget = true,
	focus = true,
	boss = true,
}
for i = 1, 8 do
	npcColorUnits["boss" .. i] = true
end

local selectionKeyByType = {
	[0] = "enemy",
	[1] = "enemy",
	[2] = "neutral",
	[3] = "friendly",
}

function H.getNPCSelectionKey(unit)
	if not npcColorUnits[unit] then return nil end
	if UnitIsPlayer and H.UnsecretBool(UnitIsPlayer(unit)) == true then return nil end
	local t = UnitSelectionType and UnitSelectionType(unit)
	if issecretvalue and issecretvalue(t) then t = nil end
	local key = selectionKeyByType[t]

	local reaction = UnitReaction and UnitReaction(unit, "player")
	if issecretvalue and issecretvalue(reaction) then reaction = nil end
	if reaction then
		if reaction <= 3 then return "enemy" end
		if reaction == 4 then return "neutral" end
		return "friendly"
	end

	if key == "friendly" and UnitCanAttack and UnitCanAttack("player", unit) then
		if UnitIsEnemy and UnitIsEnemy("player", unit) then return "enemy" end
		return "neutral"
	end

	if key then return key end
	if UnitIsEnemy and UnitIsEnemy("player", unit) then return "enemy" end
	if UnitCanAttack and UnitCanAttack("player", unit) then return "neutral" end
	if UnitIsFriend and UnitIsFriend("player", unit) then return "friendly" end
	return nil
end

function H.getNPCOverrideColor(unit)
	local overrides = addon.db and addon.db.ufNPCColorOverrides
	if not overrides then return nil end

	local key = H.getNPCSelectionKey(unit)
	if not key then return nil end
	local override = overrides[key]
	if override then
		if override.r then return override.r, override.g, override.b, override.a or 1 end
		if override[1] then return override[1], override[2], override[3], override[4] or 1 end
	end
	return nil
end

function H.getNPCHealthColor(unit)
	local key = H.getNPCSelectionKey(unit)
	if not key then return nil end
	return H.getNPCColor(key)
end

local nameWidthCache = {}

local function utf8Iter(str) return (str or ""):gmatch("[%z\1-\127\194-\244][\128-\191]*") end

local function utf8Len(str)
	local len = 0
	for _ in utf8Iter(str) do
		len = len + 1
	end
	return len
end

local function utf8Sub(str, i, j)
	str = str or ""
	if str == "" then return "" end
	i = i or 1
	j = j or -1
	if i < 1 then i = 1 end
	local len = utf8Len(str)
	if j < 0 then j = len + j + 1 end
	if j > len then j = len end
	if i > j then return "" end
	local pos = 1
	local startByte, endByte
	local idx = 0
	for char in utf8Iter(str) do
		idx = idx + 1
		if idx == i then startByte = pos end
		if idx == j then
			endByte = pos + #char - 1
			break
		end
		pos = pos + #char
	end
	return str:sub(startByte or 1, endByte or #str)
end

local function normalizeFontOutline(outline)
	if addon.functions and addon.functions.GetFontFlagsForStyle then return addon.functions.GetFontFlagsForStyle(outline, "OUTLINE") end
	if outline == nil then return "OUTLINE" end
	if outline == "" or outline == "NONE" then return nil end
	if outline == "__EQOL_GLOBAL_FONT_STYLE__" then return "OUTLINE" end
	return outline
end

function H._looksLikeFontFile(path)
	if type(path) ~= "string" or path == "" then return false end
	local lower = path:lower()
	if path:find("\\", 1, true) or path:find("/", 1, true) then return true end
	if lower:find(".ttf", 1, true) or lower:find(".otf", 1, true) or lower:find(".ttc", 1, true) then return true end
	return false
end

function H.isKnownFontAsset(fontFile)
	if addon.functions and addon.functions.IsKnownFontAsset then return addon.functions.IsKnownFontAsset(fontFile) end
	if addon.functions and addon.functions.IsKnownFileAsset then return addon.functions.IsKnownFileAsset(fontFile) end
	if not H._looksLikeFontFile(fontFile) then return true end
	local fileAssetAPI = _G.C_UIFileAsset
	if not (fileAssetAPI and fileAssetAPI.IsKnownFile) then return true end
	local ok, known = pcall(fileAssetAPI.IsKnownFile, fontFile)
	return ok and known == true
end

function H.getAlphabetAwareFontFamily(fontFile, size, flags)
	if not (_G.Font and _G.Font.CreateFontFamily and H._looksLikeFontFile(fontFile)) then return nil end
	if not H.isKnownFontAsset(fontFile) then return nil end
	size = tonumber(size) or 14
	if size <= 0 then size = 1 end
	flags = flags or ""
	H._fontFamilyCache = H._fontFamilyCache or {}
	local fontFamilyCache = H._fontFamilyCache
	local key = table.concat({
		tostring(fontFile),
		tostring(size),
		tostring(flags or ""),
	}, "\031")
	local cached = fontFamilyCache[key]
	if cached then return cached end

	H._fontFamilyCounter = (H._fontFamilyCounter or 0) + 1
	local familyName = ("EQOLUFFontFamily%d"):format(H._fontFamilyCounter)
	local ok, family = pcall(_G.Font.CreateFontFamily, familyName, {
		{ alphabet = "roman", file = fontFile, height = size, flags = flags },
		{ alphabet = "korean", file = "Fonts\\2002.TTF", height = size, flags = flags },
		{ alphabet = "simplifiedchinese", file = "Fonts\\ARKai_T.ttf", height = size, flags = flags },
		{ alphabet = "traditionalchinese", file = "Fonts\\bLEI00D.TTF", height = size, flags = flags },
		{ alphabet = "russian", file = _G.UNIT_NAME_FONT_CYRILLIC or "Fonts\\FRIZQT___CYR.TTF", height = size, flags = flags },
	})
	if not ok or not family then return nil end
	fontFamilyCache[key] = family
	return family
end

function H.setFontWithFallback(target, fontFile, size, flags)
	if not target then return nil end
	if not H.isKnownFontAsset(fontFile) then return false end
	local family = H.getAlphabetAwareFontFamily(fontFile, size, flags)
	if family and target.SetFontObject then
		target:SetFontObject(family)
		return true
	end
	if target.SetFont then
		if addon.functions and addon.functions.SetFontWithFallback then return addon.functions.SetFontWithFallback(target, fontFile, size, flags, H.getFont(nil)) end
		local ok, applied = pcall(target.SetFont, target, fontFile, size, flags)
		return ok and applied ~= false
	end
	return nil
end

function H.clamp(value, minV, maxV)
	if value < minV then return minV end
	if value > maxV then return maxV end
	return value
end

function H.ClampNumber(value, minValue, maxValue, fallback)
	local v = tonumber(value)
	if v == nil then return fallback end
	if minValue ~= nil and v < minValue then v = minValue end
	if maxValue ~= nil and v > maxValue then v = maxValue end
	return v
end

function H.shouldHideInClientScene(cfg, def)
	local value = cfg and cfg.hideInClientScene
	if value == nil then value = def and def.hideInClientScene end
	if value == nil then value = true end
	return value == true
end

function H.applyClientSceneAlphaOverride(st, forceHide)
	if not (st and st.frame and st.frame.SetAlpha) then return end
	if forceHide then
		st._eqolClientSceneAlphaHidden = true
		if st.frame.GetAlpha and st.frame:GetAlpha() ~= 0 then st.frame:SetAlpha(0) end
	elseif st._eqolClientSceneAlphaHidden then
		st._eqolClientSceneAlphaHidden = nil
		if st.frame.GetAlpha and st.frame:GetAlpha() == 0 then st.frame:SetAlpha(1) end
	end
end

function H.getClampedAbsorbAmount(unit) return UnitGetTotalAbsorbs and UnitGetTotalAbsorbs(unit) or 0 end

function H.getHealthCurveValue(unit, curve)
	if not curve or not unit then return nil end
	return UnitHealthPercent(unit, true, curve)
end

function H.setupAbsorbClamp(health, absorb)
	if not (health and absorb) then return end

	if not health.absorbClip then
		local clip = CreateFrame("Frame", nil, health)
		clip:SetAllPoints(health)
		clip:SetClipsChildren(true)
		health.absorbClip = clip
	end

	local clip = health.absorbClip
	clip:SetAllPoints(health)
	if clip.SetFrameStrata and health.GetFrameStrata then
		local healthStrata = health:GetFrameStrata()
		if healthStrata and clip:GetFrameStrata() ~= healthStrata then clip:SetFrameStrata(healthStrata) end
	end
	if clip.SetFrameLevel and health.GetFrameLevel then
		local desiredLevel = (health:GetFrameLevel() or 0) + 1
		if clip:GetFrameLevel() ~= desiredLevel then clip:SetFrameLevel(desiredLevel) end
	end

	absorb:SetParent(clip)
	absorb:ClearAllPoints()

	local htex = health:GetStatusBarTexture()

	absorb:SetPoint("TOPLEFT", htex, "TOPRIGHT", 0, 0)
	absorb:SetPoint("BOTTOMLEFT", htex, "BOTTOMRIGHT", 0, 0)

	absorb:SetWidth(health:GetWidth())
	absorb:SetHeight(health:GetHeight())
end

function H.setupAbsorbClampReverseAware(health, absorb)
	H.setupAbsorbClamp(health, absorb)

	local htex = health:GetStatusBarTexture()
	absorb:ClearAllPoints()

	local reverse = false
	if health.GetFillStyle and Enum and Enum.StatusBarFillStyle then
		reverse = health:GetFillStyle() == Enum.StatusBarFillStyle.Reverse
	elseif health.GetReverseFill then
		reverse = health:GetReverseFill() == true
	end

	H.applyStatusBarReverseFill(absorb, reverse)

	if reverse then
		absorb:SetPoint("TOPRIGHT", htex, "TOPLEFT", 0, 0)
		absorb:SetPoint("BOTTOMRIGHT", htex, "BOTTOMLEFT", 0, 0)
	else
		absorb:SetPoint("TOPLEFT", htex, "TOPRIGHT", 0, 0)
		absorb:SetPoint("BOTTOMLEFT", htex, "BOTTOMRIGHT", 0, 0)
	end

	absorb:SetWidth(health:GetWidth())
	absorb:SetHeight(health:GetHeight())
end

function H.setupAbsorbOverShift(healthBar, overAbsorbBar, height, maxHeight, anchorTop)
	if not (healthBar and overAbsorbBar) then return end

	local htex = healthBar:GetStatusBarTexture()

	if not healthBar._healthFillClip then
		local clip = CreateFrame("Frame", nil, healthBar)
		clip:SetClipsChildren(true)
		healthBar._healthFillClip = clip
	end

	local clip = healthBar._healthFillClip
	if clip.SetFrameStrata and healthBar.GetFrameStrata then
		local healthStrata = healthBar:GetFrameStrata()
		if healthStrata and clip:GetFrameStrata() ~= healthStrata then clip:SetFrameStrata(healthStrata) end
	end
	if clip.SetFrameLevel and healthBar.GetFrameLevel then
		local desiredLevel = (healthBar:GetFrameLevel() or 0) + 1
		if clip:GetFrameLevel() ~= desiredLevel then clip:SetFrameLevel(desiredLevel) end
	end
	clip:ClearAllPoints()

	if healthBar:GetReverseFill() then
		clip:SetPoint("TOPRIGHT", healthBar, "TOPRIGHT", 0, 0)
		clip:SetPoint("BOTTOMRIGHT", healthBar, "BOTTOMRIGHT", 0, 0)
		if htex then
			clip:SetPoint("LEFT", htex, "LEFT", 0, 0)
		else
			clip:SetPoint("LEFT", healthBar, "LEFT", 0, 0)
		end
	else
		clip:SetPoint("TOPLEFT", healthBar, "TOPLEFT", 0, 0)
		clip:SetPoint("BOTTOMLEFT", healthBar, "BOTTOMLEFT", 0, 0)
		if htex then
			clip:SetPoint("RIGHT", htex, "RIGHT", 0, 0)
		else
			clip:SetPoint("RIGHT", healthBar, "RIGHT", 0, 0)
		end
	end

	overAbsorbBar:SetParent(clip)
	overAbsorbBar:ClearAllPoints()

	local desired = tonumber(height)
	local limit = tonumber(maxHeight)
	if not limit or limit <= 0 then limit = healthBar.GetHeight and healthBar:GetHeight() or 0 end
	if not desired or desired <= 0 then
		overAbsorbBar:SetPoint("TOPLEFT", healthBar, "TOPLEFT", 0, 0)
		overAbsorbBar:SetPoint("BOTTOMRIGHT", healthBar, "BOTTOMRIGHT", 0, 0)
	else
		if limit and limit > 0 and desired > limit then desired = limit end
		if anchorTop then
			overAbsorbBar:SetPoint("TOPLEFT", healthBar, "TOPLEFT", 0, 0)
			overAbsorbBar:SetPoint("TOPRIGHT", healthBar, "TOPRIGHT", 0, 0)
		else
			overAbsorbBar:SetPoint("BOTTOMLEFT", healthBar, "BOTTOMLEFT", 0, 0)
			overAbsorbBar:SetPoint("BOTTOMRIGHT", healthBar, "BOTTOMRIGHT", 0, 0)
		end
		overAbsorbBar:SetHeight(desired)
	end

	overAbsorbBar:SetOrientation("HORIZONTAL")

	overAbsorbBar:SetReverseFill(not healthBar:GetReverseFill())
end

function H.applyAbsorbClampLayout(bar, healthBar, height, maxHeight, reverseHealth, anchorTop, fallbackWidth)
	if not bar or not healthBar then return end
	bar:ClearAllPoints()
	local anchor = (healthBar.GetStatusBarTexture and healthBar:GetStatusBarTexture()) or healthBar
	local bottomPoint = reverseHealth and "BOTTOMRIGHT" or "BOTTOMLEFT"
	local bottomAnchor = reverseHealth and "BOTTOMLEFT" or "BOTTOMRIGHT"
	local topPoint = reverseHealth and "TOPRIGHT" or "TOPLEFT"
	local topAnchor = reverseHealth and "TOPLEFT" or "TOPRIGHT"
	local desired = tonumber(height)
	local limit = tonumber(maxHeight)
	if not limit or limit <= 0 then limit = healthBar.GetHeight and healthBar:GetHeight() or 0 end
	if not desired or desired <= 0 then
		bar:SetPoint(bottomPoint, anchor, bottomAnchor, 0, 0)
		bar:SetPoint(topPoint, anchor, topAnchor, 0, 0)
	else
		if limit and limit > 0 and desired > limit then desired = limit end
		if anchorTop then
			bar:SetPoint(topPoint, anchor, topAnchor, 0, 0)
		else
			bar:SetPoint(bottomPoint, anchor, bottomAnchor, 0, 0)
		end
		bar:SetHeight(desired)
	end
	local width = healthBar.GetWidth and healthBar:GetWidth() or 0
	if not width or width <= 0 then
		local fallback = tonumber(fallbackWidth)
		if fallback and fallback > 0 then width = fallback end
	end
	bar:SetWidth(width or 0)
end

function H.trim(str)
	if type(str) ~= "string" then return "" end
	return str:match("^%s*(.-)%s*$")
end

function H.getFont(path)
	local fallbackFont = (addon.functions and addon.functions.GetGlobalDefaultFontFace and addon.functions.GetGlobalDefaultFontFace())
		or (addon.variables and addon.variables.defaultFont)
		or (LSM and LSM:Fetch("font", LSM.DefaultMedia.font))
		or STANDARD_TEXT_FONT
	if addon.functions and addon.functions.ResolveFontFace then return addon.functions.ResolveFontFace(path, fallbackFont) or fallbackFont end
	if addon.functions and addon.functions.IsGlobalFontConfigValue and addon.functions.IsGlobalFontConfigValue(path) then return fallbackFont end
	if type(path) == "string" and path ~= "" then
		if LSM and LSM.Fetch then
			local fetched = LSM:Fetch("font", path, true)
			if type(fetched) == "string" and fetched ~= "" then return fetched end
		end
		if LSM and LSM.HashTable then
			local hash = LSM:HashTable("font") or {}
			for _, fontPath in pairs(hash) do
				if fontPath == path then return path end
			end
		end
	end
	return fallbackFont
end

function H.applyFont(fs, fontPath, size, outline)
	if not fs then return end
	local flags = normalizeFontOutline(outline)
	local fontFile = H.getFont(fontPath)
	if size == nil or size <= 0 then size = 1 end
	local ok = H.setFontWithFallback(fs, fontFile, size or 14, flags)
	if not ok and fontPath and fontPath ~= "" then H.setFontWithFallback(fs, H.getFont(nil), size or 14, flags) end
	if addon.functions and addon.functions.ApplyFontStyleShadow then addon.functions.ApplyFontStyleShadow(fs, outline, "OUTLINE") end
end

local function ensureCooldownFontDefault(cooldown, fontString)
	if not cooldown or not fontString or cooldown._eqolCooldownFontDefault then return end
	local fontFile, fontSize, fontFlags = fontString:GetFont()
	if not fontFile or not fontSize then return end
	cooldown._eqolCooldownFontDefault = {
		font = fontFile,
		size = fontSize,
		flags = fontFlags,
	}
end

function H.applyCooldownTextStyle(cooldown, size)
	if not cooldown or not cooldown.GetCountdownFontString then return end
	local fontString = cooldown:GetCountdownFontString()
	if not fontString or not fontString.GetFont or not fontString.SetFont then return end
	ensureCooldownFontDefault(cooldown, fontString)
	local def = cooldown._eqolCooldownFontDefault
	if not def or not def.font or not def.size then return end
	local desired = tonumber(size) or 0
	local flags = def.flags or ""
	if desired > 0 then
		local key = def.font .. "|" .. tostring(desired) .. "|" .. tostring(flags)
		if cooldown._eqolCooldownFontKey == key then return end
		fontString:SetFont(def.font, desired, flags)
		cooldown._eqolCooldownFontKey = key
	else
		local key = "default|" .. def.font .. "|" .. tostring(def.size) .. "|" .. tostring(flags)
		if cooldown._eqolCooldownFontKey == key then return end
		fontString:SetFont(def.font, def.size, flags)
		cooldown._eqolCooldownFontKey = key
	end
end

function H.resolveBorderTexture(key)
	if type(key) == "string" and key:upper() == "SOLID" then return "Interface\\Buttons\\WHITE8x8" end
	if not key or key == "" or key == "DEFAULT" then return "Interface\\Buttons\\WHITE8x8" end
	if LSM then
		local tex = LSM:Fetch("border", key)
		if tex and tex ~= "" then return tex end
	end
	return key
end

function H.resolveAuraBorderTexture(key)
	if type(key) == "string" and key:upper() == "SOLID" then return "Interface\\Buttons\\WHITE8x8", nil, true end
	if not key or key == "" or key == "DEFAULT" then return DEFAULT_AURA_BORDER_TEX, DEFAULT_AURA_BORDER_COORDS, false end
	if LSM then
		local tex = LSM:Fetch("border", key)
		if tex and tex ~= "" then return tex, nil, true end
	end
	return key, nil, false
end

function H.ensureAuraBorderFrame(btn)
	if not btn then return nil end
	local border = btn._eqolAuraBorder
	if not border then
		border = CreateFrame("Frame", nil, btn, "BackdropTemplate")
		border:EnableMouse(false)
		btn._eqolAuraBorder = border
	end
	H.syncAuraBorderFrameLayer(btn)
	border:ClearAllPoints()
	border:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0)
	border:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", 0, 0)
	return border
end

function H.syncAuraBorderFrameLayer(btn)
	if not btn then return nil end
	local border = btn._eqolAuraBorder
	if not border then return nil end

	local layerParent = btn.overlay or btn
	if border.GetParent and border:GetParent() ~= layerParent then border:SetParent(layerParent) end

	local strata = (layerParent.GetFrameStrata and layerParent:GetFrameStrata()) or (btn.GetFrameStrata and btn:GetFrameStrata())
	if strata and border.SetFrameStrata and border:GetFrameStrata() ~= strata then border:SetFrameStrata(strata) end

	local targetLevel = (layerParent.GetFrameLevel and layerParent:GetFrameLevel()) or (btn.GetFrameLevel and btn:GetFrameLevel()) or 0
	local foregroundLevel = btn.foreground and btn.foreground.GetFrameLevel and btn.foreground:GetFrameLevel()
	if foregroundLevel and foregroundLevel > targetLevel then targetLevel = foregroundLevel - 1 end
	if targetLevel < 0 then targetLevel = 0 end
	if border.SetFrameLevel and border:GetFrameLevel() ~= targetLevel then border:SetFrameLevel(targetLevel) end

	return border
end

function H.hideAuraBorderFrame(btn)
	local border = btn and btn._eqolAuraBorder
	if border then border:Hide() end
end

function H.calcAuraBorderSize(btn, ac)
	local configured = ac and tonumber(ac.borderSize)
	if configured and configured > 0 then return floor(configured + 0.5) end
	local baseSize = (btn and btn.GetWidth and btn:GetWidth()) or (ac and ac.size) or 24
	local size = floor((baseSize or 24) * 0.08 + 0.5)
	if size < 1 then size = 1 end
	if size > 6 then size = 6 end
	return size
end

local PRIVATE_AURA_INVERSE_POINTS = {
	TOP = "BOTTOM",
	BOTTOM = "TOP",
	LEFT = "RIGHT",
	RIGHT = "LEFT",
	TOPLEFT = "BOTTOMLEFT",
	TOPRIGHT = "BOTTOMRIGHT",
	BOTTOMLEFT = "TOPLEFT",
	BOTTOMRIGHT = "TOPRIGHT",
	CENTER = "CENTER",
}

local function inversePoint(point)
	if not point then return "CENTER" end
	local key = tostring(point):upper()
	return PRIVATE_AURA_INVERSE_POINTS[key] or "CENTER"
end

local function resolvePrivateAuraOffset(point, offset)
	local p = tostring(point or "RIGHT"):upper()
	local off = tonumber(offset) or 0
	if p == "LEFT" then return -off, 0 end
	if p == "TOP" then return 0, off end
	if p == "BOTTOM" then return 0, -off end
	return off, 0
end

function H.PrivateAuraNormalizeDirection(direction, fallback)
	local value = tostring(direction or fallback or "RIGHT"):upper()
	if value == "LEFT" or value == "RIGHT" or value == "UP" or value == "DOWN" then return value end
	value = tostring(fallback or "RIGHT"):upper()
	if value == "LEFT" or value == "RIGHT" or value == "UP" or value == "DOWN" then return value end
	return "RIGHT"
end

function H.PrivateAuraGetGridDimensions(count, wrapCount, primaryHorizontal)
	count = floor(tonumber(count) or 1)
	if count < 1 then count = 1 end
	wrapCount = floor(tonumber(wrapCount) or 0)
	if wrapCount < 0 then wrapCount = 0 end
	if 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 resolvePrivateAuraUnitToken(unit)
	if type(unit) ~= "string" then return unit end
	if unit ~= "player" and UnitIsUnit then
		local ok, isPlayer = pcall(UnitIsUnit, unit, "player")
		if ok and H.UnsecretBool(isPlayer) == true then return "player" end
	end
	return unit
end

local PRIVATE_AURA_SAMPLE_ICON_ID = 237555

local function ensurePrivateAuraSampleTexture(anchor)
	if not anchor then return nil end
	local tex = anchor._eqolPrivateAuraSample
	if not tex then
		tex = anchor:CreateTexture(nil, "OVERLAY")
		tex:SetAllPoints(anchor)
		tex:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		tex:SetTexture(PRIVATE_AURA_SAMPLE_ICON_ID)
		anchor._eqolPrivateAuraSample = tex
	end
	return tex
end

local privateAuraArgs = {
	unitToken = "player",
	parent = UIParent,
	auraIndex = 1,
	showCountdownFrame = true,
	showCountdownNumbers = true,
	isContainer = false,
	iconInfo = {
		iconWidth = 32,
		iconHeight = 32,
		iconAnchor = {
			point = "CENTER",
			relativePoint = "CENTER",
			offsetX = 0,
			offsetY = 0,
		},
	},
}

local privateAuraDuration = {
	point = "CENTER",
	relativePoint = "CENTER",
	offsetX = 0,
	offsetY = 0,
}

local privateAuraShowDispelType = false
local privateAuraShowDispelCount = 0
H._privateAuraDeferred = H._privateAuraDeferred or { containers = {} }

-- TODO: Remove this temporary 12.0.5 workaround once Blizzard fixes private aura anchors ignoring frame levels after reattaching.
-- Original behavior matched the parent strata and used levelFrame + 10. The workaround bumps strata and uses levelFrame + 100.
H._privateAuraStrataFix = H._privateAuraStrataFix or {
	BACKGROUND = "LOW",
	LOW = "MEDIUM",
	MEDIUM = "HIGH",
	HIGH = "DIALOG",
	DIALOG = "FULLSCREEN",
	FULLSCREEN = "FULLSCREEN_DIALOG",
	FULLSCREEN_DIALOG = "TOOLTIP",
}

function H.ApplyPrivateAuraContainerFrameLevel(container, parent, levelFrame, usePrivateAuraWorkaround)
	if not container then return end
	local strataSource = (levelFrame and levelFrame.GetFrameStrata and levelFrame) or (parent and parent.GetFrameStrata and parent)
	if container.SetFrameStrata and strataSource then
		local strata = strataSource:GetFrameStrata()
		if usePrivateAuraWorkaround ~= false then
			container:SetFrameStrata(H._privateAuraStrataFix[strata] or "DIALOG")
		else
			container:SetFrameStrata(strata or "MEDIUM")
		end
	end
	if levelFrame and container.SetFrameLevel and levelFrame.GetFrameLevel then
		local offset = (usePrivateAuraWorkaround ~= false) and 100 or 10
		container:SetFrameLevel((levelFrame:GetFrameLevel() or 0) + offset)
	end
end

function H.UpdatePrivateAuraDeferredEvent()
	local frame = H._privateAuraDeferred and H._privateAuraDeferred.frame
	if not frame then return end
	if next(H._privateAuraDeferred.containers) then
		frame:RegisterEvent("PLAYER_REGEN_ENABLED")
	else
		frame:UnregisterEvent("PLAYER_REGEN_ENABLED")
	end
end

function H.ClearDeferredPrivateAuraMutation(container)
	if not container then return end
	container._eqolPrivateAuraDeferred = nil
	H._privateAuraDeferred.containers[container] = nil
	H.UpdatePrivateAuraDeferredEvent()
end

function H.FlushDeferredPrivateAuraMutations()
	local queued = {}
	for container in pairs(H._privateAuraDeferred.containers) do
		queued[#queued + 1] = container
	end
	for i = 1, #queued do
		local container = queued[i]
		local pending = container and container._eqolPrivateAuraDeferred
		H.ClearDeferredPrivateAuraMutation(container)
		if pending then
			if pending.action == "remove" then
				H.RemovePrivateAuras(container)
			elseif pending.action == "apply" then
				H.ApplyPrivateAuras(container, pending.unit, pending.cfg, pending.parent, pending.levelFrame, pending.showSample, pending.inverseAnchor)
			elseif pending.action == "applyBlizzard" then
				H.ApplyBlizzardAuraContainer(container, pending.unit, pending.cfg, pending.parent, pending.levelFrame, pending.showSample)
			end
		end
	end
end

function H.QueueDeferredPrivateAuraMutation(container, action, payload)
	if not container then return end
	if not H._privateAuraDeferred.frame then
		H._privateAuraDeferred.frame = CreateFrame("Frame")
		H._privateAuraDeferred.frame:SetScript("OnEvent", function(_, event)
			if event == "PLAYER_REGEN_ENABLED" then H.FlushDeferredPrivateAuraMutations() end
		end)
	end
	local pending = container._eqolPrivateAuraDeferred or {}
	pending.action = action
	if action == "apply" and payload then
		pending.unit = payload.unit
		pending.cfg = payload.cfg
		pending.parent = payload.parent
		pending.levelFrame = payload.levelFrame
		pending.showSample = payload.showSample
		pending.inverseAnchor = payload.inverseAnchor
	elseif action == "applyBlizzard" and payload then
		pending.unit = payload.unit
		pending.cfg = payload.cfg
		pending.parent = payload.parent
		pending.levelFrame = payload.levelFrame
		pending.showSample = payload.showSample
		pending.inverseAnchor = nil
	else
		pending.unit = nil
		pending.cfg = nil
		pending.parent = nil
		pending.levelFrame = nil
		pending.showSample = nil
		pending.inverseAnchor = nil
	end
	container._eqolPrivateAuraDeferred = pending
	H._privateAuraDeferred.containers[container] = true
	H.UpdatePrivateAuraDeferredEvent()
end

local function removePrivateAuraAnchor(anchor)
	if anchor and anchor.anchorID and C_UnitAuras and C_UnitAuras.RemovePrivateAuraAnchor then
		pcall(C_UnitAuras.RemovePrivateAuraAnchor, anchor.anchorID)
		anchor.anchorID = nil
	end
end

function H.RemoveBlizzardAuraContainer(container)
	if not container then return true end
	if container._eqolBlizzardAuraAnchorID then
		if not (C_UnitAuras and C_UnitAuras.RemovePrivateAuraAnchor) then return false end
		local ok = pcall(C_UnitAuras.RemovePrivateAuraAnchor, container._eqolBlizzardAuraAnchorID)
		if not ok then return false end
		container._eqolBlizzardAuraAnchorID = nil
	end
	container._eqolBlizzardAuraSignature = nil
	return true
end

function H.NormalizeAuraRenderer(value)
	local renderer = tostring(value or "CUSTOM"):upper()
	if renderer == "BLIZZARD" or renderer == "BLIZZARD_CONTAINER" then return "BLIZZARD" end
	return "CUSTOM"
end

function H.IsBlizzardAuraRenderer(value)
	return H.NormalizeAuraRenderer(value) == "BLIZZARD"
end

function H.ResolveBlizzardAuraOrganization(value)
	if type(value) == "number" then return value end
	local token = tostring(value or ""):upper()
	local org = Enum and Enum.RaidAuraOrganizationType
	if token == "BUFFS_TOP_DEBUFFS_BOTTOM" or token == "TOP_BOTTOM" then
		return (org and org.BuffsTopDebuffsBottom) or 1
	elseif token == "BUFFS_RIGHT_DEBUFFS_LEFT" or token == "RIGHT_LEFT" then
		return (org and org.BuffsRightDebuffsLeft) or 2
	end
	return (org and org.Legacy) or 0
end

function H.SetBlizzardAuraContainerAttributes(container, cfg)
	local showBuffs = cfg.showBuffs == true
	local showDebuffs = cfg.showDebuffs == true
	local showDispels = cfg.showDispels == true
	local showBigDefensive = cfg.showBigDefensive == true

	container:SetAttribute("max-buffs", showBuffs and (cfg.maxBuffs or 0) or 0)
	container:SetAttribute("max-debuffs", showDebuffs and (cfg.maxDebuffs or 0) or 0)
	container:SetAttribute("max-dispel-debuffs", showDispels and (cfg.maxDispelDebuffs or 0) or 0)
	container:SetAttribute("aura-organization-type", H.ResolveBlizzardAuraOrganization(cfg.organizationType))
	container:SetAttribute("display-only-dispellable-debuffs", cfg.displayOnlyDispellableDebuffs == true)
	container:SetAttribute("ignore-buffs", not showBuffs)
	container:SetAttribute("ignore-debuffs", not showDebuffs)
	container:SetAttribute("ignore-dispel-debuffs", not showDispels)
	container:SetAttribute("dispel-indicator-option", cfg.dispelIndicatorOption or 2)
	container:SetAttribute("display-larger-role-specific-debuffs", cfg.displayLargerRoleSpecificDebuffs == true)
	container:SetAttribute("show-dispel-indicator-overlay", cfg.showDispelOverlay == true)
	container:SetAttribute("show-big-defensive", showBigDefensive)
	container:SetAttribute("big-defensive-size", cfg.bigDefensiveSize or cfg.iconSize or 16)
	container:SetAttribute("icon-size", cfg.iconSize or 16)
	container:SetAttribute("always-hide-duration", cfg.alwaysHideDuration ~= false)
	container:SetAttribute("set-aura-size-to-icon-size", true)
	container:SetAttribute("power-bar-used-height", cfg.powerBarUsedHeight or 0)
	container:SetAttribute("group-type", cfg.groupType)
	container:SetAttribute("suppress-dispel-border-icons", cfg.suppressDispelBorderIcons ~= false)
end

function H.BuildBlizzardAuraSignature(unit, cfg)
	return table.concat({
		tostring(unit or ""),
		tostring(cfg.showBuffs == true),
		tostring(cfg.showDebuffs == true),
		tostring(cfg.showDispels == true),
		tostring(cfg.showBigDefensive == true),
		tostring(cfg.maxBuffs or 0),
		tostring(cfg.maxDebuffs or 0),
		tostring(cfg.maxDispelDebuffs or 0),
		tostring(H.ResolveBlizzardAuraOrganization(cfg.organizationType)),
		tostring(cfg.displayOnlyDispellableDebuffs == true),
		tostring(cfg.displayLargerRoleSpecificDebuffs == true),
		tostring(cfg.dispelIndicatorOption or 2),
		tostring(cfg.showDispelOverlay == true),
		tostring(cfg.iconSize or 16),
		tostring(cfg.bigDefensiveSize or cfg.iconSize or 16),
		tostring(cfg.powerBarUsedHeight or 0),
		tostring(cfg.groupType or ""),
		tostring(cfg.showCountdownFrame ~= false),
		tostring(cfg.showCountdownNumbers == true),
		tostring(cfg.borderScale or ""),
	}, ":")
end

local function buildPrivateAuraAnchor(anchor, unit, index, size, borderScale, showFrame, showNumbers, durationEnabled, durationPoint, durationOffsetX, durationOffsetY, durationRelativeTo)
	if not (C_UnitAuras and C_UnitAuras.AddPrivateAuraAnchor and anchor and unit and index) then return nil end
	privateAuraArgs.unitToken = unit
	privateAuraArgs.parent = anchor
	privateAuraArgs.auraIndex = index
	privateAuraArgs.showCountdownFrame = showFrame == true
	privateAuraArgs.showCountdownNumbers = showNumbers == true
	privateAuraArgs.isContainer = false

	local icon = privateAuraArgs.iconInfo
	icon.iconWidth = size
	icon.iconHeight = size
	icon.borderScale = borderScale
	local iconAnchor = icon.iconAnchor
	iconAnchor.relativeTo = anchor
	iconAnchor.point = "CENTER"
	iconAnchor.relativePoint = "CENTER"
	iconAnchor.offsetX = 0
	iconAnchor.offsetY = 0

	if durationEnabled then
		privateAuraDuration.relativeTo = durationRelativeTo or anchor
		privateAuraDuration.point = inversePoint(durationPoint)
		privateAuraDuration.relativePoint = tostring(durationPoint or "CENTER"):upper()
		privateAuraDuration.offsetX = durationOffsetX or 0
		privateAuraDuration.offsetY = durationOffsetY or 0
		privateAuraArgs.durationAnchor = privateAuraDuration
	else
		privateAuraArgs.durationAnchor = nil
	end

	local ok, anchorID = pcall(C_UnitAuras.AddPrivateAuraAnchor, privateAuraArgs)
	if ok then return anchorID end
	return nil
end

local function updatePrivateAuraShowDispelType(container, enabled)
	local want = enabled == true
	if not (C_UnitAuras and C_UnitAuras.TriggerPrivateAuraShowDispelType) then
		if container then container._eqolPrivateAuraShowDispelType = want end
		return
	end
	local prev = container and container._eqolPrivateAuraShowDispelType == true
	if prev == want then return end
	if container then container._eqolPrivateAuraShowDispelType = want end
	if want then
		privateAuraShowDispelCount = privateAuraShowDispelCount + 1
	else
		privateAuraShowDispelCount = privateAuraShowDispelCount - 1
		if privateAuraShowDispelCount < 0 then privateAuraShowDispelCount = 0 end
	end
	local show = privateAuraShowDispelCount > 0
	if privateAuraShowDispelType ~= show then
		privateAuraShowDispelType = show
		C_UnitAuras.TriggerPrivateAuraShowDispelType(show)
	end
end

local function ensurePrivateAuraSampleCooldown(anchor)
	if not anchor then return nil end
	local cd = anchor._eqolPrivateAuraSampleCooldown
	if not cd then
		cd = CreateFrame("Cooldown", nil, anchor, "CooldownFrameTemplate")
		cd:SetAllPoints(anchor)
		anchor._eqolPrivateAuraSampleCooldown = cd
	end
	return cd
end

local function ensurePrivateAuraSampleDuration(anchor)
	if not anchor then return nil end
	local fs = anchor._eqolPrivateAuraSampleDuration
	if not fs then
		fs = anchor:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
		anchor._eqolPrivateAuraSampleDuration = fs
	end
	return fs
end

local function stripCooldownEdge(anchor)
	if not anchor or not anchor.GetRegions then return end
	for _, region in ipairs({ anchor:GetRegions() }) do
		if region and region.GetTexture and region.SetAlpha then
			local tex = region:GetTexture()
			if type(tex) == "string" and tex:find("Cooldown") and tex:find("edge") then region:SetAlpha(0) end
		end
	end
end

function H.RemovePrivateAuras(container)
	if not container then return end
	if InCombatLockdown and InCombatLockdown() then
		updatePrivateAuraShowDispelType(container, false)
		H.QueueDeferredPrivateAuraMutation(container, "remove")
		container._eqolPrivateAuraState = nil
		if container.Hide then container:Hide() end
		return
	end
	H.ClearDeferredPrivateAuraMutation(container)
	updatePrivateAuraShowDispelType(container, false)
	if not H.RemoveBlizzardAuraContainer(container) then return false end
	if container._eqolPrivateAuraFrames then
		for _, anchor in ipairs(container._eqolPrivateAuraFrames) do
			removePrivateAuraAnchor(anchor)
			if anchor._eqolPrivateAuraBlocker and anchor._eqolPrivateAuraBlocker.Hide then anchor._eqolPrivateAuraBlocker:Hide() end
			if anchor._eqolPrivateAuraSample then anchor._eqolPrivateAuraSample:Hide() end
			if anchor._eqolPrivateAuraSampleCooldown then anchor._eqolPrivateAuraSampleCooldown:Hide() end
			if anchor._eqolPrivateAuraSampleDuration then anchor._eqolPrivateAuraSampleDuration:Hide() end
			if anchor._eqolPrivateAuraLayout then
				if anchor._eqolPrivateAuraLayout._eqolPrivateAuraSample then anchor._eqolPrivateAuraLayout._eqolPrivateAuraSample:Hide() end
				if anchor._eqolPrivateAuraLayout._eqolPrivateAuraSampleCooldown then anchor._eqolPrivateAuraLayout._eqolPrivateAuraSampleCooldown:Hide() end
				if anchor._eqolPrivateAuraLayout._eqolPrivateAuraSampleDuration then anchor._eqolPrivateAuraLayout._eqolPrivateAuraSampleDuration:Hide() end
				if anchor._eqolPrivateAuraLayout.Hide then anchor._eqolPrivateAuraLayout:Hide() end
			end
			if anchor.Hide then anchor:Hide() end
		end
	end
	container._eqolPrivateAuraState = nil
	return true
end

function H.ApplyBlizzardAuraContainer(container, unit, cfg, parent, levelFrame, showSample)
	if not container then return end
	if H.PrivateAurasDisabledForClient() then
		if container.Hide then container:Hide() end
		return
	end
	if not (C_UnitAuras and C_UnitAuras.AddPrivateAuraAnchor) then return end
	cfg = cfg or {}
	if not unit then
		H.RemovePrivateAuras(container)
		if container.Hide then container:Hide() end
		return
	end
	if UnitExists and not showSample and not UnitExists(unit) then
		H.RemovePrivateAuras(container)
		if container.Hide then container:Hide() end
		return
	end
	if InCombatLockdown and InCombatLockdown() then
		H.QueueDeferredPrivateAuraMutation(container, "applyBlizzard", {
			unit = unit,
			cfg = cfg,
			parent = parent,
			levelFrame = levelFrame,
			showSample = showSample,
		})
		return
	end
	H.ClearDeferredPrivateAuraMutation(container)
	updatePrivateAuraShowDispelType(container, false)

	if container._eqolPrivateAuraFrames then
		for _, anchor in ipairs(container._eqolPrivateAuraFrames) do
			removePrivateAuraAnchor(anchor)
			if anchor.Hide then anchor:Hide() end
			if anchor._eqolPrivateAuraLayout and anchor._eqolPrivateAuraLayout.Hide then anchor._eqolPrivateAuraLayout:Hide() end
		end
	end
	container._eqolPrivateAuraState = nil

	local effectiveUnit = resolvePrivateAuraUnitToken(unit)
	if parent and container.GetParent and container:GetParent() ~= parent then container:SetParent(parent) end
	H.ApplyPrivateAuraContainerFrameLevel(container, parent, levelFrame, cfg.privateAuraFrameLevelWorkaround)
	container:ClearAllPoints()
	if parent then
		container:SetAllPoints(parent)
	else
		container:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
		container:SetSize(1, 1)
	end
	container:EnableMouse(false)
	container:Show()

	local signature = H.BuildBlizzardAuraSignature(effectiveUnit, cfg)
	if container._eqolBlizzardAuraAnchorID and container._eqolBlizzardAuraSignature == signature then return end

	H.SetBlizzardAuraContainerAttributes(container, cfg)
	if not H.RemoveBlizzardAuraContainer(container) then return end
	local iconSize = tonumber(cfg.iconSize) or 16
	local borderScale = tonumber(cfg.borderScale) or (iconSize / 11)
	local ok, anchorID = pcall(C_UnitAuras.AddPrivateAuraAnchor, {
		unitToken = effectiveUnit,
		auraIndex = 1,
		parent = container,
		showCountdownFrame = cfg.showCountdownFrame ~= false,
		showCountdownNumbers = cfg.showCountdownNumbers == true,
		isContainer = true,
		iconInfo = {
			iconWidth = iconSize,
			iconHeight = iconSize,
			borderScale = borderScale,
			iconAnchor = {
				point = "CENTER",
				relativePoint = "CENTER",
				relativeTo = container,
				offsetX = 0,
				offsetY = 0,
			},
		},
	})
	if ok and anchorID then
		container._eqolBlizzardAuraAnchorID = anchorID
		container._eqolBlizzardAuraSignature = signature
	else
		container:Hide()
	end
end

function H.ApplyPrivateAuras(container, unit, cfg, parent, levelFrame, showSample, inverseAnchor)
	if not container then return end
	if H.PrivateAurasDisabledForClient() then
		if container.Hide then container:Hide() end
		return
	end
	if not (C_UnitAuras and C_UnitAuras.AddPrivateAuraAnchor) then return end
	cfg = cfg or {}
	local enabled = cfg.enabled == true
	if not enabled or not unit then
		H.RemovePrivateAuras(container)
		if container.Hide then container:Hide() end
		return
	end
	if unit == "target" then
		H.RemovePrivateAuras(container)
		if container.Hide then container:Hide() end
		return
	end
	if UnitExists and not showSample and not UnitExists(unit) then
		H.RemovePrivateAuras(container)
		if container.Hide then container:Hide() end
		return
	end
	if InCombatLockdown and InCombatLockdown() then
		H.QueueDeferredPrivateAuraMutation(container, "apply", {
			unit = unit,
			cfg = cfg,
			parent = parent,
			levelFrame = levelFrame,
			showSample = showSample,
			inverseAnchor = inverseAnchor,
		})
		return
	end
	H.ClearDeferredPrivateAuraMutation(container)
	if not H.RemoveBlizzardAuraContainer(container) then return end

	local effectiveUnit = resolvePrivateAuraUnitToken(unit)
	local cacheState = unit == "player" or unit == "focus"

	local iconCfg = cfg.icon or {}
	local parentCfg = cfg.parent or {}
	local durationCfg = cfg.duration or {}
	local layoutCfg = cfg.layout or {}

	local amount = floor(tonumber(iconCfg.amount) or 1)
	if amount < 1 then amount = 1 end
	local minSize = floor(tonumber(iconCfg.minSize) or 4)
	if minSize < 4 then minSize = 4 end
	local maxSize = floor(tonumber(iconCfg.maxSize) or 100)
	if maxSize < minSize then maxSize = minSize end
	if maxSize > 100 then maxSize = 100 end
	local size = floor(tonumber(iconCfg.size) or 24)
	if size > maxSize then size = maxSize end
	if size < minSize then size = minSize end
	local textScale = tonumber(cfg.textScale) or 1
	if textScale < 0.5 then
		textScale = 0.5
	elseif textScale > 3 then
		textScale = 3
	end
	-- Keep the icon's visual size unchanged while letting the private aura frame scale its text.
	local logicalSize = size / textScale
	local iconPoint = tostring(iconCfg.point or "RIGHT"):upper()
	local iconOffset = tonumber(iconCfg.offset or iconCfg.spacing or 2) or 0
	local borderScale = tonumber(iconCfg.borderScale)
	if borderScale == nil then borderScale = (size / 32) * 2 end
	borderScale = borderScale / textScale
	local layoutEnabled = layoutCfg.enabled == true or layoutCfg.wrapCount ~= nil or layoutCfg.direction ~= nil or layoutCfg.wrapDirection ~= nil
	local layoutDirection = H.PrivateAuraNormalizeDirection(layoutCfg.direction or iconCfg.direction or iconPoint, iconPoint)
	local primaryHorizontal = layoutDirection == "LEFT" or layoutDirection == "RIGHT"
	local wrapCount = floor(tonumber(layoutCfg.wrapCount) or 0)
	if wrapCount < 0 then wrapCount = 0 end
	local wrapDirection = H.PrivateAuraNormalizeDirection(layoutCfg.wrapDirection, primaryHorizontal and "DOWN" or "RIGHT")
	if primaryHorizontal then
		if wrapDirection ~= "UP" and wrapDirection ~= "DOWN" then wrapDirection = "DOWN" end
	else
		if wrapDirection ~= "LEFT" and wrapDirection ~= "RIGHT" then wrapDirection = "RIGHT" end
	end

	local showFrame = cfg.countdownFrame ~= false
	local showNumbers = cfg.countdownNumbers ~= false
	local showTooltip = cfg.showTooltip == true
	local durationEnabled = durationCfg.enable == true
	local durationPoint = tostring(durationCfg.point or "CENTER"):upper()
	local durationOffsetX = tonumber(durationCfg.offsetX) or 0
	local durationOffsetY = tonumber(durationCfg.offsetY) or 0

	local parentPoint = tostring(parentCfg.point or "CENTER"):upper()
	local parentOffsetX = tonumber(parentCfg.offsetX) or 0
	local parentOffsetY = tonumber(parentCfg.offsetY) or 0
	local useInverse = inverseAnchor == true
	local anchorPoint = useInverse and inversePoint(parentPoint) or parentPoint

	if parent and container.GetParent and container:GetParent() ~= parent then container:SetParent(parent) end
	H.ApplyPrivateAuraContainerFrameLevel(container, parent, levelFrame)
	container:ClearAllPoints()
	container:SetPoint(anchorPoint, parent or container:GetParent() or UIParent, parentPoint, parentOffsetX, parentOffsetY)
	container:SetSize(size, size)
	container:Show()

	local state = cacheState and (container._eqolPrivateAuraState or {}) or {}
	local changed = not cacheState
		or state.unitToken ~= unit
		or state.effectiveUnit ~= effectiveUnit
		or state.amount ~= amount
		or state.size ~= size
		or state.countdownFrame ~= showFrame
		or state.countdownNumbers ~= showNumbers
		or state.showTooltip ~= showTooltip
		or state.borderScale ~= borderScale
		or state.textScale ~= textScale
		or state.durationEnabled ~= durationEnabled
		or state.durationPoint ~= durationPoint
		or state.durationOffsetX ~= durationOffsetX
		or state.durationOffsetY ~= durationOffsetY

	if cacheState then
		state.unitToken = unit
		state.effectiveUnit = effectiveUnit
		state.amount = amount
		state.size = size
		state.countdownFrame = showFrame
		state.countdownNumbers = showNumbers
		state.showTooltip = showTooltip
		state.borderScale = borderScale
		state.textScale = textScale
		state.durationEnabled = durationEnabled
		state.durationPoint = durationPoint
		state.durationOffsetX = durationOffsetX
		state.durationOffsetY = durationOffsetY
		container._eqolPrivateAuraState = state
	else
		container._eqolPrivateAuraState = nil
	end

	updatePrivateAuraShowDispelType(container, cfg.showDispelType == true)

	container._eqolPrivateAuraFrames = container._eqolPrivateAuraFrames or {}
	local anchors = container._eqolPrivateAuraFrames
	local attachPoint = inversePoint(iconPoint)
	local ox, oy = resolvePrivateAuraOffset(iconPoint, iconOffset)
	local cols, rows = 1, 1
	local layoutWidth, layoutHeight = size, size
	if layoutEnabled then
		cols, rows = H.PrivateAuraGetGridDimensions(amount, wrapCount, primaryHorizontal)
		layoutWidth = (cols * size) + ((cols - 1) * iconOffset)
		layoutHeight = (rows * size) + ((rows - 1) * iconOffset)
		if layoutWidth < size then layoutWidth = size end
		if layoutHeight < size then layoutHeight = size end
		container:SetSize(layoutWidth, layoutHeight)
	else
		container:SetSize(size, size)
	end
	container._eqolPrivateAuraLayoutWidth = layoutWidth
	container._eqolPrivateAuraLayoutHeight = layoutHeight

	for i = 1, amount do
		local anchor = anchors[i]
		if not anchor then
			anchor = CreateFrame("Frame", nil, container)
			anchor:EnableMouse(false)
			anchors[i] = anchor
		end
		local layout = anchor._eqolPrivateAuraLayout
		if not layout then
			layout = CreateFrame("Frame", nil, container)
			layout:EnableMouse(false)
			anchor._eqolPrivateAuraLayout = layout
		elseif layout.GetParent and layout:GetParent() ~= container then
			layout:SetParent(container)
		end
		layout:ClearAllPoints()
		if layoutEnabled then
			local col = 0
			local row = 0
			local primaryIndex = i - 1
			local secondaryIndex = 0
			if wrapCount > 0 then
				primaryIndex = (i - 1) % wrapCount
				secondaryIndex = math.floor((i - 1) / wrapCount)
			end
			if primaryHorizontal then
				col = primaryIndex
				row = secondaryIndex
				if layoutDirection == "LEFT" then col = (cols - 1) - col end
				if wrapDirection == "UP" then row = (rows - 1) - row end
			else
				row = primaryIndex
				col = secondaryIndex
				if layoutDirection == "UP" then row = (rows - 1) - row end
				if wrapDirection == "LEFT" then col = (cols - 1) - col end
			end
			local step = size + iconOffset
			local x = (-layoutWidth / 2) + (size / 2) + (col * step)
			local y = (layoutHeight / 2) - (size / 2) - (row * step)
			layout:SetPoint("CENTER", container, "CENTER", x, y)
		elseif i == 1 then
			layout:SetPoint("CENTER", container, "CENTER", 0, 0)
		else
			local prevLayout = (anchors[i - 1] and anchors[i - 1]._eqolPrivateAuraLayout) or anchors[i - 1]
			layout:SetPoint(attachPoint, prevLayout, iconPoint, ox, oy)
		end
		layout:SetScale(textScale)
		layout:SetSize(logicalSize, logicalSize)
		layout:Show()
		anchor:ClearAllPoints()
		anchor:SetPoint("CENTER", layout, "CENTER", 0, 0)
		if showTooltip then
			anchor:SetSize(logicalSize, logicalSize)
		else
			anchor:SetSize(0.001, 0.001)
		end
		anchor:Show()
		if anchor._eqolPrivateAuraBlocker and anchor._eqolPrivateAuraBlocker.Hide then anchor._eqolPrivateAuraBlocker:Hide() end
		if showSample then
			local tex = ensurePrivateAuraSampleTexture(layout)
			if tex then tex:Show() end
			local cd = ensurePrivateAuraSampleCooldown(layout)
			if cd then
				cd:SetAllPoints(layout)
				if cd.SetHideCountdownNumbers then cd:SetHideCountdownNumbers(not showNumbers) end
				local start = (GetTime and GetTime() or 0) - 10
				if CooldownFrame_Set then
					CooldownFrame_Set(cd, start, 30, true)
				elseif cd.SetCooldown then
					cd:SetCooldown(start, 30)
				end
				cd:SetShown(showFrame == true)
			end
			local dur = ensurePrivateAuraSampleDuration(layout)
			if dur then
				if durationEnabled then
					dur:ClearAllPoints()
					dur:SetPoint(inversePoint(durationPoint), layout, durationPoint, durationOffsetX, durationOffsetY)
					dur:SetText("12s")
					dur:Show()
				else
					dur:Hide()
				end
			end
		else
			if anchor._eqolPrivateAuraSample then anchor._eqolPrivateAuraSample:Hide() end
			if anchor._eqolPrivateAuraSampleCooldown then anchor._eqolPrivateAuraSampleCooldown:Hide() end
			if anchor._eqolPrivateAuraSampleDuration then anchor._eqolPrivateAuraSampleDuration:Hide() end
			if layout._eqolPrivateAuraSample then layout._eqolPrivateAuraSample:Hide() end
			if layout._eqolPrivateAuraSampleCooldown then layout._eqolPrivateAuraSampleCooldown:Hide() end
			if layout._eqolPrivateAuraSampleDuration then layout._eqolPrivateAuraSampleDuration:Hide() end
		end
		if changed or not anchor.anchorID then
			removePrivateAuraAnchor(anchor)
			anchor.anchorID =
				buildPrivateAuraAnchor(anchor, effectiveUnit, i, logicalSize, borderScale, showFrame, showNumbers, durationEnabled, durationPoint, durationOffsetX, durationOffsetY, layout)
		end
		stripCooldownEdge(anchor)
	end
	for i = amount + 1, #anchors do
		removePrivateAuraAnchor(anchors[i])
		if anchors[i]._eqolPrivateAuraBlocker and anchors[i]._eqolPrivateAuraBlocker.Hide then anchors[i]._eqolPrivateAuraBlocker:Hide() end
		if anchors[i]._eqolPrivateAuraSample then anchors[i]._eqolPrivateAuraSample:Hide() end
		if anchors[i]._eqolPrivateAuraSampleCooldown then anchors[i]._eqolPrivateAuraSampleCooldown:Hide() end
		if anchors[i]._eqolPrivateAuraSampleDuration then anchors[i]._eqolPrivateAuraSampleDuration:Hide() end
		if anchors[i]._eqolPrivateAuraLayout then
			if anchors[i]._eqolPrivateAuraLayout._eqolPrivateAuraSample then anchors[i]._eqolPrivateAuraLayout._eqolPrivateAuraSample:Hide() end
			if anchors[i]._eqolPrivateAuraLayout._eqolPrivateAuraSampleCooldown then anchors[i]._eqolPrivateAuraLayout._eqolPrivateAuraSampleCooldown:Hide() end
			if anchors[i]._eqolPrivateAuraLayout._eqolPrivateAuraSampleDuration then anchors[i]._eqolPrivateAuraLayout._eqolPrivateAuraSampleDuration:Hide() end
			if anchors[i]._eqolPrivateAuraLayout.Hide then anchors[i]._eqolPrivateAuraLayout:Hide() end
		end
		if anchors[i].Hide then anchors[i]:Hide() end
	end
end

function H.NormalizeFrameStrata(value)
	if type(value) ~= "string" or value == "" then return nil end
	local token = string.upper(value)
	if token == "BACKGROUND" or token == "LOW" or token == "MEDIUM" or token == "HIGH" or token == "DIALOG" or token == "FULLSCREEN" or token == "FULLSCREEN_DIALOG" or token == "TOOLTIP" then
		return token
	end
	return nil
end

local function ensureHighlightFrame(frame, highlightCfg)
	if not frame then return nil end
	local highlight = frame._ufHighlight
	if not highlight then
		highlight = CreateFrame("Frame", nil, frame, "BackdropTemplate")
		highlight:EnableMouse(false)
		frame._ufHighlight = highlight
	elseif highlight.GetParent and highlight:GetParent() ~= frame and highlight.SetParent then
		highlight:SetParent(frame)
	end
	local strata = H.NormalizeFrameStrata(highlightCfg and highlightCfg.strata) or (frame.GetFrameStrata and frame:GetFrameStrata())
	if strata and highlight.SetFrameStrata and highlight:GetFrameStrata() ~= strata then highlight:SetFrameStrata(strata) end
	local baseLevel = frame:GetFrameLevel() or 0
	local targetLevel = baseLevel + 4
	local border = frame._ufBorder
	if border and border.GetFrameLevel and border.GetFrameStrata and highlight.GetFrameStrata and border:GetFrameStrata() == highlight:GetFrameStrata() then
		local borderLevel = border:GetFrameLevel()
		if borderLevel and targetLevel <= borderLevel then targetLevel = borderLevel + 1 end
	end
	highlight:SetFrameLevel(targetLevel)
	highlight:ClearAllPoints()
	highlight:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0)
	highlight:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0)
	return highlight
end

local function getHighlightHostFrame(st)
	if not st then return nil end
	-- Unit frame borders are applied to the bar wrapper, not the outer frame.
	return st.barGroup or st.frame
end

function H.buildHighlightConfig(cfg, def)
	local hcfg = (cfg and cfg.highlight) or {}
	local hdef = (def and def.highlight) or {}
	local enabled = hcfg.enabled
	if enabled == nil then enabled = hdef.enabled end
	if enabled ~= true then return nil end
	local mouseover = hcfg.mouseover
	if mouseover == nil then mouseover = hdef.mouseover end
	if mouseover == nil then mouseover = true end
	local target = hcfg.target
	if target == nil then target = hdef.target end
	if target == nil then target = false end
	local aggro = hcfg.aggro
	if aggro == nil then aggro = hdef.aggro end
	if aggro == nil then aggro = true end
	local combat = hcfg.combat
	if combat == nil then combat = hdef.combat end
	if combat == nil then combat = false end
	local texture = hcfg.texture or hdef.texture or "DEFAULT"
	local size = hcfg.size
	if size == nil then size = hdef.size end
	size = tonumber(size) or 1
	if size < 1 then size = 1 end
	local color = hcfg.color
	if type(color) ~= "table" then color = hdef.color end
	if type(color) ~= "table" then color = { 1, 0, 0, 1 } end
	local mouseoverColor = hcfg.mouseoverColor
	if type(mouseoverColor) ~= "table" then mouseoverColor = hdef.mouseoverColor end
	if type(mouseoverColor) ~= "table" then mouseoverColor = color end
	local combatColor = hcfg.combatColor
	if type(combatColor) ~= "table" then combatColor = hdef.combatColor end
	if type(combatColor) ~= "table" then combatColor = color end
	local strata = H.NormalizeFrameStrata(hcfg.strata)
	if strata == nil then strata = H.NormalizeFrameStrata(hdef.strata) end
	return {
		enabled = true,
		mouseover = mouseover == true,
		target = target == true,
		aggro = aggro == true,
		combat = combat == true,
		strata = strata,
		texture = texture,
		size = size,
		color = color,
		mouseoverColor = mouseoverColor,
		combatColor = combatColor,
	}
end

function H.applyHighlightStyle(st, highlightCfg)
	local host = getHighlightHostFrame(st)
	if not host then return end
	local highlight = host._ufHighlight
	if not highlight then
		highlight = st and st._highlightFrame or nil
		if highlight and highlight.GetParent and highlight:GetParent() ~= host then
			highlight:Hide()
			highlight = nil
		end
	end
	if not highlightCfg or highlightCfg.enabled ~= true then
		if st._highlightFrame and st._highlightFrame ~= highlight then st._highlightFrame:Hide() end
		if highlight then
			highlight:SetBackdrop(nil)
			highlight:Hide()
		end
		st._highlightFrame = nil
		return
	end
	if st._highlightFrame and st._highlightFrame.GetParent and st._highlightFrame:GetParent() ~= host then st._highlightFrame:Hide() end
	if highlight and host._ufHighlight ~= highlight then host._ufHighlight = highlight end
	highlight = ensureHighlightFrame(host, highlightCfg)
	if not highlight then return end
	st._highlightFrame = highlight
	local size = highlightCfg.size or 1
	if size < 1 then size = 1 end
	local insetVal = highlightCfg.inset
	if insetVal == nil then insetVal = size end
	local color = highlightCfg.color or { 1, 0, 0, 1 }
	highlight:SetBackdrop({
		bgFile = "Interface\\Buttons\\WHITE8x8",
		edgeFile = H.resolveBorderTexture(highlightCfg.texture),
		edgeSize = size,
		insets = { left = insetVal, right = insetVal, top = insetVal, bottom = insetVal },
	})
	highlight:SetBackdropColor(0, 0, 0, 0)
	highlight:SetBackdropBorderColor(color[1] or 1, color[2] or 0, color[3] or 0, color[4] or 1)
	highlight:Hide()
end

local function hasAggro(unit)
	if not UnitThreatSituation or not unit then return false end
	if UnitExists and not UnitExists(unit) then return false end
	local threat = UnitThreatSituation(unit)
	return threat and threat >= 2
end

function H.updateHighlight(st, unit, playerUnit)
	local host = getHighlightHostFrame(st)
	if not host then return end
	local cfg = st._highlightCfg
	local highlight = host._ufHighlight
	if not highlight then
		highlight = st and st._highlightFrame or nil
		if highlight and highlight.GetParent and highlight:GetParent() ~= host then
			highlight:Hide()
			highlight = nil
		end
	end
	if not cfg or cfg.enabled ~= true then
		if highlight then highlight:Hide() end
		return
	end
	if not highlight then
		H.applyHighlightStyle(st, cfg)
		highlight = host._ufHighlight or st._highlightFrame
		if not highlight then return end
	else
		if host._ufHighlight ~= highlight then host._ufHighlight = highlight end
		st._highlightFrame = highlight
	end
	local show = false
	local color = cfg.color or { 1, 0, 0, 1 }
	if cfg.mouseover and st._hovered then
		show = true
		color = cfg.mouseoverColor or color
	elseif cfg.target
		and UnitIsUnit
		and UnitExists
		and H.UnsecretBool(UnitExists("target")) == true
		and H.UnsecretBool(UnitExists(unit)) == true
		and H.UnsecretBool(UnitIsUnit(unit, "target")) == true
	then
		show = true
	elseif cfg.aggro and (unit == (playerUnit or "player") or unit == "pet") and hasAggro(unit) then
		show = true
	elseif cfg.combat and unit == (playerUnit or "player") and UnitAffectingCombat and UnitExists and UnitExists(unit) and UnitAffectingCombat(unit) then
		show = true
		color = cfg.combatColor or color
	end
	if show then
		highlight:SetBackdropBorderColor(color[1] or 1, color[2] or 0, color[3] or 0, color[4] or 1)
		highlight:Show()
	else
		highlight:Hide()
	end
end

function H.updateTargetHighlightState(st, unit, playerUnit)
	if st and st._highlightCfg and st._highlightCfg.target == true then H.updateHighlight(st, unit, playerUnit) end
end

function H.updateTargetHighlights(states, unitTokens, maxBossFrames)
	if type(states) ~= "table" or type(unitTokens) ~= "table" then return end
	local playerUnit = unitTokens.PLAYER or "player"
	H.updateTargetHighlightState(states[playerUnit], playerUnit, playerUnit)
	if unitTokens.TARGET then H.updateTargetHighlightState(states[unitTokens.TARGET], unitTokens.TARGET, playerUnit) end
	if unitTokens.TARGET_TARGET then H.updateTargetHighlightState(states[unitTokens.TARGET_TARGET], unitTokens.TARGET_TARGET, playerUnit) end
	if unitTokens.FOCUS then H.updateTargetHighlightState(states[unitTokens.FOCUS], unitTokens.FOCUS, playerUnit) end
	if unitTokens.PET then H.updateTargetHighlightState(states[unitTokens.PET], unitTokens.PET, playerUnit) end
	for i = 1, tonumber(maxBossFrames) or 0 do
		local unit = "boss" .. i
		H.updateTargetHighlightState(states[unit], unit, playerUnit)
	end
end

function H.updateAllHighlights(states, unitTokens, maxBossFrames)
	if type(states) ~= "table" or type(unitTokens) ~= "table" then return end
	local playerUnit = unitTokens.PLAYER or "player"
	H.updateHighlight(states[playerUnit], playerUnit, playerUnit)
	if unitTokens.TARGET then H.updateHighlight(states[unitTokens.TARGET], unitTokens.TARGET, playerUnit) end
	if unitTokens.TARGET_TARGET then H.updateHighlight(states[unitTokens.TARGET_TARGET], unitTokens.TARGET_TARGET, playerUnit) end
	if unitTokens.FOCUS then H.updateHighlight(states[unitTokens.FOCUS], unitTokens.FOCUS, playerUnit) end
	if unitTokens.PET then H.updateHighlight(states[unitTokens.PET], unitTokens.PET, playerUnit) end
	local maxBoss = tonumber(maxBossFrames) or 0
	for i = 1, maxBoss do
		local unit = "boss" .. i
		H.updateHighlight(states[unit], unit, playerUnit)
	end
end

function H.resolveTexture(key)
	if key == "SOLID" then return "Interface\\Buttons\\WHITE8x8" end
	if not key or key == "DEFAULT" then return BLIZZARD_TEX end
	if key == H.BLIZZARD_RAID_FRAME_TEX_KEY then return H.BLIZZARD_RAID_FRAME_TEX end
	if LSM then
		local tex = LSM:Fetch("statusbar", key)
		if tex then return tex end
	end
	return key
end

function H.shouldUseSolidBackdropForTexture(key)
	return key == H.BLIZZARD_RAID_FRAME_TEX_KEY or key == H.BLIZZARD_RAID_FRAME_TEX
end

function H.resolveSeparatorTexture(key)
	if not key or key == "" or key == "SOLID" then return "Interface\\Buttons\\WHITE8x8" end
	if key == "DEFAULT" then return BLIZZARD_TEX end
	if key == H.BLIZZARD_RAID_FRAME_TEX_KEY then return H.BLIZZARD_RAID_FRAME_TEX end
	if LSM then
		local tex = LSM:Fetch("statusbar", key)
		if tex then return tex end
	end
	return key
end

function H.resolveCastTexture(key)
	if key == "SOLID" then return "Interface\\Buttons\\WHITE8x8" end
	if not key or key == "DEFAULT" then return BLIZZARD_CAST_STANDARD_TEX or BLIZZARD_TEX end
	if key == H.BLIZZARD_RAID_FRAME_TEX_KEY then return H.BLIZZARD_RAID_FRAME_TEX end
	if LSM then
		local tex = LSM:Fetch("statusbar", key)
		if tex then return tex end
	end
	return key
end

function H.resolveCastInterruptTexture() return BLIZZARD_CAST_INTERRUPTED_TEX end

function H.resolveCastUninterruptibleTexture() return "ui-castingbar-uninterruptable" end

function H.resolveCastIconTexture(texture)
	if issecretvalue and issecretvalue(texture) then return texture end
	if texture == nil then return BLIZZARD_CAST_ICON_FALLBACK_TEX end
	return texture
end

local function normalizePowerToken(powerToken)
	if type(powerToken) ~= "string" then return powerToken end
	if powerToken:sub(1, 11) == "POWER_TYPE_" then return powerToken:sub(12) end
	return powerToken
end

local canonicalPowerTokenByEnum = {
	[0] = "MANA",
	[1] = "RAGE",
	[2] = "FOCUS",
	[3] = "ENERGY",
	[4] = "CHI",
	[5] = "RUNES",
	[6] = "RUNIC_POWER",
	[7] = "SOUL_SHARDS",
	[8] = "LUNAR_POWER",
	[9] = "HOLY_POWER",
	[11] = "MAELSTROM",
	[13] = "INSANITY",
	[17] = "FURY",
	[18] = "PAIN",
}

local function getCanonicalPowerToken(powerEnum, powerToken)
	local enumKey = tonumber(powerEnum)
	if enumKey ~= nil then
		local canonical = canonicalPowerTokenByEnum[enumKey]
		if canonical and canonical ~= "" then return canonical end
	end
	local token = normalizePowerToken(powerToken)
	if type(token) == "string" and token ~= "" then return token end
	return nil
end

local function getPowerColorEntry(powerEnum, powerToken)
	if not PowerBarColor then return nil end
	local canonicalToken = getCanonicalPowerToken(powerEnum, powerToken)
	if canonicalToken then
		local entry = PowerBarColor[canonicalToken]
		if entry then return entry end
		entry = PowerBarColor["POWER_TYPE_" .. canonicalToken]
		if entry then return entry end
	end
	local enumKey = tonumber(powerEnum)
	if enumKey ~= nil then
		local entry = PowerBarColor[enumKey] or PowerBarColor[tostring(enumKey)]
		if entry then return entry end
	end
	local token = normalizePowerToken(powerToken)
	if type(token) == "string" and token ~= "" and token ~= canonicalToken then
		local entry = PowerBarColor[token]
		if entry then return entry end
		entry = PowerBarColor["POWER_TYPE_" .. token]
		if entry then return entry end
	end
	return nil
end

local function getPowerOverrideEntry(overrides, powerEnum, powerToken)
	if not overrides then return nil end
	local enumKey = tonumber(powerEnum)
	if enumKey ~= nil then
		local override = overrides[enumKey] or overrides[tostring(enumKey)]
		if override then return override end
	end
	local canonicalToken = getCanonicalPowerToken(powerEnum, powerToken)
	if type(canonicalToken) == "string" and canonicalToken ~= "" then
		local override = overrides[canonicalToken]
		if override then return override end
		override = overrides["POWER_TYPE_" .. canonicalToken]
		if override then return override end
	end
	local token = normalizePowerToken(powerToken)
	if type(token) == "string" and token ~= "" and token ~= canonicalToken then
		local override = overrides[token]
		if override then return override end
		override = overrides["POWER_TYPE_" .. token]
		if override then return override end
	end
	return nil
end

function H.configureSpecialTexture(bar, pType, texKey, cfg, powerEnum)
	if not bar then return end
	if not pType and powerEnum == nil then return end
	local resolvedToken = getCanonicalPowerToken(powerEnum, pType)
	local atlas = atlasByPower[resolvedToken]
	if not atlas then return end
	if texKey and texKey ~= "" and texKey ~= "DEFAULT" then return end
	cfg = cfg or bar._cfg
	local tex = bar:GetStatusBarTexture()
	if tex and tex.SetAtlas then
		local currentAtlas = tex.GetAtlas and tex:GetAtlas()
		if currentAtlas ~= atlas then tex:SetAtlas(atlas, true) end
		if tex.SetHorizTile then tex:SetHorizTile(false) end
		if tex.SetVertTile then tex:SetVertTile(false) end
	end
end

H.getCanonicalPowerToken = getCanonicalPowerToken

local powerEnumByToken = {
	MANA = (EnumPowerType and EnumPowerType.MANA) or 0,
	RAGE = (EnumPowerType and EnumPowerType.RAGE) or 1,
	FOCUS = (EnumPowerType and EnumPowerType.FOCUS) or 2,
	ENERGY = (EnumPowerType and EnumPowerType.ENERGY) or 3,
	COMBO_POINTS = (EnumPowerType and EnumPowerType.COMBO_POINTS) or 4,
	RUNES = (EnumPowerType and EnumPowerType.RUNES) or 5,
	RUNIC_POWER = (EnumPowerType and EnumPowerType.RUNIC_POWER) or 6,
	SOUL_SHARDS = (EnumPowerType and EnumPowerType.SOUL_SHARDS) or 7,
	LUNAR_POWER = (EnumPowerType and EnumPowerType.LUNAR_POWER) or (EnumPowerType and EnumPowerType.ALTERNATE) or 8,
	HOLY_POWER = (EnumPowerType and EnumPowerType.HOLY_POWER) or 9,
	MAELSTROM = (EnumPowerType and EnumPowerType.MAELSTROM) or 11,
	CHI = (EnumPowerType and EnumPowerType.CHI) or 12,
	INSANITY = (EnumPowerType and EnumPowerType.INSANITY) or 13,
	ARCANE_CHARGES = (EnumPowerType and EnumPowerType.ARCANE_CHARGES) or 16,
	FURY = (EnumPowerType and EnumPowerType.FURY) or 17,
	PAIN = (EnumPowerType and EnumPowerType.PAIN) or 18,
	ESSENCE = (EnumPowerType and EnumPowerType.ESSENCE) or 19,
}

local auraPowerTokenSet = {
	MAELSTROM_WEAPON = true,
	VOID_METAMORPHOSIS = true,
}

local SECONDARY_TRACKED_TOKENS = {
	"MANA",
	"STAGGER",
	"VOID_METAMORPHOSIS",
}

local secondaryTrackedTokenSet = {
	MANA = true,
	STAGGER = true,
	VOID_METAMORPHOSIS = true,
}

local secondaryNeverTrackSet = {
	RUNES = true,
	COMBO_POINTS = true,
	ENERGY = true,
	RAGE = true,
}

local primaryPowerExcludedSet = {
	ARCANE_CHARGES = true,
	CHI = true,
	ESSENCE = true,
	HOLY_POWER = true,
	MAELSTROM_WEAPON = true,
	RUNIC_POWER = true,
	SOUL_SHARDS = true,
	VOID_METAMORPHOSIS = true,
}

local primaryPowerTokenCache
local primaryPowerTokenSetCache
local secondaryPowerTokenCache

local function normalizePrimaryPowerToken(powerEnum, powerToken)
	local normalized = getCanonicalPowerToken(powerEnum, powerToken)
	if type(normalized) ~= "string" then return nil end
	normalized = string.upper(normalized)
	if normalized == "" or normalized == "NONE" then return nil end
	return normalized
end

local function normalizeSecondaryPowerToken(token)
	if type(token) ~= "string" then return nil end
	local normalized = getCanonicalPowerToken(nil, token)
	if type(normalized) ~= "string" then return nil end
	normalized = string.upper(normalized)
	if normalized == "" or normalized == "NONE" then return nil end
	return normalized
end

local function appendSecondaryToken(list, seen, token)
	local normalized = normalizeSecondaryPowerToken(token)
	if not normalized or seen[normalized] then return end
	seen[normalized] = true
	list[#list + 1] = normalized
end

local function isTrackedSecondaryToken(token)
	local normalized = normalizeSecondaryPowerToken(token)
	if not normalized then return false end
	if secondaryNeverTrackSet[normalized] then return false end
	return secondaryTrackedTokenSet[normalized] == true
end

function H.getPowerLabel(token)
	local normalized = normalizeSecondaryPowerToken(token)
	if not normalized then return "" end
	local rb = addon.Aura and addon.Aura.ResourceBars
	local labels = rb and rb.PowerLabels
	local fromRB = labels and labels[normalized]
	if type(fromRB) == "string" and fromRB ~= "" then return fromRB end
	local fromGlobal = _G[normalized]
	if type(fromGlobal) == "string" and fromGlobal ~= "" then return fromGlobal end
	return normalized:gsub("_", " ")
end

local function appendPrimaryPowerToken(list, seen, token)
	local normalized = normalizePrimaryPowerToken(nil, token)
	if not normalized or seen[normalized] then return end
	if primaryPowerExcludedSet[normalized] then return end
	seen[normalized] = true
	list[#list + 1] = normalized
end

local function getPrimaryPowerTokens()
	if primaryPowerTokenCache then return primaryPowerTokenCache end
	local list = {}
	local seen = {}
	local rb = addon.Aura and addon.Aura.ResourceBars
	local powertypeClasses = rb and rb.powertypeClasses
	if type(powertypeClasses) == "table" then
		for _, specs in pairs(powertypeClasses) do
			if type(specs) == "table" then
				for _, info in pairs(specs) do
					if type(info) == "table" then appendPrimaryPowerToken(list, seen, info.MAIN) end
				end
			end
		end
	end
	if #list == 0 then
		appendPrimaryPowerToken(list, seen, "MANA")
		appendPrimaryPowerToken(list, seen, "RAGE")
		appendPrimaryPowerToken(list, seen, "FOCUS")
		appendPrimaryPowerToken(list, seen, "ENERGY")
		appendPrimaryPowerToken(list, seen, "RUNIC_POWER")
		appendPrimaryPowerToken(list, seen, "LUNAR_POWER")
		appendPrimaryPowerToken(list, seen, "HOLY_POWER")
		appendPrimaryPowerToken(list, seen, "MAELSTROM")
		appendPrimaryPowerToken(list, seen, "MAELSTROM_WEAPON")
		appendPrimaryPowerToken(list, seen, "SOUL_SHARDS")
		appendPrimaryPowerToken(list, seen, "CHI")
		appendPrimaryPowerToken(list, seen, "INSANITY")
		appendPrimaryPowerToken(list, seen, "ARCANE_CHARGES")
		appendPrimaryPowerToken(list, seen, "FURY")
		appendPrimaryPowerToken(list, seen, "ESSENCE")
		appendPrimaryPowerToken(list, seen, "VOID_METAMORPHOSIS")
	end
	table.sort(list, function(a, b) return tostring(H.getPowerLabel(a)) < tostring(H.getPowerLabel(b)) end)
	primaryPowerTokenCache = list
	primaryPowerTokenSetCache = {}
	for i = 1, #list do
		primaryPowerTokenSetCache[list[i]] = true
	end
	return primaryPowerTokenCache
end

local function getPrimaryPowerTokenSet()
	if primaryPowerTokenSetCache then return primaryPowerTokenSetCache end
	getPrimaryPowerTokens()
	return primaryPowerTokenSetCache or {}
end

function H.GetPrimaryPowerTokens()
	local src = getPrimaryPowerTokens()
	local out = {}
	for i = 1, #src do
		out[i] = src[i]
	end
	return out
end

function H.GetPrimaryPowerTokenOptions()
	local options = {}
	for _, token in ipairs(getPrimaryPowerTokens()) do
		options[#options + 1] = { value = token, label = H.getPowerLabel(token) }
	end
	return options
end

function H.GetDefaultPrimaryPowerAllowedTypes()
	local defaults = {}
	for _, token in ipairs(getPrimaryPowerTokens()) do
		defaults[token] = true
	end
	return defaults
end

local function getPrimaryAllowedTypes(cfg, def)
	local src = cfg and cfg.allowedTypes
	local allowed = {}
	if type(src) == "table" then
		for _, token in ipairs(getPrimaryPowerTokens()) do
			if src[token] == true then allowed[token] = true end
		end
		return allowed
	end
	src = def and def.allowedTypes
	if type(src) == "table" then
		for _, token in ipairs(getPrimaryPowerTokens()) do
			if src[token] == true then allowed[token] = true end
		end
		return allowed
	end
	return H.GetDefaultPrimaryPowerAllowedTypes()
end

local function getEffectivePlayerPrimaryPowerToken(powerToken, powerEnum)
	local normalized = normalizePrimaryPowerToken(powerEnum, powerToken)
	return normalized
end

function H.IsPrimaryPowerAllowed(cfg, def, powerToken, powerEnum, unitToken)
	if unitToken and unitToken ~= "player" then return true end
	local normalized = getEffectivePlayerPrimaryPowerToken(powerToken, powerEnum)
	if not normalized then return true end
	if not getPrimaryPowerTokenSet()[normalized] then return true end
	local allowed = getPrimaryAllowedTypes(cfg, def)
	return allowed[normalized] == true
end

local function getSecondaryPowerTokens()
	if secondaryPowerTokenCache then return secondaryPowerTokenCache end
	local list = {}
	local seen = {}
	for i = 1, #SECONDARY_TRACKED_TOKENS do
		appendSecondaryToken(list, seen, SECONDARY_TRACKED_TOKENS[i])
	end
	table.sort(list, function(a, b) return tostring(H.getPowerLabel(a)) < tostring(H.getPowerLabel(b)) end)
	secondaryPowerTokenCache = list
	return secondaryPowerTokenCache
end

local function getSpecPowerInfo(classTag, specIndex)
	local rb = addon.Aura and addon.Aura.ResourceBars
	local class = classTag or addon.variables.unitClass
	local spec = tonumber(specIndex or addon.variables.unitSpec)
	if not class or not spec then return nil end
	local classTable = rb and rb.powertypeClasses and rb.powertypeClasses[class]
	if type(classTable) ~= "table" then return nil end
	local info = classTable[spec]
	if type(info) ~= "table" then return nil end
	return info
end

H.secondaryPowerCache = H.secondaryPowerCache or { spec = {}, auto = {} }

function H.GetSpecPowerCacheKey(classTag, specIndex)
	local class = classTag or addon.variables.unitClass
	local spec = tonumber(specIndex or addon.variables.unitSpec)
	if not class or not spec then return nil end
	return tostring(class) .. ":" .. tostring(spec), class, spec
end

function H.GetSpecMainPowerToken(classTag, specIndex)
	local info = getSpecPowerInfo(classTag, specIndex)
	return normalizeSecondaryPowerToken(info and info.MAIN)
end

function H.GetSpecSecondaryPowerTokens(classTag, specIndex)
	local cacheKey, class, spec = H.GetSpecPowerCacheKey(classTag, specIndex)
	local cache = H.secondaryPowerCache.spec
	if cacheKey and cache[cacheKey] then return cache[cacheKey] end
	local rb = addon.Aura and addon.Aura.ResourceBars
	local classPowerTypes = rb and rb.classPowerTypes
	local info = getSpecPowerInfo(class, spec)
	local list = {}
	if not info then
		if cacheKey then cache[cacheKey] = list end
		return list
	end
	local main = normalizeSecondaryPowerToken(info.MAIN)
	if type(classPowerTypes) ~= "table" then
		if cacheKey then cache[cacheKey] = list end
		return list
	end
	for i = 1, #classPowerTypes do
		local token = normalizeSecondaryPowerToken(classPowerTypes[i])
		if token and token ~= main and info[token] == true then list[#list + 1] = token end
	end
	if cacheKey then cache[cacheKey] = list end
	return list
end

function H.GetDefaultSecondaryPowerAllowedTypes()
	if H.secondaryPowerCache.defaultAllowedTypes then return H.secondaryPowerCache.defaultAllowedTypes end
	local defaults = {}
	for _, token in ipairs(getSecondaryPowerTokens()) do
		defaults[token] = true
	end
	H.secondaryPowerCache.defaultAllowedTypes = defaults
	return defaults
end

local function isSecondaryPowerAllowed(cfg, def, token)
	local src = cfg and cfg.allowedTypes
	if type(src) == "table" then return src[token] == true end
	src = def and def.allowedTypes
	if type(src) == "table" then return src[token] == true end
	return H.GetDefaultSecondaryPowerAllowedTypes()[token] == true
end

function H.IsSecondaryPowerSupportedForSpec(powerToken, classTag, specIndex)
	local normalized = normalizeSecondaryPowerToken(powerToken)
	if not normalized then return false end
	if not isTrackedSecondaryToken(normalized) then return false end
	local class = classTag or addon.variables.unitClass
	local spec = tonumber(specIndex or addon.variables.unitSpec)
	if class == "DRUID" then
		if spec == 4 then return false end
		return normalized == "MANA"
	end
	if class == "DEMONHUNTER" and spec == 3 then return normalized == "VOID_METAMORPHOSIS" end
	local info = getSpecPowerInfo(classTag, specIndex)
	if not info then return false end
	local secondaries = H.GetSpecSecondaryPowerTokens(classTag, specIndex)
	for i = 1, #secondaries do
		if secondaries[i] == normalized then return true end
	end
	return false
end

local function resolveAutoSecondaryToken(classTag, specIndex)
	local cacheKey, class, spec = H.GetSpecPowerCacheKey(classTag, specIndex)
	local cache = H.secondaryPowerCache.auto
	if cacheKey then
		local cached = cache[cacheKey]
		if cached ~= nil then return cached or nil end
	end
	local info = getSpecPowerInfo(class, spec)
	if not info then
		if cacheKey then cache[cacheKey] = false end
		return nil
	end

	if class == "DRUID" then
		if spec == 4 then
			if cacheKey then cache[cacheKey] = false end
			return nil
		end
		if info.MAIN == "MANA" then
			if cacheKey then cache[cacheKey] = false end
			return nil
		end
		if info.MANA == true then
			if cacheKey then cache[cacheKey] = "MANA" end
			return "MANA"
		end
		if cacheKey then cache[cacheKey] = false end
		return nil
	end

	if class == "DEMONHUNTER" and spec == 3 then
		if cacheKey then cache[cacheKey] = "VOID_METAMORPHOSIS" end
		return "VOID_METAMORPHOSIS"
	end

	local secondaries = H.GetSpecSecondaryPowerTokens(class, spec)
	for i = 1, #secondaries do
		local token = secondaries[i]
		if isTrackedSecondaryToken(token) then
			if cacheKey then cache[cacheKey] = token end
			return token
		end
	end
	if cacheKey then cache[cacheKey] = false end
	return nil
end

local function getCurrentPlayerPrimaryPowerToken()
	if type(UnitPowerType) ~= "function" then return nil end
	local enumId, token = UnitPowerType("player")
	return getEffectivePlayerPrimaryPowerToken(token, enumId)
end

function H.ResolveSecondaryPowerToken(cfg, def, classTag, specIndex)
	local normalized = normalizeSecondaryPowerToken(resolveAutoSecondaryToken(classTag, specIndex))
	if not normalized then return nil end
	if not isTrackedSecondaryToken(normalized) then return nil end
	local currentPrimary = getCurrentPlayerPrimaryPowerToken()
	if currentPrimary and currentPrimary == normalized then return nil end
	if isSecondaryPowerAllowed(cfg, def, normalized) then return normalized end
	return nil
end

function H.GetSecondaryPowerTokenOptions(includeNone)
	local options = {}
	if includeNone == true then options[#options + 1] = { value = "NONE", label = _G.NONE } end
	for _, token in ipairs(getSecondaryPowerTokens()) do
		options[#options + 1] = { value = token, label = H.getPowerLabel(token) }
	end
	return options
end

function H.GetPowerEnumByToken(powerToken)
	local normalized = normalizeSecondaryPowerToken(powerToken)
	if not normalized then return nil end
	return powerEnumByToken[normalized]
end

function H.IsAuraPowerToken(powerToken)
	local normalized = normalizeSecondaryPowerToken(powerToken)
	if not normalized then return false end
	return auraPowerTokenSet[normalized] == true
end

function H.IsSecondaryPowerTokenSpecial(powerToken)
	local normalized = normalizeSecondaryPowerToken(powerToken)
	if not normalized then return false end
	return normalized == "STAGGER" or auraPowerTokenSet[normalized] == true
end

function H.GetPowerValuesForToken(unit, powerToken)
	local normalized = normalizeSecondaryPowerToken(powerToken)
	if not normalized then return nil, nil, nil, nil end
	local unitToken = unit or "player"
	if normalized == "STAGGER" then
		local cur = UnitStagger and UnitStagger(unitToken) or 0
		local maxv = UnitHealthMax and UnitHealthMax(unitToken) or 0
		return cur or 0, maxv or 0, nil, normalized
	end
	if auraPowerTokenSet[normalized] then
		local rb = addon.Aura and addon.Aura.ResourceBars
		local getCounts = rb and rb.GetAuraPowerCounts
		if type(getCounts) == "function" then
			local cur, maxv = getCounts(normalized)
			return cur or 0, maxv or 0, nil, normalized
		end
		return 0, 0, nil, normalized
	end
	local enumId = powerEnumByToken[normalized]
	if enumId == nil then return nil, nil, nil, normalized end
	local cur = UnitPower and UnitPower(unitToken, enumId) or 0
	local maxv = UnitPowerMax and UnitPowerMax(unitToken, enumId) or 0
	return cur or 0, maxv or 0, enumId, normalized
end

function H.GetPowerPercentByToken(unit, powerToken, cur, maxv)
	local normalized = normalizeSecondaryPowerToken(powerToken)
	if not normalized then return 0 end
	local enumId = powerEnumByToken[normalized]
	if enumId ~= nil and addon.functions and addon.functions.GetPowerPercent then return addon.functions.GetPowerPercent(unit or "player", enumId, cur, maxv, true) end
	local current = tonumber(cur) or 0
	local maxValue = tonumber(maxv) or 0
	if maxValue > 0 then return (current / maxValue) * 100 end
	return 0
end

local reverseStyle = Enum.StatusBarFillStyle and Enum.StatusBarFillStyle.Reverse or "REVERSE"
local standardStyle = Enum.StatusBarFillStyle and Enum.StatusBarFillStyle.Standard or "STANDARD"
function H.applyStatusBarReverseFill(bar, reverse)
	if not bar then return end
	if bar.SetFillStyle then
		bar:SetFillStyle(reverse and reverseStyle or standardStyle)
	elseif bar.SetReverseFill then
		bar:SetReverseFill(reverse and true or false)
	end
end

function H.shouldUseDefaultCastArt(st) return st and st.castUseDefaultArt == true end

local function clearCastbarGradientState(bar)
	if not bar then return end
	bar._eqolGradientEnabled = nil
	bar._eqolGradientTex = nil
	bar._eqolGradDir = nil
	bar._eqolGradSR = nil
	bar._eqolGradSG = nil
	bar._eqolGradSB = nil
	bar._eqolGradSA = nil
	bar._eqolGradER = nil
	bar._eqolGradEG = nil
	bar._eqolGradEB = nil
	bar._eqolGradEA = nil
end

local function normalizeGradientColor(color)
	if type(color) ~= "table" then return 1, 1, 1, 1 end
	if color.r ~= nil then return color.r or 1, color.g or 1, color.b or 1, color.a or 1 end
	return color[1] or 1, color[2] or 1, color[3] or 1, color[4] or 1
end

local function normalizeCastbarGradientMode(value)
	if type(value) == "string" and value:upper() == "BAR_END" then return "BAR_END" end
	return "CASTBAR"
end

local function resolveCastbarGradientProgress(bar, progressOverride)
	local progress = tonumber(progressOverride)
	if not progress and bar and bar.GetMinMaxValues and bar.GetValue then
		local minValue, maxValue = bar:GetMinMaxValues()
		local value = bar:GetValue()
		if type(value) == "number" and type(minValue) == "number" and type(maxValue) == "number" then
			local range = maxValue - minValue
			if range > 0 then progress = (value - minValue) / range end
		end
	end
	if type(progress) ~= "number" then return nil end
	return H.clamp(progress, 0, 1)
end

local function applyCastbarGradient(bar, ccfg, _baseR, _baseG, _baseB, _baseA, progressOverride)
	if not bar or not ccfg or ccfg.useGradient ~= true then return false end
	local tex = bar.GetStatusBarTexture and bar:GetStatusBarTexture()
	if not tex or not tex.SetGradient then return false end

	local sr, sg, sb, sa = normalizeGradientColor(ccfg.gradientStartColor)
	local er, eg, eb, ea = normalizeGradientColor(ccfg.gradientEndColor)

	if normalizeCastbarGradientMode(ccfg.gradientMode) == "BAR_END" then
		local progress = resolveCastbarGradientProgress(bar, progressOverride)
		if progress then
			er = sr + (er - sr) * progress
			eg = sg + (eg - sg) * progress
			eb = sb + (eb - sb) * progress
			ea = sa + (ea - sa) * progress
		end
	end

	local direction = ccfg.gradientDirection or "HORIZONTAL"
	if type(direction) == "string" then direction = direction:upper() end
	if direction ~= "VERTICAL" then direction = "HORIZONTAL" end

	if
		bar._eqolGradientEnabled
		and bar._eqolGradientTex == tex
		and bar._eqolGradDir == direction
		and bar._eqolGradSR == sr
		and bar._eqolGradSG == sg
		and bar._eqolGradSB == sb
		and bar._eqolGradSA == sa
		and bar._eqolGradER == er
		and bar._eqolGradEG == eg
		and bar._eqolGradEB == eb
		and bar._eqolGradEA == ea
	then
		return true
	end

	tex:SetGradient(direction, CreateColor(sr, sg, sb, sa), CreateColor(er, eg, eb, ea))
	bar._eqolGradientEnabled = true
	bar._eqolGradientTex = tex
	bar._eqolGradDir = direction
	bar._eqolGradSR, bar._eqolGradSG, bar._eqolGradSB, bar._eqolGradSA = sr, sg, sb, sa
	bar._eqolGradER, bar._eqolGradEG, bar._eqolGradEB, bar._eqolGradEA = er, eg, eb, ea
	return true
end

function H.SetCastbarColorWithGradient(bar, ccfg, r, g, b, a, progressOverride)
	if not bar then return end
	local br, bg, bb, ba = r or 1, g or 1, b or 1, a or 1
	local renderR, renderG, renderB, renderA = br, bg, bb, ba
	if ccfg and ccfg.useGradient == true then
		-- Keep the status bar neutral so the configured gradient colors render without a base tint.
		renderR, renderG, renderB, renderA = 1, 1, 1, 1
	end
	local lastColor = bar._eqolLastColor
	if not lastColor or lastColor[1] ~= renderR or lastColor[2] ~= renderG or lastColor[3] ~= renderB or lastColor[4] ~= renderA then
		bar:SetStatusBarColor(renderR, renderG, renderB, renderA)
		bar._eqolLastColor = bar._eqolLastColor or {}
		bar._eqolLastColor[1], bar._eqolLastColor[2], bar._eqolLastColor[3], bar._eqolLastColor[4] = renderR, renderG, renderB, renderA
	end
	if ccfg and ccfg.useGradient == true then
		if not applyCastbarGradient(bar, ccfg, br, bg, bb, ba, progressOverride) then clearCastbarGradientState(bar) end
	elseif bar._eqolGradientEnabled then
		clearCastbarGradientState(bar)
	end
end

function H.RefreshCastbarGradient(bar, ccfg, r, g, b, a, progressOverride)
	if not bar then return end
	local br, bg, bb, ba = r, g, b, a
	if br == nil then
		if bar._eqolLastColor then
			br, bg, bb, ba = bar._eqolLastColor[1], bar._eqolLastColor[2], bar._eqolLastColor[3], bar._eqolLastColor[4]
		elseif bar.GetStatusBarColor then
			br, bg, bb, ba = bar:GetStatusBarColor()
		end
	end
	if ccfg and ccfg.useGradient == true then
		if not applyCastbarGradient(bar, ccfg, br or 1, bg or 1, bb or 1, ba or 1, progressOverride) then clearCastbarGradientState(bar) end
	elseif bar._eqolGradientEnabled then
		clearCastbarGradientState(bar)
	end
end

local CAST_SPARK_WIDTH = 8
local CAST_SPARK_HEIGHT = 20
local CAST_SPARK_LAYER_DEFAULT = 3
local CAST_SPARK_LAYER_CUSTOM = 7
local EMPOWER_PIP_LAYER_DEFAULT = 2
local EMPOWER_PIP_LAYER_CUSTOM = 6
local EMPOWER_CUSTOM_PIP_MIN_WIDTH = 3
local EMPOWER_CUSTOM_PIP_MAX_WIDTH = 6
local EMPOWER_CUSTOM_PIP_ALPHA = 0.9
local EMPOWER_CUSTOM_PIP_LINE_SUBLEVEL = 5
local EMPOWER_CUSTOM_PIP_FX_SUBLEVEL = 7

local function getCustomPipDimensions(barHeight)
	local baseHeight = barHeight or 0
	if baseHeight <= 0 then baseHeight = 16 end
	local lineHeight = baseHeight
	local width = floor(baseHeight * 0.18)
	if width < EMPOWER_CUSTOM_PIP_MIN_WIDTH then width = EMPOWER_CUSTOM_PIP_MIN_WIDTH end
	if width > EMPOWER_CUSTOM_PIP_MAX_WIDTH then width = EMPOWER_CUSTOM_PIP_MAX_WIDTH end
	return width, lineHeight
end

local function setPipFxLayer(pip, layer, sublevel)
	if not pip then return end
	if pip.PipGlow and pip.PipGlow.SetDrawLayer then pip.PipGlow:SetDrawLayer(layer, sublevel) end
	if pip.FlakesBottom and pip.FlakesBottom.SetDrawLayer then pip.FlakesBottom:SetDrawLayer(layer, sublevel) end
	if pip.FlakesTop and pip.FlakesTop.SetDrawLayer then pip.FlakesTop:SetDrawLayer(layer, sublevel) end
	if pip.FlakesTop02 and pip.FlakesTop02.SetDrawLayer then pip.FlakesTop02:SetDrawLayer(layer, sublevel) end
	if pip.FlakesBottom02 and pip.FlakesBottom02.SetDrawLayer then pip.FlakesBottom02:SetDrawLayer(layer, sublevel) end
end

local function ensureCastSparkTextures(st)
	if not st or not st.castBar then return nil end
	local bar = st.castBar
	if not st.castSparkLayer then
		st.castSparkLayer = CreateFrame("Frame", nil, bar)
		st.castSparkLayer:SetAllPoints(bar)
		st.castSparkLayer:EnableMouse(false)
	end
	if st.castSparkLayer:GetParent() ~= bar then st.castSparkLayer:SetParent(bar) end
	st.castSparkLayer:SetFrameStrata(bar:GetFrameStrata())
	local baseLevel = bar:GetFrameLevel() or 0
	local layerOffset = H.shouldUseDefaultCastArt(st) and CAST_SPARK_LAYER_DEFAULT or CAST_SPARK_LAYER_CUSTOM
	st.castSparkLayer:SetFrameLevel(baseLevel + layerOffset)
	local layer = st.castSparkLayer
	local spark = st.castSpark
	if not spark then
		spark = layer:CreateTexture(nil, "OVERLAY", nil, 2)
		if spark.SetAtlas then spark:SetAtlas("ui-castingbar-pip", false) end
		spark:SetSize(CAST_SPARK_WIDTH, CAST_SPARK_HEIGHT)
		spark.offsetY = 0
		spark:Hide()
		st.castSpark = spark
	end
	if not st.castSparkGlow then
		local glow = layer:CreateTexture(nil, "OVERLAY", nil, 3)
		if glow.SetBlendMode then glow:SetBlendMode("ADD") end
		glow:Hide()
		st.castSparkGlow = glow
	end
	if not st.castSparkShadow then
		local shadow = layer:CreateTexture(nil, "OVERLAY", nil, 3)
		shadow:Hide()
		st.castSparkShadow = shadow
	end
	return spark
end

local function setSparkAtlas(spark, atlas)
	if not spark then return end
	if spark.SetAtlas then
		local current = spark.GetAtlas and spark:GetAtlas()
		if current ~= atlas then spark:SetAtlas(atlas, false) end
	end
end

local function updateSparkFx(st, barType)
	if not st then return end
	local glow = st.castSparkGlow
	local shadow = st.castSparkShadow
	if glow then glow:Hide() end
	if shadow then shadow:Hide() end
	if not st.castSpark then return end

	if barType == "channel" then
		if shadow and shadow.SetAtlas then shadow:SetAtlas("cast_channel_pipshadow", true) end
		if shadow then
			shadow:ClearAllPoints()
			shadow:SetPoint("RIGHT", st.castSpark, "LEFT", 1, 0)
			shadow:Show()
		end
		return
	end

	if not glow then return end
	if barType ~= "interrupted" and barType ~= "empowered" then
		if glow.SetAtlas then glow:SetAtlas("cast_standard_pipglow", true) end
		if glow.SetScale then glow:SetScale(1) end
		glow:ClearAllPoints()
		glow:SetPoint("RIGHT", st.castSpark, "LEFT", 2, 0)
		glow:Show()
	end
end

function H.hideCastSpark(st)
	if not st then return end
	if st.castSpark then st.castSpark:Hide() end
	if st.castSparkGlow then st.castSparkGlow:Hide() end
	if st.castSparkShadow then st.castSparkShadow:Hide() end
end

function H.updateCastSpark(st, overrideType)
	if not st or not st.castBar then return end
	local barType = overrideType
	if not barType then
		if st.castInterruptActive then
			barType = "interrupted"
		elseif st.castInfo then
			if st.castInfo.isEmpowered then
				barType = "empowered"
			elseif st.castInfo.isChannel then
				barType = "channel"
			else
				barType = "standard"
			end
		end
	end
	if not barType then
		H.hideCastSpark(st)
		return
	end
	local spark = ensureCastSparkTextures(st)
	if not spark then return end

	if barType == "interrupted" then
		setSparkAtlas(spark, "ui-castingbar-pip-red")
	elseif barType == "empowered" then
		setSparkAtlas(spark, "ui-castingbar-empower-cursor")
	else
		setSparkAtlas(spark, "ui-castingbar-pip")
	end
	spark:Show()
	updateSparkFx(st, barType)

	local barHeight = st.castBar:GetHeight()
	local sparkHeight = CAST_SPARK_HEIGHT
	if barHeight and barHeight > 0 then
		sparkHeight = barHeight + 8
		if sparkHeight < CAST_SPARK_HEIGHT then sparkHeight = CAST_SPARK_HEIGHT end
	end
	spark:SetSize(CAST_SPARK_WIDTH, sparkHeight)
	local extra = (barHeight and barHeight > 0) and (sparkHeight - barHeight) or 0
	if barType == "empowered" then
		spark.offsetY = (extra > 0) and (extra * 0.5) or 0
	else
		spark.offsetY = 0
	end

	local minVal, maxVal = st.castBar:GetMinMaxValues()
	local value = st.castBar:GetValue()
	if value == nil or maxVal == nil then
		H.hideCastSpark(st)
		return
	end
	if issecretvalue and ((minVal and issecretvalue(minVal)) or issecretvalue(value) or issecretvalue(maxVal)) then
		H.hideCastSpark(st)
		return
	end
	local range = (maxVal or 0) - (minVal or 0)
	if not range or range == 0 then
		H.hideCastSpark(st)
		return
	end
	local width = st.castBar:GetWidth()
	if not width or width <= 0 then return end
	local progress = (value - (minVal or 0)) / range
	if progress < 0 then
		progress = 0
	elseif progress > 1 then
		progress = 1
	end
	local offset = width * progress
	spark:SetPoint("CENTER", st.castBar, "LEFT", offset, spark.offsetY or 0)
end

local After = C_Timer and C_Timer.After
local UnitEmpoweredChannelDuration = _G.UnitEmpoweredChannelDuration
local UnitEmpoweredStagePercentages = _G.UnitEmpoweredStagePercentages
local UnitEmpoweredStageDurations = _G.UnitEmpoweredStageDurations
local GetUnitEmpowerHoldAtMaxTime = _G.GetUnitEmpowerHoldAtMaxTime
local GetUnitEmpowerStageDuration = _G.GetUnitEmpowerStageDuration

local function normalizeStageValues(...)
	local count = select("#", ...)
	if count == 1 and type((...)) == "table" then
		local src = ...
		local values = {}
		for i = 1, #src do
			values[i] = src[i]
		end
		return values
	end
	local values = {}
	for i = 1, count do
		local val = select(i, ...)
		if val ~= nil then values[#values + 1] = val end
	end
	return values
end

local function normalizeDurationMilliseconds(value)
	if type(value) ~= "number" then return nil end
	if issecretvalue and issecretvalue(value) then return nil end
	if value < 0 then return nil end
	if value > 20 then return value end
	return value * 1000
end

H.normalizeDurationMilliseconds = normalizeDurationMilliseconds

local function durationToMilliseconds(duration)
	if type(duration) == "number" then return normalizeDurationMilliseconds(duration) end
	if type(duration) ~= "table" then return nil end
	if duration.GetMilliseconds then
		local ms = duration:GetMilliseconds()
		if issecretvalue and issecretvalue(ms) then return nil end
		return ms
	end
	if duration.GetSeconds then
		local seconds = duration:GetSeconds()
		if issecretvalue and issecretvalue(seconds) then return nil end
		return normalizeDurationMilliseconds(seconds)
	end
	if duration.GetDuration then
		local value = duration:GetDuration()
		if issecretvalue and issecretvalue(value) then return nil end
		if type(value) == "number" then return normalizeDurationMilliseconds(value) end
	end
	if duration.GetTime then
		local value = duration:GetTime()
		if issecretvalue and issecretvalue(value) then return nil end
		if type(value) == "number" then return normalizeDurationMilliseconds(value) end
	end
	return nil
end

H.getDurationMilliseconds = durationToMilliseconds

function H.getEmpoweredChannelDurationMilliseconds(unit)
	if not unit or not UnitEmpoweredChannelDuration then return nil end
	local duration = UnitEmpoweredChannelDuration(unit, true)
	return durationToMilliseconds(duration)
end

function H.getEmpowerHoldMilliseconds(unit)
	if not unit or not GetUnitEmpowerHoldAtMaxTime then return nil end
	local hold = GetUnitEmpowerHoldAtMaxTime(unit)
	return normalizeDurationMilliseconds(hold)
end

local function normalizeStagePercents(values, numStages)
	if type(values) ~= "table" then return nil end
	local cleaned = {}
	local scale = 1
	for i = 1, #values do
		local val = values[i]
		if type(val) == "number" and (not issecretvalue or not issecretvalue(val)) then
			if val > 1 then scale = 100 end
		end
	end
	local last = -1
	for i = 1, #values do
		local val = values[i]
		if type(val) == "number" and (not issecretvalue or not issecretvalue(val)) then
			if scale == 100 then val = val / 100 end
			if val < 0 then
				val = 0
			elseif val > 1 then
				val = 1
			end
			if val > last then
				cleaned[#cleaned + 1] = val
				last = val
			end
		end
		if numStages and #cleaned >= numStages then break end
	end
	if #cleaned == 0 then return nil end
	return cleaned
end

local function buildStagePercentsFromPercentages(values, numStages)
	if type(values) ~= "table" then return nil end
	local cleaned = {}
	local scale = 1
	local hasSecret = false
	for i = 1, #values do
		local val = values[i]
		if type(val) == "number" then
			if issecretvalue and issecretvalue(val) then
				hasSecret = true
			elseif val > 1 then
				scale = 100
			end
		end
	end
	if hasSecret then return nil end
	for i = 1, #values do
		local val = values[i]
		if type(val) == "number" then
			if scale == 100 then val = val / 100 end
			if val < 0 then
				val = 0
			elseif val > 1 then
				val = 1
			end
			cleaned[#cleaned + 1] = val
		end
	end
	if #cleaned == 0 then return nil end
	if numStages and #cleaned > numStages then
		while #cleaned > numStages do
			table.remove(cleaned)
		end
	end
	if numStages and #cleaned < numStages then return nil end
	if #cleaned == 0 then return nil end
	local sum = 0
	for i = 1, #cleaned do
		sum = sum + cleaned[i]
	end
	local assumeCumulative = #cleaned > 1 and sum > 1.02
	if assumeCumulative then return normalizeStagePercents(cleaned, numStages) end
	local cumulative = {}
	local running = 0
	for i = 1, #cleaned do
		running = running + cleaned[i]
		cumulative[i] = running
	end
	return normalizeStagePercents(cumulative, numStages)
end

local function buildEmpowerStagePercents(unit, numStages)
	if not numStages or numStages <= 0 then return nil end
	if UnitEmpoweredStagePercentages then
		local percents = normalizeStageValues(UnitEmpoweredStagePercentages(unit, true))
		percents = buildStagePercentsFromPercentages(percents, numStages)
		if percents then return percents end
	end
	local durations
	if UnitEmpoweredStageDurations then
		durations = normalizeStageValues(UnitEmpoweredStageDurations(unit))
	elseif GetUnitEmpowerStageDuration then
		durations = {}
		for i = 1, numStages do
			durations[i] = GetUnitEmpowerStageDuration(unit, i - 1)
		end
	end
	if type(durations) ~= "table" or #durations < numStages then return nil end
	local hold = GetUnitEmpowerHoldAtMaxTime and GetUnitEmpowerHoldAtMaxTime(unit)
	if hold ~= nil then
		hold = durationToMilliseconds(hold)
		if hold == nil then return nil end
	end
	local total = hold or 0
	local percents = {}
	local sum = 0
	for i = 1, numStages do
		local ms = durationToMilliseconds(durations[i])
		if not ms then return nil end
		sum = sum + ms
		total = total + ms
		percents[i] = sum
	end
	if not total or total <= 0 then return nil end
	for i = 1, numStages do
		percents[i] = percents[i] / total
	end
	return normalizeStagePercents(percents, numStages)
end

local function ensureEmpowerState(st)
	if not st or not st.castBar then return nil end
	if not st.castEmpower then st.castEmpower = { pips = {}, tiers = {} } end
	local emp = st.castEmpower
	if not emp.container then
		emp.container = CreateFrame("Frame", nil, st.castBar)
		emp.container:SetAllPoints(st.castBar)
		emp.container:EnableMouse(false)
	end
	if emp.container:GetParent() ~= st.castBar then emp.container:SetParent(st.castBar) end
	emp.container:SetFrameStrata(st.castBar:GetFrameStrata())
	local baseLevel = st.castBar:GetFrameLevel() or 0
	local layerOffset = H.shouldUseDefaultCastArt(st) and EMPOWER_PIP_LAYER_DEFAULT or EMPOWER_PIP_LAYER_CUSTOM
	emp.container:SetFrameLevel(baseLevel + layerOffset)
	return emp
end

local function addAlphaAnim(group, target, fromAlpha, toAlpha, duration, order, startDelay, smoothing)
	local anim = group:CreateAnimation("Alpha")
	anim:SetTarget(target)
	anim:SetFromAlpha(fromAlpha or 0)
	anim:SetToAlpha(toAlpha or 0)
	anim:SetDuration(duration or 0)
	anim:SetOrder(order or 1)
	if startDelay and startDelay > 0 then anim:SetStartDelay(startDelay) end
	if smoothing then anim:SetSmoothing(smoothing) end
	return anim
end

local function addTranslationAnim(group, target, offsetX, offsetY, duration, order, smoothing)
	local anim = group:CreateAnimation("Translation")
	anim:SetTarget(target)
	anim:SetOffset(offsetX or 0, offsetY or 0)
	anim:SetDuration(duration or 0)
	anim:SetOrder(order or 1)
	if smoothing then anim:SetSmoothing(smoothing) end
	return anim
end

local function addRotationAnim(group, target, degrees, duration, order, smoothing)
	local anim = group:CreateAnimation("Rotation")
	anim:SetTarget(target)
	anim:SetDegrees(degrees or 0)
	anim:SetDuration(duration or 0)
	anim:SetOrder(order or 1)
	if smoothing then anim:SetSmoothing(smoothing) end
	if anim.SetOrigin then anim:SetOrigin("CENTER", 0, 0) end
	return anim
end

local function ensureEmpowerPip(emp, index)
	local pip = emp.pips[index]
	if pip then return pip end
	pip = CreateFrame("Frame", nil, emp.container)
	pip:SetSize(7, 10)
	pip.BasePip = pip:CreateTexture(nil, "ARTWORK", nil, 2)
	if pip.BasePip.SetAtlas then pip.BasePip:SetAtlas("ui-castingbar-empower-pip", true) end
	pip.BasePip:SetPoint("CENTER")
	pip.PipGlow = pip:CreateTexture(nil, "OVERLAY", nil, 2)
	if pip.PipGlow.SetAtlas then pip.PipGlow:SetAtlas("cast-empowered-pipflare", true) end
	pip.PipGlow:SetAlpha(0)
	pip.PipGlow:SetPoint("CENTER")
	if pip.PipGlow.SetScale then pip.PipGlow:SetScale(0.5) end

	pip.FlakesBottom = pip:CreateTexture(nil, "OVERLAY", nil, 2)
	if pip.FlakesBottom.SetAtlas then pip.FlakesBottom:SetAtlas("Cast_Empowered_FlakesS01", true) end
	if pip.FlakesBottom.SetScale then pip.FlakesBottom:SetScale(0.5) end
	pip.FlakesBottom:SetAlpha(0)
	pip.FlakesBottom:SetPoint("CENTER")

	pip.FlakesTop = pip:CreateTexture(nil, "OVERLAY", nil, 2)
	if pip.FlakesTop.SetAtlas then pip.FlakesTop:SetAtlas("Cast_Empowered_FlakesS02", true) end
	if pip.FlakesTop.SetScale then pip.FlakesTop:SetScale(0.5) end
	pip.FlakesTop:SetAlpha(0)
	pip.FlakesTop:SetPoint("CENTER")

	pip.FlakesTop02 = pip:CreateTexture(nil, "OVERLAY", nil, 2)
	if pip.FlakesTop02.SetAtlas then pip.FlakesTop02:SetAtlas("Cast_Empowered_FlakesS03", true) end
	if pip.FlakesTop02.SetScale then pip.FlakesTop02:SetScale(0.5) end
	pip.FlakesTop02:SetAlpha(0)
	pip.FlakesTop02:SetPoint("CENTER")

	pip.FlakesBottom02 = pip:CreateTexture(nil, "OVERLAY", nil, 2)
	if pip.FlakesBottom02.SetAtlas then pip.FlakesBottom02:SetAtlas("Cast_Empowered_FlakesS03", true) end
	if pip.FlakesBottom02.SetScale then pip.FlakesBottom02:SetScale(0.5) end
	pip.FlakesBottom02:SetAlpha(0)
	pip.FlakesBottom02:SetPoint("CENTER")

	pip.StageAnim = pip:CreateAnimationGroup()
	pip.StageAnim:SetLooping("NONE")
	if pip.StageAnim.SetToFinalAlpha then pip.StageAnim:SetToFinalAlpha(true) end

	addAlphaAnim(pip.StageAnim, pip.PipGlow, 0, 0, 0, 1, nil, "NONE")
	addAlphaAnim(pip.StageAnim, pip.PipGlow, 0.1, 1, 0.1, 1, 0, "NONE")
	addAlphaAnim(pip.StageAnim, pip.PipGlow, 1, 0, 0.25, 1, 0.1, "OUT")

	addTranslationAnim(pip.StageAnim, pip.FlakesBottom, 0, -30, 0.3, 1, "OUT")
	addRotationAnim(pip.StageAnim, pip.FlakesBottom, 90, 0.3, 1, "OUT")
	addAlphaAnim(pip.StageAnim, pip.FlakesBottom, 0.1, 1, 0.1, 1, 0, "NONE")
	addAlphaAnim(pip.StageAnim, pip.FlakesBottom, 1, 0, 0.25, 1, 0.1, "NONE")

	addTranslationAnim(pip.StageAnim, pip.FlakesTop, 0, 30, 0.3, 1, "OUT")
	addRotationAnim(pip.StageAnim, pip.FlakesTop, -90, 0.3, 1, "OUT")
	addAlphaAnim(pip.StageAnim, pip.FlakesTop, 0.1, 1, 0.1, 1, 0, "NONE")
	addAlphaAnim(pip.StageAnim, pip.FlakesTop, 1, 0, 0.25, 1, 0.1, "NONE")

	addTranslationAnim(pip.StageAnim, pip.FlakesTop02, 0, 35, 0.3, 1, "IN")
	addRotationAnim(pip.StageAnim, pip.FlakesTop02, 60, 0.3, 1, "OUT")
	addAlphaAnim(pip.StageAnim, pip.FlakesTop02, 0.1, 1, 0.1, 1, 0, "NONE")
	addAlphaAnim(pip.StageAnim, pip.FlakesTop02, 1, 0, 0.25, 1, 0.1, "NONE")

	addTranslationAnim(pip.StageAnim, pip.FlakesBottom02, 0, -35, 0.3, 1, "IN")
	addRotationAnim(pip.StageAnim, pip.FlakesBottom02, -60, 0.3, 1, "OUT")
	addAlphaAnim(pip.StageAnim, pip.FlakesBottom02, 0.1, 1, 0.1, 1, 0, "NONE")
	addAlphaAnim(pip.StageAnim, pip.FlakesBottom02, 1, 0, 0.25, 1, 0.1, "NONE")

	emp.pips[index] = pip
	return pip
end

local function ensureEmpowerTier(emp, index)
	local tier = emp.tiers[index]
	if tier then return tier end
	tier = CreateFrame("Frame", nil, emp.container)
	tier.Normal = tier:CreateTexture(nil, "BACKGROUND", nil, 4)
	tier.Disabled = tier:CreateTexture(nil, "BACKGROUND", nil, 4)
	tier.Glow = tier:CreateTexture(nil, "BACKGROUND", nil, 5)
	tier.Normal:SetAllPoints(tier)
	tier.Disabled:SetAllPoints(tier)
	tier.Glow:SetAllPoints(tier)
	if tier.Glow.SetBlendMode then tier.Glow:SetBlendMode("ADD") end
	tier.Glow:SetAlpha(0)
	tier.GlowAnim = tier.Glow:CreateAnimationGroup()
	local fadeIn = tier.GlowAnim:CreateAnimation("Alpha")
	fadeIn:SetFromAlpha(0)
	fadeIn:SetToAlpha(1)
	fadeIn:SetDuration(0.1)
	fadeIn:SetOrder(1)
	local fadeOut = tier.GlowAnim:CreateAnimation("Alpha")
	fadeOut:SetFromAlpha(1)
	fadeOut:SetToAlpha(0)
	fadeOut:SetDuration(0.5)
	fadeOut:SetOrder(2)
	emp.tiers[index] = tier
	return tier
end

function H.clearEmpowerStages(st)
	local emp = st and st.castEmpower
	if not emp then return end
	emp.stagePercents = nil
	emp.stageCount = 0
	emp.currentStage = 0
	emp.layoutToken = (emp.layoutToken or 0) + 1
	if emp.container then emp.container:Hide() end
	for _, pip in pairs(emp.pips) do
		if pip.StageAnim then pip.StageAnim:Stop() end
		if pip.PipGlow then pip.PipGlow:SetAlpha(0) end
		if pip.FlakesBottom then pip.FlakesBottom:SetAlpha(0) end
		if pip.FlakesTop then pip.FlakesTop:SetAlpha(0) end
		if pip.FlakesTop02 then pip.FlakesTop02:SetAlpha(0) end
		if pip.FlakesBottom02 then pip.FlakesBottom02:SetAlpha(0) end
		pip:Hide()
	end
	for _, tier in pairs(emp.tiers) do
		if tier.GlowAnim then tier.GlowAnim:Stop() end
		tier:Hide()
	end
end

function H.layoutEmpowerStages(st)
	local emp = st and st.castEmpower
	if not st or not st.castBar or not emp or not emp.stagePercents then return end
	local baseLevel = st.castBar:GetFrameLevel() or 0
	local layerOffset = H.shouldUseDefaultCastArt(st) and EMPOWER_PIP_LAYER_DEFAULT or EMPOWER_PIP_LAYER_CUSTOM
	if emp.container then emp.container:SetFrameLevel(baseLevel + layerOffset) end
	local barLeft = st.castBar:GetLeft()
	local barRight = st.castBar:GetRight()
	if not barLeft or not barRight then
		emp.layoutToken = (emp.layoutToken or 0) + 1
		local token = emp.layoutToken
		RunNextFrame(function()
			local st2 = st
			local emp2 = st2 and st2.castEmpower
			if emp2 and emp2.layoutToken == token then H.layoutEmpowerStages(st2) end
		end)
		return
	end
	local barWidth = barRight - barLeft
	if not barWidth or barWidth <= 0 then return end
	local count = emp.stageCount or #emp.stagePercents
	if count <= 0 then return end
	local showTiers = H.shouldUseDefaultCastArt(st)
	local container = emp.container or ensureEmpowerState(st).container
	if container then container:Show() end
	local barHeight = st.castBar:GetHeight() or 0

	for i = 1, count do
		local percent = emp.stagePercents[i]
		local pip = ensureEmpowerPip(emp, i)
		pip:ClearAllPoints()
		local offset = barWidth * percent
		if showTiers then
			pip:SetSize(7, 10)
			pip:SetPoint("TOP", st.castBar, "TOPLEFT", offset, -1)
			pip:SetPoint("BOTTOM", st.castBar, "BOTTOMLEFT", offset, 1)
			if pip.BasePip then
				if pip.BasePip.SetAtlas then pip.BasePip:SetAtlas("ui-castingbar-empower-pip", true) end
				if pip.BasePip.SetDrawLayer then pip.BasePip:SetDrawLayer("ARTWORK", 2) end
				if pip.BasePip.SetVertexColor then pip.BasePip:SetVertexColor(1, 1, 1, 1) end
				pip.BasePip:ClearAllPoints()
				pip.BasePip:SetPoint("CENTER")
			end
			setPipFxLayer(pip, "OVERLAY", 2)
		else
			local lineWidth, lineHeight = getCustomPipDimensions(barHeight)
			pip:SetHeight(lineHeight)
			pip:SetWidth(lineWidth)
			pip:SetPoint("TOP", st.castBar, "TOPLEFT", offset, 0)
			pip:SetPoint("BOTTOM", st.castBar, "BOTTOMLEFT", offset, 0)
			if pip.BasePip then
				if pip.BasePip.SetDrawLayer then pip.BasePip:SetDrawLayer("OVERLAY", EMPOWER_CUSTOM_PIP_LINE_SUBLEVEL) end
				pip.BasePip:ClearAllPoints()
				pip.BasePip:SetAllPoints(pip)
				if pip.BasePip.SetColorTexture then
					pip.BasePip:SetColorTexture(1, 1, 1, EMPOWER_CUSTOM_PIP_ALPHA)
				else
					pip.BasePip:SetTexture("Interface\\Buttons\\WHITE8x8")
					if pip.BasePip.SetVertexColor then pip.BasePip:SetVertexColor(1, 1, 1, EMPOWER_CUSTOM_PIP_ALPHA) end
				end
			end
			setPipFxLayer(pip, "OVERLAY", EMPOWER_CUSTOM_PIP_FX_SUBLEVEL)
		end
		if pip.BasePip then pip.BasePip:Show() end
		pip:Show()
	end
	for i = count + 1, #emp.pips do
		emp.pips[i]:Hide()
	end

	if showTiers then
		for i = 1, count do
			local tier = ensureEmpowerTier(emp, i)
			tier:ClearAllPoints()
			local leftStagePip = emp.pips[i]
			local rightStagePip = emp.pips[i + 1]
			if leftStagePip then tier:SetPoint("TOPLEFT", leftStagePip, "TOP", 0, 0) end
			if rightStagePip then
				tier:SetPoint("BOTTOMRIGHT", rightStagePip, "BOTTOM", 0, 0)
			else
				tier:SetPoint("BOTTOMRIGHT", st.castBar, "BOTTOMRIGHT", 0, 1)
			end

			local tierLeft = tier:GetLeft()
			local tierRight = tier:GetRight()
			if tierLeft and tierRight then
				local texLeft = (tierLeft - barLeft) / barWidth
				local texRight = 1.0 - ((barRight - tierRight) / barWidth)
				tier.Normal:SetTexCoord(texLeft, texRight, 0, 1)
				tier.Disabled:SetTexCoord(texLeft, texRight, 0, 1)
				tier.Glow:SetTexCoord(texLeft, texRight, 0, 1)
			end

			if tier.Normal.SetAtlas then tier.Normal:SetAtlas(("ui-castingbar-tier%d-empower"):format(i), true) end
			if tier.Disabled.SetAtlas then tier.Disabled:SetAtlas(("ui-castingbar-disabled-tier%d-empower"):format(i), true) end
			if tier.Glow.SetAtlas then tier.Glow:SetAtlas(("ui-castingbar-glow-tier%d-empower"):format(i), true) end

			tier.Normal:SetShown(false)
			tier.Disabled:SetShown(true)
			tier.Glow:SetAlpha(0)
			tier:Show()
		end
		for i = count + 1, #emp.tiers do
			emp.tiers[i]:Hide()
		end
	else
		for _, tier in pairs(emp.tiers) do
			tier:Hide()
		end
	end
end

function H.updateEmpowerStageFromProgress(st, progress)
	local emp = st and st.castEmpower
	if not emp or not emp.stagePercents or not emp.stageCount then return end
	if issecretvalue and issecretvalue(progress) then return end
	local maxStage = 0
	for i = 1, emp.stageCount do
		local pct = emp.stagePercents[i]
		if type(pct) == "number" and progress >= pct then
			maxStage = i
		else
			break
		end
	end
	if maxStage <= emp.currentStage then return end
	local showTiers = H.shouldUseDefaultCastArt(st)
	for i = emp.currentStage + 1, maxStage do
		local pip = emp.pips[i]
		if pip and pip.StageAnim then
			pip.StageAnim:Stop()
			pip.StageAnim:Play()
		elseif pip and pip.PipGlowAnim then
			pip.PipGlowAnim:Stop()
			pip.PipGlowAnim:Play()
		end
		local tier = emp.tiers[i]
		if showTiers and tier then
			tier.Normal:SetShown(true)
			tier.Disabled:SetShown(false)
			if tier.GlowAnim then
				tier.GlowAnim:Stop()
				tier.GlowAnim:Play()
			end
		end
	end
	emp.currentStage = maxStage
end

function H.updateEmpowerStageFromBar(st)
	if not st or not st.castBar then return end
	local emp = st.castEmpower
	if not emp or not emp.stagePercents then return end
	local _, maxVal = st.castBar:GetMinMaxValues()
	local value = st.castBar:GetValue()
	if not maxVal or maxVal == 0 or value == nil then return end
	if issecretvalue and (issecretvalue(value) or issecretvalue(maxVal)) then return end
	H.updateEmpowerStageFromProgress(st, value / maxVal)
end

function H.setupEmpowerStages(st, unit, numStages)
	H.clearEmpowerStages(st)
	if not st or not unit or not numStages or numStages <= 0 then return end
	local percents = buildEmpowerStagePercents(unit, numStages)
	if not percents then return end
	local emp = ensureEmpowerState(st)
	if not emp then return end
	emp.stagePercents = percents
	emp.stageCount = #percents
	emp.currentStage = 0
	H.layoutEmpowerStages(st)
end

local WrapString = _G.C_StringUtil and _G.C_StringUtil.WrapString
local TruncateWhenZero = _G.C_StringUtil and _G.C_StringUtil.TruncateWhenZero

function H.formatCastName(nameText, castTarget, showTarget)
	if not showTarget or type(castTarget) == "nil" then return nameText end
	if WrapString then return WrapString(castTarget, nameText .. " --> ") or nameText end
	if not castTarget or castTarget == "" then return nameText end
	return nameText .. " --> " .. castTarget
end

function H.resolveTextDelimiter(delimiter)
	if delimiter == nil or delimiter == "" then delimiter = " " end
	delimiter = tostring(delimiter)
	if delimiter:find("%s") then return delimiter end
	return " " .. delimiter .. " "
end

function H.resolveTextDelimiters(primary, secondary, tertiary)
	local primaryResolved = H.resolveTextDelimiter(primary)
	if secondary == nil or secondary == "" then secondary = primary end
	local secondaryResolved = H.resolveTextDelimiter(secondary)
	if tertiary == nil or tertiary == "" then tertiary = secondary end
	local tertiaryResolved = H.resolveTextDelimiter(tertiary)
	return primaryResolved, secondaryResolved, tertiaryResolved
end

local function join2(a, b, sep) return a .. sep .. b end

local function join3(a, b, c, sep1, sep2) return a .. sep1 .. b .. sep2 .. c end

local function join4(a, b, c, d, sep1, sep2, sep3) return a .. sep1 .. b .. sep2 .. c .. sep3 .. d end

function H.formatDisplayValue(value, useShort)
	if addon.variables and addon.variables.isMidnight and issecretvalue and value ~= nil and issecretvalue(value) then
		if useShort == false then return BreakUpLargeNumbers(value) end
		return AbbreviateNumbers and AbbreviateNumbers(value) or H.shortValue(value)
	end
	if useShort == false then return tostring(value or 0) end
	return H.shortValue(value or 0)
end

function H.formatOptionalDisplayValue(value, useShort)
	if value == nil then return "" end
	return H.formatDisplayValue(value, useShort)
end

function H.formatAbsorbModeText(mode, curText, absorbText, absorbValue)
	if mode == "ABSORB" then return absorbText end
	if WrapString then
		if mode == "CURABSORB" then return curText .. (WrapString(absorbText, " (", ")") or "") end
		if mode == "CURABSORBPIPE" then return curText .. (WrapString(absorbText, " | ", "") or "") end
		if mode == "CURABSORBPLUS" then return curText .. (WrapString(absorbText, " + ", "") or "") end
	end
	if absorbText == nil or absorbText == "" then return curText end
	if mode == "CURABSORB" then return string.format("%s (%s)", curText, absorbText) end
	if mode == "CURABSORBPIPE" then return join2(curText, absorbText, " | ") end
	if mode == "CURABSORBPLUS" then return join2(curText, absorbText, " + ") end
	return curText
end

local function formatPercentModeText(mode, curText, maxText, percentText, levelText, joinPrimary, joinSecondary, joinTertiary)
	if not percentText then return "" end
	if mode == "PERCENT" then return percentText end
	if mode == "CURPERCENT" or mode == "CURPERCENTDASH" then return join2(curText, percentText, joinPrimary) end
	if mode == "CURMAXPERCENT" then return join3(curText, maxText, percentText, joinPrimary, joinSecondary) end
	if mode == "MAXPERCENT" then return join2(maxText, percentText, joinPrimary) end
	if mode == "PERCENTMAX" then return join2(percentText, maxText, joinPrimary) end
	if mode == "PERCENTCUR" then return join2(percentText, curText, joinPrimary) end
	if mode == "PERCENTCURMAX" then return join3(percentText, curText, maxText, joinPrimary, joinSecondary) end
	if mode == "LEVELPERCENT" then return join2(levelText, percentText, joinPrimary) end
	if mode == "LEVELPERCENTMAX" then return join3(levelText, percentText, maxText, joinPrimary, joinSecondary) end
	if mode == "LEVELPERCENTCUR" then return join3(levelText, percentText, curText, joinPrimary, joinSecondary) end
	if mode == "LEVELPERCENTCURMAX" then return join4(levelText, percentText, curText, maxText, joinPrimary, joinSecondary, joinTertiary) end
	return ""
end

function H.shortValue(val)
	if val == nil then return "" end
	return AbbreviateNumbers(val)
end

function H.textModeUsesLevel(mode) return type(mode) == "string" and mode:find("LEVEL", 1, true) ~= nil end
function H.textModeUsesPercent(mode) return type(mode) == "string" and mode:find("PERCENT", 1, true) ~= nil end
function H.textModeUsesAbsorb(mode) return mode == "ABSORB" or mode == "CURABSORB" or mode == "CURABSORBPIPE" or mode == "CURABSORBPLUS" end
function H.textModeUsesDeficit(mode) return mode == "DEFICIT" end

function H.getUnitLevelText(unit, levelOverride, hideClassificationText)
	if not unit then return "??" end
	local rawLevel = tonumber(levelOverride) or UnitLevel(unit) or 0
	local levelText = rawLevel > 0 and tostring(rawLevel) or "??"
	local classification = UnitClassification and UnitClassification(unit)
	if classification == "worldboss" then
		levelText = "??"
	elseif classification == "elite" then
		if not hideClassificationText then levelText = levelText .. "+" end
	elseif classification == "rareelite" then
		if not hideClassificationText then levelText = levelText .. " R+" end
	elseif classification == "rare" then
		if not hideClassificationText then levelText = levelText .. " R" end
	elseif classification == "trivial" or classification == "minus" then
		levelText = levelText .. "-"
	end
	return levelText
end

function H.formatText(mode, cur, maxv, useShort, percentValue, delimiter, delimiter2, delimiter3, hidePercentSymbol, levelText, missingValue, roundPercent, delimitersResolved, absorbValue)
	if mode == "NONE" then return "" end
	if mode == "CURRENT" then return H.formatDisplayValue(cur, useShort) end
	if mode == "MAX" then return H.formatDisplayValue(maxv, useShort) end
	if mode == "PERCENT" then
		local percentSuffix = hidePercentSymbol and "" or "%"
		local hasSecretValues = addon.variables and addon.variables.isMidnight and issecretvalue
			and (issecretvalue(cur) or issecretvalue(maxv) or issecretvalue(percentValue))
		if hasSecretValues then
			local secretPercent = issecretvalue(percentValue)
			if not secretPercent and percentValue == nil then return "" end
			if roundPercent then return ("%s%s"):format(tostring(C_StringUtil.RoundToNearestString(percentValue)), percentSuffix) end
			return ("%s%s"):format(tostring(AbbreviateLargeNumbers(percentValue)), percentSuffix)
		end
		if percentValue ~= nil then return ("%d%s"):format(floor(percentValue + 0.5), percentSuffix) end
		if not maxv or maxv == 0 then return "0" .. percentSuffix end
		return ("%d%s"):format(floor((cur or 0) / maxv * 100 + 0.5), percentSuffix)
	end
	if mode == "CURMAX" then
		local joinPrimary
		if delimitersResolved then
			joinPrimary = delimiter
		elseif H.resolveTextDelimiters then
			joinPrimary = H.resolveTextDelimiters(delimiter, delimiter2, delimiter3)
		else
			joinPrimary = delimiter
		end
		if joinPrimary == nil then joinPrimary = " " end
		return join2(H.formatDisplayValue(cur, useShort), H.formatDisplayValue(maxv, useShort), joinPrimary)
	end
	local joinPrimary, joinSecondary, joinTertiary
	if delimitersResolved then
		joinPrimary = delimiter
		joinSecondary = delimiter2
		joinTertiary = delimiter3
	else
		joinPrimary, joinSecondary, joinTertiary = H.resolveTextDelimiters(delimiter, delimiter2, delimiter3)
	end
	if joinPrimary == nil then joinPrimary = " " end
	if joinSecondary == nil then joinSecondary = joinPrimary end
	if joinTertiary == nil then joinTertiary = joinSecondary end
	local percentSuffix = hidePercentSymbol and "" or "%"
	if levelText == nil or levelText == "" then levelText = "??" end
	local isPercentMode = type(mode) == "string" and mode:find("PERCENT", 1, true) ~= nil
	if H.textModeUsesAbsorb(mode) then
		local curText = H.formatDisplayValue(cur, useShort)
		local absorbText = H.formatOptionalDisplayValue(absorbValue, useShort)
		return H.formatAbsorbModeText(mode, curText, absorbText, absorbValue)
	end
	if mode == "DEFICIT" then
		if issecretvalue and issecretvalue(missingValue) then
			local infix = useShort and H.shortValue(missingValue) or BreakUpLargeNumbers(missingValue)
			return "-" .. infix
		end
		if missingValue == nil then return "" end
		local missNum = tonumber(missingValue) or 0
		if missNum <= 0 then return "" end
		local infix = useShort == false and tostring(missNum) or (AbbreviateNumbers and AbbreviateNumbers(missNum) or H.shortValue(missNum))
		return "-" .. infix
	end
	if mode == "LEVEL" then return levelText end
	if addon.variables and addon.variables.isMidnight and issecretvalue then
		if (cur and issecretvalue(cur)) or (maxv and issecretvalue(maxv)) then
			local percentText
			if percentValue ~= nil then
				if roundPercent then
					percentText = ("%s%s"):format(tostring(C_StringUtil.RoundToNearestString(percentValue)), percentSuffix)
				else
					percentText = ("%s%s"):format(tostring(AbbreviateLargeNumbers(percentValue)), percentSuffix)
				end
			end

			if mode == "PERCENT" then return percentText or "" end
			if mode == "CURRENT" then
				local scur
				if useShort then scur = H.shortValue(cur) else scur = BreakUpLargeNumbers(cur) end
				return tostring(scur)
			end
			if mode == "MAX" then
				local smax
				if useShort then smax = H.shortValue(maxv) else smax = BreakUpLargeNumbers(maxv) end
				return tostring(smax)
			end
			if mode == "CURMAX" then
				local scur, smax
				if useShort then
					scur, smax = H.shortValue(cur), H.shortValue(maxv)
				else
					scur, smax = BreakUpLargeNumbers(cur), BreakUpLargeNumbers(maxv)
				end
				return join2(tostring(scur), tostring(smax), joinPrimary)
			end
			if isPercentMode then
				local needsCur = mode == "CURPERCENT"
					or mode == "CURPERCENTDASH"
					or mode == "CURMAXPERCENT"
					or mode == "PERCENTCUR"
					or mode == "PERCENTCURMAX"
					or mode == "LEVELPERCENTCUR"
					or mode == "LEVELPERCENTCURMAX"
				local needsMax = mode == "CURMAXPERCENT"
					or mode == "MAXPERCENT"
					or mode == "PERCENTMAX"
					or mode == "PERCENTCURMAX"
					or mode == "LEVELPERCENTMAX"
					or mode == "LEVELPERCENTCURMAX"
				local scur, smax = "", ""
				if needsCur then
					if useShort then scur = H.shortValue(cur) else scur = BreakUpLargeNumbers(cur) end
				end
				if needsMax then
					if useShort then smax = H.shortValue(maxv) else smax = BreakUpLargeNumbers(maxv) end
				end
				return formatPercentModeText(mode, tostring(scur), tostring(smax), percentText, levelText, joinPrimary, joinSecondary, joinTertiary)
			end
			return ""
		end
	end
	local percentText
	if isPercentMode then
		if percentValue ~= nil then
			percentText = ("%d%s"):format(floor(percentValue + 0.5), percentSuffix)
		elseif not maxv or maxv == 0 then
			percentText = "0" .. percentSuffix
		else
			percentText = ("%d%s"):format(floor((cur or 0) / maxv * 100 + 0.5), percentSuffix)
		end
	end
	if mode == "PERCENT" then return percentText or "" end
	if mode == "MAX" then
		local maxText = useShort == false and tostring(maxv or 0) or H.shortValue(maxv or 0)
		return maxText
	end
	if mode == "CURMAX" then
		local curText = useShort == false and tostring(cur or 0) or H.shortValue(cur or 0)
		local maxText = useShort == false and tostring(maxv or 0) or H.shortValue(maxv or 0)
		return join2(curText, maxText, joinPrimary)
	end
	if isPercentMode then
		local needsCur = mode == "CURPERCENT"
			or mode == "CURPERCENTDASH"
			or mode == "CURMAXPERCENT"
			or mode == "PERCENTCUR"
			or mode == "PERCENTCURMAX"
			or mode == "LEVELPERCENTCUR"
			or mode == "LEVELPERCENTCURMAX"
		local needsMax = mode == "CURMAXPERCENT"
			or mode == "MAXPERCENT"
			or mode == "PERCENTMAX"
			or mode == "PERCENTCURMAX"
			or mode == "LEVELPERCENTMAX"
			or mode == "LEVELPERCENTCURMAX"
		local curText = needsCur and (useShort == false and tostring(cur or 0) or H.shortValue(cur or 0)) or ""
		local maxText = needsMax and (useShort == false and tostring(maxv or 0) or H.shortValue(maxv or 0)) or ""
		return formatPercentModeText(mode, curText, maxText, percentText, levelText, joinPrimary, joinSecondary, joinTertiary)
	end
	if useShort == false then return tostring(cur or 0) end
	return H.shortValue(cur or 0)
end

H.emptyTextWriter = H.emptyTextWriter or function() return "" end

function H.compileTextWriter(mode, useShort, delimiter, delimiter2, delimiter3, hidePercentSymbol, roundPercent)
	if mode == "NONE" then return H.emptyTextWriter end
	if mode == "CURRENT" then return function(cur) return H.formatDisplayValue(cur, useShort) end end
	if mode == "MAX" then return function(_, maxv) return H.formatDisplayValue(maxv, useShort) end end
	if mode == "CURMAX" then
		local joinPrimary = delimiter or " "
		return function(cur, maxv) return join2(H.formatDisplayValue(cur, useShort), H.formatDisplayValue(maxv, useShort), joinPrimary) end
	end
	if mode == "PERCENT" then
		local suffix = hidePercentSymbol and "" or "%"
		return function(cur, maxv, percentValue)
			local hasSecretValues = addon.variables and addon.variables.isMidnight and issecretvalue
				and (issecretvalue(cur) or issecretvalue(maxv) or issecretvalue(percentValue))
			if hasSecretValues then
				if not issecretvalue(percentValue) and percentValue == nil then return "" end
				if roundPercent then return ("%s%s"):format(tostring(C_StringUtil.RoundToNearestString(percentValue)), suffix) end
				return ("%s%s"):format(tostring(AbbreviateLargeNumbers(percentValue)), suffix)
			end
			if percentValue ~= nil then return ("%d%s"):format(floor(percentValue + 0.5), suffix) end
			if not maxv or maxv == 0 then return "0" .. suffix end
			return ("%d%s"):format(floor((cur or 0) / maxv * 100 + 0.5), suffix)
		end
	end
	return function(cur, maxv, percentValue, levelText, missingValue, absorbValue)
		return H.formatText(
			mode,
			cur,
			maxv,
			useShort,
			percentValue,
			delimiter,
			delimiter2,
			delimiter3,
			hidePercentSymbol,
			levelText,
			missingValue,
			roundPercent,
			true,
			absorbValue
		)
	end
end

function H.getNameLimitWidth(fontPath, fontSize, fontOutline, maxChars)
	if not maxChars or maxChars <= 0 then return nil end
	local font = H.getFont(fontPath)
	local size = fontSize or 14
	local outline = normalizeFontOutline(fontOutline)
	local key = tostring(font) .. "|" .. tostring(size) .. "|" .. tostring(outline or "") .. "|" .. tostring(maxChars)
	if nameWidthCache[key] then return nameWidthCache[key] end
	if not nameWidthCache._measure and UIParent and UIParent.CreateFontString then
		nameWidthCache._measure = UIParent:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
		if nameWidthCache._measure then nameWidthCache._measure:Hide() end
	end
	local measure = nameWidthCache._measure
	if not measure then return nil end
	local ok = H.setFontWithFallback(measure, font, size, outline)
	if not ok then
		local fallback = H.getFont(nil)
		H.setFontWithFallback(measure, fallback, size, outline)
	end
	measure:SetText(string.rep("i", maxChars))
	local width = measure:GetStringWidth() or 0
	nameWidthCache[key] = width
	return width
end

function H.applyNameCharLimit(st, scfg, defStatus)
	if not st or not st.nameText then return end
	local maxChars = scfg and scfg.nameMaxChars
	if maxChars == nil and defStatus then maxChars = defStatus.nameMaxChars end
	maxChars = tonumber(maxChars) or 0
	if maxChars <= 0 then
		if st.nameText.SetMaxLines then st.nameText:SetMaxLines(1) end
		if st.nameText.SetWordWrap then st.nameText:SetWordWrap(false) end
		if st._eqolNameTextWidth ~= 0 then
			st.nameText:SetWidth(0)
			st._eqolNameTextWidth = 0
		end
		return
	end
	if st.nameText.SetMaxLines then st.nameText:SetMaxLines(1) end
	if st.nameText.SetWordWrap then st.nameText:SetWordWrap(false) end
	local width = H.getNameLimitWidth(scfg and scfg.font, scfg and scfg.fontSize or 14, scfg and scfg.fontOutline or "OUTLINE", maxChars)
	if width and width > 0 then
		local snappedWidth = width
		local scale = (st.nameText.GetEffectiveScale and st.nameText:GetEffectiveScale()) or (UIParent and UIParent.GetEffectiveScale and UIParent:GetEffectiveScale()) or 1
		if _G.PixelUtil and _G.PixelUtil.GetNearestPixelSize then
			-- Centered name regions blur easily on odd pixel widths; keep the width on an even pixel count.
			snappedWidth = _G.PixelUtil.GetNearestPixelSize(width * 0.5, scale, 1) * 2
		end
		if st._eqolNameTextWidth ~= snappedWidth then
			st.nameText:SetWidth(snappedWidth)
			st._eqolNameTextWidth = snappedWidth
		end
	end
end

function H.truncateTextToWidth(fontPath, fontSize, fontOutline, text, maxWidth)
	if not text or text == "" or maxWidth <= 0 then return text or "" end
	if not nameWidthCache._measure and UIParent and UIParent.CreateFontString then
		nameWidthCache._measure = UIParent:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
		if nameWidthCache._measure then nameWidthCache._measure:Hide() end
	end
	local measure = nameWidthCache._measure
	if not measure then return text end
	local size = fontSize or 14
	local outline = normalizeFontOutline(fontOutline)
	local ok = H.setFontWithFallback(measure, H.getFont(fontPath), size, outline)
	if ok == false then H.setFontWithFallback(measure, H.getFont(nil), size, outline) end
	measure:SetText(text)
	if measure:GetStringWidth() <= maxWidth then return text end
	local length = utf8Len(text)
	local low, high = 1, length
	local best = ""
	while low <= high do
		local mid = math.floor((low + high) / 2)
		local candidate = utf8Sub(text, 1, mid)
		measure:SetText(candidate)
		if measure:GetStringWidth() <= maxWidth then
			best = candidate
			low = mid + 1
		else
			high = mid - 1
		end
	end
	return best
end

function H.getTextDelimiter(cfg, def)
	local defaultDelim = (def and def.textDelimiter) or " "
	local delimiter = cfg and cfg.textDelimiter
	if delimiter == nil or delimiter == "" then delimiter = defaultDelim end
	return delimiter
end

function H.getTextDelimiterSecondary(cfg, def, primary)
	local delimiter = cfg and cfg.textDelimiterSecondary
	if delimiter == nil or delimiter == "" then delimiter = def and def.textDelimiterSecondary end
	if delimiter == nil or delimiter == "" then delimiter = primary end
	return delimiter
end

function H.getTextDelimiterTertiary(cfg, def, primary, secondary)
	local delimiter = cfg and cfg.textDelimiterTertiary
	if delimiter == nil or delimiter == "" then delimiter = def and def.textDelimiterTertiary end
	if delimiter == nil or delimiter == "" then delimiter = secondary or primary end
	return delimiter
end

local function resolvePvPAtlas(unit)
	if C_GameRules and C_GameRules.IsGameRuleActive and Enum and Enum.GameRule then
		if C_GameRules.IsGameRuleActive(Enum.GameRule.UnitFramePvPContextualDisabled) then return nil end
	end
	if UnitIsPVPFreeForAll and UnitIsPVPFreeForAll(unit) then return "UI-HUD-UnitFrame-Player-PVP-FFAIcon" end
	local factionGroup = UnitFactionGroup and UnitFactionGroup(unit)
	if factionGroup and factionGroup ~= "Neutral" and UnitIsPVP and UnitIsPVP(unit) then
		if UnitIsMercenary and UnitIsMercenary(unit) then
			if factionGroup == "Horde" then
				factionGroup = "Alliance"
			elseif factionGroup == "Alliance" then
				factionGroup = "Horde"
			end
		end
		if factionGroup == "Horde" then
			return "UI-HUD-UnitFrame-Player-PVP-HordeIcon"
		elseif factionGroup == "Alliance" then
			return "UI-HUD-UnitFrame-Player-PVP-AllianceIcon"
		end
	end
	return nil
end

local function resolveRoleAtlas(role)
	if not role or role == "NONE" then return nil end
	if GetIconForRole then return GetIconForRole(role, false) end
	if role == "TANK" then return "UI-LFG-RoleIcon-Tank" end
	if role == "HEALER" then return "UI-LFG-RoleIcon-Healer" end
	if role == "DAMAGER" then return "UI-LFG-RoleIcon-DPS" end
	return nil
end

local function resolveClassificationAtlas(classification)
	if classification == "elite" or classification == "worldboss" then
		return "nameplates-icon-elite-gold"
	elseif classification == "rare" then
		return "UI-HUD-UnitFrame-Target-PortraitOn-Boss-Rare-Star"
	elseif classification == "rareelite" then
		return "nameplates-icon-elite-silver"
	end
	return nil
end

function H.updatePvPIndicator(st, unit, cfg, def, skipDisabled)
	if unit ~= "player" and unit ~= "target" and unit ~= "focus" then return end
	if not st or not st.pvpIcon then return end
	def = def or {}
	local pcfg = (cfg and cfg.pvpIndicator) or (def and def.pvpIndicator) or {}
	local enabled = pcfg.enabled == true and not (cfg and cfg.enabled == false)
	if not enabled and skipDisabled then return end

	local offsetDef = def and def.pvpIndicator and def.pvpIndicator.offset or {}
	local sizeDef = def and def.pvpIndicator and def.pvpIndicator.size or 20
	local size = H.clamp(pcfg.size or sizeDef or 20, 10, 40)
	local ox = (pcfg.offset and pcfg.offset.x) or offsetDef.x or 0
	local oy = (pcfg.offset and pcfg.offset.y) or offsetDef.y or -2
	local centerOffset = (st and st._portraitCenterOffset) or 0
	st.pvpIcon:ClearAllPoints()
	st.pvpIcon:SetSize(size, size)
	st.pvpIcon:SetPoint("TOP", st.frame, "TOP", (ox or 0) + centerOffset, oy)

	if not enabled then
		st.pvpIcon:Hide()
		return
	end

	local inEditMode = addon.EditModeLib and addon.EditModeLib:IsInEditMode()
	local atlas = resolvePvPAtlas(unit)
	if not atlas and inEditMode then
		local sampleFaction = UnitFactionGroup and UnitFactionGroup("player")
		if sampleFaction == "Horde" then
			atlas = "UI-HUD-UnitFrame-Player-PVP-HordeIcon"
		else
			atlas = "UI-HUD-UnitFrame-Player-PVP-AllianceIcon"
		end
	end
	if atlas then
		st.pvpIcon:SetAtlas(atlas)
		st.pvpIcon:Show()
	else
		st.pvpIcon:Hide()
	end
end

function H.updateRoleIndicator(st, unit, cfg, def, skipDisabled)
	if unit ~= "player" and unit ~= "target" and unit ~= "focus" then return end
	if not st or not st.roleIcon then return end
	def = def or {}
	local rcfg = (cfg and cfg.roleIndicator) or (def and def.roleIndicator) or {}
	local enabled = rcfg.enabled == true and not (cfg and cfg.enabled == false)
	if not enabled and skipDisabled then return end

	local offsetDef = def and def.roleIndicator and def.roleIndicator.offset or {}
	local sizeDef = def and def.roleIndicator and def.roleIndicator.size or 18
	local size = H.clamp(rcfg.size or sizeDef or 18, 10, 40)
	local ox = (rcfg.offset and rcfg.offset.x) or offsetDef.x or 0
	local oy = (rcfg.offset and rcfg.offset.y) or offsetDef.y or -2
	local centerOffset = (st and st._portraitCenterOffset) or 0
	st.roleIcon:ClearAllPoints()
	st.roleIcon:SetSize(size, size)
	st.roleIcon:SetPoint("TOP", st.frame, "TOP", (ox or 0) + centerOffset, oy)

	if not enabled then
		st.roleIcon:Hide()
		return
	end

	local inEditMode = addon.EditModeLib and addon.EditModeLib:IsInEditMode()
	local inGroup = IsInGroup and IsInGroup() or false
	local role = inGroup and UnitGroupRolesAssigned and UnitGroupRolesAssigned(unit) or nil
	if role == "NONE" then role = nil end
	if not role and inEditMode then role = "DAMAGER" end

	local atlas = resolveRoleAtlas(role)
	if atlas then
		st.roleIcon:SetAtlas(atlas)
		st.roleIcon:Show()
	else
		st.roleIcon:Hide()
	end
end

function H.updateLeaderIndicator(st, unit, cfg, def, skipDisabled)
	if unit ~= "player" and unit ~= "target" and unit ~= "focus" then return end
	if not st or not st.leaderIcon then return end
	def = def or {}
	local lcfg = (cfg and cfg.leaderIcon) or (def and def.leaderIcon) or {}
	local enabled = lcfg.enabled == true and not (cfg and cfg.enabled == false)
	if not enabled and skipDisabled then return end

	local offsetDef = def and def.leaderIcon and def.leaderIcon.offset or {}
	local sizeDef = def and def.leaderIcon and def.leaderIcon.size or 12
	local size = H.clamp(lcfg.size or sizeDef or 12, 8, 40)
	local ox = (lcfg.offset and lcfg.offset.x) or offsetDef.x or 0
	local oy = (lcfg.offset and lcfg.offset.y) or offsetDef.y or 0
	local anchor = st.health or st.frame
	st.leaderIcon:ClearAllPoints()
	if anchor then
		st.leaderIcon:SetPoint("TOPLEFT", anchor, "TOPLEFT", ox, oy)
	else
		st.leaderIcon:SetPoint("TOPLEFT", st.frame, "TOPLEFT", ox, oy)
	end
	st.leaderIcon:SetSize(size, size)

	if not enabled then
		st.leaderIcon:Hide()
		return
	end

	local inEditMode = addon.EditModeLib and addon.EditModeLib:IsInEditMode()
	local showLeader = UnitIsGroupLeader and UnitIsGroupLeader(unit)
	if not showLeader and inEditMode then showLeader = true end
	if showLeader then
		st.leaderIcon:SetAtlas("UI-HUD-UnitFrame-Player-Group-LeaderIcon", false)
		st.leaderIcon:Show()
	else
		local showAssist = UnitIsGroupAssistant and UnitIsGroupAssistant(unit)
		if showAssist then
			st.leaderIcon:SetTexture("Interface\\GroupFrame\\UI-Group-AssistantIcon")
			st.leaderIcon:Show()
		else
			st.leaderIcon:Hide()
		end
	end
end

function H.updateClassificationIndicator(st, unit, cfg, def, skipDisabled)
	if unit == "player" then
		if st and st.classificationIcon then st.classificationIcon:Hide() end
		return
	end
	if not st or not st.classificationIcon then return end
	def = def or {}
	local scfg = (cfg and cfg.status) or {}
	local defStatus = def.status or {}
	local icfg = scfg.classificationIcon or defStatus.classificationIcon or {}
	local enabled = icfg.enabled == true and not (cfg and cfg.enabled == false)
	if not enabled and skipDisabled then return end

	local sizeDef = defStatus.classificationIcon and defStatus.classificationIcon.size or 16
	local offsetDef = (defStatus.classificationIcon and defStatus.classificationIcon.offset) or { x = -4, y = 0 }
	local size = H.clamp(icfg.size or sizeDef or 16, 8, 40)
	local ox = (icfg.offset and icfg.offset.x) or offsetDef.x or 0
	local oy = (icfg.offset and icfg.offset.y) or offsetDef.y or 0

	st.classificationIcon:ClearAllPoints()
	local anchorFrame = st.statusTextLayer or st.status
	if anchorFrame then st.classificationIcon:SetPoint("RIGHT", anchorFrame, "RIGHT", ox or 0, oy) end
	st.classificationIcon:SetSize(size, size)

	if not enabled then
		st.classificationIcon:Hide()
		return
	end

	local classification = UnitClassification and UnitClassification(unit)
	local atlas = resolveClassificationAtlas(classification)
	local inEditMode = addon.EditModeLib and addon.EditModeLib:IsInEditMode()
	if not atlas and inEditMode then atlas = resolveClassificationAtlas("rareelite") end
	if atlas then
		st.classificationIcon:SetAtlas(atlas)
		st.classificationIcon:Show()
	else
		st.classificationIcon:Hide()
	end
end

local function getCombatFeedbackConfig(cfg, def)
	local c = (cfg and cfg.combatFeedback) or {}
	local d = (def and def.combatFeedback) or {}
	return c, d
end

local function resolveCombatFeedbackParent(st, location)
	if not st then return nil end
	if location == "HEALTH" then return st.healthTextLayer or st.health or st.barGroup or st.frame end
	if location == "POWER" then return st.powerTextLayer or st.power or st.barGroup or st.frame end
	if location == "STATUS" then return st.statusTextLayer or st.status or st.frame end
	return st.frame
end

local function getCombatFeedbackLayer(st)
	if not st or not st.frame then return nil end
	if not st.combatFeedbackLayer then
		st.combatFeedbackLayer = CreateFrame("Frame", nil, st.frame)
		st.combatFeedbackLayer:SetAllPoints(st.frame)
	end
	local layer = st.combatFeedbackLayer
	if layer.SetFrameStrata and st.frame.GetFrameStrata then layer:SetFrameStrata(st.frame:GetFrameStrata()) end
	local maxLevel = 0
	local function consider(frame)
		if not frame or not frame.GetFrameLevel then return end
		local level = frame:GetFrameLevel() or 0
		if level > maxLevel then maxLevel = level end
	end
	consider(st.healthTextLayer)
	consider(st.powerTextLayer)
	consider(st.statusTextLayer)
	consider(st.health and st.health.absorbClip)
	consider(st.health and st.health._healthFillClip)
	consider(st.health)
	consider(st.status)
	consider(st.power)
	if layer.SetFrameLevel then layer:SetFrameLevel(maxLevel + 5) end
	return layer
end

function H.syncCombatFeedbackLayer(st)
	local layer = getCombatFeedbackLayer(st)
	if not layer then return end
	if st.combatFeedback then
		if st.combatFeedback.GetParent and st.combatFeedback:GetParent() ~= layer then st.combatFeedback:SetParent(layer) end
		if st.combatFeedback.SetFrameStrata and layer.GetFrameStrata then st.combatFeedback:SetFrameStrata(layer:GetFrameStrata()) end
		if st.combatFeedback.SetFrameLevel and layer.GetFrameLevel then st.combatFeedback:SetFrameLevel((layer:GetFrameLevel() or 0) + 1) end
	end
	if st.combatFeedbackText and st.combatFeedbackText.GetParent and st.combatFeedbackText:GetParent() ~= layer then st.combatFeedbackText:SetParent(layer) end
end

local function resolveCombatFeedbackTextParent(st, location)
	if not st then return nil end
	if location == "HEALTH" then return st.healthTextLayer or st.health or st.barGroup or st.frame end
	if location == "POWER" then return st.powerTextLayer or st.power or st.barGroup or st.frame end
	if location == "STATUS" then return st.statusTextLayer or st.status or st.frame end
	return st.frame
end

local function combatFeedbackHasEvents(events)
	if type(events) ~= "table" then return true end
	for _, enabled in pairs(events) do
		if enabled then return true end
	end
	return false
end

local function combatFeedbackJustify(anchor)
	if type(anchor) ~= "string" then return "CENTER" end
	local upper = anchor:upper()
	if upper:find("LEFT", 1, true) then return "LEFT" end
	if upper:find("RIGHT", 1, true) then return "RIGHT" end
	return "CENTER"
end

function H.combatFeedbackIsEnabled(cfg, def)
	if cfg and cfg.enabled == false then return false end
	if not CombatFeedback_OnCombatEvent then return false end
	local c, d = getCombatFeedbackConfig(cfg, def)
	local enabled = c.enabled
	if enabled == nil then enabled = d.enabled end
	if enabled ~= true then return false end
	local events = c.events
	if events == nil then events = d.events end
	return combatFeedbackHasEvents(events)
end

function H.combatFeedbackShouldShowEvent(cfg, def, event)
	if not event then return false end
	local c, d = getCombatFeedbackConfig(cfg, def)
	local events = c.events
	if events == nil then events = d.events end
	if type(events) ~= "table" then return true end
	local val = events[event]
	if val == nil and type(d.events) == "table" then val = d.events[event] end
	if val == nil then return true end
	return val == true
end

local function getCombatFeedbackSampleConfig(cfg, def)
	local c, d = getCombatFeedbackConfig(cfg, def)
	local sampleEnabled = c.sample
	if sampleEnabled == nil then sampleEnabled = d.sample end
	sampleEnabled = sampleEnabled == true
	local sampleEvent = c.sampleEvent or d.sampleEvent or "WOUND"
	local sampleAmount = tonumber(c.sampleAmount or d.sampleAmount) or 12345
	if sampleAmount < 0 then sampleAmount = 0 end
	if sampleAmount == 0 then sampleAmount = 1 end
	return sampleEnabled, sampleEvent, sampleAmount
end

function H.ensureCombatFeedbackElements(st)
	if not st or not st.frame then return nil end
	if not st.combatFeedback then st.combatFeedback = CreateFrame("Frame", nil, st.frame) end
	if not st.combatFeedbackText then
		local parent = st.statusTextLayer or st.status or st.frame
		st.combatFeedbackText = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
		st.combatFeedbackText:Hide()
	end
	st.combatFeedback.feedbackText = st.combatFeedbackText
	return st.combatFeedback, st.combatFeedbackText
end

local function stopCombatFeedbackSample(st)
	if not st then return end
	local ticker = st._combatFeedbackSampleTicker
	if ticker and ticker.Cancel then ticker:Cancel() end
	st._combatFeedbackSampleTicker = nil
	if st._combatFeedbackSampleActive then
		st._combatFeedbackSampleActive = nil
		if st.combatFeedbackText then st.combatFeedbackText:Hide() end
	end
end

function H.applyCombatFeedbackStyle(st, cfg, def)
	if not st then return end
	local c, d = getCombatFeedbackConfig(cfg, def)
	local font = c.font or d.font
	local fontSize = tonumber(c.fontSize or d.fontSize) or 30
	if fontSize <= 0 then fontSize = 30 end
	local anchor = c.anchor or d.anchor or "CENTER"
	local location = c.location or d.location or "STATUS"
	local off = c.offset or d.offset or {}
	local ox = off.x or 0
	local oy = off.y or 0
	local key = tostring(font) .. "|" .. tostring(fontSize) .. "|" .. tostring(anchor) .. "|" .. tostring(location) .. "|" .. tostring(ox) .. "|" .. tostring(oy)
	if st._combatFeedbackStyleKey == key then return end
	st._combatFeedbackStyleKey = key

	local frame, text = H.ensureCombatFeedbackElements(st)
	if not frame or not text then return end
	H.applyFont(text, font, fontSize, nil)
	H.syncCombatFeedbackLayer(st)
	local textParent = getCombatFeedbackLayer(st) or resolveCombatFeedbackTextParent(st, location) or st.frame
	if text.GetParent and text:GetParent() ~= textParent then text:SetParent(textParent) end
	if text.SetDrawLayer then text:SetDrawLayer("OVERLAY", 7) end
	text:ClearAllPoints()
	local parent = resolveCombatFeedbackParent(st, location) or st.frame
	text:SetPoint(anchor, parent, anchor, ox, oy)
	if text.SetJustifyH then text:SetJustifyH(combatFeedbackJustify(anchor)) end
	if text.SetWordWrap then text:SetWordWrap(false) end
	if text.SetMaxLines then text:SetMaxLines(1) end

	if CombatFeedback_Initialize then
		CombatFeedback_Initialize(frame, text, fontSize)
	else
		frame.feedbackText = text
		frame.feedbackFontHeight = fontSize
	end
end

function H.showCombatFeedbackSample(st, cfg, def)
	if not st or not CombatFeedback_OnCombatEvent then return end
	local enabled, sampleEvent, sampleAmount = getCombatFeedbackSampleConfig(cfg, def)
	if not enabled then return end
	local frame, text = H.ensureCombatFeedbackElements(st)
	if not frame or not text then return end
	H.applyCombatFeedbackStyle(st, cfg, def)
	if CombatFeedback_OnUpdate and frame.GetScript and frame:GetScript("OnUpdate") == nil then frame:SetScript("OnUpdate", CombatFeedback_OnUpdate) end
	CombatFeedback_OnCombatEvent(frame, sampleEvent, "", sampleAmount, 1)
	st._combatFeedbackSampleActive = true
end

function H.handleCombatFeedbackEvent(st, cfg, def, event, flags, amount, schoolMask)
	if not st or not CombatFeedback_OnCombatEvent then return end
	if not H.combatFeedbackIsEnabled(cfg, def) then return end
	if not H.combatFeedbackShouldShowEvent(cfg, def, event) then return end
	if issecretvalue and (issecretvalue(event) or issecretvalue(flags) or issecretvalue(amount) or issecretvalue(schoolMask)) then return end
	if GetTime then
		local now = GetTime()
		local last = st._combatFeedbackLastAt
		if last and (now - last) < COMBAT_FEEDBACK_THROTTLE then return end
		st._combatFeedbackLastAt = now
	end
	local frame, text = H.ensureCombatFeedbackElements(st)
	if not frame or not text then return end
	H.applyCombatFeedbackStyle(st, cfg, def)
	if CombatFeedback_OnUpdate and frame.GetScript and frame:GetScript("OnUpdate") == nil then frame:SetScript("OnUpdate", CombatFeedback_OnUpdate) end
	CombatFeedback_OnCombatEvent(frame, event, flags, amount, schoolMask)
end

function H.updateCombatFeedback(st, unit, cfg, def)
	if not st then return end
	st._combatFeedbackDef = def
	if not H.combatFeedbackIsEnabled(cfg, def) then
		if st._combatFeedbackEventFrame and st._combatFeedbackEventFrame.UnregisterEvent then st._combatFeedbackEventFrame:UnregisterEvent("UNIT_COMBAT") end
		if st.combatFeedback and st.combatFeedback.SetScript then st.combatFeedback:SetScript("OnUpdate", nil) end
		if st.combatFeedback then st.combatFeedback:Hide() end
		if st.combatFeedbackText then st.combatFeedbackText:Hide() end
		stopCombatFeedbackSample(st)
		return
	end
	if not unit then return end
	H.applyCombatFeedbackStyle(st, cfg, def)
	local frame = st.combatFeedback
	if frame then frame:Show() end
	if frame and CombatFeedback_OnUpdate and frame.GetScript and frame:GetScript("OnUpdate") == nil then frame:SetScript("OnUpdate", CombatFeedback_OnUpdate) end
	local evt = st._combatFeedbackEventFrame
	if not evt then
		evt = CreateFrame("Frame")
		st._combatFeedbackEventFrame = evt
		evt:SetScript("OnEvent", function(_, _, unitTarget, eventName, flagText, amount, schoolMask)
			if unitTarget ~= unit then return end
			local activeCfg = st.cfg or cfg
			local activeDef = st._combatFeedbackDef or def
			H.handleCombatFeedbackEvent(st, activeCfg, activeDef, eventName, flagText, amount, schoolMask)
		end)
	end
	if evt.UnregisterEvent then evt:UnregisterEvent("UNIT_COMBAT") end
	if evt.RegisterUnitEvent then
		evt:RegisterUnitEvent("UNIT_COMBAT", unit)
	elseif evt.RegisterEvent then
		evt:RegisterEvent("UNIT_COMBAT")
	end

	stopCombatFeedbackSample(st)
	local sampleEnabled = getCombatFeedbackSampleConfig(cfg, def)
	local inEditMode = addon.EditModeLib and addon.EditModeLib.IsInEditMode and addon.EditModeLib:IsInEditMode()
	if sampleEnabled and inEditMode then
		H.showCombatFeedbackSample(st, cfg, def)
		if NewTicker then
			st._combatFeedbackSampleTicker = NewTicker(1.2, function()
				local activeCfg = st.cfg or cfg
				local activeDef = st._combatFeedbackDef or def
				H.showCombatFeedbackSample(st, activeCfg, activeDef)
			end)
		end
	else
		stopCombatFeedbackSample(st)
	end
end

function H.stopCombatFeedbackSample(st) stopCombatFeedbackSample(st) end

function H.disableCombatFeedbackAll(states)
	if type(states) ~= "table" then return end
	for _, st in pairs(states) do
		if st and st._combatFeedbackEventFrame and st._combatFeedbackEventFrame.UnregisterEvent then st._combatFeedbackEventFrame:UnregisterEvent("UNIT_COMBAT") end
		if st and st.combatFeedback and st.combatFeedback.SetScript then st.combatFeedback:SetScript("OnUpdate", nil) end
		if st and st.combatFeedback then st.combatFeedback:Hide() end
		if st and st.combatFeedbackText then st.combatFeedbackText:Hide() end
		stopCombatFeedbackSample(st)
	end
end

function H._getColorComponents(color, fallback)
	color = color or fallback
	if not color then return 1, 1, 1, 1 end
	if color.r then return color.r or 1, color.g or 1, color.b or 1, color.a or 1 end
	return color[1] or 1, color[2] or 1, color[3] or 1, color[4] or 1
end

function H._getStaggerStateColorForUnit(unitTokenValue, cfg)
	local STAGGER_YELLOW_THRESHOLD = 0.30
	local STAGGER_RED_THRESHOLD = 0.60
	local STAGGER_FALLBACK_COLORS = {
		green = { r = 0.52, g = 1.0, b = 0.52, a = 1 },
		yellow = { r = 1.0, g = 0.98, b = 0.72, a = 1 },
		red = { r = 1.0, g = 0.42, b = 0.42, a = 1 },
	}
	local STAGGER_EXTRA_COLORS_FALLBACK = {
		high = { r = 0.62, g = 0.2, b = 1.0, a = 1 },
		extreme = { r = 1.0, g = 0.2, b = 0.8, a = 1 },
	}

	unitTokenValue = unitTokenValue or "player"
	local stagger = UnitStagger and UnitStagger(unitTokenValue) or 0
	local maxHealth = UnitHealthMax and UnitHealthMax(unitTokenValue) or 0
	local percent = 0
	if maxHealth and maxHealth > 0 then percent = (stagger or 0) / maxHealth end

	local rb = addon.Aura and addon.Aura.ResourceBars
	local extraHighDefault = (rb and rb.STAGGER_EXTRA_THRESHOLD_HIGH) or 200
	local extraExtremeDefault = (rb and rb.STAGGER_EXTRA_THRESHOLD_EXTREME) or 300
	local extraColors = (rb and rb.STAGGER_EXTRA_COLORS) or STAGGER_EXTRA_COLORS_FALLBACK

	if cfg and cfg.staggerHighColors == true then
		local high = tonumber(cfg.staggerHighThreshold) or extraHighDefault
		local extreme = tonumber(cfg.staggerExtremeThreshold) or extraExtremeDefault
		if high < 0 then high = 0 end
		if extreme < high then extreme = high end
		if percent >= (extreme / 100) then
			return H._getColorComponents(cfg.staggerExtremeColor, extraColors.extreme)
		elseif percent >= (high / 100) then
			return H._getColorComponents(cfg.staggerHighColor, extraColors.high)
		end
	end

	local info = (_G.GetPowerBarColor and _G.GetPowerBarColor("STAGGER")) or (PowerBarColor and PowerBarColor.STAGGER)
	local key
	if percent >= STAGGER_RED_THRESHOLD then
		key = "red"
	elseif percent >= STAGGER_YELLOW_THRESHOLD then
		key = "yellow"
	else
		key = "green"
	end
	return H._getColorComponents((info and info[key]) or STAGGER_FALLBACK_COLORS[key] or STAGGER_FALLBACK_COLORS.green)
end

function H.getPowerColor(powerEnum, powerToken, colorCfg, unitToken)
	if powerToken == nil and type(powerEnum) == "string" then
		powerToken = powerEnum
		powerEnum = nil
	end
	if powerEnum == nil and powerToken == nil then
		powerEnum = EnumPowerType and EnumPowerType.MANA or nil
		powerToken = "MANA"
	end
	local overrides = addon.db and addon.db.ufPowerColorOverrides
	local override = getPowerOverrideEntry(overrides, powerEnum, powerToken)
	if override then
		if override.r then return override.r, override.g, override.b, override.a or 1 end
		if override[1] then return override[1], override[2], override[3], override[4] or 1 end
	end
	local canonicalToken = getCanonicalPowerToken(powerEnum, powerToken)
	if canonicalToken == "STAGGER" then return H._getStaggerStateColorForUnit(unitToken, colorCfg) end
	local c = getPowerColorEntry(powerEnum, powerToken)
	if c then
		if c.r then return c.r, c.g, c.b, c.a or 1 end
		if c[1] then return c[1], c[2], c[3], c[4] or 1 end
	end
	local manaColor = getPowerColorEntry(EnumPowerType and EnumPowerType.MANA or nil, "MANA")
	if manaColor then
		if manaColor.r then return manaColor.r, manaColor.g, manaColor.b, manaColor.a or 1 end
		if manaColor[1] then return manaColor[1], manaColor[2], manaColor[3], manaColor[4] or 1 end
	end
	return 0.1, 0.45, 1, 1
end

function H.isPowerDesaturated(powerEnum, powerToken)
	if powerToken == nil and type(powerEnum) == "string" then
		powerToken = powerEnum
		powerEnum = nil
	end
	local overrides = addon.db and addon.db.ufPowerColorOverrides
	return getPowerOverrideEntry(overrides, powerEnum, powerToken) ~= nil
end

function H.getAbsorbColor(hc, defH)
	local defaultAbsorb = (defH and defH.absorbColor) or { 0.85, 0.95, 1, 0.7 }
	if hc and hc.absorbUseCustomColor and hc.absorbColor then
		return hc.absorbColor[1] or defaultAbsorb[1], hc.absorbColor[2] or defaultAbsorb[2], hc.absorbColor[3] or defaultAbsorb[3], hc.absorbColor[4] or defaultAbsorb[4]
	end
	return defaultAbsorb[1], defaultAbsorb[2], defaultAbsorb[3], defaultAbsorb[4]
end

function H.getHealAbsorbColor(hc, defH)
	local defaultAbsorb = (defH and defH.healAbsorbColor) or { 1, 0.3, 0.3, 0.7 }
	if hc and hc.healAbsorbUseCustomColor and hc.healAbsorbColor then
		return hc.healAbsorbColor[1] or defaultAbsorb[1], hc.healAbsorbColor[2] or defaultAbsorb[2], hc.healAbsorbColor[3] or defaultAbsorb[3], hc.healAbsorbColor[4] or defaultAbsorb[4]
	end
	return defaultAbsorb[1], defaultAbsorb[2], defaultAbsorb[3], defaultAbsorb[4]
end

function H.getNPCColorDefault(key)
	local c = key and npcColorDefaults[key]
	if not c then return nil end
	return c[1], c[2], c[3], c[4]
end

function H.getNPCColor(key)
	if not key then return nil end
	local overrides = addon.db and addon.db.ufNPCColorOverrides
	local override = overrides and overrides[key]
	if override then
		if override.r then return override.r, override.g, override.b, override.a or 1 end
		if override[1] then return override[1], override[2], override[3], override[4] or 1 end
	end
	return H.getNPCColorDefault(key)
end

local EnableSpellRangeCheck = C_Spell and C_Spell.EnableSpellRangeCheck
local GetSpellIDForSpellIdentifier = C_Spell and C_Spell.GetSpellIDForSpellIdentifier
local IsSpellInRange = C_Spell and C_Spell.IsSpellInRange
local GetSpellName = C_Spell and C_Spell.GetSpellName
local GetSpellInfo = _G.GetSpellInfo
local SpellBook = _G.C_SpellBook
local SpellBookItemType = Enum and Enum.SpellBookItemType
local SpellBookSpellBank = Enum and Enum.SpellBookSpellBank
local UnitClass = _G.UnitClass
local GetSpecializationFn = C_SpecializationInfo and C_SpecializationInfo.GetSpecialization
local GetSpecializationInfoFn = C_SpecializationInfo and C_SpecializationInfo.GetSpecializationInfo
local GetSpecializationInfoForClassID = _G.GetSpecializationInfoForClassID
local GetNumSpecializationsForClassIDFn = C_SpecializationInfo and C_SpecializationInfo.GetNumSpecializationsForClassID
local GetNumClasses = _G.GetNumClasses
local GetClassInfo = _G.GetClassInfo
local IsHelpfulSpell = _G.IsHelpfulSpell
local IsHarmfulSpell = _G.IsHarmfulSpell
local C_CreatureInfo = _G.C_CreatureInfo
local wipeTable = wipe or (table and table.wipe)
local tinsert = table.insert
local tsort = table.sort

local rangeFadeClassDefaultSpells = {
	DEATHKNIGHT = { friendly = 47541, enemy = 49576 },
	DEMONHUNTER = { friendly = nil, enemy = 278326 },
	DRUID = { friendly = 8936, enemy = 8921 },
	EVOKER = { friendly = 355913, enemy = 362969 },
	HUNTER = { friendly = nil, enemy = 75 },
	MAGE = { friendly = 1459, enemy = 2139 },
	MONK = { friendly = 116670, enemy = 115546 },
	PALADIN = { friendly = 85673, enemy = 20271 },
	PRIEST = { friendly = 17, enemy = 589 },
	ROGUE = { friendly = nil, enemy = 36554 },
	SHAMAN = { friendly = 8004, enemy = 8042 },
	WARLOCK = { friendly = 5697, enemy = 234153 },
	WARRIOR = { friendly = nil, enemy = 355 },
}

local rangeFadeSpecOptionsCache
local rangeFadeSpellOptionCache = { friendly = {}, enemy = {} }
local rangeFadeClassSpellOptions = {
	DEATHKNIGHT = {
		friendly = {},
		enemy = { 49576, 47541 },
	},
	DEMONHUNTER = {
		friendly = {},
		enemy = { 185123, 183752, 204021 },
	},
	DRUID = {
		friendly = { 8936, 774, 2782, 88423 },
		enemy = { 5176, 339, 6795, 33786, 22568, 8921 },
	},
	EVOKER = {
		friendly = { 355913, 361469, 360823 },
		enemy = { 362969 },
	},
	HUNTER = {
		friendly = {},
		enemy = { 75 },
	},
	MAGE = {
		friendly = { 1459, 475 },
		enemy = { 44614, 118, 116, 133, 44425 },
	},
	MONK = {
		friendly = { 115450, 115546, 116670 },
		enemy = { 115546, 115078, 100780, 117952 },
	},
	PALADIN = {
		friendly = { 19750, 85673, 4987, 213644 },
		enemy = { 853, 35395, 62124, 183218, 20271, 20473 },
	},
	PRIEST = {
		friendly = { 21562, 17, 527, 2061 },
		enemy = { 589, 8092, 585 },
	},
	ROGUE = {
		friendly = { 36554, 921 },
		enemy = { 185565, 36554, 185763, 2094, 921 },
	},
	SHAMAN = {
		friendly = { 546, 8004, 188070 },
		enemy = { 370, 8042, 117014, 188196, 73899 },
	},
	WARLOCK = {
		friendly = { 20707, 5697 },
		enemy = { 234153, 198590, 232670, 686, 5782 },
	},
	WARRIOR = {
		friendly = {},
		enemy = { 355, 5246, 100 },
	},
}

local rangeFadeHandlers = {}
local rangeFadeState = {
	activeSpells = {},
	spellStates = {},
	numChecked = 0,
	numInRange = 0,
	inRange = true,
	configDirty = true,
	configValid = false,
	enabled = false,
	alpha = 1,
	ignoreUnlimited = true,
	spellListDirty = true,
	spellListCache = nil,
}
local rangeFadeIgnoredSpells = {
	[2096] = true, -- Mind Vision (unlimited range)
}

local getRangeFadeConfig

local function isRangeFadeIgnored(spellId, actionId)
	local _, _, ignoreUnlimited = getRangeFadeConfig()
	if ignoreUnlimited == false then return false end
	if spellId and rangeFadeIgnoredSpells[spellId] then return true end
	if actionId and rangeFadeIgnoredSpells[actionId] then return true end
	return false
end

local function clearTable(tbl)
	if not tbl then return end
	if wipeTable then
		wipeTable(tbl)
	else
		for k in pairs(tbl) do
			tbl[k] = nil
		end
	end
end

local function getRangeFadeClassInfo()
	if not UnitClass then return nil, nil end
	local _, classToken, classID = UnitClass("player")
	return classToken, classID
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, nil, nil
end

local getCurrentSpecId

local function buildRangeFadeSpecEntries()
	local entries = {}
	local bySpecId = {}
	local sex = UnitSex and UnitSex("player") or nil

	if GetNumClasses and GetNumSpecializationsForClassIDFn and GetSpecializationInfoForClassID then
		local numClasses = GetNumClasses() or 0
		for classIndex = 1, numClasses do
			local className, classToken, classID = getClassInfoById(classIndex)
			if classID then
				local specCount = GetNumSpecializationsForClassIDFn(classID) or 0
				for specIndex = 1, specCount do
					local specID, specName = GetSpecializationInfoForClassID(classID, specIndex, sex)
					if specID then
						local label = specName or ("Spec " .. tostring(specID))
						local classLabel = className or classToken
						if classLabel and classLabel ~= "" then label = classLabel .. " - " .. label end
						local entry = {
							value = specID,
							label = label,
							specName = specName or ("Spec " .. tostring(specID)),
							className = className or classToken,
							classToken = classToken,
							classID = classID,
						}
						tinsert(entries, entry)
						bySpecId[specID] = entry
					end
				end
			end
		end
	end

	if #entries == 0 then
		local specID = getCurrentSpecId()
		if specID then
			local classToken, classID = getRangeFadeClassInfo()
			local entry = {
				value = specID,
				label = "Spec " .. tostring(specID),
				specName = "Spec " .. tostring(specID),
				className = classToken,
				classToken = classToken,
				classID = classID,
			}
			tinsert(entries, entry)
			bySpecId[specID] = entry
		end
	end

	tsort(entries, function(a, b)
		local aClass = tostring((a and a.className) or "")
		local bClass = tostring((b and b.className) or "")
		if aClass ~= bClass then return aClass < bClass end
		local aSpec = tostring((a and a.specName) or "")
		local bSpec = tostring((b and b.specName) or "")
		if aSpec ~= bSpec then return aSpec < bSpec end
		return (tonumber(a and a.value) or 0) < (tonumber(b and b.value) or 0)
	end)

	return entries, bySpecId
end

getCurrentSpecId = function()
	if not (GetSpecializationFn and GetSpecializationInfoFn) then return nil end
	local specIndex = GetSpecializationFn()
	if not specIndex then return nil end
	local specID = GetSpecializationInfoFn(specIndex)
	if not specID or specID <= 0 then return nil end
	return specID
end

local function getRangeFadeSpellDisplayName(spellId, includeId)
	local id = tonumber(spellId)
	if not id or id <= 0 then return nil end
	local name = GetSpellName and GetSpellName(id) or nil
	if (not name or name == "") and GetSpellInfo then name = GetSpellInfo(id) end
	if not name or name == "" then name = tostring(id) end
	if includeId then return string.format("%s (%d)", tostring(name), id) end
	return tostring(name)
end

local function normalizeConfiguredSpellId(value)
	if value == false or value == 0 or value == "0" then return false end
	local id = tonumber(value)
	if not id or id <= 0 then return nil end
	return floor(id)
end

function H.RangeFadeGetDefaultSpellPair(specValue)
	local specId = tonumber(specValue)
	local classToken
	if specId and specId > 0 then
		H.RangeFadeGetSpecOptions()
		local cache = rangeFadeSpecOptionsCache
		if cache and cache.bySpecId and cache.bySpecId[specId] then classToken = cache.bySpecId[specId].classToken end
	end
	if not classToken then classToken = getRangeFadeClassInfo() end
	local defaults = classToken and rangeFadeClassDefaultSpells[classToken] or nil
	if not defaults then return nil, nil end
	return defaults.friendly, defaults.enemy
end

function H.RangeFadeGetCurrentSpecId() return getCurrentSpecId() end

function H.RangeFadeGetSpecOptions()
	if rangeFadeSpecOptionsCache and rangeFadeSpecOptionsCache.entries then return rangeFadeSpecOptionsCache.entries end
	local entries, bySpecId = buildRangeFadeSpecEntries()
	rangeFadeSpecOptionsCache = {
		entries = entries,
		bySpecId = bySpecId,
	}
	return entries
end

function H.RangeFadeResolveSpellPair(rangeFadeConfig, specId)
	local spec = tonumber(specId)
	if not spec or spec <= 0 then spec = getCurrentSpecId() end
	local defaultsFriendly, defaultsEnemy = H.RangeFadeGetDefaultSpellPair(spec)

	local specEntry
	if type(rangeFadeConfig) == "table" and spec then
		local bySpec = rangeFadeConfig.specSpells
		if type(bySpec) == "table" then specEntry = bySpec[spec] end
	end

	local configuredFriendly = specEntry and normalizeConfiguredSpellId(specEntry.friendly)
	local configuredEnemy = specEntry and normalizeConfiguredSpellId(specEntry.enemy)

	local friendly
	if configuredFriendly == false then
		friendly = nil
	elseif type(configuredFriendly) == "number" then
		friendly = configuredFriendly
	else
		friendly = defaultsFriendly
	end

	local enemy
	if configuredEnemy == false then
		enemy = nil
	elseif type(configuredEnemy) == "number" then
		enemy = configuredEnemy
	else
		enemy = defaultsEnemy
	end

	return friendly, enemy
end

function H.RangeFadeBuildSpellListForConfig(rangeFadeConfig, specId)
	local friendly, enemy = H.RangeFadeResolveSpellPair(rangeFadeConfig, specId)
	local list = {}
	if friendly then list[#list + 1] = friendly end
	if enemy and enemy ~= friendly then list[#list + 1] = enemy end
	return list
end

function H.RangeFadeGetSpellLabel(spellId, includeId) return getRangeFadeSpellDisplayName(spellId, includeId == true) end

local function buildRangeFadeSpellOptionsFromIds(spellIds)
	local options = {}
	local seen = {}
	if type(spellIds) ~= "table" then return options end
	for i = 1, #spellIds do
		local spellId = tonumber(spellIds[i])
		if spellId and spellId > 0 and not seen[spellId] then
			seen[spellId] = true
			tinsert(options, {
				value = spellId,
				label = getRangeFadeSpellDisplayName(spellId, true) or tostring(spellId),
			})
		end
	end
	tsort(options, function(a, b) return tostring(a.label) < tostring(b.label) end)
	return options
end

local function buildRangeFadeSpellOptionsFromSpellBook(kind)
	local options = {}
	local seen = {}
	if not (SpellBook and SpellBook.GetNumSpellBookSkillLines and SpellBook.GetSpellBookSkillLineInfo and SpellBook.GetSpellBookItemInfo) then return options end
	local bank = SpellBookSpellBank and SpellBookSpellBank.Player or 0
	local spellType = (SpellBookItemType and SpellBookItemType.Spell) or 1
	local numLines = SpellBook.GetNumSpellBookSkillLines() or 0
	for line = 1, numLines do
		local lineInfo = SpellBook.GetSpellBookSkillLineInfo(line)
		local offset = lineInfo and lineInfo.itemIndexOffset or 0
		local count = lineInfo and lineInfo.numSpellBookItems or 0
		for slot = offset + 1, offset + count do
			local info = SpellBook.GetSpellBookItemInfo(slot, bank)
			if info and info.itemType == spellType and info.isPassive ~= true then
				local spellId = info.spellID or info.actionID
				local include = spellId and not seen[spellId]
				if include and kind == "friendly" and IsHelpfulSpell then include = IsHelpfulSpell(spellId) == true end
				if include and kind == "enemy" and IsHarmfulSpell then include = IsHarmfulSpell(spellId) == true end
				if include and spellId then
					seen[spellId] = true
					tinsert(options, {
						value = spellId,
						label = getRangeFadeSpellDisplayName(spellId, true) or tostring(spellId),
					})
				end
			end
		end
	end
	tsort(options, function(a, b) return tostring(a.label) < tostring(b.label) end)
	return options
end

function H.RangeFadeGetSpellOptions(kind, classToken)
	local key = (kind == "enemy") and "enemy" or "friendly"
	local cache = rangeFadeSpellOptionCache[key]
	if type(cache) ~= "table" then
		cache = {}
		rangeFadeSpellOptionCache[key] = cache
	end

	local currentClassToken = getRangeFadeClassInfo()
	local normalizedClassToken = type(classToken) == "string" and classToken or currentClassToken
	if cache[normalizedClassToken] then return cache[normalizedClassToken] end

	local classLists = rangeFadeClassSpellOptions[normalizedClassToken]
	local options
	if classLists and type(classLists[key]) == "table" then
		options = buildRangeFadeSpellOptionsFromIds(classLists[key])
	elseif normalizedClassToken == currentClassToken then
		options = buildRangeFadeSpellOptionsFromSpellBook(key)
	else
		options = {}
	end
	cache[normalizedClassToken] = options
	return options
end

local function normalizeRangeFadeConfig(enabled, alpha, ignoreUnlimited)
	enabled = enabled == true
	alpha = tonumber(alpha)
	if alpha == nil then alpha = 1 end
	if alpha < 0 then alpha = 0 end
	if alpha > 1 then alpha = 1 end
	if ignoreUnlimited == nil then
		ignoreUnlimited = true
	else
		ignoreUnlimited = ignoreUnlimited == true
	end
	return enabled, alpha, ignoreUnlimited
end

local function syncRangeFadeConfig()
	local fn = rangeFadeHandlers.getConfig
	local enabled, alpha, ignoreUnlimited
	if fn then
		enabled, alpha, ignoreUnlimited = fn()
	else
		enabled, alpha, ignoreUnlimited = false, 1, true
	end
	enabled, alpha, ignoreUnlimited = normalizeRangeFadeConfig(enabled, alpha, ignoreUnlimited)

	if rangeFadeState.configValid and rangeFadeState.ignoreUnlimited ~= ignoreUnlimited then rangeFadeState.spellListDirty = true end

	rangeFadeState.enabled = enabled
	rangeFadeState.alpha = alpha
	rangeFadeState.ignoreUnlimited = ignoreUnlimited
	rangeFadeState.configValid = true
	rangeFadeState.configDirty = false
end

getRangeFadeConfig = function()
	if rangeFadeState.configDirty or not rangeFadeState.configValid then syncRangeFadeConfig() end
	return rangeFadeState.enabled, rangeFadeState.alpha, rangeFadeState.ignoreUnlimited
end

local function applyRangeFadeAlpha(inRange, force)
	local applyFn = rangeFadeHandlers.applyAlpha
	if not applyFn then return end
	local enabled, alpha = getRangeFadeConfig()
	local targetAlpha = (enabled and not inRange) and alpha or 1
	applyFn(targetAlpha, force)
end

local function removeRangeFadeSpellState(spellId)
	local oldState = rangeFadeState.spellStates[spellId]
	if oldState == nil then return false end
	rangeFadeState.spellStates[spellId] = nil
	if oldState == true then rangeFadeState.numInRange = math.max(0, (rangeFadeState.numInRange or 0) - 1) end
	rangeFadeState.numChecked = math.max(0, (rangeFadeState.numChecked or 0) - 1)
	return true
end

local function setRangeFadeSpellState(spellId, inRange)
	local newState = (inRange == true)
	local oldState = rangeFadeState.spellStates[spellId]
	if oldState == nil then
		rangeFadeState.spellStates[spellId] = newState
		rangeFadeState.numChecked = (rangeFadeState.numChecked or 0) + 1
		if newState then rangeFadeState.numInRange = (rangeFadeState.numInRange or 0) + 1 end
		return true
	end
	if oldState == newState then return false end
	rangeFadeState.spellStates[spellId] = newState
	if oldState == true then rangeFadeState.numInRange = math.max(0, (rangeFadeState.numInRange or 0) - 1) end
	if newState then rangeFadeState.numInRange = (rangeFadeState.numInRange or 0) + 1 end
	return true
end

local function recomputeRangeFade()
	local numChecked = rangeFadeState.numChecked or 0
	local numInRange = rangeFadeState.numInRange or 0
	rangeFadeState.inRange = (numChecked == 0) or (numInRange > 0)
	applyRangeFadeAlpha(rangeFadeState.inRange)
end

local function buildRangeFadeSpellList()
	local list = {}
	local spellListFn = rangeFadeHandlers.getSpells
	if spellListFn then
		local explicit = spellListFn()
		if type(explicit) == "table" then
			for key, value in pairs(explicit) do
				local spellId
				if type(key) == "number" and value == true then
					spellId = key
				else
					spellId = value
				end
				spellId = tonumber(spellId)
				if spellId and spellId > 0 and not isRangeFadeIgnored(spellId) then list[spellId] = true end
			end
		end
		return list
	end

	if not (EnableSpellRangeCheck and SpellBook and SpellBook.GetNumSpellBookSkillLines and SpellBook.GetSpellBookSkillLineInfo and SpellBook.GetSpellBookItemInfo) then return list end
	local bank = SpellBookSpellBank and SpellBookSpellBank.Player or 0
	local spellType = (SpellBookItemType and SpellBookItemType.Spell) or 1
	local numLines = SpellBook.GetNumSpellBookSkillLines() or 0
	for line = 1, numLines do
		local lineInfo = SpellBook.GetSpellBookSkillLineInfo(line)
		local offset = lineInfo and lineInfo.itemIndexOffset or 0
		local count = lineInfo and lineInfo.numSpellBookItems or 0
		for slot = offset + 1, offset + count do
			local info = SpellBook.GetSpellBookItemInfo(slot, bank)
			if info and info.itemType == spellType and info.isPassive ~= true then
				if not isRangeFadeIgnored(info.spellID, info.actionID) then
					local spellId = info.spellID or info.actionID
					if spellId then list[spellId] = true end
				end
			end
		end
	end
	return list
end

function H.RangeFadeRegister(getConfigFn, applyAlphaFn, getSpellListFn)
	rangeFadeHandlers.getConfig = getConfigFn
	rangeFadeHandlers.applyAlpha = applyAlphaFn
	rangeFadeHandlers.getSpells = getSpellListFn
	rangeFadeState.configDirty = true
	rangeFadeState.spellListDirty = true
	rangeFadeState.spellListCache = nil
	rangeFadeSpellOptionCache.friendly = {}
	rangeFadeSpellOptionCache.enemy = {}
end

function H.RangeFadeReset()
	clearTable(rangeFadeState.spellStates)
	rangeFadeState.numChecked = 0
	rangeFadeState.numInRange = 0
	rangeFadeState.inRange = true
	applyRangeFadeAlpha(true, true)
end

function H.RangeFadeRefreshTargetState(targetUnit)
	local enabled = getRangeFadeConfig()
	if not enabled or not IsSpellInRange then
		H.RangeFadeReset()
		return
	end

	local unit = targetUnit or "target"
	if not (UnitExists and UnitExists(unit)) then
		H.RangeFadeReset()
		return
	end

	clearTable(rangeFadeState.spellStates)
	rangeFadeState.numChecked = 0
	rangeFadeState.numInRange = 0
	rangeFadeState.inRange = true

	for spellId in pairs(rangeFadeState.activeSpells) do
		local inRange = IsSpellInRange(spellId, unit)
		if inRange ~= nil then setRangeFadeSpellState(spellId, inRange == true) end
	end

	recomputeRangeFade()
end

function H.RangeFadeApplyCurrent(force) applyRangeFadeAlpha(rangeFadeState.inRange, force) end

function H.RangeFadeMarkConfigDirty() rangeFadeState.configDirty = true end

function H.RangeFadeMarkSpellListDirty()
	rangeFadeState.spellListDirty = true
	rangeFadeState.spellListCache = nil
	rangeFadeSpellOptionCache.friendly = {}
	rangeFadeSpellOptionCache.enemy = {}
	rangeFadeSpecOptionsCache = nil
end

function H.RangeFadeUpdateFromEvent(spellIdentifier, isInRange, checksRange)
	local enabled = getRangeFadeConfig()
	if not enabled then return end
	local id = tonumber(spellIdentifier)
	if not id and GetSpellIDForSpellIdentifier then id = GetSpellIDForSpellIdentifier(spellIdentifier) end
	if isRangeFadeIgnored(id) then return end
	if not id or not rangeFadeState.activeSpells[id] then return end
	if checksRange then
		setRangeFadeSpellState(id, isInRange == true)
	else
		removeRangeFadeSpellState(id)
	end
	recomputeRangeFade()
end

function H.RangeFadeUpdateSpells()
	if not EnableSpellRangeCheck then return end
	local enabled = getRangeFadeConfig()
	if not enabled then
		for spellId in pairs(rangeFadeState.activeSpells) do
			EnableSpellRangeCheck(spellId, false)
		end
		clearTable(rangeFadeState.activeSpells)
		H.RangeFadeReset()
		return
	end
	if rangeFadeState.spellListDirty or not rangeFadeState.spellListCache then
		rangeFadeState.spellListCache = buildRangeFadeSpellList()
		rangeFadeState.spellListDirty = false
	end
	local wanted = rangeFadeState.spellListCache or {}
	for spellId in pairs(rangeFadeState.activeSpells) do
		if not wanted[spellId] then
			EnableSpellRangeCheck(spellId, false)
			rangeFadeState.activeSpells[spellId] = nil
			removeRangeFadeSpellState(spellId)
		end
	end
	for spellId in pairs(wanted) do
		if not rangeFadeState.activeSpells[spellId] then
			EnableSpellRangeCheck(spellId, true)
			rangeFadeState.activeSpells[spellId] = true
		end
	end
	recomputeRangeFade()
end
