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

-- =============================================================
-- Taint-Safe Helpers
-- =============================================================

-- 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(function() return val + 0 end)
    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(function()
        return aura.sourceUnit == "player"
    end)
    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(function()
        return aura.auraInstanceID ~= nil and aura.auraInstanceID ~= 0
    end)
    -- 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(function()
        return aura.spellId and aura.spellId == id
    end)
    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

local function CountActiveRenewingMist()
    local count = 0
    local seen  = {}
    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.
        -- GUID can be a secret value in M+/rated PvP — wrap table
        -- operations in pcall since indexing with a secret key throws.
        local guid = UnitGUID(unit)
        local alreadySeen = false
        if guid then
            local ok, result = pcall(function() return seen[guid] end)
            if ok then
                alreadySeen = result
            end
            -- If pcall failed, guid is secret — skip dedup for this unit
            -- (minor risk of double-count but avoids crash)
        end
        if not alreadySeen then
            if FindAuraOnUnit(unit, RENEWING_MIST_ID, true) then
                count = count + 1
                if guid then
                    pcall(function() seen[guid] = true end)
                end
            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

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 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(function()
        return spellId == SOOTHING_MIST_ID
    end)
    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, then scans for the aura to identify
-- the target unit token.
--
-- CRITICAL: Scans party members FIRST, player LAST. In Midnight,
-- the player gets 115175 on themselves as a passive component
-- whenever SM is channeled on anyone. Scanning "player" first
-- would always return self, never the actual channel target.
-- If any party member has 115175, that's the real target. Only
-- fall back to "player" if nobody else has it (self-channel).
-- Also scans pet unit tokens since SM can target pets/minions.
local function FindPlayerSoothingTarget()
    if not IsPlayerChannelingSoothing() then return nil end
    -- Scan party members first
    local foundOnPlayer = false
    for _, unit in ipairs(NHA:GetGroupUnits()) do
        local aura = FindAuraOnUnit(unit, SOOTHING_MIST_ID, false)
        if aura then
            if unit == "player" then
                foundOnPlayer = true
            else
                return unit  -- party member has SM → real target
            end
        end
    end
    -- Scan pet unit 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
    -- No party member or pet has SM; player is channeling on self
    if foundOnPlayer then return "player" end
    return nil
end

-- =============================================================
-- Renewing Mist Tracking (for pandemic glow — mirrors LB/Atonement)
-- =============================================================
local state  -- forward declaration; assigned below

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

-- =============================================================
-- Glow Frame Tables (owned entirely by this module)
-- =============================================================
local rmGlowFrames = {}  -- buffFrame → safe aura copy (pandemic glow)
local emGlowFrames = {}  -- buffFrame → true (Enveloping Mist full-duration glow)
local smGlowFrames = {}  -- buffFrame → true (Soothing Mist active channel glow)

-- =============================================================
-- 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 = 0.659, b = 0.420 },  -- #00A86B
        hasTimer     = false,
        yOffset      = REML_Y,
        defaultSound = "Ping",
    },
    {
        key          = "env",
        label        = "Enveloping Mist",
        text         = "* ENVELOPING MIST *",
        defaultColor = { r = 1.000, g = 0.957, b = 0.671 },  -- #FFF4AB
        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 = 0.659, b = 0.420 },  -- #00A86B
        env = { r = 1.000, g = 0.957, b = 0.671 },  -- #FFF4AB
        sm  = { r = 0.000, g = 1.000, b = 0.596 },  -- monk teal
    },
}

-- =============================================================
-- 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
    specName  = "Mistweaver",
    alerts    = ALERTS,
    defaults  = DEFAULTS,
    state     = state,

    extraEvents = {
        "UNIT_AURA",
    },
}

-- =============================================================
-- ClearAllGlows
-- =============================================================
function MonkMW:ClearAllGlows()
    for buffFrame in pairs(rmGlowFrames) do
        NHA:HideProcGlow(buffFrame)
    end
    wipe(rmGlowFrames)
    for buffFrame in pairs(emGlowFrames) do
        NHA:HideProcGlow(buffFrame)
    end
    wipe(emGlowFrames)
    for buffFrame in pairs(smGlowFrames) do
        NHA:HideProcGlow(buffFrame)
    end
    wipe(smGlowFrames)
