-- =============================================================
-- Specs/MonkMistweaver.lua
-- Nem: Healer Alerts — Mistweaver Monk spec module
--
-- FULLY SELF-CONTAINED. This file contains ALL Mistweaver Monk
-- spell IDs, aura scan logic, state management, evaluate logic,
-- event handling, glow evaluation, and options panel additions.
--
-- It has ZERO dependencies on any other spec module. Adding or
-- removing other spec files will never affect this module.
--
-- Registers itself with the core via NHA:RegisterSpec().
--
-- Whitelisted spell IDs (non-secret in Midnight):
--   115175 - Soothing Mist
--   119611 - Renewing Mist
--   124682 - Enveloping Mist
--   450769 - Aspect of Harmony


-- =============================================================
-- Aura Scanning
-- =============================================================
-- Mistweaver spell IDs (115175, 119611, 124682) are whitelisted
-- as non-secret in Midnight — spellId is readable. However,
-- sourceUnit is NOT whitelisted and returns a secret value when
-- querying auras on other players in M+/rated PvP. We use a
-- safe helper that tries sourceUnit but accepts the aura even
-- if sourceUnit is tainted (in a dungeon you're the only
-- Mistweaver, so any RM/Soothing/Enveloping found is yours).

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

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

-- =============================================================
-- Spell / Aura IDs
-- =============================================================
local SOOTHING_MIST_ID   = 115175   -- Player's Soothing Mist (buff on target)
local RENEWING_MIST_ID   = 119611
local ENVELOPING_MIST_ID = 124682
local SOOTH_GRACE        = 3.0      -- seconds before EM-missing alert arms after SM channel start

-- Forward declaration: state is assigned its real table further down
-- (after Alert definitions). Hoisted up here because the
-- UpdateRMPresenceForUnit helper references state, and Lua resolves
-- `local` names at the lexical position of the declaration.
local state

-- =============================================================
-- Taint-Safe Helpers
-- =============================================================
-- IMPORTANT: each helper uses the `pcall(staticfn, args...)` form
-- rather than `pcall(function() ... end)`. The closure form allocates
-- a fresh function object on EVERY call (it captures the `aura`/`val`
-- upvalues), which at 4 Hz × ~30 buffs × 5+ units multiplies into
-- thousands of allocations per second and drives encounter CPU above
-- 20%. The static-function form takes args as parameters and never
-- allocates. Same correctness; pcall still catches taint errors when
-- the inner field access throws on secret values. Mirrors v12.0.19's
-- Druid Restoration refactor for the same reason.
-- =============================================================

-- Static workers — DO NOT call directly, always invoke via pcall.
-- Each performs the field access that may throw on secret values.
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

-- Safely read a numeric aura field (expirationTime, duration, etc.)
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

-- Returns true if sourceUnit is "player" OR if sourceUnit is
-- tainted (secret). Only returns false if sourceUnit is readable
-- and belongs to someone else.
local function IsPlayerOrUnknownSource(aura)
    if not aura then return false end
    local ok, result = pcall(_checkPlayerSource, aura)
    if not ok then
        -- sourceUnit is a secret value — can't determine source.
        -- In a dungeon with one Mistweaver, it's almost certainly
        -- ours. Accept it rather than dropping the count to 0.
        return true
    end
    return result
end

-- Same check but for auraInstanceID ~= 0
-- CRITICAL: Must use ~= nil (comparison) instead of bare `and`
-- (boolean test). Secret number values throw "attempt to perform
-- boolean test on secret value" when tested in boolean context,
-- which kills CountActiveRenewingMist and drops RM count to 0.
local function HasValidInstanceID(aura)
    if not aura then return false end
    local ok, result = pcall(_checkInstanceID, aura)
    -- If pcall failed, auraInstanceID is secret but exists —
    -- accept it (same logic as IsPlayerOrUnknownSource)
    if not ok then return true end
    return result
end

-- Returns true if aura.spellId safely equals the given ID.
-- Mirrors Disc's AuraSpellIs() helper for taint-safe comparison.
local function AuraSpellIs(aura, id)
    local ok, result = pcall(_checkSpellIs, aura, id)
    return ok and result
end

-- ── Two-path aura finder (mirrors Disc FindAtonementOnUnit) ──
-- Path 1: GetAuraDataBySpellID (fast, direct lookup)
-- Path 2: GetAuraDataByIndex   (slow, walks all auras)
-- GetAuraDataBySpellID can return nil in follower dungeons and
-- other edge cases where GetAuraDataByIndex still works. The
-- glow pipeline never hits this because it uses instance IDs.
local function FindAuraOnUnit(unit, spellId, checkSource)
    if not UnitExists(unit) then return nil end
    if not C_UnitAuras then return nil end

    -- Path 1: direct spell ID lookup (fast)
    if C_UnitAuras.GetAuraDataBySpellID then
        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
        -- Fall through to index scan if direct lookup failed
    end

    -- Path 2: index scan fallback (taint-safe)
    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

-- earlyOut: optional threshold; stop scanning once count reaches it.
-- Pass 0/nil to get the exact count (used by the debug command).
-- Note: Evaluate no longer calls this function — RM coverage is tracked
-- event-driven via state.rmCachedCount. This function is debug-only.
local function CountActiveRenewingMist(earlyOut)
    local count = 0
    local seen  = {}
    earlyOut = earlyOut or 0
    for _, unit in ipairs(NHA:GetGroupUnits()) do
        -- Deduplicate by GUID: GetGroupUnits includes "target"/"focus"
        -- which can alias the same unit as "player" or "party1", etc.
        -- Without dedup, self-casting RM while targeting yourself
        -- counts the aura twice and falsely clears the alert.
        --
        -- UnitGUID returns a SECRET VALUE for hostile units (target,
        -- focus) in tainted execution contexts. Indexing the `seen`
        -- table with a secret key throws "attempted to index a table
        -- that cannot be indexed with secret keys" — pcall around
        -- every read/write would work but allocates closures and
        -- unwinds the stack on each failure. issecretvalue() is a
        -- single C-level call: check upfront and skip dedup for
        -- those units. Worst case target/focus get scanned twice if
        -- they appear under partyN as well — fine, since
        -- FindAuraOnUnit returns nil for enemies anyway.
        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

