-- =============================================================
-- Specs/PriestDiscipline.lua
-- Nem: Healer Alerts — Discipline Priest spec module.
-- =============================================================

local NHA = _G.NemHealerAlerts
if not NHA then return end

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 ATONEMENT_ID   = 194384   -- SpellID, whitelisted
local VOID_SHIELD_ID = 1253593  -- SpellID, whitelisted
local PWS_ID         = 17       -- SpellID, whitelisted

local PWS_NORMAL_ICONS = { [135940] = true }  -- IconID

local PWS_WASTE_NO_TIME_REDUCE = 1.5
local WASTE_NO_TIME_NODE_ID    = 94679    -- TalentID
local WASTE_NO_TIME_ENTRY_ID   = 117282   -- EntryID
local WASTE_NO_TIME_SPELL_ID   = 440681   -- SpellID

local state

-- =============================================================
-- Taint-Safe Static Helpers
-- =============================================================
local function _toNumber(val)           return val + 0 end
local function _checkPlayerSource(aura) return aura.sourceUnit == "player" end
local function _checkInstanceID(aura)
    return aura.auraInstanceID ~= nil and aura.auraInstanceID ~= 0
end
local function _checkSpellIs(aura, id)
    return aura.spellId and aura.spellId == id
end
local function _spellIdEquals(a, b) return a == b end
local function _guidsEqual(a, b)    return a == b end
local function _greaterThan0(v)     return v > 0  end
local function _infoStartTime(info) return info.startTime end
local function _infoDuration(info)  return info.duration  end
local function _durGetTotal(d)      return d:GetTotalDuration() end
local function _durGetEnd(d)        return d:GetEndTime()       end
local function _durGetStart(d)      return d:GetStartTime()     end

-- =============================================================
-- DurationObject Unwrap
-- =============================================================
local function ReadPWSDurationObj(raw)
    if raw == nil then return 0 end
    if type(raw) == "table" then
        if raw.GetTotalDuration then
            local ok, v = pcall(_durGetTotal, raw)
            if ok and type(v) == "number" then return v end
        end
        if raw.GetEndTime and raw.GetStartTime then
            local okE, e = pcall(_durGetEnd,   raw)
            local okS, s = pcall(_durGetStart, raw)
            if okE and okS and type(e) == "number" and type(s) == "number" then
                return e - s
            end
        end
        return 0
    end
    return raw
end

local function SafeNum(val, fallback)
    if val == nil then return fallback end
    local ok, result = pcall(_toNumber, val)
    if ok then return result end
    return fallback
end

local function IsPlayerOrUnknownSource(aura)
    if not aura then return false end
    local ok, result = pcall(_checkPlayerSource, aura)
    if not ok then return true end
    return result
end

local function HasValidInstanceID(aura)
    if not aura then return false end
    local ok, result = pcall(_checkInstanceID, aura)
    if not ok then return true end
    return result
end

local function AuraSpellIs(aura, id)
    local ok, result = pcall(_checkSpellIs, aura, id)
    return ok and result
end

-- =============================================================
-- Two-Path Aura Scanner
-- =============================================================
local function FindAuraOnUnit(unit, spellId, checkSource)
    if not UnitExists(unit) then return nil end
    if not C_UnitAuras then return nil end

    if C_UnitAuras.GetAuraDataBySpellID then
        local ok, aura = pcall(C_UnitAuras.GetAuraDataBySpellID,
            unit, spellId, "HELPFUL")
        if ok then
            if not aura then return nil end
            if HasValidInstanceID(aura) then
                if not checkSource or IsPlayerOrUnknownSource(aura) then
                    return aura
                end
            end
            return nil
        end
    end

    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

-- =============================================================
-- Atonement Presence Cache
-- =============================================================
local atonPresence = {}

local function RebuildAtonPresenceCache()
    wipe(atonPresence)
    local count = 0
    local seen  = {}
    for _, unit in ipairs(NHA:GetGroupUnits()) do
        local guid     = UnitGUID(unit)
        local canDedup = guid and not issecretvalue(guid)
        if not canDedup or not seen[guid] then
            if FindAuraOnUnit(unit, ATONEMENT_ID, true) then
                atonPresence[unit] = true
                count = count + 1
            end
            if canDedup then seen[guid] = true end
        end
    end
    state.atonCachedCount = count
end

local function UpdateAtonPresenceForUnit(unit, updateInfo)
    local had = atonPresence[unit]

    if not updateInfo or updateInfo.isFullUpdate then
        local has = FindAuraOnUnit(unit, ATONEMENT_ID, true) ~= nil
        if has ~= (had ~= nil) then
            atonPresence[unit] = has or nil
            state.atonCachedCount = (state.atonCachedCount or 0) + (has and 1 or -1)
        end
        return
    end

    local touchedAton = false

    if updateInfo.addedAuras then
        for _, aura in ipairs(updateInfo.addedAuras) do
            if not issecretvalue(aura.spellId)
               and aura.spellId == ATONEMENT_ID
               and IsPlayerOrUnknownSource(aura) then
                touchedAton = true
                break
            end
        end
    end

    if not touchedAton and had
       and updateInfo.removedAuraInstanceIDs
       and #updateInfo.removedAuraInstanceIDs > 0 then
        touchedAton = true
    end

    if not touchedAton then return end

    local has = FindAuraOnUnit(unit, ATONEMENT_ID, true) ~= nil
    if has ~= (had ~= nil) then
        atonPresence[unit] = has or nil
        state.atonCachedCount = (state.atonCachedCount or 0) + (has and 1 or -1)
    end
