local addonName, ns = ...
local HET = ns.addon

local AC = LibStub("AceConfig-3.0")
local ACD = LibStub("AceConfigDialog-3.0")
local ACR = LibStub("AceConfigRegistry-3.0")

local LSM = LibStub("LibSharedMedia-3.0", true)

local MAX_ELEMENTS = ns.MAX_ELEMENTS or 4

local STYLE_VALUES = {
    icon    = "Icon",
    bar     = "Bar",
    circle  = "Circle",
    text    = "Text",
    texture = "Texture",
    sound   = "Sound Alert",
}
local STYLE_SORTING = { "icon", "bar", "circle", "text", "texture", "sound" }

-- Progress Texture "Drain From": the offered edges depend on the axis, so
-- the values/sorting members are functions. Hoisted to file scope because
-- AceConfigDialog calls both members on every pane refresh — inline table
-- literals would allocate four throwaway tables per open.
local TEX_DRAIN_H_VALUES  = { right = "Right", left = "Left" }
local TEX_DRAIN_H_SORTING = { "right", "left" }
local TEX_DRAIN_V_VALUES  = { top = "Top", bottom = "Bottom" }
local TEX_DRAIN_V_SORTING = { "top", "bottom" }

-- Font outline flags (WoW SetFont). Keys are the literal flag strings
-- passed to FontString:SetFont.
local FONT_BORDER_VALUES = {
    [""]                         = "None",
    ["OUTLINE"]                  = "Outline",
    ["THICKOUTLINE"]             = "Thick Outline",
    ["MONOCHROME"]               = "Monochrome",
    ["OUTLINE, MONOCHROME"]      = "Outline + Monochrome",
    ["THICKOUTLINE, MONOCHROME"] = "Thick Outline + Monochrome",
}
local FONT_BORDER_SORTING = {
    "", "OUTLINE", "THICKOUTLINE", "MONOCHROME",
    "OUTLINE, MONOCHROME", "THICKOUTLINE, MONOCHROME",
}

-- "Show When" visibility modes for ID-kind (ability/item) indicators. Shared by
-- the per-id row dropdown in the Tracked Abilities / Items list and (historically)
-- the element-level dropdown. Matches the runtime IDPasses modes in Tracker.lua.
local ID_VISIBILITY_VALUES = {
    always  = "Always (while loaded)",
    oncd    = "Only While On Cooldown",
    usable  = "Only When Usable",
}
local ID_VISIBILITY_SORTING = { "always", "oncd", "usable" }

-- Icon-parented text needs corners (a countdown and a stack count share
-- one square); bars keep their 3-position left/center/right scheme.
local ICON_TEXT_POS = {
    topleft    = "Top Left",    top    = "Top",    topright    = "Top Right",
    left       = "Left",        center = "Center", right       = "Right",
    bottomleft = "Bottom Left", bottom = "Bottom", bottomright = "Bottom Right",
}
local ICON_TEXT_POS_SORTING = {
    "topleft", "top", "topright",
    "left", "center", "right",
    "bottomleft", "bottom", "bottomright",
}
local BAR_TEXT_POS = { left = "Left", center = "Center", right = "Right" }
local BAR_TEXT_POS_SORTING = { "left", "center", "right" }
-- Vertical bars run bottom->top, so the 3 positions along the fill are
-- named for that axis instead.
local BAR_TEXT_POS_V = { bottom = "Bottom", center = "Center", top = "Top" }
local BAR_TEXT_POS_V_SORTING = { "bottom", "center", "top" }

-- Text entry types. Each maps to ONE engine binding (ns.TEXT_BINDING),
-- and a binding drives only one FontString — so Stacks and
-- Stacks / Max Stacks are alternatives, not companions.
local TEXT_TYPE_VALUES = {
    duration = "Duration",
    stacks   = "Stacks",
    name     = "Buff Name",
}
local TEXT_TYPE_SORTING = { "duration", "stacks", "name" }

-- How a Stacks entry renders. Current is the bare binding (engine hides
-- it below 2 applications); the other two supply a format string and so
-- always render.
local STACK_DISPLAY_VALUES = {
    current    = "Current Stacks",
    currentmax = "Current / Max",
    max        = "Max",
}
local STACK_DISPLAY_SORTING = { "current", "currentmax", "max" }
-- Distinct bindings = the hard ceiling on simultaneous text entries.
local MAX_TEXTS = 3

-- Load Conditions "Instance Type" buckets, in display order. Keys match
-- ns.CurrentInstanceBucket() (Tracker.lua).
local INSTANCE_TYPES = {
    { key = "world",   label = "Open World" },
    { key = "dungeon", label = "Dungeon" },
    { key = "delve",   label = "Delve" },
    { key = "raid",    label = "Raid" },
    { key = "arena",   label = "Arena" },
    { key = "bg",      label = "Battleground" },
}

-- Load Conditions "Role" buckets, in display order. Keys match the strings
-- returned by GetSpecializationRoleByID / cached in ns.playerRole
-- (Tracker.lua). Role icons use the standard Blizzard role atlases.
local ROLE_TYPES = {
    { key = "TANK",    label = "Tank",   atlas = "roleicon-tiny-tank"   },
    { key = "HEALER",  label = "Healer", atlas = "roleicon-tiny-healer" },
    { key = "DAMAGER", label = "DPS",    atlas = "roleicon-tiny-dps"    },
}

-- Load Conditions "Spec" data (WoW 12.0 specializations), with a `class`
-- field so the list can be grouped by class then alphabetized. Pre-sorted
-- here: class alphabetical
-- (by display name), then spec name alphabetical within each class — the
-- SPEC_SORTING array below mirrors this order for the AceConfig group.
local SPEC_TYPES = {
    { id=250, name="Blood", icon="Interface\\Icons\\Spell_DeathKnight_BloodPresence", role="TANK", class="DEATHKNIGHT" },
    { id=251, name="Frost", icon="Interface\\Icons\\Spell_Deathknight_FrostPresence", role="DAMAGER", class="DEATHKNIGHT" },
    { id=252, name="Unholy", icon="Interface\\Icons\\Spell_DeathKnight_UnholyPresence", role="DAMAGER", class="DEATHKNIGHT" },
    { id=1480, name="Devourer", icon="Interface\\Icons\\Classicon_demonhunter_void", role="DAMAGER", class="DEMONHUNTER" },
    { id=577, name="Havoc", icon="Interface\\Icons\\Ability_DemonHunter_SpecDPS", role="DAMAGER", class="DEMONHUNTER" },
    { id=581, name="Vengeance", icon="Interface\\Icons\\Ability_DemonHunter_SpecTank", role="TANK", class="DEMONHUNTER" },
    { id=102, name="Balance", icon="Interface\\Icons\\Spell_Nature_StarFall", role="DAMAGER", class="DRUID" },
    { id=103, name="Feral", icon="Interface\\Icons\\Ability_Druid_CatForm", role="DAMAGER", class="DRUID" },
    { id=104, name="Guardian", icon="Interface\\Icons\\Ability_Racial_BearForm", role="TANK", class="DRUID" },
    { id=105, name="Restoration", icon="Interface\\Icons\\Spell_Nature_HealingTouch", role="HEALER", class="DRUID" },
    { id=1473, name="Augmentation", icon="Interface\\Icons\\ClassIcon_Evoker_Augmentation", role="DAMAGER", class="EVOKER" },
    { id=1467, name="Devastation", icon="Interface\\Icons\\ClassIcon_Evoker_Devastation", role="DAMAGER", class="EVOKER" },
    { id=1468, name="Preservation", icon="Interface\\Icons\\ClassIcon_Evoker_Preservation", role="HEALER", class="EVOKER" },
    { id=253, name="Beast Mastery", icon="Interface\\Icons\\Ability_Hunter_BeastMastery", role="DAMAGER", class="HUNTER" },
    { id=254, name="Marksmanship", icon="Interface\\Icons\\Ability_Hunter_FocusedAim", role="DAMAGER", class="HUNTER" },
    { id=255, name="Survival", icon="Interface\\Icons\\Ability_Hunter_Camouflage", role="DAMAGER", class="HUNTER" },
    { id=62, name="Arcane", icon="Interface\\Icons\\Spell_Holy_MagicalSentry", role="DAMAGER", class="MAGE" },
    { id=63, name="Fire", icon="Interface\\Icons\\Spell_Fire_FireBolt02", role="DAMAGER", class="MAGE" },
    { id=64, name="Frost", icon="Interface\\Icons\\Spell_Frost_FrostBolt02", role="DAMAGER", class="MAGE" },
    { id=268, name="Brewmaster", icon="Interface\\Icons\\Monk_Stance_DrunkenOx", role="TANK", class="MONK" },
    { id=270, name="Mistweaver", icon="Interface\\Icons\\Monk_Stance_WiseSerpent", role="HEALER", class="MONK" },
    { id=269, name="Windwalker", icon="Interface\\Icons\\Spell_Monk_Windwalker_Spec", role="DAMAGER", class="MONK" },
    { id=65, name="Holy", icon="Interface\\Icons\\Spell_Holy_HolyBolt", role="HEALER", class="PALADIN" },
    { id=66, name="Protection", icon="Interface\\Icons\\Ability_Paladin_ShieldOfTheTemplar", role="TANK", class="PALADIN" },
    { id=70, name="Retribution", icon="Interface\\Icons\\Spell_Holy_AuraOfLight", role="DAMAGER", class="PALADIN" },
    { id=256, name="Discipline", icon="Interface\\Icons\\Spell_Holy_PowerWordShield", role="HEALER", class="PRIEST" },
    { id=257, name="Holy", icon="Interface\\Icons\\Spell_Holy_GuardianSpirit", role="HEALER", class="PRIEST" },
    { id=258, name="Shadow", icon="Interface\\Icons\\Spell_Shadow_ShadowWordPain", role="DAMAGER", class="PRIEST" },
    { id=259, name="Assassination", icon="Interface\\Icons\\Ability_Rogue_DeadlyBrew", role="DAMAGER", class="ROGUE" },
    { id=260, name="Outlaw", icon="Interface\\Icons\\Ability_Rogue_Waylay", role="DAMAGER", class="ROGUE" },
    { id=261, name="Subtlety", icon="Interface\\Icons\\Ability_Stealth", role="DAMAGER", class="ROGUE" },
    { id=262, name="Elemental", icon="Interface\\Icons\\Spell_Nature_Lightning", role="DAMAGER", class="SHAMAN" },
    { id=263, name="Enhancement", icon="Interface\\Icons\\Spell_Nature_LightningShield", role="DAMAGER", class="SHAMAN" },
    { id=264, name="Restoration", icon="Interface\\Icons\\Spell_Nature_MagicImmunity", role="HEALER", class="SHAMAN" },
    { id=265, name="Affliction", icon="Interface\\Icons\\Spell_Shadow_DeathCoil", role="DAMAGER", class="WARLOCK" },
    { id=266, name="Demonology", icon="Interface\\Icons\\Spell_Shadow_Metamorphosis", role="DAMAGER", class="WARLOCK" },
    { id=267, name="Destruction", icon="Interface\\Icons\\Spell_Shadow_RainOfFire", role="DAMAGER", class="WARLOCK" },
    { id=71, name="Arms", icon="Interface\\Icons\\Ability_Warrior_SavageBlow", role="DAMAGER", class="WARRIOR" },
    { id=72, name="Fury", icon="Interface\\Icons\\Ability_Warrior_InnerRage", role="DAMAGER", class="WARRIOR" },
    { id=73, name="Protection", icon="Interface\\Icons\\Ability_Warrior_DefensiveStance", role="TANK", class="WARRIOR" },
}

-- specID -> role, exported for the Spec Filter. A tracker gated by ROLE alone
-- still has to match when you filter to a spec that HAS that role, so the
-- matcher (ns.MatchesSpecFilter, Tracker.lua) needs this mapping. Built here
-- because SPEC_TYPES lives here; read at call time, never at load.
ns.SPEC_ROLE = {}
for i = 1, #SPEC_TYPES do
    ns.SPEC_ROLE[SPEC_TYPES[i].id] = SPEC_TYPES[i].role
end

-- The player's own specs, for the Spec Filter dropdown. Only your class's specs
-- are listed — you can only ever play those, and the filter answers "what shows
-- up when I'm in spec X". "All Specs" is the escape hatch that shows everything,
-- including trackers configured for other classes' specs, so nothing you have
-- configured can ever become unreachable in the panel.
-- SPEC_TYPES indexed by class, built ONCE. The Specs checkbox grid used to
-- rescan all 40 entries per class header to collect that class's spec ids —
-- O(classes x specs) per tracker, on every options rebuild — for a mapping that
-- is a static property of the table.
local SPECS_BY_CLASS, SPEC_CLASS_ORDER = {}, {}
for i = 1, #SPEC_TYPES do
    local cls = SPEC_TYPES[i].class or "OTHER"
    if not SPECS_BY_CLASS[cls] then
        SPECS_BY_CLASS[cls] = {}
        SPEC_CLASS_ORDER[#SPEC_CLASS_ORDER + 1] = cls
    end
    local t = SPECS_BY_CLASS[cls]
    t[#t + 1] = SPEC_TYPES[i]
end
table.sort(SPEC_CLASS_ORDER)
-- Spec ids per class, likewise precomputed for the class-header toggles.
local SPEC_IDS_BY_CLASS = {}
for cls, specs in pairs(SPECS_BY_CLASS) do
    local ids = {}
    for j = 1, #specs do ids[j] = specs[j].id end
    SPEC_IDS_BY_CLASS[cls] = ids
end

-- Spec id -> owning class token, for the auto-spec rule below.
local SPEC_CLASS = {}
for i = 1, #SPEC_TYPES do
    SPEC_CLASS[SPEC_TYPES[i].id] = SPEC_TYPES[i].class
end

-------------------------------------------------------------------------------
-- AUTO SPEC CONDITIONS
--
-- The curated spell tables carry an optional spec list per entry
-- ({ 195181, "Personal Buff", {250} } -- SpellData_*.lua), and the data engine
-- already parses it into entry.spec. Nothing consumed it until now, which is
-- why setting a Spell ID never filled in the Spec condition.
--
-- THE ROUNDING RULE: a spell listed for ONE spec of a class restricts to that
-- spec; a spell listed for MORE THAN ONE spec of a class rounds UP to every
-- spec of that class. The curated data is already written that way, so this is
-- normally a no-op — but applying it here rather than trusting the tables
-- means the rule lives in code, in one place, and a mis-authored entry cannot
-- quietly produce a half-restricted tracker. Grouping is PER CLASS, so a spell
-- shared across classes rounds each class independently.
local function RoundSpecs(specIDs)
    local byClass, out = {}, {}
    for i = 1, #specIDs do
        local id = tonumber(specIDs[i])
        local cls = id and SPEC_CLASS[id]
        if cls then
            local t = byClass[cls]
            if not t then t = {}; byClass[cls] = t end
            t[#t + 1] = id
        elseif id then
            -- A spec id this client does not know (older or newer data). Keep
            -- it verbatim rather than dropping the restriction entirely.
            out[id] = true
        end
    end
    for cls, ids in pairs(byClass) do
        if #ids > 1 then
            local all = SPEC_IDS_BY_CLASS[cls]
            if all then
                for j = 1, #all do out[all[j]] = true end
            else
                for j = 1, #ids do out[ids[j]] = true end
            end
        else
            out[ids[1]] = true
        end
    end
    return out
end

-- CATEGORIES THAT MUST NEVER AUTO-SPEC.
--
-- A curated `spec` list names the specs that can CAST the spell. For an
-- ability tracker that is exactly right — you can only press Time Dilation as
-- a Preservation Evoker. For an aura that lands on SOMEBODY ELSE it is the
-- opposite of right: a Warrior wants Time Dilation on their frame precisely
-- BECAUSE they are not the Evoker who cast it, and a spec condition built from
-- the caster's spec means the tracker never loads for the only players who
-- need it.
--
-- Keyed by the category string in the curated tables, so widening this is one
-- line. Only the aura-side category is listed: the ability-side entry for the
-- same spell (Time Dilation is "Essential" {1468} in SpellData_AbilitiesItems)
-- keeps its spec list, because pressing the button really is spec-locked.
local NO_AUTO_SPEC_CAT = {
    ["External Defensive"] = true,
}

-- Aura kinds, for the built-in veto below. An ability or item tracker is about
-- pressing the thing, which IS spec-locked, so the veto never applies to those.
local AURA_KINDS = { buff = true, debuff = true }

-- EVERY AURA THE ADDON SHIPS A TRACKER FOR is exempt too, on the same
-- reasoning as the category above and reaching the spells the category misses:
-- Power Infusion, Innervate, Spatial Paradox, Bloodlust / Heroism / Time Warp /
-- Fury of the Aspects / Primal Rage / Harrier's Cry, and the twelve Time
-- Spirals. Those all arrive from somebody else, and all of them carry the
-- CASTER's spec in the curated table. A hand-rolled custom tracker for one of
-- them must not inherit that.
--
-- Read off ns.trackerConfigs rather than restated as a list, so a spell added
-- to a built-in group is covered the day it is added and cannot drift out of
-- sync. cfg.custom is skipped: user-created trackers are the things being
-- filled in here, not a source of exemptions.
--
-- SELF_CAST_TRACKERS is skipped for the opposite reason. Personal Defensives
-- never arrive from anyone else, so caster spec and wearer spec are the same
-- spec and the auto-fill is right: Barkskin only ever lands on the Druid who
-- pressed it, and Prismatic Barrier {62} is a genuinely useful default on a
-- hand-rolled Arcane Mage copy. Skipping the tracker, not its spells — an ID
-- that ALSO belongs to some other built-in is still exempt through that one.
--
-- Built once, on first use. Every built-in registers at file load and all of
-- them load before this file (see the .toc), while the first call cannot
-- happen until someone types a spell into a tracker; customs register later
-- but are skipped, so nothing can arrive after the cache is warm.
local SELF_CAST_TRACKERS = { persdef = true }
local builtinAuraIDs
local function BuiltinAuraIDs()
    if builtinAuraIDs then return builtinAuraIDs end
    builtinAuraIDs = {}
    for key, cfg in pairs(ns.trackerConfigs) do
        if not cfg.custom and not SELF_CAST_TRACKERS[key]
            and type(cfg.buffSpellIDs) == "table" then
            local ids = cfg.buffSpellIDs
            for i = 1, #ids do
                local n = tonumber(ids[i])
                if n then builtinAuraIDs[n] = true end
            end
        end
    end
    return builtinAuraIDs
end

-- The curated spec list for a spell/item id.
--
-- SPELL IDS AND ITEM IDS ARE DIFFERENT NAMESPACES and must never be searched
-- across: item 5512 and spell 5512 are unrelated things, so falling back from
-- the item table into the spell tables would hand a Healthstone tracker a
-- Death Knight spec restriction. Within the spell namespace the fallback IS
-- wanted — the same spell often appears as both a buff and an ability, and
-- either entry's spec list is the right answer for it.
--
-- Returns nil when the id is not curated, is curated with no spec
-- restriction, or is vetoed by category or by belonging to a built-in
-- tracker; all of those correctly mean "leave conditions alone".
local SPELL_KINDS = { "buff", "debuff", "ability" }
local function CuratedSpecs(id, preferKind)
    local SD = ns.SpellData
    local kinds = SD and SD.KINDS
    if not kinds then return nil end
    local function entryFor(kind)
        local k = kinds[kind]
        return k and k.BYID and k.BYID[id] or nil
    end
    -- Note the ORDER: the built-in test comes first and does not need an
    -- entry, because a built-in group's spell list is not obliged to be
    -- curated (Rallying Cry's ally-side buff carries no category at all).
    local function Vetoed(kind)
        if AURA_KINDS[kind] and BuiltinAuraIDs()[id] then return true end
        local e = entryFor(kind)
        return (e and e.cat and NO_AUTO_SPEC_CAT[e.cat]) and true or false
    end
    local function look(kind)
        if Vetoed(kind) then return nil end
        local e = entryFor(kind)
        return e and e.spec or nil
    end
    if preferKind == "item" then return look("item") end
    -- THE VETO HAS TO END THE SEARCH, not just skip one table. Every one of
    -- these spells is ALSO in the ability table carrying the caster's spec
    -- (Time Dilation is {1468} there, Power Infusion {256,257,258}), so a buff
    -- tracker that merely declined its own entry would fall through the loop
    -- below and pick the identical wrong list straight back up. A veto in the
    -- kind the tracker actually IS is final.
    if preferKind then
        if Vetoed(preferKind) then return nil end
        local hit = look(preferKind)
        if hit then return hit end
    end
    for i = 1, #SPELL_KINDS do
        local kind = SPELL_KINDS[i]
        if kind ~= preferKind then
            local hit = look(kind)
            if hit then return hit end
        end
    end
    return nil
end

-- Apply the curated spec restriction for `id` to this tracker's Load
-- Conditions. Returns true when it wrote something.
--
-- NEVER TOUCHES A CONDITION THE USER SET BY HAND. The only conditions this
-- will overwrite or widen are ones it wrote itself, which is what the
-- <prefix>loadSpecAuto marker records. Without that marker there is no way to
-- tell "auto-filled a moment ago" from "deliberately narrowed last week", and
-- the group union below would quietly re-enable specs the user had excluded.
-- ns.ClearAutoSpecMark is called from every spec checkbox, so the first manual
-- edit hands ownership to the user permanently.
--
-- An ON-but-empty condition counts as unfinished rather than chosen, and is
-- filled in — that is a help, not a hijack.
--
-- Groups UNION: adding a second spell widens the condition to cover both,
-- which is the only answer that keeps every member of the group able to load.
function ns.ApplyAutoSpec(prefix, id, preferKind, union)
    local p = HET.db.profile
    local specs = CuratedSpecs(id, preferKind)
    if type(specs) ~= "table" or #specs == 0 then return false end

    local existing = p[prefix .. "loadSpecTypes"]
    local hasAny = false
    if p[prefix .. "loadSpec"] and type(existing) == "table" then
        for _, v in pairs(existing) do
            if v then hasAny = true break end
        end
    end
    local ours = p[prefix .. "loadSpecAuto"] and true or false
    if hasAny and not (union and ours) then return false end

    local want = RoundSpecs(specs)
    if next(want) == nil then return false end

    local t = (hasAny and ours) and existing or {}
    for specID in pairs(want) do t[specID] = true end
    p[prefix .. "loadSpecTypes"] = t
    p[prefix .. "loadSpec"] = true
    p[prefix .. "loadSpecAuto"] = true

    -- Check BEFORE exempting, or the answer is always "it passes".
    -- ns.PassesSpecFilter, not the file-local: this function is defined ABOVE
    -- it, and a local referenced before its declaration would resolve to a nil
    -- global instead.
    local hiddenByFilter = ns.specFilter and ns.PassesSpecFilter
        and not ns.PassesSpecFilter(prefix)

    -- KEEP IT VISIBLE while it is being set up. The restriction just written is
    -- correct and the filter is correct, but a row that vanishes the instant
    -- you finish typing a spell name reads as a bug.
    if ns.ExemptFromSpecFilter then ns.ExemptFromSpecFilter(prefix) end

    -- Say so once, so the disappearance later — on the next View or Filter
    -- change, or the next time the panel is opened — is expected rather than
    -- mysterious.
    if hiddenByFilter then
        print("|cffff8c14Buzzard Auras|r Spec conditions were set automatically"
            .. " for this spell. They do not include the spec you are filtering"
            .. " by, so this tracker will be hidden once you change View or"
            .. " |cffffd100Filter By Spec|r — set the filter to"
            .. " |cffffd100All Specs|r to keep it in the list.")
    end
    return true
end

-- The user has taken over this tracker's Spec condition; auto-spec must never
-- widen or overwrite it again.
function ns.ClearAutoSpecMark(prefix)
    HET.db.profile[prefix .. "loadSpecAuto"] = nil
end

-- "|cffRRGGBB<text>|r" in a class's colour. RAID_CLASS_COLORS.colorStr already
-- carries the alpha byte, hence "|c" and not "|cff".
local function ClassColored(cls, text)
    local cc = RAID_CLASS_COLORS and RAID_CLASS_COLORS[cls]
    if cc and cc.colorStr then return "|c" .. cc.colorStr .. text .. "|r" end
    return text
end
local function ClassName(cls)
    return (LOCALIZED_CLASS_NAMES_MALE and LOCALIZED_CLASS_NAMES_MALE[cls]) or cls
end

-- Bake every string the Specs checkbox grid needs, ONCE. All of them are pure
-- functions of SPEC_TYPES, which is a load-time constant — but the grid is
-- rebuilt per tracker on every options rebuild, so building them there cost
-- ~212 string constructions per tracker (over 5,000 on a large profile), of
-- which 40 were `gsub` pattern passes over an icon path. Lua interns strings so
-- these were not GC garbage, but the concat + hash + bucket walk is real CPU.
--
-- Baked on FIRST USE, not at file scope. The strings themselves are constant,
-- but two of them go through ClassColored, which reads RAID_CLASS_COLORS — and
-- a class-colour addon rewrites that table at its OWN load or at PLAYER_LOGIN,
-- both of which can land after this chunk runs. Baking at file scope would pin
-- Blizzard's default colours for the session.
-- Since the options table is no longer primed at ADDON_LOADED, the first bake
-- normally happens on the first panel open, which is already well after
-- PLAYER_LOGIN. The combat watcher at the bottom of this file still clears
-- `specStringsBaked` once on PLAYER_LOGIN as a belt-and-braces guard for the
-- case where something builds the table earlier. Two bakes per session at most,
-- against one per options rebuild before.
local SPEC_CLASS_META = {}
local specStringsBaked = false
local function EnsureSpecStrings()
    if specStringsBaked then return end
    specStringsBaked = true
    for i = 1, #SPEC_CLASS_ORDER do
        local cls   = SPEC_CLASS_ORDER[i]
        local cname = ClassName(cls)
        SPEC_CLASS_META[cls] = {
            name    = cname,
            brkKey  = "brk_" .. cls,
            hdrKey  = "hdr_" .. cls,
            hdrName = ClassColored(cls, cname),
            hdrDesc = "Check or uncheck every " .. cname .. " specialization.",
        }
    end
    for i = 1, #SPEC_TYPES do
        local s = SPEC_TYPES[i]
        local cname = SPEC_CLASS_META[s.class or "OTHER"].name
        -- AceConfig eats "\\" in some texture paths, so the icon is fed with
        -- forward slashes. Falls back to the class-coloured NAME if a spec ever
        -- has no icon: an unlabelled check box is unusable, a clipped one is
        -- just ugly.
        local icon = s.icon and s.icon:gsub("\\", "/") or nil
        s._argKey = "sp_" .. s.id
        s._label  = icon and ("|T" .. icon .. ":14:14:0:0|t")
            or ClassColored(s.class, s.name)
        -- The name lives in the tooltip. ACD's tooltip title IS the label, so
        -- hovering shows the icon with the name under it. The class is spelled
        -- out because six spec names are shared between classes (Frost, Holy,
        -- Protection, Restoration...) and colour alone does not separate them.
        s._desc   = s.name .. " (" .. cname .. ")"
    end
end

local SPEC_FILTER_ALL = "all"
-- The dropdown's values/sorting tables. Memoized LAZILY rather than built at
-- file scope: the player's class cannot change in a session, but UnitClass may
-- not be answerable at ADDON_LOADED under every load order, so the first call
-- that gets a class token wins and every later call is two table reads.
local specFilterValues, specFilterSorting
-- Key of the fallback row described in EnsureFilterValue, or nil when none is
-- in place. At most one exists at a time, and it is always the LAST entry of
-- the sorting table, which is what makes removing it a single pop.
local specFilterExtraKey
-- Guarantee the value currently in force has a row. AceConfigDialog blanks a
-- select whose value is missing from `values`, so without this the dropdown
-- would show NOTHING while a real, restrictive filter was still in force, and
-- every spec-gated tracker would vanish from all four tabs with no visible
-- reason. Reachable when a patch adds a specialization SPEC_TYPES doesn't list
-- yet: ns.specFilter is the raw API spec id, so it can name a spec with no
-- entry here. ns.MatchesSpecFilter already defends the matcher against that
-- case (Tracker.lua); this is the other half of it.
local function EnsureFilterValue(v, order)
    local sf = ns.specFilter
    local key = sf and tostring(sf) or nil
    -- A spec the table DOES list needs no fallback row -- but `v` is the
    -- memoized table we may have written the fallback into ourselves on an
    -- earlier call, so a bare `v[key]` test cannot tell "SPEC_TYPES lists this"
    -- from "I added this". Excluding our own key is what makes the function
    -- idempotent; without it, it removes on the call after it adds, and since
    -- AceConfigDialog reads `values` then `sorting` (two calls per feed) the
    -- row was gone again by the time the control was built.
    if key and v[key] and key ~= specFilterExtraKey then key = nil end
    if specFilterExtraKey == key then return end
    if specFilterExtraKey then
        v[specFilterExtraKey] = nil
        if order[#order] == specFilterExtraKey then order[#order] = nil end
    end
    specFilterExtraKey = key
    if key then
        -- Name it from the API rather than showing a bare id: this only ever
        -- fires for a spec the addon predates, and the API will know it.
        local name, icon
        if GetSpecializationInfoByID then
            local _, n, _, ic = GetSpecializationInfoByID(sf)
            name, icon = n, ic
        end
        v[key] = (icon and ("|T" .. icon .. ":14|t ") or "")
            .. (name or ("Spec " .. key))
        order[#order + 1] = key
    end
end
local function SpecFilterTables()
    if specFilterValues then
        EnsureFilterValue(specFilterValues, specFilterSorting)
        return specFilterValues, specFilterSorting
    end
    local _, classToken = UnitClass("player")
    local v = { [SPEC_FILTER_ALL] = "|cffffd100All Specs|r" }
    local order = { SPEC_FILTER_ALL }
    if not classToken then
        -- Don't cache a classless result — try again on the next feed.
        -- No fallback row either: without a class token there is no memo to
        -- keep it in step with, and the next feed rebuilds from scratch.
        return v, order
    end
    for i = 1, #SPEC_TYPES do
        local s = SPEC_TYPES[i]
        if s.class == classToken then
            local key = tostring(s.id)
            v[key] = "|T" .. s.icon .. ":14|t " .. s.name
            order[#order + 1] = key
        end
    end
    specFilterValues, specFilterSorting = v, order
    EnsureFilterValue(v, order)
    return v, order
end

-- The Spec Filter dropdown. ONE shared table at file scope: it captures no
-- BuildOptions local, so there is no reason to rebuild it (plus five closures
-- and a six-part desc concat) on every options rebuild. AceConfigDialog only
-- ever READS option tables — it writes to the separate status table.
-- Search box, between the Spec Filter (0.05) and Add New Tracker (0.1).
--
-- `input` rather than a search-editbox widget: this filters a list that is
-- rebuilt anyway, so there is nothing to autocomplete against, and an input
-- commits on Enter -- which is what stops every keystroke from triggering a
-- full options rebuild.
local TRACKER_SEARCH_INPUT = {
    type = "input",
    name = "Search",
    desc = "Show only trackers matching what you type."
        .. "\n\nMatches the tracker's |cffffd100name|r, its |cffffd100type|r"
        .. " (buff, debuff, ability, item, custom, group), the"
        .. " |cffffd100tracker types|r it uses (icon, bar, circle, text,"
        .. " texture, sound) and any |cffffd100spec or role|r conditions set on"
        .. " it (spec name, class name, tank/healer/damage)."
        .. "\n\nSeveral words narrow further -- |cffffd100mage bar|r shows only"
        .. " Mage-spec trackers that have a Bar."
        .. "\n\nSearches within the Spec Filter, and is cleared when the panel"
        .. " closes.",
    order = 0.07,
    width = 0.9,
    get = function() return ns.trackerSearch or "" end,
    set = function(_, val)
        val = (val or ""):lower():gsub("^%s+", ""):gsub("%s+$", "")
        ns.trackerSearch = (val ~= "") and val or nil
        -- Same treatment a Spec Filter change gets: drop previews that just
        -- left scope and re-render the list.
        if ns.OnSpecFilterChanged then ns.OnSpecFilterChanged() end
    end,
}

local SPEC_FILTER_SELECT = {
        type = "select",
        name = "Filter By Spec",
        desc = "Show only the trackers that would load in this spec:"
            .. " anything matching its role and/or spec conditions, plus"
            .. " everything with no role or spec condition set."
            .. "\n\n|cffffd100All Specs|r shows every tracker, including"
            .. " ones set up for other classes' specs."
            .. "\n\nFollows your current spec and updates when you change"
            .. " it. Previews follow this filter too.",
        -- Leftmost on the top row: ordered ahead of Add New Tracker (0.1) and
        -- the preview eyes (0.3+). Width is ABSOLUTE PIXELS (170 * n), and the
        -- row it shares is already busy, so keep this narrow — the dropdown
        -- only has to show one spec name and a 14px icon.
        order = 0.05,
        width = 0.8,
        -- Both tables come from one memoized builder (see SpecFilterTables):
        -- ACD evaluates these at feed time, and they were each rescanning all 40
        -- specs and rebuilding their tables on every evaluation.
        values  = function() return (SpecFilterTables()) end,
        sorting = function() local _, order = SpecFilterTables() return order end,
        get = function()
            return ns.specFilter and tostring(ns.specFilter) or SPEC_FILTER_ALL
        end,
        set = function(_, val)
            -- Keys are STRINGS (mixed number/string keys in one values
            -- table are asking for trouble), so convert back here.
            ns.specFilter = (val ~= SPEC_FILTER_ALL) and tonumber(val) or nil
            -- Manual pick: re-arm the spec-change tracker to the CURRENT
            -- spec so the next real spec change still re-snaps, but this
            -- choice survives until then. `or ns._specFilterSpec` keeps a
            -- pick made before the spec API resolved from being clobbered
            -- by the first refresh that does resolve one.
            ns._specFilterSpec = ns.playerSpec or ns._specFilterSpec
            -- Drop out-of-scope previews and re-render, same as a spec change.
            -- No node-table bookkeeping here: BuildOptions records the filter
            -- it built under, and the rebuild this causes does that for us.
            ns.OnSpecFilterChanged()
        end,
}

-- Multi-aura tracker = spellList group OR group-kind custom OR ID-kind group
-- (abilitygroup/itemgroup, which fan out over their id list just like a custom
-- group). "Multi-aura" here means "presents as a fan-out" for the element
-- display options — the ID groups have no aura, but share the same layout UI.
local function IsMultiAuraCfg(cfgKey)
    local cfg = ns.trackerConfigs[cfgKey]
    if cfg.spellList then return true end
    if cfg.custom then
        local t = ns.trackers[cfgKey]
        local entry = t and t:GetCustomEntry()
        return (entry and (entry.kind == "group"
            or entry.kind == "abilitygroup" or entry.kind == "itemgroup"))
            and true or false
    end
    return false
end

-- Single-active groups are spellList groups where only ONE of their buffs
-- can be on the player at a time (Time Spiral, Bloodlust/Heroism), so a
-- single display always shows the active one. Data-driven via
-- config.singleActive so any such group opts in with one field.
local function IsSingleActiveCfg(cfgKey)
    local cfg = ns.trackerConfigs[cfgKey]
    return (cfg and cfg.singleActive) and true or false
end

-- Type labels pluralize on multi-aura trackers (Icons/Bars fan out) —
-- EXCEPT single-active groups, which show one buff so keep Icon/Bar.
local function StyleLabel(cfgKey, k)
    if IsMultiAuraCfg(cfgKey) and not IsSingleActiveCfg(cfgKey) then
        if k == "icon" then return "Icons" end
        if k == "bar" then return "Bars" end
    end
    return STYLE_VALUES[k]
end

-- Multi-aura trackers don't offer the Text type (single-display
-- countdown text is meaningless across several simultaneous buffs) —
-- except single-active groups (config.singleActive), which show one buff
-- and so keep it. A grandfathered text element keeps working regardless.
-- The customTrackers entry for a cfgKey ("custom<id>"), or nil for built-ins.
--
-- MEMOIZED against ns.customEntryGen -- the same generation counter Tracker.lua
-- already uses for its own entry cache, bumped by every customTrackers mutation
-- (add, duplicate, delete, kind toggle, import, profile swap).
--
-- This used to be a raw linear scan of customTrackers, and the callers are not
-- occasional: BuildElementGroup opens with four of these lookups PER ELEMENT
-- (IsCustomKindCfg + IsIDKindCfg's two + IsItemKindCfg), BuildSpellGroup adds
-- two more per tracker, and AllowedTypeSorting is five in a single call from
-- closures ACD re-evaluates on every feed. That made BuildOptions
-- O(N x elements x N) -- roughly 9,000 entry comparisons at 30 trackers and
-- 100,000 at 100 -- which was the last real O(N^2) left in the addon.
--
-- One shared table, rebuilt whole on a generation miss rather than per key, so
-- a cfgKey with no entry (a built-in, or a just-deleted custom) is a hit too
-- and does not re-scan. NEGATIVE_ENTRY stands in for "looked up, not a custom"
-- because a nil value is indistinguishable from an unpopulated slot.
local NEGATIVE_ENTRY = {}
local customEntryMemo, customEntryMemoGen = {}, nil
local function CustomEntryFor(cfgKey)
    if customEntryMemoGen ~= ns.customEntryGen then
        customEntryMemo = {}
        customEntryMemoGen = ns.customEntryGen
        local list = HET.db.profile and HET.db.profile.customTrackers
        if type(list) == "table" then
            for i = 1, #list do
                local en = list[i]
                if en and en.id then
                    customEntryMemo["custom" .. en.id] = en
                end
            end
        end
    end
    local hit = customEntryMemo[cfgKey]
    if hit ~= nil then
        if hit == NEGATIVE_ENTRY then return nil end
        return hit
    end
    customEntryMemo[cfgKey] = NEGATIVE_ENTRY
    return nil
end

-- Is this tracker a custom DEBUFF? Only custom trackers can be debuffs;
-- every built-in is a buff. Drives the subtab it lives under and which
-- tracker types it offers (Sound Alert is buff-only).
local function IsDebuffCfg(cfgKey)
    local en = CustomEntryFor(cfgKey)
    return (en and en.harmful) and true or false
end

-- Is this tracker a "custom" kind (no aura dependency, Custom subtab)? These
-- render a user icon/text/sound driven purely by Load Conditions.
-- User-CREATED trackers, as opposed to the ones the addon ships pre-built
-- (Power Infusion, External Defensives, Bloodlust, ...). A pre-built tracker
-- points at a fixed, curated aura, so any feature that only makes sense for an
-- arbitrary user-chosen aura has no business appearing on one.
local function IsUserCustomCfg(cfgKey)
    local c = ns.trackerConfigs[cfgKey]
    return (c and c.custom) and true or false
end

local function IsCustomKindCfg(cfgKey)
    local e = CustomEntryFor(cfgKey)
    return (e and e.kind == "custom") and true or false
end

-- Ability/Item kinds (no aura): a spell/item ID drives an icon with an
-- optional Cooldown swipe + Charges/Stacks text. Groups fan out over an ID
-- list. These live in the merged Abilities/Items subtab.
--
-- MIXED-GROUP MODEL (12.1): a single ID-kind GROUP now holds BOTH abilities
-- and items, distinguished PER ENTRY by a side-table p[prefix.."idKinds"] =
-- { [id] = "ability" | "item" }. We keep the two kind values abilitygroup /
-- itemgroup as-is for storage compatibility (no kind migration) and treat
-- BOTH as mixed-capable: the kind only sets the group's DEFAULT entry type
-- (which of the two "Add ..." rows is primed), while the per-id tag governs
-- each entry's actual ability/item behavior. IsAbilityKindCfg / IsItemKindCfg
-- therefore describe a group's DEFAULT type only — for per-entry decisions in
-- a group, use IDKindOfCfg. Single ability/item trackers stay pure (they have
-- exactly one id, tagged by their own kind).
local function IsAbilityKindCfg(cfgKey)
    local e = CustomEntryFor(cfgKey)
    return (e and (e.kind == "ability" or e.kind == "abilitygroup")) and true or false
end
local function IsItemKindCfg(cfgKey)
    local e = CustomEntryFor(cfgKey)
    return (e and (e.kind == "item" or e.kind == "itemgroup")) and true or false
end
-- Either ability or item — the shared "ID kind" that has no aura but a real
-- ID and cooldown/charge/stack tracking (unlike custom, which is static art).
local function IsIDKindCfg(cfgKey)
    return IsAbilityKindCfg(cfgKey) or IsItemKindCfg(cfgKey)
end
-- Is this an ID-kind GROUP (fan-out over an ID list)?
local function IsIDGroupCfg(cfgKey)
    local e = CustomEntryFor(cfgKey)
    return (e and (e.kind == "abilitygroup" or e.kind == "itemgroup")) and true or false
end
-- Per-id "is this entry an item?" for a tracker. Reads the per-entry side-table
-- p[prefix.."idKinds"][id] ("ability"/"item"); when absent falls back to the
-- tracker's own default kind (IsItemKindCfg). This is the cfg-level mirror of
-- ns.IDKindOf (Tracker.lua). Callers deciding an ID's ability/item behavior in
-- a GROUP must use this, not IsItemKindCfg (which is the group DEFAULT only).
local function IDKindOfCfg(cfgKey, prefix, id)
    local kinds = HET.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 IsItemKindCfg(cfgKey)
end

-- Custom-kind trackers have no aura, so bar/circle (duration/stacks fills)
-- make no sense; only Icon, Text, Texture and Sound Alert are offered.
-- Texture DOES apply: it is a static image with no duration binding even on
-- the aura path ("a presence light" — see StyleButtonRegions), so having no
-- aura costs it nothing. Custom draws it on the same holder-level pIcon the
-- aura preview uses (RenderCustomArt's texture arm).
local CUSTOM_STYLE_SORTING = { "icon", "text", "texture", "sound" }

local function AllowedTypeSorting(cfgKey)
    if IsCustomKindCfg(cfgKey) then return CUSTOM_STYLE_SORTING end
    local debuff = IsDebuffCfg(cfgKey)
    local multi  = IsMultiAuraCfg(cfgKey)
    local single = IsSingleActiveCfg(cfgKey)
    -- ID kinds (ability/item) have NO aura, so they never create a slot
    -- (CreateElement's skipSlots) and draw on holder regions through the ID
    -- art pass instead. That pass has no Texture arm, so offering the style
    -- here would produce an element that silently renders nothing.
    local idKind = IsIDKindCfg(cfgKey)
    if not (debuff or multi or idKind) then return STYLE_SORTING end
    -- Multi-aura groups drop "text" (a single countdown is meaningless across
    -- several simultaneous buffs) and "texture" (a single chosen image fanned
    -- out over every match says nothing about which is which — the same reason
    -- Icon Source is single-slot only) — but single-active groups only ever
    -- show one buff, so they keep both. Debuff trackers drop "sound".
    local order = {}
    for _, k in ipairs(STYLE_SORTING) do
        local dropMulti = multi and not single and (k == "text" or k == "texture")
        local dropID    = idKind and k == "texture"
        if not dropMulti and not dropID and not (debuff and k == "sound") then
            order[#order + 1] = k
        end
    end
    return order
end

-- "Custom Sound" dropdown: LSM sounds only (the bundled voice clips are
-- selected via the Sound Type dropdown, not a sentinel entry). Rendered
-- via SharedMediaWidgets' LSM30_Sound control (per-entry speaker button
-- to preview) — it expects the LSM name→path hash as values.
local function GetCustomSoundValues()
    if LSM then
        return LSM:HashTable("sound")
    end
    return { ["None"] = "None" }
end

-- Spell name + icon for arbitrary IDs (custom trackers). Unknown IDs
-- render as "#12345" with a question-mark icon.
local function SpellIDLabel(id)
    local name = C_Spell.GetSpellName and C_Spell.GetSpellName(id)
    local tex = C_Spell.GetSpellTexture(id) or 134400
    return ("|T%s:16|t %s"):format(tex, name or ("#" .. id))
end

-- Does this spell have multiple charges? C_Spell.GetSpellCharges returns a
-- table with maxCharges; a spell "has charges" when maxCharges > 1.
-- Options-UI only (never a combat hot path), so the read
-- is direct. nil/unknown → false (no charges), so a charge-less ability never
-- gets an auto-added Charges text. Returns false for items (they use bag
-- Stacks, not charges) so this only ever gates ability trackers.
local function SpellHasCharges(id)
    id = tonumber(id)
    if not id or id <= 0 then return false end
    if not (C_Spell and C_Spell.GetSpellCharges) then return false end
    local info = C_Spell.GetSpellCharges(id)
    return (info and info.maxCharges and info.maxCharges > 1) and true or false
end

-- Item name + icon for an item ID (Item Usable rows). Uncached items resolve
-- to nil name until the client caches them, so fall back to "#id".
local function ItemIDLabel(id)
    local name = C_Item and C_Item.GetItemInfo and C_Item.GetItemInfo(id)
    local tex = (C_Item and C_Item.GetItemIconByID and C_Item.GetItemIconByID(id))
        or 134400
    return ("|T%s:16|t %s"):format(tex, name or ("#" .. id))
end

-- Can this item be equipped? GetItemInfoInstant's 4th return is the equip
-- location (an INVTYPE_* string), or "" for non-equippable items — and it
-- never needs a server query, so it's reliable in the options UI. Nil id or a
-- missing API means "unknown" → treated as equippable (fail open, so the
-- toggle still shows rather than silently vanishing).
local function IsEquippableItem(id)
    if not (id and id > 0) then return false end
    if not (C_Item and C_Item.GetItemInfoInstant) then return true end
    local _, _, _, equipLoc = C_Item.GetItemInfoInstant(id)
    return equipLoc ~= nil and equipLoc ~= ""
end

-- Unique display name PER SECTION (Buffs / Debuffs / Abilities+Items /
-- Other), case-insensitive: "Dream Breath" taken in Buffs → "Dream Breath 2"
-- there, but the SAME name is free to use in Abilities/Items. The built-in
-- trackers all live in the Buffs section, so their names are reserved only
-- when naming within Buffs.
local BUILTIN_NAMES = {
    ["external defensives"] = true,
    ["innervate"] = true,
    ["power infusion"] = true,
    ["spatial paradox"] = true,
}
-- Which name-uniqueness bucket a customTrackers entry belongs to. Names only
-- have to be unique WITHIN a bucket, so "Dream Breath" can exist as both a
-- buff and an ability.
local function SectionBucketOf(en)
    if not en then return "buffs" end
    local k = en.kind
    if k == "ability" or k == "abilitygroup" or k == "item" or k == "itemgroup" then
        return "idkinds"
    elseif k == "custom" then
        return "custom"
    elseif en.harmful then
        return "debuffs"
    end
    return "buffs"
end
local function UniqueCustomName(base, selfEntry)
    local taken = {}
    local selfBucket = SectionBucketOf(selfEntry)
    if selfBucket == "buffs" then
        for k in pairs(BUILTIN_NAMES) do taken[k] = true end
    end
    local list = HET.db.profile.customTrackers
    if type(list) == "table" then
        for j = 1, #list do
            if list[j] ~= selfEntry and list[j].name
                and SectionBucketOf(list[j]) == selfBucket then
                taken[list[j].name:lower()] = true
            end
        end
    end
    if not taken[base:lower()] then return base end
    local n = 2
    while taken[(base .. " " .. n):lower()] do n = n + 1 end
    return base .. " " .. n
end
-- Published for Transmission.lua: an imported tracker whose name clashes with
-- an existing one needs exactly this treatment, and it is already built for it.
ns.UniqueCustomName = UniqueCustomName

-- First unused integer id in a customTrackers list. ONE implementation, shared
-- by AddCustom, DuplicateCustom and import — three places that must agree
-- about what "the next id" means.
function ns.AllocateCustomID(list)
    local used = {}
    if type(list) == "table" then
        for j = 1, #list do
            if list[j] and list[j].id then used[list[j].id] = true end
        end
    end
    local id = 1
    while used[id] do id = id + 1 end
    return id
end

-- REUSE LATCH for the combat-edge re-feed. See the combat watcher at the bottom
-- of this file: entering or leaving combat has to re-feed the panel so every
-- control picks up the root `disabled = InCombatLockdown` lock, but nothing in
-- the options DATA can have changed (the lock itself prevents edits in combat),
-- so that one feed is served from the previously built table instead of
-- constructing ~400 nodes and ~1000 closures per tracker at the pull.
--
-- Cleared by every path that CAN invalidate the table -- Notify, NotifyThrottled
-- and a real panel open -- so a latch left set by a refresh that never ran
-- (panel closed first) can never serve a stale table to a later feed.
local lastBuiltOptions
local function ClearOptionsReuse()
    ns._reuseOptions = nil
end
ns.ClearOptionsReuse = ClearOptionsReuse

local function Notify()
    ClearOptionsReuse()
    ACR:NotifyChange(addonName)
end

-- Run BuildOptions purely for its side effect of priming ns._firstTreeNode —
-- the node the FIRST panel open selects.
--
-- NOT called at ADDON_LOADED any more. A full build is roughly 400 option-table
-- nodes and 1000 closures per tracker, and paying that on the loading screen to
-- seed one string was the addon's single largest load-time cost for a panel
-- most sessions never open. hetOpen now builds at the point of USE instead,
-- guarded on ns._optionsBuilt / the Spec Filter having moved. Kept as a public
-- entry point for anything that wants to force the node tables fresh.
function ns.PrimeOptionsNodes()
    pcall(ns._BuildOptions)
end

-- Called by Core after a spec change or a login has re-snapped ns.specFilter,
-- and by the Spec Filter dropdown itself. Drops previews that just fell out of
-- scope and re-renders the panel so the tree and the dropdown label agree with
-- the filter actually in force. Notify only when the panel is OPEN: on a closed
-- panel it would rebuild the whole options table for nothing — the node tables
-- it would refresh are only read when the panel opens, and hetOpen re-derives
-- them there if the filter has moved since the last build.
function ns.OnSpecFilterChanged()
    -- The user just told us what they want to see, so nothing keeps its
    -- work-in-progress exemption. BEFORE HidePreviewsOutOfSpecScope, so a
    -- previously-exempt tracker's previews are dropped in the same pass.
    ns.ClearSpecFilterExempt()
    HET:HidePreviewsOutOfSpecScope()
    if ACD.OpenFrames and ACD.OpenFrames[addonName] then
        Notify()
    end
end

-- Recursive deep-copy for duplicating a tracker's nested DB tables
-- (elements, positions, color arrays).
local function DeepCopy(v)
    if type(v) ~= "table" then return v end
    local t = {}
    for k, val in pairs(v) do t[k] = DeepCopy(val) end
    return t
end

-------------------------------------------------------------------------------
-- Reusable red X button.
--
-- Attaches (or re-points) one X to `parent`, cached on the parent under
-- `key` so repeated calls reuse the same frame — the AceGUI widgets these
-- ride on are POOLED and restyled constantly, so this must be cheap and
-- idempotent. Pass spec = nil to hide the X without destroying it.
--
--   spec = {
--     point, x, y   -- anchor on the parent (default TOPRIGHT, -4, -4)
--     size          -- default 16
--     level         -- frame levels ABOVE the parent (default 2)
--     tooltip       -- tooltip text
--     anchor        -- tooltip anchor (default ANCHOR_RIGHT)
--     confirm       -- StaticPopup name; omit to fire onClick directly
--     confirmArg    -- %s substituted into the popup text
--     onClick       -- the actual removal
--   }
--
-- Options UI only, so no combat-path cost.
-------------------------------------------------------------------------------
local X_TEXTURE = "Interface\\RaidFrame\\ReadyCheck-NotReady"
local function CornerX(parent, spec, key)
    if not parent then return end
    key = key or "_hetX"
    local x = parent[key]
    if not spec then
        if x then
            x:Hide()
            x._hetOnClick = nil
        end
        return x
    end
    if not x then
        x = CreateFrame("Button", nil, parent)
        x:SetNormalTexture(X_TEXTURE)
        x:SetHighlightTexture(X_TEXTURE, "ADD")
        x:SetScript("OnClick", function(f)
            -- Combat lock: raw decoration buttons aren't covered by the
            -- AceConfig root `disabled`, so guard here (covers every
            -- remove-tracker / delete-custom X — all go through CornerX).
            if InCombatLockdown() then return end
            if f._hetOnClick then f._hetOnClick() end
        end)
        x:SetScript("OnEnter", function(f)
            GameTooltip:SetOwner(f, f._hetAnchor or "ANCHOR_RIGHT")
            GameTooltip:SetText(f._hetTip or "Remove", 1, 1, 1)
            GameTooltip:Show()
        end)
        x:SetScript("OnLeave", function() GameTooltip:Hide() end)
        parent[key] = x
    end
    local size = spec.size or 16
    x:SetSize(size, size)
    x:ClearAllPoints()
    x:SetPoint(spec.point or "TOPRIGHT", spec.x or -4, spec.y or -4)
    x:SetFrameLevel(parent:GetFrameLevel() + (spec.level or 2))
    x._hetTip = spec.tooltip
    x._hetAnchor = spec.anchor
    local fn, confirm, carg = spec.onClick, spec.confirm, spec.confirmArg
    x._hetOnClick = function()
        if confirm then
            local dialog = StaticPopup_Show(confirm, carg)
            if dialog then dialog.data = { fn = fn } end
        elseif fn then
            fn()
        end
    end
    x:Show()
    return x
end
ns.CornerX = CornerX

-- Color pickers fire their setter on EVERY tick while the color wheel
-- is dragged; a full RefreshTracking per tick (global restyle + engine
-- sound re-registration) stutters the whole client. Debounce to a
-- trailing 0.1s edge — the db value is already written before each
-- call, so the last scheduled run always applies the final color.
-- (this addon's refresh is global by design, hence the debounce.)
--
-- Optional `previewKey`: a tracker cfgKey whose element PREVIEW should be
-- re-rendered on the SAME trailing edge. RefreshPreviewFor -> RefreshPreview
-- opens with an unconditional ApplyStyle() over all MAX_ELEMENTS, so leaving it
-- outside the debounce (as it was) meant half this helper still ran at the
-- slider's native 30-60 Hz. Keys accumulate in a SET, not a single slot, so two
-- different trackers' setters landing in one window both get their preview.
-- Order is preserved and matters: RefreshTracking first, previews after — the
-- preview art must be rendered on top of the freshly applied style.
-- SCOPING. When every setter that landed in one debounce window named the SAME
-- tracker -- which is the case for an entire slider or colour drag -- the
-- refresh is scoped to that tracker via HET:RefreshTrackerFor. That skips the
-- redraw of every OTHER tracker while still running all of RefreshAll's global
-- passes (watch rebuilds, aura sounds); see the comment there for why that
-- split is safe. Two different trackers touched in one window, or any setter
-- that did not name one, falls back to the full RefreshTracking.
local refreshQueued = false
local pendingPreview = {}
local scopeKey, scopeAmbiguous = nil, false
local function RefreshTrackingThrottled(previewKey)
    if previewKey then
        pendingPreview[previewKey] = true
        if scopeKey == nil then
            scopeKey = previewKey
        elseif scopeKey ~= previewKey then
            scopeAmbiguous = true
        end
    else
        -- A setter with no tracker key: it may have changed anything.
        scopeAmbiguous = true
    end
    if refreshQueued then return end
    refreshQueued = true
    C_Timer.After(0.1, function()
        refreshQueued = false
        local key = (not scopeAmbiguous) and scopeKey or nil
        scopeKey, scopeAmbiguous = nil, false
        if key then
            HET:RefreshTrackerFor(key)
        else
            HET:RefreshTracking()
        end
        for k in pairs(pendingPreview) do
            HET:RefreshPreviewFor(k)
        end
        wipe(pendingPreview)
    end)
end

-- DEBOUNCED NotifyChange, for drag controls that have to repaint a DIFFERENT
-- widget — currently only the Texture style's preview swatch, which reads
-- color / rotation / blend / desaturate / size off the element rather than from
-- its own value. Most controls need no NotifyChange at all; AceGUI already
-- shows the value the user just set.
--
-- Debounced, NOT periodic, for two reasons. ACR:NotifyChange on an open panel
-- makes ACD REBUILD the group, which (a) is far too expensive to run on every
-- drag tick and (b) destroys and recreates the very slider being dragged, which
-- can drop the drag mid-gesture. Firing only once motion has stopped for
-- DEBOUNCE seconds avoids both: nothing rebuilds while the user is moving, and
-- the swatch snaps the moment they pause or release.
--
-- One closure per drag burst, not per tick: the poller re-arms itself while
-- motion continues instead of scheduling a timer per setter call.
-- Longest edge of the Texture style's preview swatch, in pixels.
local TEX_PREVIEW_MAX = 96

local NOTIFY_DEBOUNCE = 0.2
local notifyPending, notifyLastTouch = false, 0
local function NotifyThrottled()
    notifyLastTouch = GetTime()
    if notifyPending then return end
    notifyPending = true
    local function tick()
        if GetTime() - notifyLastTouch < NOTIFY_DEBOUNCE then
            C_Timer.After(0.05, tick)   -- still moving; wait for the lull
            return
        end
        notifyPending = false
        ClearOptionsReuse()
        ACR:NotifyChange(addonName)
    end
    C_Timer.After(NOTIFY_DEBOUNCE, tick)
end

-- The tracker moved underneath the options panel -- re-feed so the X/Y Position
-- sliders show where it actually is.
--
-- Those sliders read the element's offset at FEED time, and dragging the
-- preview writes the new position straight to the profile without going near
-- AceConfig, so nothing would otherwise tell the panel its numbers are stale.
-- Called from the preview's OnDragStop (Tracker.lua) and from the three
-- Position buttons, which move the tracker the same way.
--
-- Debounced, and only while the panel is open: a rebuild is pointless for a
-- closed panel, and dropping several elements in quick succession should cost
-- one refresh rather than one each. Safe to rebuild here even though a drag
-- just ended -- what was being dragged is a world frame, not an AceGUI widget,
-- so nothing in the panel can lose its gesture.
function ns.NotifyPositionChanged()
    if ACD.OpenFrames and ACD.OpenFrames[addonName] then
        NotifyThrottled()
    end
end

-- Node key and stored-key prefix per built-in tracker. Custom trackers
-- ("customN") use their cfgKey as node key and their config's dbPrefix.
local SPELL_PREFIX = {
    spatial = "", innervate = "inn_", powerinfusion = "pi_",
    extdef = "ed_", persdef = "pd_",
}
local SPELL_NODE = {
    spatial = "spatialParadox",
    innervate = "innervate",
    powerinfusion = "powerInfusion",
    extdef = "externalDefensives",
    persdef = "personalDefensives",
}

-- Does this tracker have bundled voice clips? (Customs never; spellList
-- groups only when at least one listed spell has one.)
local function HasVoice(cfgKey)
    local cfg = ns.trackerConfigs[cfgKey]
    if cfg.custom then return false end
    if cfg.spellList then return (cfg.hasVoiceClips or cfg.voiceOptions) and true or false end
    return true
end
local function PrefixFor(cfgKey)
    return SPELL_PREFIX[cfgKey]
        or (ns.trackerConfigs[cfgKey] and ns.trackerConfigs[cfgKey].dbPrefix)
        or cfgKey
end
local function NodeFor(cfgKey)
    return SPELL_NODE[cfgKey] or cfgKey
end
-- Which KIND bucket a tracker belongs to. FIVE answers, matching the By Type
-- view's five nodes: "buffs" / "debuffs" / "abilities" / "items" / "other".
--
-- Abilities and Items are separate here even though they share almost all of
-- their behaviour — the view splits them, so the classifier has to.
--
-- ONE classifier for the whole addon, so nothing can disagree about what a
-- tracker is: By Type buckets on it, and the tree row eyes take their tint
-- from it. (Core's ns.SubtabForTracker answers the same question from a live
-- tracker object rather than a cfgKey.)
local function SpellTab(cfgKey)
    if IsItemKindCfg(cfgKey) then return "items" end
    if IsAbilityKindCfg(cfgKey) then return "abilities" end
    if IsCustomKindCfg(cfgKey) then return "other" end
    return IsDebuffCfg(cfgKey) and "debuffs" or "buffs"
end
ns.TypeKeyFor = SpellTab
ns.NodeForCFG = NodeFor

-- ONE navigation entry point for every jump to a tracker node. The path is two
-- segments — folder, then node — and a helper that owns the whole call is what
-- lets the trailing "trackers", "el3" arguments compose, which a NavPath()
-- returning varargs could not (Lua only flattens multiple returns in the FINAL
-- argument position).
local function SelectTracker(cfgKey, ...)
    local node = ns.TreeNodeFor and ns.TreeNodeFor(cfgKey)
    if not node then return end
    ACD:SelectGroup(addonName, node, NodeFor(cfgKey), ...)
end
-- Published so Transmission.lua can land an import selected without
-- duplicating the path logic.
ns.SelectTracker = SelectTracker

local function GetEls(prefix)
    return HET.db.profile[prefix .. "elements"] or {}
end

-- GetEls for the paths that WRITE. The `or {}` above is right for readers — a
-- tracker with no stored element list has no elements — but it hands a writer a
-- throwaway table, so `els[#els + 1] = newEl` succeeds, changes nothing, and
-- reports no error. That is exactly how a copy whose "<prefix>elements" key
-- went missing presented: the Add Tracker page took a type and did nothing,
-- for every type, with no message. Materialise instead, so the write lands.
local function EnsureEls(prefix)
    local p = HET.db.profile
    local els = p[prefix .. "elements"]
    if type(els) ~= "table" then
        els = {}
        p[prefix .. "elements"] = els
    end
    return els
end

-- A "tracker" is an element with a style set. True when a spell has NONE
-- configured (empty list, or every entry style-less) — used to flag enabled
-- spells that won't show anything with a warning glyph in the tree.
local function SpellHasNoTrackers(prefix)
    local els = GetEls(prefix)
    for j = 1, #els do
        if els[j].style then return false end
    end
    return true
end

-- "Nothing is checked" for the three checkbox conditions. Storage convention:
-- an entry is checked ONLY when explicitly true, so an ABSENT table means
-- nothing is checked at all.
--
-- This IS hot -- reached per visible tree row on every tree redraw, including
-- scroll, via RoleSpecNeverMatches -- but the list walk stays. A `pairs(t)`
-- walk over what is stored was tried and reverted: unticking writes `false`
-- rather than deleting, and Select All / Deselect All / Select Current Spec
-- each write all 40 spec ids at once, so the stored table is routinely
-- full-length and the walk is the same length either way. On stock Lua 5.1 a
-- generic for pays a C call to `next` per iteration, against one array read
-- here, so it would more likely have been slower.
-- The real fix is to stop calling this per row per redraw at all (memoize the
-- warning per prefix, invalidated on rebuild and profile swap), not to shave
-- the scan.
local function NoneChecked(p, prefix, key, list, field)
    local t = p[prefix .. key]
    if t == nil then return true end
    for i = 1, #list do
        if t[list[i][field]] == true then return false end
    end
    return true
end
local function NoSpecsChecked(p, prefix)
    return NoneChecked(p, prefix, "loadSpecTypes", SPEC_TYPES, "id")
end
local function NoRolesChecked(p, prefix)
    return NoneChecked(p, prefix, "loadRoleTypes", ROLE_TYPES, "key")
end
local function NoInstancesChecked(p, prefix)
    return NoneChecked(p, prefix, "loadInstanceTypes", INSTANCE_TYPES, "key")
end
-- True when the Role/Spec condition can never be satisfied by ANY spec, i.e.
-- the tracker will never load no matter what you play or where you go. Drives
-- the "!" warning in the tree, so it has to mirror LoadConditionsMet exactly:
--
--   * Instance Type is AND-ed with everything else, so an empty instance list
--     kills the tracker on its own.
--   * Role and Spec are OR-ed with EACH OTHER, so with both switched on either
--     one passing is enough — both have to be empty before those two are dead.
--     Warning on an empty spec list while the role list still has something
--     checked would be a false alarm.
-- Just the Role/Spec half: the conditions are switched ON but nothing is
-- ticked, so they match NO spec at all. That is an INCOMPLETE config, not a
-- deliberate scope — you get it the moment you click Spec to start setting one
-- up — so the Spec Filter keeps these visible under every selection. Without
-- that, clicking Spec would hide the tracker you are configuring.
local function RoleSpecIncomplete(prefix)
    local p = HET.db.profile
    local roleOn = p[prefix .. "loadRole"] and true or false
    local specOn = p[prefix .. "loadSpec"] and true or false
    if roleOn and specOn then
        return NoRolesChecked(p, prefix) and NoSpecsChecked(p, prefix)
    end
    if specOn then return NoSpecsChecked(p, prefix) end
    if roleOn then return NoRolesChecked(p, prefix) end
    return false
end

-- MEMOIZED, because the tree-row decorator asks this per VISIBLE ROW on every
-- tree redraw -- expand, collapse, resize and every scroll tick -- and the
-- uncached answer is up to three 40-entry list scans plus six key concats. A
-- scroll through a large profile was re-deriving the same answers thousands of
-- times for data that had not moved.
--
-- Invalidation. The six keys it reads (loadRole/loadSpec/loadInstance and their
-- three type tables) change through exactly two doors:
--   * an options setter -- and every one of those ends in a rebuild, so
--     wiping at the top of BuildOptions covers all of them at once;
--   * a profile swap, which replaces all six wholesale WITHOUT a rebuild
--     (OnProfileSwapped only runs a refresh), hence the exported wipe below.
-- Nothing else writes them. The cache therefore never outlives the data.
local condWarnMemo = {}
local function WipeCondWarnCache() wipe(condWarnMemo) end
ns.WipeCondWarnCache = WipeCondWarnCache

local function RoleSpecNeverMatches(prefix)
    local hit = condWarnMemo[prefix]
    if hit ~= nil then return hit end
    local p = HET.db.profile
    local out
    if p[prefix .. "loadInstance"] and NoInstancesChecked(p, prefix) then
        out = true
    else
        out = RoleSpecIncomplete(prefix)
    end
    condWarnMemo[prefix] = out
    return out
end

-- Keep/drop for ONE tracker under the Spec Filter. THE predicate for the whole
-- feature: the tracker list uses it here, and Core's preview code uses it
-- through the ns. alias below. They must be the same function. When previews
-- used the bare ns.MatchesSpecFilter instead, the RoleSpecIncomplete leg had no
-- counterpart on the preview side, so a tracker with Spec ticked and nothing
-- under it stayed in the list but could not be previewed by the tab eye -- and
-- a preview turned on by its own row eye got wiped the next time the dropdown
-- moved, with no way to bring it back.
--
-- Called once per tracker per options build, AND once per tracker per eye
-- repaint through Core (AreAllPreviewsActive re-runs on every feed, once per
-- subtab), so both early exits below earn their keep.
--
--   * "All Specs" -- provably true in every branch, so answer it with the one
--     table read ns.MatchesSpecFilter used to do. Without this, picking "All
--     Specs" (the cheapest possible filter) became the most expensive one.
--   * Neither toggle on -- the overwhelmingly common tracker. Both answers are
--     already settled by those two reads: not incomplete, and global, so it
--     matches every spec.
--
-- Anything else falls through to the original two functions rather than
-- re-deriving their logic inline: the filter has to agree with the "!" warning
-- and with what actually loads in game, and a second copy of those rules is
-- exactly how that drifts.
-- WORK-IN-PROGRESS EXEMPTION.
--
-- A tracker you are in the middle of setting up is exempt from the Spec
-- Filter. Without this, giving a tracker a spec condition that excludes the
-- spec you are filtering by makes its row vanish mid-edit — which is the
-- filter working correctly and looks exactly like a bug, and is worst right
-- after ns.ApplyAutoSpec fills the condition in for you the moment you type a
-- spell name.
--
-- SESSION-LOCAL and short-lived by design. It is cleared the moment the user
-- does anything that means "show me the list as filtered": switch View, change
-- the Filter By Spec dropdown (or change spec, which re-snaps it), or close the
-- panel. So the exemption never outlives the editing session that earned it,
-- and the filter is never quietly wrong.
--
-------------------------------------------------------------------------------
-- TRACKER SEARCH
--
-- Session-only, exactly like ns.specFilter: a search is a way of finding
-- something right now, not a setting, and one saved across a reload would hide
-- most of the panel with no obvious cause.
--
-- Matches a tracker on any of:
--   * its NAME (the user-given one, and the built-in label)
--   * its KIND        -- buff, debuff, ability, item, custom, group
--   * its ELEMENT STYLES -- icon, bar, circle, text, texture, sound
--   * its SPEC / ROLE conditions -- spec name, class name, tank/healer/damage
--
-- All of that is flattened into one lowercase haystack per tracker and matched
-- with a plain substring find, so "fire" hits both a tracker called "Fire Blast"
-- and every tracker scoped to the Fire spec, and "bar" hits every tracker with
-- a Bar element. Multiple words are ANDed, so "mage bar" narrows properly.
-------------------------------------------------------------------------------
-- All of this lives in a do-block and is reached through ns rather than
-- file-scope locals: Lua 5.1 allows only 200 locals per function and this
-- file's main chunk is at the ceiling. Build-time only, so the extra hash
-- lookup is irrelevant.
do
local KIND_WORDS = {
    group        = "group",
    custom       = "custom",
    ability      = "ability spell",
    abilitygroup = "ability spell group",
    item         = "item",
    itemgroup    = "item group",
}

-- Cached per prefix and dropped whenever anything it reads can have changed.
-- BuildOptions is no longer on a hot path, but the haystack walks the element
-- list and up to 40 spec ids per tracker, and ACD can feed several times for
-- one user action.
local searchHay, searchHayGen = {}, nil

-- Strip WoW markup before lowering. Curated names can carry class colouring
-- (the per-class Time Spiral rows) and the tree embeds spell icons, and a raw
-- "|cff33937fevoker|r" in the haystack would let "cff33937" match a tracker.
local function PlainLower(str)
    if type(str) ~= "string" or str == "" then return "" end
    str = str:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
    str = str:gsub("|T.-|t", "")
    return str:lower()
end

-- Display name for one tracked id, preferring the curated override so a search
-- finds what the list actually shows (see Flatten in SpellData_Buffs).
local function TrackedIDName(id, isItem)
    local kinds = ns.SpellData and ns.SpellData.KINDS
    if kinds then
        for _, k in pairs(kinds) do
            local e = k.BYID and k.BYID[id]
            if e and e.name then return e.name end
        end
    end
    if isItem then
        return (C_Item and C_Item.GetItemNameByID
            and C_Item.GetItemNameByID(id)) or nil
    end
    return C_Spell.GetSpellName and C_Spell.GetSpellName(id) or nil
end

local function TrackerHaystack(prefix, def)
    if searchHayGen ~= ns.customEntryGen then
        searchHay = {}
        searchHayGen = ns.customEntryGen
    end
    local hit = searchHay[prefix]
    if hit then return hit end

    local p = HET.db.profile
    local parts = { (def.name or ""):lower() }

    -- Kind. Built-ins are auras; `harmful` splits buff from debuff.
    local kind
    if def.custom then
        local en = CustomEntryFor(def.cfgKey)
        kind = en and en.kind
    end
    parts[#parts + 1] = KIND_WORDS[kind or ""] or ""
    if not (def.abilityKind or def.itemKind or def.customKind) then
        parts[#parts + 1] = def.harmful and "debuff" or "buff"
    end
    if def.isGroup then parts[#parts + 1] = "group" end

    -- Element styles, by their UI labels ("Sound Alert" also matches "sound").
    local els = p[prefix .. "elements"]
    if type(els) == "table" then
        for j = 1, #els do
            local st = els[j] and els[j].style
            if st then
                parts[#parts + 1] = st
                parts[#parts + 1] = (STYLE_VALUES[st] or ""):lower()
            end
        end
    end

    -- Spec / role conditions. Only when the condition is actually ON -- a
    -- tracker with no spec condition should not answer to every spec name.
    if p[prefix .. "loadSpec"] then
        local t = p[prefix .. "loadSpecTypes"]
        if type(t) == "table" then
            for j = 1, #SPEC_TYPES do
                local sp = SPEC_TYPES[j]
                if t[sp.id] == true then
                    parts[#parts + 1] = sp.name:lower()
                    local cls = sp.class
                    local nm = cls and LOCALIZED_CLASS_NAMES_MALE
                        and LOCALIZED_CLASS_NAMES_MALE[cls]
                    parts[#parts + 1] = (nm or cls or ""):lower()
                end
            end
        end
    end
    if p[prefix .. "loadRole"] then
        local t = p[prefix .. "loadRoleTypes"]
        if type(t) == "table" then
            if t.TANK then parts[#parts + 1] = "tank" end
            if t.HEALER then parts[#parts + 1] = "healer" end
            if t.DAMAGER then parts[#parts + 1] = "damage damager dps" end
        end
    end

    -- THE SPELLS THE TRACKER ACTUALLY TRACKS. Without these the search only
    -- knew tracker names, so "time warp" missed the Bloodlust/Heroism group
    -- that contains it and "pain supp" missed External Defensives -- which is
    -- most of what anyone would type into a box like this.
    local cfg = ns.trackerConfigs and ns.trackerConfigs[def.cfgKey]
    if cfg and type(cfg.spellList) == "table" then
        -- EVERY row, not just the ticked ones. The question being asked is
        -- "which tracker handles Time Warp", and the answer is the same
        -- whether or not that row happens to be enabled right now -- finding
        -- the tracker is how you get to the tick box.
        for j = 1, #cfg.spellList do
            parts[#parts + 1] = PlainLower(cfg.spellList[j].name)
        end
    elseif cfg and type(cfg.buffSpellIDs) == "table" then
        -- Single-aura built-in: no list, just the ids it watches.
        local ids = cfg.buffSpellIDs
        for j = 1, math.min(#ids, 24) do
            parts[#parts + 1] = PlainLower(TrackedIDName(ids[j], false))
        end
    end
    -- Custom trackers (auras, abilities, items, and their groups) carry their
    -- own id list.
    local ids = p[prefix .. "ids"]
    if type(ids) == "table" then
        local kinds = p[prefix .. "idKinds"]
        for j = 1, math.min(#ids, 24) do
            local id = tonumber(ids[j])
            if id and id > 0 then
                -- Mixed groups tag item-ness per id; fall back to the
                -- tracker's own kind for untagged ones.
                local tag = kinds and kinds[id]
                local isItem = (tag == "item")
                    or (tag == nil and def.itemKind and true or false)
                parts[#parts + 1] = PlainLower(TrackedIDName(id, isItem))
            end
        end
    end

    hit = " " .. table.concat(parts, " ") .. " "
    searchHay[prefix] = hit
    return hit
end

-- Every whitespace-separated term must appear somewhere in the haystack.
function ns.MatchesTrackerSearch(prefix, def)
    local q = ns.trackerSearch
    if not q or q == "" then return true end
    local hay = TrackerHaystack(prefix, def)
    for term in q:gmatch("%S+") do
        -- Anchored to a WORD START (every token in the haystack is
        -- space-prefixed). Plain substring made "buff" match "debuff", which is
        -- exactly backwards for the one search a user is most likely to type.
        -- It also gives prefix matching for free: "prot" finds "Protection".
        if not hay:find(" " .. term, 1, true) then return false end
    end
    return true
end

-- The element list and the condition tables are edited without touching
-- customTrackers, so the generation counter alone would serve a stale haystack
-- after a style or spec-condition change. Every such setter ends in a rebuild,
-- and a rebuild is exactly when the haystack is re-read -- so drop it there.
function ns.WipeSearchCache()
    if next(searchHay) ~= nil then searchHay = {} end
end
end

-- Keyed by dbPrefix so it sits INSIDE PassesSpecFilter, which is the single
-- choke point the tree build, the preview scoping (Core's
-- HidePreviewsOutOfSpecScope) and the auto-spec notice all go through. Anywhere
-- else and those three would disagree about whether a tracker is in scope.
local specExempt = {}

function ns.ExemptFromSpecFilter(prefix)
    if type(prefix) == "string" then specExempt[prefix] = true end
end

function ns.ClearSpecFilterExempt()
    if next(specExempt) ~= nil then wipe(specExempt) end
end

local function PassesSpecFilter(prefix)
    if ns.specFilter == nil then return true end
    if specExempt[prefix] then return true end
    local p = HET.db.profile
    if not (p[prefix .. "loadRole"] or p[prefix .. "loadSpec"]) then
        return true
    end
    return RoleSpecIncomplete(prefix) or ns.MatchesSpecFilter(prefix)
end
ns.PassesSpecFilter = PassesSpecFilter

-- Any bar-type tracker on this spell? Gates the per-buff color pickers
-- in the Tracked Buffs list (Ordered bars color per buff).
local function HasBarTracker(prefix)
    local els = GetEls(prefix)
    for j = 1, #els do
        if els[j].style == "bar" then return true end
    end
    return false
end

-- Reorderable Tracked Buffs lists. spellList groups store their display
-- order in profile[prefix.."buffOrder"] (array of entry keys; entries
-- missing from it append in default order — new spells tagged in
-- SpellData land at the bottom). Custom groups reorder their ids array
-- directly.
local UP_TEX = "Interface\\ChatFrame\\UI-ChatIcon-ScrollUp-Up"
local DOWN_TEX = "Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Up"
-- Main-tree preview eye icons (custom Media; see TreeEye in the
-- widget-decoration section). PREVIEW_MASK_TEX = white eye, tinted per tab
-- and shown while a preview is ACTIVE; PREVIEW_HIDE_TEX = red eye, shown
-- while hidden.
-- No .tga extension (WoW infers it).
-- PREVIEW_MASK_TEX is the same eye art with its RGB flattened to white, so
-- it can be vertex-coloured per tab (see PREVIEW_TINT / TreeEye). The red
-- hidden eye keeps its baked-in colour.
local PREVIEW_MASK_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\previewmask"
local PREVIEW_HIDE_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\previewhide"
-- Active-preview eye colour per main tab: blue = buffs (the art's original
-- colour), orange = debuffs, purple = abilities/items. Anything else (the
-- "Other" tab) falls back to blue.
-- Row-eye tint per KIND bucket (ns.TypeKeyFor). The colour language predates
-- the views and outlives them: a buff row's eye is blue and a debuff row's
-- orange wherever the row happens to be drawn. Abilities and Items share the
-- purple the merged Abilities/Items tab used to carry; Other reuses blue.
local PREVIEW_TINT = {
    buffs     = { 0.063, 0.463, 0.816 },
    debuffs   = { 1.000, 0.549, 0.078 },
    abilities = { 0.667, 0.353, 1.000 },
    items     = { 0.667, 0.353, 1.000 },
}
local PREVIEW_TINT_DEFAULT = PREVIEW_TINT.buffs
local function OrderedSpellList(cfgKey, prefix)
    local list = ns.trackerConfigs[cfgKey].spellList
    local order = HET.db.profile[prefix .. "buffOrder"]
    if type(order) ~= "table" then return list end
    local byKey, out, seen = {}, {}, {}
    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
            out[#out + 1] = sp
            seen[k] = true
        end
    end
    for _, sp in ipairs(list) do
        if not seen[sp.key] then out[#out + 1] = sp end
    end
    return out
end

local function MoveOrderedKey(cfgKey, prefix, idx, delta)
    local display = OrderedSpellList(cfgKey, prefix)
    local o = {}
    for i = 1, #display do o[i] = display[i].key end
    local j = idx + delta
    if not (o[idx] and o[j]) then return end
    o[idx], o[j] = o[j], o[idx]
    HET.db.profile[prefix .. "buffOrder"] = o
    -- Order drives the live fan-out (per-spell group layoutIndex).
    HET:RefreshTracking()
    Notify()
end

local function MoveCustomID(prefix, idx, delta)
    local ids = HET.db.profile[prefix .. "ids"]
    if type(ids) ~= "table" then return end
    local j = idx + delta
    if not (ids[idx] and ids[j]) then return end
    ids[idx], ids[j] = ids[j], ids[idx]
    -- Order drives the live fan-out (per-spell group layoutIndex).
    HET:RefreshTracking()
    Notify()
end

-- Overlay-capable pair styles: only opaque styles can obscure the cooldown
-- art underneath — circle (transparent center) can't, and TEXT pairs are a
-- separate standalone kind (aura duration text, no parent element).
-- Declared here because DoRemoveTracker's cascade below reads it; the rest
-- of the pair helpers live after NewElementDefaults.
local PAIRABLE_STYLES = { icon = true, bar = true }

-- Tracker removal, shared by the tree-row X buttons. Pins selection off
-- the removed key so the re-render fallback can't land on the "+" entry.
local NODE_TO_CFG = {}
for cfg, node in pairs(SPELL_NODE) do NODE_TO_CFG[node] = cfg end
-- Tree node key -> cfgKey. Published for CustomView.lua's drag resolution,
-- which only ever has a uniquevalue to work from.
function ns.CFGForNode(node) return NODE_TO_CFG[node] or node end
local function DoRemoveTracker(cfgKey, i)
    local prefix = PrefixFor(cfgKey)
    local els = GetEls(prefix)
    if not els[i] then return end
    local removed = els[i]
    table.remove(els, i)
    -- Compound parent removal cascades to its "(Active)" overlay pair
    -- (identified by style — see the pair helpers after NewElementDefaults).
    -- Only icon/bar overlays cascade: removing a regular Text element must
    -- NOT delete the standalone Text (Active).
    if not removed.pairFor and removed.style and PAIRABLE_STYLES[removed.style] then
        for j = #els, 1, -1 do
            if els[j].pairFor == removed.style then table.remove(els, j) end
        end
    end
    local t = ns.trackers[cfgKey]
    if t then t:RestoreAllPositions() end
    HET:RefreshTracking()
    HET:RefreshPreviewFor(cfgKey)
    Notify()
    if #GetEls(prefix) > 0 then
        SelectTracker(cfgKey, "trackers", "el1")
    else
        SelectTracker(cfgKey, "trackers")
    end
end

-- Custom-tracker duplication (the per-tracker "Duplicate" button). Copies
-- the customTrackers entry and every stored "cuN_" key into a fresh identity
-- slot, but blanks the tracked Spell/Item ID and resets the name to the
-- creation default so the copy starts unassigned (its Label field stays
-- hidden until a Spell ID is set — same rule as a new tracker).
local function DuplicateCustom(cfgKey)
    local id = tonumber(cfgKey and cfgKey:match("^custom(%d+)$"))
    if not id then return end
    local p = HET.db.profile
    local list = p.customTrackers
    if type(list) ~= "table" then return end
    local src
    for j = 1, #list do
        if list[j].id == id then src = list[j]; break end
    end
    if not src then return end
    -- Allocate a new identity slot (first unused integer, as in AddCustom).
    local newId = ns.AllocateCustomID(list)
    -- Copy the entry (scalar fields) into the new slot with a fresh default
    -- name for its kind, so the list row reads sensibly and the copy does not
    -- carry the source spell's auto-name.
    local newEntry = DeepCopy(src)
    newEntry.id = newId
    -- DeepCopy carried the SOURCE'S uid across. Overwrite it, or the copy IS
    -- the source to every uid-keyed system: it would inherit the original's
    -- folder membership and collide with it on export/import. Same class of
    -- trap as the recycled integer id, and just as quiet.
    newEntry.uid = ns.GenerateUID()
    local k = src.kind
    if k == "custom" then newEntry.name = "New Custom"
    elseif k == "ability" then newEntry.name = "New Ability"
    elseif k == "abilitygroup" then newEntry.name = "New Ability/Item Group"
    elseif k == "item" then newEntry.name = "New Item"
    elseif k == "itemgroup" then newEntry.name = "New Item Group"
    elseif src.harmful then newEntry.name = (k == "group") and "New Debuff Group" or "New Debuff"
    else newEntry.name = (k == "group") and "New Buff Group" or "New Buff" end
    newEntry.autoName = (k == "aura" or k == "ability" or k == "item")
    list[#list + 1] = newEntry
    -- newId comes from the same recycled-id allocator as AddCustom.
    ns.BumpCustomEntryGen()
    -- Custom view: drop the copy immediately after the original in whichever
    -- folder holds it, so it lands next to what it was copied from rather
    -- than at the bottom of Ungrouped.
    if ns.InsertAfterInFolder then ns.InsertAfterInFolder(src.uid, newEntry.uid) end
    -- Deep-copy every stored key of the source identity into the new prefix.
    --
    -- COLLECTED FIRST, WRITTEN SECOND, and it has to be that way round. The
    -- obvious single loop writes p[newPref..] while iterating pairs(p), and
    -- adding keys to a table during a pairs() traversal is undefined in Lua:
    -- `next` may skip entries, repeat them, or error outright. Skipping is the
    -- one that actually bit — a copy that silently lost its "<prefix>elements"
    -- key, so the new tracker had no element list, its Add Tracker page
    -- accepted a type and did nothing with it (GetEls hands back a throwaway
    -- table when the key is absent), and deleting the copy and copying again
    -- appeared to "fix" it because the traversal happened to land differently.
    local srcPref = "cu" .. id .. "_"
    local newPref = "cu" .. newId .. "_"
    local srcKeys = {}
    for key in pairs(p) do
        if type(key) == "string" and key:sub(1, #srcPref) == srcPref then
            srcKeys[#srcKeys + 1] = key
        end
    end
    for j = 1, #srcKeys do
        local key = srcKeys[j]
        p[newPref .. key:sub(#srcPref + 1)] = DeepCopy(p[key])
    end
    -- The element list is what every "add a tracker to this" path writes into,
    -- so it must exist even if the source somehow had none stored.
    if type(p[newPref .. "elements"]) ~= "table" then
        p[newPref .. "elements"] = {}
    end
    -- Blank the Spell ID / Name field on the copy.
    p[newPref .. "ids"] = {}
    -- The copy is about to be configured, same as a brand new tracker — and it
    -- inherited the source's conditions, so it may be out of filter scope from
    -- the moment it exists.
    ns.ExemptFromSpecFilter(newPref)
    ns.RegisterCustomConfig(newId)
    HET:RefreshTracking()
    local newKey = "custom" .. newId
    -- Preview the copy immediately, exactly as the Enable toggle does for a
    -- freshly enabled tracker: a copy is made to be repositioned, and its whole
    -- point is that it starts life on top of its source. AFTER RefreshTracking,
    -- which is what creates the tracker object; ShowPreviewFor is a no-op
    -- without one, and combat-guarded on its own (a copy made in combat gets
    -- its previews when the user next turns them on).
    --
    -- This is also what puts it IN FRONT — ShowElementPreview lifts the
    -- tracker it belongs to above the rest (methods:BringPreviewToFront), so
    -- the copy is the one the mouse finds rather than the original it is
    -- sitting exactly on top of.
    HET:ShowPreviewFor(newKey)
    Notify()
    -- Select the new node (deferred, same rationale as AddCustom).
    C_Timer.After(0, function()
        SelectTracker(newKey)
        if ACD.OpenFrames and ACD.OpenFrames[addonName] then
            ACD:Open(addonName)
        end
    end)
end

-- Custom-tracker deletion, shared by the pane Delete button and the
-- main-tree row X buttons. Works even when no tracker object exists
-- (prefix is derivable); an orphaned tracker object parks itself on
-- the next refresh once its customTrackers entry is gone.
local function DoDeleteCustom(id)
    local p = HET.db.profile
    local list = p.customTrackers or {}
    -- Captured BEFORE table.remove: folder membership is account-wide and
    -- keyed by uid, so the prune below needs the identity that is about to
    -- leave this profile.
    local wasUID
    for idx = #list, 1, -1 do
        if list[idx].id == id then
            wasUID = list[idx].uid
            table.remove(list, idx)
        end
    end
    -- MUST happen before the HidePreview below: HidePreview -> UpdateGating
    -- branches on IsCustomKind / IsIDKind, and the tracker object survives this
    -- deletion with its entry cache still pointing at the removed table.
    ns.BumpCustomEntryGen()
    -- Orphan prune, and ONLY here: deletion is rare and user-driven, so the
    -- cross-profile scan it needs costs nothing in practice. Skipping it would
    -- only leave an inert uid in a folder list (it resolves to nothing and the
    -- render filter drops it) — benign by construction, which is the whole
    -- point of uids never being recycled.
    if wasUID and ns.PruneFolderUID then ns.PruneFolderUID(wasUID) end
    -- Wipe every stored key of this identity slot.
    local pref = "cu" .. id .. "_"
    for k in pairs(p) do
        if type(k) == "string" and k:sub(1, #pref) == pref then
            p[k] = nil
        end
    end
    local tracker = ns.trackers["custom" .. id]
    if tracker then tracker:HidePreview() end
    -- Parks the now-keyless tracker (enabled reads nil) and releases its slot
    -- filters. MUST run while it is still on the iteration list.
    HET:RefreshTracking()
    -- ...and only then drop it from that list, so every later fan-out stops
    -- paying for it. The object and its frames are kept for re-adoption if the
    -- id is allocated again -- see ns.UnregisterTracker.
    --
    -- NOT while a refresh has been deferred. UpdateGating bails early and sets
    -- ns.deferredRefresh whenever ns.IsRestricted() is true -- combat, but also
    -- the secret-aura window, which is reachable out of combat -- so the park
    -- the unlist depends on may not have happened. Unlisting then would strand
    -- the tracker's engine slot filter and group registration, because the
    -- deferred re-run walks ns.trackerOrder and would no longer find it.
    -- Core's PLAYER_REGEN_ENABLED handler drains ns.pendingUnlist after its
    -- RefreshAll, which is the pass that finally parks them.
    if ns.deferredRefresh then
        ns.pendingUnlist = ns.pendingUnlist or {}
        ns.pendingUnlist["custom" .. id] = true
    elseif ns.UnregisterTracker then
        ns.UnregisterTracker("custom" .. id)
    end
    Notify()
    -- Stay in the tab the deleted tracker lived in, landing on its Addon
    -- Options node (present under both subtabs). Re-feed the frame so the
    -- pane updates in place (same as the add flow).
    --
    -- DEFERRED for the same reason as AddCustom (see the long note there).
    -- This path does not currently throw, because both callers are raw
    -- CreateFrame CornerX buttons that never enter ACD's ActivateControl --
    -- but releasing the frame that owns the button being clicked is the same
    -- anti-pattern, and it would start throwing the moment the tree X becomes
    -- an AceGUI widget. Deferred so the two flows stay identical.
    C_Timer.After(0, function()
        -- Addon Options is a single top-level node.
        ACD:SelectGroup(addonName, "addonOptions")
        if ACD.OpenFrames and ACD.OpenFrames[addonName] then
            ACD:Open(addonName)
        end
    end)
end

StaticPopupDialogs["HET_REMOVE_TRACKER"] = {
    text = "Remove %s?",
    button1 = YES,
    button2 = NO,
    OnAccept = function(self, data)
        if data and data.fn then data.fn() end
    end,
    -- The options panel sits at FULLSCREEN_DIALOG strata; StaticPopups
    -- default to DIALOG and would open BEHIND it. Raise while shown.
    OnShow = function(self)
        self._hetOldStrata = self:GetFrameStrata()
        self:SetFrameStrata("TOOLTIP")
    end,
    OnHide = function(self)
        if self._hetOldStrata then
            self:SetFrameStrata(self._hetOldStrata)
            self._hetOldStrata = nil
        end
    end,
    timeout = 0,
    whileDead = 1,
    hideOnEscape = 1,
    preferredIndex = 3,
}

StaticPopupDialogs["HET_DELETE_CUSTOM"] = {
    text = "Delete %s and all of its settings?",
    button1 = YES,
    button2 = NO,
    OnAccept = function(self, data)
        if data and data.fn then data.fn() end
    end,
    OnShow = function(self)
        self._hetOldStrata = self:GetFrameStrata()
        self:SetFrameStrata("TOOLTIP")
    end,
    OnHide = function(self)
        if self._hetOldStrata then
            self:SetFrameStrata(self._hetOldStrata)
            self._hetOldStrata = nil
        end
    end,
    timeout = 0,
    whileDead = 1,
    hideOnEscape = 1,
    preferredIndex = 3,
}

-- Kind-specific default bar/circle fill for USER-CREATED (custom) entries:
-- debuffs red, buffs blue, abilities/items purple, all at 70% alpha.
-- Predefined spell-list entries keep their authored defaults.barCircleColor,
-- and "custom"-kind (no aura) entries keep the neutral white cfg default.
-- Per-id colors in a GROUP need no seeding: an unset buffColors entry falls
-- back to the bar element's own color (both in the per-id picker's get and
-- in the Tracker fill path), so they inherit these defaults automatically.
local KIND_BAR_COLOR_DEBUFF = { 1.000000, 0.172549, 0.062745, 0.7 } -- #FF2C10 @ 70%
local KIND_BAR_COLOR_BUFF   = { 0.196078, 0.623529, 1.000000, 0.7 } -- #329FFF @ 70%
local KIND_BAR_COLOR_ID     = { 0.588235, 0.286275, 1.000000, 0.7 } -- #9649FF @ 70%
local function DefaultElementColor(cfgKey)
    local cfg = ns.trackerConfigs[cfgKey]
    if cfg.custom then
        local e = CustomEntryFor(cfgKey)
        local kind = e and e.kind
        if kind == "aura" or kind == "group" then
            return e.harmful and KIND_BAR_COLOR_DEBUFF or KIND_BAR_COLOR_BUFF
        elseif kind == "ability" or kind == "item"
            or kind == "abilitygroup" or kind == "itemgroup" then
            return KIND_BAR_COLOR_ID
        end
        -- kind == "custom" (or unknown): fall through to the cfg default.
    end
    return cfg.defaults.barCircleColor or { 1, 1, 1, 1 }
end

-- Fresh element config for "Add Tracker": full field set so style
-- switches never hit nil options; style itself starts unselected — the
-- style-specific options (and the live slot) activate once it's chosen.
local function NewElementDefaults(cfgKey)
    local defColor = DefaultElementColor(cfgKey)
    return {
        style          = nil,
        trackedProperty = "duration",
        maxStacks      = 10,
        showTooltip    = false,
        iconSize       = 40,
        -- Icon source override (aura Icon trackers): "auto" uses the aura's
        -- own icon (engine-bound); "spell"/"icon" replace the TEXTURE only
        -- (the aura still gates visibility, cooldown, duration/stacks).
        auraIconMode   = "auto",
        auraIconInput  = nil,
        cooldownSwipe  = true,
        iconShowDurText = true,
        iconDurFontSize = 14,
        iconDurTextPosition = "center",
        -- None of the predefined buffs/groups track a stacking aura, so a
        -- fresh icon/bar tracker on them omits the stack text by default.
        -- Custom trackers keep it on (a user-chosen aura may stack).
        iconShowStackText = ns.trackerConfigs[cfgKey].custom and true or false,
        iconStackFontSize = 12,
        iconStackTextPosition = "bottomright",
        barShowStackText = false,
        barStackFontSize = 12,
        barStackTextPosition = "right",
        borderType     = "rounded",
        borderThickness = 1,
        borderColor    = { 0.815686, 0.815686, 0.815686, 0.85 }, -- #D0D0D0 @ 85%
        roundedColor   = { 0, 0, 0, 0.9 },
        color          = { defColor[1], defColor[2], defColor[3], defColor[4] },
        barOrientation = "horizontal",
        barWidth       = 200,
        barHeight      = 20,
        barBgColor     = { 0.1, 0.1, 0.1, 0.7 },
        barTexture     = ns.DEFAULT_BAR_TEXTURE,
        barShowText    = true,
        barFontSize    = 12,
        barTextPosition = "center",
        barShowIcon    = true,
        barIconPosition = "left",
        barIconBorderType = "rounded",
        barIconBorderThickness = 1,
        barIconBorderColor = { 0.815686, 0.815686, 0.815686, 0.85 }, -- #D0D0D0 @ 85% (matches the icon border default)
        barIconRoundedColor = { 0, 0, 0, 0.9 }, -- legacy; unused (bar-icon border color unified onto barIconBorderColor)
        barIconSwipe   = true,
        circleRadius   = 80,
        ringTexture    = "Ring_20px",
        -- Texture style. `textureKind` discriminates the engine call needed to
        -- draw `textureValue`: "file" = SetTexture (FileDataID or path),
        -- "atlas" = SetAtlas. Sniffing the value at render time cannot tell an
        -- atlas name from a texture path, hence the explicit tag.
        textureValue      = 449490,   -- Hot Streak; a visible default beats a blank holder
        textureKind       = "file",
        textureWidth      = 128,
        textureHeight     = 128,
        textureBlend      = "ADD",    -- the alert art is authored for additive
        textureRotation   = 0,
        textureDesaturate = false,
        soundType      =ns.trackerConfigs[cfgKey].custom and "custom" or "voice",
        soundName      = "None",
        soundChannel   = "Master",
        voiceShort     = false,
        growDirection  = "right",
        spacing        = 2,
        -- Ability/item GROUPS default to showing all 10 (the list cap); aura
        -- groups default to 3 (Unordered overflow).
        maxFrames      = IsIDGroupCfg(cfgKey) and 10 or 3,
        -- Custom-kind (no aura) fields; inert for aura trackers.
        customIconMode    = "icon",   -- "icon" | "spell"
        customIconInput   = nil,      -- raw ID/path string
        customText        = "",       -- single static string
        customTextSize    = 14,
        customTextColor   = { 1, 1, 1, 1 },
        customTextPosition = "bottom",
        -- ID-kind (ability/item) visibility: "always" shows whenever enabled +
        -- Load Conditions met (like a buff); "oncd" shows only while the
        -- ability/item is on cooldown. Inert for aura/custom kinds.
        idVisibility      = "always",
    }
end

-------------------------------------------------------------------------------
-- COMPOUND (ability/item + applied aura) "(Active)" PAIR elements. A single
-- ability/item tracker whose stored ID is compound-flagged (ns.CompoundInfo,
-- SpellData_AbilitiesItems.lua) auto-adds an aura-display overlay per
-- Icon/Bar element: the engine shows the applied aura's display over the
-- cooldown art while the aura is up. Pair configs are marked pairFor =
-- <parent element's style> (style-based identity — one non-pair element per
-- type — so it survives list renumbering).
-------------------------------------------------------------------------------
local function CompoundInfoFor(cfgKey, prefix)
    if not ns.CompoundInfo then return nil end
    local e = CustomEntryFor(cfgKey)
    local kind = e and e.kind
    -- SINGLE ability/item trackers only (groups are out of scope).
    if kind ~= "ability" and kind ~= "item" then return nil end
    local ids = HET.db.profile[prefix .. "ids"]
    return ns.CompoundInfo(ids and ids[1], kind == "item")
end

-- v1 ships self-buff ("help") pairs only; "harm" (enemy-target debuffs) is
-- a data-shape reservation until the target-unit overlay is PTR-verified.
local function PairableFor(cfgKey, prefix)
    local ci = CompoundInfoFor(cfgKey, prefix)
    return (ci and ci.polarity == "help") and ci or nil
end
-- (PAIRABLE_STYLES is declared above DoRemoveTracker, which needs it.)

local function FindPairIndex(els, style)
    for j = 1, #els do
        if els[j].pairFor == style then return j end
    end
end
local function FindParentIndex(els, style)
    for j = 1, #els do
        if els[j].style == style and not els[j].pairFor then return j end
    end
end

local function MakePairElement(cfgKey, style)
    local el = NewElementDefaults(cfgKey)
    el.style = style
    el.pairFor = style
    -- The overlay is an AURA display: default its fill to the buff blue
    -- rather than the ability/item purple, and skip the charge/stack text
    -- (the predefined-buff rule; the parent already shows charges).
    el.color = { KIND_BAR_COLOR_BUFF[1], KIND_BAR_COLOR_BUFF[2],
        KIND_BAR_COLOR_BUFF[3], KIND_BAR_COLOR_BUFF[4] }
    el.iconShowStackText = false
    el.barShowStackText = false
    if style == "bar" then
        -- Bar pairs default to the "Border" animation: transparent body,
        -- yellow border wiped away by the engine drain. Overlay mode
        -- shares these stored defaults (no static border, fully
        -- transparent background), so switching Animation Type is purely
        -- presentational — nothing gets stomped.
        el.pairBarAnim = "border"
        el.color = { 1, 0.82, 0, 1 }  -- yellow border / fill
        el.barBgColor = { 0, 0, 0, 0 }
        el.borderType = "none"
    elseif style == "icon" then
        -- Icons default to Replace Cooldown; the color only matters after
        -- switching to Animate Border (yellow, matching the bar default).
        el.pairIconAnim = "replace"
        el.color = { 1, 0.82, 0, 1 }
    end
    return el
end

-------------------------------------------------------------------------------
-- Per-element options subtab
-------------------------------------------------------------------------------
local function BuildElementGroup(cfgKey, prefix, i, isSpellDisabled)
    local groupKey = NodeFor(cfgKey)
    -- Custom-kind trackers render user art driven by Load Conditions (no aura).
    local isCustomKind = IsCustomKindCfg(cfgKey)
    -- ID kinds (ability/item): no aura, but a real spell/item ID drives an icon
    -- with an optional Cooldown swipe + Charges/Stacks text. The element UI
    -- relabels Duration→Cooldown and Stacks→Charges (abilities) / Stacks
    -- (items), keeping the internal binding/property keys unchanged.
    local isIDKind      = IsIDKindCfg(cfgKey)
    local isItemKind    = IsItemKindCfg(cfgKey)
    -- PAIR "(Active)" elements on a compound ability/item tracker are AURA
    -- displays: present the aura option set (labels, bindings, thresholds)
    -- rather than the ID one. Geometry/position options are hidden below —
    -- pairs are locked over their parent element.
    local e0 = GetEls(prefix)[i]
    local isPairEl = (e0 and e0.pairFor) and true or false
    -- Locked pairs (icon/bar) are position/size-slaved to a parent element;
    -- TEXT pairs are standalone (free-positioned, draggable).
    local isLockedPair = (isPairEl and e0.pairFor ~= "text") and true or false
    if isPairEl then
        isIDKind, isItemKind = false, false
    end
    -- The user-facing label for the "stacks" binding/property. The unified
    -- Ability/Item section always uses "Charges" (a single tracker may be either
    -- type and groups mix both, so a per-type Ability=Charges/Item=Stacks split
    -- would read inconsistently). Auras keep "Stacks".
    local stacksLabel   = isIDKind and "Charges" or "Stacks"
    -- The user-facing label for the "duration" binding/property: ID kinds track
    -- an ability/item COOLDOWN rather than an aura's remaining duration.
    local durationLabel = isIDKind and "Cooldown" or "Duration"
    -- Text-type dropdown labels for THIS tracker (relabels Duration/Stacks for
    -- ID kinds; "name" stays "Buff Name" — inert for ID kinds, which don't
    -- offer a name binding meaningfully but the machinery is harmless).
    local function TextTypeLabel(ty)
        if ty == "duration" then return durationLabel end
        if ty == "stacks" then return stacksLabel end
        return TEXT_TYPE_VALUES[ty]
    end
    -- "Presents as multi-aura" for the element display options: true fan-out
    -- groups show Icons/Bars + grow/max/spacing controls, while single-active
    -- groups (only one buff up at a time) present like a single tracker. The
    -- Tracked Buffs spell list is separate (config.spellList) and unaffected.
    local function isMultiAura()
        return IsMultiAuraCfg(cfgKey) and not IsSingleActiveCfg(cfgKey)
    end
    local function el()
        return GetEls(prefix)[i]
    end
    local function refresh()
        HET:RefreshTracking()
        HET:RefreshPreviewFor(cfgKey)
    end
    -- For continuous-drag controls (color pickers AND range sliders —
    -- both fire their setter on every drag tick): debounced refresh.
    -- BOTH halves are debounced — the preview used to run per tick.
    local function refreshThrottled()
        RefreshTrackingThrottled(cfgKey)
    end
    local function styleIs(s)
        local e = el()
        return e and e.style == s
    end
    local function hasVisualStyle()
        local e = el()
        local s = e and e.style
        return s ~= nil and s ~= "sound"
    end
    -- Texture preview swatch dimensions: the element's real Width/Height
    -- scaled so the LONGEST edge is TEX_PREVIEW_MAX. Keeps the swatch's aspect
    -- ratio honest (a 256x64 banner previews as a banner, not a square) and
    -- makes rotation clip the same way it will in game, while capping how much
    -- vertical space the row can claim. Defaults mirror ApplyStyle's 128x128.
    local function texPreviewSize()
        local e = el()
        local w = tonumber(e and e.textureWidth) or 128
        local h = tonumber(e and e.textureHeight) or 128
        local longest = math.max(w, h)
        if longest <= 0 then return TEX_PREVIEW_MAX, TEX_PREVIEW_MAX end
        local scale = TEX_PREVIEW_MAX / longest
        return math.max(1, math.floor(w * scale + 0.5)),
               math.max(1, math.floor(h * scale + 0.5))
    end
    -- Bar-pair "Border" animation (Animation Type = Border): the fill
    -- texture, background, and static border are all forced by the
    -- animation, so their controls hide while it's active.
    local function pairBorderMode()
        if not isPairEl then return false end
        local e = el()
        return (e and e.style == "bar")
            and ((e.pairBarAnim or "border") == "border") and true or false
    end
    -- Icon-pair "Animate Border" (Animation Type = Animate Border): no icon
    -- overlay; the bound Cooldown sweeps a border-shaped swipe around the
    -- parent's icon. Hides the icon-content and static-border controls.
    local function pairIconBorderMode()
        if not isPairEl then return false end
        local e = el()
        return (e and e.style == "icon")
            and ((e.pairIconAnim or "replace") == "border") and true or false
    end
    -- One tracker of each type per spell: types claimed by OTHER
    -- elements disappear from this element's dropdown (its own current
    -- type always stays listed).
    local function usedByOthers()
        local used = {}
        local els = GetEls(prefix)
        for j = 1, #els do
            -- PAIR elements don't claim a type: "one tracker of each type"
            -- applies to the non-pair elements (a pair shares its parent's).
            if j ~= i and els[j].style and not els[j].pairFor then
                used[els[j].style] = true
            end
        end
        return used
    end

    local function ElementLabel(j)
        local ej = GetEls(prefix)[j]
        local lbl = (ej and ej.style and StyleLabel(cfgKey, ej.style)) or "Tracker"
        if ej and ej.pairFor then lbl = lbl .. " (Active)" end
        return lbl
    end
    local tabName = ElementLabel(i)

    -- Tree entries sort ALPHABETICALLY by their own label, not by creation
    -- order. Elements are appended in whatever sequence the user happens to
    -- pick types in, so a creation-ordered tree reshuffles whenever one is
    -- added or removed, and the same tracker type sits in a different place on
    -- every aura. Rank is computed from the siblings' ACTUAL labels rather than
    -- a fixed list of style names, because StyleLabel is per-config (a
    -- multi-aura tracker reads "Icons", not "Icon") -- so this sorts by what is
    -- genuinely on screen. Ties fall back to index, which only bites if two
    -- entries ever render the same label; a pair's " (Active)" suffix already
    -- sorts it directly after its parent. O(n^2) over at most MAX_ELEMENTS
    -- entries, and only while the options table is being built.
    local elemRank = 1
    do
        local els = GetEls(prefix)
        for j = 1, #els do
            if j ~= i then
                local lj = ElementLabel(j)
                if lj < tabName or (lj == tabName and j < i) then
                    elemRank = elemRank + 1
                end
            end
        end
    end
    -- Explicit WHITE. An uncoloured tree entry inherits GameFontNormal, which is
    -- gold (#FFD100) — the same colour as the "+ Add Tracker" entry sitting in
    -- this same tree, so the two read as one kind of thing. The type label is
    -- content, the "+" row is an action; keep them visually distinct.
    tabName = "|cffffffff" .. tabName .. "|r"


    -- Text entries: one per engine binding. ns.EnsureTexts migrates the
    -- old fixed bar/icon text keys into el.texts on first access.
    local function texts()
        local e = el()
        return (e and ns.EnsureTexts(e)) or {}
    end
    local function hasTextStyle()
        -- Custom-kind uses a single static string (customText), not the
        -- aura-property text-entry system, so its text UI is suppressed here.
        if isCustomKind then return false end
        return styleIs("icon") or styleIs("bar") or styleIs("text")
    end
    -- Bindings claimed by text entries OTHER than k.
    local function bindingsUsedByOthers(k)
        local used, t = {}, texts()
        for j = 1, #t do
            -- An unrecognised type (hand-edited SavedVariables, or a
            -- profile from another version) maps to NO binding; indexing
            -- `used` with nil would error out of the whole options build.
            local b = t[j].type and ns.TEXT_BINDING[t[j].type]
            if j ~= k and b then used[b] = true end
        end
        return used
    end
    local function CanAddText()
        local used, t = {}, texts()
        for j = 1, #t do
            local b = t[j].type and ns.TEXT_BINDING[t[j].type]
            if b then used[b] = true end
        end
        local n = 0
        for _ in pairs(used) do n = n + 1 end
        return #t < MAX_TEXTS and n < MAX_TEXTS
    end
    local function AddText()
        -- EnsureTexts returns a THROWAWAY table on a style-less element
        -- (it refuses to cache one), so appending there would vanish.
        if not hasTextStyle() then return end
        local e = el(); if not e then return end
        local t = ns.EnsureTexts(e)
        local used = {}
        for j = 1, #t do
            local b = t[j].type and ns.TEXT_BINDING[t[j].type]
            if b then used[b] = true end
        end
        -- First type whose binding is still free.
        local pick
        for _, ty in ipairs(TEXT_TYPE_SORTING) do
            if not pick and not used[ns.TEXT_BINDING[ty]] then pick = ty end
        end
        if not pick then return end
        t[#t + 1] = {
            type = pick,
            stackDisplay = "current",
            fontSize = ns.DefaultTextSize(e.style, ns.TEXT_BINDING[pick]),
        }
        refresh()
        Notify()
    end
    -- Position set follows the style: icons get the 9-point grid, bars
    -- the 3 positions along the fill.
    local function barIsVertical()
        local e = el()
        return (e and e.barOrientation == "vertical") and true or false
    end
    local function posValues()
        if styleIs("bar") then
            return barIsVertical() and BAR_TEXT_POS_V or BAR_TEXT_POS
        end
        return ICON_TEXT_POS
    end
    local function posSorting()
        if styleIs("bar") then
            return barIsVertical() and BAR_TEXT_POS_V_SORTING or BAR_TEXT_POS_SORTING
        end
        return ICON_TEXT_POS_SORTING
    end
    local function defaultPos(ty)
        local b = ns.TEXT_BINDING[ty]
        if styleIs("bar") then
            if barIsVertical() then
                return (b == "stacks" and "top") or (b == "name" and "bottom") or "center"
            end
            return (b == "stacks" and "right") or (b == "name" and "left") or "center"
        end
        return (b == "stacks" and "bottomright") or (b == "name" and "top") or "center"
    end
    -- A position stored under the OTHER anchoring scheme (after a style
    -- switch) would render the dropdown blank — coerce to this style's
    -- default instead.
    local function readPos(k)
        local t = texts()[k]
        if not t then return "center" end
        local pos = t.position or defaultPos(t.type)
        if posValues()[pos] == nil then return defaultPos(t.type) end
        return pos
    end
    local function BuildTextGroup(k)
        return {
            type = "group",
            inline = true,
            name = function()
                local t = texts()[k]
                local label = t and TextTypeLabel(t.type)
                return "Text " .. k .. (label and (" \226\128\148 " .. label) or "")
            end,
            order = 80.1 + k / 100,
            hidden = function() return not hasTextStyle() end,
            args = {
                ttype = {
                    type = "select",
                    name = "Text Type",
                    desc = "Which property this text shows.",
                    order = 1,
                    values = function()
                        local used = bindingsUsedByOthers(k)
                        local t = texts()[k]
                        local own = t and t.type
                        local v = {}
                        for _, ty in ipairs(TEXT_TYPE_SORTING) do
                            if ty == own or not used[ns.TEXT_BINDING[ty]] then
                                v[ty] = TextTypeLabel(ty)
                            end
                        end
                        return v
                    end,
                    sorting = function()
                        local used = bindingsUsedByOthers(k)
                        local t = texts()[k]
                        local own = t and t.type
                        local o = {}
                        for _, ty in ipairs(TEXT_TYPE_SORTING) do
                            if ty == own or not used[ns.TEXT_BINDING[ty]] then
                                o[#o + 1] = ty
                            end
                        end
                        return o
                    end,
                    get = function() local t = texts()[k]; return t and t.type end,
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        if bindingsUsedByOthers(k)[ns.TEXT_BINDING[val]] then return end
                        t.type = val
                        refresh()
                        Notify()
                    end,
                },
                stackDisplay = {
                    type = "select",
                    -- Relabel for ability trackers (Charges) and items (Stacks);
                    -- auras keep Stacks. stacksLabel is "Charges" for abilities,
                    -- "Stacks" otherwise (see top of BuildElementGroup).
                    name = stacksLabel .. " Display Type",
                    desc = ("Current %s shows the count on its own. Current / Max appends your Max %s value. Max shows the maximum alone."):format(stacksLabel, stacksLabel),
                    order = 2,
                    -- "Current Stacks"/"Current Charges" relabels the first entry;
                    -- Current / Max and Max are kind-agnostic. (Abilities render
                    -- Current / Max via a separate static "/max" FontString since the
                    -- secret charge count can't be concatenated — see RenderIDText.)
                    values = {
                        current    = "Current " .. stacksLabel,
                        currentmax = "Current / Max",
                        max        = "Max",
                    },
                    sorting = STACK_DISPLAY_SORTING,
                    hidden = function()
                        local t = texts()[k]
                        return not (t and t.type == "stacks")
                    end,
                    get = function()
                        local t = texts()[k]
                        return (t and t.stackDisplay) or "current"
                    end,
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        t.stackDisplay = val
                        refresh()
                        Notify()
                    end,
                },
                hideBelow2 = {
                    type = "toggle",
                    name = "Show at 2+ " .. stacksLabel .. " Only",
                    desc = ("Hide this text while there is only a single %s, so the count only appears at 2 %s or more. On by default for Icon and Text trackers."):format(
                        stacksLabel:lower():gsub("s$", ""), stacksLabel:lower()),
                    order = 2.5,
                    hidden = function()
                        local t = texts()[k]
                        if not (t and t.type == "stacks") then return true end
                        -- Not offered for ability/item (ID-kind) trackers.
                        if isIDKind then return true end
                        return false
                    end,
                    get = function()
                        local e = el()
                        return ns.HideBelow2(e and e.style, texts()[k])
                    end,
                    -- Stored as an explicit true/false: nil is the
                    -- "untouched" state that reads the style default, so
                    -- collapsing false to nil would make an unchecked box
                    -- re-check itself on the next options build.
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        t.hideBelow2 = val and true or false
                        refresh()
                    end,
                },
                font = {
                    type = "select",
                    name = "Font",
                    order = 3,
                    dialogControl = "LSM30_Font",
                    -- Function form so the list is re-read from LSM's live
                    -- table every time the panel builds, not snapshotted once.
                    values = function() return LSM and LSM:HashTable("font") or {} end,
                    get = function()
                        local t = texts()[k]
                        return (t and t.font) or ns.DEFAULT_FONT_NAME
                    end,
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        t.font = val
                        refresh()
                    end,
                },
                fontBorder = {
                    type = "select",
                    name = "Font Border",
                    order = 3.5,
                    values = FONT_BORDER_VALUES,
                    sorting = FONT_BORDER_SORTING,
                    get = function()
                        local t = texts()[k]
                        return (t and t.fontBorder) or "OUTLINE"
                    end,
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        t.fontBorder = val
                        refresh()
                    end,
                },
                fontSize = {
                    type = "range",
                    name = "Font Size",
                    order = 4,
                    min = 6, max = 72, step = 1,
                    get = function()
                        local e, t = el(), texts()[k]
                        return (t and t.fontSize)
                            or ns.DefaultTextSize(e and e.style, t and ns.TEXT_BINDING[t.type])
                    end,
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        t.fontSize = val
                        refreshThrottled()
                    end,
                },
                color = {
                    type = "color",
                    name = "Text Color",
                    order = 4.5,
                    hasAlpha = true,
                    get = function()
                        local t = texts()[k]
                        local c = (t and t.color) or { 1, 1, 1, 1 }
                        return c[1], c[2], c[3], c[4]
                    end,
                    set = function(_, r, g, b, a)
                        local t = texts()[k]; if not t then return end
                        t.color = { r, g, b, a }
                        refreshThrottled()
                    end,
                },
                position = {
                    type = "select",
                    name = "Text Position",
                    order = 5,
                    values = posValues,
                    sorting = posSorting,
                    get = function() return readPos(k) end,
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        t.position = val
                        refresh()
                    end,
                },
                -- Nudge off the chosen anchor. The nine grid positions (three
                -- along a bar) are coarse, so these turn them into a starting
                -- point. 0,0 is exactly the un-nudged position.
                --
                -- SOFT bounds: a nudge is normally a few pixels, but a text can
                -- legitimately be pushed right outside its icon -- a stack
                -- count parked above the frame, say -- so the edit box stays
                -- open past the slider's range.
                offsetX = {
                    type = "range",
                    name = "X Offset",
                    desc = "Move the text horizontally from its position. Positive is right.",
                    order = 5.1,
                    softMin = -100, softMax = 100, step = 1,
                    get = function()
                        local t = texts()[k]
                        return (t and tonumber(t.offsetX)) or 0
                    end,
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        val = math.floor(val + 0.5)
                        -- nil, not 0, to keep saved profiles sparse.
                        t.offsetX = val ~= 0 and val or nil
                        refreshThrottled()
                    end,
                },
                offsetY = {
                    type = "range",
                    name = "Y Offset",
                    desc = "Move the text vertically from its position. Positive is up.",
                    order = 5.2,
                    softMin = -100, softMax = 100, step = 1,
                    get = function()
                        local t = texts()[k]
                        return (t and tonumber(t.offsetY)) or 0
                    end,
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        val = math.floor(val + 0.5)
                        t.offsetY = val ~= 0 and val or nil
                        refreshThrottled()
                    end,
                },
                -- Duration-only: change the timer text color as the aura
                -- runs out. Evaluated engine-side against the (secret)
                -- remaining duration. Hidden for non-duration text.
                thresholdColors = {
                    type = "group",
                    name = "Threshold Colors",
                    order = 6,
                    inline = true,
                    hidden = function()
                        local t = texts()[k]
                        return not (t and t.type == "duration")
                    end,
                    args = {
                        enabled = {
                            type = "toggle",
                            name = "Change Color based on Remaining Time",
                            desc = "When enabled, the duration text changes color as the remaining time falls below the threshold(s) below.",
                            order = 1,
                            width = "full",
                            get = function()
                                local t = texts()[k]
                                return (t and t.thresholdColorEnabled) or false
                            end,
                            set = function(_, val)
                                local t = texts()[k]; if not t then return end
                                t.thresholdColorEnabled = val or nil
                                refresh()
                            end,
                        },
                        threshold1Color = {
                            type = "color",
                            name = "Threshold Color",
                            order = 2,
                            hasAlpha = true,
                            hidden = function()
                                local t = texts()[k]
                                return not (t and t.thresholdColorEnabled)
                            end,
                            get = function()
                                local t = texts()[k]
                                local c = (t and t.thresholdColor) or { 1, 0.5, 0, 1 }
                                return c[1], c[2], c[3], c[4]
                            end,
                            set = function(_, r, g, b, a)
                                local t = texts()[k]; if not t then return end
                                t.thresholdColor = { r, g, b, a }
                                refreshThrottled()
                            end,
                        },
                        threshold1Seconds = {
                            type = "range",
                            name = "Threshold (seconds)",
                            order = 3,
                            min = 1, max = 59, step = 1,
                            hidden = function()
                                local t = texts()[k]
                                return not (t and t.thresholdColorEnabled)
                            end,
                            get = function()
                                local t = texts()[k]
                                return (t and t.thresholdSeconds) or 8
                            end,
                            set = function(_, val)
                                local t = texts()[k]; if not t then return end
                                t.thresholdSeconds = val
                                -- Keep the secondary threshold strictly below
                                -- the primary one.
                                if t.threshold2Seconds and t.threshold2Seconds >= val then
                                    t.threshold2Seconds = math.max(1, val - 1)
                                end
                                -- THROTTLED: a range slider fires its setter on
                                -- every drag tick exactly like a colour picker,
                                -- and the unthrottled refresh() is a GLOBAL
                                -- RefreshAll plus a full preview ApplyStyle.
                                refreshThrottled()
                            end,
                        },
                        threshold2Enabled = {
                            type = "toggle",
                            name = "Enable Secondary Threshold",
                            desc = "When enabled, a second color is applied when the remaining time falls below a lower threshold.",
                            order = 4,
                            width = "full",
                            hidden = function()
                                local t = texts()[k]
                                return not (t and t.thresholdColorEnabled)
                            end,
                            get = function()
                                local t = texts()[k]
                                return (t and t.threshold2Enabled) or false
                            end,
                            set = function(_, val)
                                local t = texts()[k]; if not t then return end
                                t.threshold2Enabled = val or nil
                                refresh()
                            end,
                        },
                        threshold2Color = {
                            type = "color",
                            name = "Secondary Threshold Color",
                            order = 5,
                            hasAlpha = true,
                            hidden = function()
                                local t = texts()[k]
                                return not (t and t.thresholdColorEnabled and t.threshold2Enabled)
                            end,
                            get = function()
                                local t = texts()[k]
                                local c = (t and t.threshold2Color) or { 1, 0, 0, 1 }
                                return c[1], c[2], c[3], c[4]
                            end,
                            set = function(_, r, g, b, a)
                                local t = texts()[k]; if not t then return end
                                t.threshold2Color = { r, g, b, a }
                                refreshThrottled()
                            end,
                        },
                        threshold2Seconds = {
                            type = "range",
                            name = "Secondary Threshold (seconds)",
                            order = 6,
                            min = 1, max = 59, step = 1,
                            hidden = function()
                                local t = texts()[k]
                                return not (t and t.thresholdColorEnabled and t.threshold2Enabled)
                            end,
                            get = function()
                                local t = texts()[k]
                                local prim = (t and t.thresholdSeconds) or 8
                                local limit = math.max(1, prim - 1)
                                return math.min((t and t.threshold2Seconds) or 4, limit)
                            end,
                            set = function(_, val)
                                local t = texts()[k]; if not t then return end
                                local prim = t.thresholdSeconds or 8
                                t.threshold2Seconds = math.min(val, math.max(1, prim - 1))
                                -- THROTTLED: see threshold1Seconds above.
                                refreshThrottled()
                            end,
                        },
                    },
                },
            },
        }
    end

    local group = {
        type = "group",
        name = tabName,
        order = 100 + elemRank,   -- alphabetical; see ElementLabel above
        hidden = isSpellDisabled,
        args = {
            -- PAIR "(Active)" explainer. Locked pairs (icon/bar) follow the
            -- parent element; standalone Text pairs are free-positioned.
            pairNote = {
                type = "description",
                name = isLockedPair
                    and ("This tracker shows the aura this ability/item applies, overlaid on its "
                        .. ((e0 and e0.pairFor and StyleLabel(cfgKey, e0.pairFor)) or "parent")
                        .. " tracker while the aura is active (the cooldown is revealed when it drops). "
                        .. "Position and size follow the parent tracker; the appearance settings below are its own.")
                    or "This tracker shows the remaining duration of the aura this ability/item applies, only while the aura is active. It is positioned independently (use Move/Preview to drag it).",
                order = 0.5,
                width = "full",
                hidden = function() return not isPairEl end,
            },
            -- Icon (Active) pairs: how the overlay presents the aura.
            pairIconAnim = {
                type = "select",
                name = "Animation Type",
                desc = "Replace Cooldown: the aura's icon covers the cooldown icon while active, with its own swipe and text. Animate Border: nothing covers the icon — a colored border (in the parent icon's border style) sweeps away around it like a cooldown swipe as the aura runs out.",
                order = 0.8,
                values = { replace = "Replace Cooldown", border = "Animate Border" },
                sorting = { "replace", "border" },
                hidden = function() return not (isPairEl and styleIs("icon")) end,
                get = function()
                    local e = el()
                    return (e and e.pairIconAnim) or "replace"
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.pairIconAnim = val
                    refresh()
                    Notify()
                end,
            },
            -- Weight of the animated border ring (the shape still follows
            -- the parent icon's border style).
            pairAnimThickness = {
                type = "range",
                name = "Border Thickness",
                desc = "Thickness of the animated border. (Scales with the icon: the value reads as pixels at a 64px icon, slightly thinner below that.)",
                order = 0.85,
                min = 1, max = 6, step = 1,
                hidden = function() return not pairIconBorderMode() end,
                get = function()
                    local e = el()
                    return (e and e.pairAnimThickness) or 2
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.pairAnimThickness = val
                    refreshThrottled()
                end,
            },
            -- Bar (Active) pairs: how the overlay renders the aura duration.
            pairBarAnim = {
                type = "select",
                name = "Animation Type",
                desc = "Border: a transparent bar body with an outline that wipes away right-to-left (top-to-bottom on vertical bars) as the aura runs out. Overlay: a regular bar fill drawn over the cooldown bar, using the bar options below.",
                order = 0.8,
                values = { border = "Border", overlay = "Overlay" },
                sorting = { "border", "overlay" },
                hidden = function() return not (isPairEl and styleIs("bar")) end,
                get = function()
                    local e = el()
                    return (e and e.pairBarAnim) or "border"
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.pairBarAnim = val
                    refresh()
                    Notify()
                end,
            },
            style = {
                type = "select",
                name = "Tracker Type",
                desc = "What kind of tracker this is. Visual types show while the buff is active; Sound Alert plays a sound when the buff is applied. Only one tracker of each type per spell.",
                order = 1,
                -- PAIR elements mirror their parent's type; not selectable.
                hidden = function() return isPairEl end,
                values = function()
                    local used = usedByOthers()
                    local own = el() and el().style
                    local v = {}
                    for _, k in ipairs(AllowedTypeSorting(cfgKey)) do
                        if not used[k] then v[k] = StyleLabel(cfgKey, k) end
                    end
                    if own and not v[own] then v[own] = StyleLabel(cfgKey, own) end
                    return v
                end,
                sorting = function()
                    local used = usedByOthers()
                    local own = el() and el().style
                    local order = {}
                    local seenOwn = false
                    for _, k in ipairs(AllowedTypeSorting(cfgKey)) do
                        if not used[k] then
                            order[#order + 1] = k
                            if k == own then seenOwn = true end
                        end
                    end
                    if own and not seenOwn then order[#order + 1] = own end
                    return order
                end,
                get = function() local e = el(); return e and e.style end,
                set = function(_, val)
                    local e = el()
                    if not e or usedByOthers()[val] then return end
                    -- Compound parent type change: its "(Active)" pair follows
                    -- when the new type is pairable, else it's removed. Pairs
                    -- are appended AFTER their parent, so the remove can't
                    -- shift THIS element's index i.
                    if not e.pairFor and PAIRABLE_STYLES[e.style] then
                        local els = GetEls(prefix)
                        local pj = FindPairIndex(els, e.style)
                        if pj then
                            if PAIRABLE_STYLES[val] and PairableFor(cfgKey, prefix) then
                                els[pj].pairFor = val
                                els[pj].style = val
                            else
                                table.remove(els, pj)
                                local t = ns.trackers[cfgKey]
                                if t then t:RestoreAllPositions() end
                            end
                        end
                    end
                    e.style = val
                    refresh()
                    Notify()
                    -- Pin selection to THIS entry. The rebuild un-hides
                    -- the "+" entry, and ACD's re-render fallback
                    -- (SelectByValue saved-or-first) could otherwise land
                    -- on it and fire its add trigger.
                    SelectTracker(cfgKey, "trackers", "el" .. i)
                end,
            },

            -- ── Custom-kind controls (no aura) ──────────────────────────────
            -- Icon source: an explicit mode dropdown BEFORE the ID field so a
            -- number is never ambiguous. "Icon ID" = texture file ID / path,
            -- "Spell ID" = derive the icon via GetSpellTexture.
            customIconMode = {
                type = "select",
                name = "Icon Source",
                desc = "Icon ID: a texture file ID (e.g. 134400) or an icon path. Spell ID: the icon is taken from that spell.",
                -- Under the Icon Settings header alongside Icon Size, not up in
                -- the tracker's identity row: this is icon ART, and it is where
                -- the aura tab keeps its own Icon Source pair (11.3 / 11.4).
                order = 11.1,
                width = 0.9,
                values = { icon = "Icon ID", spell = "Spell ID" },
                sorting = { "icon", "spell" },
                hidden = function() return not (isCustomKind and styleIs("icon")) end,
                get = function() local e = el(); return (e and e.customIconMode) or "icon" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.customIconMode = val
                    refresh()
                    Notify()
                end,
            },
            customIconInput = {
                type = "input",
                name = "Icon ID",
                desc = "The icon to show. Interpreted per the Icon Source dropdown.",
                order = 11.2,
                width = 1.2,
                hidden = function() return not (isCustomKind and styleIs("icon")) end,
                get = function() local e = el(); return (e and e.customIconInput) or "" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.customIconInput = (val and val ~= "") and val or nil
                    refresh()
                    Notify()
                end,
            },
            -- One optional static label for an Icon element; the full label
            -- string for a Text element. Positioned via the same text controls.
            -- Row break so the Label block starts a fresh row under Copy
            -- Tracker (see iconRecolorBreak for why a spacer is required).
            -- Predicate mirrors customText exactly.
            customLabelBreak = {
                type = "description",
                name = "",
                order = 1.38,
                width = "full",
                hidden = function()
                    return not (isCustomKind and (styleIs("icon") or styleIs("text")))
                end,
            },
            customText = {
                type = "input",
                name = function() return styleIs("text") and "Text" or "Label Text" end,
                desc = "A static text string to show (no aura, so nothing counts down).",
                order = 1.4,
                width = 1.5,
                hidden = function()
                    return not (isCustomKind and (styleIs("icon") or styleIs("text")))
                end,
                get = function() local e = el(); return (e and e.customText) or "" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.customText = val or ""
                    refresh()
                    Notify()
                end,
            },
            customTextBreak = {
                type = "description",
                name = "",
                order = 1.45,
                width = "full",
                hidden = function()
                    return not (isCustomKind and (styleIs("icon") or styleIs("text")))
                end,
            },
            customTextSize = {
                type = "range",
                name = "Text Size",
                desc = "Font size of the custom text.",
                order = 1.46,
                min = 6, max = 48, step = 1,
                hidden = function()
                    return not (isCustomKind and (styleIs("icon") or styleIs("text")))
                end,
                get = function() local e = el(); return (e and e.customTextSize) or 14 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.customTextSize = val
                    refreshThrottled()
                end,
            },
            customTextColor = {
                type = "color",
                name = "Text Color",
                desc = "Color of the custom text.",
                order = 1.47,
                hasAlpha = true,
                hidden = function()
                    return not (isCustomKind and (styleIs("icon") or styleIs("text")))
                end,
                get = function()
                    local e = el()
                    local c = (e and e.customTextColor) or { 1, 1, 1, 1 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.customTextColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },
            -- Icon label placement (Text style is centered on its own holder).
            customTextPosition = {
                type = "select",
                name = "Label Position",
                desc = "Where the label sits on the icon.",
                -- On the Label Text row (1.4), not down with Text Size /
                -- Text Color: it positions the label, so it reads with it.
                order = 1.42,
                values = ICON_TEXT_POS,
                sorting = ICON_TEXT_POS_SORTING,
                hidden = function()
                    return not (isCustomKind and styleIs("icon"))
                end,
                get = function() local e = el(); return (e and e.customTextPosition) or "bottom" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.customTextPosition = val
                    refresh()
                end,
            },

            -- Single-buff only: on a multi-aura group the engine assigns
            -- each frame a secret aura, and a stack fill needs a known
            -- per-buff maximum.
            trackedProperty = {
                type = "select",
                name = "Tracked Property",
                desc = isIDKind
                    and "Which property the bar fill follows. Cooldown drains as the ability/item cooldown runs out; "
                        .. (isItemKind and "Stacks fills toward Max Stacks (item count in bags)." or "Charges fills toward Max Stacks (spell charges).")
                    or  "Which property of the buff the bar fill follows. Duration drains as the buff runs out; Stacks fills toward Max Stacks.",
                order = 2,
                -- Internal keys stay "duration"/"stacks" (no text-system churn);
                -- the LABELS relabel for ID kinds (Cooldown / Charges|Stacks).
                values = { duration = durationLabel, stacks = stacksLabel },
                sorting = { "duration", "stacks" },
                hidden = function()
                    -- Irrelevant for ability/item bars: the fill always follows
                    -- the cooldown / charge recharge, never a "stacks" property.
                    if isIDKind then return true end
                    if isMultiAura() then return true end
                    return not styleIs("bar")
                end,
                get = function()
                    local e = el()
                    return (e and e.trackedProperty) or "duration"
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.trackedProperty = val
                    refresh()
                end,
            },
            -- ID-kind (ability/item) visibility. "Always" shows the art whenever
            -- the tracker is enabled + its Load Conditions are met (like a buff);
            -- "Only While On Cooldown" restricts it to while the ability/item is
            -- on cooldown; "Only When Usable" restricts it to while the
            -- ability/item is currently usable (off cooldown + has charges /
            -- resources). Shown for ID kinds only; inert for aura/custom.
            idVisibility = {
                type = "select",
                name = "Show When",
                desc = "When this indicator is shown. Always (while loaded) shows it whenever the tracker is enabled and its Conditions are met. Only While On Cooldown restricts it to while the ability/item is on cooldown. Only When Usable restricts it to while the ability/item is ready to use (off cooldown and, for abilities, having charges and resources).",
                order = 2.2,
                values = ID_VISIBILITY_VALUES,
                sorting = ID_VISIBILITY_SORTING,
                -- GROUPS drive visibility PER ENTRY from the Tracked Abilities /
                -- Items list, so this element-level dropdown is hidden for them
                -- (kept only for SINGLE ID trackers, which have no per-id list).
                hidden = function()
                    if not isIDKind or IsIDGroupCfg(cfgKey) then return true end
                    local e = el()
                    -- Sound elements have no visible art to gate.
                    return not (e and e.style and e.style ~= "sound")
                end,
                get = function() local e = el(); return (e and e.idVisibility) or "always" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.idVisibility = val
                    refresh()
                end,
            },
            -- Qualifies every "Stacks" reading on this element: the bar
            -- fill AND the Stacks / Max Stacks text. The engine cannot
            -- infer a buff's maximum, so it has to be stated once here.
            maxStacks = {
                type = "range",
                name = "Max Stacks",
                desc = "Stack count that counts as full. Used by a stack-tracking bar and by the Stacks / Max Stacks text.",
                order = 2.5,
                min = 1, max = 99, step = 1,
                hidden = function()
                    local e = el()
                    if not e then return true end
                    if styleIs("bar") and not isMultiAura()
                        and e.trackedProperty == "stacks" then
                        return false
                    end
                    local t = ns.EnsureTexts(e)
                    for j = 1, #t do
                        if t[j].type == "stacks" then
                            local d = t[j].stackDisplay or "current"
                            if d == "currentmax" or d == "max" then return false end
                        end
                    end
                    return true
                end,
                get = function() local e = el(); return (e and e.maxStacks) or 10 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.maxStacks = val
                    refreshThrottled()
                end,
            },
            -- Only for the BAR fill, not the Stacks text: maxStacks is shared
            -- with "3/10"-style text, and segmenting a number is meaningless.
            -- The predicate is the bar-fill clause of maxStacks' own `hidden`,
            -- which is also exactly ApplyBarBinding's wantStacks test -- so this
            -- can never show while the stacks binding is not the live one.
            barSegmentStacks = {
                type = "toggle",
                name = "Segment Stacks",
                desc = "Draw one segment per stack instead of a single continuous fill, the way multi-charge ability bars do."
                    .. "\n\nSegments come from |cffffd100Max Stacks|r, so a Max Stacks of 2 gives two halves that fill one at a time."
                    .. "\n\nApplies up to a Max Stacks of 20; above that, and on bars too small for the segments to read, the bar stays a single fill.",
                order = 2.55,
                hidden = function()
                    local e = el()
                    return not (e and styleIs("bar") and not isMultiAura()
                        and e.trackedProperty == "stacks")
                end,
                get = function()
                    local e = el()
                    return (e and e.barSegmentStacks) and true or false
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    -- nil, not false, to keep saved profiles sparse.
                    e.barSegmentStacks = val and true or nil
                    refresh()
                end,
            },
            showTooltip = {
                type = "toggle",
                name = "Show Tooltip",
                desc = "Show the aura tooltip when hovering this indicator. If a change doesn't take effect immediately, /reload applies it.",
                -- Custom ("Other") trackers put this straight after Tracker
                -- Type, so their first row reads Enable / Tracker Type / Show
                -- Tooltip -- the same opening the Abilities/Items tab has.
                -- Everywhere else it keeps its old slot AFTER Show When, which
                -- is where the ability/item layout already wants it.
                order = function() return isCustomKind and 1.05 or 3 end,
                hidden = function() return not hasVisualStyle() end,
                get = function() local e = el(); return e and e.showTooltip end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.showTooltip = val
                    refresh()
                end,
            },

            -- Icon
            iconHeader = {
                type = "header",
                name = "Icon Settings",
                order = 10,
                hidden = function() return not styleIs("icon") end,
            },
            iconSize = {
                type = "range",
                name = "Icon Size",
                desc = "Size of the tracker icon in pixels.",
                order = 11,
                min = 20, max = 100, step = 1,
                -- Locked PAIR elements mirror the parent element's geometry.
                hidden = function() return isLockedPair or not styleIs("icon") end,
                -- Fallbacks mirror the render defaults: preset-seeded elements
                -- are SPARSE (see Core.SeedPresets), so unset fields must show
                -- the value the renderer actually uses — a nil here made the
                -- slider display 0 (below its own minimum).
                get = function() local e = el(); return (e and e.iconSize) or 40 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.iconSize = val
                    refreshThrottled()
                end,
            },
            -- Icon Source (aura Icon trackers): Auto uses the aura's own icon;
            -- the Custom modes replace only the displayed TEXTURE (the aura
            -- still gates visibility, cooldown, and duration/stack text).
            -- Custom-kind trackers use their own customIconMode instead.
            auraIconMode = {
                type = "select",
                name = "Icon Source",
                desc = "Auto uses the tracked aura's own icon. Custom (Spell ID) shows a chosen spell's icon; Custom (Icon ID) shows a texture file ID or icon path. Only the image changes — the aura still drives when the tracker shows and its cooldown/text.",
                order = 11.3,
                width = 1.1,
                values = {
                    auto  = "Auto (Aura Icon)",
                    spell = "Custom (Spell ID)",
                    icon  = "Custom (Icon ID)",
                },
                sorting = { "auto", "spell", "icon" },
                -- Single-aura Icon trackers only: a multi-aura fan-out would
                -- have every icon replaced by one texture.
                hidden = function()
                    return pairIconBorderMode() or isCustomKind
                        or isMultiAura() or not styleIs("icon")
                end,
                get = function() local e = el(); return (e and e.auraIconMode) or "auto" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.auraIconMode = val
                    e._iconBindDirty = true  -- rebind the slot icon region (see RebindSlotIcon)
                    refresh()
                    Notify()
                end,
            },
            auraIconInput = {
                type = "input",
                name = function()
                    local e = el()
                    return (e and e.auraIconMode == "icon") and "Icon ID" or "Spell ID"
                end,
                desc = "The icon to show instead of the aura's own icon. Interpreted per Icon Source.",
                order = 11.4,
                width = 1.1,
                hidden = function()
                    if pairIconBorderMode() then return true end
                    local e = el()
                    local mode = e and e.auraIconMode
                    return isCustomKind or isMultiAura() or not styleIs("icon")
                        or not (mode == "spell" or mode == "icon")
                end,
                get = function() local e = el(); return (e and e.auraIconInput) or "" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.auraIconInput = (val and val ~= "") and val or nil
                    e._iconBindDirty = true  -- rebind the slot icon region (see RebindSlotIcon)
                    refresh()
                    Notify()
                end,
            },
            cooldownSwipe = {
                type = "toggle",
                name = "Cooldown Swipe",
                desc = "Show a cooldown swipe overlay on the icon.",
                order = 12,
                -- Custom-kind has no aura/cooldown to swipe. Icon-pair
                -- Animate Border repurposes the swipe as the border itself.
                hidden = function()
                    return pairIconBorderMode() or not styleIs("icon") or isCustomKind
                end,
                -- Render treats nil as ON (`~= false`), so the toggle must too.
                get = function() local e = el(); return e and e.cooldownSwipe ~= false end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.cooldownSwipe = val
                    refresh()
                end,
            },

            -- GCD Swipe + Cooldown Finished: ability/item trackers only, on the
            -- two surfaces that draw an icon (the Icon style's icon, the Bar
            -- style's end icon square). Orders place them directly after the
            -- matching Cooldown Swipe toggle in each style -- 12 for the icon,
            -- 28.6 (barIconSwipe) for the bar.
            -- Row break after Cooldown Swipe, so Cooldown Finished
            -- Animation + GCD Swipe get their own row (see iconRecolorBreak).
            idCooldownBreak = {
                type = "description",
                name = "",
                order = function() return styleIs("icon") and 12.05 or 28.61 end,
                width = "full",
                hidden = function()
                    local e = el()
                    if not isIDKind or pairIconBorderMode() then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
            },
            idGcdSwipe = {
                type = "toggle",
                name = "GCD Swipe",
                desc = "Sweep a light swipe across the icon during the global cooldown. Only while the ability is not on a real cooldown of its own, and never for abilities that are off the global.",
                order = function() return styleIs("icon") and 12.2 or 28.64 end,
                hidden = function()
                    local e = el()
                    if not isIDKind or pairIconBorderMode() then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function()
                    local e = el()
                    return (e and e.idGcdSwipe) and true or false
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.idGcdSwipe = val or nil
                    refresh()
                end,
            },
            idCooldownBling = {
                type = "toggle",
                name = "Cooldown Finished Animation",
                desc = "Play Blizzard's native shine over the icon the moment the cooldown ends — the same flash the default action bars use.",
                order = function() return styleIs("icon") and 12.1 or 28.62 end,
                hidden = function()
                    local e = el()
                    if not isIDKind or pairIconBorderMode() then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function()
                    local e = el()
                    return (e and e.idCooldownBling) and true or false
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.idCooldownBling = val or nil
                    refresh()
                end,
            },

            -- Texture. A chosen image shown while the aura is present: no
            -- engine icon binding, no swipe, no stack text. Single-slot only
            -- (see AllowedTypeSorting).
            textureHeader = {
                type = "header",
                name = "Texture Settings",
                order = 12.5,
                hidden = function() return not styleIs("texture") end,
            },
            texturePreview = {
                type = "description",
                order = 12.51,
                width = "full",
                -- Custom widget, not AceConfig's stock description image: the
                -- stock path can only SetTexture/SetTexCoord, so it could show
                -- neither the Color/Desaturate/Rotation/Blend settings nor an
                -- atlas. See RegisterTexturePreviewWidget.
                dialogControl = "HETTexturePreview",
                -- Returns the raw value AND stages the draw state for the
                -- widget. ACD calls this immediately before the control's
                -- SetImage, which consumes and clears the staging table — the
                -- option member API has no other channel for these. The return
                -- must stay a string or number or ACD skips SetImage entirely.
                image = function()
                    local e = el()
                    if not e then return nil end
                    local v = e.textureValue
                    if v == nil or v == "" then return nil end
                    -- Mirrors the live render (Tracker.lua ApplyStyle
                    -- "texture"): color is el.color, rotation is stored in
                    -- degrees but set in radians.
                    local c = e.color or { 1, 1, 1, 1 }
                    ns.texPreviewPending = {
                        kind  = e.textureKind,
                        r     = c[1] or 1,
                        g     = c[2] or 1,
                        b     = c[3] or 1,
                        a     = c[4] or 1,
                        blend = e.textureBlend or "ADD",
                        desat = e.textureDesaturate and true or false,
                        rot   = math.rad(e.textureRotation or 0),
                    }
                    -- Size rides the 2nd/3rd RETURNS, not imageWidth/
                    -- imageHeight members: AceConfigRegistry types those as
                    -- `optnumber` with no function form and hard-rejects a
                    -- getter (validation error on panel open), while `image` is
                    -- `optstringnumberfunc` and ACD reads
                    -- `local image, width, height = GetOptionsMemberValue(...)`,
                    -- consulting the members only when width comes back nil.
                    -- This is the only way to size the swatch dynamically.
                    local pw, ph = texPreviewSize()
                    if e.textureKind == "atlas" then return tostring(v), pw, ph end
                    return (tonumber(v) or v), pw, ph
                end,
                name = function()
                    local e = el()
                    if not e then return "" end
                    if e.textureKind == "atlas" then
                        return "Atlas: |cffffff00" .. tostring(e.textureValue or "-") .. "|r"
                    end
                    return " "
                end,
                hidden = function() return not styleIs("texture") end,
            },
            texturePick = {
                type = "execute",
                name = "Choose Texture...",
                desc = "Browse the bundled spell-alert art, every Blizzard atlas, and your shared-media textures.",
                order = 12.52,
                width = 1.1,
                hidden = function() return not styleIs("texture") end,
                func = function()
                    local e = el(); if not e then return end
                    ns.OpenTexturePicker(e.textureValue, e.textureKind,
                        function(value, kind)
                            e.textureValue = value
                            e.textureKind  = kind
                            refresh()
                            -- Notify() IS required here, unlike the plain
                            -- setters below. This is the picker's own OnClick
                            -- callback, fired when the user picks a tile —
                            -- long after AceConfigDialog re-Opened the panel
                            -- for the `execute` that opened the picker. Nothing
                            -- else on this path rebuilds, so without it the
                            -- swatch, the "Atlas: ..." label and the Texture
                            -- input box all keep showing the OLD texture until
                            -- some unrelated control is touched.
                            Notify()
                        end)
                end,
            },
            textureKind = {
                type = "select",
                name = "Source",
                desc = "How the value below is interpreted. File covers FileDataIDs and texture paths; Atlas is a named Blizzard atlas.",
                order = 12.53,
                width = 0.8,
                values = { file = "File / ID", atlas = "Atlas" },
                sorting = { "file", "atlas" },
                hidden = function() return not styleIs("texture") end,
                get = function() local e = el(); return (e and e.textureKind) or "file" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.textureKind = val
                    refresh()
                end,
            },
            textureValue = {
                type = "input",
                name = "Texture",
                desc = "FileDataID, texture path, or atlas name. Anything the picker does not list can be typed here directly.",
                order = 12.54,
                width = 1.3,
                hidden = function() return not styleIs("texture") end,
                get = function() local e = el(); return e and tostring(e.textureValue or "") or "" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    val = val and val:gsub("^%s+", ""):gsub("%s+$", "") or ""
                    e.textureValue = (val ~= "") and (tonumber(val) or val) or nil
                    refresh()
                end,
            },
            textureWidth = {
                type = "range",
                name = "Width",
                desc = "Texture width in pixels.",
                order = 12.55,
                min = 8, max = 512, step = 1,
                hidden = function() return not styleIs("texture") end,
                get = function() local e = el(); return (e and e.textureWidth) or 128 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.textureWidth = val
                    refreshThrottled()
                    NotifyThrottled()  -- reshape the preview swatch
                end,
            },
            textureHeight = {
                type = "range",
                name = "Height",
                desc = "Texture height in pixels.",
                order = 12.56,
                min = 8, max = 512, step = 1,
                hidden = function() return not styleIs("texture") end,
                get = function() local e = el(); return (e and e.textureHeight) or 128 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.textureHeight = val
                    refreshThrottled()
                    NotifyThrottled()  -- reshape the preview swatch
                end,
            },
            textureBlend = {
                type = "select",
                name = "Blend Mode",
                desc = "Additive makes the art glow against whatever is behind it — the spell-alert textures are authored for it. Normal blends conventionally.",
                order = 12.57,
                width = 0.9,
                values = { ADD = "Additive", BLEND = "Normal" },
                sorting = { "ADD", "BLEND" },
                hidden = function() return not styleIs("texture") end,
                get = function() local e = el(); return (e and e.textureBlend) or "ADD" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.textureBlend = val
                    refresh()
                end,
            },
            textureRotation = {
                type = "range",
                name = "Rotation",
                desc = "Rotation in degrees.",
                order = 12.58,
                min = 0, max = 359, step = 1,
                hidden = function() return not styleIs("texture") end,
                get = function() local e = el(); return (e and e.textureRotation) or 0 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.textureRotation = val
                    refreshThrottled()
                    NotifyThrottled()  -- repaint the preview swatch (drag control)
                end,
            },
            textureDesaturate = {
                type = "toggle",
                name = "Desaturate",
                desc = "Draw the texture in greyscale.",
                order = 12.59,
                hidden = function() return not styleIs("texture") end,
                get = function() local e = el(); return e and e.textureDesaturate == true end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.textureDesaturate = val and true or nil
                    refresh()
                end,
            },

            -- Progress Texture. The art is CLIPPED by the aura's remaining
            -- duration instead of standing still — the Texture style stops
            -- being a pure presence light.
            --
            -- Nothing here computes a fraction: 12.1 keeps remaining duration
            -- SECRET, so the drain is driven ENGINE-side off the slot button's
            -- already-bound duration StatusBar (ApplyTextureProgress in
            -- Tracker.lua). These options only choose the axis and which edge
            -- stays pinned.
            --
            -- Hidden for custom-kind trackers: they have no aura and therefore
            -- no duration, and their Texture art is a Load-Condition light
            -- drawn on the holder path (RenderCustomArt), which has no bound
            -- StatusBar to drain. ID kinds never offer the Texture style at
            -- all (AllowedTypeSorting), so they need no gate here.
            textureProgressHeader = {
                type = "header",
                name = "Progress Texture",
                order = 12.6,
                hidden = function()
                    return isCustomKind or not styleIs("texture")
                end,
            },
            textureProgress = {
                type = "select",
                name = "As Duration Decreases...",
                desc = "Wipe the texture away as the aura runs out. The art keeps its shape and proportions — it is CLIPPED along the chosen axis, not squashed — so detailed art and rotated art both stay readable. Meant for auras that actually expire; on a permanent buff there is nothing to drain.",
                order = 12.61,
                width = 1.2,
                values = {
                    none   = "Do nothing",
                    width  = "Decrease Width",
                    height = "Decrease Height",
                },
                sorting = { "none", "width", "height" },
                hidden = function()
                    return isCustomKind or not styleIs("texture")
                end,
                get = function()
                    local e = el()
                    return (e and e.textureProgress) or "none"
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    local new = (val ~= "none") and val or nil
                    -- Drop the edge only on a real AXIS change: "right" is
                    -- meaningless once the art drains vertically. Guarded
                    -- because AceGUI fires the setter even when the user
                    -- re-picks the entry that was already selected, and an
                    -- unconditional clear would silently flip a configured
                    -- Drain From back to its default.
                    if new ~= e.textureProgress then
                        e.textureProgressFrom = nil
                    end
                    e.textureProgress = new
                    refresh()
                    Notify()  -- show/hide the Drain From dropdown
                end,
            },
            textureProgressFrom = {
                type = "select",
                name = "Drain From",
                desc = "The edge the art retreats FROM. The opposite edge stays pinned, so that end of the texture is the last to disappear.",
                order = 12.62,
                width = 1.0,
                values = function()
                    local e = el()
                    return (e and e.textureProgress == "height")
                        and TEX_DRAIN_V_VALUES or TEX_DRAIN_H_VALUES
                end,
                sorting = function()
                    local e = el()
                    return (e and e.textureProgress == "height")
                        and TEX_DRAIN_V_SORTING or TEX_DRAIN_H_SORTING
                end,
                hidden = function()
                    if isCustomKind or not styleIs("texture") then return true end
                    local e = el()
                    local m = e and e.textureProgress
                    return not (m == "width" or m == "height")
                end,
                -- Validated against the AXIS, matching the two render paths
                -- (ApplyTextureProgress / ApplyPreviewTextureProgress): an
                -- imported or hand-edited profile can hold "right" on a
                -- vertical drain, and returning a key the values table does
                -- not contain leaves the dropdown blank while the tracker
                -- quietly drains from the axis default.
                get = function()
                    local e = el()
                    if not e then return "right" end
                    local v = e.textureProgressFrom
                    if e.textureProgress == "height" then
                        return v == "bottom" and "bottom" or "top"
                    end
                    return v == "left" and "left" or "right"
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.textureProgressFrom = val
                    refresh()
                end,
            },
            -- Shared visibility for the two soft-edge controls: only while a
            -- drain axis is actually selected.
            textureProgressFade = {
                type = "toggle",
                name = "Soft Edge",
                desc = "Fade the art out at the draining edge instead of cutting it off, so the texture dissolves as the duration runs down rather than showing a hard line.",
                order = 12.63,
                hidden = function()
                    if isCustomKind or not styleIs("texture") then return true end
                    local e = el()
                    local m = e and e.textureProgress
                    return not (m == "width" or m == "height")
                end,
                get = function()
                    local e = el()
                    return (e and e.textureProgressFade) and true or false
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    -- nil, not false, to keep saved profiles sparse.
                    e.textureProgressFade = val and true or nil
                    refresh()
                    Notify()  -- show/hide the width slider
                end,
            },
            textureProgressFadeSize = {
                type = "range",
                name = "Soft Edge Width",
                desc = "How many pixels the fade spans, measured back from the draining edge into the surviving art.",
                order = 12.64,
                min = 2, max = 128, step = 1,
                hidden = function()
                    if isCustomKind or not styleIs("texture") then return true end
                    local e = el()
                    local m = e and e.textureProgress
                    if not (m == "width" or m == "height") then return true end
                    return not e.textureProgressFade
                end,
                get = function()
                    local e = el()
                    return (e and tonumber(e.textureProgressFadeSize)) or 24
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.textureProgressFadeSize = math.floor(val + 0.5)
                    refreshThrottled()
                end,
            },

            -- Border (icon type)
            borderHeader = {
                type = "header",
                name = "Border Settings",
                order = 13,
                -- Animate Border IS the border (parent-styled, animated).
                hidden = function()
                    return pairIconBorderMode() or not styleIs("icon")
                end,
            },
            borderType = {
                type = "select",
                name = "Border Style",
                desc = "Blizzard-Style keeps the icon's default border. Rounded crops it and draws the rounded frame instead, with the icon masked to match; Thick is the same frame ~1px heavier. None zooms the icon to hide it. Square draws a flat colored border.",
                order = 14,
                values = {
                    blizzard      = "Blizzard-Style",
                    rounded       = "Rounded",
                    rounded_thick = "Rounded (Thick)",
                    none          = "None",
                    square        = "Square",
                },
                sorting = { "rounded", "rounded_thick", "square", "blizzard", "none" },
                hidden = function() return pairIconBorderMode() or not styleIs("icon") end,
                get = function() local e = el(); return ns.BorderType(e and e.style, e) end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.borderType = val
                    refresh()
                end,
            },
            iconRoundedColor = {
                type = "color",
                name = "Border Color",
                desc = "Tint of the rounded frame.",
                order = 14.5,
                hasAlpha = true,
                hidden = function()
                    if pairIconBorderMode() then return true end
                    local e = el()
                    return not (styleIs("icon") and ns.IsRoundedBorder(ns.BorderType(e and e.style, e)))
                end,
                get = function()
                    local e = el()
                    local c = (e and e.borderColor) or { 0.815686, 0.815686, 0.815686, 0.85 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.borderColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },
            borderThickness = {
                type = "range",
                name = "Border Thickness",
                desc = "Thickness of the square border in pixels.",
                order = 15,
                min = 1, max = 8, step = 1,
                hidden = function()
                    if pairIconBorderMode() then return true end
                    local e = el()
                    return not (styleIs("icon") and e and e.borderType == "square")
                end,
                get = function() local e = el(); return (e and e.borderThickness) or 1 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.borderThickness = val
                    refreshThrottled()
                end,
            },
            borderColor = {
                type = "color",
                name = "Border Color",
                order = 16,
                hasAlpha = true,
                hidden = function()
                    if pairIconBorderMode() then return true end
                    local e = el()
                    return not (styleIs("icon") and e and e.borderType == "square")
                end,
                get = function()
                    local e = el()
                    local c = (e and e.borderColor) or { 0.815686, 0.815686, 0.815686, 0.85 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.borderColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },

            -- Text entries (Icon, Bar and Text). Each TYPE maps to one
            -- engine binding, and a binding drives exactly ONE FontString
            -- - so a type whose binding another entry already claims
            -- drops out of the dropdown, the same rule as Tracker Type.
            -- The icon's Cooldown countdown numbers stay off; duration
            -- renders through the engine-bound FontString so it is
            -- configurable.
            textEntriesHeader = {
                type = "header",
                name = "Text",
                order = 80,
                -- Also publishes this element's text closures for the
                -- InlineGroup corner-X override below. ACD evaluates
                -- hidden only for the pane it is actually feeding, and
                -- this header sorts ahead of its sibling text groups, so
                -- the context is always the visible element's.
                hidden = function()
                    local show = hasTextStyle()
                    if show then
                        ns._activeTextCtx = {
                            hasTextStyle = hasTextStyle,
                            texts = texts,
                            refresh = refresh,
                        }
                    end
                    return not show
                end,
            },
            addText = {
                type = "execute",
                name = "+ Add Text",
                desc = "Add another text readout to this tracker.",
                -- Below the per-text groups (order 80.1 + k/100, k up to
                -- MAX_TEXTS = 3, so max 80.13) instead of above them.
                order = 80.2,
                hidden = function() return not (hasTextStyle() and CanAddText()) end,
                func = function() AddText() end,
            },


            -- Bar/Circle shared color
            colorHeader = {
                type = "header",
                name = "Style Settings",
                order = 20,
                hidden = function()
                    return not (styleIs("bar") or styleIs("circle")
                        or styleIs("texture"))
                end,
            },
            color = {
                type = "color",
                -- In the pair Border animations this tints the animated border.
                name = function()
                    return (pairBorderMode() or pairIconBorderMode())
                        and "Border Color" or "Color"
                end,
                -- On the Texture style this is the vertex color: it TINTS the
                -- art rather than replacing it (white = untinted), and the
                -- alpha channel is the texture's opacity.
                desc = function()
                    return styleIs("texture")
                        and "Tint applied to the texture. White leaves the art unchanged; the alpha channel sets its opacity."
                        or  "Color of the bar or circle."
                end,
                order = 24,
                hasAlpha = true,
                hidden = function()
                    return not (styleIs("bar") or styleIs("circle")
                        or styleIs("texture") or pairIconBorderMode())
                end,
                get = function()
                    local e = el()
                    local c = (e and e.color) or { 1, 1, 1, 1 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.color = { r, g, b, a }
                    refreshThrottled()
                    -- Texture style only: repaint the preview swatch, which
                    -- tints from el.color. Bar/circle have no swatch, so they
                    -- must not pay for a pane rebuild per drag tick.
                    if styleIs("texture") then NotifyThrottled() end
                end,
            },

            -- Bar
            barOrientation = {
                type = "select",
                name = "Bar Orientation",
                desc = "Direction of the bar fill.",
                order = 21,
                values = {
                    horizontal = "Horizontal",
                    vertical   = "Vertical",
                },
                sorting = { "horizontal", "vertical" },
                -- Locked PAIR elements mirror the parent element's geometry.
                hidden = function() return isLockedPair or not styleIs("bar") end,
                get = function() local e = el(); return (e and e.barOrientation) or "horizontal" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barOrientation = val
                    if val == "vertical" then
                        e.barWidth, e.barHeight = 20, 200
                    else
                        e.barWidth, e.barHeight = 200, 20
                    end
                    refresh()
                end,
            },
            barWidth = {
                type = "range",
                name = "Bar Width",
                order = 22,
                min = 4, max = 600, step = 1,
                hidden = function() return isLockedPair or not styleIs("bar") end,
                get = function() local e = el(); return (e and e.barWidth) or 200 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barWidth = val
                    refreshThrottled()
                end,
            },
            barHeight = {
                type = "range",
                name = "Bar Height",
                order = 23,
                min = 4, max = 600, step = 1,
                hidden = function() return isLockedPair or not styleIs("bar") end,
                get = function() local e = el(); return (e and e.barHeight) or 20 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barHeight = val
                    refreshThrottled()
                end,
            },

            barBgColor = {
                -- (Hidden in bar-pair Border mode: background is forced
                -- transparent by the animation — see pairBorderMode.)
                type = "color",
                name = "Background Color",
                desc = "Background fill behind the bar.",
                order = 25,
                hasAlpha = true,
                hidden = function() return pairBorderMode() or not styleIs("bar") end,
                get = function()
                    local e = el()
                    local bg = (e and e.barBgColor) or { 0.1, 0.1, 0.1, 0.7 }
                    return bg[1], bg[2], bg[3], bg[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.barBgColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },
            barTexture = {
                type = "select",
                name = "Bar Texture",
                desc = "Statusbar texture for the bar fill (from LibSharedMedia).",
                order = 26,
                dialogControl = "LSM30_Statusbar",
                values = function()
                    local vals = {}
                    if LSM then
                        for name in pairs(LSM:HashTable("statusbar")) do
                            vals[name] = name
                        end
                    end
                    return vals
                end,
                -- Border mode forces the outline texture.
                hidden = function() return pairBorderMode() or not styleIs("bar") end,
                get = function() local e = el(); return (e and e.barTexture) or ns.DEFAULT_BAR_TEXTURE end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barTexture = val
                    refreshThrottled()
                end,
            },

            -- Unusable styling (ID kinds only, all visual styles that draw
            -- an icon): grays out and/or color-overlays the icon while the
            -- ability/item is not usable (on cooldown or missing
            -- resources). Combat-safe boolean reads; the ID watcher
            -- re-renders on flips (enabling these arms the usable events —
            -- Core.UpdateIDReadyWatch). Ordered to land at the END of the
            -- style section for each style (icons after Cooldown Swipe,
            -- bars/circles after their fill options).
            -- Row break so the two "when Unusable" toggles start their own
            -- row rather than trailing the GCD Swipe row (see iconRecolorBreak).
            idUnusableBreak = {
                type = "description",
                name = "",
                order = function() return styleIs("icon") and 12.55 or 26.45 end,
                width = "full",
                hidden = function()
                    return not isIDKind
                        or not (styleIs("icon") or styleIs("bar") or styleIs("circle"))
                end,
            },
            idDesatUnusable = {
                type = "toggle",
                name = "Desaturate when Unusable",
                desc = "Gray out the icon while the ability/item is not usable (on cooldown or missing charges/resources). Previews show the effect while active.",
                order = function() return styleIs("icon") and 12.6 or 26.5 end,
                hidden = function()
                    return not isIDKind
                        or not (styleIs("icon") or styleIs("bar") or styleIs("circle"))
                end,
                get = function()
                    local e = el()
                    return (e and e.idDesatUnusable) and true or false
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.idDesatUnusable = val or nil
                    refresh()
                end,
            },
            idRecolorUnusable = {
                type = "toggle",
                name = "Recolor when Unusable",
                desc = "While the ability/item is not usable (on cooldown or missing charges/resources): icons get a colored overlay; bar fills are recolored (the picker's opacity applies to the icon overlay only). Previews show the effect while active.",
                order = function() return styleIs("icon") and 12.7 or 26.6 end,
                hidden = function()
                    return not isIDKind
                        or not (styleIs("icon") or styleIs("bar") or styleIs("circle"))
                end,
                get = function()
                    local e = el()
                    return (e and e.idRecolorUnusable) and true or false
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.idRecolorUnusable = val or nil
                    refresh()
                end,
            },
            idRecolorColor = {
                type = "color",
                name = "Unusable Overlay Color",
                desc = "Color (and opacity) of the unusable overlay.",
                order = function() return styleIs("icon") and 12.8 or 26.7 end,
                hasAlpha = true,
                hidden = function()
                    local e = el()
                    return not isIDKind
                        or not (styleIs("icon") or styleIs("bar") or styleIs("circle"))
                        or not (e and e.idRecolorUnusable)
                end,
                get = function()
                    local e = el()
                    local c = (e and e.idRecolorColor) or { 1, 0.15, 0.15, 0.45 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.idRecolorColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },

            -- Always-on icon styling — the NON-ability/item counterpart of
            -- the "when Unusable" options above. No state to evaluate: these
            -- apply whenever the icon is drawn, so they cost two widget
            -- setters at style time and nothing on any render path. Shown for
            -- the Icon style (the icon) and the Bar style (its icon square);
            -- the bar FILL keeps its own Color option untouched.
            -- Row break. Flow wraps only on a "full"/fill child, so a
            -- zero-height full-width description is the only reliable way to
            -- start a row. Ends the Cooldown Swipe row (Icon style) / the Show
            -- Cooldown Swipe row (Bar style) -- and in the Other tab, where
            -- neither of those exists, it ends the Icon Size row. Desaturate +
            -- Recolor + Overlay Color begin the next row. Predicate mirrors
            -- iconDesaturate exactly, or it leaves a blank row behind.
            iconRecolorBreak = {
                type = "description",
                name = "",
                order = function() return styleIs("icon") and 12.61 or 28.69 end,
                width = "full",
                hidden = function()
                    local e = el()
                    if isIDKind then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
            },
            iconDesaturate = {
                type = "toggle",
                name = "Desaturate",
                desc = "Gray out the icon whenever it is visible.",
                order = function() return styleIs("icon") and 12.62 or 28.7 end,
                hidden = function()
                    local e = el()
                    if isIDKind then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function()
                    local e = el()
                    return (e and e.iconDesaturate) and true or false
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.iconDesaturate = val or nil
                    refresh()
                end,
            },
            iconRecolor = {
                type = "toggle",
                name = "Recolor",
                desc = "Lay a colored overlay over the icon whenever it is visible.",
                order = function() return styleIs("icon") and 12.72 or 28.72 end,
                hidden = function()
                    local e = el()
                    if isIDKind then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function()
                    local e = el()
                    return (e and e.iconRecolor) and true or false
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.iconRecolor = val or nil
                    refresh()
                end,
            },
            iconTintColor = {
                type = "color",
                name = "Overlay Color",
                desc = "Color (and opacity) of the overlay laid over the icon.",
                order = function() return styleIs("icon") and 12.82 or 28.74 end,
                hasAlpha = true,
                hidden = function()
                    local e = el()
                    if isIDKind or not (e and e.iconRecolor) then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e.barShowIcon ~= false)
                end,
                get = function()
                    local e = el()
                    local c = (e and e.iconTintColor) or { 0.2, 0.45, 1, 0.45 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.iconTintColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },

            -- Icon Effect: one Blizzard-style alert over the icon — the
            -- action-button glow RING (optionally pulsing), the raid frames'
            -- MARCHING ANTS, or a FLASH pulse. Same two surfaces and the same
            -- hidden predicate as Recolor above: the Icon style's icon and the
            -- Bar style's icon square, non-ability/item kinds only.
            --
            -- Ability/item trackers are excluded for the same reason Recolor
            -- is: their icons are holder-drawn art, not engine aura buttons.
            -- Pair "Animate Border" is excluded because it hides the icon.
            -- Cost is Show/Hide + geometry setters at style time; the
            -- animation groups run only while an animated effect is picked
            -- (see ApplyIconEffect in Tracker.lua).
            -- Row break. AceConfig's Flow layout wraps only on a "full"/fill
            -- child, so a zero-height full-width description is the one
            -- reliable way to start a new row: Desaturate + Recolor + Overlay
            -- Color end here, Icon Effect begins the next row. Carries the same
            -- hidden predicate as the options it separates, or it would leave a
            -- blank row behind on ability/item trackers.
            iconEffectBreak = {
                type = "description",
                name = "",
                order = function() return styleIs("icon") and 12.835 or 28.755 end,
                width = "full",
                hidden = function()
                    local e = el()
                    if isIDKind or isCustomKind or pairIconBorderMode() then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
            },
            iconEffect = {
                type = "select",
                name = "Icon Effect",
                desc = "An animated alert drawn over the icon whenever it is visible. Glow is the action-button alert ring; Marching Ants and Flash are the same alerts the default raid frames use for tracked buffs. All three animate in combat.",
                order = function() return styleIs("icon") and 12.84 or 28.76 end,
                width = 1.1,
                values = {
                    none  = "None",
                    glow  = "Glow",
                    ants  = "Marching Ants",
                    flash = "Flash",
                },
                sorting = { "none", "glow", "ants", "flash" },
                hidden = function()
                    local e = el()
                    if isIDKind or isCustomKind or pairIconBorderMode() then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function() local e = el(); return (e and e.iconEffect) or "none" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.iconEffect = (val ~= "none") and val or nil
                    refresh()
                    Notify()  -- reveal/hide the Glow Style + color widgets
                end,
            },
            -- Glow sub-style. Only these two exist by design: every other
            -- glow style (pixel / autocast / proc) is animated by an OnUpdate
            -- script, and engine-owned aura buttons and their descendants sit
            -- in the forbidden partition where SetScript is refused. A pulse
            -- is expressible because an ANIMATION GROUP is declarative — the
            -- engine steps it, no script runs.
            iconGlowStyle = {
                type = "select",
                name = "Glow Style",
                desc = "Steady draws the ring at a constant strength. Pulsing fades it in and out. Other glow styles are not available on aura icons — they are driven by per-frame scripts, which the engine refuses on its own aura buttons.",
                order = function() return styleIs("icon") and 12.85 or 28.77 end,
                width = 1.1,
                values = { ring = "Steady", pulse = "Pulsing" },
                sorting = { "ring", "pulse" },
                hidden = function()
                    local e = el()
                    if isIDKind or isCustomKind or pairIconBorderMode() then return true end
                    if not (e and e.iconEffect == "glow") then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e.barShowIcon ~= false)
                end,
                get = function() local e = el(); return (e and e.iconGlowStyle) or "ring" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.iconGlowStyle = (val ~= "ring") and val or nil
                    refresh()
                end,
            },
            iconEffectColor = {
                type = "color",
                name = "Effect Color",
                desc = "Color of the selected effect. For Flash the opacity sets how bright the pulse peaks; for Glow and Marching Ants it tints the art directly.",
                order = function() return styleIs("icon") and 12.86 or 28.78 end,
                hasAlpha = true,
                hidden = function()
                    local e = el()
                    if isIDKind or isCustomKind or pairIconBorderMode() then return true end
                    local fx = e and e.iconEffect
                    if not fx or fx == "none" then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e.barShowIcon ~= false)
                end,
                get = function()
                    local e = el()
                    local c = (e and e.iconEffectColor) or { 1, 0.82, 0.25, 1 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.iconEffectColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },

            -- Pandemic Effect. AuraButton:AddPandemicRegion hands the engine a
            -- texture it renders only while the aura sits inside its pandemic
            -- (refresh) window. The window is computed ENGINE-side against the
            -- secret remaining duration, so there is no threshold to configure
            -- here and nothing polls — which is also why this cannot be
            -- offered for ability/item trackers (no aura, no pandemic) or in
            -- pair "Animate Border" mode (no visible icon).
            -- Row break before Pandemic Effect (see iconEffectBreak).
            pandemicBreak = {
                type = "description",
                name = "",
                order = function() return styleIs("icon") and 12.865 or 28.785 end,
                width = "full",
                hidden = function()
                    local e = el()
                    if isIDKind or isCustomKind or pairIconBorderMode() then return true end
                    -- Pre-built trackers track a fixed curated aura, and none
                    -- of them is a HoT/DoT -- so the engine would never open a
                    -- pandemic window for them and the option would sit there
                    -- doing nothing. User-created aura trackers only.
                    if not IsUserCustomCfg(cfgKey) then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
            },
            pandemicEffect = {
                type = "toggle",
                name = "Pandemic Effect",
                desc = "Recolor the icon when the aura reaches its pandemic window (<30% duration). Only applies to auras that have a pandemic refresh window (HoTs and DoTs).",
                order = function() return styleIs("icon") and 12.87 or 28.79 end,
                hidden = function()
                    local e = el()
                    if isIDKind or isCustomKind or pairIconBorderMode() then return true end
                    -- Pre-built trackers track a fixed curated aura, and none
                    -- of them is a HoT/DoT -- so the engine would never open a
                    -- pandemic window for them and the option would sit there
                    -- doing nothing. User-created aura trackers only.
                    if not IsUserCustomCfg(cfgKey) then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function()
                    local e = el()
                    return (e and e.pandemicEffect) and true or false
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.pandemicEffect = val or nil
                    refresh()
                    Notify()  -- reveal/hide the color picker
                end,
            },
            pandemicColor = {
                type = "color",
                name = "Pandemic Color",
                desc = "Color (and opacity) of the pandemic tint.",
                order = function() return styleIs("icon") and 12.88 or 28.795 end,
                hasAlpha = true,
                hidden = function()
                    local e = el()
                    if isIDKind or isCustomKind or pairIconBorderMode() then return true end
                    -- Pre-built trackers track a fixed curated aura, and none
                    -- of them is a HoT/DoT -- so the engine would never open a
                    -- pandemic window for them and the option would sit there
                    -- doing nothing. User-created aura trackers only.
                    if not IsUserCustomCfg(cfgKey) then return true end
                    if not (e and e.pandemicEffect) then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e.barShowIcon ~= false)
                end,
                get = function()
                    local e = el()
                    -- Keep in step with PANDEMIC_DEFAULT_COLOR in Tracker.lua:
                    -- the getter's fallback IS what an unset element renders.
                    local c = (e and e.pandemicColor) or { 0.239216, 1, 0.254902, 0.15 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.pandemicColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },

            -- Bar border (same fields as the icon border, bar-specific
            -- labels/ordering)
            barBorderHeader = {
                type = "header",
                name = "Border Settings",
                order = 27,
                -- Border mode IS the border (animated); no static one.
                hidden = function() return pairBorderMode() or not styleIs("bar") end,
            },
            barBorderType = {
                type = "select",
                name = "Border Style",
                desc = "Rounded draws the rounded bar frame, with the fill and background masked to match; Thick is the same frame ~1px heavier. None leaves the bar bare. Square draws a flat colored border.",
                order = 27.1,
                values = {
                    rounded       = "Rounded",
                    rounded_thick = "Rounded (Thick)",
                    none          = "None",
                    square        = "Square",
                },
                sorting = { "rounded", "rounded_thick", "square", "none" },
                hidden = function() return pairBorderMode() or not styleIs("bar") end,
                get = function() local e = el(); return ns.BorderType(e and e.style, e) end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.borderType = val
                    refresh()
                end,
            },
            -- Shared with the square Border Color (both read/write
            -- e.borderColor) so switching Border Type keeps the same color.
            barRoundedColor = {
                type = "color",
                name = "Border Color",
                desc = "Tint of the rounded frame.",
                order = 27.15,
                hasAlpha = true,
                hidden = function()
                    if pairBorderMode() then return true end
                    local e = el()
                    return not (styleIs("bar") and ns.IsRoundedBorder(ns.BorderType(e and e.style, e)))
                end,
                get = function()
                    local e = el()
                    local c = (e and e.borderColor) or { 0.815686, 0.815686, 0.815686, 0.85 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.borderColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },
            barBorderThickness = {
                type = "range",
                name = "Border Thickness",
                desc = "Thickness of the square border in pixels.",
                order = 27.2,
                min = 1, max = 8, step = 1,
                hidden = function()
                    if pairBorderMode() then return true end
                    local e = el()
                    return not (styleIs("bar") and e and e.borderType == "square")
                end,
                get = function() local e = el(); return (e and e.borderThickness) or 1 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.borderThickness = val
                    refreshThrottled()
                end,
            },
            barBorderColor = {
                type = "color",
                name = "Border Color",
                order = 27.3,
                hasAlpha = true,
                hidden = function()
                    if pairBorderMode() then return true end
                    local e = el()
                    return not (styleIs("bar") and e and e.borderType == "square")
                end,
                get = function()
                    local e = el()
                    local c = (e and e.borderColor) or { 0.815686, 0.815686, 0.815686, 0.85 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.borderColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },

            -- Bar icon
            barIconHeader = {
                type = "header",
                name = "Icon",
                order = 28,
                hidden = function() return not styleIs("bar") end,
            },
            barShowIcon = {
                type = "toggle",
                name = "Show Icon",
                desc = "Show the buff's icon at the end of the bar.",
                order = 28.1,
                -- PAIR elements mirror the parent element's geometry (the icon
                -- square changes the holder footprint).
                hidden = function() return isLockedPair or not styleIs("bar") end,
                get = function() local e = el(); return not e or e.barShowIcon ~= false end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barShowIcon = val
                    refresh()
                end,
            },
            -- Icon Source for the BAR's icon square (single-aura only; hidden
            -- when Show Icon is off). Writes the SAME el.auraIconMode /
            -- el.auraIconInput fields as the Icon-style dropdown, so the
            -- override logic is unified.
            barAuraIconMode = {
                type = "select",
                name = "Icon Source",
                desc = "Auto uses the tracked aura's own icon. Custom (Spell ID) shows a chosen spell's icon; Custom (Icon ID) shows a texture file ID or icon path. Only the bar's icon image changes — the aura still drives when the tracker shows and its cooldown/text.",
                order = 28.15,
                width = 1.1,
                values = {
                    auto  = "Auto (Aura Icon)",
                    spell = "Custom (Spell ID)",
                    icon  = "Custom (Icon ID)",
                },
                sorting = { "auto", "spell", "icon" },
                hidden = function()
                    local e = el()
                    return isCustomKind or isMultiAura()
                        or not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function() local e = el(); return (e and e.auraIconMode) or "auto" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.auraIconMode = val
                    e._iconBindDirty = true  -- rebind the slot icon region (see RebindSlotIcon)
                    refresh()
                    Notify()
                end,
            },
            barAuraIconInput = {
                type = "input",
                name = function()
                    local e = el()
                    return (e and e.auraIconMode == "icon") and "Icon ID" or "Spell ID"
                end,
                desc = "The icon to show instead of the aura's own icon. Interpreted per Icon Source.",
                order = 28.16,
                width = 1.1,
                hidden = function()
                    local e = el()
                    local mode = e and e.auraIconMode
                    return isCustomKind or isMultiAura()
                        or not (styleIs("bar") and e and e.barShowIcon ~= false)
                        or not (mode == "spell" or mode == "icon")
                end,
                get = function() local e = el(); return (e and e.auraIconInput) or "" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.auraIconInput = (val and val ~= "") and val or nil
                    e._iconBindDirty = true  -- rebind the slot icon region (see RebindSlotIcon)
                    refresh()
                    Notify()
                end,
            },
            barIconPosition = {
                type = "select",
                name = "Icon Position",
                desc = "Which end of the bar the icon sits on.",
                order = 28.2,
                values = function()
                    local e = el()
                    if e and e.barOrientation == "vertical" then
                        return { bottom = "Bottom", top = "Top" }
                    end
                    return { left = "Left", right = "Right" }
                end,
                sorting = function()
                    local e = el()
                    if e and e.barOrientation == "vertical" then
                        return { "bottom", "top" }
                    end
                    return { "left", "right" }
                end,
                hidden = function()
                    if isLockedPair then return true end -- mirrors parent geometry
                    local e = el()
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function()
                    local e = el()
                    local v = e and e.barIconPosition
                    if e and e.barOrientation == "vertical" then
                        return v == "top" and "top" or "bottom"
                    end
                    return v == "right" and "right" or "left"
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barIconPosition = val
                    refresh()
                end,
            },
            barIconBorderType = {
                type = "select",
                name = "Icon Border Style",
                desc = "Blizzard-Style keeps the icon's default border. Rounded masks the icon to a rounded rect and frames it; Thick is the same frame ~1px heavier. None zooms the icon to hide its border. Square draws a flat colored border.",
                order = 28.3,
                values = {
                    blizzard      = "Blizzard-Style",
                    rounded       = "Rounded",
                    rounded_thick = "Rounded (Thick)",
                    none          = "None",
                    square        = "Square",
                },
                sorting = { "rounded", "rounded_thick", "square", "blizzard", "none" },
                hidden = function()
                    local e = el()
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function() local e = el(); return (e and e.barIconBorderType) or "rounded" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barIconBorderType = val
                    refresh()
                end,
            },
            barIconRoundedColor = {
                type = "color",
                name = "Icon Border Color",
                desc = "Color of the border around the bar's icon.",
                order = 28.35,
                hasAlpha = true,
                hidden = function()
                    local e = el()
                    return not (styleIs("bar") and e and e.barShowIcon ~= false
                        and ns.IsRoundedBorder(e.barIconBorderType))
                end,
                -- Shared with the square Icon Border Color (both read/write
                -- e.barIconBorderColor) so switching border style keeps it.
                get = function()
                    local e = el()
                    local c = (e and e.barIconBorderColor) or { 0.815686, 0.815686, 0.815686, 0.85 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.barIconBorderColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },
            barIconBorderThickness = {
                type = "range",
                name = "Icon Border Thickness",
                desc = "Thickness of the square icon border in pixels.",
                order = 28.4,
                min = 1, max = 8, step = 1,
                hidden = function()
                    local e = el()
                    return not (styleIs("bar") and e and e.barShowIcon ~= false
                        and e.barIconBorderType == "square")
                end,
                get = function() local e = el(); return (e and e.barIconBorderThickness) or 1 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barIconBorderThickness = val
                    refreshThrottled()
                end,
            },
            barIconBorderColor = {
                type = "color",
                name = "Icon Border Color",
                order = 28.5,
                hasAlpha = true,
                hidden = function()
                    local e = el()
                    return not (styleIs("bar") and e and e.barShowIcon ~= false
                        and e.barIconBorderType == "square")
                end,
                get = function()
                    local e = el()
                    local c = (e and e.barIconBorderColor) or { 0.815686, 0.815686, 0.815686, 0.85 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.barIconBorderColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },
            barIconSwipe = {
                type = "toggle",
                name = "Show Cooldown Swipe",
                desc = "Darken the icon progressively as the buff runs out.",
                order = 28.6,
                hidden = function()
                    local e = el()
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function() local e = el(); return not e or e.barIconSwipe ~= false end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barIconSwipe = val
                    refresh()
                end,
            },

            -- Circle
            circleRadius = {
                type = "range",
                name = "Circle Size",
                order = 25,
                min = 50, max = 150, step = 1,
                hidden = function() return not styleIs("circle") end,
                get = function() local e = el(); return (e and e.circleRadius) or 80 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.circleRadius = val
                    refreshThrottled()
                end,
            },
            ringTexture = {
                type = "select",
                name = "Circle Thickness",
                desc = "Thickness of the circle ring.",
                order = 26,
                values = {
                    ["Ring_10px"] = "Thin (10px)",
                    ["Ring_20px"] = "Medium (20px)",
                    ["Ring_30px"] = "Thick (30px)",
                    ["Ring_40px"] = "Extra Thick (40px)",
                },
                sorting = { "Ring_10px", "Ring_20px", "Ring_30px", "Ring_40px" },
                hidden = function() return not styleIs("circle") end,
                get = function() local e = el(); return (e and e.ringTexture) or "Ring_20px" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.ringTexture = val
                    refresh()
                end,
            },

            -- Growth (multi-spell groups only: icon/bar fan out when
            -- several tracked buffs are active at once)
            growHeader = {
                type = "header",
                name = "Growth",
                order = 35,
                hidden = function()
                    return not (isMultiAura()
                        and (styleIs("icon") or styleIs("bar")))
                end,
            },
            growDirection = {
                type = "select",
                name = "Grow Direction",
                desc = "Direction additional icons/bars grow when more than one tracked buff is active. Bars stack perpendicular to their fill: horizontal bars grow down/up, vertical bars grow left/right.",
                order = 36,
                values = function()
                    local labels = { right = "Right", left = "Left", down = "Down", up = "Up" }
                    local e = el()
                    if e and e.style == "bar" then
                        if e.barOrientation == "vertical" then
                            return { left = "Left", right = "Right" }
                        end
                        return { down = "Down", up = "Up" }
                    end
                    return labels
                end,
                sorting = function()
                    local e = el()
                    if e and e.style == "bar" then
                        if e.barOrientation == "vertical" then
                            return { "left", "right" }
                        end
                        return { "down", "up" }
                    end
                    return { "right", "left", "down", "up" }
                end,
                hidden = function()
                    return not (isMultiAura()
                        and (styleIs("icon") or styleIs("bar")))
                end,
                -- Effective value: coerced to the allowed set for the
                -- current style/orientation (stored value may predate an
                -- orientation switch).
                get = function() local e = el(); return e and ns.EffectiveGrowDirection(e) end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.growDirection = val
                    refresh()
                end,
            },
            growMax = {
                type = "range",
                name = function() return styleIs("bar") and "Max Bars" or "Max Icons" end,
                -- Aura groups: hidden in Ordered mode (per-spell 1-frame groups
                -- have no engine-side total cap). Ability/Item groups: ALWAYS
                -- shown — they fan out over a fixed id list, collapse gaps via
                -- Show When, and use this as the display cap.
                desc = function()
                    if IsIDGroupCfg(cfgKey) then
                        return "Maximum number of icons/bars shown at once (the rest of the tracked list is capped off)."
                    end
                    return "Maximum number shown at once when several tracked buffs are active (Unordered group type only)."
                end,
                order = 36.5,
                min = 1, max = 10, step = 1,
                hidden = function()
                    if not (isMultiAura() and (styleIs("icon") or styleIs("bar"))) then
                        return true
                    end
                    -- Single-active groups are hard-capped to ONE frame in
                    -- ApplyGroupAssignments; a Max Icons control would be a lie.
                    if IsSingleActiveCfg(cfgKey) then return true end
                    if IsIDGroupCfg(cfgKey) then return false end  -- always capped
                    local t = ns.trackers[cfgKey]
                    return (t and t:IsOrdered()) and true or false
                end,
                get = function() local e = el(); return (e and e.maxFrames) or 3 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.maxFrames = val
                    refreshThrottled()
                end,
            },
            growSpacing = {
                type = "range",
                name = "Spacing",
                desc = "Gap between icons/bars in pixels.",
                order = 37,
                min = 0, max = 30, step = 1,
                hidden = function()
                    return not (isMultiAura()
                        and (styleIs("icon") or styleIs("bar")))
                end,
                get = function() local e = el(); return (e and e.spacing) or 2 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.spacing = val
                    refreshThrottled()
                end,
            },

            -- Sound Alert
            soundHeader = {
                type = "header",
                name = "Sound Settings",
                order = 40,
                hidden = function() return not styleIs("sound") end,
            },
            -- Sound Type: Spell Name (bundled clip) vs Custom Sound
            -- (LSM). Custom trackers have no bundled clips — the
            -- dropdown is hidden and Custom Sound shows directly.
            soundType = {
                type = "select",
                name = "Sound Type",
                desc = "Spell Name plays a bundled voice clip saying the spell name; Custom Sound plays a sound of your choice.",
                order = 41,
                values = function()
                    -- Groups with a voiceOptions list label the bundled
                    -- option "Voice" (the clip is chosen below, not fixed
                    -- to the spell name).
                    local voiceLabel = ns.trackerConfigs[cfgKey].voiceOptions
                        and "Voice" or "Spell Name"
                    return { voice = voiceLabel, custom = "Custom Sound" }
                end,
                sorting = { "voice", "custom" },
                hidden = function() return not styleIs("sound") or not HasVoice(cfgKey) end,
                get = function()
                    local e = el()
                    return (e and e.soundType) or "voice"
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.soundType = val
                    -- Sound fields feed ONLY the engine aura-sound plan; nothing
                    -- visual reads them. RefreshTracking's fleet-wide restyle
                    -- (and the three watcher rebuilds it drags along) is pure
                    -- waste here, and RefreshPreviewFor is a provable no-op:
                    -- RefreshPreview skips style == "sound" elements, which is
                    -- the only style these options are visible for.
                    -- UpdateAuraSounds keeps its own combat-lockdown deferral.
                    HET:UpdateAuraSounds()
                end,
            },
            voiceShort = {
                type = "toggle",
                name = "Short Callouts",
                desc = "Use the short voice callouts (e.g. \"Bop\", \"Sac\", \"Pain Supp\") instead of full spell names. Spells without a short clip use their full one.",
                order = 41.5,
                hidden = function()
                    local e = el()
                    return not (styleIs("sound")
                        and HasVoice(cfgKey)
                        and e and (e.soundType or "voice") == "voice"
                        and ns.trackerConfigs[cfgKey].spellList
                        and not ns.trackerConfigs[cfgKey].voiceOptions)
                end,
                get = function() local e = el(); return e and e.voiceShort end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.voiceShort = val
                    HET:UpdateAuraSounds()   -- sound-only; see soundType above
                end,
            },
            -- Voice clip picker for voiceOptions groups (e.g. Time Spiral:
            -- "Time Spiral" or "Free Movement"). Shown only in voice mode.
            voiceChoice = {
                type = "select",
                name = "Voice Clip",
                desc = "Which voice clip to play when the buff is applied.",
                order = 41.6,
                values = function()
                    local opts = ns.trackerConfigs[cfgKey].voiceOptions
                    local t = {}
                    if opts then
                        for _, o in ipairs(opts) do t[o.value] = o.name end
                    end
                    return t
                end,
                sorting = function()
                    local opts = ns.trackerConfigs[cfgKey].voiceOptions
                    local t = {}
                    if opts then
                        for _, o in ipairs(opts) do t[#t + 1] = o.value end
                    end
                    return t
                end,
                hidden = function()
                    local cfg = ns.trackerConfigs[cfgKey]
                    if not (styleIs("sound") and cfg.voiceOptions) then return true end
                    local e = el()
                    return not (e and (e.soundType or "voice") == "voice")
                end,
                get = function()
                    local e = el()
                    local opts = ns.trackerConfigs[cfgKey].voiceOptions
                    return (e and e.voiceChoice) or (opts and opts[1] and opts[1].value)
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.voiceChoice = val
                    HET:UpdateAuraSounds()   -- sound-only; see soundType above
                end,
            },
            soundName = {
                type = "select",
                name = "Custom Sound",
                desc = "Sound to play when the buff is applied.",
                dialogControl = "LSM30_Sound",
                order = 41.7,
                width = 1.5,
                values = GetCustomSoundValues,
                hidden = function()
                    if not styleIs("sound") then return true end
                    if not HasVoice(cfgKey) then return false end
                    local e = el()
                    return (e and e.soundType or "voice") ~= "custom"
                end,
                get = function() local e = el(); return (e and e.soundName) or "None" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.soundName = val
                    HET:UpdateAuraSounds()   -- sound-only; see soundType above
                end,
            },
            soundChannel = {
                type = "select",
                name = "Channel",
                desc = "Audio channel to play the alert on.",
                order = 42,
                values = {
                    Master = "Master",
                    SFX    = "Sound Effects",
                    Dialog = "Dialog",
                },
                sorting = { "Master", "SFX", "Dialog" },
                hidden = function() return not styleIs("sound") end,
                get = function() local e = el(); return (e and e.soundChannel) or "Master" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.soundChannel = val
                    HET:UpdateAuraSounds()   -- sound-only; see soundType above
                end,
            },
            soundTest = {
                type = "execute",
                name = "Test",
                desc = "Preview this sound.",
                order = 43,
                width = "half",
                hidden = function() return not styleIs("sound") end,
                func = function()
                    local e = el()
                    local t = ns.trackers[cfgKey]
                    if not (e and t) then return end
                    local path = ns.ResolveElementSoundPath(t, e)
                    if path then
                        PlaySoundFile(path, e.soundChannel or "Master")
                    end
                end,
            },

            -- Position
            positionHeader = {
                type = "header",
                name = "Position",
                order = 90,
                -- Locked PAIR elements are position-locked over their parent.
                hidden = function() return isLockedPair or not hasVisualStyle() end,
            },
            resetPosition = {
                type = "execute",
                name = "Reset Position",
                desc = "Reset this tracker's position to the center of the screen.",
                order = 92,
                hidden = function() return isLockedPair or not hasVisualStyle() end,
                func = function()
                    local t = ns.trackers[cfgKey]
                    if t then
                        t:ResetElementPosition(i)
                        t:RefreshPreview()
                        ns.NotifyPositionChanged()
                    end
                end,
            },
            -- Centre one axis and leave the other where it is -- the common
            -- case of "line this up with the middle of the screen" without
            -- losing the height (or the side) already dialled in.
            centerHorizontal = {
                type = "execute",
                name = "Center Horizontal",
                desc = "Centre this tracker across the screen, keeping its current height.",
                order = 93,
                hidden = function() return isLockedPair or not hasVisualStyle() end,
                func = function()
                    local t = ns.trackers[cfgKey]
                    if t then
                        t:CenterElementPosition(i, "h")
                        t:RefreshPreview()
                        ns.NotifyPositionChanged()
                    end
                end,
            },
            centerVertical = {
                type = "execute",
                name = "Center Vertical",
                desc = "Centre this tracker vertically, keeping its current horizontal position.",
                order = 94,
                hidden = function() return isLockedPair or not hasVisualStyle() end,
                func = function()
                    local t = ns.trackers[cfgKey]
                    if t then
                        t:CenterElementPosition(i, "v")
                        t:RefreshPreview()
                        ns.NotifyPositionChanged()
                    end
                end,
            },
            -- X / Y sliders, below the three buttons.
            --
            -- Range follows the screen, re-read in the GETTER (which ACD calls
            -- on every feed) rather than baked at build time: the options table
            -- is built once and cached, so a resolution or UI-scale change
            -- would otherwise leave the slider clamped to the old screen.
            -- softMin/softMax rather than min/max so a user who wants a
            -- position further out -- a multi-monitor or heavily scaled setup --
            -- can still type one into the edit box.
            --
            -- The value is a CENTRE-relative offset; the setter normalises the
            -- stored anchor to CENTER/CENTER so the number always means the
            -- same thing (see methods:SetElementOffset).
            positionX = {
                type = "range",
                name = "X Position",
                desc = "Horizontal position of this tracker, relative to the centre of the screen.",
                order = 95,
                -- Evaluated HERE, at build time, and deliberately not from
                -- inside get(): AceConfigDialog reads the bounds off the node
                -- before it calls the getter, so the original
                -- info.option.softMin assignment never reached the widget.
                -- Plain numbers, not functions -- AceConfigRegistry types these
                -- as optnumber (nil|number) and a function fails validation
                -- outright, taking the whole panel with it.
                -- Re-read per feed regardless, because BuildOptions is
                -- registered as a GENERATOR and re-runs on every one, so a
                -- resolution or UI-scale change is picked up on the next open.
                -- SOFT bounds, so a multi-monitor or heavily scaled setup can
                -- still type a value past the screen edge.
                softMin = -math.floor((GetScreenWidth() or 2048) / 2),
                softMax = math.floor((GetScreenWidth() or 2048) / 2),
                step = 1,
                hidden = function() return isLockedPair or not hasVisualStyle() end,
                get = function()
                    local t = ns.trackers[cfgKey]
                    if not t then return 0 end
                    local x = t:GetElementOffset(i)
                    return math.floor((x or 0) + 0.5)
                end,
                set = function(_, val)
                    local t = ns.trackers[cfgKey]
                    if not t then return end
                    -- No refresh of any kind: SetElementOffset re-anchors the
                    -- holder and the preview art is parented to it, so it moves
                    -- with it. A range slider fires its setter on every drag
                    -- tick, and RefreshPreview opens with an unconditional
                    -- ApplyStyle over all six elements -- which is what makes
                    -- the other continuous controls here use refreshThrottled.
                    t:SetElementOffset(i, "x", math.floor(val + 0.5))
                end,
            },
            positionY = {
                type = "range",
                name = "Y Position",
                desc = "Vertical position of this tracker, relative to the centre of the screen.",
                order = 96,
                -- Evaluated HERE, at build time, and deliberately not from
                -- inside get(): AceConfigDialog reads the bounds off the node
                -- before it calls the getter, so the original
                -- info.option.softMin assignment never reached the widget.
                -- Plain numbers, not functions -- AceConfigRegistry types these
                -- as optnumber (nil|number) and a function fails validation
                -- outright, taking the whole panel with it.
                -- Re-read per feed regardless, because BuildOptions is
                -- registered as a GENERATOR and re-runs on every one, so a
                -- resolution or UI-scale change is picked up on the next open.
                -- SOFT bounds, so a multi-monitor or heavily scaled setup can
                -- still type a value past the screen edge.
                softMin = -math.floor((GetScreenHeight() or 1152) / 2),
                softMax = math.floor((GetScreenHeight() or 1152) / 2),
                step = 1,
                hidden = function() return isLockedPair or not hasVisualStyle() end,
                get = function()
                    local t = ns.trackers[cfgKey]
                    if not t then return 0 end
                    local _, y = t:GetElementOffset(i)
                    return math.floor((y or 0) + 0.5)
                end,
                set = function(_, val)
                    local t = ns.trackers[cfgKey]
                    if not t then return end
                    -- No refresh of any kind: SetElementOffset re-anchors the
                    -- holder and the preview art is parented to it, so it moves
                    -- with it. A range slider fires its setter on every drag
                    -- tick, and RefreshPreview opens with an unconditional
                    -- ApplyStyle over all six elements -- which is what makes
                    -- the other continuous controls here use refreshThrottled.
                    t:SetElementOffset(i, "y", math.floor(val + 0.5))
                end,
            },
        },
    }

    -- Per-text inline groups, generated from the element's texts array.
    -- BuildOptions is registered as a FUNCTION, so the whole table is
    -- rebuilt on every NotifyChange and dynamic keys are safe.
    if e0 and (e0.style == "icon" or e0.style == "bar" or e0.style == "text") then
        local t0 = ns.EnsureTexts(e0)
        for k = 1, #t0 do
            group.args["text" .. k] = BuildTextGroup(k)
        end
    end

    return group
end

-------------------------------------------------------------------------------
-- Per-spell group (tree node with element subtabs)
-------------------------------------------------------------------------------
local function BuildSpellGroup(cfgKey, prefix, displayName, iconTex, order)
    local function P(key) return HET.db.profile[prefix .. key] end
    local function SetP(key, val) HET.db.profile[prefix .. key] = val end
    local function isDisabled() return not P("enabled") end
    local groupKey = NodeFor(cfgKey)
    local cfgIsCustom = ns.trackerConfigs[cfgKey].custom and true or false
    -- Show Previews is pointless without at least one VISUAL tracker.
    local function hasVisualTracker()
        local els = GetEls(prefix)
        for j = 1, #els do
            local s = els[j].style
            if s and s ~= "sound" then return true end
        end
        return false
    end

    -- Adding is allowed while there's an unclaimed (allowed) type — or, on
    -- compound trackers, while a deleted "(Active)" overlay could be
    -- re-added for an existing Icon/Bar. PAIR elements don't claim a type.
    local function CanAddTracker()
        local els = GetEls(prefix)
        if #els >= MAX_ELEMENTS then return false end
        local n = 0
        for j = 1, #els do
            if not els[j].pairFor then n = n + 1 end
        end
        if n < #AllowedTypeSorting(cfgKey) then return true end
        if PairableFor(cfgKey, prefix) then
            for k in pairs(PAIRABLE_STYLES) do
                if FindParentIndex(els, k) and not FindPairIndex(els, k) then
                    return true
                end
            end
            -- Standalone Text (Active): addable whenever missing.
            if not FindPairIndex(els, "text") then return true end
        end
        return false
    end

    -- Tracker list in its own column: nested trees MERGE into the main
    -- left tree, so the spell group is a TAB group (breaks the tree
    -- chain) holding one "Trackers" child whose childGroups="tree"
    -- renders the tracker entries as a vertical list column inside the
    -- pane (tab group → tree group → entries).
    local group = {
        type = "group",
        name = displayName,
        icon = iconTex,
        order = order,
        childGroups = "tab",
        args = {
            enableToggle = {
                type = "toggle",
                name = "Enable",
                desc = "Enable or disable the " .. displayName .. " tracker.",
                order = 1,
                -- Compact on custom pages so Enable + Name + Show
                -- Previews share the top row.
                width = cfgIsCustom and 0.6 or 1.1,
                get = function() return P("enabled") end,
                set = function(_, val)
                    SetP("enabled", val)
                    if not val then
                        HET:HidePreviewFor(cfgKey)
                    end
                    HET:RefreshTracking()
                    if val then
                        -- Enabling turns the tracker's previews ON (same rule
                        -- as adding a new tracker). After RefreshTracking so a
                        -- first enable has preset-seeded its elements.
                        HET:ShowPreviewFor(cfgKey)
                    end
                    -- Rebuild so the row restyles (a disabled tracker reads
                    -- greyed). NO re-select, even under the "Active First"
                    -- sort where the row DOES move to the bottom of its node:
                    -- ACD re-runs SelectByValue on every feed, which scrolls
                    -- the tree to the still-selected row wherever it landed.
                    -- SelectGroup would only add a synchronous options build.
                    Notify()
                end,
            },
            groupTypeBreak = {
                type = "description",
                name = "",
                order = 3.9,
                width = "full",
                hidden = function()
                    return isDisabled() or not IsMultiAuraCfg(cfgKey)
                        or IsSingleActiveCfg(cfgKey)
                        or ns.trackerConfigs[cfgKey].spellList ~= nil
                end,
            },
            groupType = {
                type = "select",
                name = "Group Type",
                desc = "Unordered shows active buffs in the engine's order with a Max Icons/Bars cap. Ordered shows them in the Tracked Buffs list order (reorder with the arrows); no total cap is available in that mode. The built-in defensive groups are always Unordered.",
                order = 4,
                values = {
                    unordered = "Unordered",
                    ordered   = "Ordered",
                },
                sorting = { "unordered", "ordered" },
                -- Built-in group trackers (spellList) are locked to Unordered,
                -- so the dropdown is hidden for them. Ability/Item groups have NO
                -- Unordered mode (always ordered — see methods:IsOrdered), so the
                -- dropdown is hidden for them too.
                hidden = function()
                    return isDisabled() or not IsMultiAuraCfg(cfgKey)
                        or IsSingleActiveCfg(cfgKey)
                        or ns.trackerConfigs[cfgKey].spellList ~= nil
                        or IsIDGroupCfg(cfgKey)
                end,
                get = function()
                    if ns.trackerConfigs[cfgKey].spellList then
                        return "unordered"
                    end
                    return P("groupOrdered") and "ordered" or "unordered"
                end,
                set = function(_, val)
                    SetP("groupOrdered", (val == "ordered") and true or nil)
                    -- Toggling Ordered restructures the options tree (per-row
                    -- reorder arrows appear, multi-aura element visibility
                    -- flips). Running RefreshTracking + NotifyChange
                    -- SYNCHRONOUSLY here rebuilds and RELEASES the very dropdown
                    -- widget that is still executing, so AceConfigDialog then
                    -- reads user.rootframe (nil) on the released widget and
                    -- errors ("attempt to index field 'rootframe'"). Defer to the
                    -- next tick so the widget finishes its OnClick first, THEN
                    -- rebuild. (C_Timer.After(0) = next frame.)
                    C_Timer.After(0, function()
                        HET:RefreshTracking()
                        HET:RefreshPreviewFor(cfgKey)
                        Notify()
                    end)
                end,
            },
            -- Duplicate: creates a copy of this custom tracker with all the same
            -- trackers/settings, but a blank Label and Spell ID (see
            -- DuplicateCustom). Preview/move now lives on the eye icons, so the
            -- old "Move/Preview" button was removed. Custom trackers only.
            duplicateTracker = {
                type = "execute",
                name = "Copy Tracker",
                desc = "Create a copy of this tracker with all the same trackers and settings, but a blank Label and Spell ID.",
                -- Ahead of the Label block (1.4+), not after it: Label starts
                -- its own row, and this is the last control on the row above.
                -- Only ever visible on custom trackers, so the move cannot
                -- disturb any other tab.
                order = 1.35,
                width = cfgIsCustom and 0.85 or nil,
                hidden = function()
                    return isDisabled() or not cfgIsCustom
                end,
                func = function()
                    DuplicateCustom(cfgKey)
                end,
            },
        },
    }

    -- (The old Spatial-only "Enable for DPS Evoker" toggle was removed on
    -- 12.1 — Show Conditions → Spec covers the same opt-out.)

    -- Tracker entries (dynamic — the options table is a generator, so
    -- Add/Remove rebuild this list via NotifyChange). They live in a
    -- single "Trackers" tab whose childGroups="tree" gives them their
    -- own vertical list column inside the pane.
    local trackerArgs = {}
    for i = 1, #GetEls(prefix) do
        trackerArgs["el" .. i] = BuildElementGroup(cfgKey, prefix, i, isDisabled)
    end

    -- "+ Add Tracker" entry: its pane IS the new-tracker page — a
    -- Tracker Type dropdown (only unclaimed types listed); choosing a
    -- type creates the tracker and selects its entry. No render-time
    -- side effects, so ACD's fallback selection landing here (e.g. when
    -- the list is empty) is harmless and shows exactly this page.
    local function ClaimedTypes()
        local used = {}
        local els = GetEls(prefix)
        for j = 1, #els do
            -- PAIR "(Active)" elements don't claim a type (they share their
            -- parent's; an orphan pair must not block re-adding the parent).
            if els[j].style and not els[j].pairFor then used[els[j].style] = true end
        end
        return used
    end
    trackerArgs.addTab = {
        type = "group",
        name = "|cffffd100+ Add Tracker|r",
        order = 999,
        hidden = function() return isDisabled() or not CanAddTracker() end,
        args = {
            -- Nudge shown only while this aura has no trackers yet, so a fresh
            -- aura reads as "you still need to pick a type".
            emptyNote = {
                type = "description",
                name = "|cffff4444No trackers defined for this aura: select a tracker type.|r",
                order = 1,
                width = "full",
                hidden = function()
                    local els = GetEls(prefix)
                    for j = 1, #els do
                        if els[j].style then return true end
                    end
                    return false
                end,
            },
            newType = {
                type = "select",
                name = "Tracker Type",
                desc = "Visual types show while the buff is active; Sound Alert plays a sound when the buff is applied. Only one tracker of each type per spell.",
                order = 2,
                values = function()
                    local used = ClaimedTypes()
                    local v = {}
                    for _, k in ipairs(AllowedTypeSorting(cfgKey)) do
                        if not used[k] then v[k] = StyleLabel(cfgKey, k) end
                    end
                    -- Compound trackers: a deleted "(Active)" overlay can be
                    -- re-added while its parent Icon/Bar exists, and the
                    -- standalone Text (Active) is offered regardless of any
                    -- regular Text element.
                    if PairableFor(cfgKey, prefix) then
                        local els = GetEls(prefix)
                        for k in pairs(PAIRABLE_STYLES) do
                            if FindParentIndex(els, k) and not FindPairIndex(els, k) then
                                v["pair_" .. k] = StyleLabel(cfgKey, k) .. " (Active)"
                            end
                        end
                        if not FindPairIndex(els, "text") then
                            v.pair_text = "Text (Active)"
                        end
                    end
                    return v
                end,
                sorting = function()
                    local used = ClaimedTypes()
                    local order = {}
                    local els = GetEls(prefix)
                    local pairable = PairableFor(cfgKey, prefix)
                    for _, k in ipairs(AllowedTypeSorting(cfgKey)) do
                        if not used[k] then order[#order + 1] = k end
                        -- "(Active)" entries sort right after their type.
                        if pairable and PAIRABLE_STYLES[k]
                            and FindParentIndex(els, k) and not FindPairIndex(els, k) then
                            order[#order + 1] = "pair_" .. k
                        end
                        if pairable and k == "text"
                            and not FindPairIndex(els, "text") then
                            order[#order + 1] = "pair_text"
                        end
                    end
                    return order
                end,
                get = function() return nil end,
                set = function(_, val)
                    -- "(Active)" overlay re-add for an existing Icon/Bar on a
                    -- compound tracker.
                    local pairStyle = val:match("^pair_(%a+)$")
                    if pairStyle then
                        local els = EnsureEls(prefix)
                        -- Text (Active) is standalone (no parent needed);
                        -- icon/bar overlays require their parent element.
                        local okStyle = PAIRABLE_STYLES[pairStyle]
                            and FindParentIndex(els, pairStyle) ~= nil
                            or pairStyle == "text"
                        if not PairableFor(cfgKey, prefix)
                            or not okStyle
                            or FindPairIndex(els, pairStyle)
                            or #els >= MAX_ELEMENTS then return end
                        els[#els + 1] = MakePairElement(cfgKey, pairStyle)
                        local newIdx = #els
                        HET:RefreshTracking()
                        HET:RefreshPreviewFor(cfgKey)
                        Notify()
                        SelectTracker(cfgKey, "trackers", "el" .. newIdx)
                        return
                    end
                    if not CanAddTracker() or ClaimedTypes()[val] then return end
                    local els = EnsureEls(prefix)
                    local newEl = NewElementDefaults(cfgKey)
                    newEl.style = val
                    -- Texture TINTS the chosen art (ApplyStyle SetVertexColor);
                    -- it doesn't fill a shape the way bar/circle do. The kind
                    -- default from DefaultElementColor (buff blue / debuff
                    -- orange / ability purple) would therefore recolor the
                    -- user's image the instant they picked it. White leaves the
                    -- art exactly as authored.
                    if val == "texture" then
                        newEl.color = { 1, 1, 1, 1 }
                    end
                    -- Ability trackers auto-add a Charges text by default, but a
                    -- single-charge ability has no charges to show — suppress the
                    -- auto Charges text unless the tracked spell actually has
                    -- charges (maxCharges > 1). Item trackers keep Stacks (bag
                    -- count); other kinds are unaffected.
                    if IsAbilityKindCfg(cfgKey) then
                        local ids = HET.db.profile[prefix .. "ids"]
                        local firstID = ids and ids[1]
                        if not SpellHasCharges(firstID) then
                            newEl.iconShowStackText = false
                            newEl.barShowStackText = false
                        end
                    end
                    els[#els + 1] = newEl
                    local newIdx = #els
                    -- Compound ability/item: a new Icon/Bar auto-adds its
                    -- "(Active)" aura overlay right after it.
                    if PAIRABLE_STYLES[val] and PairableFor(cfgKey, prefix)
                        and not FindPairIndex(els, val) and #els < MAX_ELEMENTS then
                        els[#els + 1] = MakePairElement(cfgKey, val)
                    end
                    HET:RefreshTracking()
                    -- A newly added visual tracker previews IMMEDIATELY (not
                    -- just when siblings are already previewing) so the user
                    -- can see and position it right away. Sound trackers have
                    -- nothing to preview.
                    local t = ns.trackers[cfgKey]
                    if t and val ~= "sound" then
                        t:ShowElementPreview(newIdx)
                    end
                    HET:RefreshPreviewFor(cfgKey)
                    Notify()
                    SelectTracker(cfgKey, "trackers", "el" .. newIdx)
                end,
            },
        },
    }

    group.args.trackers = {
        type = "group",
        name = "Trackers",
        order = 100,
        childGroups = "tree",
        hidden = isDisabled,
        args = trackerArgs,
    }

    -- Custom trackers: Name / Delete / (aura kind) Spell ID on the main
    -- page; (group kind) Tracked Buffs tab below.
    local cfg = ns.trackerConfigs[cfgKey]
    if cfg and cfg.custom then
        local tracker = ns.trackers[cfgKey]
        local isDebuff = IsDebuffCfg(cfgKey)
        local function entry()
            return tracker and tracker:GetCustomEntry()
        end
        -- ID-kind (ability/item) helpers, mirroring the aura-kind ones above.
        -- Single kinds ("ability"/"item") get a labeled ID input + label;
        -- group kinds ("abilitygroup"/"itemgroup") reuse the auraList machinery.
        local function isAbilitySingle()
            local en = entry(); return en and en.kind == "ability"
        end
        local function isItemSingle()
            local en = entry(); return en and en.kind == "item"
        end
        local function isIDSingle()
            return isAbilitySingle() or isItemSingle()
        end
        local function isIDGroup()
            local en = entry()
            return en and (en.kind == "abilitygroup" or en.kind == "itemgroup")
        end
        -- Is this a SINGLE item tracker? Picks item vs spell labels for the
        -- single-input path (customIDLabel). GROUPS are mixed now — their rows
        -- resolve item-ness PER ID via idKinds (IDKindOfCfg), not this.
        local function entryIsItem()
            local en = entry()
            return en and en.kind == "item"
        end
        -- Row break so Label starts the line under Copy Tracker. Flow wraps
        -- only on a "full" child, so a spacer is the only reliable way.
        group.args.customNameBreak = {
            type = "description",
            name = "",
            order = 1.36,
            width = "full",
            hidden = function()
                if isDisabled() then return true end
                -- Only the "Other" kind moves its Label up here; every other
                -- kind keeps Label down with its Spell ID input, and a break
                -- at 1.36 would split a row that is meant to stay together.
                local en = entry()
                return not (en and en.kind == "custom")
            end,
        }
        group.args.customName = {
            type = "input",
            name = "Label",
            desc = "Display label for this tracker in the spell list.",
            -- Sits just BEFORE the Spell ID / Name field and its icon+name
            -- display (per kind); hidden until a Spell ID or Name has been
            -- entered (single kinds only — groups and "Other" trackers have no
            -- single Spell ID field, so theirs always shows).
            order = function()
                local en = entry()
                if en and (en.kind == "ability" or en.kind == "item") then
                    return 1.68  -- right before the single ability/item ID input
                end
                -- The "Other" KIND only -- en.kind == "custom", not merely a
                -- user-created config. Gating this on cfgIsCustom also caught
                -- custom BUFF/DEBUFF trackers, which lifted their Label above
                -- customSpellBreak (2.5) and stranded Spell ID + the icon/name
                -- display alone on the row below it.
                if en and en.kind == "custom" then return 1.4 end
                -- Aura kinds: just before the Spell ID input, so Label, the
                -- input and the icon/name display share one row.
                return 2.55
            end,
            width = 1.2,
            hidden = function()
                if isDisabled() then return true end
                local en = entry()
                if not en then return true end
                if en.kind == "aura" or en.kind == "ability" or en.kind == "item" then
                    local ids = P("ids")
                    return not (ids and ids[1])
                end
                return false
            end,
            get = function()
                local en = entry()
                return (en and en.name) or ""
            end,
            set = function(_, val)
                local en = entry()
                if not en then return end
                val = val:gsub("^%s+", ""):gsub("%s+$", "")
                if val ~= "" then
                    en.name = val
                    -- Manually named: stop auto-naming from spell ID.
                    en.autoName = false
                end
                Notify()
            end,
        }
        -- Unit + Own Only. Debuffs pick Target/Focus and default Own Only ON
        -- (HARMFUL|PLAYER); buffs are always on the player (single "Player
        -- (Self)" option) and default Own Only OFF (HELPFUL, opt in to
        -- HELPFUL|PLAYER). Both apply live via the refresh pipeline. Shown for
        -- aura-kind customs only (not custom-"Other" trackers, which have no
        -- aura).
        local function isAuraCustom()
            local en = entry()
            return en and en.kind == "aura"
        end
        group.args.customUnit = {
            type = "select",
            name = "Unit",
            desc = function()
                local en = entry()
                return (en and en.harmful)
                    and "Which unit to watch this debuff on."
                    or  "Buffs are tracked on you."
            end,
            order = 1.3,
            width = 0.9,
            values = function()
                local en = entry()
                if en and en.harmful then return { target = "Target (Enemy)", focus = "Focus (Enemy)" } end
                return { player = "Player (Self)" }
            end,
            sorting = function()
                local en = entry()
                if en and en.harmful then return { "target", "focus" } end
                return { "player" }
            end,
            hidden = function()
                return isDisabled() or not isAuraCustom()
            end,
            get = function()
                local en = entry()
                if en and en.harmful then return en.unit or "target" end
                return "player"
            end,
            set = function(_, val)
                local en = entry(); if not en then return end
                -- Buffs have only the "player" option; nothing to store.
                if not en.harmful then return end
                if en.unit == val then return end
                en.unit = val
                HET:RefreshTracking() -- SyncElementUnits rebuilds in the new container
                Notify()
            end,
        }
        -- (Own Only lives at the top of the Load Conditions subtab — see lcArgs.)
        group.args.customSpellBreak = {
            type = "description",
            name = "",
            order = 2.5,
            width = "full",
            hidden = function()
                local en = entry()
                return isDisabled() or not en or en.kind ~= "aura"
            end,
        }
        group.args.customSpellID = {
            type = "input",
            name = "Spell ID or Name",
            desc = isDebuff
                and "The debuff to track — type a spell ID, or start typing a name to search."
                or  "The buff to track on you — type a spell ID, or start typing a name to search.",
            -- Debuff input suggests only "Debuff"-category spells; buff input
            -- excludes them.
            dialogControl = isDebuff and "EditBoxHET_DebuffSearch" or "EditBoxHET_SpellSearch",
            order = 2.6,
            width = 1.2,
            hidden = function()
                local en = entry()
                return isDisabled() or not en or en.kind ~= "aura"
            end,
            get = function()
                local ids = P("ids")
                return (ids and ids[1] and tostring(ids[1])) or ""
            end,
            set = function(_, val)
                local n = tonumber(val)
                if not n or n <= 0 then return end
                n = math.floor(n)
                SetP("ids", { n })
                -- AUTO SPEC: the curated tables know which specs this aura
                -- belongs to, so fill in the Spec condition for the user
                -- (unless they have already set one). See ns.ApplyAutoSpec.
                ns.ApplyAutoSpec(prefix, n, isDebuff and "debuff" or "buff", false)
                -- Single-aura trackers default their name to the aura's
                -- name (deduped with a numeric suffix) until the user
                -- names them manually. Pre-autoName entries still on the
                -- creation default opt in.
                local en = entry()
                if en and (en.autoName or en.name == "New Aura" or en.name == "New Buff") then
                    local spellName = C_Spell.GetSpellName and C_Spell.GetSpellName(n)
                    if spellName and spellName ~= "" then
                        en.name = UniqueCustomName(spellName, en)
                        en.autoName = true
                    end
                end
                HET:RefreshTracking()
                Notify()
            end,
        }
        group.args.customSpellLabel = {
            type = "description",
            order = 2.7,
            width = 1.4,
            name = function()
                local ids = P("ids")
                if ids and ids[1] then
                    return SpellIDLabel(ids[1])
                end
                return "|cffff4444Enter a spell ID to start tracking.|r"
            end,
            -- Mousing over the icon/name shows the spell's tooltip. A valid
            -- base option key (see AceConfigRegistry basekeys); rendered by
            -- the Label patcher in ns.EnsureUIHooks (description Labels do not
            -- get AceConfigDialog's native OnEnter tooltip path).
            tooltipHyperlink = function()
                local ids = P("ids")
                if ids and ids[1] then return "spell:" .. ids[1] end
            end,
            hidden = function()
                local en = entry()
                return isDisabled() or not en or en.kind ~= "aura"
            end,
        }

        -- Compound reconcile after the tracked ID or kind changes: gaining
        -- the flag (ns.CompoundInfo) auto-adds "(Active)" overlays for the
        -- existing Icon/Bar elements; losing it removes the now-inert pair
        -- entries (they'd park anyway, but dead "(Active)" tree entries
        -- would only confuse).
        local function ReconcilePairs()
            local els = EnsureEls(prefix)
            local changed = false
            if PairableFor(cfgKey, prefix) then
                for k in pairs(PAIRABLE_STYLES) do
                    if FindParentIndex(els, k) and not FindPairIndex(els, k)
                        and #els < MAX_ELEMENTS then
                        els[#els + 1] = MakePairElement(cfgKey, k)
                        changed = true
                    end
                end
            else
                for j = #els, 1, -1 do
                    if els[j].pairFor then
                        table.remove(els, j)
                        changed = true
                    end
                end
            end
            if changed then
                local t = ns.trackers[cfgKey]
                if t then t:RestoreAllPositions() end
            end
        end

        -- Single ID tracker Type toggle (one "Add Ability/Item" button creates
        -- these): flips the tracker between Ability and Item by rewriting
        -- entry.kind (+ idKind), which every ability/item helper keys off. The
        -- ID field, icon resolution, cooldown/charge reads, and the watcher all
        -- follow the kind live via the refresh pipeline.
        group.args.customIDType = {
            type = "select",
            name = "Type",
            desc = "Whether this tracker follows an ability (spell ID) or an item (item ID).",
            order = 1.1,  -- before Name
            width = 0.7,
            values = { ability = "Ability", item = "Item" },
            sorting = { "ability", "item" },
            hidden = function()
                return isDisabled() or not isIDSingle()
            end,
            get = function()
                return isItemSingle() and "item" or "ability"
            end,
            set = function(_, val)
                local en = entry(); if not en then return end
                if en.kind == val then return end
                en.kind = val            -- "ability" | "item"
                en.idKind = val
                -- IN-PLACE kind rewrite: the entry TABLE is unchanged, so the
                -- tracker's cached _entry pointer stays valid while its kind
                -- booleans go stale. Only a generation bump catches this.
                ns.BumpCustomEntryGen()
                -- If the name is still a creation default ("New Ability" / "New
                -- Item"), swap it to match the new type so the tree label stays
                -- sensible. A manually-set name is left untouched.
                if en.name == "New Ability" or en.name == "New Item" then
                    en.name = (val == "item") and "New Item" or "New Ability"
                end
                -- The compound flag is looked up per kind (ability vs item
                -- tables), so a kind flip can gain/lose "(Active)" overlays.
                ReconcilePairs()
                HET:RefreshTracking()
                Notify()
            end,
        }
        -- Break: pushes the ID input onto its OWN LINE below the Type / Name /
        -- Move-Preview top row.
        group.args.customIDBreak = {
            type = "description",
            name = "",
            order = 1.6,
            width = "full",
            hidden = function()
                return isDisabled() or not isIDSingle()
            end,
        }
        -- ID-kind (single ability/item): a labeled ID input storing into
        -- ids[1] (same storage as the aura Spell ID), with name→ID search
        -- scoped to the tracker's kind (SpellData_AbilitiesItems.lua). A raw
        -- numeric ID is always accepted. dialogControl is resolved statically
        -- by AceConfig, and a single tracker can flip between ability and item
        -- at runtime, so we use TWO inputs (one per kind), each hidden unless
        -- its kind is active — mirroring the ability/item group add-inputs.
        local function customIDSet(val)
            local n = tonumber(val)
            if not n or n <= 0 then return end
            n = math.floor(n)
            SetP("ids", { n })
            ns.ApplyAutoSpec(prefix, n, isItemSingle() and "item" or "ability", false)
            ReconcilePairs()
            -- Single ID kinds auto-name from the spell/item name until
            -- manually renamed (same rule as the aura Spell ID).
            local en = entry()
            if en and (en.autoName or en.name == "New Ability" or en.name == "New Item") then
                local nm
                if isItemSingle() then
                    nm = C_Item and C_Item.GetItemInfo and C_Item.GetItemInfo(n)
                else
                    nm = C_Spell.GetSpellName and C_Spell.GetSpellName(n)
                end
                if nm and nm ~= "" then
                    en.name = UniqueCustomName(nm, en)
                    en.autoName = true
                end
            end
            HET:RefreshTracking()
            Notify()
        end
        local function customIDGet()
            local ids = P("ids")
            return (ids and ids[1] and tostring(ids[1])) or ""
        end
        group.args.customAbilityInput = {
            type = "input",
            name = "Spell ID or Name",
            desc = "The ability to track — type a spell ID, or start typing a name to search.",
            dialogControl = "EditBoxHET_AbilitySearch",
            order = 1.7,
            width = 1.2,
            hidden = function()
                return isDisabled() or not isIDSingle() or isItemSingle()
            end,
            get = customIDGet,
            set = function(_, val) customIDSet(val) end,
        }
        group.args.customItemInput = {
            type = "input",
            name = "Item ID or Name",
            desc = "The item to track — type an item ID, or start typing a name to search.",
            dialogControl = "EditBoxHET_ItemSearch",
            order = 1.71,
            width = 1.2,
            hidden = function()
                return isDisabled() or not isIDSingle() or not isItemSingle()
            end,
            get = customIDGet,
            set = function(_, val) customIDSet(val) end,
        }
        group.args.customIDLabel = {
            type = "description",
            order = 1.75,  -- same row as Label + the ID input
            width = 1.4,
            name = function()
                local ids = P("ids")
                if ids and ids[1] then
                    return entryIsItem() and ItemIDLabel(ids[1]) or SpellIDLabel(ids[1])
                end
                return isItemSingle()
                    and "|cffff4444Enter an item ID to start tracking.|r"
                    or  "|cffff4444Enter a spell ID to start tracking.|r"
            end,
            tooltipHyperlink = function()
                local ids = P("ids")
                if not (ids and ids[1]) then return end
                return (entryIsItem() and "item:" or "spell:") .. ids[1]
            end,
            hidden = function()
                return isDisabled() or not isIDSingle()
            end,
        }

        -- Tracked Buffs (group kind): add/remove IDs; each ID is a checkbox
        -- row driving the trackers' filters and sound registrations.
        --
        -- MIXED ID GROUPS: an ID-kind group (abilitygroup/itemgroup) may hold
        -- BOTH abilities and items — so it gets TWO add inputs ("Add Ability ID"
        -- and "Add Item ID"), each tagging the new id's kind in the idKinds
        -- side-table. Buff/debuff groups keep their single spell-search input.
        -- Append helper: adds a numeric id to the ids array (dedup) and, when a
        -- kindTag is given, records it in the per-entry idKinds side-table so the
        -- row/runtime resolve the right ability/item path.
        local function AppendGroupID(val, kindTag)
            local n = tonumber(val)
            if not n or n <= 0 then return end
            n = math.floor(n)
            local ids = P("ids")
            if not ids then ids = {}; SetP("ids", ids) end
            for j = 1, #ids do
                if ids[j] == n then return end
            end
            ids[#ids + 1] = n
            if kindTag then
                local kinds = P("idKinds")
                if not kinds then kinds = {}; SetP("idKinds", kinds) end
                kinds[n] = kindTag
            end
            -- UNION for groups: every member has to be able to load, so
            -- adding a second spell widens the Spec condition rather than
            -- replacing it.
            ns.ApplyAutoSpec(prefix, n,
                kindTag or (isDebuff and "debuff" or "buff"), true)
            HET:RefreshTracking()
            Notify()
        end
        local alArgs = {}
        if isIDGroup() then
            -- ID groups: an ability input and an item input, each with name→ID
            -- autocomplete scoped to its own kind (SpellData_AbilitiesItems.lua).
            -- A raw numeric ID is always accepted too. Each tags the id's kind so
            -- a single group can fan out over a mix of abilities and items.
            alArgs.addAbility = {
                type = "input",
                name = "Add Ability (ID or Name)",
                desc = "Add an ability to this group — type a spell ID, or start typing a name to search.",
                dialogControl = "EditBoxHET_AbilitySearch",
                order = 1,
                width = 1.2,
                get = function() return "" end,
                set = function(_, val) AppendGroupID(val, "ability") end,
            }
            alArgs.addItem = {
                type = "input",
                name = "Add Item (ID or Name)",
                desc = "Add an item to this group — type an item ID, or start typing a name to search.",
                dialogControl = "EditBoxHET_ItemSearch",
                order = 1.5,
                width = 1.2,
                get = function() return "" end,
                set = function(_, val) AppendGroupID(val, "item") end,
            }
        else
            -- Buff/debuff group: single spell-search input (name→ID autocomplete).
            -- No idKinds tagging (these groups aren't ID kinds; buffColors is
            -- keyed by id as before). Kind is fixed for the tracker's lifetime,
            -- so resolve dialogControl statically (it is not a function).
            alArgs.addInput = {
                type = "input",
                name = "Add Spell (ID or Name)",
                desc = "Add a buff to this group — type a spell ID, or start typing a name to search.",
                dialogControl = "EditBoxHET_SpellSearch",
                order = 1,
                width = 1.2,
                get = function() return "" end,
                set = function(_, val) AppendGroupID(val, nil) end,
            }
        end
        -- Full-width empty description = line break: the buff rows always start
        -- below the Add box(es).
        alArgs.addBreak = {
            type = "description",
            name = "",
            order = 2,
            width = "full",
        }
        -- "Ordered behavior" gates the reorder arrows + per-entry colors. Aura
        -- groups toggle it via Group Type (groupOrdered); ability/item groups are
        -- ALWAYS ordered (no Unordered mode — see methods:IsOrdered), so treat
        -- them as ordered unconditionally.
        local function groupOrderedActive()
            return isIDGroup() or P("groupOrdered")
        end
        -- Column headings for the ID-kind (ability/item) list. Ordered just
        -- below the header (order 9.x, ahead of the first data row at 11) and
        -- shown only for ID groups. Widths mirror the row controls beneath so
        -- each heading sits over its column, followed by a full-width break.
        --   [arrows spacer 0.24][Ability/Item = label][Bar Color = c][When to Show = v]
        -- Headings show only for an ID group that actually has ≥1 entry (no
        -- orphan headers above an empty list).
        local function hdrShown()
            local ids = HET.db.profile[prefix .. "ids"]
            return isIDGroup() and type(ids) == "table" and ids[1] ~= nil
        end
        local hasBarCol = function() return groupOrderedActive() and HasBarTracker(prefix) end
        alArgs["hdrSpace"] = {
            type = "description", name = "", order = 9.0, width = 0.24,
            hidden = function() return not hdrShown() end,
        }
        alArgs["hdrName"] = {
            type = "description", name = "|cffffd100Ability/Item|r",
            fontSize = "medium", order = 9.1, width = 0.9,
            hidden = function() return not hdrShown() end,
        }
        alArgs["hdrColor"] = {
            type = "description", name = "|cffffd100Bar Color|r",
            fontSize = "medium", order = 9.2, width = 0.5,
            hidden = function() return not (hdrShown() and hasBarCol()) end,
        }
        alArgs["hdrShow"] = {
            type = "description", name = "|cffffd100When to Show|r",
            fontSize = "medium", order = 9.3, width = 1.15,
            hidden = function() return not hdrShown() end,
        }
        alArgs["hdrBreak"] = {
            type = "description", name = "", order = 9.4, width = "full",
            hidden = function() return not hdrShown() end,
        }
        local storedIDs = HET.db.profile[prefix .. "ids"]
        if type(storedIDs) == "table" then
            for idx = 1, #storedIDs do
                local id = storedIDs[idx]
                local o = 10 + idx
                alArgs["row" .. idx .. "u"] = {
                    type = "execute",
                    name = "",
                    desc = "Move up",
                    image = UP_TEX, imageWidth = 20, imageHeight = 20,
                    order = o,
                    width = 0.12,
                    -- Literal disabled overrides the root's combat lock
                    -- (deepest-wins); restated. Build-time eval is fine —
                    -- the combat watcher NotifyChanges on both combat edges.
                    disabled = idx == 1 or InCombatLockdown(),
                    hidden = function() return not groupOrderedActive() end,
                    func = function() MoveCustomID(prefix, idx, -1) end,
                }
                alArgs["row" .. idx .. "d"] = {
                    type = "execute",
                    name = "",
                    desc = "Move down",
                    image = DOWN_TEX, imageWidth = 20, imageHeight = 20,
                    order = o + 0.01,
                    width = 0.12,
                    disabled = idx == #storedIDs or InCombatLockdown(),
                    hidden = function() return not groupOrderedActive() end,
                    func = function() MoveCustomID(prefix, idx, 1) end,
                }
                -- Per-ROW item-ness: a mixed ID group tags each id in idKinds,
                -- so the label + tooltip follow THIS id's kind (not the group's
                -- default). Buff/debuff groups have no idKinds → falls back to
                -- false (spell labels), preserving old behavior. Resolved at
                -- build time (rows rebuild on every Notify).
                local rowIsItem = IDKindOfCfg(cfgKey, prefix, id)
                -- Label narrows for ID-kind groups to make room for the per-id
                -- Show When dropdown on the same row; other groups keep the wider
                -- label (no dropdown).
                alArgs["row" .. idx .. "t"] = {
                    type = "description",
                    name = rowIsItem and ItemIDLabel(id) or SpellIDLabel(id),
                    fontSize = "medium",
                    order = o + 0.02,
                    width = isIDGroup() and 0.9 or 1.4,
                    -- Hover shows this row's spell/item tooltip (see the Label
                    -- patcher in ns.EnsureUIHooks).
                    tooltipHyperlink = (rowIsItem and "item:" or "spell:") .. id,
                }
                alArgs["row" .. idx .. "c"] = {
                    type = "color",
                    name = "",
                    desc = "Bar color for this buff. Applies to Bars trackers while the Group Type is Ordered.",
                    order = o + 0.025,
                    -- Wider than the swatch needs so the "Bar Color" heading
                    -- above it aligns; the extra is just trailing pad.
                    width = 0.5,
                    hasAlpha = true,
                    hidden = function()
                        return not (groupOrderedActive() and HasBarTracker(prefix))
                    end,
                    get = function()
                        local bc = P("buffColors")
                        local v = bc and bc[id]
                        if not v then
                            -- Unset: show the bar tracker's own color.
                            local els = GetEls(prefix)
                            for j = 1, #els do
                                if els[j].style == "bar" then
                                    v = els[j].color
                                    break
                                end
                            end
                        end
                        v = v or { 1, 1, 1, 1 }
                        return v[1], v[2], v[3], v[4]
                    end,
                    set = function(_, cr, cg, cb, ca)
                        local bc = P("buffColors")
                        if not bc then bc = {}; SetP("buffColors", bc) end
                        bc[id] = { cr, cg, cb, ca }
                        -- Pass cfgKey: this was the one throttled colour setter
                        -- that omitted it, so its tracker's PREVIEW never
                        -- repainted while the swatch was dragged. It also lets
                        -- the drag scope its refresh to this tracker.
                        RefreshTrackingThrottled(cfgKey)
                    end,
                }
                -- Per-id "Show When": overrides nothing at the element level —
                -- for ID-kind groups the visibility gate is PER ENTRY, stored in
                -- the idVisibilities side-table keyed by id (mirrors buffColors).
                -- Unset ⇒ "always". Only shown for ID-kind groups (the element
                -- dropdown is hidden for them; see idVisibility.hidden).
                alArgs["row" .. idx .. "v"] = {
                    type = "select",
                    name = "",
                    desc = "When this ability/item is shown: Always, Only While On Cooldown, or Only When Usable.",
                    order = o + 0.028,
                    width = 1.15,
                    values = ID_VISIBILITY_VALUES,
                    sorting = ID_VISIBILITY_SORTING,
                    hidden = function() return not isIDGroup() end,
                    get = function()
                        local v = P("idVisibilities")
                        return (v and v[id]) or "always"
                    end,
                    set = function(_, val)
                        local v = P("idVisibilities")
                        if not v then v = {}; SetP("idVisibilities", v) end
                        v[id] = val
                        HET:RefreshTracking()
                    end,
                }
                alArgs["row" .. idx .. "x"] = {
                    type = "execute",
                    name = "",
                    desc = "Remove",
                    image = X_TEXTURE,
                    imageWidth = 16, imageHeight = 16,
                    order = o + 0.03,
                    width = 0.12,
                    func = function()
                        local ids = P("ids") or {}
                        for j = #ids, 1, -1 do
                            if ids[j] == id then table.remove(ids, j) end
                        end
                        local bc = P("buffColors")
                        if bc then bc[id] = nil end
                        -- Drop this id's per-entry visibility + kind tag too, so a
                        -- later id reusing the number doesn't inherit stale state.
                        local vis = P("idVisibilities")
                        if vis then vis[id] = nil end
                        local kinds = P("idKinds")
                        if kinds then kinds[id] = nil end
                        HET:RefreshTracking()
                        Notify()
                    end,
                }
                -- Full-width break keeps the list strictly one row per
                -- buff.
                alArgs["row" .. idx .. "b"] = {
                    type = "description",
                    name = "",
                    order = o + 0.04,
                    width = "full",
                }
            end
        end
        group.args.auraList = {
            type = "group",
            -- Relabel per kind: buff/debuff groups → "Tracked Buffs"; ID-kind
            -- groups (mixed ability+item) → "Tracked Abilities / Items".
            name = function()
                if isIDGroup() then return "Tracked Abilities / Items" end
                return "Tracked Buffs"
            end,
            order = 101,
            -- Shown for every group kind (buff/debuff "group" AND the ID-kind
            -- groups abilitygroup/itemgroup).
            hidden = function()
                local en = entry()
                if isDisabled() or not en then return true end
                return not (en.kind == "group"
                    or en.kind == "abilitygroup" or en.kind == "itemgroup")
            end,
            args = alArgs,
        }
    end

    -- Multi-spell groups: a Tracked Buffs tab, one checkbox row per
    -- spell (always vertical) with up/down reorder arrows. Checked
    -- spells drive the trackers' aura filters and the per-spell sound
    -- registrations.
    local spellList = ns.trackerConfigs[cfgKey].spellList
    if spellList then
        local function isOrdered()
            -- Built-in spellList groups are always Unordered.
            return false
        end
        local spellArgs = {}
        local display = OrderedSpellList(cfgKey, prefix)
        for idx, sp in ipairs(display) do
            local o = 10 + idx
            local key = sp.key
            local tex = C_Spell.GetSpellTexture(sp.ids[1])
            local label = (tex and ("|T" .. tex .. ":16|t ") or "") .. sp.name
            spellArgs["up" .. idx] = {
                type = "execute",
                name = "",
                desc = "Move up",
                image = UP_TEX, imageWidth = 20, imageHeight = 20,
                order = o,
                width = 0.12,
                -- Restates the root combat lock (deepest-wins inheritance).
                disabled = idx == 1 or InCombatLockdown(),
                hidden = function() return not isOrdered() end,
                func = function() MoveOrderedKey(cfgKey, prefix, idx, -1) end,
            }
            spellArgs["down" .. idx] = {
                type = "execute",
                name = "",
                desc = "Move down",
                image = DOWN_TEX, imageWidth = 20, imageHeight = 20,
                order = o + 0.01,
                width = 0.12,
                disabled = idx == #display or InCombatLockdown(),
                hidden = function() return not isOrdered() end,
                func = function() MoveOrderedKey(cfgKey, prefix, idx, 1) end,
            }
            spellArgs["sp_" .. key] = {
                type = "toggle",
                name = label,
                order = o + 0.02,
                width = 1.6,
                get = function()
                    local sel = P("spells")
                    return sel and sel[key]
                end,
                set = function(_, val)
                    local sel = P("spells")
                    if not sel then
                        sel = {}
                        SetP("spells", sel)
                    end
                    sel[key] = val
                    HET:RefreshTracking()
                end,
            }
            -- Full-width break keeps the list strictly one row per buff.
            spellArgs["brk" .. idx] = {
                type = "description",
                name = "",
                order = o + 0.03,
                width = "full",
            }
        end
        group.args.spells = {
            type = "group",
            name = "Tracked Buffs",
            order = 101,
            hidden = isDisabled,
            args = spellArgs,
        }
    end

    -- Built-in trackers (non-custom): they're always player buffs, so show a
    -- read-only "Player (Self)" Unit field to match custom trackers,
    -- and — for single-aura built-ins — a "Spell ID: X" label + spell icon in
    -- place of the (custom-only) Spell ID input.
    if not (ns.trackerConfigs[cfgKey].custom) then
        local biCfg = ns.trackerConfigs[cfgKey]
        group.args.builtinUnit = {
            type = "select",
            name = "Unit",
            desc = "This tracker watches buffs on you.",
            order = 1.3,
            width = 0.9,
            values = { player = "Player (Self)" },
            sorting = { "player" },
            hidden = isDisabled,
            get = function() return "player" end,
            set = function() end,  -- single fixed option; nothing to store
        }
        -- Single-aura built-ins have a displaySpell; groups (spellList) don't.
        if not biCfg.spellList and biCfg.displaySpell then
            group.args.builtinSpellBreak = {
                type = "description",
                name = "",
                order = 2.5,
                width = "full",
                hidden = isDisabled,
            }
            group.args.builtinSpellID = {
                type = "description",
                order = 2.6,
                width = 1.4,
                name = function()
                    local id = biCfg.displaySpell
                    return ("Spell ID: %d   %s"):format(id, SpellIDLabel(id))
                end,
                tooltipHyperlink = function()
                    return "spell:" .. biCfg.displaySpell
                end,
                hidden = isDisabled,
            }
        end
    end

    -- Load Conditions subtab (after Trackers / Tracked Buffs). With no
    -- toggle checked the tracker loads always, as before. Combat Status
    -- gates the visual + sound; Instance Type gates the slot.
    -- Own Only (aura-kind customs only) sits at the top of Load Conditions:
    -- only track the aura when YOU applied it. Debuffs default ON, buffs OFF.
    local function lcCustomEntry() return CustomEntryFor(cfgKey) end
    local lcIsCustomKind = IsCustomKindCfg(cfgKey)
    local lcArgs = {
        -- Header note explaining the no-conditions default. Custom-"Other"
        -- trackers show ALWAYS with no conditions; aura trackers show while the
        -- aura is present on the unit.
        intro = {
            type = "description",
            -- AceConfigDialog sorts NEGATIVE orders to the BOTTOM
            -- (compareOptions), so pin to the top with a small POSITIVE order
            -- below every other arg here (ownOnly bumped to 0.5).
            order = 0.1,
            width = "full",
            fontSize = "small",
            name = lcIsCustomKind
                and "If no conditions are selected, configured trackers will show Always."
                or  "If no conditions are selected, configured trackers will show whenever the Aura is present on the Unit.",
        },
        ownOnly = {
            type = "toggle",
            name = "Own Only",
            desc = function()
                local en = lcCustomEntry()
                local isGroup = en and en.kind == "group"
                if en and en.harmful then
                    return isGroup
                        and "Only track these debuffs when YOU applied them."
                        or  "Only track this debuff when YOU applied it."
                end
                return isGroup
                    and "Only track these buffs when YOU applied them."
                    or  "Only track this buff when YOU applied it."
            end,
            order = 0.5,  -- below the intro note (0.1), above Combat (1).
            width = "full",
            -- Single-aura AND group customs (not custom-"Other"). For groups
            -- the filter applies to every aura in the group.
            hidden = function()
                local en = lcCustomEntry()
                return isDisabled()
                    or not (en and (en.kind == "aura" or en.kind == "group"))
            end,
            get = function()
                local en = lcCustomEntry()
                if not en then return false end
                -- Debuffs default ON (nil = checked); buffs default OFF.
                if en.harmful then return en.ownOnly ~= false end
                return en.ownOnly == true
            end,
            set = function(_, val)
                local en = lcCustomEntry(); if not en then return end
                en.ownOnly = val and true or false
                HET:RefreshTracking() -- SyncElementFilters pushes the new filter live
                Notify()
            end,
        },
        combatToggle = {
            type = "toggle",
            name = "Combat Status",
            -- Sound Alerts are registered engine-side (C_UnitAuras.AddAuraSound)
            -- and the registration can only be created/destroyed OUT of combat,
            -- so the combat edge can't arm or disarm them. Called out here
            -- rather than silently surprising the user.
            desc = "Only load this tracker based on your combat state."
                .. "\n\n|cffff8080This setting does not apply to Sound Alerts.|r",
            order = 1,
            width = 0.9,
            get = function() return P("loadCombat") end,
            set = function(_, val)
                SetP("loadCombat", val or nil)
                HET:RefreshTracking()
            end,
        },
        combatMode = {
            type = "select",
            name = "Combat State",
            desc = "In Combat Only loads the tracker while you are in combat; Out of Combat Only loads it while you are not.",
            order = 2,
            width = 1.2,
            values = { ["in"] = "In Combat Only", out = "Out of Combat Only" },
            sorting = { "in", "out" },
            hidden = function() return not P("loadCombat") end,
            get = function() return P("loadCombatMode") or "in" end,
            set = function(_, val)
                SetP("loadCombatMode", val)
                HET:RefreshTracking()
            end,
        },
        -- State conditions (default ON): hide the tracker while mounted or in
        -- a pet battle. Stored nil = on / false = off (no migration; existing
        -- profiles get them on). Live-updated by Core's mount/pet-battle
        -- events. Kept together on their own row. (No vehicle toggle: vehicles
        -- hide ALL trackers unconditionally via the container gate —
        -- Tracker's ApplyVehicleGate — so a per-tracker toggle would be a
        -- no-op; leftover loadNotVehicle DB keys are unread.)
        stateBreak = {
            type = "description",
            name = "",
            order = 2.9,
            width = "full",
        },
        notPetBattleToggle = {
            type = "toggle",
            name = "Not in Pet Battle",
            desc = "Only load this tracker while you are NOT in a pet battle. On by default.",
            order = 4,
            width = 1.1,
            get = function() return P("loadNotPetBattle") ~= false end,
            set = function(_, val)
                if val then SetP("loadNotPetBattle", nil) else SetP("loadNotPetBattle", false) end
                HET:RefreshTracking()
            end,
        },
        notMountedToggle = {
            type = "toggle",
            name = "Not Mounted",
            desc = "Only load this tracker while you are NOT mounted. On by default.",
            order = 5,
            width = 1.1,
            get = function() return P("loadNotMounted") ~= false end,
            set = function(_, val)
                if val then SetP("loadNotMounted", nil) else SetP("loadNotMounted", false) end
                HET:RefreshTracking()
            end,
        },
        instanceToggle = {
            type = "toggle",
            name = "Instance Type",
            desc = "Only load this tracker in the checked instance types.",
            order = 10,
            width = "full",
            get = function() return P("loadInstance") end,
            set = function(_, val)
                SetP("loadInstance", val or nil)
                HET:RefreshTracking()
                -- Switching the condition on starts it with nothing
                -- checked, so the tree "!" (and, for Spec/Role, the
                -- Spec Filter) may change on this very click. Throttled:
                -- a burst of clicks collapses to one rebuild instead of N.
                NotifyThrottled()
            end,
        },
        roleToggle = {
            type = "toggle",
            name = "Role",
            desc = "Only load this tracker while your current role is checked below. Combines with Spec by OR: if Spec is also enabled, the tracker loads when EITHER your role OR your spec is checked.",
            order = 12,
            width = "full",
            get = function() return P("loadRole") end,
            set = function(_, val)
                SetP("loadRole", val or nil)
                HET:RefreshTracking()
                -- Switching the condition on starts it with nothing
                -- checked, so the tree "!" (and, for Spec/Role, the
                -- Spec Filter) may change on this very click. Throttled:
                -- a burst of clicks collapses to one rebuild instead of N.
                NotifyThrottled()
            end,
        },
        specToggle = {
            type = "toggle",
            name = "Spec",
            desc = "Only load this tracker while your current specialization is checked below. Combines with Role by OR: if Role is also enabled, the tracker loads when EITHER your role OR your spec is checked.",
            order = 14,
            width = "full",
            get = function() return P("loadSpec") end,
            set = function(_, val)
                SetP("loadSpec", val or nil)
                -- No seeding needed: an absent list already means nothing is
                -- checked, which is the state enabling Spec should start in.
                HET:RefreshTracking()
                NotifyThrottled()  -- "!" may appear/clear; a burst of clicks = one rebuild
            end,
        },
        spellToggle = {
            type = "toggle",
            name = "Spell Usable/Unusable",
            desc = "Only show this tracker while specific spells are usable (usable and off cooldown) — or, per row, while they are UNusable.",
            order = 20,
            width = "full",
            get = function() return P("loadSpell") end,
            set = function(_, val)
                SetP("loadSpell", val or nil)
                -- Turning it ON seeds the first empty spell row so the user
                -- lands straight on an input (mirrors "+ Add Spell Usable
                -- Condition"). Only when the list is empty, so re-toggling
                -- never stacks blank rows.
                if val then
                    local t = P("loadSpellIDs")
                    if type(t) ~= "table" then
                        t = {}
                        SetP("loadSpellIDs", t)
                    end
                    if #t == 0 then t[1] = 0 end
                end
                HET:RefreshTracking()
                Notify()
            end,
        },
        itemToggle = {
            type = "toggle",
            name = "Item Usable/Unusable",
            desc = "Only show this tracker while specific items are usable (usable and off cooldown), e.g. a trinket or potion — or, per row, while they are UNusable.",
            order = 22,
            width = "full",
            get = function() return P("loadItem") end,
            set = function(_, val)
                SetP("loadItem", val or nil)
                -- Seed the first empty item row on enable (same as Spell Usable).
                if val then
                    local t = P("loadItemIDs")
                    if type(t) ~= "table" then
                        t = {}
                        SetP("loadItemIDs", t)
                    end
                    if #t == 0 then t[1] = 0 end
                end
                HET:RefreshTracking()
                Notify()
            end,
        },
    }
    local itArgs = {}
    for i = 1, #INSTANCE_TYPES do
        local key = INSTANCE_TYPES[i].key
        itArgs["it_" .. key] = {
            type = "toggle",
            name = INSTANCE_TYPES[i].label,
            order = i,
            width = 0.9,
            -- Storage convention: checked ONLY when explicitly true. An unset
            -- table or unset entry is UNCHECKED, so switching Instance Type on
            -- starts with nothing selected and the tracker loads nowhere until
            -- you pick one (the tree shows a "!" while that is the case).
            get = function()
                local t = P("loadInstanceTypes")
                return t ~= nil and t[key] == true
            end,
            set = function(_, val)
                local t = P("loadInstanceTypes")
                if not t then
                    t = {}
                    SetP("loadInstanceTypes", t)
                end
                t[key] = val and true or false
                HET:RefreshTracking()
                NotifyThrottled()  -- "!" may appear/clear; a burst of clicks = one rebuild
            end,
        }
    end
    -- Instance Type is AND-ed with every other condition, so an empty list is
    -- fatal on its own — no Role/Spec caveat to make here.
    itArgs.noneWarning = {
        type = "description",
        order = 9999,
        width = "full",
        name = function()
            if not NoInstancesChecked(HET.db.profile, prefix) then return "" end
            return "|cffff4444No instance type is checked, so this tracker will never load.|r"
        end,
    }
    lcArgs.instanceGroup = {
        type = "group",
        name = "Instance Types",
        inline = true,
        order = 11,
        hidden = function() return not P("loadInstance") end,
        args = itArgs,
    }

    -- Role checkboxes. Same convention as Instance Types: checked only when
    -- explicitly true, so the list starts empty.
    local roArgs = {}
    for i = 1, #ROLE_TYPES do
        local key   = ROLE_TYPES[i].key
        local icon  = (CreateAtlasMarkup and CreateAtlasMarkup(ROLE_TYPES[i].atlas, 16, 16)) or ""
        roArgs["ro_" .. key] = {
            type = "toggle",
            name = (icon ~= "" and (icon .. " ") or "") .. ROLE_TYPES[i].label,
            order = i,
            width = 0.9,
            get = function()
                local t = P("loadRoleTypes")
                return t ~= nil and t[key] == true
            end,
            set = function(_, val)
                local t = P("loadRoleTypes")
                if not t then
                    t = {}
                    SetP("loadRoleTypes", t)
                end
                t[key] = val and true or false
                HET:RefreshTracking()
                NotifyThrottled()  -- "!" may appear/clear; a burst of clicks = one rebuild
            end,
        }
    end
    roArgs.noneWarning = {
        type = "description",
        order = 9999,
        width = "full",
        name = function()
            if not NoRolesChecked(HET.db.profile, prefix) then return "" end
            -- Spec is OR'd with Role, so an empty role list is only fatal when
            -- Spec isn't picking the tracker up instead. RoleSpecIncomplete,
            -- NOT RoleSpecNeverMatches: the latter also returns true for an
            -- empty Instance Type list, which would blame the ROLE grid for a
            -- problem that lives in another group -- and that group is already
            -- showing its own red warning a few rows up.
            if RoleSpecIncomplete(prefix) then
                return "|cffff4444No role is checked, so this tracker will never load.|r"
            end
            -- Deliberately says the ROLE/SPEC GATE passes, not that the tracker
            -- loads: Instance Type is AND-ed on top and may still be blocking
            -- it. Claiming "this tracker loads on its Spec condition only"
            -- would be a false positive whenever that happens.
            return "|cffaaaaaaNo role is checked. This tracker's Role/Spec condition passes on Spec alone.|r"
        end,
    }
    lcArgs.roleGroup = {
        type = "group",
        name = "Roles",
        inline = true,
        order = 13,
        hidden = function() return not P("loadRole") end,
        args = roArgs,
    }

    -- Materialize loadSpecTypes for a WRITE. Nothing to densify: an absent
    -- entry already means unchecked, so an empty table is the correct starting
    -- point and only the boxes you tick get written. A getter must never call
    -- this (it writes to the profile).
    --
    -- Also the choke point where the user TAKES OWNERSHIP of the Spec
    -- condition: every manual edit goes through here or through the two
    -- buttons below, and from that moment auto-spec (ns.ApplyAutoSpec) must
    -- never widen or overwrite it again.
    local function EnsureSpecTypes()
        ns.ClearAutoSpecMark(prefix)
        -- Editing the very condition that decides whether this row stays in
        -- the list should not make it leave mid-edit.
        ns.ExemptFromSpecFilter(prefix)
        local t = P("loadSpecTypes")
        if not t then
            t = {}
            SetP("loadSpecTypes", t)
        end
        return t
    end

    -- Spec checkboxes, grouped by class (class header rows) then spec name.
    -- Storage convention: an entry is CHECKED only when it is explicitly true,
    -- so an absent entry -- and an absent table -- means UNCHECKED.
    -- A Deselect All / Select All button sits at the top.
    local spcArgs = {}
    spcArgs.toggleAll = {
        type = "execute",
        order = 0,
        width = "normal",
        -- Label reflects the action: if every spec is currently checked,
        -- the button deselects all; otherwise it selects all. Convention:
        -- checked only when explicitly true, so an absent table is "none".
        name = function()
            local t = P("loadSpecTypes")
            if t == nil then return "Select All" end
            for i = 1, #SPEC_TYPES do
                if t[SPEC_TYPES[i].id] ~= true then return "Select All" end
            end
            return "Deselect All"
        end,
        func = function()
            local t = P("loadSpecTypes")
            local allChecked = t ~= nil
            if t ~= nil then
                for i = 1, #SPEC_TYPES do
                    if t[SPEC_TYPES[i].id] ~= true then allChecked = false; break end
                end
            end
            -- All checked -> clear; otherwise -> check everything.
            local target = not allChecked
            t = {}
            for i = 1, #SPEC_TYPES do t[SPEC_TYPES[i].id] = target end
            ns.ClearAutoSpecMark(prefix)
            ns.ExemptFromSpecFilter(prefix)
            SetP("loadSpecTypes", t)
            HET:RefreshTracking()
            Notify()
        end,
    }
    -- One-click "just this character": clears every spec and checks the one
    -- you are currently in.
    spcArgs.selectCurrent = {
        type = "execute",
        order = 0.1,
        width = "normal",
        name = "Select Current Spec",
        desc = "Check only the specialization you are in right now, and uncheck every other.",
        -- Own `disabled` OVERRIDES the root's (deepest-defined wins in ACD), so
        -- the combat lock has to be restated here or this button stays live in
        -- combat when everything around it greys out.
        disabled = function()
            return InCombatLockdown() or ns.playerSpec == nil
        end,
        func = function()
            local cur = ns.playerSpec
            if not cur then return end
            local t = {}
            for i = 1, #SPEC_TYPES do
                t[SPEC_TYPES[i].id] = (SPEC_TYPES[i].id == cur)
            end
            ns.ClearAutoSpecMark(prefix)
            ns.ExemptFromSpecFilter(prefix)
            SetP("loadSpecTypes", t)
            HET:RefreshTracking()
            Notify()
        end,
    }
    -- Full-width spacer keeps the two buttons above on their own row, so the
    -- class grid always starts on a fresh line.
    spcArgs.toggleAllBreak = {
        type = "description",
        name = "",
        order = 0.5,
        width = "full",
    }
    -- ICON-ONLY grid, one class per row: [Class header] [icon][icon][icon].
    -- The spec NAME was the only reason a spec toggle needed a wide widget;
    -- dropping it to the tooltip is what lets a whole class share one line.
    --
    -- Widths are ABSOLUTE PIXELS, not fractions of a row: AceConfigDialog turns
    -- a numeric width into SetWidth(170 * width), and Flow wraps once the
    -- accumulated widget widths exceed the container. 0.9 -> 153px for the
    -- header (a 24px check box plus room for "Demon Hunter" and its longer
    -- localisations); 0.3 -> 51px per spec (24px box + a 14px icon + air).
    -- Worst case is a 4-spec class at 153 + 4*51 = 357px, inside the width the
    -- Specs group gets in the default window, so it survives being resized.
    do
        -- One boolean test after the first call; see EnsureSpecStrings.
        EnsureSpecStrings()
        for ci = 1, #SPEC_CLASS_ORDER do
            local class    = SPEC_CLASS_ORDER[ci]
            local specs    = SPECS_BY_CLASS[class]
            local classIDs = SPEC_IDS_BY_CLASS[class]
            local meta     = SPEC_CLASS_META[class]
            -- Orders: break at ci*100-0.5, header at ci*100, specs at ci*100+j.
            local base = ci * 100
            -- The header is a fixed width now rather than "full", so it no
            -- longer buys a line break for free — Flow would happily pack the
            -- next class onto the same row. An empty full-width description is
            -- the cheapest explicit break: Flow gives a "fill" child its own row
            -- and resets the used width, and an empty label measures 0 high.
            spcArgs[meta.brkKey] = {
                type = "description", name = "", order = base - 0.5, width = "full",
            }
            spcArgs[meta.hdrKey] = {
                type = "toggle",
                name = meta.hdrName,
                desc = meta.hdrDesc,
                order = base,
                width = 0.9,
                -- Checked only when EVERY spec in the class is checked. An
                -- absent entry means UNCHECKED (storage convention), so a
                -- sparse table reads as not-all-checked. There is no tri-state
                -- — a partly-checked class shows UNCHECKED, so getting from
                -- partial to all-off is two clicks (on, then off).
                get = function()
                    local t = P("loadSpecTypes")
                    if t == nil then return false end
                    for k = 1, #classIDs do
                        if t[classIDs[k]] ~= true then return false end
                    end
                    return true
                end,
                set = function(_, val)
                    local t = EnsureSpecTypes()
                    local on = val and true or false
                    for k = 1, #classIDs do t[classIDs[k]] = on end
                    HET:RefreshTracking()
                    Notify()
                end,
            }
            for j = 1, #specs do
                local s = specs[j]
                local id = s.id
                -- Key, label and tooltip are baked at load (SPEC_CLASS_META /
                -- the SPEC_TYPES pass above) — they are pure functions of a
                -- constant table, and this grid is rebuilt per tracker.
                spcArgs[s._argKey] = {
                    type = "toggle",
                    order = base + j,
                    width = 0.3,
                    name = s._label,
                    desc = s._desc,
                    get = function()
                        local t = P("loadSpecTypes")
                        return t ~= nil and t[id] == true
                    end,
                    set = function(_, val)
                        local t = EnsureSpecTypes()
                        t[id] = val and true or false
                        HET:RefreshTracking()
                        Notify()
                    end,
                }
            end
        end
    end
    -- A tracker with Spec on and nothing checked never loads. That is now the
    -- state you land in when you first enable Spec, so say so rather than
    -- leaving a silently dead tracker.
    spcArgs.noneWarning = {
        type = "description",
        order = 9999,
        width = "full",
        name = function()
            if not NoSpecsChecked(HET.db.profile, prefix) then return "" end
            -- Role is OR'd with Spec, so an empty spec list is only fatal when
            -- Role isn't picking the tracker up instead. RoleSpecIncomplete,
            -- NOT RoleSpecNeverMatches: the latter also returns true for an
            -- empty Instance Type list, which would blame the SPEC grid for a
            -- problem that lives in another group -- and that group is already
            -- showing its own red warning a few rows up.
            if RoleSpecIncomplete(prefix) then
                return "|cffff4444No specialization is checked, so this tracker will never load.|r"
            end
            -- Says the ROLE/SPEC GATE passes, not that the tracker loads --
            -- Instance Type is AND-ed on top and may still be blocking it.
            return "|cffaaaaaaNo specialization is checked. This tracker's Role/Spec condition passes on Role alone.|r"
        end,
    }
    lcArgs.specGroup = {
        type = "group",
        name = "Specs",
        inline = true,
        order = 15,
        hidden = function() return not P("loadSpec") end,
        args = spcArgs,
    }

    -- Spell Usable rows: generated from the stored list (the options
    -- generator re-runs on every NotifyChange, so dynamic keys are safe —
    -- same pattern as the custom aura ID list).
    local spArgs = {}
    local storedSpells = HET.db.profile[prefix .. "loadSpellIDs"]
    if type(storedSpells) == "table" then
        for idx = 1, #storedSpells do
            local o = 10 + idx
            -- Direction dropdown, sits to the LEFT of the input. Stored in
            -- loadSpellUsable (array parallel to loadSpellIDs); only an
            -- explicit false means "Unusable", so absent/nil = Usable (the
            -- pre-existing behaviour, so old profiles need no migration).
            spArgs["sp" .. idx .. "d"] = {
                type = "select",
                name = "",
                desc = "Usable: this spell must be usable and off cooldown. Unusable: it must NOT be (on cooldown, or not castable).",
                order = o - 0.005,
                width = 0.7,
                values = { usable = "Usable", unusable = "Unusable" },
                sorting = { "usable", "unusable" },
                get = function()
                    local t = P("loadSpellUsable")
                    return (t and t[idx] == false) and "unusable" or "usable"
                end,
                set = function(_, val)
                    local t = P("loadSpellUsable")
                    if type(t) ~= "table" then
                        t = {}
                        SetP("loadSpellUsable", t)
                    end
                    t[idx] = (val ~= "unusable")
                    HET:RefreshTracking()
                    Notify()
                end,
            }
            spArgs["sp" .. idx] = {
                type = "input",
                name = "Spell " .. idx,
                desc = function()
                    local t = P("loadSpellIDs")
                    local id = t and tonumber(t[idx])
                    local name = id and id > 0 and C_Spell
                        and C_Spell.GetSpellName and C_Spell.GetSpellName(id)
                    return name and ("Currently: " .. name)
                        or "The spell this condition checks — type a spell ID, or start typing a name to search."
                end,
                -- Name search over the curated ability list (same predictor the
                -- Ability tracker input uses). Suggestions are keyed by ID, so
                -- picking one writes the ID; a raw numeric ID is still accepted
                -- for anything unlisted.
                dialogControl = "EditBoxHET_AbilitySearch",
                order = o,
                width = 1.2,
                get = function()
                    local t = P("loadSpellIDs")
                    local id = t and tonumber(t[idx])
                    return (id and id > 0) and tostring(id) or ""
                end,
                set = function(_, val)
                    local t = P("loadSpellIDs")
                    if type(t) ~= "table" then return end
                    -- CLICKING a predictor suggestion fires OnEnterPressed with
                    -- the suggestion KEY, and for these ID-keyed lists that is a
                    -- NUMBER (Search-EditBox-Widget PredictorButton_OnClick ->
                    -- ValidateValue returns `key`). Typing an ID by hand passes
                    -- a string, which is why only the click path errored.
                    -- Coerce before touching any string method.
                    val = tostring(val or ""):gsub("^%s+", ""):gsub("%s+$", "")
                    if val == "" then
                        t[idx] = 0
                    else
                        -- A half-typed name that matched no suggestion must not
                        -- wipe an already-good row, so unresolvable text is
                        -- ignored rather than coerced to 0.
                        local n = tonumber(val)
                        if not n then return end
                        t[idx] = n
                    end
                    HET:RefreshTracking()
                    Notify()
                end,
            }
            -- Resolved spell icon + name, shown inline once the ID resolves.
            spArgs["sp" .. idx .. "l"] = {
                type = "description",
                order = o + 0.005,
                width = 1.4,
                name = function()
                    local t = P("loadSpellIDs")
                    local id = t and tonumber(t[idx])
                    if id and id > 0 then return SpellIDLabel(id) end
                    return ""
                end,
                -- Hover the icon/name for the spell tooltip (Label patcher).
                tooltipHyperlink = function()
                    local t = P("loadSpellIDs")
                    local id = t and tonumber(t[idx])
                    if id and id > 0 then return "spell:" .. id end
                end,
            }
            spArgs["sp" .. idx .. "x"] = {
                type = "execute",
                name = "",
                desc = "Remove",
                image = X_TEXTURE,
                imageWidth = 16, imageHeight = 16,
                order = o + 0.01,
                width = 0.12,
                func = function()
                    local t = P("loadSpellIDs")
                    if type(t) == "table" then table.remove(t, idx) end
                    -- Keep the parallel Usable/Unusable array aligned.
                    local u = P("loadSpellUsable")
                    if type(u) == "table" then table.remove(u, idx) end
                    HET:RefreshTracking()
                    Notify()
                end,
            }
            -- Full-width break keeps the list strictly one row per spell.
            spArgs["sp" .. idx .. "b"] = {
                type = "description",
                name = "",
                order = o + 0.02,
                width = "full",
            }
        end
    end
    spArgs.addSpell = {
        type = "execute",
        name = "+ Add Spell Usable Condition",
        desc = "Add another spell whose usable/unusable state gates this tracker.",
        order = 50,
        width = 1.5,
        func = function()
            local t = P("loadSpellIDs")
            if type(t) ~= "table" then
                t = {}
                SetP("loadSpellIDs", t)
            end
            t[#t + 1] = 0
            Notify()
        end,
    }
    spArgs.spellMode = {
        type = "select",
        name = "Mode",
        desc = "All: every listed spell must match its Usable/Unusable setting. Any: at least one must match.",
        order = 51,
        width = 0.7,
        values = { any = "Any", all = "All" },
        sorting = { "any", "all" },
        hidden = function()
            local t = P("loadSpellIDs")
            return type(t) ~= "table" or #t < 2
        end,
        get = function() return P("loadSpellMode") or "all" end,
        set = function(_, val)
            SetP("loadSpellMode", val)
            HET:RefreshTracking()
        end,
    }
    lcArgs.spellGroup = {
        type = "group",
        name = "Spell Usable/Unusable",
        inline = true,
        order = 21,
        hidden = function() return not P("loadSpell") end,
        args = spArgs,
    }

    -- Item Usable rows: exact parallel of the Spell Usable list above, keyed
    -- off loadItemIDs / loadItemMode. ns.itemReady drives the runtime gate.
    local itemArgs = {}
    local storedItems = HET.db.profile[prefix .. "loadItemIDs"]
    if type(storedItems) == "table" then
        for idx = 1, #storedItems do
            local o = 10 + idx
            -- Direction dropdown to the LEFT of the input; see the spell row.
            -- loadItemUsable is parallel to loadItemIDs, absent/nil = Usable.
            itemArgs["item" .. idx .. "d"] = {
                type = "select",
                name = "",
                desc = "Usable: this item must be usable and off cooldown. Unusable: it must NOT be (on cooldown, unequipped, or otherwise unusable).",
                order = o - 0.005,
                width = 0.7,
                values = { usable = "Usable", unusable = "Unusable" },
                sorting = { "usable", "unusable" },
                get = function()
                    local t = P("loadItemUsable")
                    return (t and t[idx] == false) and "unusable" or "usable"
                end,
                set = function(_, val)
                    local t = P("loadItemUsable")
                    if type(t) ~= "table" then
                        t = {}
                        SetP("loadItemUsable", t)
                    end
                    t[idx] = (val ~= "unusable")
                    HET:RefreshTracking()
                    Notify()
                end,
            }
            itemArgs["item" .. idx] = {
                type = "input",
                name = "Item " .. idx,
                desc = function()
                    local t = P("loadItemIDs")
                    local id = t and tonumber(t[idx])
                    -- GetItemInfo returns the name as its first value (nil for
                    -- an uncached item — the generic hint shows until cached).
                    local name = id and id > 0 and C_Item
                        and C_Item.GetItemInfo and C_Item.GetItemInfo(id)
                    return name and ("Currently: " .. name)
                        or "The item this condition checks — type an item ID, or start typing a name to search."
                end,
                -- Name search over the curated item list (same predictor the
                -- Item tracker input uses); see the spell row above.
                dialogControl = "EditBoxHET_ItemSearch",
                order = o,
                width = 1.2,
                get = function()
                    local t = P("loadItemIDs")
                    local id = t and tonumber(t[idx])
                    return (id and id > 0) and tostring(id) or ""
                end,
                set = function(_, val)
                    local t = P("loadItemIDs")
                    if type(t) ~= "table" then return end
                    -- Number-vs-string on the predictor click path; see the
                    -- Spell row above.
                    val = tostring(val or ""):gsub("^%s+", ""):gsub("%s+$", "")
                    if val == "" then
                        t[idx] = 0
                    else
                        local n = tonumber(val)
                        if not n then return end
                        t[idx] = n
                    end
                    HET:RefreshTracking()
                    Notify()
                end,
            }
            -- Resolved item icon + name, shown inline once the ID resolves
            -- (blank until the client has the item cached).
            itemArgs["item" .. idx .. "l"] = {
                type = "description",
                order = o + 0.005,
                -- Narrower than the spell row's label: this row also carries
                -- the Require Equipped toggle, and the new direction dropdown
                -- has to fit alongside both without wrapping.
                width = 0.8,
                name = function()
                    local t = P("loadItemIDs")
                    local id = t and tonumber(t[idx])
                    if id and id > 0 then return ItemIDLabel(id) end
                    return ""
                end,
                tooltipHyperlink = function()
                    local t = P("loadItemIDs")
                    local id = t and tonumber(t[idx])
                    if id and id > 0 then return "item:" .. id end
                end,
            }
            -- Require Equipped (ON by default): the item must be equipped to
            -- count. Stored in loadItemEquipped (array parallel to loadItemIDs);
            -- only an explicit false disables it, so absent = required.
            itemArgs["item" .. idx .. "e"] = {
                type = "toggle",
                name = "Require Equipped",
                desc = "Only count this item as ready while it is equipped (e.g. a trinket in a trinket slot). On by default.",
                order = o + 0.008,
                width = 1.0,
                -- Only meaningful for equippable items (trinkets, weapons…);
                -- hidden for consumables like potions, which can't be equipped.
                hidden = function()
                    local t = P("loadItemIDs")
                    return not IsEquippableItem(t and tonumber(t[idx]))
                end,
                get = function()
                    local t = P("loadItemEquipped")
                    return not (t and t[idx] == false)
                end,
                set = function(_, val)
                    local t = P("loadItemEquipped")
                    if type(t) ~= "table" then
                        t = {}
                        SetP("loadItemEquipped", t)
                    end
                    t[idx] = val and true or false
                    HET:RefreshTracking()
                    Notify()
                end,
            }
            itemArgs["item" .. idx .. "x"] = {
                type = "execute",
                name = "",
                desc = "Remove",
                image = X_TEXTURE,
                imageWidth = 16, imageHeight = 16,
                order = o + 0.01,
                width = 0.12,
                func = function()
                    local t = P("loadItemIDs")
                    if type(t) == "table" then table.remove(t, idx) end
                    -- Keep the parallel Require-Equipped / direction arrays aligned.
                    local eq = P("loadItemEquipped")
                    if type(eq) == "table" then table.remove(eq, idx) end
                    local u = P("loadItemUsable")
                    if type(u) == "table" then table.remove(u, idx) end
                    HET:RefreshTracking()
                    Notify()
                end,
            }
            itemArgs["item" .. idx .. "b"] = {
                type = "description",
                name = "",
                order = o + 0.02,
                width = "full",
            }
        end
    end
    itemArgs.addItem = {
        type = "execute",
        name = "+ Add Item Usable Condition",
        desc = "Add another item whose usable/unusable state gates this tracker.",
        order = 50,
        width = 1.5,
        func = function()
            local t = P("loadItemIDs")
            if type(t) ~= "table" then
                t = {}
                SetP("loadItemIDs", t)
            end
            t[#t + 1] = 0
            Notify()
        end,
    }
    itemArgs.itemMode = {
        type = "select",
        name = "Mode",
        desc = "All: every listed item must match its Usable/Unusable setting. Any: at least one must match.",
        order = 51,
        width = 0.7,
        values = { any = "Any", all = "All" },
        sorting = { "any", "all" },
        hidden = function()
            local t = P("loadItemIDs")
            return type(t) ~= "table" or #t < 2
        end,
        get = function() return P("loadItemMode") or "all" end,
        set = function(_, val)
            SetP("loadItemMode", val)
            HET:RefreshTracking()
        end,
    }
    lcArgs.itemGroup = {
        type = "group",
        name = "Item Usable/Unusable",
        inline = true,
        order = 23,
        hidden = function() return not P("loadItem") end,
        args = itemArgs,
    }
    group.args.loadConditions = {
        type = "group",
        name = "Conditions",
        order = 102,
        hidden = isDisabled,
        args = lcArgs,
    }

    -- "Options": the per-tracker settings that are about the tracker's place in
    -- the addon rather than about what it displays — which folder it lives in,
    -- and handing it to someone else. Kept off the main page because neither is
    -- something you touch while tuning a tracker's look, and the main page's
    -- top row is the busiest real estate in the panel.
    --
    -- Hidden while the tracker is disabled, matching Conditions and Trackers:
    -- a disabled tracker's pane collapses to Enable + Name on purpose.
    group.args.trackerOptions = {
        type = "group",
        name = "Options",
        order = 103,
        hidden = isDisabled,
        args = {
            -- FOLDER ASSIGNMENT, and the permanent accessibility fallback for
            -- the By Folder view's drag-and-drop: anyone who cannot drag, or
            -- whose drag misses, can move a tracker from its own pane.
            trackerFolder = {
                type = "select",
                name = "Folder",
                desc = "Which folder this tracker appears in under the"
                    .. " |cffffd100By Folder|r view. Navigation only —"
                    .. " position, anchoring and load conditions are"
                    .. " unaffected.",
                order = 1,
                width = 1.2,
                hidden = function() return not ns.Folders end,
                values = function()
                    local v = { [""] = "|cffaaaaaaUngrouped|r" }
                    local folders = ns.Folders()
                    for i = 1, #folders do
                        v[tostring(folders[i].id)] = folders[i].name
                    end
                    return v
                end,
                sorting = function()
                    local order = { "" }
                    local folders = ns.Folders()
                    for i = 1, #folders do
                        order[#order + 1] = tostring(folders[i].id)
                    end
                    return order
                end,
                get = function()
                    local id = ns.FolderOf and ns.FolderOf(cfgKey)
                    return id and tostring(id) or ""
                end,
                set = function(_, val)
                    if not ns.MoveTracker then return end
                    ns.MoveTracker(cfgKey, (val ~= "") and tonumber(val) or nil)
                    Notify()
                    -- The node moves to a different parent in the tree, so
                    -- follow it. Deferred for the same reason as every other
                    -- re-select from inside an AceConfig setter. Re-selecting
                    -- lands on the tracker's page, not this tab — acceptable,
                    -- since the move is done and the tree is what moved.
                    C_Timer.After(0, function()
                        SelectTracker(cfgKey)
                        -- (No-op in By Type, where the row has not moved.)
                        if ACD.OpenFrames and ACD.OpenFrames[addonName] then
                            ACD:Open(addonName)
                        end
                    end)
                end,
            },
            -- The case the dropdown above cannot serve: the folder you want
            -- does not exist yet. Name and colour are chosen in the dialog, so
            -- this is one step rather than create-find-rename-recolour-move.
            trackerNewFolder = {
                type = "execute",
                name = "Add to New Folder",
                desc = "Create a folder and move this tracker into it.",
                order = 1.5,
                width = 1.0,
                hidden = function() return not ns.ShowNewFolderDialog end,
                disabled = function() return InCombatLockdown() end,
                func = function()
                    if ns.ShowNewFolderDialog then
                        ns.ShowNewFolderDialog(cfgKey)
                    end
                end,
            },
            folderBreak = {
                type = "description", name = "", order = 2, width = "full",
            },
            -- Export: produces the !BZA string for this tracker (custom
            -- trackers export as a whole tracker; built-ins export as a
            -- SETTINGS OVERLAY — the tracker itself ships with the addon).
            exportTracker = {
                type = "execute",
                name = "Export",
                desc = "Produce a |cffffd100!BZA|r string for this tracker, to"
                    .. " paste elsewhere."
                    .. "\n\nTo link it in chat instead, open the chat box you"
                    .. " want to post in and shift-click the tracker's name in"
                    .. " the list at the left.",
                order = 3,
                width = 0.8,
                hidden = function() return not ns.PayloadForTracker end,
                func = function()
                    if not ns.PayloadForTracker then return end
                    local payload = ns.PayloadForTracker(cfgKey)
                    if payload then
                        ns.ShowExportDialog(payload, displayName, cfgKey, false)
                    end
                end,
            },
            advanced = {
                type = "group",
                name = "Advanced",
                inline = true,
                order = 10,
                args = {
                    frameLevelEnabled = {
                        type = "toggle",
                        name = "Set Relative Frame Level",
                        desc = "Nudge this tracker up or down the draw order,"
                            .. " so you can decide which one shows on top when"
                            .. " two trackers overlap."
                            .. "\n\nRELATIVE, not absolute: the value is added"
                            .. " to the level this tracker would normally get,"
                            .. " so the internal ordering of its own parts"
                            .. " (bar, swipe, text) is preserved.",
                        order = 1,
                        width = 1.4,
                        get = function() return P("frameLevelEnabled") and true or false end,
                        set = function(_, val)
                            SetP("frameLevelEnabled", val and true or nil)
                            HET:RefreshTracking()
                            Notify()  -- show/hide the dropdown
                        end,
                    },
                    frameLevelOffset = {
                        type = "select",
                        name = "Frame Level",
                        desc = "Higher draws on top of lower. 0 is the normal"
                            .. " level -- every tracker sits there unless you"
                            .. " change it, so a single tracker set to 1 is"
                            .. " enough to lift it above the rest.",
                        order = 2,
                        width = 0.8,
                        hidden = function() return not P("frameLevelEnabled") end,
                        -- STRING keys: this file's Spec Filter learned the hard
                        -- way that mixing number and string keys in one values
                        -- table invites trouble, so the number lives in the
                        -- label and is converted back on set.
                        values = function()
                            local v = {}
                            for n = ns.FRAME_LEVEL_MIN, ns.FRAME_LEVEL_MAX do
                                v[tostring(n)] = (n > 0) and ("+" .. n) or tostring(n)
                            end
                            return v
                        end,
                        sorting = function()
                            local o = {}
                            -- Highest first, so the list reads top-of-screen
                            -- down -- the same direction the option describes.
                            for n = ns.FRAME_LEVEL_MAX, ns.FRAME_LEVEL_MIN, -1 do
                                o[#o + 1] = tostring(n)
                            end
                            return o
                        end,
                        get = function()
                            return tostring(tonumber(P("frameLevelOffset")) or 0)
                        end,
                        set = function(_, val)
                            local n = tonumber(val) or 0
                            -- nil at 0 to keep saved profiles sparse.
                            SetP("frameLevelOffset", n ~= 0 and n or nil)
                            HET:RefreshTracking()
                        end,
                    },
                },
            },
        },
    }

    return group
end

-------------------------------------------------------------------------------
-- Options generator — AceConfig re-invokes this on every NotifyChange,
-- which is what lets the element subtabs come and go dynamically.
-------------------------------------------------------------------------------
-- "Add Aura" / "Add Aura Group": create a customTrackers entry using
-- the smallest unused id, register its config, and jump to the new
-- node. The tracker object itself is created by the refresh pipeline —
-- immediately when unrestricted, or on leaving combat otherwise.
local function AddCustom(kind, harmful)
    local p = HET.db.profile
    if type(p.customTrackers) ~= "table" then
        p.customTrackers = {}
    end
    local list = p.customTrackers
    local id = ns.AllocateCustomID(list)
    -- Ability/Item kinds (no aura): a single spell/item ID drives an icon with
    -- an optional Cooldown swipe + Charges/Stacks text. `idKind` distinguishes
    -- ability (Spell ID) from item (Item ID); groups fan out over an ID list.
    -- kind is one of: "aura","group","custom","ability","abilitygroup",
    -- "item","itemgroup".
    local isAbility = (kind == "ability" or kind == "abilitygroup")
    local isItem    = (kind == "item" or kind == "itemgroup")
    local defName
    if kind == "custom" then
        defName = "New Custom"
    elseif kind == "ability" then
        defName = "New Ability"
    elseif kind == "abilitygroup" then
        defName = "New Ability/Item Group"
    elseif kind == "item" then
        defName = "New Item"
    elseif kind == "itemgroup" then
        defName = "New Item Group"
    elseif harmful then
        defName = (kind == "group") and "New Debuff Group" or "New Debuff"
    else
        defName = (kind == "group") and "New Buff Group" or "New Buff"
    end
    -- Held in a local rather than appended inline, so the folder filing below
    -- can name its uid — the same shape DuplicateCustom already uses.
    local newEntry = {
        id = id,
        -- Stable, never-recycled identity (Core.lua). The integer `id` above
        -- is a LOCAL handle only: it is recycled on delete and means nothing
        -- in another profile, so folder membership and export/import key off
        -- the uid instead.
        uid = ns.GenerateUID(),
        kind = kind,
        name = defName,
        -- Single-aura trackers auto-name from their spell until manually
        -- renamed. Ability/Item single kinds auto-name from their spell/item
        -- name too. Group/custom kinds never auto-name.
        autoName = (kind == "aura" or kind == "ability" or kind == "item"),
        -- ability/item id kind marker (read by IsAbilityKindCfg / IsItemKindCfg).
        idKind = (isAbility and "ability") or (isItem and "item") or nil,
        -- Debuff trackers: HARMFUL on target/focus, "Own Only" (HARMFUL|
        -- PLAYER) on by default. Buffs, custom, ability, and item kinds omit
        -- these (ability/item have no Unit — always the player).
        harmful = harmful or nil,
        unit    = harmful and "target" or nil,
        ownOnly = harmful and true or nil,
    }
    list[#list + 1] = newEntry
    -- Custom ids are RECYCLED (first unused integer above), and tracker objects
    -- are never removed from ns.trackers — so this id may already have a live
    -- tracker object holding the DELETED entry's kind cache.
    ns.BumpCustomEntryGen()
    -- CUSTOM VIEW: file the new tracker into whichever folder the tree is
    -- parked in — the folder itself being selected, or any tracker inside it —
    -- so "open a folder, add a tracker" puts it there rather than at the
    -- bottom of Ungrouped. The copy path already does the equivalent with
    -- ns.InsertAfterInFolder.
    --
    -- AFTER BumpCustomEntryGen, because MoveTracker resolves the uid through
    -- Core's uid maps and those rebuild against ns.customEntryGen: filing
    -- before the bump would hand it a map that predates this entry, the uid
    -- would resolve to nothing, and the move would silently do nothing.
    --
    -- nil (By Type, Ungrouped, or a selection the folder model cannot place)
    -- is not passed on: MoveTracker treats it as "move to Ungrouped", which is
    -- where a new tracker already is, so the call would only cost a folder
    -- scan to reach the same state.
    local intoFolder = ns.SelectedFolderID and ns.SelectedFolderID()
    if intoFolder and ns.MoveTracker then
        ns.MoveTracker(newEntry.uid, intoFolder)
    end
    local pref = "cu" .. id .. "_"
    p[pref .. "ids"] = {}
    if type(p[pref .. "elements"]) ~= "table" then
        p[pref .. "elements"] = {}
    end
    p[pref .. "enabled"] = true
    -- Brand new and about to be configured: exempt from the Spec Filter until
    -- the user changes View or Filter, or closes the panel.
    ns.ExemptFromSpecFilter(pref)
    -- Ability/Item groups are ALWAYS ordered (no Unordered mode — see
    -- methods:IsOrdered), so no groupOrdered flag is stored for them: the icons
    -- follow the list order and the Show When gate collapses gaps per-icon.
    ns.RegisterCustomConfig(id)
    -- Creates the tracker via the pipeline when unrestricted.
    HET:RefreshTracking()
    Notify()
    local cfgKey = "custom" .. id
    if not ns.trackers[cfgKey] then
        -- Mid-combat: the refresh pipeline builds it once the
        -- restriction lifts.
        print("|cff11ace9HET|r Tracker will activate when you leave combat.")
    end
    -- Show the brand-new node's options. Selecting a freshly-added node in
    -- place — especially while already on its subtab — doesn't reliably
    -- re-feed the content pane under the Buffs/Debuffs split (a manual click
    -- does, which is why navigating away and back worked). Set the selection,
    -- then re-feed the EXISTING frame with :Open (exactly what ACD's own
    -- refresh does on the next frame — no close, so the panel stays up).
    --
    -- DEFERRED (next frame): every caller is an AceConfig execute func (the
    -- Add New Tracker dropdown, AddButton below). ACD:Open calls ReleaseChildren
    -- SYNCHRONOUSLY, which releases the clicked button and wipes its userdata
    -- while ACD's ActivateControl is still mid-flight; ActivateControl then
    -- indexes user.rootframe (now nil) right after the func returns and errors
    -- on every click (AceConfigDialog-3.0.lua:844). SelectGroup alone is safe
    -- — it ends in NotifyChange, which ACD defers to OnUpdate — but the Open
    -- has to defer the same way. Next frame is also exactly when ACD's own
    -- refresh would have run, so the re-feed behaviour above is unchanged.
    C_Timer.After(0, function()
        SelectTracker(cfgKey)
        if ACD.OpenFrames and ACD.OpenFrames[addonName] then
            ACD:Open(addonName)
        end
    end)
end

-------------------------------------------------------------------------------
-- Custom-view top row
--
-- The type view spreads seven add actions across four tabs. One row cannot
-- hold seven buttons legibly, so the custom view collapses them into a single
-- "Add New Tracker" dropdown whose set() calls the same AddCustom, then
-- resets itself.
-- "Import…" rides along as an eighth entry (it is offered in BOTH views —
-- see the type-view tabs — because importing must never require the custom
-- view).
-------------------------------------------------------------------------------
-- The View selector, rendered as a row ABOVE the tree.
--
-- AceConfigDialog feeds a group's NON-GROUP args into the container BEFORE it
-- adds the child-group widget, so a plain select in the ROOT args lands above
-- the tree. No ScrollFrame is interposed at the root (the FeedGroup condition
-- needs parenttype == "tree", and the root's parenttype is "none"), so the
-- select and the tree are siblings under a flow layout.
--
-- One shared file-scope table: ACD only ever READS option tables.
local VIEW_SELECT = {
    type   = "select",
    name   = "View",
    desc   = "|cffffd100By Type|r: Buffs, Debuffs, Abilities, Items and Other,"
        .. " grouped automatically by what each tracker is."
        .. "\n\n|cffffd100By Folder|r: your own folders, plus Ungrouped for"
        .. " anything you have not filed. Drag a tracker onto a folder to move"
        .. " it there."
        .. "\n\nEither way this is navigation only — neither view affects a"
        .. " tracker's position, anchoring or load conditions.",
    order  = 0.01,
    width  = 0.75,
    values = { type = "By Type", folder = "By Folder" },
    sorting = { "type", "folder" },
    get = function() return ns.ViewMode and ns.ViewMode() or "type" end,
    set = function(_, v) if ns.SetViewMode then ns.SetViewMode(v) end end,
}

local ADD_KINDS = {
    aura        = { label = "Buff",                kind = "aura",         harmful = false },
    auraH       = { label = "Debuff",              kind = "aura",         harmful = true  },
    group       = { label = "Buff Group",          kind = "group",        harmful = false },
    groupH      = { label = "Debuff Group",        kind = "group",        harmful = true  },
    ability     = { label = "Ability/Item",        kind = "ability",      harmful = false },
    abilitygrp  = { label = "Ability/Item Group",  kind = "abilitygroup", harmful = false },
    custom      = { label = "Custom Tracker",      kind = "custom",       harmful = false },
}
local ADD_SORTING = {
    "aura", "group", "auraH", "groupH", "ability", "abilitygrp", "custom", "import",
}
local ADD_VALUES = {}
for k, v in pairs(ADD_KINDS) do ADD_VALUES[k] = v.label end
ADD_VALUES.import = "|cffffd100Import…|r"

local ADD_SELECT = {
    type = "select",
    name = "|cffffd100Add New Tracker|r",
    desc = "Create a new tracker of the chosen kind, or import one from a"
        .. " |cffffd100!BZA|r string.",
    order = 0.1,
    -- Wide enough for the label above the dropdown, which is longer than the
    -- widest entry inside it.
    width = 1.35,
    values = function()
        -- Import only appears once Transmission.lua is present.
        if not ns.ShowImportDialog then
            local v = {}
            for k, label in pairs(ADD_VALUES) do
                if k ~= "import" then v[k] = label end
            end
            return v
        end
        return ADD_VALUES
    end,
    sorting = function()
        if ns.ShowImportDialog then return ADD_SORTING end
        local t = {}
        for i = 1, #ADD_SORTING do
            if ADD_SORTING[i] ~= "import" then t[#t + 1] = ADD_SORTING[i] end
        end
        return t
    end,
    -- Always reads blank: this is an action menu, not a stored setting.
    get = function() return nil end,
    set = function(_, val)
        if val == "import" then
            if ns.ShowImportDialog then ns.ShowImportDialog() end
            return
        end
        local spec = ADD_KINDS[val]
        if spec then AddCustom(spec.kind, spec.harmful) end
    end,
}

local NEW_FOLDER_BUTTON = {
    type = "execute",
    name = "|cffffd100Add Folder|r",
    -- By Type's nodes are derived, not chosen; there is nothing to add to it.
    hidden = function() return not ns.ViewMode or ns.ViewMode() ~= "folder" end,
    desc = "Create a folder to organise trackers into. Folders are navigation"
        .. " only — a tracker's position, anchoring and load conditions are"
        .. " untouched by which folder it sits in.",
    order = 0.2,
    width = 0.95,
    -- Opens the same window as a tracker's "Add to New Folder", minus the
    -- tracker: the name and colour are chosen up front rather than dropping a
    -- folder called "New Folder" into the list for the user to go and rename.
    func = function()
        if ns.ShowNewFolderDialog then ns.ShowNewFolderDialog() end
    end,
}

-- The trackers that ship with the addon, with the label each one shows in the
-- list. Module scope, not a local inside BuildOptions, because the label has
-- to be resolvable from a bare cfgKey OUTSIDE a build — the import dialog
-- describes an incoming settings overlay by the tracker it targets, and
-- "extdef" is a storage key, not something to show anyone.
local BUILTIN_DEFS = {
    { cfgKey = "bloodlust",     name = "Bloodlust/Heroism",   iconID = 2825,  isGroup = true, spellIcon = 80353 },
    { cfgKey = "extdef",        name = "External Defensives", iconID = 33206, isGroup = true },
    { cfgKey = "innervate",     name = "Innervate",           iconID = 29166 },
    { cfgKey = "persdef",       name = "Personal Defensives", iconID = 22812, isGroup = true },
    { cfgKey = "powerinfusion", name = "Power Infusion",      iconID = 10060 },
    { cfgKey = "spatial",       name = "Spatial Paradox",     iconID = 406732, isGroup = true, spellIcon = 406732 },
    { cfgKey = "timespiral",    name = "Time Spiral",         iconID = 375234, isGroup = true, spellIcon = 375234 },
}
local BUILTIN_LABEL = {}
for i = 1, #BUILTIN_DEFS do
    BUILTIN_LABEL[BUILTIN_DEFS[i].cfgKey] = BUILTIN_DEFS[i].name
end

-- Built-ins ONLY, and nil for anything else — deliberately not a general
-- cfgKey -> name resolver. Its callers describe an INCOMING payload, and a
-- corrupt string claiming d.k = "custom3" must not be described using the name
-- of whichever custom tracker the RECEIVER happens to hold at id 3.
function ns.BuiltinTrackerLabel(cfgKey)
    return type(cfgKey) == "string" and BUILTIN_LABEL[cfgKey] or nil
end

local function BuildOptions()
    -- COMBAT-EDGE FAST PATH. The combat watcher sets this latch immediately
    -- before its NotifyChange; nothing else does. Hand back the table built for
    -- the previous feed rather than reconstructing it: AceConfigDialog still
    -- walks it and still re-evaluates every `disabled`/`hidden` FUNCTION on the
    -- nodes, so the combat lock applies exactly as before -- it just doesn't
    -- rebuild thousands of tables at the moment the pull starts.
    -- NOT one-shot. AceConfigDialog can call the generator more than once while
    -- feeding a single refresh (tree, then content), and serving the cache to
    -- the first call and a freshly built table to the second would hand one
    -- feed two different node identities. The latch is cleared by the paths
    -- that can invalidate the table instead -- Notify, NotifyThrottled, a real
    -- open -- plus a short timed expiry armed where it is set, so it can never
    -- linger into an unrelated feed.
    if ns._reuseOptions and lastBuiltOptions then
        return lastBuiltOptions
    end
    ns._reuseOptions = nil
    -- Record the Spec Filter this build assumed. Written HERE rather than
    -- predicted by callers: a caller that merely SCHEDULES a rebuild (Notify
    -- defers to the next OnUpdate, and that refresh is skipped outright if the
    -- panel closes first) would otherwise mark the tables fresh for a build
    -- that never ran, and the stale node tables would survive the session.
    ns._primedSpecFilter = ns.specFilter
    -- "The tables have been built at least once this session." hetOpen tests
    -- this to decide whether it has to build before seeding the first
    -- selection, now that RegisterOptions no longer primes on the loading
    -- screen. A separate flag rather than an ns._firstTreeNode nil-test
    -- because that value is a RESULT of the build and could legitimately come
    -- back nil, which would make every open rebuild.
    ns._optionsBuilt = true
    -- Every Show-Conditions setter ends in a rebuild, so this is the one place
    -- the "never loads" answers can go stale from an edit. Wiping here rather
    -- than in each setter means a new condition key can never be added with the
    -- invalidation forgotten. (Profile swap does NOT rebuild — see
    -- ns.WipeCondWarnCache.)
    WipeCondWarnCache()
    -- Every tracker node built this pass, in tree-sort order (groups first,
    -- then alphabetical by the user-given name). CustomView.lua re-buckets
    -- this into folders; the node CONTENT is built here and is the same
    -- whatever folder it ends up in.
    local builtNodes = {}
    -- A fresh ARRAY over the shared definitions: the sort below reorders it and
    -- the customs are appended to it, so the module-level table must not be the
    -- one being mutated. The entries themselves can be shared — only the
    -- custom entries (built new every pass) are ever written to (`_stub`).
    local spellDefs = {}
    for i = 1, #BUILTIN_DEFS do spellDefs[i] = BUILTIN_DEFS[i] end
    local customs = HET.db.profile.customTrackers
    if type(customs) == "table" then
        for j = 1, #customs do
            local ck = customs[j].kind
            spellDefs[#spellDefs + 1] = {
                cfgKey = "custom" .. customs[j].id,
                name = customs[j].name or "Custom",
                custom = true,
                -- ID-kind groups fan out like buff/debuff groups, so they clump
                -- with the groups in the tree sort.
                isGroup = (ck == "group" or ck == "abilitygroup" or ck == "itemgroup"),
                customKind = ck == "custom",
                -- ID-kind flags: route to the Abilities / Items subtabs and pick
                -- the tree icon (spell vs item texture) below.
                abilityKind = (ck == "ability" or ck == "abilitygroup"),
                itemKind    = (ck == "item" or ck == "itemgroup"),
                harmful = customs[j].harmful and true or false,
            }
        end
    end
    -- Groups clump FIRST, then single auras; alphabetical within each.
    table.sort(spellDefs, function(a, b)
        if (a.isGroup or false) ~= (b.isGroup or false) then
            return a.isGroup and true or false
        end
        local an, bn = a.name:lower(), b.name:lower()
        if an ~= bn then return an < bn end
        return a.cfgKey < b.cfgKey
    end)

    -- SPEC FILTER: drop trackers the selected spec wouldn't load. Applied AFTER
    -- the sort, so "the next one in the list" below means what it says, and
    -- before the build loop, so a filtered-out tracker also skips its whole
    -- BuildSpellGroup — the filter costs nothing and saves the build.
    -- ns.MatchesSpecFilter returns true for everything when "All Specs" is
    -- selected, so this is a no-op in that case. The dropdown itself lives in
    -- the TAB args (above the tree), never inside it, so it stays reachable
    -- even when the filter empties every section.
    --
    -- The selected tracker is NOT exempt: a tracker that stops matching leaves
    -- the list immediately, including while you are editing the very condition
    -- that dropped it. What keeps that from stranding the pane on Addon
    -- Options is the re-selection below.
    -- A tracker that stops matching leaves the list immediately, including
    -- while you are editing the very condition that dropped it. The selection
    -- is NOT rescued here any more: ACD's own GroupExists() check rejects a
    -- stale uniquevalue and the tree falls back to its first entry, which the
    -- four-subtab layout could not rely on (its section headings are
    -- unselectable, so the fallback skipped past them onto Addon Options).
    -- One flat tree of selectable folder rows has no such problem.
    --
    -- SEARCH is applied in the same pass and on the same terms: a
    -- non-matching tracker skips its whole BuildSpellGroup. The two narrow
    -- independently, so a search runs WITHIN whatever the Spec Filter is
    -- showing -- which is what "filter" then "find" should mean.
    local searching = ns.trackerSearch ~= nil and ns.trackerSearch ~= ""
    if ns.specFilter ~= nil or searching then
        -- The haystack reads element styles and condition tables, which are
        -- edited without touching customTrackers, so the generation counter
        -- alone would not catch them. Every such setter ends in a rebuild --
        -- i.e. here -- so this is the one place the drop has to happen.
        if ns.WipeSearchCache then ns.WipeSearchCache() end
        local kept = {}
        for j = 1, #spellDefs do
            local s = spellDefs[j]
            local pfx = PrefixFor(s.cfgKey)
            -- A tracker whose Role/Spec condition is on but EMPTY stays visible
            -- under every filter selection: that is an unfinished condition, not
            -- one scoped to another spec, and hiding it would pull the tracker
            -- out from under you the instant you tick Spec to start setting it
            -- up. The "!" in the tree is what flags the state instead.
            if (ns.specFilter == nil or PassesSpecFilter(pfx))
                and (not searching or ns.MatchesTrackerSearch(pfx, s)) then
                kept[#kept + 1] = s
            end
        end
        spellDefs = kept
    end

    -- Multi-spell groups show a custom overlapping-squares glyph IN
    -- PLACE of a spell icon in the tree (Media\Textures\GroupIcon.tga, white on
    -- transparent), marking them as nested lists rather than single
    -- auras.
    local GROUP_ICON = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\GroupIcon"
    for o = 1, #spellDefs do
        local s = spellDefs[o]
        local prefix = PrefixFor(s.cfgKey)
        local iconTex
        if s.isGroup then
            iconTex = GROUP_ICON
        elseif s.customKind then
            -- Custom-kind: no aura id — pull the first Icon element's custom
            -- icon (resolved by mode), else the question-mark fallback.
            iconTex = ns.ResolveCustomTreeIcon(prefix) or 134400
        elseif s.itemKind then
            -- Item single: the item's own icon (groups use GROUP_ICON above).
            local ids = HET.db.profile[prefix .. "ids"]
            iconTex = (ids and ids[1] and C_Item and C_Item.GetItemIconByID
                and C_Item.GetItemIconByID(ids[1])) or 134400
        elseif s.abilityKind then
            -- Ability single: the spell's texture.
            local ids = HET.db.profile[prefix .. "ids"]
            iconTex = (ids and ids[1] and C_Spell.GetSpellTexture(ids[1])) or 134400
        elseif s.custom then
            local ids = HET.db.profile[prefix .. "ids"]
            iconTex = (ids and ids[1] and C_Spell.GetSpellTexture(ids[1])) or 134400
        else
            iconTex = C_Spell.GetSpellTexture(s.iconID)
        end
        local group
        if s.custom and not ns.trackers[s.cfgKey] then
            -- Saved custom whose tracker object doesn't exist YET
            -- (added mid-combat; the refresh pipeline builds it when
            -- the restriction lifts). Transient only.
            s._stub = true
            group = {
                type = "group",
                name = s.name,
                icon = iconTex,
                order = o,
                args = {
                    desc = {
                        type = "description",
                        name = "Will activate when you leave combat.",
                        order = 1,
                        width = "full",
                    },
                },
            }
        else
            group = BuildSpellGroup(s.cfgKey, prefix, s.name, iconTex, o)
        end
        -- Built-in trackers get a light-blue name so they read distinctly from
        -- the (white) user-created custom trackers in the same list. Applied to
        -- the NAME before brackets/glyphs are added around it.
        if not s.custom then
            group.name = "|cff66ccff" .. group.name .. "|r"
        end
        -- Groups render bracketed in the tree: [External Defensives]
        -- (plus the icon swap); grayed while pending combat-end
        -- activation.
        if s.isGroup then
            group.name = "[" .. group.name .. "]"
        end
        -- Optional per-group spell glyph: the group icon stays the
        -- overlapping-squares GroupIcon (node icon), and the spell's
        -- own texture is embedded inline so the row shows the group
        -- icon THEN the spell icon (e.g. Time Spiral).
        if s.spellIcon then
            local stex = C_Spell.GetSpellTexture(s.spellIcon)
            if stex then
                group.name = "|T" .. stex .. ":16|t " .. group.name
            end
        end
        if s._stub then
            group.name = "|cff888888" .. group.name .. "|r"
        end
        local enabled = HET.db.profile[prefix .. "enabled"]
        if enabled == nil then enabled = true end
        -- Enabled spells with NO trackers configured used to prepend a
        -- warning glyph to the name here; the glyph now renders at the
        -- row's RIGHT edge instead, in the preview-eye slot (see TreeEye
        -- in the widget-decoration section), so the label stays clean.
        --
        -- Enabled/Disabled is no longer a STRUCTURAL split — there are no
        -- section headings to sort into. A disabled tracker sits where the
        -- user filed it and reads greyed (CustomView.lua's GreyName), which is
        -- what lets it stay selectable, draggable and hoverable.
        builtNodes[#builtNodes + 1] = {
            cfgKey  = s.cfgKey,
            node    = NodeFor(s.cfgKey),
            group   = group,
            enabled = enabled and true or false,
        }
    end
    -- Global preview eye, shown next to each subtab's per-section eye: drives
    -- EVERY tracker on every subtab (unscoped Show/Hide). White eye (vs the
    -- per-tab coloured eyes) reads as "all". Replaces the old top-right eye.
    local function GlobalPreviewButton()
        return {
            type = "execute",
            -- Tooltip text via `name` (see PreviewButton) — SetLabel is a no-op
            -- so it never renders as a label; an empty name shows no tooltip.
            name = "Show/Hide All Trackers",
            dialogControl = "HETPreviewEye",
            -- White (active) eye ONLY when every preview on every section is
            -- showing — not merely some (that was confusing). Clicking the red
            -- eye shows all; clicking the white eye hides all.
            image = function()
                return HET:AreAllPreviewsActive() and PREVIEW_MASK_TEX or PREVIEW_HIDE_TEX
            end,
            imageWidth = 20, imageHeight = 20,
            order = 0.35, width = 0.2,
            func = function()
                if HET:AreAllPreviewsActive() then
                    HET:HideAllPreviews()
                else
                    HET:ShowAllPreviews()
                end
            end,
        }
    end
    -- Addon Options: one top-level node, sorted after every folder. Returns a
    -- fresh table per call; the get/set act on global db.
    local function AddonOptions()
        return {
            type = "group",
            name = "|cffffd100Addon Options|r",
            order = 3,
            args = {
                showMinimapIcon = {
                    type = "toggle",
                    name = "Show Minimap Icon",
                    desc = "Show or hide the minimap button.",
                    order = 1,
                    width = "full",
                    get = function() return not HET.db.global.minimapIcon.hide end,
                    set = function(_, val)
                        HET.db.global.minimapIcon.hide = not val
                        local LibDBIcon = LibStub("LibDBIcon-1.0", true)
                        if not LibDBIcon then return end
                        if val then
                            LibDBIcon:Show("BuzzardAuras")
                        else
                            LibDBIcon:Hide("BuzzardAuras")
                        end
                    end,
                },
                -- The seed for ns.specFilter, which is session state (never
                -- saved) re-snapped at login and on every spec change — see
                -- UpdatePlayerSpecRole in Core.lua. Toggling here applies to
                -- the CURRENT session immediately as well, so the effect is
                -- visible now rather than only after the next spec change.
                specFilterFollowsSpec = {
                    type = "toggle",
                    name = "Default Filter to Current Spec",
                    desc = "When the options panel is opened, it will default"
                        .. " the \"Filter By Spec\" option to your current"
                        .. " spec. If toggled off, it will default to"
                        .. " \"All Specs\".",
                    order = 2,
                    width = "full",
                    get = function()
                        return HET.db.global.specFilterFollowsSpec ~= false
                    end,
                    set = function(_, val)
                        HET.db.global.specFilterFollowsSpec = val and true or false
                        ns.specFilter = val and ns.playerSpec or nil
                        -- Same re-seed bookkeeping UpdatePlayerSpecRole does,
                        -- so a later RefreshAll does not see a "spec change"
                        -- that never happened and overwrite this.
                        ns._specFilterSpec = ns.playerSpec or ns._specFilterSpec
                        if ns.OnSpecFilterChanged then ns.OnSpecFilterChanged() end
                    end,
                },
                -- Read as "not false" rather than as a plain truth test, so
                -- the feature is on for profiles that predate the setting
                -- without relying on the defaults merge having landed first.
                showGridWhenPreviewing = {
                    type = "toggle",
                    name = "Show Grid when Previewing",
                    desc = "Draw a full-screen alignment grid while any tracker"
                        .. " preview is showing. Lines are spaced 50 units apart"
                        .. " outward from the centre, and the gold cross marks"
                        .. " the screen centre — the same zero point"
                        .. " |cffffd100Center Horizontal|r and"
                        .. " |cffffd100Center Vertical|r move a tracker to.",
                    order = 3,
                    width = "full",
                    hidden = function() return not ns.ApplyPreviewGrid end,
                    get = function()
                        return HET.db.global.showGridWhenPreviewing ~= false
                    end,
                    set = function(_, val)
                        HET.db.global.showGridWhenPreviewing = val and true or false
                        -- Immediate, not the coalesced update: the user is
                        -- looking straight at it and a frame of lag reads as
                        -- the click not registering.
                        ns.ApplyPreviewGrid()
                    end,
                },
                sortOrder = {
                    type = "select",
                    name = "Sort Order",
                    desc = "How trackers are ordered inside each type or"
                        .. " folder."
                        .. "\n\n|cffffd100Alphabetical|r — one run, A to Z."
                        .. "\n\n|cffffd100Active First|r — the same order, but"
                        .. " disabled trackers are grouped after the enabled"
                        .. " ones.",
                    order = 4,
                    width = 1.2,
                    hidden = function() return not ns.SortOrder end,
                    values = function()
                        return { alpha = "Alphabetical", active = "Active First" }
                    end,
                    sorting = function() return { "alpha", "active" } end,
                    get = function() return ns.SortOrder() end,
                    set = function(_, val)
                        if val == ns.SortOrder() then return end
                        ns.SetSortOrder(val)
                        -- The options table is registered as a FUNCTION, so
                        -- NotifyChange re-runs BuildOptions and the tree comes
                        -- back in the new order. Node KEYS are unchanged, only
                        -- their order, so the current selection survives.
                        Notify()
                    end,
                },
            },
        }
    end

    -- ONE FLAT TREE at the root, whichever view is showing: top-level nodes,
    -- each holding its tracker nodes. No tab level and no Enabled/Disabled
    -- level — a disabled tracker sits alphabetically among the rest and simply
    -- reads greyed.
    --
    -- The tracker node tables are exactly the ones built above; CustomView.lua
    -- only decides which node each one hangs under, so nothing about a
    -- tracker's pane, previews or behaviour depends on the view.
    local treeArgs = ns.BuildTreeArgs and ns.BuildTreeArgs(builtNodes) or {}
    local args = {
        viewMode        = VIEW_SELECT,
        trackerSearch   = TRACKER_SEARCH_INPUT,
        addTracker      = ADD_SELECT,
        newFolder       = NEW_FOLDER_BUTTON,
        specFilter      = SPEC_FILTER_SELECT,
        -- Only the GLOBAL eye up here. Per-NODE eyes live on the tree rows
        -- themselves (see DecorateTreeXs), where they can say which node they
        -- belong to without a row of five buttons competing for the top strip.
        globalPreviews  = GlobalPreviewButton(),
        addonOptions    = AddonOptions(),
    }
    -- Addon Options sorts after every node, Ungrouped (9000) included.
    args.addonOptions.order = 9999
    for k, v in pairs(treeArgs) do args[k] = v end
    local root = {
        type = "group",
        name = "|cffff8c14Buzzard Auras|r",
        childGroups = "tree",
        -- COMBAT LOCK: every CONTROL is disabled in combat (previews and
        -- tracker restyles touch engine-owned regions that are forbidden
        -- then). Two ACD subtleties shape this:
        -- 1. `disabled` inheritance is deepest-defined-WINS, not OR — any
        --    nested `disabled` in this table overrides this one and must
        --    include its own combat check (see the folder groups in
        --    CustomView.lua and the reorder arrows).
        -- 2. GROUP nodes must NEVER be combat-disabled: ACD skips tree
        --    selection for disabled group entries ("if not entry.disabled
        --    then SelectByValue"), which blanks the whole pane instead of
        --    greying its contents. Hence the info.type guard — groups stay
        --    enabled (navigation works, content feeds), only the controls
        --    inside grey out.
        -- The combat watcher at the bottom of this file refreshes an open
        -- panel on both combat edges.
        disabled = function(info)
            return info.type ~= "group" and InCombatLockdown()
        end,
        args = args,
    }
    -- Held for the combat-edge fast path at the top of this function.
    lastBuiltOptions = root
    return root
end

function HET:RegisterOptions()
    AC:RegisterOptionsTable(addonName, BuildOptions)
    -- NOT primed here. Registering a FUNCTION makes AceConfig build the table
    -- lazily on the first feed (inside ACD:Open), and this used to throw that
    -- away by running a full BuildOptions at ADDON_LOADED purely to seed
    -- ns._firstTreeNode -- roughly 400 option-table nodes and 1000 closures PER
    -- TRACKER, on the loading screen, for a panel most sessions never open.
    -- hetOpen primes it at the point of use instead (see the guard there); it
    -- was already re-deriving the node whenever the Spec Filter had moved, so
    -- this only widens that guard to cover "never built at all".
    --
    -- Seeding the Spec Filter stays here: it is two API reads, and hetOpen's
    -- guard compares against ns.specFilter, which has to be populated for the
    -- first open to build filtered the same way the real one will be.
    if ns.UpdatePlayerSpecRole then ns.UpdatePlayerSpecRole() end
    -- BuildOptions is a file-local defined above; publish it so hetOpen, the
    -- Spec Filter re-prime and CustomView's view switch can reach it.
    --
    -- Wrapped to clear the combat-edge reuse latch first. Every caller of
    -- ns._BuildOptions wants the SIDE EFFECTS of a real build (ns._firstTreeNode,
    -- ns._treeNodeMembers, ns._primedSpecFilter), and the cache fast path returns
    -- before any of them are written -- CustomView's view switch in particular
    -- rebuilds specifically so it can read the new view's first node two lines
    -- later. The registered generator stays the raw BuildOptions, so only feeds
    -- that AceConfig drives can ever be served from the cache.
    ns._BuildOptions = function()
        ClearOptionsReuse()
        return BuildOptions()
    end
    -- Other addons often register LibSharedMedia fonts/media a moment after
    -- login (deferred registration). The font dropdown reads LSM's live list
    -- when it's built, so media registered after the panel was last built
    -- only appears on a later open — the "missing until I reopen" symptom.
    -- Refresh the options table (debounced) whenever new media registers so
    -- an open panel's font list stays complete without a manual reopen.
    if LSM and ACR and not self._lsmRefreshHooked then
        self._lsmRefreshHooked = true
        local pending = false
        LSM.RegisterCallback(self, "LibSharedMedia_Registered", function()
            if pending then return end
            pending = true
            C_Timer.After(0.5, function()
                pending = false
                -- New media means the font/texture dropdowns must be rebuilt
                -- with the live LSM list, so this feed cannot be served from
                -- the combat-edge cache.
                ClearOptionsReuse()
                ACR:NotifyChange(addonName)
            end)
        end)
    end
    -- Installed here AND re-checked on every panel open: a load-on-demand
    -- addon can register a newer AceGUI widget version long after us.
    ns.EnsureUIHooks()

    local g = self.db.global
    local w = g.optionsPanelW or 850
    local h = g.optionsPanelH or 550
    ACD:SetDefaultSize(addonName, w, h)
end

-------------------------------------------------------------------------------
-- AceGUI widget decoration
--
-- LibStub hands ONE AceGUI to every addon, and we cannot know whose copy
-- wins, what version it is, or whether a load-on-demand addon will
-- register a NEWER widget version over ours later. Everything below is
-- built for that:
--   * every field is capability-checked, so an unrecognised widget shape
--     passes through completely untouched;
--   * our added work is pcall'd, so an unexpected library version can
--     never break our options UI or any other addon's;
--   * installation is idempotent and re-run every time the panel opens,
--     wrapping whatever constructor is current by then;
--   * widgets already sitting in AceGUI's pool are patched in place,
--     since they were built by a constructor that predates ours and
--     would otherwise never gain the decoration.
-------------------------------------------------------------------------------

-- Main-tree preview eyes: each ENABLED spell row gets an eye button at the
-- row's right edge that toggles that tracker's previews. Previews are
-- per-TRACKER (same rule as the Tracked Buffs row eyes), so one click
-- shows/hides ALL of the spell's visual elements — exact same toggle logic
-- as the row eye above (reuses ShowElementPreview / HidePreview, no engine
-- changes). Icon swaps by state: preview.tga (blue) while a preview is
-- active, previewhide.tga (red) otherwise. Tree buttons are pooled and
-- restyled every RefreshTree, so state is re-resolved on each pass.
local function TrackerPreviewActive(cfgKey)
    local t = ns.trackers[cfgKey]
    if not t then return false end
    local els = GetEls(PrefixFor(cfgKey))
    for j = 1, #els do
        if t:IsElementPreviewActive(j) then return true end
    end
    return false
end

local function ToggleTrackerPreview(cfgKey)
    local t = ns.trackers[cfgKey]
    if not t then return end
    if TrackerPreviewActive(cfgKey) then
        t:HidePreview()
        return
    end
    local els = GetEls(PrefixFor(cfgKey))
    for j = 1, #els do
        if els[j].style and els[j].style ~= "sound" then
            t:ShowElementPreview(j)
        end
    end
end

-- The same two questions for a SET of trackers — what a top-level tree row's
-- eye drives. "Active" means ANY member is previewing, and clicking an active
-- eye hides them all: the same all-or-nothing rule the panel's global eye
-- uses, so one eye never leaves the user guessing which half it will toggle.
local function AnyPreviewActive(keys)
    for i = 1, #keys do
        if TrackerPreviewActive(keys[i]) then return true end
    end
    return false
end

local function ToggleGroupPreview(keys)
    if AnyPreviewActive(keys) then
        for i = 1, #keys do
            local t = ns.trackers[keys[i]]
            if t then t:HidePreview() end
        end
        return
    end
    for i = 1, #keys do
        -- Sound-only trackers have nothing to show; ToggleTrackerPreview
        -- already skips them, and a tracker with no object yet is a no-op.
        if not TrackerPreviewActive(keys[i]) then ToggleTrackerPreview(keys[i]) end
    end
end

-- Right-click-on-preview navigation, called from a tracker holder's OnMouseUp
-- (Tracker.lua). Acts ONLY while the options panel is open: jumps the panel to
-- the tracker's settings node and ensures it is in preview mode (never toggles
-- preview OFF). A holder is only mouse-enabled while previewing, so today this
-- fires for icon/bar/circle previews; permanently-shown live trackers would
-- need their holders mouse-enabled while the panel is open (follow-up).
function ns.NavigateToTracker(cfgKey)
    if not cfgKey then return end
    if not (ACD.OpenFrames and ACD.OpenFrames[addonName]) then return end
    -- Combat guard (2026-08-13): this was the ONLY preview entry point
    -- without one (eye OnClick, ShowPreviewFor, Show/HideAllPreviews all
    -- guard). ShowElementPreview sets _elPreview[i]=true BEFORE
    -- ApplyElementStyle touches engine-owned button regions — forbidden in
    -- combat — so an in-combat right-click threw mid-sequence and stranded
    -- partial preview state (flag/mouse desync). Navigation-only in combat.
    if InCombatLockdown() then return end
    if ns.trackers[cfgKey] and not TrackerPreviewActive(cfgKey) then
        ToggleTrackerPreview(cfgKey)
    end
    -- Deferred (mirrors AddCustom): keeps the navigation off the click's own
    -- control flow and lets any preview refresh land first.
    C_Timer.After(0, function()
        SelectTracker(cfgKey)
        if ACD.OpenFrames and ACD.OpenFrames[addonName] then
            ACD:Open(addonName)
        end
    end)
end

-- Yellow "!" warning mark (AvailableQuestIcon — stable across clients),
-- shown in the eye slot for enabled spells with no configured trackers.
local NO_TRACKERS_TEX = "Interface\\GossipFrame\\AvailableQuestIcon"

-- Applies the eye's look for the current preview state: showing = tinted
-- (per-tab colour) white eye mask at full alpha, hidden = red eye at 50%.
-- Eye opacity. The IDLE (preview hidden) state is the one every row shows by
-- default, so it stays deliberately recessive — it should read as available
-- rather than compete with the tracker names it sits beside. The ADD highlight
-- texture still brightens it on hover, so being faint costs no discoverability.
-- LOCKED sits below IDLE so the combat lock is still distinguishable now that
-- IDLE is faint; it also desaturates, so the two never look alike.
local EYE_ALPHA_ACTIVE = 1
local EYE_ALPHA_IDLE   = 0.3
local EYE_ALPHA_LOCKED = 0.2

local function SetEyeState(f, active)
    local tint = f._hetTint or PREVIEW_TINT_DEFAULT
    if active then
        f:SetAlpha(EYE_ALPHA_ACTIVE)
        f:SetNormalTexture(PREVIEW_MASK_TEX)
        f:SetHighlightTexture(PREVIEW_MASK_TEX, "ADD")
        f:GetNormalTexture():SetVertexColor(tint[1], tint[2], tint[3])
        f:GetHighlightTexture():SetVertexColor(tint[1], tint[2], tint[3])
    else
        f:SetAlpha(EYE_ALPHA_IDLE)
        f:SetNormalTexture(PREVIEW_HIDE_TEX)
        f:SetHighlightTexture(PREVIEW_HIDE_TEX, "ADD")
        f:GetNormalTexture():SetVertexColor(1, 1, 1)
        f:GetHighlightTexture():SetVertexColor(1, 1, 1)
    end
    -- Combat lock: grey the eye while options are frozen (re-runs on the
    -- combat watcher's NotifyChange via DecorateTreeXs; OnClick guards too).
    local locked = InCombatLockdown()
    f:GetNormalTexture():SetDesaturated(locked)
    if locked then f:SetAlpha(EYE_ALPHA_LOCKED) end
end

-- `keys` is the tracker (or trackers) this eye drives: one cfgKey for a
-- tracker row, every member for a top-level node row. nil hides the eye.
-- Right-edge inset for a row's decorations.
--
-- AceGUI tree rows put the +/- EXPAND TOGGLE at the right edge, and only rows
-- WITH CHILDREN show it — so a top-level node row (folder, type node,
-- Ungrouped) has one and a tracker row does not. Decorations have to step left
-- past it or they sit on top of it.
--
-- Re-applied on EVERY pass rather than once at creation, because tree buttons
-- are POOLED: the same button is a folder header this refresh and a tracker the
-- next, and an offset baked in at creation would be wrong for whichever of the
-- two it was not built for.
local ROW_SLOT_W = 18   -- 16px of art + a 2px gap
local function RowDecorInset(button)
    local tog = button.toggle
    if tog and type(tog.IsShown) == "function" and tog:IsShown() then
        local w = type(tog.GetWidth) == "function" and tog:GetWidth() or nil
        -- GetWidth can report 0 before the first layout pass; the template's
        -- toggle is 16px, so fall back rather than overlap.
        if type(w) ~= "number" or w <= 0 then w = 16 end
        return -4 - (w + 2)
    end
    return -4
end

-- SHIFT-CLICK A ROW TO LINK IT. Shift-click is the game's universal "put this
-- in the thing I'm typing" gesture, so a tracker row should honour it while a
-- chat box is open — and only then, which is what makes it safe to take the
-- click away from row selection.
--
-- Implemented by WRAPPING the row's OnClick rather than hooking it: a hook
-- cannot stop the original from also selecting the row. The original is kept
-- on the button and always called when the link is declined, so a row that
-- travels back into another addon's tree still behaves exactly as before
-- (ns.ShiftLinkRow checks the owning tree and refuses).
local function Row_OnClickWithLink(self, ...)
    if IsShiftKeyDown() and ns.ShiftLinkRow then
        local ok, handled = pcall(ns.ShiftLinkRow, self)
        if ok and handled then return end
    end
    local orig = self._hetClickOrig
    if orig then return orig(self, ...) end
end

-- Self-healing, and symmetrical. Wrap when the live script is not already
-- ours; UNWRAP on any row that is not one of ours this pass, restoring the
-- script we displaced. Rows are pooled across addons, so leaving the wrapper
-- riding a row into a foreign panel would mean the NEXT time it came back to
-- us we would capture whatever that addon had set as our fallthrough.
local function WireRowChatLink(button, enable)
    if type(button) ~= "table" or type(button.GetScript) ~= "function" then return end
    local cur = button:GetScript("OnClick")
    if enable then
        if cur == Row_OnClickWithLink then return end
        button._hetClickOrig = cur
        button:SetScript("OnClick", Row_OnClickWithLink)
    elseif cur == Row_OnClickWithLink then
        button:SetScript("OnClick", button._hetClickOrig)
        button._hetClickOrig = nil
    end
end

local function TreeEye(button, keys, warn, tab)
    local eye = button._hetEye
    if not keys or #keys == 0 then
        if eye then eye:Hide() end
        return
    end
    if not eye then
        eye = CreateFrame("Button", nil, button)
        eye:SetSize(16, 16)
        eye:SetScript("OnClick", function(f)
            if InCombatLockdown() then return end  -- combat lock
            if f._hetWarn then return end
            ToggleGroupPreview(f._hetKeys)
            local active = AnyPreviewActive(f._hetKeys)
            SetEyeState(f, active)
            if GameTooltip:GetOwner() == f then
                GameTooltip:SetText(active and "Preview showing" or "Preview hidden", 1, 1, 1)
            end
            -- RESTYLE THE WHOLE TREE, not just this eye. A node row's eye and
            -- its children's eyes describe overlapping sets, so toggling one
            -- changes what the others should be showing — and nothing else
            -- redraws them (the preview calls never NotifyChange, and
            -- RefreshTree otherwise only runs on scroll/resize/expand). Left
            -- alone, a node eye stays red while a child previews, and the next
            -- click on that red "show" eye would HIDE. RefreshTree is wrapped
            -- to re-run DecorateTreeXs, which re-resolves every row's state.
            local row = f:GetParent()
            local tree = row and row.obj
            if tree and type(tree.RefreshTree) == "function" then
                pcall(tree.RefreshTree, tree)
            end
        end)
        eye:SetScript("OnEnter", function(f)
            GameTooltip:SetOwner(f, "ANCHOR_RIGHT")
            if InCombatLockdown() then
                GameTooltip:SetText("Locked during combat", 1, 0.3, 0.3)
            elseif f._hetWarn then
                -- _hetWarn carries the REASON string (see the tree decorator);
                -- older call sites passed a plain boolean, hence the fallback.
                GameTooltip:SetText(type(f._hetWarn) == "string" and f._hetWarn
                    or "No trackers configured", 1, 1, 1)
            elseif #f._hetKeys > 1 then
                GameTooltip:SetText(AnyPreviewActive(f._hetKeys)
                    and ("Hide all " .. #f._hetKeys .. " previews")
                    or ("Show all " .. #f._hetKeys .. " previews"), 1, 1, 1)
            else
                GameTooltip:SetText(AnyPreviewActive(f._hetKeys)
                    and "Preview showing" or "Preview hidden", 1, 1, 1)
            end
            GameTooltip:Show()
        end)
        eye:SetScript("OnLeave", function() GameTooltip:Hide() end)
        button._hetEye = eye
    end
    eye:ClearAllPoints()
    eye:SetPoint("RIGHT", RowDecorInset(button), 0)
    eye._hetKeys = keys
    eye._hetWarn = warn or nil
    eye._hetTint = PREVIEW_TINT[tab] or PREVIEW_TINT_DEFAULT
    eye:SetFrameLevel(button:GetFrameLevel() + 2)
    if warn then
        eye:SetAlpha(1)
        eye:SetNormalTexture(NO_TRACKERS_TEX)
        eye:SetHighlightTexture(NO_TRACKERS_TEX, "ADD")
        eye:GetNormalTexture():SetVertexColor(1, 1, 1)
        eye:GetHighlightTexture():SetVertexColor(1, 1, 1)
    else
        SetEyeState(eye, AnyPreviewActive(keys))
    end
    eye:Show()
end

-- Speaker button one slot LEFT of the preview-eye position, on ENABLED
-- spell rows whose tracker has at least one Sound Alert element: click
-- plays every configured sound, resolved exactly like the tracker pane's
-- Test button (ResolveElementSoundPath + PlaySoundFile on the element's
-- channel). Combat lock matches the eye: greyed, clicks ignored — the
-- whole options panel is control-disabled in combat, so the raw-frame
-- buttons restate that. Gold speaker art is a stock texture (baked
-- colour, so no per-tab tinting like the eyes).
local SPEAKER_TEX = "Interface\\Common\\VoiceChat-Speaker"

local function PlayTrackerSounds(cfgKey)
    local t = ns.trackers[cfgKey]
    if not t then return end
    local els = GetEls(PrefixFor(cfgKey))
    for j = 1, #els do
        local e = els[j]
        if e.style == "sound" then
            local path = ns.ResolveElementSoundPath(t, e)
            if path then
                PlaySoundFile(path, e.soundChannel or "Master")
            end
        end
    end
end

local function TreeSpeaker(button, cfgKey)
    local spk = button._hetSpeaker
    if not cfgKey then
        if spk then spk:Hide() end
        return
    end
    if not spk then
        spk = CreateFrame("Button", nil, button)
        spk:SetSize(16, 16)
        spk:SetNormalTexture(SPEAKER_TEX)
        spk:SetHighlightTexture(SPEAKER_TEX, "ADD")
        spk:SetScript("OnClick", function(f)
            if InCombatLockdown() then return end  -- combat lock
            PlayTrackerSounds(f._hetCfgKey)
        end)
        spk:SetScript("OnEnter", function(f)
            GameTooltip:SetOwner(f, "ANCHOR_RIGHT")
            if InCombatLockdown() then
                GameTooltip:SetText("Locked during combat", 1, 0.3, 0.3)
            else
                GameTooltip:SetText("Play sound alert", 1, 1, 1)
            end
            GameTooltip:Show()
        end)
        spk:SetScript("OnLeave", function() GameTooltip:Hide() end)
        button._hetSpeaker = spk
    end
    -- One slot further left than the eye, whatever inset that ended up at.
    -- Rows with no eye (sound-only trackers) keep the same column, so speakers
    -- stay aligned down the list.
    spk:ClearAllPoints()
    spk:SetPoint("RIGHT", RowDecorInset(button) - ROW_SLOT_W, 0)
    spk._hetCfgKey = cfgKey
    spk:SetFrameLevel(button:GetFrameLevel() + 2)
    -- Combat lock: same greying as SetEyeState (restyled on the combat
    -- watcher's NotifyChange via DecorateTreeXs).
    local locked = InCombatLockdown()
    spk:GetNormalTexture():SetDesaturated(locked)
    spk:SetAlpha(locked and 0.3 or 1)
    spk:Show()
end

-- TreeGroup: remove-tracker Xs on the rows of HET's tracker lists, plus a
-- delete-custom X on the content pane's top-right while a custom entry is
-- selected. Rows are pooled plain Buttons the lib restyles on every
-- RefreshTree, so this re-runs after each refresh. Scoped hard to our
-- appName; other addons' trees are left alone.
local function DecorateTreeXs(self)
    local user = type(self.GetUserDataTable) == "function" and self:GetUserDataTable()
    local path = user and user.path
    local ours = user and user.appName == addonName
    local isTrackers = ours and path and path[#path] == "trackers"
    -- The tracker list is the ROOT tree (path length 0), with uniquevalues
    -- shaped "folder1\1custom3". Row decoration — the preview eyes, speaker
    -- icons and warning "!" — keys off that, and so does the pane's
    -- delete-custom X further down.
    local isMain = ours and path and #path == 0

    local buttons = self.buttons
    if type(buttons) == "table" then
        for bi = 1, #buttons do
            local button = buttons[bi]
            local tip, popup, parg, fn
            local uv = type(button) == "table"
                and type(button.IsShown) == "function"
                and button:IsShown() and button.uniquevalue
            -- Only our tracker list offers shift-to-link. The wrapper re-checks
            -- ownership at click time too; this is where it goes on and off.
            WireRowChatLink(button, isMain and uv and true or false)
            if isTrackers and uv then
                local idx = type(uv) == "string" and uv:match("^el(%d+)$")
                if idx then
                    local i = tonumber(idx)
                    local node = path[#path - 1]
                    local cfgKey = NODE_TO_CFG[node] or node
                    local e = GetEls(PrefixFor(cfgKey))[i]
                    parg = (e and e.style and StyleLabel(cfgKey, e.style)) or "Tracker"
                    if e and e.pairFor then parg = parg .. " (Active)" end
                    tip, popup = "Remove " .. parg, "HET_REMOVE_TRACKER"
                    fn = function() DoRemoveTracker(cfgKey, i) end
                end
            end
            CornerX(button, fn and {
                point = "RIGHT", x = -4, y = 0, size = 14, level = 2,
                tooltip = tip, anchor = "ANCHOR_RIGHT",
                confirm = popup, confirmArg = parg, onClick = fn,
            } or nil)
            -- Right-edge slot on ENABLED spell rows only (disabled spells
            -- have nothing to preview; section headers and the "+" add rows
            -- never match). Custom nodes use their cfgKey as node key, so
            -- the NODE_TO_CFG fallback covers them. Slot contents:
            --   * no styled elements   → yellow "!" (was inline in the label)
            --   * any visual tracker   → preview eye
            --   * styled but sound-only → nothing (no visual to preview)
            -- Plus a second slot one step left: any Sound Alert element →
            -- speaker button (see TreeSpeaker above).
            local eyeKeys, eyeWarn, spkKey, eyeTint
            if isMain and uv and type(uv) == "string" and not uv:find("\1", 1, true) then
                -- TOP-LEVEL ROW (a folder, a type node, Ungrouped): one eye
                -- driving every tracker under it. The member list comes from
                -- the build that produced the row, so it is already
                -- spec-filtered and already excludes anything not rendered.
                -- "addonOptions" has no entry and correctly gets nothing.
                local members = ns._treeNodeMembers and ns._treeNodeMembers[uv]
                if members and #members > 0 then
                    local visual = {}
                    for m = 1, #members do
                        local ck = members[m]
                        -- EXACTLY the rows that show a clickable eye of their
                        -- own, so the node eye and its children can never
                        -- disagree about what is previewable. A row whose eye
                        -- is the inert "!" warning is excluded here too.
                        local en = HET.db.profile[PrefixFor(ck) .. "enabled"]
                        if (en == nil or en) and ns.trackers[ck] then
                            local pfx = PrefixFor(ck)
                            local els = GetEls(pfx)
                            local anyStyled, anyVisual = false, false
                            for j = 1, #els do
                                local st = els[j].style
                                if st then
                                    anyStyled = true
                                    if st ~= "sound" then anyVisual = true end
                                end
                            end
                            local blankID = false
                            local t = ns.trackers[ck]
                            local en2 = t and t.GetCustomEntry and t:GetCustomEntry()
                            if en2 and (en2.kind == "aura" or en2.kind == "ability"
                                or en2.kind == "item") then
                                local ids = HET.db.profile[pfx .. "ids"]
                                if not (ids and ids[1]) then blankID = true end
                            end
                            if anyStyled and anyVisual and not blankID
                                and not RoleSpecNeverMatches(pfx) then
                                visual[#visual + 1] = ck
                            end
                        end
                    end
                    if #visual > 0 then
                        eyeKeys = visual
                        -- Mixed nodes (a folder can hold anything) get the
                        -- neutral default rather than the first member's tint.
                        local tint
                        for m = 1, #visual do
                            local t = SpellTab(visual[m])
                            if tint == nil then tint = t
                            elseif tint ~= t then tint = false break end
                        end
                        eyeTint = tint or nil
                    end
                end
            end
            local eyeKey
            if isMain and uv and type(uv) == "string" then
                -- Tracker rows, under any top-level node.
                local node = uv:match("^[^\1]+\1(.+)$")
                if node then
                    local ck = NODE_TO_CFG[node] or node
                    -- There is no Enabled/Disabled split in the tree any more,
                    -- so DISABLED trackers reach the decorator too and the
                    -- enablement test has to be made explicitly — a disabled
                    -- tracker has nothing to preview and its "!" would be noise.
                    local rowEnabled = true
                    if ns.trackerConfigs[ck] then
                        local en = HET.db.profile[PrefixFor(ck) .. "enabled"]
                        rowEnabled = (en == nil) or (en and true or false)
                    end
                    if rowEnabled and ns.trackerConfigs[ck] then
                        -- Resolved once: this ran three times per row, and the
                        -- decorator re-runs on every tree redraw — including
                        -- expand/collapse, resize and SCROLL.
                        local pfx = PrefixFor(ck)
                        local els = GetEls(pfx)
                        local anyStyled, anyVisual, anySound = false, false, false
                        for j = 1, #els do
                            local s = els[j].style
                            if s then
                                anyStyled = true
                                if s ~= "sound" then
                                    anyVisual = true
                                else
                                    anySound = true
                                end
                            end
                        end
                        -- A single spell/item/aura tracker with no Spell ID set
                        -- warns too (nothing to track yet), even if it already
                        -- has styled elements — same "!" as no trackers.
                        local blankID = false
                        local t = ns.trackers[ck]
                        local en = t and t.GetCustomEntry and t:GetCustomEntry()
                        if en and (en.kind == "aura" or en.kind == "ability" or en.kind == "item") then
                            local ids = HET.db.profile[pfx .. "ids"]
                            if not (ids and ids[1]) then blankID = true end
                        end
                        -- An elseif chain, not a precomputed `deadCond`: the
                        -- Show-Conditions test is the expensive one (up to three
                        -- list scans) and a row that already warns for a cheaper
                        -- reason never needs it. The reason doubles as the eye's
                        -- tooltip text.
                        if not anyStyled then
                            eyeKey, eyeWarn = ck, "No trackers configured"
                        elseif blankID then
                            eyeKey, eyeWarn = ck, "No Spell ID set"
                        elseif RoleSpecNeverMatches(pfx) then
                            -- Fully configured but can never load.
                            eyeKey, eyeWarn = ck,
                                "Never loads: nothing checked under Show Conditions"
                        elseif anyVisual and ns.trackers[ck] then
                            eyeKey = ck
                        end
                        if anySound and ns.trackers[ck] then
                            spkKey = ck
                        end
                    end
                end
            end
            -- Eye tint follows the tracker's aura TYPE — never the row's
            -- position, because a folder is renameable and can hold anything.
            if eyeKey then
                eyeKeys = { eyeKey }
                eyeTint = SpellTab(eyeKey)
            end
            TreeEye(button, eyeKeys, eyeWarn, eyeTint)
            TreeSpeaker(button, spkKey)
        end
    end

    -- CONTENT-PANE DELETE X, top-right. Two things can own it, and they are
    -- mutually exclusive because the patterns are: a CUSTOM TRACKER is selected
    -- (a child row, so the value carries a \1), or a FOLDER row is selected (a
    -- top-level value in the By Folder view). Built-in trackers, type nodes,
    -- Ungrouped and Addon Options own no X.
    local spec
    if isMain then
        local status = self.status or self.localstatus
        local selected = status and status.selected
        if type(selected) == "string" then
            local cid = selected:match("^[^\1]+\1custom(%d+)$")
            local fid = not cid and tonumber(selected:match("^folder(%d+)$"))
            if cid then
                local id = tonumber(cid)
                -- Memoized lookup: this runs on every tree redraw, which
                -- includes every scroll tick.
                local en = CustomEntryFor("custom" .. id)
                local nm = (en and en.name) or ("Custom " .. id)
                spec = {
                    point = "TOPRIGHT", x = -4, y = -4, size = 16, level = 10,
                    tooltip = "Delete " .. nm, anchor = "ANCHOR_LEFT",
                    confirm = "HET_DELETE_CUSTOM", confirmArg = nm,
                    onClick = function() DoDeleteCustom(id) end,
                }
            elseif fid and ns.FolderByID then
                local folder = ns.FolderByID(fid)
                if folder then
                    local nm = folder.name
                    spec = {
                        point = "TOPRIGHT", x = -4, y = -4, size = 16, level = 10,
                        -- Says what it does NOT do, because "delete" next to a
                        -- list of trackers reads like it deletes them.
                        tooltip = "Delete the folder \"" .. nm .. "\""
                            .. "\n|cffaaaaaaThe trackers inside it are not"
                            .. " deleted.|r",
                        anchor = "ANCHOR_LEFT",
                        confirm = "BUZZARDAURAS_DELETE_FOLDER", confirmArg = nm,
                        onClick = function()
                            if ns.DoDeleteFolder then ns.DoDeleteFolder(fid) end
                        end,
                    }
                end
            end
        end
    end
    CornerX(self.border, spec, "_hetDeleteX")

    -- FOLDER BOUNDING BOX (FolderMover.lua) rides this same pass. It depends on
    -- exactly what this function has just finished re-resolving — which node is
    -- selected, and which trackers are previewing — and RefreshTree is called
    -- on every one of the events that can change either: a row click
    -- (Button_OnClick -> SetSelected -> RefreshTree), a programmatic Select, an
    -- expand/collapse, a scroll, a resize, and the preview eye's own explicit
    -- RefreshTree. Driving the box from here is why it needs no polling.
    --
    -- isMain only: this decorator runs for every AceGUI tree, including other
    -- addons', and the box belongs to our root tracker list alone.
    if isMain and ns.RefreshFolderMover then
        pcall(ns.RefreshFolderMover)
    end
end

-- TreeGroup divider fix: the stock dragger uses Frame:StartSizing(), which
-- does nothing on a freshly-opened dialog until the frame has been moved
-- or re-laid-out (PTR-observed). Resize manually from cursor deltas via
-- the widget's own SetTreeWidth instead — works from the first frame.
-- Default tree widths (px), both persisted per-profile. The far-left
-- Enabled/Disabled spell (left navigation) tree; the per-tracker "Tracker
-- list" tree. Left-nav default trimmed 10px (202 → 192).
local SPELL_LIST_TREE_WIDTH   = 192
local TRACKER_LIST_TREE_WIDTH = 140

-- Which saved-width key + default this tree uses, or nil if it isn't one of
-- HET's. Re-evaluated at call time because AceGUI recycles tree widgets across
-- addons. The panel root IS the tracker tree (path length 0); a per-tracker
-- "Tracker list" tree is deeper (folder -> node -> trackers, length 3). No
-- tree exists at length 1, so the <= 1 test reads as "the root tree".
local function HETTreeWidthKey(widget)
    if type(widget.GetUserData) ~= "function"
        or widget:GetUserData("appName") ~= addonName then return nil end
    local path = widget:GetUserData("path")
    if not path or #path <= 1 then
        return "spellListTreeWidth", SPELL_LIST_TREE_WIDTH
    end
    return "trackerListTreeWidth", TRACKER_LIST_TREE_WIDTH
end

-- PANEL OPACITY.
--
-- AceGUI's TreeGroup builds both panes with SetBackdropColor(0.1, 0.1, 0.1, 0.5)
-- -- half transparent, so the game world reads straight through the tracker
-- list and the settings pane behind it. Overridden from OUR side rather than by
-- editing the library, for the same reason the suggestion flyout is restyled
-- here: the lib is shared, an edit is undone by the next update, and another
-- addon's copy of the widget can win the version race.
--
-- Colour only -- the backdrop TABLE (background + border art) is left alone, so
-- the pane keeps AceGUI's look and only stops being see-through.
-- do-block + ns entry point rather than six file-scope locals: Lua 5.1 caps a
-- function at 200 locals and this file's main chunk is at that ceiling.
--
-- Ours, and AceGUI's own values so a recycled widget can be handed back.
do
-- Between AceGUI's half-transparent 0.5 and fully solid: the panel reads as a
-- panel rather than a window into the game world, but still sits ON the UI
-- rather than blacking it out.
local PANE_BG_HET   = { 0.07, 0.07, 0.08, 0.80 }
local PANE_EDGE_HET = { 0.30, 0.30, 0.32, 1 }
local PANE_BG_ACE   = { 0.1, 0.1, 0.1, 0.5 }
local PANE_EDGE_ACE = { 0.4, 0.4, 0.4, 1 }

local function PaneColor(f, bg, edge)
    if not f or type(f.SetBackdropColor) ~= "function" then return end
    -- pcall: SetBackdropColor only exists while a backdrop is set, and AceGUI
    -- recycles these frames across addons.
    pcall(f.SetBackdropColor, f, bg[1], bg[2], bg[3], bg[4])
    pcall(f.SetBackdropBorderColor, f, edge[1], edge[2], edge[3], edge[4])
end

-- Applied per FEED, not at construction, and symmetric.
--
-- AceGUI pools TreeGroup widgets across every addon that uses one, and a
-- widget's owner is only knowable once ACD has fed it (appName lands in its
-- user data) -- so the constructor cannot tell ours apart. Colouring on each
-- refresh and handing AceGUI's own values back on a foreign refresh means a
-- recycled widget corrects itself the first time another addon draws it,
-- instead of silently inheriting our palette.
function ns.ApplyPaneOpacity(widget, mine)
    local bg = mine and PANE_BG_HET or PANE_BG_ACE
    local edge = mine and PANE_EDGE_HET or PANE_EDGE_ACE
    -- Never touch a widget we have not coloured. _hetPaneMine starts nil, and
    -- `nil == false` is false -- so without this the "hand AceGUI's values
    -- back" arm fired on every OTHER addon's TreeGroup the first time it
    -- refreshed, overwriting whatever skin it was using.
    if not mine and widget._hetPaneMine == nil then return end
    if widget._hetPaneMine == mine then return end
    widget._hetPaneMine = mine
    PaneColor(widget.treeframe, bg, edge)  -- the tracker/folder list, left
    PaneColor(widget.border, bg, edge)     -- the settings pane, right
end
end

local function PatchTreeGroup(widget)
    if widget._hetPatched then return end
    local dragger, treeframe = widget.dragger, widget.treeframe
    if dragger and treeframe
        and type(dragger.SetScript) == "function"
        and type(treeframe.GetWidth) == "function"
        and type(widget.SetTreeWidth) == "function" then
        dragger:SetScript("OnMouseDown", function(f)
            f._hetStartX = GetCursorPosition()
            f._hetStartW = treeframe:GetWidth()
            f:SetScript("OnUpdate", function(f2)
                local cx = GetCursorPosition()
                local scale = treeframe:GetEffectiveScale()
                local w = f2._hetStartW + (cx - f2._hetStartX) / scale
                local status = widget.status or widget.localstatus
                local maxW = math.min(400, ((status and status.fullwidth) or 800) - 50)
                if w < 100 then w = 100 elseif w > maxW then w = maxW end
                widget:SetTreeWidth(w, true)
            end)
        end)
        dragger:SetScript("OnMouseUp", function(f)
            f:SetScript("OnUpdate", nil)
            widget:Fire("OnTreeResize", treeframe:GetWidth())
        end)
    end
    if type(widget.RefreshTree) == "function" then
        local origRefreshTree = widget.RefreshTree
        widget.RefreshTree = function(self, ...)
            origRefreshTree(self, ...)
            -- Opacity rides the same per-feed hook as the decorators, because
            -- this is the first point at which the widget's owner is known.
            pcall(ns.ApplyPaneOpacity, self, HETTreeWidthKey(self) ~= nil)
            pcall(DecorateTreeXs, self)
            -- Drag-and-drop wiring rides the same rebuild: rows are pooled and
            -- restyled on every RefreshTree, and the decorator both WIRES our
            -- custom-view root rows and CLEARS every other row it sees (they
            -- are shared with other addons' trees).
            if ns.DecorateTreeDnD then pcall(ns.DecorateTreeDnD, self) end
        end
    end

    -- Tree width defaults + reload persistence. AceGUI wipes a widget's
    -- callbacks on release but keeps its methods, so wrap methods (not
    -- SetCallback) and re-check ownership at call time. SetStatusTable seeds
    -- the width from the saved profile value (or the narrower default) when a
    -- tree is built; SetTreeWidth writes the applied width back on drag.
    if type(widget.SetStatusTable) == "function" then
        local origSetStatusTable = widget.SetStatusTable
        widget.SetStatusTable = function(self, status, ...)
            -- The library's SetStatusTable, if status.treewidth is nil, seeds
            -- it to its OWN default and immediately applies that width to the
            -- tree frame. So we can't just set status.treewidth afterwards —
            -- the frame is already sized to the wrong (default) width and won't
            -- update on its own. Seed our saved/default width into the status
            -- table BEFORE the library reads it, so it applies the right one.
            if status and status.treewidth == nil and HET.db then
                local key, default = HETTreeWidthKey(self)
                if key then status.treewidth = HET.db.global[key] or default end
            end
            origSetStatusTable(self, status, ...)
        end
    end
    if type(widget.SetTreeWidth) == "function" then
        local origSetTreeWidth = widget.SetTreeWidth
        widget.SetTreeWidth = function(self, ...)
            origSetTreeWidth(self, ...)
            if HET.db then
                local key = HETTreeWidthKey(self)
                local st = self.status or self.localstatus
                local w = st and st.treewidth
                if key and type(w) == "number" then HET.db.global[key] = w end
            end
        end
    end

    widget._hetPatched = true
end

-- InlineGroup: the per-text groups get a red X inside their top-right
-- corner. The text index comes out of the title; the element it belongs
-- to comes from the context the Text header publishes while its pane is
-- fed. Runs for EVERY addon's inline groups, so anything that is not one
-- of ours simply clears the X.
local function DecorateInlineGroupTitle(self, title)
    local k = type(title) == "string" and title:match("^Text (%d+)")
    local ctx = k and ns._activeTextCtx
    local spec
    if ctx and type(ctx.texts) == "function"
        and type(ctx.hasTextStyle) == "function"
        and type(ctx.refresh) == "function" then
        local idx = tonumber(k)
        spec = {
            point = "TOPRIGHT", x = -4, y = -4, size = 14, level = 10,
            tooltip = "Remove " .. title, anchor = "ANCHOR_LEFT",
            onClick = function()
                if not ctx.hasTextStyle() then return end
                local t = ctx.texts()
                if not t[idx] then return end
                table.remove(t, idx)
                ctx.refresh()
                Notify()
            end,
        }
    end
    -- InlineGroup's widget table exposes only frame / content / titletext
    -- — the bordered box its constructor creates is NOT stored (unlike
    -- TreeGroup, which does expose .border). content's parent IS that box.
    local content = self.content
    local box = (content and type(content.GetParent) == "function"
        and content:GetParent()) or self.frame
    CornerX(box, spec, "_hetTextX")
end

local function PatchInlineGroup(widget)
    if widget._hetPatched then return end
    local origSetTitle = widget.SetTitle
    if type(origSetTitle) ~= "function" then return end
    widget.SetTitle = function(self, title, ...)
        origSetTitle(self, title, ...)
        pcall(DecorateInlineGroupTitle, self, title)
    end
    widget._hetPatched = true
end

-- Label (type = "description") mouse-over tooltip. AceConfigDialog only
-- wires its native OnEnter tooltip path (OptionOnMouseOver, which honours
-- tooltipHyperlink) onto interactive controls, not description Labels, so
-- our spell-ID rows would otherwise be inert. This patcher gives OUR
-- description Labels a hover tooltip driven by the option's tooltipHyperlink
-- (e.g. "spell:12345"), leaving every other addon's Labels untouched.
--
-- Timing: AceConfigDialog sets the widget's userdata (option/appName) via
-- InjectInfo AFTER all the SetText/SetImage calls, then AddChild → layout →
-- frame:Show. So OnShow is the first point where the userdata is readable;
-- it recomputes each feed (widgets are pooled and re-fed) and only enables
-- mouse for rows that actually carry a hyperlink.
local function LabelTip_OnEnter(frame)
    local hyperlink = frame._hetHyperlink
    if not hyperlink then return end
    -- Match AceConfigDialog's own option tooltips (OptionOnMouseOver uses
    -- SetOwner(frame, "ANCHOR_TOPRIGHT")).
    GameTooltip:SetOwner(frame, "ANCHOR_TOPRIGHT")
    GameTooltip:SetHyperlink(hyperlink)
    GameTooltip:Show()
end
local function LabelTip_OnLeave()
    GameTooltip:Hide()
end
local function LabelTip_OnShow(frame)
    local w = frame._hetWidget
    local hyperlink
    if w and type(w.GetUserData) == "function"
        and w:GetUserData("appName") == addonName then
        local opt = w:GetUserData("option")
        local h = opt and opt.tooltipHyperlink
        if type(h) == "function" then
            local ok, v = pcall(h)
            h = ok and v or nil
        end
        if type(h) == "string" then hyperlink = h end
    end
    frame._hetHyperlink = hyperlink
    frame:EnableMouse(hyperlink ~= nil)
end

local function PatchLabel(widget)
    if widget._hetPatched then return end
    widget._hetPatched = true
    local frame = widget.frame
    if type(frame) ~= "table" or type(frame.HookScript) ~= "function" then return end
    frame._hetWidget = widget
    frame:HookScript("OnShow", LabelTip_OnShow)
    frame:HookScript("OnEnter", LabelTip_OnEnter)
    frame:HookScript("OnLeave", LabelTip_OnLeave)
end

-- Defined at the end of the file; forward-declared so the single public
-- ensure entry point below can drive it too.
local EnsureOpenHook

-- Custom AceGUI widget for the per-tab preview toggles (used via
-- dialogControl on their icon-executes). AceGUI's stock Icon widget hovers
-- with a big blue glow texture behind the image
-- (UI-Character-Tab-Highlight); the tree-row/global eyes hover by
-- ADD-brightening the eye art over itself. This is a stripped-down Icon
-- whose HIGHLIGHT texture always mirrors the current image, so hovering
-- looks identical to the row eyes. Registered under our own type name, so
-- no other addon's widget versions can collide with it; safe to call
-- repeatedly (RegisterWidgetType no-ops on an equal-or-older version).
local function RegisterPreviewEyeWidget(AceGUI)
    local Type, Version = "HETPreviewEye", 1
    if (AceGUI:GetWidgetVersion(Type) or 0) >= Version then return end
    local methods = {
        OnAcquire = function(self)
            self:SetWidth(24)
            self:SetImage(nil)
            self:SetImageSize(20, 20)
            self:SetDisabled(false)
        end,
        -- Icon-only widget: ACD calls SetLabel(name) on icon-executes, but
        -- our options pass name = "" — accept and ignore any text so nothing
        -- ever renders under the eye (and height stays image-driven).
        SetLabel = function() end,
        SetImage = function(self, path, ...)
            local image = self.image
            image:SetTexture(path)
            if image:GetTexture() then
                local n = select("#", ...)
                if n == 4 or n == 8 then
                    image:SetTexCoord(...)
                else
                    image:SetTexCoord(0, 1, 0, 1)
                end
            end
            -- The hover highlight IS the current image (ADD-blended).
            self.highlight:SetTexture(path)
        end,
        SetImageSize = function(self, width, height)
            self.image:SetWidth(width)
            self.image:SetHeight(height)
            -- +4 keeps the eye row the same height as the add buttons (24).
            self:SetHeight(height + 4)
        end,
        SetDisabled = function(self, disabled)
            self.disabled = disabled
            if disabled then
                self.frame:Disable()
                self.image:SetVertexColor(0.5, 0.5, 0.5, 0.5)
            else
                self.frame:Enable()
                self.image:SetVertexColor(1, 1, 1, 1)
            end
        end,
    }
    local function Constructor()
        local frame = CreateFrame("Button", nil, UIParent)
        frame:Hide()
        frame:EnableMouse(true)
        frame:SetScript("OnEnter", function(f) f.obj:Fire("OnEnter") end)
        frame:SetScript("OnLeave", function(f) f.obj:Fire("OnLeave") end)
        frame:SetScript("OnClick", function(f, button)
            f.obj:Fire("OnClick", button)
            AceGUI:ClearFocus()
        end)
        local image = frame:CreateTexture(nil, "BACKGROUND")
        image:SetSize(20, 20)
        image:SetPoint("CENTER")
        local highlight = frame:CreateTexture(nil, "HIGHLIGHT")
        highlight:SetAllPoints(image)
        highlight:SetBlendMode("ADD")
        local widget = {
            image = image,
            highlight = highlight,
            frame = frame,
            type = Type,
        }
        for method, func in pairs(methods) do widget[method] = func end
        return AceGUI:RegisterAsWidget(widget)
    end
    AceGUI:RegisterWidgetType(Type, Constructor, Version)
end

-- Custom AceGUI widget for the Texture style's preview swatch (used via
-- dialogControl on a `description` entry — ACD honours dialogControl there,
-- AceConfigDialog-3.0.lua CreateControl(v.dialogControl or v.control, "Label")).
--
-- WHY NOT THE STOCK LABEL: AceGUI's Label:SetImage only does SetTexture +
-- SetTexCoord, so a description image can never show vertex color, alpha,
-- blend mode, desaturation or rotation — and Label:SetColor is the TEXT color,
-- not the image's. This widget applies exactly the five calls the live render
-- makes (Tracker.lua ApplyStyle, "texture" branch) so the swatch matches what
-- lands on screen. It also previews ATLAS picks, which the stock path could not
-- (AceConfig's image member has no atlas form).
--
-- Own type name, own pool: AceGUI recycles widgets across every Ace3 addon, and
-- rotation/blend/desaturation are sticky texture state. Patching the shared
-- Label would bleed a rotated ADD-blended image into other addons' icons. Even
-- within our own pool SetImage resets to neutral first, so a recycled swatch
-- can't inherit the previous element's look.
local function RegisterTexturePreviewWidget(AceGUI)
    local Type, Version = "HETTexturePreview", 1
    if (AceGUI:GetWidgetVersion(Type) or 0) >= Version then return end

    -- Lifted from AceGUIWidget-Label's UpdateImageAnchor: image on top when
    -- there is no caption or the row is narrow, otherwise image left of it.
    -- Height is driven by the content, which is what makes the row lay out.
    local function UpdateAnchor(self)
        if self.resizing then return end
        local frame, image, label = self.frame, self.image, self.label
        local width = frame.width or frame:GetWidth() or 0
        local height
        label:ClearAllPoints()
        image:ClearAllPoints()
        if self.imageshown then
            local iw = image:GetWidth()
            if (width - iw) < 200 or (label:GetText() or "") == "" then
                image:SetPoint("TOP")
                label:SetPoint("TOP", image, "BOTTOM")
                label:SetPoint("LEFT")
                label:SetWidth(width)
                height = image:GetHeight() + label:GetStringHeight()
            else
                image:SetPoint("TOPLEFT")
                if image:GetHeight() > label:GetStringHeight() then
                    label:SetPoint("LEFT", image, "RIGHT", 4, 0)
                else
                    label:SetPoint("TOPLEFT", image, "TOPRIGHT", 4, 0)
                end
                label:SetWidth(width - iw - 4)
                height = math.max(image:GetHeight(), label:GetStringHeight())
            end
        else
            label:SetPoint("TOPLEFT")
            label:SetWidth(width)
            height = label:GetStringHeight()
        end
        if not height or height == 0 then height = 1 end
        self.resizing = true
        frame:SetHeight(height)
        frame.height = height
        self.resizing = nil
    end

    local methods = {
        OnAcquire = function(self)
            self.resizing = true
            self:SetWidth(200)
            self:SetText("")
            self:SetFontObject(GameFontHighlightSmall)
            self:SetColor()
            self:SetImage(nil)
            self:SetImageSize(96, 96)
            self.resizing = nil
            UpdateAnchor(self)
        end,
        SetText = function(self, text)
            self.label:SetText(text or "")
            UpdateAnchor(self)
        end,
        SetFontObject = function(self, font)
            self.label:SetFontObject(font or GameFontHighlightSmall)
        end,
        -- Label parity: this is the CAPTION color, never the image's.
        SetColor = function(self, r, g, b)
            if not (r and g and b) then r, g, b = 1, 0.82, 0 end
            self.label:SetTextColor(r, g, b)
        end,
        SetImageSize = function(self, width, height)
            self.image:SetWidth(width)
            self.image:SetHeight(height)
            UpdateAnchor(self)
        end,
        -- The draw state rides in ns.texPreviewPending rather than through the
        -- argument list: ACD's option member API only carries a path. The
        -- handoff is ordered, not racy — ACD evaluates the `image` getter
        -- immediately before calling this (AceConfigDialog-3.0.lua ~:1414 then
        -- ~:1424/1426) — and we consume-and-clear so a stale table can never be
        -- picked up by the next swatch.
        SetImage = function(self, value)
            local st = ns.texPreviewPending
            ns.texPreviewPending = nil
            local image = self.image

            -- Neutral FIRST. Pooled widget: every one of these persists across
            -- a release/acquire, and SetAtlas overwrites texcoords, so the
            -- reset has to precede the apply.
            image:SetTexCoord(0, 1, 0, 1)
            image:SetRotation(0)
            image:SetBlendMode("BLEND")
            image:SetDesaturated(false)
            image:SetVertexColor(1, 1, 1, 1)
            image:SetAlpha(1)

            if value == nil or value == "" then
                image:SetTexture(nil)
            elseif type(ns.ApplyTextureValue) == "function" then
                -- Branches file vs atlas for us (TexturePicker.lua).
                ns.ApplyTextureValue(image, value, st and st.kind)
            else
                image:SetTexture(tonumber(value) or value)
            end
            -- Ask the region, don't trust the setter: SetAtlas fails silently
            -- on an unknown name and SetTexture on a bad path.
            self.imageshown = image:GetTexture() and true or nil

            if self.imageshown and st then
                image:SetVertexColor(st.r, st.g, st.b)
                image:SetAlpha(st.a)
                image:SetBlendMode(st.blend)
                image:SetDesaturated(st.desat)
                image:SetRotation(st.rot)
            end
            UpdateAnchor(self)
        end,
        OnWidthSet = function(self)
            UpdateAnchor(self)
        end,
    }

    local function Constructor()
        local frame = CreateFrame("Frame", nil, UIParent)
        frame:Hide()
        local label = frame:CreateFontString(nil, "BACKGROUND", "GameFontHighlightSmall")
        label:SetJustifyH("LEFT")
        label:SetJustifyV("TOP")
        local image = frame:CreateTexture(nil, "BACKGROUND")
        local widget = {
            label = label,
            image = image,
            frame = frame,
            type  = Type,
        }
        for method, func in pairs(methods) do widget[method] = func end
        return AceGUI:RegisterAsWidget(widget)
    end
    AceGUI:RegisterWidgetType(Type, Constructor, Version)
end

local WIDGET_PATCHERS = {
    TreeGroup   = PatchTreeGroup,
    InlineGroup = PatchInlineGroup,
    Label       = PatchLabel,
}
local installedCtor = {}

-- One entry point for every shared-library hook HET installs. Safe to
-- call repeatedly; each piece re-wraps only if something replaced it.
function ns.EnsureUIHooks()
    if EnsureOpenHook then pcall(EnsureOpenHook) end
    local AceGUI = LibStub("AceGUI-3.0", true)
    if type(AceGUI) ~= "table" then return end
    local reg = AceGUI.WidgetRegistry
    if type(reg) ~= "table" then return end
    local canRegister = type(AceGUI.RegisterWidgetType) == "function"
        and type(AceGUI.GetWidgetVersion) == "function"
    if canRegister then
        pcall(RegisterPreviewEyeWidget, AceGUI)
        pcall(RegisterTexturePreviewWidget, AceGUI)
    end
    for widgetType, patch in pairs(WIDGET_PATCHERS) do
        local current = reg[widgetType]
        -- Re-wrap whenever the registered constructor is not the one we
        -- installed: a later-loading addon may have registered a newer
        -- widget version over ours.
        if canRegister and type(current) == "function"
            and current ~= installedCtor[widgetType] then
            local base = current
            local ours = function()
                local widget = base()
                if type(widget) == "table" then pcall(patch, widget) end
                return widget
            end
            local ok = pcall(AceGUI.RegisterWidgetType, AceGUI, widgetType, ours,
                (AceGUI:GetWidgetVersion(widgetType) or 0) + 1)
            installedCtor[widgetType] = ok and ours or nil
        end
        local pools = AceGUI.objPools
        local pool = type(pools) == "table" and pools[widgetType]
        if type(pool) == "table" then
            for obj in pairs(pool) do
                if type(obj) == "table" then pcall(patch, obj) end
            end
        end
    end
end

-- AceConfigDialog panel customisation: the top-right X (replacing the
-- native Close button), panel size persistence, tree-state seeding and
-- preview cleanup on hide.
--
-- Same load-order hazard as the widget overrides above. ACD declares
-- `function AceConfigDialog:Open(...)` as a plain assignment on the
-- SHARED library table (AceConfigDialog-3.0.lua:1858) and only bails for
-- an equal-or-older MINOR, so a NEWER copy loading after us reassigns
-- :Open and silently discards this wrapper — no error, the X just stops
-- appearing. Hence: idempotent, and re-checked on every panel open so it
-- wraps whatever :Open is current by then.
-- Collapsed (minimized) panel height: just the title bar. File-scope because
-- both the minimize machinery below AND the status-height seeding in hetOpen
-- need it (a refresh of a minimized panel must seed the COLLAPSED height, see
-- below).
local COLLAPSED_HEIGHT = 40
local hetOpen
function EnsureOpenHook()
    if type(ACD) ~= "table" then return end
    local current = ACD.Open
    if type(current) ~= "function" or current == hetOpen then return end
    local origOpen = current
    hetOpen = function(self, appName, ...)
        if appName == addonName and HET.db then
            -- The panel root is the folder TREE, so root.groups.selected
            -- holds a tree uniquevalue ("folder1\1custom3"). It is seeded
            -- further down, once the node tables are known to be fresh. (Tree
            -- widths are handled by the PatchTreeGroup method wrappers, so
            -- nothing to seed for those.)
            local root = self:GetStatusTable(addonName, {})
            root.groups = root.groups or {}
            -- Panel size: AceGUI's Frame reads status.width/height on open, so
            -- seed them here (every open) from the saved size, else the default.
            -- SetDefaultSize alone isn't reliable — the status table can be
            -- created before it runs, and AceGUI then falls back to its own
            -- built-in default size. Seeding here is authoritative.
            --
            -- MINIMIZED refresh: this wrapper also runs for NotifyChange
            -- refreshes of an already-open panel (ACD re-Opens it), and
            -- AceGUI's ApplyStatus re-applies status.height every time. If the
            -- panel is currently minimized, seeding the saved FULL height here
            -- would expand the frame while the body regions stay hidden — a
            -- big blank box (seen when the title-bar eye's NotifyChange fired
            -- while collapsed). Seed the collapsed height instead so a refresh
            -- never changes a minimized panel's geometry. Real opens are safe:
            -- they force _hetRestore below, which restores the true height.
            local prior = self.OpenFrames and self.OpenFrames[addonName]
            local priorMinimized = prior and prior.frame and prior.frame._hetMinimized
            root.width  = HET.db.global.optionsPanelW or 850
            root.height = priorMinimized and COLLAPSED_HEIGHT
                or HET.db.global.optionsPanelH or 550

            -- Default selection and folder expand state.
            --
            -- ns._firstTreeNode is a side effect of the LAST BuildOptions.
            -- Two cases need a build here, and both are FIRST-OPEN work that
            -- used to be paid on the loading screen:
            --
            --   not _optionsBuilt -- nothing has ever been built. This is the
            --     normal path now that RegisterOptions no longer primes. It
            --     cannot be folded into the specFilter test below: with "All
            --     Specs" selected ns.specFilter is nil AND ns._primedSpecFilter
            --     is nil, so that comparison is false and the panel would open
            --     on Addon Options every time.
            --
            --   the Spec Filter has moved since the last build (a spec change,
            --     or the spec API only resolving late) -- the stored node can
            --     name a tracker the filter now removes.
            --
            -- Either way this is the point of USE, so a spec change still costs
            -- nothing for a panel that is never opened.
            --
            -- REAL OPENS ONLY (`not prior`). This wrapper also runs for
            -- NotifyChange refreshes of an already-open panel, and a filter
            -- change IS such a refresh -- so without the guard every dropdown
            -- pick would add a third full build for nothing.
            if not prior then
                -- A real open must never be served the combat-edge reuse
                -- latch: it can only still be set if the refresh it was armed
                -- for never ran (the panel closed first), in which case the
                -- held table may predate whatever happened while it was shut.
                ClearOptionsReuse()
                if not ns._optionsBuilt
                    or ns._primedSpecFilter ~= ns.specFilter then
                    pcall(ns._BuildOptions)
                end
            end
            -- Top-level nodes start EXPANDED, but only where the user has
            -- never touched them: a deliberately collapsed node stays
            -- collapsed across opens, which a blanket assignment would undo.
            root.groups.groups = root.groups.groups or {}
            local keys = ns.TreeNodeKeys and ns.TreeNodeKeys() or {}
            for i = 1, #keys do
                if root.groups.groups[keys[i]] == nil then
                    root.groups.groups[keys[i]] = true
                end
            end
            if root.groups.selected == nil and ns._firstTreeNode then
                root.groups.selected = ns._firstTreeNode
            end
        end
        -- Distinguish refresh-vs-open BEFORE the real Open runs: during a
        -- NotifyChange refresh of an already-open panel the OpenFrames entry
        -- exists here; on a real user open it's only created inside origOpen.
        -- Needed below so a refresh (e.g. the global eye's NotifyChange, which
        -- can fire while minimized — the eye lives on the title bar) doesn't
        -- get treated as an open and force-expand a minimized panel.
        local wasOpen = self.OpenFrames and self.OpenFrames[appName] and true or false
        origOpen(self, appName, ...)
        if appName ~= addonName then return end

        local aceFrame = self.OpenFrames and self.OpenFrames[addonName]
        if not aceFrame then return end

        -- AceGUI Frame widgets are POOLED across addons, and `content`'s
        -- anchors are set ONCE in the constructor
        -- (AceGUIContainer-Frame.lua:295) -- neither OnAcquire nor
        -- OnRelease restores them. So any addon that re-anchors content
        -- to make room for its own chrome (e.g. pushing TOPLEFT down for a
        -- status row) leaks that offset into whoever acquires the frame
        -- next, as a gap at the top of the panel.
        -- Don't assume the shared frame is pristine: assert what we need
        -- on EVERY open, ahead of the one-time decoration below.
        if aceFrame.content then
            aceFrame.content:ClearAllPoints()
            aceFrame.content:SetPoint("TOPLEFT",     aceFrame.frame, "TOPLEFT",      17, -27)
            aceFrame.content:SetPoint("BOTTOMRIGHT", aceFrame.frame, "BOTTOMRIGHT", -17,  40)
        end

        local frame = aceFrame.frame
        -- Real opens start expanded: if the panel was left minimized when
        -- closed, restore it now. Refreshes (wasOpen) must NOT do this —
        -- a minimized panel being refreshed stays minimized.
        if frame and not wasOpen and frame._hetMinimized and frame._hetRestore then
            frame._hetRestore()
        end

        -- EVERY-OPEN ownership assertion. The AceGUI panel frame is POOLED across
        -- all Ace3 addons, so a frame we get may have just been used by another
        -- addon (which left ITS chrome/native-Close state on it), OR may be one we
        -- hooked in a PRIOR open (so the one-time block below is skipped). Either
        -- way our chrome must be re-asserted here, not just at first-hook time.
        -- Find + cache the native Close button on this frame if not already known
        -- (it may have been hidden by another addon, so match regardless of shown).
        if frame and not frame._hetNativeCloseBtn then
            for i = 1, select("#", frame:GetChildren()) do
                local child = select(i, frame:GetChildren())
                if child and child.GetObjectType and child:GetObjectType() == "Button" then
                    local text = child.GetText and child:GetText()
                    if text == CLOSE or text == "Close" then
                        frame._hetNativeCloseBtn = child
                        break
                    end
                end
            end
        end
        if frame and frame._hetApplyChromeOwnership then
            frame._hetApplyChromeOwnership()
        end

        -- OWNERSHIP LATCH, for the hooks that run too late to ask ACD.
        -- Set on every open and cleared by our OnHide below, so between those
        -- two points the flag says "the panel currently holds this pooled
        -- frame" — which OpenFrames cannot answer at OnHide time (see the
        -- comment on that hook).
        if frame then frame._hetPanelHolds = true end

        if not frame or frame._hetHooked then return end
        frame._hetHooked = true

        -- Shared-frame ownership test. The AceGUI Frame is POOLED across every
        -- Ace3 addon — including our own standalone Export/Import dialogs — and
        -- the scripts we hook below stay installed on the frame object forever
        -- (a HookScript cannot be removed, and _hetHooked keeps us from adding
        -- a second copy). So every hook has to ask whether the frame is
        -- currently OURS before acting on it.
        local function hetOwnsFrame()
            local of = ACD.OpenFrames and ACD.OpenFrames[addonName]
            return of and of.frame and of.frame == frame and frame:IsShown()
        end
        -- NOTE for OnHide: OpenFrames is useless there. The widget's OWN
        -- OnHide script (set in AceGUI's constructor, so it runs before any
        -- HookScript) fires OnClose, and AceConfigDialog's OnClose handler
        -- clears OpenFrames[addonName] before our hook is reached — so an
        -- OpenFrames test would answer false for our own close and the whole
        -- teardown would be skipped. That hook uses the _hetPanelHolds latch.

        -- Save panel size on resize — but NOT while minimized (the collapsed
        -- height must never overwrite the user's real panel height), and NOT
        -- while some other consumer holds the pooled frame. Without the
        -- ownership test, our Export/Import dialogs acquiring this very frame
        -- and sizing themselves (560x320) wrote THEIR size into
        -- optionsPanelW/H; the NotifyChange that ACD fires after the Export
        -- button's func then re-seeded status.width/height from it and the
        -- options panel visibly shrank to dialog size.
        frame:HookScript("OnSizeChanged", function(self, width, height)
            if HET.db and not frame._hetMinimized and hetOwnsFrame() then
                HET.db.global.optionsPanelW = width
                HET.db.global.optionsPanelH = height
            end
        end)

        -- Cache the native AceConfigDialog "Close" button (may already be cached by
        -- the every-open block above). Visibility is driven by the ownership
        -- assertion, not hidden unconditionally here.
        if not frame._hetNativeCloseBtn then
            for i = 1, select("#", frame:GetChildren()) do
                local child = select(i, frame:GetChildren())
                if child and child.GetObjectType and child:GetObjectType() == "Button" then
                    local text = child.GetText and child:GetText()
                    if text == CLOSE or text == "Close" then
                        frame._hetNativeCloseBtn = child
                        break
                    end
                end
            end
        end

        -- Hide previews when panel closes. Also un-minimize on the way out so
        -- the pooled AceGUI frame is released EXPANDED — otherwise the hidden
        -- content/status regions (which the library's OnRelease/OnAcquire never
        -- re-show) would leak into the next addon that reuses the frame.
        frame:HookScript("OnHide", function(self)
            -- Pooled frame: only tear our session down when the frame being
            -- hidden is the one WE hold. Otherwise closing an Export/Import
            -- dialog that happened to acquire this frame would kill the user's
            -- previews and their in-progress spec-filter exemptions.
            -- The latch, not OpenFrames — by the time this runs the entry is
            -- already gone (see the note where hetOwnsFrame is defined).
            if not self._hetPanelHolds then return end
            self._hetPanelHolds = false
            HET:HideAllPreviews()
            -- The folder bounding box is driven by the tree decorator, and a
            -- closing panel produces no further RefreshTree — so it has to be
            -- told here or it would be left on screen over an empty UI.
            if ns.RefreshFolderMover then ns.RefreshFolderMover() end
            -- OnDragStop covers a mouse released off-frame, but a panel closed
            -- mid-drag never fires it — the ghost and the drop highlight would
            -- be left on screen with no owner.
            if ns.EndCustomViewDrag then ns.EndCustomViewDrag() end
            -- Closing the panel ends the editing session, so work-in-progress
            -- trackers stop being exempt from the Spec Filter. Done on CLOSE
            -- rather than on open: an import run from /ba with the panel shut
            -- opens the panel itself, and clearing there would filter the
            -- freshly imported tracker out before it was ever seen.
            if ns.ClearSpecFilterExempt then ns.ClearSpecFilterExempt() end
            -- Same reasoning for the search box: a search is how you find
            -- something now, not a setting. Left set, it would greet the user
            -- next time with most of their trackers missing and a one-word box
            -- as the only clue. No rebuild is needed -- the panel is closing,
            -- and the next open builds fresh.
            ns.trackerSearch = nil
            if ns.WipeSearchCache then ns.WipeSearchCache() end
            if self._hetMinimized and self._hetRestore then
                self._hetRestore()
            end
        end)

        -- Add X button in top-right corner
        local xBtn = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
        xBtn:SetSize(26, 26)
        xBtn:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -8, -8)
        xBtn:SetScript("OnClick", function()
            PlaySound(799)
            aceFrame:Hide()
        end)
        frame._hetXBtn = xBtn  -- stored so OnShow/OnHide can toggle it by owner

        -- ────────────────────────────────────────────────────────────────
        -- Floating Buzzard logo anchored above the top-center of the panel.
        -- Mirrors BuzzardFrames' Core_AceConfigDialog.lua chrome exactly:
        -- parented to the PANEL (so it tracks its position for free) and
        -- dragging it drags the whole panel.
        -- ────────────────────────────────────────────────────────────────
        local LOGO_SIZE = 64
        local logoFrame = CreateFrame("Frame", nil, frame)
        frame._hetLogoFrame = logoFrame  -- stored so the ownership toggle can show/hide it
        logoFrame:SetSize(LOGO_SIZE, LOGO_SIZE)
        logoFrame:EnableMouse(true)
        -- Bottom-center of the logo sits flush on the panel's top-center.
        logoFrame:SetPoint("BOTTOM", frame, "TOP", 0, 6)

        local logoTex = logoFrame:CreateTexture(nil, "ARTWORK")
        logoTex:SetAllPoints(logoFrame)
        -- 256x256 (power-of-two) art + TRILINEAR = mipmapped filtering, so the
        -- logo doesn't alias/jag when drawn into the 64px logo box.
        logoTex:SetTexture("Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\BuzzardAuras", nil, nil, "TRILINEAR")

        -- Drag the logo to move the panel. The logo is a CHILD anchored to the
        -- panel, so moving the PANEL carries it along (anchoring the panel to
        -- the logo instead would be a parent-anchored-to-child cycle).
        -- Mirrors AceGUI's own title-bar drag, including writing the final
        -- position back to the ACD status table so it survives rebuilds and
        -- reopens (MoverSizer_OnMouseUp, AceGUIContainer-Frame.lua:41-50).
        -- Only top/left are written: hetOpen seeds status.width/height
        -- authoritatively on every open (and must keep seeding COLLAPSED_HEIGHT
        -- while minimized), so writing size here could stamp a collapsed height
        -- into the status table.
        logoFrame:SetScript("OnMouseDown", function(self, button)
            if button ~= "LeftButton" then return end
            frame:StartMoving()
        end)
        logoFrame:SetScript("OnMouseUp", function(self, button)
            if button ~= "LeftButton" then return end
            frame:StopMovingOrSizing()
            local status = aceFrame.status or aceFrame.localstatus
            if status then
                status.top  = frame:GetTop()
                status.left = frame:GetLeft()
            end
        end)

        -- (The global preview eye used to live here, to the left of the
        -- minimize button. It now renders next to each subtab's per-section
        -- eye in the tree header — see GlobalPreviewButton — so it's reachable
        -- from every section with a "Show/Hide All Trackers" tooltip.)

        -- Minimize / restore button, to the LEFT of the X: Blizzard's native
        -- MaximizeMinimizeButtonFrameTemplate, a clean up/down chevron widget.
        -- Collapses the panel to just its
        -- title bar; content, status bar and resize grips are hidden so a
        -- collapsed panel can't be dragged/resized. (COLLAPSED_HEIGHT is
        -- file-scope — the status seeding in hetOpen needs it too.)
        local minBtn = CreateFrame("Frame", nil, frame, "MaximizeMinimizeButtonFrameTemplate")
        minBtn:SetPoint("RIGHT", xBtn, "LEFT", 0, 0)
        frame._hetMinBtn = minBtn  -- stored so OnShow/OnHide can toggle it by owner
        -- The template's default arrows point the opposite way to what the user
        -- expects (it shows a DOWN arrow while expanded). Flip each sub-button's
        -- glyphs vertically so: EXPANDED shows an UP arrow (click to minimize),
        -- MINIMIZED shows a DOWN arrow (click to expand). Vertical texcoord flip
        -- reverses the arrow regardless of the underlying atlas, so no atlas
        -- names are hardcoded (robust across client builds).
        local function FlipButtonArrows(btn)
            if not btn then return end
            for _, getter in ipairs({ "GetNormalTexture", "GetPushedTexture",
                "GetHighlightTexture", "GetDisabledTexture" }) do
                local tex = btn[getter] and btn[getter](btn)
                if tex and tex.SetTexCoord then tex:SetTexCoord(0, 1, 1, 0) end
            end
        end
        -- Flip now AND on every OnShow, so a template atlas re-apply (which
        -- resets texcoords) can never leave the arrow pointing the wrong way,
        -- even for one frame.
        local function FlipAndHookShow(btn)
            if not btn then return end
            FlipButtonArrows(btn)
            btn:HookScript("OnShow", function(self) FlipButtonArrows(self) end)
        end
        FlipAndHookShow(minBtn.MinimizeButton)
        FlipAndHookShow(minBtn.MaximizeButton)
        -- Body regions to hide while minimized. content is the pane; the
        -- status bar frame is the parent of statustext (AceGUI doesn't expose
        -- it on the widget directly); the three sizers are the resize grips.
        local statusbg = aceFrame.statustext and aceFrame.statustext:GetParent()
        -- Fixed list; iterated with a numeric loop (not ipairs) so a nil entry
        -- never truncates the rest.
        local bodyRegions = {
            aceFrame.content,
            statusbg,
            aceFrame.sizer_se, aceFrame.sizer_s, aceFrame.sizer_e,
        }
        local BODY_N = 5
        local function ShowBody(show)
            for i = 1, BODY_N do
                local r = bodyRegions[i]
                if r then if show then r:Show() else r:Hide() end end
            end
        end
        -- Pin the frame by its current TOP-RIGHT before changing height, so the
        -- top edge never moves when we collapse/expand. The AceGUI frame may be
        -- anchored by its bottom/SE corner (depending on how the user dragged
        -- the resize grips), which is why height changes otherwise shift the top
        -- inconsistently. Re-anchoring TOP-RIGHT to a fixed screen point makes it
        -- deterministic.
        local function PinTop()
            local right, top = frame:GetRight(), frame:GetTop()
            if not (right and top) then return end
            frame:ClearAllPoints()
            frame:SetPoint("TOPRIGHT", UIParent, "BOTTOMLEFT", right, top)
        end
        -- Apply the collapse/restore effect for a given state. Pure effect —
        -- the template owns the button visuals via its callbacks/Minimize()/
        -- Maximize(); this resizes the frame (top-pinned) and shows/hides body.
        local function ApplyMinimized(min)
            frame._hetMinimized = min and true or false
            PinTop()  -- lock the top edge BEFORE resizing
            if frame._hetMinimized then
                -- Remember the real height BEFORE collapsing so restore is exact.
                frame._hetRestoreHeight = frame:GetHeight()
                ShowBody(false)
                frame:SetHeight(COLLAPSED_HEIGHT)
            else
                ShowBody(true)
                local h = frame._hetRestoreHeight
                    or (HET.db and HET.db.global.optionsPanelH) or 550
                frame:SetHeight(h)
            end
        end
        -- Re-flip after each look change too, in case the template re-applies
        -- its atlas (which would reset the texcoords) when swapping buttons.
        local function ReflipBoth()
            FlipButtonArrows(minBtn.MinimizeButton)
            FlipButtonArrows(minBtn.MaximizeButton)
        end
        -- Template callbacks fire on the user's click. "Minimized look" =
        -- collapsed; "Maximized look" = expanded.
        minBtn:SetOnMinimizedCallback(function() ApplyMinimized(true); ReflipBoth() end)
        minBtn:SetOnMaximizedCallback(function() ApplyMinimized(false); ReflipBoth() end)
        -- Programmatic restore (reopen while previously minimized): drive the
        -- template so its button state and our effect stay in sync. Maximize()
        -- fires the callback above, which does the restore.
        frame._hetRestore = function()
            if minBtn.Maximize then minBtn:Maximize() else ApplyMinimized(false) end
        end
        -- Hover tooltips on the two sub-buttons.
        local function AddTip(btn, text)
            if not btn then return end
            btn:HookScript("OnEnter", function(self)
                GameTooltip:SetOwner(self, "ANCHOR_LEFT")
                GameTooltip:SetText(text, 1, 1, 1)
                GameTooltip:Show()
            end)
            btn:HookScript("OnLeave", function() GameTooltip:Hide() end)
        end
        AddTip(minBtn.MinimizeButton, "Minimize")
        AddTip(minBtn.MaximizeButton, "Expand")
        -- Each fresh open starts expanded; put the widget in its maximized look.
        frame._hetMinimized = false
        if minBtn.Maximize then minBtn:Maximize() end

        -- Shared-frame ownership toggle. The AceGUI Frame is POOLED across every
        -- Ace3 addon, and our chrome (X + minimize buttons) is parented to it —
        -- so without this it leaks onto the NEXT addon that reuses the frame, and
        -- another addon's native Close button leaks onto US. Show our chrome and
        -- hide the native Close ONLY while WE own the frame; otherwise hide our
        -- chrome and restore the native Close so the other addon looks correct.
        -- (hetOwnsFrame is defined once, near the top of this one-time block.)
        -- Stored on the frame so the EVERY-OPEN block above can re-assert it even
        -- when this one-time setup is skipped (frame already hooked).
        -- The logo sits OUTSIDE the panel rect (above its top edge), so it does
        -- not benefit from the panel's own child-clipping/level ordering the way
        -- in-rect chrome does. Strata outranks frame level, and the pooled AceGUI
        -- frame is re-levelled on acquire, so re-assert both on every open.
        -- The offset has to clear another panel's whole widget stack, not just
        -- its frame: AceGUI nests deeply and every child defaults to parent
        -- level + 1 (Frame(100) -> content -> TreeGroup -> border -> content ->
        -- ScrollFrame -> InlineGroup -> ... ), so other addons' controls sit
        -- around 105-115. AceGUI picks 100 precisely to leave headroom
        -- ("Lots of room to draw under it", AceGUIContainer-Frame.lua:186), so
        -- +50 is comfortably clear and still far below the level cap.
        local CHROME_LEVEL_OFFSET = 50
        frame._hetApplyChromeOwnership = function()
            if hetOwnsFrame() then
                if frame._hetLogoFrame then
                    frame._hetLogoFrame:SetFrameStrata(frame:GetFrameStrata())
                    frame._hetLogoFrame:SetFrameLevel(frame:GetFrameLevel() + CHROME_LEVEL_OFFSET)
                    frame._hetLogoFrame:Show()
                end
                if frame._hetXBtn then frame._hetXBtn:Show() end
                if frame._hetMinBtn then frame._hetMinBtn:Show() end
                if frame._hetEyeBtn then frame._hetEyeBtn:Show() end
                if frame._hetNativeCloseBtn then frame._hetNativeCloseBtn:Hide() end
            else
                if frame._hetLogoFrame then frame._hetLogoFrame:Hide() end
                if frame._hetXBtn then frame._hetXBtn:Hide() end
                if frame._hetMinBtn then frame._hetMinBtn:Hide() end
                if frame._hetEyeBtn then frame._hetEyeBtn:Hide() end
                if frame._hetNativeCloseBtn then frame._hetNativeCloseBtn:Show() end
            end
        end
        frame:HookScript("OnShow", frame._hetApplyChromeOwnership)
        -- On hide, always retract our chrome and restore the native Close so the
        -- next opener (us or another addon) starts from a clean frame.
        frame:HookScript("OnHide", function()
            if frame._hetLogoFrame then frame._hetLogoFrame:Hide() end
            if frame._hetXBtn then frame._hetXBtn:Hide() end
            if frame._hetMinBtn then frame._hetMinBtn:Hide() end
            if frame._hetEyeBtn then frame._hetEyeBtn:Hide() end
            if frame._hetNativeCloseBtn then frame._hetNativeCloseBtn:Show() end
        end)
        -- Assert the correct state for this open (we own the frame right now).
        frame._hetApplyChromeOwnership()
    end
    ACD.Open = hetOpen
end

-- Install now; ns.EnsureUIHooks re-checks on every open.
EnsureOpenHook()

-- COMBAT WATCHER for the options combat lock. On both combat edges,
-- re-render an open panel so every AceConfig control picks up / drops its
-- disabled state (the root `disabled = InCombatLockdown` only evaluates on
-- a render). On leaving combat, also run a HideAllPreviews that was
-- deferred by its combat guard (panel closed mid-combat with previews up —
-- see Core.lua). The raw chrome eye greys itself via its OnUpdate poll;
-- tree-row eyes restyle through this NotifyChange (DecorateTreeXs).
--
-- The re-feed REUSES the previously built options table (ns._reuseOptions, see
-- BuildOptions). PLAYER_REGEN_DISABLED lands at the pull -- the single most
-- latency-sensitive moment in the game -- and a full rebuild there was
-- thousands of table allocations for a table whose DATA cannot have changed:
-- the combat lock is precisely what stops anything editing it. ACD still walks
-- the reused table and still re-evaluates every `disabled` function on it, so
-- the lock applies exactly as before.
local combatWatcher = CreateFrame("Frame")
combatWatcher:RegisterEvent("PLAYER_REGEN_DISABLED")
combatWatcher:RegisterEvent("PLAYER_REGEN_ENABLED")
-- Also the one-shot re-bake trigger for the spec-grid strings, kept as a guard:
-- any build that happened before PLAYER_LOGIN would be too early to see a
-- class-colour addon's RAID_CLASS_COLORS rewrite. PLAYER_LOGIN fires after every
-- addon has loaded, so clearing the flag once here lets the next options build
-- re-bake with the final colours -- at most one extra bake per session, and none
-- at all for the panel-never-opened case (which is now every build, since the
-- table is only built on the first open).
combatWatcher:RegisterEvent("PLAYER_LOGIN")
combatWatcher:SetScript("OnEvent", function(_, event)
    if event == "PLAYER_LOGIN" then
        specStringsBaked = false
        return
    end
    if event == "PLAYER_REGEN_ENABLED" and ns.pendingHideAllPreviews then
        ns.pendingHideAllPreviews = nil
        HET:HideAllPreviews()
    end
    if ACD.OpenFrames and ACD.OpenFrames[addonName] then
        -- Serve this refresh from the last built table (see above). The timed
        -- clear is a backstop only: every invalidating path clears the latch
        -- itself, and this just guarantees it cannot survive into a later feed
        -- if the refresh it was armed for never ran (panel closed first).
        ns._reuseOptions = true
        -- Short backstop. ACD services a NotifyChange on the very next
        -- OnUpdate, so 0.1s is comfortably longer than the feed it is armed for
        -- and short enough that the latch cannot reach a user action. Every
        -- structural mutation clears it explicitly anyway (Notify /
        -- NotifyThrottled here, CustomView's and Transmission's Notify, the LSM
        -- refresh, a real open, and ns._BuildOptions) -- this only covers the
        -- case where the refresh never ran because the panel closed first.
        C_Timer.After(0.1, ClearOptionsReuse)
        ACR:NotifyChange(addonName)
    end
end)