-- =============================================================
-- rmPresence: event-driven per-unit RM count cache.
-- Keyed by unit token (e.g. "raid5", "party2"); value is true when
-- that unit currently has our Renewing Mist. state.rmCachedCount is
-- the live integer derived from this table.
--
-- Maintained by UpdateRMPresenceForUnit on UNIT_AURA events and
-- rebuilt wholesale by RebuildRMPresenceCache on combat enter,
-- roster change, and zone transitions. A periodic 5s drift-correction
-- rebuild in RefreshGlows catches self-RM edge cases (in a party,
-- "player" fires UNIT_AURA but UnitInGroup("player") = false, so the
-- event path skips it; the rebuild catches it within 5s).
--
-- Only tracks UnitInGroup(unit) tokens — NOT "player" directly — to
-- avoid double-counting in raids where the player fires UNIT_AURA as
-- both "player" and "raidX". UnitInGroup("player") returns false, so
-- the player's canonical raidX token is tracked and "player" is
-- filtered, eliminating the alias problem without GUID dedup.
local rmPresence = {}

-- Rebuilds the event-driven RM count cache with a full group walk.
-- Called on combat enter, zone change, and roster updates so any
-- auras already present populate the cache before the first tick.
-- Uses the same GUID dedup as the old CountActiveRenewingMist so
-- target/focus alias units are never double-counted.
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

-- Updates the RM count cache for a single unit from a UNIT_AURA
-- payload. Fast path: no aura API call when the payload contains
-- no RM-relevant changes. Only pays the per-unit FindAuraOnUnit
-- cost when RM is actually added or removed on this unit.
--
-- Called from OnEvent for every UnitInGroup(unit) UNIT_AURA.
-- "player" is intentionally excluded (UnitInGroup("player") = false)
-- to prevent double-counting with the raidX alias in raids. The
-- 5s periodic rebuild in RefreshGlows catches self-RM in parties.
local function UpdateRMPresenceForUnit(unit, updateInfo)
    local had = rmPresence[unit]

    -- isFullUpdate or missing payload: diff unavailable, rescan 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

    -- Quick filter: does this payload touch RM on this unit?
    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 we had RM on this unit and something was removed, recheck.
    -- We don't store per-unit instance IDs (avoids taint comparisons),
    -- so any removal on a unit that had RM warrants one FindAuraOnUnit.
    if not touchedRM and had
       and updateInfo.removedAuraInstanceIDs
       and #updateInfo.removedAuraInstanceIDs > 0 then
        touchedRM = true
    end

    if not touchedRM then return end

    -- RM was touched — recheck with a single per-unit API call.
    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
-- =============================================================
-- CRITICAL: The statue applies Soothing Mist (possibly 115175)
-- on its targets too. Aura scanning alone cannot distinguish the
-- player's channel from the statue's copy, because sourceUnit
-- may be secret or may report "player" for the statue (totems
-- are considered extensions of the player). We use
-- UnitChannelInfo("player") to confirm the PLAYER is personally
-- channeling before firing env/SOR alerts.

local function _spellIdEquals(a, b)
    return a == b
end

local function IsPlayerChannelingSoothing()
    if not UnitChannelInfo then return false end
    local name, _, _, _, _, _, _, spellId = UnitChannelInfo("player")
    if not name then return false end
    -- Check by spellId first (may be secret in M+)
    local ok, match = pcall(_spellIdEquals, spellId, SOOTHING_MIST_ID)
    if ok and match then return true end
    -- Fallback: check by name (covers secret spellId case)
    if name == "Soothing Mist" then return true end
    return false
end