end

local function GetAtonThreshold()
    local db = NHA.db
    if IsInRaid and IsInRaid() then
        return db.atonThresholdRaid or 4
    end
    return db.atonThresholdDungeon or 2
end

-- =============================================================
-- Void Shield Proc Detection
-- =============================================================
local function CheckVoidShieldProc()
    if C_Spell and C_Spell.GetSpellTexture then
        local ok, tex = pcall(C_Spell.GetSpellTexture, PWS_ID)
        if ok and tex then
            return not PWS_NORMAL_ICONS[tex]
        end
    end
    if GetSpellTexture then
        local ok, tex = pcall(GetSpellTexture, PWS_ID)
        if ok and tex then
            return not PWS_NORMAL_ICONS[tex]
        end
    end
    return false
end

-- =============================================================
-- Power Word: Shield Cooldown
-- =============================================================
local PWS_BASE_CD_SEC = 7.5

local function _hasteCDFromPct(pct)
    return PWS_BASE_CD_SEC / (1 + pct / 100)
end

local function ReadPlayerHastePct()
    if UnitSpellHaste then
        local ok, val = pcall(UnitSpellHaste, "player")
        if ok and val ~= nil and type(val) == "number" and not issecretvalue(val)
           and val >= 0 then
            if state then state.lastKnownHastePct = val end
            return val
        end
    end
    if GetHaste then
        local ok, val = pcall(GetHaste)
        if ok and val ~= nil and type(val) == "number" and not issecretvalue(val)
           and val >= 0 then
            if state then state.lastKnownHastePct = val end
            return val
        end
    end
    if state and state.lastKnownHastePct and state.lastKnownHastePct > 0 then
        return state.lastKnownHastePct
    end
    return 0
end

local function _entryIDEquals(entry, expected)
    return entry.entryID == expected
end

local function CheckWasteNoTimeTalent()
    if IsPlayerSpell then
        local ok, has = pcall(IsPlayerSpell, WASTE_NO_TIME_SPELL_ID)
        if ok and has == true then return true end
    end
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then return false end
    local ni = C_Traits and C_Traits.GetNodeInfo
               and C_Traits.GetNodeInfo(configID, WASTE_NO_TIME_NODE_ID)
    if not ni then return false end
    if not (ni.ranksPurchased and ni.ranksPurchased > 0) then return false end
    if ni.activeEntry then
        local cmpOk, matches = pcall(_entryIDEquals, ni.activeEntry, WASTE_NO_TIME_ENTRY_ID)
        if cmpOk and matches == true then return true end
        if ni.activeEntry.definitionID then
            local def = C_Traits and C_Traits.GetDefinitionInfo
                        and C_Traits.GetDefinitionInfo(ni.activeEntry.definitionID)
            if def and def.spellID == WASTE_NO_TIME_SPELL_ID then return true end
        end
    end
    return false
end


local function ComputePWSCooldown()
    local effectiveBase = PWS_BASE_CD_SEC
    if state and state.hasWasteNoTimeTalent then
        effectiveBase = effectiveBase - PWS_WASTE_NO_TIME_REDUCE
    end
    local pct = ReadPlayerHastePct()
    local cd = effectiveBase / (1 + pct / 100)
    if type(cd) == "number" and cd > 0 then return cd end
    return effectiveBase
end

local function GetPWSCooldownDuration()
    if C_Spell and C_Spell.GetSpellCooldown then
        local ok, info = pcall(C_Spell.GetSpellCooldown, PWS_ID)
        if ok and info then
            local durOk, duration = pcall(_infoDuration, info)
            if durOk and duration ~= nil and not issecretvalue(duration) then
                local d = ReadPWSDurationObj(duration)
                if type(d) == "number" and not issecretvalue(d) then
                    local cmpOk, isPos = pcall(_greaterThan0, d)
                    if cmpOk and isPos then return d end
                end
            end
        end
    end
    local ok, cd = pcall(ComputePWSCooldown)
    if ok and type(cd) == "number" and cd > 0 then return cd end
    if state and state.pwsKnownCD and state.pwsKnownCD > 0 then
        return state.pwsKnownCD
    end
    return PWS_BASE_CD_SEC
end

local function _PWSCooldownExpiredCB()
    state.pwsOnCooldown = false
    state.pwsCDTimerSet = false
    if not UnitAffectingCombat("player") then
        state.pwsWasActive = true
    end
    NHA.ScheduleEvaluate()
end

local function TryInitPWSCooldownState()
    if not (C_Spell and C_Spell.GetSpellCooldown) then return end
    local ok, info = pcall(C_Spell.GetSpellCooldown, PWS_ID)
    if not ok or not info then return end

    local startOk, startTime = pcall(_infoStartTime, info)
    if not startOk or type(startTime) ~= "number" then return end

    local durOk, duration = pcall(_infoDuration, info)
    if not durOk or duration == nil or issecretvalue(duration) then return end

    duration = ReadPWSDurationObj(duration)
    if type(duration) ~= "number" or duration <= 0 then return end

    if startTime == 0 then return end

    if duration > 1.5 then state.pwsKnownCD = duration end

    local remaining = startTime + duration - GetTime()
    if remaining > 0 then
        state.pwsOnCooldown = true
        if not state.pwsCDTimerSet then
            state.pwsCDTimerSet = true
            C_Timer.After(remaining, _PWSCooldownExpiredCB)
        end
    end
end

