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

-- Pandemic threshold for Renewing Mist glow (same as LB/Atonement)
local RM_PANIC_THRESHOLD = 4.5

-- Forward declaration: state is assigned its real table further down
-- (after Alert definitions). Hoisted up here because the RM scan
-- throttle helpers (ScanRMOrThrottle / ResetRMFullScanThrottle) and
-- StoreRMAura / ClearRMTracking reference state, and Lua resolves
-- `local` names at the lexical position of the declaration. Without
-- this, those helpers would see `state` as a global and silently
-- fail to update / reset the validation cache.
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

-- 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; once count meets it we stop scanning.
-- The Spread RM alert only cares whether count >= threshold, so once
-- we've hit it there's no reason to keep walking units. Pass 0/nil to
-- get the exact count (debug command path). Mirrors the v12.0.19
-- CountRejuvGerminationStacks(earlyOut) optimization on Druid.
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

local function FindSoonestRenewingMist()
    local bestUnit, bestAura, bestRemaining = nil, nil, math.huge
    local now = GetTime()

    for _, unit in ipairs(NHA:GetGroupUnits()) do
        local aura = FindAuraOnUnit(unit, RENEWING_MIST_ID, true)
        if aura then
            local expire = SafeNum(aura.expirationTime, 0)
            local remaining = expire - now
            if remaining > 0 and remaining < bestRemaining then
                bestUnit       = unit
                bestAura       = aura
                bestRemaining  = remaining
            end
        end
    end

    return bestUnit, bestAura
end

-- =============================================================
-- Scan throttles (mirrors v12.0.19 Druid Restoration optimization)
-- =============================================================
-- Two independent throttles:
--   RM_COUNT_PERIOD     — caches the "RM low coverage" fire state
--                         for the Spread RM alert. Crossing the
--                         threshold rarely happens faster than ~0.4s
--                         of casting; latency is invisible.
--   RM_FULLSCAN_PERIOD  — throttles the FindSoonestRenewingMist
--                         fallback scan when cached-aura validation
--                         fails. The cached-validation path itself
--                         (one GetAuraDataByAuraInstanceID call) is
--                         unaffected; only the expensive group walk
--                         is rate-limited. Cast/aura events that
--                         signal real RM state changes call
--                         ResetRMFullScanThrottle so genuine
--                         transitions still get sub-frame detection.
local RM_COUNT_PERIOD     = 0.4
local lastRMCountScan     = 0
local cachedRMLow         = false

local RM_FULLSCAN_PERIOD  = 0.4
local lastRMFullScan      = 0

local function ScanRMOrThrottle()
    -- Returns the (unit, aura) tuple from FindSoonestRenewingMist if
    -- the scan actually ran. Returns false to signal "throttled —
    -- caller must not touch state.rmUnit / state.rmInstID this tick".
    local now = GetTime()
    if (now - lastRMFullScan) < RM_FULLSCAN_PERIOD then
        return false
    end
    lastRMFullScan = now
    return FindSoonestRenewingMist()
end

local function ResetRMFullScanThrottle()
    -- Resets BOTH the FindSoonestRenewingMist throttle (so the next
    -- Evaluate is allowed to scan) and the rmValidatedAt cache (so
    -- the next Evaluate is forced to re-call GetAuraDataByAuraInstanceID
    -- instead of trusting the cached state). Called from cast/aura
    -- event paths that signal a real RM state change.
    lastRMFullScan = 0
    if state then state.rmValidatedAt = nil 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 FindSoothingMistTarget()
    for _, unit in ipairs(NHA:GetGroupUnits()) do
        if FindAuraOnUnit(unit, SOOTHING_MIST_ID, true) then
            return unit
        end
    end
    return nil
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.
local function FindPlayerSoothingTarget()
    if not IsPlayerChannelingSoothing() then return nil end

    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(function() return uGUID == pGUID end)
                    sameAsPlayer = ok and eq
                end
                if sameAsPlayer then
                    return "player"
                end
                return unit
            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
            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.
    return "player"
end

-- =============================================================
-- Renewing Mist Tracking (for pandemic glow — mirrors LB/Atonement)
-- =============================================================
-- (state forward-declared at top of file)

