-- =============================================================
-- Specs/DruidRestoration.lua
-- Nem: Healer Alerts — Restoration Druid spec module
--
-- FULLY SELF-CONTAINED. This file contains ALL Resto Druid
-- spell IDs, aura scan logic, state management, evaluate logic,
-- event handling, glow evaluation, and options panel additions.
--
-- It has ZERO dependencies on any other spec module. Adding or
-- removing other spec files will never affect this module.
--
-- Registers itself with the core via NHA:RegisterSpec().
--
-- Whitelisted spell IDs (non-secret in Midnight):
--      774 - Rejuv
--     8936 - Regrowth
--    33763 - Lifebloom
--    48438 - Wild Growth
--   155777 - Germination


-- =============================================================

local NHA = _G.NemHealerAlerts
if not NHA then return end  -- core must load first

local pairs, ipairs       = pairs, ipairs
local math_floor          = math.floor
local string_format       = string.format
local GetTime             = GetTime
local UnitExists          = UnitExists
local UnitAffectingCombat = UnitAffectingCombat
local C_UnitAuras         = C_UnitAuras
local issecretvalue       = NHA.issecretvalue

-- =============================================================
-- Spell / Aura IDs
-- =============================================================
local LIFEBLOOM_IDS = {
    [33763]  = true,   -- Lifebloom
}

local REJUV_IDS = {
    [774]    = true,   -- Rejuvenation
    [155777] = true,   -- Germination
}

local SOTF_EMPOWERED_ICONS = {
    [7195165] = true,  -- Rejuvenation (empowered)
    [1033486] = true,  -- Germination  (empowered)
}

local SPELL_SOTF          = 114108
local LB_PANIC_THRESHOLD  = 4.5
local WILD_GROWTH_ID      = 48438
local REFORESTATION_BUFF_ID   = 392360
local REFORESTATION_NODE_ID   = 82069
local REGROWTH_ID         = 8936
local SWIFTMEND_ID        = 18562
local SOTF_CONSUMER_IDS   = { [774]=true, [155777]=true, [8936]=true }
local SOTF_BUFF_DURATION  = 15.0

-- Abundance talent node
local ABUNDANCE_NODE_ID           = 103876 -- verified in live (was 82053)
local ABUNDANCE_BUFF_ID           = 207640 -- Abundance stacking player buff (1 stack per Rejuv/Germination)
local ABUNDANCE_STACK_THRESHOLD   = 7      -- Default threshold; overridden by db.abundanceStackThreshold

-- Wild Growth cooldown modifiers
-- Base 10s, reduced by talents and tier set bonuses.
-- The fallback timer path uses GetWGDuration() which auto-detects
-- Early Spring talent and S1 4pc set, and scales the 4pc reduction
-- correctly for PvP (arenas: -1s) vs PvE (-2s).
--
-- 12.0.5 NOTE: C_Spell.GetSpellCooldown now returns cdInfo.duration as a
-- DurationObject (script object) rather than a plain Lua number. All reads
-- of cdInfo.duration must go through ReadWGDurationObj() which calls
-- DurationObject:GetTotalDuration() and falls back to plain-number addition
-- for any pre-12.0.5 code paths. Additionally, the API now bakes in cooldown
-- aura modifiers (talents, set bonuses) directly into the returned duration,
-- so wgKnownCD will already reflect Early Spring / 4pc reductions on the
-- real-CD path. The M+ fallback still uses the manual calculation via
-- GetWGDuration() → hasEarlySpringTalent / has4pcWGSet flags.
local WG_DURATION_BASE       = 10.0
local WG_EARLY_SPRING_REDUCE =  1.0   -- Hero talent: Early Spring (node 94591)
local WG_4PC_REDUCE_PVE      =  2.0   -- S1 tier 4pc: -2s in PvE
local WG_4PC_REDUCE_PVP      =  1.0   -- S1 tier 4pc: -1s in arenas (PvP scaling)
local EARLY_SPRING_NODE_ID   = 94591  -- "Early Spring" hero talent node
local EARLY_SPRING_ENTRY_ID  = 117895 -- Entry ID for Early Spring (vs Bounteous Bloom 117184)
local EARLY_SPRING_SPELL_ID  = 428937 -- Spell ID used as definitionID fallback check
local WG_4PC_SET_ID          = 1980   -- Sprouts of the Luminous Bloom set ID

-- =============================================================
-- Module State (owned entirely by this module)
-- =============================================================
local state = {
    -- Lifebloom
    lbUnit         = nil,
    lbInstID       = nil,
    lbExpire       = 0,
    lbDuration     = 0,
    lbTimeMod      = 1,
    lbActive       = false,   -- "Active" = in pandemic window
    lbWasActive    = false,
    lbScanPending       = false,
    playerAuraPending   = false,
    rosterUpdatePending = false,
    -- SOTF
    sotfActive      = false,
    sotfWasActive   = false,
    sotfExpire      = 0,
    sotfTimeMod     = 1,
    sotfProcPending = false,
    -- Wild Growth
    wgActive      = false,   -- "Active" = ready to cast
    wgWasReady    = false,
    wgCDTimerSet  = false,
    wgJustCast    = false,
    wgOnRealCD    = false,
    wgInitialized = false,
    wgCastTime    = 0,
    hasEarlySpringTalent = false,  -- Early Spring hero talent (-1s WG CD)
    has4pcWGSet          = false,  -- S1 tier 4pc equipped (-2s PvE / -1s PvP)
    wgKnownCD            = 0,      -- last readable WG duration from the real CD API
    -- Reforestation
    refoActive    = false,
    refoWasActive = false,
    hasRefoTalent  = false,
    swiftmendCount = 0,
    -- Abundance (Rejuv-stack gated Regrowth alert)
    abundanceActive   = false,
    abundanceWasActive= false,
    hasAbundanceTalent= false,
    -- Cast Lifebloom (no LB active in combat)
    castlbActive   = false,
    castlbWasActive= false,
}

