-- GUI.lua
local addonName, Wise = ...

-- Wise Logo Color Palette:
-- ID 1: Gold - #e6ba3e / RGB: 230, 186, 62
-- ID 2: Amber - #d58221 / RGB: 213, 130, 33
-- ID 3: Burnt Orange - #c0550d / RGB: 192, 85, 13
-- ID 4: Rust - #973707 / RGB: 151, 55, 7
-- ID 5: Dark Brown - #501903 / RGB: 80, 25, 3
-- ID 6: Bright Green - #9fe870 / RGB: 159, 232, 112
-- ID 7: Forest Green - #163300 / RGB: 22, 51, 0

-- Capability flags for patch-12.0.5+ cooldown APIs. The `ignoreGCD` second arg on
-- GetSpellCooldownDuration / GetActionCooldownDuration arrived in interface 120005;
-- we can't introspect arity, so gate on the interface number (extra arg is ignored
-- as a no-op on clients that don't support it, but gating keeps intent explicit).
local INTERFACE_VERSION = select(4, GetBuildInfo()) or 0
local HAS_IGNORE_GCD = INTERFACE_VERSION >= 120005
Wise.HAS_IGNORE_GCD = HAS_IGNORE_GCD

-- 12.0.5 native countdown formatters: Cooldown:SetCountdownFormatter lets us style
-- the built-in (combat / secret-mode) countdown text to match Wise's own out-of-
-- combat format, instead of being stuck with Blizzard's default whole-second look.
-- Probe by method presence (more robust than a version number for widget methods).
local HAS_COUNTDOWN_FORMATTER = false
-- 12.0.5 Cooldown:SetCountdownMillisecondsThreshold(seconds): below the given
-- remaining time, Blizzard's native countdown text shows one decimal place.
-- We use it on the combat / secret-mode path so the built-in text ticks as
-- smoothly as our out-of-combat decimal format. Method is protected; probe by
-- presence and always pcall the call site.
local HAS_COUNTDOWN_MS_THRESHOLD = false
do
	local probe = CreateFrame("Cooldown", nil, UIParent, "CooldownFrameTemplate")
	HAS_COUNTDOWN_FORMATTER = type(probe.SetCountdownFormatter) == "function"
		and type(C_StringUtil) == "table"
		and type(C_StringUtil.CreateSecondsFormatter) == "function"
	HAS_COUNTDOWN_MS_THRESHOLD = type(probe.SetCountdownMillisecondsThreshold) == "function"
	probe:Hide()
	probe:SetParent(nil)
end
Wise.HAS_COUNTDOWN_FORMATTER = HAS_COUNTDOWN_FORMATTER
Wise.HAS_COUNTDOWN_MS_THRESHOLD = HAS_COUNTDOWN_MS_THRESHOLD

-- Helper: Resolve per-group display settings with fallback to global
local _G = _G
local GetTime = GetTime
local strformat = string.format
local ceil = math.ceil
local floor = math.floor
local pairs = pairs
local ipairs = ipairs
local type = type
local tostring = tostring
local tonumber = tonumber
local CreateFrame = CreateFrame
local UIParent = UIParent
local GetCursorPosition = GetCursorPosition
local InCombatLockdown = InCombatLockdown
local C_Spell = C_Spell
local C_Item = C_Item
local C_SpellActivationOverlay = C_SpellActivationOverlay
local SecureHandlerWrapScript = SecureHandlerWrapScript
local RegisterStateDriver = RegisterStateDriver

-- Countdown text format. The 12.0.5 patch can render cooldown text in two styles:
--   "short"    — bare number, no unit   (9, 30, 5, 1)   [default]
--   "extended" — number + 1-letter unit (9s, 30s, 5m, 1h)
-- This is resolved per group (with global fallback) via GetGroupDisplaySettings,
-- and the same convention is applied to both the out-of-combat numeric path (where
-- Wise writes its own text) and the combat / secret-mode path (where Blizzard's
-- native countdown drives the text via SetCountdownFormatter).
local COUNTDOWN_FORMAT_DEFAULT = "short"
Wise.COUNTDOWN_FORMAT_DEFAULT = COUNTDOWN_FORMAT_DEFAULT

-- Below this many seconds remaining, the countdown shows one decimal place
-- (e.g. 2.9, 0.4) so it ticks smoothly like Blizzard's native cooldown text,
-- instead of jumping a whole second at a time. Matches Blizzard's default
-- decimal threshold. The combat / secret-mode path mirrors this via
-- Cooldown:SetCountdownMillisecondsThreshold (see below).
local COUNTDOWN_DECIMAL_THRESHOLD = 3
Wise.COUNTDOWN_DECIMAL_THRESHOLD = COUNTDOWN_DECIMAL_THRESHOLD

-- Shared text helper for the out-of-combat numeric path. `rem` is a plain number
-- of seconds remaining; `format` is "short" or "extended".
local function FormatWiseCountdownText(rem, format)
	-- Sub-threshold: one decimal place for a smooth, native-feeling tick.
	-- Clamp at 0 so we never print "-0.0" on the frame the cooldown expires.
	if rem < COUNTDOWN_DECIMAL_THRESHOLD then
		if rem < 0 then
			rem = 0
		end
		if format == "extended" then
			return strformat("%.1fs", rem)
		end
		return strformat("%.1f", rem)
	end
	if format == "extended" then
		if rem >= 3600 then
			return strformat("%dh", ceil(rem / 3600))
		elseif rem >= 60 then
			return strformat("%dm", ceil(rem / 60))
		else
			return strformat("%ds", ceil(rem))
		end
	end
	-- "short": bare number, no unit.
	if rem >= 3600 then
		return strformat("%d", ceil(rem / 3600))
	elseif rem >= 60 then
		return strformat("%d", ceil(rem / 60))
	else
		return strformat("%d", ceil(rem))
	end
end
Wise.FormatWiseCountdownText = FormatWiseCountdownText

-- Lazily-built shared SecondsFormatter objects for the combat / secret-mode path,
-- one per format style, reused across all buttons. We can't compute remaining time
-- in combat (secret numbers), so we hand Blizzard's native countdown a formatter
-- that mirrors our own text convention.
--   extended → Enum.SecondsFormatterAbbreviation.OneLetter (9s / 5m / 1h)
--   short    → nil formatter (Blizzard default: whole seconds, no unit) — matches
--              our bare-number look for the sub-minute durations that dominate the
--              combat/secret path. (longer combat cooldowns are rare; best-effort.)
local _wiseSecondsFormatters = {}
local function GetWiseCountdownFormatter(format)
	if not HAS_COUNTDOWN_FORMATTER then
		return nil
	end
	if format ~= "extended" then
		-- "short" maps to the native default formatter (nil).
		return nil
	end
	local cached = _wiseSecondsFormatters[format]
	if cached == nil then
		local ok, fmt = pcall(C_StringUtil.CreateSecondsFormatter)
		if ok and fmt then
			-- OneLetter abbreviation → "9s" / "5m" / "1h", matching the extended
			-- numeric path. Guard each setter: the method set has shifted between
			-- builds, and a missing one shouldn't nil out the whole formatter.
			local abbrev = _G.Enum and _G.Enum.SecondsFormatterAbbreviation
			if abbrev and abbrev.OneLetter ~= nil and fmt.SetDefaultAbbreviation then
				pcall(fmt.SetDefaultAbbreviation, fmt, abbrev.OneLetter)
			end
			if fmt.SetStripIntervalWhitespace then
				local ws = _G.Enum and _G.Enum.SecondsFormatterIntervalWhitespace
				if ws and ws.StripIgnoreLocale ~= nil then
					pcall(fmt.SetStripIntervalWhitespace, fmt, ws.StripIgnoreLocale)
				end
			end
		end
		cached = (ok and fmt) or false
		_wiseSecondsFormatters[format] = cached
	end
	return cached or nil
end

local issecretvalue = issecretvalue or (_G and _G.issecretvalue)

-- Helper: Safely read a field from a table that may contain secret number values.
-- Returns the raw value only if it can be accessed without error.
-- Accepts a table and a key (string) rather than the already-read value,
-- because even *reading* the field `table.key` can crash on secret values.
local function SafeReadField(tbl, key)
	local ok, val = pcall(function()
		return tbl[key]
	end)
	if not ok then
		return nil
	end
	return val
end

-- Helper: Clean secret number values in WoW 11.1+/12.0+ to prevent comparison errors in tainted execution.
-- Pass the *containing table* and *key* instead of the value directly when the
-- field might be secret; use CleanSecretNumber(SafeReadField(t, k)) together.
local function CleanSecretNumber(val)
	if val == nil then
		return nil
	end
	-- Fast-path: issecretvalue() is available in WoW 12.0+
	local svOk, isSecret = pcall(function()
		return issecretvalue and issecretvalue(val)
	end)
	if svOk and isSecret then
		return nil
	end
	-- tostring() on a secret value will also throw, so wrap it too
	local ok, str = pcall(tostring, val)
	if ok and str then
		return tonumber(str)
	end
	return nil
end

-- Mouse button keys need the 5th arg to SetOverrideBindingClick so the
-- simulated click uses the correct button name; without it WoW silently
-- drops the event for some mouse keys (BUTTON3 in particular).
local MOUSE_BUTTON_CLICK_NAME = {
	["BUTTON3"] = "MiddleButton",
	["BUTTON4"] = "Button4",
	["BUTTON5"] = "Button5",
	["MOUSEWHEELUP"] = "LeftButton",
	["MOUSEWHEELDOWN"] = "LeftButton",
	["MIDDLEMOUSE"] = "MiddleButton",
}

local function GetMouseClickName(key)
	if not key then
		return nil
	end
	-- Strip modifier prefix (ALT-SHIFT-BUTTON3 → BUTTON3)
	local base = key:match("[^%-]+$")
	return MOUSE_BUTTON_CLICK_NAME[base]
end

-- True when the key already carries an explicit modifier prefix.
-- We intentionally don't treat a literal "-" (the minus key) as a prefix:
-- e.g. plain "-" or "SHIFT--" both end with the bare key after the last dash.
local function HasModifierPrefix(key)
	if not key or key == "" then
		return false
	end
	return key:find("^SHIFT%-") or key:find("^CTRL%-") or key:find("^ALT%-") or false
end

-- Mirror an override binding under SHIFT-/CTRL-/ALT- prefixes. Some global
-- bindings (often written by other addons via SetBinding/SaveBindings) leave
-- entries like "SHIFT-- NONE" which swallow modified keypresses before WoW
-- falls back to the unmodified key. Registering our own overrides for the
-- modified variants guarantees that pressing e.g. Shift+- still routes the
-- click to our slot button so the macro's [mod:shift] clause can fire.
local function ApplyModifierMirrors(frame, key, target, mouseBtn)
	if HasModifierPrefix(key) then
		return
	end
	local mods = { "SHIFT-", "CTRL-", "ALT-" }
	for _, m in ipairs(mods) do
		if mouseBtn then
			SetOverrideBindingClick(frame, true, m .. key, target, mouseBtn)
		else
			SetOverrideBindingClick(frame, true, m .. key, target)
		end
	end
end

-- Returns a lookup set { [spellID] = true } of the zone abilities the game
-- currently considers active for this zone, via C_ZoneAbility.GetActiveAbilities().
-- This is the AUTHORITATIVE signal: the SpellButtonContainer's child buttons retain
-- a stale .spellID and stay :IsShown() after the zone ability goes away (e.g. exiting
-- the G-99 Breakneck in Undermine), so checking the button alone leaves the Wise slot
-- stuck visible. GetActiveAbilities drops the ability the moment it's no longer
-- available. canBeSecret=false, so it's safe to read in combat. Returns nil if the
-- API is unavailable (older client) — callers then fall back to the button check.
local function GetActiveZoneAbilitySet()
	if not (C_ZoneAbility and C_ZoneAbility.GetActiveAbilities) then
		return nil
	end
	local active = C_ZoneAbility.GetActiveAbilities()
	if not active then
		return nil
	end
	local set = {}
	for _, info in ipairs(active) do
		if info and info.spellID then
			set[info.spellID] = true
		end
	end
	return set
end

-- True if `child` is a zone-ability button whose spell is genuinely active right now.
-- Combines the button's own shown state (so a ZoneAbilityFrame hidden by Wise's
-- "Hide Zone Ability" setting still counts — children keep IsShown()) with the
-- authoritative active-ability set (so a stale child from a previous zone does NOT).
-- `activeSet` is the result of GetActiveZoneAbilitySet(); when nil (API missing) we
-- fall back to the legacy shown-only behavior.
local function IsZoneAbilityButtonActive(child, activeSet)
	if not (child and child.spellID and child:IsShown()) then
		return false
	end
	if activeSet then
		return activeSet[child.spellID] == true
	end
	return true
end

-- Helper: Get the first active spell button from ZoneAbilityFrame.
-- Modern WoW (11.0+) uses SpellButtonContainer with dynamic children
-- instead of a direct .SpellButton child.
local function GetZoneAbilitySpellButton()
	local zoneFrame = _G["ZoneAbilityFrame"]
	if not zoneFrame then
		return nil
	end
	-- Modern: SpellButtonContainer with dynamic children
	if zoneFrame.SpellButtonContainer then
		local children = { zoneFrame.SpellButtonContainer:GetChildren() }
		for _, child in ipairs(children) do
			if child.spellID and child:IsShown() then
				return child
			end
		end
		-- Return first child even if not shown (for secure click binding)
		for _, child in ipairs(children) do
			if child.spellID then
				return child
			end
		end
	end
	-- Legacy fallback: direct SpellButton
	if zoneFrame.SpellButton then
		return zoneFrame.SpellButton
	end
	return nil
end

function Wise:SelectEditModeLayout(name)
	if not EditModeManagerFrame then
		return
	end
	-- Presets are at fixed indices and not returned by GetLayouts()
	if name == "Modern" then
		EditModeManagerFrame:SelectLayout(1)
		return
	end
	if name == "Classic" then
		EditModeManagerFrame:SelectLayout(2)
		return
	end
	-- Custom layouts start at index 3
	if C_EditMode and C_EditMode.GetLayouts then
		local layoutInfo = C_EditMode.GetLayouts()
		if layoutInfo and layoutInfo.layouts then
			for i, layout in ipairs(layoutInfo.layouts) do
				if layout.layoutName == name then
					EditModeManagerFrame:SelectLayout(i + 2)
					return
				end
			end
		end
	end
end

-- Expose on Wise table for use in other modules (Actions.lua)
function Wise:GetZoneAbilitySpellButton()
	return GetZoneAbilitySpellButton()
end

-- Resolve the real action ID for override/possess/vehicle bar slots.
-- Raw IDs 133-144 (override) and 121-132 (possess/vehicle) don't return correct
-- textures via GetActionTexture without remapping. We use taint-free bar index APIs
-- (same approach as EXTRA_ACTION_BUTTON_SLOT) instead of reading .action from
-- Blizzard's protected ActionButton frames which would spread taint.
function Wise:ResolveBarActionID(aID)
	local buttonIndex
	if aID >= 133 and aID <= 144 then
		buttonIndex = aID - 132
	elseif aID >= 121 and aID <= 132 then
		buttonIndex = aID - 120
	elseif aID >= 145 and aID <= 156 then
		buttonIndex = aID - 144
	end

	if buttonIndex then
		local specialPage
		local getOverride = C_ActionBar and C_ActionBar.GetOverrideBarIndex or GetOverrideBarIndex
		local getVehicle = C_ActionBar and C_ActionBar.GetVehicleBarIndex or GetVehicleBarIndex
		local getShapeshift = C_ActionBar and C_ActionBar.GetTempShapeshiftBarIndex or GetTempShapeshiftBarIndex

		if HasOverrideActionBar and HasOverrideActionBar() and getOverride then
			specialPage = getOverride()
		elseif HasVehicleActionBar and HasVehicleActionBar() and getVehicle then
			specialPage = getVehicle()
		elseif HasTempShapeshiftActionBar and HasTempShapeshiftActionBar() and getShapeshift then
			specialPage = getShapeshift()
		end

		if specialPage then
			return buttonIndex + (specialPage - 1) * NUM_ACTIONBAR_BUTTONS
		end
	end

	return aID
end

-- Derive the ExtraActionButton1 slot at load time via GetExtraBarIndex().
-- Reading .action from the Blizzard frame taints the value; this avoids taint.
-- Formula: buttonIndex + (page - 1) * NUM_ACTIONBAR_BUTTONS
local EXTRA_ACTION_BUTTON_SLOT = 1 + (GetExtraBarIndex() - 1) * NUM_ACTIONBAR_BUTTONS
Wise.EXTRA_ACTION_BUTTON_SLOT = EXTRA_ACTION_BUTTON_SLOT

-- Returns true if a zone ability is genuinely active (has a valid, currently-active
-- spell AND its button is shown). The shown check alone is NOT enough: child buttons
-- retain a stale .spellID and stay :IsShown() after the zone ability goes away (e.g.
-- exiting the G-99 Breakneck), so we cross-check against C_ZoneAbility's authoritative
-- active set via IsZoneAbilityButtonActive. We deliberately keep IsShown() (not
-- IsVisible()) so Wise's "Hide Zone Ability" setting — which hides ZoneAbilityFrame
-- itself while the child keeps its shown flag — still reports the ability as active.
local function IsZoneAbilityActive()
	local zoneFrame = _G["ZoneAbilityFrame"]
	if not zoneFrame then
		return false
	end
	local activeSet = GetActiveZoneAbilitySet()
	if zoneFrame.SpellButtonContainer then
		local children = { zoneFrame.SpellButtonContainer:GetChildren() }
		for _, child in ipairs(children) do
			if IsZoneAbilityButtonActive(child, activeSet) then
				return true
			end
		end
	end
	if zoneFrame.SpellButton then
		return IsZoneAbilityButtonActive(zoneFrame.SpellButton, activeSet)
	end
	return false
end

-- Exposed for other modules (Actions.lua IsActionKnown) so the dynamic-availability
-- snapshot uses the same authoritative C_ZoneAbility-backed check as everything else.
function Wise:IsZoneAbilityActive()
	return IsZoneAbilityActive()
end

-- All custom conditionals that are NOT understood by WoW's secure state driver.
-- Used by BuildVisibilityDriver (SanitizeCustom) and UpdateGroupDisplay (CheckCustomVisibility).
local CUSTOM_VIS_CONDITIONALS = {
	["guildbank"] = true,
	["bank"] = true,
	["mailbox"] = true,
	["auctionhouse"] = true,
	["zoneability"] = true,
	["undermouse"] = true,
}

-- Evaluate a single custom conditional token. Returns true/false.
local function EvalCustomToken(token)
	local negated = false
	local t = token:match("^%s*(.-)%s*$") -- trim
	if t:sub(1, 2) == "no" and not CUSTOM_VIS_CONDITIONALS[t:lower()] then
		negated = true
		t = t:sub(3)
	end
	local base = t:match("^([^:]+)") or t
	base = base:lower()

	local result = false
	if base == "bank" then
		result = BankFrame and BankFrame:IsShown() or false
	elseif base == "guildbank" then
		result = GuildBankFrame and GuildBankFrame:IsShown() or false
	elseif base == "mailbox" then
		result = MailFrame and MailFrame:IsShown() or false
	elseif base == "auctionhouse" then
		result = AuctionHouseFrame and AuctionHouseFrame:IsShown() or false
	elseif base == "zoneability" then
		result = IsZoneAbilityActive()
	end

	if negated then
		result = not result
	end
	return result
end

-- Helper to determine if a condition string contains any custom conditionals or interface dependencies
local function HasCustomConditionals(str)
	if not str or str == "" then
		return false
	end
	if str:find("wise:") then
		return true
	end
	for block in str:gmatch("%[([^%]]*)%]") do
		for token in block:gmatch("[^,]+") do
			token = token:match("^%s*(.-)%s*$")
			local base = token:match("^no?(.+)") or token
			base = base:match("^([^:]+)") or base
			if CUSTOM_VIS_CONDITIONALS[base:lower()] then
				return true
			end
		end
	end
	return false
end

-- Evaluate a full condition string (e.g. "[extrabar]", "[zoneability]", "[combat,bank]")
-- Returns true if ANY bracket group matches (OR across groups). Handles both custom and secure conditionals.
-- Used by dynamic groups to determine per-slot visibility.
local function EvalFullConditionString(str)
	if not str or str == "" then
		return true
	end -- No condition = always show

	for block in str:gmatch("%[([^%]]*)%]") do
		local customTokens = {}
		local secureTokens = {}

		for token in block:gmatch("[^,]+") do
			local trimmed = token:match("^%s*(.-)%s*$")
			local check = trimmed:lower()
			local lookupBase = check
			if lookupBase:sub(1, 2) == "no" then
				local stripped = lookupBase:sub(3)
				local stripBase = stripped:match("^([^:]+)") or stripped
				if CUSTOM_VIS_CONDITIONALS[stripBase] then
					lookupBase = stripped
				end
			end
			local baseToken = lookupBase:match("^([^:]+)") or lookupBase

			if CUSTOM_VIS_CONDITIONALS[baseToken] then
				table.insert(customTokens, trimmed)
			else
				table.insert(secureTokens, trimmed)
			end
		end

		local groupMatch = true

		for _, ct in ipairs(customTokens) do
			if not EvalCustomToken(ct) then
				groupMatch = false
				break
			end
		end

		if groupMatch and #secureTokens > 0 then
			-- Secure tokens represent standard WoW macro conditionals (like combat, mod, stealth, etc.)
			-- which can change at runtime. Since secure layouts cannot be restructured in combat,
			-- we must assume secure conditionals match for layout generation so the secure buttons
			-- are created and positioned. They will be evaluated securely when clicked/used.
		end

		if groupMatch then
			return true
		end
	end

	return false
end

-- Exact insecure evaluation of a full condition string, mixing native and custom tokens.
-- Within each bracket group (AND), native tokens (combat, mod, stealth, …) are evaluated
-- via SecureCmdOptionParse and Wise custom tokens (zoneability, bank, undermouse, …) via
-- EvalCustomToken; groups are OR'd together — matching WoW macro semantics. Unlike
-- EvalFullConditionString (which assumes native tokens match, for in-combat layout), this
-- reflects the CURRENT state and is used to pick which icon a multi-state slot shows.
-- It also keeps raw custom tokens out of SecureCmdOptionParse, which would otherwise make
-- the client print "unknown macro option: <name>".
local function EvalConditionExact(str)
	if not str or str == "" then
		return true -- No condition = always matches
	end

	for block in str:gmatch("%[([^%]]*)%]") do
		local secureTokens = {}
		local groupMatch = true

		for token in block:gmatch("[^,]+") do
			local trimmed = token:match("^%s*(.-)%s*$")
			local check = trimmed:lower()
			-- Determine the base name, tolerating a leading 'no' negation on custom tokens.
			local lookupBase = check
			if lookupBase:sub(1, 2) == "no" then
				local stripped = lookupBase:sub(3)
				local stripBase = stripped:match("^([^:]+)") or stripped
				if CUSTOM_VIS_CONDITIONALS[stripBase] then
					lookupBase = stripped
				end
			end
			local baseToken = lookupBase:match("^([^:]+)") or lookupBase

			if CUSTOM_VIS_CONDITIONALS[baseToken] then
				if not EvalCustomToken(trimmed) then
					groupMatch = false
					break
				end
			else
				table.insert(secureTokens, trimmed)
			end
		end

		if groupMatch and #secureTokens > 0 then
			local secureStr = "[" .. table.concat(secureTokens, ",") .. "] true; false"
			if SecureCmdOptionParse(secureStr) ~= "true" then
				groupMatch = false
			end
		end

		if groupMatch then
			return true
		end
	end

	return false
end

-- Public wrapper so other modules (e.g. IndicatorRules) can evaluate a macro
-- condition the SAME way the slot engine does — native tokens via
-- SecureCmdOptionParse + Wise custom tokens, OR'd across bracket groups, reflecting
-- the CURRENT state. Empty/nil condition = true (always).
function Wise:EvalConditionExact(str)
	return EvalConditionExact(str)
end

-- Evaluate a condition string (e.g. "[zoneability][extrabar][combat,bank]")
-- Returns true if ANY bracket group containing a custom conditional matches (OR across groups).
-- Bracket groups with ONLY built-in conditionals are skipped (secure driver handles those).
local function EvalConditionString(str)
	if not str or str == "" then
		return false
	end

	for block in str:gmatch("%[([^%]]*)%]") do
		local blockHasCustom = false
		local customTokens = {}
		local secureTokens = {}

		for token in block:gmatch("[^,]+") do
			local trimmed = token:match("^%s*(.-)%s*$")
			local check = trimmed:lower()
			-- Strip 'no' prefix for lookup
			local lookupBase = check
			if lookupBase:sub(1, 2) == "no" then
				local stripped = lookupBase:sub(3)
				local stripBase = stripped:match("^([^:]+)") or stripped
				if CUSTOM_VIS_CONDITIONALS[stripBase] then
					lookupBase = stripped
				end
			end
			local baseToken = lookupBase:match("^([^:]+)") or lookupBase

			if CUSTOM_VIS_CONDITIONALS[baseToken] then
				blockHasCustom = true
				table.insert(customTokens, trimmed)
			else
				table.insert(secureTokens, trimmed)
			end
		end

		if blockHasCustom then
			local groupMatch = true

			for _, ct in ipairs(customTokens) do
				if not EvalCustomToken(ct) then
					groupMatch = false
					break
				end
			end

			if groupMatch and #secureTokens > 0 then
				local secureStr = "[" .. table.concat(secureTokens, ",") .. "] true; false"
				local result = SecureCmdOptionParse(secureStr)
				if result ~= "true" then
					groupMatch = false
				end
			end

			if groupMatch then
				return true
			end
		end
	end

	return false
end

function Wise:GetGroupDisplaySettings(groupName)
	local group = groupName and WiseDB.groups[groupName]
	local settings = WiseDB.settings or {}

	local globalIconSize = settings.iconSize or 30
	local globalTextSize = settings.textSize or 12
	local globalFont = settings.font or "Fonts\\FRIZQT__.TTF"
	local globalIconStyle = settings.iconStyle or "rounded"

	local iconSize = globalIconSize
	local textSize = globalTextSize
	local fontPath = globalFont
	local iconStyle = globalIconStyle

	if group then
		if group.iconSize then
			iconSize = group.iconSize
		end
		if group.textSize then
			textSize = group.textSize
		end
		if group.font then
			fontPath = group.font
		end
		if group.iconStyle then
			iconStyle = group.iconStyle
		end
	end

	local showKeybinds = (group and group.showKeybinds ~= nil and group.showKeybinds)
		or (group and group.showKeybinds == nil and settings.showKeybinds)
		or (not group and settings.showKeybinds)
	local keybindPosition = (group and group.keybindPosition) or settings.keybindPosition
	local keybindTextSize = (group and group.keybindTextSize) or settings.keybindTextSize
	local chargeTextSize = (group and group.chargeTextSize) or settings.chargeTextSize or 12
	local chargeTextPosition = (group and group.chargeTextPosition) or settings.chargeTextPosition or "TOP"

	local countdownTextSize = (group and group.countdownTextSize) or settings.countdownTextSize or 12
	local countdownTextPosition = (group and group.countdownTextPosition) or settings.countdownTextPosition or "CENTER"

	-- Countdown text format: "short" (bare number) or "extended" (number + unit).
	-- Per-group override falls back to the global setting, then the hard default.
	local countdownFormat = (group and group.countdownFormat) or settings.countdownFormat or COUNTDOWN_FORMAT_DEFAULT

	-- Tri-state logic for overrides: nil = global, true/false = override
	local showGlows = settings.showGlows
	if group and group.showGlows ~= nil then
		showGlows = group.showGlows
	end
	if showGlows == nil then
		showGlows = true
	end -- Default true

	local showBuffs = settings.showBuffs
	if group and group.showBuffs ~= nil then
		showBuffs = group.showBuffs
	end
	if showBuffs == nil then
		showBuffs = false
	end -- Default false

	local showGCD = settings.showGCD
	if group and group.showGCD ~= nil then
		showGCD = group.showGCD
	end
	if showGCD == nil then
		showGCD = true
	end -- Default true

	local showChargeText = settings.showChargeText
	if group and group.showChargeText ~= nil then
		showChargeText = group.showChargeText
	end
	if showChargeText == nil then
		showChargeText = true
	end

	local showCountdownText = settings.showCountdownText
	if group and group.showCountdownText ~= nil then
		showCountdownText = group.showCountdownText
	end
	if showCountdownText == nil then
		showCountdownText = true
	end

	local hideEmptySlots = settings.hideEmptySlots
	if group and group.hideEmptySlots ~= nil then
		hideEmptySlots = group.hideEmptySlots
	end
	if hideEmptySlots == nil then
		hideEmptySlots = false
	end

	local showInterfaceKeybind = settings.showInterfaceKeybind
	if group and group.showInterfaceKeybind ~= nil then
		showInterfaceKeybind = group.showInterfaceKeybind
	end
	if showInterfaceKeybind == nil then
		showInterfaceKeybind = false
	end

	-- Debuff overlay (CDM-like target debuff display)
	local showDebuffs = settings.showDebuffs
	if group and group.showDebuffs ~= nil then
		showDebuffs = group.showDebuffs
	end
	if showDebuffs == nil then
		showDebuffs = true
	end -- Default true (CDM parity)

	-- Buff stack counter
	local showBuffStacks = settings.showBuffStacks
	if group and group.showBuffStacks ~= nil then
		showBuffStacks = group.showBuffStacks
	end
	if showBuffStacks == nil then
		showBuffStacks = true
	end

	local buffStackPosition = (group and group.buffStackPosition) or settings.buffStackPosition or "TOPRIGHT"
	local buffStackTextSize = (group and group.buffStackTextSize) or settings.buffStackTextSize or 12

	-- Swipe direction overrides
	local cooldownSwipeReverse = settings.cooldownSwipeReverse
	if group and group.cooldownSwipeReverse ~= nil then
		cooldownSwipeReverse = group.cooldownSwipeReverse
	end
	if cooldownSwipeReverse == nil then
		cooldownSwipeReverse = false
	end

	local buffSwipeReverse = settings.buffSwipeReverse
	if group and group.buffSwipeReverse ~= nil then
		buffSwipeReverse = group.buffSwipeReverse
	end
	if buffSwipeReverse == nil then
		buffSwipeReverse = false
	end

	local debuffSwipeReverse = settings.debuffSwipeReverse
	if group and group.debuffSwipeReverse ~= nil then
		debuffSwipeReverse = group.debuffSwipeReverse
	end
	if debuffSwipeReverse == nil then
		debuffSwipeReverse = false
	end

	return iconSize,
		textSize,
		fontPath,
		showKeybinds,
		keybindPosition,
		keybindTextSize,
		chargeTextSize,
		chargeTextPosition,
		countdownTextSize,
		countdownTextPosition,
		showGlows,
		showBuffs,
		iconStyle,
		showGCD,
		showChargeText,
		showCountdownText,
		hideEmptySlots,
		showInterfaceKeybind,
		showDebuffs,
		showBuffStacks,
		buffStackPosition,
		buffStackTextSize,
		cooldownSwipeReverse,
		buffSwipeReverse,
		debuffSwipeReverse,
		countdownFormat
end

function Wise:CreateGroup(name, type)
	if not WiseDB.groups[name] then
		WiseDB.groups[name] = {
			type = type or "circle",
			dynamic = false,
			actions = {},
			anchor = { point = "CENTER", x = 0, y = 0 },
			visibilitySettings = {},
			keybindSettings = {},
		}
	end

	-- Ensure display is created and updated
	Wise:UpdateGroupDisplay(name)
	local f = Wise.frames[name]
	if f then
		f:Show()
		-- Apply Edit Mode if active (skip for mouse-anchored)
		local group = name and WiseDB.groups[name]
		if Wise.editMode and group and group.anchorMode ~= "mouse" then
			Wise:SetFrameEditMode(f, name, true)
		end
	end

	Wise:UpdateOptionsUI()
end

function Wise:DeleteGroup(name)
	local f = Wise.frames[name]
	if f then
		if InCombatLockdown() then
			print("|cff00ccff[Wise]|r Cannot delete interface in combat (protected).")
			return
		end
		f:Hide()
		if f.toggleBtn then
			f.toggleBtn:Hide()
		end
		if f.visualDisplay then
			f.visualDisplay:Hide()
		end
		-- Unregister events/scripts to stop updates
		f:SetScript("OnUpdate", nil)
		if f.Anchor then
			f.Anchor:SetScript("OnUpdate", nil)
		end
		if f.conditionTicker then
			f.conditionTicker:Cancel()
			f.conditionTicker = nil
		end
		-- Deregister from the event-driven dynamic refresh so events no longer
		-- invoke this deleted group's closure.
		if Wise._dynamicGroups then
			Wise._dynamicGroups[f] = nil
		end
		f._dynamicRefresh = nil

		-- Clear from internal frame registry
		Wise.frames[name] = nil
	end

	WiseDB.groups[name] = nil

	local modifiedParents = {}
	for gName, group in pairs(WiseDB.groups) do
		if gName ~= name then
			local modified = false
			if group.actions then
				for slotIdx, states in pairs(group.actions) do
					if type(slotIdx) == "number" and type(states) == "table" then
						for i = #states, 1, -1 do
							local action = states[i]
							if action.type == "interface" and action.value == name then
								table.remove(states, i)
								modified = true
							end
						end
					end
				end
			end
			if group.buttons then
				for i = #group.buttons, 1, -1 do
					local action = group.buttons[i]
					if action.type == "interface" and action.value == name then
						table.remove(group.buttons, i)
						modified = true
					end
				end
			end
			if modified then
				modifiedParents[gName] = true
			end
		end
	end

	for pName in pairs(modifiedParents) do
		Wise:UpdateGroupDisplay(pName)
	end

	Wise:UpdateOptionsUI()
end

Wise.frames = {}
function Wise:UpdateMouseWheelState()
	if not Wise.frames then
		return
	end
	local enable = false
	for _, f in pairs(Wise.frames) do
		if not InCombatLockdown() then
			f:EnableMouseWheel(enable)
			if f.buttons then
				for _, btn in ipairs(f.buttons) do
					btn:EnableMouseWheel(enable)
				end
			end
		end
	end
end

-- Central Cooldown Update Frame
Wise.CooldownUpdateFrame = CreateFrame("Frame")
Wise.CooldownUpdateFrame._wiseProfileName = "CooldownUpdateFrame"
Wise.ActiveCooldownButtons = {}
Wise.CooldownUpdateFrame:Hide()

-- ─── Combat-safe countdown: Blizzard native text fallback ──────────
-- WoW 11.1+ returns "secret numbers" for cooldown start/duration in combat.
-- We cannot do arithmetic on them. Instead, when secrets are detected we:
--   1. Enable Blizzard's built-in countdown on the cooldown widget
--   2. Reparent & restyle its FontString to match our custom positioning
--   3. Hide our own btn.countdown FontString to avoid double text
-- Outside combat, we compute remaining time normally and use our own text.

-- Forward declaration — real implementation follows after UpdateAllCooldowns.
local scheduleCooldownUpdate

-- Reusable remaining-time computation for the cooldown loop below.
-- In combat, start/duration can be "secret numbers" that throw on arithmetic,
-- so the call must be wrapped in pcall. We use a single hoisted closure with
-- scratch upvalues instead of allocating a fresh closure per button per frame —
-- the per-frame allocation was the dominant GC cost in this loop. Set
-- _cdStart/_cdDuration/_cdNow, then pcall(ComputeCooldownRemaining).
local _cdStart, _cdDuration, _cdNow
local function ComputeCooldownRemaining()
	return (_cdStart + _cdDuration) - _cdNow
end

Wise.CooldownUpdateFrame:SetScript("OnUpdate", function(self, elapsed)
	local now = GetTime()
	local hasActive = false

	-- Throttled aura re-validation for buff/debuff timers (~10 Hz)
	self._auraCheckElapsed = (self._auraCheckElapsed or 0) + elapsed
	local doAuraCheck = self._auraCheckElapsed >= 0.1
	if doAuraCheck then
		self._auraCheckElapsed = 0
	end

	for btn, info in pairs(Wise.ActiveCooldownButtons) do
		hasActive = true

		-- Re-validate buff/debuff auras periodically. If the aura was
		-- dispelled, expired early, or the target changed, schedule a
		-- full re-evaluation so the button falls back to showing its
		-- regular cooldown (or clears entirely).
		if doAuraCheck and info.scanSpellID and (info.isBuffActive or info.isDebuffActive) then
			local auraGone = false
			local spellName = C_Spell.GetSpellName(info.scanSpellID)
			if info.isBuffActive then
				local aura = C_UnitAuras.GetPlayerAuraBySpellID(info.scanSpellID)
				if not aura and spellName then
					aura = C_UnitAuras.GetAuraDataBySpellName("player", spellName)
				end
				if not aura then
					auraGone = true
				end
			elseif info.isDebuffActive then
				if not UnitExists("target") then
					auraGone = true
				else
					local debuffAura
					if spellName then
						debuffAura = C_UnitAuras.GetAuraDataBySpellName("target", spellName, "HARMFUL|PLAYER")
					end
					if not debuffAura then
						auraGone = true
					end
				end
			end
			if auraGone then
				-- Clear the aura flags so this entry is treated as a
				-- regular cooldown until the deferred re-evaluation runs.
				info.isBuffActive = false
				info.isDebuffActive = false
				info.scanSpellID = nil
				-- Schedule a proper re-evaluation (taint-safe, deferred)
				scheduleCooldownUpdate()
			end
		end

		local start = info.start
		local duration = info.duration
		local groupName = info.groupName
		local isListMode = info.isListMode

		-- Try to compute remaining time. start/duration may be secret in combat.
		local rem = 0
		local secretMode = false
		_cdStart, _cdDuration, _cdNow = start, duration, now
		local success, val = pcall(ComputeCooldownRemaining)
		if success then
			rem = val
		else
			secretMode = true
		end

		-- Check if cooldown is finished
		local finished = false
		if secretMode then
			finished = not (btn.cooldown and btn.cooldown:IsShown())
		else
			finished = (rem <= 0)
		end

		if finished then
			Wise.ActiveCooldownButtons[btn] = nil

			-- Restore our custom text mode
			if btn.cooldown then
				btn.cooldown:SetHideCountdownNumbers(true)
			end
			if btn._wiseBlizzCDActive then
				btn._wiseBlizzCDActive = nil
				local fs = btn.cooldown
					and btn.cooldown.GetCountdownFontString
					and btn.cooldown:GetCountdownFontString()
				if fs then
					pcall(fs.SetParent, fs, btn.cooldown)
				end
			end
			do
				local meta = Wise.buttonMeta and Wise.buttonMeta[btn]
				local vClone = (meta and meta.visualClone) or btn.visualClone
				if vClone and vClone._wiseBlizzCDActive then
					vClone._wiseBlizzCDActive = nil
					if vClone.cooldown then
						vClone.cooldown:SetHideCountdownNumbers(true)
						local vfs = vClone.cooldown.GetCountdownFontString and vClone.cooldown:GetCountdownFontString()
						if vfs then
							pcall(vfs.SetParent, vfs, vClone.cooldown)
						end
					end
				end
			end

			-- Cooldown Finished
			if isListMode then
				if btn.timerLabel then
					btn.timerLabel:SetText("")
				end
				if btn.redLine then
					btn.redLine:SetWidth(0)
					btn.redLine:Hide()
				end
				if btn.cooldown then
					btn.cooldown:SetAlpha(0)
				end
			else
				Wise:Text_UpdateCountdown(btn, groupName, "")
				local meta = Wise.buttonMeta and Wise.buttonMeta[btn]
				local vClone = (meta and meta.visualClone) or btn.visualClone
				if vClone then
					Wise:Text_UpdateCountdown(vClone, groupName, "")
				end
			end
		elseif secretMode then
			-- Secret numbers: let Blizzard render its own countdown text.
			-- Enable Blizzard countdown on the cooldown widget and style it.
			if not btn._wiseBlizzCDActive then
				btn._wiseBlizzCDActive = true
				btn.cooldown:SetHideCountdownNumbers(false)
				local ds = { Wise:GetGroupDisplaySettings(groupName) }
				local fontPath, countdownTextSize, countdownFormat = ds[3], ds[9], ds[26]
				-- Match the native countdown text to Wise's own format (12.0.5+).
				local formatter = GetWiseCountdownFormatter(countdownFormat)
				-- Reapply unconditionally: a nil formatter clears any previously set
				-- one (so "short" reverts to Blizzard's default look) rather than
				-- leaving a stale "extended" formatter from a prior cooldown.
				pcall(btn.cooldown.SetCountdownFormatter, btn.cooldown, formatter)
				-- Show decimals under the threshold so the native combat text ticks
				-- as smoothly as our out-of-combat decimal format.
				if HAS_COUNTDOWN_MS_THRESHOLD then
					pcall(btn.cooldown.SetCountdownMillisecondsThreshold, btn.cooldown, COUNTDOWN_DECIMAL_THRESHOLD)
				end
				-- Hide our custom countdown to avoid double text
				if btn.countdown then
					btn.countdown:Hide()
				end
				-- Style Blizzard's countdown FontString to match our look
				local fs = btn.cooldown.GetCountdownFontString and btn.cooldown:GetCountdownFontString()
				if fs then
					if fontPath and countdownTextSize then
						pcall(fs.SetFont, fs, fontPath, countdownTextSize, "OUTLINE")
					end
					-- Reparent to text overlay so it renders above the cooldown swipe
					local overlay = btn._textOverlay or btn
					pcall(fs.SetParent, fs, overlay)
				end
			end
			-- Mirror onto the visual clone (mouse-follow combat mirror): its
			-- custom countdown text is only written in the numeric branch, so
			-- without this it freezes at the last out-of-combat value.
			local meta = Wise.buttonMeta and Wise.buttonMeta[btn]
			local vClone = (meta and meta.visualClone) or btn.visualClone
			if vClone and vClone.cooldown and not vClone._wiseBlizzCDActive then
				vClone._wiseBlizzCDActive = true
				vClone.cooldown:SetHideCountdownNumbers(false)
				local ds = { Wise:GetGroupDisplaySettings(groupName) }
				local fontPath, countdownTextSize, countdownFormat = ds[3], ds[9], ds[26]
				local vFormatter = GetWiseCountdownFormatter(countdownFormat)
				pcall(vClone.cooldown.SetCountdownFormatter, vClone.cooldown, vFormatter)
				if HAS_COUNTDOWN_MS_THRESHOLD then
					pcall(
						vClone.cooldown.SetCountdownMillisecondsThreshold,
						vClone.cooldown,
						COUNTDOWN_DECIMAL_THRESHOLD
					)
				end
				if vClone.countdown then
					vClone.countdown:Hide()
				end
				local vfs = vClone.cooldown.GetCountdownFontString and vClone.cooldown:GetCountdownFontString()
				if vfs then
					if fontPath and countdownTextSize then
						pcall(vfs.SetFont, vfs, fontPath, countdownTextSize, "OUTLINE")
					end
					local vOverlay = vClone._textOverlay or vClone
					pcall(vfs.SetParent, vfs, vOverlay)
				end
			end
		else
			-- Normal mode: we have numeric remaining time
			-- Ensure Blizzard countdown is off, our custom text is active
			if btn._wiseBlizzCDActive then
				btn._wiseBlizzCDActive = nil
				btn.cooldown:SetHideCountdownNumbers(true)
				-- Restore Blizzard FontString parent if we changed it
				local fs = btn.cooldown.GetCountdownFontString and btn.cooldown:GetCountdownFontString()
				if fs then
					pcall(fs.SetParent, fs, btn.cooldown)
				end
			end
			do
				local meta = Wise.buttonMeta and Wise.buttonMeta[btn]
				local vClone = (meta and meta.visualClone) or btn.visualClone
				if vClone and vClone._wiseBlizzCDActive then
					vClone._wiseBlizzCDActive = nil
					if vClone.cooldown then
						vClone.cooldown:SetHideCountdownNumbers(true)
						local vfs = vClone.cooldown.GetCountdownFontString and vClone.cooldown:GetCountdownFontString()
						if vfs then
							pcall(vfs.SetParent, vfs, vClone.cooldown)
						end
					end
				end
			end

			if isListMode then
				local m = floor(rem / 60)
				local s = floor(rem % 60)
				local text = strformat("%d:%02d", m, s)
				if info.lastText ~= text then
					if btn.timerLabel then
						btn.timerLabel:SetText(text)
					end
					info.lastText = text
				end

				local maxWidth = 50
				if btn.redLine then
					-- In this branch rem and info.duration are both plain numbers
					-- (secret/finished cases handled above), so the division can't
					-- throw — no pcall/closure needed.
					local dur = info.duration
					if type(rem) == "number" and type(dur) == "number" and dur > 0 then
						local pct = rem / dur
						if pct > 1 then
							pct = 1
						elseif pct < 0 then
							pct = 0
						end
						btn.redLine:SetWidth(maxWidth * pct)
					end
				end
			else
				-- Standard Mode
				local text = FormatWiseCountdownText(rem, info.countdownFormat)

				if info.lastText ~= text then
					Wise:Text_UpdateCountdown(btn, groupName, text)
					local meta = Wise.buttonMeta and Wise.buttonMeta[btn]
					local vClone = (meta and meta.visualClone) or btn.visualClone
					if vClone then
						Wise:Text_UpdateCountdown(vClone, groupName, text)
					end
					info.lastText = text
				end
			end
		end
	end

	if not hasActive then
		self:Hide()
	end
end)

-- ============================================================================
-- Glow Overlay Implementation (modern proc glow)
-- Mirrors Blizzard's ActionButtonSpellAlertTemplate: a one-shot "start burst"
-- flipbook that hands off to a 1s looping flipbook — the exact visual the
-- default action bars show for spell-activation procs.
--
-- Ownership: TWO independent systems drive glows on the same button — the
-- proc engine (UpdateButtonUsability / IsSpellOverlayed) and the indicator
-- rules engine (modules/IndicatorRules.lua, per-rule "Glow" flag). Every
-- Show/Hide therefore carries an owner tag, and the overlay is only torn
-- down when NO owner still wants it. Without this, the rules engine's
-- unconditional per-pass HideOverlayGlow destroyed the proc engine's glow on
-- every event burst and the re-shown overlay replayed its entrance animation
-- — a constant, oversized ~1 Hz pulse instead of a steady glow.
-- ============================================================================
local glowUnusedOverlays = {}
local glowNumOverlays = 0

local function OverlayGlow_OnShow(self)
	-- Fresh attach plays the entrance burst; a re-show after a transient parent
	-- hide (layout refresh / ring close) resumes the steady loop instead —
	-- _burstPending is consumed by the first play so the burst can't re-pop.
	-- NOTE: frame._wiseGlowOn on the BUTTON still tracks the proc engine's
	-- intent to glow and is managed only by UpdateButtonUsability.
	if self.ProcStartAnim:IsPlaying() or self.ProcLoopAnim:IsPlaying() then
		return
	end
	if self._burstPending then
		self._burstPending = nil
		self.ProcStart:Show()
		self.ProcLoop:Hide()
		self.ProcStartAnim:Play()
	else
		self.ProcStart:Hide()
		self.ProcLoop:Show()
		self.ProcLoopAnim:Play()
	end
end

local function OverlayGlow_OnHide(self)
	-- Transient parent hide (layout/refresh pass), NOT proc end: stop (don't
	-- finish) the animations and keep the overlay attached — OnShow resumes the
	-- loop. No teardown here means no entrance-animation replay when the button
	-- comes back.
	if self.ProcStartAnim:IsPlaying() then
		self.ProcStartAnim:Stop()
	end
	if self.ProcLoopAnim:IsPlaying() then
		self.ProcLoopAnim:Stop()
	end
end

local function CreateOverlayGlow()
	glowNumOverlays = glowNumOverlays + 1
	local overlay = CreateFrame("Frame", "WiseButtonGlowOverlay" .. glowNumOverlays, UIParent)

	-- One-shot entrance burst. Blizzard authors this art 150px against a 42px
	-- button; ShowOverlayGlow sizes it with the same ratio.
	overlay.ProcStart = overlay:CreateTexture(nil, "ARTWORK")
	overlay.ProcStart:SetBlendMode("ADD")
	overlay.ProcStart:SetAtlas("UI-HUD-ActionBar-Proc-Start-Flipbook")
	overlay.ProcStart:SetPoint("CENTER")

	-- Steady looping glow filling the overlay (1.4x the button, the same
	-- footprint as Blizzard's SpellActivationAlert).
	overlay.ProcLoop = overlay:CreateTexture(nil, "ARTWORK")
	overlay.ProcLoop:SetAtlas("UI-HUD-ActionBar-Proc-Loop-Flipbook")
	overlay.ProcLoop:SetAlpha(0)
	overlay.ProcLoop:SetAllPoints()

	overlay.ProcLoopAnim = overlay:CreateAnimationGroup()
	overlay.ProcLoopAnim:SetLooping("REPEAT")
	overlay.ProcLoopAnim:SetToFinalAlpha(true)
	local loopAlpha = overlay.ProcLoopAnim:CreateAnimation("Alpha")
	loopAlpha:SetChildKey("ProcLoop")
	loopAlpha:SetFromAlpha(1)
	loopAlpha:SetToAlpha(1)
	loopAlpha:SetDuration(0.001)
	loopAlpha:SetOrder(0)
	local loopFlipbook = overlay.ProcLoopAnim:CreateAnimation("FlipBook")
	loopFlipbook:SetChildKey("ProcLoop")
	loopFlipbook:SetDuration(1)
	loopFlipbook:SetOrder(0)
	loopFlipbook:SetFlipBookRows(6)
	loopFlipbook:SetFlipBookColumns(5)
	loopFlipbook:SetFlipBookFrames(30)
	loopFlipbook:SetFlipBookFrameWidth(0)
	loopFlipbook:SetFlipBookFrameHeight(0)

	overlay.ProcStartAnim = overlay:CreateAnimationGroup()
	overlay.ProcStartAnim:SetToFinalAlpha(true)
	-- The 1→1 alpha looks pointless but is load-bearing: with SetToFinalAlpha
	-- the burst texture would otherwise stay at its finished alpha (0) on every
	-- replay; the explicit from-alpha reapplies at Play().
	local startAlphaIn = overlay.ProcStartAnim:CreateAnimation("Alpha")
	startAlphaIn:SetChildKey("ProcStart")
	startAlphaIn:SetDuration(0.001)
	startAlphaIn:SetOrder(0)
	startAlphaIn:SetFromAlpha(1)
	startAlphaIn:SetToAlpha(1)
	local startFlipbook = overlay.ProcStartAnim:CreateAnimation("FlipBook")
	startFlipbook:SetChildKey("ProcStart")
	startFlipbook:SetDuration(0.7)
	startFlipbook:SetOrder(1)
	startFlipbook:SetFlipBookRows(6)
	startFlipbook:SetFlipBookColumns(5)
	startFlipbook:SetFlipBookFrames(30)
	startFlipbook:SetFlipBookFrameWidth(0)
	startFlipbook:SetFlipBookFrameHeight(0)
	local startAlphaOut = overlay.ProcStartAnim:CreateAnimation("Alpha")
	startAlphaOut:SetChildKey("ProcStart")
	startAlphaOut:SetDuration(0.001)
	startAlphaOut:SetOrder(2)
	startAlphaOut:SetFromAlpha(1)
	startAlphaOut:SetToAlpha(0)
	overlay.ProcStartAnim:SetScript("OnFinished", function(group)
		local f = group:GetParent()
		f.ProcLoop:Show()
		f.ProcLoopAnim:Play()
	end)

	overlay:SetScript("OnShow", OverlayGlow_OnShow)
	overlay:SetScript("OnHide", OverlayGlow_OnHide)
	overlay:Hide()

	return overlay
end

local function GetOverlayGlow()
	local overlay = table.remove(glowUnusedOverlays)
	if not overlay then
		overlay = CreateOverlayGlow()
	end
	return overlay
end

-- owner names the driver ("proc", "rule"; nil = "generic" for config-time UI
-- like drag-and-drop and the settings demo). Show records the owner; Hide
-- removes it and only tears the overlay down once no owner wants the glow.
function Wise:ShowOverlayGlow(frame, targetRegion, owner)
	local owners = frame.__WiseGlowOwners
	if not owners then
		owners = {}
		frame.__WiseGlowOwners = owners
	end
	owners[owner or "generic"] = true
	if frame.__WiseOverlay then
		return -- already lit; a redundant Show must not replay any animation
	end
	targetRegion = targetRegion or frame
	local overlay = GetOverlayGlow()
	local targetWidth, targetHeight = targetRegion:GetSize()
	overlay:SetParent(frame)
	overlay:SetFrameLevel(frame:GetFrameLevel() + 5)
	overlay:ClearAllPoints()
	-- 1.4x the button, matching Blizzard's spell alert footprint. SetSize makes
	-- the dimensions available before the anchors resolve on the next frame.
	overlay:SetSize(targetWidth * 1.4, targetHeight * 1.4)
	overlay:SetPoint("TOPLEFT", targetRegion, "TOPLEFT", -targetWidth * 0.2, targetHeight * 0.2)
	overlay:SetPoint("BOTTOMRIGHT", targetRegion, "BOTTOMRIGHT", targetWidth * 0.2, -targetHeight * 0.2)
	overlay.ProcStart:SetSize(targetWidth * 150 / 42, targetHeight * 150 / 42)
	overlay._burstPending = true
	frame.__WiseOverlay = overlay
	overlay:Show()
end

function Wise:HideOverlayGlow(frame, owner)
	local owners = frame.__WiseGlowOwners
	if owners then
		owners[owner or "generic"] = nil
		if next(owners) then
			return -- another driver still wants this glow lit
		end
	end
	local overlay = frame.__WiseOverlay
	if overlay then
		-- Blizzard hides the spell alert instantly on proc end; matching that
		-- also leaves no fade-out window for a re-Show to race against.
		overlay:Hide()
		overlay:ClearAllPoints()
		overlay:SetParent(UIParent)
		overlay._burstPending = nil
		frame.__WiseOverlay = nil
		table.insert(glowUnusedOverlays, overlay)
	end
end
-- ============================================================================

-- ============================================================================
-- Hover Indication System
-- Provides a subtle glow + 5% scale increase on hovered ring buttons.
-- Uses a lightweight overlay (no ants/spark) at 50% brightness.
-- ============================================================================

local HOVER_SCALE = 1.05
local HOVER_GLOW_ALPHA = 0.5

local function IsHiddenEmptySlot(btn)
	local btnMeta = Wise.buttonMeta and Wise.buttonMeta[btn]
	local btnActionType = (btnMeta and btnMeta.actionType) or btn.actionType
	return btnActionType == "empty"
end

local function CreateHoverGlow(parent)
	local glow = CreateFrame("Frame", nil, parent)
	glow:SetFrameLevel(parent:GetFrameLevel() + 3)
	glow:SetAllPoints(parent)

	glow.inner = glow:CreateTexture(nil, "ARTWORK")
	glow.inner:SetPoint("CENTER")
	glow.inner:SetTexture([[Interface\SpellActivationOverlay\IconAlert]])
	glow.inner:SetTexCoord(0.00781250, 0.50781250, 0.27734375, 0.52734375)
	glow.inner:SetBlendMode("ADD")
	glow.inner:SetAlpha(HOVER_GLOW_ALPHA)

	glow.outer = glow:CreateTexture(nil, "ARTWORK")
	glow.outer:SetPoint("CENTER")
	glow.outer:SetTexture([[Interface\SpellActivationOverlay\IconAlert]])
	glow.outer:SetTexCoord(0.00781250, 0.50781250, 0.27734375, 0.52734375)
	glow.outer:SetBlendMode("ADD")
	glow.outer:SetAlpha(HOVER_GLOW_ALPHA * 0.6)

	glow:Hide()
	return glow
end

local function ShowHoverGlow(btn)
	if IsHiddenEmptySlot(btn) then
		return
	end

	if not btn._hoverGlow then
		btn._hoverGlow = CreateHoverGlow(btn)
	end
	-- For list layouts, anchor glow to the icon only (not the wide text button)
	local parentFrame = btn:GetParent()
	local isListLayout = parentFrame and parentFrame.effectiveDisplayType == "list"
	if isListLayout and btn.icon then
		local iw, ih = btn.icon:GetSize()
		btn._hoverGlow:ClearAllPoints()
		btn._hoverGlow:SetPoint("CENTER", btn.icon, "CENTER")
		btn._hoverGlow:SetSize(iw, ih)
		btn._hoverGlow.inner:SetSize(iw * 1.2, ih * 1.2)
		btn._hoverGlow.outer:SetSize(iw * 1.5, ih * 1.5)
	else
		local w, h = btn:GetSize()
		btn._hoverGlow:ClearAllPoints()
		btn._hoverGlow:SetAllPoints(btn)
		btn._hoverGlow.inner:SetSize(w * 1.2, h * 1.2)
		btn._hoverGlow.outer:SetSize(w * 1.5, h * 1.5)
	end
	btn._hoverGlow:Show()
end

local function HideHoverGlow(btn)
	if btn._hoverGlow then
		btn._hoverGlow:Hide()
	end
end

local function ApplyHoverScale(btn, scale)
	if IsHiddenEmptySlot(btn) then
		return
	end

	if btn.icon then
		btn.icon:SetScale(scale)
	end
	if btn.hotkey then
		btn.hotkey:SetScale(scale)
	end
	if btn.count then
		btn.count:SetScale(scale)
	end
end

function Wise:AddHoverIndication(btn)
	if not btn then
		return
	end

	btn:HookScript("OnEnter", function(self)
		if IsHiddenEmptySlot(self) then
			return
		end

		local parentFrame = self:GetParent()
		local isListLayout = parentFrame and parentFrame.effectiveDisplayType == "list"
		local isLineLayout = parentFrame and parentFrame.effectiveDisplayType == "line"
		if not isListLayout then
			-- Scale icon texture by 5% (not the button frame, to avoid hit-rect flicker)
			ApplyHoverScale(self, HOVER_SCALE)
		end
		-- Raise FrameLevel on line layout to prevent slot overlap
		if isLineLayout and not InCombatLockdown() then
			self._savedFrameLevel = self:GetFrameLevel()
			self:SetFrameLevel(self:GetFrameLevel() + 5)
		end
		-- Show dim glow (overlay frame, not protected)
		ShowHoverGlow(self)
	end)

	btn:HookScript("OnLeave", function(self)
		-- Reset icon scale
		ApplyHoverScale(self, 1.0)
		-- Restore FrameLevel on line layout
		if self._savedFrameLevel and not InCombatLockdown() then
			self:SetFrameLevel(self._savedFrameLevel)
			self._savedFrameLevel = nil
		end
		-- Hide glow
		HideHoverGlow(self)
	end)
end

-- ============================================================================

-- WiseStateDriver: Central secure handler for cross-interface visibility.
-- Holds boolean state ("active"/"inactive") for each interface and propagates
-- changes to all registered group frames so [wise:interfaceName] conditionals work.
local WiseStateDriver = CreateFrame("Frame", "WiseAddonStateDriver", UIParent, "SecureHandlerBaseTemplate")
Wise.WiseStateDriver = WiseStateDriver
WiseStateDriver:SetAttribute("frameCount", 0)

-- Secure snippet: Toggle the state for a given group name, then update all frames.
WiseStateDriver:SetAttribute(
	"ToggleState",
	[[
    local name = ...
    local current = self:GetAttribute("state_" .. name) or "inactive"
    local newState = (current == "active") and "inactive" or "active"
    self:SetAttribute("state_" .. name, newState)
    self:RunAttribute("UpdateAll")
]]
)

-- Secure snippet: Iterate all registered frames and trigger their UpdateWiseState.
WiseStateDriver:SetAttribute(
	"UpdateAll",
	[[
    local count = self:GetAttribute("frameCount") or 0
    for i = 1, count do
        local frame = self:GetFrameRef("Group_" .. i)
        if frame then
            frame:RunAttribute("UpdateWiseState")
        end
    end
]]
)

-- Secure snippet: Explicitly set state for a group (fixes desync issues)
WiseStateDriver:SetAttribute(
	"SetState",
	[[
    local name, state = ...
    local current = self:GetAttribute("state_" .. name)
    if current ~= state then
        self:SetAttribute("state_" .. name, state)
        self:RunAttribute("UpdateAll")
    end
]]
)

-- Secure snippet: Allow insecure code (Lua) to trigger SetState via attribute change
WiseStateDriver:SetAttribute(
	"_onattribute-wisesetstate",
	[[
    if not value then return end
    local name, state = strsplit(":", value)
    if name and state then
        self:RunAttribute("SetState", name, state)
    end
]]
)

function Wise:CreateGroupFrame(name, instanceId)
	local frameKey = instanceId or name
	if Wise.frames[frameKey] then
		return Wise.frames[frameKey]
	end

	local uiName = "WiseGroup_" .. (instanceId and instanceId:gsub("[: ]", "_") or name)
	local f = CreateFrame("Frame", uiName, UIParent, "SecureHandlerStateTemplate, SecureHandlerShowHideTemplate")
	f:SetSize(50, 50)
	f:EnableMouse(false) -- Default to click-through (enabled only in Edit Mode)

	-- Proxy Anchor Pattern:
	-- Create an insecure anchor frame that we can move freely (even in combat).
	-- The Secure Group is anchored to this proxy.
	-- This allows "spawn at mouse" logic to work in combat (mostly).
	f.Anchor = CreateFrame("Frame", nil, UIParent)
	f.Anchor._wiseProfileName = "anchor:" .. name
	f.Anchor:SetSize(1, 1)
	f.Anchor:SetPoint("CENTER")

	f:ClearAllPoints()
	f:SetPoint("CENTER", f.Anchor, "CENTER")
	f.buttons = {}

	-- Visual anchor for Edit Mode
	f.texture = f:CreateTexture(nil, "BACKGROUND")
	f.texture:SetAllPoints()
	f.texture:SetColorTexture(0, 0, 0, 0.5)
	f.texture:Hide()

	-- Secure Toggle Button (Hidden)
	-- Secure Toggle Button (Hidden but active)
	-- Must be parented to UIParent (or similar) so it doesn't get hidden when 'f' is hidden by State Driver
	local toggleBtn = CreateFrame(
		"Button",
		"WiseGroupToggle_" .. (instanceId and instanceId:gsub("[: ]", "_") or name),
		UIParent,
		"SecureActionButtonTemplate, SecureHandlerAttributeTemplate"
	)
	toggleBtn:RegisterForClicks("AnyDown", "AnyUp")
	-- SecureHandlerAttributeTemplate provides SetFrameRef via SecureHandler_OnLoad mixin

	-- Debug Attribute Changes (insecure script, safe to print)
	toggleBtn:SetScript("OnAttributeChanged", function(self, key, value)
		-- Debug Attribute Changes (Cleaned up)
	end)

	-- Keep small size to ensure pressAndHoldAction valid
	toggleBtn:SetSize(2, 2)
	toggleBtn:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 0, 0)

	local gatekeeper = [[
        local editmode = self:GetAttribute("state-editmode") or "hide"
        local game = self:GetAttribute("state-game") or "hide"
        local manual = self:GetAttribute("state-manual") or "hide"
        local custom = self:GetAttribute("state-custom") or "hide"
        local wiseShow = self:GetAttribute("state-wise-show") or "hide"
        local wiseHide = self:GetAttribute("state-wise-hide") or "hide"
        local activeOpacity = self:GetAttribute("activeOpacity") or 1

        local willShow = false
        if editmode == "show" then
            willShow = true
            self:Show()
            self:SetAlpha(activeOpacity)
        elseif (game == "show" or manual == "show" or custom == "show" or wiseShow == "show") and (wiseHide ~= "show") then
            willShow = true
            self:Show()
            self:SetAlpha(activeOpacity)
        else
            local inactiveOpacity = self:GetAttribute("inactiveOpacity")
            if inactiveOpacity and inactiveOpacity > 0 then
                willShow = false
                self:Show()
                self:SetAlpha(inactiveOpacity)
            else
                willShow = false
                self:Hide()
            end
        end

        local groupName = self:GetAttribute("wiseGroupName")
        local driver = self:GetFrameRef("WiseStateDriver")

        if driver and groupName then
             local driverState = willShow and "active" or "inactive"
             driver:RunAttribute("SetState", groupName, driverState)
        end
    ]]
	f:SetAttribute("wiseGroupName", name)
	f:SetAttribute("_onstate-game", gatekeeper)
	f:SetAttribute("_onstate-manual", gatekeeper)
	f:SetAttribute("_onstate-custom", gatekeeper)
	f:SetAttribute("_onstate-wise-show", gatekeeper)
	f:SetAttribute("_onstate-wise-hide", gatekeeper)
	f:SetAttribute("_onstate-editmode", gatekeeper)

	-- Support for Nested Keybinds (Active only when shown)
	f:SetAttribute(
		"_onshow",
		[[
        local nested = self:GetAttribute("nestedKeybinds")
        if nested then
            local max = self:GetAttribute("nested_max_keys") or 100
            for i = 1, max do
                local key = self:GetAttribute("nested_key_" .. i)
                local btnName = self:GetAttribute("nested_btn_name_" .. i)
                local mouseBtn = self:GetAttribute("nested_mouse_" .. i)

                if key and btnName then
                    if mouseBtn then
                        self:SetBindingClick(true, key, btnName, mouseBtn)
                    else
                        self:SetBindingClick(true, key, btnName)
                    end
                end
            end
        end
    ]]
	)

	f:SetAttribute(
		"_onhide",
		[[
        self:ClearBindings()
        local max = self:GetAttribute("buttonCount") or 0
        for i = 1, max do
            local btn = self:GetFrameRef("btn" .. i)
            if btn then
                btn:ClearBindings()
            end
        end
    ]]
	)

	-- Inline condition resolver for secure restricted environment (no 'function' keyword allowed).
	-- _rv_ref must be set before this block runs; it sets _rv_t, _rv_s, _rv_i, _rv_m.
	local RESOLVE_BLOCK = [[
        do
            local _rc = _rv_ref:GetAttribute("isa_count") or 0
            _rv_t = _rv_ref:GetAttribute("type")
            _rv_s = _rv_ref:GetAttribute("spell")
            _rv_i = _rv_ref:GetAttribute("item")
            _rv_m = _rv_ref:GetAttribute("macrotext")
            if _rc > 1 then
                local _conflict = _rv_ref:GetAttribute("isa_conflict") or "priority"
                local _matches = newtable()
                for _ci = 1, _rc do
                    local _cond = _rv_ref:GetAttribute("isa_cond_" .. _ci) or ""
                    if _cond == "" then
                        tinsert(_matches, _ci)
                    else
                        local _cr = SecureCmdOptionParse(_cond .. " true; false")
                        if _cr == "true" then
                            tinsert(_matches, _ci)
                        end
                    end
                end
                if #_matches > 0 then
                    local _chosen = nil
                    if _conflict == "priority" or _conflict == "sequence" or _conflict == "waterfall" then
                        local _startIdx = 1
                        if _conflict == "sequence" then
                            local _seq = _rv_ref:GetAttribute("isa_seq") or 1
                            _startIdx = nil
                            for _mi = 1, #_matches do
                                if _matches[_mi] >= _seq then
                                    _startIdx = _mi
                                    break
                                end
                            end
                            if not _startIdx then _startIdx = 1 end
                        end
                        _chosen = _matches[_startIdx]
                        local _newMacro = ""
                        local _nextIdx = _startIdx
                        for _si = _startIdx, #_matches do
                            local _m = _matches[_si]
                            local _mt = _rv_ref:GetAttribute("isa_type_" .. _m)
                            local _ms = _rv_ref:GetAttribute("isa_spell_" .. _m)
                            local _mi = _rv_ref:GetAttribute("isa_item_" .. _m)
                            local _mm = _rv_ref:GetAttribute("isa_macrotext_" .. _m)
                            local _mc = _rv_ref:GetAttribute("isa_cond_" .. _m) or ""
                            local _mo = _rv_ref:GetAttribute("isa_offgcd_" .. _m) or 0
                            local _prefix = (_mc ~= "") and (_mc .. " ") or ""
                            local _line = ""
                            if _mt == "spell" then
                                _line = "/cast " .. _prefix .. _ms
                            elseif _mt == "item" or _mt == "toy" then
                                _line = "/use " .. _prefix .. _mi
                            elseif _mt == "macro" then
                                _line = (_mm and _mm ~= "") and _mm or ""
                            elseif _mt == "click" then
                                local _mcb_name = _rv_ref:GetAttribute("isa_clickbutton_name_" .. _m)
                                if _mcb_name and _mcb_name ~= "" then
                                    _line = "/click " .. _prefix .. _mcb_name
                                end
                            end
                            if _line ~= "" then
                                if _newMacro == "" then
                                    -- Macro-type lines already contain their own directives
                                    if _mt == "macro" then
                                        _newMacro = _line
                                    else
                                        _newMacro = "#showtooltip\n" .. _line
                                    end
                                else
                                    _newMacro = _newMacro .. "\n" .. _line
                                end
                            end
                            _nextIdx = _si + 1
                            -- Waterfall always stacks every match so the engine can skip cooldowns down the list.
                            -- Priority/sequence keep the original off-GCD-only stacking behavior.
                            if _conflict ~= "waterfall" and _mo ~= 1 then
                                break
                            end
                        end
                        _rv_t = "macro"
                        _rv_s = nil
                        _rv_i = nil
                        _rv_m = _newMacro
                        if _conflict == "sequence" then
                            local _lastM = _matches[_nextIdx - 1]
                            _rv_ref:SetAttribute("isa_seq", _lastM + 1)
                        end
                    elseif _conflict == "random" then
                        _chosen = _matches[math.random(#_matches)]
                    end
                    if _chosen and _conflict == "random" then
                        _rv_t = _rv_ref:GetAttribute("isa_type_" .. _chosen)
                        _rv_s = _rv_ref:GetAttribute("isa_spell_" .. _chosen)
                        _rv_i = _rv_ref:GetAttribute("isa_item_" .. _chosen)
                        _rv_m = _rv_ref:GetAttribute("isa_macrotext_" .. _chosen)

                        if _rv_t == "click" then
                            local _mcb_name = _rv_ref:GetAttribute("isa_clickbutton_name_" .. _chosen)
                            local _mc = _rv_ref:GetAttribute("isa_cond_" .. _chosen) or ""
                            local _prefix = (_mc ~= "") and (_mc .. " ") or ""
                            if _mcb_name and _mcb_name ~= "" then
                                _rv_t = "macro"
                                _rv_m = "/click " .. _prefix .. _mcb_name
                            end
                        end
                    end
                end
            end
            -- Nesting: Handle interface buttons directly in secure context.
            -- Jump mode: toggle the child group frame directly (bypasses /click).
            -- Rotation modes: resolve a child action instead of /click.
            if _rv_ref:GetAttribute("isa_is_interface") then
                local _nm = _rv_ref:GetAttribute("isa_nest_mode") or "jump"
                if _nm == "jump" then
                    -- Toggle child interface via parent keybind (hover opens, keybind toggles)
                    local _childName = _rv_ref:GetAttribute("isa_interface_target")
                    if _childName then
                        local _childGroup = self:GetFrameRef("nested_" .. _childName)
                        if _childGroup then
                            local _cManual = _childGroup:GetAttribute("state-manual") or "hide"
                            local _cTarget = (_cManual == "show") and "hide" or "show"
                            _childGroup:SetAttribute("state-manual", _cTarget)
                            local _driver = self:GetFrameRef("WiseStateDriver")
                            if _driver and _childName then
                                _driver:RunAttribute("SetState", _childName, (_cTarget == "show") and "active" or "inactive")
                            end
                        end
                    end
                    -- Clear the action so /click macro doesn't also fire
                    _rv_t = nil
                    _rv_s = nil
                    _rv_i = nil
                    _rv_m = nil
                elseif _nm ~= "jump" then
                    local _nc = _rv_ref:GetAttribute("isa_nest_count") or 0
                    if _nc > 0 then
                        local _nmatches = newtable()
                        for _ni = 1, _nc do
                            local _ncond = _rv_ref:GetAttribute("isa_nest_cond_" .. _ni) or ""
                            if _ncond == "" then
                                tinsert(_nmatches, _ni)
                            else
                                local _ncr = SecureCmdOptionParse(_ncond .. " true; false")
                                if _ncr == "true" then
                                    tinsert(_nmatches, _ni)
                                end
                            end
                        end
                        if #_nmatches > 0 then
                            local _nchosen = nil
                            if _nm == "priority" then
                                _nchosen = _nmatches[1]
                            elseif _nm == "cycle" then
                                local _nseq = _rv_ref:GetAttribute("isa_nest_seq") or 1
                                local _nStartIdx = nil
                                for _ni = 1, #_nmatches do
                                    if _nmatches[_ni] >= _nseq then
                                        _nStartIdx = _ni
                                        break
                                    end
                                end
                                if not _nStartIdx then _nStartIdx = 1 end
                                _nchosen = _nmatches[_nStartIdx]
                                _rv_ref:SetAttribute("isa_nest_seq", _nchosen + 1)
                            elseif _nm == "random" then
                                _nchosen = _nmatches[math.random(#_nmatches)]
                            end
                            if _nchosen then
                                _rv_t = _rv_ref:GetAttribute("isa_nest_type_" .. _nchosen)
                                _rv_s = _rv_ref:GetAttribute("isa_nest_spell_" .. _nchosen)
                                _rv_i = _rv_ref:GetAttribute("isa_nest_item_" .. _nchosen)
                                _rv_m = _rv_ref:GetAttribute("isa_nest_macrotext_" .. _nchosen)

                                if _rv_t == "click" then
                                    local _mcb_name = _rv_ref:GetAttribute("isa_nest_clickbutton_name_" .. _nchosen)
                                    local _mc = _rv_ref:GetAttribute("isa_nest_cond_" .. _nchosen) or ""
                                    local _prefix = (_mc ~= "") and (_mc .. " ") or ""
                                    if _mcb_name and _mcb_name ~= "" then
                                        _rv_t = "macro"
                                        _rv_m = "/click " .. _prefix .. _mcb_name
                                    end
                                end
                            end
                        end
                    end
                end
            end
        end
    ]]

	-- Store RESOLVE_BLOCK on Wise table so UpdateGroupDisplay can reuse the same code
	Wise.RESOLVE_BLOCK = RESOLVE_BLOCK

	local snippet = [[
        local f = self:GetFrameRef("group")
        local trigger = self:GetAttribute("trigger") or "release_mouseover"
        local layoutType = self:GetAttribute("layoutType") or "circle"
        local heldMode = self:GetAttribute("visibleWhenHeld")
        local toggleMode = self:GetAttribute("toggleOnPress")
        local hideOnUse = self:GetAttribute("hideOnUse")
        local keybind = self:GetAttribute("keybind") or ""
        local isMouseWheel = keybind:find("MOUSEWHEEL")

        -- Shared resolve variables (set by RESOLVE_BLOCK)
        local _rv_ref, _rv_t, _rv_s, _rv_i, _rv_m

        -- 'down' is provided by the SecureHandlerWrapScript environment
        if down then
            self:SetAttribute("debug_msg", "Key DOWN. Trigger="..tostring(trigger).." Layout="..tostring(layoutType).." Button="..tostring(button))
            -- VISIBILITY LOGIC (Key Down) - Skip when trigger is "press" to avoid
            -- toggling visibility instead of casting
            if trigger ~= "press" then
                if heldMode then
                     f:SetAttribute("state-manual", "show")
                elseif toggleMode then
                     local f = self:GetFrameRef("group")
                     local currentManual = f:GetAttribute("state-manual") or "hide"
                     local targetState = (currentManual == "show") and "hide" or "show"

                     f:SetAttribute("state-manual", targetState)

                     -- When hiding parent, also hide any open child interfaces
                     if targetState == "hide" then
                         local _bc = self:GetAttribute("buttonCount") or 0
                         for _bi = 1, _bc do
                             local _pb = self:GetFrameRef("btn" .. _bi)
                             if _pb and _pb:GetAttribute("isa_is_interface") then
                                 local _cid = _pb:GetAttribute("isa_interface_target")
                                 if _cid then
                                     local _cf = self:GetFrameRef("nested_" .. _cid)
                                     if _cf then
                                         _cf:SetAttribute("state-manual", "hide")
                                     end
                                 end
                             end
                         end
                     end
                end
            end

            -- EXECUTION LOGIC (Press / Scroll Down)
            if trigger == "press" then
                if layoutType == "button" then
                    local targetRef = nil
                    local count = self:GetAttribute("buttonCount") or 0

                    for i = 1, count do
                        local ref = self:GetFrameRef("btn" .. i)
                        if ref then
                            targetRef = ref
                            break
                        end
                    end

                    if targetRef then
                        self:SetAttribute("debug_msg", "Press+Button: Firing target " .. tostring(targetRef))
                        self:SetAttribute("pressAndHoldAction", 1)

                        _rv_ref = targetRef
                        ]] .. RESOLVE_BLOCK .. [[
                        self:SetAttribute("type", _rv_t)
                        self:SetAttribute("spell", _rv_s)
                        self:SetAttribute("item", _rv_i)
                        self:SetAttribute("macrotext", _rv_m)
                        if hideOnUse and _rv_t then f:SetAttribute("state-manual", "hide") end
                    else
                        self:SetAttribute("debug_msg", "Press+Button: No valid target found. Count="..count)
                    end
                else
                    -- Non-button layout: find the hovered button and execute it
                    local hovName = self:GetAttribute("hoveredButton")
                    local targetRef = nil
                    if hovName then
                        local count = self:GetAttribute("buttonCount") or 0
                        for i = 1, count do
                            local ref = self:GetFrameRef("btn" .. i)
                            if ref and ref:GetName() == hovName then
                                targetRef = ref
                                break
                            end
                        end
                    end
                    if not targetRef then
                        -- Fallback: use first button
                        local count = self:GetAttribute("buttonCount") or 0
                        for i = 1, count do
                            local ref = self:GetFrameRef("btn" .. i)
                            if ref then
                                targetRef = ref
                                break
                            end
                        end
                    end
                    if targetRef then
                        self:SetAttribute("debug_msg", "Press+Other: Firing target " .. tostring(targetRef))
                        self:SetAttribute("pressAndHoldAction", 1)
                        _rv_ref = targetRef
                        ]] .. RESOLVE_BLOCK .. [[
                        self:SetAttribute("type", _rv_t)
                        self:SetAttribute("spell", _rv_s)
                        self:SetAttribute("item", _rv_i)
                        self:SetAttribute("macrotext", _rv_m)
                        if hideOnUse and _rv_t then f:SetAttribute("state-manual", "hide") end
                    else
                        self:SetAttribute("debug_msg", "Press+Other: No valid target found")
                        self:SetAttribute("type", nil)
                        self:SetAttribute("pressAndHoldAction", nil)
                    end
                end
            elseif isMouseWheel and (trigger == "release_mouseover" or trigger == "release_repeat") then
                -- For mouse wheel scroll, run the release/hovered execution immediately on Down click
                local hovered = self:GetAttribute("hoveredButton")
                local targetRef = nil
                if hovered then
                    local count = self:GetAttribute("buttonCount") or 0
                    for i = 1, count do
                        local ref = self:GetFrameRef("btn" .. i)
                        if ref and ref:GetName() == hovered then
                            targetRef = ref
                            break
                        end
                    end
                end
                if not targetRef then
                    -- Fallback: first button
                    local count = self:GetAttribute("buttonCount") or 0
                    for i = 1, count do
                        local ref = self:GetFrameRef("btn" .. i)
                        if ref then
                            targetRef = ref
                            break
                        end
                    end
                end
                if targetRef then
                    _rv_ref = targetRef
                    ]] .. RESOLVE_BLOCK .. [[
                    self:SetAttribute("type", _rv_t)
                    self:SetAttribute("spell", _rv_s)
                    self:SetAttribute("item", _rv_i)
                    self:SetAttribute("macrotext", _rv_m)
                    if hideOnUse and _rv_t then f:SetAttribute("state-manual", "hide") end
                end
            else
                self:SetAttribute("type", nil)
                self:SetAttribute("pressAndHoldAction", nil)
            end
        else
            -- Read hoveredButton BEFORE hiding the frame (hiding clears hover via OnLeave)
            local _pre_hovered = self:GetAttribute("hoveredButton")

            -- Check child interfaces for hovered buttons.
            -- Only override parent hover when: (a) no parent button is hovered, or
            -- (b) the hovered parent button is the interface button that owns the child.
            local _usedChildGroup = nil
            local _btnCount = self:GetAttribute("buttonCount") or 0
            for _ci = 1, _btnCount do
                local _parentBtn = self:GetFrameRef("btn" .. _ci)
                if _parentBtn and _parentBtn:GetAttribute("isa_is_interface") then
                    local _childId = _parentBtn:GetAttribute("isa_interface_target")
                    local _childMode = _parentBtn:GetAttribute("isa_nest_mode") or "jump"
                    if _childId and _childMode == "jump" then
                        local _childToggle = self:GetFrameRef("childToggle_" .. _childId)
                        if _childToggle then
                            local _childHovered = _childToggle:GetAttribute("hoveredButton")
                            if _childHovered then
                                local _childBtnRef = _childToggle:GetFrameRef(_childHovered)
                                if _childBtnRef then
                                    -- Only take child action if parent hover is nil or is the owning interface button
                                    if not _pre_hovered or _pre_hovered == _parentBtn:GetName() then
                                        _pre_hovered = "_child_"
                                        _rv_ref = _childBtnRef
                                        _usedChildGroup = self:GetFrameRef("nested_" .. _childId)
                                    end
                                end
                                break
                            end
                        end
                    end
                end
            end

            -- VISIBILITY LOGIC (Key Up)
            if heldMode then
                f:SetAttribute("state-manual", "hide")
            end

            -- EXECUTION LOGIC (Release)
            if trigger == "release_mouseover" then
                 local hovered = _pre_hovered
                 if hovered then
                    if hovered == "_child_" then
                        -- Child button already resolved above
                        ]] .. RESOLVE_BLOCK .. [[

                        self:SetAttribute("type", _rv_t)
                        self:SetAttribute("spell", _rv_s)
                        self:SetAttribute("item", _rv_i)
                        self:SetAttribute("macrotext", _rv_m)

                        self:SetAttribute("ul_type", _rv_t)
                        self:SetAttribute("ul_spell", _rv_s)
                        self:SetAttribute("ul_item", _rv_i)
                        self:SetAttribute("ul_macrotext", _rv_m)

                        if hideOnUse and _rv_t then f:SetAttribute("state-manual", "hide") end
                        -- Close used child (unless keepOpenAfterUse)
                        if _usedChildGroup and not _usedChildGroup:GetAttribute("keepOpenAfterUse") then
                            _usedChildGroup:SetAttribute("state-manual", "hide")
                        end
                    else
                        local ref = self:GetFrameRef(hovered)
                        if ref then
                            _rv_ref = ref
                            ]] .. RESOLVE_BLOCK .. [[

                            self:SetAttribute("type", _rv_t)
                            self:SetAttribute("spell", _rv_s)
                            self:SetAttribute("item", _rv_i)
                            self:SetAttribute("macrotext", _rv_m)

                            self:SetAttribute("ul_type", _rv_t)
                            self:SetAttribute("ul_spell", _rv_s)
                            self:SetAttribute("ul_item", _rv_i)
                            self:SetAttribute("ul_macrotext", _rv_m)

                            if hideOnUse and _rv_t then f:SetAttribute("state-manual", "hide") end
                        end
                    end
                 else
                    self:SetAttribute("type", nil)
                    self:SetAttribute("spell", nil)
                    self:SetAttribute("item", nil)
                    self:SetAttribute("macrotext", nil)
                 end
            elseif trigger == "release_repeat" then
                 local hovered = _pre_hovered
                 if hovered then
                    if hovered == "_child_" then
                        -- Child button already resolved above
                        ]] .. RESOLVE_BLOCK .. [[

                        self:SetAttribute("type", _rv_t)
                        self:SetAttribute("spell", _rv_s)
                        self:SetAttribute("item", _rv_i)
                        self:SetAttribute("macrotext", _rv_m)

                        self:SetAttribute("ul_type", _rv_t)
                        self:SetAttribute("ul_spell", _rv_s)
                        self:SetAttribute("ul_item", _rv_i)
                        self:SetAttribute("ul_macrotext", _rv_m)

                        if hideOnUse and _rv_t then f:SetAttribute("state-manual", "hide") end
                        -- Close used child (unless keepOpenAfterUse)
                        if _usedChildGroup and not _usedChildGroup:GetAttribute("keepOpenAfterUse") then
                            _usedChildGroup:SetAttribute("state-manual", "hide")
                        end
                    else
                        local ref = self:GetFrameRef(hovered)
                        if ref then
                            _rv_ref = ref
                            ]] .. RESOLVE_BLOCK .. [[

                            self:SetAttribute("type", _rv_t)
                            self:SetAttribute("spell", _rv_s)
                            self:SetAttribute("item", _rv_i)
                            self:SetAttribute("macrotext", _rv_m)

                            self:SetAttribute("ul_type", _rv_t)
                            self:SetAttribute("ul_spell", _rv_s)
                            self:SetAttribute("ul_item", _rv_i)
                            self:SetAttribute("ul_macrotext", _rv_m)

                            if hideOnUse and _rv_t then f:SetAttribute("state-manual", "hide") end
                        end
                    end
                 else
                    local t = self:GetAttribute("ul_type")
                    if t then
                         self:SetAttribute("type", t)
                         self:SetAttribute("spell", self:GetAttribute("ul_spell"))
                         self:SetAttribute("item", self:GetAttribute("ul_item"))
                         self:SetAttribute("macrotext", self:GetAttribute("ul_macrotext"))
                         if hideOnUse and _rv_t then f:SetAttribute("state-manual", "hide") end
                    else
                         self:SetAttribute("type", nil)
                    end
                 end
            else
                 self:SetAttribute("type", nil)
                 self:SetAttribute("spell", nil)
                 self:SetAttribute("item", nil)
                 self:SetAttribute("macrotext", nil)
            end

            -- UNCONDITIONAL: When parent is hiding, always hide ALL child interfaces.
            -- This catches every path: held release, toggle hide, hideOnUse, trigger=none.
            local _finalState = f:GetAttribute("state-manual") or "hide"
            if _finalState == "hide" then
                local _bc2 = self:GetAttribute("buttonCount") or 0
                for _bi2 = 1, _bc2 do
                    local _pb2 = self:GetFrameRef("btn" .. _bi2)
                    if _pb2 and _pb2:GetAttribute("isa_is_interface") then
                        local _cid2 = _pb2:GetAttribute("isa_interface_target")
                        if _cid2 then
                            local _cf2 = self:GetFrameRef("nested_" .. _cid2)
                            if _cf2 then
                                _cf2:SetAttribute("state-manual", "hide")
                            end
                        end
                    end
                end
            end
        end
    ]]

	-- Use PreClick so attributes are set BEFORE the action executes
	SecureHandlerWrapScript(toggleBtn, "PreClick", toggleBtn, snippet)
	toggleBtn:SetFrameRef("group", f)
	toggleBtn:SetFrameRef("WiseStateDriver", WiseStateDriver)
	toggleBtn:SetAttribute("groupName", name)

	-- Error suppression: check ANY button in this group for isa_suppress before the action fires.
	-- For keybind presses on button layouts, hoveredButton is nil, so we must check all group buttons.
	toggleBtn:HookScript("PreClick", function(self, mouseButton, isDown)
		local groupName = self:GetAttribute("groupName")
		if groupName and Wise.frames[groupName] then
			for _, btn in ipairs(Wise.frames[groupName].buttons) do
				if btn:GetAttribute("isa_suppress") == 1 then
					Wise:BeginErrorSuppression()
					return
				end
			end
		end
		-- Debug: log resolved action after secure PreClick (/wise resolve)
		if Wise.debugResolve then
			local t = self:GetAttribute("type") or "nil"
			local s = self:GetAttribute("spell") or ""
			local m = self:GetAttribute("macrotext") or ""
			local hovered = self:GetAttribute("hoveredButton") or "none"
			local preview = m ~= "" and m:sub(1, 80):gsub("\n", "\\n") or s
			print(
				string.format(
					"|cffFFD700[Wise Resolve]|r toggle=%s down=%s hovered=%s type=%s → %s",
					self:GetName() or "?",
					tostring(isDown),
					hovered,
					t,
					tostring(preview)
				)
			)
			self._resolveTime = debugprofilestop()
		end
	end)
	toggleBtn:HookScript("PostClick", function(self)
		if Wise.debugResolve and self._resolveTime then
			local elapsed = debugprofilestop() - self._resolveTime
			print(
				string.format(
					"|cffFFD700[Wise Resolve]|r PostClick %s: |cff00ff00%.2fms|r",
					self:GetName() or "?",
					elapsed
				)
			)
			self._resolveTime = nil
		end
	end)

	f.toggleBtn = toggleBtn
	f.groupName = name -- Store for animation lookup
	f.isClosing = false -- Flag to track closing animation in progress

	-- OnUpdate for continuous mouse following (when in mouse anchor mode)
	local function MouseFollowOnUpdate(self)
		if self.mouseAnchorLocked then
			return
		end -- Locked in place on keydown (for hold/toggle modes)

		local group = WiseDB.groups[self.groupName]
		if not group or group.anchorMode ~= "mouse" then
			return
		end

		local x, y = GetCursorPosition()
		local offsetX = group.mouseOffsetX or 0
		local offsetY = group.mouseOffsetY or 0

		-- Position frame at cursor using BOTTOMLEFT (raw pixel coords)
		if not InCombatLockdown() then
			self:ClearAllPoints()
			self:SetPoint("CENTER", UIParent, "BOTTOMLEFT", x + offsetX, y + offsetY)
		end
	end

	-- Module 2: Blocker Strategy
	-- Prevent "Invisible Walls" by enabling mouse only when shown.
	f:SetScript("OnShow", function(self)
		if self.buttons then
			local optionsOpen = Wise.OptionsFrame and Wise.OptionsFrame:IsShown()
			local inEditMode = Wise.editMode
			for _, btn in ipairs(self.buttons) do
				if not InCombatLockdown() then
					btn:EnableMouse(btn.isValid or not not optionsOpen or not not inEditMode)
				end
			end
		end
		if self.isClosing then
			-- Safety: if isClosing is stuck (e.g. WoW forced hide during close animation),
			-- reset it so the frame can re-show properly
			if not self._closingAnimActive then
				self.isClosing = false
			else
				return
			end
		end

		-- Stop any lingering animations from a previous show/hide cycle
		Wise:StopSlideAnimations(self)

		local group = WiseDB.groups[self.groupName]

		local isNestedInstance = self.parentInstanceId and not (group and group.isWiser)

		if isNestedInstance then
			-- Nested child: always position relative to parent button,
			-- regardless of the child group's own anchorMode setting.
			Wise:PositionNestedChild(self, self.instanceId, self.parentInstanceId)

			-- Auto-close on leave: start monitoring mouse proximity
			Wise:StartNestedCloseOnLeave(self, self.groupName, self.parentInstanceId)
		elseif group and group.anchorMode == "mouse" then
			-- Standalone mouse-anchor mode: position at cursor
			local cursorX, cursorY = GetCursorPosition()
			local uiScale = UIParent:GetScale()
			local frameScale = self:GetScale()

			local correctedX = (cursorX / uiScale) / frameScale
			local correctedY = (cursorY / uiScale) / frameScale
			local offsetX = (group.mouseOffsetX or 0) / frameScale
			local offsetY = (group.mouseOffsetY or 0) / frameScale

			local layoutType = group.type or "circle"
			local mode = group.interaction or "toggle"
			local willFollow = (layoutType == "button" and mode == "press_visible")
				or (group.visibility == "always" or group.visibility == "combat")

			-- Persistent followers must never sit under the cursor (they'd
			-- intercept every click forever). Locked rings and press-to-show
			-- interfaces may center on it — that's their designed behavior.
			if group.visibility == "always" or group.visibility == "combat" then
				offsetX, offsetY = Wise:ClampMouseFollowOffset(self, offsetX, offsetY)
			end

			if self.Anchor and not InCombatLockdown() then
				self.Anchor:ClearAllPoints()
				self.Anchor:SetPoint("CENTER", UIParent, "BOTTOMLEFT", correctedX + offsetX, correctedY + offsetY)
			end

			if not InCombatLockdown() then
				self:ClearAllPoints()
				self:SetPoint("CENTER", UIParent, "BOTTOMLEFT", correctedX + offsetX, correctedY + offsetY)
			end

			self.mouseAnchorLocked = not willFollow
		end

		-- Button manipulation (ClearAllPoints/SetPoint on secure buttons) is protected
		if InCombatLockdown() then
			return
		end

		-- Rebuild group display on show so dynamic slots (extrabutton, zoneability, etc.)
		-- reflect current availability. Visibility drivers may show the frame after conditions
		-- change (e.g. [extrabar] becomes true), but UpdateGroupDisplay hasn't run since then.
		if group and group.dynamic and not self._rebuildingOnShow then
			self._rebuildingOnShow = true
			Wise:UpdateGroupDisplay(self.groupName)
			self._rebuildingOnShow = nil
		end

		-- Refresh usability and active state for all buttons on show
		-- (e.g. addon magic slots need to reflect current addon load state)
		if self.buttons then
			for _, btn in ipairs(self.buttons) do
				Wise:UpdateButtonUsability(btn)
				Wise:UpdateButtonState(btn)
			end
		end

		local shouldAnimate = (group and group.animation) or (self.parentInstanceId and self.parentAnimation)
		if shouldAnimate then
			-- Animate: slide buttons from center to target
			-- Nested children inherit animation setting from parent
			Wise:PlaySlideAnimation(self, true)
		else
			-- No animation: place buttons directly at target positions
			for _, btn in ipairs(self.buttons or {}) do
				if btn:IsShown() then
					btn:ClearAllPoints()
					btn:SetPoint("CENTER", btn.targetX or 0, btn.targetY or 0)
				end
			end
		end

		-- Fix for button selection on spawn (OnEnter doesn't fire if appearing under mouse)
		-- Skip if animating, as movement will naturally trigger OnEnter
		if not InCombatLockdown() and self.buttons and not shouldAnimate then
			local found = nil
			for _, btn in ipairs(self.buttons) do
				if btn:IsShown() and btn:IsMouseOver() then
					found = btn:GetName()
					break
				end
			end
			-- Force update the attribute
			if self.toggleBtn then
				self.toggleBtn:SetAttribute("hoveredButton", found)
			end
		end
	end)

	f:SetScript("OnHide", function(self)
		if self.buttons then
			for _, btn in ipairs(self.buttons) do
				if not InCombatLockdown() then
					btn:EnableMouse(false)
				end
			end
		end
		-- Reset hover indication on all buttons (skip in combat — protected frames)
		if not InCombatLockdown() then
			for _, btn in ipairs(self.buttons or {}) do
				btn:SetScale(1.0)
				HideHoverGlow(btn)
			end
		end

		-- Cancel nested close-on-leave ticker
		if self.nestedCloseTicker then
			self.nestedCloseTicker:Cancel()
			self.nestedCloseTicker = nil
		end

		-- Cascade close child interfaces ONLY if this is a deliberate close
		-- (not a held-mode release, which may have just opened a child via nesting)
		local group2 = WiseDB.groups[self.groupName]
		local isHeld = group2 and group2.visibilitySettings and group2.visibilitySettings.held
		local customShow2 = group2 and group2.visibilitySettings and group2.visibilitySettings.customShow or ""
		local autoHeld2 = customShow2:find("wise:", 1, true)
		if not isHeld and not autoHeld2 then
			Wise:CloseChildInterfaces(self.groupName)
		end

		local group = WiseDB.groups[self.groupName]

		-- For mouse anchor mode: unlock and keep tracking while hidden
		-- This allows the frame to appear at cursor position on next show
		if group and group.anchorMode == "mouse" then
			self.mouseAnchorLocked = false
			-- Keep OnUpdate running to track mouse position while hidden
		else
			self:SetScript("OnUpdate", nil)
		end

		if self.isClosing then
			-- This is the final hide after animation completed - allow it and reset flag
			self.isClosing = false
			self._closingAnimActive = false
			return
		end

		local group = WiseDB.groups[self.groupName]
		local shouldAnimateClose = (group and group.animation) or (self.parentInstanceId and self.parentAnimation)
		if shouldAnimateClose and not InCombatLockdown() then
			-- Detect WoW-forced UI hide (quest dialogues, cutscenes, etc.)
			-- UIParent is hidden during these events; fighting it with Show() causes
			-- animation state corruption that leaves frames permanently invisible.
			if not UIParent:IsShown() then
				-- WoW forced the hide — stop any in-flight animations and reset buttons
				Wise:StopSlideAnimations(self)
				for _, btn in ipairs(self.buttons or {}) do
					if not InCombatLockdown() then
						btn:ClearAllPoints()
						btn:SetPoint("CENTER", btn.targetX or 0, btn.targetY or 0)
					end
				end
				self.isClosing = false
				self._closingAnimActive = false
				return
			end
			-- User-initiated hide: animate close, then truly hide
			self.isClosing = true
			self._closingAnimActive = true
			self:Show()
			Wise:PlaySlideAnimation(self, false, function()
				self._closingAnimActive = false
				-- isClosing stays true so the Hide() call below passes through OnHide
				self:Hide()
			end)
		end
	end)

	-- Mousewheel scroll support: cycle through buttons on scroll
	f:EnableMouseWheel(false)
	f:SetScript("OnMouseWheel", function(self, delta)
		if not self.buttons or #self.buttons == 0 then
			return
		end
		if not self.toggleBtn then
			return
		end

		-- Build list of visible button names
		local visible = {}
		for _, btn in ipairs(self.buttons) do
			if btn:IsShown() then
				table.insert(visible, btn)
			end
		end
		if #visible == 0 then
			return
		end

		-- Find current hovered index
		local currentName = self.toggleBtn:GetAttribute("hoveredButton")
		local currentIdx = 0
		for i, btn in ipairs(visible) do
			if btn:GetName() == currentName then
				currentIdx = i
				break
			end
		end

		-- Advance by scroll direction (delta > 0 = up = previous, delta < 0 = down = next)
		local newIdx
		if currentIdx == 0 then
			newIdx = (delta > 0) and 1 or #visible
		else
			newIdx = currentIdx - delta
			if newIdx < 1 then
				newIdx = #visible
			end
			if newIdx > #visible then
				newIdx = 1
			end
		end

		local newBtn = visible[newIdx]
		if newBtn and not InCombatLockdown() then
			self.toggleBtn:SetAttribute("hoveredButton", newBtn:GetName())
			-- Update hover indication visuals
			for _, btn in ipairs(visible) do
				if btn:GetName() == newBtn:GetName() then
					ApplyHoverScale(btn, HOVER_SCALE)
					ShowHoverGlow(btn)
				else
					ApplyHoverScale(btn, 1.0)
					HideHoverGlow(btn)
				end
			end
		end
	end)

	-- Register with WiseStateDriver for cross-interface visibility
	local driver = WiseStateDriver
	f:SetFrameRef("WiseStateDriver", driver)

	local count = (driver:GetAttribute("frameCount") or 0) + 1
	driver:SetAttribute("frameCount", count)
	driver:SetFrameRef("Group_" .. count, f)
	f:SetAttribute("wiseDriverIndex", count)

	-- Secure snippet on each frame: evaluate [wise:] dependencies and set state-wise-show / state-wise-hide
	f:SetAttribute(
		"UpdateWiseState",
		[[
        local driver = self:GetFrameRef("WiseStateDriver")
        if not driver then return end

        local deps = self:GetAttribute("wise_dependencies") or ""
        if deps == "" then return end

        local shouldShow = false
        local shouldHide = false
        local myName = self:GetAttribute("wiseGroupName")

        -- Parse comma-separated dependency list
        for dep in deps:gmatch("[^,]+") do
            local depState = driver:GetAttribute("state_" .. dep) or "inactive"
            local isShowDep = self:GetAttribute("wise_dep_show_" .. dep)
            local isHideDep = self:GetAttribute("wise_dep_hide_" .. dep)

            if depState == "active" then
                if isShowDep then shouldShow = true end
                if isHideDep then shouldHide = true end
            end
        end

        self:SetAttribute("state-wise-show", shouldShow and "show" or "hide")
        self:SetAttribute("state-wise-hide", shouldHide and "show" or "hide")
    ]]
	)

	Wise.frames[frameKey] = f
	return f
end

local function Sanitize(str)
	if not str then
		return ""
	end

	-- 1. Remove solitary [wise:...] blocks entirely FIRST
	-- Handle separators to avoid parsing errors
	-- Replace [wise:...] with nothing, then clean up semicolons
	str = str:gsub("%s*%[%s*wise:[^%]]+%]%s*", "")

	-- Clean up semicolons (e.g. "; ;" -> ";")
	str = str:gsub(";", " ; ") -- padding
	str = str:gsub("%s+", " ") -- normalize spaces
	str = str:gsub("%s*;%s*", "; ") -- normalize semicolons
	str = str:gsub("^;%s*", "") -- remove leading
	str = str:gsub(";%s*$", "") -- remove trailing
	str = str:gsub("; ;", ";") -- remove doubles

	-- 2. Strip wise:name content inside mixed brackets (e.g. [mod:shift, wise:dev])
	-- (This handles "AND" logic inside single brackets)
	str = str:gsub("wise:[^,^%]]+,?", "") -- Remove "wise:name," or "wise:name"
	str = str:gsub(",?%s*wise:[^,^%]]+", "") -- Remove ", wise:name"

	-- 3. Convert [always] to []
	str = str:gsub("%[always%]", "[]")
	str = str:gsub("%[always, ", "[")

	-- Final Clean
	str = str:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")

	return str
end

local function SanitizeCustom(str)
	if not str then
		return ""
	end
	-- Replace each bracket group that contains ANY custom conditional with [actionbar:99]
	-- so the secure driver always returns false for it (Lua handles it via state-custom).
	str = str:gsub("%[([^%]]+)%]", function(inner)
		for token in inner:gmatch("[^,]+") do
			token = token:match("^%s*(.-)%s*$") -- trim
			local base = token:match("^no?(.+)") or token
			base = base:match("^([^:]+)") or base
			if CUSTOM_VIS_CONDITIONALS[base:lower()] then
				return "[actionbar:99]"
			end
		end
		return "[" .. inner .. "]"
	end)
	return str
end

-- Module 1: The Visibility Engine (Helper)
function Wise:BuildVisibilityDriver(f, group)
	local showStr = group.visibilitySettings.customShow or ""
	local hideStr = group.visibilitySettings.customHide or ""
	local name = f:GetAttribute("wiseGroupName") or ""

	-- Parse [wise:name] dependencies from show/hide strings
	-- Store as attributes so the secure UpdateWiseState snippet can evaluate them
	-- Clear old dependency attributes first (handles reconfiguration)
	local oldDeps = f:GetAttribute("wise_dependencies") or ""
	for oldDep in oldDeps:gmatch("[^,]+") do
		f:SetAttribute("wise_dep_show_" .. oldDep, nil)
		f:SetAttribute("wise_dep_hide_" .. oldDep, nil)
	end

	local deps = {}
	local depSet = {}

	for dep in showStr:gmatch("%[%s*wise:([^%],]+)") do
		dep = dep:match("^%s*(.-)%s*$") -- trim
		if string.lower(dep) ~= string.lower(name) then
			if not depSet[dep] then
				depSet[dep] = true
				table.insert(deps, dep)
			end
			f:SetAttribute("wise_dep_show_" .. dep, true)
		end
	end

	for dep in hideStr:gmatch("%[%s*wise:([^%],]+)") do
		dep = dep:match("^%s*(.-)%s*$") -- trim
		if string.lower(dep) ~= string.lower(name) then
			if not depSet[dep] then
				depSet[dep] = true
				table.insert(deps, dep)
			end
			f:SetAttribute("wise_dep_hide_" .. dep, true)
		end
	end

	f:SetAttribute("wise_dependencies", table.concat(deps, ","))

	showStr = Sanitize(showStr)
	hideStr = Sanitize(hideStr)

	showStr = Wise:SanitizeMacroCondition(showStr)
	hideStr = Wise:SanitizeMacroCondition(hideStr)

	showStr = SanitizeCustom(showStr)
	hideStr = SanitizeCustom(hideStr)

	-- Apply defaults AFTER sanitization (wise: tags and custom conditionals are now stripped).
	-- If both strings are effectively empty, set showStr based on interaction mode / base visibility.
	-- Hold/Toggle modes always default to hidden (state-manual controls visibility via hotkey).
	-- Only press_visible mode respects base visibility for the state-game driver.
	if showStr == "" and hideStr == "" then
		local mode = group.interaction or "toggle"
		local base = f.effectiveBaseVisibility or group.visibilitySettings.baseVisibility
		local isHeldOrToggle = (
			group.visibilitySettings.held
			or (f.effectiveToggleOnPress ~= nil and f.effectiveToggleOnPress or group.visibilitySettings.toggleOnPress)
		)

		if isHeldOrToggle then
			-- Hold/Toggle: driver stays empty → defaults to "hide"
			-- Visibility is controlled by state-manual via hotkey
		elseif mode == "press_visible" then
			if base == "COMBAT_ONLY" then
				showStr = "[combat]"
			elseif base == "NO_COMBAT_ONLY" then
				showStr = "[nocombat]"
			else
				showStr = "[]"
			end
		end
	end

	-- Construct Driver
	-- Logic: Hide conditions take precedence? Standard convention is typically Deny > Allow?
	-- If user has "Hide in Combat" and "Show on Shift". If I am in Combat+Shift:
	-- If Hide First: [combat] hide; [mod:shift] show; ... -> Hides.
	-- If Show First: [mod:shift] show; [combat] hide; ... -> Shows.
	-- Given the UI separates them, we need a deterministic order.
	-- Let's do HIDE then SHOW.

	local parts = {}

	if hideStr ~= "" then
		table.insert(parts, hideStr .. " hide")
	end

	-- Only add 'show' if we have a valid condition string (e.g. [mod:shift]) or explicit [always] ([])
	-- If Sanitize returned "", it means we stripped [wise:name] and had nothing left.
	-- In that case, we want the driver to default to HIDE (waiting for manual).
	if showStr ~= "" then
		table.insert(parts, showStr .. " show")
	end

	-- Default fallback if nothing matches
	table.insert(parts, "hide")

	local driverString = table.concat(parts, "; ")
	-- Note: We don't need to force "; hide" at the end because we explicitly added "hide" to parts list
	-- But just to be safe if table.concat does something weird with empty parts (it doesn't)

	RegisterStateDriver(f, "game", driverString)
	return driverString
end

-- Helper: Compute secure attributes for a given action data table
-- Returns secureType, secureAttr, secureValue
function Wise:SanitizeMacroCondition(str)
	if not str or str == "" then
		return str
	end
	str = str:gsub("%[([^%]]+)%]", function(inner)
		for token in inner:gmatch("[^,]+") do
			token = token:match("^%s*(.-)%s*$")
			local base, arg = token:match("^(.-):(.*)$")
			if not base then
				base = token
			end
			if base:lower() == "aml" then
				local isActive = false
				if WiseDB and WiseDB.addonMagicSlots then
					local slotName = (arg or ""):lower()
					for _, slot in ipairs(WiseDB.addonMagicSlots) do
						if slot.name and slot.name:lower() == slotName then
							isActive = true
							if slot.addons and #slot.addons > 0 then
								for _, a in ipairs(slot.addons) do
									if not C_AddOns.IsAddOnLoaded(a) then
										isActive = false
										break
									end
								end
							else
								isActive = false
							end
							break
						end
					end
				end

				if not isActive then
					return "[actionbar:99]"
				end
			end
		end

		local cleanInner = inner:gsub("%s*aml:[^,]+,?%s*", "")
		cleanInner = cleanInner:gsub(",%s*$", "")
		cleanInner = cleanInner:gsub("^%s*,", "")
		cleanInner = cleanInner:gsub(",%s*,", ",")

		if cleanInner:match("^%s*$") then
			return "[]"
		end
		return "[" .. cleanInner .. "]"
	end)
	return str
end

function Wise:GetSecureAttributes(actionData, conditions)
	local aType = actionData.type
	local aValue = actionData.value
	local hasCond = conditions and conditions ~= ""

	if hasCond then
		conditions = Wise:SanitizeMacroCondition(conditions)
		-- Strip Wise custom conditionals (zoneability, bank, undermouse, …) before they
		-- get baked into a /cast|/use macrotext below. WoW's macro engine only knows
		-- native options, so a leaked custom token makes the client print
		-- "unknown macro option: <name>" when the macro is parsed (notably on reload).
		-- SanitizeCustom rewrites any bracket carrying a custom token to [actionbar:99]
		-- (always-false in the secure context); these conditions are resolved insecurely
		-- for display instead.
		conditions = SanitizeCustom(conditions)
		-- If our sanitization wiped it out or replaced it, update hasCond
		-- "[]" is technically still a condition, but we want it to apply as unconditional
		if conditions == "[]" then
			conditions = ""
			hasCond = false
		end
	end

	local secureType = "macro"
	local secureAttr = "macrotext"
	local secureValue = ""

	if aType == "spell" then
		-- Resolve spell name for use in commands
		-- Use GetOverrideSpell to handle talent-replaced spells (e.g. skyriding abilities)
		local spellName
		local castID
		local n = tonumber(aValue)
		if n then
			castID = Wise:GetOverrideSpellID(n) or n
			local info = C_Spell.GetSpellInfo(castID)
			if not info then
				-- Fallback to original ID if override lookup failed
				info = C_Spell.GetSpellInfo(n)
			end
			spellName = info and info.name
		else
			spellName = aValue
		end
		-- Final fallback: use the stored display name from the action data
		local fallbackName = spellName or actionData.name or aValue
		-- Build subtext-qualified name for /cast commands (e.g. "Whirling Surge(Skyriding)")
		-- Only append subtext "Skyriding" — those abilities share names across subsystems
		-- and require the qualifier.  Other subtexts (e.g. form-specific labels on Wild
		-- Charge) are form-dependent and break /cast when the player changes shapeshift form.
		local castName = fallbackName
		if n and C_Spell.GetSpellSubtext then
			local subtext = C_Spell.GetSpellSubtext(castID or n)
			if subtext and subtext == "Skyriding" then
				castName = fallbackName .. "(" .. subtext .. ")"
			end
		end
		if hasCond then
			secureType = "macro"
			secureAttr = "macrotext"
			-- Conditional /cast lines MUST use the spell NAME, not the numeric ID:
			-- retail's /cast does not accept a bare spell ID (a line like
			-- "/cast [cond] 1229376" silently casts nothing — only "/cast [cond] Name"
			-- works). An earlier version emitted the ID so ResolveMacroData could resolve
			-- the icon for spells not known by name (e.g. untalented talents); that icon
			-- need is now met by the node's stored icon, which the runtime carries through
			-- as a fallback (FilterMacroTextForCharacter's resolvedIcon), so we no longer
			-- have to sacrifice castability for it.
			secureValue = "/cast " .. conditions .. " " .. castName
		else
			secureType = "spell"
			secureAttr = "spell"
			-- type="spell" accepts both spell names and spell IDs
			secureValue = spellName or castID or aValue
		end
	elseif aType == "item" or aType == "toy" then
		if hasCond then
			secureType = "macro"
			secureAttr = "macrotext"
			local n = tonumber(aValue)
			local itemRef = n and ("item:" .. n) or aValue
			secureValue = "/use " .. conditions .. " " .. itemRef
		else
			secureType = "item"
			secureAttr = "item"
			local n = tonumber(aValue)
			if n then
				secureValue = "item:" .. n
			else
				secureValue = aValue
			end
		end
	elseif aType == "macro" then
		secureType = "macro"
		-- Append slash command arguments if present (e.g. "/ti" + "window" → "/ti window")
		local effectiveValue = aValue
		if actionData.slashArgs and actionData.slashArgs ~= "" then
			effectiveValue = aValue .. " " .. actionData.slashArgs
		end
		if string.sub(effectiveValue, 1, 1) == "/" then
			secureAttr = "macrotext"
			if hasCond then
				-- e.g. "/click ActionButton1" -> "/click [possessbar] ActionButton1"
				local cmd, rest = string.match(effectiveValue, "^(/%a+)%s+(.*)$")
				if cmd and rest then
					secureValue = cmd .. " " .. conditions .. " " .. rest
				else
					secureValue = effectiveValue
				end
			else
				secureValue = effectiveValue
			end
		else
			secureAttr = "macro"
			secureValue = effectiveValue
		end
	elseif aType == "action" then
		local aNum = tonumber(aValue)
		local isOverride = aNum and aNum >= 133 and aNum <= 144
		local isPossess = aNum and (aNum >= 121 and aNum <= 132 or aNum >= 145 and aNum <= 156)

		if isOverride or isPossess then
			secureType = "macro"
			secureAttr = "macrotext"
			local resolvedCond = conditions or ""
			if isPossess then
				if resolvedCond == "" or resolvedCond == "[possessbar]" then
					resolvedCond = "[possessbar][vehicleui]"
				end

				local vehicleParts = {}
				local possessParts = {}
				for block in resolvedCond:gmatch("%[([^%]]*)%]") do
					local cleanBlock = {}
					for token in block:gmatch("[^,]+") do
						local t = token:match("^%s*(.-)%s*$")
						if t ~= "possessbar" and t ~= "vehicleui" and t ~= "" then
							table.insert(cleanBlock, t)
						end
					end
					local subCond = table.concat(cleanBlock, ",")
					local vPart = subCond ~= "" and ("[vehicleui," .. subCond .. "]") or "[vehicleui]"
					local pPart = subCond ~= "" and ("[possessbar," .. subCond .. "]") or "[possessbar]"

					local vExists = false
					for _, val in ipairs(vehicleParts) do
						if val == vPart then
							vExists = true
							break
						end
					end
					if not vExists then
						table.insert(vehicleParts, vPart)
					end

					local pExists = false
					for _, val in ipairs(possessParts) do
						if val == pPart then
							pExists = true
							break
						end
					end
					if not pExists then
						table.insert(possessParts, pPart)
					end
				end

				if #vehicleParts == 0 then
					table.insert(vehicleParts, "[vehicleui]")
					table.insert(possessParts, "[possessbar]")
				end

				local condVehicle = table.concat(vehicleParts)
				local condPossess = table.concat(possessParts)

				local offset = (aNum >= 145 and aNum <= 156) and 144 or 120
				secureValue = "/click "
					.. condVehicle
					.. " OverrideActionBarButton"
					.. (aNum - offset)
					.. "\n/click "
					.. condPossess
					.. " ActionButton"
					.. (aNum - offset)
			else
				if resolvedCond == "" then
					resolvedCond = "[overridebar]"
				end
				local prefix = resolvedCond ~= "" and (resolvedCond .. " ") or ""
				secureValue = "/click " .. prefix .. "OverrideActionBarButton" .. (aNum - 132)
			end
		elseif hasCond then
			secureType = "macro"
			secureAttr = "macrotext"
			secureValue = "/click " .. conditions .. " ActionButton" .. (aNum or 1)
		else
			secureType = "action"
			secureAttr = "action"
			secureValue = tonumber(aValue)
		end
	elseif aType == "mount" then
		if C_MountJournal then
			local mountName, spellID = C_MountJournal.GetMountInfoByID(aValue)
			if hasCond then
				secureType = "macro"
				secureAttr = "macrotext"
				if spellID then
					local info = C_Spell.GetSpellInfo(spellID)
					local castName = (info and info.name) or spellID
					secureValue = "/cast " .. conditions .. " " .. castName
				else
					secureValue = "/use " .. conditions .. " " .. (mountName or "")
				end
			else
				if spellID then
					secureType = "spell"
					secureAttr = "spell"
					secureValue = spellID
				else
					secureValue = "/use " .. (mountName or "")
				end
			end
		end
	elseif aType == "battlepet" then
		secureValue = "/summonpet " .. (aValue or "")
	elseif aType == "equipped" then
		if hasCond then
			secureValue = "/use " .. conditions .. " " .. (aValue or 1)
		else
			secureValue = "/use " .. (aValue or 1)
		end
	elseif aType == "equipmentset" then
		secureValue = "/equipset " .. (aValue or "")
	elseif aType == "raidmarker" then
		secureValue = "/tm " .. (aValue or 0)
	elseif aType == "worldmarker" then
		secureValue = "/wm " .. (aValue or 0)
	elseif aType == "uivisibility" then
		secureType = "macro"
		secureAttr = "macrotext"

		local element, state = strsplit(":", aValue)
		local frameName

		if element == "minimap" then
			frameName = "MinimapCluster"
		elseif element == "micromenu" then
			frameName = "MicroMenuContainer"
		elseif element == "bags" then
			frameName = "BagsBar"
		elseif element == "xpbar" or element == "repbar" then
			frameName = "StatusTrackingBarManager"
		elseif element == "objectives" then
			frameName = "ObjectiveTrackerFrame"
		elseif element == "player" then
			frameName = "PlayerFrame"
		elseif element == "target" then
			frameName = "TargetFrame"
		elseif element == "buffs" then
			frameName = "BuffFrame"
		elseif element == "debuffs" then
			frameName = "DebuffFrame"
		elseif element == "social" then
			frameName = "QuickJoinToastButton"
		elseif element == "chatchannels" then
			frameName = "ChatFrameChannelButton"
		elseif element == "quickchat" then
			frameName = "ChatFrameMenuButton"
		end

		if element == "editmode" then
			secureValue = '/run Wise:SelectEditModeLayout("' .. state .. '")'
		elseif element == "friendlynameplates" or element == "enemynameplates" then
			local cvar = element == "friendlynameplates" and "nameplateShowFriends" or "nameplateShowEnemies"
			local op = ""
			if state == "show" then
				op = 'SetCVar("' .. cvar .. '", 1)'
			elseif state == "hide" then
				op = 'SetCVar("' .. cvar .. '", 0)'
			elseif state == "toggle" then
				op = 'SetCVar("' .. cvar .. '", GetCVar("' .. cvar .. '") == "1" and 0 or 1)'
			end
			if op ~= "" then
				secureValue = "/run " .. op
			end
		elseif element == "chat" then
			local op = ""
			if state == "show" then
				op = "ChatFrame1:Show(); if GeneralDockManager then GeneralDockManager:Show() end"
			elseif state == "hide" then
				op = "ChatFrame1:Hide(); if GeneralDockManager then GeneralDockManager:Hide() end"
			elseif state == "toggle" then
				op =
					"local v=not ChatFrame1:IsShown(); ChatFrame1:SetShown(v); if GeneralDockManager then GeneralDockManager:SetShown(v) end"
			end
			if op ~= "" then
				secureValue = "/run if ChatFrame1 then " .. op .. " end"
			end
		elseif frameName then
			local op = ""
			if state == "show" then
				op = ":Show()"
			elseif state == "hide" then
				op = ":Hide()"
			elseif state == "toggle" then
				op = ":SetShown(not " .. frameName .. ":IsShown())"
			end

			if op ~= "" then
				secureValue = "/run if " .. frameName .. " then " .. frameName .. op .. " end"
			end
		end
	elseif aType == "uipanel" then
		local microButtons = {
			achievements = "AchievementMicroButton",
			questlog = "QuestLogMicroButton",
			groupfinder = "LFDMicroButton",
			adventureguide = "EJMicroButton",
			collections = "CollectionsMicroButton",
			shop = "StoreMicroButton",
			professions = "ProfessionMicroButton",
			housing = "HousingMicroButton",
		}

		local btnName = microButtons[aValue]
		local btnFrame = btnName and _G[btnName]

		if not btnFrame and btnName then
			if _G.MicroMenuContainer and _G.MicroMenuContainer[btnName] then
				btnFrame = _G.MicroMenuContainer[btnName]
			elseif _G.MicroMenu and _G.MicroMenu[btnName] then
				btnFrame = _G.MicroMenu[btnName]
			end
		end

		if btnFrame then
			secureType = "click"
			secureAttr = "clickbutton"
			secureValue = btnFrame
		else
			local scripts = {
				character = "ToggleCharacter('PaperDollFrame')",
				spellbook = "C_AddOns.LoadAddOn('Blizzard_PlayerSpells');if PlayerSpellsUtil and PlayerSpellsUtil.ToggleSpellBookFrame then PlayerSpellsUtil.ToggleSpellBookFrame() end",
				talents = "C_AddOns.LoadAddOn('Blizzard_PlayerSpells');if PlayerSpellsUtil and PlayerSpellsUtil.ToggleClassTalentOrSpecFrame then PlayerSpellsUtil.ToggleClassTalentOrSpecFrame() end",
				specialization = "C_AddOns.LoadAddOn('Blizzard_PlayerSpells');local f=PlayerSpellsFrame;if f then if f:IsShown() then HideUIPanel(f) else ShowUIPanel(f);if f.TabSystem then f.TabSystem:SetTab(1) end end end",
				collections = "if ToggleCollectionsJournal then ToggleCollectionsJournal() end",
				map = "if ToggleWorldMap then ToggleWorldMap() end",
				groupfinder = "if PVEFrame_ToggleFrame then PVEFrame_ToggleFrame() end",
				adventureguide = "if EncounterJournal_OpenJournal then EncounterJournal_OpenJournal() end",
				achievements = "if ToggleAchievementFrame then ToggleAchievementFrame() end",
				guild = "if ToggleCommunitiesFrame then ToggleCommunitiesFrame() elseif ToggleGuildFrame then ToggleGuildFrame() end",
				menu = "ToggleFrame(GameMenuFrame)",
				shop = "if ToggleStoreUI then ToggleStoreUI() end",
				questlog = "if ToggleQuestLog then ToggleQuestLog() end",
				professions = "if ToggleProfessionsBook then ToggleProfessionsBook() end",
				housing = "if ToggleHousingDashboard then ToggleHousingDashboard() elseif HousingFramesUtil and HousingFramesUtil.ToggleHousingDashboard then HousingFramesUtil.ToggleHousingDashboard() elseif C_Housing and C_Housing.ToggleHousingDashboard then C_Housing.ToggleHousingDashboard() end",
				bag_backpack = "ToggleBackpack()",
				bag_1 = "ToggleBag(1)",
				bag_2 = "ToggleBag(2)",
				bag_3 = "ToggleBag(3)",
				bag_4 = "ToggleBag(4)",
				bag_reagent = "ToggleBag(5)",
				bag_all = "ToggleAllBags()",
				collections_mounts = "if ToggleCollectionsJournal then ToggleCollectionsJournal(1) end",
				collections_pets = "if ToggleCollectionsJournal then ToggleCollectionsJournal(2) end",
				collections_toys = "if ToggleCollectionsJournal then ToggleCollectionsJournal(3) end",
				collections_heirlooms = "if ToggleCollectionsJournal then ToggleCollectionsJournal(4) end",
				collections_appearances = "if ToggleCollectionsJournal then ToggleCollectionsJournal(5) end",
				social = "if ToggleFriendsFrame then ToggleFriendsFrame(1) end",
				social_friends = "if ToggleFriendsFrame then ToggleFriendsFrame(1) end",
				social_who = "if ToggleFriendsFrame then ToggleFriendsFrame(2) end",
				social_raid = "if ToggleFriendsFrame then ToggleFriendsFrame(3) end",
				pvp = "if TogglePVPUI then TogglePVPUI() elseif PVEFrame_ToggleFrame then PVEFrame_ToggleFrame('PVPUIFrame') end",
				dungeons = "PVEFrame_ToggleFrame('GroupFinderFrame')",
				mythicplus = "if PVEFrame and PVEFrame:IsShown() and PVEFrame.activeTabIndex == 3 then HideUIPanel(PVEFrame) else ShowUIPanel(PVEFrame) PVEFrame_ShowFrame('ChallengesFrame') end",
				reputation = "ToggleCharacter('ReputationFrame')",
				currency = "ToggleCharacter('TokenFrame')",
				statistics = "AchievementFrame_LoadUI() AchievementFrame_ToggleAchievementFrame(true)",
				map_size = "if ToggleWorldMap then ToggleWorldMap() end",
				map_zone = "if ToggleBattlefieldMap then ToggleBattlefieldMap() end",
				map_minimap = "if Minimap then if Minimap:IsShown() then Minimap:Hide() else Minimap:Show() end end",
				garrison = "if ExpansionLandingPageMinimapButton then ExpansionLandingPageMinimapButton.Click(ExpansionLandingPageMinimapButton) elseif GarrisonLandingPageMinimapButton then GarrisonLandingPageMinimapButton.Click(GarrisonLandingPageMinimapButton) end",
				omnium_folio = "if ToggleExpansionLandingPage then ToggleExpansionLandingPage() end",
			}
			if scripts[aValue] then
				secureValue = "/run " .. scripts[aValue]
			end
		end
	elseif aType == "misc" then
		if aValue == "hearthstone" then
			secureType = "item"
			secureAttr = "item"
			secureValue = "Hearthstone"
		elseif aValue == "extrabutton" then
			secureType = "macro"
			secureAttr = "macrotext"
			secureValue = "/click ExtraActionButton1"
		elseif aValue == "zoneability" then
			secureType = "click"
			secureAttr = "clickbutton"
			local zoneBtn = GetZoneAbilitySpellButton()
			if zoneBtn then
				secureValue = zoneBtn
				if zoneBtn.GetName and zoneBtn:GetName() then
					secureType = "macro"
					secureAttr = "macrotext"
					secureValue = "/click " .. zoneBtn:GetName()
				elseif zoneBtn.spellID then
					secureType = "spell"
					secureAttr = "spell"
					secureValue = zoneBtn.spellID
				end
			end
		elseif aValue == "overridebar" then
			secureType = "click"
			secureAttr = "clickbutton"
			local overrideBtn = _G["OverrideActionBarButton1"]
			if overrideBtn then
				secureValue = overrideBtn
				if overrideBtn.GetName and overrideBtn:GetName() then
					secureType = "macro"
					secureAttr = "macrotext"
					secureValue = "/click " .. overrideBtn:GetName()
				end
			end
		elseif aValue == "possessbar" then
			secureType = "macro"
			secureAttr = "macrotext"
			secureValue = "/click [vehicleui] OverrideActionBarButton1; [possessbar] ActionButton1"
		elseif aValue == "leave_vehicle" then
			secureType = "macro"
			secureAttr = "macrotext"
			secureValue = "/leavevehicle"
		elseif aValue == "toggle_sound" then
			secureType = "macro"
			secureAttr = "macrotext"
			secureValue =
				'/run local c="Sound_EnableAllSound" local v=GetCVar(c) local n=v=="1" and "0" or "1" SetCVar(c, n) print("Wise toggle sound " .. (n=="1" and "on" or "off"))'
		elseif aValue == "toggle_sfx" then
			secureType = "macro"
			secureAttr = "macrotext"
			secureValue =
				'/run local c="Sound_EnableSFX" local v=GetCVar(c) local n=v=="1" and "0" or "1" SetCVar(c, n) print("Wise toggle sound effects " .. (n=="1" and "on" or "off"))'
		elseif aValue == "vol_up" then
			secureType = "macro"
			secureAttr = "macrotext"
			secureValue =
				'/run local c="Sound_MasterVolume" local v=tonumber(GetCVar(c)) if v then local n=math.min(1, v+0.1) SetCVar(c, tostring(n)) print("Wise volume set to " .. math.floor(n*100) .. "%") end'
		elseif aValue == "vol_down" then
			secureType = "macro"
			secureAttr = "macrotext"
			secureValue =
				'/run local c="Sound_MasterVolume" local v=tonumber(GetCVar(c)) if v then local n=math.max(0, v-0.1) SetCVar(c, tostring(n)) print("Wise volume set to " .. math.floor(n*100) .. "%") end'
		elseif aValue == "custom_macro" then
			secureType = "macro"
			secureAttr = "macrotext"
			secureValue = actionData.macroText or ""
		elseif aValue:match("^spec_equip_") then
			local slotIdx = tonumber(aValue:match("^spec_equip_(%d+)"))
			secureType = "macro"
			secureAttr = "macrotext"
			if slotIdx then
				secureValue = "/run Wise:ExecuteSpecEquip(" .. slotIdx .. ")"
			else
				secureValue = "/run print('[Wise] Invalid spec_equip action')"
			end
		elseif aValue:match("^spec_") then
			local val = tonumber(aValue:match("^spec_(%d+)"))
			local specIndex = val
			if val and val > 10 then
				specIndex = 1
				for i = 1, GetNumSpecializations() do
					local id = GetSpecializationInfo(i)
					if id == val then
						specIndex = i
						break
					end
				end
			end
			secureType = "macro"
			secureAttr = "macrotext"
			if specIndex then
				secureValue = "/run local func = C_SpecializationInfo and C_SpecializationInfo.SetSpecialization or SetSpecialization; if func then func("
					.. specIndex
					.. ") else print('[Wise] SetSpecialization API not found') end"
			else
				secureValue = "/run print('[Wise] Unknown spec value')"
			end
		elseif aValue:match("^form_") then
			local formIndex = tonumber(aValue:match("^form_(%d+)"))
			if formIndex then
				local icon, isActive, isCastable, spellID = GetShapeshiftFormInfo(formIndex)
				if spellID then
					local info = C_Spell.GetSpellInfo(spellID)
					local castName = (info and info.name) or spellID
					secureType = "macro"
					secureAttr = "macrotext"
					secureValue = "/cast " .. castName
				end
			end
		elseif aValue:match("^lootspec_") then
			local specID = tonumber(aValue:match("^lootspec_(%d+)"))
			secureType = "macro"
			secureAttr = "macrotext"
			secureValue = "/run local func = C_SpecializationInfo and C_SpecializationInfo.SetLootSpecialization or SetLootSpecialization; if func then func("
				.. specID
				.. ") else print('[Wise] SetLootSpecialization API not found') end"
		elseif aValue:match("^addon_magic_") then
			local slotIdx = tonumber(aValue:match("^addon_magic_(%d+)"))
			secureType = "macro"
			secureAttr = "macrotext"
			secureValue = "/run if Wise and Wise.ExecuteAddonMagic then Wise:ExecuteAddonMagic("
				.. (slotIdx or 1)
				.. ") end"
		end
	elseif aType == "interface" then
		-- Toggle the target interface via its secure toggle button.
		-- Must use macro+macrotext because the toggleBtn PreClick snippet
		-- only copies type/spell/item/macrotext (not clickbutton).
		secureType = "macro"
		secureAttr = "macrotext"
		secureValue = "/click WiseGroupToggle_" .. aValue
	elseif aType == "empty" then
		secureType = nil
		secureAttr = nil
		secureValue = nil
	end

	return secureType, secureAttr, secureValue
end

-- Helper: Evaluate slot conditions (insecure context) for icon updates
function Wise:EvaluateSlotConditions(states, conflictStrategy, btn)
	local matches = {}
	for i, state in ipairs(states) do
		local cond = Wise:ComputeEffectiveConditions(states, i)
		-- EvalConditionExact evaluates native tokens (combat, mod, …) via
		-- SecureCmdOptionParse AND Wise custom tokens (zoneability, bank, …) insecurely.
		-- Passing a raw custom token straight to SecureCmdOptionParse made the client
		-- print "unknown macro option: <name>" on every display refresh, including reload.
		if EvalConditionExact(cond) then
			tinsert(matches, i)
		end
	end

	-- No state's condition currently matches. Return state 1 as the icon to DISPLAY
	-- (so the slot stays populated), but signal hadMatch=false so callers can grey it
	-- out — nothing would actually fire right now. The secure cast path has no such
	-- fallback (an unmatched macro simply does nothing), so without this flag the icon
	-- would misleadingly show state 1 fully lit while a click casts nothing.
	if #matches == 0 then
		return 1, false
	end

	if conflictStrategy == "sequence" then
		local seq = (btn and btn:GetAttribute("isa_seq")) or 1
		local startIdx = nil
		for i = 1, #matches do
			if matches[i] >= seq then
				startIdx = i
				break
			end
		end
		if not startIdx then
			startIdx = 1
		end
		return matches[startIdx], true
	elseif conflictStrategy == "random" then
		return matches[math.random(#matches)], true
	elseif conflictStrategy == "waterfall" then
		-- Walk matches in priority order; return the first off-cooldown action so the
		-- icon mirrors what the engine's stacked-/cast macro will actually fire.
		for i = 1, #matches do
			local state = states[matches[i]]
			if state and not Wise:IsActionOnCooldown(state.type, state.value, state) then
				return matches[i], true
			end
		end
		return matches[1], true
	else
		-- priority (default)
		return matches[1], true
	end
end

-- Helper: Find if a group is nested inside another group as an "interface" action.
-- Returns parentName, parentGroup or nil if not nested.
function Wise:GetParentInfo(groupName)
	if not WiseDB or not WiseDB.groups then
		return nil, nil
	end
	for name, group in pairs(WiseDB.groups) do
		if name ~= groupName and group.actions then
			for slotIdx, states in pairs(group.actions) do
				if type(slotIdx) == "number" and type(states) == "table" then
					for _, action in ipairs(states) do
						if action.type == "interface" and action.value == groupName then
							return name, group
						end
					end
				end
			end
		end
		-- Also check legacy buttons
		if name ~= groupName and group.buttons then
			for _, action in ipairs(group.buttons) do
				if action.type == "interface" and action.value == groupName then
					return name, group
				end
			end
		end
	end
	return nil, nil
end

-- Helper: Store a child group's resolved actions on a parent interface button
-- so the secure RESOLVE_BLOCK can execute them for rotation modes.
function Wise:StoreChildActionsOnButton(btn, childGroupName, nestMode)
	-- Clear previous child attributes
	local prevCount = btn:GetAttribute("isa_nest_count") or 0
	for ci = 1, prevCount do
		btn:SetAttribute("isa_nest_type_" .. ci, nil)
		btn:SetAttribute("isa_nest_spell_" .. ci, nil)
		btn:SetAttribute("isa_nest_item_" .. ci, nil)
		btn:SetAttribute("isa_nest_macrotext_" .. ci, nil)
		btn:SetAttribute("isa_nest_clickbutton_name_" .. ci, nil)
		btn:SetAttribute("isa_nest_cond_" .. ci, nil)
	end
	btn:SetAttribute("isa_nest_count", 0)

	if nestMode == "jump" or nestMode == nil then
		return
	end -- Jump mode opens child, no attrs needed

	local childGroup = WiseDB and WiseDB.groups and WiseDB.groups[childGroupName]
	if not childGroup or not childGroup.actions then
		return
	end

	-- Collect child slots in order
	local slots = {}
	for slotIdx, states in pairs(childGroup.actions) do
		if type(slotIdx) == "number" and type(states) == "table" then
			table.insert(slots, { index = slotIdx, states = states })
		end
	end
	table.sort(slots, function(a, b)
		return a.index < b.index
	end)

	local nestIdx = 0
	for _, slotInfo in ipairs(slots) do
		local states = slotInfo.states
		-- For single-state slots, use the action directly
		-- For multi-state slots, resolve to the currently active one (insecure)
		local activeState = 1
		if #states > 1 then
			local conflictStrategy = states.conflictStrategy or "priority"
			activeState = Wise:EvaluateSlotConditions(states, conflictStrategy, nil) or 1
		end
		local action = states[activeState]
		if action and action.type and action.type ~= "interface" then
			nestIdx = nestIdx + 1
			local sType, sAttr, sValue = Wise:GetSecureAttributes(action, action.conditions)
			local spellVal = (sAttr == "spell") and tostring(sValue) or ""
			local itemVal = (sAttr == "item") and tostring(sValue) or ""
			local macroVal = (sAttr == "macrotext" or sAttr == "macro") and tostring(sValue) or ""
			local cbName = ""
			if sType == "click" and sValue and type(sValue) == "table" and sValue.GetName then
				cbName = sValue:GetName() or ""
			elseif sType == "click" and type(sValue) == "string" then
				cbName = sValue
			end
			btn:SetAttribute("isa_nest_type_" .. nestIdx, sType)
			btn:SetAttribute("isa_nest_spell_" .. nestIdx, spellVal)
			btn:SetAttribute("isa_nest_item_" .. nestIdx, itemVal)
			btn:SetAttribute("isa_nest_macrotext_" .. nestIdx, macroVal)
			btn:SetAttribute("isa_nest_clickbutton_name_" .. nestIdx, cbName)
			-- isa_nest_cond_N is fed to SecureCmdOptionParse in the secure RESOLVE_BLOCK,
			-- so strip Wise custom conditionals (else WoW prints "unknown macro option").
			btn:SetAttribute("isa_nest_cond_" .. nestIdx, SanitizeCustom(action.conditions or ""))
		end
	end
	btn:SetAttribute("isa_nest_count", nestIdx)
	-- Initialize sequence counter for cycle mode
	if not btn:GetAttribute("isa_nest_seq") then
		btn:SetAttribute("isa_nest_seq", 1)
	end
end

-- Helper: Get the icon for the current rotation action on an interface button.
-- For cycle, shows the NEXT action. For random/priority, shows best match.
function Wise:GetRotationIcon(btn, childGroupName, nestMode)
	local childGroup = WiseDB and WiseDB.groups and WiseDB.groups[childGroupName]
	if not childGroup or not childGroup.actions then
		return nil
	end

	-- Collect child slots in order
	local slots = {}
	for slotIdx, states in pairs(childGroup.actions) do
		if type(slotIdx) == "number" and type(states) == "table" then
			table.insert(slots, { index = slotIdx, states = states })
		end
	end
	table.sort(slots, function(a, b)
		return a.index < b.index
	end)

	-- Build list of valid (non-interface) child actions with resolved states
	local actions = {}
	for _, slotInfo in ipairs(slots) do
		local states = slotInfo.states
		local activeState = 1
		if #states > 1 then
			local cs = states.conflictStrategy or "priority"
			activeState = Wise:EvaluateSlotConditions(states, cs, nil) or 1
		end
		local action = states[activeState]
		if action and action.type and action.type ~= "interface" then
			table.insert(actions, action)
		end
	end

	if #actions == 0 then
		return nil
	end

	local chosen = nil
	if nestMode == "priority" then
		-- Show first action whose conditions match. EvalConditionExact evaluates native
		-- tokens securely and Wise custom tokens insecurely; feeding a raw custom token to
		-- SecureCmdOptionParse printed "unknown macro option" on every refresh.
		for _, action in ipairs(actions) do
			if EvalConditionExact(action.conditions or "") then
				chosen = action
				break
			end
		end
		if not chosen then
			chosen = actions[1]
		end
	elseif nestMode == "cycle" then
		-- Show the action at current sequence position
		local seq = btn:GetAttribute("isa_nest_seq") or 1
		local idx = ((seq - 1) % #actions) + 1
		chosen = actions[idx]
	elseif nestMode == "random" then
		-- For random, just show the first action (can't predict)
		chosen = actions[1]
	end

	if chosen then
		return Wise:GetActionIcon(chosen.type, chosen.value, chosen)
	end
	return nil
end

function Wise:UpdateGroupDisplay(name, instanceId, overrideOpts)
	-- Protect against combat execution (SecureStateDriver, SetPoint, etc.)
	if InCombatLockdown() then
		Wise.pendingUpdates = Wise.pendingUpdates or {}
		Wise.pendingUpdates[name] = true
		return
	end

	local group = WiseDB.groups[name]
	if not group then
		return
	end

	local f = Wise:CreateGroupFrame(name, instanceId)

	f.instanceId = instanceId or name
	if overrideOpts and overrideOpts._parentInstanceId then
		f.parentInstanceId = overrideOpts._parentInstanceId
	end

	-- Apply Anchor (only for fixed mode or initial positioning)
	-- Mouse mode will reposition via OnUpdate
	-- Determine if this is the "Always Visible + Mouse" mode
	-- Determine if this is the "Always Visible + Mouse" mode
	local displayType = group.type or "circle"
	if overrideOpts and overrideOpts.nestedInterfaceType and overrideOpts.nestedInterfaceType ~= "default" then
		displayType = overrideOpts.nestedInterfaceType
	end
	f.effectiveDisplayType = displayType

	-- Allow nesting options to override the child's dynamic/static style
	local isDynamic = group.dynamic
	if overrideOpts and overrideOpts.nestedInterfaceStyle and overrideOpts.nestedInterfaceStyle ~= "default" then
		isDynamic = (overrideOpts.nestedInterfaceStyle == "dynamic")
	end

	local mode = group.interaction or "toggle"

	-- Check Availability override (Wiser interfaces)
	if not Wise:IsGroupAvailable(name) then
		RegisterStateDriver(f, "visibility", "hide")
		f:Hide()
		return
	else
		-- Clear any previous "visibility" hide driver that may have been registered
		-- when the group was unavailable. Without this, the "visibility" state driver
		-- permanently overrides all other visibility logic (game, manual, custom, etc.).
		UnregisterStateDriver(f, "visibility")
	end

	-- Migration: Ensure baseVisibility is set if missing
	if not group.visibilitySettings then
		group.visibilitySettings = {}
	end
	if not group.visibilitySettings.baseVisibility then
		if group.visibilitySettings.always then
			group.visibilitySettings.baseVisibility = "ALWAYS_VISIBLE"
		elseif group.visibilitySettings.combat then
			group.visibilitySettings.baseVisibility = "COMBAT_ONLY"
		elseif group.visibilitySettings.nocombat then
			group.visibilitySettings.baseVisibility = "NO_COMBAT_ONLY"
		else
			group.visibilitySettings.baseVisibility = "ALWAYS_HIDDEN"
		end
	end

	-- Ensure defaults
	group.keybindSettings = group.keybindSettings or { trigger = "press" }

	-- Nested Interface Detection (used for strata, toggleOnPress, visibility)
	local parentName, parentGroup = Wise:GetParentInfo(name)

	-- Determine if THIS invocation is for a nested instance (has instanceId from parent)
	-- vs the base/standalone instance. A group can be both standalone (own keybind)
	-- AND nested (referenced as interface action in another group). Only the nested
	-- instance should be forced to toggleOnPress/ALWAYS_HIDDEN.
	local isNestedInstance = (instanceId ~= nil) and parentName and not group.isWiser

	-- Effective visibility overrides for nested instances only (never mutate saved data)
	local effectiveToggleOnPress = group.visibilitySettings.toggleOnPress
	local effectiveBaseVisibility = group.visibilitySettings.baseVisibility
	local effectiveHideOnUse = group.visibilitySettings.hideOnUse

	if isNestedInstance then
		effectiveToggleOnPress = true
		effectiveBaseVisibility = "ALWAYS_HIDDEN"
		if effectiveHideOnUse == nil then
			effectiveHideOnUse = true
		end
	end

	-- Store effective values on frame for BuildVisibilityDriver and toggleBtn setup
	f.effectiveToggleOnPress = effectiveToggleOnPress
	f.effectiveBaseVisibility = effectiveBaseVisibility
	f.effectiveHideOnUse = effectiveHideOnUse

	-- Nested instances inherit animation from parent
	if isNestedInstance and overrideOpts then
		f.parentAnimation = overrideOpts._parentAnimation or false
	end

	-- Module 1: Apply Visibility Engine
	local driverString = Wise:BuildVisibilityDriver(f, group)

	-- Apply Strata based on anchor mode and nesting depth
	local baseStrata = (group.anchorMode == "mouse") and "TOOLTIP" or "MEDIUM"
	-- Nested interfaces must render above their parent so their buttons are clickable
	if parentName then
		if baseStrata == "MEDIUM" then
			baseStrata = "HIGH"
		end
	end
	if Wise.editMode then
		f.originalStrata = baseStrata
	else
		f:SetFrameStrata(baseStrata)
	end

	-- Gatekeeper Logic: Union of all visibility sources, with wise-hide override
	local gatekeeper = [[
        local editmode = self:GetAttribute("state-editmode") or "hide"
        local game = self:GetAttribute("state-game") or "hide"
        local manual = self:GetAttribute("state-manual") or "hide"
        local custom = self:GetAttribute("state-custom") or "hide"
        local wiseShow = self:GetAttribute("state-wise-show") or "hide"
        local wiseHide = self:GetAttribute("state-wise-hide") or "hide"
        local activeOpacity = self:GetAttribute("activeOpacity") or 1

        local willShow = false
        if editmode == "show" then
            willShow = true
            self:Show()
            self:SetAlpha(activeOpacity)
        elseif (game == "show" or manual == "show" or custom == "show" or wiseShow == "show") and (wiseHide ~= "show") then
            willShow = true
            self:Show()
            self:SetAlpha(activeOpacity)
        else
            local inactiveOpacity = self:GetAttribute("inactiveOpacity")
            if inactiveOpacity and inactiveOpacity > 0 then
                willShow = false
                self:Show()
                self:SetAlpha(inactiveOpacity)
            else
                willShow = false
                self:Hide()
            end
        end

        local groupName = self:GetAttribute("wiseGroupName")
        local driver = self:GetFrameRef("WiseStateDriver")

        if driver and groupName then
             local driverState = willShow and "active" or "inactive"
             driver:RunAttribute("SetState", groupName, driverState)
        end
    ]]
	f:SetAttribute("_onstate-game", gatekeeper)
	f:SetAttribute("_onstate-manual", gatekeeper)
	f:SetAttribute("_onstate-custom", gatekeeper)
	f:SetAttribute("_onstate-wise-show", gatekeeper)
	f:SetAttribute("_onstate-wise-hide", gatekeeper)
	f:SetAttribute("_onstate-editmode", gatekeeper)

	-- Set opacity attributes for gatekeeper
	local activeOpacity = group.activeOpacity or (WiseDB.settings and WiseDB.settings.activeOpacity) or 1
	local inactiveOpacity = group.inactiveOpacity or (WiseDB.settings and WiseDB.settings.inactiveOpacity)
	if not InCombatLockdown() then
		f:SetAttribute("activeOpacity", activeOpacity < 1 and activeOpacity or nil)
		f:SetAttribute("inactiveOpacity", inactiveOpacity and inactiveOpacity > 0 and inactiveOpacity or nil)
	end

	-- Initialize Manual and Wise State if missing (Prevent reset on config update)
	if not InCombatLockdown() then
		if not f:GetAttribute("state-manual") then
			f:SetAttribute("state-manual", "hide")
		end
		if not f:GetAttribute("state-wise-show") then
			f:SetAttribute("state-wise-show", "hide")
		end
		if not f:GetAttribute("state-wise-hide") then
			f:SetAttribute("state-wise-hide", "hide")
		end

		if Wise.editMode and group.anchorMode ~= "mouse" then
			f:SetAttribute("state-editmode", "show")
		else
			f:SetAttribute("state-editmode", "hide")
		end
	end

	-- Custom Visibility Logic (Immediate + Ticker/OnUpdate)
	-- Pre-cache condition flags for undermouse (these don't change until UpdateGroupDisplay is called again)
	local cvShowStr = (group.visibilitySettings and group.visibilitySettings.customShow) or ""
	local cvHideStr = (group.visibilitySettings and group.visibilitySettings.customHide) or ""
	local cvShowUnderMouse = cvShowStr:find("undermouse") and true or false
	local cvHideUnderMouse = cvHideStr:find("undermouse") and true or false
	local hasUnderMouse = cvShowUnderMouse or cvHideUnderMouse
	-- Check if any non-mouse custom conditionals are present (bank, mailbox, zoneability, etc.)
	local hasNonMouseConditions = false
	for keyword in pairs(CUSTOM_VIS_CONDITIONALS) do
		if keyword ~= "undermouse" then
			if cvShowStr:find(keyword) or cvHideStr:find(keyword) then
				hasNonMouseConditions = true
				break
			end
		end
	end

	-- Detect combat modifier paired with undermouse (e.g. "[combat,undermouse]" or "[nocombat,undermouse]")
	local undermouseNoCombat = cvShowStr:find("nocombat") and cvShowUnderMouse and true or false
	local undermouseCombatOnly = cvShowStr:find("combat")
			and not cvShowStr:find("nocombat")
			and cvShowUnderMouse
			and true
		or false
	if cvShowUnderMouse and not undermouseCombatOnly and not undermouseNoCombat then
		undermouseNoCombat = true
	end

	-- Bounding box offsets for undermouse hit testing (populated after ApplyLayout)
	local cachedMinX, cachedMaxX, cachedMinY, cachedMaxY = -35, 35, -35, 35

	-- Fast undermouse check (no string ops, no iteration — just cursor vs cached bounds)
	local function IsMouseOverInterface()
		local anchorFrame = f.Anchor
		if not anchorFrame then
			return false
		end
		local aLeft, aBottom, aWidth, aHeight = anchorFrame:GetRect()
		if not aLeft then
			return false
		end
		local scale = anchorFrame:GetEffectiveScale()
		local cx, cy = GetCursorPosition()
		cx = cx / scale
		cy = cy / scale
		local centerX = aLeft + aWidth / 2
		local centerY = aBottom + aHeight / 2
		return cx >= centerX + cachedMinX
			and cx <= centerX + cachedMaxX
			and cy >= centerY + cachedMinY
			and cy <= centerY + cachedMaxY
	end

	-- Generic custom condition check using EvalConditionString (handles bank, mailbox, zoneability, etc.)
	local function CheckCustomVisibility()
		if InCombatLockdown() then
			return false
		end
		local showStr = (group.visibilitySettings and group.visibilitySettings.customShow) or ""
		local hideStr = (group.visibilitySettings and group.visibilitySettings.customHide) or ""

		local showResult = EvalConditionString(showStr)
		local hideResult = EvalConditionString(hideStr)

		-- Hide overrides show
		if hideResult then
			return false
		end
		return showResult
	end

	-- Initial Custom Check
	local initialCustomState = "hide"
	if CheckCustomVisibility() then
		initialCustomState = "show"
		if not InCombatLockdown() then
			f:SetAttribute("state-custom", "show")
		end
	else
		if not InCombatLockdown() then
			f:SetAttribute("state-custom", "hide")
		end
	end

	-- Clean up previous custom visibility handlers
	if f.customVisTicker then
		f.customVisTicker:Cancel()
		f.customVisTicker = nil
	end
	if f.undermouseFrame then
		f.undermouseFrame:SetScript("OnUpdate", nil)
		f.undermouseFrame:Hide()
	end

	if hasUnderMouse then
		-- Use a dedicated hidden frame with OnUpdate for frame-rate mouse tracking.
		-- OnUpdate fires every render frame (~16ms @ 60fps) for near-instant response.
		-- We use a separate frame to avoid conflicting with f.Anchor's OnUpdate (mouse-follow mode).
		if not f.undermouseFrame then
			f.undermouseFrame = CreateFrame("Frame")
			f.undermouseFrame._wiseProfileName = "undermouse:" .. name
		end
		local nonMouseElapsed = 0
		local wasShowingInCombat = false
		local throttledElapsed = 0
		f.undermouseFrame:Show()
		f.undermouseFrame:SetScript("OnUpdate", function(self, elapsed)
			throttledElapsed = throttledElapsed + elapsed
			if throttledElapsed < 0.05 then
				return
			end
			throttledElapsed = 0

			local inCombat = InCombatLockdown()

			-- Respect combat modifiers: [combat][undermouse] vs [nocombat][undermouse]
			-- If undermouseNoCombat and in combat → undermouse should not trigger
			-- If undermouseCombatOnly and not in combat → undermouse should not trigger
			local undermouseActive = true
			if undermouseNoCombat and inCombat then
				undermouseActive = false
			end
			if undermouseCombatOnly and not inCombat then
				undermouseActive = false
			end

			local isOver = undermouseActive and IsMouseOverInterface() or false
			local customShow = false

			-- Check non-mouse conditions at reduced rate (every 0.5s) using generic evaluator
			if hasNonMouseConditions then
				nonMouseElapsed = nonMouseElapsed + elapsed
				if nonMouseElapsed >= 0.5 then
					nonMouseElapsed = 0
					f._lastNonMouseResult = CheckCustomVisibility()
				end
				if f._lastNonMouseResult then
					customShow = true
				end
			end

			if cvShowUnderMouse and isOver then
				customShow = true
			end
			if cvHideUnderMouse and isOver then
				customShow = false
			end

			if inCombat then
				-- In combat: can't touch secure frame attributes.
				-- Use f.visualDisplay (insecure mirror) for show/hide instead.
				if f.visualDisplay then
					if customShow then
						if not wasShowingInCombat then
							f.visualDisplay:Show()
							f:SetAlpha(0) -- Hide secure frame visually (keeps keybinds active)
							wasShowingInCombat = true
							if f.buttons then
								local optionsOpen = Wise.OptionsFrame and Wise.OptionsFrame:IsShown()
								local inEditMode = Wise.editMode
								for _, btn in ipairs(f.buttons) do
									if not InCombatLockdown() then
										btn:EnableMouse(btn.isValid or not not optionsOpen or not not inEditMode)
									end
								end
							end
						end
					else
						if wasShowingInCombat then
							f.visualDisplay:Hide()
							wasShowingInCombat = false
							if f.buttons then
								for _, btn in ipairs(f.buttons) do
									if not InCombatLockdown() then
										btn:EnableMouse(false)
									end
								end
							end
						end
					end
				end
			else
				-- Out of combat: use secure attribute path
				-- If we were showing via visualDisplay in combat, clean up on transition
				if wasShowingInCombat then
					f.visualDisplay:Hide()
					f:SetAlpha(activeOpacity)
					wasShowingInCombat = false
				end
				local target = customShow and "show" or "hide"
				if f:GetAttribute("state-custom") ~= target then
					f:SetAttribute("state-custom", target)
				end
				if f.buttons then
					local optionsOpen = Wise.OptionsFrame and Wise.OptionsFrame:IsShown()
					local inEditMode = Wise.editMode
					for _, btn in ipairs(f.buttons) do
						if not InCombatLockdown() then
							btn:EnableMouse((btn.isValid or not not optionsOpen or not not inEditMode) and customShow)
						end
					end
				end
			end
		end)
	elseif hasNonMouseConditions then
		-- No undermouse: use slower ticker for bank/mailbox/etc (0.5s is fine)
		f.customVisTicker = C_Timer.NewTicker(0.5, function()
			local isShow = CheckCustomVisibility()
			local target = isShow and "show" or "hide"
			if f:GetAttribute("state-custom") ~= target and not InCombatLockdown() then
				f:SetAttribute("state-custom", target)
			end
		end)
	end

	-- Check if combat visible for mouse tracking
	-- Use raw strings to determine intent
	local rawShow = group.visibilitySettings.customShow or ""
	local rawHide = group.visibilitySettings.customHide or ""

	local showInCombat = rawShow:find("%[%]") or rawShow:find("%[always%]") or rawShow:find("%[combat%]")
	local hideInCombat = rawHide:find("%[%]") or rawHide:find("%[always%]") or rawHide:find("%[combat%]")

	local isCombatVisible = (showInCombat and not hideInCombat)
	local isAlwaysVisibleMouse = isCombatVisible and (group.anchorMode == "mouse")

	local rawShowStr = group.visibilitySettings.customShow or ""
	local groupToken = "wise:" .. name
	local autoHeld = rawShowStr:find(groupToken, 1, true)

	if not InCombatLockdown() then
		-- Force correct visibility synchronously
		-- Don't trust f:GetAttribute("state-game") immediately after RegisterStateDriver as it may be stale.
		-- We calculate what it SHOULD be using the driver string we just built.
		local gameResult = SecureCmdOptionParse_Internal and SecureCmdOptionParse_Internal(driverString)
			or SecureCmdOptionParse(driverString)
		-- Note: driverString is formatted as "cond show; hide". SecureCmdOptionParse returns "show" or "hide" (or nil->hide).

		local gameState = (gameResult == "show") and "show" or "hide"
		local manualState = f:GetAttribute("state-manual") or "hide"
		local customState = initialCustomState

		local base = effectiveBaseVisibility

		-- Mirror Gatekeeper Logic: Union (OR) with wise-hide override
		local wiseShowState = f:GetAttribute("state-wise-show") or "hide"
		local wiseHideState = f:GetAttribute("state-wise-hide") or "hide"
		local editmodeState = f:GetAttribute("state-editmode") or "hide"

		local shouldShow = false
		if editmodeState == "show" then
			shouldShow = true
		else
			if manualState == "show" then
				shouldShow = true
			end
			if customState == "show" then
				shouldShow = true
			end
			if gameState == "show" then
				shouldShow = true
			end
			if wiseShowState == "show" then
				shouldShow = true
			end

			if wiseHideState == "show" then
				shouldShow = false
			end
		end

		if shouldShow then
			f:Show()
			f:SetAlpha(activeOpacity)
		elseif inactiveOpacity and inactiveOpacity > 0 then
			f:Show()
			f:SetAlpha(inactiveOpacity)
		else
			f:Hide()
		end
	end

	-- Setup Visual Mirror (Insecure frame for combat display)
	if not f.visualDisplay then
		f.visualDisplay = CreateFrame("Frame", nil, UIParent)
		f.visualDisplay:SetSize(50, 50) -- Match group size
		f.visualDisplay.buttons = {}
	end

	-- Anchor Visual Display to Proxy (so it follows mouse in combat)
	f.visualDisplay:ClearAllPoints()
	f.visualDisplay:SetPoint("CENTER", f.Anchor, "CENTER")
	f.visualDisplay:SetScale(f:GetScale()) -- Sync scale
	f.visualDisplay:SetFrameStrata(f:GetFrameStrata()) -- Sync strata

	-- Event Handler to toggle visual display in combat
	-- When [undermouse] is active, the OnUpdate handler manages visualDisplay directly.
	f.visualDisplay:SetScript("OnEvent", function(self, event)
		if hasUnderMouse then
			-- Undermouse OnUpdate handles combat show/hide — don't interfere.
			-- Only restore alpha when leaving combat (OnUpdate handles the rest).
			if event == "PLAYER_REGEN_ENABLED" then
				f:SetAlpha(activeOpacity)
			end
		elseif isAlwaysVisibleMouse then
			if event == "PLAYER_REGEN_DISABLED" then
				self:Show()
				f:SetAlpha(0)
			elseif event == "PLAYER_REGEN_ENABLED" then
				self:Hide()
				f:SetAlpha(activeOpacity)
			end
		else
			self:Hide()
			f:SetAlpha(activeOpacity)
		end
	end)
	f.visualDisplay:RegisterEvent("PLAYER_REGEN_DISABLED")
	f.visualDisplay:RegisterEvent("PLAYER_REGEN_ENABLED")

	-- Initial State
	if InCombatLockdown() and isAlwaysVisibleMouse then
		f.visualDisplay:Show()
		f:SetAlpha(0)
	else
		f.visualDisplay:Hide()
		f:SetAlpha(activeOpacity)
	end

	-- ... (Positioning Logic for Proxy Anchor) ...

	-- Apply Anchor (only for fixed mode or initial positioning)
	-- Mouse mode will reposition via OnUpdate
	-- Nested instances always use static positioning (PositionNestedChild handles them)
	if isNestedInstance or group.anchorMode ~= "mouse" then
		-- Static Mode: Restore anchor to f.Anchor
		if not InCombatLockdown() then
			-- Ensure f is anchored to f.Anchor using the group's point
			-- (We might have detached it in Mouse mode)
			f:ClearAllPoints()
			local point = (group.anchor and group.anchor.point) or "CENTER"
			f:SetPoint(point, f.Anchor, point)
		end

		-- Position f.Anchor acting as the actual position holder
		-- Use full 5-parameter SetPoint to preserve the exact coordinate system from edit mode.
		-- After dragging, WoW anchors as TOPLEFT→BOTTOMLEFT (absolute screen coords), so we
		-- must restore with the same relativePoint to avoid position shifts on edit mode exit.
		f.Anchor:ClearAllPoints()
		if group.anchor then
			local point = group.anchor.point or "CENTER"
			local relPoint = group.anchor.relativePoint or point
			f.Anchor:SetPoint(point, UIParent, relPoint, group.anchor.x or 0, group.anchor.y or 0)
		else
			f.Anchor:SetPoint("CENTER")
		end
		f.Anchor:SetScript("OnUpdate", nil)
		f:SetScript("OnUpdate", nil)
	else
		-- Mouse anchor mode: set up continuous mouse tracking

		-- Break dependency on f.Anchor so we can move it (and visualDisplay) in combat
		if not InCombatLockdown() then
			f:ClearAllPoints()
			-- Set initial point to prevent jumping
			local cursorX, cursorY = GetCursorPosition()
			local uiScale = UIParent:GetScale() or 1
			local frameScale = f:GetScale() or 1
			local correctedX = (cursorX / uiScale) / frameScale
			local correctedY = (cursorY / uiScale) / frameScale
			f:SetPoint("CENTER", UIParent, "BOTTOMLEFT", correctedX, correctedY)
		end

		-- Attach to ANCHOR frame so it runs even if secure frame is hidden (combat)
		local function MouseFollowUpdate()
			-- Closure captures 'f' and 'group'
			-- Skip while the group is hidden: there's nothing to position, and
			-- OnShow already snaps the frame to the cursor when it appears, so
			-- tracking while hidden is pure waste (this was the top idle cost for
			-- toggle/hold rings that sit hidden most of the time). The OnUpdate
			-- stays attached and resumes automatically when the frame shows.
			if not f:IsShown() then
				return
			end

			-- Combat mirror enforcement: the secure gatekeeper re-applies
			-- activeOpacity when the [combat] state driver shows the frame,
			-- which can race the REGEN_DISABLED handler's SetAlpha(0) and
			-- leave the secure frame visible as a stationary ghost next to
			-- the mouse-following visualDisplay. Alpha isn't protected, so
			-- re-assert it every frame while the mirror is active.
			if
				isAlwaysVisibleMouse
				and InCombatLockdown()
				and f.visualDisplay
				and f.visualDisplay:IsShown()
				and f:GetAlpha() > 0
			then
				f:SetAlpha(0)
			end

			if f.mouseAnchorLocked then
				return
			end

			-- Get cursor position and correct for UI scale (like UltimateMouseCursor)
			local cursorX, cursorY = GetCursorPosition()
			local uiScale = UIParent:GetScale() or 1
			local frameScale = f:GetScale() or 1

			-- Apply scale correction and offsets
			local correctedX = (cursorX / uiScale) / frameScale
			local correctedY = (cursorY / uiScale) / frameScale
			local offsetX = (group.mouseOffsetX or 0) / frameScale
			local offsetY = (group.mouseOffsetY or 0) / frameScale

			-- Keep persistently visible followers clear of the cursor — a frame
			-- riding under the cursor intercepts every click on the screen.
			-- Press-to-show interfaces (transient, shown on key hold) are
			-- exempt: appearing at the cursor is their designed behavior.
			if group.visibility == "always" or group.visibility == "combat" then
				offsetX, offsetY = Wise:ClampMouseFollowOffset(f, offsetX, offsetY)
			end

			-- Move the PROXY ANCHOR (Insecure, safe to move in combat NOW that f is detached)
			if f.Anchor then
				f.Anchor:ClearAllPoints()
				f.Anchor:SetPoint("CENTER", UIParent, "BOTTOMLEFT", correctedX + offsetX, correctedY + offsetY)
			end

			-- Move the SECURE FRAME (Only safe out of combat)
			if not InCombatLockdown() then
				f:ClearAllPoints()
				f:SetPoint("CENTER", UIParent, "BOTTOMLEFT", correctedX + offsetX, correctedY + offsetY)
			end
		end

		-- Determine if should lock or follow continuously
		if isAlwaysVisibleMouse then
			-- Always visible: continuously follow mouse
			f.mouseAnchorLocked = false
		else
			-- Hold/toggle modes: will lock when shown
			-- If currently hidden (startup), allow tracking (unlocked) so it's ready at cursor
			if f:IsShown() then
				f.mouseAnchorLocked = true
			else
				f.mouseAnchorLocked = false
			end
		end

		f.Anchor:SetScript("OnUpdate", MouseFollowUpdate)
		f:SetScript("OnUpdate", nil) -- Ensure secure frame doesn't run it
	end

	if f.toggleBtn then
		-- Default trigger: release_mouseover (standard ring behavior)
		-- We will support explicit selection in options later, but for now map 'press' correctly.
		local derivedTrigger = (group.keybindSettings and group.keybindSettings.trigger) or "release_mouseover"
		-- Legacy support: if repeatPrevious is explicitly true, upgrade to release_repeat
		if derivedTrigger == "release_mouseover" and group.keybindSettings and group.keybindSettings.repeatPrevious then
			derivedTrigger = "release_repeat"
		end

		f.toggleBtn:SetAttribute("trigger", derivedTrigger)
		f.toggleBtn:SetAttribute("repeatPrevious", (group.keybindSettings and group.keybindSettings.repeatPrevious))

		-- Set visibleWhenHeld if explicit OR auto-detected from [wise:Name]
		-- CRITICAL: If Toggle On Press is enabled, we MUST NOT enable visibleWhenHeld,
		-- because visibleWhenHeld causes the frame to hide on release.
		local isHeld = group.visibilitySettings.held or autoHeld
		if effectiveToggleOnPress then
			isHeld = false
		end

		f.toggleBtn:SetAttribute("visibleWhenHeld", isHeld)

		f.toggleBtn:SetAttribute("toggleOnPress", effectiveToggleOnPress)
		f.toggleBtn:SetAttribute("hideOnUse", effectiveHideOnUse)
		f.toggleBtn:SetAttribute("layoutType", displayType)

		Wise:DebugPrint(
			string.format(
				"Group '%s' Config: trigger='%s', held='%s', toggle='%s', repeat='%s'",
				name,
				derivedTrigger,
				tostring(f.toggleBtn:GetAttribute("visibleWhenHeld")),
				tostring(effectiveToggleOnPress),
				tostring(group.keybindSettings and group.keybindSettings.repeatPrevious)
			)
		)
	end

	-- Build list of actions to display (Slots -> Active State)

	-- Ensure data migration (Transform legacy .buttons to .actions if needed)
	if Wise.MigrateGroupToActions then
		Wise:MigrateGroupToActions(group)
	end

	local actionsToShow = {}

	if group.actions then
		-- Gather sorted slots
		local slots = {}
		for slotIdx, states in pairs(group.actions) do
			table.insert(slots, { index = slotIdx, states = states })
		end
		table.sort(slots, function(a, b)
			return a.index < b.index
		end)

		for _, slotInfo in ipairs(slots) do
			local slotIdx = slotInfo.index
			local states = slotInfo.states

			-- Filter states based on Visibility Logic (Class/Spec/etc) AND spell availability.
			-- This ensures we only generate secure attributes for actions that are valid for this character.
			local validStates = {}
			-- Copy conflictStrategy if present (though usually passed separately)
			validStates.conflictStrategy = states.conflictStrategy
			validStates.resetOnCombat = states.resetOnCombat
			validStates.suppressErrors = states.suppressErrors
			validStates.pressAndHold = states.pressAndHold
			-- Tracks the live (per-character) macro text of every graph-compiled step we
			-- have already accepted for this slot, so two canonical steps that collapse to
			-- the SAME macro after filtering don't both fire. A multi-spec branching graph
			-- compiles one canonical step per root-to-leaf path; for a given character many
			-- of those branches strip down to identical lines (e.g. several branches that
			-- only leave the shared trinket/item head). Without this, the sequence presses
			-- through byte-identical clones — the spurious "Step 1/2/3" on a single-shot slot.
			local seenLiveMacro = {}
			for _, state in ipairs(states) do
				-- A graph-compiled step (the slot carries a graph) with no pathNodeIds is
				-- stale pre-pathNodeIds data: it can't be re-filtered per character, so the
				-- block below would skip filtering and the engine would fire its stored
				-- macroText with no availability check — leaking an off-spec/off-class cast
				-- (e.g. a Disc-only Single-Button Assistant firing on a Shadow priest). Drop
				-- it; the pathNodeIdsRecompileV3 migration rebuilds these on login, but this
				-- guards any that slip through. Hand-authored macros (no slot graph) are not
				-- affected and still pass through.
				local isUnfilterableGraphStep = type(state) == "table"
					and state.type == "misc"
					and state.value == "custom_macro"
					and not state.pathNodeIds
					and states.graph ~= nil
				if Wise:IsActionAllowed(state) and not isUnfilterableGraphStep then
					-- Graph-compiled custom_macro steps store the CANONICAL (all-character)
					-- macro text. Re-derive a LIVE, per-character macro here so the bar
					-- fires/shows only spells this character can use (e.g. no Druid lines
					-- on a Priest). This is recomputed every UpdateGroupDisplay, which runs
					-- on spec/talent change, so it tracks availability. Saved data is left
					-- canonical — we filter into a shallow copy, never the stored table.
					if
						state.type == "misc"
						and state.value == "custom_macro"
						and state.pathNodeIds
						and Wise.FilterMacroTextForCharacter
					then
						local liveMacro, liveCond, liveIcon = Wise:FilterMacroTextForCharacter(state, states.graph)
						-- A step that filters down to no castable line for this character is
						-- dropped entirely (it would be a dead press / blank icon).
						if
							not (liveMacro:match("\n/cast") or liveMacro:match("\n/use") or liveMacro:match("\n/click"))
						then
							state = nil
						elseif seenLiveMacro[liveMacro] then
							-- Identical to an earlier accepted step after filtering — a collapsed
							-- branch duplicate. Drop it so the sequence doesn't repeat the press.
							state = nil
						else
							seenLiveMacro[liveMacro] = true
							local copy = {}
							for k, v in pairs(state) do
								copy[k] = v
							end
							copy.macroText = liveMacro
							-- Remember this step's castable lines so a post-pass can drop steps
							-- whose casts are a subset of another step's (a collapsed branch that
							-- reduces to just the shared trinket/item head — fires nothing distinct).
							copy._liveCastSet = Wise.GetMacroCastSet and Wise:GetMacroCastSet(liveMacro) or nil
							-- Carry the node's resolved icon so the bar button renderer can use it
							-- as a fallback when ResolveMacroData can't resolve the spell by name/ID
							-- (e.g. an untalented talent spell).
							if liveIcon and not copy.icon then
								copy.icon = liveIcon
							end
							-- Derive the slot-level condition live from the graph so the secure
							-- visibility driver hides the slot when unmet (e.g. [combat]). This
							-- works even for slots compiled before conditions were promoted.
							if liveCond and liveCond ~= "" then
								copy.conditions = liveCond
							end
							state = copy
						end
					end
					if not state then -- step dropped for this character
						-- skip insertion below
					else
						-- Also filter out spells/items that aren't known to the current character.
						-- This prevents unknown off-spec talents from winning over valid same-spec spells
						-- during condition evaluation.  Only apply to types with meaningful "known" checks.
						-- Skip the filter for states with conditions (e.g. [overridebar]) — those are
						-- temporary/encounter spells whose availability is gated by the condition itself.
						local sType = state.type
						local hasCond = state.conditions and state.conditions ~= ""
						if
							not hasCond
							and (
								sType == "spell"
								or sType == "item"
								or sType == "toy"
								or sType == "mount"
								or sType == "battlepet"
							)
						then
							if Wise:IsActionKnown(sType, state.value) then
								table.insert(validStates, state)
							end
						else
							table.insert(validStates, state)
						end
					end -- close per-character custom_macro guard
				end
			end

			-- Subset-suppression pass over the accepted graph steps: drop any whose
			-- castable lines are wholly contained in another kept step's. Exact-text
			-- dedupe above removes byte-identical clones; this additionally removes a
			-- branch that collapsed to just the shared head (e.g. trinkets + /target with
			-- no class spell) once its character-specific cast was filtered out. Without
			-- it the sequence still presses through a step that fires nothing distinct.
			if Wise.MacroCastSetIsSubset then
				local removeIdx = {}
				for i = #validStates, 1, -1 do
					local si = validStates[i]
					local setI = type(si) == "table" and si._liveCastSet
					if setI then
						for j = 1, #validStates do
							local sj = validStates[j]
							local setJ = type(sj) == "table" and sj._liveCastSet
							if i ~= j and setJ and Wise:MacroCastSetIsSubset(setI, setJ) then
								local sizeI, sizeJ = 0, 0
								for _ in pairs(setI) do
									sizeI = sizeI + 1
								end
								for _ in pairs(setJ) do
									sizeJ = sizeJ + 1
								end
								-- Drop i if j fires strictly more; on a tie keep the earlier index.
								if sizeI < sizeJ or (sizeI == sizeJ and j < i) then
									removeIdx[i] = true
									break
								end
							end
						end
					end
				end
				for i = #validStates, 1, -1 do
					if removeIdx[i] then
						table.remove(validStates, i)
					end
				end
				-- The transient cast-set must not leak into rendering/secure attributes.
				for _, s in ipairs(validStates) do
					if type(s) == "table" then
						s._liveCastSet = nil
					end
				end
			end

			if #validStates > 0 then
				-- Evaluate conditions to pick the active state from VALID states
				local conflictStrategy = validStates.conflictStrategy or "priority"
				local chosenIdx, slotHadMatch = Wise:EvaluateSlotConditions(validStates, conflictStrategy, nil)
				local actionData = chosenIdx and validStates[chosenIdx] or validStates[1]

				if actionData then
					-- Check category metadata filter ONLY for the options UI, not the bar renderer itself.
					local shouldShow = true
					-- Check if spell/item is known
					local isKnown = Wise:IsActionKnown(actionData.type, actionData.value)

					if isDynamic then
						-- For dynamic groups, collapse "Spacer" actions (empty custom macros)
						-- A spacer is misc/custom_macro with either no name/macrotext or explicitly named "Empty"
						local isSpacer = (actionData.type == "misc" and actionData.value == "custom_macro")
							and (actionData.name == "Empty" or (not actionData.macroText or actionData.macroText == ""))

						-- Also check cooldowns for dynamic mode: hide on-cooldown actions
						-- Skip for CooldownWiser — those interfaces exist to SHOW cooldowns
						local isOnCooldown = false
						if group.propertyType ~= "CooldownWiser" then
							isOnCooldown = isKnown
								and Wise:IsActionOnCooldown(actionData.type, actionData.value, actionData)
						end

						-- For dynamic groups, evaluate per-slot conditions (e.g. [extrabar], [zoneability])
						-- to hide slots whose conditions are not currently met.
						local conditionMet = true
						if actionData.conditions and actionData.conditions ~= "" then
							conditionMet = EvalFullConditionString(actionData.conditions)
						end

						if shouldShow and isKnown and not isSpacer and not isOnCooldown and conditionMet then
							table.insert(actionsToShow, {
								data = actionData,
								known = true,
								categoryMatch = true,
								slot = slotIdx,
								states = validStates,
								conflictStrategy = conflictStrategy,
								suppressErrors = validStates.suppressErrors,
								pressAndHold = validStates.pressAndHold,
								activeState = chosenIdx,
								hadMatch = slotHadMatch,
							})
						end
					else
						-- Static interfaces: preserve slot positions to prevent collapsing.
						-- If the chosen state is not known, try to find another valid state that IS known
						-- (e.g. conditional override bar won priority but isn't available — fall back to a real spell)
						if not isKnown and not Wise.editMode and #validStates > 1 then
							for fallbackIdx, fallbackState in ipairs(validStates) do
								if
									fallbackIdx ~= chosenIdx
									and Wise:IsActionKnown(fallbackState.type, fallbackState.value)
								then
									chosenIdx = fallbackIdx
									actionData = fallbackState
									isKnown = true
									break
								end
							end
						end
						-- Always preserve the slot with its actual data (known actions render normally,
						-- unknown ones render desaturated). Only user-placed "empty" type slots go invisible.
						table.insert(actionsToShow, {
							data = actionData,
							known = isKnown,
							categoryMatch = true,
							slot = slotIdx,
							states = validStates,
							conflictStrategy = conflictStrategy,
							resetOnCombat = validStates.resetOnCombat,
							suppressErrors = validStates.suppressErrors,
							pressAndHold = validStates.pressAndHold,
							activeState = chosenIdx,
							hadMatch = slotHadMatch,
						})
					end
				end
			elseif not isDynamic then
				-- Static interfaces: always preserve empty slots (no collapsing)
				table.insert(
					actionsToShow,
					{ data = { type = "empty", value = nil }, known = true, categoryMatch = true, slot = slotIdx }
				)
			end
		end
	end
	-- Fallback for legacy if not migrated (safety)
	if not group.actions and group.buttons then
		for i, actionData in ipairs(group.buttons) do
			local shouldShow = Wise:ShouldShowAction(actionData)
			local isKnown = Wise:IsActionKnown(actionData.type, actionData.value)
			if isDynamic then
				local isOnCooldown = false
				if group.propertyType ~= "CooldownWiser" then
					isOnCooldown = isKnown and Wise:IsActionOnCooldown(actionData.type, actionData.value, actionData)
				end
				if shouldShow and isKnown and not isOnCooldown then
					table.insert(actionsToShow, { data = actionData, known = true, categoryMatch = true, slot = i })
				end
			else
				table.insert(
					actionsToShow,
					{ data = actionData, known = isKnown, categoryMatch = shouldShow, slot = i }
				)
			end
		end
	end

	-- Expand embedded interface actions: replace interface slots with child's resolved actions
	-- Clear stale embedded parent mappings for this group
	if Wise._embeddedParents then
		for childName, parentName in pairs(Wise._embeddedParents) do
			if parentName == name then
				Wise._embeddedParents[childName] = nil
			end
		end
	end
	local expanded = {}
	for _, actionInfo in ipairs(actionsToShow) do
		local ad = actionInfo.data
		if ad and ad.type == "interface" and ad.value then
			local nestOpts = Wise:GetNestingOptions(ad)
			-- Box parents don't support embedded mode; skip expansion
			local pType = group and group.type or "circle"
			if nestOpts and nestOpts.rotationMode == "embedded" and pType ~= "box" then
				local childGroupName = ad.value
				local childGroup = WiseDB and WiseDB.groups and WiseDB.groups[childGroupName]
				if childGroup then
					-- Resolve child actions the same way the main loop does
					local childActions = childGroup.actions
					local childButtons = not childActions and childGroup.buttons
					local childSlots = {}
					if childActions then
						for slotIdx, states in pairs(childActions) do
							if type(slotIdx) == "number" and type(states) == "table" then
								table.insert(childSlots, { index = slotIdx, states = states })
							end
						end
						table.sort(childSlots, function(a, b)
							return a.index < b.index
						end)
					elseif childButtons then
						for ci, cAction in ipairs(childButtons) do
							table.insert(childSlots, { index = ci, states = { cAction } })
						end
					end

					local childIsDynamic = childGroup.dynamic
					for _, cSlot in ipairs(childSlots) do
						local cStates = cSlot.states
						local validStates = {}
						validStates.conflictStrategy = cStates.conflictStrategy
						validStates.resetOnCombat = cStates.resetOnCombat
						validStates.suppressErrors = cStates.suppressErrors
						validStates.pressAndHold = cStates.pressAndHold
						for _, st in ipairs(cStates) do
							if Wise:IsActionAllowed(st) then
								local sType = st.type
								local hasCond = st.conditions and st.conditions ~= ""
								if
									not hasCond
									and (
										sType == "spell"
										or sType == "item"
										or sType == "toy"
										or sType == "mount"
										or sType == "battlepet"
									)
								then
									if Wise:IsActionKnown(sType, st.value) then
										table.insert(validStates, st)
									end
								else
									table.insert(validStates, st)
								end
							end
						end
						if #validStates > 0 then
							local cs = validStates.conflictStrategy or "priority"
							local chosenIdx = Wise:EvaluateSlotConditions(validStates, cs, nil)
							local cData = chosenIdx and validStates[chosenIdx] or validStates[1]
							if cData and cData.type ~= "interface" then
								local cKnown = Wise:IsActionKnown(cData.type, cData.value)
								if childIsDynamic or isDynamic then
									local isSpacer = (cData.type == "misc" and cData.value == "custom_macro")
										and (cData.name == "Empty" or (not cData.macroText or cData.macroText == ""))
									local isOnCD = false
									if
										(childGroup.propertyType ~= "CooldownWiser")
										and (group.propertyType ~= "CooldownWiser")
									then
										isOnCD = cKnown and Wise:IsActionOnCooldown(cData.type, cData.value, cData)
									end
									if cKnown and not isSpacer and not isOnCD then
										table.insert(expanded, {
											data = cData,
											known = true,
											categoryMatch = true,
											slot = cSlot.index,
											states = validStates,
											conflictStrategy = cs,
											suppressErrors = validStates.suppressErrors,
											pressAndHold = validStates.pressAndHold,
											activeState = chosenIdx,
											embeddedFrom = childGroupName,
										})
									end
								else
									if Wise.editMode or cKnown then
										table.insert(expanded, {
											data = cData,
											known = cKnown,
											categoryMatch = true,
											slot = cSlot.index,
											states = validStates,
											conflictStrategy = cs,
											resetOnCombat = validStates.resetOnCombat,
											suppressErrors = validStates.suppressErrors,
											pressAndHold = validStates.pressAndHold,
											activeState = chosenIdx,
											embeddedFrom = childGroupName,
										})
									end
								end
							end
						end
					end

					-- Track this parent so child updates can propagate
					if not Wise._embeddedParents then
						Wise._embeddedParents = {}
					end
					Wise._embeddedParents[childGroupName] = name
				else
					-- Child group not found, skip
				end
			else
				table.insert(expanded, actionInfo)
			end
		else
			table.insert(expanded, actionInfo)
		end
	end
	actionsToShow = expanded

	Wise:DebugPrint(string.format("Group '%s': actionsToShow count = %d", name, #actionsToShow))

	-- Create/Update Buttons
	local iconSize, _, _, _, _, _, _, _, _, _, _, _, iconStyle, _, _, _, hideEmptySlots =
		Wise:GetGroupDisplaySettings(name)

	-- Nested instances inherit icon size from parent
	if isNestedInstance and overrideOpts and overrideOpts._parentIconSize then
		iconSize = overrideOpts._parentIconSize
		f.inheritedIconSize = iconSize
	end

	-- Nested line children: inherit orientation/anchor overrides from nesting logic
	if isNestedInstance and overrideOpts then
		f.nestedLineOrientation = overrideOpts._nestedLineOrientation or nil
		f.nestedLineAnchor = overrideOpts._nestedLineAnchor or nil
		f.nestedListAnchor = overrideOpts._nestedListAnchor or nil
		f.nestedTextAlign = overrideOpts._nestedTextAlign or nil
	end

	for i, actionInfo in ipairs(actionsToShow) do
		local actionData = actionInfo.data
		local isKnown = actionInfo.known

		local btn = f.buttons[i]
		if not btn then
			btn = CreateFrame(
				"Button",
				"WiseGroup_" .. name .. "_Btn" .. i,
				f,
				"SecureActionButtonTemplate, SecureHandlerEnterLeaveTemplate"
			)
			btn:SetSize(iconSize, iconSize)
			btn:RegisterForClicks("AnyUp", "AnyDown")

			-- Icon
			btn.icon = btn:CreateTexture(nil, "ARTWORK")
			btn.icon:SetAllPoints()

			-- Active state highlight (manually managed, not CheckButton)
			btn.activeHighlight = btn:CreateTexture(nil, "OVERLAY")
			btn.activeHighlight:SetAllPoints(btn.icon)
			btn.activeHighlight:SetTexture("Interface\\Buttons\\ButtonHilight-Square")
			btn.activeHighlight:SetBlendMode("ADD")
			btn.activeHighlight:Hide()

			-- Cooldown frame — skip CooldownFrameTemplate to avoid its baked-in swipe insets
			btn.cooldown = CreateFrame("Cooldown", nil, btn)
			btn.cooldown:SetAllPoints(btn.icon)
			btn.cooldown:SetSwipeTexture("Interface\\Buttons\\WHITE8x8")
			btn.cooldown:SetSwipeColor(0, 0, 0, 0.8)
			btn.cooldown:SetDrawEdge(true)
			btn.cooldown:SetDrawSwipe(true)
			btn.cooldown:SetHideCountdownNumbers(false)

			-- Text layers (count, keybind, customText) via Text module
			Wise:Text_CreateFontStrings(btn)

			tinsert(f.buttons, btn)

			-- Masque Support
			-- Note: HotKey (btn.keybind) is intentionally excluded.
			-- Wise manages its own keybind text via core/Text.lua.
			-- Passing it to Masque causes Masque's Skin_Text to call
			-- SetSize(36,0) which clips/hides wider strings like MWU, MWD, M3.
			if Wise.MasqueGroup then
				Wise.MasqueGroup:AddButton(btn, {
					Icon = btn.icon,
					Cooldown = btn.cooldown,
					Count = btn.count,
				})
			end

			-- Tooltip support
			Wise:AddInterfaceTooltip(btn)

			-- Hover indication (5% scale + dim glow)
			Wise:AddHoverIndication(btn)

			-- Mousewheel: propagate to parent group frame for scroll navigation
			btn:EnableMouseWheel(false)
			btn:SetScript("OnMouseWheel", function(self, delta)
				local parent = self:GetParent()
				if parent and parent:GetScript("OnMouseWheel") then
					parent:GetScript("OnMouseWheel")(parent, delta)
				end
			end)

			-- Secure hover tracking
			SecureHandlerWrapScript(
				btn,
				"OnEnter",
				f.toggleBtn,
				[[
					owner:SetAttribute("hoveredButton", self:GetName())
					local key = self:GetAttribute("keybind")
					if key and key:find("MOUSEWHEEL") then
						self:SetBindingClick(true, key, self:GetName())
						if not (key:find("SHIFT%-") or key:find("CTRL%-") or key:find("ALT%-")) then
							self:SetBindingClick(true, "SHIFT-" .. key, self:GetName())
							self:SetBindingClick(true, "CTRL-" .. key, self:GetName())
							self:SetBindingClick(true, "ALT-" .. key, self:GetName())
						end
					end
				]]
			)
			SecureHandlerWrapScript(
				btn,
				"OnLeave",
				f.toggleBtn,
				[[
					owner:SetAttribute("hoveredButton", nil)
					local key = self:GetAttribute("keybind")
					if key and key:find("MOUSEWHEEL") then
						self:ClearBindings()
					end
				]]
			)
			btn:SetAttribute(
				"_onhide",
				[[
				self:ClearBindings()
			]]
			)

			-- Debug hooks removed to prevent secret value errors

			-- Drag and Drop Support
			Wise:DebugPrint("Registering OnReceiveDrag for " .. btn:GetName())

			-- Ensure button can receive mouse events
			btn:EnableMouse(true)
			btn:RegisterForClicks("AnyUp", "AnyDown")

			-- Register for drag to be safe
			btn:RegisterForDrag("LeftButton")

			btn:SetScript("OnReceiveDrag", function(self)
				Wise:OnDragReceive(name, self.slot)
			end)

			btn:SetScript("OnMouseUp", function(self, button)
				-- Check if cursor has item; if so, OnReceiveDrag SHOULD have fired.
				local type = GetCursorInfo()
				if type then
					-- Fallback?
					Wise:OnDragReceive(name, self.slot)
				else
					-- Normal click
					-- print("Wise Debug: OnMouseUp fired (No Cursor)")
				end
			end)
		end

		f.toggleBtn:SetFrameRef(btn:GetName(), btn)
		f.toggleBtn:SetFrameRef("btn" .. i, btn)
		f:SetFrameRef("btn" .. i, btn)

		-- Explicitly clear btn2 ref if we only have 1 button (to avoid stale refs from recycled frames)
		-- MOVED outside loop for clarity
		-- if #actionsToShow == 1 then
		--      f.toggleBtn:SetAttribute("frameref-btn2", nil)
		-- end

		btn:Show()
		btn.slot = actionInfo.slot -- Store slot for binding reference
		-- Always apply current global icon size (for existing buttons too)
		btn:SetSize(iconSize, iconSize)

		-- Compute secure attributes via helper
		local aType = actionData.type
		local aValue = actionData.value
		local secureType, secureAttr, secureValue = Wise:GetSecureAttributes(actionData, actionData.conditions)

		-- Reset Attributes
		btn:SetAttribute("type", nil)
		btn:SetAttribute("spell", nil)
		btn:SetAttribute("item", nil)
		btn:SetAttribute("macro", nil)
		btn:SetAttribute("macrotext", nil)
		btn:SetAttribute("clickbutton", nil)

		btn:SetAttribute("type", secureType)
		if secureType then
			btn:SetAttribute(secureAttr, secureValue)
		end

		-- Press-and-hold: disabled by default, opt-in per-slot
		if actionInfo.pressAndHold then
			btn:SetAttribute("pressAndHoldAction", 1)
		else
			btn:SetAttribute("pressAndHoldAction", nil)
		end

		-- Mark interface buttons with nesting attributes
		if aType == "interface" then
			local frameKey = instanceId or name
			local childInstanceId = frameKey .. "_" .. tostring(i) .. "_" .. aValue
			btn:SetAttribute("isa_is_interface", true)
			btn:SetAttribute("isa_interface_target", childInstanceId)

			local nestOpts = Wise:GetNestingOptions(actionData)
			local nestMode = (nestOpts and nestOpts.rotationMode) or "jump"
			-- Box parents only support button nesting (jump/embedded too complex for grid)
			local parentType = group and group.type or "circle"
			if parentType == "box" then
				nestMode = "button"
			end
			-- For button mode, pass the buttonMode to the secure handler
			-- (secure handler uses cycle/random/priority directly)
			local secureNestMode = nestMode
			if nestMode == "button" then
				secureNestMode = (nestOpts and nestOpts.buttonMode) or "cycle"
			end
			btn:SetAttribute("isa_nest_mode", secureNestMode)

			if nestMode == "jump" then
				-- Jump mode: create child frame and set up hover-to-open
				if nestOpts then
					btn:SetAttribute("isa_open_direction", nestOpts.openDirection or "auto")

					-- Always open on hover (the only open method for nested interfaces)
					btn:SetAttribute(
						"_onenter",
						[[
                        local target = self:GetAttribute("isa_interface_target")
                        if target then
                            local childGroup = self:GetFrameRef("child_group")
                            if childGroup then
                                local _cManual = childGroup:GetAttribute("state-manual") or "hide"
                                if _cManual == "hide" then
                                    childGroup:SetAttribute("state-manual", "show")
                                    local driver = self:GetFrameRef("WiseStateDriver")
                                    if driver then
                                        driver:SetAttribute("wisesetstate", target .. ":active")
                                    end
                                end
                            end
                        end
                    ]]
					)
					btn:SetAttribute("_onclick", nil)
				end

				-- Set frame ref from parent toggleBtn to child group frame (for direct toggle in secure snippet)
				local childGroup = WiseDB and WiseDB.groups and WiseDB.groups[aValue]
				if childGroup then
					local childFrame = Wise:CreateGroupFrame(aValue, childInstanceId)
					if childFrame then
						f.toggleBtn:SetFrameRef("nested_" .. childInstanceId, childFrame)
						btn:SetFrameRef("child_group", childFrame)
						if Wise.WiseStateDriver then
							btn:SetFrameRef("WiseStateDriver", Wise.WiseStateDriver)
						end
						-- Store ref to child's toggleBtn so parent can check child hover state
						if childFrame.toggleBtn then
							f.toggleBtn:SetFrameRef("childToggle_" .. childInstanceId, childFrame.toggleBtn)
						end
					end

					-- Store owning button name and parent toggleBtn on child frame
					-- so the close ticker knows when the parent moved to a different slot
					if childFrame then
						childFrame.ownerButtonName = btn:GetName()
						childFrame.parentToggleBtn = f.toggleBtn
						if not InCombatLockdown() then
							childFrame:SetAttribute("keepOpenAfterUse", nestOpts.keepOpenAfterUse or false)
						end
					end

					-- Recursive update to prepare child layout with overrides
					if not nestOpts then
						nestOpts = {}
					end
					nestOpts._parentInstanceId = frameKey

					-- Inherit icon size from parent so nested children match
					nestOpts._parentIconSize = iconSize
					nestOpts._parentAnimation = group.animation or false

					-- Compute line orientation/anchor overrides for line children
					local effectiveChildType = nestOpts.nestedInterfaceType or "default"
					if effectiveChildType == "default" then
						local childGroup = WiseDB.groups[aValue]
						effectiveChildType = (childGroup and childGroup.type) or "circle"
					end
					local parentType = group.type or "circle"
					local openDir = nestOpts.openDirection or "auto"

					if effectiveChildType == "line" then
						if parentType == "circle" then
							-- Circle/Line: child line always points away from circle center
							local bx = btn.targetX or 0
							local by = btn.targetY or 0
							local dist = math.sqrt(bx * bx + by * by)
							if dist > 0.1 then
								local absX = math.abs(bx)
								local absY = math.abs(by)
								if absX >= absY then
									nestOpts._nestedLineOrientation = "horizontal"
									nestOpts._nestedLineAnchor = (bx > 0) and "LEFT" or "RIGHT"
								else
									nestOpts._nestedLineOrientation = "vertical"
									nestOpts._nestedLineAnchor = (by > 0) and "BOTTOM" or "TOP"
								end
							end
						elseif parentType == "line" or parentType == "list" then
							-- Line/Line or List/Line: child line opens perpendicular to parent
							local parentOrientation = group.lineOrientation or "horizontal"
							if parentType == "list" then
								parentOrientation = "vertical"
							end

							if parentOrientation == "horizontal" then
								nestOpts._nestedLineOrientation = "vertical"
								if openDir == "up" then
									nestOpts._nestedLineAnchor = "BOTTOM"
								elseif openDir == "down" then
									nestOpts._nestedLineAnchor = "TOP"
								else
									nestOpts._nestedLineAnchor = "TOP"
								end
							else
								nestOpts._nestedLineOrientation = "horizontal"
								if openDir == "right" then
									nestOpts._nestedLineAnchor = "LEFT"
								elseif openDir == "left" then
									nestOpts._nestedLineAnchor = "RIGHT"
								else
									nestOpts._nestedLineAnchor = "LEFT"
								end
							end
						end
					elseif effectiveChildType == "list" then
						if parentType == "line" or parentType == "list" then
							-- Line/List or List/List: child list opens perpendicular to parent
							local parentOrientation = group.lineOrientation or "horizontal"
							if parentType == "list" then
								parentOrientation = "vertical"
							end

							if parentOrientation == "horizontal" then
								-- Parent horizontal → child list opens up or down
								if openDir == "up" then
									nestOpts._nestedListAnchor = "BOTTOM"
								elseif openDir == "down" then
									nestOpts._nestedListAnchor = "TOP"
								else
									nestOpts._nestedListAnchor = "TOP"
								end
							else
								-- Parent vertical → child list opens left or right
								if openDir == "right" then
									nestOpts._nestedListAnchor = "LEFT"
								elseif openDir == "left" then
									nestOpts._nestedListAnchor = "RIGHT"
								else
									nestOpts._nestedListAnchor = "LEFT"
								end
							end

							-- Text align override
							local nta = nestOpts.nestedTextAlign or "auto"
							if nta ~= "auto" then
								nestOpts._nestedTextAlign = nta
							else
								-- Auto: place text away from parent
								-- If opening right/down, text goes right; if left/up, text goes left
								local anchor = nestOpts._nestedListAnchor or "TOP"
								if anchor == "LEFT" or anchor == "TOP" then
									nestOpts._nestedTextAlign = "right"
								else
									nestOpts._nestedTextAlign = "left"
								end
							end
						end
					end

					local currentDepth = (overrideOpts and overrideOpts._depth) or 0
					if currentDepth < 3 then
						nestOpts._depth = currentDepth + 1
						Wise:UpdateGroupDisplay(aValue, childInstanceId, nestOpts)
					else
						Wise:DebugPrint("Max nesting depth reached, aborting recursive instantiation for " .. aValue)
					end
				end
			else
				-- Button modes (cycle/random/priority): no child frame needed.
				-- The RESOLVE_BLOCK resolves child actions via isa_nest_* attributes.
				btn:SetAttribute("_onenter", nil)
				btn:SetAttribute("_onclick", nil)
				btn:SetAttribute("isa_open_button", nil)
				btn:SetAttribute("isa_open_direction", nil)
			end

			-- Store child group's actions for rotation modes
			Wise:StoreChildActionsOnButton(btn, aValue, nestMode)
		else
			btn:SetAttribute("isa_is_interface", nil)
			btn:SetAttribute("isa_interface_target", nil)
			btn:SetAttribute("isa_open_button", nil)
			btn:SetAttribute("isa_open_direction", nil)
			btn:SetAttribute("isa_nest_mode", nil)
			btn:SetAttribute("isa_nest_count", nil)
			btn:SetAttribute("_onenter", nil)
			btn:SetAttribute("_onclick", nil)
		end

		-- Store all states as secure attributes for condition evaluation
		local allStates = actionInfo.states
		local stateCount = allStates and #allStates or 1
		if stateCount > 1 then
			for sIdx = 1, stateCount do
				local stateAction = allStates[sIdx]
				if stateAction then
					local computedCond = Wise:ComputeEffectiveConditions(allStates, sIdx)
					-- isa_cond_N is fed to SecureCmdOptionParse inside the secure
					-- RESOLVE_BLOCK (both to pick a state and to build the firing
					-- /cast [cond] line). WoW only understands native macro options there, so
					-- strip Wise custom conditionals (zoneability, bank, undermouse, …) first
					-- — otherwise the client prints "unknown macro option: <name>" on every
					-- secure re-evaluation, notably on reload. SanitizeCustom rewrites any
					-- bracket carrying a custom token to [actionbar:99] (always-false in the
					-- secure context); custom conditionals are resolved insecurely for display.
					local secureCond = SanitizeCustom(computedCond)
					local sType, sAttr, sValue = Wise:GetSecureAttributes(stateAction, computedCond)
					-- Only store string-safe values for secure snippets (clickbutton is a frame ref)
					-- For spell type, build subtext-qualified name for /cast in RESOLVE_BLOCK
					-- (e.g. "Whirling Surge(Skyriding)") so skyriding abilities resolve correctly.
					local spellVal = ""
					if sAttr == "spell" then
						local rawName = tostring(sValue)
						local sid = tonumber(stateAction.value)
						if sid and C_Spell.GetSpellSubtext then
							local castID = Wise:GetOverrideSpellID(sid) or sid
							local subtext = C_Spell.GetSpellSubtext(castID) or C_Spell.GetSpellSubtext(sid)
							if subtext and subtext == "Skyriding" then
								rawName = rawName .. "(" .. subtext .. ")"
							end
						end
						spellVal = rawName
					end
					local itemVal = (sAttr == "item") and tostring(sValue) or ""
					local macroVal = (sAttr == "macrotext" or sAttr == "macro") and tostring(sValue) or ""
					local cbName = ""
					if sType == "click" and sValue and type(sValue) == "table" and sValue.GetName then
						cbName = sValue:GetName() or ""
					elseif sType == "click" and type(sValue) == "string" then
						cbName = sValue
					end
					-- Off-GCD stacking lets a genuinely off-GCD atomic action (item,
					-- shapeshift, /target …) ride along in the SAME press as the next
					-- step instead of consuming a sequence advance on its own. It must
					-- NOT apply to configurator-compiled steps: each compiled step
					-- (type "misc"/value "custom_macro") is its own discrete press, so
					-- the sequence advances one step at a time no matter what the
					-- embedded spell's GCD is. Resolving its macro body here (as the
					-- display readout does) made off-GCD-casting steps stack and
					-- collapse the sequence onto its first GCD spell. Treat every
					-- compiled step as on-GCD so stepping is purely positional; the
					-- GCD colour the configurator shows stays a display-only hint.
					local isCompiledStep = stateAction.type == "misc" and stateAction.value == "custom_macro"
					local isOffGcd = false
					if not isCompiledStep then
						isOffGcd = select(3, Wise:GetCastTimeText(stateAction.type, stateAction.value, stateAction))
					end
					btn:SetAttribute("isa_type_" .. sIdx, sType)
					btn:SetAttribute("isa_spell_" .. sIdx, spellVal)
					btn:SetAttribute("isa_item_" .. sIdx, itemVal)
					btn:SetAttribute("isa_macrotext_" .. sIdx, macroVal)
					btn:SetAttribute("isa_clickbutton_name_" .. sIdx, cbName)
					btn:SetAttribute("isa_cond_" .. sIdx, secureCond)
					btn:SetAttribute("isa_offgcd_" .. sIdx, isOffGcd and 1 or 0)
				end
			end
			btn:SetAttribute("isa_count", stateCount)
			btn:SetAttribute("isa_conflict", actionInfo.conflictStrategy or "priority")
			btn:SetAttribute("isa_suppress", actionInfo.suppressErrors and 1 or 0)
			btn:SetAttribute("isa_action_on_down", GetCVarBool("ActionButtonUseKeyDown"))
			btn:SetAttribute("isa_seq", 1)

			-- PreClick secure snippet: uses the SAME RESOLVE_BLOCK as the keybind path
			-- to guarantee identical condition evaluation, sequencing, and macro generation.
			if not btn.isaConditionWrapped then
				local condSnippet = [[
                    local downOnly = self:GetAttribute("isa_action_on_down")
                    local keybind = self:GetAttribute("keybind") or ""
                    local isMouseWheel = keybind:find("MOUSEWHEEL")
                    if not isMouseWheel and ((down and not downOnly) or (not down and downOnly)) then return end
                    
                    local _rv_ref = self
                    local _rv_t, _rv_s, _rv_i, _rv_m
                ]] .. Wise.RESOLVE_BLOCK .. [[
                    if _rv_t then
                        self:SetAttribute("type", _rv_t)
                        self:SetAttribute("spell", _rv_s)
                        self:SetAttribute("item", _rv_i)
                        self:SetAttribute("macrotext", _rv_m)
                    end
                ]]
				SecureHandlerWrapScript(btn, "PreClick", btn, condSnippet)
				btn.isaConditionWrapped = true

				-- Error suppression hook (insecure, once per button)
				btn:HookScript("PreClick", function(self, mouseButton, isDown)
					if self:GetAttribute("isa_suppress") == 1 then
						Wise:BeginErrorSuppression()
					end
					-- Debug: log resolved action after secure PreClick (/wise resolve)
					if Wise.debugResolve then
						local t = self:GetAttribute("type") or "nil"
						-- Only log the phase that actually fires (secure guard filters the other)
						local downOnly = self:GetAttribute("isa_action_on_down")
						local willFire = (isDown and downOnly) or (not isDown and not downOnly)
						if willFire and t ~= "nil" then
							local s = self:GetAttribute("spell") or ""
							local m = self:GetAttribute("macrotext") or ""
							local seq = self:GetAttribute("isa_seq") or "?"
							local conflict = self:GetAttribute("isa_conflict") or "?"
							local preview = m ~= "" and m:gsub("\n", "\\n") or s
							print(
								string.format(
									"|cffFFD700[Wise Resolve]|r %s seq=%s %s type=%s",
									self:GetName() or "?",
									tostring(seq),
									conflict,
									t
								)
							)
							print(string.format("|cffFFD700  →|r %s", preview))
							self._resolveTime = debugprofilestop()
						end
					end
				end)
				btn:HookScript("PostClick", function(self)
					-- Multi-state sequence/waterfall: the secure PreClick just advanced the
					-- state pointer (isa_seq) / picked the firing state, but the event
					-- dispatcher deliberately schedules NO dynamic refresh in combat (perf),
					-- which froze these icons for entire fights (regressed in 1ac54d4; the
					-- press itself still cast correctly). This press-scoped resync is the
					-- exact-event replacement: it runs only when THIS button is clicked, and
					-- the refresh closure self-guards every secure write on canSetAttrs, so
					-- it is combat-safe (same class as the REGEN_DISABLED refresh).
					local meta = Wise.buttonMeta and Wise.buttonMeta[self]
					if
						meta
						and meta.states
						and #meta.states > 1
						and (meta.conflictStrategy == "sequence" or meta.conflictStrategy == "waterfall")
					then
						local f = self.groupName and Wise.frames and Wise.frames[self.groupName]
						local refresh = f and f._dynamicRefresh
						if refresh then
							C_Timer.After(0, function()
								-- Guard: group may have been rebuilt between press and next frame.
								if f._dynamicRefresh == refresh then
									refresh()
								end
							end)
						end
					end
					if Wise.debugResolve and self._resolveTime then
						local elapsed = debugprofilestop() - self._resolveTime
						print(
							string.format(
								"|cffFFD700[Wise Resolve]|r PostClick %s: |cff00ff00%.2fms|r",
								self:GetName() or "?",
								elapsed
							)
						)
						self._resolveTime = nil
					end
				end)
			end
		else
			-- Single state: clear multi-state attributes
			btn:SetAttribute("isa_count", 0)
		end

		Wise:DebugPrint(
			string.format(
				"  Btn%d: SecureType='%s' Attr='%s' Value='%s' States=%d",
				i,
				tostring(secureType),
				tostring(secureAttr),
				tostring(secureValue):gsub("\n", "\\n"):sub(1, 50),
				stateCount
			)
		)

		-- Update Icon
		local texture = Wise:GetActionIcon(aType, aValue, actionData)

		-- Custom Macro Dynamic Resolution (Initial)
		local resolvedType, resolvedValue, resolvedIcon
		if aType == "misc" and aValue == "custom_macro" then
			resolvedType, resolvedValue, resolvedIcon = Wise:ResolveMacroData(actionData.macroText)
			-- Priority: stored icon (user override, OR the build-pass fallback icon a graph step
			-- carries for spells ResolveMacroData can't resolve by name, e.g. /cast Abundance) >
			-- dynamic resolution (#showtooltip) > question mark. The placeholder "?" (numeric
			-- 134400 or the INV_Misc_QuestionMark path) is never a real icon — skip it at each
			-- rung so an override/possess step that stores "?" falls through to live resolution
			-- instead of locking on. Mirrors the dynamic-refresh closure below. See memory:
			-- override_bar_torch_event_127.
			local function isPlaceholderIcon(ic)
				return not ic
					or ic == 134400
					or (type(ic) == "string" and ic:lower():find("inv_misc_questionmark", 1, true))
			end
			if not isPlaceholderIcon(actionData.icon) then
				texture = actionData.icon
			elseif not isPlaceholderIcon(resolvedIcon) then
				texture = resolvedIcon
			end
		end

		if texture then
			btn.icon:SetTexture(texture)
			btn.icon:Show()
		else
			btn.icon:Hide()
		end

		Wise:ApplyIconStyle(btn, iconStyle)

		-- Store action info for cooldown tracking
		btn.actionType = aType
		btn.actionValue = aValue
		btn.actionData = actionData -- needed for ApplyLayout text

		-- Calculate spellID for cooldown tracking
		local spellID, itemID
		if aType == "spell" then
			local n = tonumber(aValue)
			if n then
				spellID = n
			else
				local spellInfo = C_Spell.GetSpellInfo(aValue)
				if spellInfo then
					spellID = spellInfo.spellID
				end
			end
		elseif aType == "item" or aType == "toy" then
			itemID = aValue
		elseif aType == "mount" and C_MountJournal then
			local _, mSpellID = C_MountJournal.GetMountInfoByID(aValue)
			spellID = mSpellID
		elseif aType == "misc" and aValue == "custom_macro" and resolvedType then
			if resolvedType == "spell" then
				local info = C_Spell.GetSpellInfo(resolvedValue)
				if info then
					spellID = info.spellID
				end
			elseif resolvedType == "item" then
				itemID = resolvedValue
			end
		elseif aType == "misc" and type(aValue) == "string" and aValue:match("^form_") then
			local formIndex = tonumber(aValue:match("^form_(%d+)"))
			if formIndex then
				local _, _, _, formSpellID = GetShapeshiftFormInfo(formIndex)
				if formSpellID then
					local info = C_Spell.GetSpellInfo(formSpellID)
					if info then
						spellID = info.spellID
					end
				end
			end
		end

		-- Graph-compiled macro whose text ResolveMacroData could not resolve (12.0.7
		-- name lookups fail for talent/passive spells): fall back to the SOURCE graph
		-- node's numeric spell id so cooldown/usability tracking still works.
		if aType == "misc" and aValue == "custom_macro" and not spellID and not itemID then
			spellID = Wise:ResolveCompiledStateSpellID(actionData, group.actions and group.actions[actionInfo.slot])
		end

		-- Store in metadata (safe for combat)
		Wise.buttonMeta = Wise.buttonMeta or {}
		Wise.buttonMeta[btn] = {
			baseSpellID = spellID,
			spellID = Wise:GetOverrideSpellID(spellID),
			itemID = itemID,
			actionType = aType,
			actionValue = aValue,
			actionData = actionData,
			states = allStates,
			conflictStrategy = actionInfo.conflictStrategy,
			resetOnCombat = actionInfo.resetOnCombat,
			activeState = actionInfo.activeState or 1,
			-- nil for single-state slots (no condition gating); only multi-state slots
			-- carry a meaningful match flag that the dynamic refresh greys out on.
			activeHadMatch = actionInfo.hadMatch,
		}
		btn.groupName = name -- Store for Text lookups

		-- Update cooldown and active state immediately
		Wise:UpdateButtonCooldown(btn)
		Wise:UpdateButtonState(btn)

		-- Apply visual state for known/unknown and category match.
		-- hadMatch is false only for multi-state slots where no condition currently
		-- matches (the shown action is a non-firing placeholder); grey those too.
		-- nil (single-state slots) is treated as matched.
		local categoryMatch = actionInfo.categoryMatch
		local hasCurrentMatch = actionInfo.hadMatch ~= false
		local isValid = isKnown and categoryMatch and hasCurrentMatch
		btn.isValid = isValid

		local isEmptySlot = (aType == "empty")
		if isEmptySlot then
			-- Empty slots are pure space maintainers — completely invisible
			btn:SetAlpha(0)
			if not InCombatLockdown() then
				btn:EnableMouse(false)
			end
			if btn._textOverlay then
				btn._textOverlay:Hide()
			end
		else
			btn:SetAlpha(1)
			local optionsOpen = Wise.OptionsFrame and Wise.OptionsFrame:IsShown()
			local inEditMode = Wise.editMode
			if not InCombatLockdown() then
				btn:EnableMouse(isValid or not not optionsOpen or not not inEditMode)
			end
			if btn.activeHighlight then
				btn.activeHighlight:Hide()
			end

			if isValid then
				-- Initial state saturated; Usability check will refine this later
				btn.icon:SetDesaturated(false)
				btn.icon:SetAlpha(1)
			else
				btn.icon:SetDesaturated(true)
				btn.icon:SetAlpha(0.5)
			end
		end

		-- Update count (items, consumable spells, and spell charges)
		local count = 0
		local isChargeSpell = false
		if aType == "item" or aType == "toy" then
			-- Smart Item Source Check
			-- Parse ID if "item:123"
			local itemVal = aValue
			if type(itemVal) == "string" and itemVal:match("^item:(%d+)") then
				itemVal = tonumber(itemVal:match("^%a+:(%d+)"))
			end

			if group.smartSources then
				local bagCount = GetItemCount(itemVal, false)
				local bankCount = (GetItemCount(itemVal, true) or 0) - bagCount
				if bankCount < 0 then
					bankCount = 0
				end

				if group.smartSources.bags then
					count = count + bagCount
				end
				if group.smartSources.bank then
					count = count + bankCount
				end
				-- Warband/Guild counts not generically available via synchronous API yet
			else
				count = GetItemCount(itemVal, true)
			end
		elseif aType == "spell" then
			-- Check for spell charges first
			if spellID then
				local chargeInfo = C_Spell.GetSpellCharges(spellID)
				if chargeInfo then
					local maxCharges = CleanSecretNumber(SafeReadField(chargeInfo, "maxCharges"))
					local currentCharges = CleanSecretNumber(SafeReadField(chargeInfo, "currentCharges"))
					-- Only treat as a charge spell when it can hold >1 charge.
					-- Single-charge spells (e.g. Mangle) would otherwise show a
					-- meaningless "1" that can never increase.
					if maxCharges and maxCharges > 1 then
						count = currentCharges
						isChargeSpell = true
					end
				end
			end
			-- Fallback to consumable spell count if not a charge spell
			if not isChargeSpell and IsConsumableSpell and IsConsumableSpell(aValue) then
				count = GetSpellCount(aValue)
			end
		elseif aType == "action" and tonumber(aValue) then
			if IsConsumableAction(tonumber(aValue)) then
				count = GetActionCount(tonumber(aValue))
			end
			local charges, maxCharges, chargeStart, chargeDuration, chargeModRate = GetActionCharges(tonumber(aValue))
			charges = CleanSecretNumber(charges)
			maxCharges = CleanSecretNumber(maxCharges)
			if maxCharges and maxCharges > 1 then
				count = charges
				isChargeSpell = true
			end
		end

		-- Apply charge/count text via Text
		Wise:Text_UpdateCharges(btn, name, count, isChargeSpell)

		-- Register a visibility state driver on the secure button to safely hide/show in combat
		UnregisterStateDriver(btn, "visibility")
		btn:Show()
		if not isEmptySlot then
			local combinedCond = ""
			if stateCount > 1 then
				-- Only register a hide-fallback driver when EVERY state contributed a
				-- driver-expressible condition. A state with no conditions always
				-- matches (it's the fallback the cast macro fires), and custom
				-- conditionals can't be evaluated by the secure driver — in either
				-- case the trailing "; hide" would wrongly hide the slot, so skip
				-- the driver and leave the button always visible.
				local conds = {}
				local allDriverExpressible = true
				for sIdx = 1, stateCount do
					local stateAction = allStates[sIdx]
					if stateAction then
						local expressed = false
						if stateAction.conditions and stateAction.conditions ~= "" then
							if not HasCustomConditionals(stateAction.conditions) then
								local sanitized =
									SanitizeCustom(Wise:SanitizeMacroCondition(Sanitize(stateAction.conditions)))
								if sanitized ~= "" then
									local inner = sanitized:gsub("^%[", ""):gsub("%]$", "")
									table.insert(conds, "[" .. inner .. "] show")
									expressed = true
								end
							end
						end
						if not expressed then
							allDriverExpressible = false
						end
					end
				end
				if allDriverExpressible and #conds > 0 then
					combinedCond = table.concat(conds, "; ") .. "; hide"
				end
			else
				if actionData.conditions and actionData.conditions ~= "" then
					if not HasCustomConditionals(actionData.conditions) then
						local sanitized = SanitizeCustom(Wise:SanitizeMacroCondition(Sanitize(actionData.conditions)))
						if sanitized ~= "" then
							combinedCond = sanitized .. " show; hide"
						end
					end
				end
			end

			if combinedCond ~= "" then
				RegisterStateDriver(btn, "visibility", combinedCond)
			end
		end
	end

	-- Sync Visual Display Buttons
	if f.visualDisplay then
		local visualIconSize, _, visualFontPath, visualShowKB, visualKBPos, visualKBSize, visualChargeSize, visualChargePos, _, _, _, _, visualIconStyle, _, _, _, visualHideEmpty =
			Wise:GetGroupDisplaySettings(name)
		-- Nested instances inherit icon size from parent
		if f.inheritedIconSize then
			visualIconSize = f.inheritedIconSize
		end
		for i, actionInfo in ipairs(actionsToShow) do
			local actionData = actionInfo.data
			local isKnown = actionInfo.known
			local categoryMatch = actionInfo.categoryMatch

			local vBtn = f.visualDisplay.buttons[i]
			if not vBtn then
				vBtn = CreateFrame("Button", nil, f.visualDisplay)
				vBtn:SetSize(visualIconSize, visualIconSize)
				vBtn:EnableMouse(false) -- Not clickable

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

				-- Active state highlight (manually managed)
				vBtn.activeHighlight = vBtn:CreateTexture(nil, "OVERLAY")
				vBtn.activeHighlight:SetAllPoints(vBtn.icon)
				vBtn.activeHighlight:SetTexture("Interface\\Buttons\\ButtonHilight-Square")
				vBtn.activeHighlight:SetBlendMode("ADD")
				vBtn.activeHighlight:Hide()
				-- Cooldown frame — skip CooldownFrameTemplate to avoid its baked-in swipe insets
				vBtn.cooldown = CreateFrame("Cooldown", nil, vBtn)
				vBtn.cooldown:SetAllPoints(vBtn.icon)
				vBtn.cooldown:SetSwipeTexture("Interface\\Buttons\\WHITE8x8")
				vBtn.cooldown:SetSwipeColor(0, 0, 0, 0.8)
				vBtn.cooldown:SetDrawEdge(true)
				vBtn.cooldown:SetDrawSwipe(true)
				-- Hidden by default; the cooldown ticker enables Blizzard's
				-- countdown in combat when start/duration are secret numbers
				-- (mirrors btn._wiseBlizzCDActive handling).
				vBtn.cooldown:SetHideCountdownNumbers(true)

				-- Text layers via Text module
				Wise:Text_CreateFontStrings(vBtn)

				tinsert(f.visualDisplay.buttons, vBtn)

				-- Masque Support (HotKey excluded — managed by Wise's Text layer)
				if Wise.MasqueGroup then
					Wise.MasqueGroup:AddButton(vBtn, {
						Icon = vBtn.icon,
						Cooldown = vBtn.cooldown,
						Count = vBtn.count,
					})
				end

				-- Tooltip support
				Wise:AddInterfaceTooltip(vBtn)
			end
			vBtn:Show()
			-- Always apply current global icon size
			vBtn:SetSize(visualIconSize, visualIconSize)

			-- Apply visuals
			local texture = Wise:GetActionIcon(actionData.type, actionData.value, actionData)
			vBtn.icon:SetTexture(texture)

			-- Update action data for tooltips
			vBtn.actionType = actionData.type
			vBtn.actionValue = actionData.value
			vBtn.actionData = actionData
			vBtn.slot = actionInfo.slot -- Required for keybind lookups

			Wise:ApplyIconStyle(vBtn, visualIconStyle)

			-- Link to real button for cooldown sync
			local realBtn = f.buttons[i]
			if realBtn then
				realBtn.visualClone = vBtn -- Soft deprecated, keeping for now
				if Wise.buttonMeta and Wise.buttonMeta[realBtn] then
					Wise.buttonMeta[realBtn].visualClone = vBtn
				end
			end

			-- Empty slots are pure space maintainers — completely invisible
			local vIsEmpty = (actionData.type == "empty")
			if vIsEmpty then
				vBtn:SetAlpha(0)
			else
				vBtn:SetAlpha(1)
				if vBtn.activeHighlight then
					vBtn.activeHighlight:Hide()
				end
				if isKnown and categoryMatch then
					vBtn.icon:SetDesaturated(false)
					vBtn.icon:SetAlpha(1)
				else
					vBtn.icon:SetDesaturated(true)
					vBtn.icon:SetAlpha(0.5)
				end
			end

			-- Sync count (apply position from settings, copy text from real button)
			local realBtn = f.buttons[i]
			if realBtn and realBtn.count and realBtn.count:IsShown() then
				vBtn.count:SetText(realBtn.count:GetText())
				vBtn.count:SetFont(visualFontPath, visualChargeSize, "OUTLINE")
				Wise:Text_ApplyPosition(vBtn.count, visualChargePos or "TOP")
				vBtn.count:Show()
			else
				vBtn.count:Hide()
			end

			-- Sync keybind
			if realBtn and realBtn.keybind and realBtn.keybind:IsShown() then
				vBtn.keybind:SetText(realBtn.keybind:GetText())
				vBtn.keybind:SetFont(visualFontPath, visualKBSize, "OUTLINE")
				Wise:Text_ApplyPosition(vBtn.keybind, visualKBPos or "BOTTOM")
				vBtn.keybind:Show()
			else
				vBtn.keybind:Hide()
			end

			-- Register a visibility state driver on the visual clone button to safely hide/show in combat
			UnregisterStateDriver(vBtn, "visibility")
			vBtn:Show()
			if not vIsEmpty then
				local allStates = actionInfo.states
				local stateCount = allStates and #allStates or 1
				local combinedCond = ""
				if stateCount > 1 then
					-- Same rule as the real button above: a state with no conditions
					-- (or custom conditionals) always matches, so a hide-fallback
					-- driver would wrongly hide the slot — skip it in that case.
					local conds = {}
					local allDriverExpressible = true
					for sIdx = 1, stateCount do
						local stateAction = allStates[sIdx]
						if stateAction then
							local expressed = false
							if stateAction.conditions and stateAction.conditions ~= "" then
								if not HasCustomConditionals(stateAction.conditions) then
									local sanitized =
										SanitizeCustom(Wise:SanitizeMacroCondition(Sanitize(stateAction.conditions)))
									if sanitized ~= "" then
										local inner = sanitized:gsub("^%[", ""):gsub("%]$", "")
										table.insert(conds, "[" .. inner .. "] show")
										expressed = true
									end
								end
							end
							if not expressed then
								allDriverExpressible = false
							end
						end
					end
					if allDriverExpressible and #conds > 0 then
						combinedCond = table.concat(conds, "; ") .. "; hide"
					end
				else
					if actionData.conditions and actionData.conditions ~= "" then
						if not HasCustomConditionals(actionData.conditions) then
							local sanitized =
								SanitizeCustom(Wise:SanitizeMacroCondition(Sanitize(actionData.conditions)))
							if sanitized ~= "" then
								combinedCond = sanitized .. " show; hide"
							end
						end
					end
				end

				if combinedCond ~= "" then
					RegisterStateDriver(vBtn, "visibility", combinedCond)
				end
			end
		end
		-- Hide unused
		for i = #actionsToShow + 1, #f.visualDisplay.buttons do
			local vBtn = f.visualDisplay.buttons[i]
			UnregisterStateDriver(vBtn, "visibility")
			vBtn:Hide()
		end

		-- Propagate inherited display settings to visual display
		f.visualDisplay.inheritedIconSize = f.inheritedIconSize
		f.visualDisplay.nestedCircleRotation = f.nestedCircleRotation
		f.visualDisplay.nestedLineOrientation = f.nestedLineOrientation
		f.visualDisplay.nestedLineAnchor = f.nestedLineAnchor
		f.visualDisplay.nestedListAnchor = f.nestedListAnchor
		f.visualDisplay.nestedTextAlign = f.nestedTextAlign
		-- Apply Layout to Visual Display
		Wise:ApplyLayout(f.visualDisplay, displayType, #actionsToShow, name)
	end

	-- Hide unused buttons
	for i = #actionsToShow + 1, #f.buttons do
		local btn = f.buttons[i]
		UnregisterStateDriver(btn, "visibility")
		btn:Hide()
	end

	-- Cleanup Stale Refs (Critical for Single Button Fallback)
	-- Set the total count so the snippet knows how far to check
	f.toggleBtn:SetAttribute("buttonCount", #actionsToShow)
	f:SetAttribute("buttonCount", #actionsToShow)

	-- IMPORTANT: Clear old references if the list got shorter
	local maxExisting = f.toggleBtn:GetAttribute("maxButtonRefs") or 0
	if maxExisting > #actionsToShow then
		for i = #actionsToShow + 1, maxExisting do
			Wise:DebugPrint("Cleanup: Clearing frameref-btn" .. i)
			f.toggleBtn:SetAttribute("frameref-btn" .. i, nil)
		end
	end
	f.toggleBtn:SetAttribute("maxButtonRefs", #actionsToShow)
	f:SetAttribute("maxButtonRefs", #actionsToShow)

	-- Setup Nested Keybind Attributes (on 'f' - the Show/Hide frame)
	local nested = (group.keybindSettings and group.keybindSettings.nested)
	f:SetAttribute("nestedKeybinds", nested)

	-- Always update attributes to keep them in sync
	if nested then
		for i, actionInfo in ipairs(actionsToShow) do
			local btn = f.buttons[i]
			local slotKey = group.actions[actionInfo.slot] and group.actions[actionInfo.slot].keybind
			local keyToSet = btn.isValid and slotKey or nil

			f:SetAttribute("nested_key_" .. i, keyToSet) -- Set or Clear (if nil)
			f:SetAttribute("nested_btn_name_" .. i, btn:GetName())
			f:SetAttribute("nested_mouse_" .. i, keyToSet and GetMouseClickName(keyToSet) or nil)
		end
	end

	-- Cleanup Stale Keys
	local maxKeys = f:GetAttribute("nested_max_keys") or 0
	if maxKeys > #actionsToShow then
		for i = #actionsToShow + 1, maxKeys do
			f:SetAttribute("nested_key_" .. i, nil)
			f:SetAttribute("nested_btn_name_" .. i, nil)
			f:SetAttribute("nested_mouse_" .. i, nil)
		end
	end
	f:SetAttribute("nested_max_keys", #actionsToShow)

	Wise:ApplyLayout(f, displayType, #actionsToShow, name)

	-- Compute undermouse bounding box from button layout (must be after ApplyLayout sets targetX/targetY)
	if hasUnderMouse and f.buttons and #f.buttons > 0 then
		local first = true
		for _, btn in ipairs(f.buttons) do
			local tx = btn.targetX or 0
			local ty = btn.targetY or 0
			local bw, bh = btn:GetSize()
			local halfW = (bw or 40) / 2
			local halfH = (bh or 40) / 2
			if first then
				cachedMinX = tx - halfW
				cachedMaxX = tx + halfW
				cachedMinY = ty - halfH
				cachedMaxY = ty + halfH
				first = false
			else
				if tx - halfW < cachedMinX then
					cachedMinX = tx - halfW
				end
				if tx + halfW > cachedMaxX then
					cachedMaxX = tx + halfW
				end
				if ty - halfH < cachedMinY then
					cachedMinY = ty - halfH
				end
				if ty + halfH > cachedMaxY then
					cachedMaxY = ty + halfH
				end
			end
		end
		-- Bake in padding
		cachedMinX = cachedMinX - 10
		cachedMaxX = cachedMaxX + 10
		cachedMinY = cachedMinY - 10
		cachedMaxY = cachedMaxY + 10
	end

	-- Sync Edit Mode state (skip for mouse-anchored)
	if Wise.editMode and group.anchorMode ~= "mouse" then
		Wise:SetFrameEditMode(f, name, true)
		f:Show()
	else
		Wise:SetFrameEditMode(f, name, false)
	end

	-- Sync Cooldowns, Usability, and Active State once after setup
	for _, btn in ipairs(f.buttons) do
		Wise:UpdateButtonCooldown(btn)
		Wise:UpdateButtonUsability(btn)
		Wise:UpdateButtonState(btn)
	end

	-- Condition evaluation for multi-state and interface icon updates.
	-- Cancel any legacy per-group ticker (from older sessions / hot-reload) and
	-- clear the event-driven registration; both are re-established below if the
	-- group still qualifies as dynamic.
	if f.conditionTicker then
		f.conditionTicker:Cancel()
		f.conditionTicker = nil
	end
	if Wise._dynamicGroups then
		Wise._dynamicGroups[f] = nil
	end
	local needsTicker = false
	-- For dynamic groups, check if any slot has per-action conditions or availability-dependent
	-- misc types (extrabutton, zoneability, overridebar, possessbar). These slots may not have
	-- visible buttons yet, but the ticker must run to detect when they become available.
	local AVAILABILITY_MISC = { extrabutton = true, zoneability = true, overridebar = true, possessbar = true }
	local hasDynamicConditions = false
	local hasDynamicCooldowns = false
	-- Some condition inputs have NO game event to react to and must be polled by
	-- the safety-net ticker: modifier keys ([mod:shift], [nomod]). Most others
	-- (stance, spec, target reaction, vehicle, action slots) are covered by the
	-- DynamicRefreshDriver's events, so groups without these tokens never poll.
	-- NOTE: needsPoll is independent of interface style. "dynamic" vs "static"
	-- controls only whether the interface resizes its visible slot COUNT based on
	-- availability (hasDynamicConditions / hasDynamicCooldowns below). Per-slot icon
	-- swapping to reflect the action that would fire ([harm]/[help]/[mod] etc.) must
	-- happen in BOTH styles, so modifier polling is detected regardless of isDynamic.
	local needsPoll = false
	if group.actions then
		for _, states in pairs(group.actions) do
			if type(states) == "table" then
				for _, state in ipairs(states) do
					if state.conditions and state.conditions ~= "" then
						hasDynamicConditions = true
					elseif isDynamic and (state.type == "misc" and AVAILABILITY_MISC[state.value]) then
						hasDynamicConditions = true
					end

					if
						isDynamic
						and group.propertyType ~= "CooldownWiser"
						and (state.type == "spell" or state.type == "item" or state.type == "toy")
					then
						hasDynamicCooldowns = true
					end
					-- Event-less modifier conditions need polling in any style so the
					-- displayed icon stays in sync with the modifier key.
					if state.conditions and state.conditions:find("mod") then
						needsPoll = true
					end
				end
			end
		end
	end
	if hasDynamicConditions or hasDynamicCooldowns then
		needsTicker = true
	end
	for _, btn in ipairs(f.buttons) do
		if btn:IsShown() then
			local meta = Wise.buttonMeta[btn]
			if meta then
				if (meta.states and #meta.states > 1) or meta.actionType == "interface" then
					needsTicker = true
					break
				end
				-- Single-state slots that carry a condition (e.g. a lone [combat]
				-- potion) still need refreshing: the condition is only evaluated at
				-- build time, so without a ticker an out-of-combat-built [combat] slot
				-- stays greyed forever once combat starts. Multi-state slots are caught
				-- above; this catches the single-state conditional case.
				if meta.states and #meta.states == 1 then
					local only = meta.states[1]
					if only and only.conditions and only.conditions ~= "" then
						needsTicker = true
						break
					end
				end
				if
					(
						meta.actionType == "misc"
						and (
							meta.actionValue == "custom_macro"
							or meta.actionValue == "extrabutton"
							or meta.actionValue == "zoneability"
							or meta.actionValue == "overridebar"
							or meta.actionValue == "possessbar"
						)
					) or meta.actionType == "action"
				then
					needsTicker = true
					break
				end
			end
		end
	end
	-- Track which conditional/availability slots are currently visible, so we can detect changes
	if hasDynamicConditions then
		local condSnapshot = {}
		if group.actions then
			for slotIdx, states in pairs(group.actions) do
				if type(states) == "table" then
					condSnapshot[slotIdx] = {}
					for sIdx, state in ipairs(states) do
						if state.conditions and state.conditions ~= "" then
							condSnapshot[slotIdx][sIdx] = EvalConditionExact(state.conditions)
						elseif state.type == "misc" and AVAILABILITY_MISC[state.value] then
							condSnapshot[slotIdx][sIdx] = Wise:IsActionKnown(state.type, state.value)
						end
					end
				end
			end
		end
		f._dynamicCondSnapshot = condSnapshot
	end
	-- Track cooldown & availability state for dynamic groups so slot add/remove happens promptly
	if hasDynamicCooldowns then
		local cdSnapshot = {}
		if group.actions then
			for slotIdx, states in pairs(group.actions) do
				if type(states) == "table" then
					for _, state in ipairs(states) do
						if state.type == "spell" or state.type == "item" or state.type == "toy" then
							local isKnown = Wise:IsActionKnown(state.type, state.value)
							local status = "unknown"
							if isKnown then
								local onCD = Wise:IsActionOnCooldown(state.type, state.value, state)
								status = onCD and "cd" or "ready"
							end
							cdSnapshot[slotIdx] = status
						end
					end
				end
			end
		end
		f._dynamicCDSnapshot = cdSnapshot
	end
	-- Build the refresh closure for dynamic groups. Previously this ran on a
	-- 0.2s C_Timer.NewTicker, which re-ran the full per-button loop 5x/sec
	-- forever (dominant idle CPU cost). Now we store it as f._dynamicRefresh
	-- and drive it from game events (see Wise._dynamicEventFrame below), with a
	-- slow 1s safety-net poll for state that lacks a clean event. The closure
	-- body is unchanged — only WHEN it runs has changed.
	if needsTicker then
		f._dynamicRefresh = function()
			-- For dynamic groups with conditional/availability slots, check if state changed
			-- and trigger a full rebuild when it does (e.g. [extrabar] or [zoneability] toggled)
			if hasDynamicConditions and f._dynamicCondSnapshot and not InCombatLockdown() then
				local changed = false
				if group.actions then
					for slotIdx, states in pairs(group.actions) do
						if type(states) == "table" and f._dynamicCondSnapshot[slotIdx] then
							for sIdx, state in ipairs(states) do
								local now
								if state.conditions and state.conditions ~= "" then
									now = EvalConditionExact(state.conditions)
								elseif state.type == "misc" and AVAILABILITY_MISC[state.value] then
									now = Wise:IsActionKnown(state.type, state.value)
								end
								if now ~= nil and now ~= f._dynamicCondSnapshot[slotIdx][sIdx] then
									changed = true
									break
								end
							end
						end
						if changed then
							break
						end
					end
				end
				if changed then
					Wise:UpdateGroupDisplay(name)
					return
				end
			end
			-- For dynamic groups with cooldown-filterable slots, detect CD or availability state changes
			if hasDynamicCooldowns and f._dynamicCDSnapshot and not InCombatLockdown() then
				local changed = false
				if group.actions then
					for slotIdx, states in pairs(group.actions) do
						if type(states) == "table" then
							for _, state in ipairs(states) do
								if state.type == "spell" or state.type == "item" or state.type == "toy" then
									local isKnown = Wise:IsActionKnown(state.type, state.value)
									local status = "unknown"
									if isKnown then
										local onCD = Wise:IsActionOnCooldown(state.type, state.value, state)
										status = onCD and "cd" or "ready"
									end
									if status ~= f._dynamicCDSnapshot[slotIdx] then
										changed = true
										break
									end
								end
							end
						end
						if changed then
							break
						end
					end
				end
				if changed then
					Wise:UpdateGroupDisplay(name)
					return
				end
			end
			local canSetAttrs = not InCombatLockdown()
			for _, btn in ipairs(f.buttons) do
				if btn:IsShown() then
					local meta = Wise.buttonMeta[btn]
					if not meta then -- skip
					elseif meta.actionType == "action" then
						local aID = tonumber(meta.actionValue)
						if aID then
							local realID = Wise:ResolveBarActionID(aID)
							local tex = GetActionTexture(realID)
							local isBarSlot = (aID >= 121 and aID <= 156)
							if tex then
								f._retryCount = 0
								btn.icon:SetTexture(tex)
								btn.icon:Show()
								local vClone = meta.visualClone or btn.visualClone
								if vClone and vClone.icon then
									vClone.icon:SetTexture(tex)
									vClone.icon:Show()
								end
							else
								local overrideActive = HasOverrideActionBar and HasOverrideActionBar()
								local vehicleActive = HasVehicleActionBar and HasVehicleActionBar()
								if isBarSlot and (overrideActive or vehicleActive) then
									f._retryCount = (f._retryCount or 0) + 1
									if f._retryCount <= 5 and not f._retryTimerScheduled then
										f._retryTimerScheduled = true
										C_Timer.After(0.2, function()
											f._retryTimerScheduled = false
											if f._dynamicRefresh then
												f._dynamicRefresh()
											end
										end)
									end
								end

								if isBarSlot then
									btn.icon:Hide()
									local vClone = meta.visualClone or btn.visualClone
									if vClone and vClone.icon then
										vClone.icon:Hide()
									end
								else
									btn.icon:SetTexture(134400)
									btn.icon:Show()
									local vClone = meta.visualClone or btn.visualClone
									if vClone and vClone.icon then
										vClone.icon:SetTexture(134400)
										vClone.icon:Show()
									end
								end
							end
							Wise:UpdateButtonCooldown(btn)
							Wise:UpdateButtonUsability(btn)
						end
					elseif meta.actionType == "misc" and meta.actionValue == "custom_macro" then
						-- Update Custom Macro
						local mType, mVal, mIcon = Wise:ResolveMacroData(meta.actionData.macroText)

						-- Priority: stored icon (user override OR the build-pass fallback icon a graph
						-- step carries for spells ResolveMacroData can't resolve by NAME — e.g. /cast
						-- Abundance, where C_Spell.GetSpellInfo("Abundance") returns nil because the
						-- name isn't an exact spellbook match; without it the slot locks to "?") >
						-- dynamic resolution (#showtooltip / mIcon) > question mark. The placeholder
						-- "?" (numeric 134400 or the "INV_Misc_QuestionMark" path string) is never a
						-- real icon: a graph-compiled override/possess step stores it, and letting it
						-- win would keep a static interface stuck on "?" after the override bar ends.
						-- Skip the placeholder at each rung so mIcon (resolved from the per-character
						-- macro / live /click) takes over. Mirrors the initial icon path above.
						-- See memory: override_bar_torch_event_127.
						local function isPlaceholderIcon(ic)
							return not ic
								or ic == 134400
								or (type(ic) == "string" and ic:lower():find("inv_misc_questionmark", 1, true))
						end
						local displayIcon
						if not isPlaceholderIcon(meta.actionData.icon) then
							displayIcon = meta.actionData.icon
						elseif not isPlaceholderIcon(mIcon) then
							displayIcon = mIcon
						else
							displayIcon = 134400
						end

						local overrideActive = HasOverrideActionBar and HasOverrideActionBar()
						local vehicleActive = HasVehicleActionBar and HasVehicleActionBar()
						local needsRetry = false
						if (not mIcon or mIcon == 134400) and (overrideActive or vehicleActive) then
							needsRetry = true
						elseif
							overrideActive
							and meta.actionData
							and meta.actionData.macroText
							and meta.actionData.macroText:find("overridebar", 1, true)
							and mType ~= "action"
						then
							needsRetry = true
						elseif
							vehicleActive
							and meta.actionData
							and meta.actionData.macroText
							and meta.actionData.macroText:find("vehicleui", 1, true)
							and mType ~= "action"
						then
							needsRetry = true
						end

						if needsRetry then
							f._retryCount = (f._retryCount or 0) + 1
							if f._retryCount <= 5 and not f._retryTimerScheduled then
								f._retryTimerScheduled = true
								C_Timer.After(0.2, function()
									f._retryTimerScheduled = false
									if f._dynamicRefresh then
										f._dynamicRefresh()
									end
								end)
							end
						else
							f._retryCount = 0
						end

						btn.icon:SetTexture(displayIcon)
						local vClone = meta.visualClone or btn.visualClone
						if vClone and vClone.icon then
							vClone.icon:SetTexture(displayIcon)
						end

						if mType then
							local spellID, itemID
							if mType == "spell" then
								local n = tonumber(mVal)
								if n then
									spellID = n
								else
									local info = C_Spell.GetSpellInfo(mVal)
									if info then
										spellID = info.spellID
									end
								end
							elseif mType == "item" then
								itemID = mVal
							end
							meta.baseSpellID = spellID
							meta.spellID = Wise:GetOverrideSpellID(spellID)
							meta.itemID = itemID

							Wise:UpdateButtonCooldown(btn)
							Wise:UpdateButtonUsability(btn)
						else
							-- Reset dynamic metadata (base icon handled above via displayIcon)
							meta.baseSpellID = nil
							meta.spellID = nil
							meta.itemID = nil

							Wise:UpdateButtonCooldown(btn)
							Wise:UpdateButtonUsability(btn)
						end
					elseif meta.actionType == "misc" and meta.actionValue == "extrabutton" then
						-- Update Extra Action Button icon dynamically
						-- Use derived slot constant instead of reading .action from Blizzard frame to avoid taint
						-- GetActionTexture returns nil when no action is in the slot, so no gate needed
						local tex = GetActionTexture(EXTRA_ACTION_BUTTON_SLOT)
						tex = tex or "Interface\\Icons\\Temp"
						btn.icon:SetTexture(tex)
						local vClone = meta.visualClone or btn.visualClone
						if vClone and vClone.icon then
							vClone.icon:SetTexture(tex)
						end
						Wise:UpdateButtonCooldown(btn)
						Wise:UpdateButtonUsability(btn)
					elseif meta.actionType == "misc" and meta.actionValue == "zoneability" then
						-- Update Zone Ability icon dynamically
						local zoneBtn = GetZoneAbilitySpellButton()
						local tex
						if zoneBtn and zoneBtn.spellID then
							local info = C_Spell.GetSpellInfo(zoneBtn.spellID)
							if info then
								tex = info.iconID
							end
						end
						tex = tex or "Interface\\Icons\\Temp"
						btn.icon:SetTexture(tex)
						local vClone = meta.visualClone or btn.visualClone
						if vClone and vClone.icon then
							vClone.icon:SetTexture(tex)
						end
						-- Rebind clickbutton when the spell button changes (e.g. entering garrison)
						if canSetAttrs and zoneBtn then
							if zoneBtn.GetName and zoneBtn:GetName() then
								btn:SetAttribute("type", "macro")
								btn:SetAttribute("macrotext", "/click " .. zoneBtn:GetName())
							elseif zoneBtn.spellID then
								btn:SetAttribute("type", "spell")
								btn:SetAttribute("spell", zoneBtn.spellID)
							else
								btn:SetAttribute("type", "click")
								btn:SetAttribute("clickbutton", zoneBtn)
							end
						end
						Wise:UpdateButtonCooldown(btn)
						Wise:UpdateButtonUsability(btn)
					elseif meta.actionType == "misc" and meta.actionValue == "overridebar" then
						-- Update Override Bar icon dynamically
						local realID = Wise:ResolveBarActionID(133)
						local tex = GetActionTexture(realID)
						if tex then
							btn.icon:SetTexture(tex)
							btn.icon:Show()
							local vClone = meta.visualClone or btn.visualClone
							if vClone and vClone.icon then
								vClone.icon:SetTexture(tex)
								vClone.icon:Show()
							end
						else
							btn.icon:Hide()
							local vClone = meta.visualClone or btn.visualClone
							if vClone and vClone.icon then
								vClone.icon:Hide()
							end
						end
						-- Rebind clickbutton in case override bar appeared
						local overrideBtn = _G["OverrideActionBarButton1"]
						if canSetAttrs and overrideBtn then
							if overrideBtn.GetName and overrideBtn:GetName() then
								btn:SetAttribute("type", "macro")
								btn:SetAttribute("macrotext", "/click " .. overrideBtn:GetName())
							else
								btn:SetAttribute("type", "click")
								btn:SetAttribute("clickbutton", overrideBtn)
							end
						end
						Wise:UpdateButtonCooldown(btn)
						Wise:UpdateButtonUsability(btn)
					elseif meta.actionType == "misc" and meta.actionValue == "possessbar" then
						-- Update Possess Bar icon dynamically
						local realID = Wise:ResolveBarActionID(121)
						local tex = GetActionTexture(realID)
						if tex then
							btn.icon:SetTexture(tex)
							btn.icon:Show()
							local vClone = meta.visualClone or btn.visualClone
							if vClone and vClone.icon then
								vClone.icon:SetTexture(tex)
								vClone.icon:Show()
							end
						else
							btn.icon:Hide()
							local vClone = meta.visualClone or btn.visualClone
							if vClone and vClone.icon then
								vClone.icon:Hide()
							end
						end
						-- Rebind clickbutton in case possess bar appeared (route to OverrideActionBarButton1 in vehicle, ActionButton1 in possess)
						if canSetAttrs then
							btn:SetAttribute("type", "macro")
							btn:SetAttribute(
								"macrotext",
								"/click [vehicleui] OverrideActionBarButton1; [possessbar] ActionButton1"
							)
						end
						Wise:UpdateButtonCooldown(btn)
						Wise:UpdateButtonUsability(btn)
					elseif meta.actionType == "interface" then
						-- Update interface icon dynamically (reflects child's current active action)
						local nestMode = btn:GetAttribute("isa_nest_mode") or "jump"
						if nestMode ~= "jump" then
							-- For rotation modes, show the NEXT action that would fire
							local rotIcon = Wise:GetRotationIcon(btn, meta.actionValue, nestMode)
							if rotIcon then
								btn.icon:SetTexture(rotIcon)
								local vClone = meta.visualClone or btn.visualClone
								if vClone and vClone.icon then
									vClone.icon:SetTexture(rotIcon)
								end
							end
						else
							local newIcon = Wise:GetActionIcon("interface", meta.actionValue)
							if newIcon then
								btn.icon:SetTexture(newIcon)
								local vClone = meta.visualClone or btn.visualClone
								if vClone and vClone.icon then
									vClone.icon:SetTexture(newIcon)
								end
							end
						end
						-- Refresh child action attributes when not in combat
						if canSetAttrs and nestMode ~= "jump" then
							Wise:StoreChildActionsOnButton(btn, meta.actionValue, nestMode)
						end
					elseif
						(meta.states and #meta.states > 1)
						or (
							meta.states
							and #meta.states == 1
							and meta.states[1]
							and meta.states[1].conditions
							and meta.states[1].conditions ~= ""
						)
					then
						local chosen, hadMatch = Wise:EvaluateSlotConditions(meta.states, meta.conflictStrategy, btn)
						-- Grey the icon when nothing currently matches: the displayed state is
						-- only a placeholder and a click would cast nothing. Update desaturation
						-- whenever hadMatch flips, even if the chosen index is unchanged (e.g. the
						-- fallback index equals the previously-matched index).
						if hadMatch ~= meta.activeHadMatch then
							meta.activeHadMatch = hadMatch
							-- Keep isValid in sync so UpdateButtonUsability (which early-returns
							-- on isValid==false) skips a placeholder and refines a real match.
							-- States in meta.states are pre-filtered to known/allowed, so a match
							-- is castable; usability coloring takes over from here when matched.
							btn.isValid = hadMatch
							btn.icon:SetDesaturated(not hadMatch)
							btn.icon:SetAlpha(hadMatch and 1 or 0.5)
							local vc = meta.visualClone or btn.visualClone
							if vc and vc.icon then
								vc.icon:SetDesaturated(not hadMatch)
								vc.icon:SetAlpha(hadMatch and 1 or 0.5)
							end
							-- Now that the slot matches, let usability coloring (usable /
							-- OOM / on-cooldown) take over instead of leaving it flat-saturated.
							-- Skipped while unmatched (the usability early-return guards isValid==false).
							if hadMatch then
								Wise:UpdateButtonUsability(btn)
							end
						end
						if chosen and chosen ~= meta.activeState then
							meta.activeState = chosen
							local state = meta.states[chosen]
							if state then
								btn.icon:SetTexture(Wise:GetActionIcon(state.type, state.value, state))
								btn.actionType = state.type
								btn.actionValue = state.value
								btn.actionData = state

								-- Apply/remove empty slot visual treatment (alpha is safe in combat, EnableMouse is not)
								if state.type == "empty" then
									btn:SetAlpha(0)
									if canSetAttrs then
										btn:EnableMouse(false)
									end
								else
									btn:SetAlpha(1)
									if canSetAttrs then
										btn:EnableMouse(true)
									end
									-- Restore keybinds when switching away from empty
									local _, _, _, showKeybinds, _, _, _, _, _, _, _, _, _, _, _, _, _, showInterfaceKeybind =
										Wise:GetGroupDisplaySettings(f.groupName or name)
									Wise:Text_UpdateKeybind(btn, f.groupName or name, showKeybinds)
									Wise:Text_UpdateInterfaceKeybind(btn, f.groupName or name, showInterfaceKeybind)
									local vClone = meta.visualClone or btn.visualClone
									if vClone then
										Wise:Text_UpdateKeybind(vClone, f.groupName or name, showKeybinds)
										Wise:Text_UpdateInterfaceKeybind(
											vClone,
											f.groupName or name,
											showInterfaceKeybind
										)
									end
								end

								-- Update secure attributes when not in combat
								-- BUT NOT for sequence/waterfall with >1 state: the PreClick
								-- secure snippet builds the firing macrotext at press time, so
								-- writing a single-state macro here would clobber the stacked
								-- waterfall macro (or the sequence pointer) on the next press.
								-- Single-state slots fall through and get normal attributes.
								local snippetManagesAttrs = (
									meta.conflictStrategy == "sequence" or meta.conflictStrategy == "waterfall"
								)
									and meta.states
									and #meta.states > 1
								if canSetAttrs and not snippetManagesAttrs then
									local sType, sAttr, sValue = Wise:GetSecureAttributes(state, state.conditions)
									btn:SetAttribute("type", nil)
									btn:SetAttribute("spell", nil)
									btn:SetAttribute("item", nil)
									btn:SetAttribute("macro", nil)
									btn:SetAttribute("macrotext", nil)
									btn:SetAttribute("clickbutton", nil)
									btn:SetAttribute("type", sType)
									if sAttr then
										btn:SetAttribute(sAttr, sValue)
									end
								end

								-- Update cooldown tracking for new active state
								local spellID, itemID
								if state.type == "spell" then
									local n = tonumber(state.value)
									if n then
										spellID = n
									else
										local info = C_Spell.GetSpellInfo(state.value)
										if info then
											spellID = info.spellID
										end
									end
								elseif state.type == "item" or state.type == "toy" then
									itemID = state.value
								elseif state.type == "mount" and C_MountJournal then
									local _, mSpellID = C_MountJournal.GetMountInfoByID(state.value)
									spellID = mSpellID
								elseif state.type == "misc" and state.value == "custom_macro" then
									-- Graph-compiled step: resolve from the source node's numeric
									-- id (12.0.7 name lookups can't parse it out of the macro text).
									spellID = Wise:ResolveCompiledStateSpellID(
										state,
										group.actions and btn.slot and group.actions[btn.slot]
									)
								end
								meta.baseSpellID = spellID
								meta.spellID = Wise:GetOverrideSpellID(spellID)
								meta.itemID = itemID
								meta.actionType = state.type
								meta.actionValue = state.value
								meta.actionData = state
								Wise:UpdateButtonCooldown(btn)

								-- Sync visual clone
								local vClone = meta.visualClone or btn.visualClone
								if vClone then
									if state.type == "empty" then
										vClone:SetAlpha(0)
									else
										vClone:SetAlpha(1)
										if vClone.icon then
											vClone.icon:SetTexture(Wise:GetActionIcon(state.type, state.value, state))
										end
									end
								end
							end
						end
					end
				end
			end
		end

		-- Register this frame for event-driven refresh.
		Wise._dynamicGroups = Wise._dynamicGroups or {}
		Wise._dynamicGroups[f] = true
		-- Only groups with event-less modifier conditions need the safety-net poll.
		f._needsPoll = needsPoll or nil
		-- Run once after setup completes so initial state is correct. Deferred by
		-- a frame to preserve the old ticker's ordering (it first fired ~0.2s after
		-- setup, i.e. after UpdateBindings / embedded-parent propagation below).
		local refresh = f._dynamicRefresh
		C_Timer.After(0, function()
			-- Guard: the group may have been deleted or rebuilt before this fires.
			if Wise._dynamicGroups and Wise._dynamicGroups[f] and f._dynamicRefresh == refresh then
				refresh()
			end
		end)
	else
		-- Not (or no longer) a dynamic group — deregister and drop any stale
		-- refresh closure so events don't keep invoking the old one.
		if Wise._dynamicGroups then
			Wise._dynamicGroups[f] = nil
		end
		f._dynamicRefresh = nil
	end

	-- Ensure bindings are active (fixes potential staleness on new groups)
	if Wise.UpdateBindings then
		Wise:UpdateBindings()
	end

	-- Propagate to parents that embed this group
	if Wise._embeddedParents and Wise._embeddedParents[name] then
		local parentName = Wise._embeddedParents[name]
		-- Guard against re-entrancy: only propagate if not already updating this parent
		if not Wise._embeddedUpdating then
			Wise._embeddedUpdating = true
			if not InCombatLockdown() then
				Wise:UpdateGroupDisplay(parentName)
			end
			Wise._embeddedUpdating = nil
		end
	end

	-- Sanitize mouse-anchor offsets if layout changes might cause collisions
	if Wise.SanitizeMouseAnchorOffsets then
		Wise:SanitizeMouseAnchorOffsets(name)
	end
end

-- Metadata storage to avoid reading SecureFrames in combat
Wise.buttonMeta = {}

function Wise:ApplyIconStyle(btn, style)
	if not btn or not btn.icon then
		return
	end

	-- Skip Wise styling if Masque is loaded and active for this group
	if Wise.MasqueGroup and not (Wise.MasqueGroup.db and Wise.MasqueGroup.db.Disabled) then
		return
	end

	style = style or "rounded"

	-- Clear previous mask if exists
	if btn.styleMask then
		btn.icon:RemoveMaskTexture(btn.styleMask)
		if btn.activeHighlight then
			btn.activeHighlight:RemoveMaskTexture(btn.styleMask)
		end
		btn.styleMask:Hide()
	end

	-- Reset cooldown shape to default square
	if btn.cooldown then
		btn.cooldown:SetUseCircularEdge(false)
		btn.cooldown:SetSwipeTexture("Interface\\Buttons\\WHITE8x8")
	end

	if style == "rounded" then
		-- Default WoW Icon (slightly rounded square)
		btn.icon:SetTexCoord(0, 1, 0, 1)
		if btn.activeHighlight then
			btn.activeHighlight:SetTexCoord(0, 1, 0, 1)
		end
	elseif style == "square" then
		-- Zoom in to remove rounded borders
		btn.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		if btn.activeHighlight then
			btn.activeHighlight:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		end
	elseif style == "round" then
		-- Apply circular mask
		if not btn.styleMask then
			btn.styleMask = btn:CreateMaskTexture()
			btn.styleMask:SetAllPoints(btn.icon)
		end
		btn.styleMask:SetTexture(
			"Interface\\CHARACTERFRAME\\TempPortraitAlphaMask",
			"CLAMPTOBLACKADDITIVE",
			"CLAMPTOBLACKADDITIVE"
		)
		btn.styleMask:Show()
		btn.icon:AddMaskTexture(btn.styleMask)
		if btn.activeHighlight then
			btn.activeHighlight:AddMaskTexture(btn.styleMask)
		end
		btn.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		if btn.activeHighlight then
			btn.activeHighlight:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		end
		-- Circular cooldown swipe
		if btn.cooldown then
			btn.cooldown:SetUseCircularEdge(true)
			btn.cooldown:SetSwipeTexture("Interface\\CHARACTERFRAME\\TempPortraitAlphaMask")
		end
	elseif style == "hexagon" then
		if not btn.styleMask then
			btn.styleMask = btn:CreateMaskTexture()
			btn.styleMask:SetAllPoints(btn.icon)
		end
		btn.styleMask:SetTexture(
			"Interface\\AddOns\\Wise\\Media\\HexagonMask.tga",
			"CLAMPTOBLACKADDITIVE",
			"CLAMPTOBLACKADDITIVE"
		)
		btn.styleMask:Show()
		btn.icon:AddMaskTexture(btn.styleMask)
		if btn.activeHighlight then
			btn.activeHighlight:AddMaskTexture(btn.styleMask)
		end
		btn.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		if btn.activeHighlight then
			btn.activeHighlight:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		end
		-- Hexagon-shaped cooldown swipe
		if btn.cooldown then
			btn.cooldown:SetSwipeTexture("Interface\\AddOns\\Wise\\Media\\HexagonMask.tga")
		end
	elseif style == "octagon" then
		if not btn.styleMask then
			btn.styleMask = btn:CreateMaskTexture()
			btn.styleMask:SetAllPoints(btn.icon)
		end
		btn.styleMask:SetTexture(
			"Interface\\AddOns\\Wise\\Media\\OctagonMask.tga",
			"CLAMPTOBLACKADDITIVE",
			"CLAMPTOBLACKADDITIVE"
		)
		btn.styleMask:Show()
		btn.icon:AddMaskTexture(btn.styleMask)
		if btn.activeHighlight then
			btn.activeHighlight:AddMaskTexture(btn.styleMask)
		end
		btn.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		if btn.activeHighlight then
			btn.activeHighlight:SetTexCoord(0.08, 0.92, 0.08, 0.92)
		end
		-- Octagon-shaped cooldown swipe
		if btn.cooldown then
			btn.cooldown:SetSwipeTexture("Interface\\AddOns\\Wise\\Media\\OctagonMask.tga")
		end
	end
end

function Wise:GetGrowthInfo(groupName)
	local group = WiseDB.groups[groupName]
	if not group then
		return 0, 0, false, false, "Unknown"
	end

	local type = group.type or "circle"
	local anchorPoint = (group.anchor and group.anchor.point) or "CENTER"

	if type == "circle" then
		return 0, 0, true, true, "Radial"
	elseif type == "button" then
		return 0, 0, false, false, "None"
	elseif type == "list" then
		local dy = -1
		local text = "Down"
		if anchorPoint:find("BOTTOM") then
			dy = 1
			text = "Up"
		elseif anchorPoint:find("TOP") then
			dy = -1
			text = "Down"
		else
			dy = 0 -- 0 means bidirectional/centered
			text = "Centered\n(Vertical)"
		end
		return 0, dy, false, true, text
	elseif type == "line" then
		local orientation = group.lineOrientation or "horizontal"
		if orientation == "horizontal" then
			local dx = 1
			local text = "Right"
			if anchorPoint:find("RIGHT") then
				dx = -1
				text = "Left"
			elseif anchorPoint:find("LEFT") then
				dx = 1
				text = "Right"
			else
				dx = 0 -- 0 means bidirectional/centered
				text = "Centered\n(Horizontal)"
			end
			return dx, 0, true, false, text
		else
			local dy = -1
			local text = "Down"
			if anchorPoint:find("BOTTOM") then
				dy = 1
				text = "Up"
			elseif anchorPoint:find("TOP") then
				dy = -1
				text = "Down"
			else
				dy = 0 -- 0 means bidirectional/centered
				text = "Centered\n(Vertical)"
			end
			return 0, dy, false, true, text
		end
	elseif type == "box" then
		local dirX = 1
		local dirY = -1
		local tX = "Right"
		local tY = "Down"

		if anchorPoint:find("RIGHT") then
			dirX = -1
			tX = "Left"
		elseif not anchorPoint:find("LEFT") then
			dirX = 0
			tX = "Centered\n(Horizontal)"
		end

		if anchorPoint:find("BOTTOM") then
			dirY = 1
			tY = "Up"
		elseif not anchorPoint:find("TOP") then
			dirY = 0
			tY = "Centered\n(Vertical)"
		end

		local text = ""
		local primaryAxis = group.fixedAxis or "x"
		if anchorPoint == "CENTER" then
			text = "Centered\n(Both)"
		else
			if primaryAxis == "x" then
				text = tY .. " & " .. tX .. " (Row-First)"
			else
				text = tX .. " & " .. tY .. " (Column-First)"
			end
		end

		return dirX, dirY, true, true, text
	end

	return 0, 0, false, false, "Unknown"
end

function Wise:ApplyLayout(frame, type, count, groupName)
	frame.buttons = frame.buttons or {}
	local buttons = frame.buttons
	local iconSize, _, _, showKeybinds, _, _, _, _, _, _, _, _, _, _, _, _, _, showInterfaceKeybind =
		Wise:GetGroupDisplaySettings(groupName)

	-- Nested instances inherit icon size from their nesting parent
	if frame.inheritedIconSize then
		iconSize = frame.inheritedIconSize
	end

	-- Try to extract group name if not provided (fallback for legacy/secure frames)
	if not groupName and frame:GetName() then
		groupName = frame:GetName():match("WiseGroup_(.+)")
		-- Handle _Visual suffix if present (though we should pass explicit name)
		if groupName and groupName:match("_Visual$") then
			groupName = groupName:gsub("_Visual$", "")
		end
	end

	local invertOrder = false
	if groupName and WiseDB.groups[groupName] and WiseDB.groups[groupName].invertOrder then
		invertOrder = true
	end

	-- Ensure all buttons are shown initially
	for i = 1, count do
		local btn = buttons[i]
		btn:Show()

		-- Skip keybind display for empty slots (they are invisible space maintainers)
		local btnIsEmpty = btn.actionType == "empty"
		if not btnIsEmpty then
			-- Update Keybind Display via Text
			Wise:Text_UpdateKeybind(btn, groupName, showKeybinds)
			Wise:Text_UpdateInterfaceKeybind(btn, groupName, showInterfaceKeybind)
		else
			if btn.keybind then
				btn.keybind:Hide()
			end
			if btn.interfaceKeybind then
				btn.interfaceKeybind:Hide()
			end
		end

		-- Sync keybind and interface keybind to visual clone via Text module
		if btn.visualClone then
			if not btnIsEmpty then
				Wise:Text_UpdateKeybind(btn.visualClone, groupName, showKeybinds)
				Wise:Text_UpdateInterfaceKeybind(btn.visualClone, groupName, showInterfaceKeybind)
			else
				if btn.visualClone.keybind then
					btn.visualClone.keybind:Hide()
				end
				if btn.visualClone.interfaceKeybind then
					btn.visualClone.interfaceKeybind:Hide()
				end
			end
		end
	end

	if type == "button" then
		for i = 1, count do
			local btn = buttons[i]
			if i == 1 then
				btn:Show()
				buttons[i].targetX = 0
				buttons[i].targetY = 0
				buttons[i]:SetPoint("CENTER", 0, 0)
			else
				buttons[i]:Hide()
				buttons[i].targetX = 0
				buttons[i].targetY = 0
			end
		end
	elseif type == "line" then
		local linePadding = 5 -- default padding between buttons
		local anchorPoint = "CENTER"
		local orientation = "horizontal" -- or "vertical"
		if groupName and WiseDB.groups[groupName] then
			if WiseDB.groups[groupName].padding then
				linePadding = WiseDB.groups[groupName].padding
			end
			if WiseDB.groups[groupName].anchor and WiseDB.groups[groupName].anchor.point then
				anchorPoint = WiseDB.groups[groupName].anchor.point
			end
			if WiseDB.groups[groupName].lineOrientation then
				orientation = WiseDB.groups[groupName].lineOrientation
			end
		end
		-- Nested line children: override orientation and anchor from nesting logic
		if frame.nestedLineOrientation then
			orientation = frame.nestedLineOrientation
		end
		if frame.nestedLineAnchor then
			anchorPoint = frame.nestedLineAnchor
		end
		local spacing = iconSize + linePadding

		local dx, dy = 0, 0
		local startX, startY = 0, 0

		if orientation == "horizontal" then
			if anchorPoint:find("RIGHT") then
				dx = -spacing
			elseif anchorPoint:find("LEFT") then
				dx = spacing
			else -- Center horizontally
				dx = spacing
				startX = -(math.max(count - 1, 0) * spacing) / 2
			end
		else -- vertical
			if anchorPoint:find("BOTTOM") then
				dy = spacing
			elseif anchorPoint:find("TOP") then
				dy = -spacing
			else -- Center vertically
				dy = -spacing -- default grow down for center vertical
				startY = (math.max(count - 1, 0) * spacing) / 2
			end
		end

		for i = 1, count do
			local idx = i - 1
			if invertOrder then
				idx = count - i
			end
			buttons[i].targetX = startX + idx * dx
			buttons[i].targetY = startY + idx * dy
			buttons[i]:SetPoint("CENTER", buttons[i].targetX, buttons[i].targetY)
		end
	elseif type == "box" then
		local fixedAxis = "x"
		local boxW = 3
		local boxH = 3
		local boxPaddingX = 5 -- default X padding
		local boxPaddingY = 5 -- default Y padding
		local anchorPoint = "CENTER"

		if groupName and WiseDB.groups[groupName] then
			local g = WiseDB.groups[groupName]
			fixedAxis = g.fixedAxis or "x"
			boxW = g.boxWidth or 3
			boxH = g.boxHeight or 3
			if g.paddingX then
				boxPaddingX = g.paddingX
			end
			if g.paddingY then
				boxPaddingY = g.paddingY
			end
			if g.anchor and g.anchor.point then
				anchorPoint = g.anchor.point
			end
		end
		local spacingX = iconSize + boxPaddingX
		local spacingY = iconSize + boxPaddingY

		local cols, rows
		if fixedAxis == "x" then
			cols = math.max(1, boxW) -- Safety
			rows = math.ceil(count / cols)
		else
			rows = math.max(1, boxH) -- Safety
			cols = math.ceil(count / rows)
		end
		if cols < 1 then
			cols = 1
		end -- Double safety if rows calculation results in 0 cols (count=0)

		local totalH = (rows - 1) * spacingY
		local dirX = 1
		local dirY = -1 -- Default grow down (negative Y)

		if anchorPoint:find("RIGHT") then
			dirX = -1
		end
		if anchorPoint:find("BOTTOM") then
			dirY = 1
		end

		local startY = 0
		if anchorPoint:find("TOP") then
			startY = 0
		elseif anchorPoint:find("BOTTOM") then
			startY = 0
		else -- CENTER vertically
			startY = (dirY == -1) and (totalH / 2) or (-totalH / 2)
		end

		for i = 1, count do
			local posIndex = i - 1
			if invertOrder then
				posIndex = count - i
			end

			local r = math.floor(posIndex / cols)
			local c = posIndex % cols

			local itemsInThisRow = cols
			if r == rows - 1 then
				local rem = count % cols
				if rem > 0 then
					itemsInThisRow = rem
				end
			end

			local rowIdx = c
			local rowWidth = (itemsInThisRow - 1) * spacingX

			local startX = 0
			if anchorPoint:find("LEFT") then
				startX = 0
			elseif anchorPoint:find("RIGHT") then
				startX = 0
			else -- CENTER horizontally
				startX = (dirX == 1) and (-rowWidth / 2) or (rowWidth / 2)
			end

			buttons[i].targetX = startX + (rowIdx * spacingX * dirX)
			buttons[i].targetY = startY + (r * spacingY * dirY)

			buttons[i]:SetPoint("CENTER", buttons[i].targetX, buttons[i].targetY)
		end
	elseif type == "list" then
		-- Vertical text-based list
		local _, textSize, fontPath = Wise:GetGroupDisplaySettings(groupName)
		local listPadding = 8 -- default line padding
		local anchorPoint = "CENTER"
		if groupName and WiseDB.groups[groupName] then
			if WiseDB.groups[groupName].padding then
				listPadding = WiseDB.groups[groupName].padding
			end
			if WiseDB.groups[groupName].anchor and WiseDB.groups[groupName].anchor.point then
				anchorPoint = WiseDB.groups[groupName].anchor.point
			end
		end
		-- Nested list children: override anchor and text align from nesting logic
		if frame.nestedListAnchor then
			anchorPoint = frame.nestedListAnchor
		end
		local listIconSize = iconSize
		local contentHeight = math.max(textSize, listIconSize)
		local lineHeight = contentHeight + listPadding
		local maxTextWidth = 0
		-- groupName is now passed as argument

		-- Alignment (Pre-calculate for loop)
		local textAlign = (WiseDB.groups[groupName] and WiseDB.groups[groupName].textAlign) or "right"
		-- Nested list children: override text align
		if frame.nestedTextAlign then
			textAlign = frame.nestedTextAlign
		end

		local dy = -lineHeight
		local startY = 0
		local totalH = math.max(count - 1, 0) * lineHeight

		if anchorPoint:find("BOTTOM") then
			dy = lineHeight
			startY = 0
		elseif anchorPoint:find("TOP") then
			dy = -lineHeight
			startY = 0
		else -- CENTER vertically
			dy = -lineHeight
			startY = totalH / 2
		end

		for i = 1, count do
			local idx = i - 1
			if invertOrder then
				idx = count - i
			end
			buttons[i].targetX = 0
			buttons[i].targetY = startY + idx * dy
			buttons[i]:SetPoint("CENTER", buttons[i].targetX, buttons[i].targetY)
			buttons[i]:SetSize(150, lineHeight) -- Wider for text

			-- Create or update text label
			if not buttons[i].textLabel then
				buttons[i].textLabel = buttons[i]:CreateFontString(nil, "OVERLAY")
			end

			-- Apply global font settings
			buttons[i].textLabel:SetFont(fontPath, textSize, "")

			buttons[i].textLabel:ClearAllPoints()
			buttons[i].icon:ClearAllPoints()
			buttons[i].icon:SetSize(listIconSize, listIconSize)

			-- Icon fixed at center (spine) - icons never move regardless of text position
			buttons[i].icon:SetPoint("CENTER", 0, 0)

			-- Re-anchor count text to the icon using Text
			if buttons[i].count and buttons[i].groupName then
				local _, _, _, _, _, _, _, cPos = Wise:GetGroupDisplaySettings(buttons[i].groupName)
				Wise:Text_ApplyPosition(buttons[i].count, cPos or "TOP")
			end

			if textAlign == "right" then
				-- Text Right (Left Aligned)
				buttons[i].textLabel:SetPoint("LEFT", buttons[i].icon, "RIGHT", 5, 0)
				buttons[i].textLabel:SetJustifyH("LEFT")
			else
				-- Text Left (Right Aligned)
				buttons[i].textLabel:SetPoint("RIGHT", buttons[i].icon, "LEFT", -5, 0)
				buttons[i].textLabel:SetJustifyH("RIGHT")
			end

			-- Get action name
			local btn = buttons[i]
			if btn.actionData then
				local name = Wise:GetActionName(btn.actionType, btn.actionValue, btn.actionData)
				btn.textLabel:SetText(name)
			end
			buttons[i].textLabel:Show()

			-- Measure Width (Always measure, maxTextWidth used for sizing)
			local w = buttons[i].textLabel:GetStringWidth()
			if w > maxTextWidth then
				maxTextWidth = w
			end
		end

		-- Second pass: Align Timers and Lines
		local timerOffset = 0
		if textAlign == "right" then
			-- IconHalf + Gap + Text + Gap
			timerOffset = (listIconSize / 2) + 5 + maxTextWidth + 8
		else
			-- IconHalf + Gap
			timerOffset = (listIconSize / 2) + 8
		end

		for i = 1, count do
			-- Timer Label
			if not buttons[i].timerLabel then
				buttons[i].timerLabel = buttons[i]:CreateFontString(nil, "OVERLAY")
				buttons[i].timerLabel:SetJustifyH("LEFT")
			end
			-- Apply global font settings to timer label
			buttons[i].timerLabel:SetFont(fontPath, textSize, "")

			buttons[i].timerLabel:ClearAllPoints()
			-- Anchor relative to icon center (spine), not button center
			buttons[i].timerLabel:SetPoint("LEFT", buttons[i].icon, "CENTER", timerOffset, 0)
			-- Hide initially
			buttons[i].timerLabel:SetText("")

			-- Red Line
			if not buttons[i].redLine then
				buttons[i].redLine = buttons[i]:CreateTexture(nil, "ARTWORK")
				buttons[i].redLine:SetColorTexture(1, 0, 0, 0.8)
				buttons[i].redLine:SetHeight(1) -- Thin line
			end

			buttons[i].redLine:ClearAllPoints()
			buttons[i].redLine:SetPoint("RIGHT", buttons[i].timerLabel, "LEFT", -5, 0)
			buttons[i].redLine:SetWidth(0)
			buttons[i].redLine:Hide()
		end

		-- Resize button frame to fit actual content, not symmetric padding
		-- leftSide/rightSide = distance from icon center to content edge on each side
		local rightSide = timerOffset + 35 -- Timer text width (~30px) + small margin
		local leftSide = listIconSize / 2 -- At minimum, the icon half
		if textAlign == "left" then
			leftSide = (listIconSize / 2) + 5 + maxTextWidth + 5
		end
		local totalWidth = leftSide + rightSide
		-- Shift button center so icon stays at the visual spine (original targetX)
		local centerShift = (rightSide - leftSide) / 2

		for i = 1, count do
			-- Update targetX to include the shift (used by slide animation)
			buttons[i].targetX = buttons[i].targetX + centerShift
			buttons[i]:ClearAllPoints()
			buttons[i]:SetPoint("CENTER", buttons[i].targetX, buttons[i].targetY)
			buttons[i]:SetSize(totalWidth, lineHeight)
			-- Offset icon back so it stays at the visual spine
			buttons[i].icon:ClearAllPoints()
			buttons[i].icon:SetPoint("CENTER", -centerShift, 0)
		end
	else -- circle
		-- Configurable radius with minimum to prevent overlap
		local circleRadius, circleRotation
		if groupName and WiseDB.groups[groupName] then
			circleRadius = WiseDB.groups[groupName].circleRadius
			circleRotation = WiseDB.groups[groupName].circleRotation or 0
		end

		-- Default radius if not set (uses inherited iconSize for nested children)
		if not circleRadius then
			circleRadius = iconSize * 2
		end
		if not circleRotation then
			circleRotation = 0
		end

		-- Nested child: override rotation so button 1 points back toward parent
		if frame.nestedCircleRotation then
			circleRotation = frame.nestedCircleRotation
		end
		local step = 360 / max(count, 1)
		for i = 1, count do
			local angle
			if invertOrder then
				angle = -(i - 1) * step + circleRotation
			else
				angle = (i - 1) * step + circleRotation
			end
			local rad = math.rad(angle + 90)
			buttons[i].targetX = math.cos(rad) * circleRadius
			buttons[i].targetY = math.sin(rad) * circleRadius
			buttons[i]:SetPoint("CENTER", buttons[i].targetX, buttons[i].targetY)
			buttons[i]:SetSize(iconSize, iconSize) -- Reset size to global setting
			buttons[i].icon:ClearAllPoints()
			buttons[i].icon:SetAllPoints()
			if buttons[i].textLabel then
				buttons[i].textLabel:Hide()
			end
		end
	end

	-- Reset non-list buttons to normal size
	if type ~= "list" then
		for i = 1, count do
			buttons[i]:SetSize(iconSize, iconSize)
			buttons[i].icon:ClearAllPoints()
			buttons[i].icon:SetAllPoints()
			if buttons[i].textLabel then
				buttons[i].textLabel:Hide()
			end
			-- Reset count anchor back to button frame using Text positioning
			if buttons[i].count and buttons[i].groupName then
				local _, _, _, _, _, _, _, cPos = Wise:GetGroupDisplaySettings(buttons[i].groupName)
				Wise:Text_ApplyPosition(buttons[i].count, cPos or "TOP")
			elseif buttons[i].count then
				buttons[i].count:ClearAllPoints()
				buttons[i].count:SetPoint("TOP", buttons[i], "TOP", 0, -2)
			end
		end
	end
end

function Wise:StopSlideAnimations(frame)
	for _, btn in ipairs(frame.buttons or {}) do
		if btn.animGroup and btn.animGroup:IsPlaying() then
			btn.animGroup:Stop()
		end
	end
end

function Wise:PlaySlideAnimation(frame, isOpening, onComplete)
	-- Stop any in-flight animations to prevent closure/callback corruption
	Wise:StopSlideAnimations(frame)

	local animatingCount = 0
	local completedCount = 0

	for _, btn in ipairs(frame.buttons) do
		if btn:IsShown() or not isOpening then
			animatingCount = animatingCount + 1

			if not btn.animGroup then
				btn.animGroup = btn:CreateAnimationGroup()
				btn.animTranslate = btn.animGroup:CreateAnimation("Translation")
				btn.animTranslate:SetDuration(0.15)
				btn.animTranslate:SetSmoothing("OUT")
			end

			if isOpening then
				-- Opening: slide from center to target position
				btn:ClearAllPoints()
				btn:SetPoint("CENTER", 0, 0)
				btn.animTranslate:SetOffset(btn.targetX or 0, btn.targetY or 0)
				btn.animGroup:SetScript("OnFinished", function()
					-- Skip button manipulation if in combat (secure frame protection)
					if not InCombatLockdown() then
						btn:ClearAllPoints()
						btn:SetPoint("CENTER", btn.targetX or 0, btn.targetY or 0)
					end
					completedCount = completedCount + 1
					if completedCount >= animatingCount and onComplete then
						onComplete()
					end
				end)
			else
				-- Closing: slide from target position back to center
				btn:ClearAllPoints()
				btn:SetPoint("CENTER", btn.targetX or 0, btn.targetY or 0)
				btn.animTranslate:SetOffset(-(btn.targetX or 0), -(btn.targetY or 0))
				btn.animGroup:SetScript("OnFinished", function()
					-- Skip button manipulation if in combat (secure frame protection)
					if not InCombatLockdown() then
						btn:ClearAllPoints()
						btn:SetPoint("CENTER", 0, 0)
					end
					completedCount = completedCount + 1
					if completedCount >= animatingCount and onComplete then
						onComplete()
					end
				end)
			end
			btn.animGroup:Play()
		end
	end

	-- If no buttons to animate, call callback immediately
	if animatingCount == 0 and onComplete then
		onComplete()
	end
end

function Wise:ActivateGroup(name)
	local f = Wise.frames[name]
	if not f then
		Wise:UpdateGroupDisplay(name)
		f = Wise.frames[name]
	end

	if f.toggleBtn then
		f.toggleBtn:Click()
	else
		if f:IsShown() then
			f:Hide()
		else
			f:Show()
			-- Animation is handled by OnShow script
		end
	end
end

-- Safety clamp for PERSISTENTLY VISIBLE mouse-following interfaces (always/
-- combat visibility): if the configured offset leaves the cursor inside the
-- frame's bounds, the frame rides under the cursor and intercepts EVERY
-- click — the rest of the screen becomes unclickable. Push the frame clear
-- of the cursor along the Y axis (preserving the configured direction).
-- Exempt (not called for): locked toggle/hold rings (they stop following on
-- show, so the cursor can move off them) and press-to-show interfaces
-- (appearing at the cursor on key press/hold is their designed behavior, and
-- they vanish on release). Offsets are in frame-local units (callers divide
-- by frame scale before passing them in).
function Wise:ClampMouseFollowOffset(frame, offsetX, offsetY)
	local margin = 5
	local adjusted = true
	local iterations = 0

	local buttons = {}
	for _, btn in ipairs(frame.buttons or {}) do
		if btn:IsShown() then
			local bx, by = btn.targetX or 0, btn.targetY or 0
			local bw, bh = btn:GetWidth() or 0, btn:GetHeight() or 0
			if bw == 0 or bh == 0 then
				local groupName = frame.groupName
				local iconSize = groupName and WiseDB.groups[groupName] and WiseDB.groups[groupName].iconSize or 30
				bw, bh = iconSize, iconSize
			end
			table.insert(buttons, {
				minX = -bx - bw / 2 - margin,
				maxX = -bx + bw / 2 + margin,
				minY = -by - bh / 2 - margin,
				maxY = -by + bh / 2 + margin,
			})
		end
	end

	while adjusted and iterations < 10 do
		adjusted = false
		iterations = iterations + 1
		for _, btn in ipairs(buttons) do
			if offsetX >= btn.minX and offsetX <= btn.maxX then
				if offsetY >= btn.minY and offsetY <= btn.maxY then
					adjusted = true
					if offsetY < 0 then
						offsetY = btn.minY
					else
						offsetY = btn.maxY
					end
				end
			end
		end
	end

	return offsetX, offsetY
end

-- Retroactive repair for saved configs (WTF) where a persistently visible
-- mouse-anchored interface stored an offset that parks it under the cursor —
-- the frame then intercepts every click and the rest of the UI becomes
-- unclickable. Rewrites the stored offset with the same clamp the live follow
-- path enforces, so the saved value and the Properties UI match what is
-- actually rendered. Press-to-show / toggle / hold interfaces are exempt
-- (appearing at the cursor is their designed behavior).
function Wise:SanitizeMouseAnchorOffsets(onlyName)
	if not (WiseDB and WiseDB.groups) or not Wise.ClampMouseFollowOffset then
		return
	end
	for name, group in pairs(WiseDB.groups) do
		if
			(not onlyName or name == onlyName)
			and group.anchorMode == "mouse"
			and (group.visibility == "always" or group.visibility == "combat")
		then
			local f = Wise.frames and Wise.frames[name]
			if f then
				local scale = f:GetScale() or 1
				if scale <= 0 then
					scale = 1
				end
				local ox = (group.mouseOffsetX or 0) / scale
				local oy = (group.mouseOffsetY or 0) / scale
				local cx, cy = Wise:ClampMouseFollowOffset(f, ox, oy)
				if cx ~= ox or cy ~= oy then
					group.mouseOffsetX = cx * scale
					group.mouseOffsetY = cy * scale
					print(
						"|cffffd700Wise:|r interface '"
							.. name
							.. "' was riding under the mouse cursor and would block clicks — its offset has been adjusted."
					)
				end
			end
		end
	end
end

Wise.BindingFrame = CreateFrame("Frame")
function Wise:UpdateBindings()
	if InCombatLockdown() then
		return
	end
	ClearOverrideBindings(Wise.BindingFrame)

	for name, group in pairs(WiseDB.groups) do
		-- 1. Group Toggle Binding
		if group.binding and string.len(group.binding) > 0 then
			-- "WiseGroupToggle_"..name is the global name of the secure button
			local toggleName = "WiseGroupToggle_" .. name
			local toggleBtn = _G[toggleName]
			if toggleBtn then
				toggleBtn:SetAttribute("keybind", group.binding)
			end
			local mouseBtn = GetMouseClickName(group.binding)
			if mouseBtn then
				SetOverrideBindingClick(Wise.BindingFrame, true, group.binding, toggleName, mouseBtn)
			else
				SetOverrideBindingClick(Wise.BindingFrame, true, group.binding, toggleName)
			end
			ApplyModifierMirrors(Wise.BindingFrame, group.binding, toggleName, mouseBtn)
		else
			local toggleName = "WiseGroupToggle_" .. name
			local toggleBtn = _G[toggleName]
			if toggleBtn then
				toggleBtn:SetAttribute("keybind", nil)
			end
		end

		-- 2. Slot Bindings (Direct Mode only)
		-- Nested bindings are handled by the SecureFrame itself (via attributes)
		local f = Wise.frames[name]
		local _, _, _, showKeybinds = Wise:GetGroupDisplaySettings(name)

		if group.actions then
			local nested = (group.keybindSettings and group.keybindSettings.nested)
			if not nested then
				for slotIdx, actionList in pairs(group.actions) do
					if actionList.keybind and string.len(actionList.keybind) > 0 then
						-- Find button matching this slot (don't require IsShown —
						-- bindings must work even when the group UI is hidden)
						if f and f.buttons then
							local foundBtn = nil
							for _, btn in ipairs(f.buttons) do
								if btn.slot == slotIdx then
									foundBtn = btn
									break
								end
							end

							if foundBtn and _G[foundBtn:GetName()] then
								foundBtn:SetAttribute("keybind", actionList.keybind)
								local isMouseWheel = actionList.keybind:find("MOUSEWHEEL")
								if not isMouseWheel then
									local slotMouseBtn = GetMouseClickName(actionList.keybind)
									if slotMouseBtn then
										SetOverrideBindingClick(
											Wise.BindingFrame,
											true,
											actionList.keybind,
											foundBtn:GetName(),
											slotMouseBtn
										)
									else
										SetOverrideBindingClick(
											Wise.BindingFrame,
											true,
											actionList.keybind,
											foundBtn:GetName()
										)
									end
									ApplyModifierMirrors(
										Wise.BindingFrame,
										actionList.keybind,
										foundBtn:GetName(),
										slotMouseBtn
									)
								end
							end
						end
					else
						-- Clear keybind attribute if keybind was removed
						if f and f.buttons then
							for _, btn in ipairs(f.buttons) do
								if btn.slot == slotIdx then
									btn:SetAttribute("keybind", nil)
								end
							end
						end
					end
				end
			end
		end

		-- 3. Refresh Keybind UI Text
		if f and f.buttons then
			local _, _, _, showKeybinds, _, _, _, _, _, _, _, _, _, _, _, _, _, showInterfaceKeybind =
				Wise:GetGroupDisplaySettings(name)
			for _, btn in ipairs(f.buttons) do
				if btn:IsShown() and btn.actionType ~= "empty" then
					Wise:Text_UpdateKeybind(btn, name, showKeybinds)
					Wise:Text_UpdateInterfaceKeybind(btn, name, showInterfaceKeybind)
				end
			end
		end
	end
end

-- Cooldown Update Functions
-- Cooldown Update Functions
function Wise:UpdateButtonCooldown(btn)
	if not btn or not btn.cooldown then
		return
	end

	-- Retrieve metadata safely
	local meta = Wise.buttonMeta and Wise.buttonMeta[btn]

	if meta and meta.baseSpellID then
		local oldSpellID = meta.spellID
		meta.spellID = Wise:GetOverrideSpellID(meta.baseSpellID)
		if oldSpellID ~= meta.spellID and btn.icon then
			local texture = Wise:GetActionIcon(meta.actionType, meta.actionValue, meta.actionData)
			btn.icon:SetTexture(texture)
			local vClone = meta.visualClone or btn.visualClone
			if vClone and vClone.icon then
				vClone.icon:SetTexture(texture)
			end
		end
	end

	local spellID = (meta and meta.spellID) or btn.spellID
	local itemID = (meta and meta.itemID) or btn.itemID
	local visualClone = (meta and meta.visualClone) or btn.visualClone

	local actionType = (meta and meta.actionType) or btn.actionType
	local actionValue = (meta and meta.actionValue) or btn.actionValue

	local _, _, _, _, _, _, _, _, _, _, _, showBuffs, _, showGCD, _, _, _, _, showDebuffs, _, _, _, cooldownSwipeReverse, buffSwipeReverse, debuffSwipeReverse =
		Wise:GetGroupDisplaySettings(btn.groupName)

	-- Swipe-repaint guard: Cooldown:SetCooldown() and SetCooldownFromDurationObject()
	-- always restart the swipe animation, even when called with identical values.
	-- Because UNIT_AURA (player + target) fires frequently during combat — DoT ticks,
	-- buff refreshes, nearby enemy auras — UpdateAllCooldowns() re-runs often, which
	-- would re-paint every active swipe on every tick and cause visible "pulsing" on
	-- short timers like the GCD. The helpers below short-circuit redundant writes by
	-- caching the last applied tuple per-cooldown-frame.
	-- The cache tuple is (start, duration, reverse) — source is stored for
	-- debugging but not compared, because the rendered swipe is determined
	-- entirely by the numeric tuple + reverse flag. Comparing source would
	-- cause false-negatives across layers (CD → buff → CD) where nothing
	-- actually changed from the Cooldown frame's perspective.
	local function applyCD(cdFrame, newStart, newDur, reverse, source)
		if not cdFrame then
			return
		end
		local cache = cdFrame._wiseLastCD
		local ok, matched = pcall(function()
			return cache and cache.start == newStart and cache.duration == newDur and cache.reverse == reverse
		end)
		if ok and matched then
			return
		end
		if cdFrame.SetReverse then
			cdFrame:SetReverse(reverse == true)
		end
		cdFrame:SetCooldown(newStart, newDur)
		cdFrame._wiseLastCD = { start = newStart, duration = newDur, reverse = reverse, source = source }
	end
	local function applyCDFromDuration(cdFrame, durObj, numStart, numDur, reverse)
		if not cdFrame then
			return
		end
		-- numStart/numDur may be "secret numbers" in combat (WoW 11.1+) that
		-- throw on any arithmetic or comparison. pcall the cache check so
		-- secrets just force a re-write (safe — DurationObject handles them).
		local cache = cdFrame._wiseLastCD
		local ok, matched = pcall(function()
			return cache and cache.start == numStart and cache.duration == numDur and cache.reverse == reverse
		end)
		if ok and matched then
			return
		end
		if cdFrame.SetReverse then
			cdFrame:SetReverse(reverse == true)
		end
		cdFrame:SetCooldownFromDurationObject(durObj, true)
		-- Store the raw values; next comparison will pcall too.
		cdFrame._wiseLastCD = { start = numStart, duration = numDur, reverse = reverse, source = "durObj" }
	end
	local function clearCD(cdFrame)
		if not cdFrame then
			return
		end
		local cache = cdFrame._wiseLastCD
		local ok, isZero = pcall(function()
			return cache and cache.start == 0 and cache.duration == 0
		end)
		if ok and isZero then
			return
		end
		cdFrame:SetCooldown(0, 0)
		cdFrame._wiseLastCD = { start = 0, duration = 0, reverse = false, source = "clear" }
	end

	-- GetActionCooldown (and C_Item.GetItemCooldown) return "secret numbers" in
	-- combat on retail 11.1+. The legacy path below compares those values
	-- (start > 0) and passes them to the PLAIN Cooldown:SetCooldown(start, duration),
	-- which rejects secret args while execution is tainted ("Secret values are only
	-- allowed during untainted execution for this argument."). Unlike the
	-- DurationObject path, plain SetCooldown can't take secrets — so force-strip the
	-- taint at the read. tonumber(tostring(v)) round-trips through a string, which
	-- yields a plain number and severs the secret/taint flag. See memory:
	-- wow_taint_stripping. Falls back to 0 if the value isn't representable.
	local function stripCooldown(start, duration)
		local s = tonumber(tostring(start)) or 0
		local d = tonumber(tostring(duration)) or 0
		return s, d
	end

	-- Apply cooldown swipe direction (default: non-reversed). Overlays below
	-- may override this before calling SetCooldown with their own direction.
	if btn.cooldown.SetReverse then
		btn.cooldown:SetReverse(cooldownSwipeReverse == true)
	end
	if visualClone and visualClone.cooldown and visualClone.cooldown.SetReverse then
		visualClone.cooldown:SetReverse(cooldownSwipeReverse == true)
	end

	-- ─── DurationObject path (spells) ───────────────────────────────
	-- WoW 11.1+ returns secret numbers for cooldown start/duration in combat.
	-- The DurationObject API passes opaque values the Cooldown frame can consume.
	local usedDurationObject = false
	local isOnGCD = false
	-- start/duration captured for countdown text tracker (may be secret in combat)
	local start, duration = 0, 0

	if spellID and C_Spell.GetSpellCooldownDuration then
		-- Detect GCD via isOnGCD field (secret-safe, no numeric comparison)
		local cdInfo = C_Spell.GetSpellCooldown(spellID)
		if cdInfo and cdInfo.isOnGCD == true then
			isOnGCD = true
		end
		-- Capture start/duration for countdown text (secret-safe: pcall in ticker)
		if cdInfo then
			start = cdInfo.startTime or 0
			duration = cdInfo.duration or 0
		end

		-- Swipe color
		if btn.cooldown.SetSwipeColor then
			if isOnGCD then
				btn.cooldown:SetSwipeColor(0.2, 0.2, 0.2, 0.6)
			else
				btn.cooldown:SetSwipeColor(0, 0, 0, 0.8)
			end
		end

		-- Charge spells use charge duration (tracks recharge, ignores GCD)
		local chargeInfo = C_Spell.GetSpellCharges(spellID)
		local isChargeSpell = chargeInfo ~= nil

		if isChargeSpell and C_Spell.GetSpellChargeDuration then
			-- Override start/duration with the recharge timer. These numbers feed two
			-- things: the swipe-repaint cache in applyCDFromDuration (so we skip
			-- redundant repaints) and the countdown-text tracker — NOT the render
			-- itself, which uses the DurationObject. As of 12.0.5 the charge
			-- DurationObject is zero-span at max charges (a fully-elapsed object), so
			-- the frame renders empty on its own; no explicit "fully charged → clear"
			-- branch is needed here.
			if chargeInfo then
				local cs = SafeReadField(chargeInfo, "cooldownStartTime")
				local cd = SafeReadField(chargeInfo, "cooldownDuration")
				cs = cs and CleanSecretNumber(cs)
				cd = cd and CleanSecretNumber(cd)
				if cs and cd then
					start = cs
					duration = cd
				end
			end

			local chargeDurObj = C_Spell.GetSpellChargeDuration(spellID)
			if chargeDurObj then
				applyCDFromDuration(btn.cooldown, chargeDurObj, start, duration, cooldownSwipeReverse)
			else
				clearCD(btn.cooldown)
			end
		else
			-- Normal spell. When the user doesn't want the GCD shown, ask Blizzard
			-- for the GCD-excluded duration directly (12.0.5+ `ignoreGCD` arg). A
			-- spell that is ONLY on the GCD yields a zero-span object → renders empty
			-- (same as clearing), but a real cooldown underneath the GCD is preserved
			-- instead of being wiped — which the old isOnGCD+clearCD branch got wrong.
			if HAS_IGNORE_GCD and not showGCD then
				local durObj = C_Spell.GetSpellCooldownDuration(spellID, true)
				if durObj then
					applyCDFromDuration(btn.cooldown, durObj, start, duration, cooldownSwipeReverse)
				else
					clearCD(btn.cooldown)
				end
			elseif isOnGCD and not showGCD then
				-- Pre-12.0.5 fallback: can't isolate the GCD, so hide the whole sweep.
				clearCD(btn.cooldown)
			else
				local durObj = C_Spell.GetSpellCooldownDuration(spellID)
				if durObj then
					applyCDFromDuration(btn.cooldown, durObj, start, duration, cooldownSwipeReverse)
				else
					clearCD(btn.cooldown)
				end
			end
		end

		-- Mirror the same logic onto the visual clone (charge recharge timer,
		-- GCD filter, GCD swipe color) so it behaves like the real button.
		if visualClone and visualClone.cooldown then
			if visualClone.cooldown.SetSwipeColor then
				if isOnGCD then
					visualClone.cooldown:SetSwipeColor(0.2, 0.2, 0.2, 0.6)
				else
					visualClone.cooldown:SetSwipeColor(0, 0, 0, 0.8)
				end
			end

			if isChargeSpell and C_Spell.GetSpellChargeDuration then
				local chargeDurObj = C_Spell.GetSpellChargeDuration(spellID)
				if chargeDurObj then
					applyCDFromDuration(visualClone.cooldown, chargeDurObj, start, duration, cooldownSwipeReverse)
				else
					clearCD(visualClone.cooldown)
				end
			elseif HAS_IGNORE_GCD and not showGCD then
				local durObj = C_Spell.GetSpellCooldownDuration(spellID, true)
				if durObj then
					applyCDFromDuration(visualClone.cooldown, durObj, start, duration, cooldownSwipeReverse)
				else
					clearCD(visualClone.cooldown)
				end
			elseif isOnGCD and not showGCD then
				clearCD(visualClone.cooldown)
			else
				local durObj = C_Spell.GetSpellCooldownDuration(spellID)
				if durObj then
					applyCDFromDuration(visualClone.cooldown, durObj, start, duration, cooldownSwipeReverse)
				else
					clearCD(visualClone.cooldown)
				end
			end
			visualClone.cooldown:Show()
		end

		usedDurationObject = true
	end

	-- ─── Legacy path (action slots, items, misc) ────────────────────
	if not usedDurationObject then
		if actionType == "action" and tonumber(actionValue) then
			local realID = Wise:ResolveBarActionID(tonumber(actionValue))
			start, duration = stripCooldown(GetActionCooldown(realID))
		elseif actionType == "misc" and actionValue == "extrabutton" then
			if HasExtraActionBar and HasExtraActionBar() then
				start, duration = stripCooldown(GetActionCooldown(EXTRA_ACTION_BUTTON_SLOT))
			end
		elseif actionType == "misc" and actionValue == "zoneability" then
			local zoneBtn = GetZoneAbilitySpellButton()
			if zoneBtn and zoneBtn.spellID then
				-- Zone ability is a spell — try DurationObject
				if C_Spell.GetSpellCooldownDuration then
					local durObj = C_Spell.GetSpellCooldownDuration(zoneBtn.spellID)
					if durObj then
						local zInfo = C_Spell.GetSpellCooldown(zoneBtn.spellID)
						applyCDFromDuration(
							btn.cooldown,
							durObj,
							(zInfo and zInfo.startTime) or 0,
							(zInfo and zInfo.duration) or 0,
							cooldownSwipeReverse
						)
						usedDurationObject = true
					end
				end
				if not usedDurationObject then
					local cooldownInfo = C_Spell.GetSpellCooldown(zoneBtn.spellID)
					if cooldownInfo then
						start, duration = stripCooldown(cooldownInfo.startTime, cooldownInfo.duration)
					end
				end
			end
		elseif actionType == "misc" and actionValue == "overridebar" then
			local realID = Wise:ResolveBarActionID(133)
			start, duration = stripCooldown(GetActionCooldown(realID))
		elseif actionType == "misc" and actionValue == "possessbar" then
			local realID = Wise:ResolveBarActionID(121)
			start, duration = stripCooldown(GetActionCooldown(realID))
		elseif itemID then
			start, duration = stripCooldown(C_Item.GetItemCooldown(itemID))
		end

		if not usedDurationObject then
			start = start or 0
			duration = duration or 0

			-- GCD detection (legacy numeric comparison)
			local isGCD = false
			local _, gcdDuration = 0, 0
			if GetSpellCooldown then
				_, gcdDuration = GetSpellCooldown(61304)
			elseif C_Spell.GetSpellCooldown then
				local info = C_Spell.GetSpellCooldown(61304)
				if info then
					gcdDuration = info.duration or 0
				end
			end

			local s = pcall(function()
				if start > 0 and duration > 0 and gcdDuration and gcdDuration > 0 then
					if math.abs(duration - gcdDuration) < 0.1 then
						isGCD = true
					end
				end
			end)

			if isGCD and not showGCD then
				start = 0
				duration = 0
			end

			-- Swipe color
			if btn.cooldown.SetSwipeColor then
				if isGCD then
					btn.cooldown:SetSwipeColor(0.2, 0.2, 0.2, 0.6)
				else
					btn.cooldown:SetSwipeColor(0, 0, 0, 0.8)
				end
			end

			local cache = btn.cooldown._wiseLastCD
			local okC, unchanged = pcall(function()
				return cache
					and cache.start == start
					and cache.duration == duration
					and cache.reverse == cooldownSwipeReverse
			end)
			if not (okC and unchanged) then
				local cdOk = pcall(function()
					if btn.cooldown.SetReverse then
						btn.cooldown:SetReverse(cooldownSwipeReverse == true)
					end
					btn.cooldown:SetCooldown(start, duration)
				end)
				if cdOk then
					btn.cooldown._wiseLastCD =
						{ start = start, duration = duration, reverse = cooldownSwipeReverse, source = "legacy" }
				else
					btn.cooldown:SetCooldown(0, 0)
					btn.cooldown._wiseLastCD = { start = 0, duration = 0, reverse = false, source = "clear" }
				end
			end

			if visualClone and visualClone.cooldown then
				local vCache = visualClone.cooldown._wiseLastCD
				local okV, vUnchanged = pcall(function()
					return vCache
						and vCache.start == start
						and vCache.duration == duration
						and vCache.reverse == cooldownSwipeReverse
				end)
				if not (okV and vUnchanged) then
					local cloneOk = pcall(function()
						if visualClone.cooldown.SetReverse then
							visualClone.cooldown:SetReverse(cooldownSwipeReverse == true)
						end
						visualClone.cooldown:SetCooldown(start, duration)
					end)
					if cloneOk then
						visualClone.cooldown._wiseLastCD =
							{ start = start, duration = duration, reverse = cooldownSwipeReverse, source = "legacy" }
					else
						visualClone.cooldown:SetCooldown(0, 0)
						visualClone.cooldown._wiseLastCD =
							{ start = 0, duration = 0, reverse = false, source = "clear" }
					end
				end
				visualClone.cooldown:Show()
			end
		end
	end

	-- ─── Buff overlay (overrides cooldown display with aura timer) ───
	-- Runs AFTER the CD path so the overlay's SetCooldown overwrites the CD
	-- swipe. Cache guards prevent the next tick's CD path from pulsing.
	local isBuffActive = false
	local buffStacks = 0
	local scanSpellID = spellID
	if not scanSpellID and itemID and C_Item and C_Item.GetItemSpell then
		local _, itemSpellID = C_Item.GetItemSpell(itemID)
		scanSpellID = itemSpellID
	end

	if showBuffs and scanSpellID then
		local aura = C_UnitAuras.GetPlayerAuraBySpellID(scanSpellID)
		if not aura then
			local spellName = C_Spell.GetSpellName(scanSpellID)
			if spellName then
				aura = C_UnitAuras.GetAuraDataBySpellName("player", spellName)
			end
		end

		if aura and aura.expirationTime and aura.duration then
			local isValidDur = false
			local s1, res = pcall(function(d)
				return d > 0
			end, aura.duration)
			if s1 then
				isValidDur = res
			else
				isValidDur = true
			end

			if isValidDur then
				local s2, calcStart, calcDuration = pcall(function(e, d)
					return e - d, d
				end, aura.expirationTime, aura.duration)
				if s2 then
					start = calcStart
					duration = calcDuration
					isBuffActive = true
					buffStacks = aura.applications or aura.charges or 0
					applyCD(btn.cooldown, start, duration, buffSwipeReverse, "buff")
					if btn.cooldown.SetSwipeColor then
						btn.cooldown:SetSwipeColor(0, 0.4, 0, 0.6)
					end
					if visualClone and visualClone.cooldown then
						applyCD(visualClone.cooldown, start, duration, buffSwipeReverse, "buff")
						if visualClone.cooldown.SetSwipeColor then
							visualClone.cooldown:SetSwipeColor(0, 0.4, 0, 0.6)
						end
					end
				end
			end
		end
	end

	-- Pick up stack count even when buff swipe overlay is off
	if buffStacks == 0 and scanSpellID then
		local aura = C_UnitAuras.GetPlayerAuraBySpellID(scanSpellID)
		if not aura then
			local spellName = C_Spell.GetSpellName(scanSpellID)
			if spellName then
				aura = C_UnitAuras.GetAuraDataBySpellName("player", spellName)
			end
		end
		if aura then
			buffStacks = aura.applications or aura.charges or 0
		end
	end
	Wise:Text_UpdateBuffStack(btn, btn.groupName, buffStacks)
	if visualClone then
		Wise:Text_UpdateBuffStack(visualClone, btn.groupName, buffStacks)
	end

	-- ─── Debuff overlay (target only, player-applied harmful auras) ──
	-- Precedence: buff > debuff > cooldown.
	local isDebuffActive = false
	if showDebuffs and not isBuffActive and UnitExists and UnitExists("target") and scanSpellID then
		local spellName = C_Spell.GetSpellName(scanSpellID)
		local debuffAura
		if spellName then
			debuffAura = C_UnitAuras.GetAuraDataBySpellName("target", spellName, "HARMFUL|PLAYER")
		end

		if debuffAura and debuffAura.expirationTime and debuffAura.duration then
			local isValidDur = false
			local s1, res = pcall(function(d)
				return d > 0
			end, debuffAura.duration)
			if s1 then
				isValidDur = res
			else
				isValidDur = true
			end

			if isValidDur then
				local s2, calcStart, calcDuration = pcall(function(e, d)
					return e - d, d
				end, debuffAura.expirationTime, debuffAura.duration)
				if s2 then
					start = calcStart
					duration = calcDuration
					isDebuffActive = true
					applyCD(btn.cooldown, start, duration, debuffSwipeReverse, "debuff")
					if btn.cooldown.SetSwipeColor then
						btn.cooldown:SetSwipeColor(0.6, 0.2, 0, 0.6)
					end
					if visualClone and visualClone.cooldown then
						applyCD(visualClone.cooldown, start, duration, debuffSwipeReverse, "debuff")
						if visualClone.cooldown.SetSwipeColor then
							visualClone.cooldown:SetSwipeColor(0.6, 0.2, 0, 0.6)
						end
					end
				end
			end
		end
	end

	-- ─── Countdown text, list mode, active tracking ─────────────────
	local parent = btn:GetParent()
	local isListMode = false
	local groupName = btn.groupName
	if parent and parent.groupName and WiseDB.groups[parent.groupName] then
		isListMode = (WiseDB.groups[parent.groupName].type == "list")
		if not groupName then
			groupName = parent.groupName
		end
	end

	-- Hide Blizzard countdown text by default; the OnUpdate ticker will
	-- enable it during combat when secret numbers prevent our custom text.
	-- If the ticker already activated Blizzard countdown (secret mode), don't
	-- reset it — that causes visual flickering on rapid cooldown updates.
	if not btn._wiseBlizzCDActive then
		btn.cooldown:SetHideCountdownNumbers(true)
	end
	if visualClone and visualClone.cooldown and not visualClone._wiseBlizzCDActive then
		visualClone.cooldown:SetHideCountdownNumbers(true)
	end

	-- Determine active state: for DurationObject path, check if cooldown frame is showing
	-- Also fall back to numeric start/duration check since the cooldown frame from
	-- SetCooldownFromDurationObject may not be visible yet (async rendering).
	local isActive = false
	if usedDurationObject and not isBuffActive and not isDebuffActive then
		isActive = btn.cooldown:IsShown()
		if not isActive then
			local s, r = pcall(function()
				return (start and start > 0) and (duration and duration > 0) and (GetTime() < (start + duration))
			end)
			if s and r then
				isActive = true
			end
		end
	else
		local s, r = pcall(function()
			return (start and start > 0) and (duration and duration > 0) and (GetTime() < (start + duration))
		end)
		if s then
			isActive = r
		end
	end

	-- Clear old OnUpdate script (crucial for migration/performance)
	btn:SetScript("OnUpdate", nil)

	if isActive then
		-- Resolve the countdown text format ("short"/"extended") once at registration
		-- so the per-frame hot loop stays allocation-free. Re-runs (and thus picks up
		-- option changes) whenever UpdateButtonCooldown fires for this button.
		local countdownFormat = select(26, Wise:GetGroupDisplaySettings(groupName))
		Wise.ActiveCooldownButtons[btn] = {
			start = start,
			duration = duration,
			groupName = groupName,
			isListMode = isListMode,
			isBuffActive = isBuffActive,
			isDebuffActive = isDebuffActive,
			scanSpellID = (isBuffActive or isDebuffActive) and scanSpellID or nil,
			countdownFormat = countdownFormat,
			lastText = "",
		}
		Wise.CooldownUpdateFrame:Show()

		if isListMode then
			btn.cooldown:SetAlpha(0)
			if btn.timerLabel then
				btn.timerLabel:Show()
				if isBuffActive then
					btn.timerLabel:SetTextColor(0, 1, 0)
				elseif isDebuffActive then
					btn.timerLabel:SetTextColor(1, 0.5, 0)
				else
					btn.timerLabel:SetTextColor(1, 1, 1)
				end
			end
			if btn.redLine then
				btn.redLine:Show()
				if isBuffActive then
					btn.redLine:SetVertexColor(0, 1, 0)
				elseif isDebuffActive then
					btn.redLine:SetVertexColor(1, 0.5, 0)
				else
					btn.redLine:SetVertexColor(1, 0, 0)
				end
			end
		else
			btn.cooldown:SetAlpha(1)
		end
	else
		Wise.ActiveCooldownButtons[btn] = nil

		if isListMode then
			if btn.timerLabel then
				btn.timerLabel:SetText("")
			end
			if btn.redLine then
				btn.redLine:SetWidth(0)
				btn.redLine:Hide()
			end
			btn.cooldown:SetAlpha(0)
		else
			btn.cooldown:SetAlpha(1)
			Wise:Text_UpdateCountdown(btn, groupName, "")
			local meta = Wise.buttonMeta and Wise.buttonMeta[btn]
			local vClone = (meta and meta.visualClone) or btn.visualClone
			if vClone then
				Wise:Text_UpdateCountdown(vClone, groupName, "")
			end
		end
	end
end

function Wise:UpdateAllCooldowns()
	for name, frame in pairs(Wise.frames) do
		-- Skip if group data is missing (stale frame check)
		if not WiseDB.groups[name] then
			Wise.frames[name] = nil
		elseif frame.buttons then
			for _, btn in ipairs(frame.buttons) do
				-- Check visibility safely.
				-- Note: btn:IsShown() on secure frame is safe.
				local meta = Wise.buttonMeta and Wise.buttonMeta[btn]
				local visualClone = (meta and meta.visualClone) or btn.visualClone

				if btn:IsShown() or (visualClone and visualClone:IsShown()) then
					Wise:UpdateButtonCooldown(btn)
				end
			end
		end
	end
end

-- Charge Count Update Functions
function Wise:UpdateButtonCharges(btn)
	if not btn or not btn.count then
		return
	end

	local meta = Wise.buttonMeta and Wise.buttonMeta[btn]
	if not meta then
		return
	end

	local actionType = meta.actionType
	local actionValue = meta.actionValue
	local spellID = meta.spellID

	local count = 0
	local isChargeSpell = false
	local hasCount = false

	if actionType == "item" or actionType == "toy" then
		local itemVal = actionValue
		if type(itemVal) == "string" then
			local num = tonumber(itemVal:match("^%a+:(%d+)")) or tonumber(itemVal)
			if num then
				itemVal = num
			end
		end

		local groupName = btn.groupName
		local group = groupName and WiseDB.groups[groupName]
		if group and group.smartSources then
			local bagCount = GetItemCount(itemVal, false) or 0
			local bankCount = (GetItemCount(itemVal, true) or 0) - bagCount
			if bankCount < 0 then
				bankCount = 0
			end

			if group.smartSources.bags then
				count = count + bagCount
			end
			if group.smartSources.bank then
				count = count + bankCount
			end
		else
			count = GetItemCount(itemVal, true) or 0
		end
		hasCount = true
	elseif actionType == "spell" then
		if spellID then
			local chargeInfo = C_Spell.GetSpellCharges(spellID)
			if chargeInfo then
				local maxCharges = CleanSecretNumber(SafeReadField(chargeInfo, "maxCharges"))
				local currentCharges = CleanSecretNumber(SafeReadField(chargeInfo, "currentCharges"))
				-- Only treat as a charge spell when it can hold >1 charge.
				-- Single-charge spells (e.g. Mangle) would otherwise show a
				-- meaningless "1" that can never increase.
				if maxCharges and maxCharges > 1 then
					count = currentCharges
					isChargeSpell = true
					hasCount = true
				end
			end
		end
		if not isChargeSpell and IsConsumableSpell and IsConsumableSpell(actionValue) then
			count = GetSpellCount(actionValue) or 0
			hasCount = true
		end
	elseif actionType == "action" and tonumber(actionValue) then
		local aVal = tonumber(actionValue)
		if IsConsumableAction(aVal) then
			count = GetActionCount(aVal) or 0
			hasCount = true
		end
		local charges, maxCharges = GetActionCharges(aVal)
		charges = CleanSecretNumber(charges)
		maxCharges = CleanSecretNumber(maxCharges)
		if maxCharges and maxCharges > 1 then
			count = charges
			isChargeSpell = true
			hasCount = true
		end
	end

	if not hasCount then
		btn.count:Hide()
		local visualClone = (meta and meta.visualClone) or btn.visualClone
		if visualClone and visualClone.count then
			visualClone.count:Hide()
		end
		return
	end

	local groupName = btn.groupName or ""
	Wise:Text_UpdateCharges(btn, groupName, count, isChargeSpell)

	local visualClone = (meta and meta.visualClone) or btn.visualClone
	if visualClone and visualClone.count then
		Wise:Text_UpdateCharges(visualClone, groupName, count, isChargeSpell)
	end
end

function Wise:UpdateAllCharges()
	for name, frame in pairs(Wise.frames) do
		-- Skip if group data is missing (stale frame check)
		if not WiseDB.groups[name] then
			Wise.frames[name] = nil
		elseif frame.buttons then
			for _, btn in ipairs(frame.buttons) do
				local meta = Wise.buttonMeta and Wise.buttonMeta[btn]
				local visualClone = (meta and meta.visualClone) or btn.visualClone

				if btn:IsShown() or (visualClone and visualClone:IsShown()) then
					Wise:UpdateButtonCharges(btn)
				end
			end
		end
	end
end

-- Usability Update Functions
function Wise:UpdateButtonUsability(btn)
	if not btn or not btn.icon then
		return
	end

	-- If permanently disabled (filtered/unknown), do not touch
	if btn.isValid == false then
		return
	end

	-- Retrieve metadata safely
	local meta = Wise.buttonMeta and Wise.buttonMeta[btn]

	if meta and meta.baseSpellID then
		local oldSpellID = meta.spellID
		meta.spellID = Wise:GetOverrideSpellID(meta.baseSpellID)
		if oldSpellID ~= meta.spellID and btn.icon then
			local texture = Wise:GetActionIcon(meta.actionType, meta.actionValue, meta.actionData)
			btn.icon:SetTexture(texture)
			local vClone = meta.visualClone or btn.visualClone
			if vClone and vClone.icon then
				vClone.icon:SetTexture(texture)
			end
		end
	end

	local spellID = (meta and meta.spellID) or btn.spellID
	local itemID = (meta and meta.itemID) or btn.itemID
	local visualClone = (meta and meta.visualClone) or btn.visualClone
	local vIcon = visualClone and visualClone.icon

	local isUsable, noMana = true, false

	local actionType = (meta and meta.actionType) or btn.actionType
	local actionValue = (meta and meta.actionValue) or btn.actionValue

	-- Module 4: API Compatibility (Polyfill)
	if actionType == "action" and tonumber(actionValue) then
		local realID = Wise:ResolveBarActionID(tonumber(actionValue))
		isUsable, noMana = IsUsableAction(realID)
	elseif actionType == "misc" and actionValue == "extrabutton" then
		if HasExtraActionBar and HasExtraActionBar() then
			isUsable, noMana = IsUsableAction(EXTRA_ACTION_BUTTON_SLOT)
		end
	elseif actionType == "misc" and actionValue == "zoneability" then
		-- Only usable when the zone ability is genuinely active. GetZoneAbilitySpellButton
		-- can return a stale child (kept .spellID + IsShown after the zone ability went
		-- away, e.g. G-99 Breakneck exit); IsZoneAbilityActive cross-checks C_ZoneAbility,
		-- so a stale slot greys out instead of looking usable.
		local zoneBtn = GetZoneAbilitySpellButton()
		if zoneBtn and zoneBtn.spellID and IsZoneAbilityActive() then
			isUsable, noMana = Wise:IsSpellUsable(zoneBtn.spellID)
		else
			isUsable = false
		end
	elseif actionType == "misc" and actionValue == "overridebar" then
		local realID = Wise:ResolveBarActionID(133)
		isUsable, noMana = IsUsableAction(realID)
	elseif actionType == "misc" and actionValue == "possessbar" then
		local realID = Wise:ResolveBarActionID(121)
		isUsable, noMana = IsUsableAction(realID)
	elseif actionType == "misc" and type(actionValue) == "string" and actionValue:sub(1, 12) == "addon_magic_" then
		local amIdx = tonumber(actionValue:sub(13))
		if amIdx and WiseDB.addonMagicSlots and WiseDB.addonMagicSlots[amIdx] then
			local slot = WiseDB.addonMagicSlots[amIdx]
			if slot.addons and #slot.addons > 0 then
				isUsable = true
				for _, addon in ipairs(slot.addons) do
					addon = strtrim(addon)
					if addon ~= "" and not C_AddOns.IsAddOnLoaded(addon) then
						isUsable = false
						break
					end
				end
			else
				isUsable = false -- No addons selected
			end
		end
	elseif actionType == "toy" then
		local toyID = tonumber(actionValue)
		if toyID then
			if C_ToyBox and C_ToyBox.IsToyUsable then
				isUsable = C_ToyBox.IsToyUsable(toyID)
			elseif PlayerHasToy then
				isUsable = PlayerHasToy(toyID)
			end
		end
	elseif spellID then
		isUsable, noMana = Wise:IsSpellUsable(spellID)
	elseif itemID then
		if C_Item and C_Item.IsUsableItem then
			isUsable, noMana = C_Item.IsUsableItem(itemID)
		else
			isUsable, noMana = IsUsableItem(itemID)
		end
	end

	if isUsable then
		btn.icon:SetDesaturated(false)
		btn.icon:SetVertexColor(1, 1, 1)
		btn.icon:SetAlpha(1)
		if vIcon then
			vIcon:SetDesaturated(false)
			vIcon:SetVertexColor(1, 1, 1)
			vIcon:SetAlpha(1)
		end
	elseif noMana then
		-- Blue tint for OOM
		btn.icon:SetDesaturated(false)
		btn.icon:SetVertexColor(0.5, 0.5, 1.0)
		btn.icon:SetAlpha(1)
		if vIcon then
			vIcon:SetDesaturated(false)
			vIcon:SetVertexColor(0.5, 0.5, 1.0)
			vIcon:SetAlpha(1)
		end
	else
		btn.icon:SetAlpha(0.3) -- Lower alpha to make it harder to see
		if vIcon then
			vIcon:SetDesaturated(true)
			vIcon:SetVertexColor(1, 1, 1)
			vIcon:SetAlpha(0.3)
		end
	end

	local _, _, _, _, _, _, _, _, _, _, showGlows, _, _, _, _, _, btnHideEmpty =
		Wise:GetGroupDisplaySettings(btn.groupName)
	local btnMeta = Wise.buttonMeta and Wise.buttonMeta[btn]
	local btnActionType = (btnMeta and btnMeta.actionType) or btn.actionType
	if btnHideEmpty and btnActionType == "empty" then
		btn.icon:SetAlpha(0)
		if vIcon then
			vIcon:SetAlpha(0)
		end
	end

	-- Proc Glow Handling
	local shouldGlow = false
	if showGlows and spellID then
		-- Check the overlay against BOTH the base and the override spell ID.
		-- WoW registers a proc glow against whichever ID the engine considers
		-- "active", and for spells with an override relationship (talented /
		-- spec-modified) that can disagree with our resolved meta.spellID — and
		-- which one IsSpellOverlayed() returns true for flips frame-to-frame as
		-- the override state settles. Checking only one ID makes those spells
		-- flash at ~0.5 Hz (glow tears down and re-pops). Glow if EITHER is
		-- overlayed so the steady proc state stays steady.
		local baseSpellID = meta and meta.baseSpellID
		shouldGlow = (C_SpellActivationOverlay and C_SpellActivationOverlay.IsSpellOverlayed(spellID)) or false
		if not shouldGlow and baseSpellID and baseSpellID ~= spellID then
			shouldGlow = C_SpellActivationOverlay and C_SpellActivationOverlay.IsSpellOverlayed(baseSpellID) or false
		end
		if shouldGlow then
			-- If the spell has charges and current charges is 0, do not glow.
			-- Use SafeReadField because accessing chargeInfo fields can throw when
			-- the engine returns secret number values (WoW 12.0+ taint system).
			-- Note: CleanSecretNumber returns nil for secret values (common in
			-- combat), so a secret charge count leaves shouldGlow untouched rather
			-- than flipping it — that is intentional; we only suppress on a
			-- definitely-zero charge count.
			local chargeInfo = C_Spell.GetSpellCharges(spellID)
			if chargeInfo then
				local currentCharges = CleanSecretNumber(SafeReadField(chargeInfo, "currentCharges"))
				if currentCharges and currentCharges == 0 then
					shouldGlow = false
				end
			end
		end
	end

	-- Hysteresis on the OFF edge. The proc read (IsSpellOverlayed + override-ID
	-- resolution + charge check) can momentarily report false on a single pass
	-- while the proc is genuinely still up — the override spell ID is re-resolved
	-- every pass and can briefly flip, and mouse-over-driven refreshes run this
	-- pass constantly. A single false used to immediately HideOverlayGlow, which
	-- played animOut→recycle, and the next true re-played animIn — a visible
	-- ~0.5 Hz flash. Require TWO consecutive false reads before tearing the glow
	-- down so a one-pass blip can't restart the animation. A true read clears the
	-- counter instantly, so a real proc-end still hides within ~2 passes.
	if shouldGlow then
		btn._wiseGlowOffStreak = nil
		-- Show on the rising edge OR when intent is on but the overlay is gone
		-- (self-heal after any teardown). ShowOverlayGlow is idempotent while the
		-- overlay is attached, so this never replays the entrance burst on a
		-- steady proc; transient parent hides pause/resume the overlay in place.
		if not btn._wiseGlowOn or not btn.__WiseOverlay or (visualClone and not visualClone.__WiseOverlay) then
			btn._wiseGlowOn = true
			Wise:ShowOverlayGlow(btn, nil, "proc")
			if visualClone then
				Wise:ShowOverlayGlow(visualClone, nil, "proc")
			end
		end
	elseif not Wise.isDragging then
		if btn._wiseGlowOn then
			local streak = (btn._wiseGlowOffStreak or 0) + 1
			btn._wiseGlowOffStreak = streak
			if streak >= 2 then
				btn._wiseGlowOffStreak = nil
				btn._wiseGlowOn = nil
				Wise:HideOverlayGlow(btn, "proc")
				if visualClone then
					Wise:HideOverlayGlow(visualClone, "proc")
				end
			end
		end
	end
end

function Wise:UpdateAllOverrideIcons()
	if not Wise.buttonMeta then
		return
	end
	for btn, meta in pairs(Wise.buttonMeta) do
		if meta.baseSpellID then
			local newSpellID = Wise:GetOverrideSpellID(meta.baseSpellID)
			if newSpellID ~= meta.spellID then
				meta.spellID = newSpellID
				if btn.icon then
					local texture = Wise:GetActionIcon(meta.actionType, meta.actionValue, meta.actionData)
					btn.icon:SetTexture(texture)
					local vClone = meta.visualClone or btn.visualClone
					if vClone and vClone.icon then
						vClone.icon:SetTexture(texture)
					end
				end
			end
		end
	end
end

function Wise:UpdateAllUsability()
	for name, frame in pairs(Wise.frames) do
		-- Skip if group data is missing (stale frame check)
		if not WiseDB.groups[name] then
			Wise.frames[name] = nil
		elseif frame.buttons then
			for _, btn in ipairs(frame.buttons) do
				-- Check visibility safely
				local meta = Wise.buttonMeta and Wise.buttonMeta[btn]
				local visualClone = (meta and meta.visualClone) or btn.visualClone

				if btn:IsShown() or (visualClone and visualClone:IsShown()) then
					Wise:UpdateButtonUsability(btn)
				end
			end
		end
	end
end

-- Active State Highlight Functions

-- Cache: maps shapeshift form spellIDs to their form index
local shapeshiftSpellToForm = {}
local shapeshiftBaseSpellCache = {}
local function RebuildShapeshiftCache()
	wipe(shapeshiftSpellToForm)
	local numForms = GetNumShapeshiftForms() or 0
	for i = 1, numForms do
		local _, _, _, formSpellID = GetShapeshiftFormInfo(i)
		if formSpellID then
			shapeshiftSpellToForm[formSpellID] = i
			-- Also map base spellID in case the button stores a different rank/variant
			if shapeshiftBaseSpellCache[formSpellID] == nil then
				local info = C_Spell.GetSpellInfo(formSpellID)
				if info and info.spellID and info.spellID ~= formSpellID then
					shapeshiftBaseSpellCache[formSpellID] = info.spellID
				else
					shapeshiftBaseSpellCache[formSpellID] = false
				end
			end

			local baseSpellID = shapeshiftBaseSpellCache[formSpellID]
			if baseSpellID then
				shapeshiftSpellToForm[baseSpellID] = i
			end
		end
	end
end

-- Rebuild cache on relevant events
local shapeshiftCacheFrame = CreateFrame("Frame")
shapeshiftCacheFrame:RegisterEvent("UPDATE_SHAPESHIFT_FORMS")
shapeshiftCacheFrame:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED")
shapeshiftCacheFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
shapeshiftCacheFrame:SetScript("OnEvent", RebuildShapeshiftCache)

function Wise:UpdateButtonState(btn)
	if not btn then
		return
	end

	local meta = Wise.buttonMeta and Wise.buttonMeta[btn]
	if not meta then
		return
	end

	local isActive = false
	local actionType = meta.actionType
	local actionValue = meta.actionValue
	local spellID = meta.spellID
	local itemID = meta.itemID

	if actionType == "action" and tonumber(actionValue) then
		local aID = tonumber(actionValue)
		local isCurrent = false
		if C_ActionBar and C_ActionBar.IsCurrentAction then
			isCurrent = C_ActionBar.IsCurrentAction(aID)
		elseif IsCurrentAction then
			isCurrent = IsCurrentAction(aID)
		end
		local isAutoRepeat = false
		if C_ActionBar and C_ActionBar.IsAutoRepeatAction then
			isAutoRepeat = C_ActionBar.IsAutoRepeatAction(aID)
		elseif IsAutoRepeatAction then
			isAutoRepeat = IsAutoRepeatAction(aID)
		end
		isActive = isCurrent or isAutoRepeat
	elseif actionType == "spell" and spellID then
		local isCurrent = false
		if C_Spell and C_Spell.IsCurrentSpell then
			isCurrent = C_Spell.IsCurrentSpell(spellID)
		elseif IsCurrentSpell then
			isCurrent = IsCurrentSpell(spellID)
		end
		local isAutoRepeat = false
		if C_Spell and C_Spell.IsAutoRepeatSpell then
			isAutoRepeat = C_Spell.IsAutoRepeatSpell(spellID)
		elseif IsAutoRepeatSpell then
			isAutoRepeat = IsAutoRepeatSpell(spellID)
		end
		isActive = isCurrent or isAutoRepeat
		-- Also check if this spell is a shapeshift form
		if not isActive then
			local formIndex = shapeshiftSpellToForm[spellID]
			if formIndex then
				isActive = GetShapeshiftForm() == formIndex
			end
		end
	elseif actionType == "item" and itemID then
		local isCurrent = false
		if C_Item and C_Item.IsCurrentItem then
			isCurrent = C_Item.IsCurrentItem(itemID)
		elseif IsCurrentItem then
			isCurrent = IsCurrentItem(itemID)
		end
		isActive = isCurrent
	elseif actionType == "misc" and type(actionValue) == "string" then
		local formIndex = actionValue:match("^form_(%d+)")
		if formIndex then
			local currentForm = GetShapeshiftForm()
			isActive = currentForm == tonumber(formIndex)
			-- Refresh the icon from GetShapeshiftFormInfo (may change per form state)
			local formIcon = GetShapeshiftFormInfo(tonumber(formIndex))
			if formIcon and btn.icon then
				btn.icon:SetTexture(formIcon)
				local vClone = (meta and meta.visualClone) or btn.visualClone
				if vClone and vClone.icon then
					vClone.icon:SetTexture(formIcon)
				end
			end
		end
		local amIdx = actionValue:match("^addon_magic_(%d+)")
		if amIdx then
			amIdx = tonumber(amIdx)
			if amIdx and WiseDB.addonMagicSlots and WiseDB.addonMagicSlots[amIdx] then
				local slot = WiseDB.addonMagicSlots[amIdx]
				if slot.addons and #slot.addons > 0 then
					isActive = true
					for _, addon in ipairs(slot.addons) do
						addon = strtrim(addon)
						if addon ~= "" and not C_AddOns.IsAddOnLoaded(addon) then
							isActive = false
							break
						end
					end
				end
			end
		end
	end

	if btn.activeHighlight then
		btn.activeHighlight:SetShown(isActive)
	end

	local visualClone = (meta and meta.visualClone) or btn.visualClone
	if visualClone and visualClone.activeHighlight then
		visualClone.activeHighlight:SetShown(isActive)
	end
end

function Wise:UpdateAllStates()
	for name, frame in pairs(Wise.frames) do
		if not WiseDB.groups[name] then
			Wise.frames[name] = nil
		elseif frame.buttons then
			for _, btn in ipairs(frame.buttons) do
				local meta = Wise.buttonMeta and Wise.buttonMeta[btn]
				local visualClone = (meta and meta.visualClone) or btn.visualClone

				if btn:IsShown() or (visualClone and visualClone:IsShown()) then
					Wise:UpdateButtonState(btn)
				end
			end
		end
	end
end

-- Error Suppression System
-- When a Wise button with "Suppress all errors" is clicked, temporarily prevent
-- UIErrorsFrame from receiving UI_ERROR_MESSAGE at all (no text, no sound).
do
	local suppressing = false
	local ERROR_FILTER_DURATION = 0.5
	local savedErrorSpeech = nil

	-- Hook AddMessage to catch any error text that bypasses the event system
	local origAddMessage = UIErrorsFrame.AddMessage
	UIErrorsFrame.AddMessage = function(self, ...)
		if suppressing then
			return
		end
		return origAddMessage(self, ...)
	end

	function Wise:BeginErrorSuppression()
		if suppressing then
			return
		end
		suppressing = true

		-- 1. Block red error text via event
		UIErrorsFrame:UnregisterEvent("UI_ERROR_MESSAGE")
		UIErrorsFrame:Clear()

		-- 2. Block error speech/voice ("That is still recharging", etc.)
		savedErrorSpeech = GetCVarBool("Sound_EnableErrorSpeech")
		SetCVar("Sound_EnableErrorSpeech", 0)

		-- Re-enable after a short window
		C_Timer.After(ERROR_FILTER_DURATION, function()
			UIErrorsFrame:RegisterEvent("UI_ERROR_MESSAGE")
			-- Restore error speech to whatever the user had it set to
			if savedErrorSpeech then
				SetCVar("Sound_EnableErrorSpeech", 1)
			end
			suppressing = false
		end)
	end
end

-- Cooldown & Usability Event Handler
local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("SPELL_UPDATE_COOLDOWN")
eventFrame:RegisterEvent("ACTIONBAR_UPDATE_COOLDOWN")
eventFrame:RegisterEvent("BAG_UPDATE_COOLDOWN")
eventFrame:RegisterEvent("BAG_UPDATE_DELAYED")
eventFrame:RegisterEvent("SPELL_UPDATE_CHARGES")
-- Usability events
eventFrame:RegisterEvent("SPELL_UPDATE_USABLE")
eventFrame:RegisterEvent("PLAYER_TARGET_CHANGED")
eventFrame:RegisterUnitEvent("UNIT_AURA", "player", "target")
eventFrame:RegisterEvent("SPELL_ACTIVATION_OVERLAY_GLOW_SHOW")
eventFrame:RegisterEvent("SPELL_ACTIVATION_OVERLAY_GLOW_HIDE")
-- Spell override events (procs that change spell icons, e.g. Maul -> Raze)
eventFrame:RegisterEvent("SPELL_UPDATE_ICON")
-- Active state (checked) events
eventFrame:RegisterEvent("UPDATE_SHAPESHIFT_FORM")
eventFrame:RegisterEvent("ACTIONBAR_UPDATE_STATE")
-- Defer cooldown updates by one frame to avoid tainting Blizzard's secure
-- event handlers. WoW 11.1+ uses "secret numbers" for cooldown values in
-- combat; touching them in an addon event handler taints the execution
-- context, which then prevents Blizzard's ActionButton_UpdateCooldown from
-- calling SetCooldown with those same secret values. By deferring to the
-- next frame via C_Timer.After(0, ...), Wise's cooldown work runs outside
-- the Blizzard event dispatch chain and cannot propagate taint.
local pendingCooldownUpdate = false
local pendingChargeUpdate = false
scheduleCooldownUpdate = function()
	if not pendingCooldownUpdate then
		pendingCooldownUpdate = true
		C_Timer.After(0, function()
			pendingCooldownUpdate = false
			Wise:UpdateAllCooldowns()
		end)
	end
end
local function scheduleChargeUpdate()
	if not pendingChargeUpdate then
		pendingChargeUpdate = true
		C_Timer.After(0, function()
			pendingChargeUpdate = false
			Wise:UpdateAllCharges()
		end)
	end
end
-- Usability/glow refresh is driven by UNIT_AURA (player+target), which fires many
-- times per second in combat, plus SPELL_UPDATE_USABLE and the glow show/hide
-- events. Running the full all-button usability+glow pass synchronously per event
-- is wasted churn (and was re-triggering the proc glow every aura tick). Coalesce
-- a burst into a single deferred pass (Rule 12 #2), same pattern as cooldowns.
local pendingUsabilityUpdate = false
local function scheduleUsabilityUpdate()
	if not pendingUsabilityUpdate then
		pendingUsabilityUpdate = true
		C_Timer.After(0, function()
			pendingUsabilityUpdate = false
			Wise:UpdateAllUsability()
		end)
	end
end

eventFrame:SetScript("OnEvent", function(self, event, unit)
	if event == "UNIT_AURA" then
		if unit == "target" or unit == "player" then
			scheduleUsabilityUpdate()
			scheduleCooldownUpdate()
		end
		if unit == "player" then
			Wise:UpdateAllStates()
		end
	elseif event == "PLAYER_TARGET_CHANGED" then
		scheduleUsabilityUpdate()
		scheduleCooldownUpdate()
	elseif event == "SPELL_UPDATE_USABLE" then
		scheduleUsabilityUpdate()
	elseif event == "SPELL_UPDATE_ICON" then
		Wise:UpdateAllOverrideIcons()
		scheduleUsabilityUpdate()
	elseif event == "SPELL_ACTIVATION_OVERLAY_GLOW_SHOW" or event == "SPELL_ACTIVATION_OVERLAY_GLOW_HIDE" then
		scheduleUsabilityUpdate()
	elseif event == "UPDATE_SHAPESHIFT_FORM" or event == "ACTIONBAR_UPDATE_STATE" then
		Wise:UpdateAllStates()
	elseif event == "SPELL_UPDATE_CHARGES" then
		scheduleChargeUpdate()
		scheduleCooldownUpdate()
	elseif event == "SPELL_UPDATE_COOLDOWN" or event == "ACTIONBAR_UPDATE_COOLDOWN" then
		scheduleCooldownUpdate()
	else
		-- BAG_UPDATE_COOLDOWN
		scheduleCooldownUpdate()
		scheduleChargeUpdate()
	end
end)

-- ─── Dynamic Group Refresh Driver (event-driven) ──────────────────
-- Replaces the old per-group 0.2s conditionTicker. Each dynamic group stores
-- its refresh closure on f._dynamicRefresh and registers in Wise._dynamicGroups.
-- We run those closures only when a relevant game event fires, instead of
-- polling 5x/sec forever. Refreshes are coalesced to once per frame so a burst
-- of events (common on stance/spec change) costs a single pass.
local pendingDynamicRefresh = false
local function RefreshAllDynamicGroups()
	local reg = Wise._dynamicGroups
	if not reg then
		return
	end
	for frame in pairs(reg) do
		local fn = frame._dynamicRefresh
		if fn then
			fn()
		else
			reg[frame] = nil
		end
	end
end
local function scheduleDynamicRefresh()
	if not pendingDynamicRefresh then
		pendingDynamicRefresh = true
		C_Timer.After(0, function()
			pendingDynamicRefresh = false
			RefreshAllDynamicGroups()
		end)
	end
end
Wise.ScheduleDynamicRefresh = scheduleDynamicRefresh

-- Coalesced full rebuild for spec/spell changes. Unlike scheduleDynamicRefresh
-- (which runs the cheap snapshot-diff closures), this forces a full
-- UpdateGroupDisplay on every active group — needed because spec/spell changes
-- invalidate the per-character graph macroText that the snapshot-diff can't see.
-- SPELLS_CHANGED fires in bursts, so the deferred flag collapses a burst into a
-- single pass instead of rebuilding every frame N times (Rule 12 #2).
local pendingFullRebuild = false
local function scheduleFullRebuild()
	if not pendingFullRebuild then
		pendingFullRebuild = true
		C_Timer.After(0, function()
			pendingFullRebuild = false
			if InCombatLockdown() then
				return -- UpdateGroupDisplay would defer anyway; combat-exit flush catches it
			end
			if Wise.frames then
				for name in pairs(Wise.frames) do
					Wise:UpdateGroupDisplay(name)
				end
			end
		end)
	end
end

local dynEventFrame = CreateFrame("Frame")
dynEventFrame._wiseProfileName = "DynamicRefreshDriver"
-- Action-slot texture changes (action / overridebar / possessbar slots)
dynEventFrame:RegisterEvent("ACTIONBAR_SLOT_CHANGED")
dynEventFrame:RegisterEvent("ACTIONBAR_PAGE_CHANGED")
dynEventFrame:RegisterEvent("UPDATE_BONUS_ACTIONBAR")
dynEventFrame:RegisterEvent("UPDATE_OVERRIDE_ACTIONBAR")
dynEventFrame:RegisterEvent("UPDATE_VEHICLE_ACTIONBAR")
-- Extra action button / zone ability appearing or changing
dynEventFrame:RegisterEvent("UPDATE_EXTRA_ACTIONBAR")
dynEventFrame:RegisterEvent("ZONE_CHANGED")
dynEventFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
dynEventFrame:RegisterEvent("SPELL_UPDATE_ICON")
-- Multi-state condition inputs (stances, spec, learning spells)
dynEventFrame:RegisterEvent("UPDATE_SHAPESHIFT_FORM")
dynEventFrame:RegisterEvent("ACTIONBAR_UPDATE_STATE")
dynEventFrame:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED")
dynEventFrame:RegisterEvent("SPELLS_CHANGED")
-- Cooldown-filtered dynamic slots (slot becomes ready / goes on CD)
dynEventFrame:RegisterEvent("SPELL_UPDATE_COOLDOWN")
dynEventFrame:RegisterEvent("BAG_UPDATE_COOLDOWN")
-- Target / mouseover reaction conditions ([harm], [help], [@target,exists])
dynEventFrame:RegisterEvent("PLAYER_TARGET_CHANGED")
dynEventFrame:RegisterEvent("UPDATE_MOUSEOVER_UNIT")
-- NOTE: Modifier keys ([mod:shift] etc.) are deliberately NOT driven here.
-- A MODIFIER_STATE_CHANGED driver fires a refresh of EVERY dynamic group on
-- every shift/ctrl/alt tap (constant in combat), even for groups with no
-- modifier condition. Modifier-driven slots are served instead by the slow
-- per-group _needsPoll ticker below (Rule 12 #4), so groups without [mod:*]
-- poll zero times.
-- Vehicle/possess transitions
dynEventFrame:RegisterEvent("UNIT_ENTERED_VEHICLE")
dynEventFrame:RegisterEvent("UNIT_EXITED_VEHICLE")
dynEventFrame:RegisterEvent("PLAYER_REGEN_ENABLED") -- flush after leaving combat
dynEventFrame:RegisterEvent("PLAYER_REGEN_DISABLED") -- resaturate [combat] slots on combat enter
dynEventFrame:SetScript("OnEvent", function(_, event, arg1)
	-- UNIT_*_VEHICLE fire for every unit; only the player matters here.
	if (event == "UNIT_ENTERED_VEHICLE" or event == "UNIT_EXITED_VEHICLE") and arg1 ~= "player" then
		return
	end
	-- Spec/spell changes need a full rebuild (they invalidate the per-character
	-- graph macroText the snapshot-diff can't see). PLAYER_SPECIALIZATION_CHANGED
	-- can't fire in combat, but SPELLS_CHANGED can (learning a spell mid-fight,
	-- pet swaps). If it lands in combat, UpdateGroupDisplay would defer anyway, so
	-- mark it pending and let PLAYER_REGEN_ENABLED flush one rebuild on exit.
	local isFullRebuildEvent = (event == "PLAYER_SPECIALIZATION_CHANGED" or event == "SPELLS_CHANGED")

	-- In combat the refresh is a no-op: every closure that touches secure
	-- attributes self-guards on InCombatLockdown(), and cooldown/target events
	-- fire every GCD — so don't even schedule. PLAYER_REGEN_ENABLED flushes on
	-- combat exit to catch anything that changed (Rule 12 #3).
	-- PLAYER_REGEN_DISABLED is the exception: it fires AT combat start (when
	-- InCombatLockdown() is already true) and is the only trigger for [combat]
	-- conditional slots flipping to matched. The refresh it runs is visual-only
	-- in combat (secure writes self-guard on canSetAttrs), so it's safe to let
	-- through — without it a [combat] potion stays greyed for the whole fight.
	if event ~= "PLAYER_REGEN_ENABLED" and event ~= "PLAYER_REGEN_DISABLED" and InCombatLockdown() then
		if isFullRebuildEvent then
			dynEventFrame._fullRebuildPending = true
		end
		return
	end

	-- On combat exit, honor any spec/spell rebuild that was suppressed in combat.
	if event == "PLAYER_REGEN_ENABLED" and dynEventFrame._fullRebuildPending then
		dynEventFrame._fullRebuildPending = nil
		scheduleFullRebuild()
		-- fall through so the normal combat-exit refresh still runs below
	end

	-- Spec and spell changes force a full UpdateGroupDisplay on every active group.
	-- SPELLS_CHANGED is spammy (fires repeatedly on load / talent / book changes)
	-- and each rebuild runs the O(n^2) graph subset pass, so coalesce a burst into
	-- ONE rebuild via the deferred flag in scheduleFullRebuild — the per-frame
	-- C_Timer.After(0) collapses N events into a single all-frames pass (Rule 12 #2).
	if isFullRebuildEvent then
		scheduleFullRebuild()
		return
	end
	scheduleDynamicRefresh()
end)

-- Safety-net poll for inputs that have NO clean event: modifier-key conditions
-- ([mod:shift] etc.). Only groups flagged f._needsPoll are refreshed here, so
-- the common case (no modifier conditions) does ZERO polling — those groups
-- update purely from the DynamicRefreshDriver events above. Modifier-driven
-- slots update within ~0.3s of a key press, imperceptible for icon/visibility.
C_Timer.NewTicker(0.3, function()
	local reg = Wise._dynamicGroups
	if not reg or InCombatLockdown() then
		return
	end
	for frame in pairs(reg) do
		if frame._needsPoll then
			local fn = frame._dynamicRefresh
			if fn then
				fn()
			end
		end
	end
end)

-- Flash Animation for Immediate Feedback
function Wise:FlashGroup(groupName)
	local f = Wise.frames[groupName]
	if not f then
		return
	end

	if not f.flashAnim then
		f.flashAnim = f:CreateAnimationGroup()
		f.flashAnim:SetLooping("NONE")

		-- Flash Out (Fade to 50%)
		local a1 = f.flashAnim:CreateAnimation("Alpha")
		a1:SetOrder(1)
		a1:SetDuration(0.2)
		a1:SetFromAlpha(1)
		a1:SetToAlpha(0.3)
		a1:SetSmoothing("OUT")

		-- Flash In (Back to 100%)
		local a2 = f.flashAnim:CreateAnimation("Alpha")
		a2:SetOrder(2)
		a2:SetDuration(0.3)
		a2:SetFromAlpha(0.3)
		a2:SetToAlpha(1)
		a2:SetSmoothing("IN")
	end

	if f:IsShown() then
		f.flashAnim:Stop()
		f.flashAnim:Play()
	end

	-- Also flash the visual display if active (combat + mouse mode)
	if f.visualDisplay and f.visualDisplay:IsShown() then
		if not f.visualDisplay.flashAnim then
			f.visualDisplay.flashAnim = f.visualDisplay:CreateAnimationGroup()
			-- Mirror animation
			local a1 = f.visualDisplay.flashAnim:CreateAnimation("Alpha")
			a1:SetOrder(1)
			a1:SetDuration(0.2)
			a1:SetFromAlpha(1)
			a1:SetToAlpha(0.3)
			a1:SetSmoothing("OUT")
			local a2 = f.visualDisplay.flashAnim:CreateAnimation("Alpha")
			a2:SetOrder(2)
			a2:SetDuration(0.3)
			a2:SetFromAlpha(0.3)
			a2:SetToAlpha(1)
			a2:SetSmoothing("IN")
		end
		f.visualDisplay.flashAnim:Stop()
		f.visualDisplay.flashAnim:Play()
	end
end

-- Pending Update Handler
local pendingFrame = CreateFrame("Frame")
pendingFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
pendingFrame:SetScript("OnEvent", function(self, event)
	if Wise.pendingUpdates then
		local updates = Wise.pendingUpdates
		Wise.pendingUpdates = nil -- Clear first to avoid loops if errors occur

		for name, _ in pairs(updates) do
			Wise:UpdateGroupDisplay(name)
		end
	end
	-- Refresh all cooldowns on combat end so start/duration secret numbers
	-- are replaced with real values for accurate countdown text.
	Wise:UpdateAllCooldowns()
end)

function Wise:ResetSequences()
	if InCombatLockdown() or not Wise.buttonMeta then
		return
	end

	for btn, meta in pairs(Wise.buttonMeta) do
		if meta.conflictStrategy == "sequence" and meta.resetOnCombat then
			btn:SetAttribute("isa_seq", 1)
			-- Trigger an immediate evaluation to update the icon
			if meta.states and #meta.states > 1 then
				local chosen = Wise:EvaluateSlotConditions(meta.states, "sequence", btn)
				if chosen then
					meta.activeState = chosen
					local state = meta.states[chosen]
					if state then
						btn.icon:SetTexture(Wise:GetActionIcon(state.type, state.value, state))
						btn.actionType = state.type
						btn.actionValue = state.value
						btn.actionData = state

						local sType, sAttr, sValue = Wise:GetSecureAttributes(state, state.conditions)
						btn:SetAttribute("type", nil)
						btn:SetAttribute("spell", nil)
						btn:SetAttribute("item", nil)
						btn:SetAttribute("macro", nil)
						btn:SetAttribute("macrotext", nil)
						btn:SetAttribute("clickbutton", nil)
						btn:SetAttribute("type", sType)
						if sAttr then
							btn:SetAttribute(sAttr, sValue)
						end
					end
				end
			end
		end
	end
end
