-- =============================================================
-- 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
--
-- NOTE: GetTotemInfo() returns SECRET BOOLEAN values in M+/rated
-- PvP combat. The return values succeed (pcall ok=true) but the
-- booleans themselves are tainted and cannot be tested with `if`.
-- We must pcall the entire conditional check, not just the call.
-- =============================================================

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 SUMMON_JADE_STATUE_ID   = 115313   -- Summon Jade Serpent Statue (cast spell)
local STATUE_SOOTHING_MIST_ID = 198533   -- Statue's Soothing Mist (buff on target)

-- Talent node for Jade Serpent Statue (choice node with Jade Infusion)
local JADE_STATUE_NODE_ID     = 101164

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

-- =============================================================
-- Talent Check  (mirrors Druid Reforestation pattern)
-- =============================================================
local function CheckJadeStatueTalent()
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then return false end
    local nodeInfo = C_Traits and C_Traits.GetNodeInfo
                     and C_Traits.GetNodeInfo(configID, JADE_STATUE_NODE_ID)
    if not nodeInfo then return false end

    if nodeInfo.ranksPurchased and nodeInfo.ranksPurchased > 0 then
        if nodeInfo.activeEntry and nodeInfo.activeEntry.entryID then
            if nodeInfo.activeEntry.entryID == 133997 then return true end
            if nodeInfo.activeEntry.definitionID then
                local defInfo = C_Traits and C_Traits.GetDefinitionInfo
                                and C_Traits.GetDefinitionInfo(nodeInfo.activeEntry.definitionID)
                if defInfo and defInfo.spellID == SUMMON_JADE_STATUE_ID then
                    return true
                end
            end
        end
    end
    return false
end

-- =============================================================
-- Jade Serpent Statue Detection  (via GetTotemInfo API)
-- =============================================================
-- CRITICAL: GetTotemInfo returns SECRET BOOLEAN values in M+
-- and rated PvP. pcall on the function call itself succeeds
-- (ok=true), but the returned `haveTotem` boolean is tainted
-- and throws "attempt to perform boolean test on secret value"
-- if you try `if haveTotem then`. We must pcall the ENTIRE
-- check including the boolean test.
--
-- Additionally, totemName/startTime/duration may be <no value>
-- (secret) in combat. We track statue placement via
-- PLAYER_TOTEM_UPDATE events outside of combat, and use the
-- cast-tracking fallback (UNIT_SPELLCAST_SUCCEEDED) in combat.
local statueKnownPlaced = false
local statueKnownExpire = 0     -- GetTime() + duration at placement

local function IsJadeStatuePlaced()
    -- GetTotemInfo return values in M+/rated PvP:
    --   haveTotem  — may be a secret boolean (throws on `if haveTotem`)
    --   totemName  — readable (not secret)
    --   startTime  — may be secret
    --   duration   — may be secret
    --   icon       — readable (not secret)
    --
    -- Strategy: skip haveTotem entirely. Read totemName and icon
    -- directly — if they match the statue, it's placed regardless
    -- of haveTotem. Wrap only the GetTotemInfo call itself in pcall.

    if not GetTotemInfo then
        -- No API: fall back to event-tracked state
        if statueKnownPlaced then
            if statueKnownExpire > 0 and GetTime() > statueKnownExpire then
                statueKnownPlaced = false
                state.statueSlot  = 0
                return false
            end
            return true
        end
        return false
    end

    local maxTotems = MAX_TOTEMS or 5
    local anySlotFailed = false

    for i = 1, maxTotems do
        local gotName, totemName, icon = false, nil, nil
        local ok = pcall(function()
            local _haveTotem, _name, _startTime, _duration, _icon =
                GetTotemInfo(i)
            -- Don't test _haveTotem — may be secret boolean.
            -- Read name and icon directly; they are not secret.
            totemName = _name
            icon      = _icon
            gotName   = true
            -- Try to store timing data (may throw if secret)
            if _startTime ~= nil and _duration ~= nil then
                local expOk = pcall(function()
                    statueKnownExpire = _startTime + _duration
                end)
            end
        end)

        if not ok then
            anySlotFailed = true
        elseif gotName then
            -- Check name and icon (neither is secret)
            local isJade = false
            if icon == 620831 then
                isJade = true
            elseif type(totemName) == "string" and totemName ~= "" then
                local nameOk, found = pcall(function()
                    return totemName:find("Jade Serpent") ~= nil
                end)
                isJade = nameOk and found
            end
            if isJade then
                state.statueSlot  = i
                statueKnownPlaced = true
                return true
            end
        end
    end

    -- Finished scanning all slots
    if not anySlotFailed then
        -- Clean scan, statue not found → truly gone
        statueKnownPlaced = false
        state.statueSlot  = 0
        return false
    end

    -- At least one slot threw — fall back to event-tracked state
    if statueKnownPlaced then
        if statueKnownExpire > 0 and GetTime() > statueKnownExpire then
            statueKnownPlaced = false
            state.statueSlot  = 0
            return false
        end
        return true
    end
    return false
