-- =============================================================
-- Specs/PriestDiscipline.lua
-- Nem: Healer Alerts — Discipline Priest spec module
--
-- FULLY SELF-CONTAINED. This file contains ALL Disc Priest
-- 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().
-- =============================================================

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 UnitIsEnemy         = UnitIsEnemy
local UnitAffectingCombat = UnitAffectingCombat
local C_UnitAuras         = C_UnitAuras
local issecretvalue       = NHA.issecretvalue

-- =============================================================
-- Spell / Aura IDs
-- =============================================================
local ATONEMENT_ID   = 194384
local MTD_PROC_ID    = 1253591   -- Master the Darkness (player proc buff)
local MTD_ICON_ID    = 5976941   -- Master the Darkness icon texture
local VOID_SHIELD_ID = 1253593   -- Void Shield (absorb on target)
local PWS_ID         = 17

-- Atonement count thresholds (mirrors Monk RM pattern)
-- Alert fires when active Atonement count drops BELOW the threshold.
local ATON_THRESHOLD_DUNGEON = 2   -- need ≥2 Atonements in a party/dungeon
local ATON_THRESHOLD_RAID    = 4   -- need ≥4 Atonements in a raid

-- =============================================================
-- Taint-Safe Helpers
-- =============================================================
-- In WoW Midnight, aura spell IDs can be "secret number values"
-- that throw errors on ANY operation (==, tostring, etc.).
-- These helpers wrap comparisons in pcall to avoid taint errors.

-- Returns true if aura.spellId safely equals the given ID
local function AuraSpellIs(aura, id)
    local ok, result = pcall(function()
        return aura.spellId and aura.spellId == id
    end)
    return ok and result
end

-- Returns the spellId as a number, or nil if tainted/missing
local function SafeGetSpellId(aura)
    local ok, result = pcall(function()
        return aura.spellId
    end)
    if ok and result and not issecretvalue(result) then
        return result
    end
    return nil
end

-- =============================================================
-- Module State (owned entirely by this module)
-- =============================================================
local state = {
    -- Atonement low coverage counter (mirrors Monk RM pattern)
    atonActive      = false,
    atonWasActive   = false,
    atonCount       = 0,    -- current active Atonement count
    -- Void Shield proc (Master the Darkness)
    vsActive        = false,
    vsWasActive     = false,
    -- Power Word: Shield ready
    pwsActive       = false,
    pwsWasActive    = false,
    pwsOnCD         = false,   -- true while PW:S is on cooldown
    pwsCDTimerSet   = false,   -- true while a C_Timer callback is pending
    -- Debounce
    playerAuraPending   = false,
    atonAuraPending     = false,
    atonScanPending     = false,
    rosterUpdatePending = false,
}

-- =============================================================
-- Glow Frame Tables (owned entirely by this module)
-- =============================================================
local atonGlowFrames      = {}   -- buffFrame → safe aura copy (kept for glow pipeline compat)
local voidShieldGlowFrames = {}  -- buffFrame → true

-- =============================================================
-- Alert Definitions
-- =============================================================
local VS_Y    = -30
local ATON_Y  = -54
local PWS_ALERT_Y = -78

local ALERTS = {
    {
        key          = "vs",
        label        = "Cast Void Shield",
        text         = "* CAST VOID SHIELD *",
        defaultColor = { r = 0.600, g = 0.200, b = 0.800 },  -- Purple/void
        hasTimer     = false,
        yOffset      = VS_Y,
        defaultSound = "Ping",
    },
    {
        key          = "aton",
        label        = "Spread Atonement",
        -- Text is dynamic: updated in Evaluate to show current count
        text         = "* SPREAD ATONEMENT *",
        defaultColor = { r = 1.000, g = 0.957, b = 0.671 },  -- Very light yellow (matches Enveloping Mist)
        hasTimer     = false,
        yOffset      = ATON_Y,
        defaultSound = "Bell",
        -- NOTE: Fires when Atonement count < threshold (2 party / 4 raid),
        -- matching the Monk Renewing Mist spread-alert pattern.
    },
    {
        key          = "pws",
        label        = "Power Word: Shield Ready",
        text         = "* POWER WORD: SHIELD *",
        defaultColor = { r = 1.000, g = 1.000, b = 1.000 },  -- White
        hasTimer     = false,
        yOffset      = PWS_ALERT_Y,
        defaultSound = "Ping",
    },
}

