-- =============================================================
-- Specs/ShamanRestoration.lua
-- Nem: Healer Alerts — Restoration Shaman spec module.
-- =============================================================

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

local pairs, ipairs       = pairs, ipairs
local string_format       = string.format
local GetTime             = GetTime
local UnitExists          = UnitExists
local UnitAffectingCombat = UnitAffectingCombat
local UnitGUID            = UnitGUID
local C_UnitAuras         = C_UnitAuras
local C_Spell             = C_Spell
local C_Timer             = C_Timer
local issecretvalue       = NHA.issecretvalue

-- =============================================================
-- Spell / Aura IDs
-- =============================================================
local EARTH_SHIELD_IDS = {
    [974]    = true,   -- SpellID, whitelisted
    [383648] = true,   -- SpellID, whitelisted
}
local RIPTIDE_ID                       = 61295    -- SpellID, whitelisted
local HEALING_STREAM_TOTEM_ID          = 5394     -- SpellID
local UNLEASH_LIFE_ID                  = 73685    -- SpellID, NOT whitelisted (cast-event sim)
local CHAIN_HEAL_ID                    = 1064     -- SpellID, UL consumer
local HEALING_WAVE_ID                  = 77472    -- SpellID, UL consumer
local TOTEMIC_MOMENTUM_SPELL_ID        = 1260644  -- SpellID
local WATER_TOTEM_MASTERY_SPELL_ID     = 382030   -- SpellID
local TOTEMIC_HERO_SPELL_ID            = 444995   -- SpellID — Totemic hero-tree anchor (Surging Totem)

local UL_BASE_CD       = 20.0
local UL_BUFF_DURATION = 10.0

local SURGING_TOTEM_SPELL_ID = 444995   -- cast spell ID for Surging Totem (verify in-game if alert never fires)
local ST_WARN_THRESHOLD      = 3.0      -- seconds before expiry to fire alert
local _surgingTotemName               -- cached localized name; resolved lazily on first use

local function _getSurgingTotemName()
    if _surgingTotemName then return _surgingTotemName end
    if C_Spell and C_Spell.GetSpellName then
        local ok, name = pcall(C_Spell.GetSpellName, SURGING_TOTEM_SPELL_ID)
        if ok and type(name) == "string" then
            _surgingTotemName = name
            return name
        end
    end
    if GetSpellInfo then
        local ok, name = pcall(GetSpellInfo, SURGING_TOTEM_SPELL_ID)
        if ok and type(name) == "string" then
            _surgingTotemName = name
            return name
        end
    end
    return nil
end

local TALENT_REPROBE_DELAY = 0.5

local state

-- =============================================================
-- Taint-Safe Helpers
-- =============================================================
local function _toNumber(val)         return val + 0                       end
local function _checkPlayerSource(aura) return aura.sourceUnit == "player" end
local function _checkInstanceID(aura)
    return aura.auraInstanceID ~= nil and aura.auraInstanceID ~= 0
end
local function _checkSpellIs(aura, id)
    return aura.spellId and aura.spellId == id
end
local function _guidsEqual(a, b)      return a == b                        end
local function _greaterThan1_5(v)     return v > 1.5                       end

local function SafeNum(val, fallback)
    if val == nil then return fallback end
    local ok, result = pcall(_toNumber, val)
    if ok then return result end
    return 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 ReadDurationObj(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

-- =============================================================
-- FindAuraOnUnit — two-path scanner
-- =============================================================
local function FindAuraOnUnit(unit, spellId, checkSource)
    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) then
                if not checkSource or IsPlayerOrUnknownSource(aura) then
                    return aura
                end
            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) then
            if not checkSource or IsPlayerOrUnknownSource(aura) then
                return aura
            end
        end
        i = i + 1
    end
    return nil
end

-- =============================================================
-- Earth Shield Coverage
-- =============================================================
local esPresence = {}

local function FindESOnUnit(unit)
    for esID in pairs(EARTH_SHIELD_IDS) do
        local aura = FindAuraOnUnit(unit, esID, true)
        if aura then return aura end
    end
    return nil
end

local function _isPlayerEquivalentUnit(unit)
    if unit == "player" then return true end
    local uGUID = UnitGUID(unit)
    local pGUID = UnitGUID("player")
    if uGUID and pGUID
       and not issecretvalue(uGUID)
       and not issecretvalue(pGUID) then
        local ok, eq = pcall(_guidsEqual, uGUID, pGUID)
        return ok and eq
    end
    return false
end

local function RebuildESPresenceCache()
    wipe(esPresence)
    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 FindESOnUnit(unit) then esPresence[unit] = true end
            if canDedup then seen[guid] = true end
        end
    end
    if not esPresence["player"] and FindESOnUnit("player") then
        esPresence["player"] = true
    end
end

local function UpdateESPresenceForUnit(unit)
    if unit ~= "player" and _isPlayerEquivalentUnit(unit) then return end
    local had = esPresence[unit]
    local has = FindESOnUnit(unit) ~= nil
    if has == (had ~= nil) then return end
    esPresence[unit] = has or nil
end

local function HasPlayerESOnSelf()
    return esPresence["player"] ~= nil
end

local function HasPlayerESOnOther()
    for unit in pairs(esPresence) do
        if unit ~= "player" then return true end
    end
    return false
end

-- =============================================================
-- Riptide Presence Cache
-- =============================================================
local rtPresence = {}

