if select(2, ...).IS_LEGACY_CLIENT then return end -- no-op once Legacy1207 is deleted
-------------------------------------------------------------------------------
-- 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)
    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\\Ring_10px",
    ["Ring_20px"] = "Interface\\AddOns\\BuzzardAuras\\Media\\Ring_20px",
    ["Ring_30px"] = "Interface\\AddOns\\BuzzardAuras\\Media\\Ring_30px",
    ["Ring_40px"] = "Interface\\AddOns\\BuzzardAuras\\Media\\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

-- 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
    local LSM = LibStub("LibSharedMedia-3.0", true)
    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\\BuzzardGlass")
        LSM:Register("statusbar", "Buzzard Glass Dark",
            "Interface\\AddOns\\BuzzardAuras\\Media\\BuzzardGlassDark")
        LSM:Register("statusbar", "Buzzard Glass Shine",
            "Interface\\AddOns\\BuzzardAuras\\Media\\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\\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.
local function BarTexturePath(name)
    if not name or name == "" then return WHITE8X8 end
    if type(name) == "string" and name:find("[\\/]") then return name end
    local LSM = LibStub("LibSharedMedia-3.0", true)
    return (LSM and LSM:Fetch("statusbar", name, true)) or WHITE8X8
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")
        c:SetUnit(unit)
        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

-- 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 pcall(e.holder.Hide, e.holder) 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 }

local ROUND_BORDER_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\BarBorder"
local ROUND_MASK_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\BarMask"
local ROUND_BORDER_THICK_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\BarBorderThick"
local ROUND_MASK_THICK_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\BarMaskThick"
local ICON_ROUND_BORDER_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\IconBorder"
local ICON_ROUND_BORDER_THICK_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\IconBorderThick"
local ICON_ROUND_MASK_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\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\\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 },
}

local function FontPath(name)
    if not name then return TEXT_FONT end
    local LSM = LibStub("LibSharedMedia-3.0", true)
    return (LSM and LSM:Fetch("font", name)) or TEXT_FONT
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 { 1, 1, 1, 1 }
    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 { 1, 1, 1, 1 }
    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

    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