-- =============================================================
-- Saved-Variable Defaults (merged into core db)
-- =============================================================
local DEFAULTS = {
    vsEnabled            = true,
    vsSoundEnabled       = true,
    vsSoundName          = "Ping",
    atonEnabled          = true,
    atonSoundEnabled     = true,
    atonSoundName        = "Bell",
    pwsEnabled           = true,
    pwsSoundEnabled      = true,
    pwsSoundName         = "Ping",
    atonThresholdDungeon = 2,   -- alert fires when count < 2 in party/dungeon
    atonThresholdRaid    = 4,   -- alert fires when count < 4 in raid
    alertColors          = {
        vs   = { r = 0.600, g = 0.200, b = 0.800 },
        aton = { r = 1.000, g = 0.957, b = 0.671 },  -- Very light yellow
        pws  = { r = 1.000, g = 1.000, b = 1.000 },  -- White
    },
}

-- =============================================================
-- Atonement Count Helper
-- =============================================================
-- Counts active player-sourced Atonements across all group members.
-- Mirrors CountActiveRenewingMist in MonkMistweaver.lua exactly.
local function CountActiveAtonements()
    local count = 0
    local seen  = {}
    for _, unit in ipairs(NHA:GetGroupUnits()) do
        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
        end
        if not alreadySeen then
            if not UnitExists(unit) or UnitIsEnemy("player", unit) then
                -- skip
            else
                local found = false
                -- Path 1: direct spell ID lookup
                if C_UnitAuras and C_UnitAuras.GetAuraDataBySpellID then
                    local ok2, aura = pcall(C_UnitAuras.GetAuraDataBySpellID,
                        unit, ATONEMENT_ID, "HELPFUL")
                    if ok2 and aura and aura.auraInstanceID ~= 0
                       and aura.sourceUnit == "player" then
                        found = true
                    end
                end
                -- Path 2: index scan fallback
                if not found and C_UnitAuras and C_UnitAuras.GetAuraDataByIndex then
                    local i = 1
                    while true do
                        local ok3, aura = pcall(C_UnitAuras.GetAuraDataByIndex,
                            unit, i, "HELPFUL")
                        if not ok3 or not aura then break end
                        if AuraSpellIs(aura, ATONEMENT_ID)
                           and aura.sourceUnit == "player" then
                            found = true
                            break
                        end
                        i = i + 1
                    end
                end
                if found then
                    count = count + 1
                    if guid then pcall(function() seen[guid] = true end) end
                end
            end
        end
    end
    return count
end

local function GetAtonThreshold()
    if IsInRaid and IsInRaid() then
        return NHA.db.atonThresholdRaid or ATON_THRESHOLD_RAID
    end
    return NHA.db.atonThresholdDungeon or ATON_THRESHOLD_DUNGEON
end

-- (Atonement per-unit tracking helpers removed — count-based model needs no per-unit tracking)

-- =============================================================
-- Void Shield Proc Detection
-- =============================================================
-- PW:S normal icon texture IDs (when MTD is NOT active)
local PWS_NORMAL_ICONS = {
    [135940] = true,   -- Standard PW:S icon
}
-- Void Shield icon texture ID (when MTD IS active, PW:S shows this)
local VOID_SHIELD_SPELL_ICON = 7514191  -- Purple void shield icon on spell

