-- =============================================================
-- Specs/MonkMistweaver.lua
-- Nem: Healer Alerts — Mistweaver Monk spec module.
-- =============================================================

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

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

-- =============================================================
-- Spell / Aura IDs
-- =============================================================
local SOOTHING_MIST_ID       = 115175   -- SpellID; Node 101143, Entry 124933 (live-verified 2026-05-27)
local SOOTHING_MIST_NODE_ID  = 101143   -- TalentID (class tree)
local SOOTHING_MIST_ENTRY_ID = 124933   -- EntryID
local RENEWING_MIST_ID       = 119611   -- SpellID — baseline spell (not a talent tree pickup; no node/entry)
local ENVELOPING_MIST_ID       = 124682   -- SpellID; Node 101134, Entry 124922 (live-verified 2026-05-27)
local ENVELOPING_MIST_NODE_ID  = 101134   -- TalentID (class tree)
local ENVELOPING_MIST_ENTRY_ID = 124922   -- EntryID
local SOOTH_GRACE        = 3.0

local THUNDER_FOCUS_TEA_ID         = 116680  -- SpellID; Node 101133, Entry 124921 (live-verified 2026-05-27)
local THUNDER_FOCUS_TEA_NODE_ID    = 101133  -- TalentID (class tree)
local THUNDER_FOCUS_TEA_ENTRY_ID   = 124921  -- EntryID
local TFT_BASE_CD_SEC              = 30.0
local TFT_HEART_REDUCED_CD_SEC     = 24.0
local TFT_DEFAULT_MAX_CHGS         = 1
local TFT_CAST_SUPPRESS            = 0.5
-- Hero subtree IDs (live-verified 2026-05-27 via C_ClassTalents.GetActiveHeroTalentSpec())
local MASTER_OF_HARMONY_SUBTREE_ID   = 66   -- subTreeID (Master of Harmony hero tree)
local CONDUIT_SUBTREE_ID             = 64   -- subTreeID (Conduit of the Celestials hero tree)

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 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 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 and aura and HasValidInstanceID(aura) then
            if not checkSource or IsPlayerOrUnknownSource(aura) then
                return aura
            end
        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

local function CountActiveRenewingMist(earlyOut)
    local count = 0
    local seen  = {}
    earlyOut = earlyOut or 0
    for _, unit in ipairs(NHA:GetGroupUnits()) do
        local guid     = UnitGUID(unit)
        local canDedup = guid and not issecretvalue(guid)
        if not canDedup or not seen[guid] then
            if FindAuraOnUnit(unit, RENEWING_MIST_ID, true) then
                count = count + 1
                if earlyOut > 0 and count >= earlyOut then
                    return count
                end
            end
            if canDedup then seen[guid] = true end
        end
    end
    return count
end

local rmPresence = {}

local function RebuildRMPresenceCache()
    wipe(rmPresence)
    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, RENEWING_MIST_ID, true) then
                rmPresence[unit] = true
                count = count + 1
            end
            if canDedup then seen[guid] = true end
        end
    end
    state.rmCachedCount = count
end

local function UpdateRMPresenceForUnit(unit, updateInfo)
    local had = rmPresence[unit]

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

    local touchedRM = false

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

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

    if not touchedRM then return end

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

local function GetRMThreshold()
    local db = NHA.db
    if IsInRaid and IsInRaid() then
        return db.rmThresholdRaid or 4
    end
    return db.rmThresholdDungeon or 2
end

local function UnitHasEnvelopingMist(unit)
    if not unit then return false end
    return FindAuraOnUnit(unit, ENVELOPING_MIST_ID, true) ~= nil
end


-- =============================================================
-- Player Channel Detection
-- =============================================================
local function _spellIdEquals(a, b)
    return a == b
end

-- Cached localized name for Soothing Mist. Used as a fallback when the spellID
-- read from UNIT_SPELLCAST_CHANNEL_* or UnitChannelInfo is issecretvalue-tainted
-- and the pcall'd equality check returns false. Refreshed on SPELLS_CHANGED.
local cachedSoothingMistName

local function RefreshSoothingMistName()
    if C_Spell and C_Spell.GetSpellName then
        cachedSoothingMistName = C_Spell.GetSpellName(SOOTHING_MIST_ID)
    end
end
RefreshSoothingMistName()

local function IsPlayerChannelingSoothing()
    if not UnitChannelInfo then return false end
    local name, _, _, _, _, _, _, spellId = UnitChannelInfo("player")
    if not name then return false end
    local ok, match = pcall(_spellIdEquals, spellId, SOOTHING_MIST_ID)
    if ok and match then return true end
    if cachedSoothingMistName and name == cachedSoothingMistName then return true end
    return false
end

local cachedSoothUnit = nil