end

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

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

local function FindStatueSoothingMistTarget()
    for _, unit in ipairs(NHA:GetGroupUnits()) do
        -- No source check: statue is the source, not player
        if FindAuraOnUnit(unit, STATUE_SOOTHING_MIST_ID, false) then
            return unit
        end
    end
    return 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).
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
    -- No party member 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,
    -- Jade Statue: not placed
    jsActive       = false,
    jsWasActive    = false,
    hasJadeStatueTalent = false,
    -- Statue Not Casting
    siActive       = false,
    siWasActive    = false,
    -- Statue timer: suppresses si while statue is mid-channel
    statueExpireTime        = 0,
    statueSlot              = 0,     -- totem slot index (1-5), 0 if unknown
    -- Grace period
    soothingStartTime = 0,
    -- Debounce
    playerAuraPending   = false,
    rosterUpdatePending = false,
}

-- =============================================================
-- Statue Range Check
-- =============================================================
-- Must be defined AFTER state table since it references state.statueSlot.
-- 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)
local function IsStatueInRange()
    local slot = state.statueSlot
    if not slot or slot == 0 then return nil end  -- slot unknown

    local unit = "totem" .. slot
    local ok, result = pcall(function()
        return UnitExists(unit)
    end)
    if not ok or not result then return nil end  -- unit doesn't exist or tainted

    -- UnitInRange returns inRange, checkedRange
    -- checkedRange = false means the API couldn't determine range
    local ok2, inRange, checkedRange = pcall(function()
        return UnitInRange(unit)
    end)
    if not ok2 then return nil end           -- tainted
    if not checkedRange then return nil end  -- API couldn't determine range
    return inRange
end

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

-- =============================================================
-- Alert Definitions
-- =============================================================
local JS_Y   =  22
local SI_Y   =  -2
local REML_Y = -30
local ENV_Y  = -60

local ALERTS = {
    -- Sound panel order: rm → env → js → si
    {
        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 = "Redfox",
    },
    {
        key          = "js",
        label        = "Place Jade Statue",
        text         = "* PLACE JADE STATUE *",
        defaultColor = { r = 0.000, g = 0.502, b = 0.502 },  -- #008080
        hasTimer     = false,
        yOffset      = JS_Y,
        defaultSound = "Lamp",
    },
    {
        key          = "si",
        label        = "Statue Not Casting",
        text         = "* STATUE NOT CASTING *",
        defaultColor = { r = 0.996, g = 0.337, b = 0.027 },  -- #FE5607
        hasTimer     = false,
        yOffset      = SI_Y,
        defaultSound = "Bell",
    },
}

-- =============================================================
-- Saved-Variable Defaults (merged into core db)
-- =============================================================
local DEFAULTS = {
    rmEnabled            = true,
    rmSoundEnabled       = true,
    rmSoundName          = "Ping",
    envEnabled           = true,
    envSoundEnabled      = true,
    envSoundName         = "Redfox",
    jsEnabled            = true,
    jsSoundEnabled       = true,
    jsSoundName          = "Lamp",
    siEnabled            = true,
    siSoundEnabled       = true,
    siSoundName          = "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
        js  = { r = 0.000, g = 0.502, b = 0.502 },  -- #008080
        si  = { r = 0.996, g = 0.337, b = 0.027 },  -- #FE5607
    },
}

