-- =============================================================
-- Specs/DruidRestoration.lua
-- Nem: Healer Alerts — Restoration Druid spec module
-- =============================================================

local NHA = _G.NemHealerAlerts
if not NHA then return end

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

-- =============================================================
-- Spell / Aura IDs
-- =============================================================
local LIFEBLOOM_IDS = {
    [33763]  = true,   -- SpellID; Node 82049, Entry 103100 (live-verified 2026-05-27)
}
local LIFEBLOOM_NODE_ID   = 82049  -- TalentID
local LIFEBLOOM_ENTRY_ID  = 103100 -- EntryID

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

local SOTF_EMPOWERED_ICONS = {
    [7195165] = true,  -- IconID
    [1033486] = true,  -- IconID
}

local SPELL_SOTF              = 114108  -- SpellID, buff aura (not whitelisted); talent def = 158478 (live-verified 2026-05-27)
local SOTF_NODE_ID            = 82055   -- TalentID
local SOTF_ENTRY_ID           = 103109  -- EntryID
local SOTF_TALENT_SPELL_ID    = 158478  -- SpellID (talent definition; distinct from buff aura 114108)
local LB_PANIC_THRESHOLD      = 4.5
local TALENT_REPROBE_DELAY    = 0.5
local WILD_GROWTH_ID          = 48438   -- SpellID; Node 82205, Entry 103283 (live-verified 2026-05-27)
local WILD_GROWTH_NODE_ID     = 82205   -- TalentID
local WILD_GROWTH_ENTRY_ID    = 103283  -- EntryID
local REFORESTATION_BUFF_ID   = 392360  -- SpellID, buff aura; talent def = 392356 (live-verified 2026-05-27)
local REFORESTATION_NODE_ID   = 82069   -- TalentID
local REFORESTATION_ENTRY_ID  = 103125  -- EntryID
local REFORESTATION_TALENT_SPELL_ID = 392356  -- SpellID (talent definition; distinct from buff aura 392360)
local REGROWTH_ID             = 8936    -- SpellID
local SWIFTMEND_ID            = 18562   -- SpellID; Node 82047, Entry 103098 (live-verified 2026-05-27)
local SWIFTMEND_NODE_ID       = 82047   -- TalentID
local SWIFTMEND_ENTRY_ID      = 103098  -- EntryID
local SOTF_CONSUMER_IDS       = { [774]=true, [155777]=true, [8936]=true }
local SOTF_BUFF_DURATION      = 15.0

local ABUNDANCE_NODE_ID           = 103876  -- TalentID; buff not scannable (secret value — not applied as a visible player buff)
local ABUNDANCE_ENTRY_ID          = 128277  -- EntryID (live-verified 2026-05-27)
local ABUNDANCE_SPELL_ID          = 207383  -- SpellID (live-verified 2026-05-27)
local ABUNDANCE_STACK_THRESHOLD   = 7

local WG_DURATION_BASE       = 10.0
local WG_EARLY_SPRING_REDUCE =  1.0
-- SEASON-BOUND constants for WG 4pc. Each new season, read the 4pc tooltip
-- and update if it reduces WG CD: WG_4PC_REDUCE_PVE/PVP (below),
-- WG_4PC_SET_ID (a few lines down), and the "Luminous Bloom" literal in
-- CheckWG4pcSet's name-match path. If the new 4pc doesn't touch WG, leave
-- alone — has4pcWGSet stays false, GetWGDuration falls through.
local WG_4PC_REDUCE_PVE      =  2.0
local WG_4PC_REDUCE_PVP      =  1.0
-- Hero subtree IDs (live-verified 2026-05-27 via C_ClassTalents.GetActiveHeroTalentSpec())
local KEEPER_OF_THE_GROVE_SUBTREE_ID = 23  -- Keeper of the Grove hero tree
local WILDSTALKER_SUBTREE_ID         = 22  -- Wildstalker hero tree
local EARLY_SPRING_NODE_ID   = 94591    -- TalentID (Keeper of the Grove HERO talent)
local EARLY_SPRING_ENTRY_ID  = 117895   -- EntryID
local EARLY_SPRING_SPELL_ID  = 428937   -- SpellID
local WG_4PC_SET_ID          = 1980     -- SetID

local SWIFTMEND_BASE_CD_SEC          = 15.0
local SWIFTMEND_EARLY_SPRING_RED     = 1.0
local SWIFTMEND_RENEWING_SURGE_BASE_RED = 2.05
local SWIFTMEND_DEFAULT_MAX_CHGS     = 1
local SW_CAST_SUPPRESS               = 0.5
local PROSPERITY_NODE_ID             = 82079   -- TalentID
local PROSPERITY_ENTRY_ID            = 103136  -- EntryID
local PROSPERITY_SPELL_ID            = 200383  -- SpellID
local RENEWING_SURGE_NODE_ID         = 82060   -- TalentID
local RENEWING_SURGE_ENTRY_ID        = 103114  -- EntryID
local RENEWING_SURGE_SPELL_ID        = 470562  -- SpellID

-- =============================================================
-- Module State
-- =============================================================
local state = {
    lbUnit         = nil,
    lbGUID         = nil,
    lbInstID       = nil,
    lbExpire       = 0,
    lbDuration     = 0,
    lbTimeMod      = 1,
    lbActive       = false,
    lbWasActive    = false,
    lbScanPending  = false,
    playerAuraPending   = false,
    rosterUpdatePending = false,
    sotfActive      = false,
    sotfWasActive   = false,
    sotfExpire      = 0,
    sotfTimeMod     = 1,
    sotfProcPending = false,
    sotfInstID      = nil,
    sotfEmpoweredWindowExpire = 0,
    wgActive      = false,
    wgWasReady    = false,
    wgCDTimerSet  = false,
    wgJustCast    = false,
    wgOnRealCD    = false,
    wgInitialized = false,
    wgCastTime    = 0,
    wgLogEnabled       = false,
    wgLogLastCastAt    = 0,
    wgLogLastPredicted = 0,
    hasEarlySpringTalent = false,
    has4pcWGSet          = false,
    wgKnownCD            = 0,
    refoActive    = false,
    refoWasActive = false,
    hasRefoTalent  = false,
    swiftmendCount = 0,
    swiftmendActive     = false,
    swWasReady          = false,
    swCurCharges           = SWIFTMEND_DEFAULT_MAX_CHGS,
    swMaxCharges           = SWIFTMEND_DEFAULT_MAX_CHGS,
    swChargeTimerSet       = false,
    swChargeReadyAt        = 0,
    swChargeGen            = 0,
    swLastCastAt           = 0,
    swSuppressUntil        = 0,
    swKnownCD              = 0,
    hasProsperityTalent    = false,
    hasRenewingSurgeTalent = false,
    abundanceActive    = false,
    abundanceWasActive = false,
    hasAbundanceTalent = false,
    castlbActive   = false,
    castlbWasActive= false,
}

-- =============================================================
-- CUF Whole-Frame Glow State
-- =============================================================
local cufLBCache     = {}
local cufSOTFCache   = {}
local cufStateMap    = {}
local dirtyCUFFrames = {}
local drainScheduled = false

local SOTF_CACHE_TTL = 0.3

-- =============================================================
-- Alert Definitions
-- =============================================================
local REFO_Y      =  50
local ABUND_Y     =  25
local WG_Y        =   0
local SWIFTMEND_Y = -25
local SOTF_Y      = -50
local LB_TEXT_Y   = -75
local CAST_LB_Y   = LB_TEXT_Y

local ALERTS = {
    {
        key          = "lb",
        label        = "Lifebloom Expiring",
        text         = "* LIFEBLOOM EXPIRING *",
        defaultColor = { r = 0.141, g = 0.631, b = 0.282 },  -- #24A148
        hasTimer     = false,
        yOffset      = LB_TEXT_Y,
        defaultSound = "Druid: Lifebloom Expiring",
        priority     = NHA.VOICE_PRIO_CRITICAL,
    },
    {
        key          = "castlb",
        label        = "Cast Lifebloom",
        text         = "* CAST LIFEBLOOM *",
        defaultColor = { r = 0.141, g = 0.631, b = 0.282 },  -- #24A148
        hasTimer     = false,
        yOffset      = CAST_LB_Y,
        noTest       = true,
        defaultSound = "Druid: Lifebloom Missing",
        priority     = NHA.VOICE_PRIO_CRITICAL,
    },
    {
        key          = "sotf",
        label        = "Soul of the Forest",
        text         = "* SOUL OF THE FOREST *",
        defaultColor = { r = 0.0,   g = 0.898, b = 0.753 },  -- #00E5C0
        hasTimer     = false,
        yOffset      = SOTF_Y,
        defaultSound = "Druid: Soul of the Forest",
        priority     = NHA.VOICE_PRIO_NORMAL,
    },
    {
        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 = "Druid: Wild Growth",
        priority     = NHA.VOICE_PRIO_LOW,
    },
    {
        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,
        defaultSound = "Druid: Tree of Life",
        priority     = NHA.VOICE_PRIO_NORMAL,
    },
    {
        key          = "swiftmend",
        label        = "Swiftmend Ready",
        text         = "* SWIFTMEND READY *",
        defaultColor = { r = 0.282, g = 0.396, b = 0.510 },  -- #485F82 blued gunmetal
        hasTimer     = false,
        yOffset      = SWIFTMEND_Y,
        defaultSound = "Druid: Swiftmend",
        priority     = NHA.VOICE_PRIO_HIGH,
    },
    {
        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 = "Druid: Abundance",
        priority     = NHA.VOICE_PRIO_LOW,
    },
}

