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

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

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

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

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

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

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

-- Abundance talent node
local ABUNDANCE_NODE_ID       = 103876 -- verified in live (was 82053)
local ABUNDANCE_BUFF_ID       = 207640 -- Abundance stacking player buff (1 stack per Rejuv/Germination)
local ABUNDANCE_STACK_THRESHOLD = 7    -- Alert fires at >= 7 combined Rejuv+Germination stacks

-- Wild Growth cooldown modifiers
-- Base 10s, reduced by talents and tier set bonuses.
-- The fallback timer path uses GetWGDuration() which auto-detects
-- Early Spring talent and S1 4pc set, and scales the 4pc reduction
-- correctly for PvP (arenas: -1s) vs PvE (-2s).
local WG_DURATION_BASE       = 10.0
local WG_EARLY_SPRING_REDUCE =  1.0   -- Hero talent: Early Spring (node 94591)
local WG_4PC_REDUCE_PVE      =  2.0   -- S1 tier 4pc: -2s in PvE
local WG_4PC_REDUCE_PVP      =  1.0   -- S1 tier 4pc: -1s in arenas (PvP scaling)
local EARLY_SPRING_NODE_ID   = 94591  -- "Early Spring" hero talent node
local EARLY_SPRING_ENTRY_ID  = 117895 -- Entry ID for Early Spring (vs Bounteous Bloom 117184)
local EARLY_SPRING_SPELL_ID  = 428937 -- Spell ID used as definitionID fallback check
local WG_4PC_SET_ID          = 1980   -- Sprouts of the Luminous Bloom set ID

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

-- =============================================================
-- Glow Frame Tables (owned entirely by this module)
-- =============================================================
local lbGlowFrames       = {}
local rejuvGlowFrames    = {}
local regrowthGlowFrames = {}

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

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

-- =============================================================
-- Saved-Variable Defaults (merged into core db)
-- =============================================================
local DEFAULTS = {
    lbEnabled        = true,
    sotfEnabled      = true,
    lbTimerShown     = true,
    sotfTimerShown   = true,
    lbSoundEnabled      = true,
    lbSoundName         = "Bell",
    sotfSoundEnabled    = true,
    sotfSoundName       = "Ping",
    wgEnabled           = true,
    wgSoundEnabled      = true,
    wgSoundName         = "Lamp",
    refoEnabled         = true,
    empRegrowthEnabled  = true,
    castlbEnabled       = true,    -- Alert when no Lifebloom active in combat
    abundanceEnabled    = true,    -- Alert when 10+ Rejuvs active (Abundance talent gated)
    abundanceSoundEnabled = true,
    abundanceSoundName  = "Ping",
    alertColors         = {
        lb        = { r = 0.141, g = 0.631, b = 0.282 },
        castlb    = { r = 0.141, g = 0.631, b = 0.282 },
        sotf      = { r = 0.0,   g = 0.898, b = 0.753 },
        wg        = { r = 0.651, g = 0.886, b = 0.180 },
        refo      = { r = 0.204, g = 0.596, b = 0.859 },
        abundance = { r = 1.0, g = 0.820, b = 0.0 },  -- #FFD100
    },
}

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

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

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

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

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

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

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

    return false
end

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

-- =============================================================
-- Utility: Effective Wild Growth cooldown duration
-- Priority: (1) wgKnownCD — real duration stored from a readable
--           API call (ground truth, unaffected by detection gaps)
--           (2) Calculated from detected talent + set flags.
-- base(10) - EarlySpring(0/1) - 4pc(0/1/2 depending on PvP)
-- =============================================================
local function GetWGDuration()
    -- Ground truth: use the last value the CD API gave us directly
    if state.wgKnownCD and state.wgKnownCD > 0 then
        return state.wgKnownCD
    end
    -- Calculated fallback when wgKnownCD not yet populated
    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: Count active player Rejuvs across the raid/party