local function FindPlayerSoothingTarget()
    if not IsPlayerChannelingSoothing() then
        cachedSoothUnit = nil
        return nil
    end

    if cachedSoothUnit then
        if cachedSoothUnit == "player" then
            return "player"
        end
        if UnitExists(cachedSoothUnit)
           and FindAuraOnUnit(cachedSoothUnit, SOOTHING_MIST_ID, false) then
            return cachedSoothUnit
        end
        cachedSoothUnit = nil
    end

    local pGUID = UnitGUID("player")

    for _, unit in ipairs(NHA:GetGroupUnits()) do
        if unit ~= "player" then
            local aura = FindAuraOnUnit(unit, SOOTHING_MIST_ID, false)
            if aura then
                local uGUID = UnitGUID(unit)
                local sameAsPlayer = false
                if pGUID and uGUID then
                    local ok, eq = pcall(_guidsEqual, uGUID, pGUID)
                    sameAsPlayer = ok and eq
                end
                local found = sameAsPlayer and "player" or unit
                cachedSoothUnit = found
                return found
            end
        end
    end

    for _, unit in ipairs({ "pet", "partypet1", "partypet2", "partypet3", "partypet4" }) do
        if UnitExists(unit) and FindAuraOnUnit(unit, SOOTHING_MIST_ID, false) then
            cachedSoothUnit = unit
            return unit
        end
    end

    cachedSoothUnit = "player"
    return "player"
end

-- =============================================================
-- Talent Checks
-- =============================================================
local function CheckEndlessDraughtTalent()
    if C_ClassTalents and C_ClassTalents.GetActiveHeroTalentSpec then
        local ok, active = pcall(C_ClassTalents.GetActiveHeroTalentSpec)
        if ok and active == MASTER_OF_HARMONY_SUBTREE_ID then return true end
    end
    return false
end

local function CheckHeartOfJadeSerpentTalent()
    if C_ClassTalents and C_ClassTalents.GetActiveHeroTalentSpec then
        local ok, active = pcall(C_ClassTalents.GetActiveHeroTalentSpec)
        if ok and active == CONDUIT_SUBTREE_ID then return true end
    end
    return false
end

-- =============================================================
-- Module State
-- =============================================================
state = {
    rmActive             = false,
    rmWasActive          = false,
    rmCachedCount        = 0,
    envActive            = false,
    envWasActive         = false,
    envLastSeenTime      = 0,
    soothingUnit         = nil,
    soothingStartTime    = 0,
    playerAuraPending    = false,
    rosterUpdatePending  = false,
    tftActive            = false,
    tftWasActive         = false,
    tftCurCharges        = TFT_DEFAULT_MAX_CHGS,
    tftMaxCharges        = TFT_DEFAULT_MAX_CHGS,
    tftChargeTimerSet    = false,
    tftChargeReadyAt     = 0,
    tftChargeGen         = 0,
    tftLastCastAt        = 0,
    tftSuppressUntil     = 0,
    tftLastRecoverAt     = 0,
    hasEndlessDraughtTalent     = false,
    hasHeartOfJadeSerpentTalent = false,
}

-- =============================================================
-- Utility: Effective Thunder Focus Tea Cooldown
-- Base: 30s flat. Heart of the Jade Serpent (Conduit of the Celestials
-- hero talent) procs +75% recovery rate for 8s on each TFT cast, which
-- maths to a consistent 24s effective CD (8s real time * 1.75 = 14s of
-- "cooldown progress" + remaining 16s of cooldown progress at 1x rate =
-- 8 + 16 = 24s real time to recover).
-- =============================================================
local function GetTFTCooldownDuration()
    -- ASSUMPTION: HoJS talented ⇒ +75% recovery buff applied on every TFT cast.
    -- True in current TWW design (talent auto-procs the buff per cast, no other
    -- gate). If Blizzard ever conditions the buff on a proc / hero-spec / item,
    -- this needs to look at the actual buff state on the player at cast time,
    -- not just the talent flag.
    if state and state.hasHeartOfJadeSerpentTalent then
        return TFT_HEART_REDUCED_CD_SEC
    end
    return TFT_BASE_CD_SEC
end

local function GetTFTEffectiveMaxCharges()
    local apiMax    = state.tftMaxCharges or TFT_DEFAULT_MAX_CHGS
    local talentMax = state.hasEndlessDraughtTalent and 2 or 1
    if apiMax >= talentMax then return apiMax end
    return talentMax
end

local _OnTFTChargeRecover
local _ArmTFTChargeRecover

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

_OnTFTChargeRecover = function()
    -- Cast-suppression: if a cast just landed, the server may not have credited
    -- the new CD yet. Defer briefly so we don't false-ready right after a cast.
    local now   = GetTime()
    local since = now - (state.tftLastCastAt or 0)
    if since < TFT_CAST_SUPPRESS then
        local myGen = state.tftChargeGen
        C_Timer.After(TFT_CAST_SUPPRESS - since + 0.05, function()
            if state.tftChargeGen ~= myGen then return end
            _OnTFTChargeRecover()
        end)
        return
    end
    state.tftChargeTimerSet = false
    state.tftChargeReadyAt  = 0
    state.tftLastRecoverAt  = now
    local cur = state.tftCurCharges or 0
    local max = GetTFTEffectiveMaxCharges()
    if cur < max then
        state.tftCurCharges = cur + 1
    end
    if (state.tftCurCharges or 0) < max then
        _ArmTFTChargeRecover(GetTFTCooldownDuration())
    end
    NHA.ScheduleEvaluate()