end

-- =============================================================
-- ClearAllState
-- =============================================================
function MonkMW:ClearAllState()
    state.rmActive          = false
    state.rmWasActive       = false
    ClearRMTracking()
    state.rmScanPending     = false
    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 RM pandemic glow refresh
    if state.rmUnit ~= nil then return true end
    return false
end

-- =============================================================
-- RefreshGlows (called every 0.25s by ticker)
-- =============================================================
function MonkMW:RefreshGlows(db)
    if not db or not db.glowEnabled then return end

    local now = GetTime()
    local c   = db.alertColors and db.alertColors.rm
                or { r=0, g=1, b=0.596 }
    for buffFrame, auraCopy in pairs(rmGlowFrames) do
        if buffFrame.auraInstanceID ~= auraCopy.auraInstanceID then
            NHA:HideProcGlow(buffFrame)
            rmGlowFrames[buffFrame] = nil
        else
            local remaining = (auraCopy.expirationTime - now) / auraCopy.timeMod
            if remaining > 0 and remaining <= RM_PANIC_THRESHOLD then
                NHA:ShowProcGlow(buffFrame, c.r, c.g, c.b)
            elseif remaining <= 0 then
                NHA:HideProcGlow(buffFrame)
                rmGlowFrames[buffFrame] = nil
            else
                NHA:HideProcGlow(buffFrame)
            end
        end
    end
end