-- Finds the unit the player is personally channeling Soothing
-- Mist on. Only returns a result when UnitChannelInfo confirms
-- the player's channel.
--
-- The decision tree:
--   1. UnitChannelInfo says no SM channel    → return nil
--   2. Some non-player unit has 115175       → that's the channel target
--      (canonicalized to "player" if it resolves to the player's GUID,
--       e.g. "target" when self-targeting or "raidN" in raid)
--   3. Some pet token has 115175             → return that token
--   4. Nothing else has 115175               → return "player"
--
-- Step 4 is the critical fix for the EM-alert-not-firing-on-self-cast
-- bug. The previous logic required finding 115175 on at least one
-- group unit (`foundOnPlayer` fallback), which silently dropped
-- self-channels in any context where the channel itself doesn't
-- register as a queryable aura on the channeler. UnitChannelInfo
-- has already confirmed the player is channeling; when nobody else
-- has 115175, it MUST be a self-channel — regardless of whether
-- the buff appears in the player's own aura list.
--
-- ── Performance: target caching ──
-- During a Soothing Mist channel this function used to be the single
-- hottest block in Evaluate (~50% of total Evaluate time in raid)
-- because it walked every group unit on every tick. The channel
-- target cannot change without the channel breaking and `UnitChannelInfo`
-- already confirms whether the channel is live, so once we've located
-- the target we can validate it cheaply on subsequent ticks instead
-- of rescanning the group.
--
-- Cache strategy:
--   • cachedSoothUnit = nil   → no channel, or first call after start
--                                → fall through to the slow group walk,
--                                  populate cache before returning.
--   • cachedSoothUnit set     → fast path: validate that the cached
--                                  unit still has 115175 with one
--                                  FindAuraOnUnit call. If yes, return
--                                  the cached token. If not, invalidate
--                                  and fall through to the slow path.
--   • cachedSoothUnit "player" → trust without validation. 115175 is
--                                  not reliably queryable on the
--                                  channeler itself, so revalidating
--                                  would constantly fail and force the
--                                  slow path on every tick — defeating
--                                  the optimization entirely.
--
-- The cache is module-local rather than a state field because it's a
-- pure implementation detail of this function. It's cleared in
-- ClearAllState (spec deactivation) and self-clears whenever
-- IsPlayerChannelingSoothing() returns false.
local cachedSoothUnit = nil

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

    -- ── Fast path: validate cached target ──
    if cachedSoothUnit then
        if cachedSoothUnit == "player" then
            -- Self-channel: trust without revalidation (see header).
            return "player"
        end
        if UnitExists(cachedSoothUnit)
           and FindAuraOnUnit(cachedSoothUnit, SOOTHING_MIST_ID, false) then
            return cachedSoothUnit
        end
        -- Stale (target lost SM, retargeted mid-channel, roster shuffle, etc.)
        cachedSoothUnit = nil
    end

    -- ── Slow path: full group walk + cache populate ──
    local pGUID = UnitGUID("player")

    -- Scan group units, skipping "player" — that path is handled by
    -- the unconditional self-channel default at the bottom.
    for _, unit in ipairs(NHA:GetGroupUnits()) do
        if unit ~= "player" then
            local aura = FindAuraOnUnit(unit, SOOTHING_MIST_ID, false)
            if aura then
                -- Canonicalize player-equivalent tokens. "target" can
                -- shift mid-channel and "raidN" is a stable but
                -- spec-incorrect identifier when raidN == self.
                --
                -- UnitGUID(unit) can return a secret-tainted string
                -- in M+/rated PvP for non-player units (the player's
                -- own GUID is whitelisted, so pGUID is safe). The
                -- bare == comparison throws on secret values, so
                -- pcall the equality and treat a thrown comparison
                -- as "not the player" — falls through to returning
                -- the unit token, same as if either GUID were nil.
                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

    -- Pet tokens (not included in GetGroupUnits)
    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

    -- UnitChannelInfo said the player IS channeling SM and nobody
    -- else has 115175 — this is a self-channel. Return "player"
    -- whether or not 115175 is queryable on the player's aura list.
    cachedSoothUnit = "player"
    return "player"
end

-- =============================================================
-- Module State (owned entirely by this module)
-- =============================================================
-- (state forward-declared above)

state = {
    -- Renewing Mist low (Spread RM alert)
    rmActive       = false,
    rmWasActive    = false,
    rmCachedCount  = 0,         -- event-driven count (UpdateRMPresenceForUnit)
    -- Enveloping missing (conditional)
    envActive      = false,
    envWasActive   = false,
    envLastSeenTime = 0,   -- debounce: last time EM was detected
    -- Soothing Mist channel tracking
    soothingUnit   = nil,
    -- Grace period
    soothingStartTime = 0,
    -- Debounce
    playerAuraPending    = false,
    rosterUpdatePending  = false,
}

-- =============================================================
-- CUF Whole-Frame Glow State
-- =============================================================
-- Per-cache: maps each tracked CUF (CompactUnitFrame) to a small
-- "safe aura" table when that frame currently has the corresponding
-- buff. Safety-net one-shot timers are scheduled when each glow
-- starts; they fire at aura expiry + 0.5s to catch any dropped
-- UNIT_AURA removal events. In normal play the timer is a no-op
-- (cache already cleared by the UNIT_AURA handler).
--
-- Priority chain in EvaluateCUFGlow (highest first):
--   1. Enveloping Mist → cufStateMap[frame] = "em" (cufEMCache)
--   2. Renewing Mist   → cufStateMap[frame] = "rm" (cufRMCache)
--
-- Coalesced refresh queue: dirtyCUFFrames buffers (frame -> unit)
-- pairs from UNIT_AURA events. The EvaluateCUFGlow shim marks
-- frames dirty then fires a single C_Timer.After(0) one-shot
-- (_DrainGlowDeferred) which processes all dirty frames on the next
-- render frame boundary. Multiple UNIT_AURA events for the same
-- frame within a render frame collapse into one evaluation — the
-- v12.0.21 raid telemetry showed 51 synchronous calls in a single
-- frame; this approach caps it at one drain per frame, zero ticker.
local cufRMCache     = {}    -- frame -> { expirationTime, timeMod, auraInstanceID, duration }
local cufEMCache     = {}    -- frame -> { expirationTime, timeMod, auraInstanceID, duration }
local cufStateMap    = {}    -- frame -> "em" | "rm"
local dirtyCUFFrames = {}    -- frame -> unit (pending refresh queue)
local drainScheduled = false -- true while a _DrainGlowDeferred C_Timer is in flight

-- =============================================================
-- Alert Definitions
-- =============================================================
local REML_Y = 46
local ENV_Y  = 16

local ALERTS = {
    -- Sound panel order: rm → env
    {
        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 = "Ping",
    },
    {
        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 = "Bell",
    },
}

-- =============================================================
-- Saved-Variable Defaults (merged into core db)
-- =============================================================
local DEFAULTS = {
    rmEnabled            = true,
    rmSoundEnabled       = true,
    rmSoundName          = "Ping",
    envEnabled           = true,
    envSoundEnabled      = true,
    envSoundName         = "Bell",
    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
    },
}