-- 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

    if not style or style == "sound" then
        r.cooldown:SetDrawSwipe(false)
        r.cooldown:SetHideCountdownNumbers(true)
        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)
            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)
        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 (the sliders size the bar, not
        -- bar+icon). The icon square matches the bar's cross-axis (bar width for
        -- vertical, bar height for horizontal) and sits OUTSIDE the bar; the button
        -- (holder) grows by iconSize+gap on the icon's axis. Button TOP-LEFT stays
        -- put, so the bar shifts to make room for the icon.
        local iconSize = vertical and bw or bh
        local gap = showIcon and (iconSize + 2) or 0
        button:SetSize((not vertical) and (bw + gap) or bw,
                       vertical and (bh + gap) or bh)
        r.bar:ClearAllPoints()
        r.bar:SetSize(bw, bh)
        if showIcon then
            -- Icon square at one end, 2px gap, bar (fixed bw x bh) beside it.
            -- Vertical bars: "top" = TOP end, else BOTTOM end.
            local pos = el.barIconPosition or (vertical and "bottom" or "left")
            r.icon:ClearAllPoints()
            r.icon:SetSize(iconSize, iconSize)
            -- Captured so the rounded mask/frame can reproduce the icon
            -- square's rect BUTTON-relative (see ApplyRoundedIcon rule 2).
            local iconPoint
            if vertical then
                if pos == "top" then
                    iconPoint = "TOP"
                    r.icon:SetPoint("TOP", button, "TOP")
                    r.bar:SetPoint("TOP", r.icon, "BOTTOM", 0, -2)
                else -- bottom (default; legacy left/right values fall here)
                    iconPoint = "BOTTOM"
                    r.icon:SetPoint("BOTTOM", button, "BOTTOM")
                    r.bar:SetPoint("BOTTOM", r.icon, "TOP", 0, 2)
                end
            else
                if pos == "right" then
                    iconPoint = "RIGHT"
                    r.icon:SetPoint("RIGHT", button, "RIGHT")
                    r.bar:SetPoint("RIGHT", r.icon, "LEFT", -2, 0)
                else
                    iconPoint = "LEFT"
                    r.icon:SetPoint("LEFT", button, "LEFT")
                    r.bar:SetPoint("LEFT", r.icon, "RIGHT", 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.
                ApplyRoundedIcon(r, button, el.barIconBorderColor,
                    iconPoint, iconSize, iconSize, 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)
            -- 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
            r.bar:SetAllPoints(button)
            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
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.
-------------------------------------------------------------------------------
function methods:GetCustomEntry()
    if not self.config.custom then return nil end
    local list = ns.addon.db.profile.customTrackers
    if type(list) ~= "table" then return nil end
    for i = 1, #list do
        if list[i].id == self.config.customID then
            return list[i]
        end
    end
end

-- True for a "custom" kind tracker (no aura dependency; art driven purely by
-- Load Conditions). Buffs/debuffs/groups return false.
function methods:IsCustomKind()
    local e = self:GetCustomEntry()
    return (e and e.kind == "custom") and true or false
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()
    local e = self:GetCustomEntry()
    return (e and (e.kind == "ability" or e.kind == "abilitygroup")) and true or false
end
function methods:IsItemKind()
    local e = self:GetCustomEntry()
    return (e and (e.kind == "item" or e.kind == "itemgroup")) and true or false
end
-- Either ability or item — the shared "ID kind" (has a real ID + cooldown/
-- charge/stack tracking, unlike custom which is static art).
function methods:IsIDKind()
    return self:IsAbilityKind() or self:IsItemKind()
end
-- Is this an ID-kind GROUP (fan-out over an id list)?
function methods:IsIDGroupKind()
    local e = self:GetCustomEntry()
    return (e and (e.kind == "abilitygroup" or e.kind == "itemgroup")) and true or false
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

-- 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
                local sc = GetSlotContainer(e._unit)
                pcall(sc.SetAuraSlotFilterString, sc, e.slotKey, filter)
                pcall(sc.UpdateAllAuras, sc)
            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
        -- Bar sliders size the BAR itself; the icon sits outside, so the holder
        -- grows by iconSize+gap on the icon's axis (icon = bar cross-axis).
        local bw = el.barWidth or 200
        local bh = el.barHeight or 20
        local vertical = el.barOrientation == "vertical"
        local iconSize = vertical and bw or bh
        local gap = (el.barShowIcon ~= false) and (iconSize + 2) or 0
        e.holder:SetSize((not vertical) and (bw + gap) or bw,
                         vertical and (bh + gap) or bh)
    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

-- 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
            pcall(e.button.EnableMouse, e.button, show)
            if e.hasGroup then
                for j = 1, #e.groupButtons do
                    pcall(e.groupButtons[j].EnableMouse, 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
    local t = self:GetDBVal("loadInstanceTypes")
    if t == nil then return true end
    return t[ns.CurrentInstanceBucket()] ~= false
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.
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
    -- 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).
    local function itemPasses(i, id)
        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
    for i = 1, #ids do
        local id = tonumber(ids[i])
        if id and id > 0 then
            if anyMode then
                sawValid = true
                if itemPasses(i, id) then return true end
            elseif not itemPasses(i, id) then
                return false
            end
        end
    end
    return not (anyMode and sawValid)
end

-- Role condition. Combat-free (role only changes with a spec change, which
-- is out of combat and fires PLAYER_SPECIALIZATION_CHANGED -> RefreshAll). A
-- nil table, or a nil entry, counts as checked — same "all checked by
-- default" convention as Instance Type, expressed without seeding defaults.
-- 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
    local role = ns.playerRole
    if role == nil then return true end  -- unknown role: fail open
    local t = self:GetDBVal("loadRoleTypes")
    if t == nil then return true end
    return t[role] ~= false
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
    local spec = ns.playerSpec
    if spec == nil then return true end  -- unknown spec: fail open
    local t = self:GetDBVal("loadSpecTypes")
    if t == nil then return true end
    return t[spec] ~= false
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.
function methods:LoadRoleSpecMet()
    local roleOn = self:GetDBVal("loadRole") and true or false
    local specOn = self:GetDBVal("loadSpec") and true or false
    if roleOn and specOn then
        -- OR: pass if either the role check or the spec check passes.
        return self:LoadRoleMet() or self:LoadSpecMet()
    end
    -- At most one enabled: AND is equivalent (disabled side is true).
    return self:LoadRoleMet() and self:LoadSpecMet()
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()
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
            local isVisual = style == "icon" or style == "text"
            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()

    local els = self:GetElementsDB()
    local 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
            if e.hasGroup then
                -- In-combat visual gate: assignment is untouched (it doesn't
                -- fold the in-combat-flippable conditions — see
                -- UpdateGating), only the container alpha flips. pcall out
                -- of caution for the combat/secret window.
                pcall(e.container.SetAlpha, e.container, gateShown and 1 or 0)
            end
        end
    end
    for sc in pairs(touched) do
        pcall(sc.UpdateAllAuras, sc)
    end
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()
    -- 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()

    local els = self:GetElementsDB()
    local touched = {}  -- slot containers whose slots changed (per e._unit)
    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
            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
                -- In-combat visual gate (see header): our own frame, base
                -- widget method, propagates to every pooled group button.
                e.container:SetAlpha(gateShown and 1 or 0)
            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
        end
    end
    for sc in pairs(touched) do
        sc:UpdateAllAuras()
    end
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 = {}
    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
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

local function HideElementPreviewArt(e)
    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
    -- 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 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 cd:Clear(); cd:Hide() 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
        -- Bar ALWAYS occupies barWidth x barHeight (sliders size the bar, not
        -- bar+icon). Icon square matches the bar's cross-axis (bar width for
        -- vertical, bar height for horizontal) and sits OUTSIDE the bar; the
        -- holder grew by iconSize+gap in ApplyElementStyle. 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 = showIcon and (iconSize + 2) or 0
        local iconPoint = "TOPLEFT"
        local barPoint = "TOPLEFT"
        local iconOX, iconOY, barOX, barOY, barW, barH
        barW, barH = bw, bh
        if not showIcon then
            barOX, barOY = 0, 0
            iconOX, iconOY = 0, 0
        elseif vertical and pos == "top" then
            iconOX, iconOY = 0, 0
            barOX, barOY = 0, -gap
        elseif vertical then                       -- icon at BOTTOM
            iconOX, iconOY = 0, -bh - 2
            barOX, barOY = 0, 0
        elseif pos == "right" then
            iconOX, iconOY = bw + 2, 0
            barOX, barOY = 0, 0
        else                                       -- icon at LEFT (default)
            iconOX, iconOY = 0, 0
            barOX, barOY = 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)
            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()
        -- 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 == "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