-- =============================================================
-- EvaluateFrameGlow (called from hook pipeline)
-- =============================================================
-- RM pandemic glow: glows when RM has < 4.5s remaining.
--   Gated by master Icon Glow toggle (db.glowEnabled) only —
--   no separate per-glow toggle in the options panel.
-- EM full-duration glow: glows for the entire EM duration.
--   Color from db.alertColors.env.
function MonkMW:EvaluateFrameGlow(db, buffFrame, aura)
    local rmTracked = rmGlowFrames[buffFrame]
    local emTracked = emGlowFrames[buffFrame]
    local smTracked = smGlowFrames[buffFrame]

    if not db or not db.glowEnabled or not db.enabled then
        if rmTracked then NHA:HideProcGlow(buffFrame); rmGlowFrames[buffFrame] = nil end
        if emTracked then NHA:HideProcGlow(buffFrame); emGlowFrames[buffFrame] = nil end
        if smTracked then NHA:HideProcGlow(buffFrame); smGlowFrames[buffFrame] = nil end
        return
    end

    if not aura then
        if rmTracked then NHA:HideProcGlow(buffFrame); rmGlowFrames[buffFrame] = nil end
        if emTracked then NHA:HideProcGlow(buffFrame); emGlowFrames[buffFrame] = nil end
        if smTracked then NHA:HideProcGlow(buffFrame); smGlowFrames[buffFrame] = nil end
        return
    end

    if issecretvalue(aura.spellId) then
        if rmTracked then NHA:HideProcGlow(buffFrame); rmGlowFrames[buffFrame] = nil end
        if emTracked then NHA:HideProcGlow(buffFrame); emGlowFrames[buffFrame] = nil end
        if smTracked then NHA:HideProcGlow(buffFrame); smGlowFrames[buffFrame] = nil end
        return
    end

    -- sourceUnit may be secret in M+; use taint-safe helper
    if not IsPlayerOrUnknownSource(aura) then
        if rmTracked then NHA:HideProcGlow(buffFrame); rmGlowFrames[buffFrame] = nil end
        if emTracked then NHA:HideProcGlow(buffFrame); emGlowFrames[buffFrame] = nil end
        if smTracked then NHA:HideProcGlow(buffFrame); smGlowFrames[buffFrame] = nil end
        return
    end

    -- ── Soothing Mist active channel glow ────────────────────
    -- Glows for the full duration while SM is active on target.
    -- 115175 is whitelisted non-secret — spellId always readable.
    if aura.spellId == SOOTHING_MIST_ID then
        if rmTracked then NHA:HideProcGlow(buffFrame); rmGlowFrames[buffFrame] = nil end
        if emTracked then NHA:HideProcGlow(buffFrame); emGlowFrames[buffFrame] = nil end
        local sc = db.alertColors and db.alertColors.sm
                   or { r=0.0, g=1.0, b=0.596 }  -- monk teal
        smGlowFrames[buffFrame] = true
        NHA:ShowProcGlow(buffFrame, sc.r, sc.g, sc.b)
        return
    end

    -- ── Renewing Mist pandemic glow (< 4.5s remaining) ───────
    if aura.spellId == RENEWING_MIST_ID then
        if emTracked then NHA:HideProcGlow(buffFrame); emGlowFrames[buffFrame] = nil end
        if smTracked then NHA:HideProcGlow(buffFrame); smGlowFrames[buffFrame] = nil end

        if not buffFrame._safeNHAAura then buffFrame._safeNHAAura = {} end
        local sa = buffFrame._safeNHAAura
        sa.auraInstanceID = aura.auraInstanceID
        sa.expirationTime = aura.expirationTime or 0
        sa.duration       = aura.duration or 0
        sa.timeMod        = (aura.timeMod and aura.timeMod > 0)
                            and aura.timeMod or 1
        rmGlowFrames[buffFrame] = sa

        local now       = GetTime()
        local remaining = (sa.expirationTime - now) / sa.timeMod
        if remaining > 0 and remaining <= RM_PANIC_THRESHOLD then
            local rc = db.alertColors and db.alertColors.rm
                       or { r=0, g=1, b=0.596 }
            NHA:ShowProcGlow(buffFrame, rc.r, rc.g, rc.b)
        else
            if buffFrame._nhaGlowing then NHA:HideProcGlow(buffFrame) end
        end
        return
    end

    -- ── Enveloping Mist full-duration glow ───────────────────
    if aura.spellId == ENVELOPING_MIST_ID then
        if rmTracked then NHA:HideProcGlow(buffFrame); rmGlowFrames[buffFrame] = nil end
        if smTracked then NHA:HideProcGlow(buffFrame); smGlowFrames[buffFrame] = nil end

        local ec = db.alertColors and db.alertColors.env
                   or { r=0, g=0.8, b=0.478 }
        emGlowFrames[buffFrame] = true
        NHA:ShowProcGlow(buffFrame, ec.r, ec.g, ec.b)
        return
    end

    -- ── Not a spell we care about ─────────────────────────────
    if rmTracked then NHA:HideProcGlow(buffFrame); rmGlowFrames[buffFrame] = nil end
    if emTracked then NHA:HideProcGlow(buffFrame); emGlowFrames[buffFrame] = nil end
    if smTracked then NHA:HideProcGlow(buffFrame); smGlowFrames[buffFrame] = 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
    local rmLow = false
    if db.rmEnabled and UnitAffectingCombat("player") then
        local count     = CountActiveRenewingMist()
        local threshold = GetRMThreshold()
        rmLow = count < threshold
    end
    state.rmActive = rmLow

    -- ── Renewing Mist pandemic glow tracking (soonest-expiring) ─
    -- Gated by db.glowEnabled only; no separate rmGlowEnabled toggle.
    if state.rmUnit and state.rmInstID then
        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)
        else
            local u, a = FindSoonestRenewingMist()
            if u and a then StoreRMAura(u, a) else ClearRMTracking() end
        end
    else
        local u, a = FindSoonestRenewingMist()
        if u and a then StoreRMAura(u, a) else 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)
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
                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()
                        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
                        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
                        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
                    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("  glowEnabled: "      .. tostring(db.glowEnabled))
        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 glow 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

        -- Glow frame counts
        local emCount = 0
        for _ in pairs(emGlowFrames) do emCount = emCount + 1 end
        NHA.Msg("  emGlowFrames: " .. emCount)
        local smCount = 0
        for _ in pairs(smGlowFrames) do smCount = smCount + 1 end
        NHA.Msg("  smGlowFrames: " .. smCount)

        -- 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
    end,
}
-- Register with core
NHA:RegisterSpec("MONK_MW", MonkMW)