-- =============================================================
-- =============================================================
-- Utility: Read Abundance stack count from the player buff.
-- The stacking buff maintains 1 stack per active Rejuv/Germination.
-- We scan by index rather than spell ID because the buff's spell ID
-- may differ from the talent's spell ID (207383) — we identify it
-- by sourceUnit=="player" and applications > 0 near the talent.
-- As a fast path we try the two known candidate IDs first, then
-- fall back to a full index scan looking for a player-sourced buff
-- whose name contains "Abundance".
-- =============================================================
-- =============================================================
-- Utility: Count Rejuv + Germination across the group
-- Uses the exact same two-path pattern as MonkMistweaver
-- CountActiveRenewingMist / FindAuraOnUnit:
--   Path 1: GetAuraDataBySpellID (fast)
--   Path 2: GetAuraDataByIndex fallback (follower dungeons etc.)
-- sourceUnit is secret on other players in M+ — LBSourceIsPlayer
-- accepts a tainted sourceUnit as "ours" (sole Resto Druid).
-- HasValidLBInstID guards the auraInstanceID boolean test.
-- Each target can contribute up to 2 (Rejuv + Germination).
-- =============================================================
local function FindRejuvOnUnit(unit, spellId)
    if not UnitExists(unit) then return nil end
    if not C_UnitAuras then return nil end

    local function sourceOK(aura)
        local ok, result = pcall(function() return aura.sourceUnit == "player" end)
        if not ok then return true end  -- secret in M+ → accept as ours
        return result
    end

    local function instOK(aura)
        local ok, result = pcall(function()
            return aura.auraInstanceID ~= nil and aura.auraInstanceID ~= 0
        end)
        if not ok then return true end
        return result
    end

    -- Path 1: direct spell ID lookup
    if C_UnitAuras.GetAuraDataBySpellID then
        local ok, aura = pcall(C_UnitAuras.GetAuraDataBySpellID,
            unit, spellId, "HELPFUL")
        if ok and aura and instOK(aura) and sourceOK(aura) then
            return aura
        end
    end
    -- Path 2: index scan fallback
    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
        local spellOk, match = pcall(function() return aura.spellId == spellId end)
        if spellOk and match and sourceOK(aura) then return aura end
        i = i + 1
    end
    return nil
end

local function CountRejuvGerminationStacks()
    local count = 0
    local seen  = {}
    for _, unit in ipairs(NHA:GetGroupUnits()) do
        local guid = UnitGUID(unit)
        local alreadySeen = false
        if guid then
            local ok, result = pcall(function() return seen[guid] end)
            if ok then alreadySeen = result end
        end
        if not alreadySeen then
            -- Check each spell ID independently — Rejuv AND Germination
            -- can both be active on the same target (2 stacks)
            for rejuvID in pairs(REJUV_IDS) do
                if FindRejuvOnUnit(unit, rejuvID) then
                    count = count + 1
                end
            end
            if guid then pcall(function() seen[guid] = true end) end
        end
    end
    return count
end

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

-- =============================================================
-- Lifebloom Scan
-- =============================================================
local function ScanUnitForLB(unit)
    if not UnitExists(unit) then return nil end
    if C_UnitAuras and C_UnitAuras.GetAuraDataBySpellID then
        for spellId in pairs(LIFEBLOOM_IDS) do
            local aura = C_UnitAuras.GetAuraDataBySpellID(unit, spellId, "HELPFUL")
            if aura and aura.auraInstanceID ~= 0
                and aura.sourceUnit == "player" then
                return aura
            end
        end
        return nil
    end
    local i = 1
    while true do
        local aura = C_UnitAuras.GetAuraDataByIndex(unit, i, "HELPFUL")
        if not aura then break end
        if issecretvalue(aura.spellId) then
            -- skip
        elseif LIFEBLOOM_IDS[aura.spellId]
            and aura.sourceUnit == "player" then
            return aura
        end
        i = i + 1
    end
    return nil
end

