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

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

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

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

local ABUNDANCE_NODE_ID           = 103876  -- TalentID
local ABUNDANCE_STACK_THRESHOLD   = 7

local WG_DURATION_BASE       = 10.0
local WG_EARLY_SPRING_REDUCE =  1.0
local WG_4PC_REDUCE_PVE      =  2.0
local WG_4PC_REDUCE_PVP      =  1.0
local EARLY_SPRING_NODE_ID   = 94591    -- TalentID
local EARLY_SPRING_ENTRY_ID  = 117895   -- EntryID
local EARLY_SPRING_SPELL_ID  = 428937   -- SpellID
local WG_4PC_SET_ID          = 1980     -- SetID

-- =============================================================
-- 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,
    hasEarlySpringTalent = false,
    has4pcWGSet          = false,
    wgKnownCD            = 0,
    refoActive    = false,
    refoWasActive = false,
    hasRefoTalent  = false,
    swiftmendCount = 0,
    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

-- =============================================================
-- Alert Definitions
-- =============================================================
local REFO_Y    =  46
local ABUND_Y   =  21
local WG_Y      =  -4
local SOTF_Y    = -29
local LB_TEXT_Y = -54
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",
    },
    {
        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,
        noSound      = true,
    },
    {
        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",
    },
    {
        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",
    },
    {
        key          = "refo",
        label        = "Tree of Life Ready",
        text         = "* TREE: NEXT SWIFTMEND *",
        defaultColor = { r = 0.204, g = 0.596, b = 0.859 },  -- #3498DB
        hasTimer     = false,
        yOffset      = REFO_Y,
        noSound      = true,
    },
    {
        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",
    },
}

-- =============================================================
-- 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,
    castlbEnabled       = true,
    abundanceEnabled    = true,
    abundanceSoundEnabled = true,
    abundanceSoundName  = "Druid: Abundance",
    abundanceStackThreshold = 7,
    lbAlertText         = nil,
    castlbAlertText     = nil,
    sotfAlertText       = nil,
    wgAlertText         = nil,
    refoAlertText       = 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   },
        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()
    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, EARLY_SPRING_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 == 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 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
        local ok, _, _, playerHas = pcall(GetItemSetInfo, WG_4PC_SET_ID)
        if ok and type(playerHas) == "number" and playerHas >= 4 then return true end
        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

-- =============================================================
-- Taint-Safe Static Workers
-- =============================================================
local function _toNumber(val)         return val + 0                        end
local function _checkPlayerSource(aura) return aura.sourceUnit == "player"  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 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 aura = C_UnitAuras.GetAuraDataBySpellID("player", REFORESTATION_BUFF_ID, "HELPFUL")
    if aura and not issecretvalue(aura.applications) then
        state.swiftmendCount = aura.applications or 0
    elseif not aura then
        state.swiftmendCount = 0
    end
end