local function RebuildRTPresenceCache()
    wipe(rtPresence)
    local count = 0
    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 FindAuraOnUnit(unit, RIPTIDE_ID, true) then
                rtPresence[unit] = true
                count = count + 1
            end
            if canDedup then seen[guid] = true end
        end
    end
    state.rtCachedCount = count
end

local function UpdateRTPresenceForUnit(unit, updateInfo)
    local had = rtPresence[unit]

    if not updateInfo or updateInfo.isFullUpdate then
        local has = FindAuraOnUnit(unit, RIPTIDE_ID, true) ~= nil
        if has ~= (had ~= nil) then
            rtPresence[unit] = has or nil
            state.rtCachedCount = (state.rtCachedCount or 0) + (has and 1 or -1)
        end
        return
    end

    local touchedRT = false

    if updateInfo.addedAuras then
        for _, aura in ipairs(updateInfo.addedAuras) do
            if not issecretvalue(aura.spellId)
               and aura.spellId == RIPTIDE_ID
               and IsPlayerOrUnknownSource(aura) then
                touchedRT = true
                break
            end
        end
    end

    if not touchedRT and had
       and updateInfo.removedAuraInstanceIDs
       and #updateInfo.removedAuraInstanceIDs > 0 then
        touchedRT = true
    end

    if not touchedRT then return end

    local has = FindAuraOnUnit(unit, RIPTIDE_ID, true) ~= nil
    if has ~= (had ~= nil) then
        rtPresence[unit] = has or nil
        state.rtCachedCount = (state.rtCachedCount or 0) + (has and 1 or -1)
    end
end

local function GetRTThreshold()
    local db = NHA.db
    if IsInRaid and IsInRaid() then
        return db.rtThresholdRaid or 4
    end
    return db.rtThresholdDungeon or 2
end

local function CountActiveRiptide()
    local count = 0
    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 FindAuraOnUnit(unit, RIPTIDE_ID, true) then
                count = count + 1
            end
            if canDedup then seen[guid] = true end
        end
    end
    return count
end

-- =============================================================
-- Healing Stream Totem — charge / cooldown reads
-- =============================================================
local function GetHSTCharges()
    if not C_Spell or not C_Spell.GetSpellCharges then return nil, nil end
    local ok, info = pcall(C_Spell.GetSpellCharges, HEALING_STREAM_TOTEM_ID)
    if not ok or not info then return nil, nil end
    if issecretvalue(info.currentCharges) or issecretvalue(info.maxCharges) then
        return nil, nil
    end
    return info.currentCharges, info.maxCharges
end

-- =============================================================
-- HST Charge Tracking — taint-safe (mirrors PriestHoly PoM pattern)
--
-- In combat, C_Spell.GetSpellCharges and C_Spell.GetSpellCooldown both return
-- secret values for HST under 12.0.5, so neither charge count nor cooldown
-- duration is readable. State is therefore the source of truth: cast events
-- decrement, one-shot timers increment, and the API is consulted only at
-- known-clean moments (OnActivate, PEW, REGEN boundaries, SPELLS_CHANGED).
-- =============================================================
local HST_BASE_CD                      = 25.0
local HST_TOTEMIC_MOMENTUM_REDUCE      = 3.0
local HST_WATER_TOTEM_MASTERY_REDUCE   = 5.0
local HST_DEFAULT_MAX_CHARGES          = 2

local function ComputeHSTCooldown()
    local cd = HST_BASE_CD
    if state and state.hasTotemicMomentum   then cd = cd - HST_TOTEMIC_MOMENTUM_REDUCE   end
    if state and state.hasWaterTotemMastery then cd = cd - HST_WATER_TOTEM_MASTERY_REDUCE end
    return cd
end

local function GetHSTCooldownDuration()
    if C_Spell and C_Spell.GetSpellCooldown then
        local ok, info = pcall(C_Spell.GetSpellCooldown, HEALING_STREAM_TOTEM_ID)
        if ok and info then
            local rawDur = info.duration
            if rawDur ~= nil and not issecretvalue(rawDur) then
                local d = ReadDurationObj(rawDur)
                if type(d) == "number" and not issecretvalue(d) and d > 1.5 then
                    return d
                end
            end
        end
    end
    if state.hstKnownCD and state.hstKnownCD > 1.5 then
        return state.hstKnownCD
    end
    local cd = ComputeHSTCooldown()
    if type(cd) == "number" and cd > 0 then return cd end
    return HST_BASE_CD
end

local function _OnHSTChargeRecover()
    state.hstChargeTimerSet = false
    local cur = state.hstCurCharges or 0
    local max = state.hstMaxCharges or HST_DEFAULT_MAX_CHARGES
    if cur < max then
        state.hstCurCharges = cur + 1
    end
    if (state.hstCurCharges or 0) < max then
        state.hstChargeTimerSet = true
        C_Timer.After(GetHSTCooldownDuration(), _OnHSTChargeRecover)
    end
    NHA.ScheduleEvaluate()
end

local function _OnULBExpire()
    state.ulbSimTimerSet = false
    if not state.ulbSimActive then return end
    local remaining = (state.ulbSimExpire or 0) - GetTime()
    if remaining > 0.05 then
        state.ulbSimTimerSet = true
        C_Timer.After(remaining, _OnULBExpire)
        return
    end
    state.ulbSimActive = false
    state.ulbSimExpire = 0
    NHA.ScheduleEvaluate()