-- =============================================================
-- MODULE TABLE
-- =============================================================
local MonkMW = {
    class     = "MONK",
    specIndex = 2,  -- Mistweaver is spec index 2
    specID    = 270, -- fallback: Mistweaver Monk spec ID for secret-value contexts
    specName  = "Mistweaver",
    alerts    = ALERTS,
    defaults  = DEFAULTS,
    state     = state,

    extraEvents = {
        "UNIT_AURA",
        "UNIT_SPELLCAST_CHANNEL_START",   -- SM channel begins → cache soothTarget
        "UNIT_SPELLCAST_CHANNEL_STOP",    -- SM channel ends   → clear soothTarget
        "UNIT_SPELLCAST_CHANNEL_UPDATE",  -- SM retargets mid-channel (mouseover)
    },

    -- Spell IDs whose UNIT_AURA add/remove on a tracked CUF unit
    -- should trigger an EvaluateCUFGlow refresh. Without this filter
    -- the core listener would refresh on every aura change including
    -- HoT ticks from other classes — most of UNIT_AURA traffic.
    -- Two spells drive the CUF whole-frame glow on Mistweaver, in
    -- priority order: Enveloping Mist > Renewing Mist.
    cufRelevantSpells = {
        [124682] = true,   -- Enveloping Mist
        [119611] = true,   -- Renewing Mist
    },
}

-- =============================================================
-- ClearAllGlows
-- =============================================================
function MonkMW:ClearAllGlows()
    -- CUF whole-frame glows
    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  -- cancel any in-flight deferred drain
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
    cachedSoothUnit         = nil
    self:ClearAllGlows()
end

-- =============================================================
-- NeedsTicker
-- =============================================================
function MonkMW:NeedsTicker(db)
    -- CUF glows are fully event-driven (UNIT_AURA → _DrainGlowDeferred
    -- one-shot) with per-glow safety-net C_Timer.After timers. No
    -- ticker required. Return false so the core stops polling between
    -- pulls, dropping to exactly zero idle CPU.
    return false
end

-- =============================================================
-- Per-block + per-call perf instrumentation
-- =============================================================
-- This block must precede every function that references the
-- `_perf*` locals or the `Perf*` helpers. Lua closures capture
-- file-scope locals by reference at FUNCTION-CREATION time; if a
-- function is parsed before its locals are declared, the names
-- silently fall through to globals — and writes go to globals
-- while reads from later-defined functions go to locals, breaking
-- attribution invisibly. (This is the bug that produced the
-- "all NONE" CUF perf result in v12.0.21 raid telemetry: the
-- public EvaluateCUFGlow's `_perfCUFLastPath = "rm"` etc. writes
-- targeted a global, while the perf wrapper's read targeted the
-- local — always nil → always "none".)
--
-- Off-state cost: each Perf helper bails on a nil/false table
-- check (~10ns). When perf is on, helpers cost a few μs each.
-- Block boundaries map to the comment headers in Evaluate:
-- RMCOUNT / SOOTH / ENV / END.
local _perfBlockTimer = 0

-- ── CUF glow path tag ────────────────────────────────────────
-- Set by DoEvaluateCUFGlow just before each return so the inline
-- instrumentation in InstrumentedDoEvaluateCUFGlow can attribute
-- the call to whichever priority path actually fired:
-- "sm", "em", "rm", "none", or "disabled".
local _perfCUFLastPath = nil

-- ── Per-frame spike accumulator ──────────────────────────────
-- Sums Evaluate + every CUF glow eval + every RefreshGlows that
-- fire within the same render frame, then rolls the total into
-- the spike record at frame boundary. WoW frames run at ~60fps
-- (~16.7ms); anything synchronous within one frame stacks against
-- the per-frame CPU budget — the actual cause of "20% CPU spike"
-- readouts in the addon panel that per-call averages smooth away.
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

-- Forward-declared so DoEvaluateCUFGlow's safety-net timer closures
-- can reference it. Body is assigned after InstrumentedDoEvaluateCUFGlow
-- (which _DrainGlowDeferred calls) is defined below.
local _DrainGlowDeferred

-- =============================================================
-- DoEvaluateCUFGlow (private worker — the actual priority chain)
-- =============================================================
-- This is the historical body of EvaluateCUFGlow, lifted out so it
-- can be invoked by:
--   1. DrainGlowDirtySet — batched per-tick from RefreshGlows.
--   2. RefreshGlows safety-net — when a top-priority cache expires
--      and a lower-priority glow needs to take over immediately.
--
-- The public MonkMW:EvaluateCUFGlow is now a cheap shim that just
-- enqueues into dirtyCUFFrames; this worker is where work happens.
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

    -- ── Path 1: Enveloping Mist (highest priority) ───────────
    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 fallback
            NHA:ShowFrameGlow(frame, c.r, c.g, c.b)
            cufStateMap[frame] = "em"
            -- Drop lower-priority caches so stale entries don't linger
            cufRMCache[frame] = nil
            _perfCUFLastPath = "em"
            -- Safety-net: one-shot timer at expiry + 0.5s. Catches any
            -- dropped UNIT_AURA removal under heavy aura traffic. If
            -- UNIT_AURA fired correctly, the cache entry is already gone
            -- and this timer exits on the first nil-check (no-op).
            -- capturedExpiry is a plain Lua number (SafeNum unwrapped it)
            -- so the comparison is always safe — no pcall needed.
            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 expiry changed, aura was refreshed — a newer
                    -- timer is already in flight for this frame; skip.
                    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

    -- ── Path 2: Renewing Mist ─────────────────────────────────
    -- Find player-source Renewing Mist on the unit. checkSource=true
    -- enforces that we only glow for OUR RM, not for another
    -- Mistweaver's overlapping casts in the same group.
    local rmAura = FindAuraOnUnit(unit, RENEWING_MIST_ID, true)
    if rmAura then
        -- Reuse the same per-frame safe-aura table (no per-call alloc)
        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 fallback
            NHA:ShowFrameGlow(frame, c.r, c.g, c.b)
            cufStateMap[frame] = "rm"
            _perfCUFLastPath = "rm"
            -- Safety-net: one-shot timer at expiry + 0.5s. Same pattern
            -- as EM above — catches dropped UNIT_AURA removals only.
            -- RM bounces frequently; if the timer fires while RM is now
            -- on a different unit, the re-eval finds no RM → clears glow.
            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
        -- Aura is present but already expired — fall through to clear.
        cufRMCache[frame] = nil
    else
        cufRMCache[frame] = nil
    end

    -- No glow conditions met
    if cufStateMap[frame] then
        NHA:HideFrameGlow(frame)
        cufStateMap[frame] = nil
    end
    _perfCUFLastPath = "none"