-- =============================================================
-- CUF Whole-Frame Glow State
-- =============================================================
-- Maps each tracked CUF (CompactUnitFrame) to a small "safe aura"
-- table when that frame is currently showing a Lifebloom pandemic
-- glow. The OnUpdate ticker (RefreshGlows) walks this set every
-- 0.25s to drive glow-on/glow-off transitions across the pandemic
-- threshold — UNIT_AURA doesn't fire on time progression, so without
-- a ticker the glow couldn't change state mid-duration.
--
-- Frames showing a SOTF-empowered Rejuv/Germ glow do NOT need a
-- ticker entry: their on/off is driven entirely by aura add/remove
-- events, which fire UNIT_AURA naturally.
local cufLBCache = {}    -- frame -> { expirationTime, timeMod, auraInstanceID }
local cufStateMap = {}   -- frame -> "lb" | "sotf" (what's currently glowing)

-- =============================================================
-- Alert Definitions
-- =============================================================
-- Display Y offsets (bottom-to-top: LB, SOTF, WG, Refo)
local REFO_Y       =  46          -- Tree of Life (moved up, swapped with Abundance)
local WG_Y         =  -2
local SOTF_Y       = -30
local SOTF_TIMER_Y = -50
local LB_TEXT_Y    = -76
local LB_TIMER_Y   = -96
-- New alerts share the LB Expiring Y position (mutually exclusive: one fires while the other is silent)
local CAST_LB_Y    = LB_TEXT_Y   -- "Cast Lifebloom" shown in same spot as LB Expiring
local ABUND_Y      = 22          -- Abundance alert (moved down, swapped with Tree of Life)

local ALERTS = {
    {
        key          = "lb",
        label        = "Lifebloom Expiring",
        text         = "* LIFEBLOOM EXPIRING *",
        defaultColor = { r = 0.141, g = 0.631, b = 0.282 },  -- #24A148
        hasTimer     = true,
        timerCycleSec = 4.5,
        timerThresholds = {
            { 2.0, 0.667, 1.0, 0.0 },   -- > 2s: #aaff00
            { 1.0, 1.0, 0.816, 0.0 },    -- > 1s: #ffd000
            { 0,   1.0, 0.4,   0.0 },    -- ≤ 1s: #ff6600
        },
        yOffset      = LB_TEXT_Y,
        timerYOffset = LB_TIMER_Y,
        defaultSound = "Bell",
    },
    {
        key          = "castlb",
        label        = "Cast Lifebloom",
        text         = "* CAST LIFEBLOOM *",
        defaultColor = { r = 0.141, g = 0.631, b = 0.282 },  -- #24A148 (matches LB Expiring)
        hasTimer     = false,
        yOffset      = CAST_LB_Y,
        noTest       = true,   -- hidden during test mode (overlaps LB Expiring)
        noSound      = true,   -- no sound channel for this alert
    },
    {
        key          = "sotf",
        label        = "Soul of the Forest",
        text         = "* SOUL OF THE FOREST *",
        defaultColor = { r = 0.0,   g = 0.898, b = 0.753 },  -- #00E5C0
        hasTimer     = true,
        timerCycleSec = 15.0,
        timerThresholds = {
            { 10.0, 0.667, 1.0, 0.0 },   -- > 10s: #aaff00
            { 5.0,  1.0, 0.816, 0.0 },    -- > 5s:  #ffd000
            { 0,    1.0, 0.4,   0.0 },    -- ≤ 5s:  #ff6600
        },
        yOffset      = SOTF_Y,
        timerYOffset = SOTF_TIMER_Y,
        defaultSound = "Ping",
    },
    {
        key          = "wg",
        label        = "Wild Growth Ready",
        text         = "* WILD GROWTH READY *",
        defaultColor = { r = 0.651, g = 0.886, b = 0.180 },  -- #A6E22E
        hasTimer     = false,
        yOffset      = WG_Y,
        defaultSound = "Redfox",
    },
    {
        key          = "refo",
        label        = "Tree of Life Ready",
        text         = "* TREE: NEXT SWIFTMEND *",
        defaultColor = { r = 0.204, g = 0.596, b = 0.859 },  -- #3498DB
        hasTimer     = false,
        yOffset      = REFO_Y,
        noSound      = true,   -- no sound channel for this alert
    },
    {
        key          = "abundance",
        label        = "Abundance (Cast Regrowth)",
        text         = "* ABUNDANCE: CAST REGROWTH *",
        defaultColor = { r = 1.0, g = 0.820, b = 0.0 },  -- #FFD100
        hasTimer     = false,
        yOffset      = ABUND_Y,
        defaultSound = "Lamp",
        -- NOTE: Gated by Abundance talent — alert will only fire when talent is learned
    },
}

-- =============================================================
-- Saved-Variable Defaults (merged into core db)
-- =============================================================
local DEFAULTS = {
    lbEnabled        = true,
    sotfEnabled      = true,
    lbTimerShown     = true,
    sotfTimerShown   = true,
    lbSoundEnabled      = true,
    lbSoundName         = "Bell",
    sotfSoundEnabled    = true,
    sotfSoundName       = "Ping",
    wgEnabled           = true,
    wgSoundEnabled      = true,
    wgSoundName         = "Redfox",
    refoEnabled         = true,
    castlbEnabled       = true,    -- Alert when no Lifebloom active in combat
    abundanceEnabled    = true,    -- Alert when Rejuv+Germination count reaches threshold
    abundanceSoundEnabled = true,
    abundanceSoundName  = "Lamp",
    abundanceStackThreshold = 7,   -- User-configurable (7–12); alert fires at >= this many stacks
    -- Alert text defaults (nil = use alert.text from ALERTS table)
    lbAlertText         = nil,
    castlbAlertText     = nil,
    sotfAlertText       = nil,
    wgAlertText         = nil,
    refoAlertText       = nil,
    abundanceAlertText  = nil,
    alertColors         = {
        lb        = { r = 0.0,   g = 1.0,   b = 0.400 },  -- #00FF66
        castlb    = { r = 0.0,   g = 1.0,   b = 0.400 },  -- #00FF66
        sotf      = { r = 0.0,   g = 1.0,   b = 0.898 },  -- #00FFE5
        wg        = { r = 0.714, g = 1.0,   b = 0.0   },  -- #B6FF00
        refo      = { r = 0.0,   g = 0.749, b = 1.0   },  -- #00BFFF
        abundance = { r = 1.0,   g = 0.949, b = 0.0   },  -- #FFF200
    },
}

-- =============================================================
-- Utility: Reforestation talent check
-- =============================================================
local function CheckRefoTalent()
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then return false end
    local nodeInfo = C_Traits and C_Traits.GetNodeInfo
                     and C_Traits.GetNodeInfo(configID, REFORESTATION_NODE_ID)
    if not nodeInfo then return false end
    if nodeInfo.ranksPurchased and nodeInfo.ranksPurchased > 0 then return true end
    if nodeInfo.activeRank     and nodeInfo.activeRank > 0     then return true end
    return false
end

-- =============================================================
-- Utility: Abundance talent check
-- =============================================================
local function CheckAbundanceTalent()
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then return false end
    local nodeInfo = C_Traits and C_Traits.GetNodeInfo
                     and C_Traits.GetNodeInfo(configID, ABUNDANCE_NODE_ID)
    if not nodeInfo then return false end
    if nodeInfo.ranksPurchased and nodeInfo.ranksPurchased > 0 then return true end
    if nodeInfo.activeRank     and nodeInfo.activeRank > 0     then return true end
    return false
end

-- =============================================================
-- Utility: Early Spring hero talent check
-- This is a CHOICE NODE (Node 94591) shared with Bounteous Bloom.
-- ranksPurchased > 0 is always true — must verify the ACTIVE entry
-- is Early Spring, not Bounteous Bloom (Entry ID 117184).
-- Mirrors CheckJadeStatueTalent: entryID check first (fast),
-- then definitionID → spellID as fallback.
-- =============================================================
local function CheckEarlySpringTalent()
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then return false end
    local nodeInfo = C_Traits and C_Traits.GetNodeInfo
                     and C_Traits.GetNodeInfo(configID, EARLY_SPRING_NODE_ID)
    if not nodeInfo then return false end
    if not (nodeInfo.ranksPurchased and nodeInfo.ranksPurchased > 0) then return false end
    if nodeInfo.activeEntry then
        -- Primary: direct entry ID match (most reliable)
        if nodeInfo.activeEntry.entryID == EARLY_SPRING_ENTRY_ID then
            return true
        end
        -- Fallback: definition → spell ID (mirrors Monk pattern)
        if nodeInfo.activeEntry.definitionID then
            local defInfo = C_Traits and C_Traits.GetDefinitionInfo
                            and C_Traits.GetDefinitionInfo(nodeInfo.activeEntry.definitionID)
            if defInfo and defInfo.spellID == EARLY_SPRING_SPELL_ID then
                return true
            end
        end
    end
    return false
end

-- =============================================================
-- Utility: S1 tier 4pc set bonus check
-- Primary path: scan equipped armor slots and match item names
-- against "Luminous Bloom" (Sprouts of the Luminous Bloom set).
-- This is independent of set ID mapping, which varies between
-- the tooltip SetID and the internal ID used by GetItemSetInfo.
-- Fallback path: GetItemSetInfo with manual slot count.
-- =============================================================
local function CheckWG4pcSet()
    -- Armor slots: head(1) shoulder(3) chest(5) waist(6) legs(7)
    --              feet(8) wrists(9) hands(10) back(15)
    local ARMOR_SLOTS = { 1, 3, 5, 6, 7, 8, 9, 10, 15 }

    -- Path 1: name scan — works regardless of set ID mapping
    local count = 0
    for _, slot in ipairs(ARMOR_SLOTS) do
        local link = GetInventoryItemLink and GetInventoryItemLink("player", slot)
        if link then
            local ok, name = pcall(GetItemInfo, link)
            if ok and name and name:find("Luminous Bloom", 1, true) then
                count = count + 1
            end
        end
    end
    if count >= 4 then return true end

    -- Path 2: GetItemSetInfo playerHas count
    if GetItemSetInfo then
        local ok, _, _, playerHas = pcall(GetItemSetInfo, WG_4PC_SET_ID)
        if ok and type(playerHas) == "number" and playerHas >= 4 then
            return true
        end
    end

    -- Path 3: GetItemSetInfo item list → manual slot compare
    if GetItemSetInfo then
        local ok, _, setItems = pcall(GetItemSetInfo, WG_4PC_SET_ID)
        if ok and type(setItems) == "table" then
            local setItemIDs = {}
            for _, iID in ipairs(setItems) do
                if type(iID) == "number" then setItemIDs[iID] = true end
            end
            local count2 = 0
            for _, slot in ipairs(ARMOR_SLOTS) do
                local itemID = GetInventoryItemID and GetInventoryItemID("player", slot)
                if itemID and itemID > 0 and setItemIDs[itemID] then
                    count2 = count2 + 1
                end
            end
            if count2 >= 4 then return true end
        end
    end

    return false
end

-- =============================================================
-- Utility: Arena detection for PvP 4pc scaling
-- The S1 4pc gives -2s in PvE but only -1s in arenas.
-- =============================================================
local function IsInArena()
    local ok, inInstance, instanceType = pcall(IsInInstance)
    if not ok then return false end
    return inInstance and instanceType == "arena"
end

-- =============================================================
-- Utility: Read a numeric second-count from a cdInfo.duration value.
-- 12.0.5: C_Spell.GetSpellCooldown may return cdInfo.duration as a
-- DurationObject (script object) rather than a plain Lua number in
-- some contexts. We call :GetTotalDuration() when the object exposes
-- it to unwrap the numeric value.
--
-- IMPORTANT: Plain numbers (including secret numbers) are returned
-- unchanged. Do NOT attempt raw + 0 here — that throws on a secret
-- value and destroys the secret. Callers must pass the return value
-- through issecretvalue() themselves before using it arithmetically.
-- The only job of this function is to unwrap DurationObject tables;
-- everything else passes through as-is.
-- =============================================================
local function ReadWGDurationObj(raw)
    if raw == nil then return 0 end
    -- DurationObject path (type == "table"): unwrap to a plain number
    if type(raw) == "table" then
        if raw.GetTotalDuration then
            local ok, v = pcall(raw.GetTotalDuration, raw)
            if ok and type(v) == "number" then return v end
        end
        -- Fallback: GetEndTime() - GetStartTime()
        if raw.GetEndTime and raw.GetStartTime then
            local okE, e = pcall(raw.GetEndTime, raw)
            local okS, s = pcall(raw.GetStartTime, raw)
            if okE and okS and type(e) == "number" and type(s) == "number" then
                return e - s
            end
        end
        return 0
    end
    -- Plain number (normal or secret): return as-is so issecretvalue()
    -- upstream can inspect it correctly. Never pcall raw+0 here.
    return raw
end

-- =============================================================
-- Utility: Effective Wild Growth cooldown duration
-- Priority: (1) wgKnownCD — real duration stored from a readable
--           API call (ground truth, unaffected by detection gaps).
--           In 12.0.5+ the CD API bakes in aura modifiers itself,
--           so wgKnownCD already reflects Early Spring / 4pc.
--           (2) Calculated from detected talent + set flags.
-- base(10) - EarlySpring(0/1) - 4pc(0/1/2 depending on PvP)
-- =============================================================
local function GetWGDuration()
    -- Ground truth: use the last value the CD API gave us directly.
    -- 12.0.5: this value already has talent/set reductions baked in
    -- by the API, so do NOT subtract them again here.
    if state.wgKnownCD and state.wgKnownCD > 0 then
        return state.wgKnownCD
    end
    -- Calculated fallback when wgKnownCD not yet populated (M+ path
    -- where the CD API is tainted and never populates wgKnownCD).
    local dur = WG_DURATION_BASE
    if state.hasEarlySpringTalent then
        dur = dur - WG_EARLY_SPRING_REDUCE
    end
    if state.has4pcWGSet then
        dur = dur - (IsInArena() and WG_4PC_REDUCE_PVP or WG_4PC_REDUCE_PVE)
    end
    return dur
end

-- =============================================================
-- Taint-Safe Aura Helpers (canonical Monk Mistweaver pattern)
-- =============================================================
-- These helpers are defined ONCE at module level and reused by
-- every aura scanner in this file. Defining them as inner closures
-- inside FindXOnUnit would allocate fresh function objects on every
-- call — at 20 group members × 2 spell IDs × multiple Evaluate()s
-- per second this becomes serious GC pressure. Mirrors MonkMistweaver
-- IsPlayerOrUnknownSource / HasValidInstanceID / AuraSpellIs.
--
-- IMPORTANT: each helper uses the `pcall(staticfn, args...)` form
-- rather than `pcall(function() ... end)`. The closure form allocates
-- a fresh function object on EVERY call (it captures the `aura`/`set`
-- upvalues), which at 4 Hz × ~30 buffs × 5+ units multiplies into
-- thousands of allocations per second and drives encounter CPU above
-- 20%. The static-function form takes args as parameters and never
-- allocates. Same correctness; pcall still catches taint errors when
-- the inner field access throws on secret values.
-- =============================================================

-- Static workers — DO NOT call directly, always invoke via pcall.
-- Each performs the field access that may throw on secret values.
local function _checkPlayerSource(aura)
    return aura.sourceUnit == "player"
end
local function _checkInstanceID(aura)
    return aura.auraInstanceID ~= nil and aura.auraInstanceID ~= 0
end
local function _checkSpellIs(aura, id)
    return aura.spellId and aura.spellId == id
end
local function _checkSpellInSet(aura, set)
    return aura.spellId and set[aura.spellId] ~= nil
end
local function _seenGet(seen, guid)
    return seen[guid]
end
local function _seenSet(seen, guid)
    seen[guid] = true
end

-- True if aura.sourceUnit safely equals "player". When sourceUnit
-- is a secret value (M+/rated PvP), pcall fails — accept as ours
-- since the player is the sole Resto Druid in the group.
local function IsPlayerOrUnknownSource(aura)
    if not aura then return false end
    local ok, result = pcall(_checkPlayerSource, aura)
    if not ok then return true end  -- tainted = accept (sole Resto Druid)
    return result
end

-- True if aura.auraInstanceID exists and isn't 0. Must use ~= nil
-- comparisons rather than a bare boolean test — secret number values
-- throw "attempt to perform boolean test on secret value" otherwise.
local function HasValidInstanceID(aura)
    if not aura then return false end
    local ok, result = pcall(_checkInstanceID, aura)
    if not ok then return true end  -- secret = exists, accept it
    return result
end

-- True if aura.spellId safely equals the given ID.
local function AuraSpellIs(aura, id)
    local ok, result = pcall(_checkSpellIs, aura, id)
    return ok and result
end

-- True if aura.spellId is a key in the given set table.
local function AuraSpellInSet(aura, set)
    local ok, result = pcall(_checkSpellInSet, aura, set)
    return ok and result
end

-- =============================================================
-- Utility: Find Rejuv/Germination on a unit (taint-safe)
-- Two-path scan mirroring MonkMistweaver FindAuraOnUnit:
--   Path 1: GetAuraDataBySpellID (fast)
--   Path 2: GetAuraDataByIndex fallback (follower dungeons etc.)
-- Each target can contribute up to 2 stacks (Rejuv + Germination)
-- to the Abundance count.
-- =============================================================
local function FindRejuvOnUnit(unit, spellId)
    if not UnitExists(unit) then return nil end
    if not C_UnitAuras then return nil end

    -- Path 1: direct spell ID lookup (fast, authoritative)
    if C_UnitAuras.GetAuraDataBySpellID then
        local ok, aura = pcall(C_UnitAuras.GetAuraDataBySpellID,
            unit, spellId, "HELPFUL")
        if ok then
            -- Clean call. Trust whatever we got — Blizzard guarantees
            -- whitelisted IDs (Rejuv 774, Germination 155777) read
            -- back even in M+/rated, so a nil result here means the
            -- unit definitively does NOT have this buff. Falling
            -- through to the index scan in this case is the perf
            -- regression that drove avg=5.24ms / max=41ms — it walks
            -- ~30 helpful auras per unit, and most of their spellIds
            -- are secret values, so every pcall(AuraSpellIs) throws
            -- and stack-unwinds (~50μs/throw vs <1μs clean).
            if aura and HasValidInstanceID(aura)
               and IsPlayerOrUnknownSource(aura) then
                return aura
            end
            return nil
        end
        -- ok == false: pcall caught a thrown error from the API call
        -- itself. THIS is the legitimate fallback case (follower
        -- dungeon edge cases, taint propagation through the API).
        -- Fall through to Path 2.
    end
    -- Path 2: index scan fallback — only on Path 1 failure.
    if not C_UnitAuras.GetAuraDataByIndex then return nil end
    local i = 1
    while true do
        local ok, aura = pcall(C_UnitAuras.GetAuraDataByIndex, unit, i, "HELPFUL")
        if not ok or not aura then break end
        if AuraSpellIs(aura, spellId) and IsPlayerOrUnknownSource(aura) then
            return aura
        end
        i = i + 1
    end
    return nil
end

-- earlyOut: optional threshold; once count meets it we stop scanning.
-- The Abundance alert only cares whether count >= threshold, so once
-- we've hit it there's no reason to keep walking units. Pass 0/nil to
-- get the exact count (debug command path).
local function CountRejuvGerminationStacks(earlyOut)
    local count = 0
    local seen  = {}
    earlyOut = earlyOut or 0
    for _, unit in ipairs(NHA:GetGroupUnits()) do
        local guid = UnitGUID(unit)
        -- UnitGUID returns a SECRET VALUE for hostile units (target,
        -- focus) in tainted execution contexts. Indexing the `seen`
        -- table with a secret key throws "attempted to index a table
        -- that cannot be indexed with secret keys" — that's why the
        -- original code wrapped these in pcall (NOT defensive overkill,
        -- a real protection — apologies for getting that wrong earlier).
        --
        -- issecretvalue() is faster than a thrown pcall for the failure
        -- case (function call vs. stack unwind), so we use it as a
        -- pre-check and just skip dedup for secret-GUID units. Worst
        -- case: target/focus get scanned twice if they appear under
        -- raidN as well — fine, since FindRejuvOnUnit returns nil for
        -- enemies anyway (no Rejuv on the boss) and the duplicate
        -- count would push us toward earlyOut faster, not produce a
        -- wrong answer.
        local canDedup = guid and not issecretvalue(guid)
        if not canDedup or not seen[guid] then
            -- Check each spell ID independently — Rejuv AND Germination
            -- can both be active on the same target (2 stacks)
            for rejuvID in pairs(REJUV_IDS) do
                if FindRejuvOnUnit(unit, rejuvID) then
                    count = count + 1
                    if earlyOut > 0 and count >= earlyOut then
                        return count
                    end
                end
            end
            if canDedup then seen[guid] = true end
        end
    end
    return count
end

-- Throttle state for the abundance group scan.
-- Polling at the full 4 Hz ticker rate is overkill — the count rarely
-- crosses the threshold faster than ~1s of casting. The Abundance
-- alert tells the player "your next Regrowth gets the bonus" — it's
-- a steady-state advisory during heavy raid healing, not a reactive
-- alert with sub-second urgency. 1.0s update cadence is plenty and
-- cuts scan rate by 2.5× vs. the original 0.4s window.
-- Worst-case alert latency: ABUNDANCE_SCAN_PERIOD seconds.
local ABUNDANCE_SCAN_PERIOD = 1.0
local lastAbundanceScan     = 0
local cachedAbundanceFire   = false

local function SyncSwiftmendCount()
    if not C_UnitAuras or not C_UnitAuras.GetAuraDataBySpellID then return end
    local aura = C_UnitAuras.GetAuraDataBySpellID("player", REFORESTATION_BUFF_ID, "HELPFUL")
    if aura and not issecretvalue(aura.applications) then
        state.swiftmendCount = aura.applications or 0
    elseif not aura then
        state.swiftmendCount = 0
    end
end

-- =============================================================
-- LB Tracking State
-- =============================================================
local function StoreLBAura(unit, aura)
    state.lbUnit     = unit
    state.lbInstID   = aura.auraInstanceID
    state.lbExpire   = aura.expirationTime or 0
    state.lbDuration = aura.duration or 0
    state.lbTimeMod  = (aura.timeMod and aura.timeMod > 0) and aura.timeMod or 1
end

local function ClearLBTracking()
    state.lbUnit     = nil
    state.lbInstID   = nil
    state.lbExpire   = 0
    state.lbDuration = 0
    state.lbTimeMod  = 1
end

-- =============================================================
-- Taint-Safe LB Group Scan  (Cast Lifebloom alert)
-- Mirrors MonkMistweaver CountActiveRenewingMist pattern.
-- Uses the module-level helpers IsPlayerOrUnknownSource,
-- HasValidInstanceID, and AuraSpellInSet defined above — do NOT
-- redefine equivalent inner closures here. Allocating a closure
-- per call inside a hot scan loop is the regression that drove
-- encounter CPU above 20% and 4000+ ticks over 5ms.
-- =============================================================
local function FindLBOnUnit(unit)
    if not UnitExists(unit) then return nil end
    if not C_UnitAuras then return nil end
    -- Path 1: direct spell ID lookup (fast, authoritative)
    -- See FindRejuvOnUnit for the rationale on trusting clean nils;
    -- Lifebloom (33763) is on Blizzard's whitelist same as Rejuv.
    --
    -- WoW Lua is 5.1-extended and does NOT support goto/labels, so
    -- we use a needsFallback flag rather than a labeled jump out of
    -- the per-spellId loop. Setting the flag and break-ing exits the
    -- inner loop; the outer if-block then decides whether to return
    -- nil (clean Path 1 result for every spellId) or fall through to
    -- the index scan (Path 1 actually threw on at least one spellId).
    local needsFallback = false
    if C_UnitAuras.GetAuraDataBySpellID then
        for spellId in pairs(LIFEBLOOM_IDS) do
            local ok, aura = pcall(C_UnitAuras.GetAuraDataBySpellID,
                unit, spellId, "HELPFUL")
            if ok then
                if aura and HasValidInstanceID(aura)
                   and IsPlayerOrUnknownSource(aura) then
                    return aura
                end
                -- Clean nil for this spellId — try the next one in
                -- LIFEBLOOM_IDS rather than diving into an index scan.
            else
                -- pcall failed on the API call itself: real fallback
                -- case. Stop the per-spellId loop and proceed to Path 2.
                needsFallback = true
                break
            end
        end
        if not needsFallback then return nil end
    end
    -- Path 2: index scan fallback (only when Path 1 threw)
    if not C_UnitAuras.GetAuraDataByIndex then return nil end
    local i = 1
    while true do
        local ok, aura = pcall(C_UnitAuras.GetAuraDataByIndex, unit, i, "HELPFUL")
        if not ok or not aura then break end
        if AuraSpellInSet(aura, LIFEBLOOM_IDS)
           and IsPlayerOrUnknownSource(aura) then
            return aura
        end
        i = i + 1
    end
    return nil
end

-- FullScanForLB walks the group and returns the first unit with a
-- player-applied Lifebloom. Routes through FindLBOnUnit so the LB
-- pandemic tracking path uses the same taint-safe scanner as the
-- GroupHasLB path — no duplicated logic, no direct field access on
-- secret values. Defined AFTER FindLBOnUnit because Lua locals are
-- resolved at compile time of the enclosing function.
local function FullScanForLB()
    for _, unit in ipairs(NHA:GetGroupUnits()) do
        local aura = FindLBOnUnit(unit)
        if aura then return unit, aura end
    end
    return nil, nil
end

-- Throttle state for FullScanForLB. Block-level perf instrumentation
-- showed the LB pandemic check accounted for 72% of Evaluate cost in
-- 30-man raid (avg 4.4ms/call), because cached-aura validation was
-- failing on most calls and dropping into FullScanForLB every tick.
-- Throttle the scan to LB_FULLSCAN_PERIOD; cast/aura events that
-- signal a real LB application call ResetLBFullScanThrottle() so
-- genuine transitions still get sub-frame detection. The fast cached
-- path (validation succeeds, no scan) is unaffected.
local LB_FULLSCAN_PERIOD = 0.4
local lastLBFullScan     = 0

local function ScanLBOrThrottle()
    -- Returns the (unit, aura) tuple from FullScanForLB if the scan
    -- actually ran. Returns false to signal "throttled — caller must
    -- not touch state.lbUnit / state.lbInstID this tick".
    local now = GetTime()
    if (now - lastLBFullScan) < LB_FULLSCAN_PERIOD then
        return false
    end
    lastLBFullScan = now
    return FullScanForLB()
end

local function ResetLBFullScanThrottle()
    -- Resets BOTH the FullScanForLB throttle (so the next Evaluate is
    -- allowed to scan) and the lbValidatedAt cache (so the next
    -- Evaluate is forced to re-call GetAuraDataByAuraInstanceID
    -- instead of trusting the cached state). Called from cast/aura
    -- event paths that signal a real LB state change.
    lastLBFullScan = 0
    if state then state.lbValidatedAt = nil end
end

local function GroupHasLB()
    -- Secret-GUID protection: see CountRejuvGerminationStacks for the
    -- full rationale. UnitGUID can return secret values for hostile
    -- target/focus units in tainted execution; indexing seen[guid]
    -- with a secret key throws. Use issecretvalue() upfront and skip
    -- dedup for those units. (This function is now only called by the
    -- legacy `/nha abundance` debug path; Evaluate consults
    -- state.lbUnit directly. Keeping it correct & cheap regardless.)
    local seen = {}
    for _, unit in ipairs(NHA:GetGroupUnits()) do
        local guid = UnitGUID(unit)
        local canDedup = guid and not issecretvalue(guid)
        if not canDedup or not seen[guid] then
            if FindLBOnUnit(unit) then return true end
            if canDedup then seen[guid] = true end
        end
    end
    return false
end

-- =============================================================
-- SOTF Detection
-- =============================================================
local function CheckSOTF()
    if not C_UnitAuras then return nil end
    if C_UnitAuras.GetAuraDataBySpellID then
        local aura = C_UnitAuras.GetAuraDataBySpellID("player", SPELL_SOTF, "HELPFUL")
        if aura and aura.auraInstanceID and aura.auraInstanceID ~= 0 then
            return aura
        end
        return nil
    end
    local i = 1
    while true do
        local aura = C_UnitAuras.GetAuraDataByIndex("player", i, "HELPFUL")
        if not aura then break end
        if not issecretvalue(aura.spellId) and aura.spellId == SPELL_SOTF then
            return aura
        end
        i = i + 1
    end
    return nil
end

-- =============================================================
-- MODULE TABLE
-- =============================================================
local DruidResto = {
    class     = "DRUID",
    specIndex = 4,
    specID    = 105, -- fallback: Restoration Druid spec ID for secret-value contexts
    specName  = "Restoration",
    alerts    = ALERTS,
    defaults  = DEFAULTS,
    state     = state,

    extraEvents = {
        "UNIT_AURA",
        "UNIT_SPELLCAST_SUCCEEDED",
        "SPELL_UPDATE_COOLDOWN",
        "PLAYER_EQUIPMENT_CHANGED",
    },

    -- Spell IDs whose UNIT_AURA add/remove on a tracked CUF unit
    -- should trigger an EvaluateCUFGlow refresh. Without this filter
    -- the core listener would refresh on every aura change including
    -- HoT ticks from other classes — most of UNIT_AURA traffic.
    -- Lifebloom + Rejuv + Germination cover both glow conditions
    -- (LB pandemic and SOTF-empowered Rejuv/Germ via icon swap).
    cufRelevantSpells = {
        [33763]  = true,   -- Lifebloom
        [774]    = true,   -- Rejuvenation
        [155777] = true,   -- Germination
    },
}

-- =============================================================
-- ClearAllGlows
-- =============================================================
function DruidResto:ClearAllGlows()
    -- CUF whole-frame glows
    for frame in pairs(cufStateMap) do
        if frame._nhaFrameGlowing then NHA:HideFrameGlow(frame) end
    end
    wipe(cufStateMap)
    wipe(cufLBCache)
end

-- =============================================================
-- ClearAllState
-- =============================================================
function DruidResto:ClearAllState()
    ClearLBTracking()
    state.lbActive           = false
    state.lbWasActive        = false
    state.sotfActive         = false
    state.sotfWasActive      = false
    state.sotfProcPending    = false
    state.sotfExpire         = 0
    state.sotfTimeMod        = 1
    state.wgActive           = false
    state.wgWasReady         = false
    state.wgOnRealCD         = false
    state.wgJustCast         = false
    state.wgCDTimerSet       = false
    state.wgCastTime         = 0
    state.wgKnownCD          = 0
    state.refoActive         = false
    state.refoWasActive      = false
    state.swiftmendCount     = 0
    state.castlbActive       = false
    state.castlbWasActive    = false
    state.abundanceActive    = false
    state.abundanceWasActive = false
    self:ClearAllGlows()
end

-- =============================================================
-- NeedsTicker — keep ticker alive when LB is tracked (for glows)
-- =============================================================
function DruidResto:NeedsTicker(db)
    return state.lbUnit ~= nil
        or (db.castlbEnabled and UnitAffectingCombat("player"))
        or next(cufLBCache) ~= nil
end

-- =============================================================
-- RefreshGlows (called every 0.25s by ticker)
-- =============================================================
-- Drives the LB pandemic threshold crossings on default Blizzard
-- CUFs. The cache is populated by EvaluateCUFGlow whenever a player
-- LB is found on the frame's unit, regardless of pandemic state.
-- Here we drive on/off transitions purely from time progression
-- because UNIT_AURA doesn't fire on time elapsing alone.
function DruidResto:RefreshGlows(db)
    if not db or not db.cufGlowEnabled then return end
    if not next(cufLBCache) then return end

    local now = GetTime()
    local c   = db.alertColors and db.alertColors.lb
                or { r=0.0, g=1.0, b=0.400 }
    for frame, sa in pairs(cufLBCache) do
        local remaining = (sa.expirationTime - now) / sa.timeMod
        if remaining <= 0 then
            if cufStateMap[frame] == "lb" then
                NHA:HideFrameGlow(frame)
                cufStateMap[frame] = nil
            end
            cufLBCache[frame] = nil
        elseif remaining <= LB_PANIC_THRESHOLD then
            -- LB has priority over SOTF — always show LB color in pandemic
            NHA:ShowFrameGlow(frame, c.r, c.g, c.b)
            cufStateMap[frame] = "lb"
        else
            -- Out of pandemic; if LB was glowing, drop back so SOTF
            -- can re-claim or the frame can clear. Re-running the
            -- evaluator handles both cases cleanly.
            if cufStateMap[frame] == "lb" then
                NHA._UpdateCUFFrame(frame)
            end
        end
    end
end

-- =============================================================
-- EvaluateCUFGlow (default Blizzard CompactUnitFrames — 12.0.5
-- whole-frame workaround for forbidden buff icons)
-- =============================================================
-- Driven by core's CUF tracking pipeline (NemHealerAlerts.lua
-- InitCUFHooks). Called for every tracked CUF on UNIT_AURA delta
-- for the unit, on PLAYER_LOGIN/spec change, and via the OnUpdate
-- ticker (RefreshGlows) for LB pandemic threshold crossings.
--
-- Glow conditions, in priority order:
--   1. Lifebloom from player on the unit, in pandemic window
--      → glow frame with LB color (db.alertColors.lb)
--   2. Rejuvenation OR Germination from player on the unit,
--      with the SOTF-empowered icon swap detected
--      → glow frame with SOTF color (db.alertColors.sotf)
--   3. Otherwise → no glow.
--
-- Lifebloom takes priority because the panic threshold is the
-- more time-sensitive signal — missing a refresh costs HoT
-- coverage on the tank, while empowered Rejuv/Germ are
-- informational and persist for their full duration.
--
-- The LB cache (cufLBCache) is written here whenever ANY player
-- LB is found on the unit (not just pandemic-state ones), so the
-- ticker can drive the on/off transition across the threshold
-- without needing another aura event to fire.
-- =============================================================