-- =============================================================
-- Saved-Variable Defaults
-- =============================================================
local DEFAULTS = {
    lbEnabled           = true,
    sotfEnabled         = true,
    lbSoundEnabled      = true,
    lbSoundName         = "Druid: Lifebloom Expiring",
    sotfSoundEnabled    = true,
    sotfSoundName       = "Druid: Soul of the Forest",
    wgEnabled           = true,
    wgSoundEnabled      = true,
    wgSoundName         = "Druid: Wild Growth",
    refoEnabled         = true,
    refoSoundEnabled    = true,
    refoSoundName       = "Druid: Tree of Life",
    castlbEnabled       = true,
    castlbSoundEnabled  = true,
    castlbSoundName     = "Druid: Lifebloom Missing",
    swiftmendEnabled        = true,
    swiftmendSoundEnabled   = true,
    swiftmendSoundName      = "Druid: Swiftmend",
    abundanceEnabled    = true,
    abundanceSoundEnabled = true,
    abundanceSoundName  = "Druid: Abundance",
    abundanceStackThreshold = 7,
    lbAlertText         = nil,
    castlbAlertText     = nil,
    sotfAlertText       = nil,
    wgAlertText         = nil,
    refoAlertText       = nil,
    swiftmendAlertText  = nil,
    abundanceAlertText  = nil,
    alertColors         = {
        lb        = { r = 0.0,   g = 1.0,   b = 0.400 },
        castlb    = { r = 0.0,   g = 1.0,   b = 0.400 },
        sotf      = { r = 0.0,   g = 1.0,   b = 0.898 },
        wg        = { r = 0.714, g = 1.0,   b = 0.0   },
        refo      = { r = 0.0,   g = 0.749, b = 1.0   },
        swiftmend = { r = 0.439, g = 0.580, b = 0.722 },  -- #7094B8 lighter blued for live
        abundance = { r = 1.0,   g = 0.949, b = 0.0   },
    },
}

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

local function CheckAbundanceTalent()
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then return false end
    local ni = C_Traits and C_Traits.GetNodeInfo
               and C_Traits.GetNodeInfo(configID, ABUNDANCE_NODE_ID)
    if not ni then return false end
    if ni.ranksPurchased and ni.ranksPurchased > 0 then return true end
    if ni.activeRank     and ni.activeRank > 0     then return true end
    return false
end

local function CheckEarlySpringTalent()
    if not (C_ClassTalents and C_ClassTalents.GetActiveHeroTalentSpec) then return false end
    local ok, activeHero = pcall(C_ClassTalents.GetActiveHeroTalentSpec)
    if not (ok and activeHero == KEEPER_OF_THE_GROVE_SUBTREE_ID) then return false end
    local configID = C_ClassTalents.GetActiveConfigID and C_ClassTalents.GetActiveConfigID()
    if not configID then return false end
    local ni = C_Traits and C_Traits.GetNodeInfo
               and C_Traits.GetNodeInfo(configID, EARLY_SPRING_NODE_ID)
    if not ni then return false end
    if not (ni.activeRank and ni.activeRank > 0) then return false end
    if ni.activeEntry then
        if ni.activeEntry.entryID == EARLY_SPRING_ENTRY_ID then return true end
        if ni.activeEntry.definitionID then
            local def = C_Traits and C_Traits.GetDefinitionInfo
                        and C_Traits.GetDefinitionInfo(ni.activeEntry.definitionID)
            if def and def.spellID == EARLY_SPRING_SPELL_ID then return true end
        end
    end
    return false
end

local function CheckProsperityTalent()
    if IsPlayerSpell then
        local ok, has = pcall(IsPlayerSpell, PROSPERITY_SPELL_ID)
        if ok and has == true then return true end
    end
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then return false end
    local ni = C_Traits and C_Traits.GetNodeInfo
               and C_Traits.GetNodeInfo(configID, PROSPERITY_NODE_ID)
    if not ni then return false end
    if not (ni.ranksPurchased and ni.ranksPurchased > 0) then return false end
    if ni.activeEntry then
        if ni.activeEntry.entryID == PROSPERITY_ENTRY_ID then return true end
        if ni.activeEntry.definitionID then
            local def = C_Traits and C_Traits.GetDefinitionInfo
                        and C_Traits.GetDefinitionInfo(ni.activeEntry.definitionID)
            if def and def.spellID == PROSPERITY_SPELL_ID then return true end
        end
    end
    return false
end

local function CheckRenewingSurgeTalent()
    if IsPlayerSpell then
        local ok, has = pcall(IsPlayerSpell, RENEWING_SURGE_SPELL_ID)
        if ok and has == true then return true end
    end
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then return false end
    local ni = C_Traits and C_Traits.GetNodeInfo
               and C_Traits.GetNodeInfo(configID, RENEWING_SURGE_NODE_ID)
    if not ni then return false end
    if not (ni.ranksPurchased and ni.ranksPurchased > 0) then return false end
    if ni.activeEntry then
        if ni.activeEntry.entryID == RENEWING_SURGE_ENTRY_ID then return true end
        if ni.activeEntry.definitionID then
            local def = C_Traits and C_Traits.GetDefinitionInfo
                        and C_Traits.GetDefinitionInfo(ni.activeEntry.definitionID)
            if def and def.spellID == RENEWING_SURGE_SPELL_ID then return true end
        end
    end
    return false
end

local function CheckWG4pcSet()
    local ARMOR_SLOTS = { 1, 3, 5, 6, 7, 8, 9, 10, 15 }
    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
    if GetItemSetInfo then
        -- Legacy GetItemSetInfo's return signature is undocumented in canonical_functions.csv;
        -- the 4th-return "playerHas" assumption was demoted to ⚠️ Unverified and removed.
        -- Iterate setItems against equipped IDs instead — that path doesn't rely on return position.
        local ok2, _, setItems = pcall(GetItemSetInfo, WG_4PC_SET_ID)
        if ok2 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
-- =============================================================
local function IsInArena()
    local ok, inInstance, instanceType = pcall(IsInInstance)
    if not ok then return false end
    return inInstance and instanceType == "arena"
end

-- =============================================================
-- Utility: DurationObject Unwrap
-- =============================================================
local function ReadWGDurationObj(raw)
    if raw == nil then return 0 end
    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
        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
    return raw
end

-- =============================================================
-- Utility: Effective Wild Growth Cooldown
-- =============================================================
local function GetWGDuration()
    if state.wgKnownCD and state.wgKnownCD > 0 then
        return state.wgKnownCD
    end
    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

-- =============================================================
-- Utility: Effective Swiftmend Cooldown
-- =============================================================
local function GetSwiftmendCooldownDuration()
    local result
    if C_Spell and C_Spell.GetSpellCharges then
        local ok, info = pcall(C_Spell.GetSpellCharges, SWIFTMEND_ID)
        if ok and info and info.cooldownDuration ~= nil
           and not issecretvalue(info.cooldownDuration)
           and type(info.cooldownDuration) == "number"
           and info.cooldownDuration > 1.5 then
            state.swKnownCD = info.cooldownDuration
            result = info.cooldownDuration
        end
    end
    if not result and C_Spell and C_Spell.GetSpellCooldown then
        local ok, info = pcall(C_Spell.GetSpellCooldown, SWIFTMEND_ID)
        if ok and info then
            local d = ReadWGDurationObj(info.duration)
            if type(d) == "number" and not issecretvalue(d) and d > 1.5 then
                state.swKnownCD = d
                result = d
            end
        end
    end
    if not result and state.swKnownCD and state.swKnownCD > 0 then
        result = state.swKnownCD
    end
    if not result then
        local base = SWIFTMEND_BASE_CD_SEC
        if state.hasEarlySpringTalent then
            base = base - SWIFTMEND_EARLY_SPRING_RED
        end
        result = base
    end
    if state.hasRenewingSurgeTalent then
        result = result - SWIFTMEND_RENEWING_SURGE_BASE_RED
    end
    return result
end

local function GetSwiftmendEffectiveMaxCharges()
    local apiMax    = state.swMaxCharges or SWIFTMEND_DEFAULT_MAX_CHGS
    local talentMax = state.hasProsperityTalent and 2 or 1
    if apiMax >= talentMax then return apiMax end
    return talentMax
end

local _OnSwChargeRecover
local _ArmSwChargeRecover

-- Arm recover timer with generation counter. Stale callbacks no-op if gen
-- changed (cast handler bumps gen to invalidate races at predicted-ready instant).
_ArmSwChargeRecover = function(cdDur, readyAtOverride)
    state.swChargeTimerSet = true
    state.swChargeReadyAt  = readyAtOverride or (GetTime() + cdDur)
    state.swChargeGen      = (state.swChargeGen or 0) + 1
    local myGen = state.swChargeGen
    C_Timer.After(cdDur, function()
        if state.swChargeGen ~= myGen then return end
        _OnSwChargeRecover()
    end)
end

_OnSwChargeRecover = function()
    -- Cast-suppression: if a cast just landed, the server may not have credited
    -- the new CD yet. Defer briefly so we don't false-ready right after a cast.
    local now = GetTime()
    local since = now - (state.swLastCastAt or 0)
    if since < SW_CAST_SUPPRESS then
        local myGen = state.swChargeGen
        C_Timer.After(SW_CAST_SUPPRESS - since + 0.05, function()
            if state.swChargeGen ~= myGen then return end
            _OnSwChargeRecover()
        end)
        return
    end
    state.swChargeTimerSet = false
    state.swChargeReadyAt  = 0
    local cur = state.swCurCharges or 0
    local max = GetSwiftmendEffectiveMaxCharges()
    if cur < max then
        state.swCurCharges = cur + 1
    end
    if (state.swCurCharges or 0) < max then
        _ArmSwChargeRecover(GetSwiftmendCooldownDuration())
    end
    NHA.ScheduleEvaluate()
end