local function FullScanForLB()
    for _, unit in ipairs(NHA:GetGroupUnits()) do
        local aura = ScanUnitForLB(unit)
        if aura then return unit, aura end
    end
    return nil, nil
end

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

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

-- =============================================================
-- Taint-Safe LB Group Scan  (Cast Lifebloom alert)
-- Mirrors MonkMistweaver CountActiveRenewingMist pattern.
-- In M+/rated content aura.sourceUnit and aura.auraInstanceID
-- are secret values — direct comparisons throw. pcall every
-- field access, and accept a tainted sourceUnit as "ours"
-- (only one Resto Druid per group).
-- =============================================================
local function HasValidLBInstID(aura)
    if not aura then return false end
    local ok, result = pcall(function()
        return aura.auraInstanceID ~= nil and aura.auraInstanceID ~= 0
    end)
    if not ok then return true end  -- secret = exists, accept it
    return result
end

local function LBSourceIsPlayer(aura)
    if not aura then return false end
    local ok, result = pcall(function()
        return aura.sourceUnit == "player"
    end)
    if not ok then return true end  -- tainted in M+: accept (sole Resto Druid)
    return result
end

local function FindLBOnUnit(unit)
    if not UnitExists(unit) then return nil end
    if not C_UnitAuras then return nil end
    -- Path 1: direct spell ID lookup (fast)
    if C_UnitAuras.GetAuraDataBySpellID then
        for spellId in pairs(LIFEBLOOM_IDS) do
            local ok, aura = pcall(C_UnitAuras.GetAuraDataBySpellID,
                unit, spellId, "HELPFUL")
            if ok and aura and HasValidLBInstID(aura)
               and LBSourceIsPlayer(aura) then
                return aura
            end
        end
    end
    -- Path 2: index scan fallback (follower dungeons / edge cases)
    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
        local spellOk, isLB = pcall(function()
            return aura.spellId and LIFEBLOOM_IDS[aura.spellId] ~= nil
        end)
        if spellOk and isLB and LBSourceIsPlayer(aura) then return aura end
        i = i + 1
    end
    return nil
end

local function GroupHasLB()
    local seen = {}
    for _, unit in ipairs(NHA:GetGroupUnits()) do
        local guid = UnitGUID(unit)
        local alreadySeen = false
        if guid then
            local ok, result = pcall(function() return seen[guid] end)
            if ok then alreadySeen = result end
        end
        if not alreadySeen then
            if FindLBOnUnit(unit) then return true end
            if guid then pcall(function() seen[guid] = true end) 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