-- Local lookup helper to find player Rejuv or Germination on a unit
-- and return the aura ONLY if the empowered icon is present (the
-- visual signature of a SOTF-buffed cast).
--
-- Implementation note (12.0.5): we deliberately use GetAuraDataByIndex
-- with the "PLAYER|HELPFUL" filter rather than GetAuraDataBySpellID,
-- because the latter has been observed to return the aura with a
-- non-empowered or secret icon field even when the buff frame visibly
-- displays the empowered icon. The index-scan path returns the aura
-- in the same form Blizzard's own frame paint code reads from, which
-- is the form whose icon field reliably distinguishes empowered casts.
-- This is the same approach LifebloomGlow uses successfully on raid
-- frames in 12.0.5 — see Compatibility.lua / LifebloomGlow.lua refs.
--
-- Filter "PLAYER|HELPFUL" restricts to player-applied buffs, so the
-- IsPlayerOrUnknownSource check is redundant but kept for defense.
-- The scan caps at 40 auras (game max) and breaks on first nil.
local function FindEmpoweredRejuvOnUnit(unit)
    if not UnitExists(unit) or not C_UnitAuras then return nil end
    if not C_UnitAuras.GetAuraDataByIndex then return nil end
    local i = 1
    while i <= 40 do
        local ok, aura = pcall(C_UnitAuras.GetAuraDataByIndex,
            unit, i, "PLAYER|HELPFUL")
        if not ok or not aura then break end
        -- Field reads through pcall — sourceUnit and spellId are
        -- whitelisted now, but defensive in case Blizzard tightens.
        local sidOk, sid = pcall(function() return aura.spellId end)
        -- sid itself can be a secret value (truthy but not indexable).
        -- Wrap the table lookup in its own pcall to avoid the
        -- "cannot index with secret keys" crash.
        local inRejuvIds = false
        if sidOk and sid then
            local idxOk, idxVal = pcall(function() return REJUV_IDS[sid] end)
            inRejuvIds = idxOk and idxVal
        end
        if inRejuvIds
           and HasValidInstanceID(aura)
           and IsPlayerOrUnknownSource(aura) then
            local iconOk, isEmp = pcall(function()
                return SOTF_EMPOWERED_ICONS[aura.icon]
            end)
            if iconOk and isEmp then
                return aura
            end
        end
        i = i + 1
    end
    return nil