local function AbilityKnown(spellID)
    if C_SpellBook_IsSpellKnown then
        if KnownInBook(spellID) then return true end
        -- A talent may REPLACE the tracked spell with an override the player
        -- actually has — count that as known.
        if C_Spell_GetOverrideSpell then
            local ov = C_Spell_GetOverrideSpell(spellID)
            if ov and ov ~= spellID and KnownInBook(ov) then return true end
        end
        return false
    end
    if IsPlayerSpell then return IsPlayerSpell(spellID) and true or false end
    return true  -- API missing: fail open (show it)
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

-- 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 { 1, 1, 1, 1 }
    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

-- 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)
    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
        cdWidget:Clear()
        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
        cdWidget:Clear()
        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
    cdWidget:Clear()
    return false
end

-- Charges info for a spell. Returns (current, max). maxCharges is a STATIC
-- property (safe to compare any time); currentCharges is a SECRET number in
-- combat, so it's returned ONLY when canaccess permits — otherwise `current` is
-- nil and callers must treat the count as unknown (never compare a secret value,
-- or execution taints — see the SECRET rule). Abilities only; items have no
-- charges. Defined ahead of ArmIDSwipe, which needs it.
local function SpellChargeInfo(spellID)
    if not C_Spell_GetSpellCharges then return nil end
    local ch = C_Spell_GetSpellCharges(spellID)
    if not (ch and canaccess(ch)) then return nil end
    local cur = ch.currentCharges
    if not canaccess(cur) then cur = nil end  -- secret in combat → unknown
    return cur, ch.maxCharges
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

