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

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

-- "|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.
-- The FIRST bake still lands early (the options prime runs at ADDON_LOADED), so
-- the combat watcher at the bottom of this file clears `specStringsBaked` once
-- on PLAYER_LOGIN -- after every addon has loaded -- and the next options build
-- re-bakes with the final colours. 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, and it was being rebuilt (plus five closures and a
-- six-part desc concat) once per tab, four times per options rebuild.
-- AceConfigDialog only ever READS option tables — it writes to the separate
-- status table — so one instance can safely serve all four tabs.
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 the +Add buttons (0.1)
        -- and the preview eyes (0.3+). Width is ABSOLUTE PIXELS (170 * n),
        -- and the row it shares is already busy on the Abilities/Items tab
        -- (two wide +Add buttons), 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.
-- 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 id = cfgKey:match("^custom(%d+)$")
    if not id then return false end
    id = tonumber(id)
    local list = HET.db.profile.customTrackers
    if type(list) == "table" then
        for i = 1, #list do
            if list[i].id == id then return list[i].harmful and true or false end
        end
    end
    return false
end

-- The customTrackers entry for a cfgKey ("custom<id>"), or nil for built-ins.
local function CustomEntryFor(cfgKey)
    local id = cfgKey:match("^custom(%d+)$")
    if not id then return nil end
    id = tonumber(id)
    local list = HET.db.profile.customTrackers
    if type(list) == "table" then
        for i = 1, #list do
            if list[i].id == id then return list[i] end
        end
    end
    return nil
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 nav section a customTrackers entry lives in (mirrors the subtab
-- routing in BuildOptions).
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

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

-- Run BuildOptions purely for its side effect of priming ns._firstEnabledNode /
-- _firstDisabledNode — the nodes the FIRST panel open selects. Called once from
-- RegisterOptions (ADDON_LOADED); the repair for a filter that moves afterwards
-- lives at the point of USE, in hetOpen.
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()
    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.