end

function DruidResto:EvaluateCUFGlow(db, frame, unit)
    if not db or not db.enabled or not db.cufGlowEnabled then
        if cufStateMap[frame] then
            NHA:HideFrameGlow(frame)
            cufStateMap[frame] = nil
        end
        cufLBCache[frame] = nil
        return
    end

    -- Path 1: Lifebloom (highest priority)
    local lbAura = FindLBOnUnit(unit)
    if lbAura then
        -- Capture safe-number copies for the ticker — expirationTime,
        -- duration, and timeMod can be secret values in M+; reading
        -- them through a +0 pcall normalizes both nil and secret cases.
        local function safeNum(val, fallback)
            if val == nil then return fallback end
            local ok, result = pcall(function() return val + 0 end)
            return ok and result or fallback
        end
        local sa = cufLBCache[frame]
        if not sa then sa = {}; cufLBCache[frame] = sa end
        sa.auraInstanceID = lbAura.auraInstanceID
        sa.expirationTime = safeNum(lbAura.expirationTime, 0)
        sa.duration       = safeNum(lbAura.duration, 0)
        sa.timeMod        = safeNum(lbAura.timeMod, 1)
        if sa.timeMod <= 0 then sa.timeMod = 1 end

        local now       = GetTime()
        local remaining = (sa.expirationTime - now) / sa.timeMod
        if remaining > 0 and remaining <= LB_PANIC_THRESHOLD then
            local c = db.alertColors and db.alertColors.lb
                      or { r=0.0, g=1.0, b=0.400 }
            NHA:ShowFrameGlow(frame, c.r, c.g, c.b)
            cufStateMap[frame] = "lb"
            return
        end
        -- LB present but not in pandemic — fall through to SOTF check.
        -- Cache stays populated so the ticker can flip on at threshold.
    else
        cufLBCache[frame] = nil
    end

    -- Path 2: SOTF-empowered Rejuv / Germination
    local empAura = FindEmpoweredRejuvOnUnit(unit)
    if empAura then
        local sc = db.alertColors and db.alertColors.sotf
                   or { r=0.0, g=1.0, b=0.898 }
        NHA:ShowFrameGlow(frame, sc.r, sc.g, sc.b)
        cufStateMap[frame] = "sotf"
        return
    end

    -- No glow conditions met
    if cufStateMap[frame] then
        NHA:HideFrameGlow(frame)
        cufStateMap[frame] = nil
    end