end

-- =============================================================
-- InstrumentedDoEvaluateCUFGlow — wraps DoEvaluateCUFGlow with
-- inline perf accounting (gated on state._perf.enabled).
-- =============================================================
-- Off-state cost: one table lookup + nil check (~10ns). On-state
-- cost: 2× debugprofilestop() + a few table writes (~5μs).
--
-- The accounting flows through the same perf.cuf accumulator that
-- the old external wrap on MonkMW.EvaluateCUFGlow used. After the
-- coalesce redesign, the public shim is too cheap to be worth
-- timing — actual work happens here, in batches from drain.
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 — frame-boundary CUF glow drain
-- =============================================================
-- Scheduled via C_Timer.After(0, _DrainGlowDeferred) from the
-- EvaluateCUFGlow shim and from safety-net timers. C_Timer.After(0)
-- defers to the next engine frame boundary, coalescing all UNIT_AURA
-- events that arrived within a single render frame into exactly one
-- drain call per frame — the same guarantee the 0.25s ticker gave,
-- but with zero idle CPU and zero polling.
--
-- Processes up to 4 frames per call to cap worst-case render-frame
-- CPU on big burst events (whole-raid pull, AOE mechanics). If more
-- frames remain dirty after the cap, immediately reschedules itself
-- for the next frame — spreading remaining work across consecutive
-- frames. In normal play (single RM bounce = 2 dirty frames) the cap
-- is never reached and all frames resolve in a single drain call.
--
-- Frame validity: uses frame._nhaCUFUnit (live unit token) rather
-- than the stashed unit from the dirty-set, in case the frame was
-- reassigned between mark-time and drain-time.
_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
    -- More frames remain — schedule immediately for next frame.
    if next(dirtyCUFFrames) then
        drainScheduled = true
        C_Timer.After(0, _DrainGlowDeferred)
    end
end

-- =============================================================
-- EvaluateCUFGlow (public — called by core's UNIT_AURA dispatch)
-- =============================================================
-- Cheap O(1) dispatch shim. Marks the (frame, unit) pair dirty and
-- schedules _DrainGlowDeferred via C_Timer.After(0) if not already
-- in flight. The one-shot fires on the next render frame boundary,
-- coalescing all UNIT_AURA events within a single frame into one
-- drain call. drainScheduled prevents stacking multiple C_Timers.
--
-- ── Combat gating ──
-- Out of combat, raid frames receive heavy UNIT_AURA traffic from
-- food/flask/world buffs. The v12.0.21 telemetry showed 0.15-3%
-- sustained CPU just standing in raid lobby. Gate on combat so
-- no deferred calls are scheduled out of combat. On PLAYER_REGEN_
-- DISABLED, OnEvent calls NHA:RefreshAllCUFs() to catch any
-- pre-pull EM/RM auras the gate suppressed.
--
-- ── Disabled path ──
-- When cufGlowEnabled is off, eagerly clean up existing glow state.
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
    if not UnitAffectingCombat("player") then
        return
    end
    dirtyCUFFrames[frame] = unit
    if not drainScheduled then
        drainScheduled = true
        C_Timer.After(0, _DrainGlowDeferred)
    end
end

-- =============================================================
-- RefreshGlows — intentional no-op stub
-- =============================================================
-- CUF glows are now fully event-driven:
--   • Glow on/off: UNIT_AURA → EvaluateCUFGlow shim →
--                  C_Timer.After(0) → _DrainGlowDeferred
--   • Safety net:  C_Timer.After(remaining+0.5) per glow start
--   • RM presence: UNIT_AURA → UpdateRMPresenceForUnit (all group
--                  tokens), with player-token handling for parties
--
-- The core calls RefreshGlows on every Evaluate() (ticker + events).
-- Keeping this as an empty stub avoids a nil-guard error in the
-- perf instrumentation wrapper. Zero CPU cost when empty.
local _safetyNetLastRun = 0  -- retained so perf wrap restore doesn't error

function MonkMW:RefreshGlows(db)
    -- Intentionally empty. All CUF glow work is event-driven.
end

-- =============================================================
-- Evaluate (called by core ticker and events)
-- =============================================================
function MonkMW:Evaluate(db)
    PerfBeginBlocks()
    -- ── Renewing Mist low coverage (combat only) ─────────────
    -- state.rmCachedCount is maintained event-driven by
    -- UpdateRMPresenceForUnit (UNIT_AURA) and rebuilt wholesale by
    -- RebuildRMPresenceCache on combat enter / roster / zone events,
    -- with a 5s drift-correction rebuild in RefreshGlows as backup.
    -- Per-tick cost: one integer comparison. Zero group walking.
    local rmLow = false
    if db.rmEnabled and UnitAffectingCombat("player") then
        rmLow = (state.rmCachedCount or 0) < GetRMThreshold()
    end
    state.rmActive = rmLow
    PerfMarkBlock("RMCOUNT")

    -- ── Player's Soothing Mist target (for env checks) ───────
    -- soothingUnit is maintained event-driven by the three
    -- UNIT_SPELLCAST_CHANNEL_* handlers in OnEvent:
    --   START  → FindPlayerSoothingTarget() once, cache target + startTime
    --   UPDATE → retarget mid-channel (mouseover), reset grace + startTime
    --   STOP   → clear soothingUnit and startTime
    -- No polling or FindPlayerSoothingTarget() call needed here.
    -- Combat gate: suppress EM alerts during pre-pull SM top-offs.
    local now = GetTime()
    local soothUnit = UnitAffectingCombat("player") and state.soothingUnit or nil
    PerfMarkBlock("SOOTH")

    -- ── Enveloping Mist missing on Soothing Mist target ──────
    -- Only fires when:
    --   • the PLAYER is channeling (soothUnit set), AND
    --   • the channel has been on the SAME target for ≥ SOOTH_GRACE
    --     seconds (suppresses spot-heal noise during raid play), AND
    --   • the target doesn't already have Enveloping Mist (debounced
    --     at 0.5s to ride through aura list enumeration misses).
    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
            -- EM not found — check debounce
            if state.envLastSeenTime > 0
               and (now - state.envLastSeenTime) < 0.5 then
                envMissing = false  -- seen recently, suppress flicker
            else
                envMissing = true
            end
        end
    elseif not soothUnit then
        -- Not channeling; reset debounce
        state.envLastSeenTime = 0
    end
    state.envActive = envMissing
    PerfMarkBlock("ENV")

    -- ── Rising-edge sounds ───────────────────────────────────
    if rmLow      and not state.rmWasActive  then NHA:PlayAlertSound("rm")  end
    if envMissing and not state.envWasActive then NHA:PlayAlertSound("env") end

    state.rmWasActive  = rmLow
    state.envWasActive = envMissing
    PerfMarkBlock("END")