end

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

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

    local maxC = state.hstMaxCharges or HST_DEFAULT_MAX_CHARGES

    if info.currentCharges ~= nil and not issecretvalue(info.currentCharges)
       and type(info.currentCharges) == "number" then
        state.hstCurCharges = info.currentCharges

        if info.currentCharges < maxC
           and info.cooldownStart    ~= nil and not issecretvalue(info.cooldownStart)
           and info.cooldownDuration ~= nil and not issecretvalue(info.cooldownDuration) then
            local startT = info.cooldownStart
            local durT   = info.cooldownDuration
            if type(startT) == "number" and type(durT) == "number"
               and startT > 0 and durT > 0 then
                if durT > 1.5 then state.hstKnownCD = durT end
                local remaining = (startT + durT) - GetTime()
                if remaining > 0 and not state.hstChargeTimerSet then
                    state.hstChargeTimerSet = true
                    C_Timer.After(remaining, _OnHSTChargeRecover)
                end
            end
        end
    else
        if state.hstCurCharges == nil then
            state.hstCurCharges = maxC
        end
    end
end

-- =============================================================
-- Talent Detection (IsPlayerSpell first, taint-safe)
-- =============================================================
local function CheckTotemicMomentumTalent()
    if IsPlayerSpell then
        local ok, has = pcall(IsPlayerSpell, TOTEMIC_MOMENTUM_SPELL_ID)
        if ok and has == true then return true end
    end
    return false
end

local function CheckWaterTotemMasteryTalent()
    if IsPlayerSpell then
        local ok, has = pcall(IsPlayerSpell, WATER_TOTEM_MASTERY_SPELL_ID)
        if ok and has == true then return true end
    end
    return false
end

local function CheckTotemicTalent()
    if IsPlayerSpell then
        local ok, has = pcall(IsPlayerSpell, TOTEMIC_HERO_SPELL_ID)
        if ok and has == true then return true end
    end
    return false
end

-- =============================================================
-- Surging Totem Cache (Pattern E — event-driven totem expiry)
-- =============================================================
local function _refreshSurgingTotemCache()
    state.stTotemSlot = nil
    state.stStartTime = 0
    state.stDuration  = 0
    state.stWarnFired = false
    if not GetTotemInfo then return end
    local expectedName = _getSurgingTotemName()
    for slot = 1, 4 do  -- shamans have 4 totem slots
        local haveTotem, totemName, startTime, duration = GetTotemInfo(slot)
        if haveTotem and expectedName and totemName == expectedName then
            state.stTotemSlot = slot
            state.stStartTime = startTime or 0
            state.stDuration  = duration  or 0
            return
        end
    end
end

-- =============================================================
-- Module State
-- =============================================================
state = {
    esActive            = false,
    esWasActive         = false,

    rtActive            = false,
    rtWasActive         = false,
    rtCachedCount       = 0,

    hstActive           = false,
    hstWasActive        = false,
    hstCurCharges       = HST_DEFAULT_MAX_CHARGES,
    hstMaxCharges       = HST_DEFAULT_MAX_CHARGES,
    hstChargeTimerSet   = false,
    hstSuppressUntil    = 0,
    hstKnownCD          = 0,

    ulActive            = false,
    ulWasReady          = false,
    ulInitialized       = false,
    ulJustCast          = false,
    ulOnRealCD          = false,
    ulCDTimerSet        = false,
    ulCastTime          = 0,
    ulKnownCD           = 0,

    ulbActive           = false,
    ulbWasActive        = false,
    ulbSimActive        = false,
    ulbSimExpire        = 0,
    ulbSimTimerSet      = false,

    stTotemSlot          = nil,    -- 1..MAX_TOTEMS slot index, set on PLAYER_TOTEM_UPDATE
    stStartTime          = 0,
    stDuration           = 0,
    stWarnFired          = false,

    playerAuraPending   = false,
    rosterUpdatePending = false,
}

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

-- =============================================================
-- Alert Definitions  (uniform 25-px stack: 50, 25, 0, -25, -50)
-- =============================================================
local ES_Y  =  50
local RT_Y  =  25
local HST_Y =   0
local UL_Y  = -25