-- =============================================================
-- Module State
-- =============================================================
state = {
    atonActive          = false,
    atonWasActive       = false,
    atonCachedCount     = 0,
    vsActive            = false,
    vsWasActive         = false,
    pwsActive           = false,
    pwsWasActive        = false,
    pwsOnCooldown       = false,
    pwsKnownCD          = 0,
    pwsCDTimerSet       = false,
    hasWasteNoTimeTalent = false,
    lastKnownHastePct   = 0,
    playerAuraPending   = false,
    rosterUpdatePending = false,
}

-- =============================================================
-- CUF Glow State
-- =============================================================
local cufVSCache     = {}
local cufAtonCache   = {}
local cufStateMap    = {}
local dirtyCUFFrames = {}
local drainScheduled = false

-- =============================================================
-- Alert Definitions
-- =============================================================
local ATON_Y      = 46
local VS_Y        = 21
local PWS_ALERT_Y = -4

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 = "Priest: Void Shield",
    },
    {
        key          = "aton",
        label        = "Spread Atonement",
        text         = "* SPREAD ATONEMENT *",
        defaultColor = { r = 1.000, g = 0.957, b = 0.671 },  -- Light yellow
        hasTimer     = false,
        yOffset      = ATON_Y,
        defaultSound = "Priest: Spread Atonement",
    },
    {
        key          = "pws",
        label        = "Power Word: Shield Ready",
        text         = "* POWER WORD: SHIELD *",
        defaultColor = { r = 1.000, g = 1.000, b = 1.000 },  -- White (Priest class color)
        hasTimer     = false,
        yOffset      = PWS_ALERT_Y,
        defaultSound = "Priest: Power Word Shield",
    },
}

-- =============================================================
-- Saved-Variable Defaults
-- =============================================================
local DEFAULTS = {
    vsEnabled            = true,
    vsSoundEnabled       = true,
    vsSoundName          = "Priest: Void Shield",
    atonEnabled          = true,
    atonSoundEnabled     = true,
    atonSoundName        = "Priest: Spread Atonement",
    pwsEnabled           = true,
    pwsSoundEnabled      = true,
    pwsSoundName         = "Priest: Power Word Shield",
    atonThresholdDungeon = 2,
    atonThresholdRaid    = 4,
    alertColors          = {
        vs   = { r = 0.600, g = 0.200, b = 0.800 },
        aton = { r = 1.000, g = 0.957, b = 0.671 },
        pws  = { r = 1.000, g = 1.000, b = 1.000 },
    },
}

-- =============================================================
-- Module Table
-- =============================================================
local PriestDisc = {
    class     = "PRIEST",
    specIndex = 1,
    specID    = 256,
    specName  = "Discipline",
    alerts    = ALERTS,
    defaults  = DEFAULTS,
    state     = state,

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

    cufRelevantSpells = {
        [1253593] = true,   -- SpellID
        [194384]  = true,   -- SpellID
    },
}

-- =============================================================
-- ClearAllGlows
-- =============================================================
function PriestDisc:ClearAllGlows()
    for frame in pairs(cufStateMap) do
        if frame._nhaFrameGlowing then NHA:HideFrameGlow(frame) end
    end
    wipe(cufStateMap)
    wipe(cufVSCache)
    wipe(cufAtonCache)
    wipe(dirtyCUFFrames)
    drainScheduled = false
end

-- =============================================================
-- ClearAllState
-- =============================================================
function PriestDisc:ClearAllState()
    state.atonActive          = false
    state.atonWasActive       = false
    wipe(atonPresence)
    state.atonCachedCount     = 0
    state.vsActive            = false
    state.vsWasActive         = false
    state.pwsActive           = false
    state.pwsWasActive        = false
    state.pwsOnCooldown       = false
    state.pwsKnownCD          = 0
    state.pwsCDTimerSet       = false
    state.playerAuraPending   = false
    state.rosterUpdatePending = false
    self:ClearAllGlows()
end

-- =============================================================
-- NeedsTicker
-- =============================================================
function PriestDisc:NeedsTicker(db)
    return false
end

-- =============================================================
-- Perf Instrumentation
-- =============================================================
local _perfBlockTimer = 0
local _perfCUFLastPath = nil

local _perfFrameStamp    = 0
local _perfFrameTotal    = 0
local _perfFrameEval     = 0
local _perfFrameCUF      = 0
local _perfFrameRefr     = 0
local _perfFrameCUFCount = 0

local function PerfFrameRoll()
    local p = state._perf
    if not (p and p.enabled) then return end
    local now = GetTime()
    if now ~= _perfFrameStamp then
        if _perfFrameTotal > (p.spike and p.spike.totalMs or 0) then
            p.spike = p.spike or {}
            p.spike.totalMs   = _perfFrameTotal
            p.spike.evalMs    = _perfFrameEval
            p.spike.cufMs     = _perfFrameCUF
            p.spike.refreshMs = _perfFrameRefr
            p.spike.cufCount  = _perfFrameCUFCount
            p.spike.atTime    = now
        end
        _perfFrameStamp    = now
        _perfFrameTotal    = 0
        _perfFrameEval     = 0
        _perfFrameCUF      = 0
        _perfFrameRefr     = 0
        _perfFrameCUFCount = 0
    end
end

local function PerfBeginBlocks()
    local p = state._perf
    if not (p and p.blocksEnabled) then return end
    _perfBlockTimer = debugprofilestop()
end

