-------------------------------------------------------------------------------
-- Core.lua
-- AceAddon lifecycle, tracker registry, engine-side alert registration.
--
-- 12.1: NO POLLING. Displays are driven entirely by each tracker's
-- AuraContainer slot (Tracker.lua). Alerts are registered with
-- C_UnitAuras.AddAuraSound — the engine plays the sound when the tracked
-- buff is applied to the player; addon code never observes the aura, so
-- this works while aura data is secret in combat.
--
-- PTR-VERIFY (aura sounds): registration lifetime is assumed per-session
-- (the 10.1 private-aura ancestor was). We defensively re-register on
-- PLAYER_ENTERING_WORLD with remove-first semantics; if registrations
-- turn out to persist harder/softer than that, adjust there.
-------------------------------------------------------------------------------
local addonName, ns = ...

local HET = LibStub("AceAddon-3.0"):NewAddon(addonName, "AceEvent-3.0", "AceConsole-3.0")
ns.addon = HET

-------------------------------------------------------------------------------
-- Build merged defaults from all registered trackers
-------------------------------------------------------------------------------
local function BuildDefaults()
    local profileDefaults = {}
    for i = 1, #ns.trackerOrder do
        local cfg = ns.trackerConfigs[ns.trackerOrder[i]]
        if cfg.defaults then
            for k, v in pairs(cfg.defaults) do
                profileDefaults[cfg.dbPrefix .. k] = v
            end
        end
    end
    return {
        global = {
            minimapIcon    = {},
            optionsPanelW  = 650,
            optionsPanelH  = 450,
            -- FOLDERS (CustomView.lua). Nav-only organisation of the options
            -- tree, stored ACCOUNT-WIDE and keyed by tracker UID (see
            -- ns.GenerateUID below) rather than cfgKey, because cfgKey is
            -- per-profile and its integer id is recycled.
            --   viewMode      "type" (Buffs/Debuffs/Abilities/Items/Other,
            --                 derived) | "folder" (the user's own folders)
            --   folders       ordered array of { id, name }
            --   folderNextID  monotonic; NEVER recycled, or a deleted folder's
            --                 membership would be inherited by the next one
            --                 created.
            --   folderMembers [folderID] = array of UIDs. A SET, despite the
            --                 shape: every node renders alphabetically, so
            --                 position within a folder means nothing.
            -- "Ungrouped" is COMPUTED (any live tracker in no folder list),
            -- never stored, so creating a tracker needs no write at all.
            viewMode       = "type",
            folders        = {},
            folderNextID   = 1,
            folderMembers  = {},
            -- Options-list preferences, account-wide like the rest of this
            -- block. Neither touches what the addon TRACKS — only how the
            -- options list reads.
            --   specFilterFollowsSpec  seed "Filter By Spec" to the player's
            --                          current spec (true) or to All Specs.
            --   sortOrder              "alpha" — one alphabetical run per node
            --                          | "active" — the same order, but
            --                          disabled trackers grouped after the
            --                          enabled ones within each node.
            specFilterFollowsSpec = true,
            sortOrder             = "alpha",
        },
        profile = profileDefaults,
    }
end

-------------------------------------------------------------------------------
-- Stable tracker UIDs
--
-- A tracker's cfgKey is NOT a portable identity: custom trackers are
-- "custom"..id where id is the first unused integer (so ids are handed back
-- out after a delete), and profile A's custom3 is unrelated to profile B's.
-- Account-wide folder membership and export/import both need an identity that
-- never changes and never recycles, so a `uid` is added as a SECOND identity.
-- The integer `id` is left exactly as it is: it drives cfgKey, dbPrefix
-- ("cu<id>_", i.e. every stored profile key) and frameSuffix, and re-keying
-- those would mean rewriting every stored key in every profile.
--
-- The "~" prefix is a NAMESPACE RESERVATION, not decoration. Built-in trackers
-- have no customTrackers entry, so their stable identity is their cfgKey
-- ("extdef", "bloodlust", ...) — fixed strings, identical in every profile.
-- No built-in key contains "~", so the two identity spaces are provably
-- disjoint and ns.UIDOf can safely return the raw cfgKey for built-ins.
-------------------------------------------------------------------------------
local UID_PREFIX = "~"
local B64 = {
    [0] = "a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p",
          "q","r","s","t","u","v","w","x","y","z",
          "A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P",
          "Q","R","S","T","U","V","W","X","Y","Z",
          "0","1","2","3","4","5","6","7","8","9","_","-",
}
-- UNIQUENESS DOES NOT REST ON THE RNG.
--
-- WoW's Lua sandbox does not expose math.randomseed (the client seeds its own
-- generator at startup), so there is no way to seed from here and no promise
-- about what the sequence looks like across clients. Rather than hope, the uid
-- is built from three parts, only the last of which is random:
--
--   6 chars (36 bits)  per-session SALT — the player GUID (globally unique,
--                      so two characters can never share one) folded together
--                      with the wall clock and frame timer (so two sessions on
--                      the SAME character differ).
--   2 chars (12 bits)  monotonic COUNTER — guarantees uniqueness within a
--                      session outright, even if the RNG repeated every call.
--   3 chars (18 bits)  RNG — spreads collisions between two sessions that
--                      somehow landed on the same salt.
--
-- 11 chars over a 64-symbol alphabet, 66 bits total, same shape as before. The
-- stamping pass still dedupes within a profile as a last line of defence.
local uidSalt, uidCounter = nil, 0
local uidScratch = {}

local UID_SALT_MOD = 68719476736   -- 2^36, the 6 salt chars

local function UIDSalt()
    if uidSalt then return uidSalt end
    local n = (time and time()) or 0
    local g = (GetTime and GetTime()) or 0
    n = n + math.floor((g % 1) * 100000)
    local guid = UnitGUID and UnitGUID("player")
    if type(guid) == "string" then
        for i = 1, #guid do
            n = (n * 33 + guid:byte(i)) % UID_SALT_MOD
        end
    end
    uidSalt = n % UID_SALT_MOD
    return uidSalt
end

function ns.GenerateUID()
    local n = UIDSalt()
    for i = 1, 6 do
        uidScratch[i] = B64[n % 64]
        n = math.floor(n / 64)
    end
    uidCounter = uidCounter + 1
    n = uidCounter % 4096
    for i = 7, 8 do
        uidScratch[i] = B64[n % 64]
        n = math.floor(n / 64)
    end
    for i = 9, 11 do
        uidScratch[i] = B64[math.random(0, 63)]
    end
    return UID_PREFIX .. table.concat(uidScratch, "", 1, 11)
end

-- Idempotent stamp/dedupe over one profile's customTrackers. Handles the
-- first-stamp case and the duplicate-collision case in one pass.
local function StampUIDs(p)
    local list = p and p.customTrackers
    if type(list) ~= "table" then return end
    local seen = {}
    for i = 1, #list do
        local e = list[i]
        if type(e) == "table" then
            if type(e.uid) ~= "string" or e.uid == "" or seen[e.uid] then
                repeat e.uid = ns.GenerateUID() until not seen[e.uid]
            end
            seen[e.uid] = true
        end
    end
end
ns.StampUIDs = StampUIDs

-- uid <-> cfgKey for the CURRENT profile. Cached against the existing
-- ns.customEntryGen counter (Tracker.lua), which every customTrackers
-- mutation and every profile swap already bumps — so there is no new cache to
-- remember to invalidate.
local uidMap, keyMap, uidMapGen = {}, {}, -1
local function BuildUIDMaps()
    if uidMapGen == ns.customEntryGen then return end
    wipe(uidMap)
    wipe(keyMap)
    uidMapGen = ns.customEntryGen
    local list = HET.db and HET.db.profile and HET.db.profile.customTrackers
    if type(list) == "table" then
        for i = 1, #list do
            local e = list[i]
            if type(e) == "table" and type(e.uid) == "string" and e.id then
                local key = "custom" .. e.id
                uidMap[e.uid] = key
                keyMap[key] = e.uid
            end
        end
    end
    -- Built-ins are their own uid.
    for k, cfg in pairs(ns.trackerConfigs) do
        if not cfg.custom then
            uidMap[k] = k
            keyMap[k] = k
        end
    end
end

-- uid -> cfgKey, or nil when that uid is not live in the current profile
-- (it may still be live in ANOTHER profile — see the prune in Options.lua).
function ns.CFGKeyForUID(uid)
    if type(uid) ~= "string" then return nil end
    BuildUIDMaps()
    return uidMap[uid]
end

-- cfgKey -> uid. Built-ins return their own cfgKey.
function ns.UIDOf(cfgKey)
    if type(cfgKey) ~= "string" then return nil end
    BuildUIDMaps()
    return keyMap[cfgKey]
end

-- The whole uid -> cfgKey map, for callers that need to test many uids at once
-- (the folder resolver). Read-only: do not mutate the returned table.
function ns.UIDMap()
    BuildUIDMaps()
    return uidMap
end

-------------------------------------------------------------------------------
-- Engine-side aura sounds (C_UnitAuras.AddAuraSound, 12.1)
-------------------------------------------------------------------------------
local AddAuraSound    = C_UnitAuras and C_UnitAuras.AddAuraSound
local RemoveAuraSound = C_UnitAuras and C_UnitAuras.RemoveAuraSound
local TRIGGER_ADDED   = (Enum.UnitAuraSoundTrigger and Enum.UnitAuraSoundTrigger.Added) or 0

local registeredSoundIDs = {}

-- Change-guard scratch for UpdateAuraSounds: the desired registrations are
-- built here as flat (spellID, path, channel) triples, hashed, and only pushed
-- to the engine when the hash moves. Reused across calls so a refresh that
-- changes nothing allocates nothing but the signature string.
local soundPlan = {}
local soundPlanCount = 0
local appliedSoundSig = nil

-- REGISTRATION OUTCOMES (2026-08-13). AddAuraSound's return is documented
-- nilable and we used to discard it, so an engine REFUSAL was both silent and
-- permanent for the session: appliedSoundSig above is written BEFORE these
-- calls, so once a plan is marked applied nothing retries it until the plan
-- changes again. That is what hid the FileDataID bug below for a whole release
-- — the picker offered sounds that could never fire and said nothing. Keep this
-- surface: any future engine refusal now names itself in chat, and
-- `/het debug soundplan` dumps the live plan with the id per entry.
--
-- Parallel to soundPlan: soundResultIDs[k] is the auraSoundID of the k-th
-- planned registration, or false if refused. Flat and reused, so a rebuild
-- allocates nothing beyond the plan itself. Stays aligned with soundPlan
-- because the only path that skips the Add loop is the signature guard, and an
-- unchanged signature means an identical plan.
local soundResultIDs = {}
local soundResultCount = 0
-- Paths already reported this session: one bad sound fanned out over N spell
-- IDs should print once, not N times.
local soundAddReported = {}

-- Reverse LSM path -> registered name, for human-readable diagnostics. Only
-- ever called from the refusal print and the /het debug soundplan dump, never
-- from a refresh path.
local function LSMSoundName(path)
    local LSM = LibStub("LibSharedMedia-3.0", true)
    if LSM then
        for name, file in pairs(LSM:HashTable("sound")) do
            if file == path then return name end
        end
    end
    return nil
end

-- Live bisection flags (/het debug ...): each force-parks a subsystem.
ns.debugSuppress = { slots = false, groups = false, sounds = false }

local VOICE_PATH = "Interface\\AddOns\\BuzzardAuras\\Media\\Audio\\Voice_%s.ogg"

-- One-time pre-resolution (NormalizeVoiceData, run once at init). All
-- voice playback strings are baked here so the refresh path never formats
-- a path or scans a list — it only does O(1) lookups and the unavoidable
-- engine AddAuraSound calls.
--
-- Per spellList record we cache: clipPath / clipShortPath (full file
-- paths). Per config.voiceOptions entry we cache a `kind`:
--   "perspell" (+ short bool) — plays the per-spell name clip, full/short
--   "fixed"    (+ path)       — plays one clip Media\Audio\Voice_<value>.ogg on
--                               every tracked ID (e.g. "Free Movement")
-- plus cfg.voiceByValue (value → entry) and cfg.voiceDefault.
local voiceDataReady = false
local function NormalizeVoiceData()
    if voiceDataReady then return end
    voiceDataReady = true
    for i = 1, #ns.trackerOrder do
        local cfg = ns.trackerConfigs[ns.trackerOrder[i]]
        local spellList = cfg.spellList
        if spellList then
            for _, sp in ipairs(spellList) do
                if sp.clip then sp.clipPath = VOICE_PATH:format(sp.clip) end
                if sp.clipShort then sp.clipShortPath = VOICE_PATH:format(sp.clipShort) end
            end
        end
        local opts = cfg.voiceOptions
        if opts then
            local byValue = {}
            for _, o in ipairs(opts) do
                if o.perSpell then
                    o.kind, o.short = "perspell", (o.perSpell == "short")
                else
                    -- Fixed clip: plays Media\Audio\Voice_<value>.ogg unless the
                    -- option supplies an explicit `file` (lets the stored
                    -- choice value differ from the clip filename, e.g. the
                    -- "PI" choice reusing Voice_PowerInfusionShort.ogg).
                    o.kind, o.path = "fixed", VOICE_PATH:format(o.file or o.value)
                end
                byValue[o.value] = o
            end
            cfg.voiceByValue = byValue
            cfg.voiceDefault = opts[1] and opts[1].value
        end
    end
end
ns.NormalizeVoiceData = NormalizeVoiceData

-- Per-spell voice clip path (pre-formatted). `short` picks the short
-- callout; spells without one fall back to the full clip.
local function SpellVoicePath(sp, short)
    return (short and sp.clipShortPath) or sp.clipPath
end

-- The chosen voiceOptions entry for an element (O(1)); nil if the tracker
-- has no voiceOptions.
local function SelectedVoiceOption(cfg, el)
    local map = cfg.voiceByValue
    if not map then return nil end
    return map[el.voiceChoice or cfg.voiceDefault] or cfg.voiceOptions[1]