end

local function TryInitTFTChargesFromAPI()
    if not (C_Spell and C_Spell.GetSpellCharges) then return end
    local ok, info = pcall(C_Spell.GetSpellCharges, THUNDER_FOCUS_TEA_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.tftMaxCharges = info.maxCharges
    end

    local maxC = state.tftMaxCharges or TFT_DEFAULT_MAX_CHGS

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

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

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

-- =============================================================
-- Alert Definitions
-- =============================================================
local REML_Y = 50
local ENV_Y  = 25
local TFT_Y  =  0

local ALERTS = {
    {
        key          = "rm",
        label        = "Spread Renewing Mist",
        text         = "* SPREAD RENEWING MIST *",
        defaultColor = { r = 0.000, g = 1.000, b = 0.612 },  -- #00FF9C
        hasTimer     = false,
        yOffset      = REML_Y,
        defaultSound = "Monk: Renewing Mist",
        priority     = NHA.VOICE_PRIO_HIGH,
    },
    {
        key          = "env",
        label        = "Enveloping Mist",
        text         = "* ENVELOPING MIST *",
        defaultColor = { r = 0.992, g = 1.000, b = 0.714 },  -- #FDFFB6
        hasTimer     = false,
        yOffset      = ENV_Y,
        defaultSound = "Monk: Enveloping Mist",
        priority     = NHA.VOICE_PRIO_NORMAL,
    },
    {
        key          = "tft",
        label        = "Thunder Focus Tea Ready",
        text         = "* THUNDER FOCUS TEA *",
        defaultColor = { r = 0.298, g = 0.749, b = 1.000 },  -- #4CBFFF
        hasTimer     = false,
        yOffset      = TFT_Y,
        defaultSound = "Monk: Thunder Focus Tea",
        priority     = NHA.VOICE_PRIO_CRITICAL,
    },
}

-- =============================================================
-- Saved-Variable Defaults
-- =============================================================
local DEFAULTS = {
    rmEnabled            = true,
    rmSoundEnabled       = true,
    rmSoundName          = "Monk: Renewing Mist",
    envEnabled           = true,
    envSoundEnabled      = true,
    envSoundName         = "Monk: Enveloping Mist",
    tftEnabled           = true,
    tftSoundEnabled      = true,
    tftSoundName         = "Monk: Thunder Focus Tea",
    rmThresholdDungeon   = 2,
    rmThresholdRaid      = 4,
    alertColors          = {
        rm       = { r = 0.000, g = 1.000, b = 0.612 },  -- #00FF9C
        env      = { r = 0.992, g = 1.000, b = 0.714 },  -- #FDFFB6
        tft      = { r = 0.298, g = 0.749, b = 1.000 },  -- #4CBFFF
    },
}

-- =============================================================
-- Module Table
-- =============================================================
local MonkMW = {
    class     = "MONK",
    specIndex = 2,
    specID    = 270,
    specName  = "Mistweaver",
    alerts    = ALERTS,
    defaults  = DEFAULTS,
    state     = state,

    extraEvents = {
        "UNIT_AURA",
        "UNIT_SPELLCAST_CHANNEL_START",
        "UNIT_SPELLCAST_CHANNEL_STOP",
        "UNIT_SPELLCAST_CHANNEL_UPDATE",
        "UNIT_SPELLCAST_SUCCEEDED",
        "SPELL_UPDATE_CHARGES",
        "SPELLS_CHANGED",
    },

    cufRelevantSpells = {
        [124682] = true,   -- SpellID
        [119611] = true,   -- SpellID
    },
}

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

-- =============================================================
-- ClearAllState
-- =============================================================
function MonkMW:ClearAllState()
    state.rmActive          = false
    state.rmWasActive       = false
    wipe(rmPresence)
    state.rmCachedCount     = 0
    state.envActive         = false
    state.envWasActive      = false
    state.envLastSeenTime   = 0
    state.soothingUnit      = nil
    state.soothingStartTime = 0
    state.playerAuraPending = false
    state.rosterUpdatePending = false
    state.tftActive         = false
    state.tftWasActive      = false
    state.tftCurCharges     = state.tftMaxCharges or TFT_DEFAULT_MAX_CHGS
    state.tftChargeTimerSet = false
    state.tftChargeReadyAt  = 0
    state.tftChargeGen      = (state.tftChargeGen or 0) + 1
    state.tftLastCastAt     = 0
    state.tftSuppressUntil  = 0
    cachedSoothUnit         = nil
    self:ClearAllGlows()
end

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

-- =============================================================
-- Perf Instrumentation
-- =============================================================
local _perfBlockTimer = 0
local _perfCUFLastPath = nil
local _perfFrameStamp = 0
local _perfFrameTotal = 0
local _perfFrameEval  = 0
local _perfFrameCUF   = 0
local _perfFrameRefr  = 0
local _perfFrameCUFCount = 0

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

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

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

local _DrainGlowDeferred

-- =============================================================
-- DoEvaluateCUFGlow
-- =============================================================
local function DoEvaluateCUFGlow(self, db, frame, unit)
    if not db or not db.enabled or not db.cufGlowEnabled then
        if cufStateMap[frame] then
            NHA:HideFrameGlow(frame)
            cufStateMap[frame] = nil
        end
        cufRMCache[frame] = nil
        cufEMCache[frame] = nil
        _perfCUFLastPath = "disabled"
        return
    end

    local emAura = FindAuraOnUnit(unit, ENVELOPING_MIST_ID, true)
    if emAura then
        local sa = cufEMCache[frame]
        if not sa then sa = {}; cufEMCache[frame] = sa end
        sa.auraInstanceID = emAura.auraInstanceID
        sa.expirationTime = SafeNum(emAura.expirationTime, 0)
        sa.duration       = SafeNum(emAura.duration, 0)
        sa.timeMod        = SafeNum(emAura.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.env
                      or { r=0.992, g=1.000, b=0.714 }  -- #FDFFB6
            NHA:ShowFrameGlow(frame, c.r, c.g, c.b)
            cufStateMap[frame] = "em"
            cufRMCache[frame] = nil
            _perfCUFLastPath = "em"
            do
                local capturedFrame  = frame
                local capturedExpiry = sa.expirationTime
                C_Timer.After(remaining + 0.5, function()
                    local cached = cufEMCache[capturedFrame]
                    if not cached then return end
                    if cached.expirationTime ~= capturedExpiry then return end
                    local u = capturedFrame._nhaCUFUnit
                    if u then
                        dirtyCUFFrames[capturedFrame] = u
                        if not drainScheduled then
                            drainScheduled = true
                            C_Timer.After(0, _DrainGlowDeferred)
                        end
                    else
                        cufEMCache[capturedFrame] = nil
                    end
                end)
            end
            return
        end
        cufEMCache[frame] = nil
    else
        cufEMCache[frame] = nil
    end

    local rmAura = FindAuraOnUnit(unit, RENEWING_MIST_ID, true)
    if rmAura then
        local sa = cufRMCache[frame]
        if not sa then sa = {}; cufRMCache[frame] = sa end
        sa.auraInstanceID = rmAura.auraInstanceID
        sa.expirationTime = SafeNum(rmAura.expirationTime, 0)
        sa.duration       = SafeNum(rmAura.duration, 0)
        sa.timeMod        = SafeNum(rmAura.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.rm
                      or { r=0, g=1, b=0.612 }  -- #00FF9C
            NHA:ShowFrameGlow(frame, c.r, c.g, c.b)
            cufStateMap[frame] = "rm"
            _perfCUFLastPath = "rm"
            do
                local capturedFrame  = frame
                local capturedExpiry = sa.expirationTime
                C_Timer.After(remaining + 0.5, function()
                    local cached = cufRMCache[capturedFrame]
                    if not cached then return end
                    if cached.expirationTime ~= capturedExpiry then return end
                    local u = capturedFrame._nhaCUFUnit
                    if u then
                        dirtyCUFFrames[capturedFrame] = u
                        if not drainScheduled then
                            drainScheduled = true
                            C_Timer.After(0, _DrainGlowDeferred)
                        end
                    else
                        cufRMCache[capturedFrame] = nil
                    end
                end)
            end
            return
        end
        cufRMCache[frame] = nil
    else
        cufRMCache[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 MonkMW: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
        cufRMCache[frame] = nil
        cufEMCache[frame] = nil
        dirtyCUFFrames[frame] = nil
        return
    end
    dirtyCUFFrames[frame] = unit
    if not drainScheduled then
        drainScheduled = true
        C_Timer.After(0, _DrainGlowDeferred)
    end
end

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

function MonkMW:RefreshGlows(db)
end

-- =============================================================
-- Evaluate
-- =============================================================
function MonkMW:Evaluate(db)
    PerfBeginBlocks()
    local rmLow = false
    if db.rmEnabled then
        rmLow = (state.rmCachedCount or 0) < GetRMThreshold()
    end
    state.rmActive = rmLow
    PerfMarkBlock("RMCOUNT")

    local now = GetTime()
    local soothUnit = state.soothingUnit
    PerfMarkBlock("SOOTH")

    local envMissing  = false
    local pastGrace   = soothUnit
        and state.soothingStartTime > 0
        and (now - state.soothingStartTime) >= SOOTH_GRACE
    if db.envEnabled and pastGrace then
        local hasEnv = UnitHasEnvelopingMist(soothUnit)
        if hasEnv then
            state.envLastSeenTime = now
            envMissing = false
        else
            if state.envLastSeenTime > 0
               and (now - state.envLastSeenTime) < 0.5 then
                envMissing = false
            else
                envMissing = true
            end
        end
    elseif not soothUnit then
        state.envLastSeenTime = 0
    end
    state.envActive = envMissing
    PerfMarkBlock("ENV")

    local tftReady = false
    if db.tftEnabled then
        local now = GetTime()
        local suppressed = (state.tftSuppressUntil or 0) > now
        local cur = state.tftCurCharges or 0
        tftReady = (cur >= 1) and (not suppressed)
    end
    state.tftActive = tftReady
    PerfMarkBlock("TFT")

    if rmLow      and not state.rmWasActive  then NHA:PlayAlertSound("rm")  end
    if envMissing and not state.envWasActive then NHA:PlayAlertSound("env") end
    if tftReady   and not state.tftWasActive then NHA:PlayAlertSound("tft") 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.rmWasActive  = rmLow
    state.envWasActive = envMissing
    state.tftWasActive = tftReady
    PerfMarkBlock("END")
end

-- =============================================================
-- OnActivate
-- =============================================================
function MonkMW:OnActivate(db)
    self:ClearAllState()
    state.hasEndlessDraughtTalent     = CheckEndlessDraughtTalent()
    state.hasHeartOfJadeSerpentTalent = CheckHeartOfJadeSerpentTalent()
    TryInitTFTChargesFromAPI()
    if NHA.RefreshAllCUFs then
        C_Timer.After(0.1, function()
            NHA:RefreshAllCUFs()
        end)
    end
end

-- =============================================================
-- BuildAlertOptions
-- =============================================================
function MonkMW:BuildAlertOptions(parent, db, helpers)
    local r, g, b = GetClassColor(select(2, UnitClass("player")))
    r, g, b = r or 0.0, g or 1.0, b or 0.596

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

    helpers.Spacer(-28)

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

    local _, partyLbl = partyBox:GetPoint(1)
    local _, raidLbl  = raidBox:GetPoint(1)
    if partyLbl and raidLbl
       and partyLbl ~= helpers.alertOptionsBox
       and raidLbl  ~= helpers.alertOptionsBox then
        raidLbl:ClearAllPoints()
        raidLbl:SetPoint("LEFT", partyLbl, "RIGHT", partyBox:GetWidth() + 24, 0)
    end
end

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

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

        local isRaid = IsInRaid and IsInRaid()
        if not (unit == "player" and isRaid) then
            if unit == "player" or UnitInParty(unit) or UnitInRaid(unit) then
                UpdateRMPresenceForUnit(unit, updateInfo)
            end
        end

        if state.soothingUnit and unit == state.soothingUnit then
            NHA.ScheduleEvaluate()
            return true
        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 == "UNIT_SPELLCAST_CHANNEL_START"
    or event == "UNIT_SPELLCAST_CHANNEL_UPDATE"
    or event == "UNIT_SPELLCAST_CHANNEL_STOP" then
        local unit, _, spellID = ...
        if unit ~= "player" then return false end
        local ok, match = pcall(_spellIdEquals, spellID, SOOTHING_MIST_ID)
        if not (ok and match) then
            local name = UnitChannelInfo and (UnitChannelInfo("player"))
            if not (cachedSoothingMistName and name == cachedSoothingMistName) then return false end
        end

        if event == "UNIT_SPELLCAST_CHANNEL_STOP" then
            cachedSoothUnit         = nil
            state.soothingUnit      = nil
            state.soothingStartTime = 0
            state.envLastSeenTime   = 0
            state.envActive         = false
            NHA.ScheduleEvaluate()
            return false
        end

        if event == "UNIT_SPELLCAST_CHANNEL_UPDATE" then
            cachedSoothUnit         = nil
            state.soothingUnit      = nil
            state.soothingStartTime = 0
            state.envLastSeenTime   = 0
            state.envActive         = false
            NHA.ScheduleEvaluate()

            C_Timer.After(0.15, function()
                if not IsPlayerChannelingSoothing() then return end
                cachedSoothUnit = nil
                local newTarget = FindPlayerSoothingTarget()
                state.soothingUnit      = newTarget
                state.soothingStartTime = newTarget and GetTime() or 0
                state.envLastSeenTime   = 0
                if newTarget then
                    local graceTarget = newTarget
                    C_Timer.After(3.0, function()
                        if state.soothingUnit == graceTarget then
                            NHA.Evaluate()
                        end
                    end)
                end
                NHA.ScheduleEvaluate()
            end)
            return false
        end

        cachedSoothUnit         = nil
        state.soothingUnit      = nil
        state.soothingStartTime = 0
        state.envLastSeenTime   = 0

        C_Timer.After(0.15, function()
            if not IsPlayerChannelingSoothing() then return end
            cachedSoothUnit = nil
            local target = FindPlayerSoothingTarget()
            state.soothingUnit      = target
            state.soothingStartTime = target and GetTime() or 0
            state.envLastSeenTime   = 0
            if target then
                local graceTarget = target
                C_Timer.After(3.0, function()
                    if state.soothingUnit == graceTarget then
                        NHA.Evaluate()
                    end
                end)
            end
            NHA.ScheduleEvaluate()
        end)
        return false
    end

    if event == "UNIT_SPELLCAST_SUCCEEDED" then
        local unit, _, spellID = ...
        if unit == "player" and spellID == THUNDER_FOCUS_TEA_ID then
            if C_Spell and C_Spell.GetSpellCharges then
                local apiOk, info = pcall(C_Spell.GetSpellCharges, THUNDER_FOCUS_TEA_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.tftMaxCharges = info.maxCharges
                end
            end
            -- Cast is the source of truth. Hard-suppress alert for the full
            -- predicted CD regardless of what charges API or recover timer says.
            local now   = GetTime()
            local cdDur = GetTFTCooldownDuration()
            if state.tftLogEnabled then
                local prev = state.tftLogLastCastAt or 0
                if prev > 0 then
                    local gap   = now - prev
                    local delta = gap - (state.tftLogLastPredicted or cdDur)
                    NHA.Msg(string_format(
                        "|cffffd700TFT cast gap:|r %.3fs  (predicted %.2fs, real off by %+0.3fs)",
                        gap, state.tftLogLastPredicted or cdDur, delta))
                end
                state.tftLogLastCastAt    = now
                state.tftLogLastPredicted = cdDur
            end
            state.tftLastCastAt    = now
            state.tftSuppressUntil = now + cdDur
            local cur = state.tftCurCharges or 0
            state.tftCurCharges = (cur > 0) and (cur - 1) or 0
            if (state.tftCurCharges or 0) == 0 then
                _ArmTFTChargeRecover(cdDur)
            elseif not state.tftChargeTimerSet then
                _ArmTFTChargeRecover(cdDur)
            end
            C_Timer.After(cdDur + 0.01, function()
                NHA.ScheduleEvaluate()
            end)
            NHA.ScheduleEvaluate()
        end
        return true
    end

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

        local changed = false
        -- Hard suppression: during the post-cast window, REFUSE to raise
        -- tftCurCharges (API can briefly return non-secret charge=1 mid-CD,
        -- which false-fires the alert). Decreases (external desync correction)
        -- always pass through.
        if info.currentCharges ~= nil and not issecretvalue(info.currentCharges)
           and type(info.currentCharges) == "number"
           and info.currentCharges ~= state.tftCurCharges then
            local suppressed = (state.tftSuppressUntil or 0) > GetTime()
            if not (suppressed and info.currentCharges > (state.tftCurCharges or 0)) then
                state.tftCurCharges = info.currentCharges
                changed = true
            end
        end
        if info.maxCharges ~= nil and not issecretvalue(info.maxCharges)
           and type(info.maxCharges) == "number" and info.maxCharges > 0
           and info.maxCharges ~= state.tftMaxCharges then
            state.tftMaxCharges = info.maxCharges
            changed = true
        end
        if changed then NHA.ScheduleEvaluate() end
        return true
    end

    if event == "SPELLS_CHANGED" then
        RefreshSoothingMistName()
        TryInitTFTChargesFromAPI()
        NHA.ScheduleEvaluate()
        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
                RebuildRMPresenceCache()
                NHA.Evaluate()
            end)
        end
        return true
    end

    if event == "PLAYER_REGEN_ENABLED" then
        self:ClearAllGlows()
        cachedSoothUnit         = nil
        state.soothingUnit      = nil
        state.soothingStartTime = 0
        state.envLastSeenTime   = 0
        state.envActive         = false
        state.rmActive          = false
        state.tftActive         = false
        NHA.ScheduleEvaluate()
        return false
    end

    if event == "PLAYER_REGEN_DISABLED" then
        RebuildRMPresenceCache()
        TryInitTFTChargesFromAPI()
        if NHA.RefreshAllCUFs then
            NHA:RefreshAllCUFs()
        end
        NHA.ScheduleEvaluate()
        return false
    end

    if event == "PLAYER_TALENT_UPDATE"
    or event == "PLAYER_SPECIALIZATION_CHANGED"
    or event == "TRAIT_CONFIG_UPDATED" then
        state.hasEndlessDraughtTalent     = false
        state.hasHeartOfJadeSerpentTalent = false
        C_Timer.After(0.5, function()
            state.hasEndlessDraughtTalent     = CheckEndlessDraughtTalent()
            state.hasHeartOfJadeSerpentTalent = CheckHeartOfJadeSerpentTalent()
            TryInitTFTChargesFromAPI()
            NHA.ScheduleEvaluate()
        end)
        return false
    end

    if event == "PLAYER_ENTERING_WORLD" then
        RebuildRMPresenceCache()
        state.hasEndlessDraughtTalent     = CheckEndlessDraughtTalent()
        state.hasHeartOfJadeSerpentTalent = CheckHeartOfJadeSerpentTalent()
        TryInitTFTChargesFromAPI()
        NHA.ScheduleEvaluate()
        return false
    end

    return false
end

-- =============================================================
-- Slash Commands
-- =============================================================
MonkMW.slashCommands = {
    ["tftlog"] = function(self, db)
        state.tftLogEnabled = not state.tftLogEnabled
        if state.tftLogEnabled then
            state.tftLogLastCastAt    = 0
            state.tftLogLastPredicted = 0
            NHA.Msg("|cff00ff00TFT cast-gap logging: ON|r — cast TFT twice on-ready to measure real CD vs predicted. /nha tftlog to stop.")
        else
            NHA.Msg("|cffffd700TFT cast-gap logging: OFF|r")
        end
    end,

    ["debug"] = function(self, db)
        NHA.Msg("|cffffd700Mistweaver Monk Debug:|r")
        NHA.Msg("  enabled: "          .. tostring(db.enabled))
        NHA.Msg("  rmEnabled: "        .. tostring(db.rmEnabled))
        NHA.Msg("  envEnabled: "       .. tostring(db.envEnabled))
        NHA.Msg("  tftEnabled: "       .. tostring(db.tftEnabled))
        NHA.Msg("  cufGlowEnabled: "   .. tostring(db.cufGlowEnabled))
        NHA.Msg("  rmActive: "         .. tostring(state.rmActive))
        NHA.Msg("  envActive: "        .. tostring(state.envActive))
        NHA.Msg("  tftActive: "        .. tostring(state.tftActive))
        NHA.Msg("  tftCurCharges: "    .. tostring(state.tftCurCharges)
            .. " / " .. tostring(state.tftMaxCharges)
            .. "  (effectiveMax=" .. tostring(GetTFTEffectiveMaxCharges()) .. ")")
        NHA.Msg("  tftChargeTimerSet: " .. tostring(state.tftChargeTimerSet))
        NHA.Msg("  tftChargeGen: "      .. tostring(state.tftChargeGen))
        if state.tftChargeTimerSet and state.tftChargeReadyAt > 0 then
            local rem = state.tftChargeReadyAt - GetTime()
            NHA.Msg(string_format("  tftChargeReadyIn: %.2fs", rem))
        end
        if state.tftLastRecoverAt and state.tftLastRecoverAt > 0 then
            local ago = GetTime() - state.tftLastRecoverAt
            NHA.Msg(string_format("  tftLastRecoverAgo: %.2fs", ago))
        else
            NHA.Msg("  tftLastRecoverAgo: never")
        end
        if state.tftLastCastAt and state.tftLastCastAt > 0 then
            local ago = GetTime() - state.tftLastCastAt
            NHA.Msg(string_format("  tftLastCastAgo: %.2fs  (cast handler fired)", ago))
        else
            NHA.Msg("  tftLastCastAgo: never  (cast handler has NOT fired)")
        end
        do
            local rem = (state.tftSuppressUntil or 0) - GetTime()
            if rem > 0 then
                NHA.Msg(string_format("  tftSuppressUntil: %.2fs remaining", rem))
            else
                NHA.Msg("  tftSuppressUntil: expired/none")
            end
        end
        NHA.Msg("  tftWasActive: "     .. tostring(state.tftWasActive))
        NHA.Msg("  inCombat: "         .. tostring(UnitAffectingCombat("player")))
        NHA.Msg("  IsInGroup(): "      .. tostring(IsInGroup()))
        NHA.Msg("  hasEndlessDraughtTalent: "
            .. tostring(state.hasEndlessDraughtTalent))
        NHA.Msg("  hasHeartOfJadeSerpentTalent: "
            .. tostring(state.hasHeartOfJadeSerpentTalent))
        NHA.Msg(string_format(
            "  GetTFTCooldownDuration(): %.2fs", GetTFTCooldownDuration()))
        NHA.Msg("  soothingUnit: "     .. tostring(state.soothingUnit))
        NHA.Msg("  rmThresholdDungeon: " .. tostring(db.rmThresholdDungeon))
        NHA.Msg("  rmThresholdRaid: "  .. tostring(db.rmThresholdRaid))

        local activeRM = CountActiveRenewingMist()
        local rmThreshold = GetRMThreshold()
        NHA.Msg("  activeRenewingMist: " .. activeRM .. " / threshold: " .. rmThreshold)

        if state.soothingUnit then
            local hasEnv = UnitHasEnvelopingMist(state.soothingUnit)
            NHA.Msg("  hasEnvelopingMist(soothingUnit): " .. tostring(hasEnv))
        end

        local channelName = UnitChannelInfo and UnitChannelInfo("player")
        NHA.Msg("  UnitChannelInfo(player): " .. tostring(channelName or "(none)"))
        NHA.Msg("  IsPlayerChannelingSoothing: " .. tostring(IsPlayerChannelingSoothing()))
        NHA.Msg("  player has 115175: "
            .. tostring(FindAuraOnUnit("player", SOOTHING_MIST_ID, false) ~= nil))
        NHA.Msg("  detected soothUnit (live): " .. tostring(FindPlayerSoothingTarget()))
        NHA.Msg("  cachedSoothUnit: " .. tostring(cachedSoothUnit))
        local smList = {}
        for _, unit in ipairs(NHA:GetGroupUnits()) do
            if FindAuraOnUnit(unit, SOOTHING_MIST_ID, false) then
                smList[#smList+1] = unit
            end
        end
        if #smList > 0 then
            NHA.Msg("  units with 115175: " .. table.concat(smList, ", "))
        else
            NHA.Msg("  units with 115175: (none)")
        end
    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 emCacheCount = 0
        for _ in pairs(cufEMCache) do emCacheCount = emCacheCount + 1 end
        local rmCacheCount = 0
        for _ in pairs(cufRMCache) do rmCacheCount = rmCacheCount + 1 end
        local dirtyCount = 0
        for _ in pairs(dirtyCUFFrames) do dirtyCount = dirtyCount + 1 end
        local stateEMCount, stateRMCount = 0, 0
        for _, kind in pairs(cufStateMap) do
            if     kind == "em" then stateEMCount = stateEMCount + 1
            elseif kind == "rm" then stateRMCount = stateRMCount + 1
            end
        end
        NHA.Msg("  tracked CUFs: "    .. trackedCount)
        NHA.Msg("  in combat: "       .. tostring(UnitAffectingCombat("player")))
        NHA.Msg("  cufEMCache size: " .. emCacheCount)
        NHA.Msg("  cufRMCache size: " .. rmCacheCount)
        NHA.Msg("  dirtyCUFFrames: "  .. dirtyCount .. " (pending refreshes for next ticker drain)")
        NHA.Msg("  glowing (EM/RM): "
            .. stateEMCount .. " / " .. stateRMCount)

        NHA.Msg("|cffffd700  Tracked _unitCUFs entries:|r")
        local entries = 0
        for unit, set in pairs(NHA._unitCUFs or {}) do
            local n = 0
            for _ in pairs(set) do n = n + 1 end
            local nameOk, name = pcall(UnitName, unit)
            local existsOk, exists = pcall(UnitExists, unit)
            NHA.Msg(string_format(
                "  %s -> %d frame(s)  name=%s  exists=%s",
                tostring(unit),
                n,
                (nameOk and tostring(name)) or "?",
                tostring(existsOk and exists)))
            entries = entries + 1
            if entries >= 12 then
                NHA.Msg("  ... (truncated)")
                break
            end
        end

        NHA.Msg("|cffffd700  Per-unit aura detection (EM/RM):|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 hasEM = FindAuraOnUnit(unit, ENVELOPING_MIST_ID, true) ~= nil
                local rmAura = FindAuraOnUnit(unit, RENEWING_MIST_ID,  true)
                local rmStr  = "no"
                if rmAura then
                    local exp = SafeNum(rmAura.expirationTime, 0)
                    local tm  = SafeNum(rmAura.timeMod, 1)
                    if tm <= 0 then tm = 1 end
                    rmStr = 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): EM=%s  RM=%s  glow=%s",
                    unit,
                    (nameOk and tostring(name)) or "?",
                    tostring(hasEM),
                    rmStr,
                    glowKind))
                probedUnits = probedUnits + 1
            end
        end
    end,

    ["env"] = function(self, db)
        local now = GetTime()
        NHA.Msg("|cffffd700Mistweaver ENV debug:|r")
        local su = state.soothingUnit
        NHA.Msg("  soothingUnit:     " .. tostring(su))
        NHA.Msg("  soothingStartTime: " .. tostring(state.soothingStartTime))
        if state.soothingStartTime and state.soothingStartTime > 0 then
            local age = now - state.soothingStartTime
            local grace = 3.0 - age
            NHA.Msg(string_format(
                "  age=%.2fs  graceRemaining=%.2fs  pastGrace=%s",
                age, grace, tostring(age >= 3.0)))
        end
        NHA.Msg("  envActive:        " .. tostring(state.envActive))
        NHA.Msg("  envEnabled(db):   " .. tostring(db.envEnabled))
        NHA.Msg("  envLastSeenTime:  " .. tostring(state.envLastSeenTime))
        if state.envLastSeenTime and state.envLastSeenTime > 0 then
            NHA.Msg(string_format(
                "  time since EM last seen: %.2fs", now - state.envLastSeenTime))
        end
        NHA.Msg("  UnitChannelInfo:  "
            .. tostring(UnitChannelInfo and UnitChannelInfo("player") or "(none)"))
        NHA.Msg("  IsChannelingSooth: "
            .. tostring(IsPlayerChannelingSoothing()))
        if su then
            local hasEM = UnitHasEnvelopingMist(su)
            NHA.Msg("  UnitHasEnvelopingMist(" .. su .. "): "
                .. tostring(hasEM))
        end
        NHA.Msg("  playerAuraPending: " .. tostring(state.playerAuraPending))
        NHA.Msg("  inCombat:          " .. tostring(UnitAffectingCombat("player")))
    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 = { em = 0, rm = 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  = MonkMW.Evaluate
        MonkMW.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 = MonkMW.RefreshGlows
        MonkMW.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
        MonkMW.Evaluate         = perf.origEvaluate
        MonkMW.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 = { "RMCOUNT", "SOOTH", "ENV", "TFT", "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(
                    "  %-8s 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: EM=%d  RM=%d  NONE=%d  DISABLED=%d",
                cuf.paths.em or 0, cuf.paths.rm 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,
}
NHA:RegisterSpec("MONK_MW", MonkMW)