local ALERTS = {
    {
        key          = "es",
        label        = "Earth Shield Missing",
        text         = "* EARTH SHIELD MISSING *",
        defaultColor = { r = 0.910, g = 0.725, b = 0.275 },  -- #E8B946
        hasTimer     = false,
        yOffset      = ES_Y,
        defaultSound = "Shaman: Earth Shield Missing",
        priority     = NHA.VOICE_PRIO_NORMAL,
    },
    {
        key          = "rt",
        label        = "Spread Riptide",
        text         = "* SPREAD RIPTIDE *",
        defaultColor = { r = 0.247, g = 0.796, b = 0.902 },  -- #3FCBE6
        hasTimer     = false,
        yOffset      = RT_Y,
        defaultSound = "Shaman: Spread Riptide",
        priority     = NHA.VOICE_PRIO_CRITICAL,
    },
    {
        key          = "hst",
        label        = "Place Healing Stream",
        text         = "* PLACE HEALING STREAM *",
        defaultColor = { r = 0.310, g = 0.655, b = 0.878 },  -- #4FA7E0
        hasTimer     = false,
        yOffset      = HST_Y,
        defaultSound = "Shaman: Place Healing Stream",
        priority     = NHA.VOICE_PRIO_LOW,
    },
    {
        key          = "ul",
        label        = "Unleash Life Ready",
        text         = "* UNLEASH LIFE READY *",
        defaultColor = { r = 0.239, g = 0.851, b = 0.416 },  -- #3DD96A
        hasTimer     = false,
        yOffset      = UL_Y,
        defaultSound = "Shaman: Unleash Life Ready",
        priority     = NHA.VOICE_PRIO_HIGH,
    },
    {
        key          = "ulb",
        label        = "Consume Unleash Life",
        text         = "* CONSUME UNLEASH LIFE *",
        defaultColor = { r = 0.247, g = 0.796, b = 0.902 },  -- #3FCBE6 (matches Riptide)
        hasTimer     = false,
        yOffset      = UL_Y,
        defaultSound = "Shaman: Unleash Riptide",
        noTest       = true,
        priority     = NHA.VOICE_PRIO_NORMAL,
    },
    {
        key          = "st",
        label        = "Surging Totem Expiring",
        text         = "* RECAST SURGING TOTEM *",
        defaultColor = { r = 0.486, g = 0.831, b = 1.000 },  -- #7CD4FF
        hasTimer     = true,
        yOffset      = -50,
        defaultSound = "Shaman: Surging Totem Expiring",
        priority     = NHA.VOICE_PRIO_HIGH,
    },
}

-- =============================================================
-- Saved-Variable Defaults
-- =============================================================
local DEFAULTS = {
    esEnabled            = true,
    esSoundEnabled       = true,
    esSoundName          = "Shaman: Earth Shield Missing",

    rtEnabled            = true,
    rtSoundEnabled       = true,
    rtSoundName          = "Shaman: Spread Riptide",
    rtThresholdDungeon   = 2,
    rtThresholdRaid      = 4,

    hstEnabled           = true,
    hstSoundEnabled      = true,
    hstSoundName         = "Shaman: Place Healing Stream",

    ulEnabled            = true,
    ulSoundEnabled       = true,
    ulSoundName          = "Shaman: Unleash Life Ready",

    ulbEnabled           = true,
    ulbSoundEnabled      = true,
    ulbSoundName         = "Shaman: Unleash Riptide",

    stEnabled            = true,
    stSoundEnabled       = true,
    stSoundName          = "Shaman: Surging Totem Expiring",

    alertColors = {
        es  = { r = 0.910, g = 0.725, b = 0.275 },
        rt  = { r = 0.247, g = 0.796, b = 0.902 },
        hst = { r = 0.310, g = 0.655, b = 0.878 },
        ul  = { r = 0.239, g = 0.851, b = 0.416 },
        ulb = { r = 0.247, g = 0.796, b = 0.902 },
        st  = { r = 0.486, g = 0.831, b = 1.000 },  -- #7CD4FF
    },
}

-- =============================================================
-- Module Table
-- =============================================================
local ShamanResto = {
    class     = "SHAMAN",
    specIndex = 3,
    specID    = 264,
    specName  = "Restoration",
    alerts    = ALERTS,
    defaults  = DEFAULTS,
    state     = state,

    extraEvents = {
        "UNIT_AURA",
        "UNIT_SPELLCAST_SUCCEEDED",
        "SPELL_UPDATE_COOLDOWN",
        "SPELLS_CHANGED",
        "PLAYER_TOTEM_UPDATE",
    },

    cufRelevantSpells = {
        [RIPTIDE_ID] = true,
    },
}

-- =============================================================
-- ClearAllGlows
-- =============================================================
function ShamanResto:ClearAllGlows()
    for frame in pairs(cufStateMap) do
        if frame._nhaFrameGlowing then NHA:HideFrameGlow(frame) end
    end
    wipe(cufStateMap)
    wipe(cufRTCache)
    wipe(dirtyCUFFrames)
    drainScheduled = false
end

-- =============================================================
-- ClearAllState  (player-metadata flags must NOT be wiped)
-- =============================================================
function ShamanResto:ClearAllState()
    state.esActive          = false
    state.esWasActive       = false

    state.rtActive          = false
    state.rtWasActive       = false
    wipe(rtPresence)
    state.rtCachedCount     = 0

    state.hstActive         = false
    state.hstWasActive      = false
    state.hstCurCharges     = state.hstMaxCharges or HST_DEFAULT_MAX_CHARGES
    state.hstChargeTimerSet = false
    state.hstSuppressUntil  = 0
    state.hstKnownCD        = 0

    state.ulActive          = false
    state.ulWasReady        = false
    state.ulInitialized     = false
    state.ulJustCast        = false
    state.ulOnRealCD        = false
    state.ulCDTimerSet      = false
    state.ulCastTime        = 0

    state.ulbActive         = false
    state.ulbWasActive      = false
    state.ulbSimActive      = false
    state.ulbSimExpire      = 0
    state.ulbSimTimerSet    = false

    -- Surging Totem warn latch — sound-suppressing, must reset across
    -- combat boundaries or the ST warn stays silent next combat if the
    -- totem was still up at combat-exit.
    state.stWarnFired       = false

    state.playerAuraPending   = false
    state.rosterUpdatePending = false

    self:ClearAllGlows()
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

local _DrainGlowDeferred