end

-- How a voice-mode element plays. Returns either:
--   "fixed",   <path>        -- one clip for all tracked IDs
--   "perspell", <short bool> -- per-spell name clips (short or full)
-- No voiceOptions ⇒ legacy per-spell using the element's voiceShort flag.
local function ResolveVoicePlan(cfg, el)
    local opt = SelectedVoiceOption(cfg, el)
    if opt then
        if opt.kind == "fixed" then return "fixed", opt.path end
        return "perspell", opt.short
    end
    return "perspell", el.voiceShort
end

-- Effective sound type of a sound element: "voice" (bundled spell-name
-- clip) or "custom" (LSM sound). Custom trackers and spellList groups
-- without any bundled clips (hasVoiceClips false, e.g. Personal
-- Defensives) are always "custom".
local function ElementSoundType(tracker, el)
    local cfg = tracker.config
    if cfg.custom or (cfg.spellList and not cfg.hasVoiceClips and not cfg.voiceOptions) then
        return "custom"
    end
    return el.soundType or "voice"
end
ns.ElementSoundType = ElementSoundType

-- Resolve a sound element's playback path. For spellList trackers in
-- voice mode the clip is per-spell — this returns the FIRST checked
-- spell's clip (used by the options Test button; registrations
-- enumerate all).
local function ResolveElementSoundPath(tracker, el)
    if ElementSoundType(tracker, el) == "voice" then
        local kind, a = ResolveVoicePlan(tracker.config, el)
        if kind == "fixed" then return a end
        -- per-spell: first checked spell's clip (registrations enumerate all)
        local spellList = tracker.config.spellList
        if spellList then
            local sel = tracker:GetDBVal("spells") or {}
            for _, sp in ipairs(spellList) do
                if sel[sp.key] and sp.clip then
                    return SpellVoicePath(sp, a)
                end
            end
            return nil
        end
        return tracker.config.voiceClip
    end
    local name = el.soundName
    if not name or name == "None" then return nil end
    local LSM = LibStub("LibSharedMedia-3.0", true)
    return LSM and LSM:Fetch("sound", name) or nil
end
ns.ResolveElementSoundPath = ResolveElementSoundPath