-- =============================================================
-- MODULE TABLE
-- =============================================================
local MonkMW = {
    class     = "MONK",
    specIndex = 2,  -- Mistweaver is spec index 2
    specName  = "Mistweaver",
    alerts    = ALERTS,
    defaults  = DEFAULTS,
    state     = state,

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

-- =============================================================
-- 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)
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.jsActive          = false
    state.jsWasActive       = false
    state.siActive          = false
    state.siWasActive       = false
    state.statueExpireTime        = 0
        state.statueSlot              = 0
    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
    -- Keep ticker alive while statue timer is live so Evaluate
    -- fires when it expires and can set siActive/jsActive
    if state.statueExpireTime > 0
       and GetTime() < state.statueExpireTime 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]

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

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

    -- ── Statue placement check ────────────────────────────────
    -- Wrapped in pcall: GetTotemInfo can return secret values in
    -- M+/rated PvP.
    local statuePlaced = false
    if state.hasJadeStatueTalent then
        local ok, placed = pcall(IsJadeStatuePlaced)
        if ok then
            statuePlaced = placed
        else
            statuePlaced = statueKnownPlaced
            if statueKnownPlaced and statueKnownExpire > 0
               and GetTime() > statueKnownExpire then
                statueKnownPlaced = false
                statuePlaced = false
            end
        end
    end

    -- If statue just despawned mid-combat, reset the timer so si
    -- doesn't keep firing. js will fire immediately on "not placed".
    if not statuePlaced and state.statueExpireTime > 0 then
        state.statueExpireTime = 0
    end

    -- ── Timer-based statue activity tracking ──────────────────
    -- When the player starts a new SM channel in combat with the statue
    -- placed, arm a 13s timer representing the statue's independent
    -- channel. Statue Idle is suppressed while this timer is live.
    local inCombat = UnitAffectingCombat("player")
    -- Timer set to 12s. Statue channels for exactly 11.4s per tooltip.
    -- 0.6s buffer prevents early si firing.
    if smJustStarted and inCombat and statuePlaced then
        state.statueExpireTime = GetTime() + 12.0
        -- Schedule Evaluate for when timer expires so si can fire
        -- even if no aura events occur at that moment
        C_Timer.After(12.1, function()
            if UnitAffectingCombat("player") then
                NHA.ScheduleEvaluate()
            end
        end)
    end

    -- Statue is actively channeling while our 13s timer is live
    local statueIsCasting = inCombat and GetTime() < state.statueExpireTime

    -- ── Place Jade Statue ────────────────────────────────────
    -- Fires when:
    --   1. Statue not placed at all
    --   2. Statue placed but confirmed out of range (>40 yds) — only
    --      when IsStatueInRange() returns false (not nil). If the API
    --      is tainted or unavailable it returns nil and we don't alert.
    local statueInRange = IsStatueInRange()  -- true/false/nil
    local jsMissing = false
    if db.jsEnabled and state.hasJadeStatueTalent and inCombat then
        if not statuePlaced then
            jsMissing = true
        elseif statueInRange == false then
            -- API confirmed statue is out of 40yd range
            jsMissing = true
        end
    end
    state.jsActive = jsMissing

    -- ── Statue Not Casting ───────────────────────────────────
    -- Fires when: in combat + statue placed + in range (or range unknown)
    -- + player not channeling SM + 22s timer expired.
    -- If statue is confirmed out of range, js takes priority and si
    -- is suppressed (out-of-range is a placement issue, not an idle issue).
    local siMissing = db.siEnabled and state.hasJadeStatueTalent
                      and inCombat
                      and statuePlaced
                      and statueInRange ~= false  -- don't fire if confirmed OOR
                      and not soothUnit           -- player not channeling
                      and not statueIsCasting     -- 22s timer expired
    state.siActive = siMissing

    -- ── Rising-edge sounds ───────────────────────────────────
    if jsMissing   and not state.jsWasActive   then NHA:PlayAlertSound("js")   end
    if siMissing   and not state.siWasActive   then NHA:PlayAlertSound("si")   end
    if rmLow       and not state.rmWasActive   then NHA:PlayAlertSound("rm")   end
    if envMissing  and not state.envWasActive  then NHA:PlayAlertSound("env")  end
    state.jsWasActive  = jsMissing
    state.siWasActive  = siMissing
    state.rmWasActive  = rmLow
    state.envWasActive = envMissing
end

-- =============================================================
-- OnActivate
-- =============================================================
function MonkMW:OnActivate(db)
    state.hasJadeStatueTalent = CheckJadeStatueTalent()
    -- Check statue placement on login (out of combat, API works)
    statueKnownPlaced = false
    statueKnownExpire = 0
    pcall(function()
        local placed = IsJadeStatuePlaced()
        -- IsJadeStatuePlaced updates statueKnownPlaced internally
    end)