end

-- =============================================================
-- Per-block perf instrumentation (gated by state._perf.blocksEnabled)
-- =============================================================
-- Allows /nha perf to break Evaluate's cost down by sub-block so we
-- can localize the hot path instead of guessing. The OFF cost is one
-- table lookup + nil check per checkpoint (~10ns). The ON cost is a
-- debugprofilestop() + table update per checkpoint (~5μs each), which
-- at 6 checkpoints × 10 Evaluates/s is 0.3ms/s — invisible against
-- the 50ms/s of Evaluate work we're trying to attribute.
--
-- Block boundaries are placed AFTER each logical chunk of Evaluate
-- so the blocks named in the report correspond directly to the code
-- comments above them: LB / SOTF / WG / MISC / ABUND / END.
local _perfBlockTimer = 0

local function PerfBeginBlocks()
    local p = state._perf
    if not (p and p.blocksEnabled) then return end
    _perfBlockTimer = debugprofilestop()
end

local function PerfMarkBlock(name)
    local p = state._perf
    if not (p and p.blocksEnabled) then return end
    local now = debugprofilestop()
    local dt  = now - _perfBlockTimer
    local b   = p.blocks[name]
    if not b then
        b = { calls = 0, totalMs = 0, maxMs = 0 }
        p.blocks[name] = b
    end
    b.calls   = b.calls + 1
    b.totalMs = b.totalMs + dt
    if dt > b.maxMs then b.maxMs = dt end
    _perfBlockTimer = now
end

-- =============================================================
-- Evaluate (called by core ticker and events)
-- =============================================================
function DruidResto:Evaluate(db)
    PerfBeginBlocks()
    -- ── Lifebloom pandemic check ─────────────────────────────
    -- Validation cache: skip the per-tick GetAuraDataByAuraInstanceID
    -- call when LB was validated <0.25s ago AND the cached expiration
    -- is comfortably (>5s) in the future. The state.lbExpire timer is
    -- already maintained, and the API call here only re-confirms it.
    -- Cast events (refresh) and the LB-target UNIT_AURA path both
    -- call ResetLBFullScanThrottle which clears state.lbValidatedAt,
    -- so genuine state changes still force a re-validation on the
    -- next tick. Worst-case staleness: 0.25s. Worst-case alert delay
    -- for the pandemic threshold (4s remaining): 0.25s — invisible.
    --
    -- Validation must be taint-safe: aura.sourceUnit goes secret in
    -- raid contexts where the player has triggered tainted execution
    -- (chat input, certain UI actions). Use IsPlayerOrUnknownSource
    -- — same canonical helper as every other source check in the file.
    if state.lbUnit and state.lbInstID then
        local now = GetTime()
        if state.lbValidatedAt
           and (now - state.lbValidatedAt) < 0.25
           and state.lbExpire and state.lbExpire > (now + 5) then
            -- Cached state is healthy and not approaching expiry.
            -- Trust it; lbActive is recomputed below from state.lbExpire.
        else
            local aura = C_UnitAuras.GetAuraDataByAuraInstanceID(
                state.lbUnit, state.lbInstID)
            if aura and not issecretvalue(aura.spellId)
                and LIFEBLOOM_IDS[aura.spellId]
                and IsPlayerOrUnknownSource(aura) then
                StoreLBAura(state.lbUnit, aura)
                state.lbValidatedAt = now
            else
                local u, a = ScanLBOrThrottle()
                if u and a then
                    StoreLBAura(u, a)
                    state.lbValidatedAt = now
                elseif u == nil and a == nil then
                    -- Real scan happened, found nothing → clear tracking
                    ClearLBTracking()
                    state.lbValidatedAt = nil
                end
                -- u == false: throttled, leave state alone
            end
        end
    else
        local u, a = ScanLBOrThrottle()
        if u and a then
            StoreLBAura(u, a)
            state.lbValidatedAt = GetTime()
        elseif u == nil and a == nil then
            ClearLBTracking()
            state.lbValidatedAt = nil
        end
    end

    local lbPanic = false
    if state.lbUnit then
        local remaining = (state.lbExpire - GetTime()) / state.lbTimeMod
        if remaining > 0 then
            if db.lbEnabled then
                lbPanic = remaining <= LB_PANIC_THRESHOLD
            end
        else
            ClearLBTracking()
        end
    end
    state.lbActive = lbPanic
    -- Expose for OnUpdate timer
    state.lbExpire_  = state.lbExpire
    state.lbTimeMod_ = state.lbTimeMod
    PerfMarkBlock("LB")

    -- ── SOTF check ───────────────────────────────────────────
    local sotfActive = false
    if db.sotfEnabled then
        local sotfAura = CheckSOTF()
        if sotfAura then
            sotfActive = true
            state.sotfProcPending = false
            state.sotfExpire  = sotfAura.expirationTime or 0
            state.sotfTimeMod = (sotfAura.timeMod and sotfAura.timeMod > 0)
                                and sotfAura.timeMod or 1
        elseif state.sotfProcPending then
            if state.sotfExpire > 0 and GetTime() > state.sotfExpire then
                state.sotfProcPending = false
            else
                sotfActive = true
            end
        else
            state.sotfExpire  = 0
            state.sotfTimeMod = 1
        end
    end
    state.sotfActive = sotfActive
    if not sotfActive then
        state.sotfExpire  = 0
        state.sotfTimeMod = 1
    end
    PerfMarkBlock("SOTF")

    -- ── Wild Growth cooldown check ────────────────────────────
    -- GetWGDuration() returns the effective CD for the M+ fallback path.
    -- 12.0.5: cdInfo.duration is now a DurationObject, not a plain number.
    -- ReadWGDurationObj() safely extracts the numeric value from either form.
    -- The API also now bakes in cooldown aura modifiers (Early Spring, 4pc)
    -- directly into the returned duration, so wgKnownCD = dur is already the
    -- talent-adjusted value — no manual subtraction needed on that path.
    local wgFallbackDur = GetWGDuration()
    local wgReady = false
    if db.wgEnabled and UnitAffectingCombat("player") then
        local cdInfo = C_Spell.GetSpellCooldown(WILD_GROWTH_ID)
        local start  = cdInfo and cdInfo.startTime or 0
        -- 12.0.5: duration may be a DurationObject — extract via helper
        local dur    = ReadWGDurationObj(cdInfo and cdInfo.duration)
        if issecretvalue(start) or issecretvalue(dur) then
            if state.wgJustCast then
                wgReady = false; state.wgJustCast = false; state.wgOnRealCD = true
            elseif state.wgOnRealCD then
                if state.wgCastTime > 0
                   and (GetTime() - state.wgCastTime) >= wgFallbackDur then
                    wgReady = true
                else
                    wgReady = false
                end
            else
                wgReady = true
            end
        elseif state.wgJustCast then
            wgReady = false; state.wgJustCast = false; state.wgOnRealCD = true
        elseif dur == 0 or (start > 0 and dur > 0 and dur <= 1.5) then
            wgReady = true
        elseif start > 0 and dur > 1.5 then
            state.wgOnRealCD = true
            -- 12.0.5: dur already reflects Early Spring / 4pc reductions from
            -- the API — store as-is. GetWGDuration() will return this value
            -- directly on the fallback path without subtracting again.
            state.wgKnownCD  = dur
            local remaining = (start + dur) - GetTime()
            if remaining > 0 and not state.wgCDTimerSet then
                state.wgCDTimerSet = true
                C_Timer.After(remaining, function()
                    state.wgCDTimerSet = false
                    NHA.ScheduleEvaluate()
                end)
            end
        end
    end
    state.wgActive = wgReady
    PerfMarkBlock("WG")

    -- ── Reforestation check ────────────────────────────────────
    state.refoActive = db.refoEnabled and state.hasRefoTalent
                       and (state.swiftmendCount == 3)

    -- ── Cast Lifebloom (no Lifebloom active in combat) ────────
    -- The LB pandemic check at the top of Evaluate() has already
    -- run FullScanForLB and populated state.lbUnit (or cleared it
    -- via ClearLBTracking() when no group member has player-applied
    -- LB). state.lbUnit == nil is therefore EXACTLY the answer
    -- GroupHasLB() returns — no need to scan the group a second time.
    -- This was the single biggest CPU sink in combat: every 0.25s
    -- ticker tick was paying for two full group LB scans instead of
    -- one. Mistweaver doesn't have this duplication for the same
    -- reason — it consults its tracking state directly.
    local inCombat = UnitAffectingCombat("player")
    local castLbFire = false
    if db.castlbEnabled and inCombat then
        castLbFire = (state.lbUnit == nil)
    end
    state.castlbActive = castLbFire
    PerfMarkBlock("MISC")

    -- ── Abundance: Rejuv+Germination group count → Cast Regrowth ─
    -- Uses whitelisted spell IDs (774, 155777) — both are non-secret
    -- in M+/rated per Blizzard's Midnight healer buff whitelist.
    --
    -- Throttled to ABUNDANCE_SCAN_PERIOD (0.4s) — the full ticker
    -- rate is faster than the alert needs, and the scan walks every
    -- helpful aura on every group member when no Rejuv is up yet.
    -- earlyOut=threshold so the inner loop bails the moment we've
    -- proven the threshold is met.
    local abundanceFire = false
    if db.abundanceEnabled and state.hasAbundanceTalent and inCombat then
        local now = GetTime()
        if (now - lastAbundanceScan) >= ABUNDANCE_SCAN_PERIOD then
            local threshold = db.abundanceStackThreshold or ABUNDANCE_STACK_THRESHOLD
            cachedAbundanceFire = CountRejuvGerminationStacks(threshold) >= threshold
            lastAbundanceScan = now
        end
        abundanceFire = cachedAbundanceFire
    else
        cachedAbundanceFire = false
        lastAbundanceScan   = 0
    end
    state.abundanceActive = abundanceFire
    PerfMarkBlock("ABUND")

    -- ── Rising-edge sounds ───────────────────────────────────
    if lbPanic    and not state.lbWasActive   then NHA:PlayAlertSound("lb")        end
    if sotfActive and not state.sotfWasActive then NHA:PlayAlertSound("sotf")      end
    if wgReady and not state.wgWasReady
       and state.wgOnRealCD and state.wgInitialized then
        NHA:PlayAlertSound("wg"); state.wgOnRealCD = false
    end
    if not wgReady then state.wgInitialized = true end
    if state.abundanceActive and not state.abundanceWasActive then NHA:PlayAlertSound("abundance") end
    state.lbWasActive        = lbPanic
    state.sotfWasActive      = sotfActive
    state.wgWasReady         = wgReady
    state.refoWasActive      = state.refoActive
    state.castlbWasActive    = state.castlbActive
    state.abundanceWasActive = state.abundanceActive
    PerfMarkBlock("END")