local function StoreRMAura(unit, aura)
    state.rmUnit     = unit
    state.rmInstID   = aura.auraInstanceID
    state.rmExpire   = SafeNum(aura.expirationTime, 0)
    state.rmDuration = SafeNum(aura.duration, 0)
    local tm = SafeNum(aura.timeMod, 1)
    state.rmTimeMod  = (tm > 0) and tm or 1
end

local function ClearRMTracking()
    state.rmUnit     = nil
    state.rmInstID   = nil
    state.rmExpire   = 0
    state.rmDuration = 0
    state.rmTimeMod  = 1
    state.rmValidatedAt = nil
end

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

state = {
    -- Renewing Mist low (Spread RM alert)
    rmActive       = false,
    rmWasActive    = false,
    -- Renewing Mist pandemic glow tracking (soonest-expiring)
    rmUnit         = nil,
    rmInstID       = nil,
    rmExpire       = 0,
    rmDuration     = 0,
    rmTimeMod      = 1,
    rmScanPending  = false,
    rmValidatedAt  = nil,  -- GetTime() of last successful instance-ID validation
    -- Enveloping missing (conditional)
    envActive      = false,
    envWasActive   = false,
    envLastSeenTime = 0,   -- debounce: last time EM was detected
    -- Soothing Mist channel tracking
    soothingUnit   = nil,
    soothingGUID   = nil,
    -- Grace period
    soothingStartTime = 0,
    -- Debounce
    playerAuraPending   = false,
    rosterUpdatePending = false,
}

-- =============================================================
-- Statue Slot Recovery  (must be after state declaration)
-- =============================================================
-- Checks whether the Jade Serpent Statue is within 40 yards using
-- the totem unit token (totem1–totem5). UnitInRange() on a totem
-- token returns true if within ~40 yards (friendly range check).
-- Fully wrapped in pcall — totem unit tokens may not support this
-- API in all contexts, and values may be secret in M+/rated PvP.
-- Returns: true (in range), false (out of range), nil (unknown/tainted)