end

-- =============================================================
-- OnActivate
-- =============================================================
function MonkMW:OnActivate(db)
    -- Populate raid-frame glows immediately rather than waiting for the
    -- next UNIT_AURA. CUFs may already be tracked at this point if the
    -- player just changed spec mid-group; without this nudge the glows
    -- would only appear after the first relevant aura change.
    if NHA.RefreshAllCUFs then
        C_Timer.After(0.1, function()
            NHA:RefreshAllCUFs()
        end)
    end
end

-- =============================================================
-- BuildAlertOptions (spec-specific options in the Alert Options section)
-- =============================================================
function MonkMW:BuildAlertOptions(parent, db, helpers)
    -- Single row with two numeric inputs side by side:
    --   "Target Renewing Mist (Party) [2]      (Raid) [4]"
    -- Built directly to control x positioning.
    -- Class-colored editboxes via GetClassColor.
    local r, g, b = GetClassColor(select(2, UnitClass("player")))
    r, g, b = r or 0.0, g or 1.0, b or 0.596

    -- Use MakeNumericInput for Party (advances cursor by -28, handles tracking)
    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)

    -- Undo cursor advance — Raid will share the same row
    helpers.Spacer(-28)

    -- Raid input — use MakeNumericInput with short label then reposition
    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)

    -- Reanchor raidBox's label to sit right of the party editbox.
    -- partyBox is 4px below partyLbl (from the "LEFT, lbl, RIGHT, 8, 4" anchor).
    -- We anchor raidLbl LEFT of partyBox with +4 Y to compensate, keeping them
    -- on the same visual baseline.
    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)
    -- State is cleared by core calling ClearAllState + ClearAllGlows