end

-- =============================================================
-- OnActivate
-- =============================================================
function DruidResto:OnActivate(db)
    state.hasRefoTalent          = CheckRefoTalent()
    state.hasAbundanceTalent     = CheckAbundanceTalent()
    state.hasEarlySpringTalent   = CheckEarlySpringTalent()
    state.has4pcWGSet            = CheckWG4pcSet()
    SyncSwiftmendCount()
    -- Opportunistically pre-populate wgKnownCD if WG is already on
    -- cooldown at login/reload (e.g. player reloaded mid-dungeon shortly
    -- after casting). Falls back to GetWGDuration() from talent+set
    -- detection if WG is off CD (duration = 0).
    -- 12.0.5: cdInfo.duration is a DurationObject — use ReadWGDurationObj().
    C_Timer.After(0.3, function()
        if not C_Spell or not C_Spell.GetSpellCooldown then return end
        local cdInfo = C_Spell.GetSpellCooldown(WILD_GROWTH_ID)
        if not cdInfo then return end
        local dur = ReadWGDurationObj(cdInfo.duration)
        -- Must check issecretvalue() before any arithmetic comparison;
        -- ReadWGDurationObj() passes secret numbers through unchanged.
        if dur and not issecretvalue(dur) and dur > 1.5 then
            state.wgKnownCD = dur
        end
    end)
    -- Populate raid-frame glows immediately rather than waiting for the
    -- next UNIT_AURA. CUFs may already be tracked at this point if the
    -- player just changed spec mid-group; without this nudge the glows
    -- would only appear after the first relevant aura change.
    if NHA.RefreshAllCUFs then
        C_Timer.After(0.1, function()
            NHA:RefreshAllCUFs()
        end)
    end
end

-- =============================================================
-- OnDeactivate
-- =============================================================
function DruidResto:OnDeactivate(db)
    -- State is cleared by core calling ClearAllState + ClearAllGlows
end

-- =============================================================
-- OnEvent
-- =============================================================
function DruidResto:OnEvent(db, event, ...)
    if event == "UNIT_AURA" then
        local unit, updateInfo = ...

        -- LB target
        if unit == state.lbUnit then
            if not updateInfo or updateInfo.isFullUpdate then
                NHA.ScheduleEvaluate(); return true
            end
            if updateInfo.removedAuraInstanceIDs and state.lbInstID then
                for _, instID in ipairs(updateInfo.removedAuraInstanceIDs) do
                    if instID == state.lbInstID then
                        ClearLBTracking()
                        NHA.ScheduleEvaluate()
                        return true
                    end
                end
            end
            if updateInfo.updatedAuraInstanceIDs and state.lbInstID then
                for _, instID in ipairs(updateInfo.updatedAuraInstanceIDs) do
                    if instID == state.lbInstID then
                        NHA.ScheduleEvaluate(); return true
                    end
                end
            end
            if updateInfo.addedAuras then
                for _, aura in ipairs(updateInfo.addedAuras) do
                    local sid = aura.spellId
                    if sid and not issecretvalue(sid)
                        and LIFEBLOOM_IDS[sid]
                        and IsPlayerOrUnknownSource(aura) then
                        NHA.ScheduleEvaluate(); return true
                    end
                end
            end
            return true  -- handled, no default ScheduleEvaluate
        end

        -- Player (SOTF buff)
        -- Debounce window must NOT be shorter than the ticker (0.25s)
        -- — at 100ms in M+ combat the constant aura churn (HoT ticks,
        -- debuffs, buff procs) re-arms this every time it fires,
        -- doubling the effective Evaluate rate (measured 8.8/s with
        -- a 100ms window vs. the expected 4/s from the ticker alone).
        -- The cast-event path already drives Evaluate when SOTF is
        -- procced or consumed, so this UNIT_AURA path only catches
        -- natural buff expiry — a 250ms detection lag is invisible
        -- given SOTF lasts 15s and the ticker hits it at the same rate.
        if unit == "player" then
            if not updateInfo or updateInfo.isFullUpdate
               or updateInfo.addedAuras or updateInfo.removedAuraInstanceIDs then
                if not state.playerAuraPending then
                    state.playerAuraPending = true
                    C_Timer.After(0.25, function()
                        state.playerAuraPending = false
                        NHA.Evaluate()
                    end)
                end
            end
            return true
        end

        -- Other units: LB not yet tracked
        if not state.lbUnit and not state.lbScanPending then
            if not updateInfo or updateInfo.isFullUpdate or updateInfo.addedAuras then
                state.lbScanPending = true
                ResetLBFullScanThrottle()  -- bypass throttle for the queued scan
                C_Timer.After(0.1, function()
                    state.lbScanPending = false
                    NHA.Evaluate()
                end)
            end
        end
        return true
    end

    if event == "SPELL_UPDATE_COOLDOWN" then
        if db.wgEnabled then NHA.ScheduleEvaluate() end
        return true
    end

    if event == "UNIT_SPELLCAST_SUCCEEDED" then
        local unit, _, spellID = ...
        if unit == "player" then
            if spellID == WILD_GROWTH_ID then
                state.wgJustCast = true
                state.wgActive   = false
                state.wgCastTime = GetTime()
                -- 12.0.5: Immediately try to read the real CD duration from the
                -- API right after the cast so wgKnownCD is up-to-date before the
                -- C_Timer fires. The API now returns a DurationObject for duration
                -- and bakes in talent/set reductions, so use ReadWGDurationObj().
                if C_Spell and C_Spell.GetSpellCooldown then
                    local cdInfo = C_Spell.GetSpellCooldown(WILD_GROWTH_ID)
                    if cdInfo then
                        local d = ReadWGDurationObj(cdInfo.duration)
                        -- Defensive: under tainted execution context, the
                        -- normal `not issecretvalue(d) and d > 1.5` guard
                        -- has been observed to leak through and throw a
                        -- secret-value comparison error (user-reported,
                        -- 23x burst). Wrap the comparison itself in pcall
                        -- so a secret value can't crash the OnEvent path.
                        if d and not issecretvalue(d) then
                            local cmpOk, cmpResult = pcall(function()
                                return d > 1.5
                            end)
                            if cmpOk and cmpResult then
                                state.wgKnownCD = d
                            end
                        end
                    end
                end
                if not state.wgCDTimerSet then
                    state.wgCDTimerSet = true
                    C_Timer.After(GetWGDuration(), function()
                        state.wgCDTimerSet = false
                        NHA.ScheduleEvaluate()
                    end)
                end
                NHA.ScheduleEvaluate()
            end
            -- Lifebloom cast: reset the FullScanForLB throttle so the
            -- next Evaluate is allowed to scan immediately. Without
            -- this, casting LB on a new tank could wait up to
            -- LB_FULLSCAN_PERIOD before the alert system catches up.
            -- We don't know which target was hit (UNIT_SPELLCAST_*
            -- only gives the caster), so the next Evaluate's scan
            -- discovers the target — same path that already runs.
            if LIFEBLOOM_IDS[spellID] then
                ResetLBFullScanThrottle()
                NHA.ScheduleEvaluate()
            end
            if spellID == SWIFTMEND_ID then
                state.swiftmendCount = state.swiftmendCount + 1
                if state.swiftmendCount >= 4 then
                    state.swiftmendCount = 0
                    state.refoActive = false
                end
                state.sotfProcPending = true
                state.sotfExpire  = GetTime() + SOTF_BUFF_DURATION
                state.sotfTimeMod = 1
                C_Timer.After(0.1, NHA.Evaluate)
                C_Timer.After(0.3, NHA.Evaluate)
            elseif SOTF_CONSUMER_IDS[spellID] then
                -- A SOTF consumer (Rejuv, Germination, or Regrowth) was
                -- cast — clear SOTF state. With the empowered-Regrowth
                -- icon glow gone in 12.0.20 there's no detection scan
                -- to schedule; the consumer simply ends the SOTF window.
                state.sotfProcPending = false
                state.sotfActive      = false
                state.sotfExpire      = 0
                state.sotfWasActive   = false
                NHA.ScheduleEvaluate()
            end
        end
        return true
    end

    -- Talent change events
    if event == "PLAYER_TALENT_UPDATE"
    or event == "PLAYER_SPECIALIZATION_CHANGED"
    or event == "TRAIT_CONFIG_UPDATED" then
        state.hasRefoTalent          = false
        state.hasAbundanceTalent     = false
        state.hasEarlySpringTalent   = false
        state.wgKnownCD              = 0     -- force re-read after talent change
        state.swiftmendCount = 0
        state.refoActive     = false
        C_Timer.After(0.5, function()
            state.hasRefoTalent          = CheckRefoTalent()
            state.hasAbundanceTalent     = CheckAbundanceTalent()
            state.hasEarlySpringTalent   = CheckEarlySpringTalent()
            SyncSwiftmendCount()
            NHA.ScheduleEvaluate()
        end)
        return false  -- let core handle spec switch detection too
    end

    if event == "PLAYER_ENTERING_WORLD" then
        state.sotfProcPending        = false
        state.hasRefoTalent          = CheckRefoTalent()
        state.hasAbundanceTalent     = CheckAbundanceTalent()
        state.hasEarlySpringTalent   = CheckEarlySpringTalent()
        state.has4pcWGSet            = CheckWG4pcSet()
        SyncSwiftmendCount()
        return false  -- let core handle
    end

    if event == "PLAYER_REGEN_DISABLED" then
        if state.sotfActive and state.sotfExpire > GetTime() then
            state.sotfProcPending = true
        end
        return false
    end

    if event == "PLAYER_REGEN_ENABLED" then
        state.castlbActive = false
        SyncSwiftmendCount()
        return false
    end

    if event == "GROUP_ROSTER_UPDATE" then
        if not state.rosterUpdatePending then
            state.rosterUpdatePending = true
            C_Timer.After(0.5, function()
                state.rosterUpdatePending = false
                NHA.Evaluate()
            end)
        end
        return true  -- suppress core's default ScheduleEvaluate (we debounce)
    end

    if event == "PLAYER_EQUIPMENT_CHANGED" then
        -- Re-check 4pc set bonus whenever gear changes
        state.has4pcWGSet = CheckWG4pcSet()
        NHA.ScheduleEvaluate()
        return false
    end

    return false