-- =============================================================
-- Empowered Regrowth Active Scan
-- =============================================================
local function ScanForEmpoweredRegrowth(db)
    if not db or not db.empRegrowthEnabled or not db.glowEnabled then return end
    if not state.empRegrowthPending then return end
    if GetTime() > state.empRegrowthDeadline then return end

    local GlobalAuras = _G.C_UnitAuras
    if not GlobalAuras or not GlobalAuras.GetAuraDataByAuraInstanceID then return end

    local function ScanFramePool(frame)
        if not frame or not frame.auraPools or not frame.unit then return end
        for buffFrame in frame.auraPools:EnumerateActive() do
            if buffFrame.auraInstanceID then
                local ok, aura = pcall(GlobalAuras.GetAuraDataByAuraInstanceID,
                    frame.unit, buffFrame.auraInstanceID)
                if ok and aura and aura.spellId
                   and not issecretvalue(aura.spellId)
                   and aura.spellId == REGROWTH_ID
                   and aura.sourceUnit == "player" then
                    local instID = aura.auraInstanceID
                    if instID and instID ~= 0 then
                        state.empRegrowthInstIDs[instID] = true
                        regrowthGlowFrames[buffFrame] = instID
                        local sc = db.alertColors.sotf
                        NHA:ShowProcGlow(buffFrame, sc.r, sc.g, sc.b)
                    end
                end
            end
        end
    end

    ScanFramePool(TargetFrame)
    ScanFramePool(FocusFrame)

    if CompactRaidFrameContainer then
        local ok, children = pcall(function()
            return { CompactRaidFrameContainer:GetChildren() }
        end)
        if ok and children then
            for _, unitFrame in ipairs(children) do
                if unitFrame.buffFrames then
                    for _, buffFrame in ipairs(unitFrame.buffFrames) do
                        if buffFrame:IsShown() and buffFrame.auraInstanceID then
                            local unit = unitFrame.unit
                            if unit then
                                local ok2, aura = pcall(
                                    GlobalAuras.GetAuraDataByAuraInstanceID,
                                    unit, buffFrame.auraInstanceID)
                                if ok2 and aura and aura.spellId
                                   and not issecretvalue(aura.spellId)
                                   and aura.spellId == REGROWTH_ID
                                   and aura.sourceUnit == "player" then
                                    local instID = aura.auraInstanceID
                                    if instID and instID ~= 0 then
                                        state.empRegrowthInstIDs[instID] = true
                                        regrowthGlowFrames[buffFrame] = instID
                                        local sc = db.alertColors.sotf
                                        NHA:ShowProcGlow(buffFrame,
                                            sc.r, sc.g, sc.b)
                                    end
                                end
                            end
                        end
                    end
                end
            end
        end
    end

    if state._debugTrace then
        local instCount = 0
        for _ in pairs(state.empRegrowthInstIDs) do instCount = instCount + 1 end
        local frameCount = 0
        for _ in pairs(regrowthGlowFrames) do frameCount = frameCount + 1 end
        NHA.Msg("|cff00ff00[TRACE]|r Scan complete. Tagged " .. instCount
            .. " instance(s), " .. frameCount .. " frame(s) glowing.")
        state._debugTrace = false
    end
end