end

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

        -- Update the event-driven RM count cache for this unit.
        -- In raids: exclude "player" — the player fires UNIT_AURA as
        -- both "player" and "raidX"; processing only raidX prevents
        -- double-counting.
        -- In parties: "player" is the ONLY token for self (no partyX
        -- self-slot), so we must process it here or self-RM is never
        -- counted. IsInRaid() guards the exclusion to raids only.
        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

        -- Fast path: when the active SM target's auras change, bypass
        -- the shared playerAuraPending gate and schedule Evaluate
        -- immediately. This covers EM apply/remove on soothUnit without
        -- waiting for a pending 0.1s debounce that might have fired on
        -- a previous UNIT_AURA before EM landed, leaving no in-flight
        -- timer to pick up the change.
        if state.soothingUnit and unit == state.soothingUnit then
            NHA.ScheduleEvaluate()
            return true
        end

        -- General debounce: coalesce rapid UNIT_AURA bursts into one
        -- Evaluate call 0.1s later. Handles RM count changes and
        -- AOH threshold transitions on non-soothUnit units.
        if not state.playerAuraPending then
            state.playerAuraPending = true
            C_Timer.After(0.1, function()
                state.playerAuraPending = false
                NHA.Evaluate()
            end)
        end
        return true
    end

    -- ── Soothing Mist channel tracking (event-driven) ─────────
    -- Three channel events replace the per-Evaluate FindPlayerSoothingTarget()
    -- polling call. On START: detect target once and cache it, record
    -- the grace-period start time, schedule a one-shot Evaluate after
    -- SOOTH_GRACE so the EM-missing check fires even if no UNIT_AURA
    -- arrives on the channel target during the grace window.
    -- On UPDATE: retarget fired (mouseover macro mid-channel) -- reset
    -- cached target and grace timer so the new target gets a fresh 3s.
    -- On STOP: clear cached state immediately.
    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)
        -- Fallback: name check for secret spellID (M+ taint edge case)
        if not (ok and match) then
            local name = UnitChannelInfo and (UnitChannelInfo("player"))
            if name ~= "Soothing Mist" 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
            -- Race condition: when SM retargets mid-channel, the old
            -- target still has the 115175 buff at the moment this event
            -- fires. FindPlayerSoothingTarget() would return the OLD
            -- target and cache it, causing soothingUnit to never update.
            --
            -- Fix: clear soothingUnit immediately (so any in-flight
            -- grace timers for the old target become no-ops via the
            -- graceTarget guard), then re-detect the new target after
            -- 0.15s — enough time for the SM buff to move in the aura
            -- list without being noticeable as an alert gap.
            cachedSoothUnit         = nil
            state.soothingUnit      = nil
            state.soothingStartTime = 0
            state.envLastSeenTime   = 0
            state.envActive         = false
            NHA.ScheduleEvaluate()  -- alert clears immediately on retarget

            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

        -- CHANNEL_START: do NOT scan immediately. The SM buff (115175)
        -- has not propagated to the target's aura list by the time this
        -- event fires — the synchronous FindPlayerSoothingTarget() walk
        -- finds nobody and falls through to the "player" self-channel
        -- fallback, poisoning soothingUnit with the wrong token.
        -- (Confirmed by /nha env: soothingUnit="player" while channeling
        -- on a group member, UnitHasEnvelopingMist(player)=false.)
        -- Apply the same 0.15s delay used by CHANNEL_UPDATE so the buff
        -- has time to appear on the real target before we scan.
        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 == "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
        cachedSoothUnit         = nil
        state.soothingUnit      = nil
        state.soothingStartTime = 0
        NHA.ScheduleEvaluate()
        return false
    end

    if event == "PLAYER_REGEN_DISABLED" then
        -- Rebuild RM count cache: any pre-pull RMs light up immediately
        -- without waiting for a UNIT_AURA event to update the cache.
        RebuildRMPresenceCache()
        -- Combat enter: drive a fresh evaluation of every tracked
        -- CompactUnitFrame so any auras applied in the pre-pull
        -- window light up the moment the pull starts. Without this,
        -- the public EvaluateCUFGlow shim would have ignored every
        -- pre-pull UNIT_AURA event (it's combat-gated to suppress
        -- raid-lobby noise), so the dirty queue would be empty and
        -- the next ticker drain would have nothing to process.
        --
        -- RefreshAllCUFs in core walks _cufPool and calls
        -- EvaluateCUFGlow(db, frame, unit) for each — which now
        -- passes the combat check, marks frames dirty, and fires
        -- _DrainGlowDeferred to process them on the next frame boundary.
        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
        NHA.ScheduleEvaluate()
        return false
    end

    if event == "PLAYER_ENTERING_WORLD" then
        RebuildRMPresenceCache()
        NHA.ScheduleEvaluate()
        return false
    end

    return false
end