-- =============================================================
-- DoEvaluateCUFGlow — Riptide present-on-unit glow
-- =============================================================
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
        cufRTCache[frame] = nil
        _perfCUFLastPath = "disabled"
        return
    end

    local rtAura = FindAuraOnUnit(unit, RIPTIDE_ID, true)
    if rtAura then
        local sa = cufRTCache[frame]
        if not sa then sa = {}; cufRTCache[frame] = sa end
        sa.auraInstanceID = rtAura.auraInstanceID
        sa.expirationTime = SafeNum(rtAura.expirationTime, 0)
        sa.duration       = SafeNum(rtAura.duration, 0)
        sa.timeMod        = SafeNum(rtAura.timeMod, 1)
        if sa.timeMod <= 0 then sa.timeMod = 1 end

        local now       = GetTime()
        local remaining = (sa.expirationTime - now) / sa.timeMod
        if remaining > 0 then
            local c = db.alertColors and db.alertColors.rt
                      or { r = 0.247, g = 0.796, b = 0.902 }
            NHA:ShowFrameGlow(frame, c.r, c.g, c.b)
            cufStateMap[frame] = "rt"
            _perfCUFLastPath = "rt"
            return
        end
        cufRTCache[frame] = nil
    else
        cufRTCache[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 ShamanResto: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
        cufRTCache[frame]     = nil
        dirtyCUFFrames[frame] = nil
        return
    end
    dirtyCUFFrames[frame] = unit
    if not drainScheduled then
        drainScheduled = true
        C_Timer.After(0, _DrainGlowDeferred)
    end
end

-- =============================================================
-- RefreshGlows — intentional empty stub
-- =============================================================
function ShamanResto:RefreshGlows(db)
end

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

    -- Earth Shield Missing
    local esMissing = false
    if db.esEnabled then
        local hasOnSelf  = HasPlayerESOnSelf()
        local hasOnOther = HasPlayerESOnOther()
        esMissing = not (hasOnSelf and hasOnOther)
    end
    state.esActive = esMissing
    PerfMarkBlock("ES")

    -- Spread Riptide
    local rtLow = false
    if db.rtEnabled then
        rtLow = (state.rtCachedCount or 0) < GetRTThreshold()
    end
    state.rtActive = rtLow
    PerfMarkBlock("RT")

    -- Healing Stream Totem ready (cast-suppress gate, taint-safe)
    local hstReady = false
    if db.hstEnabled then
        local cur = state.hstCurCharges or 0
        hstReady = (cur >= 1) and ((state.hstSuppressUntil or 0) <= GetTime())
    end
    state.hstActive = hstReady
    PerfMarkBlock("HST")

    -- Unleash Life ready (WG-style state machine)
    local ulReady = false
    if db.ulEnabled
       and C_Spell and C_Spell.GetSpellCooldown then
        local ok, cdInfo = pcall(C_Spell.GetSpellCooldown, UNLEASH_LIFE_ID)
        if ok and cdInfo then
            local start  = cdInfo.startTime or 0
            local rawDur = cdInfo.duration
            if issecretvalue(start) or issecretvalue(rawDur) then
                if state.ulJustCast then
                    ulReady = false
                    state.ulJustCast = false
                    state.ulOnRealCD = true
                elseif state.ulOnRealCD then
                    if state.ulCastTime > 0
                       and (GetTime() - state.ulCastTime) >= UL_BASE_CD then
                        ulReady = true
                    else
                        ulReady = false
                    end
                else
                    ulReady = true
                end
            else
                local dur = ReadDurationObj(rawDur)
                if state.ulJustCast then
                    ulReady = false
                    state.ulJustCast = false
                    state.ulOnRealCD = true
                elseif dur == 0 or (start > 0 and dur > 0 and dur <= 1.5) then
                    ulReady = true
                elseif start > 0 and dur > 1.5 then
                    state.ulOnRealCD = true
                    local cmpOk, big = pcall(_greaterThan1_5, dur)
                    if cmpOk and big then state.ulKnownCD = dur end
                    local remaining = (start + dur) - GetTime()
                    if remaining > 0 and not state.ulCDTimerSet then
                        state.ulCDTimerSet = true
                        C_Timer.After(remaining, function()
                            state.ulCDTimerSet = false
                            NHA.ScheduleEvaluate()
                        end)
                    end
                end
            end
        end
    end
    state.ulActive = ulReady
    PerfMarkBlock("UL")

    -- Unleash Life buff active (cast-event simulation; spellID 73685 is secret-tainted in M+/rated PvP)
    local ulbActive = false
    if db.ulbEnabled and state.ulbSimActive then
        if (state.ulbSimExpire or 0) > GetTime() then
            ulbActive = true
        else
            state.ulbSimActive = false
            state.ulbSimExpire = 0
        end
    end
    state.ulbActive = ulbActive
    PerfMarkBlock("ULB")

    -- Surging Totem expiry (Pattern E, Totemic-gated). Combat-gated per
    -- the suite-wide invariant in [[nha-combat-gate-rule]] — a lingering
    -- totem cast before pull would otherwise trip the warn out of combat.
    if state.hasTotemicTalent and state.stTotemSlot then
        local remaining = (state.stStartTime + state.stDuration) - GetTime()
        if remaining > 0 and remaining <= ST_WARN_THRESHOLD and not state.stWarnFired then
            NHA:PlayAlertSound("st")
            state.stWarnFired = true
        elseif remaining <= 0 then
            -- totem expired; cache will be cleared by the next PLAYER_TOTEM_UPDATE
            state.stWarnFired = false
        end
    end

    -- Edge-trigger sounds
    if esMissing and not state.esWasActive then NHA:PlayAlertSound("es") end
    if rtLow     and not state.rtWasActive then NHA:PlayAlertSound("rt") end

    if hstReady and not state.hstWasActive then
        NHA:PlayAlertSound("hst")
    end

    if ulReady and not state.ulWasReady
       and state.ulOnRealCD and state.ulInitialized then
        NHA:PlayAlertSound("ul")
        state.ulOnRealCD = false
    end
    if not ulReady then state.ulInitialized = true end

    if ulbActive and not state.ulbWasActive then
        NHA:PlayAlertSound("ulb")
    end

    -- Suite-wide rule (see [[nha-combat-gate-rule]]): preserve all latches
    -- across combat boundaries so the next combat's first eval can fire on
    -- actual rising edges instead of resetting to false and re-tripping.
    state.esWasActive  = esMissing
    state.rtWasActive  = rtLow
    state.hstWasActive = hstReady
    state.ulWasReady   = ulReady
    state.ulbWasActive = ulbActive
    PerfMarkBlock("END")
end

-- =============================================================
-- OnActivate
-- =============================================================
function ShamanResto:OnActivate(db)
    state.hasTotemicMomentum   = CheckTotemicMomentumTalent()
    state.hasWaterTotemMastery = CheckWaterTotemMasteryTalent()
    state.hasTotemicTalent     = CheckTotemicTalent()
    RebuildRTPresenceCache()
    RebuildESPresenceCache()
    TryInitHSTChargesFromAPI()
    C_Timer.After(0.3, function()
        if not C_Spell or not C_Spell.GetSpellCooldown then return end
        local okU, cdU = pcall(C_Spell.GetSpellCooldown, UNLEASH_LIFE_ID)
        if okU and cdU then
            local dur = ReadDurationObj(cdU.duration)
            if dur and not issecretvalue(dur) and dur > 1.5 then
                state.ulKnownCD = dur
            end
        end
    end)
    if NHA.RefreshAllCUFs then
        C_Timer.After(0, function() NHA:RefreshAllCUFs() end)
    end
end

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

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

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

        if not state.playerAuraPending then
            state.playerAuraPending = true
            C_Timer.After(0.1, function()
                state.playerAuraPending = false
                NHA.Evaluate()
            end)
        end
        return true
    end

    if event == "SPELL_UPDATE_COOLDOWN" then
        if db.hstEnabled or db.ulEnabled then NHA.ScheduleEvaluate() end
        return true
    end

    if event == "UNIT_SPELLCAST_SUCCEEDED" then
        local unit, _, spellID = ...
        if unit == "player" then
            if spellID == HEALING_STREAM_TOTEM_ID then
                if C_Spell and C_Spell.GetSpellCharges then
                    local apiOk, info = pcall(C_Spell.GetSpellCharges,
                                              HEALING_STREAM_TOTEM_ID)
                    if apiOk and info and info.maxCharges ~= nil
                       and not issecretvalue(info.maxCharges)
                       and type(info.maxCharges) == "number"
                       and info.maxCharges > 0 then
                        state.hstMaxCharges = info.maxCharges
                    end
                end
                local now   = GetTime()
                local cdDur = GetHSTCooldownDuration()
                local max = state.hstMaxCharges or HST_DEFAULT_MAX_CHARGES
                local cur = state.hstCurCharges or 0
                local wasRecharging = (cur < max) and state.hstChargeTimerSet
                if cur > 0 then state.hstCurCharges = cur - 1 end
                state.hstActive    = false
                state.hstWasActive = false
                if not wasRecharging then
                    state.hstSuppressUntil  = now + cdDur
                    state.hstChargeTimerSet = true
                    C_Timer.After(cdDur, _OnHSTChargeRecover)
                end
                NHA.ScheduleEvaluate()
            elseif spellID == UNLEASH_LIFE_ID then
                state.ulJustCast    = true
                state.ulActive      = false
                state.ulCastTime    = GetTime()
                if not state.ulCDTimerSet then
                    state.ulCDTimerSet = true
                    local wait = (state.ulKnownCD and state.ulKnownCD > 1.5)
                                 and state.ulKnownCD or UL_BASE_CD
                    C_Timer.After(wait, function()
                        state.ulCDTimerSet = false
                        NHA.ScheduleEvaluate()
                    end)
                end
                state.ulbSimActive = true
                state.ulbSimExpire = GetTime() + UL_BUFF_DURATION
                if not state.ulbSimTimerSet then
                    state.ulbSimTimerSet = true
                    C_Timer.After(UL_BUFF_DURATION, _OnULBExpire)
                end
                NHA.ScheduleEvaluate()
            elseif spellID == RIPTIDE_ID
                or spellID == CHAIN_HEAL_ID
                or spellID == HEALING_WAVE_ID then
                if state.ulbSimActive then
                    state.ulbSimActive = false
                    state.ulbSimExpire = 0
                    NHA.ScheduleEvaluate()
                end
            end
        end
        return true
    end

    if event == "PLAYER_TALENT_UPDATE"
    or event == "PLAYER_SPECIALIZATION_CHANGED"
    or event == "TRAIT_CONFIG_UPDATED" then
        state.hasTotemicMomentum   = false
        state.hasWaterTotemMastery = false
        state.hasTotemicTalent     = false
        state.ulKnownCD            = 0
        state.hstKnownCD           = 0
        C_Timer.After(TALENT_REPROBE_DELAY, function()
            state.hasTotemicMomentum   = CheckTotemicMomentumTalent()
            state.hasWaterTotemMastery = CheckWaterTotemMasteryTalent()
            state.hasTotemicTalent     = CheckTotemicTalent()
            TryInitHSTChargesFromAPI()
            NHA.ScheduleEvaluate()
        end)
        return false
    end

    if event == "PLAYER_ENTERING_WORLD" then
        state.hasTotemicMomentum   = CheckTotemicMomentumTalent()
        state.hasWaterTotemMastery = CheckWaterTotemMasteryTalent()
        state.hasTotemicTalent     = CheckTotemicTalent()
        RebuildRTPresenceCache()
        RebuildESPresenceCache()
        TryInitHSTChargesFromAPI()
        NHA.ScheduleEvaluate()
        return false
    end

    if event == "SPELLS_CHANGED" then
        TryInitHSTChargesFromAPI()
        NHA.ScheduleEvaluate()
        return false
    end

    if event == "PLAYER_REGEN_DISABLED" then
        RebuildRTPresenceCache()
        RebuildESPresenceCache()
        if db and db.hstEnabled then
            TryInitHSTChargesFromAPI()
        end

        -- Unleash Life opener (mirrors Druid's WG behavior)
        if db and db.ulEnabled and C_Spell and C_Spell.GetSpellCooldown then
            local ok, cdInfo = pcall(C_Spell.GetSpellCooldown, UNLEASH_LIFE_ID)
            if ok and cdInfo then
                local dur   = ReadDurationObj(cdInfo.duration)
                local start = cdInfo.startTime or 0
                local durOk = not issecretvalue(dur)
                local startOk = not issecretvalue(start)
                local isReady = durOk and startOk
                    and (dur == 0 or (start > 0 and dur > 0 and dur <= 1.5))
                if isReady then
                    state.ulInitialized = true
                    state.ulWasReady    = true
                    NHA:PlayAlertSound("ul")
                end
            end
        end

        if NHA.RefreshAllCUFs then NHA:RefreshAllCUFs() end
        NHA.ScheduleEvaluate()
        return false
    end

    if event == "PLAYER_REGEN_ENABLED" then
        self:ClearAllGlows()
        state.hstActive    = false
        state.hstWasActive = false
        TryInitHSTChargesFromAPI()
        NHA.ScheduleEvaluate()
        return false
    end

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

    if event == "PLAYER_TOTEM_UPDATE" then
        if state.hasTotemicTalent then
            _refreshSurgingTotemCache()
            NHA.ScheduleEvaluate()
        end
        return true
    end

    return false
end

-- =============================================================
-- BuildAlertOptions
-- =============================================================
function ShamanResto:BuildAlertOptions(parent, db, helpers)
    local r, g, b = GetClassColor(select(2, UnitClass("player")))
    r, g, b = r or 0.0, g or 0.439, b or 0.867

    local partyBox = helpers.MakeNumericInput(
        "Target Riptides (Party)",
        1, 20,
        function() return db.rtThresholdDungeon or 2 end,
        function(v)
            db.rtThresholdDungeon = v
            NHA.ScheduleEvaluate()
        end)
    partyBox:SetTextColor(r, g, b)

    helpers.Spacer(-28)

    local raidBox = helpers.MakeNumericInput(
        "Target Riptides (Raid)",
        1, 40,
        function() return db.rtThresholdRaid or 4 end,
        function(v)
            db.rtThresholdRaid = v
            NHA.ScheduleEvaluate()
        end)
    raidBox:SetTextColor(r, g, b)

    local partyLbl = partyBox._nhaLabel
    local raidLbl  = raidBox._nhaLabel
    if partyLbl and raidLbl then
        raidLbl:ClearAllPoints()
        raidLbl:SetPoint("LEFT", partyLbl, "RIGHT", partyBox:GetWidth() + 24, 0)
    end
end

-- =============================================================
-- Slash Commands
-- =============================================================
ShamanResto.slashCommands = {
    ["debug"] = function(self, db)
        NHA.Msg("|cffffd700Restoration Shaman Debug:|r")
        NHA.Msg("  enabled: "          .. tostring(db.enabled))
        NHA.Msg("  esEnabled: "        .. tostring(db.esEnabled))
        NHA.Msg("  rtEnabled: "        .. tostring(db.rtEnabled))
        NHA.Msg("  hstEnabled: "       .. tostring(db.hstEnabled))
        NHA.Msg("  ulEnabled: "        .. tostring(db.ulEnabled))
        NHA.Msg("  ulbEnabled: "       .. tostring(db.ulbEnabled))
        NHA.Msg("  cufGlowEnabled: "   .. tostring(db.cufGlowEnabled))
        NHA.Msg("  inCombat: "         .. tostring(UnitAffectingCombat("player")))
        NHA.Msg("  esActive: "         .. tostring(state.esActive))
        NHA.Msg("    onSelf:  "        .. tostring(HasPlayerESOnSelf()))
        NHA.Msg("    onOther: "        .. tostring(HasPlayerESOnOther()))
        NHA.Msg("  rtActive: "         .. tostring(state.rtActive))
        NHA.Msg("  rtCachedCount: "    .. tostring(state.rtCachedCount)
                .. " / live: " .. tostring(CountActiveRiptide())
                .. " / threshold: " .. tostring(GetRTThreshold()))
        local apiCur, apiMax = GetHSTCharges()
        NHA.Msg("  hstActive: "          .. tostring(state.hstActive)
                .. "  hstWasActive: " .. tostring(state.hstWasActive))
        NHA.Msg("  hstCurCharges: "      .. tostring(state.hstCurCharges)
                .. " / hstMaxCharges: " .. tostring(state.hstMaxCharges))
        NHA.Msg("  charges(api live): "  .. tostring(apiCur) .. "/" .. tostring(apiMax))
        NHA.Msg("  hstChargeTimerSet: "  .. tostring(state.hstChargeTimerSet))
        NHA.Msg("  hstKnownCD: "         .. string_format("%.2fs", state.hstKnownCD or 0)
                .. "  computeCD: " .. string_format("%.2fs", ComputeHSTCooldown())
                .. "  effectiveCD: " .. string_format("%.2fs", GetHSTCooldownDuration()))
        NHA.Msg("  ulActive: "         .. tostring(state.ulActive))
        NHA.Msg("  ulOnRealCD: "       .. tostring(state.ulOnRealCD))
        NHA.Msg("  ulKnownCD: "        .. tostring(state.ulKnownCD))
        local ulbStr = "no"
        if state.ulbSimActive then
            local rem = (state.ulbSimExpire or 0) - GetTime()
            if rem < 0 then rem = 0 end
            ulbStr = string_format("%.1fs (sim)", rem)
        end
        NHA.Msg("  ulbActive: "        .. tostring(state.ulbActive)
                .. "  buffRemain: " .. ulbStr)
        NHA.Msg("  hasTotemicMomentum: "    .. tostring(state.hasTotemicMomentum))
        NHA.Msg("  hasWaterTotemMastery: "  .. tostring(state.hasWaterTotemMastery))
    end,

    ["cufglow"] = function(self, db)
        NHA.Msg("|cffffd700CUF Frame Glow Debug:|r")
        NHA.Msg("  cufGlowEnabled: " .. tostring(db.cufGlowEnabled))
        NHA.Msg("  cufHooks: "       .. tostring(NHA._cufHooksInstalled and "installed" or "not installed"))
        local trackedCount = 0
        for _ in pairs(NHA._cufPool or {}) do trackedCount = trackedCount + 1 end
        local rtCacheCount = 0
        for _ in pairs(cufRTCache) do rtCacheCount = rtCacheCount + 1 end
        local dirtyCount = 0
        for _ in pairs(dirtyCUFFrames) do dirtyCount = dirtyCount + 1 end
        local stateRTCount = 0
        for _, kind in pairs(cufStateMap) do
            if kind == "rt" then stateRTCount = stateRTCount + 1 end
        end
        NHA.Msg("  tracked CUFs: "    .. trackedCount)
        NHA.Msg("  in combat: "       .. tostring(UnitAffectingCombat("player")))
        NHA.Msg("  cufRTCache size: " .. rtCacheCount)
        NHA.Msg("  dirtyCUFFrames: "  .. dirtyCount)
        NHA.Msg("  glowing (RT): "    .. stateRTCount)

        NHA.Msg("|cffffd700  Per-unit Riptide presence:|r")
        local probedUnits = 0
        for unit in pairs(NHA._unitCUFs or {}) do
            if probedUnits >= 8 then break end
            local existsOk, exists = pcall(UnitExists, unit)
            if existsOk and exists then
                local nameOk, name = pcall(UnitName, unit)
                local rtAura = FindAuraOnUnit(unit, RIPTIDE_ID, true)
                local rtStr  = "no"
                if rtAura then
                    local exp = SafeNum(rtAura.expirationTime, 0)
                    local tm  = SafeNum(rtAura.timeMod, 1)
                    if tm <= 0 then tm = 1 end
                    rtStr = string_format("%.1fs", (exp - GetTime()) / tm)
                end
                local glowKind = "none"
                if NHA._unitCUFs and NHA._unitCUFs[unit] then
                    for f in pairs(NHA._unitCUFs[unit]) do
                        if cufStateMap[f] then
                            glowKind = cufStateMap[f]
                            break
                        end
                    end
                end
                NHA.Msg(string_format(
                    "  %s (%s): RT=%s  glow=%s",
                    unit, (nameOk and tostring(name)) or "?",
                    rtStr, glowKind))
                probedUnits = probedUnits + 1
            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 = { rt = 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  = ShamanResto.Evaluate
        ShamanResto.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 = ShamanResto.RefreshGlows
        ShamanResto.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 — wraps on 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
        ShamanResto.Evaluate     = perf.origEvaluate
        ShamanResto.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 = { "ES", "RT", "HST", "UL", "ULB", "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: RT=%d  NONE=%d  DISABLED=%d",
                cuf.paths.rt 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,
}

-- v12.0.25 — spec not shipped this release; re-enable in v12.0.26 by uncommenting.
-- NHA:RegisterSpec("SHAMAN_RESTO", ShamanResto)