local function PerfMarkBlock(name)
    local p = state._perf
    if not (p and p.blocksEnabled) then return end
    local now = debugprofilestop()
    local dt  = now - _perfBlockTimer
    local b   = p.blocks[name]
    if not b then
        b = { calls = 0, totalMs = 0, maxMs = 0 }
        p.blocks[name] = b
    end
    b.calls   = b.calls + 1
    b.totalMs = b.totalMs + dt
    if dt > b.maxMs then b.maxMs = dt end
    _perfBlockTimer = now
end

local _DrainGlowDeferred

-- =============================================================
-- DoEvaluateCUFGlow
-- =============================================================
local function DoEvaluateCUFGlow(self, db, frame, unit)
    if not db or not db.enabled or not db.cufGlowEnabled then
        if cufStateMap[frame] then
            NHA:HideFrameGlow(frame)
            cufStateMap[frame] = nil
        end
        cufVSCache[frame]   = nil
        cufAtonCache[frame] = nil
        _perfCUFLastPath = "disabled"
        return
    end

    local vsAura = FindAuraOnUnit(unit, VOID_SHIELD_ID, true)
    if vsAura then
        local sa = cufVSCache[frame]
        if not sa then sa = {}; cufVSCache[frame] = sa end
        sa.auraInstanceID = vsAura.auraInstanceID
        sa.expirationTime = SafeNum(vsAura.expirationTime, 0)
        sa.duration       = SafeNum(vsAura.duration, 0)
        sa.timeMod        = SafeNum(vsAura.timeMod, 1)
        if sa.timeMod <= 0 then sa.timeMod = 1 end

        local now       = GetTime()
        local remaining = (sa.expirationTime - now) / sa.timeMod
        if remaining > 0 then
            local c = db.alertColors and db.alertColors.vs
                      or { r=0.6, g=0.2, b=0.8 }
            NHA:ShowFrameGlow(frame, c.r, c.g, c.b)
            cufStateMap[frame]  = "vs"
            cufAtonCache[frame] = nil
            _perfCUFLastPath = "vs"
            do
                local capturedFrame  = frame
                local capturedExpiry = sa.expirationTime
                C_Timer.After(remaining + 0.5, function()
                    local cached = cufVSCache[capturedFrame]
                    if not cached then return end
                    if cached.expirationTime ~= capturedExpiry then return end
                    local u = capturedFrame._nhaCUFUnit
                    if u then
                        dirtyCUFFrames[capturedFrame] = u
                        if not drainScheduled then
                            drainScheduled = true
                            C_Timer.After(0, _DrainGlowDeferred)
                        end
                    else
                        cufVSCache[capturedFrame] = nil
                    end
                end)
            end
            return
        end
        cufVSCache[frame] = nil
    else
        cufVSCache[frame] = nil
    end

    local atonAura = FindAuraOnUnit(unit, ATONEMENT_ID, true)
    if atonAura then
        local sa = cufAtonCache[frame]
        if not sa then sa = {}; cufAtonCache[frame] = sa end
        sa.auraInstanceID = atonAura.auraInstanceID
        sa.expirationTime = SafeNum(atonAura.expirationTime, 0)
        sa.duration       = SafeNum(atonAura.duration, 0)
        sa.timeMod        = SafeNum(atonAura.timeMod, 1)
        if sa.timeMod <= 0 then sa.timeMod = 1 end

        local now       = GetTime()
        local remaining = (sa.expirationTime - now) / sa.timeMod
        if remaining > 0 then
            local c = db.alertColors and db.alertColors.aton
                      or { r=1, g=0.957, b=0.671 }
            NHA:ShowFrameGlow(frame, c.r, c.g, c.b)
            cufStateMap[frame] = "aton"
            _perfCUFLastPath = "aton"
            do
                local capturedFrame  = frame
                local capturedExpiry = sa.expirationTime
                C_Timer.After(remaining + 0.5, function()
                    local cached = cufAtonCache[capturedFrame]
                    if not cached then return end
                    if cached.expirationTime ~= capturedExpiry then return end
                    local u = capturedFrame._nhaCUFUnit
                    if u then
                        dirtyCUFFrames[capturedFrame] = u
                        if not drainScheduled then
                            drainScheduled = true
                            C_Timer.After(0, _DrainGlowDeferred)
                        end
                    else
                        cufAtonCache[capturedFrame] = nil
                    end
                end)
            end
            return
        end
        cufAtonCache[frame] = nil
    else
        cufAtonCache[frame] = nil
    end

    if cufStateMap[frame] then
        NHA:HideFrameGlow(frame)
        cufStateMap[frame] = nil
    end
    _perfCUFLastPath = "none"
end

-- =============================================================
-- InstrumentedDoEvaluateCUFGlow
-- =============================================================
local function InstrumentedDoEvaluateCUFGlow(self, db, frame, unit)
    local perf = state._perf
    if not (perf and perf.enabled) then
        DoEvaluateCUFGlow(self, db, frame, unit)
        return
    end
    PerfFrameRoll()
    _perfCUFLastPath = nil
    local t0 = debugprofilestop()
    DoEvaluateCUFGlow(self, db, frame, unit)
    local dt  = debugprofilestop() - t0
    local cuf = perf.cuf
    cuf.calls   = cuf.calls + 1
    cuf.totalMs = cuf.totalMs + dt
    if dt > cuf.maxMs then cuf.maxMs = dt end
    local path = _perfCUFLastPath or "none"
    cuf.paths[path] = (cuf.paths[path] or 0) + 1
    _perfFrameTotal    = _perfFrameTotal    + dt
    _perfFrameCUF      = _perfFrameCUF      + dt
    _perfFrameCUFCount = _perfFrameCUFCount + 1