-- 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 SpellChargeInfo canaccess
-- guard; recharge/cooldown are engine duration objects (no number read).
local function ArmIDSwipe(cdWidget, id, isItem, showNumbers)
    if not isItem and C_Spell_GetSpellCharges then
        local _, maxCh = SpellChargeInfo(id)
        -- 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
            cdWidget:Clear()
            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 = SpellChargeInfo(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, "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
        ApplyRoundPreview(e, (k == 0) and "iconmain" or ("icon" .. k), el.borderColor,
            "TOPLEFT", rox, roy, sz, sz,
            unusableOvl and { region, unusableOvl } or { region }, 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")
            -- 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 cdWidget:Clear() end
            cdWidget:Show()
        else
            cdWidget:Clear()
            cdWidget:Hide()
        end
    end
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 = {} }.
local function IDBarUnit(e, k)
    if k == 0 then
        return { bar = e.pIDBar, bg = e.pIDBarBg, icon = e.pIcon,
                 segs = e.pIDBarSegs, segBgs = e.pIDBarSegBgs }
    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 on e (the k=0 IDBarUnit is a transient table, so these
-- can't live on `u`). 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. Kept on e (not the
-- transient k=0 unit table) so it survives 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).
    local key = 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
        local regions = {}
        if bgTex then regions[#regions + 1] = bgTex end
        if fillTex then regions[#regions + 1] = fillTex 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

-- 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 ("bar" .. k)
    local sqKey = "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

    local bg = el.barBgColor or { 0.1, 0.1, 0.1, 0.7 }
    -- 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 c = el.color or { 1, 1, 1, 1 }
    local bc = self:GetDBVal("buffColors")
    if type(bc) == "table" and bc[id] then c = bc[id] end
    -- 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)
    local function styleBar(barFrame, bgTex)
        bgTex:SetColorTexture(bg[1], bg[2], bg[3], bg[4])
        bgTex:Show()
        barFrame:SetStatusBarTexture(barTex)
        barFrame:SetStatusBarColor(c[1], c[2], c[3], c[4])
        barFrame:SetOrientation(vertical and "VERTICAL" or "HORIZONTAL")
    end

    -- Icon square at one end + bar in the remainder (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 bar ALWAYS occupies exactly barWidth x barHeight (the sliders size the
    -- bar itself, not the bar+icon). The icon is a square matching the bar's
    -- cross-axis (bar width for vertical bars, bar height for horizontal) and sits
    -- OUTSIDE the bar; the holder grows by iconSize+gap on the icon's axis. Holder
    -- TOP-LEFT stays fixed, so the bar shifts to make room for the icon.
    local showIcon = el.barShowIcon ~= false
    local iconSize = vertical and bw or bh
    local posn = el.barIconPosition or (vertical and "bottom" or "left")
    local gap = showIcon and (iconSize + 2) or 0
    -- Total holder extent (icon axis grows by gap); sized on the main sample.
    local holderW = (not vertical) and (bw + gap) or bw
    local holderH = vertical and (bh + gap) or bh
    local iconOX, iconOY, barOX, barOY, barW, barH
    barW, barH = bw, bh
    if not showIcon then
        barOX, barOY = 0, 0
        iconOX, iconOY = 0, 0
    elseif vertical and posn == "top" then
        iconOX, iconOY = 0, 0
        barOX, barOY = 0, -gap
    elseif vertical then                       -- icon at BOTTOM
        iconOX, iconOY = 0, -bh - 2
        barOX, barOY = 0, 0
    elseif posn == "right" then
        iconOX, iconOY = bw + 2, 0
        barOX, barOY = 0, 0
    else                                       -- icon at LEFT (default)
        iconOX, iconOY = 0, 0
        barOX, barOY = gap, 0
    end
    if k == 0 then e.holder:SetSize(holderW, holderH) 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 reassigning
    -- `c` — styleBar and the segment/charge fills read it as an UPVALUE
    -- and all run after this point, so one reassignment covers the single
    -- bar, every charge segment, and the recharge overlay, 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
        c = { oc[1], oc[2], oc[3], c[4] }
    elseif idUnusable and el.idDesatUnusable then
        local g = 0.299 * c[1] + 0.587 * c[2] + 0.114 * c[3]
        c = { g, g, g, c[4] }
    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 = "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, "b" .. k,
            id, isItem, el, ibt == "blizzard" and iconSize * 0.04 or 0,
            idUnusable)
        if IsRounded(ibt) then
            ApplyRoundPreview(e, iconKey, el.barIconBorderColor,
                "TOPLEFT", ox + iconOX, oy + iconOY, iconSize, iconSize,
                unusableOvl and { icon, unusableOvl } or { icon },
                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["b" .. k]
        if ovl then ovl:Hide() end
    end

    -- 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)
            end
            iconCD:SetSwipeColor(0, 0, 0, swipeAlpha)
            if armed == false then iconCD:Clear() end
            iconCD:Show()
        else
            iconCD:Clear(); iconCD:Hide()
        end
    end

    -- Charge count (abilities only). >1 ⇒ segmented bar. Computed here (before
    -- the cooldown-number block) so a segmented bar can draw PER-SEGMENT numbers
    -- instead of one over the whole bar. 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 (currentCharges is passed to SetValue directly).
    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 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)
        styleBar(u.bar, u.bg)
        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)
        styleBar(seg, sbg)
        -- 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,
            "barseg" .. k .. "_" .. s, "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(c[1], c[2], c[3], c[4])
            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, "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