-- Drop and re-add every registration to match current settings/spec.
-- Cheap, and only runs on login/PEW/spec change/option change — NOT on the
-- combat edge or any watcher flip (see the combat-free liveness note below).
--
-- COMBAT-PROTECTED: AddAuraSound/RemoveAuraSound are blocked in combat
-- (PTR-observed 2026-08-10: ADDON_ACTION_BLOCKED on a boss pull). Every caller
-- is nominally out of combat, but a COMBAT /reload runs the login path
-- (PLAYER_ENTERING_WORLD -> RefreshAllResync) while lockdown is live, and the
-- /het debug sounds bisection can be typed mid-fight. Guard here rather than at
-- each call site, and flush from PLAYER_REGEN_ENABLED — that event is
-- guaranteed to fire when combat ends, so the flag can never strand.
--
-- InCombatLockdown() IS NOT ENOUGH, and a combat /reload is exactly where it
-- fails: during the load sequence the client has not re-armed the lockdown flag
-- for the freshly loaded UI, so it answers FALSE while the protected
-- environment is genuinely locked. The guard passed, AddAuraSound was called
-- and blocked, and the resulting "forbidden object" error was re-raised out of
-- RefreshAllResync into both OnEnable and the PLAYER_ENTERING_WORLD handler —
-- costing the whole startup refresh, not just the sounds.
-- UnitAffectingCombat("player") is the read that answers correctly that early,
-- so both are tested. Neither alone covers both windows: lockdown is the one
-- that catches a secure-frame lock with the player not yet flagged in combat.
function HET:UpdateAuraSounds()
    if not (AddAuraSound and RemoveAuraSound) then return end
    if InCombatLockdown() or UnitAffectingCombat("player") then
        ns.auraSoundsDirty = true
        return
    end
    ns.auraSoundsDirty = false

    -- CHANGE-GUARD: build the desired set into a scratch plan first, and only
    -- touch the engine if it actually differs from what is registered.
    -- Dragging ANY option slider runs RefreshTracking -> RefreshAll about ten
    -- times a second (RefreshTrackingThrottled), and this used to drop and
    -- re-add every registration on each of those ticks — even though a size,
    -- rotation or color change cannot affect which sounds are registered. The
    -- plan is identical on virtually all of those calls, so the common case is
    -- now a concat plus a string compare and zero engine calls.
    soundPlanCount = 0
    local function register(spellID, path, channel)
        soundPlan[soundPlanCount + 1] = spellID
        soundPlan[soundPlanCount + 2] = path
        soundPlan[soundPlanCount + 3] = channel
        soundPlanCount = soundPlanCount + 3
    end

    -- `/het debug sounds` parks the subsystem: plan nothing, and the diff below
    -- applies that as "remove everything". Folding it in here (rather than an
    -- early return) keeps the toggle reversible through the same one path.
    for i = 1, (ns.debugSuppress.sounds and 0 or #ns.trackerOrder) do
        local tracker = ns.trackers[ns.trackerOrder[i]]
        -- Custom-kind sounds are NOT engine aura-sounds (no aura to key on);
        -- they play via PlaySoundFile on the load-condition rising edge inside
        -- UpdateCustomArt. Skip them here so no AddAuraSound is attempted.
        --
        -- ID kinds (ability/item) are excluded for the same reason: their Sound
        -- elements fire from UpdateIDArt's PlaySoundFile on the load-condition
        -- rising edge — which IS the off-cooldown edge ("Spell/Item Usable"
        -- going met), driven by the ready watchers + the hidden Cooldown
        -- widget's OnCooldownDone. AddAuraSound cannot express that at all: its
        -- triggers are Added/ApplicationsIncreased/Removed, all AURA events.
        -- Registering here was therefore never the feature, only damage:
        --   * ability — GetActiveIncludeSet returns the tracker's `ids`, so a
        --     cooldown whose spell ID doubles as its self-buff aura ID (Avenging
        --     Wrath 31884, Recklessness 1719, ...) got a SECOND, unrequested
        --     play every time the buff landed.
        --   * item — the same `ids` list holds itemIDs, handed to AddAuraSound
        --     as spellIDs. Normally matches nothing; collides with an unrelated
        --     aura's spell ID at random.
        -- Compound "(Active)" pair elements don't need these either: pairFor is
        -- only ever "bar" | "icon" | "text", never "sound".
        --
        -- COMBAT-FREE LIVENESS (IsLiveNoCombat, NOT IsLive): registrations are
        -- keyed off enabled + applicable + instance + role/spec only, so the set
        -- CANNOT change during a fight and this never needs to run in combat.
        -- Same reasoning the group-assignment path already uses (Tracker.lua
        -- liveNoCombat) — engine group setters are combat-restricted too.
        -- The tradeoff: Combat Status / Spell Usable / Item Usable no longer
        -- gate Sound alerts. There is no alternative — groups dodge this by
        -- flipping container ALPHA, but a live aura-sound registration cannot be
        -- muted, only removed, and removal is the same protected call. Called
        -- out to the user on the Combat Status tooltip (Options.lua).
        if tracker and tracker:IsLiveNoCombat()
            and not tracker:IsCustomKind() and not tracker:IsIDKind() then
            local spellList = tracker.config.spellList
            local els = tracker:GetElementsDB()
            for j = 1, #els do
                local el = els[j]
                if el.style == "sound" then
                    local channel = el.soundChannel or "Master"
                    local isVoiceGroup = (spellList or tracker.config.voiceOptions)
                        and ElementSoundType(tracker, el) == "voice"
                    if isVoiceGroup then
                        local kind, a = ResolveVoicePlan(tracker.config, el)
                        if kind == "fixed" then
                            -- One chosen clip on every tracked ID.
                            if a then
                                for spellID in pairs(tracker:GetActiveIncludeSet()) do
                                    register(spellID, a, channel)
                                end
                            end
                        elseif spellList then
                            -- Per-spell name clips (full or short): each
                            -- checked spell registers its clip on its IDs.
                            -- Spells without a bundled clip get no voice
                            -- callout (display still works).
                            local sel = tracker:GetDBVal("spells") or {}
                            for _, sp in ipairs(spellList) do
                                if sel[sp.key] and sp.clip then
                                    local path = SpellVoicePath(sp, a)
                                    for k = 1, #sp.ids do
                                        register(sp.ids[k], path, channel)
                                    end
                                end
                            end
                        end
                    else
                        local path = ResolveElementSoundPath(tracker, el)
                        if path then
                            for spellID in pairs(tracker:GetActiveIncludeSet()) do
                                register(spellID, path, channel)
                            end
                        end
                    end
                end
            end
        end
    end

    -- Diff. Element separator is \30 (RS) so it cannot occur inside a sound
    -- path or channel name.
    --
    -- Note on ordering: the include-set walks use pairs(), whose order is only
    -- incidentally stable. That is safe here because the failure mode is
    -- one-directional — a reordered plan hashes differently and we simply do
    -- the rebuild we would have done anyway. Two DIFFERENT sets can never
    -- collide onto one signature, so the guard can't wrongly skip.
    local sig = table.concat(soundPlan, "\30", 1, soundPlanCount)
    if sig == appliedSoundSig then return end
    appliedSoundSig = sig

    for i = #registeredSoundIDs, 1, -1 do
        RemoveAuraSound(registeredSoundIDs[i])
        registeredSoundIDs[i] = nil
    end
    soundResultCount = 0
    for i = 1, soundPlanCount, 3 do
        local path = soundPlan[i + 1]
        -- LSM sound entries are NOT always paths. lib:Register only enforces
        -- the "interface\...\*.ogg|mp3" rule when the data is a string, so
        -- providers are free to register a bare FileDataID, and Fetch hands
        -- that number straight back. AddAuraSound takes the two forms in
        -- DIFFERENT fields (soundFileName vs soundFileID) — feeding a number
        -- to soundFileName is what silently killed those entries. PlaySoundFile
        -- accepts either, which is why the picker's speaker preview and the
        -- custom/ID-kind trackers played them fine and only aura alerts broke.
        local byID = type(path) == "number"
        local id = AddAuraSound(TRIGGER_ADDED, {
            unitToken     = "player",
            spellID       = soundPlan[i],
            soundFileName = (not byID) and path or nil,
            soundFileID   = byID and path or nil,
            outputChannel = soundPlan[i + 2],
        })
        soundResultCount = soundResultCount + 1
        soundResultIDs[soundResultCount] = id or false
        if id then
            registeredSoundIDs[#registeredSoundIDs + 1] = id
        elseif not soundAddReported[path] then
            -- Surface it once instead of dropping it on the floor. Deliberately
            -- does NOT invalidate appliedSoundSig: a refusal is deterministic,
            -- so retrying would re-run the whole drop/re-add on every
            -- RefreshAll for a call that is going to fail again.
            soundAddReported[path] = true
            HET:Print(("|cffff4040AddAuraSound REFUSED|r %s spellID %d |cff888888%s|r")
                :format(LSMSoundName(path) or "?", soundPlan[i], tostring(path)))
        end
    end
end

-- `/het debug soundplan` — dump the live registration plan next to what the
-- engine returned for each entry. This is the discriminator for the silent
-- sound-alert failure: an entry showing REFUSED means the engine rejects the
-- file at registration time (so BA can detect and filter unusable sounds by
-- itself); every entry showing an id while the sound still never plays means
-- registration validates nothing and detection is impossible.
function HET:DumpSoundPlan()
    if not (AddAuraSound and RemoveAuraSound) then
        self:Print("aura sounds unavailable (C_UnitAuras.AddAuraSound missing)")
        return
    end
    if soundPlanCount == 0 then
        self:Print("aura-sound plan: EMPTY (no live Sound Alert elements)")
        return
    end
    self:Print(("aura-sound plan: %d registration(s)"):format(soundPlanCount / 3))
    local k = 0
    for i = 1, soundPlanCount, 3 do
        k = k + 1
        local id = soundResultIDs[k]
        self:Print(("  [%d] spellID %d  id=%s  ch=%s  %s |cff888888%s=%s|r"):format(
            k,
            soundPlan[i],
            id and tostring(id) or "|cffff4040REFUSED|r",
            tostring(soundPlan[i + 2]),
            LSMSoundName(soundPlan[i + 1]) or "?",
            type(soundPlan[i + 1]) == "number" and "fileID" or "path",
            tostring(soundPlan[i + 1])))
    end
end

-------------------------------------------------------------------------------
-- Refresh pipeline — gating + style + sounds. Runs on login, PEW, spec
-- change, and from option setters. Never on a hot path; the engine owns
-- all per-frame work.
-------------------------------------------------------------------------------
-- Cached current player spec/role for the Role & Spec load conditions
-- (Tracker LoadRoleMet / LoadSpecMet). Both only change with a spec change,
-- which is out of combat and routes through RefreshAll (PLAYER_LOGIN,
-- PLAYER_ENTERING_WORLD, PLAYER_SPECIALIZATION_CHANGED), so refreshing the
-- cache here keeps the gating reads to plain table lookups — no API calls on
-- the gating path. nil = unknown (before a spec is chosen), which fails the
-- conditions OPEN.
ns.playerSpec = nil
ns.playerRole = nil
local GetSpecialization = GetSpecialization
local GetSpecializationInfo = GetSpecializationInfo
local GetSpecializationRole = GetSpecializationRole
local function UpdatePlayerSpecRole()
    local idx = GetSpecialization and GetSpecialization()
    if not idx then
        ns.playerSpec, ns.playerRole = nil, nil
        return
    end
    local specID = GetSpecializationInfo and GetSpecializationInfo(idx)
    ns.playerSpec = specID
    -- GetSpecializationRole returns "TANK" | "HEALER" | "DAMAGER", matching
    -- the ROLE_TYPES keys in Options.lua.
    ns.playerRole = (GetSpecializationRole and GetSpecializationRole(idx)) or nil
    -- Options "Filter By Spec" follows the player: seeded on first run and
    -- re-snapped whenever the spec ACTUALLY changes. Guarded on a real change
    -- rather than written every call, because this runs on every RefreshAll —
    -- an unconditional write would wipe a manual selection on the next option
    -- edit. ns.specFilter stays session-only (never saved).
    if ns.playerSpec and ns._specFilterSpec ~= ns.playerSpec then
        ns._specFilterSpec = ns.playerSpec
        -- "Default Filter by Spec to Current Spec" (Addon Options). Defaults to
        -- ON, including before the db exists — this can run during the early
        -- priming build, and following the player's spec is the long-standing
        -- behaviour.
        local g = HET.db and HET.db.global
        ns.specFilter = (not g or g.specFilterFollowsSpec ~= false)
            and ns.playerSpec or nil
        -- Flag, don't act: this runs INSIDE RefreshAll, and re-rendering the
        -- options table from here would be re-entrant. The spec-change handler
        -- picks the flag up once the refresh has finished.
        ns._specFilterDirty = true
    end
end
-- Exported so Options can seed the Spec Filter before its priming BuildOptions
-- at OnInitialize. Without it that first build runs unfiltered (RefreshAll has
-- not run yet) and primes _firstTreeNode with a tracker the filter may
-- then hide, so the first panel open lands on a node that no longer exists. Safe to
-- call early: if the spec API isn't ready yet it just leaves everything nil,
-- which is the behaviour we'd have had anyway.
ns.UpdatePlayerSpecRole = UpdatePlayerSpecRole

-- Consume a pending Spec Filter move. UpdatePlayerSpecRole runs INSIDE
-- RefreshAll and cannot re-render the options table from there without
-- re-entering it, so it only flags the move; this picks the flag up once the
-- refresh has finished.
--
-- Only the OPEN panel is pushed to: drop previews that just fell out of scope,
-- re-render the list and the dropdown label. A CLOSED panel needs nothing here
-- -- the node tables that decide which tracker the first open selects are
-- re-derived by hetOpen when it finds the filter has moved since the last
-- build, so a spec change costs nothing for a panel that is never opened.
-- Self-guarding, which is why the login seed can share this path without
-- causing a visible "change": with the panel shut and no previews up it is a
-- complete no-op.
--
-- Called from every path that can move the filter: the spec-change handler,
-- OnEnable's login seed, and PLAYER_ENTERING_WORLD. That last one matters --
-- GetSpecialization is unreliable early, so on some load orders PEW is where
-- the player's spec first resolves, and it was the one path that set the flag
-- with nothing downstream to act on.
local function ConsumeSpecFilterMove()
    if not ns._specFilterDirty then return end
    ns._specFilterDirty = nil
    if ns.OnSpecFilterChanged then ns.OnSpecFilterChanged() end
end

local function RefreshAll()
    -- Vehicle container gate baseline: a reload or zone-in while ALREADY in a
    -- vehicle never fires the vehicle events, so seed the gate here — and it
    -- must be set BEFORE the UpdateGating passes below read
    -- ns.vehicleGateActive for group container alphas. The CINEMATIC gate has
    -- no baseline seed here on purpose: there is no API to ask "is a cinematic
    -- playing", it is purely event-edged, and a reload mid-cinematic is
    -- covered by the PLAYER_ENTERING_WORLD failsafe in OnEnable.
    if ns.ApplyVehicleGate then ns.ApplyVehicleGate() end
    -- Seed predefined-tracker presets before anything reads elements: a
    -- newly enabled tracker gets its default elements written here (once
    -- per profile, see SeedPresets), so the creation/EnsureElements passes
    -- below build them straight away.
    ns.SeedPresets(HET.db.profile)
    -- Refresh the cached spec/role first: the Role/Spec gating passes below
    -- read ns.playerSpec / ns.playerRole.
    UpdatePlayerSpecRole()
    -- Seed/rebuild the spell-ready and item-ready watches first: the gating
    -- passes below read ns.spellReady / ns.itemReady.
    HET:UpdateSpellReadyWatch()
    HET:UpdateItemReadyWatch()
    -- ID-kind (ability/item) cooldown/charge watch: re-renders their holder art
    -- (swipe + "oncd" visibility + charge/stack text) on cooldown/charge events.
    HET:UpdateIDReadyWatch()
    -- Creation pass: tracker objects and their elements are built
    -- LAZILY here — slot/group creation and region binding work at any
    -- time OUTSIDE combat/secret windows (ns.IsRestricted). Restricted
    -- contexts defer; PLAYER_REGEN_ENABLED re-runs the refresh.
    for i = 1, #ns.trackerOrder do
        local key = ns.trackerOrder[i]
        if not ns.trackers[key] then
            if ns.IsRestricted() then
                ns.deferredRefresh = true
            else
                ns.trackers[key] = ns.Tracker.Create(ns.trackerConfigs[key], HET)
            end
        end
    end
    for i = 1, #ns.trackerOrder do
        local tracker = ns.trackers[ns.trackerOrder[i]]
        if tracker then
            -- Refresh the custom-entry cache (entry pointer + kind booleans)
            -- FIRST: every pass below reads a kind, starting with
            -- SyncElementUnits' IsCustomKind/IsIDKind on the next line.
            -- GENERATION-GUARDED, deliberately: an unconditional resolve would
            -- rescan the whole customTrackers list for every custom tracker
            -- (O(N^2) in custom count) on every RefreshAll — and
            -- RefreshTrackingThrottled drives RefreshAll ~10x/sec during any
            -- slider or colour drag, which is exactly the cost this cache
            -- exists to remove.
            tracker:GetCustomEntry()
            -- Live debuff Unit change: park any element now in the wrong
            -- container BEFORE EnsureElements rebuilds it in the right one.
            tracker:SyncElementUnits()
            tracker:EnsureElements()
            tracker:CacheIcon()
            -- ApplyStyle/ApplyTooltip use runtime property setters on the
            -- bound regions (PTR-VERIFY). If the forbidden partition
            -- denies one post-PEW, surface it ONCE instead of aborting
            -- silently — silent walk-aborts cost two debugging rounds
            -- (plan §0).
            local ok, err = pcall(function()
                tracker:ApplyCustomIdentity()
                -- Before ApplyStyle: the binding decides what fills each
                -- bar, styling only decides whether it's shown.
                tracker:UpdateAuraBindings()
                tracker:ApplyStyle()
                tracker:ApplyTooltip()
                -- Must precede UpdateGating (_noSpells parking).
                tracker:UpdateSpellFilter()
                -- Live debuff Own Only change: push the current filter string.
                tracker:SyncElementFilters()
            end)
            if not ok and not HET._styleErrPrinted then
                HET._styleErrPrinted = true
                print("|cff11ace9HET|r ApplyStyle denied: " .. tostring(err))
            end
            tracker:UpdateGating()
        end
    end
    HET:UpdateAuraSounds()
end

-- Public hook for Options.lua: re-evaluate everything after a setting
-- changes (enable toggles, alert settings, styles).
function HET:RefreshTracking()
    RefreshAll()
end

-- Reload / zone / instance / spec baseline: run a full refresh but mark it as a
-- resync so custom-tracker Sound alerts SEED their met-state silently instead
-- of replaying on the loading screen (see Tracker UpdateCustomArt). The flag is
-- cleared in a pcall-safe finally so an error mid-refresh can't leave it stuck.
local function RefreshAllResync()
    -- Every resync trigger (login, PEW, zone, instance, spec) is a point where
    -- the KNOWN-spell set may have moved, and this is the one that covers the
    -- LOGIN window — SPELLS_CHANGED does fire during login, but relying on the
    -- ordering of two events to keep a cache honest is not worth the risk when
    -- the wipe is one call on a small table at a rate of a few per session.
    if ns.WipeAbilityKnownCache then ns.WipeAbilityKnownCache() end
    ns.customSoundResync = true
    local ok, err = pcall(RefreshAll)
    ns.customSoundResync = false
    if not ok then error(err) end
end

-- Combat-transition load gating (Load Conditions "Combat Status"). Runs on
-- entering/leaving combat. Re-applies each tracker's single-aura slot filter
-- for the new combat state via tracker:ReapplySlotGating, which touches only
-- the slot CONTAINER (combat-safe), never the engine button, and pcall-guards
-- every call. Group-backed elements flip their container ALPHA (assignment
-- can't change in combat). Instance-type only
-- changes across loading screens, handled by the normal
-- PLAYER_ENTERING_WORLD -> RefreshAll.
--
-- Does NOT touch aura sounds. It used to call UpdateAuraSounds, which is how
-- the combat edge and the spell/item/ID watcher flips reached a
-- combat-protected AddAuraSound. Registrations now derive from the combat-free
-- liveness (see UpdateAuraSounds), so nothing on this path can change them and
-- there is nothing to re-register.
function HET:RefreshLoadGating()
    for i = 1, #ns.trackerOrder do
        local tracker = ns.trackers[ns.trackerOrder[i]]
        if tracker then
            tracker:ReapplySlotGating()
        end
    end
end

-- Narrow fan-out for the ID-kind cooldown/charge/usable watcher: re-render
-- ONLY ability/item trackers. Aura and custom trackers read none of that
-- state, and their sounds are engine/edge-driven — so the chatty cooldown
-- events must never pay for the full RefreshLoadGating (all-tracker re-gate +
-- complete aura-sound drop/re-register).
function HET:RefreshIDArt()
    for i = 1, #ns.trackerOrder do
        local tracker = ns.trackers[ns.trackerOrder[i]]
        if tracker and tracker:IsIDKind() then
            tracker:UpdateIDArt("live")
        end
    end
end

-------------------------------------------------------------------------------
-- Spell-ready watcher (Load Conditions "Spell Usable"). Event-driven cache:
-- ns.spellReady[spellID] = usable AND off cooldown. 12.1 cooldown NUMBERS
-- (startTime/duration) are SECRET in combat, so ready state reads only the
-- isActive/isOnGCD
-- BOOLEANS (accessible by design; "on real cooldown" = isActive and not
-- isOnGCD), and the off-cooldown edge comes from a hidden Cooldown widget
-- armed with a duration OBJECT + OnCooldownDone — engine-scheduled, no
-- numbers read (SPELL_UPDATE_COOLDOWN doesn't fire when a cooldown ENDS).
-- IsSpellUsable keeps a canaccessvalue fail-open guard. Events registered
-- only while at least one enabled tracker uses the condition; in-combat
-- handler cost is a couple of C_Spell calls per watched ID plus a compare —
-- fan-out (RefreshLoadGating, change-guarded per element) only on a flip.
-------------------------------------------------------------------------------
ns.spellReady = {}
local spellWatchFrame = CreateFrame("Frame")
local watchedSpells = {}   -- spellID -> true

local C_Spell_GetSpellCooldown         = C_Spell and C_Spell.GetSpellCooldown
local C_Spell_IsSpellUsable            = C_Spell and C_Spell.IsSpellUsable
local C_Spell_GetSpellCooldownDuration = C_Spell and C_Spell.GetSpellCooldownDuration
local UnitChannelInfo                  = UnitChannelInfo
local canaccess = canaccessvalue or function() return true end

-- While the player is EMPOWERING a spell, IsSpellUsable transiently reports
-- other spells as not usable (Evoker empowered-cast quirk), which would wrongly
-- flip ready→not-ready and hide "Only When Usable" indicators mid-empower.
-- isEmpowered (9th UnitChannelInfo return) is combat-safe. Mirrors
-- Tracker.PlayerEmpowering — keep the two in sync.
local function PlayerEmpowering()
    if not UnitChannelInfo then return false end
    local name, _, _, _, _, _, _, _, isEmpowered = UnitChannelInfo("player")
    return name ~= nil and isEmpowered == true
end

local function IsSpellReady(spellID)
    if not PlayerEmpowering() then
        local usable = C_Spell_IsSpellUsable(spellID)
        if canaccess(usable) and not usable then return false end
    end
    local cd = C_Spell_GetSpellCooldown(spellID)
    if cd and cd.isActive and not cd.isOnGCD then
        return false
    end
    return true
end

local RecheckWatchedSpells

-- Off-cooldown edge tickers: one hidden Cooldown widget per watched spell,
-- armed while that spell is on a
-- real cooldown. PTR-VERIFY: OnCooldownDone firing on a non-shown widget —
-- kept alpha-0/undrawn rather than Hide() out of caution.
local cooldownTickers = {}
local function ArmCooldownTicker(spellID)
    local w = cooldownTickers[spellID]
    if not w then
        w = CreateFrame("Cooldown", nil, spellWatchFrame, "CooldownFrameTemplate")
        w:SetSize(1, 1)
        w:SetPoint("TOPLEFT")
        w:SetAlpha(0)
        w:SetDrawSwipe(false)
        w:SetDrawEdge(false)
        w:SetDrawBling(false)
        w:SetHideCountdownNumbers(true)
        w:SetScript("OnCooldownDone", RecheckWatchedSpells)
        cooldownTickers[spellID] = w
    end
    -- true = ignore the GCD contribution; we want the REAL cooldown end.
    local dur = C_Spell_GetSpellCooldownDuration(spellID, true)
    if dur then
        w:SetCooldownFromDurationObject(dur)
    end
end

RecheckWatchedSpells = function()
    local changed = false
    for id in pairs(watchedSpells) do
        local ready = IsSpellReady(id)
        if ns.spellReady[id] ~= ready then
            ns.spellReady[id] = ready
            changed = true
        end
        if not ready then
            ArmCooldownTicker(id)
        end
    end
    if changed then
        HET:RefreshLoadGating()
    end
end
spellWatchFrame:SetScript("OnEvent", RecheckWatchedSpells)

-- Rebuild the watch list from the profile (runs from RefreshAll — covers
-- option changes, profile swaps, enable toggles). Reads the DB directly so
-- it works before tracker objects exist (combat-reload deferral).
function HET:UpdateSpellReadyWatch()
    wipe(watchedSpells)
    local p = self.db.profile
    for i = 1, #ns.trackerOrder do
        local prefix = ns.trackerConfigs[ns.trackerOrder[i]].dbPrefix
        if p[prefix .. "enabled"] and p[prefix .. "loadSpell"] then
            local ids = p[prefix .. "loadSpellIDs"]
            if type(ids) == "table" then
                for j = 1, #ids do
                    local id = tonumber(ids[j])
                    if id and id > 0 then watchedSpells[id] = true end
                end
            end
        end
    end
    if next(watchedSpells) then
        spellWatchFrame:RegisterEvent("SPELL_UPDATE_COOLDOWN")
        spellWatchFrame:RegisterEvent("SPELL_UPDATE_USABLE")
        -- Resource-edge robustness for usability: essence/power ticks
        -- don't always fire SPELL_UPDATE_USABLE.
        spellWatchFrame:RegisterUnitEvent("UNIT_POWER_FREQUENT", "player")
        RecheckWatchedSpells()
    else
        spellWatchFrame:UnregisterAllEvents()
        wipe(ns.spellReady)
        for _, w in pairs(cooldownTickers) do
            w:Clear()
        end
    end
end

-------------------------------------------------------------------------------
-- Item-ready watcher (Load Conditions "Item Usable"). Mirrors the spell
-- watcher above. ns.itemReady[itemID] = usable AND off cooldown. 12.1 item
-- cooldown NUMBERS are secret in combat, so ready state reads only the
-- BOOLEANS: C_Item.IsUsableItem (usable) and the enableCooldownTimer flag from
-- C_Item.GetItemCooldown (on cooldown). The off-cooldown edge comes from a
-- hidden Cooldown widget armed with a duration OBJECT built from the start/
-- duration numbers — plus OnCooldownDone (no event fires when a cooldown
-- ENDS). The numbers are read
-- behind a canaccessvalue guard so an in-combat secret read just skips arming;
-- BAG_UPDATE_COOLDOWN re-checks re-arm it from a safe context.
-------------------------------------------------------------------------------
ns.itemReady = {}
local itemWatchFrame = CreateFrame("Frame")
local watchedItems = {}   -- itemID -> true

local C_Item_GetItemCooldown    = C_Item and C_Item.GetItemCooldown
local C_Item_IsUsableItem       = C_Item and C_Item.IsUsableItem
local C_Item_GetItemCount       = C_Item and C_Item.GetItemCount
local C_Item_IsEquippedItem     = C_Item and C_Item.IsEquippedItem
local C_Item_GetItemInfoInstant = C_Item and C_Item.GetItemInfoInstant

-- Equipped-state cache (Require Equipped option). Refreshed alongside the
-- ready cache and on PLAYER_EQUIPMENT_CHANGED, so LoadItemMet stays pure table
-- lookups. IsEquippedItem is combat-safe (a plain boolean).
ns.itemEquipped = {}
-- Equippable-STATIC cache: itemID -> true if the item has an equip slot at all
-- (trinket, weapon…). Non-equippable items (potions) can never satisfy Require
-- Equipped, so LoadItemMet skips that requirement for them. GetItemInfoInstant
-- needs no server query, so this is populated once when the watch is built.
ns.itemEquippable = {}

-- On cooldown? enableCooldownTimer (3rd return) is the combat-safe boolean;
-- fall back to duration>0. Both reads are guarded — if secret in combat, treat
-- as NOT on cooldown (fail open, matching the spell watcher's usable guard).
local function IsItemOnCooldown(itemID)
    local start, duration, enableCD = C_Item_GetItemCooldown(itemID)
    if canaccess(enableCD) and enableCD == false then return false end
    if canaccess(duration) then
        return (duration or 0) > 0 and (start or 0) > 0
    end
    return false
end

local function IsItemReady(itemID)
    local usable = C_Item_IsUsableItem(itemID)
    if canaccess(usable) and not usable then return false end
    if IsItemOnCooldown(itemID) then return false end
    return true
end

local RecheckWatchedItems

-- Off-cooldown edge tickers: one hidden Cooldown per watched item, armed
-- from a duration OBJECT built from the start/duration
-- numbers while the item is on a real cooldown.
local itemCooldownTickers = {}
local function ArmItemCooldownTicker(itemID)
    local start, duration = C_Item_GetItemCooldown(itemID)
    -- Numbers secret (in combat) → skip; a later BAG_UPDATE_COOLDOWN re-arms.
    if not (canaccess(start) and canaccess(duration)) then return end
    if not (start and duration and start > 0 and duration > 0) then return end
    local w = itemCooldownTickers[itemID]
    if not w then
        w = CreateFrame("Cooldown", nil, itemWatchFrame, "CooldownFrameTemplate")
        w:SetSize(1, 1)
        w:SetPoint("TOPLEFT")
        w:SetAlpha(0)
        w:SetDrawSwipe(false)
        w:SetDrawEdge(false)
        w:SetDrawBling(false)
        w:SetHideCountdownNumbers(true)
        w:SetScript("OnCooldownDone", function() RecheckWatchedItems() end)
        itemCooldownTickers[itemID] = w
    end
    local durationObject = C_DurationUtil and C_DurationUtil.CreateDuration
        and C_DurationUtil.CreateDuration()
    if durationObject then
        durationObject:SetTimeFromStart(start, duration)
        w:SetCooldownFromDurationObject(durationObject)
    end
end

RecheckWatchedItems = function()
    local changed = false
    for id in pairs(watchedItems) do
        local ready = IsItemReady(id)
        if ns.itemReady[id] ~= ready then
            ns.itemReady[id] = ready
            changed = true
        end
        if not ready then
            ArmItemCooldownTicker(id)
        end
        -- Equipped state (for the per-item "Require Equipped" option). Boolean,
        -- combat-safe; a flip re-gates just like a ready flip.
        local equipped = C_Item_IsEquippedItem and C_Item_IsEquippedItem(id) and true or false
        if ns.itemEquipped[id] ~= equipped then
            ns.itemEquipped[id] = equipped
            changed = true
        end
    end
    if changed then
        HET:RefreshLoadGating()
    end
end
itemWatchFrame:SetScript("OnEvent", RecheckWatchedItems)

-- Rebuild the item watch list from the profile (runs from RefreshAll). Reads
-- the DB directly so it works before tracker objects exist.
function HET:UpdateItemReadyWatch()
    wipe(watchedItems)
    wipe(ns.itemEquippable)
    local p = self.db.profile
    for i = 1, #ns.trackerOrder do
        local prefix = ns.trackerConfigs[ns.trackerOrder[i]].dbPrefix
        if p[prefix .. "enabled"] and p[prefix .. "loadItem"] then
            local ids = p[prefix .. "loadItemIDs"]
            if type(ids) == "table" then
                for j = 1, #ids do
                    local id = tonumber(ids[j])
                    if id and id > 0 then
                        watchedItems[id] = true
                        -- Static: does the item have an equip slot at all?
                        if C_Item_GetItemInfoInstant then
                            local _, _, _, equipLoc = C_Item_GetItemInfoInstant(id)
                            ns.itemEquippable[id] = (equipLoc ~= nil and equipLoc ~= "")
                        else
                            ns.itemEquippable[id] = true  -- unknown → fail open
                        end
                    end
                end
            end
        end
    end
    if next(watchedItems) then
        -- Item cooldowns surface on these; usable changes on SPELL_UPDATE_*.
        itemWatchFrame:RegisterEvent("BAG_UPDATE_COOLDOWN")
        itemWatchFrame:RegisterEvent("SPELL_UPDATE_COOLDOWN")
        itemWatchFrame:RegisterEvent("SPELL_UPDATE_USABLE")
        itemWatchFrame:RegisterEvent("ACTIONBAR_UPDATE_USABLE")
        -- Require Equipped: re-check when gear changes.
        itemWatchFrame:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
        RecheckWatchedItems()
    else
        itemWatchFrame:UnregisterAllEvents()
        wipe(ns.itemReady)
        wipe(ns.itemEquipped)
        for _, w in pairs(itemCooldownTickers) do
            w:Clear()
        end
    end
end

-------------------------------------------------------------------------------
-- ID-kind (ability/item) cooldown/charge watcher. Ability and Item trackers
-- render a holder icon with a cooldown swipe + charge/stack text (Tracker.lua
-- RenderIDArt), gated per element by idVisibility ("Always" / "Only While On
-- Cooldown"). Their live art must re-render whenever a watched ability's
-- cooldown/charges change or a watched item's bag cooldown fires — so this
-- watcher fans out to RefreshLoadGating on the same events the spell/item load
-- watchers use, PLUS an OnCooldownDone edge ticker (SPELL_UPDATE_COOLDOWN /
-- BAG_UPDATE_COOLDOWN never fire when a cooldown ENDS, and an "oncd" element
-- must hide the moment it comes off cooldown). Mirrors the spell/item load
-- watchers: events registered only while at least one ID-kind tracker is
-- enabled; the cooldown-end tickers are armed from duration OBJECTS (spells) or
-- number-guarded start/duration (items), never per-frame number reads.
-------------------------------------------------------------------------------
local idWatchFrame = CreateFrame("Frame")
local watchedIDSpells = {}   -- spellID -> true (ability trackers, KNOWN spells only)
local watchedIDItems  = {}   -- itemID  -> true (item trackers)
-- Static charge capability (maxCharges > 1), cached at watch-build time so the
-- chatty event scans only query charge state for spells that can have one.
local watchedIDSpellCharges = {}

-- Per-id boolean state caches for the CHANGE GUARD below: the chatty events
-- (SPELL_UPDATE_COOLDOWN fires every cast, SPELL_UPDATE_USABLE and
-- UNIT_POWER_FREQUENT many times a second in combat) compare current state to
-- these and fan out to a re-render ONLY on a flip — same discipline as the
-- spell/item load watchers above. Booleans, plus ONE number: the item stack
-- count, which the "Stacks" text renders and no boolean can stand in for.
local idSpellCD, idSpellUsable, idSpellCharge = {}, {}, {}
local idItemCD, idItemUsable, idItemCount = {}, {}, {}
local function WipeIDStateCaches()
    wipe(idSpellCD); wipe(idSpellUsable); wipe(idSpellCharge)
    wipe(idItemCD); wipe(idItemUsable); wipe(idItemCount)
end

local C_Spell_GetSpellCharges        = C_Spell and C_Spell.GetSpellCharges
local C_Spell_GetSpellChargeDuration = C_Spell and C_Spell.GetSpellChargeDuration

local RecheckWatchedIDs

-- Off-cooldown edge tickers for watched ability/item IDs, armed while on a real
-- cooldown so RefreshLoadGating re-runs (re-hides "oncd" art) at cooldown end.
local idSpellTickers = {}
local idItemTickers  = {}
local function ArmIDSpellTicker(spellID)
    local w = idSpellTickers[spellID]
    if not w then
        w = CreateFrame("Cooldown", nil, idWatchFrame, "CooldownFrameTemplate")
        w:SetSize(1, 1); w:SetPoint("TOPLEFT"); w:SetAlpha(0)
        w:SetDrawSwipe(false); w:SetDrawEdge(false); w:SetDrawBling(false)
        w:SetHideCountdownNumbers(true)
        w:SetScript("OnCooldownDone", function() RecheckWatchedIDs() end)
        idSpellTickers[spellID] = w
    end
    local dur = C_Spell_GetSpellCooldownDuration and C_Spell_GetSpellCooldownDuration(spellID, true)
    if dur then w:SetCooldownFromDurationObject(dur) end
end
local function ArmIDItemTicker(itemID)
    local start, duration = C_Item_GetItemCooldown(itemID)
    -- Numbers secret (in combat) → skip; a later BAG_UPDATE_COOLDOWN re-arms.
    if not (canaccess(start) and canaccess(duration)) then return end
    if not (start and duration and start > 0 and duration > 0) then return end
    local w = idItemTickers[itemID]
    if not w then
        w = CreateFrame("Cooldown", nil, idWatchFrame, "CooldownFrameTemplate")
        w:SetSize(1, 1); w:SetPoint("TOPLEFT"); w:SetAlpha(0)
        w:SetDrawSwipe(false); w:SetDrawEdge(false); w:SetDrawBling(false)
        w:SetHideCountdownNumbers(true)
        w:SetScript("OnCooldownDone", function() RecheckWatchedIDs() end)
        idItemTickers[itemID] = w
    end
    local durationObject = C_DurationUtil and C_DurationUtil.CreateDuration
        and C_DurationUtil.CreateDuration()
    if durationObject then
        durationObject:SetTimeFromStart(start, duration)
        w:SetCooldownFromDurationObject(durationObject)
    end
end

-- Re-render ID-kind tracker art on a state flip. `event` selects which state
-- scans run (each event can only change certain state, so the chatty ones do
-- the minimum reads); nil / charge / empower-edge calls are FORCED (fan out
-- unconditionally — charge text isn't in the caches, and the OnCooldownDone
-- tickers call with no args on purpose).
-- BAG_UPDATE is deliberately NOT forced. It fires several times per loot, per
-- consumable use and per quest turn-in, in combat, and forcing rebuilt the art
-- of EVERY ID tracker — including ability-only ones a bag change cannot
-- possibly affect. It is the item stack COUNT that a bag change moves, so that
-- count is now cached like every other state below and the fan-out happens only
-- on a real delta. Every other chatty event here was already change-guarded;
-- this one was the exception.
RecheckWatchedIDs = function(event)
    local forced = event == nil
        or event == "SPELL_UPDATE_CHARGES"
        or event == "UNIT_SPELLCAST_EMPOWER_START"
        or event == "UNIT_SPELLCAST_EMPOWER_STOP"
    local changed = false

    -- Ability cooldown + charge-recharge state: only a cast/cooldown event
    -- (or a forced pass) can change these.
    if forced or event == "SPELL_UPDATE_COOLDOWN" then
        for id in pairs(watchedIDSpells) do
            -- On real cooldown? (booleans, combat-safe).
            local cd = C_Spell_GetSpellCooldown and C_Spell_GetSpellCooldown(id)
            local onCd = (cd and cd.isActive and not cd.isOnGCD) and true or false
            if idSpellCD[id] ~= onCd then
                idSpellCD[id] = onCd
                changed = true
                if onCd then ArmIDSpellTicker(id) end
            elseif forced and onCd then
                ArmIDSpellTicker(id)
            end
            -- Charge recharge running? Only queried for multi-charge spells
            -- (static capability cached at watch build) — the duration-object
            -- call allocates, so it must not run per-id on every GCD for
            -- charge-less spells.
            if watchedIDSpellCharges[id] and C_Spell_GetSpellChargeDuration then
                local chg = C_Spell_GetSpellChargeDuration(id) and true or false
                if idSpellCharge[id] ~= chg then
                    idSpellCharge[id] = chg
                    changed = true
                end
            end
        end
        -- GCD Swipe re-arm. Deliberately OUTSIDE the change guard and outside
        -- RefreshIDArt: the onCd test above ignores the global cooldown on
        -- purpose (`isActive and not isOnGCD`) so a cast never re-renders every
        -- ID tracker, and folding the GCD into it would put a full render on
        -- every GCD in combat. This walks only the GCD widgets the render pass
        -- marked as wanted and re-arms them from a duration object -- no
        -- render, no layout, and an empty loop unless the option is switched on
        -- somewhere. See the GCD Swipe block in Tracker.lua.
        if ns.ArmGcdSwipes then ns.ArmGcdSwipes() end
    end

    -- Ability usability (resources): SPELL_UPDATE_USABLE / power ticks.
    -- Skipped while EMPOWERING — IsSpellUsable transiently reports other
    -- spells unusable mid-empower (see PlayerEmpowering); the render guard
    -- ignores it anyway, so flipping the cache would just churn renders.
    -- EMPOWER_STOP is a forced pass, which re-reads the real state.
    if (forced or event == "SPELL_UPDATE_USABLE" or event == "UNIT_POWER_FREQUENT")
        and C_Spell_IsSpellUsable and not PlayerEmpowering() then
        for id in pairs(watchedIDSpells) do
            local usable = C_Spell_IsSpellUsable(id)
            local u = true
            if canaccess(usable) and not usable then u = false end
            if idSpellUsable[id] ~= u then
                idSpellUsable[id] = u
                changed = true
            end
        end
    end

    -- Item cooldown + usability + stack count.
    if forced or event == "BAG_UPDATE_COOLDOWN" or event == "BAG_UPDATE"
        or event == "SPELL_UPDATE_COOLDOWN" or event == "SPELL_UPDATE_USABLE" then
        for id in pairs(watchedIDItems) do
            local onCd = IsItemOnCooldown(id)
            if idItemCD[id] ~= onCd then
                idItemCD[id] = onCd
                changed = true
                if onCd then ArmIDItemTicker(id) end
            elseif forced and onCd then
                ArmIDItemTicker(id)
            end
            local u = true
            if C_Item_IsUsableItem then
                local usable = C_Item_IsUsableItem(id)
                if canaccess(usable) and not usable then u = false end
            end
            if idItemUsable[id] ~= u then
                idItemUsable[id] = u
                changed = true
            end
            -- Stack count: a plain number, and the one piece of state a bag
            -- change actually moves. Read behind canaccess like everything
            -- else; a DENIED read is treated as unchanged rather than cached,
            -- because RenderIDText blanks the stack text in that case anyway,
            -- so there is nothing to redraw.
            if C_Item_GetItemCount then
                local n = C_Item_GetItemCount(id)
                if canaccess(n) and idItemCount[id] ~= n then
                    idItemCount[id] = n
                    changed = true
                end
            end
        end
    end

    -- Fan out ONLY to ID-kind trackers, and only when something flipped.
    -- (The old path ran the FULL RefreshLoadGating — every tracker re-gated
    -- plus a complete aura-sound drop/re-register — on every one of these
    -- events; none of that state depends on cooldowns.)
    if changed or forced then
        HET:RefreshIDArt()
    end
end
idWatchFrame:SetScript("OnEvent", function(_, event) RecheckWatchedIDs(event) end)

-- Rebuild the ID-kind watch list from the profile (runs from RefreshAll). Reads
-- the DB directly (works before tracker objects exist) — scans customTrackers
-- for ability/item kinds and collects their stored ids.
function HET:UpdateIDReadyWatch()
    wipe(watchedIDSpells)
    wipe(watchedIDItems)
    wipe(watchedIDSpellCharges)
    WipeIDStateCaches()
    local p = self.db.profile
    -- Does any enabled ID tracker actually use "Only When Usable"? Decides
    -- whether the usability events (SPELL_UPDATE_USABLE + the very chatty
    -- UNIT_POWER_FREQUENT + the empower edges) get registered at all.
    local wantsUsable = false
    local list = p.customTrackers
    if type(list) == "table" then
        for i = 1, #list do
            local en = list[i]
            local k = en and en.kind
            local isAbility = (k == "ability" or k == "abilitygroup")
            local isItem    = (k == "item" or k == "itemgroup")
            if (isAbility or isItem) then
                local prefix = "cu" .. en.id .. "_"
                if p[prefix .. "enabled"] then
                    -- "usable" can live on the element (single trackers) or in
                    -- the per-id idVisibilities side-table (groups). The
                    -- Desaturate/Recolor-when-Unusable styles read the same
                    -- usable state, so they arm the usable events too.
                    if not wantsUsable then
                        local els = p[prefix .. "elements"]
                        if type(els) == "table" then
                            for e = 1, #els do
                                local el = els[e]
                                if el and el.style and el.style ~= "sound"
                                    and (el.idVisibility == "usable"
                                        or el.idDesatUnusable
                                        or el.idRecolorUnusable) then
                                    wantsUsable = true
                                    break
                                end
                            end
                        end
                        if not wantsUsable then
                            local vis = p[prefix .. "idVisibilities"]
                            if type(vis) == "table" then
                                for _, v in pairs(vis) do
                                    if v == "usable" then
                                        wantsUsable = true
                                        break
                                    end
                                end
                            end
                        end
                    end
                    local ids = p[prefix .. "ids"]
                    -- MIXED GROUPS: classify EACH id by its per-entry tag
                    -- (idKinds), not the tracker's default kind — so a group
                    -- registers each ability id in watchedIDSpells and each item
                    -- id in watchedIDItems. Untagged ids fall back to the
                    -- tracker's default kind (isItem). Single trackers have one
                    -- untagged id, so they behave exactly as before.
                    local kinds = p[prefix .. "idKinds"]
                    if type(ids) == "table" then
                        for j = 1, #ids do
                            local id = tonumber(ids[j])
                            if id and id > 0 then
                                local tag = kinds and kinds[id]
                                local idIsItem
                                if tag == "item" then idIsItem = true
                                elseif tag == "ability" then idIsItem = false
                                else idIsItem = isItem end
                                if idIsItem then
                                    watchedIDItems[id] = true
                                elseif not ns.AbilityKnown or ns.AbilityKnown(id) then
                                    -- UNKNOWN abilities are excluded entirely: a
                                    -- tracker for a spell this character doesn't
                                    -- have (wrong class/spec/talent) is already
                                    -- auto-hidden by the render gate, so it must
                                    -- cost NOTHING here — no watch entry, no per-
                                    -- event reads, no ticker; and if nothing else
                                    -- is watched, no events register at all.
                                    -- Known-set changes rebuild this watch via
                                    -- SPELLS_CHANGED / the spec-change refresh.
                                    watchedIDSpells[id] = true
                                    -- Static charge capability for the chatty-scan
                                    -- fast path (only multi-charge spells ever
                                    -- query the charge duration object).
                                    if C_Spell_GetSpellCharges then
                                        local ch = C_Spell_GetSpellCharges(id)
                                        if ch and canaccess(ch) and ch.maxCharges
                                            and ch.maxCharges > 1 then
                                            watchedIDSpellCharges[id] = true
                                        end
                                    end
                                end
                            end
                        end
                    end
                end
            end
        end
    end
    -- CONDITIONAL registration: each event costs its handler on every fire,
    -- so only the events the CURRENT watch set can act on are registered.
    -- The rebuild may shrink the set, so always start from a clean slate.
    idWatchFrame:UnregisterAllEvents()
    local anySpell = next(watchedIDSpells) ~= nil
    local anyItem  = next(watchedIDItems) ~= nil
    if anySpell or anyItem then
        -- Cooldown starts (abilities; also a safety net for on-use items).
        idWatchFrame:RegisterEvent("SPELL_UPDATE_COOLDOWN")
        if anySpell then
            -- Charge count changes (recharge start/finish + charges text).
            idWatchFrame:RegisterEvent("SPELL_UPDATE_CHARGES")
        end
        if anyItem then
            idWatchFrame:RegisterEvent("BAG_UPDATE_COOLDOWN")
            idWatchFrame:RegisterEvent("BAG_UPDATE")  -- item bag COUNT (stack text)
        end
        -- Usability transitions ("Only When Usable" visibility): usable state
        -- flips on SPELL_UPDATE_USABLE and on resource ticks (essence/mana/etc.
        -- don't always fire SPELL_UPDATE_USABLE — same robustness the spell-ready
        -- load watcher uses). Registered ONLY while some enabled ID tracker
        -- actually uses that mode — UNIT_POWER_FREQUENT is the chattiest event
        -- in combat and must not be paid for "Always"/"On Cooldown" setups.
        if wantsUsable then
            idWatchFrame:RegisterEvent("SPELL_UPDATE_USABLE")
            if anySpell then
                idWatchFrame:RegisterUnitEvent("UNIT_POWER_FREQUENT", "player")
                -- Empower start/stop: while empowering, IsSpellUsable transiently
                -- reports other spells unusable (see PlayerEmpowering). Re-run the
                -- gate on both edges so "Only When Usable" art stays shown during
                -- the empower and re-evaluates the instant it ends.
                idWatchFrame:RegisterUnitEvent("UNIT_SPELLCAST_EMPOWER_START", "player")
                idWatchFrame:RegisterUnitEvent("UNIT_SPELLCAST_EMPOWER_STOP", "player")
            end
        end
        RecheckWatchedIDs()
    else
        WipeIDStateCaches()
        for _, w in pairs(idSpellTickers) do w:Clear() end
        for _, w in pairs(idItemTickers) do w:Clear() end
    end
end

-------------------------------------------------------------------------------
-- ONE TRACKER'S stored settings, upgraded to the current schema.
--
-- Split out of MigrateProfile so IMPORT can call it for a single freshly
-- written prefix. That is the entire backward-compatibility story for !BZA
-- strings (Transmission.lua): an exported settings table IS stored-profile
-- shape, so an old import string is exactly an old profile fragment — and
-- every migration written for profiles from here on upgrades old import
-- strings automatically, with no export-specific migration code.
--
-- INVARIANTS (see the header of Transmission.lua): never delete a migration,
-- never repurpose a stored key, bump ns.SETTINGS_SCHEMA with every migration
-- added here.
--
-- `entry` is the tracker's customTrackers entry when it has one (built-ins
-- pass nil); it is only needed for the ability/item GROUP seeding.
--
-- `fromSchema` distinguishes the two callers, and it matters:
--   nil    PROFILE migration. One-shot migrations consult their stored flag
--          (p.ts_perClassSplit, p.pd_voiceOptionsMigrated) as they always have.
--   number IMPORT of a payload written under that ns.SETTINGS_SCHEMA. The
--          receiver's one-shot flags are about the RECEIVER'S profile and say
--          nothing about the incoming fragment, so the version is what gates
--          instead — this is precisely the job §18 gives `sv`. Without it an
--          old payload would skip every migration whose flag the receiver had
--          already set years ago, which is exactly the silent-breakage case
--          those migrations exist to prevent.
function ns.MigrateTrackerSettings(p, cfg, entry, fromSchema)
    if not (p and cfg and cfg.dbPrefix) then return end
    local imported = type(fromSchema) == "number"
    do
        local prefix = cfg.dbPrefix
        -- v1: legacy alert mode. TTS is gone (no aura-triggered speech API on
        -- 12.1); "voice" plays the bundled clips via AddAuraSound. Must run
        -- BEFORE the alertMode read below. Idempotent, so both callers run it
        -- unconditionally.
        if p[prefix .. "alertMode"] == "tts" then
            p[prefix .. "alertMode"] = "voice"
        end
        local key = prefix .. "elements"
        if type(p[key]) ~= "table" then
            -- Only build elements[1] from a GENUINELY STORED legacy
            -- style (rawget bypasses AceDB's defaults metatable) — the
            -- registered flat defaults otherwise fabricate a default
            -- Icon tracker for every spell that never had one
            -- (PTR-observed). No stored legacy display ⇒ no trackers by
            -- default; the user adds them explicitly.
            local legacyStyle = rawget(p, prefix .. "trackerStyle")
            if legacyStyle then
                local defColor = cfg.defaults.barCircleColor or { 1, 1, 1, 1 }
                local oldColor = p[prefix .. "barCircleColor"] or defColor
                p[key] = { {
                    style          = legacyStyle,
                    iconSize       = p[prefix .. "iconSize"] or 40,
                    cooldownSwipe  = p[prefix .. "cooldownSwipe"] ~= false,
                    color          = { oldColor[1], oldColor[2], oldColor[3], oldColor[4] },
                    barOrientation = p[prefix .. "barOrientation"] or "horizontal",
                    barWidth       = p[prefix .. "barWidth"] or 200,
                    barHeight      = p[prefix .. "barHeight"] or 20,
                    circleRadius   = p[prefix .. "circleRadius"] or 80,
                    ringTexture    = p[prefix .. "ringTexture"] or "Ring_20px",
                    position       = p[prefix .. "position"],
                } }
            else
                p[key] = {}
            end
        end

        local els = p[key]

        -- v2.1: spell-level "Show Tooltip" → per-element flag.
        if p[prefix .. "showTooltip"] then
            for j = 1, #els do
                if els[j].style then els[j].showTooltip = true end
            end
        end
        p[prefix .. "showTooltip"] = nil

        -- v2.1: spell-level sound alert → dedicated "sound" tracker
        -- element (Sound Alert is now a Tracker Type).
        local mode = p[prefix .. "alertMode"]
        if (mode == "sound" or mode == "voice") and #els < ns.MAX_ELEMENTS then
            els[#els + 1] = {
                style        = "sound",
                soundType    = (mode == "voice") and "voice" or "custom",
                soundName    = (mode == "voice") and "None"
                               or (p[prefix .. "soundName"] or "None"),
                soundChannel = p[prefix .. "soundChannel"] or "Master",
            }
        end
        p[prefix .. "alertMode"] = nil
        p[prefix .. "soundName"] = nil
        p[prefix .. "soundChannel"] = nil

        -- Built-in spellList groups are locked to Unordered — clear any
        -- stored mode from the brief window where it was settable.
        if cfg.spellList then
            p[prefix .. "groupOrdered"] = nil
        end

        -- v3: per-element soundType (the Sound Type dropdown) replaces
        -- the "_VOICE_" sentinel inside the sound dropdown.
        for j = 1, #els do
            local el = els[j]
            if el.soundType == nil then
                if el.soundName == ns.VOICE_SOUND_KEY then
                    el.soundType = "voice"
                    el.soundName = "None"
                else
                    el.soundType = "custom"
                end
            end
        end
    end

    -- v6 (12.1 MIXED ID GROUPS): ability/item GROUPS may now hold a mix of both,
    -- distinguished per entry by the side-table p[prefix.."idKinds"] =
    -- { [id] = "ability" | "item" }. Existing abilitygroup/itemgroup trackers
    -- predate that table, so seed it from their original whole-group kind — this
    -- makes old groups behave IDENTICALLY under the per-id model. We KEEP the
    -- abilitygroup/itemgroup kind values as-is (no kind migration): the kind now
    -- just sets the group's DEFAULT entry type, while idKinds governs each entry.
    -- Idempotent: only seed when idKinds is absent/empty (so re-runs and user
    -- edits are never clobbered). Single ability/item trackers stay untagged
    -- (one id, resolved by the tracker's own kind), so they're skipped.
    -- v4: External Defensives checkbox keys were hand-picked strings; the
    -- curated SpellData table is now the source and keys are the entry's first
    -- spell ID (locale-stable). Translate stored state. Idempotent by
    -- construction (it only moves keys that are present), so no flag and no
    -- version gate — both callers run it.
    if cfg.cfgKey == "extdef" then
        local ED_KEY_MAP = {
            auraMastery = 31821, bop = 1022, sacrifice = 6940,
            spellwarding = 204018, guardianSpirit = 47788, ironbark = 102342,
            lifeCocoon = 116849, painSuppression = 33206, rallyingCry = 97462,
            timeDilation = 357170, zephyr = 374227,
        }
        local edSel = rawget(p, "ed_spells")
        if type(edSel) == "table" then
            for old, id in pairs(ED_KEY_MAP) do
                if edSel[old] ~= nil then
                    edSel[id] = edSel[old]
                    edSel[old] = nil
                end
            end
        end
    end

    -- v5: Time Spiral split into per-class rows (perClass spellList). The old
    -- single merged row was keyed by the first TS id (375226, Death Knight);
    -- if the user had UNCHECKED it, carry that over to every per-class key —
    -- otherwise the 13 new keys default true and the group starts tracking
    -- again. NOT idempotent (the new keys are individually settable
    -- afterwards), hence the gate.
    if cfg.cfgKey == "timespiral" then
        local run
        if imported then
            run = fromSchema < 5
        else
            run = not p.ts_perClassSplit
            p.ts_perClassSplit = true
        end
        if run then
            local sel = rawget(p, "ts_spells")
            if type(sel) == "table" and sel[375226] == false then
                local list = cfg.spellList
                if list then
                    for i = 1, #list do sel[list[i].key] = false end
                end
            end
        end
    end

    -- v8: Personal Defensives gained voiceOptions ("Personal" / "Defensive").
    -- Before that it had no bundled clips at all, so ElementSoundType FORCED
    -- every persdef sound element to "custom" regardless of its stored
    -- soundType — and the element default IS "voice". Without this pin, adding
    -- voiceOptions silently flips existing sound elements off the user's chosen
    -- custom sound onto the Personal clip. One-shot, same gate as v5.
    if cfg.cfgKey == "persdef" then
        local run
        if imported then
            run = fromSchema < 8
        else
            run = not p.pd_voiceOptionsMigrated
            p.pd_voiceOptionsMigrated = true
        end
        if run then
            local pdEls = rawget(p, "pd_elements")
            if type(pdEls) == "table" then
                for j = 1, #pdEls do
                    local el = pdEls[j]
                    if el and el.style == "sound" then
                        el.soundType = "custom"
                    end
                end
            end
        end
    end

    local k = entry and entry.kind
    if k == "abilitygroup" or k == "itemgroup" then
        local prefix = cfg.dbPrefix
        local kinds = p[prefix .. "idKinds"]
        if type(kinds) ~= "table" or next(kinds) == nil then
            local groupTag = (k == "itemgroup") and "item" or "ability"
            kinds = {}
            local ids = p[prefix .. "ids"]
            if type(ids) == "table" then
                for j = 1, #ids do
                    local id = tonumber(ids[j])
                    if id and id > 0 then kinds[id] = groupTag end
                end
            end
            p[prefix .. "idKinds"] = kinds
        end

        -- v7 (PER-ID SHOW WHEN): "Show When" moved from a whole-group
        -- element setting to a per-id side-table (p[prefix.."idVisibilities"]
        -- = { [id] = mode }). Seed it from any element's stored
        -- el.idVisibility so an upgraded group keeps its prior visibility
        -- for every current id. Idempotent: only when the table is absent
        -- AND a non-default group mode was actually set.
        if p[prefix .. "idVisibilities"] == nil then
            local groupMode
            local els = p[prefix .. "elements"]
            if type(els) == "table" then
                for j = 1, #els do
                    local m = els[j] and els[j].idVisibility
                    if m and m ~= "always" then groupMode = m; break end
                end
            end
            if groupMode then
                local vis = {}
                local ids = p[prefix .. "ids"]
                if type(ids) == "table" then
                    for j = 1, #ids do
                        local id = tonumber(ids[j])
                        if id and id > 0 then vis[id] = groupMode end
                    end
                end
                p[prefix .. "idVisibilities"] = vis
            end
        end
    end
end

-- Monotonic counter, bumped whenever a migration is ADDED to
-- ns.MigrateTrackerSettings above. Travels in every !BZA payload as `sv` so an
-- older addon reading a newer string can warn precisely instead of guessing.
ns.SETTINGS_SCHEMA = 8

-------------------------------------------------------------------------------
-- Profile migration. Runs for the active profile at login AND whenever
-- the active profile changes (AceDB callbacks) — a freshly activated
-- profile may carry any older schema.
-------------------------------------------------------------------------------
local function MigrateProfile(p)
    -- Stable UIDs FIRST: everything account-wide (folder membership,
    -- export/import matching) keys off them, and this is the one pass that
    -- covers both login and every profile swap with no new plumbing.
    StampUIDs(p)

    -- customTrackers entry per custom id, so the per-tracker migration can be
    -- handed its entry without rescanning the list once per tracker.
    -- RegisterSavedCustomConfigs runs before every MigrateProfile call site,
    -- so ns.trackerOrder covers every saved entry.
    local byID
    local customs = p.customTrackers
    if type(customs) == "table" then
        byID = {}
        for i = 1, #customs do
            local e = customs[i]
            if type(e) == "table" and e.id then byID[e.id] = e end
        end
    end
    for i = 1, #ns.trackerOrder do
        local cfg = ns.trackerConfigs[ns.trackerOrder[i]]
        local entry = (byID and cfg.custom) and byID[cfg.customID] or nil
        ns.MigrateTrackerSettings(p, cfg, entry)
    end
end

-------------------------------------------------------------------------------
-- Preset seeding (12.1). A predefined tracker with a cfg.preset gets its
-- default elements written into the profile ONCE — the first time it is
-- both enabled and empty. A per-tracker flag (<prefix>presetSeeded) makes
-- this idempotent: after seeding (or after we detect the user already has
-- elements, migrated or otherwise) the flag is set and we never touch that
-- tracker's elements again. So a deliberate teardown (delete every element)
-- is respected — the preset does not come back.
--
-- Runs from RefreshAll, which fires at login, PLAYER_ENTERING_WORLD, spec
-- change, profile swap, and every option change. Consequences:
--   * the 4 default-on buffs (enabled=true) seed at the first login refresh,
--     so they render immediately;
--   * the disabled-by-default groups seed on the RefreshAll triggered by the
--     user enabling them.
-- MigrateProfile has already run by the time this does (OnInitialize /
-- OnProfileSwapped both call it before the refresh pipeline), so a
-- legacy-migrated elements[1] is present and short-circuits seeding here.
local function CopyPresetElement(src)
    local copy = {}
    for k, v in pairs(src) do
        if type(v) == "table" then
            -- Preset element tables only carry array-style values (colors);
            -- copy them so profiles never share a table reference.
            local t = {}
            for i = 1, #v do t[i] = v[i] end
            copy[k] = t
        else
            copy[k] = v
        end
    end
    return copy
end

-- Which preset styles are considered "additive" — safe to reconcile onto a
-- profile that was seeded/migrated with only its visual element. The visual
-- styles (icon/bar/circle) are the tracker's primary display and are never
-- force-added (respecting a deliberate teardown); the Sound alert is a
-- separate, non-visual companion the legacy migration path used to drop
-- (MigrateProfile builds elements[1] from the visual style only), so it is
-- reconciled once here. Keyed by style so a profile that ALREADY has a sound
-- element (user-added or migrated from alertMode) is left untouched.
local RECONCILE_PRESET_STYLES = { sound = true }

local function SeedPresets(p)
    for i = 1, #ns.trackerOrder do
        local cfg = ns.trackerConfigs[ns.trackerOrder[i]]
        local preset = cfg.preset
        if preset then
            local prefix = cfg.dbPrefix
            local seededKey = prefix .. "presetSeeded"
            if not rawget(p, seededKey) then
                local elsKey = prefix .. "elements"
                local els = rawget(p, elsKey)
                if type(els) == "table" and #els > 0 then
                    -- Already has elements (legacy migration or a prior
                    -- session): never overwrite them. But the legacy migration
                    -- path builds only the VISUAL element from the stored style,
                    -- dropping the preset's companion Sound element — reconcile
                    -- any additive preset styles the profile is missing (once,
                    -- gated by presetSeeded below, so a later manual delete of
                    -- the sound element is respected).
                    local present = {}
                    for j = 1, #els do
                        if els[j].style then present[els[j].style] = true end
                    end
                    for j = 1, #preset do
                        local ps = preset[j].style
                        if ps and RECONCILE_PRESET_STYLES[ps] and not present[ps]
                            and #els < ns.MAX_ELEMENTS then
                            els[#els + 1] = CopyPresetElement(preset[j])
                            present[ps] = true
                        end
                    end
                    p[seededKey] = true
                elseif p[prefix .. "enabled"] then
                    local newEls = {}
                    for j = 1, #preset do
                        newEls[j] = CopyPresetElement(preset[j])
                    end
                    p[elsKey] = newEls
                    p[seededKey] = true
                end
            end
        end
    end
end
ns.SeedPresets = SeedPresets

-------------------------------------------------------------------------------
-- Lifecycle
-------------------------------------------------------------------------------
-- Custom tracker registration — on demand, no pre-created pool.
-- Idempotent: registers the CONFIG for custom id `id` (appending it to
-- ns.trackerOrder). The tracker OBJECT is then created by the refresh
-- pipeline (RefreshAll creation pass) the next time it runs
-- unrestricted — slot/group creation and region binding work at any
-- time outside combat/secret windows (ns.IsRestricted).
-- `skipDefaults` (internal, batch use only): register the CONFIG but do not
-- re-fold the merged AceDB defaults. Only RegisterSavedCustomConfigs passes it;
-- it does ONE RegisterDefaults for the whole batch afterwards. Registering per
-- custom was O(N^2) at login: N BuildDefaults() rebuilds over an ever-growing
-- key set, each feeding a full AceDB defaults teardown + reapply of every key.
--
-- Returns true when a NEW config was registered, false when `id` was already
-- known, so the batch caller can skip the trailing RegisterDefaults entirely.
function ns.RegisterCustomConfig(id, skipDefaults)
    local key = "custom" .. id
    if ns.trackerConfigs[key] then return false end
    ns.RegisterTracker(key, {
        custom      = true,
        customID    = id,
        dbPrefix    = "cu" .. id .. "_",
        frameSuffix = "Custom" .. id,
        defaultPos  = { "CENTER", "CENTER", 0, 0 },
        defaults = {
            enabled        = true,
            barCircleColor = { 1, 1, 1, 1 },
        },
    })
    -- Fold the new config's defaults into the merged AceDB defaults.
    if HET.db and not skipDefaults then
        HET.db:RegisterDefaults(BuildDefaults())
    end
    return true
end

-- Register configs for every custom tracker saved in a profile.
--
-- ONE RegisterDefaults for the whole batch. AceDB's RegisterDefaults removes the
-- PREVIOUS defaults from the live profile and copies the new ones back in; doing
-- that per custom made login O(N^2) with no effect on the end state, because
-- every intermediate defaults table is a strict SUBSET of the final one
-- (RegisterTracker only ever APPENDS to ns.trackerOrder, BuildDefaults writes
-- one flat key per <dbPrefix>..<k>, custom prefixes are unique per id, so no key
-- is ever redefined — and no tracker default uses an AceDB wildcard key, so none
-- of copyDefaults/removeDefaults' wildcard branches are reachable). The single
-- trailing call therefore lands on exactly the same profile contents.
local function RegisterSavedCustomConfigs(p)
    if type(p.customTrackers) ~= "table" then return end
    local added = false
    for i = 1, #p.customTrackers do
        local id = p.customTrackers[i].id
        if id and ns.RegisterCustomConfig(id, true) then
            added = true
        end
    end
    if added and HET.db then
        HET.db:RegisterDefaults(BuildDefaults())
    end
end

function HET:OnInitialize()
    -- Bake every voice playback string once (all trackers are registered
    -- by now — TOC loads their files before Core). The refresh path then
    -- only looks up cached values.
    NormalizeVoiceData()

    -- One-time SavedVariables rename migration (HealerExternalTracker -> BuzzardAuras).
    -- Runs before AceDB reads the new global. As long as the old DB is still
    -- present it is the source of truth: adopt it wholesale and discard whatever
    -- is in the new global (this heals a prior failed/partial migration that left
    -- a defaults-only BuzzardAurasDB). Nil the old global so this fires exactly
    -- once; subsequent logins have no old DB and skip. Load order is enforced by
    -- "## OptionalDeps: HealerExternalTracker" in the TOC so the old global is
    -- populated before this runs. Remove once no old installs remain.
    local old = _G.HealerExternalTrackerDB
    if old and next(old) ~= nil then
        _G.BuzzardAurasDB = old
        _G.HealerExternalTrackerDB = nil
    end

    self.db = LibStub("AceDB-3.0"):New("BuzzardAurasDB", BuildDefaults(), true)

    -- Custom trackers: register a config ONLY for each entry actually
    -- saved in the profile.
    local p = self.db.profile
    RegisterSavedCustomConfigs(p)

    MigrateProfile(p)
    -- Tracker objects are created lazily by the refresh pipeline
    -- (RefreshAll creation pass). On login that happens at PLAYER_LOGIN,
    -- before ns.setupComplete flips at PLAYER_ENTERING_WORLD, so
    -- IsRestricted() is false and creation proceeds exactly as the old
    -- eager path did.

    self:RegisterOptions()
    self:SetupUI()

    -- Profile switches re-run migration (a newly active profile may
    -- carry an older schema) and the full refresh (which registers and
    -- creates trackers for the new profile's customTrackers).
    self.db.RegisterCallback(self, "OnProfileChanged", "OnProfileSwapped")
    self.db.RegisterCallback(self, "OnProfileCopied", "OnProfileSwapped")
    self.db.RegisterCallback(self, "OnProfileReset", "OnProfileSwapped")
end

function HET:OnProfileSwapped()
    -- db.profile is a DIFFERENT table now: every tracker object's cached
    -- customTrackers entry (and its kind booleans) points into the OLD profile.
    -- Tracker objects are never destroyed (ns.trackers is never wiped), so
    -- nothing else would invalidate them. Safe to bump first: the two calls
    -- below read p.customTrackers directly and never touch a tracker object.
    -- All three AceDB profile callbacks route here, so one bump covers them.
    if ns.BumpCustomEntryGen then ns.BumpCustomEntryGen() end
    -- Belt-and-braces: the new profile may select different fonts/textures, and
    -- the memos are keyed by NAME so they stay correct across a swap — but a
    -- wipe here costs one re-Fetch per distinct name on the first render.
    if ns.WipeMediaPathCaches then ns.WipeMediaPathCaches() end
    -- The tree's "never loads" answers are memoized per prefix and normally
    -- invalidated by the rebuild every Show-Conditions setter causes. A profile
    -- swap replaces all six condition keys at once and does NOT rebuild, so it
    -- is the one door that has to knock here.
    if ns.WipeCondWarnCache then ns.WipeCondWarnCache() end
    -- A newly active profile may carry customs never seen this session.
    RegisterSavedCustomConfigs(self.db.profile)
    MigrateProfile(self.db.profile)
    RefreshAllResync()  -- new profile = new baseline; seed sound state silently
end

-- One-time rename notice (HET -> Buzzard Auras). Shown once ever per account,
-- gated by db.global.renameNoticeShown. Defined + fired from OnEnable so the DB
-- is ready.
local function ShowRenameNoticeOnce(self)
    if not (self.db and self.db.global) then return end
    if self.db.global.renameNoticeShown then return end
    self.db.global.renameNoticeShown = true
    if not StaticPopupDialogs["BUZZARDAURAS_RENAME_NOTICE"] then
        StaticPopupDialogs["BUZZARDAURAS_RENAME_NOTICE"] = {
            text = "|cff11ace9Healer External Tracker|r is now |cffff8c14Buzzard Auras|r.\n\n"
                .. "The old HET Innervate and Spatial tracking functionality is still "
                .. "available in Buzzard Auras on 12.0.7, but the addon has been fully "
                .. "rebuilt for 12.1 to allow any buff on yourself to be tracked by spell ID.",
            button1 = OKAY,
            timeout = 0,
            whileDead = 1,
            hideOnEscape = 1,
            preferredIndex = 3,
        }
    end
    StaticPopup_Show("BUZZARDAURAS_RENAME_NOTICE")
end

function HET:OnEnable()
    -- Seed the event-derived combat state:
    -- InCombatLockdown() is only trusted OUTSIDE the transition dispatch —
    -- the REGEN handlers below own it from here on.
    -- UnitAffectingCombat is OR'd in for the combat-/reload case, where
    -- InCombatLockdown() answers false during the load sequence (see
    -- UpdateAuraSounds). Seeding false there was not self-correcting:
    -- PLAYER_REGEN_DISABLED cannot fire because combat is already underway, so
    -- ns.inCombat stayed wrong until the fight ENDED — and Tracker's Combat
    -- Status load condition reads it, so every tracker gated on "in combat"
    -- was mis-gated for the rest of that pull.
    ns.inCombat = (InCombatLockdown() or UnitAffectingCombat("player"))
        and true or false
    -- Spec change / login / entering world are RESYNC baselines: custom Sound
    -- alerts seed their met-state silently here so they don't replay on the
    -- loading screen even when the condition is already met.
    -- A spec change rewrites the KNOWN-spell set, which ns.AbilityKnown
    -- memoizes; RefreshAllResync drops that memo as its first act, before the
    -- refresh reads it, so this handler must NOT wipe it again -- a second wipe
    -- here is dead work that also invites the two copies to drift apart.
    self:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED", function()
        RefreshAllResync()
        -- The Spec Filter re-snapped to the new spec inside that refresh.
        -- Without this the panel keeps showing the OLD spec's trackers (and
        -- its previews) until some unrelated edit.
        ConsumeSpecFilterMove()
    end)
    self:RegisterEvent("PLAYER_LOGIN", RefreshAllResync)
    -- Learn/unlearn (incl. talent swaps) changes which abilities are KNOWN, which
    -- the ID-kind auto known-gate (AbilityKnown) reads. Re-run the load gate so
    -- ability trackers show/hide accordingly. RefreshLoadGating is change-guarded
    -- (cheap) and re-renders ID art via UpdateIDArt.
    local function KnownSetChanged()
        -- The KNOWN-spell set changed (learn/unlearn/talent swap): drop the
        -- AbilityKnown memo FIRST (UpdateIDReadyWatch reads it to decide which
        -- ids are worth watching at all), rebuild the ID watch so
        -- unknown-ability trackers drop out of (or rejoin) the event path, then
        -- re-gate visibility.
        if ns.WipeAbilityKnownCache then ns.WipeAbilityKnownCache() end
        HET:UpdateIDReadyWatch()
        HET:RefreshLoadGating()
    end
    self:RegisterEvent("SPELLS_CHANGED", KnownSetChanged)
    -- Loadout swaps: SPELLS_CHANGED is the documented signal for spellbook
    -- mutation, but a loadout change that fires only the trait event would
    -- otherwise leave the memo stale (an ability tracker stuck hidden). A wipe
    -- is a wipe of a small table on a rare event — cheap insurance.
    self:RegisterEvent("TRAIT_CONFIG_UPDATED", KnownSetChanged)
    -- Defensive re-register of aura sounds across loading screens
    -- (PTR-VERIFY: see header).
    self:RegisterEvent("PLAYER_ENTERING_WORLD", function()
        -- The mutation restriction gate activates only once the login
        -- init window closes (Tracker ns.IsRestricted).
        ns.setupComplete = true
        RefreshAllResync()
        -- On load orders where GetSpecialization only answers this late, the
        -- refresh above is where the Spec Filter first resolves — and the
        -- options nodes were primed unfiltered back at ADDON_LOADED. Flagged
        -- but never consumed before, which is how a cold login could open the
        -- panel on Addon Options.
        ConsumeSpecFilterMove()
        -- Cinematic gate failsafe (see the gate below): a cinematic that ends
        -- across a loading screen can swallow its stop event, which would
        -- leave the gate closed and every aura invisible until the next one.
        -- Reopens through the same deferred path; no-op when already open.
        if ns.cinematicGateActive and ns.PlanCinematicRestore then
            ns.PlanCinematicRestore()
        end
        -- Seed the assist gate for the current target/focus: if the player
        -- already has a friendly unit selected at login/zone-in, its debuff
        -- container must be born hidden rather than waiting for the next
        -- target-change event. Change-guarded; no-op when nothing is selected.
        if ns.ApplyAssistGate then
            ns.ApplyAssistGate("target")
            ns.ApplyAssistGate("focus")
        end
    end)
    -- Container mutations are deferred while restricted (combat /
    -- secret-aura windows) — re-apply from the safe context.
    self:RegisterEvent("PLAYER_REGEN_ENABLED", function()
        ns.inCombat = false
        if ns.deferredRefresh then
            ns.deferredRefresh = false
            RefreshAll()  -- also runs the combat-load gate via UpdateGating
            -- The refresh above re-renders in "render" mode (no _condMet write),
            -- so run the LIVE gate too: a combat-status condition that just
            -- became met on combat exit still fires its custom Sound alert.
            HET:RefreshLoadGating()
        else
            -- Combat Status "Out of Combat Only" trackers reappear; restore
            -- their visuals without a full slot refresh.
            HET:RefreshLoadGating()
        end
        -- Flush any aura-sound rebuild that was refused under lockdown (combat
        -- /reload, or /het debug sounds typed mid-fight). RefreshAll above
        -- already runs one and clears the flag, so this only fires on the paths
        -- that didn't.
        if ns.auraSoundsDirty then
            HET:UpdateAuraSounds()
        end
    end)
    -- Entering combat: re-apply the Combat Status gate instantly. Slots
    -- re-park via combat-safe container calls; group-backed elements flip
    -- container alpha (assignment is combat-restricted); sounds re-register.
    self:RegisterEvent("PLAYER_REGEN_DISABLED", function()
        ns.inCombat = true
        HET:RefreshLoadGating()
    end)
    -- Debuff trackers watch target/focus; those aura containers don't auto-
    -- follow the unit token, so re-point them when the token repoints
    -- (Phase 0 finding). Cheap and combat-safe; no-op when there are no
    -- target/focus trackers.
    self:RegisterEvent("PLAYER_TARGET_CHANGED", function()
        if ns.RepointUnit then ns.RepointUnit("target") end
        -- New target ⇒ re-evaluate the assist gate: a friendly target (Delve
        -- companion, escort NPC, party member) can't have its debuffs spell-ID
        -- filtered by the engine, so its debuff container is hidden. Cheap and
        -- change-guarded; no-op when there are no target debuff trackers.
        if ns.ApplyAssistGate then ns.ApplyAssistGate("target") end
    end)
    self:RegisterEvent("PLAYER_FOCUS_CHANGED", function()
        if ns.RepointUnit then ns.RepointUnit("focus") end
        if ns.ApplyAssistGate then ns.ApplyAssistGate("focus") end
    end)

    -- Not in Pet Battle / Not Mounted load conditions (default on): re-apply
    -- the load gate when the player mounts/dismounts or a pet battle
    -- opens/closes. Infrequent, out-of-combat-only changes. Only re-gates;
    -- never rebuilds. (Vehicles are deliberately NOT a load condition — see
    -- the container gate below.)
    local function RefreshStateGate() HET:RefreshLoadGating() end
    self:RegisterEvent("PLAYER_MOUNT_DISPLAY_CHANGED", RefreshStateGate)
    self:RegisterEvent("PET_BATTLE_OPENING_START", RefreshStateGate)
    self:RegisterEvent("PET_BATTLE_CLOSE", RefreshStateGate)
    -- Vehicle container gate edges (see Tracker's ApplyVehicleGate). ENTER is
    -- event-authoritative and immediate: the UNIT_* events are the earliest
    -- signal — the vehicle APIs LAG them, and reading the API inside the enter
    -- event left the gate open for a beat (the enter junk-flash).
    -- UNIT_ENTERING_VEHICLE fires before the seat is even taken; earlier is
    -- better for a hide. EXIT is DEFERRED: restoring alpha on the exit event
    -- showed junk for the ~half second the engine takes to settle its aura
    -- display (same residual flash as the post-cinematic rescan), so the gate
    -- stays closed for VEHICLE_RESTORE_DELAY, rescans while still hidden, then
    -- restores. A generation counter cancels the restore if the player
    -- re-enters inside the window; the API re-check catches event/API
    -- disagreement. VEHICLE_UPDATE is belt-and-suspenders for vehicle types
    -- that skip the UNIT_* pair — ENTER-only there, so it can never undercut a
    -- pending deferred restore.
    local VEHICLE_RESTORE_DELAY = 0.5  -- PTR-tune: engine settle time on exit
    local vehicleGen = 0
    local function VehicleGateOn()
        vehicleGen = vehicleGen + 1
        if ns.ApplyVehicleGate and ns.ApplyVehicleGate(true) then
            HET:RefreshLoadGating()  -- push alpha 0 to group containers
        end
    end
    local function VehicleGateOffDeferred()
        vehicleGen = vehicleGen + 1
        local gen = vehicleGen
        C_Timer.After(VEHICLE_RESTORE_DELAY, function()
            if gen ~= vehicleGen then return end  -- re-entered meanwhile
            if (UnitInVehicle and UnitInVehicle("player"))
                or (UnitUsingVehicle and UnitUsingVehicle("player")) then
                return  -- API says still in a vehicle: leave the gate closed
            end
            -- Rescan BEFORE the alpha restore: junk clears while invisible.
            if ns.RescanAllUnits then ns.RescanAllUnits() end
            if ns.ApplyVehicleGate and ns.ApplyVehicleGate(false) then
                HET:RefreshLoadGating()  -- restore group container alphas
            end
        end)
    end
    self:RegisterEvent("UNIT_ENTERING_VEHICLE", function(_, unit)
        if unit == "player" then VehicleGateOn() end
    end)
    self:RegisterEvent("UNIT_ENTERED_VEHICLE", function(_, unit)
        if unit == "player" then VehicleGateOn() end
    end)
    self:RegisterEvent("UNIT_EXITED_VEHICLE", function(_, unit)
        if unit == "player" then VehicleGateOffDeferred() end
    end)
    self:RegisterEvent("VEHICLE_UPDATE", function()
        if not ns.vehicleGateActive
            and ((UnitInVehicle and UnitInVehicle("player"))
                or (UnitUsingVehicle and UnitUsingVehicle("player"))) then
            VehicleGateOn()
        end
    end)
    -- Cinematic container gate (see ApplyCinematicGate in Tracker). Same junk
    -- as the vehicle gate, same alpha-gate shape, EARLIER trigger: the
    -- UNIT_FACTION rescan below only fires once the faction has already
    -- flipped, by which point the engine has been showing junk for a beat.
    -- CINEMATIC_START / PLAY_MOVIE are the earliest signals the client offers,
    -- so we close the gate there and hold it closed for the whole cinematic.
    -- The exit edge is DEFERRED for the same reason as the vehicle exit: the
    -- engine takes a moment to settle its aura display, so we rescan while
    -- still invisible and only then restore. A generation counter cancels a
    -- pending restore if another cinematic starts inside the window (movie
    -- chains, and CINEMATIC_STOP followed immediately by PLAY_MOVIE).
    local CINEMATIC_RESTORE_DELAY = 0.5  -- PTR-tune: engine settle time
    local cinematicGen = 0
    local function CinematicGateOn()
        cinematicGen = cinematicGen + 1
        if ns.ApplyCinematicGate and ns.ApplyCinematicGate(true) then
            HET:RefreshLoadGating()  -- push alpha 0 to group containers
        end
    end
    local function CinematicGateOffDeferred()
        cinematicGen = cinematicGen + 1
        local gen = cinematicGen
        C_Timer.After(CINEMATIC_RESTORE_DELAY, function()
            if gen ~= cinematicGen then return end  -- another cinematic started
            -- Rescan BEFORE the alpha restore: junk clears while invisible.
            if ns.RescanAllUnits then ns.RescanAllUnits() end
            if ns.ApplyCinematicGate and ns.ApplyCinematicGate(false) then
                HET:RefreshLoadGating()  -- restore group container alphas
            end
        end)
    end
    self:RegisterEvent("CINEMATIC_START", CinematicGateOn)
    self:RegisterEvent("PLAY_MOVIE", CinematicGateOn)
    self:RegisterEvent("CINEMATIC_STOP", CinematicGateOffDeferred)
    self:RegisterEvent("STOP_MOVIE", CinematicGateOffDeferred)
    -- Published for the PLAYER_ENTERING_WORLD failsafe above, which is
    -- registered EARLIER in this function than these locals are declared —
    -- an upvalue would still be nil there, so it goes through ns.
    ns.PlanCinematicRestore = CinematicGateOffDeferred
    -- After a cinematic the player's faction briefly flips, the tracked unit
    -- momentarily reads as non-assistable, and the engine AuraContainers flash
    -- stale/unfiltered auras until forced to re-scan. UNIT_FACTION("player")
    -- fires on the flip (and restore); re-point every live container. Also
    -- covers PvP-flag toggles (same event) — infrequent, so the sweep is cheap.
    -- KEPT alongside the gate above: it is the repair path for any cinematic
    -- form that fires neither CINEMATIC_START nor PLAY_MOVIE, and it is the
    -- only handler for a plain PvP-flag toggle.
    self:RegisterEvent("UNIT_FACTION", function(_, unit)
        if unit == "player" and ns.RescanAllUnits then ns.RescanAllUnits() end
        -- Assist relation of the tracked unit can flip WITHOUT a target/focus
        -- change: a mob's faction flips, a unit is charmed/mind-controlled, or a
        -- PvP flag toggles. UNIT_FACTION fires for that unit; re-evaluate its
        -- assist gate so a now-friendly (or now-hostile) target shows/hides its
        -- debuff container correctly. Change-guarded, so an unrelated unit's
        -- event is a table read plus a compare.
        if ns.ApplyAssistGate and (unit == "target" or unit == "focus") then
            ns.ApplyAssistGate(unit)
        end
    end)

    if IsLoggedIn() then
        RefreshAllResync()  -- startup baseline: seed sound state, don't replay
        -- The refresh above may have resolved the player's spec for the first
        -- time (GetSpecialization is unreliable at ADDON_LOADED, when the
        -- options nodes were primed). Clear the flag it set; hetOpen re-derives
        -- the node tables on the first open now that the filter has moved.
        ConsumeSpecFilterMove()
    end

    ShowRenameNoticeOnce(self)
end

function HET:OnDisable()
    if RemoveAuraSound then
        for i = #registeredSoundIDs, 1, -1 do
            RemoveAuraSound(registeredSoundIDs[i])
            registeredSoundIDs[i] = nil
        end
    end
    -- Registrations are gone but the plan hash still describes them; clear it
    -- so a re-enable rebuilds instead of being skipped by the change-guard.
    appliedSoundSig = nil
end

-------------------------------------------------------------------------------
-- Preview helpers (called from Options.lua), keyed by tracker.
-------------------------------------------------------------------------------
function HET:HidePreviewFor(key)
    local t = ns.trackers[key]
    if t then t:HidePreview() end
end
-- Show previews for every VISUAL element of one tracker. Used by the Enable
-- toggle so a freshly enabled tracker is immediately visible and positionable
-- (same rule as a freshly added tracker). Call AFTER RefreshTracking — the
-- first enable of a predefined tracker preset-seeds its elements there.
function HET:ShowPreviewFor(key)
    if InCombatLockdown() then return end  -- combat lock (previews touch engine regions)
    local t = ns.trackers[key]
    if not t then return end
    local els = t:GetElementsDB()
    for i = 1, ns.MAX_ELEMENTS do
        local el = els[i]
        if el and el.style and el.style ~= "sound" then
            t:ShowElementPreview(i)
        end
    end
end
function HET:RefreshPreviewFor(key)
    local t = ns.trackers[key]
    if t then t:RefreshPreview() end
end
-- Which options subtab a tracker lives on ("buffs" | "debuffs" | "idkinds" |
-- "custom"), mirroring the routing Options.lua uses when it buckets rows into
-- the tree: ID kinds → merged Abilities/Items tab, custom kind → Other,
-- harmful customs → Debuffs, everything else (incl. all built-ins, which are
-- never harmful) → Buffs. Used to scope the "Show All Previews" buttons to
-- the tab they sit on; nil `subtab` in the helpers below means ALL tabs.
-- Which KIND bucket a live tracker object belongs to. The SAME five answers
-- ns.TypeKeyFor gives for a cfgKey — "buffs" / "debuffs" / "abilities" /
-- "items" / "other" — so a caller can compare the two without translating.
-- (They were four subtab names once; a stale vocabulary here would silently
-- match nothing.)
function ns.SubtabForTracker(t)
    if t:IsItemKind() then return "items" end
    if t:IsAbilityKind() then return "abilities" end
    if t:IsCustomKind() then return "other" end
    local e = t:GetCustomEntry()
    if e and e.harmful then return "debuffs" end
    return "buffs"
end
local function InSubtab(t, subtab)
    return subtab == nil or ns.SubtabForTracker(t) == subtab
end
-- THE Spec Filter predicate — the same one the options tracker list uses, so
-- previews and list can never disagree about which trackers are in scope.
-- ns.PassesSpecFilter is ns.MatchesSpecFilter PLUS the rule that a tracker whose
-- Role/Spec condition is switched on with nothing ticked stays in scope for
-- every spec (an unfinished condition is not a scope). Using the bare matcher
-- here left exactly those trackers listed-but-unpreviewable.
-- The fallback is load-order insurance only: Options.lua sets the alias and
-- loads AFTER this file, but every caller below runs at click time, long after.
local function InSpecFilter(prefix)
    local f = ns.PassesSpecFilter
    if f then return f(prefix) end
    return ns.MatchesSpecFilter(prefix)
end
-- The set the "show all" eyes operate on: in-scope AND ENABLED. A disabled
-- tracker can never render live, so previewing it puts art on screen that no
-- setting will ever reproduce in play — and the user has to disable it a second
-- time (or hunt for its row eye) to get rid of it.
--
-- ShowAllPreviews and AreAllPreviewsActive MUST agree on this set. The global
-- eye reads "active" only when every element in the set is previewing, so if
-- AreAllPreviewsActive still counted disabled trackers it could never reach
-- that state — the eye would stay red and clicking it would re-show instead of
-- toggling off. IsAnyPreviewActive and HideAllPreviews stay UNFILTERED on
-- purpose: they report/clear what is actually on screen, which is how a preview
-- left over from before a tracker was disabled still gets cleaned up.
local function PreviewableTracker(t, subtab)
    if not InSubtab(t, subtab) then return false end
    if not t:GetDBVal("enabled") then return false end
    -- Options "Filter By Spec": previews follow the same filter as the list, so
    -- the art on screen always matches the trackers the panel is showing.
    return InSpecFilter(t.config.dbPrefix)
end

-- Drop previews for trackers the current Spec Filter no longer covers. Called
-- from the filter dropdown so the screen re-syncs with the list immediately
-- rather than waiting for the next eye toggle. Only touches out-of-scope
-- trackers — anything still in scope keeps its preview.
function HET:HidePreviewsOutOfSpecScope()
    if InCombatLockdown() then
        -- Same deferral as HideAllPreviews: arm the PLAYER_REGEN_ENABLED
        -- replay rather than dropping the request, or an out-of-scope preview
        -- could outlive the filter change. (Unreachable from the dropdown
        -- today — the panel's controls are combat-locked — but this is a
        -- public HET: method.)
        ns.pendingHideAllPreviews = true
        return
    end
    -- `next(t._elPreview)` first: it is a single table probe, where the filter
    -- predicate is up to four key concats plus a table walk. Nothing on screen
    -- means nothing to hide, so the filter question never needs asking — and
    -- with previews being the rare state, that skips almost every tracker.
    for key, t in pairs(ns.trackers) do
        if next(t._elPreview)
            and not InSpecFilter(t.config.dbPrefix) then
            t:HidePreview()
        end
    end
end

-- COMBAT GUARDS on both preview entry points: preview styling runs
-- through ApplyElementStyle, which touches engine-owned button regions —
-- forbidden to addon code in combat ("Attempt to access forbidden
-- object"). The options UI is also locked in combat (see Options.lua),
-- but these guards make the API itself safe. Hide is DEFERRED, not
-- dropped: the panel's OnHide calls it, and previews left showing when
-- the panel closes mid-combat must still clean up — the combat watcher
-- in Options.lua re-calls it on PLAYER_REGEN_ENABLED (unscoped: hiding
-- everything is a safe superset of any pending subtab hide).
function HET:HideAllPreviews(subtab)
    if InCombatLockdown() then
        ns.pendingHideAllPreviews = true
        return
    end
    for key, t in pairs(ns.trackers) do
        if InSubtab(t, subtab) then t:HidePreview() end
    end
end
-- Show a preview for every previewable element on every ENABLED tracker
-- (mirrors the per-tracker Move/Preview toggle: any element with a style that
-- isn't "sound"). Disabled trackers are skipped — see PreviewableTracker. The
-- per-tracker row eye is deliberately NOT filtered: that is an explicit action
-- on one tracker, and it stays available for positioning something before you
-- enable it.
function HET:ShowAllPreviews(subtab)
    if InCombatLockdown() then return end
    for key, t in pairs(ns.trackers) do
        local els = PreviewableTracker(t, subtab) and t:GetElementsDB()
        if els then
            for i = 1, ns.MAX_ELEMENTS do
                local el = els[i]
                if el and el.style and el.style ~= "sound" then
                    t:ShowElementPreview(i)
                end
            end
        end
    end
end
function HET:IsAnyPreviewActive(subtab)
    for key, t in pairs(ns.trackers) do
        if InSubtab(t, subtab) then
            for i = 1, ns.MAX_ELEMENTS do
                if t:IsElementPreviewActive(i) then
                    return true
                end
            end
        end
    end
    return false
end
-- True only when EVERY previewable element (the same set ShowAllPreviews shows)
-- on every in-scope tracker is currently previewing. An empty set (nothing to
-- preview) returns false. Used by the GLOBAL eye so it only reads "active" when
-- literally all previews are up, not merely some.
function HET:AreAllPreviewsActive(subtab)
    local any = false
    for key, t in pairs(ns.trackers) do
        -- Same set as ShowAllPreviews (enabled only) — see PreviewableTracker.
        local els = PreviewableTracker(t, subtab) and t:GetElementsDB()
        if els then
            for i = 1, ns.MAX_ELEMENTS do
                local el = els[i]
                if el and el.style and el.style ~= "sound" then
                    any = true
                    if not t:IsElementPreviewActive(i) then
                        return false
                    end
                end
            end
        end
    end
    return any
end