end

-- =============================================================
-- _DrainGlowDeferred
-- =============================================================
_DrainGlowDeferred = function()
    drainScheduled = false
    local db = NHA.db
    if not db or not db.cufGlowEnabled then
        wipe(dirtyCUFFrames)
        return
    end
    local processed = 0
    for frame, unit in pairs(dirtyCUFFrames) do
        local liveUnit = frame._nhaCUFUnit or unit
        if liveUnit then
            InstrumentedDoEvaluateCUFGlow(nil, db, frame, liveUnit)
        end
        dirtyCUFFrames[frame] = nil
        processed = processed + 1
        if processed >= 4 then break end
    end
    if next(dirtyCUFFrames) then
        drainScheduled = true
        C_Timer.After(0, _DrainGlowDeferred)
    end
end

-- =============================================================
-- EvaluateCUFGlow
-- =============================================================
function PriestDisc:EvaluateCUFGlow(db, frame, unit)
    if not db or not db.enabled or not db.cufGlowEnabled then
        if cufStateMap[frame] then
            NHA:HideFrameGlow(frame)
            cufStateMap[frame] = nil
        end
        cufVSCache[frame]     = nil
        cufAtonCache[frame]   = nil
        dirtyCUFFrames[frame] = nil
        return
    end
    if not UnitAffectingCombat("player") then return end
    dirtyCUFFrames[frame] = unit
    if not drainScheduled then
        drainScheduled = true
        C_Timer.After(0, _DrainGlowDeferred)
    end
end

-- =============================================================
-- RefreshGlows
-- =============================================================
function PriestDisc:RefreshGlows(db)
end

-- =============================================================
-- Evaluate
-- =============================================================
function PriestDisc:Evaluate(db)
    PerfBeginBlocks()

    local inCombat = UnitAffectingCombat("player")

    if C_Spell and C_Spell.GetSpellCooldown then
        local cdOk, cdInfo = pcall(C_Spell.GetSpellCooldown, PWS_ID)
        if cdOk and cdInfo then
            local sOk, start = pcall(_infoStartTime, cdInfo)
            local dOk, durRaw = pcall(_infoDuration, cdInfo)
            if sOk and dOk and start ~= nil and durRaw ~= nil
               and not issecretvalue(start) and not issecretvalue(durRaw) then
                local dur = ReadPWSDurationObj(durRaw)
                if type(start) == "number" and type(dur) == "number" then
                    if start <= 0 or dur == 0 or dur <= 1.5 then
                        if not state.pwsCDTimerSet then
                            state.pwsOnCooldown = false
                        end
                    elseif start > 0 and dur > 1.5 then
                        state.pwsOnCooldown = true
                        state.pwsKnownCD    = dur
                        local remaining = (start + dur) - GetTime()
                        if remaining > 0 and not state.pwsCDTimerSet then
                            state.pwsCDTimerSet = true
                            C_Timer.After(remaining, _PWSCooldownExpiredCB)
                        end
                    end
                end
            end
        end
    end

    local atonLow = false
    if db.atonEnabled and inCombat then
        atonLow = (state.atonCachedCount or 0) < GetAtonThreshold()
    end
    state.atonActive = atonLow
    PerfMarkBlock("ATON")

    local vsProc = false
    if db.vsEnabled and inCombat and not state.pwsOnCooldown then
        vsProc = CheckVoidShieldProc()
    end
    state.vsActive = vsProc
    PerfMarkBlock("VS")

    if state.vsWasActive and not vsProc and not state.pwsOnCooldown then
        local cd = GetPWSCooldownDuration()
        state.pwsOnCooldown = true
        if not state.pwsCDTimerSet then
            state.pwsCDTimerSet = true
            C_Timer.After(cd, _PWSCooldownExpiredCB)
        end
    end

    local pwsReady = false
    if db.pwsEnabled and inCombat then
        pwsReady = not state.pwsOnCooldown and not vsProc
    end
    state.pwsActive = pwsReady
    PerfMarkBlock("PWS")

    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
    if inCombat then
        state.vsWasActive  = vsProc
        state.pwsWasActive = pwsReady
    end
    PerfMarkBlock("END")
end

-- =============================================================
-- OnActivate
-- =============================================================
function PriestDisc:OnActivate(db)
    TryInitPWSCooldownState()
    if NHA.RefreshAllCUFs then
        C_Timer.After(0.1, function()
            NHA:RefreshAllCUFs()
        end)
    end