local refreshQueued = false
local pendingPreview = {}
local function RefreshTrackingThrottled(previewKey)
    if previewKey then pendingPreview[previewKey] = true end
    if refreshQueued then return end
    refreshQueued = true
    C_Timer.After(0.1, function()
        refreshQueued = false
        HET:RefreshTracking()
        for key in pairs(pendingPreview) do
            HET:RefreshPreviewFor(key)
        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
        ACR:NotifyChange(addonName)
    end
    C_Timer.After(NOTIFY_DEBOUNCE, tick)
end

-- The main left tree groups spells under Enabled/Disabled section nodes,
-- so every ACD path to a spell runs through its CURRENT section. 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
local function SpellSection(cfgKey)
    local enabled = HET.db.profile[PrefixFor(cfgKey) .. "enabled"]
    if enabled == nil then enabled = true end -- over-pool custom stubs
    return enabled and "sectionEnabled" or "sectionDisabled"
end

-- Top-level subtab key for a tracker ("buffs" / "debuffs"). The tree paths
-- (and every ACD:SelectGroup) are prefixed with this now that the panel is
-- split into subtabs.
local function SpellTab(cfgKey)
    -- ID kinds (ability/item, single or group) share ONE merged subtab now.
    if IsIDKindCfg(cfgKey) then return "idkinds" end
    if IsCustomKindCfg(cfgKey) then return "custom" end
    return IsDebuffCfg(cfgKey) and "debuffs" or "buffs"
end

local function GetEls(prefix)
    return HET.db.profile[prefix .. "elements"] or {}
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.
local function PassesSpecFilter(prefix)
    if ns.specFilter == nil 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

-- The four tracker subtabs, in tab order.
local SPELL_SUBTABS = { "buffs", "debuffs", "idkinds", "custom" }

-- Scratch path for ACD:GetStatusTable. That function walks path[1..#path] and
-- returns a table from its OWN store — it never keeps a reference to the path
-- itself (AceConfigDialog-3.0.lua:412-422) — so one reused table behaves
-- identically to a fresh `{ subtab }` and drops a garbage table per subtab per
-- options build. Fill index 1 immediately before the call; never hold it across
-- one.
local STATUS_PATH = {}

-- Which subtab a tracker's tree entry belongs to. Keyed off the spellDef flags
-- the tree builder already computes, so the Spec Filter and the build loop
-- cannot disagree about where a tracker lives. (Core's ns.SubtabForTracker
-- answers the same question from a live tracker object.)
local function SubtabForDef(s)
    -- Abilities and Items share ONE merged subtab; the per-tracker kind governs
    -- behaviour, not which tab it sits on.
    if s.abilityKind or s.itemKind then return "idkinds" end
    if s.customKind then return "custom" end
    if s.harmful then return "debuffs" end
    return "buffs"
end

-- 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.
local PREVIEW_TINT = {
    buffs   = { 0.063, 0.463, 0.816 },
    debuffs = { 1.000, 0.549, 0.078 },
    idkinds = { 0.667, 0.353, 1.000 },
}
local PREVIEW_TINT_DEFAULT = PREVIEW_TINT.buffs
-- The GLOBAL (all-tabs) eye on the title bar is yellow — deliberately not
-- any single tab's colour, since it drives every tab at once.
local PREVIEW_TINT_GLOBAL = { 1.000, 0.820, 0.000 }
-- Per-tab PRE-TINTED active-eye art for the subtab preview icon-buttons
-- (AceConfig image-executes render through AceGUI's Icon widget, which
-- can't be vertex-coloured like the raw-frame eyes, so each tab colour is
-- baked into its own file — previeworange/previewpurple were generated from
-- previewmask.tga with the matching PREVIEW_TINT colour; preview.tga IS the
-- blue original). "custom"/Other reuses blue, same as PREVIEW_TINT_DEFAULT.
local PREVIEW_TAB_TEX = {
    buffs   = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\preview",
    debuffs = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\previeworange",
    idkinds = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\previewpurple",
    custom  = "Interface\\AddOns\\BuzzardAuras\\Media\\Textures\\preview",
}
-- Noun used in each subtab's scoped preview button ("Show Buff Previews").
local SUBTAB_LABELS = {
    buffs   = "Buff",
    debuffs = "Debuff",
    idkinds = "Ability/Item",
    custom  = "Other",
}

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
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
        ACD:SelectGroup(addonName, SpellTab(cfgKey), SpellSection(cfgKey), NodeFor(cfgKey), "trackers", "el1")
    else
        ACD:SelectGroup(addonName, SpellTab(cfgKey), SpellSection(cfgKey), NodeFor(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 used = {}
    for j = 1, #list do used[list[j].id] = true end
    local newId = 1
    while used[newId] do newId = newId + 1 end
    -- 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
    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()
    -- Deep-copy every stored key of the source identity into the new prefix.
    local srcPref = "cu" .. id .. "_"
    local newPref = "cu" .. newId .. "_"
    for key, val in pairs(p) do
        if type(key) == "string" and key:sub(1, #srcPref) == srcPref then
            p[newPref .. key:sub(#srcPref + 1)] = DeepCopy(val)
        end
    end
    -- Blank the Spell ID / Name field on the copy.
    p[newPref .. "ids"] = {}
    ns.RegisterCustomConfig(newId)
    HET:RefreshTracking()
    Notify()
    -- Select the new node (deferred, same rationale as AddCustom).
    local newKey = "custom" .. newId
    C_Timer.After(0, function()
        ACD:SelectGroup(addonName, SpellTab(newKey), SpellSection(newKey), 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 {}
    local wasDebuff = false
    local wasTab = "buffs"  -- subtab the deleted tracker lived in (for re-select)
    for idx = #list, 1, -1 do
        if list[idx].id == id then
            wasDebuff = list[idx].harmful and true or false
            local k = list[idx].kind
            if k == "ability" or k == "abilitygroup"
                or k == "item" or k == "itemgroup" then
                -- Abilities + Items share ONE merged subtab.
                wasTab = "idkinds"
            elseif k == "custom" then
                wasTab = "custom"
            elseif wasDebuff then
                wasTab = "debuffs"
            end
            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()
    -- 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
    HET:RefreshTracking()
    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()
        ACD:SelectGroup(addonName, wasTab, "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,
                },
                -- 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
                                refresh()
                            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))
                                refresh()
                            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.
                    ACD:SelectGroup(addonName, SpellTab(cfgKey), SpellSection(cfgKey), groupKey, "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,
            },
            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,
            },

            -- 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()
                    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()
                    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()
                    end
                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
                    -- The spell node moves between the Enabled/Disabled
                    -- sections — rebuild and follow it.
                    Notify()
                    ACD:SelectGroup(addonName, SpellTab(cfgKey), SpellSection(cfgKey), groupKey)
                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 = GetEls(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()
                        ACD:SelectGroup(addonName, SpellTab(cfgKey), SpellSection(cfgKey), groupKey, "trackers", "el" .. newIdx)
                        return
                    end
                    if not CanAddTracker() or ClaimedTypes()[val] then return end
                    local els = GetEls(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()
                    ACD:SelectGroup(addonName, SpellTab(cfgKey), SpellSection(cfgKey), groupKey, "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 })
                -- 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 = GetEls(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 })
            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
            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 }
                        RefreshTrackingThrottled()
                    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).
    local function EnsureSpecTypes()
        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
            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
            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,
    }

    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 used = {}
    for j = 1, #list do
        used[list[j].id] = true
    end
    local id = 1
    while used[id] do id = id + 1 end
    -- 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
    list[#list + 1] = {
        id = id,
        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,
    }
    -- 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()
    local pref = "cu" .. id .. "_"
    p[pref .. "ids"] = {}
    if type(p[pref .. "elements"]) ~= "table" then
        p[pref .. "elements"] = {}
    end
    p[pref .. "enabled"] = true
    -- 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 ..." buttons, 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()
        ACD:SelectGroup(addonName, SpellTab(cfgKey), SpellSection(cfgKey), cfgKey)
        if ACD.OpenFrames and ACD.OpenFrames[addonName] then
            ACD:Open(addonName)
        end
    end)
end

local function BuildOptions()
    -- 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
    -- 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()
    -- Spells are grouped under Enabled/Disabled section nodes in the
    -- left tree; the generator re-sorts them on every NotifyChange, and
    -- the enable toggle re-selects the moved node. Custom trackers mix
    -- in alphabetically by their user-given name.
    -- Enabled/Disabled args split per subtab: built-ins + buff customs feed
    -- the Buffs tab; debuff customs feed the Debuffs tab.
    local buffEnabled, buffDisabled = {}, {}
    local debuffEnabled, debuffDisabled = {}, {}
    -- Custom-kind trackers (no aura dependency) feed their own third subtab.
    local customEnabled, customDisabled = {}, {}
    -- ID-kind trackers (ability/item, single or group) feed ONE merged
    -- "Abilities/Items" subtab (keyed "idkinds").
    local idEnabled, idDisabled = {}, {}
    -- First enabled (and, as a fallback, first disabled) tracker node per
    -- subtab, recorded in tree-sort order below; hetOpen reads these to seed
    -- each subtab's default selection.
    ns._firstEnabledNode  = { buffs = nil, debuffs = nil, custom = nil, idkinds = nil }
    ns._firstDisabledNode = { buffs = nil, debuffs = nil, custom = nil, idkinds = nil }
    local spellDefs = {
        { 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 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.
    ns._reselectNode = nil
    if ns.specFilter ~= nil then
        -- What each subtab has selected right now, before anything is dropped.
        local selectedCfg = {}
        for i = 1, #SPELL_SUBTABS do
            local subtab = SPELL_SUBTABS[i]
            STATUS_PATH[1] = subtab
            local st = ACD.GetStatusTable and ACD:GetStatusTable(addonName, STATUS_PATH)
            local sel = st and st.groups and st.groups.selected
            if sel then
                -- "sectionEnabled\001<node>" — take the part after the marker.
                local node = sel:match("[^\001]+$")
                if node then selectedCfg[subtab] = NODE_TO_CFG[node] or node end
            end
        end
        local kept, droppedAt = {}, {}
        for j = 1, #spellDefs do
            local s = spellDefs[j]
            s._idx = 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 PassesSpecFilter(pfx) then
                kept[#kept + 1] = s
            else
                local sub = SubtabForDef(s)
                -- Remember WHERE the selected tracker sat, so the selection can
                -- move to the one after it rather than jumping to the top.
                if selectedCfg[sub] == s.cfgKey then droppedAt[sub] = j end
            end
        end
        spellDefs = kept
        if next(droppedAt) then
            ns._reselectNode = {}
            for subtab, atIdx in pairs(droppedAt) do
                local firstKey, nextKey
                for j = 1, #kept do
                    local s = kept[j]
                    if SubtabForDef(s) == subtab then
                        firstKey = firstKey or s.cfgKey
                        -- First survivor that sat BELOW the dropped one.
                        if s._idx > atIdx then nextKey = s.cfgKey break end
                    end
                end
                -- Wrap to the top when the dropped tracker was last; `false`
                -- means the subtab has nothing left to select at all.
                local pick = nextKey or firstKey
                ns._reselectNode[subtab] = pick and NodeFor(pick) or false
            end
        end
    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.
        -- Route to the correct subtab's Enabled/Disabled bucket. Custom-kind
        -- goes to the Custom subtab; harmful customs to Debuffs; else Buffs.
        -- Same resolver the Spec Filter uses, so the two can never disagree.
        local subtab = SubtabForDef(s)
        local en, dis
        if subtab == "idkinds" then
            en, dis = idEnabled, idDisabled
        elseif subtab == "custom" then
            en, dis = customEnabled, customDisabled
        elseif subtab == "debuffs" then
            en, dis = debuffEnabled, debuffDisabled
        else
            en, dis = buffEnabled, buffDisabled
        end
        local node = NodeFor(s.cfgKey)
        if enabled then
            en[node] = group
            -- Record the FIRST enabled tracker per subtab (this loop is already
            -- in tree-sort order), so hetOpen can default the selection to it
            -- (in the Enabled section) instead of Addon Options.
            if ns._firstEnabledNode[subtab] == nil then
                ns._firstEnabledNode[subtab] = node
            end
        else
            dis[node] = group
            -- Fallback: first DISABLED tracker, used only when a subtab has no
            -- enabled trackers at all.
            if ns._firstDisabledNode[subtab] == nil then
                ns._firstDisabledNode[subtab] = node
            end
        end
    end
    local haveBuffEnabled    = next(buffEnabled)    ~= nil
    local haveBuffDisabled   = next(buffDisabled)   ~= nil
    local haveDebuffEnabled  = next(debuffEnabled)  ~= nil
    local haveDebuffDisabled = next(debuffDisabled) ~= nil
    local haveCustomEnabled  = next(customEnabled)  ~= nil
    local haveCustomDisabled = next(customDisabled) ~= nil
    local haveIDEnabled      = next(idEnabled)      ~= nil
    local haveIDDisabled     = next(idDisabled)     ~= nil

    -- Apply a pending re-selection from the Spec Filter above: the tracker that
    -- was selected no longer exists in the tree, so point the status table at
    -- the next one instead of letting ACD fall back to Addon Options (the
    -- section headings are unselectable, so its fallback skips past them).
    --
    -- Done HERE because it needs the finished node tables to know which section
    -- the replacement landed in, and it still lands on this feed: ACD calls
    -- GetOptionsTable (which runs this whole function) BEFORE it reads the
    -- status table to build the tree.
    if ns._reselectNode then
        local sections = {
            buffs   = { buffEnabled,   buffDisabled },
            debuffs = { debuffEnabled, debuffDisabled },
            idkinds = { idEnabled,     idDisabled },
            custom  = { customEnabled, customDisabled },
        }
        for subtab, node in pairs(ns._reselectNode) do
            STATUS_PATH[1] = subtab
            local st = ACD.GetStatusTable and ACD:GetStatusTable(addonName, STATUS_PATH)
            local pair = sections[subtab]
            if st and pair then
                st.groups = st.groups or {}
                if node and pair[1][node] then
                    st.groups.selected = "sectionEnabled\001" .. node
                elseif node and pair[2][node] then
                    st.groups.selected = "sectionDisabled\001" .. node
                else
                    -- Nothing left in this subtab; let ACD pick.
                    st.groups.selected = nil
                end
            end
        end
        ns._reselectNode = nil
    end

    -- Add buttons + Enabled/Disabled sections have the same shape in both
    -- subtabs — built from helpers to keep them in sync. Sections are non-
    -- selectable HEADINGS: `disabled` is inherited by children, so a path-
    -- depth test keeps the heading itself unselectable while its spells stay
    -- live. Under the subtabs the heading sits ONE LEVEL DEEPER than before
    -- (tab + section), so the threshold is `#info <= 2`, not <= 1.
    local function AddButton(label, descText, order, width, kind, harmful)
        return {
            type = "execute", name = label, desc = descText,
            order = order, width = width,
            func = function() AddCustom(kind, harmful) end,
        }
    end
    -- Per-tab preview toggle: scoped to the subtab it sits on, so the Buffs
    -- eye only drives buff previews, Debuffs only debuffs, etc. The panel's
    -- top-right eye button (see the chrome setup in EnsureOpenHook) is the
    -- global, all-tabs version. Rendered as an icon-execute (image set →
    -- AceGUI Icon widget): tab-coloured eye while any of the tab's previews
    -- show, red eye while all are hidden — same visual language as the
    -- tree-row and global eyes. The image function re-evaluates on every
    -- NotifyChange (executes trigger one after func), keeping it truthful.
    local function PreviewButton(subtab)
        local label = SUBTAB_LABELS[subtab] or "Previews"
        return {
            type = "execute",
            -- The hover tooltip text lives in `name`, not `desc`: ACD's tooltip
            -- uses the option name as its title (an empty name = no tooltip),
            -- and this widget's SetLabel is a no-op, so the name never renders
            -- as a visible label under the eye.
            name = "Show/Hide all " .. label .. " Trackers",
            -- Our own widget (see RegisterPreviewEyeWidget): hover brightens
            -- the eye art itself, exactly like the tree-row eyes, instead of
            -- the stock Icon widget's blue background glow. Falls back to the
            -- stock Icon if the type is somehow unregistered.
            dialogControl = "HETPreviewEye",
            image = function()
                return HET:IsAnyPreviewActive(subtab)
                    and (PREVIEW_TAB_TEX[subtab] or PREVIEW_TAB_TEX.custom)
                    or PREVIEW_HIDE_TEX
            end,
            imageWidth = 20, imageHeight = 20,
            order = 0.3, width = 0.2,
            func = function()
                if HET:IsAnyPreviewActive(subtab) then
                    HET:HideAllPreviews(subtab)
                else
                    HET:ShowAllPreviews(subtab)
                end
            end,
        }
    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
    -- "Filter By Spec": scopes BOTH the tracker list and the previews to the
    -- trackers that would load in the selected spec — anything whose Role
    -- and/or Spec condition matches it, plus everything with neither condition
    -- set (global). Defaults to your current spec and re-snaps on spec change
    -- (Core's UpdatePlayerSpecRole); the selection is session-only.
    local function Section(name, order, args, have)
        return {
            type = "group", name = name, order = order,
            -- Own `disabled` overrides the root's (deepest-wins), so the
            -- combat lock is restated — with the same info.type guard: the
            -- spell/tracker nodes under this section are GROUPS, and a
            -- combat-disabled group entry blanks the pane / blocks the tree
            -- instead of greying (see the root group's comment). #info <= 2
            -- keeps the section heading itself unselectable, as before.
            disabled = function(info)
                return (info.type ~= "group" and InCombatLockdown())
                    or #info <= 2
            end,
            hidden = not have, args = args,
        }
    end
    -- Addon Options is global (not per-polarity); show it in BOTH subtabs so
    -- it's reachable wherever the user is. Each call returns its own table so
    -- the two nodes get distinct ACD paths; 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,
                },
            },
        }
    end

    return {
        type = "group",
        name = "|cffff8c14Buzzard Auras|r",
        childGroups = "tab",
        -- 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 Section and the reorder
        --    arrows).
        -- 2. GROUP nodes must NEVER be combat-disabled: ACD skips tab/tree
        --    selection for disabled group entries ("if not entry.disabled
        --    then SelectTab/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 = {
            buffs = {
                type = "group",
                name = "Buffs",
                order = 1,
                childGroups = "tree",
                args = {
                    -- Inline execute args render as a top row above the tree.
                    addAura = AddButton("|cffffd100+Add Buff|r",
                        "Add a custom tracker for a single buff spell ID.",
                        0.1, 0.8, "aura", false),
                    addAuraGroup = AddButton("|cffffd100+Add Buff Group|r",
                        "Add a custom tracker for a list of buff spell IDs, with a checkbox per spell (like the External Defensives list).",
                        0.2, 0.9, "group", false),
                    hideAllPreviews = PreviewButton("buffs"),
                    globalPreviews = GlobalPreviewButton(),
                    specFilter = SPEC_FILTER_SELECT,
                    sectionEnabled  = Section("|cff44ff44Enabled|r",  1, buffEnabled,  haveBuffEnabled),
                    sectionDisabled = Section("|cffff4444Disabled|r", 2, buffDisabled, haveBuffDisabled),
                    addonOptions = AddonOptions(),
                },
            },
            debuffs = {
                type = "group",
                name = "Debuffs",
                order = 2,
                childGroups = "tree",
                args = {
                    addDebuff = AddButton("|cffffd100+Add Debuff|r",
                        "Add a custom tracker for a single debuff spell ID on your target or focus.",
                        0.1, 0.85, "aura", true),
                    addDebuffGroup = AddButton("|cffffd100+Add Debuff Group|r",
                        "Add a custom tracker for a list of debuff spell IDs, with a checkbox per spell.",
                        0.2, 1.0, "group", true),
                    hideAllPreviews = PreviewButton("debuffs"),
                    globalPreviews = GlobalPreviewButton(),
                    specFilter = SPEC_FILTER_SELECT,
                    sectionEnabled  = Section("|cff44ff44Enabled|r",  1, debuffEnabled,  haveDebuffEnabled),
                    sectionDisabled = Section("|cffff4444Disabled|r", 2, debuffDisabled, haveDebuffDisabled),
                    addonOptions = AddonOptions(),
                },
            },
            -- Merged Abilities/Items subtab: all four add buttons live here.
            -- Single ability/item trackers and mixed ability+item GROUPS coexist
            -- in one Enabled/Disabled list. The "Group" buttons set the group's
            -- DEFAULT entry type (which "Add ..." row is primed); the group panel
            -- then lets the user add either abilities or items (per-id idKinds).
            idkinds = {
                type = "group",
                name = "Abilities/Items",
                order = 3,
                childGroups = "tree",
                args = {
                    -- Two buttons only: one flexible single tracker (its panel
                    -- has an Ability/Item Type toggle), and one flexible group
                    -- (its list takes both ability and item entries).
                    addAbilityItem = AddButton("|cffffd100+Add Ability/Item|r",
                        "Add a tracker for a single ability or item — shows its icon with an optional cooldown swipe and charge/count, driven by its Load Conditions. Choose Ability or Item in its options.",
                        0.1, 1.1, "ability", false),
                    addAbilityItemGroup = AddButton("|cffffd100+Add Ability/Item Group|r",
                        "Add a tracker for a list of abilities and/or items, fanning out an icon or bar per entry. Add either type to it.",
                        0.2, 1.3, "abilitygroup", false),
                    hideAllPreviews = PreviewButton("idkinds"),
                    globalPreviews = GlobalPreviewButton(),
                    specFilter = SPEC_FILTER_SELECT,
                    sectionEnabled  = Section("|cff44ff44Enabled|r",  1, idEnabled,  haveIDEnabled),
                    sectionDisabled = Section("|cffff4444Disabled|r", 2, idDisabled, haveIDDisabled),
                    addonOptions = AddonOptions(),
                },
            },
            custom = {
                type = "group",
                name = "Other",
                order = 4,
                childGroups = "tree",
                args = {
                    addCustomTracker = AddButton("|cffffd100+Add Custom Tracker|r",
                        "Add a tracker with no aura dependency — show a custom icon, text, or sound driven purely by its Load Conditions (e.g. show an icon whenever specific spells are usable).",
                        0.1, 1.2, "custom", false),
                    hideAllPreviews = PreviewButton("custom"),
                    globalPreviews = GlobalPreviewButton(),
                    specFilter = SPEC_FILTER_SELECT,
                    sectionEnabled  = Section("|cff44ff44Enabled|r",  1, customEnabled,  haveCustomEnabled),
                    sectionDisabled = Section("|cffff4444Disabled|r", 2, customDisabled, haveCustomDisabled),
                    addonOptions = AddonOptions(),
                },
            },
        },
    }
end

function HET:RegisterOptions()
    AC:RegisterOptionsTable(addonName, BuildOptions)
    -- Prime ns._firstEnabledNode now: AceConfig only calls BuildOptions
    -- lazily on the first feed (inside ACD:Open), but hetOpen reads that table
    -- BEFORE calling the original Open to seed the default selection. Without
    -- this, the first open would find it empty and fall back to Addon Options.
    -- Seed the Spec Filter FIRST so this priming build is filtered the same way
    -- the real one will be — otherwise _firstEnabledNode points at a tracker
    -- the filter hides, and the first open selects a node that isn't there.
    if ns.UpdatePlayerSpecRole then ns.UpdatePlayerSpecRole() end
    -- BuildOptions is a file-local defined above; publish it so the login
    -- re-prime (Core's OnEnable) can reach it.
    ns._BuildOptions = BuildOptions
    ns.PrimeOptionsNodes()
    -- 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
                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

-- 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()
        ACD:SelectGroup(addonName, SpellTab(cfgKey), SpellSection(cfgKey), NodeFor(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

local function TreeEye(button, cfgKey, warn, tab)
    local eye = button._hetEye
    if not cfgKey then
        if eye then eye:Hide() end
        return
    end
    if not eye then
        eye = CreateFrame("Button", nil, button)
        eye:SetSize(16, 16)
        eye:SetPoint("RIGHT", -4, 0)
        eye:SetScript("OnClick", function(f)
            if InCombatLockdown() then return end  -- combat lock
            if f._hetWarn then return end
            ToggleTrackerPreview(f._hetCfgKey)
            local active = TrackerPreviewActive(f._hetCfgKey)
            SetEyeState(f, active)
            if GameTooltip:GetOwner() == f then
                GameTooltip:SetText(active and "Preview showing" or "Preview hidden", 1, 1, 1)
            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)
            else
                GameTooltip:SetText(TrackerPreviewActive(f._hetCfgKey)
                    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._hetCfgKey = cfgKey
    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, TrackerPreviewActive(cfgKey))
    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)
        -- Eye slot spans -4..-20 from the row's right edge; sit one slot
        -- further left with a 2px gap so the two never overlap. Rows with
        -- no eye (sound-only trackers) keep the same column so speakers
        -- stay aligned down the list.
        spk:SetPoint("RIGHT", -22, 0)
        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
    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 spell-list trees are the Buffs/Debuffs tab trees now (path length
    -- 1), not the old root tree (length 0) — same shift as HETTreeWidthKey.
    local isMain = ours and path and #path == 1

    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
            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 eyeKey, eyeWarn, spkKey
            if isMain and uv and type(uv) == "string" then
                local node = uv:match("^sectionEnabled\1(.+)$")
                if node then
                    local ck = NODE_TO_CFG[node] or node
                    if 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
            TreeEye(button, eyeKey, eyeWarn, isMain and path[1] or nil)
            TreeSpeaker(button, spkKey)
        end
    end

    local cid
    if isMain then
        local status = self.status or self.localstatus
        local selected = status and status.selected
        cid = type(selected) == "string"
            and selected:match("^section%a+\1custom(%d+)$")
    end
    local spec
    if cid then
        local id = tonumber(cid)
        local nm
        local list = HET.db.profile.customTrackers
        if type(list) == "table" then
            for j = 1, #list do
                if list[j].id == id then nm = list[j].name end
            end
        end
        nm = nm 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,
        }
    end
    CornerX(self.border, spec, "_hetDeleteX")
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 now a TAB group (not a tree), so the
-- Enabled/Disabled spell trees sit one level down under the Buffs/Debuffs
-- tabs (path length 1); a per-tracker "Tracker list" tree is deeper (length
-- >= 2). Both subtabs' spell lists share one saved width.
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

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, ...)
            pcall(DecorateTreeXs, self)
        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 a TAB group now. Default to the Buffs subtab,
            -- then seed each subtab's tree. (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 {}
            if root.groups.selected == nil then root.groups.selected = "buffs" end
            -- 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

            -- Seed each subtab's tree the same way: keep the (non-selectable)
            -- Enabled/Disabled headings expanded so their trackers are always
            -- visible, and default the selection to the FIRST enabled tracker
            -- rather than Addon Options (the first non-disabled top-level group,
            -- which AceConfigDialog would otherwise land on). Re-asserted on
            -- every open so a re-feed can't leave a section collapsed.
            -- The node tables below are a side effect of the LAST BuildOptions.
            -- If the Spec Filter has moved since (a spec change, or the spec
            -- API only resolving after the ADDON_LOADED prime), they can name a
            -- tracker the filter now removes and the panel opens on Addon
            -- Options. Re-derive them here, at the point of use, rather than
            -- rebuilding eagerly on every spec change for a panel that may
            -- never be 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, and it would feed nothing: by
            -- then every non-empty subtab has a remembered selection and the
            -- loop below is a no-op. The deferred refresh that follows sees the
            -- fresh tables anyway.
            if not prior and ns._primedSpecFilter ~= ns.specFilter then
                pcall(ns._BuildOptions)
            end
            local firstEnabled  = ns._firstEnabledNode  or {}
            local firstDisabled = ns._firstDisabledNode or {}
            for i = 1, #SPELL_SUBTABS do
                local subtab = SPELL_SUBTABS[i]
                STATUS_PATH[1] = subtab
                local st = self:GetStatusTable(addonName, STATUS_PATH)
                st.groups = st.groups or {}
                st.groups.groups = st.groups.groups or {}
                -- Always keep both section headings expanded.
                st.groups.groups.sectionEnabled = true
                st.groups.groups.sectionDisabled = true
                -- Default selection (only when nothing is remembered): the
                -- first enabled tracker in the Enabled section, else the first
                -- disabled one. Nothing is seeded for an empty subtab.
                if st.groups.selected == nil then
                    if firstEnabled[subtab] then
                        st.groups.selected = "sectionEnabled\001" .. firstEnabled[subtab]
                    elseif firstDisabled[subtab] then
                        st.groups.selected = "sectionDisabled\001" .. firstDisabled[subtab]
                    end
                end
            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

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

        -- Save panel size on resize — but NOT while minimized (the collapsed
        -- height must never overwrite the user's real panel height).
        frame:HookScript("OnSizeChanged", function(self, width, height)
            if HET.db and not frame._hetMinimized 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)
            HET:HideAllPreviews()
            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.
        local function hetOwnsFrame()
            local of = ACD.OpenFrames and ACD.OpenFrames[addonName]
            return of and of.frame and of.frame == frame and frame:IsShown()
        end
        -- 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).
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. The first bake
-- happens during the options prime at ADDON_LOADED, which is 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 -- exactly one extra bake per session, and none
-- at all for the panel-never-opened case.
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
        ACR:NotifyChange(addonName)
    end
end)