-- =============================================================
-- Slash Commands
-- =============================================================
MonkMW.slashCommands = {
    ["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("  cufGlowEnabled: "   .. tostring(db.cufGlowEnabled))
        NHA.Msg("  rmActive: "         .. tostring(state.rmActive))
        NHA.Msg("  envActive: "        .. tostring(state.envActive))
        NHA.Msg("  soothingUnit: "     .. tostring(state.soothingUnit))
        NHA.Msg("  rmThresholdDungeon: " .. tostring(db.rmThresholdDungeon))
        NHA.Msg("  rmThresholdRaid: "  .. tostring(db.rmThresholdRaid))

        -- Active Renewing Mist count
        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

        -- Soothing channel detection diagnostics — useful for
        -- troubleshooting cases where the EM alert doesn't fire
        -- when expected (e.g., self-channels, statue interference).
        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))
        -- Per-unit 115175 presence (helpful when the live detection
        -- returns a non-obvious target, e.g. statue interference).
        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,

    -- /nha cufglow — diagnose raid-frame whole-frame glow state
    -- Mirrors the Druid Restoration cufglow command. Reports hook
    -- install state, tracked CUF count, RM cache size, and per-unit
    -- aura state so we can pinpoint where a missing glow stops.
    ["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)

        -- Step 1: enumerate _unitCUFs — what unit tokens are tracked
        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

        -- Step 2: per-unit EM/RM presence — which auras are detectable?
        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
                -- Find the frame for this unit to report its glow kind
                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,

    -- /nha env — live snapshot of the Soothing Mist / EM-alert state.
    -- Use when the EM alert fires or fails to clear unexpectedly.
    -- Run it immediately when you notice the alert misbehaving.
    ["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,

    -- =========================================================
    -- /nha perf — wraps Evaluate + EvaluateCUFGlow + RefreshGlows
    -- with debugprofilestop() timing and a per-frame spike detector.
    -- The core dispatches slash commands by exact-string match on
    -- the trimmed input, so each sub-command is its own key:
    --   /nha perf on   → start timing
    --   /nha perf      → mid-fight peek (no reset)
    --   /nha perf off  → stop and dump final stats
    --
    -- Wrapping is external (monkey-patched on MonkMW.Evaluate /
    -- .EvaluateCUFGlow / .RefreshGlows) so it can be enabled/disabled
    -- without /reload, and the OFF cost is exactly zero — the original
    -- functions are restored byref.
    --
    -- Reading the output:
    --   Evaluate avg > 1ms       → spec scans are hot
    --   Evaluate avg < 0.3ms but Encounter CPU still high
    --                            → drill into CUF GLOW + spike sections.
    --                              Worst-frame total > 10ms is the
    --                              most likely cause of perceived
    --                              "20% CPU spike" reports — those
    --                              are per-frame events that per-call
    --                              averages smooth away.
    --
    -- Block breakdown (RMCOUNT / SOOTH / ENV / END):
    --   RMCOUNT — event-driven count comparison (one integer check).
    --   SOOTH   — reads state.soothingUnit (event-driven; near-zero cost).
    --   ENV     — single FindAuraOnUnit on soothUnit.
    --   END     — rising-edge sound dispatch + state assignments.
    --
    -- CUF GLOW path tags (em/rm/none/disabled):
    --   em  → glowed Enveloping Mist priority
    --   rm  → glowed Renewing Mist priority
    --   none → no aura matched (both FindAuraOnUnit ran)
    --   disabled → cufGlowEnabled=false; cheap.
    --
    -- Note: CUF glow now runs via C_Timer.After(0) one-shots, NOT via
    -- the ticker. CUF call rate should equal the UNIT_AURA event rate
    -- for relevant spell IDs only — not the Evaluate rate. REFRESH
    -- is now an empty stub; its total should be near zero.
    --
    -- Spike record reports the worst single render frame seen.
    -- =========================================================
    ["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()
        -- Per-block instrumentation — gated by blocksEnabled flag
        -- read inside Evaluate at each PerfMarkBlock checkpoint.
        perf.blocksEnabled = true
        perf.blocks        = {}
        -- CUF glow stats (per-path attribution + aggregate)
        perf.cuf = {
            calls = 0, totalMs = 0, maxMs = 0,
            paths = { em = 0, rm = 0, none = 0, disabled = 0 },
        }
        -- RefreshGlows (ticker safety net — should be cheap)
        perf.refresh = { calls = 0, totalMs = 0, maxMs = 0 }
        -- Spike record (worst single render frame seen)
        perf.spike = {
            totalMs = 0, evalMs = 0, cufMs = 0, refreshMs = 0,
            cufCount = 0, atTime = 0,
        }

        -- ── Evaluate wrap ──
        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

        -- ── CUF glow attribution ──
        -- Note: The public MonkMW.EvaluateCUFGlow is now a cheap shim
        -- that just enqueues into dirtyCUFFrames. Wrapping the shim
        -- would only measure the dispatch cost (~0.001ms) and miss
        -- the actual work. Per-call CUF stats (calls/totalMs/maxMs/
        -- paths) flow through inline instrumentation in
        -- InstrumentedDoEvaluateCUFGlow, called from _DrainGlowDeferred.
        -- That instrumentation reads `perf.enabled` directly, so it
        -- activates without any wrap install on this side.

        -- ── RefreshGlows wrap ──
        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
        -- Restore byref before any final flush so the spike-roll
        -- sees the un-instrumented state on its next call. The CUF
        -- glow attribution doesn't need a restore — it's driven by
        -- inline instrumentation gated on perf.enabled, which we
        -- clear below.
        MonkMW.Evaluate         = perf.origEvaluate
        MonkMW.RefreshGlows     = perf.origRefresh
        perf.origEvaluate       = nil
        perf.origRefresh        = nil
        -- One last roll so the final frame's totals make it into spike
        -- before we read it (perf.enabled still true at this moment).
        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))

        -- Block breakdown — fixed display order so reads are stable
        local order = { "RMCOUNT", "SOOTH", "ENV", "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

        -- CUF GLOW aggregate + per-path breakdown
        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

        -- RefreshGlows aggregate
        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

        -- Worst frame in window — the per-frame spike record
        local s = perf.spike
        if s and s.totalMs > 0 then
            NHA.Msg(string_format(
                "|cffffd700WORST FRAME:|r %.3fms total (Eval=%.3fms  CUF=%.3fms x%d  Refresh=%.3fms)",
                s.totalMs, s.evalMs, s.cufMs, s.cufCount, s.refreshMs))
        end
    end,

    ["perf"] = function(self, db)
        local perf = state._perf
        if not perf or perf.calls == 0 then
            NHA.Msg("|cffffd700NHA Perf:|r " ..
                ((perf and perf.enabled) and "RUNNING — no samples yet"
                                          or "off (use /nha perf on)"))
            return
        end
        local elapsed = (debugprofilestop() - perf.startMs) / 1000
        local rate    = elapsed > 0 and (perf.calls / elapsed) or 0
        local avg     = perf.totalMs / perf.calls
        NHA.Msg(string_format(
            "|cffffd700NHA Perf %s:|r %.1fs, %d Eval (%.1f/s), avg=%.3fms max=%.3fms",
            perf.enabled and "RUNNING" or "STOPPED",
            elapsed, perf.calls, rate, avg, perf.maxMs))
        local cuf = perf.cuf
        if cuf and cuf.calls > 0 then
            local cufRate = elapsed > 0 and (cuf.calls / elapsed) or 0
            NHA.Msg(string_format(
                "  CUF: %d calls (%.1f/s) avg=%.3fms max=%.3fms",
                cuf.calls, cufRate, cuf.totalMs / cuf.calls, cuf.maxMs))
        end
        local s = perf.spike
        if s and s.totalMs > 0 then
            NHA.Msg(string_format(
                "  worst frame: %.3fms (E=%.3f C=%.3f x%d R=%.3f)",
                s.totalMs, s.evalMs, s.cufMs, s.cufCount, s.refreshMs))
        end
    end,
}
-- Register with core
NHA:RegisterSpec("MONK_MW", MonkMW)