end

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

        local isRaid = IsInRaid and IsInRaid()
        if not (unit == "player" and isRaid) then
            if unit == "player" or UnitInParty(unit) or UnitInRaid(unit) then
                UpdateAtonPresenceForUnit(unit, updateInfo)
            end
        end

        if unit == "player" then
            if not state.playerAuraPending then
                state.playerAuraPending = true
                C_Timer.After(0.1, function()
                    state.playerAuraPending = false
                    NHA.Evaluate()
                end)
            end
            return true
        end

        NHA.ScheduleEvaluate()
        return true
    end

    if event == "SPELL_UPDATE_COOLDOWN" then
        if db.pwsEnabled then NHA.ScheduleEvaluate() end
        return true
    end

    if event == "UNIT_SPELLCAST_SUCCEEDED" then
        local unit, _, spellID = ...
        if unit == "player" then
            local okPWS, matchPWS = pcall(_spellIdEquals, spellID, PWS_ID)
            local okVS,  matchVS  = pcall(_spellIdEquals, spellID, VOID_SHIELD_ID)
            if (okPWS and matchPWS) or (okVS and matchVS) then
                local cd = GetPWSCooldownDuration()
                state.pwsOnCooldown = true
                state.pwsActive     = false
                state.pwsWasActive  = false
                if not state.pwsCDTimerSet then
                    state.pwsCDTimerSet = true
                    C_Timer.After(cd, _PWSCooldownExpiredCB)
                end
                NHA.ScheduleEvaluate()
            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
                RebuildAtonPresenceCache()
                NHA.Evaluate()
            end)
        end
        return true
    end

    if event == "PLAYER_REGEN_DISABLED" then
        RebuildAtonPresenceCache()
        if db and db.pwsEnabled and C_Spell and C_Spell.GetSpellCooldown then
            local cdOk, cdInfo = pcall(C_Spell.GetSpellCooldown, PWS_ID)
            if cdOk and cdInfo then
                local sOk, start = pcall(_infoStartTime, cdInfo)
                local dOk, durRaw = pcall(_infoDuration, cdInfo)
                if sOk and dOk and start ~= nil and durRaw ~= nil
                   and not issecretvalue(start) and not issecretvalue(durRaw) then
                    local dur = ReadPWSDurationObj(durRaw)
                    if type(start) == "number" and type(dur) == "number" then
                        if start <= 0 or dur == 0 or dur <= 1.5 then
                            if not state.pwsCDTimerSet then
                                state.pwsOnCooldown = false
                            end
                        elseif start > 0 and dur > 1.5 then
                            state.pwsOnCooldown = true
                            state.pwsKnownCD    = dur
                            local remaining = (start + dur) - GetTime()
                            if remaining > 0 and not state.pwsCDTimerSet then
                                state.pwsCDTimerSet = true
                                C_Timer.After(remaining, _PWSCooldownExpiredCB)
                            end
                        end
                    end
                end
            end
        end
        if NHA.RefreshAllCUFs then
            NHA:RefreshAllCUFs()
        end
        NHA.ScheduleEvaluate()
        return false
    end

    if event == "PLAYER_REGEN_ENABLED" then
        wipe(atonPresence)
        state.atonCachedCount = 0
        state.pwsActive       = false
        NHA.ScheduleEvaluate()
        return false
    end

    if event == "PLAYER_ENTERING_WORLD" then
        state.hasWasteNoTimeTalent = CheckWasteNoTimeTalent()
        ReadPlayerHastePct()
        RebuildAtonPresenceCache()
        NHA.ScheduleEvaluate()
        C_Timer.After(0.5, function()
            local fresh = CheckWasteNoTimeTalent()
            if fresh ~= state.hasWasteNoTimeTalent then
                state.hasWasteNoTimeTalent = fresh
                NHA.ScheduleEvaluate()
            end
        end)
        return false
    end

    if event == "PLAYER_TALENT_UPDATE"
    or event == "PLAYER_SPECIALIZATION_CHANGED"
    or event == "TRAIT_CONFIG_UPDATED" then
        state.hasWasteNoTimeTalent = false
        state.pwsKnownCD           = 0
        C_Timer.After(0.5, function()
            state.hasWasteNoTimeTalent = CheckWasteNoTimeTalent()
            NHA.ScheduleEvaluate()
        end)
        return false
    end

    return false
end

-- =============================================================
-- BuildAlertOptions
-- =============================================================
function PriestDisc:BuildAlertOptions(parent, db, helpers)
    local r, g, b = GetClassColor(select(2, UnitClass("player")))
    r, g, b = r or 1.0, g or 1.0, b or 1.0

    local partyBox = helpers.MakeNumericInput(
        "Target Atonement (Party)",
        1, 20,
        function() return db.atonThresholdDungeon or 2 end,
        function(v)
            db.atonThresholdDungeon = v
            NHA.ScheduleEvaluate()
        end)
    partyBox:SetTextColor(r, g, b)

    helpers.Spacer(-28)

    local raidBox = helpers.MakeNumericInput(
        "Target Atonement (Raid)",
        1, 40,
        function() return db.atonThresholdRaid or 4 end,
        function(v)
            db.atonThresholdRaid = v
            NHA.ScheduleEvaluate()
        end)
    raidBox:SetTextColor(r, g, b)

    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