-- =============================================================
-- MODULE TABLE
-- =============================================================
local DruidResto = {
    class     = "DRUID",
    specIndex = 4,
    specName  = "Restoration",
    alerts    = ALERTS,
    defaults  = DEFAULTS,
    state     = state,

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

-- =============================================================
-- ClearAllGlows
-- =============================================================
function DruidResto:ClearAllGlows()
    for buffFrame in pairs(lbGlowFrames) do
        NHA:HideProcGlow(buffFrame)
    end
    wipe(lbGlowFrames)
    for buffFrame in pairs(rejuvGlowFrames) do
        NHA:HideProcGlow(buffFrame)
    end
    wipe(rejuvGlowFrames)
    for buffFrame in pairs(regrowthGlowFrames) do
        NHA:HideProcGlow(buffFrame)
    end
    wipe(regrowthGlowFrames)
    state.empRegrowthPending  = false
    state.empRegrowthDeadline = 0
    wipe(state.empRegrowthInstIDs)
end

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

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

-- =============================================================
-- RefreshGlows (called every 0.25s by ticker)
-- =============================================================
function DruidResto:RefreshGlows(db)
    if not db or not db.glowEnabled then return end
    local now = GetTime()
    local c   = db.alertColors and db.alertColors.lb
                or { r=0.141, g=0.631, b=0.282 }
    for buffFrame, aura in pairs(lbGlowFrames) do
        if buffFrame.auraInstanceID ~= aura.auraInstanceID then
            NHA:HideProcGlow(buffFrame)
            lbGlowFrames[buffFrame] = nil
        else
            local remaining = (aura.expirationTime - now) / aura.timeMod
            if remaining > 0 and remaining <= LB_PANIC_THRESHOLD then
                NHA:ShowProcGlow(buffFrame, c.r, c.g, c.b)
            elseif remaining <= 0 then
                NHA:HideProcGlow(buffFrame)
                lbGlowFrames[buffFrame] = nil
            else
                NHA:HideProcGlow(buffFrame)
            end
        end
    end
end

-- =============================================================
-- EvaluateFrameGlow (called from hook pipeline)
-- =============================================================
function DruidResto:EvaluateFrameGlow(db, buffFrame, aura)
    if not db or not db.glowEnabled or not db.enabled then
        NHA:HideProcGlow(buffFrame)
        lbGlowFrames[buffFrame]       = nil
        rejuvGlowFrames[buffFrame]    = nil
        regrowthGlowFrames[buffFrame] = nil
        return
    end

    local tracked = lbGlowFrames[buffFrame] or rejuvGlowFrames[buffFrame]
                    or regrowthGlowFrames[buffFrame]
    if not aura then
        if tracked then
            NHA:HideProcGlow(buffFrame)
            lbGlowFrames[buffFrame]       = nil
            rejuvGlowFrames[buffFrame]    = nil
            regrowthGlowFrames[buffFrame] = nil
        end
        return
    end

    if issecretvalue(aura.spellId) then
        if tracked then
            NHA:HideProcGlow(buffFrame)
            lbGlowFrames[buffFrame]       = nil
            rejuvGlowFrames[buffFrame]    = nil
            regrowthGlowFrames[buffFrame] = nil
        end
        return
    end

    if aura.sourceUnit ~= "player" then
        if tracked then
            NHA:HideProcGlow(buffFrame)
            lbGlowFrames[buffFrame]       = nil
            rejuvGlowFrames[buffFrame]    = nil
            regrowthGlowFrames[buffFrame] = nil
        end
        return
    end

    -- Lifebloom
    if LIFEBLOOM_IDS[aura.spellId] then
        if rejuvGlowFrames[buffFrame] then
            NHA:HideProcGlow(buffFrame)
            rejuvGlowFrames[buffFrame] = nil
        end
        if not buffFrame._safeNHAAura then buffFrame._safeNHAAura = {} end
        local sa = buffFrame._safeNHAAura
        sa.auraInstanceID = aura.auraInstanceID
        sa.expirationTime = aura.expirationTime or 0
        sa.duration       = aura.duration or 0
        sa.timeMod        = (aura.timeMod and aura.timeMod > 0) and aura.timeMod or 1
        lbGlowFrames[buffFrame] = sa

        local now       = GetTime()
        local remaining = (sa.expirationTime - now) / sa.timeMod
        if remaining > 0 and remaining <= LB_PANIC_THRESHOLD then
            local c = db.alertColors.lb
            NHA:ShowProcGlow(buffFrame, c.r, c.g, c.b)
        else
            NHA:HideProcGlow(buffFrame)
        end
        return
    end

    -- Empowered Rejuv / Germination
    if REJUV_IDS[aura.spellId]
        and SOTF_EMPOWERED_ICONS[aura.icon] then
        if lbGlowFrames[buffFrame] then
            NHA:HideProcGlow(buffFrame)
            lbGlowFrames[buffFrame] = nil
        end
        if regrowthGlowFrames[buffFrame] then
            NHA:HideProcGlow(buffFrame)
            regrowthGlowFrames[buffFrame] = nil
        end
        rejuvGlowFrames[buffFrame] = true
        local c = db.alertColors.sotf
        NHA:ShowProcGlow(buffFrame, c.r, c.g, c.b)
        return
    end

    -- Empowered Regrowth
    if aura.spellId == REGROWTH_ID and db.empRegrowthEnabled then
        local instID = aura.auraInstanceID
        if instID and instID ~= 0
           and state.empRegrowthPending
           and GetTime() <= state.empRegrowthDeadline
           and not state.empRegrowthInstIDs[instID] then
            state.empRegrowthInstIDs[instID] = true
        end
        local isEmpowered = instID and state.empRegrowthInstIDs[instID]
        if isEmpowered or (instID and regrowthGlowFrames[buffFrame] == instID) then
            if lbGlowFrames[buffFrame] then
                NHA:HideProcGlow(buffFrame)
                lbGlowFrames[buffFrame] = nil
            end
            if rejuvGlowFrames[buffFrame] then
                NHA:HideProcGlow(buffFrame)
                rejuvGlowFrames[buffFrame] = nil
            end
            regrowthGlowFrames[buffFrame] = instID
            local sc = db.alertColors.sotf
            NHA:ShowProcGlow(buffFrame, sc.r, sc.g, sc.b)
            return
        end
        if regrowthGlowFrames[buffFrame] and regrowthGlowFrames[buffFrame] ~= instID then
            NHA:HideProcGlow(buffFrame)
            regrowthGlowFrames[buffFrame] = nil
        end
    end

    -- Not a spell we care about
    NHA:HideProcGlow(buffFrame)
    lbGlowFrames[buffFrame]       = nil
    rejuvGlowFrames[buffFrame]    = nil
    regrowthGlowFrames[buffFrame] = nil
end

-- =============================================================
-- Evaluate (called by core ticker and events)
-- =============================================================
function DruidResto:Evaluate(db)
    -- ── Lifebloom pandemic check ─────────────────────────────
    if state.lbUnit and state.lbInstID then
        local aura = C_UnitAuras.GetAuraDataByAuraInstanceID(
            state.lbUnit, state.lbInstID)
        if aura and not issecretvalue(aura.spellId)
            and LIFEBLOOM_IDS[aura.spellId] and aura.sourceUnit == "player" then
            StoreLBAura(state.lbUnit, aura)
        else
            local u, a = FullScanForLB()
            if u and a then StoreLBAura(u, a) else ClearLBTracking() end
        end
    else
        local u, a = FullScanForLB()
        if u and a then StoreLBAura(u, a) else ClearLBTracking() end
    end

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

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

    -- ── Wild Growth cooldown check ────────────────────────────
    -- GetWGDuration() returns the effective CD accounting for:
    --   Early Spring talent (-1s), S1 4pc PvE (-2s) or PvP (-1s).
    -- Used only for the taint-fallback path in M+; the real CD API
    -- handles the normal path automatically.
    local wgFallbackDur = GetWGDuration()
    local wgReady = false
    if db.wgEnabled and UnitAffectingCombat("player") then
        local cdInfo = C_Spell.GetSpellCooldown(WILD_GROWTH_ID)
        local start = cdInfo and cdInfo.startTime or 0
        local dur   = cdInfo and cdInfo.duration  or 0
        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   -- store real duration for M+ fallback
            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

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

    -- ── Empowered Regrowth deadline expiry ───────────────────
    if state.empRegrowthPending
       and GetTime() > state.empRegrowthDeadline then
        state.empRegrowthPending = false
    end

    -- ── Cast Lifebloom (no Lifebloom active in combat) ────────
    -- Uses the same taint-safe group scan pattern as MonkMistweaver
    -- CountActiveRenewingMist — pcall-wraps all aura field accesses
    -- so secret values in M+/rated content never crash Evaluate.
    local inCombat = UnitAffectingCombat("player")
    local castLbFire = false
    if db.castlbEnabled and inCombat then
        castLbFire = not GroupHasLB()
    end
    state.castlbActive = castLbFire

    -- ── Abundance: Rejuv+Germination group count → Cast Regrowth ─
    -- Uses whitelisted spell IDs (774, 155777) — both are non-secret
    -- in M+/rated per Blizzard's Midnight healer buff whitelist.
    local abundanceFire = false
    if db.abundanceEnabled and state.hasAbundanceTalent and inCombat then
        abundanceFire = CountRejuvGerminationStacks() >= ABUNDANCE_STACK_THRESHOLD
    end
    state.abundanceActive = abundanceFire

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

-- =============================================================
-- OnActivate
-- =============================================================
function DruidResto:OnActivate(db)
    state.hasRefoTalent          = CheckRefoTalent()
    state.hasAbundanceTalent     = CheckAbundanceTalent()
    state.hasEarlySpringTalent   = CheckEarlySpringTalent()
    state.has4pcWGSet            = CheckWG4pcSet()
    SyncSwiftmendCount()
    -- Opportunistically pre-populate wgKnownCD if WG is already on
    -- cooldown at login/reload (e.g. player reloaded mid-dungeon shortly
    -- after casting). Falls back to GetWGDuration() from talent+set
    -- detection if WG is off CD (duration = 0).
    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 ok, dur = pcall(function() return cdInfo.duration + 0 end)
        if ok and dur and dur > 1.5 then
            state.wgKnownCD = dur
        end
    end)