local function TryInitSwChargesFromAPI()
    if not (C_Spell and C_Spell.GetSpellCharges) then return end
    local ok, info = pcall(C_Spell.GetSpellCharges, SWIFTMEND_ID)
    if not ok or not info then return end

    if info.maxCharges ~= nil and not issecretvalue(info.maxCharges)
       and type(info.maxCharges) == "number" and info.maxCharges > 0 then
        state.swMaxCharges = info.maxCharges
    end

    local maxC = state.swMaxCharges or SWIFTMEND_DEFAULT_MAX_CHGS

    if info.currentCharges ~= nil and not issecretvalue(info.currentCharges)
       and type(info.currentCharges) == "number" then
        -- Hard suppression: refuse to raise charges during post-cast window.
        local suppressed = (state.swSuppressUntil or 0) > GetTime()
        if not (suppressed and info.currentCharges > (state.swCurCharges or 0)) then
            state.swCurCharges = info.currentCharges
        end

        if info.currentCharges < maxC
           and info.cooldownStart    ~= nil and not issecretvalue(info.cooldownStart)
           and info.cooldownDuration ~= nil and not issecretvalue(info.cooldownDuration) then
            local startT = info.cooldownStart
            local durT   = info.cooldownDuration
            if type(startT) == "number" and type(durT) == "number"
               and startT > 0 and durT > 0 then
                if durT > 1.5 then state.swKnownCD = durT end
                local remaining = (startT + durT) - GetTime()
                if remaining > 0 and not state.swChargeTimerSet then
                    _ArmSwChargeRecover(remaining, startT + durT)
                end
            end
        end
    elseif state.swCurCharges == nil then
        state.swCurCharges = maxC
    end
end

-- =============================================================
-- Taint-Safe Static Workers
-- =============================================================
local function _toNumber(val)         return val + 0                        end
local function _checkPlayerSource(aura)
    local s = aura.sourceUnit
    return s == "player" or s == nil or s == ""
end
local function _readSourceUnit(aura)  return aura.sourceUnit              end
local function _greaterThan1_5(v)     return v > 1.5                        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 _getSpellId(aura)       return aura.spellId            end
local function _rejuvIdLookup(sid)     return REJUV_IDS[sid]          end
local function _getIcon(aura)          return aura.icon               end
local function _empIconLookup(icon)    return SOTF_EMPOWERED_ICONS[icon] end
local function _instIDsMatch(a, b)     return a == b                   end
local function _getExpiration(aura)    return aura.expirationTime     end
local function _getInstanceID(aura)    return aura.auraInstanceID     end

local function SafeNum(val, fallback)
    if val == nil then return fallback end
    local ok, result = pcall(_toNumber, val)
    return ok and result or fallback
end

local function IsPlayerOrUnknownSource(aura)
    if not aura then return false end
    local ok, result = pcall(_checkPlayerSource, aura)
    if not ok then return true end
    return result
end

local function HasValidInstanceID(aura)
    if not aura then return false end
    local ok, result = pcall(_checkInstanceID, aura)
    if not ok then return true end
    return result
end

local function AuraSpellIs(aura, id)
    local ok, result = pcall(_checkSpellIs, aura, id)
    return ok and result
end

local function AuraSpellInSet(aura, set)
    local ok, result = pcall(_checkSpellInSet, aura, set)
    return ok and result
end

-- =============================================================
-- FindRejuvOnUnit
-- =============================================================
local function FindRejuvOnUnit(unit, spellId)
    if not UnitExists(unit) then return nil end
    if not C_UnitAuras then return nil end
    if C_UnitAuras.GetAuraDataBySpellID then
        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
            return nil
        end
    end
    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

local function CountRejuvGerminationStacks(earlyOut)
    local count = 0
    local seen  = {}
    earlyOut = earlyOut or 0
    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
            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

-- =============================================================
-- Abundance Count Cache
-- =============================================================
local rejuvPresence      = {}
local abundanceCachedCount = 0

local function UpdateRejuvPresenceForUnit(unit, updateInfo)
    local had = rejuvPresence[unit] or 0

    if not updateInfo or updateInfo.isFullUpdate then
        local count = 0
        for rejuvID in pairs(REJUV_IDS) do
            if FindRejuvOnUnit(unit, rejuvID) then count = count + 1 end
        end
        if count ~= had then
            abundanceCachedCount = abundanceCachedCount + (count - had)
            rejuvPresence[unit]  = count > 0 and count or nil
        end
        return
    end

    local touchedRejuv = false
    if updateInfo.addedAuras then
        for _, aura in ipairs(updateInfo.addedAuras) do
            if not issecretvalue(aura.spellId)
               and REJUV_IDS[aura.spellId]
               and IsPlayerOrUnknownSource(aura) then
                touchedRejuv = true; break
            end
        end
    end
    if not touchedRejuv and had > 0
       and updateInfo.removedAuraInstanceIDs
       and #updateInfo.removedAuraInstanceIDs > 0 then
        touchedRejuv = true
    end
    if not touchedRejuv then return end

    local count = 0
    for rejuvID in pairs(REJUV_IDS) do
        if FindRejuvOnUnit(unit, rejuvID) then count = count + 1 end
    end
    if count ~= had then
        abundanceCachedCount = abundanceCachedCount + (count - had)
        rejuvPresence[unit]  = count > 0 and count or nil
    end
end

local function RebuildRejuvPresenceCache()
    wipe(rejuvPresence)
    abundanceCachedCount = 0
    local seen = {}
    for _, unit in ipairs(NHA:GetGroupUnits()) do
        if unit ~= "target" and unit ~= "focus" then
            local guid     = UnitGUID(unit)
            local canDedup = guid and not issecretvalue(guid)
            if not canDedup or not seen[guid] then
                if canDedup then seen[guid] = true end
                local count = 0
                for rejuvID in pairs(REJUV_IDS) do
                    if FindRejuvOnUnit(unit, rejuvID) then count = count + 1 end
                end
                if count > 0 then
                    rejuvPresence[unit]   = count
                    abundanceCachedCount  = abundanceCachedCount + count
                end
            end
        end
    end
end

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

-- =============================================================
-- LB Tracking
-- =============================================================
local function StoreLBAura(unit, aura)
    state.lbUnit     = unit
    local g = UnitGUID(unit)
    state.lbGUID     = (g and not issecretvalue(g)) and g or nil
    state.lbInstID   = aura.auraInstanceID
    state.lbExpire   = SafeNum(aura.expirationTime, 0)
    state.lbDuration = SafeNum(aura.duration, 0)
    state.lbTimeMod  = SafeNum(aura.timeMod, 1)
    if state.lbTimeMod <= 0 then state.lbTimeMod = 1 end
end

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

-- =============================================================
-- ScheduleLBPanicTimer
-- =============================================================
local _DrainGlowDeferred

local function MarkLBFramesDirty()
    if not state.lbGUID then return end
    if not NHA._cufPool then return end
    for frame in pairs(NHA._cufPool) do
        local u = frame._nhaCUFUnit
        if u then
            local fg = UnitGUID(u)
            if fg and not issecretvalue(fg) then
                local ok, same = pcall(_instIDsMatch, fg, state.lbGUID)
                if (ok and same) or (not ok) then
                    dirtyCUFFrames[frame] = u
                    if not drainScheduled then
                        drainScheduled = true
                        C_Timer.After(0, _DrainGlowDeferred)
                    end
                end
            end
        end
    end
end

local function ScheduleLBPanicTimer(capturedInstID)
    local remaining = (state.lbExpire - GetTime()) / state.lbTimeMod
    if remaining <= 0 then return end

    if remaining <= LB_PANIC_THRESHOLD then
        NHA.ScheduleEvaluate()
        MarkLBFramesDirty()
        return
    end

    local delay = remaining - LB_PANIC_THRESHOLD
    C_Timer.After(delay, function()
        local ok, same = pcall(_instIDsMatch, state.lbInstID, capturedInstID)
        local shouldFire = false
        if ok and same then
            shouldFire = true
        elseif not ok then
            if state.lbUnit and FindLBOnUnit(state.lbUnit) then
                shouldFire = true
            end
        end
        if shouldFire and state.lbExpire > 0 then
            NHA.ScheduleEvaluate()
            MarkLBFramesDirty()
        end
    end)
end

-- =============================================================
-- LB Group Scanners
-- =============================================================
local function FindLBOnUnit(unit)
    if not UnitExists(unit) then return nil end
    if not C_UnitAuras then return nil end
    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
            else
                needsFallback = true
                break
            end
        end
        if not needsFallback then return nil end
    end
    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

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

local function GroupHasLB()
    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

local function OnTrackedLBDropped()
    state.lbScanPending = false
    if state.lbUnit then
        NHA.ScheduleEvaluate()
        return
    end
    local u, a = FullScanForLB()
    if u and a then
        StoreLBAura(u, a)
        ScheduleLBPanicTimer(a.auraInstanceID)
    end
    NHA.ScheduleEvaluate()
end

-- =============================================================
-- SOTF Detection
-- =============================================================
local function CheckSOTF()
    if not C_UnitAuras then return nil end
    if C_UnitAuras.GetAuraDataBySpellID then
        local ok, aura = pcall(C_UnitAuras.GetAuraDataBySpellID, "player", SPELL_SOTF, "HELPFUL")
        if ok then
            if aura and aura.auraInstanceID and aura.auraInstanceID ~= 0 then
                return aura
            end
            return nil
        end
    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

-- =============================================================
-- SOTF Buff Cache
-- =============================================================
local function UpdateSOTFForPlayer(updateInfo)
    if not updateInfo or updateInfo.isFullUpdate then
        local aura = CheckSOTF()
        if aura then
            state.sotfActive  = true
            state.sotfExpire  = SafeNum(aura.expirationTime, 0)
            state.sotfTimeMod = SafeNum(aura.timeMod, 1)
            if state.sotfTimeMod <= 0 then state.sotfTimeMod = 1 end
            state.sotfInstID  = aura.auraInstanceID
        else
            state.sotfActive  = false
            state.sotfExpire  = 0
            state.sotfTimeMod = 1
            state.sotfInstID  = nil
        end
        return
    end

    if updateInfo.addedAuras then
        for _, aura in ipairs(updateInfo.addedAuras) do
            local sidOk, sid = pcall(_checkSpellIs, aura, SPELL_SOTF)
            if sidOk and sid then
                state.sotfActive  = true
                state.sotfExpire  = SafeNum(aura.expirationTime, 0)
                state.sotfTimeMod = SafeNum(aura.timeMod, 1)
                if state.sotfTimeMod <= 0 then state.sotfTimeMod = 1 end
                state.sotfInstID  = aura.auraInstanceID
                state.sotfProcPending = false
                return
            end
        end
    end

    if updateInfo.removedAuraInstanceIDs and state.sotfInstID then
        for _, instID in ipairs(updateInfo.removedAuraInstanceIDs) do
            local ok, same = pcall(_instIDsMatch, instID, state.sotfInstID)
            if (ok and same) or (not ok) then
                state.sotfActive  = false
                state.sotfExpire  = 0
                state.sotfTimeMod = 1
                state.sotfInstID  = nil
                return
            end
        end
    end
