-------------------------------------------------------------------------------
-- Tracker.lua
-- Generic tracker factory — 12.1 AuraContainer path, multi-element.
--
-- Each tracker (Spatial Paradox, Innervate, Power Infusion, External
-- Defensives) owns up to MAX_ELEMENTS on-demand ELEMENTS. Each
-- element has a movable holder and:
--   * a single-aura SLOT (unique key, in the ONE SHARED slot container)
--     used by circle/text — and by icon/bar on single-spell trackers;
--     group-capable elements additionally get their OWN AuraContainer
--     (flow layout is container-level);
--   * for MULTI-SPELL trackers (config.spellList) and custom shells,
--     PER-SPELL 1-frame groups ("g1".."gN", + "gtail" overflow on
--     customs) used by icon/bar so simultaneous auras fan out along a
--     configurable grow direction IN THE CONFIGURED Tracked Buffs order
--     (layoutIndex; no custom sort comparator exists engine-side).
-- Whichever of slot/group the current style doesn't use is PARKED
-- ("HELPFUL|HARMFUL" matches nothing — a proven dormant parking).
--
-- Slot/group creation and region binding work at ANY time OUTSIDE
-- combat/secret windows (ns.IsRestricted). Trackers and their elements
-- are therefore created ON DEMAND through the refresh pipeline; work
-- that would land in a restricted window is deferred
-- (ns.deferredRefresh) and retried when the restriction lifts. Runtime
-- style changes only use the live-proven call class: SetSize, region
-- Show/Hide, property setters, and the documented live container
-- setters (filter strings, candidateFilters, flow layout).
--
-- The engine shows/hides buttons and drives all duration visuals with
-- the aura's TRUE duration; no polling, no timers, no OnUpdate.
-------------------------------------------------------------------------------
local addonName, ns = ...

ns.Tracker = {}

-- Registry (populated by the spell files at load time)
ns.trackerConfigs = {}
ns.trackerOrder   = {}
ns.trackers       = {}

function ns.RegisterTracker(key, config)
    config.cfgKey = key  -- so a tracker/holder can name its own options node
    ns.trackerConfigs[key] = config
    ns.trackerOrder[#ns.trackerOrder + 1] = key
end

local MAX_ELEMENTS = 6
ns.MAX_ELEMENTS = MAX_ELEMENTS

-- Pool ceiling for the shared "maingroup" (Unordered mode) and the
-- custom-overflow tail group.
local GROUP_MAX_FRAMES = 10

-- Group Type per multi-aura tracker (profile <prefix>groupOrdered):
--   Unordered (default) = ONE shared group, engine sort, Max cap works.
--   Ordered = per-spell 1-frame groups laid out by layoutIndex in
--   Tracked Buffs order (no custom sort comparator exists
--   engine-side). Both structures are created with their
--   element and parked per mode; buttons pool LAZILY (PTR-observed), so
--   idle groups cost only their registration. Custom groups pre-create
--   this many per-spell groups; ids beyond go to the unordered tail.
local CUSTOM_SPELL_GROUPS = 12

-- RETIRED sentinel (pre-v3 profiles stored this as soundName to mean
-- "bundled voice clip"); kept only for Core's soundType migration.
ns.VOICE_SOUND_KEY = "_VOICE_"

local RING_TEXTURES = {
    ["Ring_10px"] = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\Ring_10px",
    ["Ring_20px"] = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\Ring_20px",
    ["Ring_30px"] = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\Ring_30px",
    ["Ring_40px"] = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\Ring_40px",
}
ns.RING_TEXTURES = RING_TEXTURES

local GetSpellTexture = C_Spell.GetSpellTexture
local WHITE8X8 = "Interface\\Buttons\\WHITE8X8"

-- ID-kind (ability/item) cooldown / charge / stack APIs. 12.1 cooldown NUMBERS
-- (startTime/duration) are SECRET in combat and error on read; the swipe is
-- driven by duration OBJECTS instead (self-animating). Charge/stack COUNTS are
-- read event-driven behind a canaccessvalue guard (never per-frame). See
-- RenderIDArt.
local C_Spell_GetSpellCooldownDuration = C_Spell and C_Spell.GetSpellCooldownDuration
local C_Spell_GetSpellCooldown         = C_Spell and C_Spell.GetSpellCooldown
local C_Spell_GetSpellCharges          = C_Spell and C_Spell.GetSpellCharges
local C_Spell_GetSpellChargeDuration   = C_Spell and C_Spell.GetSpellChargeDuration
local C_Spell_GetSpellDisplayCount     = C_Spell and C_Spell.GetSpellDisplayCount
local C_Spell_IsSpellUsable            = C_Spell and C_Spell.IsSpellUsable
local C_SpellBook_IsSpellKnown         = C_SpellBook and C_SpellBook.IsSpellKnown
local SPELLBANK_PLAYER = Enum and Enum.SpellBookSpellBank and Enum.SpellBookSpellBank.Player
local SPELLBANK_PET    = Enum and Enum.SpellBookSpellBank and Enum.SpellBookSpellBank.Pet
local IsPlayerSpell                    = IsPlayerSpell
local C_Item_GetItemCount              = C_Item and C_Item.GetItemCount
local C_Item_GetItemCooldown           = C_Item and C_Item.GetItemCooldown
local C_Item_IsUsableItem              = C_Item and C_Item.IsUsableItem
local UnitChannelInfo                  = UnitChannelInfo
local canaccess = canaccessvalue or function() return true end

-- LibSharedMedia, resolved ONCE at file scope. This used to be a do-block local
-- (below) that FontPath and BarTexturePath could not see, so each of them did
-- its own LibStub lookup on every call — i.e. per bar sample and per text
-- region, on the per-GCD render path. The library loads at TOC line 24 and this
-- file at line 36, so it is available here (Options.lua:8 uses the same pattern).
local LSM = LibStub("LibSharedMedia-3.0", true)

-- Bundled fonts (Media\Fonts). Registered with LibSharedMedia so they
-- show up in every font dropdown; Roboto Condensed Bold is the addon's default
-- (LSM's own default "Friz Quadrata TT" can't be overridden via SetDefault
-- once set, so the default is applied addon-side instead: TEXT_FONT below and
-- the font option's fallback in Options.lua).
local HET_DEFAULT_FONT      = "Roboto Condensed Bold"
local HET_DEFAULT_FONT_PATH = "Interface\\AddOns\\BuzzardAuras\\Media\\Fonts\\RobotoCondensed-Bold.ttf"
do
    if LSM then
        LSM:Register("font", HET_DEFAULT_FONT, HET_DEFAULT_FONT_PATH)
        LSM:Register("font", "PT Sans Narrow",
            "Interface\\AddOns\\BuzzardAuras\\Media\\Fonts\\PTSansNarrow.ttf")
        -- Bundled statusbar textures. Buzzard Glass: grayscale
        -- gradient with an upper gloss pane — tintable by SetStatusBarColor like
        -- any LSM statusbar. (Path has no extension: WoW resolves the .tga/.blp.)
        LSM:Register("statusbar", "Buzzard Glass",
            "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\BuzzardGlass")
        LSM:Register("statusbar", "Buzzard Glass Dark",
            "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\BuzzardGlassDark")
        LSM:Register("statusbar", "Buzzard Glass Shine",
            "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\BuzzardGlassShine")
        -- Hollow outline (transparent center): as a StatusBar fill, the
        -- engine's drain CROP retracts the border right-to-left. No longer
        -- used by the pair "Border" animation (that draws real border art
        -- masked to the fill edge now), but kept as a user-pickable
        -- statusbar texture.
        LSM:Register("statusbar", "Buzzard Outline",
            "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\BarOutline")
    end
end
ns.DEFAULT_FONT_NAME = HET_DEFAULT_FONT
local TEXT_FONT = HET_DEFAULT_FONT_PATH

-- Bar fill texture. "Solid" is LSM's WHITE8X8, matching the addon's historical
-- solid fill, so existing bars look identical until the user picks another texture.
ns.DEFAULT_BAR_TEXTURE = "Solid"
-- Resolve an LSM statusbar name to a texture path: already-a-path returns
-- as-is; a name Fetches from
-- LSM with noDefault so a missing name falls back to solid WHITE8X8 rather than
-- LSM's Blizzard default.
--
-- MEMOIZED by name. The resolved path only changes when the user picks a
-- different texture, but this ran per bar sample per event on the per-GCD path,
-- each call doing a LibStub metamethod dispatch, an LSM table walk, and a Lua
-- pattern match. See WipeMediaPathCaches below for the two invalidation events.
local barTexPathMemo = {}
local fontPathMemo   = {}
local function BarTexturePath(name)
    if not name or name == "" then return WHITE8X8 end
    local hit = barTexPathMemo[name]
    if hit then return hit end
    local path
    if type(name) == "string" and name:find("[\\/]") then
        path = name
    else
        path = (LSM and LSM:Fetch("statusbar", name, true)) or WHITE8X8
    end
    barTexPathMemo[name] = path
    return path
end

-- Drop both media-path memos. Two triggers, and BOTH are required:
--
--  * LibSharedMedia_Registered — another addon registers media AFTER we cached
--    a miss. The user's chosen font/texture then exists, the options dropdown
--    shows it (Options.lua already refreshes on this event), but every render
--    would keep using the cached fallback: flat white bars / the default font,
--    with no error and no fix short of /reload.
--
--  * LibSharedMedia_SetGlobal — LSM:Fetch consults overrideMedia[mediatype]
--    BEFORE the requested key, so a global override makes EVERY key for that
--    media type resolve to the override. A per-key memo populated before the
--    override was set would silently ignore it.
--
-- Registered under a DEDICATED owner table, never HET: CallbackHandler stores
-- one callback per (event, owner), and Options.lua already registers
-- LibSharedMedia_Registered under HET — reusing it would silently replace the
-- options dropdown refresh.
local mediaCacheOwner = {}
function ns.WipeMediaPathCaches()
    wipe(barTexPathMemo)
    wipe(fontPathMemo)
end
if LSM and LSM.RegisterCallback then
    LSM.RegisterCallback(mediaCacheOwner, "LibSharedMedia_Registered",
        ns.WipeMediaPathCaches)
    LSM.RegisterCallback(mediaCacheOwner, "LibSharedMedia_SetGlobal",
        ns.WipeMediaPathCaches)
end

local STYLE_LABELS = { icon = "Icon", bar = "Bar", circle = "Circle", text = "Text",
                       texture = "Texture" }

-- Duration text formatter: without a
-- textFormatter, SetDurationText's engine default renders abbreviated
-- units ("5s"). This matches the Cooldown widget's native countdown
-- look instead: bare integer under a minute (rounded UP — shows "1"
-- until expiry, never "0"), then "2m"/"2h"/"2d". NumericRuleFormatter
-- breakpoints are evaluated ENGINE-side — works with secret durations,
-- no per-tick Lua.
local DURATION_RULE_FORMATTER
if C_StringUtil and C_StringUtil.CreateNumericRuleFormatter then
    local up      = Enum.NumericRuleFormatRounding and Enum.NumericRuleFormatRounding.Up or 1
    local nearest = Enum.NumericRuleFormatRounding and Enum.NumericRuleFormatRounding.Nearest or 0
    DURATION_RULE_FORMATTER = C_StringUtil.CreateNumericRuleFormatter()
    DURATION_RULE_FORMATTER:SetBreakpoints({
        { threshold = 0,     format = "%d",  step = 1, rounding = up },
        { threshold = 60,    format = "%dm", components = { { div = 60,    step = 1, rounding = nearest } } },
        { threshold = 3600,  format = "%dh", components = { { div = 3600,  step = 1, rounding = nearest } } },
        { threshold = 86400, format = "%dd", components = { { div = 86400, step = 1, rounding = nearest } } },
    })
end

local ACTIVE_FILTER = "HELPFUL"
-- Dormant parking. Two failed approaches, kept for the record:
--   * "HELPFUL|HARMFUL" filter strings are NOT impossible — polarity
--     tokens are a MASK, so parked slots/groups matched EVERYTHING
--     (ghost frames + secret-aura pool errors).
--   * candidateFilters { maxDuration = 0 } displays nothing, but a
--     DURATION comparison runs against SECRET durations in combat —
--     every aura application sprayed "attempted to store a secret
--     value in a SecureNumber" asserts from engine-side bookkeeping.
-- Dormancy therefore uses the SAME privileged mechanism as the live
-- path: a spellID include set (the security carve-out evaluates these
-- against secret auras natively) containing one ID that can never be
-- an aura. Filter strings stay "HELPFUL" forever; parking is expressed
-- ONLY through candidate include sets.
local DORMANT_CANDIDATES = { includeSpellIDs = { [599999] = true } }
ns.ACTIVE_FILTER = ACTIVE_FILTER
ns.DORMANT_CANDIDATES = DORMANT_CANDIDATES

-- Mutation restriction gate: container reconfiguration is DEFERRED
-- whenever it could collide with engine denials or secret-aura
-- processing — in combat, or while the client reports aura data as
-- secret. Inactive until the PLAYER_ENTERING_WORLD boundary (the login
-- init window must configure freely; Core flips ns.setupComplete).
-- Deferred work sets ns.deferredRefresh; Core re-runs a full refresh
-- when the restriction lifts (PLAYER_REGEN_ENABLED).
function ns.IsRestricted()
    if not ns.setupComplete then
        -- Login init window: configure freely — EXCEPT on a COMBAT
        -- reload. The window then overlaps live combat, aura data is
        -- already secret and the engine slot buttons are already
        -- forbidden objects (PTR-observed: SetPoint on a fresh slot
        -- button threw "access forbidden object" from the creation
        -- pass). Only the combat check applies here: the secret-state
        -- API can read true during loading screens, and honoring it
        -- pre-PEW would defer normal login creation forever (no
        -- PLAYER_REGEN_ENABLED ever fires out of combat).
        return InCombatLockdown()
    end
    if InCombatLockdown() then return true end
    return (C_Secrets and C_Secrets.ShouldAurasBeSecret
        and C_Secrets.ShouldAurasBeSecret()) and true or false
end

-- ONE shared container serves every single-aura slot: slot buttons
-- anchor to their element holders, so the container never lays them
-- out, and per-container aura-processing overhead stays constant no
-- matter how many trackers exist. Per-element containers are created
-- ONLY for group-capable trackers (flow layout is container-level, so
-- the multi-aura fan-out needs one per element).
local slotKeyCounter = 0
local function AcquireSlotKey()
    slotKeyCounter = slotKeyCounter + 1
    return "s" .. slotKeyCounter
end
-- One shared slot container PER UNIT (player / target / focus). All single-
-- aura trackers on a given unit share its container; the filter is per-slot
-- (AddAuraSlot arg), so HELPFUL / HARMFUL / HARMFUL|PLAYER slots coexist in
-- the same unit container. Only the UNIT partitions containers.
local slotContainers = {}
local function GetSlotContainer(unit)
    unit = unit or "player"
    local c = slotContainers[unit]
    if not c then
        c = CreateFrame("AuraContainer", nil, UIParent, "CustomAuraContainerTemplate")
        -- The container is never a mouse surface for us (tooltips live on
        -- the BUTTONS): kill mouse at creation. The template's default is
        -- undocumented, and the container flow layout auto-SetSizes the
        -- frame, so a mouse-enabled container would carry a real hit-rect.
        -- pcall'd: engine template, mutability class unverified.
        pcall(c.EnableMouse, c, false)
        if c.SetMouseMotionEnabled then
            pcall(c.SetMouseMotionEnabled, c, false)
        end
        c:SetUnit(unit)
        -- Containers created while a gate is active must be born hidden (lazy
        -- creation can run mid-vehicle, e.g. a reload-in-vehicle RefreshAll, or
        -- while the current target/focus is a friendly unit); the gate sweeps
        -- only touch EXISTING containers. Folds the global visual gate AND the
        -- per-unit assist gate (target/focus friendly).
        if ns.vehicleGateActive or ns.cinematicGateActive
            or ns.assistBlocked[unit] then
            pcall(c.SetAlpha, c, 0)
        end
        slotContainers[unit] = c
    end
    return c
end

-- Re-point every container bound to `unit` when that unit token repoints
-- (PLAYER_TARGET_CHANGED / PLAYER_FOCUS_CHANGED). The engine does NOT auto-
-- follow the token (Phase 0 finding); oUF re-SetUnit's on unit change. This
-- covers the shared slot container plus any per-element GROUP containers on
-- that unit. pcall'd: the re-point is combat-safe (spike-confirmed), but a
-- transient secret window must not throw. The `player` unit never repoints.
local function RepointUnit(unit)
    -- HOT PATH: PLAYER_TARGET_CHANGED fires on every target swap in combat.
    -- No shared container for this unit ⇒ no tracker has any element on it
    -- (CreateElement always builds the slot container first) ⇒ nothing to
    -- re-point: bail before touching any tracker.
    local c = slotContainers[unit]
    if not c then return end
    -- Direct pcalls (no closure allocation per call).
    if pcall(c.SetUnit, c, unit) then
        pcall(c.UpdateAllAuras, c)
    end
    local trackers = ns.trackers
    if trackers then
        for _, tracker in pairs(trackers) do
            if tracker.elements and tracker.GetTrackedUnit
                and tracker:GetTrackedUnit() == unit then
                for i = 1, MAX_ELEMENTS do
                    local e = tracker.elements[i]
                    if e and e.container then
                        if pcall(e.container.SetUnit, e.container, unit) then
                            pcall(e.container.UpdateAllAuras, e.container)
                        end
                    end
                end
            end
        end
    end
end
ns.RepointUnit = RepointUnit

-- Re-scan every live unit container. After a cinematic the player's faction
-- briefly flips; the tracked unit momentarily reads as non-assistable and the
-- engine AuraContainers flash stale/unfiltered auras until forced to re-scan.
-- Blizzard fires UNIT_FACTION("player") on the flip (and on restore); on it we
-- force UpdateAllAuras on every container we own (shared slot containers plus
-- per-element group containers).
--
-- Deliberately NOT RepointUnit: the unit token is unchanged, so SetUnit is
-- unnecessary — and SetUnit resets engine slot-button state, including the
-- mouse-enabled default. ApplyTooltip (the only thing that mouse-disables the
-- engine buttons) runs solely inside RefreshAll, so a SetUnit here left every
-- slot button eating clicks until the next full refresh (live regression,
-- 2026-08-13). UpdateAllAuras alone is the same rescan BuzzardFrames'
-- containers use for this fix, and it never touches button state.
local function RescanUnitContainers(unit)
    local c = slotContainers[unit]
    if not c then return end
    -- pcall'd: combat-safe, but a transient secret window must not throw.
    pcall(c.UpdateAllAuras, c)
    local trackers = ns.trackers
    if trackers then
        for _, tracker in pairs(trackers) do
            if tracker.elements and tracker.GetTrackedUnit
                and tracker:GetTrackedUnit() == unit then
                for i = 1, MAX_ELEMENTS do
                    local e = tracker.elements[i]
                    if e and e.container then
                        pcall(e.container.UpdateAllAuras, e.container)
                    end
                end
            end
        end
    end
end

local function RescanAllUnits()
    for unit in pairs(slotContainers) do
        RescanUnitContainers(unit)
    end
end
ns.RescanAllUnits = RescanAllUnits

-- Vehicle container gate. In a vehicle the engine feeds our AuraContainers
-- junk (black/secret icon art on slot buttons, duplicated group frames), and
-- candidate-filter parking does NOT clear what the engine already displays —
-- same failure class as the cinematic UNIT_FACTION junk and BuzzardFrames'
-- UnitIsVisible container gate, and the same fix: drop OUR containers' alpha
-- on a plain boolean while the state lasts, and rescan on recovery (Core owns
-- the exit-edge rescan). GLOBAL by design: the junk affects every tracker, so
-- this overrides per-tracker Not-in-Vehicle toggles for VISUALS only
-- (parking/sound still follow the toggles). Single-writer rule: shared slot
-- containers are alpha'd HERE only; per-element GROUP containers fold
-- ns.vehicleGateActive into the gating passes' alpha writes instead
-- (ReapplySlotGating / UpdateGating), so two writers never fight — that fight
-- caused a live regression (junk reappearing on combat flips in a vehicle).
-- Change-guarded (extra triggering events are free); returns true when the
-- state flipped. SetAlpha pcall'd: vehicle entry can happen in combat.
-- `state` override: the UNIT_*_VEHICLE events are authoritative but the
-- UnitInVehicle/UnitUsingVehicle APIs can LAG them by a beat — reading the API
-- inside the enter event returned false and the gate stayed open for a frame
-- or two (the enter-flash). Callers on a known edge pass the state explicitly;
-- nil = read the API (baseline/VEHICLE_UPDATE paths).
--
-- TWO gates now feed this: the vehicle gate and the cinematic gate
-- (ApplyCinematicGate below). They can overlap — a cinematic can fire while
-- the player is in a vehicle, and the vehicle exit can land inside the
-- cinematic's deferred-restore window — so neither may write slot alpha
-- directly: the second one to close would be undone by the first one to open.
-- Both set their own boolean and then call PushVisualGate, which writes the
-- UNION. Change-guarded on the union, so a gate closing under an
-- already-closed gate is a cheap no-op.
-- Per-unit ASSIST gate. A custom DEBUFF tracker binds its slot to target/focus
-- with a HARMFUL filter, but the engine's candidateFilters spellID filter is a
-- documented SECURITY CARVE-OUT: it is silently NOT applied to HARMFUL auras on
-- ASSISTABLE (friendly) units ("it shouldn't be possible to filter out debuffs
-- on player targets" — AuraContainer_API_Reference §candidateFilters). So when
-- the target/focus is friendly (a Delve companion, an escort NPC, a party
-- member), the engine ignores our spell-ID filter and dumps EVERY debuff on it
-- into the tracker. No filter-level fix is possible; the engine refuses the
-- filter. The one lever is the same one the vehicle/cinematic gates use for junk
-- that filtering can't clear: drop the CONTAINER's alpha while the state lasts.
--
-- Scope is exactly right at the SHARED slot-container level because slot
-- containers are partitioned by unit and buffs always track "player"
-- (GetTrackedUnit), so the "target"/"focus" containers hold ONLY harmful slots —
-- gating them hides debuff-on-friendly and nothing else. "player" is never
-- assistable-gated. This has no analogue elsewhere (nothing else tracks debuffs
-- on an arbitrary hostile target unit), so the approach is justified directly by
-- the engine carve-out above rather than by any existing pattern.
--
-- ns.assistBlocked[unit] == true  ⇒ unit is assistable ⇒ hide its debuff slots.
-- canaccess fail-OPEN: a secret UnitCanAssist read in a restricted window leaves
-- the unit UN-blocked (shows) rather than wrongly hiding a real enemy's debuffs;
-- the UNIT_FACTION / target-change edges re-run it from a safe context.
ns.assistBlocked = ns.assistBlocked or {}

-- Union of the global visual gate (vehicle/cinematic) and the per-unit assist
-- gate, for `unit`'s shared slot container. Player is never assist-gated.
local function SlotUnitHidden(unit)
    if ns.vehicleGateActive or ns.cinematicGateActive then return true end
    return ns.assistBlocked[unit] and true or false
end

-- SINGLE WRITER for shared slot-container alpha. Both the global visual gate
-- (PushVisualGate callers) and the per-unit assist gate (ApplyAssistGate) route
-- their slot-container alpha writes through here, per unit, so the two never
-- fight over the same container (the two-writer fight caused a live regression —
-- see the group-alpha invariant in UpdateGating/ReapplySlotGating). Group
-- containers are handled separately by those gating passes, which fold
-- ns.assistBlocked the same way they fold the vehicle/cinematic gates.
local slotUnitAlpha = {}
local function WriteSlotUnitAlpha(unit, c)
    local a = SlotUnitHidden(unit) and 0 or 1
    if slotUnitAlpha[unit] == a then return end
    slotUnitAlpha[unit] = a
    pcall(c.SetAlpha, c, a)
end

local function PushVisualGate()
    -- Global edge: re-evaluate every existing unit container's union.
    for unit, c in pairs(slotContainers) do
        WriteSlotUnitAlpha(unit, c)
    end
    return true
end

-- Recompute the assist gate for one unit and re-push its slot container alpha.
-- Change-guarded downstream (WriteSlotUnitAlpha), so a no-op edge is a table
-- read plus a compare. "player" short-circuits: it is never assistable-gated,
-- and no buff tracker's container should ever be touched by this path.
local function ApplyAssistGate(unit)
    if not unit or unit == "player" then return false end
    local blocked
    if UnitCanAssist then
        local r = UnitCanAssist("player", unit)
        -- Fail-open on a secret/inaccessible read: treat as NOT blocked.
        blocked = (canaccess(r) and r) and true or false
    else
        blocked = false
    end
    if (ns.assistBlocked[unit] or false) == blocked then return false end
    ns.assistBlocked[unit] = blocked
    local c = slotContainers[unit]
    if c then WriteSlotUnitAlpha(unit, c) end
    -- Group containers on this unit fold ns.assistBlocked in their gating pass;
    -- nudge the affected trackers to re-run it.
    local trackers = ns.trackers
    if trackers then
        for _, tracker in pairs(trackers) do
            if tracker.GetTrackedUnit and tracker:GetTrackedUnit() == unit
                and tracker.ReapplySlotGating then
                pcall(tracker.ReapplySlotGating, tracker)
            end
        end
    end
    return true
end
ns.ApplyAssistGate = ApplyAssistGate

local function ApplyVehicleGate(state)
    local inVeh
    if state == nil then
        inVeh = ((UnitInVehicle and UnitInVehicle("player"))
            or (UnitUsingVehicle and UnitUsingVehicle("player"))) and true or false
    else
        inVeh = state and true or false
    end
    if inVeh == (ns.vehicleGateActive or false) then return false end
    ns.vehicleGateActive = inVeh
    return PushVisualGate()
end
ns.ApplyVehicleGate = ApplyVehicleGate

-- Cinematic container gate. Same junk, EARLIER trigger. The pre-existing
-- cinematic handling was the UNIT_FACTION("player") rescan below, which is
-- inherently late twice over: it fires on the faction flip (already after the
-- engine has pushed junk to the containers), and a rescan REPAIRS junk rather
-- than preventing it — the visible symptom was a ~1s junk flash as a cinematic
-- starts. CINEMATIC_START / PLAY_MOVIE are the earliest signals the client
-- gives us, so we close the alpha gate on them and hold it closed across the
-- whole cinematic; Core owns the deferred exit-edge restore (rescan while
-- still invisible, exactly like the vehicle exit).
--
-- No cost concern: the gate is two boolean writes plus one SetAlpha per unit
-- container (three at most), on an event that fires a handful of times an
-- hour, and the UI is hidden for the duration anyway.
--
-- The UNIT_FACTION rescan is deliberately KEPT: it also covers plain PvP-flag
-- toggles, and it is the repair path if a cinematic form exists that fires
-- neither event.
local function ApplyCinematicGate(state)
    local on = state and true or false
    if on == (ns.cinematicGateActive or false) then return false end
    ns.cinematicGateActive = on
    return PushVisualGate()
end
ns.ApplyCinematicGate = ApplyCinematicGate

-- Park + hide an element's live regions so its slot/groups go inert. Used
-- when a debuff's UNIT changes: the slot can't move between the shared
-- per-unit containers, so we abandon the old element (its frames linger
-- hidden, exactly like deleting a tracker — the AuraContainer API has no
-- slot removal) and EnsureElements rebuilds it in the new unit's container.
-- All pcall'd: the setters run out of combat, but a transient secret window
-- must not throw.
local function ParkElement(e)
    if not e then return end
    if e.slotKey and e._unit then
        local sc = GetSlotContainer(e._unit)
        pcall(sc.SetAuraSlotCandidateFilters, sc, e.slotKey, DORMANT_CANDIDATES)
        pcall(sc.UpdateAllAuras, sc) -- re-scan so the old slot clears now
    end
    if e.button then pcall(e.button.Hide, e.button) end
    if e.container then
        -- Park every group's candidates (match nothing) AND disable/hide the
        -- per-element container — belt + suspenders if SetEnabled is denied.
        pcall(e.container.SetAuraGroupCandidateFilters, e.container, "maingroup", DORMANT_CANDIDATES)
        for g = 1, (e.groupCapacity or 0) do
            pcall(e.container.SetAuraGroupCandidateFilters, e.container, "g" .. g, DORMANT_CANDIDATES)
        end
        if e.hasTail then
            pcall(e.container.SetAuraGroupCandidateFilters, e.container, "gtail", DORMANT_CANDIDATES)
        end
        pcall(e.container.UpdateAllAuras, e.container)
        pcall(e.container.SetEnabled, e.container, false)
        pcall(e.container.Hide, e.container)
    end
    if e.holder then
        -- Mouse off too: an abandoned holder is hidden, but if anything
        -- ever re-shows it, it must not come back as a click-eater (see
        -- the UpdateGating self-heal).
        pcall(e.holder.EnableMouse, e.holder, false)
        pcall(e.holder.Hide, e.holder)
    end
    -- A parked element is ABANDONED — EnsureElements builds a fresh one rather
    -- than reviving this. Its frames linger hidden, so anything of ours that
    -- keeps being TOUCHED after parking has to be retired here or it leaks for
    -- the session: the GCD widgets out of the re-arm registry (which is walked
    -- on every cast), and the icon-effect animation groups, which the engine
    -- steps once per rendered frame for as long as they play.
    if ns.RetireGcdWidgets then ns.RetireGcdWidgets(e) end
    if e.button and ns.StopIconEffectOn then ns.StopIconEffectOn(e) end
    if e.groupButtons then
        for _, b in pairs(e.groupButtons) do
            if b and b._het_r and ns.StopIconEffectOn then
                ns.StopIconEffectOn(b._het_r)
            end
        end
    end
end

-- Grow direction → container flow layout mapping.
-- AnchorUtil.FlowLayoutAxis = { Horizontal=0, Vertical=1 };
-- AnchorUtil.FlowDirection = { Left=-1, Right=1, Up=1, Down=-1 }.
-- PTR-VERIFY: anchor-point token + growth pairing renders as intended
-- for all four directions.
local GROW = {
    right = { axis = 0, anchor = "TOPLEFT",    h =  1, v = -1 },
    left  = { axis = 0, anchor = "TOPRIGHT",   h = -1, v = -1 },
    down  = { axis = 1, anchor = "TOPLEFT",    h =  1, v = -1 },
    up    = { axis = 1, anchor = "BOTTOMLEFT", h =  1, v =  1 },
}

-- Bars only grow perpendicular to their fill: horizontal bars stack
-- down/up, vertical bars stack left/right. Coerces stored values that
-- don't fit the current orientation (e.g. after an orientation or style
-- switch). Icons allow all four directions.
local function EffectiveGrowDirection(el)
    local dir = el and el.growDirection
    if el and el.style == "bar" then
        if el.barOrientation == "vertical" then
            if dir ~= "left" and dir ~= "right" then dir = "right" end
        else
            if dir ~= "down" and dir ~= "up" then dir = "down" end
        end
    end
    return dir or "right"
end
ns.EffectiveGrowDirection = EffectiveGrowDirection

-- Media shared by the rounded border (icons and bars): white frame art
-- plus the rounded-rect mask that keeps flat fills and icon textures
-- inside the corner radius. Blizzard ships no vertical bar art, hence
-- the addon's own assets. BARS nine-slice the small Bar* files (fixed
-- radius at any aspect ratio);
-- ICONS stretch UNSLICED, because the mask file doubles as the
-- Cooldown SWIPE texture and SetSwipeTexture always stretches — the
-- radius must scale identically across mask, frame art and swipe.
-- Icons therefore get their OWN 256px renders of the same 32px design
-- (Icon* files): stretching the 32px art UP past ~40px reads blurry,
-- while high-res art downscales crisp (high-res icon border art at
-- 172px for ~50px icons for the same reason).
-- Rounded (Thick): ~1px heavier ring. Bars swap to 3px-ring border art
-- with a deeper-inset mask (sliced, so thickness stays constant). Icons
-- REUSE the same mask -- the visible side thickness is set by how far
-- outside the icon the frame art is drawn (2px instead of 1px, see
-- ApplyRoundedIcon) -- and only the ring art itself is swapped.
-- Default border tint for every icon/bar border (rounded ring + square edges)
-- when an element carries no explicit borderColor/barIconBorderColor. #D0D0D0 @
-- 85% — matches the Options element-default seed so new and legacy elements look
-- identical.
local DEFAULT_BORDER_COLOR = { 0.815686, 0.815686, 0.815686, 0.85 }

-- Read-only white, the fallback for every optional element colour. Same
-- discipline as DEFAULT_BORDER_COLOR: consumers only ever READ indices 1..4 and
-- never store them back into the DB, so one shared table serves everything. DO
-- NOT hand this to anything that could mutate or retain it.
-- EnsureTexts builds default text entries with NO `color` field, so an element
-- the user has never recoloured -- i.e. the default state -- hit `or { … }` on
-- every render, per element, per event.
local WHITE_RGBA = { 1, 1, 1, 1 }

-- Shared two-slot scratch for the `regions` array the rounded-border helpers
-- take. ApplyRoundPreview copies each entry into rr.applied and never keeps the
-- array, so reusing one table is safe. BOTH slots are always assigned (the
-- second to nil when unused) so the table never carries a stale trailing entry.
-- Never nested: the call sites (RenderCustomArt, RenderIDSample,
-- ApplyBarRectBorder, RenderIDBar's icon square) are siblings, never on one
-- another's stack.
local SCRATCH_REGIONS = {}

-- Shared scratch for the "slot containers that changed" set the three gating
-- passes build before draining it into UpdateAllAuras. One table, because the
-- three uses are provably never on one another's stack: ReapplySlotGating and
-- UpdateGating each call GatePairSlots only on an EARLY-RETURN path, before
-- they build a set of their own, and nothing inside the loops re-enters a
-- gating pass (the engine calls take no Lua callback). Wiped at entry AND
-- after the drain — a shared table must not keep AuraContainer frames alive
-- between passes, including containers for units the player has stopped
-- tracking. (UpdateSpellFilter keeps its own local table and is not a user.)
-- Worth doing because the set is almost always EMPTY: the slot write above it
-- is change-guarded, so on a routine fan-out this allocated one table per
-- aura-kind tracker purely to iterate nothing.
local SCRATCH_TOUCHED = {}

local ROUND_BORDER_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\BarBorder"
local ROUND_MASK_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\BarMask"
local ROUND_BORDER_THICK_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\BarBorderThick"
local ROUND_MASK_THICK_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\BarMaskThick"
local ICON_ROUND_BORDER_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\IconBorder"
local ICON_ROUND_BORDER_THICK_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\IconBorderThick"
local ICON_ROUND_MASK_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\IconMask"
-- Hard-edged ring swipe textures for the icon pair's "Animate Border":
-- IconRingRound_T (rounded-rect, corner radius matched to IconMask) and
-- IconRingSquare_T, T = 1..6 (thickness in texture px at 64 — reads ~1:1
-- at 64px icons, thinner at smaller). HARD alpha on purpose: the Cooldown
-- swipe SOLIDIFIES the texture's alpha into the swipe color, so the
-- soft-glow IconBorder art read far too thick (and Rounded vs Thick
-- looked identical) when used as a swipe — owner-observed.
local PAIR_RING_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\IconRing"
local function PairRingTexture(pbt, t)
    t = math.floor(math.min(6, math.max(1, t or 2)))
    local fam = (pbt == "square" or pbt == "none") and "Square" or "Round"
    return PAIR_RING_TEX .. fam .. "_" .. t
end

-- Both rounded variants share every code path; only art + the icon
-- frame offset differ.
local function IsRounded(bt)
    return bt == "rounded" or bt == "rounded_thick"
end
ns.IsRoundedBorder = IsRounded

-- Border type, coerced to one the element's style actually offers. Only
-- the Icon type has "blizzard" (stock icon art, no drawn frame); a bar
-- carrying that value -- from the shared element defaults or a style
-- switch -- reads as "rounded", which is its default.
-- CENTER offset of a w x h rect anchored at `point` + (ox, oy) on a
-- frame, so the rounded frame art can be centred on the same rect
-- without anchoring to the mask (or to the texture being masked).
local function CenterOffset(point, ox, oy, w, h)
    local cx, cy = ox, oy
    if point:find("LEFT") then cx = ox + w / 2
    elseif point:find("RIGHT") then cx = ox - w / 2 end
    if point:find("TOP") then cy = oy - h / 2
    elseif point:find("BOTTOM") then cy = oy + h / 2 end
    return cx, cy
end

local function BorderType(style, el)
    local bt = el and el.borderType
    if bt == nil then return style == "icon" and "blizzard" or "rounded" end
    if bt == "blizzard" and style ~= "icon" then return "rounded" end
    return bt
end
ns.BorderType = BorderType

-------------------------------------------------------------------------------
-- Shared button region construction + styling (slot buttons and pooled
-- group buttons carry identical region sets).
-------------------------------------------------------------------------------
-- Text anchor grid for icon-parented duration/stack text. Bars keep
-- their own 3-position (left/center/right) scheme; icons need corners so
-- a countdown and a stack count can coexist on one square.
local TEXT_ANCHOR = {
    topleft     = { "TOPLEFT",      2, -2 },
    top         = { "TOP",          0, -2 },
    topright    = { "TOPRIGHT",    -2, -2 },
    left        = { "LEFT",         2,  0 },
    center      = { "CENTER",       0,  0 },
    right       = { "RIGHT",       -2,  0 },
    bottomleft  = { "BOTTOMLEFT",   2,  2 },
    bottom      = { "BOTTOM",       0,  2 },
    bottomright = { "BOTTOMRIGHT", -2,  2 },
}

-- MEMOIZED by name (see BarTexturePath). Reached from every text region restyle
-- and from StyleIDCooldownText, i.e. per cooldown widget per sample per event.
local function FontPath(name)
    if not name then return TEXT_FONT end
    local hit = fontPathMemo[name]
    if hit then return hit end
    local path = (LSM and LSM:Fetch("font", name)) or TEXT_FONT
    fontPathMemo[name] = path
    return path
end

-- Font + anchor one of the bound FontStrings against `parent`.
local function ApplyAnchoredText(fs, parent, font, size, pos, color, border)
    fs:SetFont(FontPath(font), size or 12, border or "OUTLINE")
    local c = color or WHITE_RGBA
    fs:SetTextColor(c[1], c[2], c[3], c[4])
    fs:ClearAllPoints()
    local a = TEXT_ANCHOR[pos] or TEXT_ANCHOR.center
    fs:SetPoint(a[1], parent, a[1], a[2], a[3])
    fs:Show()
end

-- Bar-parented text: 3 positions ALONG the fill. Horizontal bars run
-- left/center/right, vertical bars bottom/center/top.
local function ApplyBarText(fs, bar, font, size, pos, vertical, color, border)
    fs:SetFont(FontPath(font), size or 12, border or "OUTLINE")
    local c = color or WHITE_RGBA
    fs:SetTextColor(c[1], c[2], c[3], c[4])
    fs:ClearAllPoints()
    if vertical then
        if pos == "bottom" then
            fs:SetPoint("BOTTOM", bar, "BOTTOM", 0, 4)
        elseif pos == "top" then
            fs:SetPoint("TOP", bar, "TOP", 0, -4)
        else
            fs:SetPoint("CENTER", bar, "CENTER")
        end
    else
        if pos == "left" then
            fs:SetPoint("LEFT", bar, "LEFT", 4, 0)
        elseif pos == "right" then
            fs:SetPoint("RIGHT", bar, "RIGHT", -4, 0)
        else
            fs:SetPoint("CENTER", bar, "CENTER")
        end
    end
    fs:Show()
end

-- Bar fill source: ONE StatusBar, bound to EITHER the duration timer or
-- the stack count — the two are separate engine bindings and cannot both
-- drive the same bar. Binding a FRESH button is the call class the engine
-- already permits from initializeFrame (BuildButtonRegions has always
-- bound SetDurationBar there); REbinding an existing button only ever
-- happens from the settings path via UpdateAuraBindings, which is
-- ns.IsRestricted-guarded. Change-guarded so refreshes are free.
-- PTR-VERIFY: SetApplicationBar fill proportion honors maxApplications,
-- and ClearDurationBar/ClearApplicationBar exist (pcall'd — a missing
-- Clear just means the opposite Set replaces the binding outright).
local function ApplyBarBinding(r, button, el, allowStacks)
    -- Stacks need ONE known buff: a multi-aura fan-out gets secret
    -- engine-assigned auras per frame and has no per-buff maximum, so it
    -- always falls back to duration no matter what the profile stores.
    local wantStacks = (allowStacks and el and el.style == "bar"
        and el.trackedProperty == "stacks") and true or false
    local maxApp = (el and el.maxStacks) or 10
    local mode = wantStacks and "stacks" or "duration"
    if r._barMode == mode and (not wantStacks or r._barMax == maxApp) then
        return false
    end
    local ok
    if wantStacks then
        if button.ClearDurationBar then pcall(button.ClearDurationBar, button) end
        ok = pcall(button.SetApplicationBar, button, r.bar, { maxApplications = maxApp })
    else
        if button.ClearApplicationBar then pcall(button.ClearApplicationBar, button) end
        ok = pcall(button.SetDurationBar, button, r.bar, {
            direction = Enum.StatusBarTimerDirection
                and Enum.StatusBarTimerDirection.RemainingTime or 1,
        })
    end
    if ok then
        r._barMode, r._barMax = mode, wantStacks and maxApp or nil
    else
        -- NEVER cache a denied binding: the change-guard above would
        -- short-circuit every retry and the bar would stay unbound until
        -- /reload. Forget the mode and let the deferred refresh re-run
        -- this from a safe context (SafeGroupCall's Finish pattern).
        r._barMode, r._barMax = nil, nil
        ns.deferredRefresh = true
    end
    return true
end

-- Text elements per display element. Each entry is
--   { type = "duration"|"stacks"|"name",
--     stackDisplay = "current"|"currentmax"|"max",   -- stacks only
--     font, fontSize, position }
-- and each TYPE maps to one engine binding. A binding drives exactly ONE
-- FontString (a second SetX REPLACES the first), so there can be at most
-- one entry per type — Options enforces that in the type dropdown. This
-- is also why the stack FORMAT is a sub-option rather than its own type.
local TEXT_BINDING = {
    duration = "duration",
    stacks   = "stacks",
    name     = "name",
}
ns.TEXT_BINDING = TEXT_BINDING

-- Font size for a text entry that carries none. The Text style is
-- nothing BUT text, so it reads large by default; on an icon the
-- countdown is the primary readout and the stack count is secondary.
local function DefaultTextSize(style, binding)
    if style == "text" then return 24 end
    if style == "icon" and binding == "duration" then return 14 end
    return 12
end
ns.DefaultTextSize = DefaultTextSize

-- Whether a stacks text hides itself at a single application. Stored
-- per-entry as an explicit true/false; unset means "never touched" and
-- falls back to the style default -- on for icon and text, off for bar.
local function HideBelow2(style, t)
    if t and t.hideBelow2 ~= nil then return t.hideBelow2 end
    return style ~= "bar"
end
ns.HideBelow2 = HideBelow2

-- Lazy migration of the fixed bar/icon text keys into the texts array.
-- Self-healing (runs on first access) so Core's profile migration needs
-- no element-key enumeration.
local function EnsureTexts(el)
    if type(el.texts) == "table" then
        -- The stacksmax TYPE was folded into stacks + stackDisplay.
        for j = 1, #el.texts do
            local t = el.texts[j]
            if t.type == "stacksmax" then
                t.type, t.stackDisplay = "stacks", "currentmax"
            end
        end
        return el.texts
    end
    -- Only icon/bar/text render text. Never CACHE an empty array on a
    -- style-less element: it would freeze in place and the migration
    -- would never run once a style was chosen.
    if el.style ~= "bar" and el.style ~= "icon" and el.style ~= "text" then
        return {}
    end
    local t = {}
    if el.style == "bar" then
        if el.barShowText ~= false then
            t[#t + 1] = { type = "duration", font = el.barFont,
                fontSize = el.barFontSize or 12,
                position = el.barTextPosition or "center" }
        end
        if el.barShowStackText then
            t[#t + 1] = { type = "stacks", stackDisplay = "current",
                font = el.barStackFont, fontSize = el.barStackFontSize or 12,
                position = el.barStackTextPosition or "right" }
        end
    elseif el.style == "icon" then
        if el.iconShowDurText ~= false then
            t[#t + 1] = { type = "duration", font = el.iconDurFont,
                fontSize = el.iconDurFontSize or 14,
                position = el.iconDurTextPosition or "center" }
        end
        if el.iconShowStackText ~= false then
            t[#t + 1] = { type = "stacks", stackDisplay = "current",
                font = el.iconStackFont, fontSize = el.iconStackFontSize or 12,
                position = el.iconStackTextPosition or "bottomright" }
        end
    elseif el.style == "text" then
        -- A bare Text tracker starts as a single centered countdown; the
        -- user adds stacks/name entries from Options like any other style.
        t[#t + 1] = { type = "duration", fontSize = 24, position = "center" }
    end
    el.texts = t
    return t
end
ns.EnsureTexts = EnsureTexts

-- The Text style has no art to size its button from, so the hit/anchor
-- rect follows the largest text entry sitting on it.
local function LargestTextSize(el)
    local texts, sz = EnsureTexts(el), 0
    for j = 1, #texts do
        local t = texts[j]
        if t and t.show ~= false then
            local s = t.fontSize or DefaultTextSize(el.style, TEXT_BINDING[t.type])
            if s > sz then sz = s end
        end
    end
    return sz > 0 and sz or 24
end

-- Which binding each element currently wants, and (for stacks) in which
-- format. Returns durationOn, stacksMode, nameOn. stacksMode is the
-- formatter cache key: "s|" (always show) or "h|" (hide below 2
-- applications) followed by the format string, e.g. "s|%d", "h|%d/5".
local function TextModes(el)
    local durationOn, stacksMode, nameOn = false, nil, false
    if not el then return durationOn, stacksMode, nameOn end
    -- Only icon/bar/text render text; every other style leaves all three
    -- FontStrings dark.
    if el.style ~= "icon" and el.style ~= "bar" and el.style ~= "text" then
        return durationOn, stacksMode, nameOn
    end
    local texts = EnsureTexts(el)
    if type(texts) ~= "table" then return durationOn, stacksMode, nameOn end
    for j = 1, #texts do
        local t = texts[j]
        if t and t.show ~= false then
            if t.type == "duration" then durationOn = true
            elseif t.type == "stacks" then
                local d = t.stackDisplay or "current"
                local fmt
                if d == "currentmax" then
                    fmt = "%d/" .. (el.maxStacks or 10)
                elseif d == "max" then
                    fmt = tostring(el.maxStacks or 10)
                else
                    fmt = "%d"
                end
                -- "h|" = hide below 2 applications, "s|" = always show.
                stacksMode = (HideBelow2(el.style, t) and "h|" or "s|") .. fmt
            elseif t.type == "name" then nameOn = true end
        end
    end
    return durationOn, stacksMode, nameOn
end

-- "3/5" formatters, cached per maximum. A NumericRuleFormatter's `format`
-- string takes literal text around the number — the same mechanism as
-- the addon's own "%dm"/"%dh" breakpoints and the format="" hide rule — so the
-- user's Max Stacks is baked in at config time and the engine renders the
-- whole string. PTR-VERIFY: SetApplicationCount honors a rule formatter
-- the way SetDurationText does, and supplying one DISABLES the built-in
-- "hide below 2 applications" rule (expected: "1/5" shows).
-- Cached per format string + hide rule. "%d/5" renders "3/5"; a format
-- with NO placeholder ("5") renders that literal whatever the count is,
-- which is how the Max-only display works.
--
-- SUPPLYING a formatter is also what makes a stack count render at ONE
-- application: the engine's built-in "hide below 2" rule only applies to
-- the BARE binding. To get that rule back we express it as a breakpoint
-- instead — an empty format below 2, the real one from 2 up. Same
-- mechanism as a hide-duration-above-threshold rule, just at the bottom
-- of the range.
local stackFormatters = {}
local function StackFormatter(fmt, hideBelow2)
    if not (C_StringUtil and C_StringUtil.CreateNumericRuleFormatter) then
        return nil
    end
    local key = (hideBelow2 and "h|" or "s|") .. fmt
    local f = stackFormatters[key]
    if not f then
        local nearest = Enum.NumericRuleFormatRounding
            and Enum.NumericRuleFormatRounding.Nearest or 0
        f = C_StringUtil.CreateNumericRuleFormatter()
        if hideBelow2 then
            f:SetBreakpoints({
                { threshold = 0, format = "" },
                { threshold = 2, format = fmt, step = 1, rounding = nearest },
            })
        else
            f:SetBreakpoints({
                { threshold = 0, format = fmt, step = 1, rounding = nearest },
            })
        end
        stackFormatters[key] = f
    end
    return f
end

-- Stack-count binding. Mode-dependent (the maximum is compiled INTO the
-- formatter), so it rebinds exactly like the bar when Max Stacks or the
-- text type changes. A DENIAL is never cached, or the FontString would
-- show blank forever; retried from UpdateAuraBindings.
local function ApplyStackBinding(r, button, el)
    local _, stacksMode = TextModes(el)
    -- Nothing shown => a plain always-on count; it costs nothing and
    -- avoids a rebind when the user adds a stack text back.
    local mode = stacksMode or "s|%d"
    if r._stackMode == mode then return false end
    local f = StackFormatter(mode:sub(3), mode:sub(1, 2) == "h|")
    local opts = f and { formatter = f } or nil
    local ok = pcall(button.SetApplicationCount, button, r.stackText, opts)
    if ok then
        r._stackMode, r._stackBound = mode, true
    else
        -- Never cache a denial (the guard above would skip every retry)
        -- but do NOT clear _stackBound: an earlier successful bind is
        -- still live and still driving the FontString, so only the FORMAT
        -- is stale. Clearing it would blank the text entirely.
        r._stackMode = nil
        ns.deferredRefresh = true
    end
    return ok
end

-- Default Threshold Colors (orange then red).
local DEFAULT_THRESHOLD_COLOR  = { 1, 0.5, 0, 1 }
local DEFAULT_THRESHOLD2_COLOR = { 1, 0,   0, 1 }

-- The duration text entry for an element (the one whose color the
-- threshold curve drives), or nil.
local function DurationTextEntry(el)
    local texts = el and EnsureTexts(el)
    if type(texts) ~= "table" then return nil end
    -- Last-wins, matching RenderTexts: the duration binding owns one
    -- FontString, so the last enabled duration entry is the one shown.
    local found
    for j = 1, #texts do
        local t = texts[j]
        if t and t.type == "duration" and t.show ~= false then found = t end
    end
    return found
end

-- Build an engine color curve (evaluated ENGINE-side against the secret
-- remaining duration — no per-tick Lua) from a duration text entry's
-- Threshold Colors settings. Builds a threshold color curve:
-- Step curve, base (above-threshold) color at the top, threshold color(s)
-- below. Returns nil when disabled or the API is unavailable.
local function BuildDurationThresholdCurve(t)
    if not (t and t.thresholdColorEnabled) then return nil end
    if not (C_CurveUtil and C_CurveUtil.CreateColorCurve and Enum and Enum.LuaCurveType) then
        return nil
    end
    local base = t.color            or { 1, 1, 1, 1 }
    local c1   = t.thresholdColor   or DEFAULT_THRESHOLD_COLOR
    local t1   = t.thresholdSeconds or 8
    -- Secondary color/seconds default so enabling the
    -- secondary threshold works even before the user touches those widgets.
    -- Keep it strictly below the primary threshold.
    local c2   = t.threshold2Color   or DEFAULT_THRESHOLD2_COLOR
    local t2   = t.threshold2Seconds or 4
    if t2 >= t1 then t2 = math.max(1, t1 - 1) end
    local curve = C_CurveUtil.CreateColorCurve()
    curve:SetType(Enum.LuaCurveType.Step)
    if t.threshold2Enabled and t2 < t1 then
        curve:AddPoint(0,  CreateColor(c2[1], c2[2], c2[3], c2[4] or 1))
        curve:AddPoint(t2, CreateColor(c1[1], c1[2], c1[3], c1[4] or 1))
        curve:AddPoint(t1, CreateColor(base[1], base[2], base[3], base[4] or 1))
    else
        curve:AddPoint(0,  CreateColor(c1[1], c1[2], c1[3], c1[4] or 1))
        curve:AddPoint(t1, CreateColor(base[1], base[2], base[3], base[4] or 1))
    end
    return curve
end

-- Change signature for the duration binding: rebind only when the formatter
-- or the threshold curve inputs actually change (mirrors the stack binding's
-- mode cache — avoids re-issuing SetDurationText on every unrelated refresh).
local function DurationBindSignature(t)
    if not (t and t.thresholdColorEnabled) then return "off" end
    local base = t.color          or { 1, 1, 1, 1 }
    local c1   = t.thresholdColor or DEFAULT_THRESHOLD_COLOR
    local sig = table.concat({
        "on", t.thresholdSeconds or 8,
        c1[1], c1[2], c1[3], c1[4] or 1,
        base[1], base[2], base[3], base[4] or 1,
    }, ":")
    if t.threshold2Enabled then
        local c2 = t.threshold2Color or DEFAULT_THRESHOLD2_COLOR
        sig = sig .. "|2:" .. table.concat({
            t.threshold2Seconds or 4, c2[1], c2[2], c2[3], c2[4] or 1,
        }, ":")
    end
    return sig
end

-- Duration-text binding: engine formatter + optional Threshold Colors curve.
-- Rebinds when the signature changes so live Threshold Colors edits take
-- effect (called from UpdateAuraBindings, the settings pipeline); a denial is
-- retried rather than aborting region construction.
local function ApplyDurationTextBinding(r, button, el)
    local t = DurationTextEntry(el)
    local sig = DurationBindSignature(t)
    if r._durBound and r._durSig == sig then return false end

    local opts
    if DURATION_RULE_FORMATTER then
        opts = { textFormatter = DURATION_RULE_FORMATTER }
    end
    local curve = BuildDurationThresholdCurve(t)
    if curve and Enum.DurationTextBindingProperty then
        opts = opts or {}
        opts.textColor = {
            curve = curve,
            property = Enum.DurationTextBindingProperty.RemainingDuration,
        }
    end

    local ok = pcall(button.SetDurationText, button, r.durText, opts)
    if ok then
        r._durBound = true
        r._durSig = sig
    else
        ns.deferredRefresh = true
    end
    return ok
end

-- Spell-name binding. No options and no modes, so it binds once; a
-- denial is retried from UpdateAuraBindings.
local function ApplyNameBinding(r, button)
    if r._nameBound then return false end
    local ok = pcall(button.SetSpellName, button, r.nameText)
    if ok then
        r._nameBound = true
    else
        ns.deferredRefresh = true
    end
    return ok
end

-- Per-binding default placement, by anchoring scheme.
local TEXT_DEFAULT_POS = {
    grid  = { duration = "center", stacks = "bottomright", name = "top" },
    barh  = { duration = "center", stacks = "right",       name = "left" },
    barv  = { duration = "center", stacks = "top",         name = "bottom" },
}
local function TextDefaults(useGrid, vertical)
    if useGrid then return TEXT_DEFAULT_POS.grid end
    return vertical and TEXT_DEFAULT_POS.barv or TEXT_DEFAULT_POS.barh
end

-- A position stored under the OTHER anchoring scheme (the element was
-- switched icon <-> bar) must not fall through to ApplyBarText's CENTER
-- default — that would silently stack two texts on top of each other
-- while the options dropdown still reads the old value.
local function CoercePos(pos, fallback, useGrid, vertical)
    if useGrid then
        return TEXT_ANCHOR[pos] and pos or fallback
    end
    if vertical then
        if pos == "bottom" or pos == "center" or pos == "top" then return pos end
    elseif pos == "left" or pos == "center" or pos == "right" then
        return pos
    end
    return fallback
end

-- Font + place every configured text entry. Each binding owns ONE
-- FontString, so at most one entry per binding can render; the Options
-- type dropdown enforces that, and this loop is simply last-wins if a
-- hand-edited profile disagrees. `useGrid` picks the 9-point icon anchor
-- scheme over the bar's left/center/right.
local function RenderTexts(r, el, parent, useGrid)
    local texts = EnsureTexts(el)
    local vertical = (not useGrid) and el.barOrientation == "vertical"
    local defs = TextDefaults(useGrid, vertical)
    for j = 1, #texts do
        local t = texts[j]
        if t and t.show ~= false then
            local binding = TEXT_BINDING[t.type]
            local fs, bound
            if binding == "duration" then
                fs, bound = r.durText, r._durBound
            elseif binding == "stacks" then
                fs, bound = r.stackText, r._stackBound
            elseif binding == "name" then
                fs, bound = r.nameText, r._nameBound
            end
            if fs and bound then
                local size = t.fontSize or DefaultTextSize(el.style, binding)
                local pos = CoercePos(t.position or defs[binding],
                    defs[binding], useGrid, vertical)
                if useGrid then
                    ApplyAnchoredText(fs, parent, t.font, size, pos, t.color, t.fontBorder)
                else
                    ApplyBarText(fs, parent, t.font, size, pos, vertical, t.color, t.fontBorder)
                end
            end
        end
    end
end

-- Icon Source override for a slot button's icon texture. "auto" (nil override)
-- keeps the engine-bound aura icon; a custom texture unbinds the region
-- (SetIcon(nil)) and draws the chosen texture — a solid-icon technique
-- (applied at frame init so the unbind
-- sticks across buff re-shows). Applied at button INIT (BuildButtonRegions) and
-- re-asserted on style + the out-of-combat refresh (UpdateGating) so a later
-- Icon Source edit takes effect. Single-slot only: a group's fan-out buttons keep
-- each matched aura's own icon. pcall-guarded; the aura button is forbidden in
-- combat, so callers gate this to unrestricted windows.
-- Re-assert the custom icon TEXTURE on a slot whose region is already unbound
-- (BuildButtonRegions / RebindSlotIcon own the SetIcon bind decision — the region
-- is never engine-bound while an override is active, so nothing re-stomps it and
-- there's no SetIcon to toggle here). This just keeps the texture set after a
-- style pass re-touches the region (texcoord/mask). No-op for Auto.
local function ApplyIconOverride(r, button, el, isSlot)
    if not (r and r.icon) then return end
    local override = isSlot and ns.ResolveAuraIcon(el) or nil
    if override then
        r._iconOverridden = true
        r.icon:SetTexture(override)
    end
end

-- Unrestricted context ONLY (ns.IsRestricted false): creates and binds
-- all display regions on a button. `el` is the element config the button
-- belongs to — only the initial bar binding reads it; `allowStacks` is
-- false for multi-aura fan-out buttons.
local function BuildButtonRegions(button, el, allowStacks)
    local r = {}

    -- Icon Source binding. KEY: when a custom icon is active on the single
    -- slot, the shown region must NEVER be passed to SetIcon — the solid icon
    -- skips SetIcon(icon) entirely, so the engine has nothing to overwrite on a
    -- buff refresh (binding then unbinding did NOT hold; the engine re-stomped
    -- on reassignment).
    --
    -- To switch Auto↔custom live WITHOUT leaking a texture per toggle, a slot keeps
    -- TWO permanent regions: `_iconBoundRegion` is passed to SetIcon ONCE (engine
    -- draws the aura icon here, for Auto) and `_iconOverrideRegion` is NEVER bound
    -- (holds the custom texture). r.icon points at the ACTIVE one; the other is
    -- hidden. RebindSlotIcon just flips which is shown — no new textures ever.
    -- Group buttons (allowStacks false) never override, so they use a single bound
    -- region, keeping each matched aura's own icon.
    local override = allowStacks and ns.ResolveAuraIcon(el) or nil
    local bound = button:CreateTexture(nil, "ARTWORK")
    bound:SetAllPoints(button)
    bound:Hide()
    button:SetIcon(bound)
    r._iconBoundRegion = bound
    if allowStacks then
        local ov = button:CreateTexture(nil, "ARTWORK")
        ov:SetAllPoints(button)
        ov:Hide()
        r._iconOverrideRegion = ov
    end
    if override then
        r._iconOverridden = true
        r._iconOverrideRegion:SetTexture(override)
        r.icon = r._iconOverrideRegion
    else
        r.icon = bound
    end

    local ringBg = button:CreateTexture(nil, "BACKGROUND")
    ringBg:SetAllPoints(button)
    ringBg:Hide()
    r.ringBg = ringBg

    local bar = CreateFrame("StatusBar", nil, button)
    bar:SetAllPoints(button)
    -- Blizzard technique (same as the parent-level aura cooldowns):
    -- run the StatusBar at the BUTTON's
    -- own frame level so the button's OVERLAY regions (duration text,
    -- square border edges) layer-sort ABOVE the ARTWORK bar fill. At
    -- the default child level (+1) the fill covers them both.
    bar:SetFrameLevel(button:GetFrameLevel())
    bar:SetStatusBarTexture(WHITE8X8)
    local barBg = bar:CreateTexture(nil, "BACKGROUND")
    barBg:SetAllPoints(bar)
    barBg:SetColorTexture(0, 0, 0, 0.5)
    bar:Hide()
    r.bar = bar
    ApplyBarBinding(r, button, el, allowStacks)

    local cooldown = CreateFrame("Cooldown", nil, button, "CooldownFrameTemplate")
    cooldown:SetAllPoints(button)
    cooldown:SetReverse(true)
    cooldown:SetDrawEdge(false)
    -- Countdown numbers OFF at CREATION, before the binding.
    -- The template defaults them ON, and duration renders through the
    -- bound durText FontString instead; relying on the style pass alone
    -- meant any denied/failed ApplyStyle left engine-driven buttons
    -- showing BOTH texts (PTR-observed double duration text).
    cooldown:SetHideCountdownNumbers(true)
    button:SetDurationCooldown(cooldown)
    r.cooldown = cooldown

    -- Text holder. The Cooldown is a CHILD FRAME of the button, so its
    -- swipe out-sorts every region on the button itself no matter what
    -- draw layer they use — FontStrings parented to the button render
    -- BEHIND the swipe. The fix is a
    -- dedicated holder one level above the cooldown; within that holder,
    -- FontStrings draw above textures, so the text stays on top.
    -- Still a descendant of the button, which the engine bindings
    -- require.
    local textFrame = CreateFrame("Frame", nil, button)
    textFrame:SetAllPoints(button)
    textFrame:SetFrameLevel(cooldown:GetFrameLevel() + 1)
    r.textFrame = textFrame

    local durText = textFrame:CreateFontString(nil, "OVERLAY")
    durText:SetPoint("CENTER", button, "CENTER")
    durText:SetFont(TEXT_FONT, 24, "OUTLINE")
    durText:Hide()
    r.durText = durText
    ApplyDurationTextBinding(r, button, el)

    -- Stack count. A SEPARATE engine binding from the duration ones, so
    -- all three text regions can be live on the same button at once and
    -- styling decides which are shown. Bare binding (no formatter) =
    -- engine default, which renders the number only at 2+ applications;
    -- the Stacks/Max format supplies a
    -- formatter and therefore always renders.
    local stackText = textFrame:CreateFontString(nil, "OVERLAY")
    stackText:SetPoint("BOTTOMRIGHT", button, "BOTTOMRIGHT", -2, 2)
    stackText:SetFont(TEXT_FONT, 12, "OUTLINE")
    stackText:Hide()
    r.stackText = stackText
    ApplyStackBinding(r, button, el)

    -- Buff name.
    local nameText = textFrame:CreateFontString(nil, "OVERLAY")
    nameText:SetPoint("CENTER", button, "CENTER")
    nameText:SetFont(TEXT_FONT, 12, "OUTLINE")
    nameText:Hide()
    r.nameText = nameText
    ApplyNameBinding(r, button)

    -- Square border: HOLLOW 4-edge-texture border (a full-bleed underlay
    -- shows through transparent fills). Created at init, positioned/colored
    -- at style time.
    -- On the text holder, above the swipe: same reason as the
    -- FontStrings — a border drawn on the BUTTON is out-sorted by the
    -- Cooldown child frame and gets dimmed by the swipe.
    r.borderEdges = {}
    for i = 1, 4 do
        local edge = textFrame:CreateTexture(nil, "OVERLAY")
        edge:SetTexture(WHITE8X8)
        edge:Hide()
        r.borderEdges[i] = edge
    end

    -- Bar extras: own nine-slice rounded border art plus a rounded-rect
    -- mask (Cooldown Manager look, but Blizzard ships no vertical bar
    -- art, so both orientations use the addon's BarBorder/BarMask
    -- files), plus a SECOND edge set for the bar icon's square border
    -- (borderEdges may serve the bar's own border at the same time).
    local barRoundBG = bar:CreateTexture(nil, "BACKGROUND", nil, -1)
    barRoundBG:SetTexture(ROUND_BORDER_TEX)
    barRoundBG:SetTextureSliceMargins(8, 8, 8, 8)
    -- Tint is applied at STYLE time (borderColor); the art itself is
    -- white, so the vertex color is the whole border color.
    barRoundBG:Hide()
    r.barRoundBG = barRoundBG
    -- Rounded-corner mask applied to the fill + background at style
    -- time.
    -- SETTLED (was PTR-VERIFY): nine-slicing a MaskTexture works —
    -- a per-border mask asset sliced by its own margins.
    -- BlockingLoads matters here too: an async-loaded mask
    -- applies as fully OPAQUE on the first frame, i.e. square corners
    -- until something happens to force a restyle.
    -- NOTE: the mask asset MUST carry an alpha channel. BarMask.tga
    -- shipped 24-bit (rounded rect stored as RGB luminance only), so the
    -- mask was opaque everywhere and the fill kept square corners that
    -- overhung the border art's rounded corners.
    local barMask = bar:CreateMaskTexture()
    if barMask.SetBlockingLoadsRequested then
        barMask:SetBlockingLoadsRequested(true)
    end
    barMask:SetTexture(ROUND_MASK_TEX,
        "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
    barMask:SetTextureSliceMargins(8, 8, 8, 8)
    barMask:SetAllPoints(bar)
    r.barMask = barMask
    r.barBg = barBg

    -- Icon rounded border: the bar's frame + mask pair again, but
    -- parented to the BUTTON and masking the icon texture. Separate
    -- objects rather than reusing the bar's, which are anchored to (and
    -- sorted inside) the StatusBar. UNSLICED, unlike the bar pair:
    -- icon masks stretch (slicing is bar-only), and the
    -- corner radius must scale with the icon to match the STRETCHED
    -- rounded swipe texture (see media comment at ROUND_MASK_TEX).
    local iconRoundBG = button:CreateTexture(nil, "BACKGROUND", nil, -1)
    iconRoundBG:SetTexture(ICON_ROUND_BORDER_TEX)
    iconRoundBG:Hide()
    r.iconRoundBG = iconRoundBG

    -- Always-on recolor overlay ("Recolor" / "Overlay Color", non-ID kinds).
    -- Created at INIT, never at style time: the style pass can run in combat,
    -- where CreateTexture on an engine-owned aura button is forbidden.
    -- ARTWORK sublevel 2 puts it above the icon (sublevel 0) and below the
    -- Cooldown, which is a child FRAME and out-sorts button regions anyway.
    local iconTint = button:CreateTexture(nil, "ARTWORK", nil, 2)
    iconTint:Hide()
    r.iconTint = iconTint

    -- ── Icon Effect + Pandemic art ──────────────────────────────────────
    -- Created at INIT for the same reason as iconTint above: the style pass
    -- is reachable in combat, where CreateTexture on an engine-owned aura
    -- button is forbidden. They share one child frame a level above the text
    -- holder, so the alert art sorts over the duration text (the raid-frame
    -- alert layering) while staying a descendant of the button.
    local fxFrame = CreateFrame("Frame", nil, button)
    fxFrame:SetAllPoints(button)
    fxFrame:SetFrameLevel(textFrame:GetFrameLevel() + 1)
    r.fxFrame = fxFrame

    -- Glow: the action-button alert RING, drawn STATICALLY. Every
    -- OnUpdate-driven glow style is denied here — engine-owned aura buttons
    -- and their descendants live in the forbidden partition, where SetScript
    -- is refused — so the ring is a plain texture and the optional pulse is
    -- an ANIMATION GROUP: declarative, engine-stepped, no script.
    local glowTex = fxFrame:CreateTexture(nil, "OVERLAY")
    glowTex:SetTexture("Interface\\SpellActivationOverlay\\IconAlert")
    glowTex:SetTexCoord(0.00781250, 0.50781250, 0.27734375, 0.52734375)
    glowTex:Hide()
    r.fxGlow = glowTex
    local glowAG = glowTex:CreateAnimationGroup()
    glowAG:SetLooping("REPEAT")
    local gIn = glowAG:CreateAnimation("Alpha")
    gIn:SetOrder(1); gIn:SetDuration(0.6)
    gIn:SetFromAlpha(0.35); gIn:SetToAlpha(1)
    local gOut = glowAG:CreateAnimation("Alpha")
    gOut:SetOrder(2); gOut:SetDuration(0.6)
    gOut:SetFromAlpha(1); gOut:SetToAlpha(0.35)
    r.fxGlowAG = glowAG
    -- Kept so the pulse's floor and ceiling can be re-scaled by the colour
    -- picker's alpha at style time (see ApplyIconEffect).
    r.fxGlowIn, r.fxGlowOut = gIn, gOut

    -- Marching Ants: FlipBook over Blizzard's 22-frame IconAlertAnts sheet
    -- (256px sheet, 48px frames on a 5x5 grid). WITHOUT the explicit frame
    -- dimensions the flipbook divides the FULL sheet into fifths (51.2px
    -- cells) and every frame samples off-grid -- the ants visibly jump.
    local antsTex = fxFrame:CreateTexture(nil, "OVERLAY", nil, 2)
    antsTex:SetTexture("Interface\\SpellActivationOverlay\\IconAlertAnts")
    antsTex:Hide()
    r.fxAnts = antsTex
    local antsAG = antsTex:CreateAnimationGroup()
    antsAG:SetLooping("REPEAT")
    local fb = antsAG:CreateAnimation("FlipBook")
    fb:SetDuration(0.7)
    fb:SetFlipBookRows(5)
    fb:SetFlipBookColumns(5)
    fb:SetFlipBookFrames(22)
    if fb.SetFlipBookFrameWidth then
        fb:SetFlipBookFrameWidth(48)
        fb:SetFlipBookFrameHeight(48)
    end
    r.fxAntsAG = antsAG

    -- Flash: additive overlay with a looping in/out alpha pulse. The peak is
    -- re-stamped at style time from the color picker's alpha (SetToAlpha /
    -- SetFromAlpha are plain setters on the animation objects), so the
    -- picker tunes flash strength without rebuilding the group.
    local flashTex = fxFrame:CreateTexture(nil, "OVERLAY", nil, 3)
    flashTex:SetTexture(WHITE8X8)
    flashTex:SetBlendMode("ADD")
    flashTex:SetAlpha(0)
    flashTex:Hide()
    r.fxFlash = flashTex
    local flashAG = flashTex:CreateAnimationGroup()
    flashAG:SetLooping("REPEAT")
    local fIn = flashAG:CreateAnimation("Alpha")
    fIn:SetOrder(1); fIn:SetDuration(0.4)
    fIn:SetFromAlpha(0); fIn:SetToAlpha(0.5)
    local fOut = flashAG:CreateAnimation("Alpha")
    fOut:SetOrder(2); fOut:SetDuration(0.4)
    fOut:SetFromAlpha(0.5); fOut:SetToAlpha(0)
    r.fxFlashAG = flashAG
    r.fxFlashIn, r.fxFlashOut = fIn, fOut


    -- NONE of the three groups is Play()ed here. The engine steps EVERY
    -- playing animation group once per rendered frame, so a group left
    -- running on each pooled button is a per-frame cost proportional to the
    -- pool size. ApplyIconEffect starts one only while it is configured and
    -- stops it the moment it is not.

    -- Pandemic: a plain texture handed to the engine via AddPandemicRegion.
    -- The ENGINE decides when it renders, as the aura enters and leaves its
    -- pandemic (refresh) window -- remaining duration is secret on 12.1, so
    -- there is no Lua-side threshold to evaluate and nothing to poll.
    local pandemicTex = fxFrame:CreateTexture(nil, "OVERLAY", nil, 1)
    pandemicTex:Hide()
    r.fxPandemic = pandemicTex

    local iconMask = button:CreateMaskTexture()
    if iconMask.SetBlockingLoadsRequested then
        iconMask:SetBlockingLoadsRequested(true)
    end
    iconMask:SetTexture(ICON_ROUND_MASK_TEX,
        "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
    iconMask:SetAllPoints(button)
    r.iconMask = iconMask
    r.barIconEdges = {}
    for i = 1, 4 do
        local edge = textFrame:CreateTexture(nil, "OVERLAY")
        edge:SetTexture(WHITE8X8)
        edge:Hide()
        r.barIconEdges[i] = edge
    end

    return r
end

-- Position + color the 4 hollow border edges around `frame`.
-- Runtime-safe: ClearAllPoints/SetPoint/SetColorTexture (the same call
-- class as the glow geometry re-padding).
local function LayoutBorderEdges(edges, frame, thickness, c)
    local t = thickness or 1
    local top, bottom, left, right = edges[1], edges[2], edges[3], edges[4]
    top:ClearAllPoints()
    top:SetPoint("TOPLEFT", frame, "TOPLEFT")
    top:SetPoint("TOPRIGHT", frame, "TOPRIGHT")
    top:SetHeight(t)
    bottom:ClearAllPoints()
    bottom:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT")
    bottom:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT")
    bottom:SetHeight(t)
    left:ClearAllPoints()
    left:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, -t)
    left:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, t)
    left:SetWidth(t)
    right:ClearAllPoints()
    right:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, -t)
    right:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, t)
    right:SetWidth(t)
    for i = 1, 4 do
        edges[i]:SetColorTexture(c[1], c[2], c[3], c[4])
        edges[i]:Show()
    end
end

local function HideBorderEdges(edges)
    for i = 1, 4 do
        edges[i]:Hide()
    end
end

-- Rounded border around an icon texture: mask the icon to the rounded
-- rect and draw the nine-slice frame 1px outside it. Serves BOTH the
-- Icon tracker type (icon fills the button) and the bar's icon square,
-- which never render on the same button.
--
-- TWO rules, both learned the hard way:
--
-- 1. Rounded applies the SAME 20% zoom crop as none/square, so the
--    stock icon border never peeks out of the rounded shape. An
--    earlier note here claimed crop + mask "do not compose" -- that
--    was observed while the mask was still NINE-SLICED (broken mask
--    sampling, since removed); a mask samples over the region's rect
--    independent of the masked texture's texcoords, so with the
--    stretched mask the two compose. PTR-VERIFY: zoomed icon still
--    masks to the rounded rect.
-- 2. The mask and the frame anchor to `anchor`, a FRAME (the button),
--    never to r.icon. r.icon is the ENGINE-BOUND icon texture; hanging
--    layout off it gave both regions a degenerate rect, which is what
--    rendered the icon tiny and off-centre inside a correct-looking
--    frame. The live bar has always anchored its mask to the StatusBar
--    frame for the same reason.
--
-- `w`/`h` are optional: the bar's icon square is smaller than the
-- button, so it passes its own size and anchor point. `thick` = the
-- Rounded (Thick) variant: heavier ring art drawn 2px outside the icon
-- instead of 1px (the outer offset is what sets the visible side
-- thickness -- the ring's inner span is covered by the icon).
-- Reset a shared texture region to neutral draw state.
--
-- The slot's icon regions and the preview's pIcon are REUSED across styles
-- (pIcon alone serves the Icon preview, the bar's icon square, and now the
-- Texture preview). The Texture style is the only one that touches blend mode,
-- rotation, desaturation and region alpha, so those must be cleared before any
-- other style draws on the same region — otherwise switching Texture -> Icon
-- leaves the icon additive, rotated and possibly translucent. Each style
-- re-applies what it needs after this, so resetting to neutral is safe.
local function ResetTextureDrawState(tex)
    if not tex then return end
    tex:SetBlendMode("BLEND")
    tex:SetDesaturated(false)
    tex:SetRotation(0)
    tex:SetAlpha(1)
    tex:SetVertexColor(1, 1, 1, 1)
end

local function ApplyRoundedIcon(r, anchor, color, point, w, h, thick)
    local o = thick and 2 or 1
    r.icon:SetTexCoord(0.1, 0.9, 0.1, 0.9)
    r.iconRoundBG:SetTexture(
        thick and ICON_ROUND_BORDER_THICK_TEX or ICON_ROUND_BORDER_TEX)
    r.iconMask:ClearAllPoints()
    r.iconRoundBG:ClearAllPoints()
    if w and h then
        r.iconMask:SetSize(w, h)
        r.iconMask:SetPoint(point, anchor, point)
        r.iconRoundBG:SetSize(w + 2 * o, h + 2 * o)
        local cx, cy = CenterOffset(point, 0, 0, w, h)
        r.iconRoundBG:SetPoint("CENTER", anchor, point, cx, cy)
    else
        r.iconMask:SetAllPoints(anchor)
        r.iconRoundBG:SetPoint("TOPLEFT", anchor, "TOPLEFT", -o, o)
        r.iconRoundBG:SetPoint("BOTTOMRIGHT", anchor, "BOTTOMRIGHT", o, -o)
    end
    -- Remove before Add: re-style calls must not double-add.
    r.icon:RemoveMaskTexture(r.iconMask)
    r.icon:AddMaskTexture(r.iconMask)
    local c = color or DEFAULT_BORDER_COLOR
    r.iconRoundBG:SetVertexColor(c[1], c[2], c[3], c[4])
    r.iconRoundBG:Show()
end

-- Always-on icon styling for the NON-ability/item tracker kinds (Options:
-- "Desaturate" / "Recolor" / "Overlay Color"). The ID kinds' "when Unusable"
-- variants read live usable state every render; these have NO state to read —
-- they apply whenever the icon is drawn. Cost is therefore two widget setters
-- at STYLE time and nothing at all on any render or combat path.
--
-- Surfaces: the Icon style's icon and the Bar style's icon square — the two
-- places a non-ID tracker draws an aura icon. The bar FILL is deliberately
-- untouched: it already has its own Color option, so a second fixed color on
-- top of it would only fight that setting.
local ICON_TINT_DEFAULT = { 0.2, 0.45, 1, 0.45 }

-- Slot-button (live) variant. The overlay texture is created ONCE per button
-- in BuildButtonRegions and never here: this runs inside the style pass,
-- which is reachable in combat, where CreateTexture on an engine-owned aura
-- button is forbidden. `pad` insets the overlay (blizzard-style borders keep
-- it inside the stock art's rounded corners); `mask` is r.iconMask when a
-- rounded border is masking the icon, so the overlay takes the same shape.
-- Returns the overlay while shown.
local function ApplyIconTint(r, el, region, pad, mask)
    if not region then return nil end
    region:SetDesaturated(el.iconDesaturate and true or false)
    local ovl = r.iconTint
    if not ovl then return nil end
    if not el.iconRecolor then
        ovl:Hide()
        return nil
    end
    local tc = el.iconTintColor or ICON_TINT_DEFAULT
    ovl:SetColorTexture(tc[1], tc[2], tc[3], tc[4])
    ovl:ClearAllPoints()
    pad = pad or 0
    ovl:SetPoint("TOPLEFT", region, "TOPLEFT", pad, -pad)
    ovl:SetPoint("BOTTOMRIGHT", region, "BOTTOMRIGHT", -pad, pad)
    if mask then
        -- Remove before Add: re-style calls must not double-add (the same
        -- rule ApplyRoundedIcon follows for the icon itself).
        ovl:RemoveMaskTexture(mask)
        ovl:AddMaskTexture(mask)
    end
    ovl:Show()
    return ovl
end

-- Icon Effect (non-ability/item kinds): one Blizzard-style alert drawn over
-- the icon — the static action-button glow RING (optionally pulsing), the
-- raid-frame MARCHING ANTS, or a FLASH pulse. Surfaces are the same two the
-- Recolor options cover: the Icon style's icon and the Bar style's icon
-- square. `region` is the icon being decorated and `size` its edge length,
-- passed in by the caller — the button's own dimensions are SECRET post-PEW
-- and must never be read for geometry.
--
-- Cost: geometry + Show/Hide setters at STYLE time and nothing on any render
-- or combat path. Each branch STOPS the two groups it does not own; the
-- engine steps every PLAYING group once per rendered frame, so a group left
-- running on an unconfigured pooled button is a per-frame cost proportional
-- to the pool size. ParkElement stops them outright for abandoned elements.
--
-- PTR-VERIFY (cost): ApplyElementStyle styles EVERY pooled group button,
-- occupied or not, so on a multi-aura element with an animated effect the
-- groups are started on buttons the engine currently keeps hidden. That is
-- only free if a hidden frame's animation groups do not advance -- the
-- expected behavior, and what this code relies on, but it is worth confirming
-- with a CPU profile on a full raid before treating an animated effect as
-- free on group elements.
local ICON_EFFECT_DEFAULT_COLOR = { 1, 0.82, 0.25, 1 }

-- How far a border style's art extends BEYOND the icon rect, in pixels. The
-- rounded frame is drawn `o` px proud of the icon on every side
-- (ApplyRoundedIcon); Blizzard art lives inside the icon, and the square edges
-- are drawn inward from its edge, so both of those end exactly at the rect.
-- Effects that are supposed to sit ON the border, rather than inside it, have
-- to start from this rect and not from the icon's.
local function BorderOverhang(borderType)
    if borderType == "rounded_thick" then return 2 end
    if borderType == "rounded" then return 1 end
    return 0
end

-- Glow ring size per border style, as a fraction of the icon PER SIDE, added
-- on top of that border's own overhang. Derived, not user-set: the right value
-- is a property of the ART meeting the border's corner shape, so it is not
-- something to hand over as a preference.
--
-- The alert ring is rounded art with a fairly tight corner radius, and the two
-- border families pull it in opposite directions:
--   * SHARP corners (Square / None) need it GROWN. At a size that hugs a
--     rounded border, the ring's curved corners fall inside the icon's square
--     ones and leave them unlit. Growing it pushes the ring's straight runs
--     out past the corners, at the cost of a little overhang on the flat edges.
--   * ROUNDED corners need it PULLED IN. Grown too far, the ring's corners
--     read as square against the icon's curve -- the icon is masked to a
--     rounded rect out there, so it is transparent and nothing covers them.
--     Hugging closer keeps the ring's curve reading as part of the border's.
-- Blizzard-style art has rounded corners of its own and sits between the two.
-- One number per border style, and that is the whole mechanism. A tracker has
-- exactly one border style at a time, so these never interact: tune the row for
-- the style you are looking at and nothing else moves.
-- Owner-tuned by eye, 2026-08-15. Do not "restore" these to a single shared
-- number or to the action button's own 0.2: that constant is authored for art
-- whose border sits INSET within the icon, whereas these borders sit at the
-- icon's edge, so a uniform value tucks the ring's bright band under the
-- border on every style at once.
local GLOW_PAD = {
    blizzard      = 0.26,
    rounded       = 0.28,
    rounded_thick = 0.30,
    square        = 0.33,
    none          = 0.31,
}
local GLOW_PAD_DEFAULT = 0.24

-- Marching-ants ring size, as a multiple of the icon.
--
-- There is exactly ONE ants sheet in the game client -- IconAlertAnts (plus a
-- purple recolour under Transmogrify, used by nothing else) -- so the default
-- raid frames' alert is this same art. It looks finer there purely because
-- their buff icons are small: each frame is a complete ring at a fixed ant
-- spacing, so the ant SIZE and the ant COUNT both scale with whatever rect the
-- ring is drawn into. Blow it up to a 40px tracker icon and the ants read
-- chunky and sparse; shrink it and they read fine and dense.
--
-- The count therefore cannot be raised without abandoning this asset for a
-- tiled/scrolling implementation. This constant only moves size, and moving it
-- moves the ring with it: 1.19 was the action-button glow's parity value
-- (ants OUTSIDE the icon), 1.0 sits them exactly on the border, below that
-- they march inside the icon's edge over the art.
-- REVERTED to the original 1.19 (action-button parity). 0.85 was tried and
-- read WORSE: shrinking the ring shrinks the ants with it, so the pattern just
-- got denser-looking without gaining any ants, and the ring stopped tracking
-- the border. Scale alone cannot fix this -- see the note above.
local ANTS_SCALE = 1.19


local function ApplyIconEffect(r, el, region, size, pad, mask, borderType, thickness)
    local fx = region and el.iconEffect or nil
    if fx == "none" then fx = nil end
    local c  = el.iconEffectColor or ICON_EFFECT_DEFAULT_COLOR
    local cr, cg, cb = c[1] or 1, c[2] or 1, c[3] or 1
    local ca = c[4] or 1
    local sz = size or el.iconSize or 40
    -- Every border-hugging effect measures from the BORDER's outer rect.
    local ov = BorderOverhang(borderType)

    -- Glow ring, grown from the border's outer edge rather than the icon's.
    local tex, ag = r.fxGlow, r.fxGlowAG
    if tex then
        if fx == "glow" then
            local gp = sz * (GLOW_PAD[borderType or "blizzard"] or GLOW_PAD_DEFAULT) + ov
            tex:ClearAllPoints()
            tex:SetPoint("TOPLEFT", region, "TOPLEFT", -gp, gp)
            tex:SetPoint("BOTTOMRIGHT", region, "BOTTOMRIGHT", gp, -gp)
            -- Opacity rides SetAlpha, NOT SetVertexColor's 4th argument: the
            -- vertex alpha does not take on this texture, so the picker's alpha
            -- silently did nothing. Colour and opacity go through separate
            -- calls -- the same split the flash already uses.
            tex:SetVertexColor(cr, cg, cb)
            tex:Show()
            if (el.iconGlowStyle or "ring") == "pulse" then
                -- Scale the pulse's floor and ceiling by the picker's alpha, so
                -- it breathes between 35% and 100% OF THE CHOSEN opacity rather
                -- than of full.
                if r.fxGlowIn then
                    r.fxGlowIn:SetFromAlpha(ca * 0.35); r.fxGlowIn:SetToAlpha(ca)
                end
                if r.fxGlowOut then
                    r.fxGlowOut:SetFromAlpha(ca); r.fxGlowOut:SetToAlpha(ca * 0.35)
                end
                if ag and not ag:IsPlaying() then ag:Play() end
            else
                -- Stop() restores the baked alpha, which for a never-pulsed
                -- ring is 1; re-assert it so a pulse -> static switch cannot
                -- strand the texture mid-fade.
                if ag and ag:IsPlaying() then ag:Stop() end
                tex:SetAlpha(ca)
            end
        else
            tex:Hide()
            if ag and ag:IsPlaying() then ag:Stop() end
        end
    end

    -- Marching ants. The art is a border-ring pattern per frame, so the sheet
    -- is drawn CENTERED and scaled (see ANTS_SCALE) rather than anchored.
    tex, ag = r.fxAnts, r.fxAntsAG
    if tex then
        if fx == "ants" then
            local aw = sz * ANTS_SCALE + 2 * ov
            tex:ClearAllPoints()
            tex:SetPoint("CENTER", region, "CENTER", 0, 0)
            tex:SetSize(aw, aw)
            -- Same split as the glow: vertex alpha does not take here either,
            -- and the FlipBook only steps texcoords, so SetAlpha is free to
            -- carry the picker's opacity.
            tex:SetVertexColor(cr, cg, cb)
            tex:SetAlpha(ca)
            tex:Show()
            if ag and not ag:IsPlaying() then ag:Play() end
        else
            tex:Hide()
            if ag and ag:IsPlaying() then ag:Stop() end
        end
    end

    -- Flash. Alpha belongs to the animation, so the tint is a 3-arg
    -- SetVertexColor and the picker's alpha becomes the pulse PEAK (a
    -- full-alpha pick lands on the raid-frame alert's own 0.5 peak).
    --
    -- Unlike the ring and the ants -- which are deliberately OVERSIZED, sitting
    -- outside the icon rect -- the flash fills the icon, so it takes the same
    -- pad and mask as the recolor overlay. Without them it lights up the four
    -- square corners outside a rounded icon's silhouette, and covers the stock
    -- art on a Blizzard-style border instead of staying inside it.
    tex, ag = r.fxFlash, r.fxFlashAG
    if tex then
        if fx == "flash" then
            local peak = ca * 0.5
            if r.fxFlashIn  then r.fxFlashIn:SetToAlpha(peak) end
            if r.fxFlashOut then r.fxFlashOut:SetFromAlpha(peak) end
            tex:ClearAllPoints()
            pad = pad or 0
            tex:SetPoint("TOPLEFT", region, "TOPLEFT", pad, -pad)
            tex:SetPoint("BOTTOMRIGHT", region, "BOTTOMRIGHT", -pad, pad)
            -- Remove before Add: a re-style must not double-add (the rule
            -- ApplyIconTint follows for the recolor overlay).
            tex:RemoveMaskTexture(r.iconMask)
            if mask then tex:AddMaskTexture(mask) end
            tex:SetVertexColor(cr, cg, cb)
            tex:Show()
            if ag and not ag:IsPlaying() then ag:Play() end
        else
            tex:Hide()
            -- Drop the mask too, or an icon(rounded) -> bar switch leaves the
            -- flash clipped to a button-sized rounded rect.
            tex:RemoveMaskTexture(r.iconMask)
            -- Stop() restores the baked alpha (0), matching the hidden state.
            if ag and ag:IsPlaying() then ag:Stop() end
        end
    end
end

-- Hard stop for every icon effect on one button's regions, with no config
-- consulted. ApplyIconEffect is the normal path; this exists for teardown
-- (ParkElement), where the element is abandoned and its config is about to
-- stop being read at all -- an animation group left playing there is stepped
-- by the engine once per rendered frame for the rest of the session.
function ns.StopIconEffectOn(r)
    if not r then return end
    local t, g = r.fxGlow, r.fxGlowAG
    if t then t:Hide() end
    if g and g:IsPlaying() then g:Stop() end
    t, g = r.fxAnts, r.fxAntsAG
    if t then t:Hide() end
    if g and g:IsPlaying() then g:Stop() end
    t, g = r.fxFlash, r.fxFlashAG
    if t then t:Hide() end
    if g and g:IsPlaying() then g:Stop() end
end

-- Pandemic Effect (non-ability/item kinds). AuraButton:AddPandemicRegion hands
-- the ENGINE a region, and the engine owns it from there. Behaviour below is
-- read off Blizzard_CustomAuraButton.lua (12.1.0.69299), not inferred:
--
--   * AddPandemicRegion stamps Enum.SecretAspect.Shown on the region, appends
--     it to the button's pandemicRegions list, calls UpdateAuraDisplay, and
--     returns its index.
--   * The button's own OnUpdate runs ApplyPandemicRegions, which is just
--     `region:SetShown(self:IsInPandemicWindow())`. Visibility is ENTIRELY the
--     engine's. Nothing on our side may Show() or Hide() the texture once it is
--     bound; alpha is the only lever left to us.
--   * That OnUpdate is enabled only while a pandemic window exists
--     (ShouldEnableOnUpdate -> pandemicStartTime ~= nil), so a button with no
--     window costs nothing per frame.
--
-- The window itself comes from UpdatePandemicWindow:
--     carriedOver = GetRefreshExtendedDuration - GetAuraBaseDuration
-- and only exists when that is > 0. So this feature is meaningful ONLY on
-- auras that actually carry time over on refresh -- the refreshable DoT/HoT
-- family. On an aura with no pandemic behaviour the engine never opens a
-- window and the region correctly never shows; that is not a bug to chase.
local PANDEMIC_DEFAULT_COLOR = { 0.239216, 1, 0.254902, 0.15 }  -- #3dff41 @ 15%

local function ApplyPandemicEffect(r, button, el, region, pad, mask)
    local tex = r.fxPandemic
    if not tex then return end
    if not (region and el.pandemicEffect) then
        if r._fxPandemicBound then
            -- RemovePandemicRegion is the proper inverse: it drops the entry
            -- AND re-runs UpdateAuraDisplay. ClearPandemicRegions only empties
            -- the table -- it does not re-apply -- so on its own it can leave
            -- the button's OnUpdate mode and the region's state stale.
            if r._fxPandemicIndex and button.RemovePandemicRegion then
                pcall(button.RemovePandemicRegion, button, r._fxPandemicIndex)
            else
                pcall(button.ClearPandemicRegions, button)
            end
            r._fxPandemicBound, r._fxPandemicIndex = nil, nil
        end
        -- ALPHA, never Hide. AddPandemicRegion stamps
        -- Enum.SecretAspect.Shown on the region, so from that moment its shown
        -- state belongs to the engine -- and a secret aspect is never removed,
        -- so it stays engine-owned even after the binding is dropped. Alpha is
        -- not an aspect it claims, so that is the part we may still drive.
        tex:SetAlpha(0)
        tex:RemoveMaskTexture(r.iconMask)
        return
    end
    local c = el.pandemicColor or PANDEMIC_DEFAULT_COLOR
    tex:SetColorTexture(c[1], c[2], c[3], c[4] or 1)
    tex:SetAlpha(1)
    tex:ClearAllPoints()
    pad = pad or 0
    tex:SetPoint("TOPLEFT", region, "TOPLEFT", pad, -pad)
    tex:SetPoint("BOTTOMRIGHT", region, "BOTTOMRIGHT", -pad, pad)
    -- Remove before Add: a re-style must not double-add (the same rule
    -- ApplyIconTint follows for the recolor overlay).
    tex:RemoveMaskTexture(r.iconMask)
    if mask then tex:AddMaskTexture(mask) end
    -- Bind ONCE. AddPandemicRegion re-runs UpdateAuraDisplay itself, which
    -- recomputes the window against the aura already in the slot, so there is
    -- nothing a re-bind would refresh -- and re-binding is actively harmful:
    -- every call stamps another SecretAspect.Shown on the region and appends a
    -- fresh entry to the button's pandemicRegions list.
    --
    -- Visibility is ENTIRELY the engine's: its OnUpdate runs
    -- ApplyPandemicRegions, which does region:SetShown(IsInPandemicWindow()).
    -- Nothing here may call Show() or Hide() on this texture again.
    if not r._fxPandemicBound then
        if not button.AddPandemicRegion then
            -- API absent on this button class: give up silently and for good.
            -- ns.deferredRefresh here would be a permanent tax rather than a
            -- retry (it turns every PLAYER_REGEN_ENABLED into a full
            -- RefreshAll), and a retry that can never succeed would re-arm it
            -- on the next style pass. Only a DENIED call is worth retrying.
            tex:SetAlpha(0)
            return
        end
        local ok, idx = pcall(button.AddPandemicRegion, button, tex)
        if not ok then
            tex:SetAlpha(0)
            ns.deferredRefresh = true
            return
        end
        r._fxPandemicBound, r._fxPandemicIndex = true, idx
    end
end

-- Runtime-safe styling of one button's regions (SetSize / Show-Hide /
-- property setters only; never reads button dimensions — SECRET
-- post-PEW).
-- PTR-VERIFY: runtime SetSwipeTexture / SetSwipeColor /
-- SetHideCountdownNumbers on bound Cooldowns after PEW; engine
-- interference with bound duration-FontString visibility.
-- isSlot = true only for the SINGLE-slot button (not group fan-out buttons).
-- The Icon Source override applies to the single slot only; on a multi-aura
-- group each fan-out button would otherwise be stomped with the same texture.
local function StyleButtonRegions(r, button, style, el, colorOverride, isSlot)
    r.icon:Hide()
    -- A slot keeps TWO icon regions — the engine-bound one and the never-bound
    -- override (see BuildButtonRegions) — and r.icon points at whichever is
    -- active. The Texture style draws on the override region, so BOTH must be
    -- cleared here: hiding only r.icon left the texture visible underneath
    -- after a texture -> icon switch.
    if r._iconBoundRegion then r._iconBoundRegion:Hide() end
    if r._iconOverrideRegion then r._iconOverrideRegion:Hide() end
    ResetTextureDrawState(r._iconBoundRegion)
    ResetTextureDrawState(r._iconOverrideRegion)
    ResetTextureDrawState(r.icon)
    r.ringBg:Hide()
    r.bar:Hide()
    r.durText:Hide()
    if r.stackText then r.stackText:Hide() end
    if r.nameText then r.nameText:Hide() end
    HideBorderEdges(r.borderEdges)
    HideBorderEdges(r.barIconEdges)
    r.barRoundBG:Hide()
    r.iconRoundBG:Hide()
    -- Drop the rounded mask up front: the ONE icon texture also serves
    -- the bar's icon square, so an icon(rounded) -> bar switch would
    -- otherwise leave it masked against a button-sized rounded rect.
    -- Re-added below only by the rounded icon border.
    r.icon:RemoveMaskTexture(r.iconMask)
    -- Same for the always-on recolor overlay, which shares that mask.
    if r.iconTint then
        r.iconTint:Hide()
        r.iconTint:RemoveMaskTexture(r.iconMask)
    end
    -- Icon Effect / Pandemic targets. Only the two styles that actually draw
    -- an aura icon fill these in; every other style leaves them nil and the
    -- single apply pair at the tail clears the effects. Resolved ONCE and
    -- applied ONCE, rather than torn down here and rebuilt in the branch: a
    -- teardown-then-reapply would Stop() and Play() the animation group on
    -- every style pass, visibly restarting the ants/flash, and would issue a
    -- redundant ClearPandemicRegions/AddPandemicRegion pair each time.
    local fxRegion, fxSize, fxPad, fxMask, fxBorder, fxThick

    if not style or style == "sound" then
        r.cooldown:SetDrawSwipe(false)
        r.cooldown:SetHideCountdownNumbers(true)
        ApplyIconEffect(r, el, nil, nil)
        ApplyPandemicEffect(r, button, el, nil)
        return
    end

    local c = colorOverride or el.color or { 1, 1, 1, 1 }

    if style == "icon" then
        local sz = el.iconSize or 40
        button:SetSize(sz, sz)
        -- PAIR icon "Animate Border": no icon overlay at all — the PARENT's
        -- icon stays visible underneath and only a border-shaped swipe
        -- drains around it. The bound Cooldown's radial swipe IS the
        -- border: swipe texture = border art in the parent's border style
        -- (el.pairBorderStyle, synced by ApplyElementStyle), tinted by the
        -- pair's color, NOT reversed so the border covers the REMAINING
        -- arc and sweeps away clockwise — the circle style's ring-swipe
        -- trick applied to a border frame.
        local pairIconBorderAnim = el.pairFor
            and (el.pairIconAnim or "replace") == "border"
        if pairIconBorderAnim then
            -- Bound icon region stays bound but hidden — the proven
            -- RebindSlotIcon pattern (the engine draws the aura icon into
            -- a hidden region without force-showing it).
            r.icon:Hide()
            local pbt = el.pairBorderStyle or "blizzard"
            r.cooldown:ClearAllPoints()
            if pbt == "square" or pbt == "none" then
                -- Square ring drawn inside the icon rect, like the square
                -- border.
                r.cooldown:SetAllPoints(button)
            else
                -- Rounded ring ("blizzard" stock art has rounded corners
                -- too, so it also gets the ring), ±1 overhang like the
                -- static rounded frame.
                r.cooldown:SetPoint("TOPLEFT", button, "TOPLEFT", -1, 1)
                r.cooldown:SetPoint("BOTTOMRIGHT", button, "BOTTOMRIGHT", 1, -1)
            end
            -- Ring family follows the parent's border SHAPE; the weight is
            -- the pair's own Border Thickness option (1-6).
            r.cooldown:SetSwipeTexture(PairRingTexture(pbt, el.pairAnimThickness))
            r.cooldown:SetSwipeColor(c[1], c[2], c[3], c[4])
            r.cooldown:SetReverse(false)
            r.cooldown:SetDrawSwipe(true)
            r.cooldown:SetHideCountdownNumbers(true)
            RenderTexts(r, el, button, true)
            -- Early return: this path never reaches the tail apply, and the
            -- icon is hidden here, so both overlays must be cleared outright
            -- (the options hide them in this mode too).
            ApplyIconEffect(r, el, nil, nil)
            ApplyPandemicEffect(r, button, el, nil)
            return
        end
        -- Restore full-button anchors (the bar style shrinks the icon
        -- to an end square).
        r.icon:ClearAllPoints()
        r.icon:SetAllPoints(button)
        -- Border Type: blizzard = stock UNCROPPED icon art with the
        -- square cooldown swipe inset 4% of the icon size so the swipe's
        -- square corners stay inside the art's rounded corner radius.
        -- none/square/rounded = 20% zoom crop with a full-bleed swipe;
        -- square additionally draws the configurable hollow border,
        -- rounded masks and draws its own frame art.
        -- PTR-VERIFY: SetTexCoord on the ENGINE-BOUND icon texture —
        -- pure texcoord sets are the live-proven call class, but the
        -- engine could reset coords on aura
        -- reassignment; if the zoom doesn't stick, re-apply cannot help
        -- (no per-assignment hook) and "none"/"square" would need an
        -- overlay-mask approach instead.
        local borderType = BorderType(style, el)
        if borderType == "blizzard" then
            r.icon:SetTexCoord(0, 1, 0, 1)
            local inset = sz * 0.04
            r.cooldown:ClearAllPoints()
            r.cooldown:SetPoint("TOPLEFT", button, "TOPLEFT", inset, -inset)
            r.cooldown:SetPoint("BOTTOMRIGHT", button, "BOTTOMRIGHT", -inset, inset)
        elseif IsRounded(borderType) then
            -- Full-bleed swipe: the Cooldown is a child frame and
            -- cannot take the mask, so the swipe TEXTURE is the
            -- rounded-rect mask asset itself (set below — the same
            -- trick as Blizzard's Cooldown Manager swipe). The swipe
            -- IS the rounded shape; no inset.
            ApplyRoundedIcon(r, button, el.borderColor, nil, nil, nil,
                borderType == "rounded_thick")
            r.cooldown:ClearAllPoints()
            r.cooldown:SetAllPoints(button)
        else
            r.icon:SetTexCoord(0.1, 0.9, 0.1, 0.9)
            r.cooldown:ClearAllPoints()
            r.cooldown:SetAllPoints(button)
            if borderType == "square" then
                LayoutBorderEdges(r.borderEdges, button,
                    el.borderThickness or 1, el.borderColor or DEFAULT_BORDER_COLOR)
            end
        end
        r.icon:Show()
        -- Icon Source override (texture only) — see ApplyIconOverride. Also
        -- re-asserted on the out-of-combat refresh so a buff re-show (engine
        -- reassignment) can't leave the aura's own icon showing.
        ApplyIconOverride(r, button, el, isSlot)
        -- Always-on Desaturate / Recolor. No-op (two cheap setters) unless
        -- the user enabled them; see ApplyIconTint.
        ApplyIconTint(r, el, r.icon,
            borderType == "blizzard" and sz * 0.04 or 0,
            IsRounded(borderType) and r.iconMask or nil)
        -- Icon Effect / Pandemic ride the same rect and mask as the recolor
        -- overlay; applied once at the tail of this function.
        fxRegion, fxSize = r.icon, sz
        fxPad  = borderType == "blizzard" and sz * 0.04 or 0
        fxMask = IsRounded(borderType) and r.iconMask or nil
        fxBorder, fxThick = borderType, el.borderThickness
        r.cooldown:SetSwipeTexture(
            IsRounded(borderType) and ICON_ROUND_MASK_TEX or WHITE8X8)
        -- Owner-tuned: 0.8 read near-opaque, 0.55 too transparent.
        r.cooldown:SetSwipeColor(0, 0, 0, 0.7)
        -- Legacy icon behavior: reversed swipe (dark area grows as the
        -- buff runs out).
        r.cooldown:SetReverse(true)
        r.cooldown:SetDrawSwipe(el.cooldownSwipe and true or false)
        -- The Cooldown widget's own countdown numbers are OFF: the icon
        -- now renders duration through the BOUND FontString instead, so
        -- font/size/position are configurable (and a stack count can sit
        -- alongside it).
        r.cooldown:SetHideCountdownNumbers(true)
        RenderTexts(r, el, button, true)

    elseif style == "bar" then
        local bw = el.barWidth or 200
        local bh = el.barHeight or 20
        local vertical = el.barOrientation == "vertical"
        r.cooldown:SetHideCountdownNumbers(true)
        local showIcon = el.barShowIcon ~= false
        -- The BAR is exactly barWidth x barHeight and IS the button rect: the
        -- button/holder represents the BAR ALONE, never bar+icon. Showing or
        -- hiding the icon therefore cannot move the bar or resize the anchor.
        -- The icon square matches the bar's cross-axis (bar width for vertical,
        -- bar height for horizontal) and hangs OUTSIDE the button, anchored to
        -- the bar's outer edge across a 2px gap.
        local iconSize = vertical and bw or bh
        button:SetSize(bw, bh)
        r.bar:ClearAllPoints()
        r.bar:SetAllPoints(button)
        if showIcon then
            -- Vertical bars: "top" = above the bar, else below.
            local pos = el.barIconPosition or (vertical and "bottom" or "left")
            r.icon:ClearAllPoints()
            r.icon:SetSize(iconSize, iconSize)
            if vertical then
                if pos == "top" then
                    r.icon:SetPoint("BOTTOM", r.bar, "TOP", 0, 2)
                else -- bottom (default; legacy left/right values fall here)
                    r.icon:SetPoint("TOP", r.bar, "BOTTOM", 0, -2)
                end
            else
                if pos == "right" then
                    r.icon:SetPoint("LEFT", r.bar, "RIGHT", 2, 0)
                else
                    r.icon:SetPoint("RIGHT", r.bar, "LEFT", -2, 0)
                end
            end
            -- Icon border style: same semantics as the Icon tracker
            -- type, applied to the bar's icon square.
            local ibt = el.barIconBorderType or "blizzard"
            local swipeInset = 0
            if ibt == "blizzard" then
                r.icon:SetTexCoord(0, 1, 0, 1)
                swipeInset = iconSize * 0.04
            elseif IsRounded(ibt) then
                -- Full-bleed rounded swipe, same as the Icon style: the
                -- swipe texture below is the mask asset, so no inset.
                -- Anchor the mask/frame to the ICON region itself, not to the
                -- button: the icon square now hangs outside the button rect, so
                -- a button-relative reconstruction would no longer land on it.
                ApplyRoundedIcon(r, r.icon, el.barIconBorderColor,
                    nil, nil, nil, ibt == "rounded_thick")
            else
                r.icon:SetTexCoord(0.1, 0.9, 0.1, 0.9)
                if ibt == "square" then
                    LayoutBorderEdges(r.barIconEdges, r.icon,
                        el.barIconBorderThickness or 1,
                        el.barIconBorderColor or DEFAULT_BORDER_COLOR)
                end
            end
            r.icon:Show()
            -- Icon Source override for the bar's icon square (single slot only) —
            -- see ApplyIconOverride; re-asserted out of combat like the Icon style.
            ApplyIconOverride(r, button, el, isSlot)
            -- Always-on Desaturate / Recolor on the bar's icon square (the
            -- bar FILL keeps its own Color option — see ApplyIconTint).
            ApplyIconTint(r, el, r.icon,
                ibt == "blizzard" and iconSize * 0.04 or 0,
                IsRounded(ibt) and r.iconMask or nil)
            -- Icon Effect / Pandemic on the bar's icon square, same rect and
            -- mask as the recolor overlay; applied once at the tail. The bar
            -- FILL is deliberately untouched, matching ApplyIconTint.
            fxRegion, fxSize = r.icon, iconSize
            fxPad  = ibt == "blizzard" and iconSize * 0.04 or 0
            fxMask = IsRounded(ibt) and r.iconMask or nil
            fxBorder, fxThick = ibt, el.barIconBorderThickness
            -- Swipe over the icon square (the one bound Cooldown serves
            -- whichever style needs it).
            if el.barIconSwipe ~= false then
                r.cooldown:ClearAllPoints()
                r.cooldown:SetPoint("TOPLEFT", r.icon, "TOPLEFT",
                    swipeInset, -swipeInset)
                r.cooldown:SetPoint("BOTTOMRIGHT", r.icon, "BOTTOMRIGHT",
                    -swipeInset, swipeInset)
                r.cooldown:SetSwipeTexture(
                    IsRounded(ibt) and ICON_ROUND_MASK_TEX or WHITE8X8)
                r.cooldown:SetSwipeColor(0, 0, 0, 0.7)
                r.cooldown:SetReverse(true)
                r.cooldown:SetDrawSwipe(true)
            else
                r.cooldown:SetDrawSwipe(false)
            end
        else
            -- No icon square: re-bind if a prior override left the shared
            -- texture unbound, so it doesn't stay stuck on the custom art.
            if r._iconOverridden then
                pcall(button.SetIcon, button, r.icon)
                r._iconOverridden = nil
            end
            -- Bar geometry is already set above and is icon-independent.
            r.cooldown:SetDrawSwipe(false)
        end
        r.bar:SetOrientation(vertical and "VERTICAL" or "HORIZONTAL")
        -- Horizontal drains toward the LEFT anchor; vertical pins to
        -- the BOTTOM (owner choice — drains downward). PTR-VERIFY
        -- drain end.
        r.bar:SetReverseFill(false)
        r.bar:SetStatusBarColor(c[1], c[2], c[3], c[4])
        -- PAIR bar "Border" animation: the pair draws NO fill at all — the
        -- bound StatusBar still drains engine-side (fill made invisible),
        -- and a border in the PARENT bar's own border style (synced onto
        -- el.pairBorderStyle/pairBorderThickness by ApplyElementStyle, so
        -- it overlays the parent's border exactly) is MASKED from the bar's
        -- start edge to the fill's moving edge. The engine animates the
        -- fill RECT; the wipe mask is anchored to it, so the border
        -- retracts with the aura's remaining time — no per-frame Lua.
        -- Works at any orientation (vertical wipes top→down: the fill pins
        -- BOTTOM and drains downward, see SetReverseFill above).
        local pairBorderAnim = el.pairFor and (el.pairBarAnim or "border") == "border"
        local bg = pairBorderAnim and { 0, 0, 0, 0 }
            or el.barBgColor or { 0.1, 0.1, 0.1, 0.7 }
        r.barBg:SetColorTexture(bg[1], bg[2], bg[3], bg[4])
        -- Bar border: rounded = own nine-slice border drawn 1px outside
        -- the bar, with a rounded-rect mask keeping the flat fill +
        -- background inside the rounded shape at ANY size or orientation
        -- (Blizzard ships no vertical bar art, hence own assets);
        -- square = the configurable hollow flat border; none = bare.
        local bt = pairBorderAnim and "none" or BorderType(style, el)
        local fillTex = r.bar:GetStatusBarTexture()
        if fillTex then
            fillTex:SetTexture(BarTexturePath(not pairBorderAnim and el.barTexture or nil))
        end
        if pairBorderAnim then
            -- Invisible fill: alpha 0 hides the pixels but the drain still
            -- animates the fill texture's RECT — which is what the wipe
            -- mask below tracks.
            r.bar:SetStatusBarColor(0, 0, 0, 0)
        end
        r.barBg:ClearAllPoints()
        r.barBg:SetAllPoints(r.bar)
        if IsRounded(bt) then
            local thick = bt == "rounded_thick"
            local bbc = el.borderColor or DEFAULT_BORDER_COLOR
            -- Thick swaps art only: the ring thickens INWARD (3px) and
            -- the mask recedes the fill to match, so the ±1 offsets and
            -- slice margins are shared with regular rounded.
            r.barRoundBG:SetTexture(
                thick and ROUND_BORDER_THICK_TEX or ROUND_BORDER_TEX)
            r.barMask:SetTexture(
                thick and ROUND_MASK_THICK_TEX or ROUND_MASK_TEX,
                "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
            r.barRoundBG:SetVertexColor(bbc[1], bbc[2], bbc[3], bbc[4])
            r.barRoundBG:ClearAllPoints()
            r.barRoundBG:SetPoint("TOPLEFT", r.bar, "TOPLEFT", -1, 1)
            r.barRoundBG:SetPoint("BOTTOMRIGHT", r.bar, "BOTTOMRIGHT", 1, -1)
            r.barRoundBG:Show()
            -- Remove before Add: re-style calls must not double-add.
            if fillTex then
                fillTex:RemoveMaskTexture(r.barMask)
                fillTex:AddMaskTexture(r.barMask)
            end
            r.barBg:RemoveMaskTexture(r.barMask)
            r.barBg:AddMaskTexture(r.barMask)
        else
            if fillTex then
                fillTex:RemoveMaskTexture(r.barMask)
            end
            r.barBg:RemoveMaskTexture(r.barMask)
            if bt == "square" then
                LayoutBorderEdges(r.borderEdges, r.bar,
                    el.borderThickness or 1, el.borderColor or DEFAULT_BORDER_COLOR)
            end
        end
        if pairBorderAnim then
            -- ── Animated border (bar pairs, Animation Type = Border) ──
            local pbt = el.pairBorderStyle or "square"
            -- Wipe mask: a PLAIN stretched WHITE8x8 mask (only nine-sliced
            -- MASKS are broken — masks sample over the region's rect, see
            -- the icon-mask note above). Lazily created: regular trackers
            -- never allocate it.
            local m = r.pairWipeMask
            if not m then
                m = button:CreateMaskTexture()
                m:SetTexture(WHITE8X8, "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
                r.pairWipeMask = m
            end
            -- Fixed edges pin to the bar's start side (+2 covers the
            -- rounded border's ±1 overhang); the moving corner anchors to
            -- the FILL texture, so the engine's drain drags it along.
            -- PTR-VERIFY: layout anchored to the BOUND StatusBar's fill
            -- texture. The drain crops the fill RECT (live-proven by the
            -- outline-texture v1 wiping on horizontal), but if bound-fill
            -- anchors read degenerate (like the bound ICON texture — see
            -- ApplyRoundedIcon rule 2), this needs the outline-texture
            -- fallback instead.
            m:ClearAllPoints()
            if vertical then
                m:SetPoint("BOTTOMLEFT", r.bar, "BOTTOMLEFT", -2, -2)
                if fillTex then
                    m:SetPoint("TOPRIGHT", fillTex, "TOPRIGHT", 2, 2)
                end
            else
                m:SetPoint("TOPLEFT", r.bar, "TOPLEFT", -2, 2)
                if fillTex then
                    m:SetPoint("BOTTOMRIGHT", fillTex, "BOTTOMRIGHT", 2, -2)
                end
            end
            if IsRounded(pbt) then
                local thick = pbt == "rounded_thick"
                r.barRoundBG:SetTexture(
                    thick and ROUND_BORDER_THICK_TEX or ROUND_BORDER_TEX)
                r.barRoundBG:SetVertexColor(c[1], c[2], c[3], c[4])
                r.barRoundBG:ClearAllPoints()
                r.barRoundBG:SetPoint("TOPLEFT", r.bar, "TOPLEFT", -1, 1)
                r.barRoundBG:SetPoint("BOTTOMRIGHT", r.bar, "BOTTOMRIGHT", 1, -1)
                -- Remove before Add: re-style calls must not double-add.
                r.barRoundBG:RemoveMaskTexture(m)
                r.barRoundBG:AddMaskTexture(m)
                r.barRoundBG:Show()
            else
                -- Parent border Square (or None → 1px square wipe): the
                -- edge textures take the mask individually.
                LayoutBorderEdges(r.borderEdges, r.bar,
                    el.pairBorderThickness or 1, c)
                for j = 1, 4 do
                    r.borderEdges[j]:RemoveMaskTexture(m)
                    r.borderEdges[j]:AddMaskTexture(m)
                end
            end
        elseif r.pairWipeMask then
            -- Left Border mode (Overlay, or a regular restyle after it):
            -- strip the wipe mask so static borders aren't clipped against
            -- a stale fill rect.
            r.barRoundBG:RemoveMaskTexture(r.pairWipeMask)
            for j = 1, 4 do
                r.borderEdges[j]:RemoveMaskTexture(r.pairWipeMask)
            end
        end
        r.bar:Show()
        -- Text entries render inside the bar rect, independent of what
        -- the FILL tracks (each is its own engine binding).
        RenderTexts(r, el, r.bar, false)

    elseif style == "circle" then
        local size = (el.circleRadius or 80) * 2
        local texPath = RING_TEXTURES[el.ringTexture] or RING_TEXTURES["Ring_20px"]
        button:SetSize(size, size)
        r.ringBg:SetTexture(texPath)
        r.ringBg:SetVertexColor(0, 0, 0, 0.5)
        r.ringBg:Show()
        -- Restore full-bleed anchors (icon/blizzard insets the swipe).
        r.cooldown:ClearAllPoints()
        r.cooldown:SetAllPoints(button)
        r.cooldown:SetSwipeTexture(texPath)
        r.cooldown:SetSwipeColor(c[1], c[2], c[3], c[4])
        -- NOT reversed: the swipe covers the REMAINING portion, so the
        -- colored ring shrinks clockwise (drain). Reverse fills.
        r.cooldown:SetReverse(false)
        r.cooldown:SetDrawSwipe(true)
        r.cooldown:SetHideCountdownNumbers(true)

    elseif style == "texture" then
        -- A chosen image shown while the aura is present. The engine drives
        -- visibility ONLY: the region is a child of the aura button, so it
        -- shows and hides with it. Nothing here runs per update — every call
        -- below happens at style time (option change / spec change / the
        -- out-of-combat refresh), exactly like the Icon Source override this
        -- reuses.
        local w = el.textureWidth or 128
        local h = el.textureHeight or 128
        button:SetSize(w, h)

        -- MUST be the never-bound region: the engine stomps a bound region's
        -- texture on every aura reassignment, which is why the Icon Source
        -- override keeps a separate one. Group fan-out buttons have no
        -- override region, but the Texture style is single-slot only
        -- (AllowedTypeSorting drops it for multi-aura groups), so the
        -- fallback is defensive rather than a real path.
        local tex = r._iconOverrideRegion or r.icon
        ns.ApplyTextureValue(tex, el.textureValue, el.textureKind)
        tex:SetVertexColor(c[1], c[2], c[3])
        tex:SetAlpha(c[4] or 1)
        tex:SetBlendMode(el.textureBlend or "ADD")
        tex:SetDesaturated(el.textureDesaturate and true or false)
        -- SetRotation takes radians; the option stores degrees.
        tex:SetRotation(math.rad(el.textureRotation or 0))
        tex:Show()

        -- No swipe, no countdown, no text: the texture is a presence light.
        -- The engine has no binding that maps duration onto a texture property
        -- (plan §6), and the text machinery is gated to icon/bar/text styles,
        -- so a duration readout comes from a separate Text element.
        r.cooldown:SetDrawSwipe(false)
        r.cooldown:SetHideCountdownNumbers(true)

    elseif style == "text" then
        -- No art at all: the button is a bare hit/anchor rect for the
        -- text entries, which use the icon 9-point grid so several can
        -- coexist without stacking on each other.
        local sz = LargestTextSize(el)
        button:SetSize(sz * 4, sz * 1.6)
        r.cooldown:SetDrawSwipe(false)
        r.cooldown:SetHideCountdownNumbers(true)
        RenderTexts(r, el, button, true)
    end

    -- Single apply point for both icon overlays (see the fxRegion note at the
    -- top). fxRegion is nil for every style that draws no aura icon, which is
    -- what clears a leftover effect on a style switch.
    ApplyIconEffect(r, el, fxRegion, fxSize, fxPad, fxMask, fxBorder, fxThick)
    ApplyPandemicEffect(r, button, el, fxRegion, fxPad, fxMask)
end

-- Does this element's current style render through the pooled GROUP
-- (multi-aura fan-out) instead of the single slot? Custom shells only
-- fan out when claimed by a GROUP-kind entry; aura-kind customs track a
-- single buff and use the slot for every style.
local function UsesGroup(tracker, e, style)
    if not e.hasGroup then return false end
    if style ~= "icon" and style ~= "bar" then return false end
    if tracker.config.custom then
        local entry = tracker:GetCustomEntry()
        return (entry and entry.kind == "group") and true or false
    end
    return true
end

-------------------------------------------------------------------------------
-- Tracker methods
-------------------------------------------------------------------------------
local methods = {}
local trackerMT = { __index = methods }

function methods:GetDBVal(key)
    return ns.addon.db.profile[self.config.dbPrefix .. key]
end

-------------------------------------------------------------------------------
-- Custom-shell identity (config.custom): a shell is "claimed" when the
-- profile's customTrackers list has an entry whose id matches; identity
-- (name, kind, ids, selection) lives entirely in the profile, so
-- claim/release is pure Lua + live setters — no frame creation.
--
-- ENTRY CACHE. The claim lookup was an O(#customTrackers) linear scan run from
-- the render path (the kind predicates are called several times per element per
-- refresh, and IsIDKind ran the scan TWICE). It is resolved ONCE per tracker
-- into _entry plus five boolean kind fields.
--
-- INVALIDATION is generation-based, not pointer-based, for two reasons the
-- source forces on us:
--   1. Options' Ability<->Item Type toggle rewrites entry.kind IN PLACE, so the
--      table pointer never changes and only a generation bump can catch it.
--   2. Custom ids are RECYCLED (first unused integer, in AddCustom and
--      DuplicateCustom) while tracker objects are never removed from
--      ns.trackers — a deleted-then-recreated id hands a brand-new entry to the
--      OLD tracker object.
-- Any mutation of db.profile.customTrackers (add / duplicate / delete / kind
-- change) and any profile swap MUST call ns.BumpCustomEntryGen().
-------------------------------------------------------------------------------
ns.customEntryGen = 1
function ns.BumpCustomEntryGen()
    ns.customEntryGen = ns.customEntryGen + 1
end

-- Scan the profile list ONCE and write every derived field. Returns the entry
-- (or nil) so GetCustomEntry can tail into it.
function methods:ResolveEntryCache()
    local entry
    if self.config.custom then
        local list = ns.addon.db.profile.customTrackers
        if type(list) == "table" then
            local want = self.config.customID
            for i = 1, #list do
                if list[i].id == want then
                    entry = list[i]
                    break
                end
            end
        end
    end
    local kind = entry and entry.kind
    self._entry         = entry
    self._isCustomKind  = (kind == "custom")
    self._isAbilityKind = (kind == "ability" or kind == "abilitygroup")
    self._isItemKind    = (kind == "item"    or kind == "itemgroup")
    self._isIDKind      = self._isAbilityKind or self._isItemKind
    self._isIDGroupKind = (kind == "abilitygroup" or kind == "itemgroup")
    self._entryGen      = ns.customEntryGen
    return entry
end

-- A fresh tracker has _entryGen == nil, so the first accessor always resolves.
function methods:GetCustomEntry()
    if self._entryGen ~= ns.customEntryGen then
        return self:ResolveEntryCache()
    end
    return self._entry
end

-- True for a "custom" kind tracker (no aura dependency; art driven purely by
-- Load Conditions). Buffs/debuffs/groups return false.
function methods:IsCustomKind()
    if self._entryGen ~= ns.customEntryGen then self:ResolveEntryCache() end
    return self._isCustomKind
end

-- ID kinds (ability/item): no aura, but a real spell/item ID drives an icon
-- with an optional Cooldown swipe + Charges/Stacks text. Groups fan out over an
-- id list. Rendered on the holder path (like custom), never the engine slot.
function methods:IsAbilityKind()
    if self._entryGen ~= ns.customEntryGen then self:ResolveEntryCache() end
    return self._isAbilityKind
end
function methods:IsItemKind()
    if self._entryGen ~= ns.customEntryGen then self:ResolveEntryCache() end
    return self._isItemKind
end
-- Either ability or item — the shared "ID kind" (has a real ID + cooldown/
-- charge/stack tracking, unlike custom which is static art). Was TWO full list
-- scans (IsAbilityKind + IsItemKind); now one field read.
function methods:IsIDKind()
    if self._entryGen ~= ns.customEntryGen then self:ResolveEntryCache() end
    return self._isIDKind
end
-- Is this an ID-kind GROUP (fan-out over an id list)?
function methods:IsIDGroupKind()
    if self._entryGen ~= ns.customEntryGen then self:ResolveEntryCache() end
    return self._isIDGroupKind
end

-------------------------------------------------------------------------------
-- COMPOUND (ability/item + applied aura) PAIR elements. A flagged SINGLE
-- ability/item tracker (ns.CompoundInfo, SpellData_AbilitiesItems.lua) can
-- carry "(Active)" pair elements: real aura displays — engine slot, bound
-- regions, the whole proven aura path — overlaid on the parent element's
-- cooldown art at a raised frame level. The ENGINE shows/hides the overlay
-- with the aura, so the "buff duration while active, cooldown otherwise"
-- switch costs zero combat-time Lua and reads no secret aura data.
--
-- A pair element's DB entry is marked pairFor = <parent element's style>
-- (its own style always equals pairFor). Identity is STYLE-based, not
-- index-based — element removal renumbers indices (Options table.remove),
-- and there is only one non-pair element per style. An orphan pair (parent
-- element removed out from under it) simply parks via GatePairSlots.
-------------------------------------------------------------------------------
function methods:GetCompoundInfo()
    if not (self.config.custom and ns.CompoundInfo) then return nil end
    local entry = self:GetCustomEntry()
    local kind = entry and entry.kind
    -- SINGLE ability/item trackers only: group fan-outs would need
    -- per-sample overlays over engine-pooled frames (out of scope).
    if kind ~= "ability" and kind ~= "item" then return nil end
    local ids = self:GetDBVal("ids")
    return ns.CompoundInfo(ids and ids[1], kind == "item")
end

function methods:IsPairElement(i)
    local el = self:GetElementsDB()[i]
    return (el and el.pairFor) and true or false
end

-- Position-locked pair? Icon/Bar pairs overlay a parent element; TEXT
-- pairs are standalone (no parent element, free-positioned, draggable) —
-- an aura-driven duration text the user places anywhere.
function methods:IsLockedPairElement(i)
    local el = self:GetElementsDB()[i]
    return (el and el.pairFor and el.pairFor ~= "text") and true or false
end

-- Any pair element configured? Refresh-path only (cheap DB scan).
function methods:HasPairElements()
    local els = self:GetElementsDB()
    for i = 1, MAX_ELEMENTS do
        local el = els[i]
        if el and el.pairFor then return true end
    end
    return false
end

-- Index of the element a pair overlays: the non-pair element whose style
-- matches pairFor. nil = orphan (parent removed); the pair parks.
function methods:PairParentIndex(i)
    local els = self:GetElementsDB()
    local pf = els[i] and els[i].pairFor
    if not pf then return nil end
    for j = 1, MAX_ELEMENTS do
        local o = els[j]
        if o and not o.pairFor and o.style == pf then return j end
    end
    return nil
end

-- Unit / filter for pair slots, from the compound polarity. help = the
-- player's own buff; harm = the player's debuff on the enemy target
-- (reserved — harm pairs aren't created yet; Own Only is forced so someone
-- else's copy of the same debuff can't light the overlay).
function methods:PairUnit()
    local ci = self:GetCompoundInfo()
    return (ci and ci.polarity == "harm") and "target" or "player"
end
function methods:PairFilter()
    local ci = self:GetCompoundInfo()
    return (ci and ci.polarity == "harm") and "HARMFUL|PLAYER" or "HELPFUL"
end

-- Live candidate set for pair slots: the compound entry's aura ID set.
-- nil when the tracker's current ID isn't compound-flagged (id was edited
-- away) — pairs park until it is again.
function methods:PairSlotCandidates()
    local ci = self:GetCompoundInfo()
    if not (ci and ci.auras and ci.auras[1]) then return nil end
    local set = {}
    for j = 1, #ci.auras do set[ci.auras[j]] = true end
    return { includeSpellIDs = set }
end

-- MIXED-GROUP MODEL (12.1): an ID-kind group holds BOTH abilities and items,
-- tagged PER ENTRY in the side-table p[prefix.."idKinds"] = { [id] = "ability"
-- | "item" }. Returns TRUE when `id` is an ITEM, resolved from idKinds; when
-- absent it falls back to `fallbackIsItem` (the tracker's own kind, e.g.
-- self:IsItemKind()). Reads the DB directly so it works before/independent of
-- tracker objects. Single trackers have one id and pass their own kind through.
function ns.IDKindOf(prefix, id, fallbackIsItem)
    local kinds = ns.addon.db.profile[prefix .. "idKinds"]
    local tag = kinds and kinds[id]
    if tag == "item" then return true end
    if tag == "ability" then return false end
    return fallbackIsItem and true or false
end

-- Resolve a custom Icon element's texture from its explicit mode + raw input.
-- mode "spell" → GetSpellTexture(id); mode "icon" → numeric file ID or a
-- path/name string passed straight to SetTexture. Returns nil when there's
-- nothing usable (caller decides: live = show nothing; preview = fallback).
function ns.ResolveCustomIcon(mode, raw)
    if raw == nil or raw == "" then return nil end
    if mode == "spell" then
        local n = tonumber(raw)
        return n and GetSpellTexture(n) or nil
    end
    -- "icon" (default): file ID if numeric, else a texture path/name.
    return tonumber(raw) or raw
end

-- Aura Icon override (buff/debuff Icon trackers). "auto" = use the aura's own
-- icon (return nil → engine-bound, no override). "spell"/"icon" resolve like
-- ns.ResolveCustomIcon. Returns the override texture, or nil for auto / unset.
function ns.ResolveAuraIcon(el)
    if not el or (el.auraIconMode or "auto") == "auto" then return nil end
    return ns.ResolveCustomIcon(el.auraIconMode, el.auraIconInput)
end

-- Tree-node / preview icon for a custom-kind tracker: the first Icon element's
-- resolved texture, read straight from the DB (works before the tracker object
-- exists). Returns nil if none configured.
function ns.ResolveCustomTreeIcon(prefix)
    local els = ns.addon.db.profile[prefix .. "elements"]
    if type(els) ~= "table" then return nil end
    for i = 1, #els do
        local e = els[i]
        if e and e.style == "icon" then
            return ns.ResolveCustomIcon(e.customIconMode or "icon", e.customIconInput)
        end
    end
    return nil
end

-- Tree-node / cache icon for an ID-kind tracker: the first stored id's texture
-- (spell for abilities, item icon for items). Read straight from the DB so it
-- works before the tracker object exists. Returns nil if none configured.
-- MIXED GROUPS: the first id may be an ability OR an item — resolve ITS kind
-- from idKinds (falling back to `fallbackIsItem`, the tracker's default kind).
function ns.ResolveIDIcon(prefix, fallbackIsItem)
    local ids = ns.addon.db.profile[prefix .. "ids"]
    if type(ids) ~= "table" or not ids[1] then return nil end
    if ns.IDKindOf(prefix, ids[1], fallbackIsItem) then
        return (C_Item and C_Item.GetItemIconByID and C_Item.GetItemIconByID(ids[1])) or nil
    end
    return GetSpellTexture(ids[1])
end

-- Tracked unit + aura filter. Buffs (and every built-in) track the player
-- with HELPFUL. A custom DEBUFF tracker tracks target/focus with HARMFUL,
-- optionally scoped to the player's own casts ("Own Only" default →
-- HARMFUL|PLAYER). Both are baked into the slot/group at creation, so
-- changing unit or Own Only on an existing tracker needs a /reload (see the
-- option notes). A fresh debuff tracker gets the right values immediately.
function methods:GetTrackedUnit()
    if self.config.custom then
        local e = self:GetCustomEntry()
        if e and e.harmful then return e.unit or "target" end
    end
    return "player"
end

function methods:GetTrackerFilter()
    if self.config.custom then
        local e = self:GetCustomEntry()
        if e and e.harmful then
            -- Debuffs (single + group): Own Only ON by default → HARMFUL|PLAYER;
            -- the group filter is applied to every aura-group in the container.
            if e.ownOnly ~= false then return "HARMFUL|PLAYER" end
            return "HARMFUL"
        elseif e and (e.kind == "aura" or e.kind == "group") then
            -- Buffs (single + group): Own Only OFF by default; opt in for
            -- HELPFUL|PLAYER (applied to every aura in the group).
            if e.ownOnly then return "HELPFUL|PLAYER" end
            return "HELPFUL"
        end
    end
    return "HELPFUL"
end

-- FILTER-STRING park: set a non-live slot's filter string to "". An EMPTY filter
-- string matches no polarity and is honored on friendly AND hostile units alike
-- — unlike the candidateFilters spellID include set (DORMANT_CANDIDATES), which
-- the engine SILENTLY IGNORES for HARMFUL auras on assistable units (the carve-
-- out — see PushVisualGate). So a debuff slot parked ONLY by DORMANT_CANDIDATES
-- still leaks every debuff on a friendly target AND still does real matching
-- work; clearing the filter string to "" stops both at the source, for enabled,
-- disabled, and condition-failed trackers equally.
--
-- WHY THIS IS SAFE despite the DORMANT_CANDIDATES header's caution: that comment
-- warns specifically against candidateFilters { maxDuration = 0 } — a NUMERIC
-- comparison against SECRET durations that throws "store a secret value in a
-- SecureNumber". An empty filter STRING does no numeric comparison; it is a
-- documented live setter (AuraContainer_API_Reference §AuraSlot) with no numeric
-- comparison, safe on the live 12.1 engine. Still pcall'd + deferred under
-- IsRestricted like every other container mutation: a denied window degrades to
-- the pre-existing candidate-only parking rather than throwing.
--
-- Change-guarded on e._slotFilterParked so the filter write is paid only on the
-- live↔parked EDGE, independent of the candidate-filter write beside it.
local function SetSlotFilterParked(self, e, parked)
    if not (e and e.slotKey and e._unit) then return end
    local want = parked and true or false
    if e._slotFilterParked == want then return end
    local sc = GetSlotContainer(e._unit)
    local fstr = want and "" or self:GetTrackerFilter()
    if pcall(sc.SetAuraSlotFilterString, sc, e.slotKey, fstr) then
        e._slotFilterParked = want
        return sc  -- caller batches the UpdateAllAuras rescan
    end
    -- Denied in a restricted window: leave the cached state stale so the next
    -- unrestricted gating pass reconciles.
    ns.deferredRefresh = true
end
ns.SetSlotFilterParked = SetSlotFilterParked

-- GROUP-container kill switch: SetEnabled(false)+SetShown(false) on a non-live
-- container. A hidden+disabled AuraContainer registers no unit events, i.e. it
-- does ZERO aura work. ParkElement already uses exactly this pair for
-- abandoned containers; here we drive it from LIVENESS so a disabled / condition-
-- failed / assist-blocked group debuff tracker stops working at the engine level
-- rather than only dropping alpha. Change-guarded on e._groupInert; pcall'd for
-- the combat/secret window.
local function SetGroupInert(e, inert)
    if not (e and e.container) then return end
    local want = inert and true or false
    if e._groupInert == want then return end
    e._groupInert = want
    if want then
        pcall(e.container.SetEnabled, e.container, false)
        pcall(e.container.SetShown, e.container, false)
    else
        pcall(e.container.SetEnabled, e.container, true)
        pcall(e.container.SetShown, e.container, true)
    end
end
ns.SetGroupInert = SetGroupInert

-- Self-healing unit move (debuff "Unit" changed live). A slot can't move
-- between the shared per-unit containers, so any element whose slot is in the
-- wrong container is parked+abandoned here and rebuilt by EnsureElements in
-- the correct one on the same refresh (its saved style/position are reused).
-- Runs from the refresh pipeline, so a combat-deferred unit change self-heals
-- once combat ends. No-op for buffs/built-ins (always "player").
function methods:SyncElementUnits()
    -- Custom-kind elements have no slot/container (e._unit is nil by
    -- design), so there's nothing to re-home.
    if self:IsCustomKind() then return end
    if self:IsIDKind() then
        -- ID trackers: only PAIR "(Active)" elements carry slots, and unlike
        -- the homogeneous aura path, element OBJECTS here are not
        -- interchangeable (pair = slot button; plain = ID art regions).
        -- Element removal renumbers the CONFIG list (Options table.remove)
        -- while objects keep their index, so two mismatches self-heal like a
        -- unit change — park + abandon, EnsureElements rebuilds the correct
        -- object in this same refresh:
        --   1. KIND mismatch: a pair config on a non-slot object (invisible
        --      overlay) or a non-pair/absent config on a slot object (which
        --      would otherwise strand a LIVE slot GatePairSlots no longer
        --      recognizes as a pair).
        --   2. UNIT mismatch: the compound polarity's unit changed (the
        --      tracked ID was swapped to a different compound entry).
        local els = self:GetElementsDB()
        for i = 1, MAX_ELEMENTS do
            local e = self.elements[i]
            if e then
                local wantSlot = (els[i] and els[i].pairFor) and true or false
                local hasSlot = e.slotKey and true or false
                local unitBad = hasSlot and wantSlot and e._unit ~= self:PairUnit()
                if wantSlot ~= hasSlot or unitBad then
                    -- Slot/frame rebuilds are combat-restricted; defer to
                    -- PLAYER_REGEN_ENABLED if we can't do it now.
                    if ns.IsRestricted() then
                        ns.deferredRefresh = true
                        return
                    end
                    ParkElement(e)
                    self.elements[i] = nil
                end
            end
        end
        return
    end
    local want = self:GetTrackedUnit()
    for i = 1, MAX_ELEMENTS do
        local e = self.elements[i]
        if e and e._unit ~= want then
            -- Rebuilding the slot in the new container is a combat-restricted
            -- mutation; defer to PLAYER_REGEN_ENABLED if we can't do it now.
            if ns.IsRestricted() then
                ns.deferredRefresh = true
                return
            end
            ParkElement(e)
            self.elements[i] = nil
        end
    end
end

-- Self-healing filter string (Own Only toggled: debuffs HARMFUL <->
-- HARMFUL|PLAYER, buffs HELPFUL <-> HELPFUL|PLAYER). Pushed live onto the slot
-- + every group via the engine's live setters. Change-guarded. Scoped to
-- aura-kind customs (debuffs and buffs); built-ins and custom-"Other" are
-- untouched.
function methods:SyncElementFilters()
    local entry = self.config.custom and self:GetCustomEntry()
    -- Aura-kind (single) and group-kind customs: Own Only pushes the filter
    -- string onto the slot AND every group container. Built-ins and
    -- custom-"Other" are untouched.
    if not (entry and (entry.kind == "aura" or entry.kind == "group")) then return end
    local filter = self:GetTrackerFilter()
    if self._appliedFilter == filter then return end
    if ns.IsRestricted() then ns.deferredRefresh = true; return end
    for i = 1, MAX_ELEMENTS do
        local e = self.elements[i]
        if e then
            if e.slotKey and e._unit then
                -- If the slot is currently FILTER-PARKED ("" — disabled /
                -- condition-failed / assist-blocked, see SetSlotFilterParked),
                -- do NOT stomp the "" back to a live filter: that would re-arm
                -- an inert debuff slot. Just drop the park cache so the next
                -- gating pass re-derives the correct parked-vs-live filter from
                -- the new GetTrackerFilter() value. Otherwise push the new live
                -- filter as before.
                if e._slotFilterParked then
                    e._slotFilterParked = nil
                else
                    local sc = GetSlotContainer(e._unit)
                    pcall(sc.SetAuraSlotFilterString, sc, e.slotKey, filter)
                    pcall(sc.UpdateAllAuras, sc)
                end
            end
            if e.container then
                pcall(e.container.SetAuraGroupFilterString, e.container, "maingroup", filter)
                for g = 1, (e.groupCapacity or 0) do
                    pcall(e.container.SetAuraGroupFilterString, e.container, "g" .. g, filter)
                end
                if e.hasTail then
                    pcall(e.container.SetAuraGroupFilterString, e.container, "gtail", filter)
                end
                pcall(e.container.UpdateAllAuras, e.container)
            end
        end
    end
    self._appliedFilter = filter
end

function methods:GetDisplaySpell()
    if self.config.custom then
        local ids = self:GetDBVal("ids")
        return ids and ids[1] or nil
    end
    return self.config.displaySpell
end

function methods:GetLabel()
    if self.config.custom then
        local entry = self:GetCustomEntry()
        return (entry and entry.name) or "Custom"
    end
    return self.config.label
end

-- Applied on refresh: group-kind customs sort their slot (circle style)
-- by Expiration = soonest-expiring shown, like External Defensives.
-- PTR-VERIFY: SetAuraSlotSortMethod live call (listed live in API ref).
function methods:ApplyCustomIdentity()
    if not self.config.custom then return end
    if ns.IsRestricted() then
        ns.deferredRefresh = true
        return
    end
    local entry = self:GetCustomEntry()
    local isGroup = (entry and entry.kind == "group") and true or false
    if self._customIsGroup == isGroup then return end
    self._customIsGroup = isGroup
    local method
    if isGroup then
        method = (AuraContainerSortMethod and AuraContainerSortMethod.Expiration) or 4
    else
        method = (AuraContainerSortMethod and AuraContainerSortMethod.Default) or 0
    end
    for i = 1, MAX_ELEMENTS do
        local e = self.elements[i]
        if e and e._unit then
            -- Use the container the slot ACTUALLY lives in (e._unit), not the
            -- tracker's current unit — they differ mid unit-change and after
            -- the entry is deleted.
            local sc = GetSlotContainer(e._unit)
            pcall(sc.SetAuraSlotSortMethod, sc, e.slotKey, method)
        end
    end
end

function methods:SetDBVal(key, val)
    ns.addon.db.profile[self.config.dbPrefix .. key] = val
end

-- Element config list (guaranteed to exist by Core's migration).
function methods:GetElementsDB()
    return ns.addon.db.profile[self.config.dbPrefix .. "elements"] or {}
end

-- Container flow layout for a group-backed element (live setters, a
-- proven call class). Per-group spacing/order is
-- applied in ApplyGroupAssignments. el.maxFrames only drives the
-- PREVIEW sample count: with per-spell 1-frame groups an engine-side
-- TOTAL cap across groups does not exist.
function methods:ApplyGroupLayout(i, el)
    local e = self.elements[i]
    if not (e and e.hasGroup) then return end
    local g = GROW[EffectiveGrowDirection(el)] or GROW.right
    local axisEnum = (AnchorUtil and AnchorUtil.FlowLayoutAxis)
        or { Horizontal = 0, Vertical = 1 }
    local c = e.container
    c:ClearAllPoints()
    c:SetPoint(g.anchor, e.holder, g.anchor)
    c:SetFlowLayoutAxis(g.axis == 0 and axisEnum.Horizontal or axisEnum.Vertical)
    c:SetFlowLayoutAnchorPoint(g.anchor)
    c:SetFlowLayoutGrowthDirection(g.h, g.v)
end

-- Group Type: Ordered = fan-out follows Tracked Buffs order. The
-- built-in spellList groups (External/Personal Defensives) are ALWAYS
-- Unordered (owner decision) — only custom Aura Groups may order.
-- Ability/Item groups have NO Unordered mode: their tracked set is fixed and
-- known (unlike a dynamic set of active auras), the Show When gate already
-- collapses gaps per-icon, and Max Frames caps the count — so "Unordered"
-- would add nothing. They are ALWAYS ordered (list order, reorderable).
function methods:IsOrdered()
    if self.config.spellList then return false end
    if self:IsIDGroupKind() then return true end
    return self:GetDBVal("groupOrdered") and true or false
end

-- Ordered, CHECKED id-sets for the group fan-out. spellList trackers
-- honor the Tracked Buffs order override (profile buffOrder — same
-- merge as Options' OrderedSpellList); customs use their ids array
-- order with the checkbox map.
function methods:GetOrderedActiveIDSets()
    local cfg = self.config
    local out, keys = {}, {}
    if cfg.spellList then
        local list = cfg.spellList
        local display = list
        local order = ns.addon.db.profile[cfg.dbPrefix .. "buffOrder"]
        if type(order) == "table" then
            local byKey, seen = {}, {}
            display = {}
            for _, sp in ipairs(list) do byKey[sp.key] = sp end
            for _, k in ipairs(order) do
                local sp = byKey[k]
                if sp and not seen[k] then
                    display[#display + 1] = sp
                    seen[k] = true
                end
            end
            for _, sp in ipairs(list) do
                if not seen[sp.key] then display[#display + 1] = sp end
            end
        end
        local sel = self:GetDBVal("spells") or {}
        for _, sp in ipairs(display) do
            if sel[sp.key] then
                out[#out + 1] = sp.ids
                keys[#keys + 1] = sp.key
            end
        end
    elseif cfg.custom then
        local entry = self:GetCustomEntry()
        if entry then
            local ids = self:GetDBVal("ids") or {}
            for j = 1, #ids do
                out[#out + 1] = { ids[j] }
                keys[#keys + 1] = ids[j]
            end
        end
    end
    return out, keys
end

-- Per-buff bar color override (Ordered mode only — group gIdx renders
-- ONE known buff there; Unordered frames get engine-assigned auras
-- whose identity is secret). profile[prefix.."buffColors"] is keyed by
-- spellList entry key or custom spell id; nil falls back to el.color.
function methods:GetOrderedBarColor(gIdx)
    if not gIdx or not self:IsOrdered() then return nil end
    local bc = self:GetDBVal("buffColors")
    if type(bc) ~= "table" then return nil end
    local _, keys = self:GetOrderedActiveIDSets()
    local k = keys[gIdx]
    if k == nil then return nil end
    return bc[k]
end

-- Apply the current Group Type: Unordered claims the shared maingroup
-- (union candidates, engine sort, live Max cap) and parks the per-spell
-- groups; Ordered assigns checked spells to per-spell groups (candidate
-- include + layoutIndex = position) and parks maingroup. Change-guarded
-- by signature; returns true when a rebuild is needed (option setters
-- don't re-evaluate existing assignments — plan §0).
-- PTR-VERIFY: live layoutIndex re-ordering via SetAuraGroupLayout, and
-- groupSpacing as the inter-button gap for 1-frame groups.
-- pcall wrapper for the live group setters. The engine may DENY these
-- post-PEW / in combat (open PTR-VERIFY) — an unguarded denial throws
-- inside engine Lua with a secret-redacted stack (useless error spam),
-- and worse: a PARTIAL apply can leave a group's filter ACTIVE while
-- its candidate include set didn't stick = MATCH-ALL (every 1-frame
-- group duplicates the same aura; secret combat auras then crash the
-- engine's pool bookkeeping). Track failures so the caller can park
-- and retry from a safe context instead.
local function SafeGroupCall(state, c, method, ...)
    local ok, err = pcall(c[method], c, ...)
    if not ok then
        state.failed = true
        if not ns._groupDenyPrinted then
            ns._groupDenyPrinted = true
            print("|cff11ace9HET|r group setter denied (" .. method .. "): "
                .. tostring(err) .. " — parked; will re-apply out of combat.")
        end
    end
    return ok
end

function methods:ApplyGroupAssignments(i, groupsLive)
    local e = self.elements[i]
    if not (e and e.hasGroup) then return false end
    if ns.IsRestricted() then
        ns.deferredRefresh = true
        return false
    end
    if ns.debugSuppress.groups then groupsLive = false end
    local el = self:GetElementsDB()[i]
    local spacing = (el and el.spacing) or 2
    local ordered = self:IsOrdered()
    local sets = self:GetOrderedActiveIDSets()
    local capacity = e.groupCapacity
    local maxN = (el and el.maxFrames) or 3
    -- Single-active groups (config.singleActive: Time Spiral, Bloodlust,
    -- Spatial Paradox) display at most ONE frame no matter how many of their
    -- tracked buffs are up (Spatial Paradox can briefly have both its self and
    -- ally-side auras at once). Hard engine-side cap via the same
    -- SetAuraGroupMaxFrameCount the Unordered cap uses; el.maxFrames is
    -- ignored (its option is hidden for these groups).
    if self.config.singleActive then maxN = 1 end

    local sigParts = { groupsLive and "1" or "0", ordered and "O" or "U",
        spacing, maxN }
    for g = 1, #sets do
        sigParts[#sigParts + 1] = sets[g][1]
    end
    local sig = table.concat(sigParts, ":")
    if sig == e._assignSig then return false end
    e._assignSig = sig

    local c = e.container
    local state = { failed = false }
    -- On ANY failure: forget the sig (so the next UpdateGating from a
    -- safe context re-applies) and flag the deferred retry.
    local function Finish()
        if state.failed then
            e._assignSig = nil
            ns.deferredRefresh = true
        end
        return true
    end

    if not ordered then
        -- Unordered: everything through the shared maingroup.
        for g = 1, capacity do
            SafeGroupCall(state, c, "SetAuraGroupCandidateFilters", "g" .. g, DORMANT_CANDIDATES)
        end
        if e.hasTail then
            SafeGroupCall(state, c, "SetAuraGroupCandidateFilters", "gtail", DORMANT_CANDIDATES)
        end
        if groupsLive and #sets > 0 then
            local union = {}
            for g = 1, #sets do
                local ids = sets[g]
                for j = 1, #ids do union[ids[j]] = true end
            end
            local okCand = SafeGroupCall(state, c, "SetAuraGroupCandidateFilters",
                "maingroup", { includeSpellIDs = union })
            SafeGroupCall(state, c, "SetAuraGroupLayout", "maingroup",
                { elementSpacing = spacing })
            SafeGroupCall(state, c, "SetAuraGroupMaxFrameCount", "maingroup", maxN)
            if not okCand then
                -- Never leave a half-applied group live: re-park.
                SafeGroupCall(state, c, "SetAuraGroupCandidateFilters",
                    "maingroup", DORMANT_CANDIDATES)
            end
        else
            SafeGroupCall(state, c, "SetAuraGroupCandidateFilters", "maingroup", DORMANT_CANDIDATES)
        end
        return Finish()
    end

    -- Ordered: per-spell groups; shared maingroup parks.
    SafeGroupCall(state, c, "SetAuraGroupCandidateFilters", "maingroup", DORMANT_CANDIDATES)
    for g = 1, capacity do
        local ids = sets[g]
        local gkey = "g" .. g
        if ids and groupsLive then
            local set = {}
            for j = 1, #ids do set[ids[j]] = true end
            local okCand = SafeGroupCall(state, c, "SetAuraGroupCandidateFilters",
                gkey, { includeSpellIDs = set })
            SafeGroupCall(state, c, "SetAuraGroupLayout", gkey, {
                elementSpacing = spacing,
                groupSpacing = spacing,
                layoutIndex = g,
            })
            if not okCand then
                SafeGroupCall(state, c, "SetAuraGroupCandidateFilters",
                    gkey, DORMANT_CANDIDATES)
            end
        else
            SafeGroupCall(state, c, "SetAuraGroupCandidateFilters", gkey, DORMANT_CANDIDATES)
        end
    end
    if e.hasTail then
        -- Custom ids beyond the per-spell capacity: unordered overflow,
        -- laid out after every ordered group.
        if groupsLive and #sets > capacity then
            local set = {}
            for g = capacity + 1, #sets do
                local ids = sets[g]
                for j = 1, #ids do set[ids[j]] = true end
            end
            local okCand = SafeGroupCall(state, c, "SetAuraGroupCandidateFilters",
                "gtail", { includeSpellIDs = set })
            SafeGroupCall(state, c, "SetAuraGroupLayout", "gtail", {
                elementSpacing = spacing,
                groupSpacing = spacing,
                layoutIndex = 1000,
            })
            if not okCand then
                SafeGroupCall(state, c, "SetAuraGroupCandidateFilters",
                    "gtail", DORMANT_CANDIDATES)
            end
        else
            SafeGroupCall(state, c, "SetAuraGroupCandidateFilters", "gtail", DORMANT_CANDIDATES)
        end
    end
    return Finish()
end

-- Flip the single slot between its Auto (engine-bound) and custom (never-bound)
-- icon regions after an Icon Source change. Both regions are PERMANENT (created
-- in BuildButtonRegions), so this allocates nothing — it just repoints e.icon,
-- moves the mask off the outgoing region, and hides it; the next ApplyElementStyle
-- pass re-applies mask/border/texcoord to the incoming region. Unrestricted only
-- (the option setters run out of combat); the bound region was passed to SetIcon
-- ONCE at creation and stays bound (the engine draws the aura icon into it even
-- while it's the hidden one — harmless).
function methods:RebindSlotIcon(i)
    -- PAIR elements are aura displays with a real slot button — allowed.
    if (self:IsCustomKind() or self:IsIDKind()) and not self:IsPairElement(i) then return end
    local e = self.elements[i]
    if not (e and e.button) then return end
    if self:IsMultiAura() then return end
    -- Slots built before this two-region scheme won't have the override region;
    -- nothing to flip (they'll pick it up on the next full rebuild/reload).
    if not (e._iconBoundRegion and e._iconOverrideRegion) then return end
    local el = self:GetElementsDB()[i]
    local override = ns.ResolveAuraIcon(el)
    local want = override and e._iconOverrideRegion or e._iconBoundRegion
    if e.icon ~= want then
        -- Move the rounded mask off the outgoing region and hide it; the style
        -- pass re-adds the mask to the incoming region.
        local outgoing = e.icon
        if outgoing then
            if e.iconMask then pcall(outgoing.RemoveMaskTexture, outgoing, e.iconMask) end
            outgoing:Hide()
        end
        e.icon = want
    end
    if override then
        e._iconOverridden = true
        e._iconOverrideRegion:SetTexture(override)
    else
        e._iconOverridden = nil
    end
end

function methods:ApplyElementStyle(i)
    local e = self.elements[i]
    if not e then return end
    -- Custom-kind AND ID-kind have no engine button/regions to style (e.button
    -- is nil); their art is drawn by RenderCustomArt / RenderIDArt. Styling here
    -- would nil-index in StyleButtonRegions. PAIR elements are the exception:
    -- they're aura displays and style like one.
    if (self:IsCustomKind() or self:IsIDKind()) and not self:IsPairElement(i) then return end
    local el = self:GetElementsDB()[i]
    -- PAIR elements mirror their PARENT element's geometry (style + sizes)
    -- so the overlay sits exactly on the cooldown art; every other style
    -- field (colors, borders, swipe, texts) stays the pair's own. Synced
    -- here — the one place holder sizing reads these — on the settings
    -- pipeline only (never a combat path).
    -- PTR-VERIFY: bar-pair pixel alignment over the ID bar (the aura bar
    -- lays out inside the slot BUTTON, centered on the holder; the ID bar
    -- draws on the holder directly — both paths were mirrored deliberately,
    -- but check the icon-square end for all four barIconPosition values).
    if el and el.pairFor then
        local pIdx = self:PairParentIndex(i)
        local pEl = pIdx and self:GetElementsDB()[pIdx]
        if pEl then
            el.style           = el.pairFor
            el.iconSize        = pEl.iconSize
            el.barOrientation  = pEl.barOrientation
            el.barWidth        = pEl.barWidth
            el.barHeight       = pEl.barHeight
            el.barShowIcon     = pEl.barShowIcon
            el.barIconPosition = pEl.barIconPosition
            -- The "Border" animations mirror the PARENT's border
            -- style/thickness so the animated border overlays the parent's
            -- static one exactly (drawn in StyleButtonRegions).
            if el.pairFor == "bar" or el.pairFor == "icon" then
                el.pairBorderStyle     = BorderType(el.pairFor, pEl)
                el.pairBorderThickness = pEl.borderThickness or 1
            end
        end
    end
    local style = el and el.style

    -- Icon Source changed (setter flags the DB element): replace the icon region
    -- with a fresh one carrying the correct bind decision, BEFORE styling re-does
    -- the mask/texcoord on it. Consumed once.
    if el and el._iconBindDirty then
        el._iconBindDirty = nil
        self:RebindSlotIcon(i)
    end

    -- Holder sizing (represents ONE button; group elements grow from it).
    if style == "icon" then
        local sz = el.iconSize or 40
        e.holder:SetSize(sz, sz)
    elseif style == "bar" then
        -- The holder IS the bar: barWidth x barHeight, independent of the icon.
        -- The icon square hangs outside it, so toggling "Show Icon" never moves
        -- the bar and never resizes the anchor/drag rect.
        e.holder:SetSize(el.barWidth or 200, el.barHeight or 20)
    elseif style == "circle" then
        local size = (el.circleRadius or 80) * 2
        e.holder:SetSize(size, size)
    elseif style == "texture" then
        e.holder:SetSize(el.textureWidth or 128, el.textureHeight or 128)
    elseif style == "text" then
        local sz = LargestTextSize(el)
        e.holder:SetSize(sz * 4, sz * 1.6)
    end

    -- Slot regions are only shown when the slot path is in use.
    local usesGroup = UsesGroup(self, e, style)
    StyleButtonRegions(e, e.button, not usesGroup and style or nil, el or {},
        nil, true)  -- isSlot = true (single slot; enables Icon Source override)

    if e.hasGroup then
        for j = 1, #e.groupButtons do
            local btn = e.groupButtons[j]
            StyleButtonRegions(btn._het_r, btn, usesGroup and style or nil,
                el or {}, self:GetOrderedBarColor(btn._hetGroupIdx))
        end
        self:ApplyGroupLayout(i, el)
    end
end

function methods:ApplyStyle()
    -- Custom-kind AND ID-kind draw their own holder art (UpdateCustomArt /
    -- UpdateIDArt); no engine buttons to style here — unless PAIR elements
    -- exist (ApplyElementStyle self-gates per element).
    if (self:IsCustomKind() or self:IsIDKind()) and not self:HasPairElements() then return end
    for i = 1, MAX_ELEMENTS do
        self:ApplyElementStyle(i)
    end
end

-- Does this tracker fan out over several buffs at once? Mirrors the
-- wantsGroups test in CreateElement. Stack tracking is single-buff only.
-- ID-kind groups (abilitygroup/itemgroup) also fan out, but they render on the
-- holder path (RenderIDArt), NOT engine group buttons — so this stays false for
-- them (it gates the aura slot/group machinery, which ID kinds never use).
function methods:IsMultiAura()
    if self.config.spellList then return true end
    if self.config.custom then
        local entry = self:GetCustomEntry()
        return (entry and entry.kind == "group") and true or false
    end
    return false
end

-- Re-point every bar StatusBar at the property its element now tracks,
-- and retry any stack-count binding that was denied. The ONLY path that
-- rebinds an already-initialized button, and it runs exclusively from the
-- settings pipeline — hence the restriction gate, matching
-- UpdateSpellFilter/UpdateGating. Change-guarded inside ApplyBarBinding /
-- ApplyStackBinding, so an unrelated refresh costs one compare per
-- button. MUST run before ApplyStyle: styling shows the bar, the binding
-- decides what fills it.
function methods:UpdateAuraBindings()
    -- Custom-kind AND ID-kind have no engine buttons (e.button is nil); nothing
    -- to bind (ID kinds drive their swipe/text from RenderIDArt instead) —
    -- unless PAIR elements exist (the e.button loop guard below skips the
    -- buttonless ID/custom elements either way).
    if (self:IsCustomKind() or self:IsIDKind()) and not self:HasPairElements() then return end
    if ns.IsRestricted() then
        ns.deferredRefresh = true
        return
    end
    local allowStacks = not self:IsMultiAura()
    local els = self:GetElementsDB()
    for i = 1, MAX_ELEMENTS do
        local e = self.elements[i]
        if e and e.button then
            local el = els[i] or {}
            ApplyBarBinding(e, e.button, el, allowStacks)
            ApplyStackBinding(e, e.button, el)
            ApplyNameBinding(e, e.button)
            ApplyDurationTextBinding(e, e.button, el)
            if e.hasGroup then
                for j = 1, #e.groupButtons do
                    local btn = e.groupButtons[j]
                    ApplyBarBinding(btn._het_r, btn, el, false)
                    ApplyStackBinding(btn._het_r, btn, el)
                    ApplyNameBinding(btn._het_r, btn)
                    ApplyDurationTextBinding(btn._het_r, btn, el)
                end
            end
        end
    end
end

-- Shared mouse-state stamp for engine aura buttons (slot + group pool +
-- pair slots). Mirrors BuzzardFrames' ContainerFactory ApplyTooltipStyle
-- + AuraTooltip pattern, which is PTR-proven non-blocking on the same
-- engine template: EnableMouse alone is NOT sufficient on 12.1
-- AuraButtons — motion input drives the native tooltip hit-testing as a
-- separate flag, and clicks are NEVER wanted (tooltip-on still keeps
-- SetMouseClickEnabled false, so an enabled tooltip can't eat world
-- clicks). SetHideTooltipInCombat(true) belts the off state. All
-- pcall'd: setters can be denied on restricted buttons post-PEW (the
-- option text notes /reload guarantees the toggle).
local function ApplyButtonMouse(btn, show)
    pcall(btn.EnableMouse, btn, show)
    if btn.SetMouseMotionEnabled then
        pcall(btn.SetMouseMotionEnabled, btn, show)
    end
    -- AFTER EnableMouse (EnableMouse(true) raises both sub-flags).
    if btn.SetMouseClickEnabled then
        pcall(btn.SetMouseClickEnabled, btn, false)
    end
    if not show and btn.SetHideTooltipInCombat then
        pcall(btn.SetHideTooltipInCombat, btn, true)
    end
end

-- Per-ELEMENT "Show Tooltip" (default off): buttons are mouse-inert
-- unless enabled.
-- PTR-VERIFY: EnableMouse on restricted buttons post-PEW (pcall'd; the
-- option text notes /reload guarantees it).
function methods:ApplyTooltip()
    -- Custom-kind AND ID-kind have no engine buttons; aura tooltips don't
    -- apply — except on PAIR elements (e.button loop guard below).
    if (self:IsCustomKind() or self:IsIDKind()) and not self:HasPairElements() then return end
    local els = self:GetElementsDB()
    for i = 1, MAX_ELEMENTS do
        local e = self.elements[i]
        if e and e.button then
            local el = els[i]
            local show = (el and el.style and el.style ~= "sound"
                and el.showTooltip) and true or false
            ApplyButtonMouse(e.button, show)
            if e.hasGroup then
                for j = 1, #e.groupButtons do
                    ApplyButtonMouse(e.groupButtons[j], show)
                end
            end
        end
    end
end

-- Dynamic-spell trackers: the active include set is the union of the
-- CHECKED spells' buff IDs (spellList trackers) or ALL of the claimed
-- entry's IDs (custom shells — the Tracked Buffs list has no
-- checkboxes; removal is the only exclusion). Unclaimed shells return
-- empty → _noSpells parking.
function methods:GetActiveIncludeSet()
    if self.config.custom then
        local entry = self:GetCustomEntry()
        if not entry then return {} end
        local ids = self:GetDBVal("ids") or {}
        local set = {}
        for j = 1, #ids do
            set[ids[j]] = true
        end
        return set
    end
    if not self.config.spellList then
        return self.includeSpellIDs
    end
    local sel = self:GetDBVal("spells") or {}
    local set = {}
    for _, sp in ipairs(self.config.spellList) do
        if sel[sp.key] then
            for j = 1, #sp.ids do
                set[sp.ids[j]] = true
            end
        end
    end
    return set
end

-- Push the checked-spell include set to every element's slot AND group
-- via the LIVE candidateFilters setters (API ref). Change-guarded by
-- signature + forced UpdateAllAuras (setters don't re-evaluate
-- assignments, plan §0). MUST run before UpdateGating: an EMPTY include
-- table is MATCH-ALL (plan §0), so zero checked spells sets _noSpells
-- and everything gets PARKED instead of filtered.
-- Live candidate set for the single-aura slot: union for dynamic
-- trackers, the static buffSpellIDs otherwise.
function methods:GetSlotCandidates()
    if self.config.spellList or self.config.custom then
        return { includeSpellIDs = self:GetActiveIncludeSet() }
    end
    return { includeSpellIDs = self.includeSpellIDs }
end

function methods:UpdateSpellFilter()
    if not (self.config.spellList or self.config.custom) then return end
    -- Custom-kind has no aura slots; nothing to filter.
    if self:IsCustomKind() then self._noSpells = true; return end
    -- ID kinds (ability/item) render on the holder path (cooldown swipe +
    -- charge/stack text), never the engine slot; no aura filter to push.
    if self:IsIDKind() then self._noSpells = true; return end
    if ns.IsRestricted() then
        ns.deferredRefresh = true
        return
    end
    local set = self:GetActiveIncludeSet()
    self._noSpells = (next(set) == nil)
    if self._noSpells then return end
    local ids = {}
    for id in pairs(set) do ids[#ids + 1] = id end
    table.sort(ids)
    local sig = table.concat(ids, ",")
    if sig == self._spellFilterSig then return end
    self._spellFilterSig = sig
    -- Union set feeds the single-aura slot (circle/text); the per-spell
    -- groups get their own candidate sets in ApplyGroupAssignments. Each slot
    -- lives in its own e._unit container (differs mid unit-change).
    local touched = {}
    for i = 1, MAX_ELEMENTS do
        local e = self.elements[i]
        if e and e._slotLive and e._unit then
            local sc = GetSlotContainer(e._unit)
            sc:SetAuraSlotCandidateFilters(e.slotKey, { includeSpellIDs = set })
            touched[sc] = true
        end
    end
    for sc in pairs(touched) do
        sc:UpdateAllAuras()
    end
end

-- Load Conditions (Load Conditions subtab).
--
-- Maps the player's current instance to one of the six checkbox buckets.
-- Non-delve scenarios fall back to Open World. difficultyID 208 == Delves
-- (DifficultyID table, current as of 12.0.1).
function ns.CurrentInstanceBucket()
    local inInstance, instanceType = IsInInstance()
    if not inInstance or instanceType == "none" then return "world" end
    if instanceType == "party" then return "dungeon" end
    if instanceType == "raid"  then return "raid" end
    if instanceType == "arena" then return "arena" end
    if instanceType == "pvp"   then return "bg" end
    if instanceType == "scenario" then
        local difficultyID = select(3, GetInstanceInfo())
        if difficultyID == 208 then return "delve" end
        return "world"
    end
    return "world"
end

-- Instance-type condition. Gates the SLOT (safe: instance type only changes
-- across loading screens, i.e. out of combat, so the normal PEW refresh
-- reparks unrestricted). A nil table, or a nil entry, counts as checked —
-- that is how "all six checked by default" is expressed without seeding
-- defaults.
function methods:LoadInstanceMet()
    if not self:GetDBVal("loadInstance") then return true end
    -- Storage convention (all three checkbox conditions share it): an entry is
    -- checked ONLY when it is explicitly true. Absent = UNCHECKED, so a
    -- condition that is switched on with nothing ticked matches nothing and the
    -- tracker never loads — the options tree flags that state with a "!".
    local t = self:GetDBVal("loadInstanceTypes")
    if t == nil then return false end
    return t[ns.CurrentInstanceBucket()] == true
end

-- Combat condition. Both conditions fold into slot liveness; the combat
-- edge is re-applied in combat by ReapplySlotGating (below), which only
-- touches the slot CONTAINER (combat-safe), never the engine button.
function methods:LoadCombatMet()
    if not self:GetDBVal("loadCombat") then return true end
    -- Event-derived combat state: the REGEN
    -- handlers in Core record ns.inCombat from the EVENT itself, never from
    -- InCombatLockdown() during the transition dispatch — the API is not
    -- reliable at the edge. Fallback covers calls before OnEnable seeds it.
    local inCombat = ns.inCombat
    if inCombat == nil then inCombat = InCombatLockdown() and true or false end
    if (self:GetDBVal("loadCombatMode") or "in") == "out" then
        return not inCombat
    end
    return inCombat
end

-- Spell usable/unusable condition (Load Conditions "Spell Usable/Unusable"):
-- each listed spell is checked against its own per-row direction —
-- "Usable" (the default) needs the spell usable AND off cooldown, "Unusable"
-- needs the opposite. loadSpellMode "all" (default) requires every entry to
-- match, "any" requires at least one. State is
-- maintained event-driven by Core's spell-ready watcher (ns.spellReady);
-- this read is table lookups only,
-- no API calls on this path. nil cache entries (unseeded / placeholder 0
-- IDs) fail OPEN in both modes and BOTH directions.
--
-- Direction lives in loadSpellUsable[i] (array parallel to loadSpellIDs);
-- only an explicit `false` means Unusable, so absent/nil = Usable — old
-- profiles keep their existing behaviour with no migration.
function methods:LoadSpellMet()
    if not self:GetDBVal("loadSpell") then return true end
    local ids = self:GetDBVal("loadSpellIDs")
    local ready = ns.spellReady
    if type(ids) ~= "table" or not ready then return true end
    local want = self:GetDBVal("loadSpellUsable")  -- may be nil (all "usable")
    local anyMode = self:GetDBVal("loadSpellMode") == "any"
    local sawValid = false
    for i = 1, #ids do
        local id = tonumber(ids[i])
        if id and id > 0 then
            -- Unusable row: only a KNOWN-ready spell fails it (nil = open).
            -- Usable row: only a KNOWN-not-ready spell fails it.
            local pass
            if want and want[i] == false then
                pass = ready[id] ~= true
            else
                pass = ready[id] ~= false
            end
            if anyMode then
                sawValid = true
                if pass then return true end
            elseif not pass then
                return false
            end
        end
    end
    return not (anyMode and sawValid)
end

-- Item usable/unusable condition (Load Conditions "Item Usable/Unusable"):
-- each listed item is checked against its own per-row direction — "Usable"
-- (the default) needs the item usable AND off cooldown, "Unusable" needs the
-- opposite. loadItemMode "all" (default) requires every entry to match, "any"
-- requires at least one. State is maintained
-- event-driven by Core's item-ready watcher (ns.itemReady / ns.itemEquipped);
-- this read is table lookups only, no API calls on this path. nil ready-cache
-- entries (unseeded / placeholder 0 IDs) fail OPEN in both modes and BOTH
-- directions.
--
-- Direction lives in loadItemUsable[i] (array parallel to loadItemIDs); only
-- an explicit `false` means Unusable, so absent/nil = Usable — old profiles
-- keep their existing behaviour with no migration.
--
-- Per-item "Require Equipped" (loadItemEquipped[i], ON by default): when set,
-- the item ALSO has to be currently equipped to count as ready. Stored as an
-- array parallel to loadItemIDs; only an explicit `false` disables it, so
-- absent/nil = required (the default). Combines with ready as an AND — so on
-- an Unusable row, an unequipped item counts as unusable.
-- Per-item pass: ready (nil fails open) AND, if Require Equipped applies,
-- equipped. Require Equipped only applies to EQUIPPABLE items -- for a
-- consumable (potion) it's meaningless and the toggle is hidden, so never gate
-- those on equipped state (they can't be equipped).
-- File-scope with its captures passed in, NOT a closure built inside
-- LoadItemMet: this sits on the load gate, and ReapplySlotGating / UpdateGating
-- each call LoadItemMet again for `gateShown`, so a per-tracker closure was
-- allocated twice per gating pass. Same split as RoleTypesMetFor above, for the
-- same reason.
local function ItemPasses(i, id, ready, reqEq, want, equipped, equippable)
    local requireEquipped = equippable[id] ~= false
        and not (reqEq and reqEq[i] == false)
    local notUsable = ready[id] == false
        or (requireEquipped and equipped[id] == false)
    if want and want[i] == false then
        -- Unusable row: passes unless the item is KNOWN usable. An unseeded
        -- ready cache (nil) is unknown, so it fails open.
        return notUsable or ready[id] == nil
    end
    return not notUsable
end

function methods:LoadItemMet()
    if not self:GetDBVal("loadItem") then return true end
    local ids = self:GetDBVal("loadItemIDs")
    local ready = ns.itemReady
    if type(ids) ~= "table" or not ready then return true end
    local reqEq = self:GetDBVal("loadItemEquipped")  -- may be nil (all required)
    local want = self:GetDBVal("loadItemUsable")     -- may be nil (all "usable")
    local equipped = ns.itemEquipped or {}
    local equippable = ns.itemEquippable or {}
    local anyMode = self:GetDBVal("loadItemMode") == "any"
    local sawValid = false
    for i = 1, #ids do
        local id = tonumber(ids[i])
        if id and id > 0 then
            local passes =
                ItemPasses(i, id, ready, reqEq, want, equipped, equippable)
            if anyMode then
                sawValid = true
                if passes then return true end
            elseif not passes then
                return false
            end
        end
    end
    return not (anyMode and sawValid)
end

-- Role/Spec condition BODIES, with the enable toggle ALREADY known to be on.
-- Split out of LoadRoleMet / LoadSpecMet so the combined gate below can read
-- loadRole / loadSpec ONCE each instead of three times each (the old
-- LoadRoleSpecMet read both toggles, then called helpers that re-read them --
-- six GetDBVal string concats per pass on a path LoadConditionsMet drives up
-- to once per GCD).
-- Profile-driven (prefix, not tracker object) so the options Spec Filter can
-- evaluate a tracker whose object doesn't exist yet — a custom added in combat
-- is a tree stub until the refresh pipeline builds it.
local function RoleTypesMetFor(p, prefix, role)
    if role == nil then return true end  -- unknown role: fail open
    local t = p[prefix .. "loadRoleTypes"]
    if t == nil then return false end  -- absent = nothing checked
    return t[role] == true
end
local function SpecTypesMetFor(p, prefix, spec)
    if spec == nil then return true end  -- unknown spec: fail open
    local t = p[prefix .. "loadSpecTypes"]
    if t == nil then return false end  -- absent = nothing checked
    return t[spec] == true
end

-- The combined Role/Spec gate evaluated against ANY (role, spec) pair, not just
-- the player's current one. ONE implementation, two callers:
--   * methods:LoadRoleSpecMet — the live gate, passes ns.playerRole/playerSpec.
--   * ns.MatchesSpecFilter    — the options "Filter By Spec", passes the
--                               selected spec and the role that spec has.
-- Keeping them on the same function is the point: the filter is supposed to
-- answer "what would load if I were in spec X", so any drift between the two
-- would show the user a list that doesn't match what actually loads.
--
-- OR semantics with both toggles on (owner-confirmed): the tracker loads when
-- EITHER the role check or the spec check passes. With one toggle on it behaves
-- as that single condition; with neither it is global and always passes.
-- File-local with an ns. alias: methods:LoadRoleSpecMet calls this on the load
-- path, and a local is a register read where `ns.RoleSpecMetFor` is a hash
-- lookup in the namespace table every single call.
local function RoleSpecMetFor(prefix, role, spec)
    local p = ns.addon.db.profile
    local roleOn = p[prefix .. "loadRole"] and true or false
    local specOn = p[prefix .. "loadSpec"] and true or false
    if roleOn and specOn then
        return RoleTypesMetFor(p, prefix, role) or SpecTypesMetFor(p, prefix, spec)
    end
    if roleOn then return RoleTypesMetFor(p, prefix, role) end
    if specOn then return SpecTypesMetFor(p, prefix, spec) end
    return true
end
ns.RoleSpecMetFor = RoleSpecMetFor

-- Options "Filter By Spec". ns.specFilter is a specID, or nil for "All Specs".
-- ns.SPEC_ROLE (built by Options.lua from SPEC_TYPES) maps specID -> role, so a
-- tracker filtered by ROLE alone still matches when the selected spec has that
-- role. Session state, never saved: it re-seeds from the player's spec at login
-- and re-snaps on every spec change (see Core's UpdatePlayerSpecRole).
function ns.MatchesSpecFilter(prefix)
    local sf = ns.specFilter
    if sf == nil then return true end  -- "All Specs"
    local role = ns.SPEC_ROLE and ns.SPEC_ROLE[sf]
    if role == nil then
        -- Spec not in SPEC_TYPES (table out of date after a patch adds one).
        -- Evaluate the SPEC leg only: handing a nil role to RoleSpecMetFor
        -- would fail OPEN on the role side, and with both toggles set the OR
        -- would then swallow the spec restriction entirely — every role+spec
        -- gated tracker would match every spec, silently.
        local p = ns.addon.db.profile
        if not p[prefix .. "loadSpec"] then return true end
        return SpecTypesMetFor(p, prefix, sf)
    end
    return RoleSpecMetFor(prefix, role, sf)
end

-- Role condition. Combat-free (role only changes with a spec change, which
-- is out of combat and fires PLAYER_SPECIALIZATION_CHANGED -> RefreshAll). An
-- entry counts as checked ONLY when explicitly true, so a nil table or a nil
-- entry is UNCHECKED — same convention as Spec and Instance Type. Switching
-- the condition on with nothing ticked therefore matches nothing; the "!" in
-- the options tree is what flags that.
-- ns.playerRole is the cached current role (Core), TANK/HEALER/DAMAGER.
-- NOTE: this is a RAW read — the OR combination with Spec lives in
-- LoadRoleSpecMet below, NOT here. Callers wanting the combined gate must
-- use LoadRoleSpecMet.
function methods:LoadRoleMet()
    if not self:GetDBVal("loadRole") then return true end
    return RoleTypesMetFor(ns.addon.db.profile, self.config.dbPrefix, ns.playerRole)
end

-- Spec condition. Combat-free, same default convention as Role. ns.playerSpec
-- is the cached current specID (Core). RAW read — see LoadRoleSpecMet for the
-- OR combination with Role.
function methods:LoadSpecMet()
    if not self:GetDBVal("loadSpec") then return true end
    return SpecTypesMetFor(ns.addon.db.profile, self.config.dbPrefix, ns.playerSpec)
end

-- Combined Role/Spec gate (OR semantics, confirmed with owner): with BOTH
-- toggles enabled the tracker loads when EITHER the current role is checked
-- OR the current spec is checked. With only one toggle enabled it behaves as
-- that single condition (the other returns true and drops out of the OR).
-- With neither enabled both return true. This whole result then ANDs with
-- the other conditions in LoadConditionsMet.
--
-- Each toggle key is read EXACTLY ONCE (inside RoleSpecMetFor); the OR still
-- short-circuits, so a passing role check skips the loadSpecTypes read.
function methods:LoadRoleSpecMet()
    return RoleSpecMetFor(self.config.dbPrefix, ns.playerRole, ns.playerSpec)
end

-- State conditions (all default ON, stored nil = on / false = off — no
-- migration; existing profiles get them on). Each passes when its toggle is off
-- (== false); otherwise it requires the player NOT be in the given state. The
-- underlying APIs are non-secret booleans, safe to read in combat; mount and
-- pet-battle only change out of combat. Live-updated by Core's mount/pet-battle
-- events (RefreshLoadGating). (Like Combat Status, these gate the VISUAL/slot
-- liveness; engine aura-sound registration keys off IsLiveNoCombat and is
-- unaffected — by design.)
-- NOTE: there is deliberately NO vehicle condition here. In a vehicle the
-- engine feeds the containers junk that filter parking cannot clear, so
-- vehicles are handled globally by ApplyVehicleGate (container alpha), not as
-- a per-tracker load condition — the old "Not in Vehicle" toggle was removed
-- because the gate made it a no-op (leftover loadNotVehicle DB keys are
-- harmless and unread).
function methods:LoadNotPetBattleMet()
    if self:GetDBVal("loadNotPetBattle") == false then return true end
    return not (C_PetBattles and C_PetBattles.IsInBattle and C_PetBattles.IsInBattle())
end
function methods:LoadNotMountedMet()
    if self:GetDBVal("loadNotMounted") == false then return true end
    return not (IsMounted and IsMounted())
end

-- Combined load state used for slot liveness + sound.
function methods:LoadConditionsMet()
    return self:LoadInstanceMet() and self:LoadRoleSpecMet()
        and self:LoadCombatMet() and self:LoadSpellMet() and self:LoadItemMet()
        and self:LoadNotPetBattleMet() and self:LoadNotMountedMet()
end

-- Whether ANY load condition is enabled on this tracker. (Custom-kind trackers
-- now show Always when no condition is set — see UpdateCustomArt — so this is
-- no longer used for gating; kept as a small utility.)
function methods:HasAnyLoadCondition()
    return (self:GetDBVal("loadCombat")
        or self:GetDBVal("loadInstance")
        or self:GetDBVal("loadRole")
        or self:GetDBVal("loadSpec")
        or self:GetDBVal("loadSpell")
        or self:GetDBVal("loadItem")) and true or false
end

-- Custom-kind gating: render/hide each element's holder art from the current
-- load state, and fire Sound elements on the unmet→met rising edge. Shared by
-- UpdateGating (unrestricted) and ReapplySlotGating (combat edge) — all calls
-- here are on non-secure holder regions / PlaySoundFile, so combat-safe.
--
-- `mode` selects how the sound edge is handled — there are THREE distinct
-- callers and getting _condMet ownership right is what makes firing reliable:
--   "live"   — a genuine in-play condition flip (ReapplySlotGating: spell-ready
--              flips, combat transitions). FIRES on unmet→met and WRITES
--              _condMet. This path owns the edge.
--   "resync" — the reload / zone / instance / spec baseline (PEW/login refresh).
--              Silently WRITES _condMet to the current state so a later live
--              flip fires correctly, but never plays a sound. Stops the
--              spurious replay on reload / instance change.
--   "render" — an incidental refresh during play (option edits, unrelated
--              RefreshAll). Re-renders art only; does NOT touch _condMet, so it
--              can't swallow an edge the live path is about to fire. (Default.)
function methods:UpdateCustomArt(mode)
    mode = mode or "render"
    local enabled = self:GetDBVal("enabled") and true or false
    local isApplicable = self.config.isApplicable
    local applicable = (not isApplicable) or isApplicable(self)
    -- "No conditions ⇒ show Always": with no Show Conditions set,
    -- LoadConditionsMet() is true, so the art shows whenever the tracker is
    -- enabled. Any configured condition then gates it as normal.
    local show = enabled and applicable and self:LoadConditionsMet()
    self._spellLive = show

    if mode == "live" then
        -- Fire each Sound element once on the true unmet→met rising edge.
        -- Preview doesn't count (config-time visual, not a live trigger).
        if show and not self._condMet then
            local els = self:GetElementsDB()
            for i = 1, MAX_ELEMENTS do
                local el = els[i]
                if el and el.style == "sound" and not self._elPreview[i] then
                    local path = ns.ResolveElementSoundPath(self, el)
                    if path then
                        pcall(PlaySoundFile, path, el.soundChannel or "Master")
                    end
                end
            end
        end
        self._condMet = show
    elseif mode == "resync" then
        -- Baseline after reload/zone/spec: seed silently, never fire.
        self._condMet = show
    end
    -- "render": leave _condMet untouched (the live path owns edge detection).

    -- PARKED FAST PATH: mirror of UpdateIDArt's — a hidden tracker whose last
    -- pass already hid everything skips the element loop on fan-out calls.
    if not show and self._customArtParked and not next(self._elPreview) then
        return
    end

    -- Draw / hide the visual (Icon/Text) art per element. Preview mode owns
    -- the holder regions while active, so skip those elements here.
    local els = self:GetElementsDB()
    for i = 1, MAX_ELEMENTS do
        local e = self.elements[i]
        if e then
            local style = els[i] and els[i].style
            -- Styles RenderCustomArt can draw. "sound" is the only custom style
            -- with no visual (it fires on the rising edge above instead), so
            -- this must list every OTHER style CUSTOM_STYLE_SORTING offers —
            -- anything missing here is routed to HideCustomArt and never
            -- appears live, however correct its render arm is.
            local isVisual = style == "icon" or style == "text"
                or style == "texture"
            if self._elPreview[i] then
                -- Preview path renders this element; leave it alone.
            elseif show and isVisual then
                self:RenderCustomArt(i)
            else
                self:HideCustomArt(i)
            end
        end
    end
    self._customArtParked = not show
end

-- Combat-transition re-gate: re-apply ONLY the single-aura slot candidate
-- filters for the current combat state. Called from RefreshLoadGating on
-- PLAYER_REGEN_DISABLED/_ENABLED, so it may run inside a combat/secret
-- window. It touches only the slot CONTAINER (SetAuraSlotCandidateFilters /
-- UpdateAllAuras), which is combat-safe (the RepointUnit spike confirmed
-- container calls in combat) — it never touches the engine aura button,
-- which is a forbidden object in combat. Everything is pcall-guarded: if a
-- call is denied in this window, the element's cached state is cleared and a
-- deferred refresh is flagged so the next unrestricted UpdateGating
-- reconciles. Group-backed elements can't have their assignment changed on
-- the combat edge (engine group setters are combat-restricted; partial
-- apply is dangerous — see SafeGroupCall), so the combat condition gates
-- their container ALPHA instead — our own frame, base widget method,
-- combat-safe, propagates to every pooled group button.
function methods:ReapplySlotGating()
    -- Custom-kind has no slots/groups — just re-render its holder art for the
    -- new combat state (combat-flippable conditions can change here). This is
    -- the LIVE edge path (combat transitions, spell-ready flips), so the sound
    -- fires on a genuine unmet→met transition.
    if self:IsCustomKind() then
        self:UpdateCustomArt("live")
        return
    end
    -- ID kinds render holder art (no slots); the live edge re-evaluates the
    -- cooldown swipe + "oncd" visibility and fires any Sound element.
    -- PAIR elements are the exception: they ride the aura slot machinery,
    -- gated after the art pass (combat-safe, pcall'd, self-deferring).
    if self:IsIDKind() then
        self:UpdateIDArt("live")
        self:GatePairSlots()
        return
    end
    local enabled = self:GetDBVal("enabled") and true or false
    local isApplicable = self.config.isApplicable
    local applicable = (not isApplicable) or isApplicable(self)
    local live = enabled and applicable and self:LoadConditionsMet()
    self._spellLive = live
    -- Conditions that can flip IN combat (combat status, spell-ready)
    -- gate group visuals via alpha; instance type can't change in combat.
    local gateShown = self:LoadCombatMet() and self:LoadSpellMet() and self:LoadItemMet()
    -- Assist gate (friendly target/focus debuff): folded into the group alpha
    -- exactly like the vehicle/cinematic gates. See PushVisualGate for why.
    local assistHidden = ns.assistBlocked[self:GetTrackedUnit()] and true or false

    local els = self:GetElementsDB()
    local touched = SCRATCH_TOUCHED
    wipe(touched)
    for i = 1, MAX_ELEMENTS do
        local e = self.elements[i]
        if e then
            local el = els[i]
            local style = el and el.style
            local usesGroup = UsesGroup(self, e, style)
            local elLive = (live and not self._elPreview[i] and not self._noSpells
                and style ~= nil and style ~= "sound") and true or false
            local slotLive = elLive and not usesGroup and not ns.debugSuppress.slots
            if slotLive ~= e._slotLive and e._unit then
                local sc = GetSlotContainer(e._unit)
                local ok = pcall(sc.SetAuraSlotCandidateFilters, sc, e.slotKey,
                    slotLive and self:GetSlotCandidates() or DORMANT_CANDIDATES)
                if ok then
                    e._slotLive = slotLive
                    touched[sc] = true
                else
                    -- Denied in this window: leave the cached state stale and
                    -- reconcile from a safe context.
                    ns.deferredRefresh = true
                end
            end
            -- FILTER-STRING park (see UpdateGating). "" is honored
            -- on friendly units where the candidate filter is not; SetSlotFilterParked
            -- is pcall'd + self-deferring, safe on the combat edge.
            local slotParked = (not slotLive) or assistHidden
            local fsc = SetSlotFilterParked(self, e, slotParked)
            if fsc then touched[fsc] = true end
            if e.hasGroup then
                -- GROUP KILL SWITCH (see UpdateGating): drive
                -- SetEnabled/SetShown from the combat-flippable liveness so a
                -- group container that just went non-live (combat gate closed,
                -- spell/item unready, assist-blocked) stops registering unit
                -- events, not merely dims. Base widget methods on our own frame,
                -- combat-safe; change-guarded on e._groupInert.
                local groupInert = not (gateShown
                    and not (ns.vehicleGateActive or ns.cinematicGateActive)
                    and not assistHidden)
                SetGroupInert(e, groupInert)
                -- Alpha belt-and-suspenders under the same union. Single-writer
                -- invariant: e.container's alpha is written HERE and in
                -- UpdateGating and nowhere else (PushVisualGate touches only the
                -- shared per-unit slotContainers). Cache ONLY on success.
                local a = (gateShown and not (ns.vehicleGateActive
                    or ns.cinematicGateActive) and not assistHidden) and 1 or 0
                if a ~= e._containerAlpha then
                    if pcall(e.container.SetAlpha, e.container, a) then
                        e._containerAlpha = a
                    end
                end
            end
        end
    end
    for sc in pairs(touched) do
        pcall(sc.UpdateAllAuras, sc)
    end
    wipe(touched)  -- shared scratch: hold no frame refs past the drain
end

-- Recompute per-element liveness and park/unpark the slot and (if
-- present) group filters, change-guarded per element; forces a rebuild
-- when anything swapped (option setters don't re-evaluate existing
-- assignments — plan §0).
function methods:UpdateGating()
    -- SELF-HEAL, ALL KINDS (2026-08-13): a holder must NEVER stay
    -- mouse-enabled outside an active preview — a mouse-enabled holder eats
    -- world clicks over the whole tracker area. MUST run BEFORE the
    -- custom/ID early-returns below: the first version sat in the aura-kind
    -- element loop and the live incident promptly moved to a CUSTOM
    -- tracker's holder (HETCustom18Holder2), which never reached it.
    -- Cost: one IsMouseEnabled read per element per gating pass
    -- (event-driven, never per-frame). Holders are our own frames — the
    -- flag reads plain, never secret (PTR-observed via /bamouse; the
    -- engine CONTAINERS' mouse flags DO read secret, ours don't).
    for i = 1, MAX_ELEMENTS do
        local e = self.elements[i]
        if e and e.holder and not self._elPreview[i]
            and e.holder:IsMouseEnabled() then
            e.holder:EnableMouse(false)
        end
    end
    -- Custom-kind renders holder art (non-secure); no slots/groups to park.
    -- On the reload/zone/spec baseline (ns.customSoundResync, set by Core
    -- around the PEW/login refresh) seed _condMet silently so the spurious
    -- reload replay is suppressed; on any other in-play refresh (option edits,
    -- etc.) just re-render and leave _condMet for the live path to own. Live
    -- edges come through ReapplySlotGating.
    if self:IsCustomKind() then
        self:UpdateCustomArt(ns.customSoundResync and "resync" or "render")
        return
    end
    -- ID kinds: holder art, no slots to park — except PAIR elements, whose
    -- aura slots are gated after the art pass. Same resync/render seeding as
    -- custom so the reload/zone/spec baseline doesn't replay a Sound element.
    if self:IsIDKind() then
        self:UpdateIDArt(ns.customSoundResync and "resync" or "render")
        self:GatePairSlots()
        return
    end
    if ns.IsRestricted() then
        ns.deferredRefresh = true
        return
    end
    local enabled = self:GetDBVal("enabled") and true or false
    local isApplicable = self.config.isApplicable
    local applicable = (not isApplicable) or isApplicable(self)
    -- Slot liveness folds BOTH load conditions: slot parking is combat-safe
    -- (container-level), so ReapplySlotGating re-applies the combat edge in
    -- combat and this handles it on combat exit. GROUP assignment cannot
    -- change on the combat edge (engine group setters are combat-restricted;
    -- a partial apply is dangerous — see SafeGroupCall), so groups are
    -- assigned from the combat-FREE liveness and the in-combat-flippable
    -- conditions (Combat Status, Spell Usable) gate the group container's
    -- ALPHA instead.
    --
    -- Engine aura SOUNDS take the same combat-free liveness for the same
    -- reason (AddAuraSound is combat-protected) but WITHOUT the alpha escape
    -- hatch — a live registration can't be muted — so those conditions simply
    -- don't gate Sound alerts. See methods:IsLiveNoCombat and Core's
    -- UpdateAuraSounds; the Combat Status tooltip says so in the UI.
    local live = enabled and applicable and self:LoadConditionsMet()
    self._spellLive = live
    -- Role/Spec are combat-free (spec change is out of combat), so they join
    -- Instance Type in the group-assignment liveness alongside enabled.
    -- Kept inline (not methods:IsLiveNoCombat) because this is on the watcher
    -- fan-out — `enabled`/`applicable` are already in hand. Keep both in sync.
    local liveNoCombat = enabled and applicable
        and self:LoadInstanceMet() and self:LoadRoleSpecMet()
    local gateShown = self:LoadCombatMet() and self:LoadSpellMet() and self:LoadItemMet()
    -- Assist gate: a debuff tracker on a friendly target/focus can't be
    -- spell-ID filtered (engine carve-out — see PushVisualGate), so hide its
    -- group containers exactly like the slot containers. Folds into the group
    -- alpha alongside the vehicle/cinematic gates. Player is never blocked.
    local assistHidden = ns.assistBlocked[self:GetTrackedUnit()] and true or false

    local els = self:GetElementsDB()
    local touched = SCRATCH_TOUCHED  -- slot containers whose slots changed
    wipe(touched)
    for i = 1, MAX_ELEMENTS do
        local e = self.elements[i]
        if e then
            local el = els[i]
            local style = el and el.style
            local elLive = (live and not self._elPreview[i] and not self._noSpells
                and style ~= nil and style ~= "sound") and true or false
            local elLiveNC = (liveNoCombat and not self._elPreview[i]
                and not self._noSpells and style ~= nil and style ~= "sound")
                and true or false
            local usesGroup = UsesGroup(self, e, style)
            local slotLive = elLive and not usesGroup
                and not ns.debugSuppress.slots
            if slotLive ~= e._slotLive and e._unit then
                e._slotLive = slotLive
                -- Park/unpark the slot in the container it actually lives in
                -- (e._unit), which differs from the tracker's current unit
                -- mid unit-change and after the entry is deleted.
                local sc = GetSlotContainer(e._unit)
                sc:SetAuraSlotCandidateFilters(e.slotKey,
                    slotLive and self:GetSlotCandidates() or DORMANT_CANDIDATES)
                touched[sc] = true
            end
            -- FILTER-STRING park: a non-live slot gets filter "",
            -- which is honored on friendly units where the candidate filter is
            -- not — so a disabled/condition-failed/assist-blocked debuff slot
            -- does no matching work and leaks nothing. Assist-blocked folds in:
            -- a friendly target parks the slot at the source, not just by alpha.
            -- Own guard (e._slotFilterParked), so it's paid only on the edge.
            local slotParked = (not slotLive) or assistHidden
            local fsc = SetSlotFilterParked(self, e, slotParked)
            if fsc then touched[fsc] = true end
            if e.hasGroup then
                if self:ApplyGroupAssignments(i, elLiveNC and usesGroup) then
                    -- Group reconfig: option setters don't re-evaluate
                    -- existing aura assignments — force the rebuild.
                    e.container:UpdateAllAuras()
                end
                -- GROUP KILL SWITCH: a group container that is not
                -- live for ANY reason (disabled, condition-failed, combat/spell/
                -- item gate closed, vehicle/cinematic, or assist-blocked friendly
                -- target) is SetEnabled(false)+SetShown(false) so it registers no
                -- unit events and does zero aura work — not merely alpha-0. Our
                -- own frame, base widget methods, combat-safe. Change-guarded on
                -- e._groupInert.
                local groupInert = (not elLive)
                    or not gateShown
                    or ns.vehicleGateActive or ns.cinematicGateActive
                    or assistHidden
                SetGroupInert(e, groupInert)
                -- Alpha kept as belt-and-suspenders under the same union: when a
                -- container is re-shown on the live edge its alpha must already
                -- be correct. Change-guarded and cached on e._containerAlpha —
                -- see the matching write in ReapplySlotGating for the invariant.
                local a = (gateShown and not (ns.vehicleGateActive
                    or ns.cinematicGateActive) and not assistHidden) and 1 or 0
                if a ~= e._containerAlpha then
                    e.container:SetAlpha(a)
                    e._containerAlpha = a
                end
            end
            -- Re-assert the Icon Source override on the SLOT button. The engine
            -- re-stomps the icon region with the aura's own texture on each
            -- reassignment (buff re-show), so a custom icon set only at style
            -- time reverts. This runs unrestricted (guarded at the top of
            -- UpdateGating), the only window the aura button is touchable —
            -- re-runs ApplySolidIcon in the live restyle walk.
            if e.button and not usesGroup and (style == "icon" or style == "bar") then
                ApplyIconOverride(e, e.button, el, true)
            end
            -- (Holder mouse self-heal runs at the TOP of UpdateGating for
            -- all kinds — do not re-add it here, custom/ID early-return
            -- before this loop.)
        end
    end
    for sc in pairs(touched) do
        sc:UpdateAllAuras()
    end
    wipe(touched)  -- shared scratch: hold no frame refs past the drain
end

-- Park/unpark PAIR-element slots (ID-kind trackers only). Runs after
-- UpdateIDArt on BOTH the settings pass (UpdateGating) and the live edge
-- (ReapplySlotGating): slot parking is container-level and combat-safe, so
-- one pcall'd path serves both — a denied window sets ns.deferredRefresh
-- and the PLAYER_REGEN_ENABLED refresh reconciles. Change-guarded on
-- liveness (e._slotLive) AND the aura-set signature (the tracked ability
-- ID was swapped to another compound entry).
function methods:GatePairSlots()
    if not self:IsIDKind() then return end
    -- EARLY OUT for the fleet: this runs on every live edge
    -- (RefreshLoadGating fans out over all trackers), and most ID trackers
    -- have no pairs — don't build candidate tables for them.
    local anySlot = false
    for i = 1, MAX_ELEMENTS do
        local e = self.elements[i]
        if e and e.slotKey then anySlot = true; break end
    end
    if not anySlot then return end
    local els = self:GetElementsDB()
    local live = self._spellLive and true or false  -- set by UpdateIDArt
    local cand = self:PairSlotCandidates()
    local sig
    if cand then
        local ids = {}
        for id in pairs(cand.includeSpellIDs) do ids[#ids + 1] = id end
        table.sort(ids)
        sig = table.concat(ids, ",")
    end
    local touched = SCRATCH_TOUCHED
    wipe(touched)
    for i = 1, MAX_ELEMENTS do
        local e = self.elements[i]
        local el = els[i]
        -- Every slot-bearing element object on an ID tracker is (or was) a
        -- pair. A config no longer marked pairFor at this index (removal
        -- renumbered the list; SyncElementUnits rebuilds next refresh) must
        -- still PARK here rather than be skipped — a live slot nobody
        -- recognizes would keep showing the overlay.
        if e and e.slotKey and e._unit then
            -- A pair is live with its tracker, while its ID is still
            -- compound-flagged and — for locked (icon/bar) pairs — it still
            -- has a parent element to overlay (orphans park; standalone
            -- TEXT pairs have no parent). A previewing pair parks like any
            -- aura element.
            local slotLive = (el and el.pairFor and el.style ~= nil
                and live and cand ~= nil
                and not self._elPreview[i]
                and (el.pairFor == "text" or self:PairParentIndex(i) ~= nil)
                and not ns.debugSuppress.slots) and true or false
            if slotLive ~= e._slotLive or (slotLive and e._pairSetSig ~= sig) then
                local sc = GetSlotContainer(e._unit)
                local ok = pcall(sc.SetAuraSlotCandidateFilters, sc, e.slotKey,
                    slotLive and cand or DORMANT_CANDIDATES)
                if ok then
                    e._slotLive = slotLive
                    e._pairSetSig = slotLive and sig or nil
                    touched[sc] = true
                else
                    ns.deferredRefresh = true
                end
            end
        end
    end
    for sc in pairs(touched) do
        pcall(sc.UpdateAllAuras, sc)
    end
    wipe(touched)  -- shared scratch: hold no frame refs past the drain
end

-- Spell-level liveness for the alert registrations (ignores preview).
function methods:IsLive()
    return self._spellLive and true or false
end

-- Liveness with the IN-COMBAT-FLIPPABLE conditions excluded: enabled +
-- applicable + Instance Type + Role/Spec. Instance type only changes across a
-- loading screen and a spec change is out of combat, so this value is stable
-- for the whole of any fight.
--
-- Used by Core's UpdateAuraSounds. Engine aura-sound registrations
-- (C_UnitAuras.AddAuraSound) can only be created/destroyed out of combat, so
-- the registered set must not depend on Combat Status / Spell Usable / Item
-- Usable — those are exactly the conditions that flip mid-fight.
--
-- Deliberately recomputed rather than reading a cached field: this runs only on
-- login/PEW/spec change/option change, whereas UpdateGating (which computes the
-- same value inline as `liveNoCombat`) is on the watcher fan-out and must not
-- pay for an extra call. Keep the two in sync.
function methods:IsLiveNoCombat()
    local isApplicable = self.config.isApplicable
    return (self:GetDBVal("enabled") and true or false)
        and ((not isApplicable) or isApplicable(self))
        and self:LoadInstanceMet() and self:LoadRoleSpecMet()
end

-------------------------------------------------------------------------------
-- Preview (PER ELEMENT): engine buttons are invisible without a real
-- aura, so previews render on HOLDER-level regions we fully control.
-- Group-backed styles preview THREE samples arranged along the grow
-- direction. A previewing element's slot/group is parked (via
-- UpdateGating); other elements stay live.
-------------------------------------------------------------------------------
-- Drag-highlight tint for the Move/Preview overlay, matched to the options
-- tab the tracker lives on: blue = buffs, orange = debuffs, purple =
-- abilities/items. Custom ("Other") trackers keep the blue default.
-- Each entry is { overlay r, g, b, a, label r, g, b }: the label under the
-- preview uses a lighter tone of the same hue.
local PREVIEW_HL_BUFF   = { 0.10, 0.40, 1.00, 0.35, 0.50, 0.80, 1.00 }
local PREVIEW_HL_DEBUFF = { 1.00, 0.45, 0.05, 0.35, 1.00, 0.70, 0.35 }
local PREVIEW_HL_IDKIND = { 0.52, 0.08, 1.00, 0.45, 0.78, 0.55, 1.00 }

local function ShowPreviewHighlight(tracker, e)
    local c = PREVIEW_HL_BUFF
    if tracker:IsIDKind() then
        c = PREVIEW_HL_IDKIND
    else
        local entry = tracker:GetCustomEntry()
        if entry and entry.harmful then c = PREVIEW_HL_DEBUFF end
    end
    e.highlight:SetColorTexture(c[1], c[2], c[3], c[4])
    e.label:SetTextColor(c[5], c[6], c[7], 1.0)
    e.highlight:Show()
end

-- `keepArmed` is passed ONLY by RenderIDArt's top-of-render reset, which is
-- about to re-arm everything it clears. Cooldown widgets whose element asked
-- for the native finish animation are left alone there: Clear() cancels the
-- bling and Hide() kills it outright, and this function runs at the head of
-- EVERY ID render -- including the one the off-cooldown ticker fires at the
-- exact moment the bling should start. Every other caller is a real teardown
-- (panel close, style change, element hide) and still clears unconditionally.
local function HideElementPreviewArt(e, keepArmed)
    e.holder:SetHitRectInsets(0, 0, 0, 0)
    HideBorderEdges(e.pBorder)
    e.pIcon:Hide()
    -- Only the Texture style ever leaves pIcon in a non-neutral draw state, so
    -- the reset is DIRTY-FLAGGED rather than unconditional: this function runs
    -- at the top of RenderIDArt / HideIDArt, which the ID watcher drives up to
    -- once per GCD in combat. An unconditional reset would put five widget
    -- setters per element on that path for no benefit (ID kinds cannot even
    -- select the Texture style — see AllowedTypeSorting).
    if e._pIconTexState then
        ResetTextureDrawState(e.pIcon)
        e._pIconTexState = nil
    end
    e.pBar:Hide()
    e.pBarBg:Hide()
    -- Unusable-recolor overlays (ID kinds; pooled per sample/bar).
    if e.pIDTintOvls then
        for _, o in pairs(e.pIDTintOvls) do o:Hide() end
    end
    -- Always-on recolor overlays (non-ID kinds; pooled per sample).
    if e.pTintOvls then
        for _, o in pairs(e.pTintOvls) do o:Hide() end
    end
    e.pRingBg:Hide()
    e.pRing:Hide()
    e.pRingFrame:Hide()
    e.pText:Hide()
    if e.pStackText then e.pStackText:Hide() end
    if e.pStackMaxText then e.pStackMaxText:Hide() end
    if e.pNameText then e.pNameText:Hide() end
    -- Every rounded frame goes away and every masked texture is released
    -- before the next render re-applies only what the current style
    -- needs (pIcon in particular serves both the Icon preview and the
    -- bar preview's icon square).
    if e.pRound then
        for _, rr in pairs(e.pRound) do
            rr.bg:Hide()
            for j = 1, #rr.applied do
                rr.applied[j]:RemoveMaskTexture(rr.mask)
                rr.applied[j] = nil
            end
        end
    end
    if e.pEdges then
        for _, edges in pairs(e.pEdges) do HideBorderEdges(edges) end
    end
    if e.pBorder2 then HideBorderEdges(e.pBorder2) end
    for j = 1, #e.pExtraIcons do e.pExtraIcons[j]:Hide() end
    for j = 1, #e.pExtraBars do
        e.pExtraBars[j].bg:Hide()
        e.pExtraBars[j].fill:Hide()
        if e.pExtraBars[j].icon then e.pExtraBars[j].icon:Hide() end
    end
    -- MAIN sample's cooldown swipe. Clearing this belongs here with the other
    -- swipe widgets, not at the individual call sites: it was previously only
    -- cleared in HideIDArt, so every other teardown path (HidePreview on panel
    -- close, HideElementPreview, RefreshPreview's style-change branch) hid the
    -- art but left this widget armed — a preview's swipe and countdown carried
    -- on animating over nothing. Clear() is safe on an already-clear widget, and
    -- the render paths that call this re-arm from a duration OBJECT, so the
    -- swipe resumes at the same remaining time with no visual restart.
    if e.pCooldown and not (keepArmed and e.pCooldown._hetKeepBling) then
        e.pCooldown:Clear(); e.pCooldown:Hide()
    end
    -- ID-kind fan-out cooldown swipes (created on demand in RenderIDSample):
    -- clear + hide so a shrunk group leaves no stale swipe behind.
    if e.pExtraCooldowns then
        for _, cd in pairs(e.pExtraCooldowns) do
            if not (keepArmed and cd._hetKeepBling) then cd:Clear(); cd:Hide() end
        end
    end
    -- GCD swipe widgets, same treatment PLUS the _gcdWanted flag. Clearing the
    -- flag is the load-bearing part: ns.ArmGcdSwipes re-arms and re-SHOWS any
    -- widget still marked wanted on every SPELL_UPDATE_COOLDOWN, so a widget
    -- hidden here but left marked would sweep over empty screen once per GCD
    -- for the rest of the fight (element gated off, group shrunk, style
    -- switched to one that never calls ApplyIDGcdSwipe).
    if e.pGcdCooldown then
        e.pGcdCooldown._gcdWanted = nil
        e.pGcdCooldown:Clear(); e.pGcdCooldown:Hide()
    end
    if e.pGcdExtras then
        for _, cd in pairs(e.pGcdExtras) do
            cd._gcdWanted = nil
            cd:Clear(); cd:Hide()
        end
    end
    -- ID-kind cooldown BAR + per-charge segments (main sample).
    if e.pIDBar then e.pIDBar:Hide() end
    if e.pIDBarBg then e.pIDBarBg:Hide() end
    if e.pIDBarSegs then
        for _, seg in ipairs(e.pIDBarSegs) do seg:Hide() end
    end
    if e.pIDBarSegBgs then
        for _, sbg in ipairs(e.pIDBarSegBgs) do sbg:Hide() end
    end
    -- ID-kind fan-out bar samples (created on demand for groups).
    if e.pIDBarExtras then
        for _, u in pairs(e.pIDBarExtras) do
            u.bar:Hide(); u.bg:Hide()
            if u.icon then u.icon:Hide() end
            for _, seg in ipairs(u.segs) do seg:Hide() end
            for _, sbg in ipairs(u.segBgs) do sbg:Hide() end
        end
    end
    -- Charge recharge overlays (offset + charging bar, per sample): hide so a
    -- single-cooldown bar or a shrunk group leaves no stale animating fill.
    if e.pIDBarCharge then
        for _, o in pairs(e.pIDBarCharge) do o.offset:Hide(); o.charge:Hide() end
    end
    -- ID bar cooldown widgets (icon-square swipe + bar-body numbers): clear +
    -- hide so a shrunk group / style switch leaves no stale swipe or number.
    if e.pIDBarCDs then
        for _, cd in pairs(e.pIDBarCDs) do
            if not (keepArmed and cd._hetKeepBling) then cd:Clear(); cd:Hide() end
        end
    end
    e.highlight:Hide()
    e.label:Hide()
    e.holder:EnableMouse(false)
end

-- Rounded-border preview regions, created on demand and cached per KEY
-- (the main sample, each extra fan-out sample, the bar's icon square) so
-- every sample carries its own frame + mask and the preview matches what
-- the live buttons draw. `sliced` mirrors the live split — nine-slice
-- for BAR bodies, stretched for ICONS (see ROUND_MASK_TEX comment) —
-- and is fixed per key, so bar and icon keys never share a region.
local function RoundPreview(e, key, sliced)
    e.pRound = e.pRound or {}
    local rr = e.pRound[key]
    if not rr then
        rr = { applied = {} }
        rr.bg = e.holder:CreateTexture(nil, "BACKGROUND", nil, -1)
        rr.mask = e.holder:CreateMaskTexture()
        if rr.mask.SetBlockingLoadsRequested then
            rr.mask:SetBlockingLoadsRequested(true)
        end
        if sliced then
            rr.bg:SetTextureSliceMargins(8, 8, 8, 8)
            rr.mask:SetTextureSliceMargins(8, 8, 8, 8)
        end
        e.pRound[key] = rr
    end
    return rr
end

-- Square-border edge set, cached per key like the rounded regions, so
-- extra fan-out samples can draw the same border as the main sample.
local function PreviewEdges(e, key)
    e.pEdges = e.pEdges or {}
    local edges = e.pEdges[key]
    if not edges then
        edges = {}
        for k = 1, 4 do
            local edge = e.holder:CreateTexture(nil, "OVERLAY")
            edge:SetTexture(WHITE8X8)
            edge:Hide()
            edges[k] = edge
        end
        e.pEdges[key] = edges
    end
    return edges
end

-- Preview mirror of ApplyIconTint (always-on Desaturate / Recolor). Overlays
-- are pooled per sample key on the element's preview holder and hidden
-- wholesale by HideElementPreviewArt. Returns the overlay while shown so a
-- rounded border can pull it into the same mask as the icon — which is why
-- callers run this BEFORE ApplyRoundPreview.
--
-- Desaturating e.pIcon sets the _pIconTexState dirty flag, so the shared hide
-- resets the region's draw state before another style reuses it.
local function ApplyIconTintPreview(e, el, region, key, pad)
    if not region then return nil end
    region:SetDesaturated(el.iconDesaturate and true or false)
    if el.iconDesaturate and region == e.pIcon then
        e._pIconTexState = true
    end
    local pool = e.pTintOvls
    if not el.iconRecolor then
        local old = pool and pool[key]
        if old then old:Hide() end
        return nil
    end
    if not pool then
        pool = {}
        e.pTintOvls = pool
    end
    local ovl = pool[key]
    if not ovl then
        ovl = e.holder:CreateTexture(nil, "ARTWORK", nil, 2)
        pool[key] = ovl
    end
    local tc = el.iconTintColor or ICON_TINT_DEFAULT
    ovl:SetColorTexture(tc[1], tc[2], tc[3], tc[4])
    ovl:ClearAllPoints()
    pad = pad or 0
    ovl:SetPoint("TOPLEFT", region, "TOPLEFT", pad, -pad)
    ovl:SetPoint("BOTTOMRIGHT", region, "BOTTOMRIGHT", -pad, pad)
    ovl:Show()
    return ovl
end

-- Preview mirror of ApplyRoundedIcon. `point`/`w`/`h` place the mask and
-- frame against the HOLDER (a frame), never against the textures being
-- masked -- same rule as the live path, and the reason a mask anchored
-- to its own masked texture came out degenerate. `regions` all share
-- that rect (a bar's fill sits exactly over its background).
local function ApplyRoundPreview(e, key, color, point, ox, oy, w, h, regions, sliced, thick)
    local rr = RoundPreview(e, key, sliced)
    -- Textures per apply, not per create: the user can flip a live
    -- element between Rounded and Rounded (Thick), and thick swaps art
    -- (bars: heavier ring + deeper mask; icons: heavier ring, shared
    -- mask, frame drawn 2px outside instead of 1 — mirrors the live
    -- paths).
    if sliced then
        rr.bg:SetTexture(thick and ROUND_BORDER_THICK_TEX or ROUND_BORDER_TEX)
        rr.mask:SetTexture(thick and ROUND_MASK_THICK_TEX or ROUND_MASK_TEX,
            "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
    else
        rr.bg:SetTexture(
            thick and ICON_ROUND_BORDER_THICK_TEX or ICON_ROUND_BORDER_TEX)
        rr.mask:SetTexture(ICON_ROUND_MASK_TEX,
            "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
    end
    local o = (not sliced and thick) and 2 or 1
    rr.mask:ClearAllPoints()
    rr.mask:SetSize(w, h)
    rr.mask:SetPoint(point, e.holder, point, ox, oy)
    local c = color or DEFAULT_BORDER_COLOR
    rr.bg:SetVertexColor(c[1], c[2], c[3], c[4])
    rr.bg:ClearAllPoints()
    rr.bg:SetSize(w + 2 * o, h + 2 * o)
    local cx, cy = CenterOffset(point, ox, oy, w, h)
    rr.bg:SetPoint("CENTER", e.holder, point, cx, cy)
    rr.bg:Show()
    -- Remove before Add, and remember what carries the mask so
    -- HideElementPreviewArt can strip it on the next render.
    for i = 1, #regions do
        local region = regions[i]
        region:RemoveMaskTexture(rr.mask)
        region:AddMaskTexture(rr.mask)
        rr.applied[#rr.applied + 1] = region
    end
end

-- Offset anchor for the k-th preview sample along the grow direction.
-- Displacement of the k-th fan-out sample along the grow direction
-- (k = 0 is the main sample). Returned as a pure translation, which
-- composes with ANY anchor corner -- so a caller keeps whatever geometry
-- it already computed and just adds this. Every region carries an
-- explicit SetSize, so the corner it hangs off doesn't matter.
local function PreviewSampleOffset(dir, k, w, h, spacing)
    local off = k * ((dir == "up" or dir == "down") and (h + spacing) or (w + spacing))
    if dir == "left" then return -off, 0 end
    if dir == "down" then return 0, -off end
    if dir == "up" then return 0, off end
    return off, 0
end

local function AnchorPreviewSample(region, holder, dir, k, w, h, spacing)
    local dx, dy = PreviewSampleOffset(dir, k, w, h, spacing)
    region:ClearAllPoints()
    region:SetPoint("TOPLEFT", holder, "TOPLEFT", dx, dy)
end

-- Preview mirror of RenderTexts, drawing on the holder-owned regions.
local function RenderPreviewTexts(e, el, parent, useGrid, label)
    local texts = EnsureTexts(el)
    local vertical = (not useGrid) and el.barOrientation == "vertical"
    local defs = TextDefaults(useGrid, vertical)
    for j = 1, #texts do
        local t = texts[j]
        if t and t.show ~= false then
            local binding = TEXT_BINDING[t.type]
            local fs = (binding == "duration" and e.pText)
                or (binding == "stacks" and e.pStackText)
                or (binding == "name" and e.pNameText)
            if fs then
                local size = t.fontSize or DefaultTextSize(el.style, binding)
                local pos = CoercePos(t.position or defs[binding],
                    defs[binding], useGrid, vertical)
                if useGrid then
                    ApplyAnchoredText(fs, parent, t.font, size, pos, t.color, t.fontBorder)
                else
                    ApplyBarText(fs, parent, t.font, size, pos, vertical, t.color, t.fontBorder)
                end
                if binding == "name" then
                    fs:SetText(label or "Buff Name")
                elseif binding == "stacks" then
                    local d = t.stackDisplay or "current"
                    if d == "currentmax" then
                        fs:SetText("3/" .. (el.maxStacks or 10))
                    elseif d == "max" then
                        fs:SetText(tostring(el.maxStacks or 10))
                    else
                        fs:SetText("3")
                    end
                else
                    fs:SetText("10")
                end
            end
        end
    end
end

local function RenderElementPreview(tracker, i)
    local e = tracker.elements[i]
    if not e then return end
    local el = tracker:GetElementsDB()[i]
    HideElementPreviewArt(e)

    local style = el and el.style
    if not style or style == "sound" then return end
    local c = el.color or { 1, 1, 1, 1 }
    local multi = UsesGroup(tracker, e, style)
    local dir = EffectiveGrowDirection(el)
    local spacing = el.spacing or 2
    -- Preview samples: Unordered follows the Max Icons/Bars cap;
    -- Ordered shows one per checked buff (capped at 10).
    -- Single-active groups only ever show one buff, so the preview is a
    -- single sample too (matches the singular options presentation).
    local sampleCount = 1
    if multi and not tracker.config.singleActive then
        if tracker:IsOrdered() then
            sampleCount = #tracker:GetOrderedActiveIDSets()
            if sampleCount < 1 then sampleCount = 1 end
            if sampleCount > 10 then sampleCount = 10 end
        else
            sampleCount = el.maxFrames or 3
        end
    end
    -- Locked (icon/bar) PAIR elements are position-locked AND sit directly
    -- over their parent element — keep the previewing pair
    -- click-transparent so the drag lands on the (draggable) parent
    -- underneath. Standalone Text pairs stay draggable.
    local lockedPair = (el.pairFor and el.pairFor ~= "text") and true or false
    e.holder:EnableMouse(not lockedPair)

    -- Multi-aura previews: extend the holder's mouse hit rect over ALL
    -- extra samples so every part of the preview is draggable (the
    -- samples are anchored to the holder, so dragging any of them moves
    -- the whole thing).
    if sampleCount > 1 then
        local w = (style == "icon") and (el.iconSize or 40) or (el.barWidth or 200)
        local h = (style == "icon") and (el.iconSize or 40) or (el.barHeight or 20)
        local ext = (sampleCount - 1)
            * (((dir == "up" or dir == "down") and h or w) + spacing)
        if dir == "right" then
            e.holder:SetHitRectInsets(0, -ext, 0, 0)
        elseif dir == "left" then
            e.holder:SetHitRectInsets(-ext, 0, 0, 0)
        elseif dir == "down" then
            e.holder:SetHitRectInsets(0, 0, 0, -ext)
        else -- up
            e.holder:SetHitRectInsets(0, 0, -ext, 0)
        end
        -- Drag highlight covers the WHOLE preview (all samples), and
        -- the label centers under the full extent.
        e.highlight:ClearAllPoints()
        e.highlight:SetPoint("TOPLEFT", e.holder, "TOPLEFT",
            dir == "left" and -ext or 0, dir == "up" and ext or 0)
        e.highlight:SetPoint("BOTTOMRIGHT", e.holder, "BOTTOMRIGHT",
            dir == "right" and ext or 0, dir == "down" and -ext or 0)
        e.label:ClearAllPoints()
        if dir == "right" then
            e.label:SetPoint("TOP", e.holder, "BOTTOM", ext / 2, -4)
        elseif dir == "left" then
            e.label:SetPoint("TOP", e.holder, "BOTTOM", -ext / 2, -4)
        elseif dir == "down" then
            e.label:SetPoint("TOP", e.holder, "BOTTOM", 0, -4 - ext)
        else -- up
            e.label:SetPoint("TOP", e.holder, "BOTTOM", 0, -4)
        end
    else
        e.holder:SetHitRectInsets(0, 0, 0, 0)
        e.highlight:ClearAllPoints()
        e.highlight:SetAllPoints(e.holder)
        e.label:ClearAllPoints()
        -- Locked-pair labels sit one row lower so they don't overlap the
        -- parent element's label when both previews are shown.
        e.label:SetPoint("TOP", e.holder, "BOTTOM", 0, lockedPair and -18 or -4)
    end

    -- Per-sample icon textures follow the configured buff list in
    -- display order (wraps when Unordered shows more samples than
    -- listed buffs). Sample 1 = the main preview region.
    local sampleSets
    local function SampleTex(idx)
        -- Aura Icon override: a single (non-fan-out) Icon element with a
        -- custom Icon Source previews that texture instead of the aura icon.
        if not multi then
            local override = ns.ResolveAuraIcon(el)
            if override then return override end
        end
        -- PAIR elements preview the APPLIED AURA's icon (which may differ
        -- from the ability/item's own — the tracker cache holds the latter).
        if el.pairFor then
            local ci = tracker:GetCompoundInfo()
            local aid = ci and ci.auras and ci.auras[1]
            local t = aid and GetSpellTexture(aid)
            if t then return t end
        end
        -- Single-active groups preview the group's representative icon
        -- (config.displaySpell -> cachedIcon), like the bar preview, rather
        -- than the first checked buff.
        if multi and not tracker.config.singleActive then
            if not sampleSets then
                sampleSets = tracker:GetOrderedActiveIDSets()
            end
            if #sampleSets > 0 then
                local ids = sampleSets[((idx - 1) % #sampleSets) + 1]
                local t = ids and GetSpellTexture(ids[1])
                if t then return t end
            end
        end
        return tracker.cachedIcon or 134400
    end

    if style == "icon" then
        local sz = el.iconSize or 40
        -- PAIR icon "Animate Border" preview: no icon — pIcon shows the
        -- border swipe art at full, tinted by the pair's color (mirrors
        -- the live radial border swipe).
        local pairIconBorderAnim = el.pairFor
            and (el.pairIconAnim or "replace") == "border"
        if pairIconBorderAnim then
            local pbt = el.pairBorderStyle or "blizzard"
            e.pIcon:SetTexture(PairRingTexture(pbt, el.pairAnimThickness))
            e.pIcon:SetTexCoord(0, 1, 0, 1)
            local bc = el.color or DEFAULT_BORDER_COLOR
            e.pIcon:SetVertexColor(bc[1], bc[2], bc[3], bc[4])
            e.pIcon:ClearAllPoints()
            e.pIcon:SetAllPoints(e.holder)
            e.pIcon:Show()
            RenderPreviewTexts(e, el, e.holder, true, tracker:GetLabel())
            -- (No extra samples: pairs are single-aura.)
        else
        local tex = SampleTex(1)
        e.pIcon:SetTexture(tex)
        -- Reset any tint left by the border-anim preview.
        e.pIcon:SetVertexColor(1, 1, 1, 1)
        e.pIcon:ClearAllPoints()
        e.pIcon:SetAllPoints(e.holder)
        local borderType = BorderType(style, el)
        -- Always-on Desaturate / Recolor. Run BEFORE the rounded border so
        -- the overlay can join the icon in its mask.
        local tintOvl = ApplyIconTintPreview(e, el, e.pIcon, "imain",
            borderType == "blizzard" and sz * 0.04 or 0)
        if borderType == "blizzard" then
            e.pIcon:SetTexCoord(0, 1, 0, 1)
        elseif IsRounded(borderType) then
            -- 20% zoom crop, exactly like the live path.
            e.pIcon:SetTexCoord(0.1, 0.9, 0.1, 0.9)
            ApplyRoundPreview(e, "iconmain", el.borderColor,
                "TOPLEFT", 0, 0, sz, sz,
                tintOvl and { e.pIcon, tintOvl } or { e.pIcon },
                nil, borderType == "rounded_thick")
        else
            e.pIcon:SetTexCoord(0.1, 0.9, 0.1, 0.9)
            if borderType == "square" then
                LayoutBorderEdges(e.pBorder, e.holder,
                    el.borderThickness or 1, el.borderColor or DEFAULT_BORDER_COLOR)
            end
        end
        e.pIcon:Show()
        -- Text samples (main sample only).
        RenderPreviewTexts(e, el, e.holder, true, tracker:GetLabel())
        for j = 1, sampleCount - 1 do
            local sample = e.pExtraIcons[j]
            if not sample then
                sample = e.holder:CreateTexture(nil, "ARTWORK")
                e.pExtraIcons[j] = sample
            end
            sample:SetSize(sz, sz)
            sample:SetTexture(SampleTex(j + 1))
            if borderType == "blizzard" then
                sample:SetTexCoord(0, 1, 0, 1)
            else
                sample:SetTexCoord(0.1, 0.9, 0.1, 0.9)
            end
            AnchorPreviewSample(sample, e.holder, dir, j, sz, sz, spacing)
            local sTint = ApplyIconTintPreview(e, el, sample, "i" .. j,
                borderType == "blizzard" and sz * 0.04 or 0)
            if IsRounded(borderType) then
                local dx, dy = PreviewSampleOffset(dir, j, sz, sz, spacing)
                ApplyRoundPreview(e, "icon" .. j, el.borderColor,
                    "TOPLEFT", dx, dy, sz, sz,
                    sTint and { sample, sTint } or { sample },
                    nil, borderType == "rounded_thick")
            elseif borderType == "square" then
                LayoutBorderEdges(PreviewEdges(e, "icon" .. j), sample,
                    el.borderThickness or 1, el.borderColor or DEFAULT_BORDER_COLOR)
            end
            sample:Show()
        end
        end -- pairIconBorderAnim / regular icon preview
    elseif style == "bar" then
        local bw = el.barWidth or 200
        local bh = el.barHeight or 20
        local vertical = el.barOrientation == "vertical"
        local showIcon = el.barShowIcon ~= false
        -- Hoisted: the extra fan-out samples below need it too.
        local ibt = el.barIconBorderType or "blizzard"
        -- Ordered per-buff colors: preview bar k takes the k-th listed
        -- buff's color override (falls back to the element color).
        local okeys, obc
        if multi and tracker:IsOrdered() then
            local _, keys = tracker:GetOrderedActiveIDSets()
            okeys = keys
            obc = tracker:GetDBVal("buffColors")
        end
        local function OColor(idx)
            if okeys and type(obc) == "table" and okeys[idx] ~= nil then
                local oc = obc[okeys[idx]]
                if oc then return oc end
            end
            return c
        end
        -- The holder IS the bar (barWidth x barHeight, sized in
        -- ApplyElementStyle), so the bar sits at the holder's TOPLEFT in every
        -- case and toggling the icon cannot move it. The icon square matches the
        -- bar's cross-axis (bar width for vertical, bar height for horizontal)
        -- and hangs OUTSIDE the holder — its offsets go NEGATIVE for the left/top
        -- positions. All TOPLEFT-relative (y down = negative), reused for extra
        -- samples + the rounded mask/frame.
        local iconSize = vertical and bw or bh
        local pos = el.barIconPosition or (vertical and "bottom" or "left")
        local gap = iconSize + 2
        local iconPoint = "TOPLEFT"
        local barPoint = "TOPLEFT"
        local iconOX, iconOY, barOX, barOY, barW, barH
        barW, barH = bw, bh
        barOX, barOY = 0, 0
        if vertical and pos == "top" then
            iconOX, iconOY = 0, gap                -- above the bar
        elseif vertical then                       -- icon at BOTTOM
            iconOX, iconOY = 0, -bh - 2
        elseif pos == "right" then
            iconOX, iconOY = bw + 2, 0
        else                                       -- icon at LEFT (default)
            iconOX, iconOY = -gap, 0
        end
        e.pBarBg:ClearAllPoints()
        e.pBarBg:SetSize(barW, barH)
        e.pBarBg:SetPoint(barPoint, e.holder, barPoint, barOX, barOY)
        if showIcon then
            e.pIcon:ClearAllPoints()
            e.pIcon:SetSize(iconSize, iconSize)
            e.pIcon:SetPoint(iconPoint, e.holder, iconPoint, iconOX, iconOY)
            -- Icon Source override (single-aura): preview the custom texture
            -- on the bar's icon square, like the live path. Vertex color
            -- reset: the icon-pair Animate Border preview tints pIcon.
            local tex = (not multi and ns.ResolveAuraIcon(el)) or tracker.cachedIcon
            if tex then e.pIcon:SetTexture(tex) end
            e.pIcon:SetVertexColor(1, 1, 1, 1)
            local bTint = ApplyIconTintPreview(e, el, e.pIcon, "bimain",
                ibt == "blizzard" and iconSize * 0.04 or 0)
            if ibt == "blizzard" then
                e.pIcon:SetTexCoord(0, 1, 0, 1)
            elseif IsRounded(ibt) then
                e.pIcon:SetTexCoord(0.1, 0.9, 0.1, 0.9)
                ApplyRoundPreview(e, "baricon", el.barIconBorderColor,
                    iconPoint, iconOX, iconOY, iconSize, iconSize,
                    bTint and { e.pIcon, bTint } or { e.pIcon },
                    nil, ibt == "rounded_thick")
            else
                e.pIcon:SetTexCoord(0.1, 0.9, 0.1, 0.9)
                if ibt == "square" then
                    if not e.pBorder2 then
                        e.pBorder2 = {}
                        for j = 1, 4 do
                            local edge = e.holder:CreateTexture(nil, "OVERLAY")
                            edge:SetTexture(WHITE8X8)
                            edge:Hide()
                            e.pBorder2[j] = edge
                        end
                    end
                    LayoutBorderEdges(e.pBorder2, e.pIcon,
                        el.barIconBorderThickness or 1,
                        el.barIconBorderColor or DEFAULT_BORDER_COLOR)
                end
            end
            e.pIcon:Show()
        end
        -- PAIR bar "Border" animation preview: transparent body, no fill,
        -- and the ANIMATED border shown at full — drawn in the PARENT's
        -- border style/thickness (el.pairBorderStyle/pairBorderThickness,
        -- synced by ApplyElementStyle) tinted by the pair's color, so it
        -- overlays the parent bar's own border exactly (mirrors the live
        -- path).
        local pairBorderAnim = el.pairFor and (el.pairBarAnim or "border") == "border"
        local bg = pairBorderAnim and { 0, 0, 0, 0 }
            or el.barBgColor or { 0.1, 0.1, 0.1, 0.7 }
        e.pBarBg:SetColorTexture(bg[1], bg[2], bg[3], bg[4])
        e.pBarBg:Show()
        e.pBar:ClearAllPoints()
        e.pBar:SetAllPoints(e.pBarBg)
        local c1 = OColor(1)
        -- Bar texture preview: the actual statusbar texture tinted by the fill
        -- color (SetTexture + SetVertexColor), mirroring the live StatusBar.
        local pBarTex = BarTexturePath(el.barTexture)
        e.pBar:SetTexture(pBarTex)
        e.pBar:SetVertexColor(c1[1], c1[2], c1[3], c1[4])
        e.pBar:SetShown(not pairBorderAnim)
        -- Bar border preview: mirrors the live path — own nine-slice
        -- rounded border 1px outside the bar plus a rounded-rect mask
        -- on fill/bg in rounded mode; hollow square edges in square
        -- mode.
        local bt = pairBorderAnim and "none" or BorderType(style, el)
        if IsRounded(bt) then
            ApplyRoundPreview(e, "barmain", el.borderColor,
                barPoint, barOX, barOY, barW, barH, { e.pBarBg, e.pBar },
                true, bt == "rounded_thick")
        else
            if bt == "square" then
                LayoutBorderEdges(e.pBorder, e.pBarBg,
                    el.borderThickness or 1, el.borderColor or DEFAULT_BORDER_COLOR)
            end
        end
        if pairBorderAnim then
            local pbt = el.pairBorderStyle or "square"
            if IsRounded(pbt) then
                ApplyRoundPreview(e, "barmain", el.color,
                    barPoint, barOX, barOY, barW, barH, {},
                    true, pbt == "rounded_thick")
            else
                LayoutBorderEdges(e.pBorder, e.pBarBg,
                    el.pairBorderThickness or 1, el.color or DEFAULT_BORDER_COLOR)
            end
        end
        -- Text samples.
        RenderPreviewTexts(e, el, e.pBarBg, false, tracker:GetLabel())
        for j = 1, sampleCount - 1 do
            local sample = e.pExtraBars[j]
            if not sample then
                local sbg = e.holder:CreateTexture(nil, "BACKGROUND")
                local fill = e.holder:CreateTexture(nil, "ARTWORK")
                local sicon = e.holder:CreateTexture(nil, "ARTWORK")
                sample = { bg = sbg, fill = fill, icon = sicon }
                e.pExtraBars[j] = sample
            end
            -- Same holder-relative geometry as the main sample, shifted
            -- by the sample offset, so a fan-out preview reads exactly
            -- like the live row.
            local dx, dy = PreviewSampleOffset(dir, j, bw, bh, spacing)
            local sBarX, sBarY = barOX + dx, barOY + dy
            sample.bg:ClearAllPoints()
            sample.bg:SetSize(barW, barH)
            sample.bg:SetPoint(barPoint, e.holder, barPoint, sBarX, sBarY)
            sample.bg:SetColorTexture(bg[1], bg[2], bg[3], bg[4])
            sample.fill:ClearAllPoints()
            sample.fill:SetAllPoints(sample.bg)
            if showIcon then
                local sIconX, sIconY = iconOX + dx, iconOY + dy
                sample.icon:ClearAllPoints()
                sample.icon:SetSize(iconSize, iconSize)
                sample.icon:SetPoint(iconPoint, e.holder, iconPoint, sIconX, sIconY)
                sample.icon:SetTexture(SampleTex(j + 1))
                if ibt == "blizzard" then
                    sample.icon:SetTexCoord(0, 1, 0, 1)
                else
                    sample.icon:SetTexCoord(0.1, 0.9, 0.1, 0.9)
                end
                local sbTint = ApplyIconTintPreview(e, el, sample.icon, "bi" .. j,
                    ibt == "blizzard" and iconSize * 0.04 or 0)
                if IsRounded(ibt) then
                    ApplyRoundPreview(e, "baricon" .. j, el.barIconBorderColor,
                        iconPoint, sIconX, sIconY, iconSize, iconSize,
                        sbTint and { sample.icon, sbTint } or { sample.icon },
                        nil, ibt == "rounded_thick")
                elseif ibt == "square" then
                    LayoutBorderEdges(PreviewEdges(e, "baricon" .. j),
                        sample.icon, el.barIconBorderThickness or 1,
                        el.barIconBorderColor or DEFAULT_BORDER_COLOR)
                end
                sample.icon:Show()
            else
                sample.icon:Hide()
            end
            local sc = OColor(j + 1)
            sample.fill:SetTexture(pBarTex)
            sample.fill:SetVertexColor(sc[1], sc[2], sc[3], sc[4])
            if IsRounded(bt) then
                ApplyRoundPreview(e, "bar" .. j, el.borderColor,
                    barPoint, sBarX, sBarY, barW, barH,
                    { sample.bg, sample.fill }, true, bt == "rounded_thick")
            elseif bt == "square" then
                LayoutBorderEdges(PreviewEdges(e, "bar" .. j), sample.bg,
                    el.borderThickness or 1, el.borderColor or DEFAULT_BORDER_COLOR)
            end
            sample.bg:Show()
            sample.fill:Show()
        end
    elseif style == "circle" then
        local texPath = RING_TEXTURES[el.ringTexture] or RING_TEXTURES["Ring_20px"]
        local size = (el.circleRadius or 80) * 2
        e.pRingFrame:Show()
        e.pRingBg:SetRingSize(size)
        e.pRingBg:SetTexturePath(texPath)
        e.pRingBg:SetColor(0, 0, 0, 0.5)
        e.pRingBg:SetProgress(1)
        e.pRingBg:Show()
        e.pRing:SetRingSize(size)
        e.pRing:SetTexturePath(texPath)
        e.pRing:SetColor(c[1], c[2], c[3], c[4])
        e.pRing:SetProgress(1)
        e.pRing:Show()
    elseif style == "texture" then
        -- Mirrors the live branch in StyleButtonRegions exactly, on the
        -- holder-level preview region. Any divergence here shows up as
        -- "the preview doesn't match what I get in combat".
        local w = el.textureWidth or 128
        local h = el.textureHeight or 128
        e.pIcon:SetTexCoord(0, 1, 0, 1)
        e.pIcon:ClearAllPoints()
        e.pIcon:SetPoint("CENTER", e.holder, "CENTER")
        e.pIcon:SetSize(w, h)
        ns.ApplyTextureValue(e.pIcon, el.textureValue, el.textureKind)
        e.pIcon:SetVertexColor(c[1], c[2], c[3])
        e.pIcon:SetAlpha(c[4] or 1)
        e.pIcon:SetBlendMode(el.textureBlend or "ADD")
        e.pIcon:SetDesaturated(el.textureDesaturate and true or false)
        e.pIcon:SetRotation(math.rad(el.textureRotation or 0))
        e.pIcon:Show()
        -- Marks pIcon as needing a draw-state reset before any other style
        -- draws on it (see HideElementPreviewArt).
        e._pIconTexState = true
    elseif style == "text" then
        RenderPreviewTexts(e, el, e.holder, true, tracker:GetLabel())
    end

    local styleLabel = STYLE_LABELS[style] or style
    if multi and not tracker.config.singleActive then
        if style == "icon" then styleLabel = "Icons" end
        if style == "bar" then styleLabel = "Bars" end
    end
    if el.pairFor then styleLabel = styleLabel .. " Active" end
    e.label:SetText(tracker:GetLabel() .. " (" .. styleLabel .. ")")
    ShowPreviewHighlight(tracker, e)
    e.label:Show()
end

-------------------------------------------------------------------------------
-- Custom-kind live art (no aura). Renders on the holder-level regions (the
-- same ones the preview uses), driven by Load Conditions via UpdateGating /
-- ReapplySlotGating. All regions are non-secure → combat-safe. Defined here so
-- the preview helpers (HideElementPreviewArt, ApplyRoundPreview) are in scope.
-------------------------------------------------------------------------------
-- Draw element i's custom art (Icon or Text). Called only for custom-kind
-- trackers whose load conditions are currently met.
function methods:RenderCustomArt(i)
    local e = self.elements[i]
    if not e then return end
    local el = self:GetElementsDB()[i]
    local style = el and el.style
    -- Clear any previous art first (shared hide covers every p* region).
    HideElementPreviewArt(e)
    if not style or style == "sound" then return end

    if style == "icon" then
        local tex = ns.ResolveCustomIcon(el.customIconMode or "icon", el.customIconInput)
        if not tex then return end  -- nothing configured yet → show nothing
        local sz = el.iconSize or 40
        e.holder:SetSize(sz, sz)
        e.pIcon:SetTexture(tex)
        e.pIcon:ClearAllPoints()
        e.pIcon:SetAllPoints(e.holder)
        local borderType = BorderType(style, el)
        -- Always-on Desaturate / Recolor (this IS the live art for Custom
        -- trackers, not just a preview).
        local tintOvl = ApplyIconTintPreview(e, el, e.pIcon, "imain",
            borderType == "blizzard" and sz * 0.04 or 0)
        if borderType == "blizzard" then
            e.pIcon:SetTexCoord(0, 1, 0, 1)
        elseif IsRounded(borderType) then
            e.pIcon:SetTexCoord(0.1, 0.9, 0.1, 0.9)
            -- Reused scratch (see SCRATCH_REGIONS): this is the LIVE art path
            -- for custom trackers, re-rendered on every load-gating fan-out,
            -- so the two-element literal it replaces was a table per render.
            -- Both slots written; slot 2 nil when there is no tint overlay.
            SCRATCH_REGIONS[1], SCRATCH_REGIONS[2] = e.pIcon, tintOvl
            ApplyRoundPreview(e, "iconmain", el.borderColor,
                "TOPLEFT", 0, 0, sz, sz, SCRATCH_REGIONS,
                nil, borderType == "rounded_thick")
        else
            e.pIcon:SetTexCoord(0.1, 0.9, 0.1, 0.9)
            if borderType == "square" then
                LayoutBorderEdges(e.pBorder, e.holder,
                    el.borderThickness or 1, el.borderColor or DEFAULT_BORDER_COLOR)
            end
        end
        e.pIcon:Show()
        -- Optional static label on the icon.
        if el.customText and el.customText ~= "" then
            e.pText:SetText(el.customText)
            ApplyAnchoredText(e.pText, e.holder, el.font,
                el.customTextSize or 14, el.customTextPosition or "bottom",
                el.customTextColor or { 1, 1, 1, 1 }, el.fontBorder)
        end
    elseif style == "texture" then
        -- Static image, mirroring the aura Texture branch (StyleButtonRegions
        -- and the preview arm in RenderElementPreview) on the holder-level
        -- region. The aura version is already a "presence light" with no
        -- duration binding, so the custom kind loses nothing by having no aura;
        -- visibility comes from Load Conditions via UpdateCustomArt instead of
        -- from the engine parenting the region to an aura button.
        local w = el.textureWidth or 128
        local h = el.textureHeight or 128
        -- Sized HERE, not in ApplyElementStyle: that function early-returns for
        -- custom kinds (no engine button to style), so its style == "texture"
        -- holder sizing never runs on this path — same reason the icon arm
        -- above sets the holder size itself.
        e.holder:SetSize(w, h)
        e.pIcon:SetTexCoord(0, 1, 0, 1)
        e.pIcon:ClearAllPoints()
        e.pIcon:SetPoint("CENTER", e.holder, "CENTER")
        e.pIcon:SetSize(w, h)
        ns.ApplyTextureValue(e.pIcon, el.textureValue, el.textureKind)
        local tc = el.color or WHITE_RGBA
        e.pIcon:SetVertexColor(tc[1], tc[2], tc[3])
        e.pIcon:SetAlpha(tc[4] or 1)
        e.pIcon:SetBlendMode(el.textureBlend or "ADD")
        e.pIcon:SetDesaturated(el.textureDesaturate and true or false)
        -- SetRotation takes radians; the option stores degrees.
        e.pIcon:SetRotation(math.rad(el.textureRotation or 0))
        e.pIcon:Show()
        -- Marks pIcon as carrying a non-neutral draw state so the shared hide
        -- resets blend/rotation/desaturation/alpha before any other style draws
        -- on it — without this, switching Texture -> Icon leaves the icon
        -- additive and rotated (see HideElementPreviewArt).
        e._pIconTexState = true
        -- Optional static label, same affordance as the icon arm.
        if el.customText and el.customText ~= "" then
            e.pText:SetText(el.customText)
            ApplyAnchoredText(e.pText, e.holder, el.font,
                el.customTextSize or 14, el.customTextPosition or "bottom",
                el.customTextColor or { 1, 1, 1, 1 }, el.fontBorder)
        end
    elseif style == "text" then
        -- Free-standing static text centered on the holder.
        local txt = el.customText or ""
        if txt == "" then return end
        e.pText:SetText(txt)
        ApplyAnchoredText(e.pText, e.holder, el.font,
            el.customTextSize or 14, "center",
            el.customTextColor or { 1, 1, 1, 1 }, el.fontBorder)
    end

    -- In preview (Move/Preview) mode add the drag affordances the aura
    -- preview path provides: highlight, label, and a mouse-enabled holder.
    if self._elPreview[i] then
        e.holder:EnableMouse(true)
        e.label:SetText(self:GetLabel() .. " (" .. (STYLE_LABELS[style] or style) .. ")")
        ShowPreviewHighlight(self, e)
        e.label:Show()
    end
end

-- Hide element i's custom art. Shares the preview hide (same regions).
function methods:HideCustomArt(i)
    local e = self.elements[i]
    if e then HideElementPreviewArt(e) end
end

-------------------------------------------------------------------------------
-- ID-kind live art (ability/item; no aura). Parallel to the custom-art path:
-- renders on the holder-level regions, gated by Load Conditions via
-- UpdateIDArt. The cooldown SWIPE is driven by a duration OBJECT
-- (self-animating, zero per-frame CPU, NO number reads — the 12.1 combat-safe
-- pattern from Core's ArmCooldownTicker). Charge/stack COUNTS are read
-- event-driven behind a canaccessvalue guard (never per-frame; RefreshLoadGating
-- re-renders on SPELL_UPDATE_* / BAG_UPDATE_COOLDOWN). All regions are
-- non-secure → combat-safe.
-------------------------------------------------------------------------------

-- Is spellID on a REAL cooldown right now? Reads only the isActive/isOnGCD
-- BOOLEANS (accessible in combat by design — Core's IsSpellReady pattern), so
-- it's safe to call for "oncd" visibility even in a secret window.
local function SpellOnCooldown(spellID)
    if not C_Spell_GetSpellCooldown then return false end
    local cd = C_Spell_GetSpellCooldown(spellID)
    return (cd and cd.isActive and not cd.isOnGCD) and true or false
end

-- Is itemID on cooldown? The numbers are secret in combat, so guard the read;
-- if denied, treat as NOT on cooldown (fail open) — a later BAG_UPDATE_COOLDOWN
-- re-evaluates from a safe context.
local function ItemOnCooldown(itemID)
    if not C_Item_GetItemCooldown then return false end
    local start, duration, enableCD = C_Item_GetItemCooldown(itemID)
    if canaccess(enableCD) and enableCD == false then return false end
    if canaccess(duration) then
        return (duration or 0) > 0 and (start or 0) > 0
    end
    return false
end

-- Is the player currently EMPOWERING a spell? While an empower is held,
-- IsSpellUsable transiently reports OTHER spells as not usable (a known Evoker
-- empowered-cast quirk — warcraft.wiki C_Spell.IsSpellUsable lists no such
-- reason, and the empower window is what surfaces it), which would wrongly hide
-- every "Only When Usable" indicator mid-empower. UnitChannelInfo's isEmpowered
-- flag (9th return) is combat-safe and lets us ignore that transient false.
local function PlayerEmpowering()
    if not UnitChannelInfo then return false end
    local name, _, _, _, _, _, _, _, isEmpowered = UnitChannelInfo("player")
    return name ~= nil and isEmpowered == true
end

-- Is spellID currently USABLE (off cooldown AND usable — resources/charges)?
-- Mirrors Core's IsSpellReady: the usable boolean is accessible in combat, and
-- cooldown reads only the isActive/isOnGCD booleans (also combat-safe). Used by
-- the "Only When Usable" visibility mode.
local function SpellUsable(spellID)
    if C_Spell_IsSpellUsable and not PlayerEmpowering() then
        local usable = C_Spell_IsSpellUsable(spellID)
        if canaccess(usable) and not usable then return false end
    end
    return not SpellOnCooldown(spellID)
end

-- Is itemID currently USABLE (usable AND off cooldown)? Mirrors Core's
-- IsItemReady. Usable boolean is combat-safe; cooldown falls back to
-- fail-open when the numbers are secret (ItemOnCooldown).
local function ItemUsable(itemID)
    if C_Item_IsUsableItem then
        local usable = C_Item_IsUsableItem(itemID)
        if canaccess(usable) and not usable then return false end
    end
    return not ItemOnCooldown(itemID)
end

-- Is an ability (spell) currently KNOWN to the player? Checks the player + pet
-- spellbooks, falling back to IsPlayerSpell
-- on older clients. Spellbook state is non-secret / combat-safe (called
-- unguarded). Used to auto-hide ability trackers for spells you don't have
-- (e.g. wrong spec). Items are always considered "known" (you either have the
-- item or its own cooldown/count gates it).
local C_Spell_GetOverrideSpell = C_Spell and C_Spell.GetOverrideSpell
local function KnownInBook(spellID)
    if SPELLBANK_PLAYER and C_SpellBook_IsSpellKnown(spellID, SPELLBANK_PLAYER) then
        return true
    end
    if SPELLBANK_PET and C_SpellBook_IsSpellKnown(spellID, SPELLBANK_PET) then
        return true
    end
    return false
end
-- MEMO: the known-set only changes on learn/unlearn/talent swap/spec change,
-- but this is called from the ID watcher fan-out (IDDrawable, once per drawn id
-- per re-render, up to once per GCD in combat) and from the watch rebuild. Each
-- miss costs 1-2 C_SpellBook_IsSpellKnown calls plus a C_Spell.GetOverrideSpell
-- -- an UNKNOWN id (exactly what a group carries for off-spec abilities) costs
-- five API calls to answer the same way every time. Wiped by
-- ns.WipeAbilityKnownCache from Core's known-set-change handlers.
local abilityKnownMemo = {}
-- maxCharges per spell. STATIC: it moves only with the known-spell set
-- (talent / loadout / spec), which is precisely what abilityKnownMemo already
-- tracks -- so it rides the SAME wipe below and adds no new invalidation
-- surface. Declared here rather than beside SpellMaxCharges so the wipe can see
-- it; Core already caches the same value at watch-build time
-- (watchedIDSpellCharges), which is the precedent for treating it as static.
local spellMaxChMemo = {}
local function AbilityKnown(spellID)
    -- Reading t[nil] is legal in Lua 5.1 (yields nil); only the WRITE below is
    -- guarded, so a nil/garbage spellID reaches the same APIs it always did.
    local memo = abilityKnownMemo[spellID]
    if memo ~= nil then return memo end
    local result
    if C_SpellBook_IsSpellKnown then
        result = false
        if KnownInBook(spellID) then
            result = true
        -- A talent may REPLACE the tracked spell with an override the player
        -- actually has — count that as known. (elseif, not if: in the original
        -- the override branch was only reachable when KnownInBook was false,
        -- because the true case returned early.)
        elseif C_Spell_GetOverrideSpell then
            local ov = C_Spell_GetOverrideSpell(spellID)
            if ov and ov ~= spellID and KnownInBook(ov) then result = true end
        end
    elseif IsPlayerSpell then
        result = IsPlayerSpell(spellID) and true or false
    else
        result = true  -- API missing: fail open (show it)
    end
    if spellID ~= nil then abilityKnownMemo[spellID] = result end
    return result
end
-- Exported: Core's ID watch build excludes UNKNOWN abilities so trackers for
-- spells this character doesn't have cost zero CPU (no watch entry → no
-- per-event reads → possibly no events registered at all).
ns.AbilityKnown = AbilityKnown
-- Exported wipe: MUST be called BEFORE any known-set-dependent rebuild (Core's
-- SPELLS_CHANGED -> UpdateIDReadyWatch, the spec-change refresh, and the
-- loadout-change edge).
function ns.WipeAbilityKnownCache()
    wipe(abilityKnownMemo)
    wipe(spellMaxChMemo)
end

-- Automatic draw gate: an ABILITY id is only drawn while the spell is known
-- (wrong-spec/untalented abilities auto-hide). Items are exempt. Applied to
-- every drawn id (single + group), on top of the user's "Show When" gate.
local function IDDrawable(id, isItem)
    if isItem then return true end
    return AbilityKnown(id)
end

-- Does `id` pass the given idVisibility mode? "oncd" = on cooldown; "usable" =
-- ready to use; anything else ("always") = always true. Shared by the single-
-- tracker gate (UpdateIDArt) and the per-icon group filter (RenderIDArt).
-- EXPLICIT branches, not `isItem and ItemX(id) or SpellX(id)`: when the item
-- check returned FALSE that expression fell through to the SPELL API with an
-- ITEM id (whatever spell happens to share the number), making the item gate
-- return that unrelated spell's state — "Only When Usable" kept showing items
-- that were on cooldown.
local function IDPasses(mode, id, isItem)
    if mode == "oncd" then
        if isItem then return ItemOnCooldown(id) end
        return SpellOnCooldown(id)
    elseif mode == "usable" then
        if isItem then return ItemUsable(id) end
        return SpellUsable(id)
    end
    return true
end

-- Does this element carry a text entry of the given binding ("duration" /
-- "stacks")? Used to decide whether to draw the cooldown-countdown numbers and
-- the charge/stack count.
local function ElementHasTextBinding(el, binding)
    local t = EnsureTexts(el)
    for j = 1, #t do
        if t[j] and t[j].show ~= false and TEXT_BINDING[t[j].type] == binding then
            return true, t[j]
        end
    end
    return false, nil
end

-- Whether an ID element wants a cooldown swipe drawn (a Bar tracking Cooldown,
-- an Icon with cooldownSwipe on, or any element with a Cooldown/duration text).
local function IDWantsSwipe(el)
    if el.style == "icon" then
        return el.cooldownSwipe ~= false
    end
    if el.style == "bar" then
        return (el.trackedProperty or "duration") == "duration"
    end
    -- circle/text: swipe if a cooldown-countdown text is present.
    return (ElementHasTextBinding(el, "duration")) and true or false
end

-- Style the ENGINE countdown FontString of a Cooldown widget from the element's
-- duration ("Cooldown") text entry. The Cooldown draws its own number into its
-- first region (a FontString on the CooldownFrameTemplate); we set only its
-- appearance — font/size/color/border + anchor — which is pure-appearance, the
-- combat-safe call class (the engine still owns the number; no Lua time read).
-- NOTE: color is STATIC here — the engine countdown FontString has no color
-- curve (that lives on DurationTextBinding, which the ID path doesn't use), so
-- Threshold Colors do not apply to ID Cooldown text.
-- Anchor: the FontString is a CHILD of cdWidget, so it must anchor to cdWidget
-- (its own parent) — NOT to an external region, which strips the engine's own
-- anchor and leaves the number unpositioned/invisible (anchor to
-- frame:GetParent()). cdWidget overlays the icon/swipe rect,
-- so the 9-point grid still places the number over the art. Re-applied every
-- render (the engine re-centres on each arm).
local function StyleIDCooldownText(cdWidget, t)
    if not cdWidget then return end
    local fs = cdWidget._hetCountdownFS
    if not fs then
        -- First region of a CooldownFrameTemplate is its countdown FontString.
        local r1 = cdWidget:GetRegions()
        if r1 and r1.GetObjectType and r1:GetObjectType() == "FontString" then
            fs = r1
            cdWidget._hetCountdownFS = fs
        end
    end
    if not fs then return end
    t = t or {}
    local size = t.fontSize or 18
    fs:SetFont(FontPath(t.font), size, t.fontBorder or "OUTLINE")
    local c = t.color or WHITE_RGBA
    fs:SetTextColor(c[1], c[2], c[3], c[4])
    local a = TEXT_ANCHOR[t.position or "center"] or TEXT_ANCHOR.center
    fs:ClearAllPoints()
    fs:SetPoint(a[1], cdWidget, a[1], a[2], a[3])
end

-- Clear an ID cooldown widget UNLESS its element asked for the native
-- "Cooldown Finished" animation.
--
-- Cooldown:Clear() is the documented way to cancel a cooldown WITHOUT playing
-- the finish bling. The off-cooldown edge ticker (Core.ArmIDSpellTicker's
-- OnCooldownDone) fires a forced RecheckWatchedIDs the instant a cooldown
-- ends, which re-renders this sample, which observes "nothing armed" and
-- clears -- on the exact frame the bling would start. So the option would
-- silently do nothing. Skipping the Clear is safe: an expired cooldown draws
-- no swipe and no numbers by itself, and the next arm overwrites it.
-- `_hetKeepBling` is stamped by the render pass alongside SetDrawBling.
-- Stamp Blizzard's standard cooldown-finish art on a widget the first time the
-- option is switched on for it. CooldownFrameTemplate is not guaranteed to ship
-- a bling texture, and SetDrawBling on a widget with no art draws nothing --
-- indistinguishable from the option being broken. Once per widget.
local function EnsureBlingTexture(cdWidget, wanted)
    if not (wanted and cdWidget) or cdWidget._hetBlingTex then return end
    cdWidget._hetBlingTex = true
    pcall(cdWidget.SetBlingTexture, cdWidget, "Interface\\Cooldown\\star4")
end

local function ClearIDCooldown(cdWidget)
    if not cdWidget then return end
    if cdWidget._hetKeepBling then return end
    cdWidget:Clear()
end

-- Arm the given Cooldown widget (swipe) for spellID/itemID from a duration
-- OBJECT. Ability path uses the engine's duration-object API directly (self-
-- animating, no number read). Item path builds a duration object from the
-- start/duration numbers behind a canaccess guard — secret in combat → skip
-- (a later BAG_UPDATE_COOLDOWN re-arms). Returns true if a live cooldown was
-- armed.
local function ArmIDCooldown(cdWidget, id, isItem, showNumbers)
    cdWidget:SetHideCountdownNumbers(not showNumbers)
    -- Cooldown Finished: a widget that is about to bling must NOT be re-armed.
    -- IDCooldownDuration already documents the trap for the BAR path --
    -- GetSpellCooldownDuration returns a NON-nil ready/ZERO duration when the
    -- spell is OFF cooldown -- but this icon path never gated on it, so it fed
    -- that zero straight into SetCooldownFromDurationObject. That resets the
    -- widget to a zero-length cooldown, which cancels the finish animation the
    -- engine was about to play. The off-cooldown ticker re-renders at exactly
    -- that instant, so it fired every single time and the option looked dead.
    -- Gate on the combat-safe booleans and leave the finished cooldown alone.
    -- Written long-hand: `isItem and A(id) or B(id)` collapses to false when A
    -- returns false, which is the and/or-with-false trap.
    if cdWidget._hetKeepBling then
        local onCd
        if isItem then onCd = ItemOnCooldown(id) else onCd = SpellOnCooldown(id) end
        if not onCd then return false end
    end
    if not isItem then
        -- true = ignore the GCD contribution; we want the REAL cooldown.
        local dur = C_Spell_GetSpellCooldownDuration and C_Spell_GetSpellCooldownDuration(id, true)
        if dur then
            cdWidget:SetCooldownFromDurationObject(dur)
            return true
        end
        ClearIDCooldown(cdWidget)
        return false
    end
    -- Item: numbers secret in combat → skip (leave the last-armed swipe running).
    -- MUST be a direct call: `f and f(id)` truncates the call to ONE return
    -- value in Lua, which left `duration` permanently nil here — items never
    -- armed their cooldown swipe at all.
    if not C_Item_GetItemCooldown then
        ClearIDCooldown(cdWidget)
        return false
    end
    local start, duration = C_Item_GetItemCooldown(id)
    if not (canaccess(start) and canaccess(duration)) then return nil end
    if start and duration and start > 0 and duration > 0 then
        local durationObject = C_DurationUtil and C_DurationUtil.CreateDuration
            and C_DurationUtil.CreateDuration()
        if durationObject then
            durationObject:SetTimeFromStart(start, duration)
            cdWidget:SetCooldownFromDurationObject(durationObject)
            return true
        end
    end
    ClearIDCooldown(cdWidget)
    return false
end

-- maxCharges for a spell, MEMOIZED (see spellMaxChMemo). It is a STATIC
-- property — safe to compare any time — unlike currentCharges, which is a
-- SECRET number in combat and must never be compared or execution taints (see
-- the SECRET rule). Abilities only; items have no charges. Defined ahead of
-- ArmIDSwipe, which needs it.
--
-- Every consumer that wants ONLY the max goes through here — ArmIDSwipe's
-- fallback, IDUnusableNow, and RenderIDText's "/N" suffix — because each was
-- calling C_Spell.GetSpellCharges per drawn id per event and throwing away the
-- whole struct table it allocates to keep one static number. RenderIDBar still
-- reads the API directly: it needs currentCharges and isActive in the same
-- breath, which are live values and cannot be cached.
-- `false` is the "no charge data" sentinel. The two ways of getting no answer
-- must NOT be conflated, which is the whole reason for the split below:
--   * a nil struct means the spell HAS no charges. That is a definite, static
--     answer and must be cached — chargeless abilities are the common case, and
--     without this the memo stays permanently empty for every one of them and
--     the API call happens on every render exactly as before.
--   * a canaccess DENIAL is a transient combat/secret window, not an answer,
--     and is never cached.
local function SpellMaxCharges(spellID)
    local m = spellMaxChMemo[spellID]
    if m ~= nil then return m or nil end
    if not C_Spell_GetSpellCharges then return nil end
    local ch = C_Spell_GetSpellCharges(spellID)
    if ch == nil then
        -- Chargeless: definite, cache it. Guarded against a nil spellID for
        -- the same reason abilityKnownMemo's write is — reading t[nil] is legal
        -- in Lua 5.1, writing it is not.
        if spellID ~= nil then spellMaxChMemo[spellID] = false end
        return nil
    end
    if not canaccess(ch) then return nil end
    m = ch.maxCharges
    if spellID ~= nil then spellMaxChMemo[spellID] = m or false end
    return m
end

-- Swipe alphas: dimmer while a usable charge remains (recharging the NEXT
-- charge), full-dark when the ability is fully spent / on a plain cooldown.
local ID_SWIPE_ALPHA_CHARGING = 0.4  -- >=1 charge left
local ID_SWIPE_ALPHA_FULL     = 0.7  -- 0 charges / plain cooldown
-- GCD swipe sits lighter than either, so a global cooldown never reads like a
-- real one at a glance.
local ID_SWIPE_ALPHA_GCD      = 0.35

-------------------------------------------------------------------------------
-- GCD Swipe
--
-- The global cooldown is expressed through the SAME duration-object API as any
-- other cooldown -- GetSpellCooldownDuration's `ignoreGCD` flag is the only
-- thing separating them. Everywhere else this addon passes ignoreGCD = true
-- because it wants the REAL cooldown end; passing it FALSE (omitted) returns
-- the duration WITH the global folded in, and on a spell that is merely
-- GCD-locked that duration IS the global. Engine-driven, self-animating, no
-- number read.
--
-- It needs its OWN Cooldown widget: the main swipe is armed from the real
-- cooldown, and one widget cannot hold both arms.
--
-- CPU: re-arming these deliberately does NOT go through RefreshIDArt. The ID
-- change guard (Core.RecheckWatchedIDs) tests `isActive and not isOnGCD`
-- precisely so a cast does not re-render every ID tracker; routing the GCD
-- through it would put a full render on every GCD in combat, which is the one
-- thing that path is built to avoid. Instead SPELL_UPDATE_COOLDOWN re-arms
-- these widgets directly through ns.ArmGcdSwipes -- no render, no layout, no
-- allocation beyond the duration object -- and the loop is empty for anyone
-- who has not switched the option on.
-------------------------------------------------------------------------------
local GCD_SPELL_ID = 61304

-- Every GCD widget ever created, each carrying the id it draws for. Bounded:
-- one per element per drawn sample, pushed once at creation.
ns.gcdWidgets = ns.gcdWidgets or {}

-- The GCD duration object for id, or nil when there is no global to draw.
-- Every read here is a NeverSecret boolean (isActive / isOnGCD) plus the
-- duration OBJECT itself; no number is read, so this is safe in combat.
local function ArmGcdDuration(id, isItem)
    if not (id and C_Spell_GetSpellCooldown
        and C_Spell_GetSpellCooldownDuration) then
        return nil
    end
    -- Items have no spell GCD of their own, so they borrow the player's global
    -- from the GCD spell itself.
    local src = isItem and GCD_SPELL_ID or id
    local cd = C_Spell_GetSpellCooldown(src)
    local onGcd
    if isItem then
        -- The GCD spell's own cooldown IS the global; isActive alone is the
        -- gate. But an item on its OWN cooldown must not also draw a GCD
        -- sweep, or the two swipes run over one icon at different rates on
        -- every cast. The ability branch gets that for free from `isOnGCD`;
        -- items have no such signal on the GCD spell, so ask about the item.
        onGcd = (cd and cd.isActive) and true or false
        if onGcd and ItemOnCooldown(id) then onGcd = false end
    else
        -- isActive AND isOnGCD = GCD-locked but not on a real cooldown -- the
        -- exact inverse of SpellOnCooldown's test, so an ability that is off
        -- the global (or genuinely recharging) draws nothing here.
        onGcd = (cd and cd.isActive and cd.isOnGCD) and true or false
    end
    if not onGcd then return nil end
    -- ignoreGCD omitted (defaults false) = the duration WITH the global in it.
    return C_Spell_GetSpellCooldownDuration(src)
end

local function ArmIDGcdSwipe(cdWidget, id, isItem)
    if not cdWidget then return false end
    local dur = ArmGcdDuration(id, isItem)
    if not dur then
        cdWidget:Clear()
        return false
    end
    cdWidget:SetCooldownFromDurationObject(dur)
    return true
end

-- Get (creating on demand) the GCD widget for fan-out sample k of element e.
-- Mirrors the main cooldown pool: k = 0 uses the element's own widget, k >= 1
-- come from a lazy table. Holder-owned frames, so CreateFrame is allowed here
-- (unlike the aura path's engine-owned buttons).
local function IDGcdWidget(e, k)
    local w
    if k == 0 then
        w = e.pGcdCooldown
    else
        e.pGcdExtras = e.pGcdExtras or {}
        w = e.pGcdExtras[k]
    end
    if not w then
        w = CreateFrame("Cooldown", nil, e.holder, "CooldownFrameTemplate")
        w:SetHideCountdownNumbers(true)
        w:SetDrawEdge(false)
        -- A global cooldown ending is not a "cooldown finished" event, so it
        -- never blings regardless of the element's own setting.
        w:SetDrawBling(false)
        w:Hide()
        if k == 0 then e.pGcdCooldown = w else e.pGcdExtras[k] = w end
        ns.gcdWidgets[#ns.gcdWidgets + 1] = w
    end
    return w
end

-- Drop an abandoned element's widgets out of the re-arm registry. Elements are
-- PARKED and rebuilt (SyncElementUnits -> ParkElement, then EnsureElements
-- builds a fresh `e` with fresh widgets), so without this the registry grows
-- for the session and every cast walks the dead entries too.
function ns.RetireGcdWidgets(e)
    if not e then return end
    local list = ns.gcdWidgets
    local dead = e.pGcdCooldown
    local extras = e.pGcdExtras
    if not (dead or extras) then return end
    for j = #list, 1, -1 do
        local w = list[j]
        local drop = (w == dead)
        if not drop and extras then
            for _, x in pairs(extras) do
                if x == w then drop = true break end
            end
        end
        if drop then
            w._gcdWanted = nil
            pcall(w.Clear, w)
            pcall(w.Hide, w)
            table.remove(list, j)
        end
    end
    e.pGcdCooldown, e.pGcdExtras = nil, nil
end

-- Per-call scratch: maps an arm SOURCE id to the duration object already
-- resolved for it this pass. Several widgets commonly share one id (a group
-- fan-out, and EVERY item, which all borrow spell 61304), so without this the
-- same C_Spell lookups run once per widget. Reused, never reallocated -- this
-- runs on SPELL_UPDATE_COOLDOWN, i.e. every cast in combat.
local GCD_ARM_CACHE = {}

-- Re-arm every wanted GCD widget. Called from Core's SPELL_UPDATE_COOLDOWN
-- handler (see the CPU note above). `_gcdWanted` is stamped by the render pass,
-- so a widget whose element turned the option off, switched style, or stopped
-- drawing is skipped without any state lookup at all.
function ns.ArmGcdSwipes()
    local list = ns.gcdWidgets
    local n = #list
    if n == 0 then return end
    local used = false
    for j = 1, n do
        local w = list[j]
        if w._gcdWanted then
            local id, isItem = w._gcdId, w._gcdIsItem
            -- Items are gated per-ITEM (their own cooldown), so they cannot
            -- share a cached verdict even though they share the GCD source.
            local key = (not isItem) and id or nil
            local dur = key and GCD_ARM_CACHE[key]
            if dur == nil then
                -- `false`, not nil, for the negative result: a nil entry is
                -- indistinguishable from "not cached" and would re-run the
                -- lookups for every widget sharing a GCD-less id.
                dur = ArmGcdDuration(id, isItem) or false
                if key then GCD_ARM_CACHE[key] = dur; used = true end
            end
            if dur then
                w:SetCooldownFromDurationObject(dur)
                w:Show()
            else
                w:Clear()
                w:Hide()
            end
        end
    end
    if used then wipe(GCD_ARM_CACHE) end
end

-- Point a GCD widget at one sample's icon rect and arm it, or park it when the
-- element does not want one. `region` is the icon; `inset` matches the main
-- swipe's border inset so the two occupy the same rect.
local function ApplyIDGcdSwipe(e, k, el, region, borderType, inset, id, isItem, wanted)
    local w = (k == 0) and e.pGcdCooldown or (e.pGcdExtras and e.pGcdExtras[k])
    if not wanted then
        if w then
            w._gcdWanted = nil
            w:Clear()
            w:Hide()
        end
        return
    end
    w = IDGcdWidget(e, k)
    w._gcdId, w._gcdIsItem, w._gcdWanted = id, isItem, true
    -- Same level as the real cooldown swipe (the holder's default child level,
    -- +1). It was one level LOWER, to make the stacking order explicit -- but
    -- that put it level with the holder itself, i.e. level with the icon
    -- TEXTURE it has to draw over, which is a difference from the known-good
    -- pCooldown that nothing justified. The two are mutually exclusive by
    -- construction (a real cooldown suppresses the GCD arm), so sharing a
    -- level costs nothing.
    w:SetFrameLevel(e.holder:GetFrameLevel() + 1)
    w:ClearAllPoints()
    w:SetPoint("TOPLEFT", region, "TOPLEFT", inset, -inset)
    w:SetPoint("BOTTOMRIGHT", region, "BOTTOMRIGHT", -inset, inset)
    w:SetSwipeTexture(IsRounded(borderType) and ICON_ROUND_MASK_TEX or WHITE8X8)
    w:SetSwipeColor(0, 0, 0, ID_SWIPE_ALPHA_GCD)
    w:SetReverse(false)
    w:SetDrawSwipe(true)
    if ArmIDGcdSwipe(w, id, isItem) then
        w:Show()
    else
        w:Clear()
        w:Hide()
    end
end

-------------------------------------------------------------------------------
-- Per-render scratch + key caches (SPELL_UPDATE_COOLDOWN path).
--
-- Everything below exists to keep the ID render path ALLOCATION-FREE. That
-- path runs up to once per GCD in combat, per element, per drawn id -- every
-- table/closure/string built there was garbage within microseconds.
-------------------------------------------------------------------------------

-- Shared `regions` scratch for ApplyRoundPreview. ApplyRoundPreview COPIES each
-- entry into rr.applied and never stores the array itself, so a reused table is
-- (SCRATCH_REGIONS and WHITE_RGBA are declared near DEFAULT_BORDER_COLOR at the
-- top of the file — RenderCustomArt uses both, and it sits above this point.)

-- Read-only colour fallbacks, same pattern as DEFAULT_BORDER_COLOR: consumers
-- only ever READ indices 1..4 and never store them into the DB, so one shared
-- table serves every element. DO NOT hand these to anything that could mutate
-- or retain them.
local ID_BAR_BG_DEFAULT   = { 0.1, 0.1, 0.1, 0.7 }
local ID_BAR_FILL_DEFAULT = { 1, 1, 1, 1 }

-- Region-pool KEY caches. Every pool these index (e.pRound, e.pEdges,
-- e.pIDBarCDs, e.pIDTintOvls) is ALREADY per-element, so a key only has to be
-- unique WITHIN an element -- it depends on the sample index k (and, for
-- segments, the charge index s) and nothing else. One shared cache therefore
-- serves every tracker and every element. Lazily grown: k is bounded by
-- el.maxFrames only when that field is set (a legacy element falls back to
-- #ids, uncapped), and maxCharges is an engine value with no static bound.
-- Generated strings are byte-identical to the concats they replace, so pool
-- entries created before this change stay addressable. No migration.
local function KeyCache(prefix)
    return setmetatable({}, { __index = function(t, k)
        local v = prefix .. k
        rawset(t, k, v)
        return v
    end })
end
-- Two-level: prefix .. k .. "_" .. s (per sample, per charge segment).
local function SegKeyCache(prefix)
    return setmetatable({}, { __index = function(t, k)
        local row = setmetatable({}, { __index = function(r, s)
            local v = prefix .. k .. "_" .. s
            rawset(r, s, v)
            return v
        end })
        rawset(t, k, row)
        return row
    end })
end
-- ONE table rather than eleven file-scope locals: Lua 5.1 caps a chunk at 200
-- locals and exceeding it is a hard load-time failure for the whole addon, so
-- a long-lived file like this one should not spend a slot per cache. The extra
-- hash lookup is off the allocation-critical path.
local K = {
    s        = KeyCache("s"),         -- icon-sample unusable overlay
    icon     = KeyCache("icon"),      -- icon-sample rounded border
    bar      = KeyCache("bar"),       -- bar-body rounded border
    idbarsq  = KeyCache("idbarsq"),   -- bar-body square edges
    baricon  = KeyCache("baricon"),   -- bar icon-square border
    b        = KeyCache("b"),         -- bar icon-square unusable overlay
    idbarchg = KeyCache("idbarchg"),  -- charging-pip square edges
    barseg   = SegKeyCache("barseg"),
    idbarseg = SegKeyCache("idbarseg"),
    -- role .. ":" .. k for IDBarCooldown.
    cd = {
        icon    = KeyCache("icon:"),
        bar     = KeyCache("bar:"),
        segtext = KeyCache("segtext:"),
    },
}

-- Arm a Cooldown widget with a DUMMY duration so the config preview shows a
-- sample countdown + swipe (no real cooldown runs while previewing). ~8s from
-- "now" so the engine draws a live-looking number. Returns true if armed.
local function ArmPreviewCooldown(cdWidget, showNumbers)
    cdWidget:SetHideCountdownNumbers(not showNumbers)
    local dur = C_DurationUtil and C_DurationUtil.CreateDuration
        and C_DurationUtil.CreateDuration()
    if dur and GetTime then
        dur:SetTimeFromStart(GetTime(), 8)
        cdWidget:SetCooldownFromDurationObject(dur)
        return true
    end
    return false
end

-- Arm a cooldown SWIPE for id and return (armed, alpha). Multi-charge abilities
-- (maxCharges > 1) drive the swipe from the CHARGE recharge (so it shows while a
-- charge refills even though the ability is still usable) and dim the swipe while
-- a usable charge remains; at 0 charges the ability is on its full cooldown and
-- the swipe is full-dark. Single-charge abilities/items keep the plain cooldown
-- swipe. Combat-safe: charge counts read behind the SpellMaxCharges canaccess
-- guard; recharge/cooldown are engine duration objects (no number read).
-- `preMaxCh` lets a caller that ALREADY read the charge table pass maxCharges
-- in (RenderIDBar does). Omitted = read here, exactly as before. It is only the
-- COUNT that is shared: the recharge duration below is still fetched locally,
-- because this function's own "is a charge recharging" test IS the nil check on
-- it, and that ungated meaning differs from the chargeActive-gated fetches in
-- RenderIDBar (GetSpellCooldownDuration's documented non-nil-when-ready
-- behaviour means the two must not be assumed interchangeable).
local function ArmIDSwipe(cdWidget, id, isItem, showNumbers, preMaxCh)
    if not isItem and C_Spell_GetSpellCharges then
        local maxCh = preMaxCh
        if maxCh == nil then maxCh = SpellMaxCharges(id) end
        -- maxCharges is a static property (safe to compare); currentCharges is
        -- SECRET in combat — never compared here (opacity uses IsSpellUsable).
        if maxCh and maxCh > 1 then
            -- "A charge is recharging?" comes from the CHARGE-recharge duration
            -- object (engine-side, combat-safe), NOT from comparing the secret
            -- count. nil ⇒ all charges up ⇒ no swipe.
            local cd = C_Spell_GetSpellChargeDuration and C_Spell_GetSpellChargeDuration(id)
            cdWidget:SetHideCountdownNumbers(not showNumbers)
            if cd then
                cdWidget:SetCooldownFromDurationObject(cd)
                -- Opacity: dim while a charge remains, DARK at 0 charges. The count
                -- is secret, but the BASE SPELL COOLDOWN (GetSpellCooldown.isActive,
                -- read via SpellOnCooldown — combat-safe booleans) is active ONLY
                -- when 0 charges remain (the whole ability is on cooldown), whereas
                -- the CHARGE recharge above runs whenever any charge is refilling.
                -- So base-cooldown-active ⇒ 0 charges ⇒ dark; otherwise ≥1 charge
                -- available ⇒ dim. (The base cooldown is tracked separately from
                -- the charge cooldown for this reason.)
                local alpha = SpellOnCooldown(id) and ID_SWIPE_ALPHA_FULL
                    or ID_SWIPE_ALPHA_CHARGING
                return true, alpha
            end
            ClearIDCooldown(cdWidget)
            return false, ID_SWIPE_ALPHA_FULL
        end
    end
    -- Single-charge ability / item: plain cooldown swipe (existing behavior).
    return ArmIDCooldown(cdWidget, id, isItem, showNumbers), ID_SWIPE_ALPHA_FULL
end

-- Render one ID sample (icon k=0 is the main holder region, k>=1 are fan-out
-- extras). `id` is the spell/item id, `cdWidget` the swipe widget for it. Draws
-- the icon + border, arms the swipe, and (main sample only) the charge/stack
-- text. Returns nothing.
-- "Unusable" styling (Desaturate when Unusable / Recolor when Unusable):
-- reads the same combat-safe usable booleans as the "Only When Usable"
-- gate (SpellUsable/ItemUsable — no secret numbers), and re-renders on
-- usable/cooldown flips via the ID watcher (Core arms the usable events
-- whenever either option is enabled). PREVIEWS always show the styled
-- state so the overlay color picker is WYSIWYG. Desaturation is set on
-- the icon texture; the recolor overlay is a pooled holder texture at
-- ARTWORK sublevel 2 (above the icon, below the Cooldown child frame),
-- anchored to the icon with an optional inset (blizzard-style borders
-- keep it inside the stock art's rounded corners). Returns the overlay
-- while shown so rounded borders can include it in their mask.
local ID_RECOLOR_DEFAULT = { 1, 0.15, 0.15, 0.45 }

-- MULTI-CHARGE debounce for the unusable STYLING: charge spells blip
-- "unusable" for roughly a GCD after each cast on this build (the strict
-- gate reads the transient cooldown state) even while charges remain —
-- and the charge count is SECRET in combat, so a charge-aware exemption
-- is impossible in Lua. A TIMING filter is the one approach that behaves
-- identically in and out of combat (owner rule): the gate's verdict must
-- hold CONTINUOUSLY for ID_UNUSABLE_DELAY before the styling applies.
-- Blips (GCD reads) never style; real states (0 charges, resource
-- starvation) style ~1.6s late and stick. Threshold must exceed a full
-- unhasted 1.5s GCD. spellID-keyed (state is per spell, shared by every
-- element that tracks it); the one-shot timer re-renders once the
-- pending threshold can have elapsed (renders are otherwise event-driven
-- and nothing fires AT the threshold).
local ID_UNUSABLE_DELAY = 0.5
local idUnusableSince = {}
local idUnusableTimer = false
local function ScheduleIDUnusableRecheck()
    if idUnusableTimer or not (C_Timer and C_Timer.After) then return end
    idUnusableTimer = true
    C_Timer.After(ID_UNUSABLE_DELAY + 0.1, function()
        idUnusableTimer = false
        if ns.addon and ns.addon.RefreshIDArt then
            ns.addon:RefreshIDArt()
        end
    end)
end

-- Is this id "unusable" for the styling options right now? false when
-- neither option is enabled (zero API reads); previews always read true.
local function IDUnusableNow(tracker, i, id, isItem, el)
    if not (el.idDesatUnusable or el.idRecolorUnusable) then return false end
    if tracker._elPreview[i] then return true end
    if isItem then return not ItemUsable(id) end
    -- STRICT gate, identical in and out of combat (OWNER RULE: one
    -- behavior regardless of combat — do not reintroduce per-state
    -- splits): unusable = resource-unusable OR any REAL (non-GCD)
    -- cooldown. All combat-safe boolean reads. Multi-charge spells put a
    -- DEBOUNCE on the verdict (see ID_UNUSABLE_DELAY above); everything
    -- else styles immediately.
    local unusable = not SpellUsable(id)
    local maxCh = SpellMaxCharges(id)
    if not (maxCh and maxCh > 1) then return unusable end
    if not unusable then
        idUnusableSince[id] = nil
        return false
    end
    local now = GetTime()
    local since = idUnusableSince[id]
    if not since then
        idUnusableSince[id] = now
        ScheduleIDUnusableRecheck()
        return false
    end
    if now - since >= ID_UNUSABLE_DELAY then return true end
    -- Re-rendered inside the pending window (some other event): make sure
    -- something still fires once the threshold can have elapsed.
    ScheduleIDUnusableRecheck()
    return false
end
local function ApplyIDUnusableStyle(tracker, i, e, region, key, id, isItem, el, inset, unusable)
    if unusable == nil then
        unusable = IDUnusableNow(tracker, i, id, isItem, el)
    end
    region:SetDesaturated((el.idDesatUnusable and unusable) and true or false)
    local pool = e.pIDTintOvls
    local ovl = pool and pool[key]
    if el.idRecolorUnusable and unusable then
        if not ovl then
            if not pool then
                pool = {}
                e.pIDTintOvls = pool
            end
            ovl = e.holder:CreateTexture(nil, "ARTWORK", nil, 2)
            pool[key] = ovl
        end
        local oc = el.idRecolorColor or ID_RECOLOR_DEFAULT
        ovl:SetColorTexture(oc[1], oc[2], oc[3], oc[4])
        ovl:ClearAllPoints()
        local pad = inset or 0
        ovl:SetPoint("TOPLEFT", region, "TOPLEFT", pad, -pad)
        ovl:SetPoint("BOTTOMRIGHT", region, "BOTTOMRIGHT", -pad, pad)
        ovl:Show()
        return ovl
    elseif ovl then
        ovl:Hide()
    end
    return nil
end

function methods:RenderIDSample(i, id, k, sz, dir, spacing, isItem, el)
    local e = self.elements[i]
    local tex = isItem
        and (C_Item and C_Item.GetItemIconByID and C_Item.GetItemIconByID(id))
        or GetSpellTexture(id)
    if not tex then return end
    -- Pure TEXT style: no icon art at all — the display is the engine
    -- countdown text (on the armed Cooldown widget below) plus
    -- RenderIDText's stack/name/static texts. The hidden icon region still
    -- anchors the widget's rect.
    local textOnly = el.style == "text"

    -- Icon texture + border. Main sample uses e.pIcon; extras use pExtraIcons.
    local region, cdWidget
    if k == 0 then
        region = e.pIcon
        cdWidget = e.pCooldown
        e.holder:SetSize(sz, sz)
        region:ClearAllPoints()
        region:SetAllPoints(e.holder)
    else
        region = e.pExtraIcons[k]
        if not region then
            region = e.holder:CreateTexture(nil, "ARTWORK")
            e.pExtraIcons[k] = region
        end
        region:SetSize(sz, sz)
        AnchorPreviewSample(region, e.holder, dir, k, sz, sz, spacing)
        -- Fan-out sample cooldown widget (created on demand).
        cdWidget = e.pExtraCooldowns[k]
        if not cdWidget then
            cdWidget = CreateFrame("Cooldown", nil, e.holder, "CooldownFrameTemplate")
            cdWidget:SetDrawEdge(false)
            e.pExtraCooldowns[k] = cdWidget
        end
        cdWidget:ClearAllPoints()
        cdWidget:SetAllPoints(region)
    end
    if not textOnly then region:SetTexture(tex) end

    local borderType = BorderType(el.style, el)
    -- Unusable styling (desaturate + recolor overlay) — per SAMPLE, so a
    -- group fan-out styles each id by its own usable state.
    local unusableOvl
    if not textOnly then
        unusableOvl = ApplyIDUnusableStyle(self, i, e, region, K.s[k],
            id, isItem, el, borderType == "blizzard" and sz * 0.04 or 0)
    end
    -- Blizzard style draws its border art in the outer ~4% of the icon, so the
    -- cooldown swipe must sit INSIDE that frame (aura path StyleButtonRegions
    -- ~1157). Every other style crops the icon to a full-bleed square/rounded
    -- area, so the swipe fills the whole region. Re-anchor per border type
    -- (the widget was SetAllPoints(region) above).
    if cdWidget then
        cdWidget:ClearAllPoints()
        if borderType == "blizzard" then
            local inset = sz * 0.04
            cdWidget:SetPoint("TOPLEFT", region, "TOPLEFT", inset, -inset)
            cdWidget:SetPoint("BOTTOMRIGHT", region, "BOTTOMRIGHT", -inset, inset)
        else
            cdWidget:SetAllPoints(region)
        end
    end
    if textOnly then
        region:Hide()
    elseif borderType == "blizzard" then
        region:SetTexCoord(0, 1, 0, 1)
    elseif IsRounded(borderType) then
        region:SetTexCoord(0.1, 0.9, 0.1, 0.9)
        -- Rounded border, anchored TOPLEFT against the holder with the k-th
        -- fan-out sample's offset (0,0 for the main sample) — mirrors the aura
        -- preview's extra-sample rounded rendering.
        local rox, roy = 0, 0
        if k ~= 0 then rox, roy = PreviewSampleOffset(dir, k, sz, sz, spacing) end
        -- Reused scratch (see SCRATCH_REGIONS). unusableOvl is nil when there is
        -- no overlay, which naturally clears slot 2 -- the old `{ region }` case.
        SCRATCH_REGIONS[1], SCRATCH_REGIONS[2] = region, unusableOvl
        ApplyRoundPreview(e, (k == 0) and "iconmain" or K.icon[k], el.borderColor,
            "TOPLEFT", rox, roy, sz, sz, SCRATCH_REGIONS, nil,
            borderType == "rounded_thick")
    else
        region:SetTexCoord(0.1, 0.9, 0.1, 0.9)
        if borderType == "square" and k == 0 then
            LayoutBorderEdges(e.pBorder, e.holder,
                el.borderThickness or 1, el.borderColor or DEFAULT_BORDER_COLOR)
        end
    end
    if not textOnly then region:Show() end

    -- Cooldown swipe + countdown numbers, both engine-driven from a duration
    -- object (no Lua number read). The swipe and the numbers are INDEPENDENT:
    -- numbers need an armed cooldown even when the swipe is off, so arm the
    -- widget whenever EITHER is wanted and gate only SetDrawSwipe on the swipe.
    if cdWidget then
        local wantSwipe = IDWantsSwipe(el)
        -- Cooldown numbers are PER-SAMPLE: each id (single tracker OR every icon
        -- in a group fan-out) has its own cooldown, so each sample with a
        -- duration/Cooldown text entry draws its OWN countdown. (Only the STACK
        -- text is single — RenderIDText follows the first drawn id.)
        local showNumbers = ElementHasTextBinding(el, "duration") and true or false
        if wantSwipe or showNumbers then
            -- Match the swipe rect to the icon's cropped area so the dark swipe
            -- doesn't spill past the icon. Rounded icons crop to a rounded-rect
            -- MASK, and the Cooldown (a child frame) can't take that mask — so
            -- the swipe TEXTURE is the mask asset itself (aura-path trick, see
            -- StyleButtonRegions ~1161). Blizzard/none/square crop to the full
            -- (or lightly inset) square, so a plain white swipe fills it.
            local rounded = IsRounded(borderType)
            cdWidget:SetSwipeTexture(rounded and ICON_ROUND_MASK_TEX or WHITE8X8)
            cdWidget:SetReverse(false)  -- dark area shrinks as the cooldown ticks down
            -- PREVIEW: no real cooldown runs, so arm a dummy duration so the sample
            -- shows a countdown + swipe. LIVE: charge-aware swipe (multi-charge
            -- abilities swipe on the recharging charge, dimmed while usable).
            local armed, swipeAlpha
            if self._elPreview[i] then
                armed = ArmPreviewCooldown(cdWidget, showNumbers)
                swipeAlpha = ID_SWIPE_ALPHA_FULL
            else
                armed, swipeAlpha = ArmIDSwipe(cdWidget, id, isItem, showNumbers)
            end
            cdWidget:SetSwipeColor(0, 0, 0, swipeAlpha)
            -- Swipe drawn only when actually wanted (pure text style: numbers
            -- only, never a swipe).
            cdWidget:SetDrawSwipe(wantSwipe and el.style ~= "text")
            -- Blizzard's native cooldown-finished shine. Set EXPLICITLY in both
            -- states: CooldownFrameTemplate ships a bling texture, so leaving
            -- this unset means the template's default decides, and the option
            -- would only ever appear to work in one direction.
            cdWidget:SetDrawBling(el.idCooldownBling and true or false)
            cdWidget._hetKeepBling = el.idCooldownBling and true or nil
            EnsureBlingTexture(cdWidget, el.idCooldownBling)
            -- Style the engine countdown FontString from the Cooldown text entry
            -- (font/size/color/position/border). Per-sample: each cdWidget caches
            -- its own FontString.
            if showNumbers then
                StyleIDCooldownText(cdWidget, DurationTextEntry(el))
            end
            if armed == false then ClearIDCooldown(cdWidget) end
            cdWidget:Show()
        else
            cdWidget:Clear()
            cdWidget:Hide()
        end
    end

    -- GCD Swipe: a SECOND cooldown over the same rect, drawing the GLOBAL
    -- cooldown while the ability is not on a real one (see ApplyIDGcdSwipe).
    -- Never in preview -- a dummy cooldown is already running there -- and
    -- never for the pure Text style, which draws no art to swipe over.
    ApplyIDGcdSwipe(e, k, el, region, borderType,
        borderType == "blizzard" and sz * 0.04 or 0, id, isItem,
        el.idGcdSwipe and not textOnly and not self._elPreview[i])
end

-- The per-sample "bar unit" for fan-out sample k (0 = main). k=0 reuses the
-- holder's pIDBar/pIDBarBg/pIcon/segment pools; k>=1 units are created on demand
-- and stored in e.pIDBarExtras[k] = { bar, bg, icon, segs = {}, segBgs = {} }.
--
-- The k=0 unit is built ONCE and cached on the element: it used to be rebuilt
-- on every call, i.e. once per bar sample per event on the per-GCD path. Every
-- field points at a region assigned exactly once in CreateElement and never
-- reassigned, so the cached table can never go stale. Built lazily rather than
-- in CreateElement so elements created before this existed still get one.
local function IDBarUnit(e, k)
    if k == 0 then
        local u0 = e.pIDBarUnit0
        if not u0 then
            u0 = { bar = e.pIDBar, bg = e.pIDBarBg, icon = e.pIcon,
                   segs = e.pIDBarSegs, segBgs = e.pIDBarSegBgs }
            e.pIDBarUnit0 = u0
        end
        return u0
    end
    e.pIDBarExtras = e.pIDBarExtras or {}
    local u = e.pIDBarExtras[k]
    if not u then
        local bg = e.holder:CreateTexture(nil, "BACKGROUND")
        bg:Hide()
        local bar = CreateFrame("StatusBar", nil, e.holder)
        bar:SetStatusBarTexture(WHITE8X8)
        -- Holder frame level so OVERLAY square-border edges sort above the fill.
        bar:SetFrameLevel(e.holder:GetFrameLevel())
        bar:Hide()
        local icon = e.holder:CreateTexture(nil, "ARTWORK")
        icon:Hide()
        u = { bar = bar, bg = bg, icon = icon, segs = {}, segBgs = {} }
        e.pIDBarExtras[k] = u
    end
    return u
end

-- Persistent charge recharge overlay for sample k: an invisible full-width
-- "offset" StatusBar (geometry driver) + a visible "charge" StatusBar anchored to
-- its fill edge. Cached in e.pIDBarCharge, keyed by sample k.
-- Returns (offsetBar, chargeBar).
local function IDChargeOverlay(e, k)
    e.pIDBarCharge = e.pIDBarCharge or {}
    local o = e.pIDBarCharge[k]
    if not o then
        local ov = CreateFrame("StatusBar", nil, e.holder)
        ov:SetStatusBarTexture(WHITE8X8)
        ov:GetStatusBarTexture():SetAlpha(0)  -- invisible: geometry driver only
        ov:Hide()
        local chg = CreateFrame("StatusBar", nil, e.holder)
        chg:SetStatusBarTexture(WHITE8X8)
        -- Holder frame level (matching the static segments) so the holder's
        -- OVERLAY square-border edges sort ABOVE the charging fill. At a higher
        -- level the fill covered the square border → the recharging pip's fill
        -- overlapped/hid the border. The recharging segment is the empty
        -- next-to-fill pip, so it never overlaps a filled segment at this level.
        chg:SetFrameLevel(e.holder:GetFrameLevel())
        chg:Hide()
        -- Rounded mask for the charging fill (rounded border mode): a rounded-rect
        -- mask on the charging bar's own fill texture, anchored to the charging bar
        -- so it follows the recharging segment. Sliced BarMask, same asset the
        -- segment fills use. Created once; attached/detached per render.
        local mask = chg:CreateMaskTexture()
        if mask.SetBlockingLoadsRequested then mask:SetBlockingLoadsRequested(true) end
        mask:SetTextureSliceMargins(8, 8, 8, 8)
        o = { offset = ov, charge = chg, mask = mask }
        e.pIDBarCharge[k] = o
    end
    return o.offset, o.charge, o.mask
end

-- Persistent Cooldown widget for an ID bar sample, cached on e by sample k +
-- role ("icon" = swipe on the end icon square, "bar" = countdown numbers over
-- the bar body). Created on demand, parented to the holder, and keyed by
-- (role, k) so every fan-out sample keeps its own across renders.
local function IDBarCooldown(e, k, role)
    e.pIDBarCDs = e.pIDBarCDs or {}
    -- Separator keeps keys unambiguous (role "seg1"+k=11 must not equal
    -- "seg11"+k=1). Precomputed per role (K.cd) so the three calls per bar
    -- sample per event don't each build a string; an unlisted role falls back
    -- to the concat.
    local kc = K.cd[role]
    local key = kc and kc[k] or (role .. ":" .. k)
    local cd = e.pIDBarCDs[key]
    if not cd then
        cd = CreateFrame("Cooldown", nil, e.holder, "CooldownFrameTemplate")
        cd:SetDrawEdge(false)
        cd:SetDrawBling(false)  -- no shine/bling flash when the cooldown finishes
        cd:SetHideCountdownNumbers(true)
        -- Layer above the bar fill. NUMBER roles ("bar"/"segtext") must draw above
        -- the SWIPE role ("icon"), so the countdown text isn't hidden behind the
        -- icon-square swipe when they overlap (holder+8 for numbers, +5 for swipe).
        local lvl = (role == "icon") and 5 or 8
        cd:SetFrameLevel(e.holder:GetFrameLevel() + lvl)
        cd:Hide()
        e.pIDBarCDs[key] = cd
    end
    return cd
end

-- Compute the cooldown duration OBJECT for id (ability or item), or nil when
-- ready / secret. Combat-safe: abilities use the engine duration object;
-- items build one from start/duration behind the canaccess guard.
local function IDCooldownDuration(id, isItem)
    if not isItem then
        -- GetSpellCooldownDuration returns a NON-nil (ready/zero) duration even
        -- when the spell is OFF cooldown, and SetTimerDuration on that drains
        -- the bar to EMPTY. Gate on the isActive/isOnGCD booleans (SpellOnCooldown,
        -- combat-safe) so a ready spell returns nil → the caller's "ready ⇒ full
        -- bar" branch runs.
        if not SpellOnCooldown(id) then return nil end
        return C_Spell_GetSpellCooldownDuration and C_Spell_GetSpellCooldownDuration(id, true)
    end
    -- Direct call — `f and f(id)` would truncate to ONE return value in Lua
    -- (duration nil ⇒ item cooldown bars never filled; same defect as
    -- ArmIDCooldown's item path).
    if not C_Item_GetItemCooldown then return nil end
    local start, duration = C_Item_GetItemCooldown(id)
    if canaccess(start) and canaccess(duration)
        and start and duration and start > 0 and duration > 0 then
        local dur = C_DurationUtil and C_DurationUtil.CreateDuration
            and C_DurationUtil.CreateDuration()
        if dur then dur:SetTimeFromStart(start, duration); return dur end
    end
    return nil
end

-- Draw ONE cooldown bar sample (fan-out index k, 0 = main) for `id`, anchored
-- at the k-th offset along `dir`. Combat-safe: the fill self-animates from a
-- duration OBJECT via StatusBar:SetTimerDuration — no per-frame CPU, no
-- secret number reads. Multi-charge abilities
-- split into per-charge SEGMENTS: full charges = filled segments, the
-- recharging charge's segment animates, the rest empty. "Show When = Always" +
-- ready ⇒ a full bar.
-- Border for ONE bar rect (the whole bar body, or a single charge segment),
-- mirroring the aura bar path (StyleButtonRegions ~1337) with the holder-relative
-- preview primitives this path uses: rounded = own nine-slice frame 1px outside +
-- rounded-rect mask on bg + fill; square = hollow flat edges; none = bare.
-- `point/ox/oy/w/h` is the rect (holder-relative). `roundKey`/`sqKey` are unique
-- per rect (per sample, and per segment) so each carries its own frame/mask/edges.
-- `bgTex`/`fillTex` are the rect's background + fill textures to mask (fill may be
-- nil). `anchorTex` is a texture whose rect the square edges anchor to (must be
-- positioned to the rect already).
local function ApplyBarRectBorder(e, el, point, ox, oy, w, h, roundKey, sqKey,
                                  bgTex, fillTex, anchorTex)
    local bt = BorderType(el.style, el)
    if IsRounded(bt) then
        -- Reused scratch: ApplyRoundPreview copies each entry into rr.applied
        -- and never keeps the array. BOTH slots are always written, so no stale
        -- trailing entry can survive into the next call.
        local regions = SCRATCH_REGIONS
        if bgTex then
            regions[1], regions[2] = bgTex, fillTex
        else
            regions[1], regions[2] = fillTex, nil
        end
        ApplyRoundPreview(e, roundKey, el.borderColor,
            point, ox, oy, w, h, regions, true, bt == "rounded_thick")
    elseif bt == "square" then
        LayoutBorderEdges(PreviewEdges(e, sqKey), anchorTex,
            el.borderThickness or 1, el.borderColor or DEFAULT_BORDER_COLOR)
    end
end

-- Style ONE bar rect (the whole body, or one charge segment). Was a closure
-- inside RenderIDBar over (bg, c, barTex, vertical), allocated per bar sample
-- per event; the captured values are explicit parameters now. The FILL colour
-- arrives as SCALARS because the unusable recolor/desaturate rewrites it -- the
-- old code reassigned the captured `c` UPVALUE to a freshly allocated table.
local function StyleIDBarRect(barFrame, bgTex, barTex, vertical, bg, cr, cg, cb, ca)
    bgTex:SetColorTexture(bg[1], bg[2], bg[3], bg[4])
    bgTex:Show()
    barFrame:SetStatusBarTexture(barTex)
    barFrame:SetStatusBarColor(cr, cg, cb, ca)
    barFrame:SetOrientation(vertical and "VERTICAL" or "HORIZONTAL")
end

-- Whole-bar-body border (single, non-segmented cooldown bar). k selects the
-- per-sample region key.
local function ApplyIDBarBorder(self, e, el, u, point, ox, oy, w, h, k)
    local roundKey = (k == 0) and "barmain" or K.bar[k]
    local sqKey = K.idbarsq[k]
    local fill = u.bar and u.bar.GetStatusBarTexture and u.bar:GetStatusBarTexture()
    ApplyBarRectBorder(e, el, point, ox, oy, w, h, roundKey, sqKey,
        u.bg, fill, u.bg)
end

function methods:RenderIDBar(i, id, k, dir, spacing, isItem, el)
    local e = self.elements[i]
    if not (e and e.pIDBar) then return end
    local bw = el.barWidth or 200
    local bh = el.barHeight or 20
    local vertical = el.barOrientation == "vertical"
    -- Holder is sized below (after icon geometry) to bar + icon extent.
    local ox, oy = 0, 0
    if k ~= 0 then ox, oy = PreviewSampleOffset(dir, k, bw, bh, spacing) end

    -- Shared read-only fallbacks: the UNSET field is the default state, so this
    -- allocated two tables per bar sample per event. Never stored into the DB,
    -- never mutated (every consumer below only indexes them).
    local bg = el.barBgColor or ID_BAR_BG_DEFAULT
    -- Fill color: per-id override (buffColors, keyed by spell/item id) if set,
    -- else the element color. This respects the group's per-entry Bar colors.
    local cSrc = el.color or ID_BAR_FILL_DEFAULT
    local bc = self:GetDBVal("buffColors")
    if type(bc) == "table" and bc[id] then cSrc = bc[id] end
    -- SCALARS from here on: the unusable restyle below rewrites the fill colour
    -- and used to allocate a replacement table to do it.
    local cr, cg, cb, ca = cSrc[1], cSrc[2], cSrc[3], cSrc[4]
    -- ElapsedTime = the fill GROWS as the cooldown progresses (fills left→right
    -- toward ready), matching the per-charge segment bars. RemainingTime would
    -- drain right→left instead (the single-bar default, not wanted here).
    local RD = Enum.StatusBarTimerDirection
        and Enum.StatusBarTimerDirection.ElapsedTime or 0
    local u = IDBarUnit(e, k)

    local barTex = BarTexturePath(el.barTexture)

    -- Bar fills the holder, icon square hangs outside it (mirror the aura bar
    -- geometry). showIcon defaults ON. The bar area (barW/barH at barOX/barOY,
    -- holder-relative) is then offset by this sample's fan-out translation.
    -- All geometry is computed as a TOPLEFT offset from the holder's TOPLEFT
    -- (y grows DOWN as negative), then shifted by this sample's fan-out
    -- translation (ox, oy). iconOX/iconOY = icon square's TOPLEFT; barOX/barOY =
    -- bar area's TOPLEFT; barW/barH = bar area size.
    -- The holder IS the bar: exactly barWidth x barHeight, independent of the
    -- icon, so the bar sits at the holder's TOPLEFT in every case and toggling
    -- "Show Icon" cannot move it. The icon is a square matching the bar's
    -- cross-axis (bar width for vertical bars, bar height for horizontal) and
    -- hangs OUTSIDE the holder — its offsets go NEGATIVE for left/top.
    local showIcon = el.barShowIcon ~= false
    local iconSize = vertical and bw or bh
    local posn = el.barIconPosition or (vertical and "bottom" or "left")
    local gap = iconSize + 2
    local iconOX, iconOY, barOX, barOY, barW, barH
    barW, barH = bw, bh
    barOX, barOY = 0, 0
    if vertical and posn == "top" then
        iconOX, iconOY = 0, gap                -- above the bar
    elseif vertical then                       -- icon at BOTTOM
        iconOX, iconOY = 0, -bh - 2
    elseif posn == "right" then
        iconOX, iconOY = bw + 2, 0
    else                                       -- icon at LEFT (default)
        iconOX, iconOY = -gap, 0
    end
    if k == 0 then e.holder:SetSize(bw, bh) end
    local baseOX, baseOY = ox + barOX, oy + barOY

    -- Unusable styling for the BAR BODY (the icon square is handled in the
    -- icon block below). BOTH effects restyle the FILL TINT by rewriting the
    -- cr/cg/cb SCALARS — every consumer (StyleIDBarRect for the single bar and
    -- each charge segment, plus the recharge overlay) is passed them AFTER this
    -- point, so one rewrite covers all three, and the fill keeps flowing
    -- through the rounded pip masks (owner: recolor the FILL, never an overlay
    -- rect — a rect can't follow the pip shapes).
    -- Recolor takes the picker's RGB but keeps the fill's own alpha (the
    -- picker alpha styles the ICON overlay); it wins over desaturate.
    -- Desaturate grays via luminance — texture desaturation alone can't
    -- change a solid-color fill.
    local idUnusable = IDUnusableNow(self, i, id, isItem, el)
    if idUnusable and el.idRecolorUnusable then
        local oc = el.idRecolorColor or ID_RECOLOR_DEFAULT
        cr, cg, cb = oc[1], oc[2], oc[3]   -- alpha stays the fill's own
    elseif idUnusable and el.idDesatUnusable then
        local g = 0.299 * cr + 0.587 * cg + 0.114 * cb
        cr, cg, cb = g, g, g
    end

    local icon = u.icon
    if showIcon and icon then
        icon:ClearAllPoints()
        icon:SetSize(iconSize, iconSize)
        icon:SetPoint("TOPLEFT", e.holder, "TOPLEFT", ox + iconOX, oy + iconOY)
        local tex = isItem
            and (C_Item and C_Item.GetItemIconByID and C_Item.GetItemIconByID(id))
            or GetSpellTexture(id)
        if tex then icon:SetTexture(tex) end
        -- Bar-icon border: same semantics as the aura bar's icon square
        -- (StyleButtonRegions ~1266), drawn with the holder-relative preview
        -- primitives this path already uses. blizzard = uncropped icon;
        -- rounded = zoom-crop + own nine-slice frame + rounded mask; square =
        -- zoom-crop + hollow flat edges; none = zoom-crop, bare.
        local ibt = el.barIconBorderType or "blizzard"
        local iconKey = K.baricon[k]
        if ibt == "blizzard" then
            icon:SetTexCoord(0, 1, 0, 1)
        else
            icon:SetTexCoord(0.1, 0.9, 0.1, 0.9)
        end
        -- Unusable styling (desaturate + recolor overlay) on the bar's icon
        -- square — per bar, so group fan-outs style each id's own state.
        local unusableOvl = ApplyIDUnusableStyle(self, i, e, icon, K.b[k],
            id, isItem, el, ibt == "blizzard" and iconSize * 0.04 or 0,
            idUnusable)
        if IsRounded(ibt) then
            -- Reused scratch (see SCRATCH_REGIONS); nil unusableOvl clears slot 2.
            SCRATCH_REGIONS[1], SCRATCH_REGIONS[2] = icon, unusableOvl
            ApplyRoundPreview(e, iconKey, el.barIconBorderColor,
                "TOPLEFT", ox + iconOX, oy + iconOY, iconSize, iconSize,
                SCRATCH_REGIONS, nil, ibt == "rounded_thick")
        elseif ibt == "square" then
            LayoutBorderEdges(PreviewEdges(e, iconKey), icon,
                el.barIconBorderThickness or 1,
                el.barIconBorderColor or DEFAULT_BORDER_COLOR)
        end
        icon:Show()
    elseif icon then
        icon:Hide()
        -- The icon square's recolor overlay must not outlive it.
        local ovl = e.pIDTintOvls and e.pIDTintOvls[K.b[k]]
        if ovl then ovl:Hide() end
    end

    -- Charge count (abilities only). >1 ⇒ segmented bar. Read HERE, above the
    -- icon-swipe block, so ArmIDSwipe can be handed the maxCharges this already
    -- read instead of calling C_Spell.GetSpellCharges a second time (that call
    -- allocates a table, once per bar sample per event on the per-GCD path).
    -- Moving the read earlier is inert: everything it now precedes is widget
    -- setters on this addon's own frames, which cannot change engine charge
    -- state, and Lua is single-threaded so no event can fire mid-function.
    -- maxCharges is STATIC (safe to compare); rawCharges (currentCharges) is
    -- SECRET in combat — passed ONLY to engine methods (StatusBar:SetValue),
    -- never compared. chargeActive (isActive) is a combat-safe boolean: is a
    -- charge recharging right now? rawCharges starts at a plain 0 (non-secret)
    -- so it's never nil-checked; when the charges table is accessible we
    -- overwrite it with the real value, which may be secret — but from here it
    -- only flows into StatusBar:SetValue, never a Lua comparison.
    local maxCh, chargeActive
    local rawCharges = 0
    if not isItem and C_Spell_GetSpellCharges then
        local ch = C_Spell_GetSpellCharges(id)
        if ch and canaccess(ch) then
            maxCh = ch.maxCharges          -- static
            rawCharges = ch.currentCharges -- secret-safe: only fed to SetValue
            chargeActive = ch.isActive     -- boolean, combat-safe
        end
    end
    local segmented = (maxCh and maxCh > 1) and true or false

    -- Cooldown SWIPE on the bar's end icon square (owner choice: bars show a
    -- swipe on the icon, numbers over the bar body). Engine-driven from the
    -- duration object — same combat-safe path as the icon style. Only when the
    -- icon is shown; swipe texture matches the icon border (rounded uses the
    -- mask asset so it clips to the rounded square). Numbers OFF here (they go
    -- over the bar body below).
    do
        local iconCD = IDBarCooldown(e, k, "icon")
        if showIcon and icon then
            local ibt = el.barIconBorderType or "blizzard"
            iconCD:ClearAllPoints()
            iconCD:SetPoint("TOPLEFT", e.holder, "TOPLEFT", ox + iconOX, oy + iconOY)
            iconCD:SetSize(iconSize, iconSize)
            iconCD:SetHideCountdownNumbers(true)
            iconCD:SetSwipeTexture(IsRounded(ibt) and ICON_ROUND_MASK_TEX or WHITE8X8)
            iconCD:SetReverse(false)  -- dark area shrinks as the cooldown ticks down
            iconCD:SetDrawSwipe(el.barIconSwipe ~= false)
            -- Charge-aware swipe on the bar's icon square (same as the icon style).
            -- Preview: dummy duration so the swipe animates.
            local armed, swipeAlpha
            if self._elPreview[i] then
                armed = ArmPreviewCooldown(iconCD, false)
                swipeAlpha = ID_SWIPE_ALPHA_FULL
            else
                armed, swipeAlpha = ArmIDSwipe(iconCD, id, isItem, false, maxCh)
            end
            iconCD:SetSwipeColor(0, 0, 0, swipeAlpha)
            iconCD:SetDrawBling(el.idCooldownBling and true or false)
            iconCD._hetKeepBling = el.idCooldownBling and true or nil
            EnsureBlingTexture(iconCD, el.idCooldownBling)
            if armed == false then ClearIDCooldown(iconCD) end
            iconCD:Show()
        else
            iconCD:Clear(); iconCD:Hide()
        end

        -- GCD Swipe over the same icon square. Anchored to the holder with the
        -- same offsets as iconCD (the bar path positions by offset, not by
        -- region) so the two rects coincide exactly.
        local wantGcd = el.idGcdSwipe and showIcon and icon
            and not self._elPreview[i]
        local g = (k == 0) and e.pGcdCooldown or (e.pGcdExtras and e.pGcdExtras[k])
        if not wantGcd then
            if g then g._gcdWanted = nil; g:Clear(); g:Hide() end
        else
            local ibt = el.barIconBorderType or "blizzard"
            g = IDGcdWidget(e, k)
            g._gcdId, g._gcdIsItem, g._gcdWanted = id, isItem, true
            -- Below the icon square's real swipe (holder+5, see IDBarCooldown)
            -- rather than level-for-level with it, which left the stacking
            -- order undefined.
            g:SetFrameLevel(e.holder:GetFrameLevel() + 4)
            g:ClearAllPoints()
            g:SetPoint("TOPLEFT", e.holder, "TOPLEFT", ox + iconOX, oy + iconOY)
            g:SetSize(iconSize, iconSize)
            g:SetSwipeTexture(IsRounded(ibt) and ICON_ROUND_MASK_TEX or WHITE8X8)
            g:SetSwipeColor(0, 0, 0, ID_SWIPE_ALPHA_GCD)
            g:SetReverse(false)
            g:SetDrawSwipe(true)
            if ArmIDGcdSwipe(g, id, isItem) then
                g:Show()
            else
                g:Clear(); g:Hide()
            end
        end
    end

    -- Cooldown NUMBERS over the bar body. A Cooldown widget over the bar-area
    -- rect, swipe OFF + numbers ON, styled from the element's Cooldown text
    -- entry — combat-safe (engine owns the number). PER-SAMPLE (each id in a
    -- group draws its own), only when a duration/Cooldown text entry exists.
    -- SEGMENTED bars skip this (each segment draws its own number in the loop
    -- below), so the whole-bar number is for single-cooldown bars only.
    local wantBarNumbers = ElementHasTextBinding(el, "duration") and true or false
    do
        local barCD = IDBarCooldown(e, k, "bar")
        if wantBarNumbers and not segmented then
            barCD:ClearAllPoints()
            barCD:SetPoint("TOPLEFT", e.holder, "TOPLEFT", baseOX, baseOY)
            barCD:SetSize(barW, barH)
            barCD:SetDrawSwipe(false)  -- numbers only; the fill IS the swipe
            barCD:SetHideCountdownNumbers(false)
            -- Preview: dummy duration so a sample countdown number shows.
            local armed = self._elPreview[i] and ArmPreviewCooldown(barCD, true)
                or ArmIDCooldown(barCD, id, isItem, true)
            StyleIDCooldownText(barCD, DurationTextEntry(el))
            if armed == false then barCD:Clear() end
            barCD:Show()
        else
            barCD:Clear(); barCD:Hide()
        end
    end

    if not segmented then
        -- Single cooldown bar. Hide any leftover segments from this unit.
        for _, seg in ipairs(u.segs) do seg:Hide() end
        for _, sbg in ipairs(u.segBgs) do sbg:Hide() end
        u.bg:ClearAllPoints()
        u.bg:SetSize(barW, barH)
        u.bg:SetPoint("TOPLEFT", e.holder, "TOPLEFT", baseOX, baseOY)
        u.bar:ClearAllPoints()
        u.bar:SetAllPoints(u.bg)
        StyleIDBarRect(u.bar, u.bg, barTex, vertical, bg, cr, cg, cb, ca)
        local dur = IDCooldownDuration(id, isItem)
        if dur then
            u.bar:SetTimerDuration(dur, nil, RD)
        else
            u.bar:SetMinMaxValues(0, 1)  -- ready ⇒ full bar
            u.bar:SetValue(1)
        end
        u.bar:Show()
        ApplyIDBarBorder(self, e, el, u, "TOPLEFT", baseOX, baseOY, barW, barH, k)
        return
    end

    -- Segmented (multi-charge). Lay maxCh equal segments across the BAR AREA
    -- (not the whole holder) with a 2px gap. Filled segments = available
    -- charges; the recharging segment animates via the charge duration; the
    -- rest stay empty.
    u.bar:Hide()
    u.bg:Hide()
    local sgap = 2
    local axis = vertical and barH or barW
    local segLen = (axis - sgap * (maxCh - 1)) / maxCh
    for s = 1, maxCh do
        local seg = u.segs[s]
        local sbg = u.segBgs[s]
        if not seg then
            sbg = e.holder:CreateTexture(nil, "BACKGROUND")
            seg = CreateFrame("StatusBar", nil, e.holder)
            seg:SetStatusBarTexture(WHITE8X8)
            -- Run the fill at the HOLDER's frame level so the holder's OVERLAY
            -- square-border edges layer ABOVE the fill (aura bar pattern ~955).
            -- At the default child level (+1) the fill covers the edges → square
            -- border invisible.
            seg:SetFrameLevel(e.holder:GetFrameLevel())
            u.segs[s] = seg
            u.segBgs[s] = sbg
        end
        -- Positioning gap is 1px SMALLER than the layout gap: the rounded border
        -- frame is centre-anchored (ox + w/2), which on the 2nd+ segment snaps a
        -- half-pixel and rendered ~1px too far along the fill axis. Shifting each
        -- subsequent segment back by 1px cancels that. segLen keeps the full sgap
        -- so segment SIZE is unchanged.
        local posGap = sgap - 1
        local soff = (s - 1) * (segLen + posGap)
        local segOX, segOY, segW, segH
        sbg:ClearAllPoints()
        seg:ClearAllPoints()
        if vertical then
            segW, segH = barW, segLen
            -- Grow segments upward from the bar area's bottom.
            segOX, segOY = baseOX, baseOY - (barH - segLen) + soff
        else
            segW, segH = segLen, barH
            segOX, segOY = baseOX + soff, baseOY
        end
        sbg:SetSize(segW, segH)
        sbg:SetPoint("TOPLEFT", e.holder, "TOPLEFT", segOX, segOY)
        seg:SetAllPoints(sbg)
        StyleIDBarRect(seg, sbg, barTex, vertical, bg, cr, cg, cb, ca)
        -- Per-SEGMENT border: each charge pip carries its own frame/mask/edges
        -- (a rounded mask can't span the gapped segments, so it's applied per
        -- segment — a 3-charge rounded bar reads as 3 rounded pips). Keys are
        -- unique per sample k AND segment s.
        local segFill = seg.GetStatusBarTexture and seg:GetStatusBarTexture()
        ApplyBarRectBorder(e, el, "TOPLEFT", segOX, segOY, segW, segH,
            K.barseg[k][s], K.idbarseg[k][s],
            sbg, segFill, sbg)
        -- AVAILABLE fill, combat-safe:
        -- give segment s the range [s-1, s] and SetValue(rawCharges). The ENGINE
        -- decides whether this segment is full (charges >= s) — the secret count is
        -- passed to SetValue, never compared in Lua. rawCharges falls back to 0
        -- (empty) when the charges table was inaccessible.
        seg:SetMinMaxValues(s - 1, s)
        seg:SetValue(rawCharges)  -- engine clamps into [s-1, s]; no Lua compare
        seg:Show()
    end
    -- Recharge overlay: while a charge is recharging (chargeActive boolean —
    -- combat-safe), a partial fill animates on the NEXT-to-fill segment. Which
    -- segment that is can't be computed in Lua (secret count), so use an
    -- offset technique: a full-width transparent "offset" StatusBar (range
    -- 0..maxCh, value = rawCharges) whose fill edge the engine places at the charge
    -- boundary; the animated charging bar is anchored to that edge and sized to one
    -- segment. Per-sample widgets, created on demand.
    local ov, chg, chgMask = IDChargeOverlay(e, k)
    do
        local cd = chargeActive and C_Spell_GetSpellChargeDuration
            and C_Spell_GetSpellChargeDuration(id) or nil
        if cd then
            -- Offset bar's coordinate space must include the inter-segment GAPS so
            -- its fill edge lands at the true segment boundary. Each charge occupies
            -- segLen + sgap along the fill axis (the trailing gap past the last
            -- segment is off the bar but harmless — the bar is invisible). With
            -- range [0, maxCh], value n ⇒ fill edge at n*(segLen+sgap) = the start
            -- of segment n+1, i.e. where the recharging segment begins.
            -- Uses the SAME positioning gap (sgap-1) as the segments so the fill
            -- edge lands on the shifted segment boundary.
            local axisLen = maxCh * (segLen + (sgap - 1))
            ov:ClearAllPoints()
            if vertical then
                ov:SetSize(barW, axisLen)
                -- Vertical grows UPWARD from the bar area's bottom (matches segs).
                ov:SetPoint("BOTTOMLEFT", e.holder, "TOPLEFT", baseOX, baseOY - barH)
            else
                ov:SetSize(axisLen, barH)
                ov:SetPoint("TOPLEFT", e.holder, "TOPLEFT", baseOX, baseOY)
            end
            ov:SetOrientation(vertical and "VERTICAL" or "HORIZONTAL")
            ov:SetReverseFill(false)
            ov:SetMinMaxValues(0, maxCh)
            ov:SetValue(rawCharges)
            -- Charging bar: one segment long, anchored to the offset fill's edge,
            -- animated by the charge recharge duration. Uses the SAME bar texture
            -- as the segments (was stuck on solid WHITE8X8).
            chg:ClearAllPoints()
            chg:SetStatusBarTexture(barTex)
            chg:SetOrientation(vertical and "VERTICAL" or "HORIZONTAL")
            chg:SetStatusBarColor(cr, cg, cb, ca)
            if vertical then
                chg:SetSize(barW, segLen)
                chg:SetPoint("BOTTOM", ov:GetStatusBarTexture(), "TOP")
            else
                chg:SetSize(segLen, barH)
                chg:SetPoint("LEFT", ov:GetStatusBarTexture(), "RIGHT")
            end
            chg:SetTimerDuration(cd, nil, Enum.StatusBarTimerDirection
                and Enum.StatusBarTimerDirection.ElapsedTime or 0)
            -- Border on the charging (recharging) segment, matching the static
            -- segments' per-pip border so the animating pip looks identical.
            -- Rounded: mask the charging fill to the segment's rounded rect so its
            -- corners match — mask anchors to the charging bar (one segment) using
            -- the same sliced BarMask the segment fills use; thick swaps to the
            -- deeper mask. Square: draw the hollow flat edges around the charging
            -- bar. The edges live in e.pEdges (hidden by the pre-render reset each
            -- frame) and sort ABOVE the fill because the charging bar now runs at
            -- holder frame level. Remove-before-Add so a live border switch doesn't
            -- double-mask.
            local chgFill = chg:GetStatusBarTexture()
            local cbt = BorderType(el.style, el)
            if chgFill then chgFill:RemoveMaskTexture(chgMask) end
            if IsRounded(cbt) and chgFill then
                chgMask:SetTexture(
                    (cbt == "rounded_thick") and ROUND_MASK_THICK_TEX or ROUND_MASK_TEX,
                    "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
                chgMask:ClearAllPoints()
                chgMask:SetAllPoints(chg)
                chgFill:AddMaskTexture(chgMask)
            elseif cbt == "square" then
                LayoutBorderEdges(PreviewEdges(e, K.idbarchg[k]), chg,
                    el.borderThickness or 1, el.borderColor or DEFAULT_BORDER_COLOR)
            end
            ov:Show()
            chg:Show()
        else
            ov:Hide()
            chg:Hide()
        end
    end
    -- Per-segment cooldown NUMBER: only when a charge is recharging (combat-safe
    -- boolean). Placed over the recharge overlay's charging bar so the number sits
    -- on the segment that's refilling.
    if wantBarNumbers then
        local segCD = IDBarCooldown(e, k, "segtext")
        local cd = chargeActive and C_Spell_GetSpellChargeDuration
            and C_Spell_GetSpellChargeDuration(id) or nil
        if cd then
            segCD:ClearAllPoints()
            segCD:SetAllPoints(chg)
            segCD:SetDrawSwipe(false)
            segCD:SetHideCountdownNumbers(false)
            segCD:SetCooldownFromDurationObject(cd)
            StyleIDCooldownText(segCD, DurationTextEntry(el))
            segCD:Show()
        else
            segCD:Clear(); segCD:Hide()
        end
    end
    -- Hide any segments left over from a higher previous maxCharges.
    for s = maxCh + 1, #u.segs do
        u.segs[s]:Hide()
        u.segBgs[s]:Hide()
    end
    -- Border is drawn PER SEGMENT inside the loop above (each charge pip carries
    -- its own frame/mask/edges), so there's no whole-area border call here.
end

-- Per-id "Show When" mode. GROUPS gate PER ENTRY out of the idVisibilities
-- side-table (keyed by id, unset => "always"); single trackers use the
-- element-level el.idVisibility. Was a per-element closure over
-- (isGroup, idVis, el) -- explicit parameters now, so it allocates never.
local function IDVisModeFor(isGroup, idVis, el, id)
    if isGroup then return (idVis and idVis[id]) or "always" end
    return el.idVisibility or "always"
end

-- Draw element i's ID art (ability/item). Called only when the tracker's load
-- conditions are met AND (per idVisibility) the on-cooldown gate passes.
function methods:RenderIDArt(i)
    local e = self.elements[i]
    if not e then return end
    local el = self:GetElementsDB()[i]
    -- PAIR elements are aura displays (engine-driven); never ID art.
    if el and el.pairFor then return end
    local style = el and el.style
    -- SOUND elements own no art, so the ~30-250 widget-call teardown below is
    -- pure waste for them -- and it is NOT their teardown path: a sound element
    -- is routed to HideIDArt by UpdateIDArt (isVisual is false there), and a
    -- previewing element switched TO sound is torn down by RefreshPreview's
    -- else-branch. Every ShowElementPreview caller filters style ~= "sound".
    -- The nil-style guard keeps its teardown (defensive arm, left unchanged).
    if style == "sound" then return end
    HideElementPreviewArt(e, true)  -- keepArmed: this render re-arms below
    if not style then return end

    -- MIXED GROUPS: item-ness is PER ID (idKinds), not per-tracker. `fallbackItem`
    -- is the tracker's own kind (used for untagged ids and single trackers);
    -- ns.IDKindOf resolves each entry's real kind. Called DIRECTLY (the closure
    -- that used to wrap it allocated once per element per event) and its result
    -- is CACHED per drawn id in the gate loop below -- ns.IDKindOf concatenates
    -- prefix .. "idKinds" on every call, so each redundant call also allocated.
    local fallbackItem = self:IsItemKind()
    local prefix = self.config and self.config.dbPrefix
    local ids = self.config and ns.addon.db.profile[prefix .. "ids"] or {}
    if type(ids) ~= "table" or not ids[1] then return end

    -- Build the list of ids to draw. For a GROUP in a gated visibility mode
    -- ("Only While On Cooldown" / "Only When Usable") the fan-out is PER-ID:
    -- include only the ids that pass the gate, packed together (no gaps).
    -- Preview always shows all so the user can position the group. Single/Always
    -- show all listed (single tracker = just ids[1]). Shared by icon and bar
    -- fan-out.
    local dir = EffectiveGrowDirection(el)
    local spacing = el.spacing or 2
    local isGroup = self:IsIDGroupKind()
    -- GROUPS gate PER ENTRY: each id's "Show When" lives in the idVisibilities
    -- side-table (keyed by id, unset ⇒ "always"). Single trackers keep the
    -- element-level el.idVisibility. Preview ignores the gate (show all so the
    -- user can position the group).
    local idVis = isGroup and self:GetDBVal("idVisibilities") or nil
    local gateActive = not self._elPreview[i]
    local cap = isGroup and (el.maxFrames or #ids) or 1
    -- REUSED scratch (was a fresh table per element per event). Entries past
    -- `n` are STALE BY DESIGN, so every read below uses `n` -- never `#`.
    -- Nothing retains a reference past this call and the path cannot reenter:
    -- RenderIDArt calls only render/hide helpers and widget setters, and the
    -- one deferred re-entry (ScheduleIDUnusableRecheck) runs on a later frame.
    local drawIDs   = self._drawIDs   or {}; self._drawIDs   = drawIDs
    local drawKinds = self._drawKinds or {}; self._drawKinds = drawKinds
    local n = 0
    for idx = 1, #ids do
        if n >= cap then break end
        local id = ids[idx]
        if id then
            -- Per-id gate: resolve THIS id's item-ness so item entries route
            -- through GetItemCooldown/GetItemCount and ability entries through
            -- the spell APIs (see IDPasses). "always" short-circuits inside
            -- IDPasses, so unset entries stay cheap. AUTO known-gate: an unknown
            -- ability never draws (wrong spec/untalented) — but preview ignores it
            -- so trackers stay editable for spells you don't currently have.
            local isItemId = ns.IDKindOf(prefix, id, fallbackItem)
            if not gateActive or IDDrawable(id, isItemId) then
                local vm = IDVisModeFor(isGroup, idVis, el, id)
                if not gateActive or vm == "always" or IDPasses(vm, id, isItemId) then
                    n = n + 1
                    drawIDs[n] = id
                    -- Cached so the render calls below don't re-run IDKindOf.
                    drawKinds[n] = isItemId
                end
            end
        end
    end

    if style == "icon" then
        local sz = el.iconSize or 40
        for k = 0, n - 1 do
            self:RenderIDSample(i, drawIDs[k + 1], k, sz, dir, spacing,
                drawKinds[k + 1], el)
        end
        -- Charge/stack text follows the FIRST drawn icon (nil when the group is
        -- fully gated out and nothing is shown).
        if n > 0 then
            self:RenderIDText(i, drawIDs[1], drawKinds[1], el)
        end
    elseif style == "bar" then
        -- Bar style: one cooldown StatusBar per drawn id, laid out along the
        -- grow direction. Each bar is single or per-charge segmented; sample 0
        -- also carries the charge/stack text.
        for k = 0, n - 1 do
            self:RenderIDBar(i, drawIDs[k + 1], k, dir, spacing,
                drawKinds[k + 1], el)
        end
        if n > 0 then
            self:RenderIDText(i, drawIDs[1], drawKinds[1], el)
        end
    else
        -- Circle/text: render the single main icon region with the swipe + text
        -- on the holder (kept simple + combat-safe). id0 may fall back to ids[1]
        -- when nothing passed the gate, in which case there is no cached kind.
        local sz = el.iconSize or 40
        local id0 = (n > 0) and drawIDs[1] or ids[1]
        -- NOT `(n > 0) and drawKinds[1] or ...`: drawKinds holds strict booleans,
        -- so an ABILITY (false) would fall through the `or` and re-run IDKindOf,
        -- silently defeating the cache this loop exists to fill.
        local isItem0
        if n > 0 then
            isItem0 = drawKinds[1]
        else
            isItem0 = ns.IDKindOf(prefix, id0, fallbackItem)
        end
        self:RenderIDSample(i, id0, 0, sz, "right", spacing, isItem0, el)
        self:RenderIDText(i, id0, isItem0, el)
    end

    -- Preview drag affordances (same as custom/aura preview). For a GROUP
    -- fan-out the highlight, hit rect, and label must span EVERY sample so the
    -- whole preview is draggable and overlaid — not just the main icon (mirrors
    -- RenderElementPreview's multi-sample handling).
    if self._elPreview[i] then
        e.holder:EnableMouse(true)
        e.label:SetText(self:GetLabel() .. " (" .. (STYLE_LABELS[style] or style) .. ")")
        -- Recompute the fan-out extent (icon AND bar styles fan out for groups;
        -- circle/text are single).
        local sampleCount = 1
        if (style == "icon" or style == "bar") and self:IsIDGroupKind() then
            sampleCount = math.min(#ids, el.maxFrames or #ids)
            if sampleCount < 1 then sampleCount = 1 end
        end
        if sampleCount > 1 then
            local dir = EffectiveGrowDirection(el)
            local spacing = el.spacing or 2
            -- Per-sample step along the grow axis: icons are square (iconSize);
            -- bars step by their width (horizontal grow) or height (vertical).
            local step
            if style == "bar" then
                local growVert = (dir == "up" or dir == "down")
                step = growVert and (el.barHeight or 20) or (el.barWidth or 200)
            else
                step = el.iconSize or 40
            end
            local ext = (sampleCount - 1) * (step + spacing)
            -- Extend the holder's mouse hit rect over all samples (they anchor
            -- to the holder, so dragging any one moves the whole thing).
            if dir == "right" then
                e.holder:SetHitRectInsets(0, -ext, 0, 0)
            elseif dir == "left" then
                e.holder:SetHitRectInsets(-ext, 0, 0, 0)
            elseif dir == "down" then
                e.holder:SetHitRectInsets(0, 0, 0, -ext)
            else -- up
                e.holder:SetHitRectInsets(0, 0, -ext, 0)
            end
            -- Tint highlight covers the WHOLE preview; label centers under it.
            e.highlight:ClearAllPoints()
            e.highlight:SetPoint("TOPLEFT", e.holder, "TOPLEFT",
                dir == "left" and -ext or 0, dir == "up" and ext or 0)
            e.highlight:SetPoint("BOTTOMRIGHT", e.holder, "BOTTOMRIGHT",
                dir == "right" and ext or 0, dir == "down" and -ext or 0)
            e.label:ClearAllPoints()
            if dir == "right" then
                e.label:SetPoint("TOP", e.holder, "BOTTOM", ext / 2, -4)
            elseif dir == "left" then
                e.label:SetPoint("TOP", e.holder, "BOTTOM", -ext / 2, -4)
            elseif dir == "down" then
                e.label:SetPoint("TOP", e.holder, "BOTTOM", 0, -4 - ext)
            else -- up
                e.label:SetPoint("TOP", e.holder, "BOTTOM", 0, -4)
            end
        else
            e.holder:SetHitRectInsets(0, 0, 0, 0)
            e.highlight:ClearAllPoints()
            e.highlight:SetAllPoints(e.holder)
            e.label:ClearAllPoints()
            e.label:SetPoint("TOP", e.holder, "BOTTOM", 0, -4)
        end
        ShowPreviewHighlight(self, e)
        e.label:Show()
    end
end

-- Charge (ability) / stack (item bag count) text for the main sample, plus any
-- Name / static text. Counts are read behind a canaccess guard (event-driven,
-- never per-frame — RefreshLoadGating re-renders on SPELL_UPDATE_CHARGES /
-- BAG_UPDATE). The cooldown-countdown text is drawn ENGINE-side by the Cooldown
-- widget (see RenderIDSample), so only the stacks binding is handled here.
function methods:RenderIDText(i, id, isItem, el)
    local e = self.elements[i]
    local texts = EnsureTexts(el)
    for j = 1, #texts do
        local t = texts[j]
        if t and t.show ~= false then
            local binding = TEXT_BINDING[t.type]
            if binding == "stacks" then
                local fs = e.pStackText
                if fs then
                    local d = t.stackDisplay or "current"
                    -- STYLE FIRST, SetText LAST: passing a SECRET value to SetText
                    -- (the in-combat count) marks the FontString's text aspect —
                    -- and its anchoring — secret, so font/position must be set
                    -- before the text or the anchor becomes inaccessible.
                    local size = t.fontSize or DefaultTextSize(el.style, binding)
                    local pos = t.position or "bottomright"
                    ApplyAnchoredText(fs, e.holder, t.font, size, pos,
                        t.color, t.fontBorder)
                    -- Maximum is STATIC (safe): items fall back to the user-set Max
                    -- Stacks; abilities use maxStacks too (their real max is only
                    -- read for items' n/max here — see below).
                    local maxN = el.maxStacks or 10
                    if isItem then
                        -- ITEM count is a plain (non-secret) number: current /
                        -- current-max / max all work. ("Show at 2+ only" is not
                        -- offered for ID kinds — see Options hideBelow2.hidden.)
                        local n = C_Item_GetItemCount and C_Item_GetItemCount(id)
                        if not (n and canaccess(n)) then
                            fs:SetText("")
                        elseif d == "max" then
                            fs:SetText(tostring(maxN))
                        elseif d == "currentmax" then
                            fs:SetText(n .. "/" .. maxN)
                        else
                            fs:SetText(tostring(n))
                        end
                    else
                        -- ABILITY charges: the count is SECRET in combat, shown via
                        -- the engine's GetSpellDisplayCount STRING.
                        -- It can't be CONCATENATED with max (would
                        -- taint), so "current/max" puts the count in fs and a SEPARATE
                        -- static "/N" FontString (pStackMaxText) right after it — max
                        -- is static (changes only on talent/spec, which refresh).
                        local sfx = e.pStackMaxText
                        -- Abilities know their true max (static); fall back to the
                        -- user Max Stacks if unavailable.
                        local abilityMax = SpellMaxCharges(id) or maxN
                        if d == "max" then
                            fs:SetText(tostring(abilityMax))  -- static
                            if sfx then sfx:Hide() end
                        elseif d == "currentmax" and C_Spell_GetSpellDisplayCount and sfx then
                            fs:SetText(C_Spell_GetSpellDisplayCount(id))
                            -- Suffix inherits the count's font/color, anchored to its
                            -- RIGHT so the pair reads "n/max".
                            sfx:SetFont(FontPath(t.font), size, t.fontBorder or "OUTLINE")
                            local col = t.color or WHITE_RGBA
                            sfx:SetTextColor(col[1], col[2], col[3], col[4])
                            sfx:ClearAllPoints()
                            sfx:SetPoint("LEFT", fs, "RIGHT", 0, 0)
                            sfx:SetText("/" .. abilityMax)
                            sfx:Show()
                        elseif C_Spell_GetSpellDisplayCount then
                            fs:SetText(C_Spell_GetSpellDisplayCount(id))
                            if sfx then sfx:Hide() end
                        else
                            fs:SetText("")
                            if sfx then sfx:Hide() end
                        end
                    end
                end
            end
            -- "duration" (Cooldown) text is engine-drawn; "name" is unused for
            -- ID kinds (no aura name) — skip both here.
        end
    end
end

-- Hide element i's ID art (shares the preview hide; also clears the swipe
-- widgets so a stale cooldown doesn't linger).
function methods:HideIDArt(i)
    local e = self.elements[i]
    if not e then return end
    -- HideElementPreviewArt now owns clearing pCooldown / pExtraCooldowns for
    -- every teardown path; the duplicates that used to live here are what let
    -- the other paths drift out of sync.
    HideElementPreviewArt(e)
end

-- ID-kind Sound alerts: fire each Sound element when a tracked ability/item
-- comes OFF a real cooldown ("ready again"), regardless of the element's Show
-- When (idVisibility). `show` gates firing on the tracker's enabled +
-- applicable + load-condition state; `silent` seeds the per-id cooldown latch
-- without firing (used on resync so the first real edge is measured from the
-- current state). Ids the character doesn't have (wrong spec/untalented
-- abilities) are skipped. Aura sounds use the engine AddAuraSound path instead;
-- this only ever runs for ability/item trackers.
function methods:UpdateIDSoundReady(show, silent)
    local els = self:GetElementsDB()
    local hasSound = false
    for i = 1, MAX_ELEMENTS do
        local el = els[i]
        if el and el.style == "sound" and not self._elPreview[i] then
            hasSound = true
            break
        end
    end
    if not hasSound then
        self._soundOnCD = nil
        return
    end
    local prefix = self.config.dbPrefix
    local ids = ns.addon.db.profile[prefix .. "ids"] or {}
    local fallbackItem = self:IsItemKind()
    local prev = self._soundOnCD
    if not prev then prev = {}; self._soundOnCD = prev end
    local anyReady = false
    local seen = {}
    for k = 1, #ids do
        local id = tonumber(ids[k])
        if id and id > 0 then
            local isItem = ns.IDKindOf(prefix, id, fallbackItem)
            -- Skip abilities the character doesn't have (auto-hidden anyway).
            if isItem or IDDrawable(id, isItem) then
                seen[id] = true
                -- EXPLICIT branch (never `isItem and ItemX or SpellX`): a false
                -- ItemOnCooldown would fall through to the SPELL API with an item
                -- id — the same trap IDPasses documents.
                local onCD
                if isItem then onCD = ItemOnCooldown(id) else onCD = SpellOnCooldown(id) end
                if not silent and prev[id] == true and not onCD then
                    anyReady = true
                end
                prev[id] = onCD
            end
        end
    end
    for id in pairs(prev) do
        if not seen[id] then prev[id] = nil end
    end
    if anyReady and show then
        for i = 1, MAX_ELEMENTS do
            local el = els[i]
            if el and el.style == "sound" and not self._elPreview[i] then
                local path = ns.ResolveElementSoundPath(self, el)
                if path then
                    pcall(PlaySoundFile, path, el.soundChannel or "Master")
                end
            end
        end
    end
end

-- ID-kind gating: render/hide each element's holder art from the current load
-- state, applying idVisibility (Always vs Only While On Cooldown). Mirrors
-- UpdateCustomArt. `mode` is "live" | "resync" | "render" (see UpdateCustomArt).
-- Sound elements no longer fire here on the load-condition edge; the ready
-- alert is driven by methods:UpdateIDSoundReady (off-cooldown edge).
function methods:UpdateIDArt(mode)
    mode = mode or "render"
    local enabled = self:GetDBVal("enabled") and true or false
    local isApplicable = self.config.isApplicable
    local applicable = (not isApplicable) or isApplicable(self)
    -- Like buffs (NOT the custom "hide if no conditions" rule): with no Show
    -- Conditions set, LoadConditionsMet() is true and the art shows whenever
    -- enabled.
    local show = enabled and applicable and self:LoadConditionsMet()
    self._spellLive = show

    if mode == "live" then
        -- ID-kind Sound alerts fire when the tracked ability/item comes OFF
        -- COOLDOWN (ready again), regardless of the per-element Show When
        -- setting — see methods:UpdateIDSoundReady. (Formerly fired on the
        -- load-condition rising edge, which never moved for an ability whose
        -- readiness was expressed via Show When rather than a load condition,
        -- so the alert was silent — that is the bug this fixes.)
        self:UpdateIDSoundReady(show, false)
        self._condMet = show
    elseif mode == "resync" then
        -- Seed the per-id cooldown latch silently (no alert on resync).
        self:UpdateIDSoundReady(show, true)
        self._condMet = show
    end

    -- PARKED FAST PATH: a hidden tracker (disabled, or load conditions unmet)
    -- whose LAST pass already hid everything has nothing to do — skip the
    -- element loop so fan-outs (combat edges, ready flips, ID re-renders)
    -- cost this tracker nothing. Previews pin elements visible, so any active
    -- preview bypasses the skip (options-panel-only, never a combat path).
    if not show and self._idArtParked and not next(self._elPreview) then
        return
    end

    -- Tracker default kind (fallback for untagged ids / single trackers). Groups
    -- are NOT gated at the element level here (the per-icon gate lives in
    -- RenderIDArt), so this only feeds the single-tracker gate below.
    local fallbackItem = self:IsItemKind()
    local isGroup = self:IsIDGroupKind()
    local prefix = self.config.dbPrefix
    local ids = ns.addon.db.profile[prefix .. "ids"] or {}
    local els = self:GetElementsDB()
    for i = 1, MAX_ELEMENTS do
        local e = self.elements[i]
        -- PAIR elements ride the aura path (GatePairSlots owns their
        -- liveness; the engine owns their visibility) — the ID art pass
        -- never renders or hides them.
        if e and not (els[i] and els[i].pairFor) then
            local el = els[i]
            local style = el and el.style
            local isVisual = style == "icon" or style == "bar"
                or style == "circle" or style == "text"
            -- Per-element idVisibility gate ("Only While On Cooldown" / "Only
            -- When Usable"; "Always" = no gate).
            --   * SINGLE trackers: one id, so the whole element is gated here on
            --     that id's cooldown / usability.
            --   * GROUPS: the gate is PER-ICON, not per-element — a group must
            --     still show while ANY of its spells/items qualifies, with each
            --     icon shown only while ITS OWN id qualifies. So don't gate the
            --     element here; RenderIDArt filters each sample (see the fan-out
            --     loop).
            local visMode = el and el.idVisibility or "always"
            local visOK = true
            if isVisual and not isGroup then
                local id0 = ids[1]
                if id0 then
                    -- Resolved ONCE: ns.IDKindOf concatenates prefix .. "idKinds"
                    -- on every call, and this ran twice with identical arguments
                    -- on a path the ID watcher drives up to once per GCD.
                    local isItem0 = ns.IDKindOf(prefix, id0, fallbackItem)
                    -- AUTO known-gate: a single unknown ability hides entirely.
                    if not IDDrawable(id0, isItem0) then
                        visOK = false
                    elseif visMode ~= "always" then
                        visOK = IDPasses(visMode, id0, isItem0)
                    end
                else
                    visOK = false
                end
            end
            if self._elPreview[i] then
                -- Preview path renders this element (idVisibility is ignored in
                -- preview so the user can always see their art while editing).
            elseif show and isVisual and visOK then
                self:RenderIDArt(i)
            else
                self:HideIDArt(i)
            end
        end
    end
    -- Everything is hidden while not shown → the next not-show pass can skip
    -- the loop entirely (see the parked fast path above).
    self._idArtParked = not show
end

function methods:ShowElementPreview(i)
    if not self.elements[i] then return end
    self._elPreview[i] = true
    -- Custom-kind: preview renders the actual custom art (icon/text incl. the
    -- static label), bypassing the aura style/preview path entirely.
    if self:IsCustomKind() then
        self:RenderCustomArt(i)
        return
    end
    -- ID kinds preview their live art (icon + swipe + charge/stack text),
    -- ignoring idVisibility so the user always sees it while editing. PAIR
    -- elements fall through to the aura preview path (their slot parks via
    -- UpdateGating → GatePairSlots like any previewing aura element).
    if self:IsIDKind() and not self:IsPairElement(i) then
        self:RenderIDArt(i)
        return
    end
    self:UpdateGating()
    self:ApplyElementStyle(i)
    RenderElementPreview(self, i)
end

function methods:HideElementPreview(i)
    if not self._elPreview[i] then return end
    self._elPreview[i] = nil
    local e = self.elements[i]
    if e then HideElementPreviewArt(e) end
    self:UpdateGating()
end

function methods:IsElementPreviewActive(i)
    return self._elPreview[i] and true or false
end

-- Hide every element preview (spell disable, options panel close).
function methods:HidePreview()
    for i = 1, MAX_ELEMENTS do
        if self._elPreview[i] then
            self._elPreview[i] = nil
            local e = self.elements[i]
            if e then HideElementPreviewArt(e) end
        end
    end
    self:UpdateGating()
end

function methods:RefreshPreview()
    self:ApplyStyle()
    local custom = self:IsCustomKind()
    local idKind = self:IsIDKind()
    local els = self:GetElementsDB()
    for i = 1, MAX_ELEMENTS do
        if self._elPreview[i] then
            if els[i] and els[i].style and els[i].style ~= "sound" then
                if custom then
                    self:RenderCustomArt(i)
                elseif idKind and not els[i].pairFor then
                    self:RenderIDArt(i)
                else
                    -- Aura elements — including PAIR "(Active)" overlays on
                    -- ID trackers, which preview like any aura element.
                    RenderElementPreview(self, i)
                end
            else
                -- Element removed or turned into a sound tracker while
                -- previewing.
                self._elPreview[i] = nil
                local e = self.elements[i]
                if e then HideElementPreviewArt(e) end
                self:UpdateGating()
            end
        end
    end
end

function methods:RestoreElementPosition(i)
    local e = self.elements[i]
    if not e then return end
    local els = self:GetElementsDB()
    -- Icon/Bar PAIR elements are position-locked over their parent
    -- element's holder (TOPLEFT-to-TOPLEFT; geometry is synced in
    -- ApplyElementStyle so the holders are the same size). Saved positions
    -- are ignored. Orphan pairs (no parent element) keep their last anchor
    -- — they're parked anyway. Standalone TEXT pairs fall through to the
    -- normal saved-position path.
    if els[i] and els[i].pairFor and els[i].pairFor ~= "text" then
        local pIdx = self:PairParentIndex(i)
        local pe = pIdx and self.elements[pIdx]
        if pe then
            e.holder:ClearAllPoints()
            e.holder:SetPoint("TOPLEFT", pe.holder, "TOPLEFT")
        end
        return
    end
    local pos = els[i] and els[i].position
    if pos then
        e.holder:ClearAllPoints()
        e.holder:SetPoint(pos[1], UIParent, pos[2], pos[3], pos[4])
    end
end

-- Centre one axis of an element, leaving the other where it is. `axis` is
-- "h" (horizontal) or "v".
--
-- The saved anchor is NOT reliably CENTER/CENTER. Holders are
-- SetClampedToScreen, so a drag that finishes near a screen edge leaves
-- GetPoint reporting an EDGE anchor ("TOPLEFT" and friends) with offsets
-- measured from that corner. Simply zeroing an offset on one of those pins the
-- tracker to the edge instead of centring it -- which is exactly what it did.
--
-- So: measure where the holder actually is, re-express that as a
-- CENTER-to-CENTER offset against UIParent, then zero the requested axis. The
-- result lands on the same anchor Reset Position uses, differing only in that
-- the other axis is preserved.
--
-- Measuring is safe here: these are options-panel buttons, and every control on
-- that panel is disabled in combat, so this never runs while geometry could be
-- secret. Scale is carried through both sides rather than assumed equal --
-- holders sit on UIParent, but a UI-scale change would otherwise skew the
-- offset.
function methods:CenterElementPosition(i, axis)
    local e = self.elements[i]
    if not e then return end
    if self:IsLockedPairElement(i) then return end
    local els = self:GetElementsDB()
    if not els[i] then return end
    local holder = e.holder
    local def = self.config.defaultPos or { "CENTER", "CENTER", 0, 0 }

    local dx, dy
    local cx, cy = holder:GetCenter()
    local ux, uy = UIParent:GetCenter()
    if cx and cy and ux and uy then
        local hs = holder:GetEffectiveScale() or 1
        local us = UIParent:GetEffectiveScale() or 1
        if us > 0 then
            dx = (cx * hs - ux * us) / us
            dy = (cy * hs - uy * us) / us
        end
    end

    local pos
    if dx then
        if axis == "h" then dx = 0 else dy = 0 end
        pos = { "CENTER", "CENTER", dx, dy }
    else
        -- Unmeasurable (never positioned yet): fall back to the same default
        -- Reset Position uses. Nothing to preserve on the other axis anyway.
        pos = { def[1], def[2], def[3] or 0, def[4] or 0 }
    end

    els[i].position = pos
    holder:ClearAllPoints()
    holder:SetPoint(pos[1], UIParent, pos[2], pos[3], pos[4])
end

function methods:ResetElementPosition(i)
    local e = self.elements[i]
    if not e then return end
    -- Locked (icon/bar) PAIR elements have no position of their own to
    -- reset; standalone Text pairs reset normally.
    if self:IsLockedPairElement(i) then return end
    local els = self:GetElementsDB()
    if els[i] then els[i].position = nil end
    local pos = self.config.defaultPos or { "CENTER", "CENTER", 0, 0 }
    e.holder:ClearAllPoints()
    e.holder:SetPoint(pos[1], UIParent, pos[2], pos[3], pos[4])
end

-- Re-anchor every element holder from the (possibly shifted) element
-- list — used after add/remove, which renumbers elements.
function methods:RestoreAllPositions()
    for i = 1, MAX_ELEMENTS do
        local e = self.elements[i]
        if e then
            local pos = self.config.defaultPos or { "CENTER", "CENTER", 0, 0 }
            e.holder:ClearAllPoints()
            e.holder:SetPoint(pos[1], UIParent, pos[2], pos[3], pos[4])
        end
        self:RestoreElementPosition(i)
    end
end

function methods:CacheIcon()
    -- Custom-kind has no aura: cache the first Icon element's resolved texture.
    if self:IsCustomKind() then
        self.cachedIcon = ns.ResolveCustomTreeIcon(self.config.dbPrefix)
            or "Interface\\Icons\\INV_Misc_QuestionMark"
        return
    end
    -- ID kinds: cache the first stored id's icon (spell/item).
    if self:IsIDKind() then
        self.cachedIcon = ns.ResolveIDIcon(self.config.dbPrefix, self:IsItemKind())
            or "Interface\\Icons\\INV_Misc_QuestionMark"
        return
    end
    local spell = self:GetDisplaySpell()
    self.cachedIcon = (spell and GetSpellTexture(spell))
        or "Interface\\Icons\\INV_Misc_QuestionMark"
end

-------------------------------------------------------------------------------
-- Factory. Runs ON DEMAND from the refresh pipeline — never while
-- ns.IsRestricted() (combat / secret-aura windows); Core defers the
-- work and retries when the restriction lifts.
-------------------------------------------------------------------------------
local function CreateElement(tracker, i)
    local config = tracker.config
    local defaultPos = config.defaultPos or { "CENTER", "CENTER", 0, 0 }
    local e = {}

    -- Holder: movable anchor + preview surface. Always shown; mouse only
    -- enabled during preview so it never eats clicks, and the drag
    -- handlers double-check the element's preview flag so live
    -- indicators are locked.
    local holder = CreateFrame("Frame", "HET" .. config.frameSuffix .. "Holder" .. i, UIParent)
    holder:SetSize(40, 40)
    holder:SetPoint(defaultPos[1], UIParent, defaultPos[2], defaultPos[3], defaultPos[4])
    holder:SetMovable(true)
    holder:EnableMouse(false)
    holder:RegisterForDrag("LeftButton")
    holder:SetClampedToScreen(true)
    holder:SetScript("OnDragStart", function(self)
        if not tracker._elPreview[i] then return end
        -- Icon/Bar PAIR elements are position-locked over their parent
        -- element (standalone Text pairs drag normally).
        if tracker:IsLockedPairElement(i) then return end
        self._hetMoving = true
        self:StartMoving()
    end)
    holder:SetScript("OnDragStop", function(self)
        if not self._hetMoving then return end
        self._hetMoving = false
        self:StopMovingOrSizing()
        local els = tracker:GetElementsDB()
        if els[i] then
            local point, _, relPoint, x, y = self:GetPoint()
            els[i].position = { point, relPoint, x, y }
        end
    end)
    -- Right-click (while mouse-enabled — i.e. a preview, and only while the
    -- options panel is open) jumps the panel to this tracker's settings and
    -- forces it into preview mode. Left button stays the drag; right is free.
    holder:SetScript("OnMouseUp", function(_, button)
        if button == "RightButton" and ns.NavigateToTracker then
            ns.NavigateToTracker(config.cfgKey)
        end
    end)
    e.holder = holder

    -- Custom-kind AND ID-kind (ability/item) elements have NO aura: they never
    -- create an AuraContainer slot or group buttons (those carry per-update
    -- engine bookkeeping and require a matched aura to show anything). Their art
    -- is drawn on the holder-level regions below, gated by Load Conditions in
    -- UpdateGating (custom) / UpdateIDArt (ID kinds).
    local isCustomKind = tracker:IsCustomKind()
    local isIDKind     = tracker:IsIDKind()
    -- PAIR "(Active)" elements on compound ID trackers ARE aura displays:
    -- they take the full slot path below even though the tracker is ID-kind.
    local isPairEl     = (tracker:GetElementsDB()[i]
        and tracker:GetElementsDB()[i].pairFor) and true or false
    local skipSlots    = (isCustomKind or isIDKind) and not isPairEl
    if skipSlots then
        e.slotKey = nil
        e._unit = nil
        e.button = nil
        e.groupButtons = {}
        e.hasGroup = false
        -- Preview / custom-art regions are created after this block.
    else

    -- Single-aura slot, born parked. On multi-spell trackers — and on
    -- group-kind customs, whose identity is already saved by the time
    -- their elements are created — the slot (circle style) shows the
    -- SOONEST-EXPIRING matching buff via the engine Expiration
    -- comparator (a later kind change is handled live by
    -- ApplyCustomIdentity).
    -- PTR-VERIFY: Normal direction = soonest first; if it shows the
    -- longest-remaining instead, switch sortDirection to Reverse.
    -- The parked filter keeps the slot inert until UpdateSpellFilter
    -- pushes a real include set (an EMPTY include table is MATCH-ALL —
    -- plan §0).
    local slotOpts = { candidateFilters = DORMANT_CANDIDATES }
    local wantExpirationSort = config.spellList and true or false
    if config.custom then
        local entry = tracker:GetCustomEntry()
        wantExpirationSort = (entry and entry.kind == "group") and true or false
    end
    if wantExpirationSort then
        slotOpts.sortMethod = (AuraContainerSortMethod
            and AuraContainerSortMethod.Expiration) or 4
    end
    -- Unit + filter for this tracker (player/HELPFUL for buffs & built-ins;
    -- target|focus / HARMFUL[|PLAYER] for custom debuffs). PAIR elements
    -- track the compound entry's APPLIED AURA instead: unit/filter follow
    -- its polarity (help = player buff; harm reserved).
    local trackedUnit   = isPairEl and tracker:PairUnit() or tracker:GetTrackedUnit()
    local trackerFilter = isPairEl and tracker:PairFilter() or tracker:GetTrackerFilter()
    e.slotKey = AcquireSlotKey()
    local button = GetSlotContainer(trackedUnit):AddAuraSlot(e.slotKey, trackerFilter, slotOpts)
    button:SetPoint("CENTER", holder, "CENTER")
    e.button = button
    e._unit = trackedUnit  -- which shared container this slot lives in

    -- Mouse state at CREATION, per the element's Show Tooltip setting
    -- (BuzzardFrames bakes its button mouse flags in the init window
    -- too). The creation window demonstrably accepts widget calls
    -- (SetPoint above); the later ApplyTooltip refresh pass can be
    -- denied on restricted buttons, so a button must never LEAVE
    -- creation in the engine's mouse-enabled default state.
    do
        local elCfg = tracker:GetElementsDB()[i]
        local show = (elCfg and elCfg.style and elCfg.style ~= "sound"
            and elCfg.showTooltip) and true or false
        ApplyButtonMouse(button, show)
    end

    if isPairEl then
        -- The overlay must draw ABOVE the parent element's ID art (cooldown
        -- swipe at holder+5, engine countdown ~+8, text frame +10). The slot
        -- button lives in the shared slot container, NOT under this holder,
        -- and frame level is absolute within a strata (both trees hang off
        -- UIParent at default strata) — so raising the button past the
        -- parent holder's sub-levels guarantees the stacking. Holders are
        -- created identically, so parent/pair holder levels match.
        -- PTR-VERIFY: SetFrameLevel accepted on the engine slot button OOC
        -- (SetPoint above is the same widget-method class and works).
        pcall(button.SetFrameLevel, button, holder:GetFrameLevel() + 12)
    end

    -- Slot regions land directly on e (e.icon, e.bar, ...).
    local slotRegions = BuildButtonRegions(button, tracker:GetElementsDB()[i],
        not tracker:IsMultiAura())
    for k, v in pairs(slotRegions) do
        e[k] = v
    end

    -- Multi-spell trackers and custom shells: PER-SPELL 1-frame groups
    -- for the ordered icon/bar fan-out (+ an unordered overflow tail on
    -- customs). All born parked; ApplyGroupAssignments claims them.
    e.groupButtons = {}
    -- Group structures only where fan-out is POSSIBLE: spellList
    -- trackers and GROUP-kind customs. Aura-kind customs are
    -- single-buff by definition (the kind is known at creation now
    -- that shells are gone) — they get the slot only. Groups are the
    -- one display structure backed by Lua-side secure FRAME POOLS, so
    -- every group that exists carries engine bookkeeping per aura
    -- update; don't create any we can't use.
    local wantsGroups = config.spellList
    if config.custom then
        local entry = tracker:GetCustomEntry()
        wantsGroups = (entry and entry.kind == "group") and true or false
    end
    if wantsGroups then
        e.hasGroup = true
        -- Per-element container (flow layout is container-level, so
        -- multi-button group elements each need their own).
        local container = CreateFrame("AuraContainer", nil, holder, "CustomAuraContainerTemplate")
        -- Never a mouse surface — this container IS anchored and auto-sized
        -- around its pool buttons, so its hit-rect sits exactly on the
        -- fan-out area. See GetSlotContainer for the full rationale.
        pcall(container.EnableMouse, container, false)
        if container.SetMouseMotionEnabled then
            pcall(container.SetMouseMotionEnabled, container, false)
        end
        container:SetPoint("TOPLEFT", holder, "TOPLEFT")
        container:SetUnit(trackedUnit)
        e.container = container
        -- Reset the alpha change-guard with the container it describes: a
        -- brand-new frame is at the engine default, so a cached value carried
        -- over from a previous container would suppress the first real write.
        e._containerAlpha = nil
        e.groupCapacity = config.spellList and #config.spellList
            or CUSTOM_SPELL_GROUPS
        local function InitGroupButton(frame, gIdx)
            -- Pool buttons can be created LAZILY (when an aura needs a
            -- frame) — style immediately, or late-created buttons render
            -- unstyled until the next global refresh (PTR-observed). The
            -- element config is read up front so the bar binds to the
            -- right property on this first (and only) binding pass.
            local el = tracker:GetElementsDB()[i]
            -- Group buttons are the multi-aura fan-out by definition, so
            -- stack tracking is never allowed on them.
            frame._het_r = BuildButtonRegions(frame, el, false)
            frame._hetGroupIdx = gIdx  -- per-spell groups only
            e.groupButtons[#e.groupButtons + 1] = frame
            -- Mouse state at init, same rule as ApplyTooltip: engine pool
            -- buttons are born mouse-ENABLED, and ApplyTooltip only runs on
            -- full refreshes — a button created lazily mid-session ate
            -- clicks until the next refresh (same class of bug as the
            -- unstyled-until-refresh note above). Full BF flag set via
            -- ApplyButtonMouse (motion + click + tooltip belt).
            local wantMouse = (el and el.style and el.style ~= "sound"
                and el.showTooltip) and true or false
            ApplyButtonMouse(frame, wantMouse)
            local style = el and el.style
            StyleButtonRegions(frame._het_r, frame,
                UsesGroup(tracker, e, style) and style or nil, el or {},
                tracker:GetOrderedBarColor(gIdx))
        end
        -- Shared group for Unordered mode. Born dormant (see
        -- DORMANT_CANDIDATES); the init window allows the setter.
        container:AddAuraGroup("maingroup", trackerFilter, {
            maxFrameCount = GROUP_MAX_FRAMES,
            initializeFrame = function(frame) InitGroupButton(frame, nil) end,
        })
        container:SetAuraGroupCandidateFilters("maingroup", DORMANT_CANDIDATES)
        -- Per-spell groups for Ordered mode.
        for g = 1, e.groupCapacity do
            container:AddAuraGroup("g" .. g, trackerFilter, {
                maxFrameCount = 1,
                initializeFrame = function(frame) InitGroupButton(frame, g) end,
            })
            container:SetAuraGroupCandidateFilters("g" .. g, DORMANT_CANDIDATES)
        end
        if config.custom then
            e.hasTail = true
            container:AddAuraGroup("gtail", trackerFilter, {
                maxFrameCount = GROUP_MAX_FRAMES,
                initializeFrame = function(frame) InitGroupButton(frame, nil) end,
            })
            container:SetAuraGroupCandidateFilters("gtail", DORMANT_CANDIDATES)
        end
    end

    end -- if skipSlots ... else (slot/group creation)

    -- Preview / custom-art regions (holder-owned, fully addon-controlled).
    -- Custom-kind trackers reuse these same regions to render their live art
    -- (see RenderCustomArt), gated by Load Conditions rather than preview mode.
    local pIcon = holder:CreateTexture(nil, "ARTWORK")
    pIcon:SetAllPoints(holder)
    pIcon:Hide()
    e.pIcon = pIcon

    -- ID-kind cooldown swipe: a holder-level Cooldown frame driven by a
    -- duration OBJECT (self-animating, zero per-frame CPU, no number reads —
    -- see RenderIDArt). Only ID kinds use it; created for all so the field is
    -- always present. The addon draws its own countdown text, so hide the
    -- engine numbers. Group fan-out samples get extra cooldowns on demand
    -- (e.pExtraCooldowns) at render time.
    if isIDKind and not isPairEl then
        local pCooldown = CreateFrame("Cooldown", nil, holder, "CooldownFrameTemplate")
        pCooldown:SetAllPoints(holder)
        pCooldown:SetHideCountdownNumbers(true)
        pCooldown:SetDrawEdge(false)
        pCooldown:Hide()
        e.pCooldown = pCooldown
        e.pExtraCooldowns = {}

        -- ID-kind BAR style: a cooldown StatusBar that self-animates from a
        -- duration OBJECT (StatusBar:SetTimerDuration; combat-safe, zero
        -- per-frame CPU, no secret number reads). A background texture sits
        -- behind it. Multi-charge
        -- abilities split into per-charge SEGMENT StatusBars (created on demand
        -- in e.pIDBarSegs); a single-cooldown bar uses e.pIDBar alone.
        local barBg = holder:CreateTexture(nil, "BACKGROUND")
        barBg:Hide()
        e.pIDBarBg = barBg
        local idBar = CreateFrame("StatusBar", nil, holder)
        idBar:SetStatusBarTexture(WHITE8X8)
        -- Holder frame level so the OVERLAY square-border edges sort above the
        -- fill (aura bar pattern ~955).
        idBar:SetFrameLevel(holder:GetFrameLevel())
        idBar:Hide()
        e.pIDBar = idBar
        e.pIDBarSegs = {}  -- per-charge segment StatusBars (lazy)
        e.pIDBarSegBgs = {} -- per-charge segment backgrounds (lazy)
    end

    -- Preview copy of the square border.
    e.pBorder = {}
    for j = 1, 4 do
        local edge = holder:CreateTexture(nil, "OVERLAY")
        edge:SetTexture(WHITE8X8)
        edge:Hide()
        e.pBorder[j] = edge
    end

    local pBarBg = holder:CreateTexture(nil, "BACKGROUND")
    pBarBg:SetAllPoints(holder)
    pBarBg:SetColorTexture(0, 0, 0, 0.5)
    pBarBg:Hide()
    e.pBarBg = pBarBg

    local pBar = holder:CreateTexture(nil, "ARTWORK")
    pBar:SetAllPoints(holder)
    pBar:Hide()
    e.pBar = pBar

    -- Extra samples for multi-aura (group) preview, created ON DEMAND at
    -- render time (holder textures are unrestricted) — the count follows
    -- the element's Max Icons/Bars setting.
    e.pExtraIcons = {}
    e.pExtraBars = {}

    -- CircleProgress resizes its parent, so the preview rings live on a
    -- dedicated centered child frame.
    local pRingFrame = CreateFrame("Frame", nil, holder)
    pRingFrame:SetPoint("CENTER", holder, "CENTER")
    pRingFrame:Hide()
    e.pRingFrame = pRingFrame
    e.pRingBg = ns.CircleProgress.Create(pRingFrame, "BACKGROUND")
    e.pRing = ns.CircleProgress.Create(pRingFrame)

    -- Text holder ABOVE the ID cooldown widgets (swipe holder+5, numbers +8) so
    -- the holder-drawn duration/stack/name FontStrings aren't hidden behind the
    -- cooldown swipe. Same idea as the aura path's textFrame over the Cooldown.
    local pTextFrame = CreateFrame("Frame", nil, holder)
    pTextFrame:SetAllPoints(holder)
    pTextFrame:SetFrameLevel(holder:GetFrameLevel() + 10)
    e.pTextFrame = pTextFrame

    local pText = pTextFrame:CreateFontString(nil, "OVERLAY")
    pText:SetPoint("CENTER", holder, "CENTER")
    pText:SetFont(TEXT_FONT, 24, "OUTLINE")
    pText:Hide()
    e.pText = pText

    -- Second preview FontString: duration and stack text can be shown at
    -- once, so the preview needs one region per binding.
    local pStackText = pTextFrame:CreateFontString(nil, "OVERLAY")
    pStackText:SetPoint("BOTTOMRIGHT", holder, "BOTTOMRIGHT", -2, 2)
    pStackText:SetFont(TEXT_FONT, 12, "OUTLINE")
    pStackText:Hide()
    e.pStackText = pStackText
    -- Static "/max" suffix for ability CHARGES current-max: the charge count is
    -- secret in combat so it can't be concatenated with max; instead the engine
    -- count sits in pStackText and this second FontString shows a plain "/N" right
    -- after it (anchored LEFT to the count's RIGHT). Only used by RenderIDText.
    local pStackMaxText = pTextFrame:CreateFontString(nil, "OVERLAY")
    pStackMaxText:SetFont(TEXT_FONT, 12, "OUTLINE")
    pStackMaxText:Hide()
    e.pStackMaxText = pStackMaxText

    local pNameText = pTextFrame:CreateFontString(nil, "OVERLAY")
    pNameText:SetPoint("CENTER", holder, "CENTER")
    pNameText:SetFont(TEXT_FONT, 12, "OUTLINE")
    pNameText:Hide()
    e.pNameText = pNameText

    local highlight = holder:CreateTexture(nil, "OVERLAY")
    highlight:SetAllPoints(holder)
    highlight:SetColorTexture(0.1, 0.4, 1.0, 0.35)
    highlight:Hide()
    e.highlight = highlight

    local label = holder:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    label:SetPoint("TOP", holder, "BOTTOM", 0, -4)
    label:SetText((config.label or "Custom") .. " " .. i)
    label:SetTextColor(0.5, 0.8, 1.0, 1.0)
    label:Hide()
    e.label = label

    return e
end

-- Create element i on demand (idempotent). Callers must ensure an
-- unrestricted context (ns.IsRestricted() false) — slot/group creation
-- and region binding are engine-denied in combat/secret windows.
function methods:EnsureElement(i)
    local e = self.elements[i]
    if e then return e end
    e = CreateElement(self, i)
    self.elements[i] = e
    self:RestoreElementPosition(i)
    return e
end

-- Build an element object for every configured element index. Deferred
-- while restricted (ns.deferredRefresh; Core re-runs the refresh when
-- the restriction lifts). Only ever ADDS objects — a stale object at an
-- index beyond the config list stays and is parked by UpdateGating.
function methods:EnsureElements()
    local els = self:GetElementsDB()
    for i = 1, MAX_ELEMENTS do
        if els[i] ~= nil and self.elements[i] == nil then
            if ns.IsRestricted() then
                ns.deferredRefresh = true
            else
                self:EnsureElement(i)
            end
        end
    end
end

function ns.Tracker.Create(config, addon)
    local tracker = setmetatable({
        config = config,
        cachedIcon = nil,
        _spellLive = false,
        _elPreview = {},
        elements = {},
    }, trackerMT)

    local include = {}
    if config.buffSpellIDs then
        for i = 1, #config.buffSpellIDs do
            include[config.buffSpellIDs[i]] = true
        end
    end
    tracker.includeSpellIDs = include

    -- Seed the entry cache before CacheIcon reads a kind. Strictly speaking the
    -- _entryGen == nil guard in the accessors would resolve it lazily anyway;
    -- doing it explicitly here keeps the "resolved once at creation" invariant
    -- visible at the site that owns the object's lifetime.
    tracker:ResolveEntryCache()

    -- NO elements are built here — EnsureElements (refresh pipeline)
    -- creates them on demand per configured element index.
    tracker:CacheIcon()

    return tracker
end

-------------------------------------------------------------------------------
-- /bamouse — mouse-blocking diagnostic (2026-08-13).
-- Phase 1: audits the LIVE mouse flags on every frame we own. A frame whose
-- disable was silently DENIED (pcall'd setters on restricted engine widgets)
-- shows up here with its flag still on — that distinction is invisible in
-- the source and is exactly what this exists to reveal.
-- Phase 2: 4 seconds later, prints the frames actually holding mouse focus —
-- hover the dead spot while waiting.
-------------------------------------------------------------------------------
do
    local issecretvalue = issecretvalue or function() return false end

    -- One flag read: "Y"/"n" live value, "?" secret, "!" call denied,
    -- "-" method absent.
    local function Flag(f, method)
        local fn = f and f[method]
        if not fn then return "-" end
        local ok, v = pcall(fn, f)
        if not ok then return "!" end
        if issecretvalue(v) then return "?" end
        return v and "Y" or "n"
    end

    local reported, scanned = 0, 0

    -- `previewFlag`: pass the tracker's _elPreview[i] for holders (nil for
    -- non-holder frames). THE discriminator for the holder mouse leak:
    -- preview=true → the flag survived panel close (cleanup never reached
    -- this tracker); preview=false → mouse was enabled OUTSIDE the preview
    -- system (impossible per source — would mean an engine/3rd-party write).
    local function Report(label, f, previewFlag)
        if not f then return end
        scanned = scanned + 1
        local shown = Flag(f, "IsShown")
        local m  = Flag(f, "IsMouseEnabled")
        local mm = Flag(f, "IsMouseMotionEnabled")
        local mc = Flag(f, "IsMouseClickEnabled")
        -- Report anything that could possibly take focus: not provably
        -- hidden, with any mouse flag live (or unreadable).
        if shown ~= "n"
            and (m == "Y" or mm == "Y" or mc == "Y" or m == "?" or m == "!") then
            reported = reported + 1
            print(("  |cffff8800%s|r  shown=%s mouse=%s motion=%s click=%s preview=%s")
                :format(label, shown, m, mm, mc, tostring(previewFlag)))
        end
    end

    SLASH_BAMOUSE1 = "/bamouse"
    SlashCmdList["BAMOUSE"] = function()
        reported, scanned = 0, 0
        print("|cff11ace9BuzzardAuras|r mouse audit — only frames with a live mouse flag are listed:")
        for unit, c in pairs(slotContainers) do
            Report("slotContainer<" .. tostring(unit) .. ">", c)
        end
        for key, tracker in pairs(ns.trackers or {}) do
            if tracker.elements then
                for i = 1, MAX_ELEMENTS do
                    local e = tracker.elements[i]
                    if e then
                        Report(key .. ".holder" .. i, e.holder,
                            tracker._elPreview[i] and true or false)
                        Report(key .. ".slotButton" .. i, e.button)
                        Report(key .. ".groupContainer" .. i, e.container)
                        if e.groupButtons then
                            for j = 1, #e.groupButtons do
                                Report(key .. ".groupBtn" .. i .. "_" .. j,
                                    e.groupButtons[j])
                            end
                        end
                        -- Preview hit-rect leak: a mouse-enabled holder with
                        -- extended insets blocks far beyond its visible rect.
                        local h = e.holder
                        if h and h.GetHitRectInsets then
                            local ok, l, r, t, b = pcall(h.GetHitRectInsets, h)
                            if ok and not issecretvalue(l)
                                and (l ~= 0 or r ~= 0 or t ~= 0 or b ~= 0) then
                                print(("  |cffffcc00%s.holder%d hitRectInsets|r %s %s %s %s")
                                    :format(key, i, tostring(l), tostring(r),
                                        tostring(t), tostring(b)))
                            end
                        end
                    end
                end
            end
        end
        print(("  audit done: %d flagged of %d scanned."):format(reported, scanned))
        print("|cff11ace9BuzzardAuras|r hover the DEAD SPOT now — capturing mouse focus in 4 seconds...")
        C_Timer.After(4, function()
            local foci = GetMouseFoci and GetMouseFoci()
            if not foci or #foci == 0 then
                print("|cff11ace9BuzzardAuras|r no mouse focus captured.")
                return
            end
            print("|cff11ace9BuzzardAuras|r mouse focus stack (top = the blocker):")
            for i = 1, #foci do
                local f = foci[i]
                local ok, name = pcall(f.GetDebugName, f)
                print(("  %d. %s"):format(i, ok and tostring(name) or "<unreadable>"))
            end
        end)
    end
end

-- ============================================================
-- /ba debug fx -- read-only state dump for Icon Effect, Pandemic Effect,
-- GCD Swipe and Cooldown Finished.
--
-- These four are the features whose failure modes are INVISIBLE from the
-- source: whether the engine exposes AddPandemicRegion on a container slot
-- button, whether a bind was accepted, and what the GCD booleans actually read
-- at a given instant are all runtime facts. This prints them rather than
-- inferring them. Read-only: it touches no widget state and triggers no
-- refresh (the soundplan dump sets the precedent).
-- ============================================================
local function YN(v) return v and "|cff40ff40yes|r" or "|cffff4040no|r" end

function ns.DumpFxDiag()
    local self = ns.addon
    if not self then return end
    self:Print("---- fx diagnostic ----")

    -- Engine capability, probed on a real button rather than assumed.
    local probe, probeSrc
    for _, tr in pairs(ns.trackers or {}) do
        for _, e in pairs(tr.elements or {}) do
            if e.button then probe, probeSrc = e.button, tr.config and tr.config.key end
            if probe then break end
        end
        if probe then break end
    end
    if probe then
        self:Print(("engine: AddPandemicRegion=%s ClearPandemicRegions=%s  (probed on %s)")
            :format(YN(probe.AddPandemicRegion ~= nil),
                    YN(probe.ClearPandemicRegions ~= nil), tostring(probeSrc)))
    else
        self:Print("engine: no aura button exists yet to probe")
    end
    self:Print(("engine: GetSpellCooldownDuration=%s  gcd widgets registered=%d")
        :format(YN(C_Spell_GetSpellCooldownDuration ~= nil), #(ns.gcdWidgets or {})))

    -- Live GCD read, right now.
    if C_Spell_GetSpellCooldown then
        local g = C_Spell_GetSpellCooldown(GCD_SPELL_ID)
        self:Print(("gcd spell %d: isActive=%s isOnGCD=%s duration=%s")
            :format(GCD_SPELL_ID, tostring(g and g.isActive),
                    tostring(g and g.isOnGCD),
                    tostring(C_Spell_GetSpellCooldownDuration
                        and C_Spell_GetSpellCooldownDuration(GCD_SPELL_ID) ~= nil)))
    end

    for _, key in ipairs(ns.trackerOrder or {}) do
        local tr = ns.trackers[key]
        if tr then
            local els = tr:GetElementsDB() or {}
            for i = 1, #els do
                local el = els[i] or {}
                local e  = tr.elements and tr.elements[i]
                local want = el.iconEffect or el.pandemicEffect
                    or el.idGcdSwipe or el.idCooldownBling
                if e and want then
                    self:Print(("|cffffd100%s[%d]|r style=%s idKind=%s")
                        :format(key, i, tostring(el.style), YN(tr:IsIDKind())))
                    if el.iconEffect then
                        self:Print(("   effect=%s glowShown=%s antsPlaying=%s flashPlaying=%s")
                            :format(tostring(el.iconEffect),
                                YN(e.fxGlow and e.fxGlow:IsShown()),
                                YN(e.fxAntsAG and e.fxAntsAG:IsPlaying()),
                                YN(e.fxFlashAG and e.fxFlashAG:IsPlaying())))
                    end
                    if el.pandemicEffect then
                        -- NOT IsShown(): AddPandemicRegion stamps
                        -- SecretAspect.Shown on the region, so reading it back
                        -- is a secret read. Alpha stays ours.
                        self:Print(("   pandemic: api=%s bound=%s index=%s alpha=%s")
                            :format(YN(e.button and e.button.AddPandemicRegion ~= nil),
                                YN(e._fxPandemicBound),
                                tostring(e._fxPandemicIndex),
                                tostring(e.fxPandemic and e.fxPandemic:GetAlpha())))
                    end
                    if el.idGcdSwipe then
                        local w = e.pGcdCooldown
                        local sid = w and w._gcdId
                        local cd = (sid and not w._gcdIsItem and C_Spell_GetSpellCooldown)
                            and C_Spell_GetSpellCooldown(sid) or nil
                        self:Print(("   gcdSwipe: widget=%s wanted=%s shown=%s id=%s isActive=%s isOnGCD=%s durWithGCD=%s")
                            :format(YN(w ~= nil), YN(w and w._gcdWanted),
                                YN(w and w:IsShown()), tostring(sid),
                                tostring(cd and cd.isActive), tostring(cd and cd.isOnGCD),
                                tostring(sid and C_Spell_GetSpellCooldownDuration
                                    and C_Spell_GetSpellCooldownDuration(sid) ~= nil)))
                    end
                    if el.idCooldownBling then
                        local cw = e.pCooldown
                        self:Print(("   bling: cdWidget=%s drawBling=%s art=%s keepFlag=%s shown=%s")
                            :format(YN(cw ~= nil),
                                YN(cw and cw.GetDrawBling and cw:GetDrawBling()),
                                YN(cw and cw._hetBlingTex),
                                YN(cw and cw._hetKeepBling),
                                YN(cw and cw:IsShown())))
                    end
                end
            end
        end
    end
    self:Print("---- end ----")
end