-- 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
    HideElementPreviewArt(e)
    if not style or style == "sound" 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); the
    -- per-id closure resolves each entry's real kind via ns.IDKindOf.
    local fallbackItem = self:IsItemKind()
    local prefix = self.config and self.config.dbPrefix
    local function idIsItem(id)
        return ns.IDKindOf(prefix, id, fallbackItem)
    end
    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 function VisModeFor(id)
        if isGroup then return (idVis and idVis[id]) or "always" end
        return el.idVisibility or "always"
    end
    local gateActive = not self._elPreview[i]
    local cap = isGroup and (el.maxFrames or #ids) or 1
    local drawIDs = {}
    for idx = 1, #ids do
        if #drawIDs >= 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 = idIsItem(id)
            if not gateActive or IDDrawable(id, isItemId) then
                local vm = VisModeFor(id)
                if not gateActive or vm == "always" or IDPasses(vm, id, isItemId) then
                    drawIDs[#drawIDs + 1] = id
                end
            end
        end
    end

    if style == "icon" then
        local sz = el.iconSize or 40
        for k = 0, #drawIDs - 1 do
            local id = drawIDs[k + 1]
            self:RenderIDSample(i, id, k, sz, dir, spacing, idIsItem(id), el)
        end
        -- Charge/stack text follows the FIRST drawn icon (nil when the group is
        -- fully gated out and nothing is shown).
        if drawIDs[1] then
            self:RenderIDText(i, drawIDs[1], idIsItem(drawIDs[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, #drawIDs - 1 do
            local id = drawIDs[k + 1]
            self:RenderIDBar(i, id, k, dir, spacing, idIsItem(id), el)
        end
        if drawIDs[1] then
            self:RenderIDText(i, drawIDs[1], idIsItem(drawIDs[1]), el)
        end
    else
        -- Circle/text: render the single main icon region with the swipe + text
        -- on the holder (kept simple + combat-safe).
        local sz = el.iconSize or 40
        local id0 = drawIDs[1] or ids[1]
        self:RenderIDSample(i, id0, 0, sz, "right", spacing, idIsItem(id0), el)
        self:RenderIDText(i, id0, idIsItem(id0), 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 realMax
                        local ch = C_Spell_GetSpellCharges and C_Spell_GetSpellCharges(id)
                        if ch and canaccess(ch) then realMax = ch.maxCharges end
                        local abilityMax = realMax 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 { 1, 1, 1, 1 }
                            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(e)
    if e.pCooldown then e.pCooldown:Clear(); e.pCooldown:Hide() end
    if e.pExtraCooldowns then
        for _, cd in pairs(e.pExtraCooldowns) do cd:Clear(); cd:Hide() 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, INCLUDING the Sound rising-edge (an ID kind with a Sound
-- element fires on the unmet→met transition). `mode` is "live" | "resync" |
-- "render" (see UpdateCustomArt).
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
        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
        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
                    -- AUTO known-gate: a single unknown ability hides entirely.
                    if not IDDrawable(id0, ns.IDKindOf(prefix, id0, fallbackItem)) then
                        visOK = false
                    elseif visMode ~= "always" then
                        visOK = IDPasses(visMode, id0, ns.IDKindOf(prefix, id0, fallbackItem))
                    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

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)
    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

    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")
        container:SetPoint("TOPLEFT", holder, "TOPLEFT")
        container:SetUnit(trackedUnit)
        e.container = container
        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
            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

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

    return tracker
end