local function CheckVoidShieldProc()
    -- ── Strategy: Check if PW:S spell texture has changed ────
    -- When Master the Darkness procs, PW:S's icon changes from
    -- the normal shield to the void shield icon. This is taint-safe
    -- because it queries the spellbook, not aura data.
    --
    -- Try C_Spell.GetSpellTexture first (Midnight API), fall back
    -- to GetSpellTexture (legacy).
    local texture = nil
    if C_Spell and C_Spell.GetSpellTexture then
        local ok, tex = pcall(C_Spell.GetSpellTexture, PWS_ID)
        if ok then texture = tex end
    end
    if not texture and GetSpellTexture then
        local ok, tex = pcall(GetSpellTexture, PWS_ID)
        if ok then texture = tex end
    end

    if texture then
        -- If the icon is NOT the normal PW:S icon, MTD is active
        if not PWS_NORMAL_ICONS[texture] then
            return true
        end
        return false
    end

    -- ── Fallback: aura scan (out-of-combat only) ─────────────
    -- In combat, aura fields are tainted and unusable.
    -- This only works reliably outside combat.
    if not C_UnitAuras then return false end

    -- Direct API lookup
    if C_UnitAuras.GetAuraDataBySpellID then
        local ok, aura = pcall(C_UnitAuras.GetAuraDataBySpellID,
            "player", MTD_PROC_ID, "HELPFUL")
        if ok and aura and aura.auraInstanceID and aura.auraInstanceID ~= 0 then
            return true
        end
    end

    -- Index scan with pcall (taint-safe but may not work in combat)
    if C_UnitAuras.GetAuraDataByIndex then
        local i = 1
        while true do
            local ok, aura = pcall(C_UnitAuras.GetAuraDataByIndex,
                "player", i, "HELPFUL")
            if not ok or not aura then break end
            -- Try icon match (may be <no value> if tainted)
            local iconOk, iconMatch = pcall(function()
                return aura.icon and aura.icon == MTD_ICON_ID
            end)
            if iconOk and iconMatch then return true end
            -- Try spell ID match (will fail if tainted)
            local idOk, idMatch = pcall(function()
                return aura.spellId and aura.spellId == MTD_PROC_ID
            end)
            if idOk and idMatch then return true end
            i = i + 1
        end
    end

    return false
end

-- =============================================================
-- Power Word: Shield Cooldown Check
-- =============================================================
-- Returns true if PW:S (spell 17) is off cooldown and the player
-- is in combat. Uses C_Spell.GetSpellCooldown (Midnight API) with
-- a legacy GetSpellCooldown fallback.
--
-- PW:S is whitelisted (non-secret), so cooldown values are safe
-- to read directly in both M+ and rated content.
-- =============================================================
local function CheckPWSReady()
    -- Try Midnight API first
    if C_Spell and C_Spell.GetSpellCooldown then
        local ok, info = pcall(C_Spell.GetSpellCooldown, PWS_ID)
        if ok and info then
            -- startTime == 0 means off cooldown
            local startTime = info.startTime or 0
            local duration  = info.duration  or 0
            if startTime == 0 or duration == 0 then
                return true
            end
            return false
        end
    end
    -- Legacy fallback
    if GetSpellCooldown then
        local ok, start, dur = pcall(GetSpellCooldown, PWS_ID)
        if ok then
            if (start or 0) == 0 or (dur or 0) == 0 then
                return true
            end
            return false
        end
    end
    return false
end