end

-- =============================================================
-- BuildAlertOptions (spec-specific options in the Alert Options section)
-- =============================================================
function DruidResto:BuildAlertOptions(parent, db, helpers)
    -- Single row: LB Timer (left) | SOTF Timer + Abundance Count (right)
    helpers.MakeCheckboxWithNumeric(
        "LB Timer",
        function() return db.lbTimerShown end,
        function(v)
            db.lbTimerShown = v and true or false
            if not db.lbTimerShown then
                local f = NHA.display
                if f then
                    for _, row in ipairs(f.alertRows) do
                        if row.key == "lb" and row.timerFS then
                            row.timerFS:Hide()
                        end
                    end
                end
            end
        end,
        "SOTF Timer",
        function() return db.sotfTimerShown end,
        function(v)
            db.sotfTimerShown = v and true or false
            if not db.sotfTimerShown then
                local f = NHA.display
                if f then
                    for _, row in ipairs(f.alertRows) do
                        if row.key == "sotf" and row.timerFS then
                            row.timerFS:Hide()
                        end
                    end
                end
            end
        end,
        "Abundance Count",
        7, 12,
        function() return db.abundanceStackThreshold or 7 end,
        function(v)
            db.abundanceStackThreshold = v
            NHA.ScheduleEvaluate()
        end)
end