end

-- =============================================================
-- Module Table
-- =============================================================
local DruidResto = {
    class     = "DRUID",
    specIndex = 4,
    specID    = 105,
    specName  = "Restoration",
    alerts    = ALERTS,
    defaults  = DEFAULTS,
    state     = state,

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

    cufRelevantSpells = {
        [33763]  = true,   -- SpellID
        [774]    = true,   -- SpellID
        [155777] = true,   -- SpellID
    },
}

-- =============================================================
-- ClearAllGlows
-- =============================================================
function DruidResto:ClearAllGlows()
    for frame in pairs(cufStateMap) do
        if frame._nhaFrameGlowing then NHA:HideFrameGlow(frame) end
    end
    wipe(cufStateMap)
    wipe(cufLBCache)
    wipe(cufSOTFCache)
    wipe(dirtyCUFFrames)
    drainScheduled = false
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.sotfInstID         = nil
    state.sotfEmpoweredWindowExpire = 0
    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.swiftmendActive    = false
    state.swWasReady         = false
    state.swCurCharges       = state.swMaxCharges or SWIFTMEND_DEFAULT_MAX_CHGS
    state.swChargeTimerSet   = false
    state.swChargeReadyAt    = 0
    state.swChargeGen        = (state.swChargeGen or 0) + 1
    state.swLastCastAt       = 0
    state.swSuppressUntil    = 0
    state.castlbActive       = false
    state.castlbWasActive    = false
    state.abundanceActive    = false
    state.abundanceWasActive = false
    wipe(rejuvPresence)
    abundanceCachedCount = 0
    self:ClearAllGlows()
end

-- =============================================================
-- Perf Instrumentation
-- =============================================================
local _perfBlockTimer   = 0
local _perfCUFLastPath  = nil

local _perfFrameStamp    = 0
local _perfFrameTotal    = 0
local _perfFrameEval     = 0
local _perfFrameCUF      = 0
local _perfFrameCUFCount = 0

local function PerfFrameRoll()
    local p = state._perf
    if not (p and p.enabled) then return end
    local now = GetTime()
    if now ~= _perfFrameStamp then
        if _perfFrameTotal > (p.spike and p.spike.totalMs or 0) then
            p.spike = p.spike or {}
            p.spike.totalMs   = _perfFrameTotal
            p.spike.evalMs    = _perfFrameEval
            p.spike.cufMs     = _perfFrameCUF
            p.spike.cufCount  = _perfFrameCUFCount
            p.spike.atTime    = now
        end
        _perfFrameStamp    = now
        _perfFrameTotal    = 0
        _perfFrameEval     = 0
        _perfFrameCUF      = 0
        _perfFrameCUFCount = 0
    end
end

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

-- =============================================================
-- FindEmpoweredRejuvOnUnit
-- =============================================================
local function FindEmpoweredRejuvOnUnit(unit)
    if not UnitExists(unit) or not C_UnitAuras then return nil end

    if C_UnitAuras.GetAuraDataBySpellID then
        local needsFallback = false
        for spellId in pairs(REJUV_IDS) do
            local ok, aura = pcall(C_UnitAuras.GetAuraDataBySpellID,
                unit, spellId, "HELPFUL")
            if not ok then
                needsFallback = true
                break
            end
            if aura and HasValidInstanceID(aura)
               and IsPlayerOrUnknownSource(aura) then
                local iconOk, icon = pcall(_getIcon, aura)
                if iconOk and icon and not issecretvalue(icon) then
                    local empOk, isEmp = pcall(_empIconLookup, icon)
                    if empOk and isEmp then return aura end
                end
            end
        end
        if not needsFallback then return nil end
    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, "HELPFUL")
        if not ok or not aura then break end
        local sidOk, sid = pcall(_getSpellId, aura)
        if sidOk and sid and not issecretvalue(sid) and REJUV_IDS[sid] then
            if HasValidInstanceID(aura) and IsPlayerOrUnknownSource(aura) then
                local iconOk, icon = pcall(_getIcon, aura)
                if iconOk and icon and not issecretvalue(icon) then
                    local empOk, isEmp = pcall(_empIconLookup, icon)
                    if empOk and isEmp then return aura end
                end
            end
        end
        i = i + 1
    end
    return nil
end