-- =============================================================
-- MODULE TABLE
-- =============================================================
local PriestDisc = {
    class     = "PRIEST",
    specIndex = 1,  -- Discipline is spec index 1
    specID    = 256, -- fallback: Discipline Priest spec ID for secret-value contexts
    specName  = "Discipline",
    alerts    = ALERTS,
    defaults  = DEFAULTS,
    state     = state,

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

-- =============================================================
-- ClearAllGlows
-- =============================================================
function PriestDisc:ClearAllGlows()
    for buffFrame in pairs(atonGlowFrames) do
        NHA:HideProcGlow(buffFrame)
    end
    wipe(atonGlowFrames)
    for buffFrame in pairs(voidShieldGlowFrames) do
        NHA:HideProcGlow(buffFrame)
    end
    wipe(voidShieldGlowFrames)
end

-- =============================================================
-- ClearAllState
-- =============================================================
function PriestDisc:ClearAllState()
    state.atonActive        = false
    state.atonWasActive     = false
    state.atonCount         = 0
    state.vsActive          = false
    state.vsWasActive       = false
    state.pwsActive         = false
    state.pwsWasActive      = false
    state.pwsOnCD           = false
    state.pwsCDTimerSet     = false
    state.playerAuraPending = false
    state.atonAuraPending   = false
    state.atonScanPending   = false
    state.rosterUpdatePending = false
    self:ClearAllGlows()
end

-- =============================================================
-- NeedsTicker — keep ticker alive for Void Shield glow cleanup
-- =============================================================
function PriestDisc:NeedsTicker(db)
    return next(voidShieldGlowFrames) ~= nil
end

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

    -- ── Atonement glow table cleanup (stale frame check) ─────
    -- atonGlowFrames is kept for the Void Shield glow pipeline compat.
    -- Entries here are Atonement auras on buff frames — we still show
    -- a glow on them while they exist (any Atonement = glowing).
    for buffFrame in pairs(atonGlowFrames) do
        -- If the frame no longer has our aura instance, clear it
        if buffFrame.auraInstanceID ~= (atonGlowFrames[buffFrame] and atonGlowFrames[buffFrame].auraInstanceID) then
            NHA:HideProcGlow(buffFrame)
            atonGlowFrames[buffFrame] = nil
        end
    end

    -- ── Void Shield glow cleanup ─────────────────────────────
    local GlobalAuras = _G.C_UnitAuras
    if not GlobalAuras or not GlobalAuras.GetAuraDataByAuraInstanceID then return end
    for buffFrame in pairs(voidShieldGlowFrames) do
        local valid = false
        if buffFrame.auraInstanceID then
            local parentUnit = nil
            local parent = buffFrame:GetParent()
            if parent and parent.unit then
                parentUnit = parent.unit
            end
            if parentUnit then
                local ok, aura = pcall(GlobalAuras.GetAuraDataByAuraInstanceID,
                    parentUnit, buffFrame.auraInstanceID)
                if ok and aura and AuraSpellIs(aura, VOID_SHIELD_ID) then
                    valid = true
                end
            end
        end
        if not valid then
            if buffFrame._nhaGlowing then NHA:HideProcGlow(buffFrame) end
            voidShieldGlowFrames[buffFrame] = nil
        end
    end
end

-- =============================================================
-- EvaluateFrameGlow (called from hook pipeline)
-- =============================================================
function PriestDisc:EvaluateFrameGlow(db, buffFrame, aura)
    if not db or not db.glowEnabled or not db.enabled then
        if buffFrame._nhaGlowing then NHA:HideProcGlow(buffFrame) end
        atonGlowFrames[buffFrame]      = nil
        voidShieldGlowFrames[buffFrame] = nil
        return
    end

    local tracked = atonGlowFrames[buffFrame] or voidShieldGlowFrames[buffFrame]

    if not aura then
        if tracked then
            if buffFrame._nhaGlowing then NHA:HideProcGlow(buffFrame) end
            atonGlowFrames[buffFrame]      = nil
            voidShieldGlowFrames[buffFrame] = nil
        end
        return
    end

    -- Use taint-safe spell ID extraction
    local spellId = SafeGetSpellId(aura)

    if not spellId then
        -- Spell ID is tainted/secret — can't identify this aura
        if tracked then
            if buffFrame._nhaGlowing then NHA:HideProcGlow(buffFrame) end
            atonGlowFrames[buffFrame]      = nil
            voidShieldGlowFrames[buffFrame] = nil
        end
        return
    end

    -- Source check: must be player's aura (except Void Shield).
    -- sourceUnit is secret in M+ — pcall the comparison.
    do
        local ok, isPlayer = pcall(function() return aura.sourceUnit == "player" end)
        if ok and not isPlayer then
            if spellId == VOID_SHIELD_ID then
                -- Void Shield: accept any source
            else
                if tracked then
                    if buffFrame._nhaGlowing then NHA:HideProcGlow(buffFrame) end
                    atonGlowFrames[buffFrame]      = nil
                    voidShieldGlowFrames[buffFrame] = nil
                end
                return
            end
        end
        -- pcall failed = secret in M+ — accept as ours
    end

    -- ── Atonement presence glow ──────────────────────────────
    -- Now that we use a count model rather than an expiry timer,
    -- the glow simply lights up for the full duration of any
    -- player-cast Atonement on a buff frame. No pandemic window.
    if spellId == ATONEMENT_ID then
        if voidShieldGlowFrames[buffFrame] then
            if buffFrame._nhaGlowing then NHA:HideProcGlow(buffFrame) end
            voidShieldGlowFrames[buffFrame] = nil
        end
        if not buffFrame._safeNHAAura then buffFrame._safeNHAAura = {} end
        local sa = buffFrame._safeNHAAura
        sa.auraInstanceID = aura.auraInstanceID
        atonGlowFrames[buffFrame] = sa
        local c = db.alertColors and db.alertColors.aton or { r=1, g=1, b=1 }
        NHA:ShowProcGlow(buffFrame, c.r, c.g, c.b)
        return
    end

    -- ── Void Shield absorb glow ──────────────────────────────
    if spellId == VOID_SHIELD_ID then
        -- Clear Atonement tracking on this frame if present
        if atonGlowFrames[buffFrame] then
            if buffFrame._nhaGlowing then NHA:HideProcGlow(buffFrame) end
            atonGlowFrames[buffFrame] = nil
        end
        voidShieldGlowFrames[buffFrame] = true
        local c = db.alertColors and db.alertColors.vs
                  or { r=0.6, g=0.2, b=0.8 }
        NHA:ShowProcGlow(buffFrame, c.r, c.g, c.b)
        return
    end

    -- ── Not a spell we care about ────────────────────────────
    if tracked then
        if buffFrame._nhaGlowing then NHA:HideProcGlow(buffFrame) end
        atonGlowFrames[buffFrame]      = nil
        voidShieldGlowFrames[buffFrame] = nil
    end
end

-- =============================================================
-- Evaluate (called by core ticker and events)
-- =============================================================
function PriestDisc:Evaluate(db)
    -- ── Atonement count check (mirrors Monk RM spread alert) ──
    -- Fires when active Atonement count falls below the threshold
    -- for the current group size: 2 in party/dungeon, 4 in raid.
    local atonLow = false
    if db.atonEnabled and UnitAffectingCombat("player") then
        local count     = CountActiveAtonements()
        local threshold = GetAtonThreshold()
        atonLow         = count < threshold
        state.atonCount = count
    else
        state.atonCount = 0
    end
    state.atonActive = atonLow

    -- ── Void Shield proc check ───────────────────────────────
    local vsProc = false
    if db.vsEnabled then
        vsProc = CheckVoidShieldProc()
    end
    state.vsActive = vsProc

    -- ── Power Word: Shield ready check ───────────────────────
    -- Alert fires in combat whenever PW:S is off cooldown.
    -- Re-evaluates on every SPELL_UPDATE_COOLDOWN event and on
    -- the C_Timer callback fired after the cast-based fallback.
    local pwsReady = false
    if db.pwsEnabled and UnitAffectingCombat("player") then
        pwsReady = CheckPWSReady()
    end
    state.pwsActive = pwsReady

    -- ── Rising-edge sounds ───────────────────────────────────
    if atonLow  and not state.atonWasActive  then NHA:PlayAlertSound("aton") end
    if vsProc   and not state.vsWasActive    then NHA:PlayAlertSound("vs")   end
    if pwsReady and not state.pwsWasActive   then NHA:PlayAlertSound("pws")  end
    state.atonWasActive = atonLow
    state.vsWasActive   = vsProc
    state.pwsWasActive  = pwsReady
end

-- =============================================================
-- OnActivate
-- =============================================================
function PriestDisc:OnActivate(db)
    NHA.ScheduleEvaluate()
end

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

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

        -- Player auras (Master the Darkness proc)
        if unit == "player" then
            if not updateInfo or updateInfo.isFullUpdate
               or updateInfo.addedAuras or updateInfo.removedAuraInstanceIDs then
                if not state.playerAuraPending then
                    state.playerAuraPending = true
                    C_Timer.After(0.1, function()
                        state.playerAuraPending = false
                        NHA.Evaluate()
                    end)
                end
            end
            return true
        end

        -- Any group member aura change: debounced re-count
        -- We don't fast-path by unit anymore since the whole count
        -- needs to be recalculated on any add/remove.
        if not updateInfo or updateInfo.isFullUpdate
           or updateInfo.addedAuras or updateInfo.removedAuraInstanceIDs then
            if not state.atonScanPending then
                state.atonScanPending = true
                C_Timer.After(0.1, function()
                    state.atonScanPending = false
                    NHA.Evaluate()
                end)
            end
        end
        return true
    end

    if event == "UNIT_SPELLCAST_SUCCEEDED" then
        local unit, _, spellID = ...
        if unit == "player" then
            local ok, match = pcall(function() return spellID == PWS_ID end)
            if ok and match then
                -- PW:S cast: goes on cooldown, hide alert until CD expires.
                -- Set a cast-tracking fallback in case SPELL_UPDATE_COOLDOWN
                -- doesn't fire reliably in the current zone (M+/rated).
                state.pwsActive    = false
                state.pwsWasActive = false
                state.pwsOnCD      = true
                -- Also consumes the Master the Darkness proc
                NHA.ScheduleEvaluate()
            end
        end
        return true
    end

    if event == "SPELL_UPDATE_COOLDOWN" then
        if db.pwsEnabled then NHA.ScheduleEvaluate() 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_ENTERING_WORLD" then
        state.atonCount = 0
        return false  -- let core handle
    end

    if event == "PLAYER_REGEN_ENABLED" then
        -- Combat ended; clear stale count and pws alert
        state.atonCount    = 0
        state.pwsActive    = false
        state.pwsWasActive = false
        state.pwsOnCD      = false
        NHA.ScheduleEvaluate()
        return false  -- let core handle
    end

    return false
end

-- =============================================================
-- BuildOptions (spec-specific options in the Display section)
-- =============================================================
function PriestDisc:BuildOptions(parent, db, helpers)
    -- Atonement threshold sliders (party vs raid), mirroring Monk RM thresholds
    helpers.MakeSlider("Party threshold", 1, 5, 1, 160, -90,
        function() return db.atonThresholdDungeon or ATON_THRESHOLD_DUNGEON end,
        function(v)
            db.atonThresholdDungeon = v
            NHA.ScheduleEvaluate()
        end)
    helpers.MakeSlider("Raid threshold", 1, 10, 1, 160, -120,
        function() return db.atonThresholdRaid or ATON_THRESHOLD_RAID end,
        function(v)
            db.atonThresholdRaid = v
            NHA.ScheduleEvaluate()
        end)
end

-- =============================================================
-- Slash Commands (spec-specific)
-- =============================================================
PriestDisc.slashCommands = {
    ["debug"] = function(self, db)
        NHA.Msg("|cffffd700Disc Priest Debug:|r")
        NHA.Msg("  enabled: " .. tostring(db.enabled))
        NHA.Msg("  atonEnabled: " .. tostring(db.atonEnabled))
        NHA.Msg("  vsEnabled: " .. tostring(db.vsEnabled))
        NHA.Msg("  glowEnabled: " .. tostring(db.glowEnabled))
        NHA.Msg("  atonActive: " .. tostring(state.atonActive))
        NHA.Msg("  atonCount: " .. tostring(state.atonCount))
        NHA.Msg("  atonThresholdDungeon: " .. tostring(db.atonThresholdDungeon or ATON_THRESHOLD_DUNGEON))
        NHA.Msg("  atonThresholdRaid: " .. tostring(db.atonThresholdRaid or ATON_THRESHOLD_RAID))
        local inRaid = IsInRaid and IsInRaid()
        NHA.Msg("  activeThreshold: " .. tostring(GetAtonThreshold()) .. (inRaid and " (raid)" or " (party)"))
        NHA.Msg("  vsActive: " .. tostring(state.vsActive))
        NHA.Msg("  pwsEnabled: " .. tostring(db.pwsEnabled))
        NHA.Msg("  pwsActive: " .. tostring(state.pwsActive))
        NHA.Msg("  pwsOnCD: " .. tostring(state.pwsOnCD))

        -- Live PW:S cooldown diagnostic
        if C_Spell and C_Spell.GetSpellCooldown then
            local ok, info = pcall(C_Spell.GetSpellCooldown, PWS_ID)
            if ok and info then
                NHA.Msg("  PW:S CD: start=" .. tostring(info.startTime)
                    .. " dur=" .. tostring(info.duration)
                    .. " ready=" .. tostring((info.startTime or 0) == 0 or (info.duration or 0) == 0))
            else
                NHA.Msg("  PW:S CD: error or nil")
            end
        end

        -- Live count diagnostic
        local liveCount = CountActiveAtonements()
        NHA.Msg("  liveAtonCount: " .. liveCount)

        -- Glow frame counts
        local atonGlowCount = 0
        for _ in pairs(atonGlowFrames) do atonGlowCount = atonGlowCount + 1 end
        local vsGlowCount = 0
        for _ in pairs(voidShieldGlowFrames) do vsGlowCount = vsGlowCount + 1 end
        NHA.Msg("  atonGlowFrames: " .. atonGlowCount)
        NHA.Msg("  voidShieldGlowFrames: " .. vsGlowCount)

        -- Test MTD proc detection via direct API
        local mtdResult = "nil"
        if C_UnitAuras and C_UnitAuras.GetAuraDataBySpellID then
            local ok, aura = pcall(C_UnitAuras.GetAuraDataBySpellID,
                "player", MTD_PROC_ID, "HELPFUL")
            if ok and aura then
                mtdResult = "found (instID=" .. tostring(aura.auraInstanceID)
                    .. " spellId=" .. tostring(aura.spellId) .. ")"
            elseif ok then
                mtdResult = "not found"
            else
                mtdResult = "error: " .. tostring(aura)
            end
        else
            mtdResult = "API missing"
        end
        NHA.Msg("  MTD direct API: " .. mtdResult)

        -- Test PW:S spell texture (primary detection method)
        local texResult = "unavailable"
        if C_Spell and C_Spell.GetSpellTexture then
            local ok, tex = pcall(C_Spell.GetSpellTexture, PWS_ID)
            if ok then
                local isNormal = PWS_NORMAL_ICONS[tex] and "YES" or "NO"
                texResult = tostring(tex) .. " normal=" .. isNormal
            else
                texResult = "error: " .. tostring(tex)
            end
        elseif GetSpellTexture then
            local ok, tex = pcall(GetSpellTexture, PWS_ID)
            if ok then
                local isNormal = PWS_NORMAL_ICONS[tex] and "YES" or "NO"
                texResult = tostring(tex) .. " normal=" .. isNormal
                    .. " (legacy API)"
            else
                texResult = "error: " .. tostring(tex)
            end
        end
        NHA.Msg("  PW:S texture: " .. texResult)

        -- Test CheckVoidShieldProc directly
        local vsOk, vsResult = pcall(CheckVoidShieldProc)
        NHA.Msg("  CheckVoidShieldProc: ok=" .. tostring(vsOk)
            .. " result=" .. tostring(vsResult))

        -- Dump player aura index scan for MTD diagnosis
        NHA.Msg("|cffffd700  Player Aura Scan:|r")
        if C_UnitAuras and C_UnitAuras.GetAuraDataByIndex then
            local idx = 1
            local found = 0
            while true do
                local ok2, aura2 = pcall(C_UnitAuras.GetAuraDataByIndex,
                    "player", idx, "HELPFUL")
                if not ok2 or not aura2 then break end
                local iconId = nil
                -- All aura fields may be tainted; wrap everything in pcall
                local sidStr = "(tainted)"
                local isSecret = false
                local isMtdSpell = false
                local isIconMatch = false
                local auraName = "(tainted)"
                pcall(function() iconId = aura2.icon end)
                pcall(function() auraName = tostring(aura2.name) end)
                pcall(function()
                    sidStr = tostring(aura2.spellId)
                    isSecret = issecretvalue(aura2.spellId)
                end)
                pcall(function()
                    isMtdSpell = (aura2.spellId == MTD_PROC_ID)
                end)
                pcall(function()
                    isIconMatch = (iconId and iconId == MTD_ICON_ID)
                end)
                -- Build tag string
                local tag = ""
                if isSecret then tag = " |cffff4444[SECRET]|r" end
                if isMtdSpell then tag = tag .. " |cff00ff00[SPELL MATCH]|r" end
                if isIconMatch then tag = tag .. " |cff00ffff[ICON MATCH]|r" end
                NHA.Msg("    #" .. idx .. ": spell="
                    .. sidStr .. " icon=" .. tostring(iconId)
                    .. " name=" .. auraName .. tag)
                found = found + 1
                if found >= 30 then
                    NHA.Msg("    ... (capped at 30)")
                    break
                end
                idx = idx + 1
            end
            if found == 0 then
                NHA.Msg("    (no buffs found)")
            end
        else
            NHA.Msg("    GetAuraDataByIndex not available")
        end
    end,
}

-- =============================================================
-- Register with core
-- =============================================================
NHA:RegisterSpec("PRIEST_DISC", PriestDisc)