-- =============================================================
-- Slash Commands
-- =============================================================
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("  pwsEnabled:           " .. tostring(db.pwsEnabled))
        NHA.Msg("  cufGlowEnabled:       " .. tostring(db.cufGlowEnabled))
        NHA.Msg("  atonActive:           " .. tostring(state.atonActive))
        NHA.Msg("  atonCachedCount:      " .. tostring(state.atonCachedCount))
        NHA.Msg("  atonThresholdDungeon: " .. tostring(db.atonThresholdDungeon or 2))
        NHA.Msg("  atonThresholdRaid:    " .. tostring(db.atonThresholdRaid    or 4))
        local inRaid = IsInRaid and IsInRaid()
        NHA.Msg("  activeThreshold:      " .. tostring(GetAtonThreshold())
            .. (inRaid and " (raid)" or " (party)"))
        NHA.Msg("  vsActive:             " .. tostring(state.vsActive))
        NHA.Msg("  pwsActive:            " .. tostring(state.pwsActive))
        NHA.Msg("  inCombat:             " .. tostring(UnitAffectingCombat("player")))

        local cacheEntries = 0
        for u, _ in pairs(atonPresence) do cacheEntries = cacheEntries + 1 end
        NHA.Msg("  atonPresence entries: " .. cacheEntries)

        local vsOk, vsRes = pcall(CheckVoidShieldProc)
        NHA.Msg("  CheckVoidShieldProc:  ok=" .. tostring(vsOk)
            .. " result=" .. tostring(vsRes))

        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(normal)" or "NO(void!)"
                texResult = tostring(tex) .. " normal=" .. isNormal
            else
                texResult = "error: " .. tostring(tex)
            end
        end
        NHA.Msg("  PW:S texture:         " .. texResult)

        NHA.Msg("  pwsOnCooldown:        " .. tostring(state.pwsOnCooldown))
        NHA.Msg("  pwsCDTimerSet:        " .. tostring(state.pwsCDTimerSet))
        NHA.Msg("  pwsKnownCD:           " .. string_format("%.2fs", state.pwsKnownCD or 0))
        NHA.Msg("  hasWasteNoTimeTalent: " .. tostring(state.hasWasteNoTimeTalent))
        local hasteRaw = "nil/missing"
        if UnitSpellHaste then
            local hOk, hVal = pcall(UnitSpellHaste, "player")
            if hOk then
                if hVal == nil then
                    hasteRaw = "nil"
                elseif issecretvalue(hVal) then
                    hasteRaw = "<secret>"
                else
                    hasteRaw = tostring(hVal)
                end
            else
                hasteRaw = "throw:" .. tostring(hVal)
            end
        end
        NHA.Msg("  UnitSpellHaste raw:   " .. hasteRaw)
        NHA.Msg("  ReadPlayerHastePct:   " .. tostring(ReadPlayerHastePct()))
        NHA.Msg("  lastKnownHastePct:    " .. tostring(state.lastKnownHastePct or 0))
        local instOk, _, instType = pcall(IsInInstance)
        NHA.Msg("  instanceType:         " .. ((instOk and tostring(instType)) or "?"))
        local cdOk, cd = pcall(ComputePWSCooldown)
        if not cdOk or type(cd) ~= "number" then cd = PWS_BASE_CD_SEC end
        NHA.Msg("  haste-only CD:        " .. string_format("%.2f", cd) .. "s")
        NHA.Msg("  effective CD:         " .. string_format("%.2f", GetPWSCooldownDuration()) .. "s")

        if C_Spell and C_Spell.GetSpellCooldown then
            local apiOk, info = pcall(C_Spell.GetSpellCooldown, PWS_ID)
            if apiOk and info then
                local sOk, start = pcall(_infoStartTime, info)
                local dOk, durRaw = pcall(_infoDuration, info)
                local startStr = (sOk and start ~= nil and not issecretvalue(start))
                                 and string_format("%.2f", start) or "tainted"
                local durDisplay = "tainted"
                if dOk and durRaw ~= nil and not issecretvalue(durRaw) then
                    local d = ReadPWSDurationObj(durRaw)
                    if type(d) == "number" then
                        durDisplay = string_format("%.2fs", d)
                    end
                end
                NHA.Msg("  live API start/dur:   " .. startStr .. " / " .. durDisplay)
            else
                NHA.Msg("  live API:             unavailable")
            end
        end
    end,

    ["cufglow"] = function(self, db)
        NHA.Msg("|cffffd700CUF Frame Glow Debug (Disc Priest):|r")
        NHA.Msg("  cufGlowEnabled: " .. tostring(db.cufGlowEnabled))
        NHA.Msg("  cufHooks:       " .. tostring(NHA._cufHooksInstalled and "installed" or "not installed"))

        local trackedCount = 0
        for _ in pairs(NHA._cufPool or {}) do trackedCount = trackedCount + 1 end
        local vsCacheCount, atonCacheCount, dirtyCount = 0, 0, 0
        for _ in pairs(cufVSCache)     do vsCacheCount   = vsCacheCount   + 1 end
        for _ in pairs(cufAtonCache)   do atonCacheCount = atonCacheCount + 1 end
        for _ in pairs(dirtyCUFFrames) do dirtyCount     = dirtyCount     + 1 end
        local stateVSCount, stateAtonCount = 0, 0
        for _, kind in pairs(cufStateMap) do
            if     kind == "vs"   then stateVSCount   = stateVSCount   + 1
            elseif kind == "aton" then stateAtonCount = stateAtonCount + 1
            end
        end

        NHA.Msg("  tracked CUFs:    " .. trackedCount)
        NHA.Msg("  in combat:       " .. tostring(UnitAffectingCombat("player")))
        NHA.Msg("  cufVSCache:      " .. vsCacheCount)
        NHA.Msg("  cufAtonCache:    " .. atonCacheCount)
        NHA.Msg("  dirtyCUFFrames:  " .. dirtyCount)
        NHA.Msg("  glowing (VS/Aton): " .. stateVSCount .. " / " .. stateAtonCount)

        NHA.Msg("|cffffd700  Per-unit aura detection (VS/Aton):|r")
        local probed = 0
        for unit in pairs(NHA._unitCUFs or {}) do
            if probed >= 8 then break end
            local existsOk, exists = pcall(UnitExists, unit)
            if existsOk and exists then
                local nameOk, name = pcall(UnitName, unit)
                local hasVS   = FindAuraOnUnit(unit, VOID_SHIELD_ID, true) ~= nil
                local hasAton = FindAuraOnUnit(unit, ATONEMENT_ID,   true) ~= nil
                local glowKind = "none"
                if NHA._unitCUFs and NHA._unitCUFs[unit] then
                    for f in pairs(NHA._unitCUFs[unit]) do
                        if cufStateMap[f] then
                            glowKind = cufStateMap[f]
                            break
                        end
                    end
                end
                NHA.Msg(string_format(
                    "  %s (%s): VS=%s  Aton=%s  glow=%s",
                    unit,
                    (nameOk and tostring(name)) or "?",
                    tostring(hasVS),
                    tostring(hasAton),
                    glowKind))
                probed = probed + 1
            end
        end
    end,

    ["perf on"] = function(self, db)
        local perf = state._perf
        if not perf then
            perf = { enabled = false }
            state._perf = perf
        end
        if perf.enabled then NHA.Msg("|cffffd700NHA Perf:|r already on"); return end
        perf.enabled       = true
        perf.calls         = 0
        perf.totalMs       = 0
        perf.maxMs         = 0
        perf.startMs       = debugprofilestop()
        perf.blocksEnabled = true
        perf.blocks        = {}
        perf.cuf = {
            calls = 0, totalMs = 0, maxMs = 0,
            paths = { vs = 0, aton = 0, none = 0, disabled = 0 },
        }
        perf.refresh = { calls = 0, totalMs = 0, maxMs = 0 }
        perf.spike = {
            totalMs = 0, evalMs = 0, cufMs = 0, refreshMs = 0,
            cufCount = 0, atTime = 0,
        }

        perf.origEvaluate = PriestDisc.Evaluate
        PriestDisc.Evaluate = function(slf, sdb)
            PerfFrameRoll()
            local t0 = debugprofilestop()
            perf.origEvaluate(slf, sdb)
            local dt = debugprofilestop() - t0
            perf.calls   = perf.calls + 1
            perf.totalMs = perf.totalMs + dt
            if dt > perf.maxMs then perf.maxMs = dt end
            _perfFrameTotal = _perfFrameTotal + dt
            _perfFrameEval  = _perfFrameEval  + dt
        end

        perf.origRefresh = PriestDisc.RefreshGlows
        PriestDisc.RefreshGlows = function(slf, sdb)
            PerfFrameRoll()
            local t0 = debugprofilestop()
            perf.origRefresh(slf, sdb)
            local dt = debugprofilestop() - t0
            local r = perf.refresh
            r.calls   = r.calls + 1
            r.totalMs = r.totalMs + dt
            if dt > r.maxMs then r.maxMs = dt end
            _perfFrameTotal = _perfFrameTotal + dt
            _perfFrameRefr  = _perfFrameRefr  + dt
        end

        NHA.Msg("|cff00ff00NHA Perf: ENABLED|r — wraps on Evaluate + RefreshGlows. Run a pull, then /nha perf off")
    end,

    ["perf off"] = function(self, db)
        local perf = state._perf
        if not perf or not perf.enabled then
            NHA.Msg("|cffffd700NHA Perf:|r already off"); return
        end
        PriestDisc.Evaluate     = perf.origEvaluate
        PriestDisc.RefreshGlows = perf.origRefresh
        perf.origEvaluate       = nil
        perf.origRefresh        = nil
        PerfFrameRoll()
        perf.enabled       = false
        perf.blocksEnabled = false

        if perf.calls == 0 then
            NHA.Msg("|cffffd700NHA Perf FINAL:|r no samples")
            return
        end

        local elapsed = (debugprofilestop() - perf.startMs) / 1000
        local rate    = elapsed > 0 and (perf.calls / elapsed) or 0
        local avg     = perf.totalMs / perf.calls
        NHA.Msg(string_format(
            "|cffffd700NHA Perf FINAL:|r %.1fs, %d calls (%.1f/s), avg=%.3fms max=%.3fms total=%.1fms",
            elapsed, perf.calls, rate, avg, perf.maxMs, perf.totalMs))

        local order = { "ATON", "VS", "PWS", "END" }
        for _, name in ipairs(order) do
            local b = perf.blocks and perf.blocks[name]
            if b and b.calls > 0 then
                NHA.Msg(string_format(
                    "  %-6s avg=%.3fms max=%.3fms total=%.1fms (%d calls)",
                    name, b.totalMs / b.calls, b.maxMs, b.totalMs, b.calls))
            end
        end

        local cuf = perf.cuf
        if cuf and cuf.calls > 0 then
            local cufRate = elapsed > 0 and (cuf.calls / elapsed) or 0
            local cufAvg  = cuf.totalMs / cuf.calls
            NHA.Msg(string_format(
                "|cffffd700CUF GLOW:|r %d calls (%.1f/s) avg=%.3fms max=%.3fms total=%.1fms",
                cuf.calls, cufRate, cufAvg, cuf.maxMs, cuf.totalMs))
            NHA.Msg(string_format(
                "  paths: VS=%d  ATON=%d  NONE=%d  DISABLED=%d",
                cuf.paths.vs or 0, cuf.paths.aton or 0,
                cuf.paths.none or 0, cuf.paths.disabled or 0))
        else
            NHA.Msg("|cffffd700CUF GLOW:|r no calls in window")
        end

        local r = perf.refresh
        if r and r.calls > 0 then
            local rRate = elapsed > 0 and (r.calls / elapsed) or 0
            NHA.Msg(string_format(
                "|cffffd700REFRESH:|r %d calls (%.1f/s) avg=%.3fms max=%.3fms total=%.1fms",
                r.calls, rRate, r.totalMs / r.calls, r.maxMs, r.totalMs))
        end

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

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

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