end

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

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

        -- Empowered Regrowth cleanup
        if updateInfo and updateInfo.removedAuraInstanceIDs
           and next(state.empRegrowthInstIDs) then
            for _, instID in ipairs(updateInfo.removedAuraInstanceIDs) do
                if state.empRegrowthInstIDs[instID] then
                    state.empRegrowthInstIDs[instID] = nil
                    for buffFrame, trackedID in pairs(regrowthGlowFrames) do
                        if trackedID == instID then
                            NHA:HideProcGlow(buffFrame)
                            regrowthGlowFrames[buffFrame] = nil
                        end
                    end
                end
            end
        end

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

        -- Player (SOTF buff)
        if unit == "player" then
            if not updateInfo or updateInfo.isFullUpdate
               or updateInfo.addedAuras or updateInfo.removedAuraInstanceIDs then
                if not state.playerAuraPending then
                    state.playerAuraPending = true
                    C_Timer.After(0.1, function()
                        state.playerAuraPending = false
                        NHA.Evaluate()
                    end)
                end
            end
            return true
        end

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

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

    if event == "UNIT_SPELLCAST_SUCCEEDED" then
        local unit, _, spellID = ...
        if unit == "player" then
            if spellID == WILD_GROWTH_ID then
                state.wgJustCast = true
                state.wgActive   = false
                state.wgCastTime = GetTime()
                if not state.wgCDTimerSet then
                    state.wgCDTimerSet = true
                    C_Timer.After(GetWGDuration(), function()
                        state.wgCDTimerSet = false
                        NHA.ScheduleEvaluate()
                    end)
                end
                NHA.ScheduleEvaluate()
            end
            if spellID == SWIFTMEND_ID then
                state.swiftmendCount = state.swiftmendCount + 1
                if state.swiftmendCount >= 4 then
                    state.swiftmendCount = 0
                    state.refoActive = false
                end
                state.sotfProcPending = true
                state.sotfExpire  = GetTime() + SOTF_BUFF_DURATION
                state.sotfTimeMod = 1
                C_Timer.After(0.1, NHA.Evaluate)
                C_Timer.After(0.3, NHA.Evaluate)
            elseif SOTF_CONSUMER_IDS[spellID] then
                if spellID == REGROWTH_ID and db.empRegrowthEnabled
                   and (state.sotfActive or state.sotfProcPending) then
                    state.empRegrowthPending  = true
                    state.empRegrowthDeadline = GetTime() + 0.75
                    if state._debugTrace then
                        NHA.Msg("|cff00ff00[TRACE]|r empRegrowthPending SET. sotfActive="
                            .. tostring(state.sotfActive)
                            .. " sotfProcPending=" .. tostring(state.sotfProcPending))
                    end
                    C_Timer.After(0.1, function() ScanForEmpoweredRegrowth(db) end)
                    C_Timer.After(0.3, function() ScanForEmpoweredRegrowth(db) end)
                    C_Timer.After(0.5, function() ScanForEmpoweredRegrowth(db) end)
                else
                    if spellID == REGROWTH_ID then
                        wipe(state.empRegrowthInstIDs)
                        for buffFrame in pairs(regrowthGlowFrames) do
                            NHA:HideProcGlow(buffFrame)
                        end
                        wipe(regrowthGlowFrames)
                    end
                    if state._debugTrace and spellID == REGROWTH_ID then
                        NHA.Msg("|cffff4444[TRACE]|r Regrowth cast but NOT empowered.")
                        state._debugTrace = false
                    end
                end
                state.sotfProcPending = false
                state.sotfActive      = false
                state.sotfExpire      = 0
                state.sotfWasActive   = false
                NHA.ScheduleEvaluate()
            end
        end
        return true
    end

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

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

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

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

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

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

    return false