-- =============================================================
-- DoEvaluateCUFGlow
-- =============================================================
local function DoEvaluateCUFGlow(self, 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
        cufSOTFCache[frame] = nil
        _perfCUFLastPath = "disabled"
        return
    end

    local now = GetTime()

    local lbFrameMatch = false
    if state.lbGUID and state.lbExpire > 0 then
        local frameGUID = UnitGUID(unit)
        if frameGUID and not issecretvalue(frameGUID) then
            local ok, same = pcall(_instIDsMatch, frameGUID, state.lbGUID)
            lbFrameMatch = ok and same
        end
    end

    if lbFrameMatch then
        local lbAura = FindLBOnUnit(unit)
        if lbAura then
            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 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"
                cufSOTFCache[frame] = nil
                _perfCUFLastPath = "lb"
                return
            elseif remaining > LB_PANIC_THRESHOLD then
            else
                cufLBCache[frame] = nil
            end
        else
            cufLBCache[frame] = nil
        end
    else
        cufLBCache[frame] = nil
    end

    if GetTime() < state.sotfEmpoweredWindowExpire then
        local sa = cufSOTFCache[frame]
        if sa and sa.expirationTime and sa.lastScanAt
           and (now - sa.lastScanAt) < SOTF_CACHE_TTL then
            local remaining = (sa.expirationTime - now) / (sa.timeMod or 1)
            if remaining > 0 then
                if cufStateMap[frame] ~= "sotf" 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"
                end
                _perfCUFLastPath = "sotf"
                return
            end
        end

        local empAura = FindEmpoweredRejuvOnUnit(unit)
        if empAura then
            if not sa then sa = {}; cufSOTFCache[frame] = sa end
            sa.auraInstanceID = empAura.auraInstanceID
            sa.expirationTime = SafeNum(empAura.expirationTime, 0)
            sa.timeMod        = SafeNum(empAura.timeMod, 1)
            if sa.timeMod <= 0 then sa.timeMod = 1 end
            sa.lastScanAt     = now

            local remaining = (sa.expirationTime - now) / sa.timeMod
            if remaining > 0 then
                if cufStateMap[frame] ~= "sotf" 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"
                end
                _perfCUFLastPath = "sotf"
                return
            end
            cufSOTFCache[frame] = nil
        else
            cufSOTFCache[frame] = nil
        end
    else
        cufSOTFCache[frame] = nil
    end
    if cufStateMap[frame] then
        NHA:HideFrameGlow(frame)
        cufStateMap[frame] = nil
    end
    _perfCUFLastPath = "none"
end

-- =============================================================
-- InstrumentedDoEvaluateCUFGlow
-- =============================================================
local function InstrumentedDoEvaluateCUFGlow(self, db, frame, unit)
    local perf = state._perf
    if not (perf and perf.enabled) then
        DoEvaluateCUFGlow(self, db, frame, unit)
        return
    end
    PerfFrameRoll()
    _perfCUFLastPath = nil
    local t0 = debugprofilestop()
    DoEvaluateCUFGlow(self, db, frame, unit)
    local dt  = debugprofilestop() - t0
    local cuf = perf.cuf
    cuf.calls   = cuf.calls + 1
    cuf.totalMs = cuf.totalMs + dt
    if dt > cuf.maxMs then cuf.maxMs = dt end
    local path = _perfCUFLastPath or "none"
    cuf.paths[path] = (cuf.paths[path] or 0) + 1
    _perfFrameTotal    = _perfFrameTotal    + dt
    _perfFrameCUF      = _perfFrameCUF      + dt
    _perfFrameCUFCount = _perfFrameCUFCount + 1
end

-- =============================================================
-- _DrainGlowDeferred
-- =============================================================
_DrainGlowDeferred = function()
    drainScheduled = false
    local db = NHA.db
    if not db or not db.cufGlowEnabled then
        wipe(dirtyCUFFrames)
        return
    end
    local processed = 0
    for frame, unit in pairs(dirtyCUFFrames) do
        local liveUnit = frame._nhaCUFUnit or unit
        if liveUnit then
            InstrumentedDoEvaluateCUFGlow(nil, db, frame, liveUnit)
        end
        dirtyCUFFrames[frame] = nil
        processed = processed + 1
        if processed >= 4 then break end
    end
    if next(dirtyCUFFrames) then
        drainScheduled = true
        C_Timer.After(0, _DrainGlowDeferred)
    end
end

-- =============================================================
-- EvaluateCUFGlow
-- =============================================================
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
        cufSOTFCache[frame] = nil
        dirtyCUFFrames[frame] = nil
        return
    end
    dirtyCUFFrames[frame] = unit
    if not drainScheduled then
        drainScheduled = true
        C_Timer.After(0, _DrainGlowDeferred)
    end
end

-- =============================================================
-- RefreshGlows — intentional empty stub (contract symmetry)
-- =============================================================
function DruidResto:RefreshGlows(db)
end

-- =============================================================
-- Evaluate
-- =============================================================
function DruidResto:Evaluate(db)
    PerfBeginBlocks()

    local lbPanic = false
    if state.lbUnit and state.lbExpire > 0 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
    PerfMarkBlock("LB")

    local sotfActive = false
    if db.sotfEnabled then
        if state.sotfActive then
            sotfActive = true
        elseif state.sotfProcPending then
            if state.sotfExpire > 0 and GetTime() > state.sotfExpire then
                state.sotfProcPending = false
            else
                sotfActive = true
            end
        end
    end
    state.sotfActive = sotfActive
    if not sotfActive and not state.sotfInstID then
        if not state.sotfProcPending then
            state.sotfExpire  = 0
            state.sotfTimeMod = 1
        end
    end
    PerfMarkBlock("SOTF")

    local wgFallbackDur = GetWGDuration()
    local wgReady = false
    if db.wgEnabled then
        local wgOk, cdInfo = pcall(C_Spell.GetSpellCooldown, WILD_GROWTH_ID)
        if not wgOk then cdInfo = nil end
        local start  = cdInfo and cdInfo.startTime or 0
        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
            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")

    state.refoActive = db.refoEnabled and state.hasRefoTalent
                       and (state.swiftmendCount == 3)

    local swReady = false
    if db.swiftmendEnabled then
        local now = GetTime()
        local suppressed = (state.swSuppressUntil or 0) > now
        local cur = state.swCurCharges or 0
        swReady = (cur >= 1) and (not suppressed)
    end
    state.swiftmendActive = swReady
    PerfMarkBlock("SM")

    local castLbFire = false
    if db.castlbEnabled
       and (state.lbUnit == nil) and (not state.lbScanPending) then
        local u, a = FullScanForLB()
        if u and a then
            StoreLBAura(u, a)
            ScheduleLBPanicTimer(a.auraInstanceID)
        else
            castLbFire = true
        end
    end
    state.castlbActive = castLbFire
    PerfMarkBlock("MISC")

    local abundanceFire = false
    if db.abundanceEnabled and state.hasAbundanceTalent then
        local threshold = db.abundanceStackThreshold or ABUNDANCE_STACK_THRESHOLD
        abundanceFire = abundanceCachedCount >= threshold
    end
    state.abundanceActive = abundanceFire
    PerfMarkBlock("ABUND")

    -- PlayAlertSound order: highest priority first.
    if lbPanic    and not state.lbWasActive     then NHA:PlayAlertSound("lb")        end
    if state.castlbActive and not state.castlbWasActive then
        NHA:PlayAlertSound("castlb")
    end
    if swReady and not state.swWasReady then
        NHA:PlayAlertSound("swiftmend")
    end
    if sotfActive and not state.sotfWasActive   then NHA:PlayAlertSound("sotf")      end
    if state.refoActive and not state.refoWasActive then
        NHA:PlayAlertSound("refo")
    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
    PerfMarkBlock("ENDSND")

    -- Suite-wide rule (see [[nha-combat-gate-rule]]): preserve all latches
    -- across combat boundaries so the next combat's first eval can fire on
    -- actual rising edges instead of resetting to false and re-tripping.
    state.lbWasActive        = lbPanic
    state.sotfWasActive      = sotfActive
    state.wgWasReady         = wgReady
    state.swWasReady         = swReady
    state.refoWasActive      = state.refoActive
    state.castlbWasActive    = state.castlbActive
    state.abundanceWasActive = state.abundanceActive
    PerfMarkBlock("ENDST")
end

-- =============================================================
-- OnActivate
-- =============================================================
function DruidResto:OnActivate(db)
    state.hasRefoTalent          = CheckRefoTalent()
    state.hasAbundanceTalent     = CheckAbundanceTalent()
    state.hasEarlySpringTalent   = CheckEarlySpringTalent()
    state.hasProsperityTalent    = CheckProsperityTalent()
    state.hasRenewingSurgeTalent = CheckRenewingSurgeTalent()
    state.has4pcWGSet            = CheckWG4pcSet()
    SyncSwiftmendCount()
    TryInitSwChargesFromAPI()
    RebuildRejuvPresenceCache()
    C_Timer.After(0.3, function()
        if not C_Spell or not C_Spell.GetSpellCooldown then return end
        local wgOk, cdInfo = pcall(C_Spell.GetSpellCooldown, WILD_GROWTH_ID)
        if not wgOk or not cdInfo then return end
        local dur = ReadWGDurationObj(cdInfo.duration)
        if dur and not issecretvalue(dur) and dur > 1.5 then
            state.wgKnownCD = dur
        end
    end)
    if NHA.RefreshAllCUFs then
        C_Timer.After(0, function() NHA:RefreshAllCUFs() end)
    end
end

-- =============================================================
-- OnDeactivate
-- =============================================================
function DruidResto:OnDeactivate(db)
end

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

        local isCanonical = (unit == "player")
            or (unit:sub(1, 5) == "party" and unit:find("^party%d"))
            or (unit:sub(1, 4) == "raid"  and unit:find("^raid%d"))
        if isCanonical then
            local isRaid = IsInRaid and IsInRaid()
            if not (unit == "player" and isRaid) then
                UpdateRejuvPresenceForUnit(unit, updateInfo)
            end
        end

        local lbAddedHere = false
        if updateInfo and 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
                    StoreLBAura(unit, aura)
                    ScheduleLBPanicTimer(aura.auraInstanceID)
                    state.lbScanPending = false
                    lbAddedHere = true
                    break
                end
            end
        end

        if unit == state.lbUnit and not lbAddedHere then
            if not updateInfo or updateInfo.isFullUpdate then
                if not FindLBOnUnit(unit) then
                    ClearLBTracking()
                    if not state.lbScanPending then
                        state.lbScanPending = true
                        C_Timer.After(0.05, OnTrackedLBDropped)
                    end
                end
                NHA.ScheduleEvaluate()
                return true
            end
            if updateInfo.removedAuraInstanceIDs and state.lbInstID then
                local matchedDrop = false
                local taintedHit  = false
                for _, instID in ipairs(updateInfo.removedAuraInstanceIDs) do
                    local ok, same = pcall(_instIDsMatch, instID, state.lbInstID)
                    if ok and same then
                        matchedDrop = true
                        break
                    elseif not ok then
                        taintedHit = true
                    end
                end
                if not matchedDrop and taintedHit
                   and state.lbUnit and not FindLBOnUnit(state.lbUnit) then
                    matchedDrop = true
                end
                if matchedDrop then
                    ClearLBTracking()
                    if not state.lbScanPending then
                        state.lbScanPending = true
                        C_Timer.After(0.05, OnTrackedLBDropped)
                    end
                    NHA.ScheduleEvaluate()
                    return true
                end
            end
            if updateInfo.updatedAuraInstanceIDs and state.lbInstID then
                for _, instID in ipairs(updateInfo.updatedAuraInstanceIDs) do
                    local ok, same = pcall(_instIDsMatch, instID, state.lbInstID)
                    if (ok and same) or (not ok) then
                        C_Timer.After(0, function()
                            if not state.lbUnit or not state.lbInstID then return end
                            local aok, aura = pcall(C_UnitAuras.GetAuraDataByAuraInstanceID,
                                state.lbUnit, state.lbInstID)
                            if aok and aura and IsPlayerOrUnknownSource(aura) then
                                StoreLBAura(state.lbUnit, aura)
                                ScheduleLBPanicTimer(aura.auraInstanceID)
                            end
                            NHA.ScheduleEvaluate()
                        end)
                        return true
                    end
                end
            end
            return true
        end

        if lbAddedHere then
            NHA.ScheduleEvaluate()
        end

        if unit == "player" then
            UpdateSOTFForPlayer(updateInfo)

            local refoTouched = (not updateInfo) or updateInfo.isFullUpdate
            if not refoTouched and updateInfo.addedAuras then
                for _, aura in ipairs(updateInfo.addedAuras) do
                    local sid = aura.spellId
                    if sid and not issecretvalue(sid) then
                        local ok, match = pcall(_checkSpellIs, aura, REFORESTATION_BUFF_ID)
                        if ok and match then
                            refoTouched = true
                            state.refoInstID = aura.auraInstanceID
                            break
                        end
                    end
                end
            end
            if not refoTouched and state.refoInstID then
                if updateInfo.updatedAuraInstanceIDs then
                    for _, instID in ipairs(updateInfo.updatedAuraInstanceIDs) do
                        if instID == state.refoInstID then
                            refoTouched = true
                            break
                        end
                    end
                end
                if not refoTouched and updateInfo.removedAuraInstanceIDs then
                    for _, instID in ipairs(updateInfo.removedAuraInstanceIDs) do
                        if instID == state.refoInstID then
                            refoTouched      = true
                            state.refoInstID = nil
                            break
                        end
                    end
                end
            end
            if refoTouched then SyncSwiftmendCount() end

            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.1, function()
                        state.playerAuraPending = false
                        NHA.Evaluate()
                    end)
                end
            end
            return true
        end

        return true
    end

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

    if event == "SPELL_UPDATE_CHARGES" then
        if not (C_Spell and C_Spell.GetSpellCharges) then return false end
        local apiOk, info = pcall(C_Spell.GetSpellCharges, SWIFTMEND_ID)
        if not apiOk or not info then return false end

        local changed = false
        if info.currentCharges ~= nil and not issecretvalue(info.currentCharges)
           and type(info.currentCharges) == "number"
           and info.currentCharges ~= state.swCurCharges then
            -- Hard suppression: during the post-cast window, REFUSE to raise
            -- swCurCharges. The API can briefly return a non-secret 1 mid-CD;
            -- trusting it flips us back to ready and the alert fires falsely.
            -- Decreases (e.g. external desync, debug) are always allowed.
            local suppressed = (state.swSuppressUntil or 0) > GetTime()
            if not (suppressed and info.currentCharges > (state.swCurCharges or 0)) then
                state.swCurCharges = info.currentCharges
                changed = true
            end
        end
        if info.maxCharges ~= nil and not issecretvalue(info.maxCharges)
           and type(info.maxCharges) == "number" and info.maxCharges > 0
           and info.maxCharges ~= state.swMaxCharges then
            state.swMaxCharges = info.maxCharges
            changed = true
        end
        if info.cooldownDuration ~= nil and not issecretvalue(info.cooldownDuration)
           and type(info.cooldownDuration) == "number"
           and info.cooldownDuration > 1.5 then
            state.swKnownCD = info.cooldownDuration
        end
        if changed then NHA.ScheduleEvaluate() end
        return true
    end

    if event == "SPELLS_CHANGED" then
        TryInitSwChargesFromAPI()
        NHA.ScheduleEvaluate()
        return false
    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()
                if C_Spell and C_Spell.GetSpellCooldown then
                    local wgOk, cdInfo = pcall(C_Spell.GetSpellCooldown, WILD_GROWTH_ID)
                    if wgOk and cdInfo then
                        local d = ReadWGDurationObj(cdInfo.duration)
                        if d and not issecretvalue(d) then
                            local cmpOk, cmpResult = pcall(_greaterThan1_5, d)
                            if cmpOk and cmpResult then state.wgKnownCD = d end
                        end
                    end
                end
                if state.wgLogEnabled then
                    local now   = GetTime()
                    local cdDur = GetWGDuration()
                    local prev  = state.wgLogLastCastAt or 0
                    if prev > 0 then
                        local gap       = now - prev
                        local predicted = state.wgLogLastPredicted or cdDur
                        local delta     = gap - predicted
                        local esFlag    = state.hasEarlySpringTalent and "ES" or "noES"
                        local setFlag   = state.has4pcWGSet and "4pc" or "no4pc"
                        local knownCD   = state.wgKnownCD or 0
                        NHA.Msg(string_format(
                            "|cffffd700WG cast gap:|r %.3fs  (predicted %.2fs, off by %+0.3fs) [%s %s knownCD=%.2f]",
                            gap, predicted, delta, esFlag, setFlag, knownCD))
                    end
                    state.wgLogLastCastAt    = now
                    state.wgLogLastPredicted = cdDur
                end
                if not state.wgCDTimerSet then
                    state.wgCDTimerSet = true
                    C_Timer.After(GetWGDuration(), function()
                        state.wgCDTimerSet = false
                        NHA.ScheduleEvaluate()
                    end)
                end
                NHA.ScheduleEvaluate()
            end
            if LIFEBLOOM_IDS[spellID] then
                C_Timer.After(0.1, function()
                    local u, a = FullScanForLB()
                    if u and a then
                        StoreLBAura(u, a)
                        ScheduleLBPanicTimer(a.auraInstanceID)
                    end
                    NHA.ScheduleEvaluate()
                end)
            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
                if C_Spell and C_Spell.GetSpellCharges then
                    local apiOk, info = pcall(C_Spell.GetSpellCharges, SWIFTMEND_ID)
                    if apiOk and info and info.maxCharges ~= nil
                       and not issecretvalue(info.maxCharges)
                       and type(info.maxCharges) == "number"
                       and info.maxCharges > 0 then
                        state.swMaxCharges = info.maxCharges
                    end
                end
                -- Cast is the source of truth. Hard-suppress the alert for the
                -- full predicted CD regardless of what charges API or recover timer
                -- says. This defeats races with stale recover timers AND defeats
                -- SPELL_UPDATE_CHARGES briefly returning non-secret 1 during the CD.
                local now = GetTime()
                local cdDur = GetSwiftmendCooldownDuration()
                if state.swLogEnabled then
                    local prev = state.swLogLastCastAt or 0
                    if prev > 0 then
                        local gap   = now - prev
                        local delta = gap - (state.swLogLastPredicted or cdDur)
                        NHA.Msg(string_format(
                            "|cffffd700SM cast gap:|r %.3fs  (predicted %.2fs, real off by %+0.3fs)",
                            gap, state.swLogLastPredicted or cdDur, delta))
                    end
                    state.swLogLastCastAt    = now
                    state.swLogLastPredicted = cdDur
                end
                state.swLastCastAt    = now
                state.swSuppressUntil = now + cdDur
                local cur = state.swCurCharges or 0
                state.swCurCharges = (cur > 0) and (cur - 1) or 0
                if (state.swCurCharges or 0) == 0 then
                    _ArmSwChargeRecover(cdDur)
                elseif not state.swChargeTimerSet then
                    _ArmSwChargeRecover(cdDur)
                end
                -- Schedule a re-eval right at suppression expiry so alert fires
                -- promptly when the window ends.
                C_Timer.After(cdDur + 0.01, function()
                    NHA.ScheduleEvaluate()
                end)
                NHA.ScheduleEvaluate()
            elseif SOTF_CONSUMER_IDS[spellID] then
                local wasSOTFActive = (state.sotfInstID ~= nil) or state.sotfProcPending
                state.sotfProcPending = false
                state.sotfActive      = false
                state.sotfExpire      = 0
                state.sotfWasActive   = false
                if spellID ~= REGROWTH_ID and wasSOTFActive then
                    local now = GetTime()
                    state.sotfEmpoweredWindowExpire = now + 18.0
                end
                NHA.ScheduleEvaluate()
            end
        end
        return true
    end

    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.hasProsperityTalent    = false
        state.hasRenewingSurgeTalent = false
        state.wgKnownCD              = 0
        state.swKnownCD              = 0
        state.swiftmendCount         = 0
        state.refoActive             = false
        C_Timer.After(TALENT_REPROBE_DELAY, function()
            state.hasRefoTalent          = CheckRefoTalent()
            state.hasAbundanceTalent     = CheckAbundanceTalent()
            state.hasEarlySpringTalent   = CheckEarlySpringTalent()
            state.hasProsperityTalent    = CheckProsperityTalent()
            state.hasRenewingSurgeTalent = CheckRenewingSurgeTalent()
            SyncSwiftmendCount()
            TryInitSwChargesFromAPI()
            NHA.ScheduleEvaluate()
        end)
        return false
    end

    if event == "PLAYER_ENTERING_WORLD" then
        state.sotfProcPending        = false
        state.hasRefoTalent          = CheckRefoTalent()
        state.hasAbundanceTalent     = CheckAbundanceTalent()
        state.hasEarlySpringTalent   = CheckEarlySpringTalent()
        state.hasProsperityTalent    = CheckProsperityTalent()
        state.hasRenewingSurgeTalent = CheckRenewingSurgeTalent()
        state.has4pcWGSet            = CheckWG4pcSet()
        UpdateSOTFForPlayer(nil)
        RebuildRejuvPresenceCache()
        SyncSwiftmendCount()
        TryInitSwChargesFromAPI()
        return false
    end

    if event == "PLAYER_REGEN_DISABLED" then
        local u, a = FullScanForLB()
        if u and a then
            StoreLBAura(u, a)
            ScheduleLBPanicTimer(a.auraInstanceID)
        end
        UpdateSOTFForPlayer(nil)
        RebuildRejuvPresenceCache()
        if db and db.wgEnabled and C_Spell and C_Spell.GetSpellCooldown then
            local wgOk, cdInfo = pcall(C_Spell.GetSpellCooldown, WILD_GROWTH_ID)
            if wgOk and cdInfo then
                local dur = ReadWGDurationObj(cdInfo.duration)
                local durOk = not issecretvalue(dur)
                local isReady = durOk
                    and (dur == 0 or (cdInfo.startTime
                         and cdInfo.startTime > 0
                         and dur > 0 and dur <= 1.5))
                if isReady then
                    state.wgInitialized = true
                    state.wgWasReady    = true
                    NHA:PlayAlertSound("wg")
                end
            end
        end
        if db and db.swiftmendEnabled then
            TryInitSwChargesFromAPI()
        end
        if NHA.RefreshAllCUFs then NHA:RefreshAllCUFs() end
        if state.sotfActive and state.sotfExpire > GetTime() then
            state.sotfProcPending = true
        end
        NHA.ScheduleEvaluate()
        return false
    end

    if event == "PLAYER_REGEN_ENABLED" then
        self:ClearAllGlows()
        state.swWasReady = false
        wipe(rejuvPresence)
        abundanceCachedCount = 0
        SyncSwiftmendCount()
        return false
    end

    if event == "GROUP_ROSTER_UPDATE" then
        if not state.rosterUpdatePending then
            state.rosterUpdatePending = true
            C_Timer.After(0.2, function()
                state.rosterUpdatePending = false
                RebuildRejuvPresenceCache()
                NHA.Evaluate()
            end)
        end
        return true
    end

    if event == "PLAYER_EQUIPMENT_CHANGED" then
        state.has4pcWGSet = CheckWG4pcSet()
        state.wgKnownCD   = 0
        NHA.ScheduleEvaluate()
        return true
    end

    return false