-- =============================================================
-- Statue Activity Detection
-- =============================================================
-- mwscan confirmed: spell 115175 (Soothing Mist) has readable fields
-- including icon=606550 and isFromPlayerOrPlayerPet=true for BOTH the
-- player's channel AND the statue's independent channel on its target.
-- Spell 198533 (statue's copy) is fully SECRET, but 115175 appears on
-- the statue's target as well (same spell ID, whitelisted).
--
-- Strategy: scan all group units for 115175 with fromPet=true.
--   Found anywhere → player or statue is channeling SM → statue active.
--   Not found → neither is channeling → statue idle.
--
-- This correctly handles all cases:
--   Player channeling on X   → 115175 on X → true
--   Statue channeling on Y   → 115175 on Y → true
--   Both channeling          → 115175 on X and/or Y → true
--
-- Returns: true (SM active somewhere = statue working), false (statue idle)

-- =============================================================
-- CUF Whole-Frame Glow State
-- =============================================================
-- Maps each tracked CUF (CompactUnitFrame) to a small "safe aura"
-- table when that frame is currently showing a Renewing Mist glow.
-- The OnUpdate ticker (RefreshGlows) walks this set every 0.25s
-- as a safety net, cleaning up entries whose RM has expired in case
-- a UNIT_AURA removal event was missed.
local cufRMCache  = {}    -- frame -> { expirationTime, timeMod, auraInstanceID }
local cufStateMap = {}    -- frame -> "rm" (kept as a map for parity with Druid
                          --                and easy expansion if future conditions
                          --                are added)

-- =============================================================
-- Alert Definitions
-- =============================================================
local REML_Y = -30
local ENV_Y  = -60

local ALERTS = {
    -- Sound panel order: rm → env → js
    {
        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
-- =============================================================

-- Persist statue placement state to SavedVariables so it survives /reload.

-- Set placement confirmed — updates module vars and persists.

-- Clear placement — updates module vars and persists.

-- Load statue placement state from SavedVariables on login/reload.
-- Only restores if the expiry hasn't elapsed.
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",
    },

    -- 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.
    -- Only Renewing Mist drives the CUF whole-frame glow on Mistweaver.
    cufRelevantSpells = {
        [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)
end

-- =============================================================
-- ClearAllState
-- =============================================================
function MonkMW:ClearAllState()
    state.rmActive          = false
    state.rmWasActive       = false
    ClearRMTracking()
    state.rmScanPending     = false
    state.rmValidatedAt     = nil
    state.envActive         = false
    state.envWasActive      = false
    state.envLastSeenTime   = 0
    state.soothingUnit      = nil
    state.soothingGUID      = nil
    state.soothingStartTime = 0
    state.playerAuraPending = false
    state.rosterUpdatePending = false
    self:ClearAllGlows()
end

-- =============================================================
-- NeedsTicker
-- =============================================================
function MonkMW:NeedsTicker(db)
    -- Keep ticker alive for the CUF whole-frame RM glow safety-net
    -- expire check and for the RM tracking validation cache the
    -- Evaluate() pipeline maintains.
    if state.rmUnit ~= nil then return true end
    if next(cufRMCache) ~= nil then return true end
    return false
end

-- =============================================================
-- RefreshGlows (called every 0.25s by ticker)
-- =============================================================
-- Whole-frame RM glow on default Blizzard CUFs is a full-duration
-- glow: the per-frame state is driven by UNIT_AURA (apply → glow on,
-- remove → glow off) via EvaluateCUFGlow. This ticker job is purely
-- a safety net — if a UNIT_AURA event for an RM expire is missed
-- (which shouldn't happen, but heavy aura traffic can in theory
-- drop events), the ticker catches the stale entry once remaining
-- drops to zero and cleans up. No threshold-crossing logic is
-- needed since the glow doesn't change state mid-duration.
function MonkMW:RefreshGlows(db)
    if not db or not db.cufGlowEnabled then return end
    if not next(cufRMCache) then return end

    local now = GetTime()
    for frame, sa in pairs(cufRMCache) do
        local remaining = (sa.expirationTime - now) / sa.timeMod
        if remaining <= 0 then
            if cufStateMap[frame] == "rm" then
                NHA:HideFrameGlow(frame)
                cufStateMap[frame] = nil
            end
            cufRMCache[frame] = nil
        end
    end
end

-- =============================================================
-- EvaluateCUFGlow (default Blizzard CompactUnitFrames — 12.0.5
-- whole-frame workaround for forbidden buff icons)
-- =============================================================
-- Driven by core's CUF tracking pipeline (NemHealerAlerts.lua
-- InitCUFHooks). Called for every tracked CUF on UNIT_AURA delta
-- for the unit (filtered by cufRelevantSpells), on PLAYER_LOGIN /
-- spec change via NHA:RefreshAllCUFs(), and via the OnUpdate ticker
-- (RefreshGlows) for safety-net expire detection.
--
-- Glow conditions:
--   1. Renewing Mist from player on the unit (any remaining time)
--      → glow frame with RM color (db.alertColors.rm)
--   2. Otherwise → no glow.
--
-- The CUF glow runs for the FULL RM duration as a coverage signal:
-- "this unit has your RM." The cache (cufRMCache) is used so the
-- ticker safety net can detect expire and clean up if a UNIT_AURA
-- event is missed.
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
        return
    end

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

        -- Full-duration glow: only check that the aura hasn't already
        -- expired (defensive — should always be > 0 if FindAuraOnUnit
        -- just returned the aura, but the field can be secret).
        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"
            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
end

-- =============================================================
-- Evaluate (called by core ticker and events)
-- =============================================================
function MonkMW:Evaluate(db)
    -- ── Renewing Mist low coverage (combat only) ─────────────
    -- Process RM FIRST so a crash in statue code can't block it.
    --
    -- Throttled at RM_COUNT_PERIOD: the count-vs-threshold result
    -- is cached between scans, with sub-second alert latency.
    -- Mirrors the v12.0.19 Druid Abundance throttle pattern and
    -- cuts the per-tick cost in raid from ~30 unit walks at 4 Hz
    -- (~120/sec) to ~30 walks at 2.5 Hz (~75/sec). The earlyOut
    -- parameter further cuts cost when coverage is healthy: once
    -- count >= threshold we stop scanning, so a fully-covered
    -- raid only walks `threshold` units (e.g. 4) instead of 30.
    local rmLow = false
    if db.rmEnabled and UnitAffectingCombat("player") then
        local now = GetTime()
        if (now - lastRMCountScan) >= RM_COUNT_PERIOD then
            lastRMCountScan = now
            local threshold = GetRMThreshold()
            local count     = CountActiveRenewingMist(threshold)
            cachedRMLow     = count < threshold
        end
        rmLow = cachedRMLow
    else
        cachedRMLow = false
    end
    state.rmActive = rmLow

    -- ── Renewing Mist tracking (soonest-expiring) ─────────────
    -- Three-tier validation strategy (mirrors v12.0.19 Druid LB):
    --   1. Cached state still healthy (validated <0.25s ago AND
    --      expire >5s out)            → skip API call entirely.
    --   2. Cached state present, refresh via cheap instance-ID
    --      lookup                      → one API call, no scan.
    --   3. Lookup failed or no cache  → fall through to throttled
    --      group rescan (ScanRMOrThrottle).
    -- Worst-case alert delay for the 4.5s pandemic threshold: 0.25s
    -- — invisible. ResetRMFullScanThrottle() in OnEvent ensures
    -- genuine state changes (RM cast, removed, refreshed) bypass
    -- the throttle for sub-frame detection.
    if state.rmUnit and state.rmInstID then
        local now = GetTime()
        if state.rmValidatedAt
           and (now - state.rmValidatedAt) < 0.25
           and state.rmExpire and state.rmExpire > (now + 5) then
            -- Cached state is healthy and not approaching expiry.
            -- Trust it; downstream code reads state.rmExpire directly.
        else
            local ok, aura = pcall(C_UnitAuras.GetAuraDataByAuraInstanceID,
                state.rmUnit, state.rmInstID)
            if ok and aura and not issecretvalue(aura.spellId)
               and aura.spellId == RENEWING_MIST_ID
               and IsPlayerOrUnknownSource(aura) then
                StoreRMAura(state.rmUnit, aura)
                state.rmValidatedAt = now
            else
                local u, a = ScanRMOrThrottle()
                if u and a then
                    StoreRMAura(u, a)
                    state.rmValidatedAt = now
                elseif u == nil and a == nil then
                    -- Real scan happened, found nothing → clear tracking
                    ClearRMTracking()
                end
                -- u == false: throttled, leave state alone
            end
        end
    else
        local u, a = ScanRMOrThrottle()
        if u and a then
            StoreRMAura(u, a)
            state.rmValidatedAt = GetTime()
        elseif u == nil and a == nil then
            ClearRMTracking()
        end
    end

    -- ── Player's Soothing Mist target (for env + statue checks) ──
    -- Uses UnitChannelInfo to confirm the PLAYER is channeling,
    -- not just the statue. This prevents false alerts when the
    -- statue is soothing but the player isn't.
    local soothUnit = FindPlayerSoothingTarget()
    -- Detect rising edge of SM channel start (new channel only)
    local smJustStarted = soothUnit and not state.soothingUnit
    if smJustStarted then
        state.soothingStartTime = GetTime()
    elseif not soothUnit then
        state.soothingStartTime = 0
    end
    state.soothingUnit = soothUnit

    -- ── Enveloping Mist missing on Soothing Mist target ──────
    -- Only fires when the PLAYER is channeling. Debounce at 0.5s
    -- prevents flashing from aura list enumeration misses.
    local envMissing = false
    local now = GetTime()
    if db.envEnabled and soothUnit 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
    else
        -- Not channeling; reset debounce
        if not soothUnit then
            state.envLastSeenTime = 0
        end
    end
    state.envActive = envMissing

        -- ── 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
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 = ...

        -- RM tracked unit: fast-path removal
        if unit == state.rmUnit then
            if not updateInfo or updateInfo.isFullUpdate then
                ResetRMFullScanThrottle()
                NHA.ScheduleEvaluate(); return true
            end
            if updateInfo.removedAuraInstanceIDs and state.rmInstID then
                for _, instID in ipairs(updateInfo.removedAuraInstanceIDs) do
                    if instID == state.rmInstID then
                        ClearRMTracking()
                        ResetRMFullScanThrottle()
                        NHA.ScheduleEvaluate()
                        return true
                    end
                end
            end
            if updateInfo.updatedAuraInstanceIDs and state.rmInstID then
                for _, instID in ipairs(updateInfo.updatedAuraInstanceIDs) do
                    if instID == state.rmInstID then
                        ResetRMFullScanThrottle()
                        NHA.ScheduleEvaluate(); return true
                    end
                end
            end
            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
                        ResetRMFullScanThrottle()
                        NHA.ScheduleEvaluate(); return true
                    end
                end
            end
            return true
        end

        -- Other units: trigger scan if RM not yet tracked
        if not state.rmUnit and not state.rmScanPending then
            if not updateInfo or updateInfo.isFullUpdate or updateInfo.addedAuras then
                state.rmScanPending = true
                C_Timer.After(0.1, function()
                    state.rmScanPending = false
                    ResetRMFullScanThrottle()
                    NHA.Evaluate()
                end)
            end
        end

        -- General debounce
        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 == "GROUP_ROSTER_UPDATE" then
        if not state.rosterUpdatePending then
            state.rosterUpdatePending = true
            C_Timer.After(0.5, function()
                state.rosterUpdatePending = false
                NHA.Evaluate()
            end)
        end
        return true
    end

    if event == "PLAYER_REGEN_ENABLED" then
        ClearRMTracking()
        state.soothingUnit      = nil
        state.soothingGUID      = nil
        state.soothingStartTime = 0
        NHA.ScheduleEvaluate()
        return false
    end

    if event == "PLAYER_REGEN_DISABLED" then
        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
        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))

        -- RM tracking
        NHA.Msg("  rmUnit: "    .. tostring(state.rmUnit))
        NHA.Msg("  rmInstID: "  .. tostring(state.rmInstID))
        if state.rmUnit and state.rmExpire > 0 then
            local remaining = (state.rmExpire - GetTime()) / state.rmTimeMod
            NHA.Msg("  rmRemaining: " .. string_format("%.1f", remaining) .. "s")
        end

        -- 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()))
        -- 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 rmCacheCount = 0
        for _ in pairs(cufRMCache) do rmCacheCount = rmCacheCount + 1 end
        local stateRMCount = 0
        for _, kind in pairs(cufStateMap) do
            if kind == "rm" then stateRMCount = stateRMCount + 1 end
        end
        NHA.Msg("  tracked CUFs: "   .. trackedCount)
        NHA.Msg("  cufRMCache size: " .. rmCacheCount)
        NHA.Msg("  glowing (RM): "   .. 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 RM presence — is OUR RM detectable?
        NHA.Msg("|cffffd700  Per-unit RM (119611) detection:|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 rmAura = FindAuraOnUnit(unit, RENEWING_MIST_ID, true)
                if rmAura then
                    local exp = SafeNum(rmAura.expirationTime, 0)
                    local tm  = SafeNum(rmAura.timeMod, 1)
                    if tm <= 0 then tm = 1 end
                    local remaining = (exp - GetTime()) / tm
                    -- Find the frame for this unit to report its glow state
                    local glowing = false
                    if NHA._unitCUFs and NHA._unitCUFs[unit] then
                        for f in pairs(NHA._unitCUFs[unit]) do
                            if cufStateMap[f] == "rm" then
                                glowing = true
                                break
                            end
                        end
                    end
                    NHA.Msg(string_format(
                        "  %s (%s): RM remaining=%.1fs  glowing=%s",
                        unit,
                        (nameOk and tostring(name)) or "?",
                        remaining,
                        tostring(glowing)))
                else
                    NHA.Msg(string_format(
                        "  %s (%s): no player RM",
                        unit,
                        (nameOk and tostring(name)) or "?"))
                end
                probedUnits = probedUnits + 1
            end
        end
    end,
}
-- Register with core
NHA:RegisterSpec("MONK_MW", MonkMW)