end

-- =============================================================
-- BuildOptions (spec-specific options in the Display section)
-- =============================================================
function DruidResto:BuildOptions(parent, db, helpers)
    -- Right-column checkboxes in the Display section
    helpers.MakeCheckbox("LB Timer", 230, -90,
        function() return db.lbTimerShown end,
        function(v)
            db.lbTimerShown = v and true or false
            if not db.lbTimerShown then
                local f = NHA.display
                if f then
                    for _, row in ipairs(f.alertRows) do
                        if row.key == "lb" and row.timerFS then
                            row.timerFS:Hide()
                        end
                    end
                end
            end
        end)

    helpers.MakeCheckbox("SOTF Timer", 230, -114,
        function() return db.sotfTimerShown end,
        function(v)
            db.sotfTimerShown = v and true or false
            if not db.sotfTimerShown then
                local f = NHA.display
                if f then
                    for _, row in ipairs(f.alertRows) do
                        if row.key == "sotf" and row.timerFS then
                            row.timerFS:Hide()
                        end
                    end
                end
            end
        end)
end

-- =============================================================
-- Slash Commands (spec-specific)
-- =============================================================
DruidResto.slashCommands = {
    ["debug"] = function(self, db)
        local GA = _G.C_UnitAuras
        NHA.Msg("|cffffd700Empowered Regrowth Debug:|r")
        NHA.Msg("  empRegrowthEnabled: " .. tostring(db.empRegrowthEnabled))
        NHA.Msg("  glowEnabled: " .. tostring(db.glowEnabled))
        NHA.Msg("  empRegrowthPending: " .. tostring(state.empRegrowthPending))
        NHA.Msg("  empRegrowthDeadline: " .. string.format("%.1f", state.empRegrowthDeadline))
        NHA.Msg("  sotfActive: " .. tostring(state.sotfActive))
        NHA.Msg("  sotfProcPending: " .. tostring(state.sotfProcPending))
        local count = 0
        for _ in pairs(state.empRegrowthInstIDs) do count = count + 1 end
        NHA.Msg("  empRegrowthInstIDs count: " .. count)
        local rgCount = 0
        for _ in pairs(regrowthGlowFrames) do rgCount = rgCount + 1 end
        NHA.Msg("  regrowthGlowFrames count: " .. rgCount)
        NHA.Msg("  local C_UnitAuras.GetAuraDataByAuraInstanceID: "
            .. tostring(C_UnitAuras and C_UnitAuras.GetAuraDataByAuraInstanceID ~= nil))
        NHA.Msg("  global C_UnitAuras.GetAuraDataByAuraInstanceID: "
            .. tostring(GA and GA.GetAuraDataByAuraInstanceID ~= nil))
        if TargetFrame and TargetFrame.auraPools and GA then
            local found = false
            for buffFrame in TargetFrame.auraPools:EnumerateActive() do
                if buffFrame.auraInstanceID then
                    local ok, aura = pcall(GA.GetAuraDataByAuraInstanceID,
                        TargetFrame.unit, buffFrame.auraInstanceID)
                    if ok and aura and aura.spellId == REGROWTH_ID then
                        found = true
                        NHA.Msg("  Target Regrowth: instID=" .. tostring(aura.auraInstanceID)
                            .. " source=" .. tostring(aura.sourceUnit)
                            .. " secret=" .. tostring(issecretvalue(aura.spellId)))
                    end
                end
            end
            if not found then NHA.Msg("  Target: no Regrowth aura found") end
        else
            NHA.Msg("  Target: no auraPools or no global API")
        end
    end,

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

    ["trace"] = function(self, db)
        state._debugTrace = true
        NHA.Msg("Trace |cff00ff00enabled|r. Cast Regrowth with SOTF active — watch chat for trace output.")
    end,
}

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