if select(2, ...).IS_LEGACY_CLIENT then return end -- HET-LEGACY-1207: no-op once Legacy1207 is deleted
-------------------------------------------------------------------------------
-- 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,
        },
        profile = profileDefaults,
    }
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

-- 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\\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\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\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.
function HET:UpdateAuraSounds()
    if not (AddAuraSound and RemoveAuraSound) then return end
    if InCombatLockdown() 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
    for i = 1, soundPlanCount, 3 do
        local id = AddAuraSound(TRIGGER_ADDED, {
            unitToken     = "player",
            spellID       = soundPlan[i],
            soundFileName = soundPlan[i + 1],
            outputChannel = soundPlan[i + 2],
        })
        if id then
            registeredSoundIDs[#registeredSoundIDs + 1] = id
        end
    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
end

local function RefreshAll()
    -- 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
            -- 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()
    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_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 only (combat-safe reads).
local idSpellCD, idSpellUsable, idSpellCharge = {}, {}, {}
local idItemCD, idItemUsable = {}, {}
local function WipeIDStateCaches()
    wipe(idSpellCD); wipe(idSpellUsable); wipe(idSpellCharge)
    wipe(idItemCD); wipe(idItemUsable)
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 / bag-count / empower-edge calls are FORCED
-- (fan out unconditionally — counts and charge text aren't in the boolean
-- caches, and the OnCooldownDone tickers call with no args on purpose).
RecheckWatchedIDs = function(event)
    local forced = event == nil
        or event == "SPELL_UPDATE_CHARGES"
        or event == "BAG_UPDATE"
        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
    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.
    if forced or event == "BAG_UPDATE_COOLDOWN"
        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
        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

-------------------------------------------------------------------------------
-- 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)
    -- Migrate legacy alert mode: TTS is gone (no aura-triggered speech
    -- API on 12.1); "voice" plays the bundled clips via AddAuraSound.
    if p.alertMode == "tts" then p.alertMode = "voice" end
    if p.inn_alertMode == "tts" then p.inn_alertMode = "voice" end

    for i = 1, #ns.trackerOrder do
        local cfg = ns.trackerConfigs[ns.trackerOrder[i]]
        local prefix = cfg.dbPrefix
        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

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

    -- 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 would default
    -- true and the group would start tracking again. One-shot flag: the
    -- new keys are individually settable afterwards, so this must never
    -- re-run.
    if not p.ts_perClassSplit then
        p.ts_perClassSplit = true
        local sel = rawget(p, "ts_spells")
        if type(sel) == "table" and sel[375226] == false then
            local cfg = ns.trackerConfigs.timespiral
            local list = cfg and cfg.spellList
            if list then
                for i = 1, #list do sel[list[i].key] = false 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.
    local customs = p.customTrackers
    if type(customs) == "table" then
        for i = 1, #customs do
            local en = customs[i]
            local k = en and en.kind
            if k == "abilitygroup" or k == "itemgroup" then
                local prefix = "cu" .. en.id .. "_"
                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
    end

    -- v8 (PERSONAL DEFENSIVES VOICE): the group 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 would
    -- silently flip existing sound elements off the user's chosen custom
    -- sound onto the Personal clip. One-shot: afterwards Sound Type is
    -- user-settable like any other voice group.
    if not p.pd_voiceOptionsMigrated then
        p.pd_voiceOptionsMigrated = true
        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

-------------------------------------------------------------------------------
-- 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).
function ns.RegisterCustomConfig(id)
    local key = "custom" .. id
    if ns.trackerConfigs[key] then return 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 then
        HET.db:RegisterDefaults(BuildDefaults())
    end
end

-- Register configs for every custom tracker saved in a profile.
local function RegisterSavedCustomConfigs(p)
    if type(p.customTrackers) ~= "table" then return end
    for i = 1, #p.customTrackers do
        local id = p.customTrackers[i].id
        if id then
            ns.RegisterCustomConfig(id)
        end
    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()
    -- 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. Duplicated in Legacy1207/Core.lua for the 12.0.7 path.
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 |cff11ace9Buzzard 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.
    ns.inCombat = InCombatLockdown() 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.
    self:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED", RefreshAllResync)
    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.
    self:RegisterEvent("SPELLS_CHANGED", function()
        -- The KNOWN-spell set changed (learn/unlearn/talent swap): rebuild the
        -- ID watch so unknown-ability trackers drop out of (or rejoin) the
        -- event path, then re-gate visibility.
        HET:UpdateIDReadyWatch()
        HET:RefreshLoadGating()
    end)
    -- 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()
    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
    end)
    self:RegisterEvent("PLAYER_FOCUS_CHANGED", function()
        if ns.RepointUnit then ns.RepointUnit("focus") end
    end)

    if IsLoggedIn() then
        RefreshAllResync()  -- startup baseline: seed sound state, don't replay
    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.
function ns.SubtabForTracker(t)
    if t:IsIDKind() then return "idkinds" end
    if t:IsCustomKind() then return "custom" 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

-- 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 tracker (mirrors the
-- per-tracker Move/Preview toggle: any element with a style that isn't "sound").
function HET:ShowAllPreviews(subtab)
    if InCombatLockdown() then return end
    for key, t in pairs(ns.trackers) do
        local els = InSubtab(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