-- =============================================================
-- Slash Commands (spec-specific)
-- =============================================================
DruidResto.slashCommands = {
    ["debug"] = function(self, db)
        NHA.Msg("|cffffd700Resto Druid Debug:|r")
        NHA.Msg("  enabled: "       .. tostring(db.enabled))
        NHA.Msg("  cufGlowEnabled: " .. tostring(db.cufGlowEnabled))
        NHA.Msg("  lbEnabled: "     .. tostring(db.lbEnabled))
        NHA.Msg("  sotfEnabled: "   .. tostring(db.sotfEnabled))
        NHA.Msg("  wgEnabled: "     .. tostring(db.wgEnabled))
        NHA.Msg("  refoEnabled: "   .. tostring(db.refoEnabled))
        NHA.Msg("  castlbEnabled: " .. tostring(db.castlbEnabled))
        NHA.Msg("  abundanceEnabled: " .. tostring(db.abundanceEnabled))
        -- Live state
        NHA.Msg("  lbUnit: "          .. tostring(state.lbUnit))
        if state.lbUnit and state.lbExpire > 0 then
            local remaining = (state.lbExpire - GetTime()) / state.lbTimeMod
            NHA.Msg("  lbRemaining: " .. string.format("%.1f", remaining) .. "s")
        end
        NHA.Msg("  sotfActive: "      .. tostring(state.sotfActive))
        NHA.Msg("  wgActive: "        .. tostring(state.wgActive))
        NHA.Msg("  refoActive: "      .. tostring(state.refoActive))
        NHA.Msg("  castlbActive: "    .. tostring(state.castlbActive))
        NHA.Msg("  abundanceActive: " .. tostring(state.abundanceActive))
        NHA.Msg("  hasRefoTalent: "        .. tostring(state.hasRefoTalent))
        NHA.Msg("  hasAbundanceTalent: "   .. tostring(state.hasAbundanceTalent))
        NHA.Msg("  hasEarlySpringTalent: " .. tostring(state.hasEarlySpringTalent))
        NHA.Msg("  has4pcWGSet: "          .. tostring(state.has4pcWGSet))
    end,

    ["abundance"] = function(self, db)
        NHA.Msg("|cffffd700Abundance Debug:|r")
        NHA.Msg("  abundanceEnabled: "     .. tostring(db.abundanceEnabled))
        NHA.Msg("  hasAbundanceTalent: "   .. tostring(state.hasAbundanceTalent))
        NHA.Msg("  abundanceActive: "      .. tostring(state.abundanceActive))
        NHA.Msg("  inCombat (API): "       .. tostring(UnitAffectingCombat("player")))
        NHA.Msg("  threshold (db): "       .. tostring(db.abundanceStackThreshold or 7))
        NHA.Msg("  CheckAbundanceTalent() live: " .. tostring(CheckAbundanceTalent()))
        NHA.Msg("  CountRejuvGerminationStacks(): " .. tostring(CountRejuvGerminationStacks(0)))
    end,

    -- /nha cufglow — diagnose raid-frame whole-frame glow state
    ["cufglow"] = function(self, db)
        NHA.Msg("|cffffd700CUF Frame Glow Debug:|r")
        NHA.Msg("  cufGlowEnabled: " .. tostring(db.cufGlowEnabled))
        NHA.Msg("  cufHooks: "       .. tostring(NHA._cufHooksInstalled and "installed" or "not installed"))
        local trackedCount = 0
        for _ in pairs(NHA._cufPool) do trackedCount = trackedCount + 1 end
        local lbCacheCount = 0
        for _ in pairs(cufLBCache) do lbCacheCount = lbCacheCount + 1 end
        local stateLBCount, stateSOTFCount = 0, 0
        for _, kind in pairs(cufStateMap) do
            if kind == "lb"   then stateLBCount   = stateLBCount   + 1 end
            if kind == "sotf" then stateSOTFCount = stateSOTFCount + 1 end
        end
        NHA.Msg("  tracked CUFs: "        .. trackedCount)
        NHA.Msg("  cufLBCache size: "    .. lbCacheCount)
        NHA.Msg("  glowing (LB): "       .. stateLBCount)
        NHA.Msg("  glowing (SOTF): "     .. stateSOTFCount)

        -- ── Step 1: enumerate _unitCUFs table — what unit tokens are
        -- actually being tracked, and what real units do they resolve to?
        NHA.Msg("|cffffd700  Tracked _unitCUFs entries:|r")
        local entries = 0
        for unit, set in pairs(NHA._unitCUFs) do
            local n = 0
            for _ in pairs(set) do n = n + 1 end
            local nameOk, name = pcall(UnitName, unit)
            local existsOk, exists = pcall(UnitExists, unit)
            NHA.Msg(string_format(
                "  %s -> %d frame(s)  name=%s  exists=%s",
                tostring(unit),
                n,
                (nameOk and tostring(name)) or "?",
                tostring(existsOk and exists)))
            entries = entries + 1
            if entries >= 12 then
                NHA.Msg("  ... (truncated)")
                break
            end
        end

        -- ── Step 2: full helpful-aura index dump for each tracked unit.
        -- Tells us authoritatively what auras the API thinks are on the
        -- unit, regardless of filter quirks. Matches LifebloomGlow's
        -- detection method (GetAuraDataByIndex + PLAYER|HELPFUL filter).
        NHA.Msg("|cffffd700  Per-unit helpful aura scan (PLAYER|HELPFUL):|r")
        local probedUnits = 0
        for unit in pairs(NHA._unitCUFs) do
            if probedUnits >= 5 then break end
            local existsOk, exists = pcall(UnitExists, unit)
            if existsOk and exists then
                local nameOk, name = pcall(UnitName, unit)
                NHA.Msg(string_format("  %s (%s):",
                    unit,
                    (nameOk and tostring(name)) or "?"))
                local i = 1
                local printed = 0
                while i <= 40 and printed < 6 do
                    local aok, aura = pcall(
                        C_UnitAuras.GetAuraDataByIndex,
                        unit, i, "PLAYER|HELPFUL")
                    if not aok or not aura then break end
                    -- Read each field through pcall — secret values throw.
                    local sidOk, sid = pcall(function() return aura.spellId end)
                    local nameOk2, an = pcall(function() return aura.name end)
                    local iconOk, icon = pcall(function() return aura.icon end)
                    local srcOk, src = pcall(function() return aura.sourceUnit end)
                    NHA.Msg(string_format(
                        "    [%d] sid=%s name=%s icon=%s src=%s",
                        i,
                        (sidOk  and tostring(sid))   or "<secret>",
                        (nameOk2 and tostring(an))   or "<secret>",
                        (iconOk and tostring(icon))  or "<secret>",
                        (srcOk  and tostring(src))   or "<secret>"))
                    printed = printed + 1
                    i = i + 1
                end
                if printed == 0 then
                    NHA.Msg("    (no PLAYER|HELPFUL auras)")
                end
                probedUnits = probedUnits + 1
            end
        end

        -- ── Step 3: targeted lookup comparison for Rejuv (774).
        -- Tries multiple filter forms and both API entry points to
        -- isolate whether the issue is filter syntax, API choice, or
        -- something else entirely.
        NHA.Msg("|cffffd700  Rejuv (774) lookup probe:|r")
        local probed = 0
        for unit in pairs(NHA._unitCUFs) do
            if probed >= 5 then break end
            local existsOk, exists = pcall(UnitExists, unit)
            if existsOk and exists then
                local function tryLookup(filter)
                    local ok, aura = pcall(C_UnitAuras.GetAuraDataBySpellID,
                        unit, 774, filter)
                    if not ok then return "<threw>" end
                    if not aura then return "nil" end
                    local instOk, instID = pcall(function() return aura.auraInstanceID end)
                    local iconOk, icon = pcall(function() return aura.icon end)
                    return string_format("inst=%s icon=%s",
                        (instOk and tostring(instID)) or "<secret>",
                        (iconOk and tostring(icon))   or "<secret>")
                end
                NHA.Msg(string_format("  %s:", unit))
                NHA.Msg("    BySpellID HELPFUL:        " .. tryLookup("HELPFUL"))
                NHA.Msg("    BySpellID PLAYER|HELPFUL: " .. tryLookup("PLAYER|HELPFUL"))
                NHA.Msg("    BySpellID nil filter:     " .. tryLookup(nil))
                probed = probed + 1
            end
        end
    end,

    -- /nha wgdebug — diagnose Wild Growth timer state and 12.0.5 DurationObject
    ["wgdebug"] = function(self, db)
        NHA.Msg("|cffffd700Wild Growth Timer Debug (12.0.5):|r")
        NHA.Msg("  wgEnabled: "            .. tostring(db.wgEnabled))
        NHA.Msg("  hasEarlySpringTalent: " .. tostring(state.hasEarlySpringTalent))
        NHA.Msg("  has4pcWGSet: "          .. tostring(state.has4pcWGSet))
        NHA.Msg("  wgKnownCD: "            .. tostring(state.wgKnownCD))
        NHA.Msg("  GetWGDuration(): "      .. tostring(GetWGDuration()))
        NHA.Msg("  wgOnRealCD: "           .. tostring(state.wgOnRealCD))
        NHA.Msg("  wgCDTimerSet: "         .. tostring(state.wgCDTimerSet))
        NHA.Msg("  wgCastTime: "           .. string.format("%.2f", state.wgCastTime))
        NHA.Msg("  CheckEarlySpringTalent() live: " .. tostring(CheckEarlySpringTalent()))
        -- Probe raw API output to verify DurationObject vs plain number
        if C_Spell and C_Spell.GetSpellCooldown then
            local cdInfo = C_Spell.GetSpellCooldown(WILD_GROWTH_ID)
            if cdInfo then
                local rawDur = cdInfo.duration
                local rawStart = cdInfo.startTime
                NHA.Msg("  cdInfo.startTime type: " .. type(rawStart)
                    .. " secret=" .. tostring(issecretvalue(rawStart)))
                NHA.Msg("  cdInfo.duration type: "  .. type(rawDur))
                if type(rawDur) == "table" then
                    NHA.Msg("  duration is DurationObject (12.0.5+)")
                    NHA.Msg("    GetTotalDuration: "
                        .. tostring(rawDur.GetTotalDuration ~= nil))
                    if rawDur.GetTotalDuration then
                        local ok, v = pcall(rawDur.GetTotalDuration, rawDur)
                        NHA.Msg("    :GetTotalDuration() = " .. (ok and tostring(v) or "ERROR: "..tostring(v)))
                    end
                else
                    -- Plain number — show value or secret status
                    NHA.Msg("  duration plain number secret="
                        .. tostring(issecretvalue(rawDur)))
                    if not issecretvalue(rawDur) then
                        NHA.Msg("  duration value: " .. tostring(rawDur))
                    end
                end
                NHA.Msg("  ReadWGDurationObj() = " .. tostring(ReadWGDurationObj(rawDur))
                    .. " secret=" .. tostring(issecretvalue(ReadWGDurationObj(rawDur))))
            else
                NHA.Msg("  GetSpellCooldown returned nil")
            end
        else
            NHA.Msg("  C_Spell.GetSpellCooldown not available")
        end
        -- Check Early Spring node directly
        local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                         and C_ClassTalents.GetActiveConfigID()
        NHA.Msg("  configID: " .. tostring(configID))
        if configID and C_Traits and C_Traits.GetNodeInfo then
            local ni = C_Traits.GetNodeInfo(configID, EARLY_SPRING_NODE_ID)
            if ni then
                NHA.Msg("  Node " .. EARLY_SPRING_NODE_ID
                    .. " ranksPurchased=" .. tostring(ni.ranksPurchased)
                    .. " activeEntry=" .. tostring(ni.activeEntry
                        and ni.activeEntry.entryID or "nil"))
            else
                NHA.Msg("  Node " .. EARLY_SPRING_NODE_ID .. " = nil (NODE ID MAY HAVE CHANGED IN 12.0.5)")
            end
        end
    end,

    -- =========================================================
    -- /nha perf — wraps Evaluate with debugprofilestop() timing.
    -- The core dispatches slash commands by exact-string match on
    -- the trimmed input, so each sub-command is its own key:
    --   /nha perf on   → start timing
    --   /nha perf      → mid-fight peek (no reset)
    --   /nha perf off  → stop and dump final stats
    --
    -- Wrapping is external (monkey-patched on DruidResto.Evaluate)
    -- so it can be enabled/disabled without /reload, and the OFF
    -- cost is exactly zero — the original Evaluate is restored byref.
    --
    -- Reading the output:
    --   avg > 1ms       → spec scans are hot (drill into them next)
    --   avg < 0.3ms but Encounter CPU still high
    --                   → cost is OUTSIDE Evaluate (event handlers,
    --                     OnUpdate paint loop, glow scans). Use
    --                     `/console scriptProfile 1; /reload` then
    --                     GetEventCPUUsage / GetFrameCPUUsage.
    -- =========================================================
    ["perf on"] = function(self, db)
        local perf = state._perf
        if not perf then
            perf = { enabled = false }
            state._perf = perf
        end
        if perf.enabled then NHA.Msg("|cffffd700NHA Perf:|r already on"); return end
        perf.enabled       = true
        perf.calls         = 0
        perf.totalMs       = 0
        perf.maxMs         = 0
        perf.startMs       = debugprofilestop()
        perf.origEvaluate  = DruidResto.Evaluate
        -- Per-block instrumentation — gated by blocksEnabled flag
        -- read inside Evaluate at each PerfMarkBlock checkpoint.
        perf.blocksEnabled = true
        perf.blocks        = {}
        DruidResto.Evaluate = function(slf, sdb)
            local t0 = debugprofilestop()
            perf.origEvaluate(slf, sdb)
            local dt = debugprofilestop() - t0
            perf.calls   = perf.calls + 1
            perf.totalMs = perf.totalMs + dt
            if dt > perf.maxMs then perf.maxMs = dt end
        end
        NHA.Msg("|cff00ff00NHA Perf: ENABLED|r — wrap on Evaluate + per-block timing. Run a pull, then /nha perf off")
    end,

    ["perf off"] = function(self, db)
        local perf = state._perf
        if not perf or not perf.enabled then
            NHA.Msg("|cffffd700NHA Perf:|r already off"); return
        end
        DruidResto.Evaluate  = perf.origEvaluate
        perf.origEvaluate    = nil
        perf.enabled         = false
        perf.blocksEnabled   = false
        if perf.calls == 0 then
            NHA.Msg("|cffffd700NHA Perf FINAL:|r no samples")
        else
            local elapsed = (debugprofilestop() - perf.startMs) / 1000
            local rate    = elapsed > 0 and (perf.calls / elapsed) or 0
            local avg     = perf.totalMs / perf.calls
            NHA.Msg(string.format(
                "|cffffd700NHA Perf FINAL:|r %.1fs, %d calls (%.1f/s), avg=%.3fms max=%.3fms total=%.1fms",
                elapsed, perf.calls, rate, avg, perf.maxMs, perf.totalMs))
            -- Block breakdown — fixed display order so reads are stable
            local order = { "LB", "SOTF", "WG", "MISC", "ABUND", "END" }
            for _, name in ipairs(order) do
                local b = perf.blocks and perf.blocks[name]
                if b and b.calls > 0 then
                    NHA.Msg(string.format(
                        "  %-6s avg=%.3fms max=%.3fms total=%.1fms (%d calls)",
                        name, b.totalMs / b.calls, b.maxMs, b.totalMs, b.calls))
                end
            end
        end
    end,

    ["perf"] = function(self, db)
        local perf = state._perf
        if not perf or perf.calls == 0 then
            NHA.Msg("|cffffd700NHA Perf:|r " ..
                ((perf and perf.enabled) and "RUNNING — no samples yet"
                                          or "off (use /nha perf on)"))
            return
        end
        local elapsed = (debugprofilestop() - perf.startMs) / 1000
        local rate    = elapsed > 0 and (perf.calls / elapsed) or 0
        local avg     = perf.totalMs / perf.calls
        NHA.Msg(string.format(
            "|cffffd700NHA Perf %s:|r %.1fs, %d calls (%.1f/s), avg=%.3fms max=%.3fms total=%.1fms",
            perf.enabled and "RUNNING" or "STOPPED",
            elapsed, perf.calls, rate, avg, perf.maxMs, perf.totalMs))
    end,
}

-- =============================================================
-- Register with core
-- =============================================================
NHA:RegisterSpec("DRUID_RESTO", DruidResto)