end

-- =============================================================
-- BuildAlertOptions
-- =============================================================
function DruidResto:BuildAlertOptions(parent, db, helpers)
    local r, g, b = GetClassColor(select(2, UnitClass("player")))
    r, g, b = r or 1.0, g or 0.486, b or 0.039

    local abBox = helpers.MakeNumericInput(
        "Abundance Threshold (Rejuv/Germ stacks)",
        1, 12,
        function() return db.abundanceStackThreshold or 7 end,
        function(v)
            db.abundanceStackThreshold = v
            NHA.ScheduleEvaluate()
        end)
    abBox:SetTextColor(r, g, b)
end

-- =============================================================
-- Slash Commands
-- =============================================================
DruidResto.slashCommands = {

    ["lb"] = function(self, db)
        NHA.Msg("|cffffd700LB State:|r")
        NHA.Msg("  lbUnit="    .. tostring(state.lbUnit))
        NHA.Msg("  lbGUID="    .. tostring(state.lbGUID))
        NHA.Msg("  lbExpire="  .. string_format("%.2f", state.lbExpire))
        NHA.Msg("  lbTimeMod=" .. tostring(state.lbTimeMod))
        NHA.Msg("  lbActive="  .. tostring(state.lbActive))
        NHA.Msg("  lbScanPending="  .. tostring(state.lbScanPending))
        NHA.Msg("  castlbActive=" .. tostring(state.castlbActive))
        if state.lbExpire > 0 then
            local rem = (state.lbExpire - GetTime()) / state.lbTimeMod
            NHA.Msg("  remaining=" .. string_format("%.2f", rem) .. "s")
            NHA.Msg("  pandemic="  .. tostring(rem <= LB_PANIC_THRESHOLD))
        end
        NHA.Msg("  GroupHasLB()=" .. tostring(GroupHasLB()))
        NHA.Msg("|cffffd700LB Per-Unit Scan:|r")
        for _, unit in ipairs(NHA:GetGroupUnits()) do
            if UnitExists(unit) then
                local apiAura
                if C_UnitAuras and C_UnitAuras.GetAuraDataBySpellID then
                    local ok, a = pcall(C_UnitAuras.GetAuraDataBySpellID,
                        unit, 33763, "HELPFUL")
                    if ok then apiAura = a end
                end
                local srcStr = "<no aura>"
                if apiAura then
                    local sok, src = pcall(_readSourceUnit, apiAura)
                    if not sok then
                        srcStr = "<tainted>"
                    elseif src == nil then
                        srcStr = "<nil>"
                    elseif src == "" then
                        srcStr = '""'
                    else
                        srcStr = tostring(src)
                    end
                end
                local found = FindLBOnUnit(unit) ~= nil
                NHA.Msg(string_format("  %-12s aura=%s  src=%s  FindLB=%s",
                    unit,
                    apiAura and "yes" or "no",
                    srcStr,
                    tostring(found)))
            end
        end
        if state.lbGUID then
            local matchCount = 0
            for frame in pairs(NHA._cufPool or {}) do
                local u = frame._nhaCUFUnit
                if u then
                    local g = UnitGUID(u)
                    if g and not issecretvalue(g) and g == state.lbGUID then
                        matchCount = matchCount + 1
                    end
                end
            end
            NHA.Msg("  CUF frames matching lbGUID: " .. matchCount)
        end
    end,

    ["abundance"] = function(self, db)
        NHA.Msg("|cffffd700Abundance State:|r")
        NHA.Msg("  hasAbundanceTalent=" .. tostring(state.hasAbundanceTalent))
        NHA.Msg("  abundanceCachedCount=" .. tostring(abundanceCachedCount))
        NHA.Msg("  threshold=" .. tostring(db.abundanceStackThreshold or ABUNDANCE_STACK_THRESHOLD))
        NHA.Msg("  abundanceActive=" .. tostring(state.abundanceActive))
        local live = CountRejuvGerminationStacks(0)
        NHA.Msg("  live scan count=" .. tostring(live))
        for unit, cnt in pairs(rejuvPresence) do
            NHA.Msg("    " .. unit .. "=" .. tostring(cnt))
        end
    end,

    ["sotf"] = function(self, db)
        NHA.Msg("|cffffd700SOTF State:|r")
        NHA.Msg("  sotfActive="      .. tostring(state.sotfActive))
        NHA.Msg("  sotfProcPending=" .. tostring(state.sotfProcPending))
        NHA.Msg("  sotfExpire="      .. string_format("%.2f", state.sotfExpire))
        local windowRem = state.sotfEmpoweredWindowExpire - GetTime()
        NHA.Msg("  sotfEmpoweredWindow=" .. (windowRem > 0
            and string_format("OPEN (%.1fs remaining)", windowRem)
            or  "closed"))
        local aura = CheckSOTF()
        NHA.Msg("  CheckSOTF()=" .. tostring(aura ~= nil))
    end,

    ["sotfscan"] = function(self, db)
        NHA.Msg("|cffffd700SOTF Empowered Icon Scan:|r")
        local windowRem = state.sotfEmpoweredWindowExpire - GetTime()
        NHA.Msg("  empoweredWindow=" .. (windowRem > 0
            and string_format("OPEN (%.1fs)", windowRem) or "closed"))
        NHA.Msg("  SOTF_EMPOWERED_ICONS table:")
        for id in pairs(SOTF_EMPOWERED_ICONS) do
            NHA.Msg("    [" .. id .. "]")
        end
        local found = 0
        for _, unit in ipairs(NHA:GetGroupUnits()) do
            if not C_UnitAuras or not C_UnitAuras.GetAuraDataByIndex then break 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
                local sidOk, sid = pcall(_getSpellId, aura)
                if sidOk and sid and not issecretvalue(sid) and REJUV_IDS[sid] then
                    local iconOk, icon = pcall(_getIcon, aura)
                    local iconStr = (iconOk and icon and not issecretvalue(icon))
                        and tostring(icon) or "<secret>"
                    local inTable = iconOk and icon and not issecretvalue(icon)
                        and SOTF_EMPOWERED_ICONS[icon] and "YES" or "no"
                    local name = (sid == 774) and "Rejuv" or "Germ"
                    local expOk, exp = pcall(_getExpiration, aura)
                    local rem = (expOk and type(exp) == "number") and
                        string_format("%.1fs", exp - GetTime()) or "?"
                    NHA.Msg(string_format("  %s %s  aura.icon=%s  rem=%s  EMPOWERED=%s",
                        unit, name, iconStr, rem, inTable))
                    found = found + 1
                end
                i = i + 1
            end
        end
        if found == 0 then
            NHA.Msg("  No Rejuv/Germ found. Apply some first, then run again after Swiftmend → Rejuv.")
        end
    end,

    -- /nha wg
    ["wg"] = function(self, db)
        NHA.Msg("|cffffd700WG State:|r")
        NHA.Msg("  wgActive="    .. tostring(state.wgActive))
        NHA.Msg("  wgOnRealCD="  .. tostring(state.wgOnRealCD))
        NHA.Msg("  wgKnownCD="   .. tostring(state.wgKnownCD))
        NHA.Msg("  GetWGDuration()=" .. tostring(GetWGDuration()))
        NHA.Msg("  hasEarlySpringTalent=" .. tostring(state.hasEarlySpringTalent))
        NHA.Msg("  has4pcWGSet=" .. tostring(state.has4pcWGSet))
    end,

    ["refo"] = function(self, db)
        NHA.Msg("|cffffd700Reforestation State:|r")
        NHA.Msg("  refoActive="    .. tostring(state.refoActive))
        NHA.Msg("  hasRefoTalent=" .. tostring(state.hasRefoTalent))
        NHA.Msg("  swiftmendCount=" .. tostring(state.swiftmendCount))
    end,

    ["smlog"] = function(self, db)
        state.swLogEnabled = not state.swLogEnabled
        if state.swLogEnabled then
            state.swLogLastCastAt    = 0
            state.swLogLastPredicted = 0
            NHA.Msg("|cff00ff00SM cast-gap logging: ON|r — cast SM twice on-ready to measure real CD vs predicted. /nha smlog to stop.")
        else
            NHA.Msg("|cffffd700SM cast-gap logging: OFF|r")
        end
    end,

    ["wglog"] = function(self, db)
        state.wgLogEnabled = not state.wgLogEnabled
        if state.wgLogEnabled then
            state.wgLogLastCastAt    = 0
            state.wgLogLastPredicted = 0
            NHA.Msg("|cff00ff00WG cast-gap logging: ON|r — cast WG twice on-ready to measure real CD vs predicted. /nha wglog to stop.")
        else
            NHA.Msg("|cffffd700WG cast-gap logging: OFF|r")
        end
    end,

    ["sm"] = function(self, db)
        local function field(v)
            if v == nil then return "nil" end
            if issecretvalue(v) then return "<secret>" end
            return tostring(v)
        end
        local function numField(v)
            if v == nil then return nil end
            if issecretvalue(v) then return nil end
            if type(v) ~= "number" then return nil end
            return v
        end

        NHA.Msg("|cffffd700Swiftmend Ready State:|r")
        NHA.Msg("  swiftmendActive="     .. tostring(state.swiftmendActive))
        NHA.Msg("  swCurCharges="        .. tostring(state.swCurCharges))
        NHA.Msg("  swMaxCharges="        .. tostring(state.swMaxCharges))
        NHA.Msg("  swChargeTimerSet="    .. tostring(state.swChargeTimerSet))
        NHA.Msg("  swChargeGen="         .. tostring(state.swChargeGen))
        NHA.Msg("  swLastCastAgo="       .. string_format("%.2f", GetTime() - (state.swLastCastAt or 0)) .. "s")
        do
            local rem = (state.swSuppressUntil or 0) - GetTime()
            if rem > 0 then
                NHA.Msg("  swSuppressUntil= " .. string_format("%.2fs remaining", rem))
            else
                NHA.Msg("  swSuppressUntil= expired/none")
            end
        end
        NHA.Msg("  swKnownCD="           .. string_format("%.2f", state.swKnownCD))
        NHA.Msg("  hasProsperityTalent=" .. tostring(state.hasProsperityTalent))
        NHA.Msg("  hasEarlySpringTalent="..tostring(state.hasEarlySpringTalent))
        NHA.Msg("  hasRenewingSurgeTalent="..tostring(state.hasRenewingSurgeTalent))
        NHA.Msg("  GetSwiftmendCooldownDuration()="
                .. string_format("%.2f", GetSwiftmendCooldownDuration()))

        local now = GetTime()
        NHA.Msg("|cffffd700  Simulator:|r")
        if state.swChargeTimerSet and state.swChargeReadyAt > 0 then
            local rem = state.swChargeReadyAt - now
            NHA.Msg("    armed, ready in " .. string_format("%.2f", rem) .. "s"
                    .. " (at GetTime " .. string_format("%.2f", state.swChargeReadyAt) .. ")")
        else
            NHA.Msg("    no timer armed")
        end

        NHA.Msg("|cffffd700  C_Spell.GetSpellCharges:|r")
        if C_Spell and C_Spell.GetSpellCharges then
            local apiOk, info = pcall(C_Spell.GetSpellCharges, SWIFTMEND_ID)
            if apiOk and info then
                NHA.Msg("    currentCharges="   .. field(info.currentCharges))
                NHA.Msg("    maxCharges="       .. field(info.maxCharges))
                NHA.Msg("    cooldownStart="    .. field(info.cooldownStart))
                NHA.Msg("    cooldownDuration=" .. field(info.cooldownDuration))
                local s = numField(info.cooldownStart)
                local d = numField(info.cooldownDuration)
                if s and d and s > 0 and d > 0 then
                    NHA.Msg("    -> live remaining "
                            .. string_format("%.2f", (s + d) - now) .. "s")
                end
            else
                NHA.Msg("    call failed")
            end
        else
            NHA.Msg("    API unavailable")
        end

        NHA.Msg("|cffffd700  C_Spell.GetSpellCooldown:|r")
        if C_Spell and C_Spell.GetSpellCooldown then
            local apiOk, info = pcall(C_Spell.GetSpellCooldown, SWIFTMEND_ID)
            if apiOk and info then
                local rawDur  = info.duration
                local unwrap  = ReadWGDurationObj(rawDur)
                local durTag  = (type(rawDur) == "table") and " (unwrapped)" or ""
                NHA.Msg("    startTime=" .. field(info.startTime))
                NHA.Msg("    duration="  .. field(unwrap) .. durTag)
                NHA.Msg("    isEnabled=" .. tostring(info.isEnabled))
                local s = numField(info.startTime)
                local d = numField(unwrap)
                if s and d and s > 0 and d > 0 then
                    NHA.Msg("    -> live remaining "
                            .. string_format("%.2f", (s + d) - now) .. "s")
                elseif d == 0 then
                    NHA.Msg("    -> ready (duration=0)")
                end
            else
                NHA.Msg("    call failed")
            end
        else
            NHA.Msg("    API unavailable")
        end
    end,

    ["cuf"] = function(self, db)
        NHA.Msg("|cffffd700CUF Glow State:|r")
        local lbCount, sotfCount = 0, 0
        for _, v in pairs(cufStateMap) do
            if v == "lb" then lbCount = lbCount + 1
            elseif v == "sotf" then sotfCount = sotfCount + 1 end
        end
        NHA.Msg("  lb glowing="   .. lbCount)
        NHA.Msg("  sotf glowing=" .. sotfCount)
        NHA.Msg("  lbCache size=" .. (function() local n=0; for _ in pairs(cufLBCache) do n=n+1 end; return n end)())
        NHA.Msg("  drainScheduled=" .. tostring(drainScheduled))
        NHA.Msg("  dirtyCUFFrames=" .. (function() local n=0; for _ in pairs(dirtyCUFFrames) do n=n+1 end; return n end)())
    end,

    ["lbscan"] = function(self, db)
        NHA.Msg("|cffffd700LB Group Scan:|r")
        local probed = 0
        for _, unit in ipairs(NHA:GetGroupUnits()) do
            if UnitExists(unit) and probed < 6 then
                local function tryLookup(filter)
                    if not C_UnitAuras or not C_UnitAuras.GetAuraDataBySpellID then
                        return "API unavailable"
                    end
                    local ok, aura = pcall(C_UnitAuras.GetAuraDataBySpellID,
                        unit, 33763, filter)
                    if not ok then return "pcall_error" end
                    if not aura then return "nil" end
                    local instOk, instID = pcall(_getInstanceID, aura)
                    local iconOk, icon   = pcall(_getIcon, aura)
                    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,


    ["cufdebug"] = function(self, db)
        NHA.Msg("|cffffd700CUF Empowered Scan (per frame):|r")
        local windowRem = state.sotfEmpoweredWindowExpire - GetTime()
        NHA.Msg("  sotfEmpoweredWindowExpire=" .. (windowRem > 0
            and string_format("OPEN %.1fs", windowRem) or "CLOSED"))
        if not NHA._cufPool then
            NHA.Msg("  _cufPool is nil")
            return
        end
        local count = 0
        for frame in pairs(NHA._cufPool) do
            local u = frame._nhaCUFUnit
            if u and UnitExists(u) then
                local empAura = FindEmpoweredRejuvOnUnit(u)
                if empAura then
                    local expOk, exp = pcall(_getExpiration, empAura)
                    local rem = (expOk and type(exp)=="number") and
                        string_format("%.1fs", exp - GetTime()) or "?"
                    local iconOk, icon = pcall(_getIcon, empAura)
                    NHA.Msg(string_format("  GLOW %s icon=%s rem=%s",
                        u,
                        (iconOk and tostring(icon)) or "<secret>",
                        rem))
                else
                    local rawResult = "nil"
                    for spellId in pairs(REJUV_IDS) do
                        local ok1, a1 = pcall(C_UnitAuras.GetAuraDataBySpellID,
                            u, spellId, "HELPFUL")
                        local ok2, a2 = pcall(C_UnitAuras.GetAuraDataBySpellID,
                            u, spellId, "PLAYER|HELPFUL")
                        local icon1Str, icon2Str = "nil", "nil"
                        if ok1 and a1 then
                            local io1, ic1 = pcall(_getIcon, a1)
                            icon1Str = (io1 and ic1 and not issecretvalue(ic1))
                                and tostring(ic1) or "<secret>"
                        end
                        if ok2 and a2 then
                            local io2, ic2 = pcall(_getIcon, a2)
                            icon2Str = (io2 and ic2 and not issecretvalue(ic2))
                                and tostring(ic2) or "<secret>"
                        end
                        rawResult = string_format(
                            "sid=%d HELPFUL:icon=%s PLAYER:icon=%s",
                            spellId, icon1Str, icon2Str)
                        if ok1 and a1 then break end
                    end
                    NHA.Msg(string_format("  none  %s raw=[%s]", u, rawResult))
                end
                count = count + 1
            end
        end
        if count == 0 then NHA.Msg("  No CUF frames with valid units.") end
    end,

    ["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("  wgLogEnabled: "         .. tostring(state.wgLogEnabled or false))
        NHA.Msg("  wgCastTime: "           .. string_format("%.2f", state.wgCastTime))
        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+)")
                    if rawDur.GetTotalDuration then
                        local ok, v = pcall(rawDur.GetTotalDuration, rawDur)
                        NHA.Msg("    :GetTotalDuration() = " .. (ok and tostring(v) or "ERROR: "..tostring(v)))
                    end
                else
                    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)))
            else
                NHA.Msg("  GetSpellCooldown returned nil")
            end
        else
            NHA.Msg("  C_Spell.GetSpellCooldown not available")
        end
        local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                         and C_ClassTalents.GetActiveConfigID()
        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
                    .. " activeRank=" .. tostring(ni.activeRank)
                    .. " ranksPurchased=" .. tostring(ni.ranksPurchased)
                    .. " activeEntry=" .. tostring(ni.activeEntry
                        and ni.activeEntry.entryID or "nil"))
            else
                NHA.Msg("  Node " .. EARLY_SPRING_NODE_ID .. " = nil")
            end
        end
    end,

    ["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.blocksEnabled = true
        perf.blocks        = {}
        perf.cuf = {
            calls = 0, totalMs = 0, maxMs = 0,
            paths = { lb = 0, sotf = 0, none = 0, disabled = 0 },
        }
        perf.spike = {
            totalMs = 0, evalMs = 0, cufMs = 0,
            cufCount = 0, atTime = 0,
        }

        perf.origEvaluate = DruidResto.Evaluate
        DruidResto.Evaluate = function(slf, sdb)
            PerfFrameRoll()
            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
            _perfFrameTotal = _perfFrameTotal + dt
            _perfFrameEval  = _perfFrameEval  + dt
        end

        NHA.Msg("|cff00ff00NHA Perf: ENABLED|r — Evaluate + EvaluateCUFGlow. 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
        PerfFrameRoll()
        perf.enabled       = false
        perf.blocksEnabled = false

        if perf.calls == 0 then
            NHA.Msg("|cffffd700NHA Perf FINAL:|r no samples")
            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 FINAL:|r %.1fs, %d calls (%.1f/s), avg=%.3fms max=%.3fms total=%.1fms",
            elapsed, perf.calls, rate, avg, perf.maxMs, perf.totalMs))

        local order = { "LB", "SOTF", "WG", "SM", "MISC", "ABUND", "ENDSND", "ENDST" }
        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

        local cuf = perf.cuf
        if cuf and cuf.calls > 0 then
            local cufRate = elapsed > 0 and (cuf.calls / elapsed) or 0
            local cufAvg  = cuf.totalMs / cuf.calls
            NHA.Msg(string_format(
                "|cffffd700CUF GLOW:|r %d calls (%.1f/s) avg=%.3fms max=%.3fms total=%.1fms",
                cuf.calls, cufRate, cufAvg, cuf.maxMs, cuf.totalMs))
            NHA.Msg(string_format(
                "  paths: LB=%d  SOTF=%d  NONE=%d  DISABLED=%d",
                cuf.paths.lb or 0, cuf.paths.sotf or 0,
                cuf.paths.none or 0, cuf.paths.disabled or 0))
        else
            NHA.Msg("|cffffd700CUF GLOW:|r no calls in window")
        end

        local s = perf.spike
        if s and s.totalMs > 0 then
            NHA.Msg(string_format(
                "|cffffd700WORST FRAME:|r %.3fms total (Eval=%.3fms  CUF=%.3fms x%d)",
                s.totalMs, s.evalMs, s.cufMs, s.cufCount))
        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 Eval (%.1f/s), avg=%.3fms max=%.3fms",
            perf.enabled and "RUNNING" or "STOPPED",
            elapsed, perf.calls, rate, avg, perf.maxMs))
        local cuf = perf.cuf
        if cuf and cuf.calls > 0 then
            local cufRate = elapsed > 0 and (cuf.calls / elapsed) or 0
            NHA.Msg(string_format(
                "  CUF: %d calls (%.1f/s) avg=%.3fms max=%.3fms",
                cuf.calls, cufRate, cuf.totalMs / cuf.calls, cuf.maxMs))
        end
        local s = perf.spike
        if s and s.totalMs > 0 then
            NHA.Msg(string_format(
                "  worst frame: %.3fms (E=%.3f C=%.3f x%d)",
                s.totalMs, s.evalMs, s.cufMs, s.cufCount))
        end
    end,
}

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