-- =============================================================
-- LB Tracking
-- =============================================================
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)
        if (ok and same) or (not ok) then
            if state.lbExpire > 0 then
                NHA.ScheduleEvaluate()
                MarkLBFramesDirty()
            end
        end
    end)

    C_Timer.After(remaining + 0.5, function()
        local ok, same = pcall(_instIDsMatch, state.lbInstID, capturedInstID)
        if (ok and same) or (not ok) then
            ClearLBTracking()
            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

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

-- =============================================================
-- 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",
        "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.castlbActive       = false
    state.castlbWasActive    = false
    state.abundanceActive    = false
    state.abundanceWasActive = false
    wipe(rejuvPresence)
    abundanceCachedCount = 0
    self:ClearAllGlows()
end

-- =============================================================
-- NeedsTicker
-- =============================================================
function DruidResto:NeedsTicker(db)
    return false
end

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

local _perfFrameStamp    = 0
local _perfFrameTotal    = 0
local _perfFrameEval     = 0
local _perfFrameCUF      = 0
local _perfFrameRefr     = 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.refreshMs = _perfFrameRefr
            p.spike.cufCount  = _perfFrameCUFCount
            p.spike.atTime    = now
        end
        _perfFrameStamp    = now
        _perfFrameTotal    = 0
        _perfFrameEval     = 0
        _perfFrameCUF      = 0
        _perfFrameRefr     = 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

    if not UnitAffectingCombat("player") then
        _perfCUFLastPath = "none"
        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"
                do
                    local capturedFrame  = frame
                    local capturedExpiry = sa.expirationTime
                    C_Timer.After(remaining + 0.5, function()
                        local cached = cufLBCache[capturedFrame]
                        if not cached then return end
                        if cached.expirationTime ~= capturedExpiry then return end
                        local u = capturedFrame._nhaCUFUnit
                        if u then
                            dirtyCUFFrames[capturedFrame] = u
                            if not drainScheduled then
                                drainScheduled = true
                                C_Timer.After(0, _DrainGlowDeferred)
                            end
                        else
                            cufLBCache[capturedFrame] = nil
                        end
                    end)
                end
                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 empAura = FindEmpoweredRejuvOnUnit(unit)
        if empAura then
            local sa = cufSOTFCache[frame]
            if not sa then sa = {}; cufSOTFCache[frame] = sa end
            sa.expirationTime = SafeNum(empAura.expirationTime, 0)
            sa.timeMod        = SafeNum(empAura.timeMod, 1)
            if sa.timeMod <= 0 then sa.timeMod = 1 end

            local remaining = (sa.expirationTime - now) / sa.timeMod
            if remaining > 0 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"
                _perfCUFLastPath = "sotf"
                do
                    local capturedFrame  = frame
                    local capturedExpiry = sa.expirationTime
                    C_Timer.After(remaining + 0.5, function()
                        local cached = cufSOTFCache[capturedFrame]
                        if not cached then return end
                        if cached.expirationTime ~= capturedExpiry then return end
                        local u = capturedFrame._nhaCUFUnit
                        if u then
                            dirtyCUFFrames[capturedFrame] = u
                            if not drainScheduled then
                                drainScheduled = true
                                C_Timer.After(0, _DrainGlowDeferred)
                            end
                        else
                            cufSOTFCache[capturedFrame] = nil
                        end
                    end)
                end
                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
    if not UnitAffectingCombat("player") then return end
    dirtyCUFFrames[frame] = unit
    if not drainScheduled then
        drainScheduled = true
        C_Timer.After(0, _DrainGlowDeferred)
    end
end

-- =============================================================
-- RefreshGlows
-- =============================================================
local _safetyNetLastRun = 0

function DruidResto:RefreshGlows(db)
end

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

    local inCombat = UnitAffectingCombat("player")

    local lbPanic = false
    if state.lbUnit and state.lbExpire > 0 and inCombat 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 and inCombat 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 and inCombat then
        local cdInfo = C_Spell.GetSpellCooldown(WILD_GROWTH_ID)
        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 inCombat and (state.swiftmendCount == 3)

    local castLbFire = false
    if db.castlbEnabled and inCombat then
        castLbFire = (state.lbUnit == nil)
    end
    state.castlbActive = castLbFire
    PerfMarkBlock("MISC")

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

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

-- =============================================================
-- OnActivate
-- =============================================================
function DruidResto:OnActivate(db)
    state.hasRefoTalent        = CheckRefoTalent()
    state.hasAbundanceTalent   = CheckAbundanceTalent()
    state.hasEarlySpringTalent = CheckEarlySpringTalent()
    state.has4pcWGSet          = CheckWG4pcSet()
    SyncSwiftmendCount()
    RebuildRejuvPresenceCache()
    C_Timer.After(0.3, function()
        if not C_Spell or not C_Spell.GetSpellCooldown then return end
        local cdInfo = C_Spell.GetSpellCooldown(WILD_GROWTH_ID)
        if not cdInfo then return end
        local dur = ReadWGDurationObj(cdInfo.duration)
        if dur and not issecretvalue(dur) and dur > 1.5 then
            state.wgKnownCD = dur
        end
    end)
    if NHA.RefreshAllCUFs then
        C_Timer.After(0.1, 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 = ...

        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


        if unit == state.lbUnit then
            if not updateInfo or updateInfo.isFullUpdate then
                NHA.ScheduleEvaluate(); return true
            end
            if updateInfo.removedAuraInstanceIDs and state.lbInstID then
                for _, instID in ipairs(updateInfo.removedAuraInstanceIDs) do
                    local ok, same = pcall(_instIDsMatch, instID, state.lbInstID)
                    if (ok and same) or (not ok) then
                        ClearLBTracking()
                        NHA.ScheduleEvaluate()
                        return true
                    end
                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
            if updateInfo.addedAuras then
                for _, aura in ipairs(updateInfo.addedAuras) do
                    local sid = aura.spellId
                    if sid and not issecretvalue(sid)
                       and LIFEBLOOM_IDS[sid]
                       and IsPlayerOrUnknownSource(aura) then
                        StoreLBAura(state.lbUnit, aura)
                        ScheduleLBPanicTimer(aura.auraInstanceID)
                        NHA.ScheduleEvaluate()
                        return true
                    end
                end
            end
            return true
        end

        if unit == "player" then
            UpdateSOTFForPlayer(updateInfo)
            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

        if not state.lbUnit and not state.lbScanPending then
            if not updateInfo or updateInfo.isFullUpdate or updateInfo.addedAuras then
                state.lbScanPending = true
                C_Timer.After(0.1, function()
                    state.lbScanPending = false
                    local u, a = FullScanForLB()
                    if u and a then
                        StoreLBAura(u, a)
                        ScheduleLBPanicTimer(a.auraInstanceID)
                    end
                    NHA.ScheduleEvaluate()
                end)
            end
        end
        return true
    end

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

    if event == "UNIT_SPELLCAST_SUCCEEDED" then
        local unit, _, spellID = ...
        if unit == "player" then
            if spellID == WILD_GROWTH_ID then
                state.wgJustCast = true
                state.wgActive   = false
                state.wgCastTime = GetTime()
                if C_Spell and C_Spell.GetSpellCooldown then
                    local cdInfo = C_Spell.GetSpellCooldown(WILD_GROWTH_ID)
                    if 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 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
                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.wgKnownCD            = 0
        state.swiftmendCount       = 0
        state.refoActive           = false
        C_Timer.After(0.5, function()
            state.hasRefoTalent        = CheckRefoTalent()
            state.hasAbundanceTalent   = CheckAbundanceTalent()
            state.hasEarlySpringTalent = CheckEarlySpringTalent()
            SyncSwiftmendCount()
            NHA.ScheduleEvaluate()
        end)
        return false
    end

    if event == "PLAYER_ENTERING_WORLD" then
        state.sotfProcPending      = false
        state.hasRefoTalent        = CheckRefoTalent()
        state.hasAbundanceTalent   = CheckAbundanceTalent()
        state.hasEarlySpringTalent = CheckEarlySpringTalent()
        state.has4pcWGSet          = CheckWG4pcSet()
        UpdateSOTFForPlayer(nil)
        RebuildRejuvPresenceCache()
        SyncSwiftmendCount()
        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 cdInfo = C_Spell.GetSpellCooldown(WILD_GROWTH_ID)
            if 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 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.castlbActive = 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.5, 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("  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()))
        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(function() return aura.expirationTime end)
                    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,

    ["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(function() return aura.auraInstanceID end)
                    local iconOk, icon   = pcall(function() return aura.icon end)
                    return string_format("inst=%s icon=%s",
                        (instOk and tostring(instID)) or "<secret>",
                        (iconOk and tostring(icon))   or "<secret>")
                end
                NHA.Msg(string_format("  %s:", unit))
                NHA.Msg("    BySpellID HELPFUL:        " .. tryLookup("HELPFUL"))
                NHA.Msg("    BySpellID PLAYER|HELPFUL: " .. tryLookup("PLAYER|HELPFUL"))
                NHA.Msg("    BySpellID nil filter:     " .. tryLookup(nil))
                probed = probed + 1
            end
        end
    end,


    ["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(function() return empAura.expirationTime end)
                    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("  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
                    .. " 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.refresh = { calls = 0, totalMs = 0, maxMs = 0 }
        perf.spike = {
            totalMs = 0, evalMs = 0, cufMs = 0, refreshMs = 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

        perf.origRefresh = DruidResto.RefreshGlows
        DruidResto.RefreshGlows = function(slf, sdb)
            PerfFrameRoll()
            local t0 = debugprofilestop()
            perf.origRefresh(slf, sdb)
            local dt = debugprofilestop() - t0
            local r = perf.refresh
            r.calls   = r.calls + 1
            r.totalMs = r.totalMs + dt
            if dt > r.maxMs then r.maxMs = dt end
            _perfFrameTotal = _perfFrameTotal + dt
            _perfFrameRefr  = _perfFrameRefr  + dt
        end

        NHA.Msg("|cff00ff00NHA Perf: ENABLED|r — Evaluate + EvaluateCUFGlow + RefreshGlows. 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
        DruidResto.RefreshGlows = perf.origRefresh
        perf.origEvaluate      = nil
        perf.origRefresh       = 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", "MISC", "ABUND", "END" }
        for _, name in ipairs(order) do
            local b = perf.blocks and perf.blocks[name]
            if b and b.calls > 0 then
                NHA.Msg(string_format(
                    "  %-6s avg=%.3fms max=%.3fms total=%.1fms (%d calls)",
                    name, b.totalMs / b.calls, b.maxMs, b.totalMs, b.calls))
            end
        end

        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 r = perf.refresh
        if r and r.calls > 0 then
            local rRate = elapsed > 0 and (r.calls / elapsed) or 0
            local rAvg  = r.totalMs / r.calls
            NHA.Msg(string_format(
                "|cffffd700REFRESH:|r %d calls (%.1f/s) avg=%.3fms max=%.3fms total=%.1fms",
                r.calls, rRate, rAvg, r.maxMs, r.totalMs))
        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  Refresh=%.3fms)",
                s.totalMs, s.evalMs, s.cufMs, s.cufCount, s.refreshMs))
        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 R=%.3f)",
                s.totalMs, s.evalMs, s.cufMs, s.cufCount, s.refreshMs))
        end
    end,
}

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