end

-- =============================================================
-- OnDeactivate
-- =============================================================
function MonkMW:OnDeactivate(db)
    -- State is cleared by core calling ClearAllState + ClearAllGlows
end

-- =============================================================
-- OnEvent
-- =============================================================
function MonkMW:OnEvent(db, event, ...)
    -- ── Totem placed or destroyed ────────────────────────────
    -- PLAYER_TOTEM_UPDATE fires out of combat AND in combat.
    -- Out of combat: GetTotemInfo works, so update tracked state.
    -- In combat: GetTotemInfo may be tainted, but we still try.
    if event == "PLAYER_TOTEM_UPDATE" then
        pcall(function()
            local placed = IsJadeStatuePlaced()
            if not placed then
                statueKnownPlaced = false
                statueKnownExpire = 0
            end
        end)
        NHA.ScheduleEvaluate()
        return true
    end

    -- ── Track statue placement via cast (combat-safe) ────────
    if event == "UNIT_SPELLCAST_SUCCEEDED" then
        local unit, _, spellID = ...
        if unit == "player" and spellID == SUMMON_JADE_STATUE_ID then
            statueKnownPlaced = true
            statueKnownExpire = GetTime() + 900
            -- Slot won't be in GetTotemInfo immediately — scan after a short delay
            C_Timer.After(0.3, function()
                pcall(function() IsJadeStatuePlaced() end)
            end)
            NHA.ScheduleEvaluate()
        end
        return true
    end

    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
        -- Combat ended; clear stale tracking.
        -- Preserve statueExpireTime if the statue is still mid-channel
        -- (timer live) so si doesn't fire immediately on next pull.
        ClearRMTracking()
        state.soothingUnit      = nil
        state.soothingGUID      = nil
        state.soothingStartTime = 0
        if GetTime() >= state.statueExpireTime then
            -- Timer already expired — safe to clear
            state.statueExpireTime = 0
        end
        -- Keep statueSlot — statue is still placed, slot hasn't changed
        NHA.ScheduleEvaluate()
        return false  -- let core handle
    end

    if event == "PLAYER_REGEN_DISABLED" then
        -- Entering combat. Don't reset statueExpireTime — if the statue
        -- was mid-channel from the previous pull it should still suppress si.
        -- Refresh slot detection while API is still readable.
        if state.hasJadeStatueTalent then
            pcall(function() IsJadeStatuePlaced() end)
        end
        return false  -- let core handle
    end

    if event == "PLAYER_TALENT_UPDATE"
    or event == "PLAYER_SPECIALIZATION_CHANGED"
    or event == "TRAIT_CONFIG_UPDATED" then
        state.hasJadeStatueTalent = false
        C_Timer.After(0.5, function()
            state.hasJadeStatueTalent = CheckJadeStatueTalent()
            NHA.ScheduleEvaluate()
        end)
        return false
    end

    if event == "PLAYER_ENTERING_WORLD" then
        state.hasJadeStatueTalent = CheckJadeStatueTalent()
        return false
    end

    -- Player died — statue despawns
    if event == "PLAYER_DEAD" then
        statueKnownPlaced = false
        statueKnownExpire = 0
        state.statueExpireTime           = 0
        state.statueSlot             = 0
        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("  jsEnabled: " .. tostring(db.jsEnabled))
        NHA.Msg("  siEnabled: " .. tostring(db.siEnabled))
        NHA.Msg("  glowEnabled: " .. tostring(db.glowEnabled))
        NHA.Msg("  rmActive: " .. tostring(state.rmActive))
        NHA.Msg("  envActive: " .. tostring(state.envActive))
        NHA.Msg("  jsActive: " .. tostring(state.jsActive))
        NHA.Msg("  siActive: " .. tostring(state.siActive))
        NHA.Msg("  statueExpireTime: " .. tostring(state.statueExpireTime))
        local timeNow = GetTime()
        local isCasting = timeNow < state.statueExpireTime
        NHA.Msg("  statueIsCasting: " .. tostring(isCasting)
            .. " (now=" .. string_format("%.1f", timeNow)
            .. " expire=" .. string_format("%.1f", state.statueExpireTime)
            .. " remaining=" .. string_format("%.1fs", state.statueExpireTime - timeNow) .. ")")
        NHA.Msg("  statueSlot: " .. tostring(state.statueSlot))
        local inRange = IsStatueInRange()
        NHA.Msg("  statueInRange: " .. tostring(inRange)
            .. (state.statueSlot > 0
                and (" (unit=totem" .. state.statueSlot .. " exists="
                    .. tostring(UnitExists("totem" .. state.statueSlot)) .. ")")
                or " (slot unknown)"))
        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

        -- EM glow frame count
        local emCount = 0
        for _ in pairs(emGlowFrames) do emCount = emCount + 1 end
        NHA.Msg("  emGlowFrames: " .. emCount)

        -- Jade Statue talent + placement
        NHA.Msg("  hasJadeStatueTalent: " .. tostring(state.hasJadeStatueTalent))
        NHA.Msg("  statueKnownPlaced: " .. tostring(statueKnownPlaced))
        if statueKnownExpire > 0 then
            local remaining = statueKnownExpire - GetTime()
            NHA.Msg("  statueTimeRemaining: " .. string_format("%.1f", remaining) .. "s")
        end

        -- Try GetTotemInfo (may fail in combat)
        NHA.Msg("|cffffd700  Totem Slot Dump:|r")
        local maxTotems = MAX_TOTEMS or 5
        for i = 1, maxTotems do
            pcall(function()
                local haveTotem, totemName, startTime, duration, icon =
                    GetTotemInfo(i)
                -- This will throw in combat if values are secret,
                -- but the pcall catches it
                if haveTotem then
                    NHA.Msg("    Slot " .. i .. ": name=" .. tostring(totemName)
                        .. " icon=" .. tostring(icon))
                else
                    NHA.Msg("    Slot " .. i .. ": empty")
                end
            end)
        end

        -- Statue Soothing Mist target (diagnostic)
        local statueTarget = FindStatueSoothingMistTarget()
        NHA.Msg("  statueSoothingTarget: " .. tostring(statueTarget))

        -- Active Renewing Mist count
        local activeRM = CountActiveRenewingMist()
        local rmThreshold = GetRMThreshold()
        NHA.Msg("  activeRenewingMist: " .. activeRM .. " / threshold: " .. rmThreshold)

        -- Soothing Mist channel detection
        local isChanneling = IsPlayerChannelingSoothing()
        NHA.Msg("  playerChannelingSoothing: " .. tostring(isChanneling))
        local playerSoothTarget = FindPlayerSoothingTarget()
        NHA.Msg("  playerSoothingTarget: " .. tostring(playerSoothTarget))

        -- Aura-based soothing scan
        local auraSoothTarget = FindSoothingMistTarget()
        NHA.Msg("  auraSoothingTarget: " .. tostring(auraSoothTarget))
        if playerSoothTarget then
            local hasEnv = UnitHasEnvelopingMist(playerSoothTarget)
            NHA.Msg("  hasEnvelopingMist: " .. tostring(hasEnv))
        end
    end,

    -- Live statue timer trace: prints every second for 25s.
    -- Usage: /nha mwtrace
    -- Start just before channeling SM to measure exact statue duration.
    -- Watch for when statueIsCasting flips false vs statue visual end.
    ["mwtrace"] = function(self, db)
        NHA.Msg("|cffffd700[MW Trace]|r Starting 20s statue timer trace...")
        local ticks = 0
        local function tick()
            ticks = ticks + 1
            local now = GetTime()
            local remaining = state.statueExpireTime - now
            local isCasting = now < state.statueExpireTime
            NHA.Msg(string_format(
                "|cffffd700[MW %2ds]|r statueIsCasting=|cff%s%s|r  remaining=|cffffd700%.2fs|r  si=%s  js=%s  sooth=%s",
                ticks,
                isCasting and "00ff00" or "ff4444",
                tostring(isCasting),
                remaining,
                tostring(state.siActive),
                tostring(state.jsActive),
                tostring(state.soothingUnit)
            ))
            if ticks < 20 then
                C_Timer.After(1.0, tick)
            else
                NHA.Msg("|cffffd700[MW Trace]|r Done.")
            end
        end
        C_Timer.After(1.0, tick)
    end,
}

-- =============================================================
-- Register with core
-- =============================================================
NHA:RegisterSpec("MONK_MW", MonkMW)
