-- =============================================================
-- 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, buff aura (whitelisted); talent def = 1253827 (live-verified 2026-05-27)
local VOID_SHIELD_NODE_ID    = 110410  -- TalentID (regular class tree, not hero)
local VOID_SHIELD_ENTRY_ID   = 136996  -- EntryID
local VOID_SHIELD_TALENT_SPELL_ID = 1253827  -- SpellID (talent definition; distinct from buff aura 1253593)
local PWS_ID         = 17       -- SpellID, whitelisted
local PENANCE_ID     = 47540    -- SpellID
local POWER_WORD_RADIANCE_ID  = 194509   -- SpellID; Node 82593, Entry 103722 (live-verified 2026-05-27)
local POWER_WORD_RADIANCE_NODE_ID  = 82593   -- TalentID
local POWER_WORD_RADIANCE_ENTRY_ID = 103722  -- EntryID
local MIND_BLAST_ID  = 8092     -- SpellID — baseline spell (not Voidweaver-exclusive); tracked only when Voidweaver is the active hero spec (VOIDWEAVER_SUBTREE_ID gate)
local ENTROPIC_RIFT_ID      = 447444   -- SpellID — Voidweaver hero talent ability; target debuff applied on MB cast
local ENTROPIC_RIFT_DURATION = 8.0     -- seconds, per spell tooltip
local VOIDWEAVER_SUBTREE_ID = 18   -- subTreeID returned by C_ClassTalents.GetActiveHeroTalentSpec() when Voidweaver is the active hero spec
local ORACLE_SUBTREE_ID     = 20   -- subTreeID returned by C_ClassTalents.GetActiveHeroTalentSpec() when Oracle is the active hero spec

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

local PWS_WASTE_NO_TIME_REDUCE = 1.5
local DISC_API_MIN_TRUSTED_DUR = 3.0     -- GCD-mask filter for Penance/Radiance reads
local DISC_CAST_SUPPRESS       = 0.5     -- min suppress window when recover defers near cast
local PENANCE_BASE_CD_SEC      = 9.0     -- fallback only; live API reflects haste + talents
local RADIANCE_BASE_CD_SEC     = 18.0    -- TWW 12.0+ baseline (was 20.0 pre-Midnight); fallback only, live API reflects haste + talents
local MIND_BLAST_BASE_CD_SEC   = 28.0    -- MB is a 1-charge spell; recharge read live from GetSpellCharges.cooldownDuration (~25.1s @ 11.48% haste). Fallback only.
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 BRIGHT_PUPIL_NODE_ID     = 82591    -- TalentID (-3s PW:R CD)
local BRIGHT_PUPIL_ENTRY_ID    = 103720   -- EntryID
local BRIGHT_PUPIL_SPELL_ID    = 390684   -- SpellID
local BRIGHT_PUPIL_REDUCE      = 3.0      -- seconds shaved off Power Word: Radiance CD
local BORROWED_TIME_NODE_ID    = 82572    -- TalentID (PW:S grants +5% haste/rank for 4s)
local BORROWED_TIME_ENTRY_ID   = 103697   -- EntryID
local BORROWED_TIME_SPELL_ID   = 390691   -- SpellID
local BORROWED_TIME_HASTE_PER_RANK = 5.0  -- % haste per allocated rank
local BORROWED_TIME_DURATION   = 4.0      -- seconds the BT haste buff lasts post-PWS-cast
local TALENT_REPROBE_DELAY     = 0.5

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 CheckVoidweaverTalent()
    if C_ClassTalents and C_ClassTalents.GetActiveHeroTalentSpec then
        local ok, active = pcall(C_ClassTalents.GetActiveHeroTalentSpec)
        if ok and active == VOIDWEAVER_SUBTREE_ID then return true end
    end
    return false
end

local function CheckWasteNoTimeTalent()
    if not (C_ClassTalents and C_ClassTalents.GetActiveHeroTalentSpec) then return false end
    local ok, activeHero = pcall(C_ClassTalents.GetActiveHeroTalentSpec)
    if not (ok and activeHero == ORACLE_SUBTREE_ID) then return false end
    local configID = 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.activeRank and ni.activeRank > 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
        return false
    end
    return true
end

-- Bright Pupil is a STANDARD Discipline class-tree talent (NOT a hero
-- talent), so it does not need the Oracle/Voidweaver hero-subtree gating
-- that CheckWasteNoTimeTalent uses. ranksPurchased / activeRank reflect
-- the actual allocation correctly for class-tree nodes regardless of
-- which hero subtree is active.
local function CheckBrightPupilTalent()
    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, BRIGHT_PUPIL_NODE_ID)
    if not ni then return false end
    if not (ni.activeRank and ni.activeRank > 0) then return false end
    if ni.activeEntry then
        local cmpOk, matches = pcall(_entryIDEquals, ni.activeEntry, BRIGHT_PUPIL_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 == BRIGHT_PUPIL_SPELL_ID then return true end
        end
    end
    return false
end

-- Returns the active rank of Borrowed Time (0, 1, or 2). Differs from the
-- WnT/BP/VW Check* functions because Borrowed Time is a 2-rank node — the
-- amount of haste BT grants scales with rank, so we need the integer count
-- rather than a boolean has/no.
local function GetBorrowedTimeRank()
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then return 0 end
    local ni = C_Traits and C_Traits.GetNodeInfo
               and C_Traits.GetNodeInfo(configID, BORROWED_TIME_NODE_ID)
    if not ni then return 0 end
    local rank = ni.activeRank or 0
    if rank <= 0 then return 0 end
    if ni.activeEntry then
        local cmpOk, matches = pcall(_entryIDEquals, ni.activeEntry, BORROWED_TIME_ENTRY_ID)
        if cmpOk and matches == true then return rank 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 == BORROWED_TIME_SPELL_ID then return rank end
        end
    end
    return rank
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 basePct = ReadPlayerHastePct() or 0
    local btRanks = (state and state.borrowedTimeRanks) or 0

    if btRanks <= 0 then
        local cd = effectiveBase / (1 + basePct / 100)
        if type(cd) == "number" and cd > 0 then return cd end
        return effectiveBase
    end

    -- Borrowed Time projection. PWS cast triggers BT, granting
    -- +5% haste per rank for BORROWED_TIME_DURATION seconds. WoW
    -- cooldowns tick at a rate proportional to current haste, so
    -- accumulate "haste-seconds" during the BT window and any
    -- remaining base CD then ticks at the post-BT rate.
    local btPct      = btRanks * BORROWED_TIME_HASTE_PER_RANK
    local btFactor   = 1 + (basePct + btPct) / 100
    local baseFactor = 1 + basePct / 100
    local btHasteSec = BORROWED_TIME_DURATION * btFactor

    if btHasteSec >= effectiveBase then
        local cd = effectiveBase / btFactor
        if type(cd) == "number" and cd > 0 then return cd end
        return effectiveBase
    end

    local remainingBase = effectiveBase - btHasteSec
    local afterBT       = remainingBase / baseFactor
    local total         = BORROWED_TIME_DURATION + afterBT
    if type(total) == "number" and total > 0 then return total end
    return effectiveBase
end

-- Cast-gap logger helper. See [[nha-cast-gap-logger]] in NemWiki — the
-- canonical instrumentation pattern. Prints elapsed wall-clock between
-- consecutive successful casts vs the predicted CD captured at the prior
-- cast, with predictor-input dump on the same line.
local function LogCastGap(label, lastAtField, lastPredField, predicted, inputsStr)
    local now = GetTime()
    local last = state[lastAtField] or 0
    local lastPred = state[lastPredField] or 0
    if last > 0 and lastPred > 0 then
        local gap = now - last
        local delta = gap - lastPred
        NHA.Msg(string_format(
            "%s cast gap: %.3fs (predicted %.2fs, off by %+.3fs) [%s]",
            label, gap, lastPred, delta, inputsStr or ""))
    else
        NHA.Msg(string_format(
            "%s cast baseline: predicted %.2fs (next cast will print gap) [%s]",
            label, predicted, inputsStr or ""))
    end
    state[lastAtField] = now
    state[lastPredField] = predicted
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)
                   and d >= DISC_API_MIN_TRUSTED_DUR 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

-- =============================================================
-- Disc Charge Trackers (Penance + PW:Radiance)
-- Rising-edge "any charge available" alerts with cast-driven suppression:
-- the alert fires whenever at least one charge is available (cur >= 1),
-- matching the Monk TFT pattern. The API can briefly false-report charges
-- immediately after a cast; the suppression window closes that race.
--
-- CD prediction delegated to live C_Spell API — handles haste, Disc
-- talents (Castigation cast count, Bright Pupil -3s for PWR, etc.), and
-- any future TWW rework automatically because the API reflects them.
-- Oracle Guiding Light raises Penance max charges 2→3; the API reflects
-- it automatically, no explicit detection needed.
-- =============================================================
local discChargeTrackers = {
    [PENANCE_ID] = {
        spellID         = PENANCE_ID,
        soundKey        = "pen",
        enabledKey      = "penEnabled",
        activeKey       = "penActive",
        baseCD          = PENANCE_BASE_CD_SEC,
        curCharges      = 0,
        maxCharges      = 0,
        suppressUntil   = 0,
        chargeTimerSet  = false,
        chargeGen       = 0,
        lastCastAt      = 0,
        wasActive       = false,
        lastKnownCD     = 0,
    },
    [POWER_WORD_RADIANCE_ID] = {
        spellID         = POWER_WORD_RADIANCE_ID,
        soundKey        = "pwr",
        enabledKey      = "pwrEnabled",
        activeKey       = "pwrActive",
        baseCD          = RADIANCE_BASE_CD_SEC,
        curCharges      = 0,
        maxCharges      = 0,
        suppressUntil   = 0,
        chargeTimerSet  = false,
        chargeGen       = 0,
        lastCastAt      = 0,
        wasActive       = false,
        lastKnownCD     = 0,
    },
    -- Mind Blast: a 1-charge offensive spell. GetSpellCooldown returns nil/0 for
    -- it (confirmed via /nha mbtrace), so it's tracked through GetSpellCharges
    -- exactly like Penance/PWR. requiresVW gates the alert to Voidweaver, matching
    -- its "Mind Blast Ready (Voidweaver)" label.
    [MIND_BLAST_ID] = {
        spellID         = MIND_BLAST_ID,
        soundKey        = "mb",
        enabledKey      = "mbEnabled",
        activeKey       = "mbActive",
        baseCD          = MIND_BLAST_BASE_CD_SEC,
        requiresVW      = true,
        curCharges      = 0,
        maxCharges      = 0,
        suppressUntil   = 0,
        chargeTimerSet  = false,
        chargeGen       = 0,
        lastCastAt      = 0,
        wasActive       = false,
        lastKnownCD     = 0,
    },
}

local function GetDiscCooldownDuration(tracker)
    if C_Spell and C_Spell.GetSpellCooldown then
        local ok, info = pcall(C_Spell.GetSpellCooldown, tracker.spellID)
        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)
                   and d >= DISC_API_MIN_TRUSTED_DUR then
                    tracker.lastKnownCD = d
                    return d
                end
            end
        end
    end
    -- Charge-based spells (Mind Blast) report nil/0 via GetSpellCooldown; their
    -- recharge lives in GetSpellCharges.cooldownDuration. Falls through to here
    -- because the GetSpellCooldown branch above never returns a trusted value.
    if C_Spell and C_Spell.GetSpellCharges then
        local ok, info = pcall(C_Spell.GetSpellCharges, tracker.spellID)
        if ok and info and info.cooldownDuration ~= nil
           and not issecretvalue(info.cooldownDuration)
           and type(info.cooldownDuration) == "number"
           and info.cooldownDuration >= DISC_API_MIN_TRUSTED_DUR then
            tracker.lastKnownCD = info.cooldownDuration
            return info.cooldownDuration
        end
    end
    if tracker.lastKnownCD and tracker.lastKnownCD > 0 then
        return tracker.lastKnownCD
    end
    local base = tracker.baseCD
    if tracker.spellID == POWER_WORD_RADIANCE_ID
       and state and state.hasBrightPupilTalent then
        base = base - BRIGHT_PUPIL_REDUCE
    end
    return base
end

local _ArmDiscChargeRecover
local _OnDiscChargeRecover

_OnDiscChargeRecover = function(tracker)
    tracker.chargeTimerSet = false

    local now = GetTime()
    local suppressed = (tracker.suppressUntil or 0) > now
    if suppressed then
        local remaining = tracker.suppressUntil - now
        if remaining < DISC_CAST_SUPPRESS then remaining = DISC_CAST_SUPPRESS end
        _ArmDiscChargeRecover(tracker, remaining)
        return
    end

    local cur = tracker.curCharges or 0
    local max = tracker.maxCharges or 0
    if cur < max then
        tracker.curCharges = cur + 1
    end
    if (tracker.curCharges or 0) < (tracker.maxCharges or 0) then
        _ArmDiscChargeRecover(tracker, GetDiscCooldownDuration(tracker))
    end
    NHA.ScheduleEvaluate()
end

_ArmDiscChargeRecover = function(tracker, cdDur)
    tracker.chargeTimerSet = true
    tracker.chargeGen      = (tracker.chargeGen or 0) + 1
    local myGen = tracker.chargeGen
    C_Timer.After(cdDur, function()
        if tracker.chargeGen ~= myGen then return end
        _OnDiscChargeRecover(tracker)
    end)
end

local function TryInitDiscChargesFromAPI(tracker)
    if not (C_Spell and C_Spell.GetSpellCharges) then return end
    local ok, info = pcall(C_Spell.GetSpellCharges, tracker.spellID)
    if not ok or not info then return end

    if info.maxCharges ~= nil and not issecretvalue(info.maxCharges)
       and type(info.maxCharges) == "number" then
        tracker.maxCharges = info.maxCharges
    end

    local maxC = tracker.maxCharges or 0
    if maxC < 1 then return end

    if info.currentCharges ~= nil and not issecretvalue(info.currentCharges)
       and type(info.currentCharges) == "number" then
        tracker.curCharges = info.currentCharges

        if info.currentCharges < maxC
           and info.cooldownStart    ~= nil and not issecretvalue(info.cooldownStart)
           and info.cooldownDuration ~= nil and not issecretvalue(info.cooldownDuration) then
            local startT = info.cooldownStart
            local durT   = info.cooldownDuration
            if type(startT) == "number" and type(durT) == "number"
               and startT > 0 and durT >= DISC_API_MIN_TRUSTED_DUR then
                tracker.lastKnownCD = durT
                local remaining = (startT + durT) - GetTime()
                if remaining > 0 and not tracker.chargeTimerSet then
                    _ArmDiscChargeRecover(tracker, remaining)
                end
            end
        end
    else
        if tracker.curCharges == 0 then
            tracker.curCharges = maxC
        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,
    hasVoidweaverTalent  = false,
    hasBrightPupilTalent = false,
    borrowedTimeRanks    = 0,
    lastKnownHastePct   = 0,
    playerAuraPending   = false,
    rosterUpdatePending = false,
    mbState             = { wasReady = false },
    riftActive           = false,
    riftWasActive        = false,
    riftProcPending      = false,
    riftExpire           = 0,
    -- Cast-gap logger toggles + measurement state (per [[nha-cast-gap-logger]])
    pwsLogEnabled           = false,
    pwsLogLastCastAt        = 0,
    pwsLogLastPredicted     = 0,
    penanceLogEnabled       = false,
    penanceLogLastCastAt    = 0,
    penanceLogLastPredicted = 0,
    pwrLogEnabled           = false,
    pwrLogLastCastAt        = 0,
    pwrLogLastPredicted     = 0,
    mbLogEnabled            = false,
    mbLogLastCastAt         = 0,
    mbLogLastPredicted      = 0,
    mbTraceEnabled          = false,
}

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

-- =============================================================
-- Alert Definitions
-- =============================================================
local ATON_Y =  50    -- Spread Atonement (above-center critical)
local VS_Y   =  25    -- Cast Void Shield (shares axis with PW:S — mutually exclusive)
local PWS_Y  =  25    -- Power Word: Shield Ready (shares axis with Void Shield)
local PEN_Y  =   0    -- Penance Ready (any charge available)
local PWR_Y  = -25    -- PW: Radiance Ready (any charge available)
local MB_Y   = -50    -- Mind Blast Ready (Voidweaver)
local RIFT_Y = -75    -- Entropic Rift Active (Voidweaver)

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",
        priority     = NHA.VOICE_PRIO_HIGH,
    },
    {
        key          = "aton",
        label        = "Spread Atonement",
        text         = "* SPREAD ATONEMENT *",
        defaultColor = { r = 1.000, g = 0.984, b = 0.569 },  -- #FFFB91
        hasTimer     = false,
        yOffset      = ATON_Y,
        defaultSound = "Priest: Spread Atonement",
        priority     = NHA.VOICE_PRIO_CRITICAL,
    },
    {
        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_Y,
        defaultSound = "Priest: Power Word Shield",
        priority     = NHA.VOICE_PRIO_LOW,
        noTest       = true,  -- shares Void Shield's axis; hidden in test mode so they don't overlap
    },
    {
        key          = "pen",
        label        = "Penance Ready",
        text         = "* PENANCE *",
        defaultColor = { r = 0.055, g = 1.000, b = 0.973 },  -- #0EFFF8
        hasTimer     = false,
        yOffset      = PEN_Y,
        defaultSound = "Priest: Penance",
        priority     = NHA.VOICE_PRIO_NORMAL,
    },
    {
        key          = "pwr",
        label        = "PW: Radiance Ready",
        text         = "* RADIANCE *",
        defaultColor = { r = 1.000, g = 0.855, b = 0.224 },  -- #FFDA39
        hasTimer     = false,
        yOffset      = PWR_Y,
        defaultSound = "Priest: Radiance",
        priority     = NHA.VOICE_PRIO_NORMAL,
    },
    {
        key          = "mb",
        label        = "Mind Blast Ready (Voidweaver)",
        text         = "* MIND BLAST *",
        defaultColor = { r = 0.671, g = 0.486, b = 1.000 },  -- #AB7CFF
        hasTimer     = false,
        yOffset      = MB_Y,
        defaultSound = "Priest: Mind Blast",
        priority     = NHA.VOICE_PRIO_NORMAL,
    },
    {
        key          = "rift",
        label        = "Entropic Rift Active",
        text         = "* RIFT OPEN *",
        defaultColor = { r = 0.486, g = 0.318, b = 0.831 },  -- #7C51D4
        hasTimer     = true,
        yOffset      = RIFT_Y,
        defaultSound = "Priest: Entropic Rift",
        priority     = NHA.VOICE_PRIO_HIGH,
    },
}

-- =============================================================
-- 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",
    penEnabled           = true,
    penSoundEnabled      = true,
    penSoundName         = "Priest: Penance",
    pwrEnabled           = true,
    pwrSoundEnabled      = true,
    pwrSoundName         = "Priest: Radiance",
    mbEnabled            = true,
    mbSoundEnabled       = true,
    mbSoundName          = "Priest: Mind Blast",
    riftEnabled          = true,
    riftSoundEnabled     = true,
    riftSoundName        = "Priest: Entropic Rift",
    atonThresholdDungeon = 2,
    atonThresholdRaid    = 4,
    alertColors          = {
        vs   = { r = 0.600, g = 0.200, b = 0.800 },
        aton = { r = 1.000, g = 0.984, b = 0.569 },  -- #FFFB91
        pws  = { r = 1.000, g = 1.000, b = 1.000 },
        pen  = { r = 0.055, g = 1.000, b = 0.973 },  -- #0EFFF8
        pwr  = { r = 1.000, g = 0.855, b = 0.224 },  -- #FFDA39
        mb   = { r = 0.671, g = 0.486, b = 1.000 },  -- #AB7CFF
        rift = { r = 0.486, g = 0.318, b = 0.831 },  -- #7C51D4
    },
}

-- =============================================================
-- 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",
        "SPELL_UPDATE_CHARGES",
    },

    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.riftActive          = false
    state.riftWasActive       = false
    state.pwsOnCooldown       = false
    state.pwsKnownCD          = 0
    state.pwsCDTimerSet       = false
    state.playerAuraPending   = false
    state.rosterUpdatePending = false
    for _, tracker in pairs(discChargeTrackers) do
        tracker.curCharges       = 0
        tracker.suppressUntil    = 0
        tracker.chargeTimerSet   = false
        tracker.chargeGen        = (tracker.chargeGen or 0) + 1
        tracker.lastCastAt       = 0
        tracker.wasActive        = false
        state[tracker.activeKey] = false
    end
    self:ClearAllGlows()
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"
            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"
            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
    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()

    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
                    else
                        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 then
        atonLow = (state.atonCachedCount or 0) < GetAtonThreshold()
    end
    state.atonActive = atonLow
    PerfMarkBlock("ATON")

    local vsProc = false
    if db.vsEnabled 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 then
        pwsReady = not state.pwsOnCooldown and not vsProc
    end
    state.pwsActive = pwsReady
    PerfMarkBlock("PWS")

    -- Penance + PW:Radiance rising-edge "any charge available" alert with
    -- cast-driven suppression. Alert fires whenever at least one charge is
    -- available AND we're not in the post-cast suppression window. The
    -- (maxCharges > 0) guard handles spells not in the player's spellbook
    -- (talent removed, never learned, etc.). Mirrors the Monk TFT pattern.
    -- Oracle Disc with Guiding Light has 3 Penance charges (API reports
    -- maxCharges automatically); the rising-edge logic is charge-count
    -- agnostic — it just needs at least one charge available.
    local nowDisc = GetTime()
    for _, tracker in pairs(discChargeTrackers) do
        local available = false
        if db[tracker.enabledKey] and (tracker.maxCharges or 0) > 0
           and (not tracker.requiresVW or state.hasVoidweaverTalent) then
            local suppressed = (tracker.suppressUntil or 0) > nowDisc
            local cur = tracker.curCharges or 0
            available = (cur >= 1) and (not suppressed)
        end
        state[tracker.activeKey] = available
        if available and not tracker.wasActive then
            NHA:PlayAlertSound(tracker.soundKey)
        end
        tracker.wasActive = available
    end
    PerfMarkBlock("DISC_CHG")

    -- Mind Blast Ready is driven by the discChargeTrackers loop above (MB is a
    -- 1-charge spell; the old EvalSpellReady/GetSpellCooldown path read nil/0 and
    -- stuck "ready" forever — see /nha mbtrace findings). state.mbActive is set there.

    -- Entropic Rift cast-trigger (Pattern D — SotF-style buff-on-cast).
    -- Mind Blast tooltip: "Mind Blast tears open an Entropic Rift that follows
    -- the enemy for 8 sec." Rift is a TARGET debuff, not a player aura — the
    -- old GetPlayerAuraBySpellID scan could never see it. UNIT_SPELLCAST_SUCCEEDED
    -- for MB sets riftProcPending + riftExpire; eval here drives the visual.
    local riftReady = false
    if db.riftEnabled and state.hasVoidweaverTalent then
        if state.riftProcPending then
            if state.riftExpire > 0 and GetTime() > state.riftExpire then
                state.riftProcPending = false
            else
                riftReady = true
            end
        end
    end
    if riftReady and not state.riftWasActive then
        NHA:PlayAlertSound("rift")
    end
    state.riftActive = riftReady
    state.riftWasActive = riftReady

    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
    PerfMarkBlock("END")
end

-- =============================================================
-- OnActivate
-- =============================================================
function PriestDisc:OnActivate(db)
    TryInitPWSCooldownState()
    for _, tracker in pairs(discChargeTrackers) do
        TryInitDiscChargesFromAPI(tracker)
    end
    if NHA.RefreshAllCUFs then
        C_Timer.After(0, function()
            NHA:RefreshAllCUFs()
        end)
    end
end

-- =============================================================
-- MBTraceDump — Phase A1 diagnostic for the Mind Blast Ready regression.
-- One line per event dumping every candidate "is MB ready" signal from the
-- canonical API audit (charges / cooldown / usable / castcount). Purpose: prove
-- whether SPELL_UPDATE_CHARGES even fires for MB and which signal tracks the
-- charge cycle deterministically, BEFORE wiring the fix. Gated on mbTraceEnabled.
-- =============================================================
local function MBTraceDump(tag)
    local cur, mx, chgCdDur = "?", "?", "?"
    if C_Spell and C_Spell.GetSpellCharges then
        local ok, info = pcall(C_Spell.GetSpellCharges, MIND_BLAST_ID)
        if ok and info then
            cur      = tostring(info.currentCharges)
            mx       = tostring(info.maxCharges)
            chgCdDur = tostring(info.cooldownDuration)
        end
    end
    local cdStart, cdDur = "?", "?"
    if C_Spell and C_Spell.GetSpellCooldown then
        local ok, info = pcall(C_Spell.GetSpellCooldown, MIND_BLAST_ID)
        if ok and info then
            local sOk, s = pcall(_infoStartTime, info)
            local dOk, d = pcall(_infoDuration, info)
            if sOk and s ~= nil and not issecretvalue(s) then cdStart = tostring(s) end
            if dOk and d ~= nil and not issecretvalue(d) then cdDur = tostring(ReadPWSDurationObj(d)) end
        end
    end
    local usable = "?"
    if C_Spell and C_Spell.IsSpellUsable then
        local ok, u, np = pcall(C_Spell.IsSpellUsable, MIND_BLAST_ID)
        if ok then usable = tostring(u) .. "/" .. tostring(np) end
    end
    local cc = "?"
    if C_Spell and C_Spell.GetSpellCastCount then
        local ok, n = pcall(C_Spell.GetSpellCastCount, MIND_BLAST_ID)
        if ok then cc = tostring(n) end
    end
    NHA.Msg(string_format(
        "|cff66ccffMBtrace[%s] %.2f|r chg=%s/%s chgCdDur=%s | cd start=%s dur=%s | usable=%s | castCount=%s",
        tag, GetTime(), cur, mx, chgCdDur, cdStart, cdDur, usable, cc))
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 state.mbTraceEnabled then MBTraceDump("COOLDOWN") end
        if db.pwsEnabled or db.mbEnabled or db.riftEnabled then NHA.ScheduleEvaluate() end
        -- Disc trackers: re-arm suppression window if API CD shifts more
        -- than 0.5s. Use C_Spell.GetSpellCooldown (NOT GetSpellCharges) —
        -- per wow_api_rules.md line 42 the former bakes in talent/set-bonus
        -- modifiers live; line 43 the latter does NOT bake in dynamic
        -- per-cast talent reductions.
        if C_Spell and C_Spell.GetSpellCooldown then
            local now = GetTime()
            for _, tracker in pairs(discChargeTrackers) do
                local cdOk, cdInfo = pcall(C_Spell.GetSpellCooldown, tracker.spellID)
                if cdOk and cdInfo then
                    local sOk, startT = pcall(_infoStartTime, cdInfo)
                    local dOk, durRaw = pcall(_infoDuration,  cdInfo)
                    if sOk and dOk and startT ~= nil and durRaw ~= nil
                       and not issecretvalue(startT) and not issecretvalue(durRaw) then
                        local durT = ReadPWSDurationObj(durRaw)
                        if type(startT) == "number" and type(durT) == "number"
                           and startT > 0 and durT >= DISC_API_MIN_TRUSTED_DUR then
                            local newSuppressEnd = startT + durT
                            local oldSuppressEnd = tracker.suppressUntil or 0
                            if newSuppressEnd > now
                               and math.abs(newSuppressEnd - oldSuppressEnd) > 0.5 then
                                tracker.suppressUntil = newSuppressEnd
                                tracker.lastKnownCD   = durT
                                local remaining = newSuppressEnd - now
                                _ArmDiscChargeRecover(tracker, remaining)
                            end
                        end
                    end
                end
            end
        end
        return true
    end

    if event == "SPELL_UPDATE_CHARGES" then
        if state.mbTraceEnabled then MBTraceDump("CHARGES") end
        if not (C_Spell and C_Spell.GetSpellCharges) then return false end
        local changed = false
        local now     = GetTime()
        for _, tracker in pairs(discChargeTrackers) do
            local apiOk, info = pcall(C_Spell.GetSpellCharges, tracker.spellID)
            if apiOk and info then
                local suppressed = (tracker.suppressUntil or 0) > now
                if info.currentCharges ~= nil and not issecretvalue(info.currentCharges)
                   and type(info.currentCharges) == "number"
                   and info.currentCharges ~= tracker.curCharges then
                    -- Refuse to RAISE charges during cast-suppression; let
                    -- decreases through (external desync correction).
                    if not (suppressed and info.currentCharges > (tracker.curCharges or 0)) then
                        tracker.curCharges = info.currentCharges
                        changed = true
                    end
                end
                if info.maxCharges ~= nil and not issecretvalue(info.maxCharges)
                   and type(info.maxCharges) == "number"
                   and info.maxCharges ~= tracker.maxCharges then
                    tracker.maxCharges = info.maxCharges
                    changed = true
                end
            end
        end
        if changed then NHA.ScheduleEvaluate() end
        return false
    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()
                if state.pwsLogEnabled then
                    local label = (okVS and matchVS) and "VS" or "PWS"
                    local wnt = state.hasWasteNoTimeTalent and "WnT" or "noWnT"
                    local btRanks = state.borrowedTimeRanks or 0
                    local btStr = (btRanks > 0) and string_format("BT%d/2", btRanks) or "noBT"
                    local inputs = string_format("haste=%.2f%% %s %s",
                        ReadPlayerHastePct() or 0, wnt, btStr)
                    LogCastGap(label, "pwsLogLastCastAt", "pwsLogLastPredicted", cd, inputs)
                end
            end

            -- Disc tracker dispatch: Penance or Radiance cast triggers
            -- cast-driven suppression on the cap-detection alert.
            local discTracker = discChargeTrackers[spellID]
            if discTracker then
                local now   = GetTime()
                local cdDur = GetDiscCooldownDuration(discTracker)
                discTracker.lastCastAt    = now
                discTracker.suppressUntil = now + cdDur
                local cur = discTracker.curCharges or 0
                discTracker.curCharges = (cur > 0) and (cur - 1) or 0
                discTracker.wasActive  = false
                _ArmDiscChargeRecover(discTracker, cdDur)
                C_Timer.After(cdDur + 0.01, function() NHA.ScheduleEvaluate() end)
                NHA.ScheduleEvaluate()
                if discTracker.spellID == PENANCE_ID and state.penanceLogEnabled then
                    local inputs = string_format("haste=%.2f%%", ReadPlayerHastePct() or 0)
                    LogCastGap("Penance", "penanceLogLastCastAt",
                        "penanceLogLastPredicted", cdDur, inputs)
                elseif discTracker.spellID == POWER_WORD_RADIANCE_ID and state.pwrLogEnabled then
                    local inputs = string_format("haste=%.2f%%", ReadPlayerHastePct() or 0)
                    LogCastGap("PWR", "pwrLogLastCastAt",
                        "pwrLogLastPredicted", cdDur, inputs)
                end
            end

            -- Entropic Rift cast-trigger: MB cast on Voidweaver opens the rift
            -- on the enemy for 8s. Set the proc-pending latch + expire timer;
            -- Evaluate drives the visual rising-edge fire from there. Schedule
            -- a follow-up eval after the duration so the alert clears cleanly.
            if spellID == MIND_BLAST_ID and state.hasVoidweaverTalent then
                state.riftProcPending = true
                state.riftExpire      = GetTime() + ENTROPIC_RIFT_DURATION
                NHA.ScheduleEvaluate()
                C_Timer.After(ENTROPIC_RIFT_DURATION + 0.05, function()
                    NHA.ScheduleEvaluate()
                end)
            end

            -- Mind Blast cast-gap logger. Fires on any MB cast regardless of
            -- state.hasVoidweaverTalent — that asymmetry IS the diagnostic
            -- (if MB casts log but the in-game MB Ready alert is silent, the
            -- bug is in the Voidweaver gate, not the cast detection).
            if spellID == MIND_BLAST_ID and state.mbTraceEnabled then MBTraceDump("CAST") end
            if spellID == MIND_BLAST_ID and state.mbLogEnabled then
                local mbPred = 0
                if C_Spell and C_Spell.GetSpellCooldown then
                    local okMB, infoMB = pcall(C_Spell.GetSpellCooldown, MIND_BLAST_ID)
                    if okMB and infoMB then
                        local durOk, durRaw = pcall(_infoDuration, infoMB)
                        if durOk and durRaw ~= nil and not issecretvalue(durRaw) then
                            local d = ReadPWSDurationObj(durRaw)
                            if type(d) == "number" and d > 0 then mbPred = d end
                        end
                    end
                end
                local vw = state.hasVoidweaverTalent and "VW" or "noVW"
                local inputs = string_format("haste=%.2f%% %s",
                    ReadPlayerHastePct() or 0, vw)
                LogCastGap("MB", "mbLogLastCastAt", "mbLogLastPredicted", mbPred, inputs)
            end
        end
        return true
    end

    if event == "GROUP_ROSTER_UPDATE" then
        if not state.rosterUpdatePending then
            state.rosterUpdatePending = true
            C_Timer.After(0.2, function()
                state.rosterUpdatePending = false
                RebuildAtonPresenceCache()
                NHA.Evaluate()
            end)
        end
        return true
    end

    if event == "PLAYER_REGEN_DISABLED" then
        RebuildAtonPresenceCache()
        for _, tracker in pairs(discChargeTrackers) do
            TryInitDiscChargesFromAPI(tracker)
        end
        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
                        else
                            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 true
    end

    if event == "PLAYER_REGEN_ENABLED" then
        wipe(atonPresence)
        state.atonCachedCount = 0
        for _, tracker in pairs(discChargeTrackers) do
            TryInitDiscChargesFromAPI(tracker)
        end
        NHA.ScheduleEvaluate()
        return true
    end

    if event == "PLAYER_ENTERING_WORLD" then
        state.hasWasteNoTimeTalent = CheckWasteNoTimeTalent()
        state.hasVoidweaverTalent  = CheckVoidweaverTalent()
        state.hasBrightPupilTalent = CheckBrightPupilTalent()
        state.borrowedTimeRanks    = GetBorrowedTimeRank()
        ReadPlayerHastePct()
        RebuildAtonPresenceCache()
        for _, tracker in pairs(discChargeTrackers) do
            TryInitDiscChargesFromAPI(tracker)
        end
        NHA.ScheduleEvaluate()
        C_Timer.After(0.5, function()
            local freshWNT = CheckWasteNoTimeTalent()
            local freshVW  = CheckVoidweaverTalent()
            local freshBP  = CheckBrightPupilTalent()
            local freshBT  = GetBorrowedTimeRank()
            local changed  = false
            if freshWNT ~= state.hasWasteNoTimeTalent then
                state.hasWasteNoTimeTalent = freshWNT
                changed = true
            end
            if freshVW ~= state.hasVoidweaverTalent then
                state.hasVoidweaverTalent = freshVW
                changed = true
            end
            if freshBP ~= state.hasBrightPupilTalent then
                state.hasBrightPupilTalent = freshBP
                changed = true
            end
            if freshBT ~= state.borrowedTimeRanks then
                state.borrowedTimeRanks = freshBT
                changed = true
            end
            if changed then 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.hasVoidweaverTalent  = false
        state.hasBrightPupilTalent = false
        state.borrowedTimeRanks    = 0
        state.pwsKnownCD           = 0
        C_Timer.After(TALENT_REPROBE_DELAY, function()
            state.hasWasteNoTimeTalent = CheckWasteNoTimeTalent()
            state.hasVoidweaverTalent  = CheckVoidweaverTalent()
            state.hasBrightPupilTalent = CheckBrightPupilTalent()
            state.borrowedTimeRanks    = GetBorrowedTimeRank()
            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._nhaLabel
    local raidLbl  = raidBox._nhaLabel
    if partyLbl and raidLbl 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))
        NHA.Msg("  hasBrightPupilTalent: " .. tostring(state.hasBrightPupilTalent))
        NHA.Msg("  borrowedTimeRanks:    " .. tostring(state.borrowedTimeRanks))
        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

        -- Disc tracker dump (Penance + Radiance). maxCharges=0 means the
        -- spell isn't in the player's spellbook (talent removed, etc.).
        do
            local nowDbgD = GetTime()
            for _, tracker in pairs(discChargeTrackers) do
                local suppLeftD = (tracker.suppressUntil or 0) - nowDbgD
                NHA.Msg(string_format(
                    "  [%s] active=%s  charges=%s/%s  suppress=%s%.2fs  lastCast=%.2f  lastCD=%.2fs",
                    tracker.soundKey,
                    tostring(state[tracker.activeKey]),
                    tostring(tracker.curCharges or 0),
                    tostring(tracker.maxCharges or 0),
                    suppLeftD > 0 and "active, " or "expired ",
                    suppLeftD > 0 and suppLeftD or -suppLeftD,
                    tracker.lastCastAt or 0,
                    tracker.lastKnownCD or 0))
            end
        end

        NHA.Msg("  cast-gap loggers:")
        NHA.Msg("    pwslog:     " .. tostring(state.pwsLogEnabled))
        NHA.Msg("    penancelog: " .. tostring(state.penanceLogEnabled))
        NHA.Msg("    pwrlog:     " .. tostring(state.pwrLogEnabled))
        NHA.Msg("    mblog:      " .. tostring(state.mbLogEnabled))
    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", "DISC_CHG", "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,

    -- Voidweaver detection diagnostic. Dumps the live talent flag, the raw
    -- C_ClassTalents.GetActiveHeroTalentSpec() return, pcall status, and the
    -- synchronous CheckVoidweaverTalent() result so the failure mode of a
    -- silent Voidweaver-gated alert is observable instead of inferred.
    ["vwdebug"] = function(self, db)
        NHA.Msg("|cffffd700Disc Voidweaver Debug:|r")
        NHA.Msg("  state.hasVoidweaverTalent:  " .. tostring(state.hasVoidweaverTalent))
        NHA.Msg("  state.hasWasteNoTimeTalent: " .. tostring(state.hasWasteNoTimeTalent))
        NHA.Msg("  state.hasBrightPupilTalent: " .. tostring(state.hasBrightPupilTalent))
        NHA.Msg("  state.borrowedTimeRanks:    " .. tostring(state.borrowedTimeRanks))
        NHA.Msg("  VOIDWEAVER_SUBTREE_ID:      " .. tostring(VOIDWEAVER_SUBTREE_ID))
        local apiAvail = (C_ClassTalents and C_ClassTalents.GetActiveHeroTalentSpec) and true or false
        NHA.Msg("  API available:              " .. tostring(apiAvail))
        if apiAvail then
            local ok, active = pcall(C_ClassTalents.GetActiveHeroTalentSpec)
            NHA.Msg("  raw pcall ok:               " .. tostring(ok))
            if ok then
                local rawDisplay
                if active == nil then
                    rawDisplay = "nil"
                elseif issecretvalue(active) then
                    rawDisplay = "<secret>"
                else
                    rawDisplay = tostring(active) .. " (" .. type(active) .. ")"
                end
                NHA.Msg("  raw API return:             " .. rawDisplay)
                NHA.Msg("  matches SUBTREE_ID:         " .. tostring(active == VOIDWEAVER_SUBTREE_ID))
            else
                NHA.Msg("  pcall threw:                " .. tostring(active))
            end
        end
        NHA.Msg("  CheckVoidweaverTalent():    " .. tostring(CheckVoidweaverTalent()))
    end,

    -- Mind Blast cooldown-state dump. Regression: MB Ready never clears because
    -- the mblog showed "predicted 0.00s" — C_Spell.GetSpellCooldown(8092) read a
    -- 0 duration at cast time, so EvalSpellReady treats MB as permanently ready.
    -- This dumps the live read on demand: sample right-after a cast, +2s, +5s.
    -- If duration is 0 at the cast frame but non-zero later, it's a cast-frame
    -- race (PWS GCD-residue class). If 0 throughout, the API never surfaces MB's
    -- CD and we must compute it (cast-driven suppression like the disc trackers).
    ["mbstate"] = function(self, db)
        NHA.Msg("|cffffd700MB State @ " .. string_format("%.2f", GetTime()) .. ":|r")
        NHA.Msg("  state.mbActive:            " .. tostring(state.mbActive))
        NHA.Msg("  state.mbState.wasReady:    " .. tostring(state.mbState and state.mbState.wasReady))
        NHA.Msg("  state.hasVoidweaverTalent: " .. tostring(state.hasVoidweaverTalent))
        if C_Spell and C_Spell.GetSpellCooldown then
            local ok, info = pcall(C_Spell.GetSpellCooldown, MIND_BLAST_ID)
            if ok and info then
                local sOk, startT = pcall(_infoStartTime, info)
                local dOk, durRaw = pcall(_infoDuration, info)
                local startNum = (sOk and type(startT) == "number" and not issecretvalue(startT)) and startT or nil
                local durNum
                if dOk and durRaw ~= nil and not issecretvalue(durRaw) then
                    durNum = ReadPWSDurationObj(durRaw)
                end
                NHA.Msg("  GetSpellCooldown.start:    " .. tostring(startNum))
                NHA.Msg("  GetSpellCooldown.duration: " .. tostring(durNum))
                if type(startNum) == "number" and type(durNum) == "number" then
                    NHA.Msg("  remaining:                 " ..
                        string_format("%.2f", startNum + durNum - GetTime()))
                end
            else
                NHA.Msg("  GetSpellCooldown: pcall failed or nil")
            end
        end
        if C_Spell and C_Spell.GetSpellCharges then
            local ok, info = pcall(C_Spell.GetSpellCharges, MIND_BLAST_ID)
            if ok and info then
                NHA.Msg("  GetSpellCharges.current:   " .. tostring(info.currentCharges))
                NHA.Msg("  GetSpellCharges.max:       " .. tostring(info.maxCharges))
                NHA.Msg("  GetSpellCharges.cdStart:   " .. tostring(info.cooldownStartTime))
                NHA.Msg("  GetSpellCharges.cdDur:     " .. tostring(info.cooldownDuration))
                NHA.Msg("  GetSpellCharges.modRate:   " .. tostring(info.chargeModRate))
            else
                NHA.Msg("  GetSpellCharges: nil (MB likely not charge-based)")
            end
        end
    end,

    -- Cast-gap loggers. See [[nha-cast-gap-logger]] in NemWiki — canonical
    -- instrumentation for verifying NHA cooldown predictions vs real server
    -- CDs. Each toggle prints elapsed-vs-predicted on every successful cast.
    ["pwslog"] = function(self, db)
        state.pwsLogEnabled = not state.pwsLogEnabled
        state.pwsLogLastCastAt = 0
        state.pwsLogLastPredicted = 0
        if state.pwsLogEnabled then
            NHA.Msg("|cff00ff00PWS cast-gap log: ON|r")
        else
            NHA.Msg("|cffffd700PWS cast-gap log: OFF|r")
        end
    end,

    ["penancelog"] = function(self, db)
        state.penanceLogEnabled = not state.penanceLogEnabled
        state.penanceLogLastCastAt = 0
        state.penanceLogLastPredicted = 0
        if state.penanceLogEnabled then
            NHA.Msg("|cff00ff00Penance cast-gap log: ON|r")
        else
            NHA.Msg("|cffffd700Penance cast-gap log: OFF|r")
        end
    end,

    ["pwrlog"] = function(self, db)
        state.pwrLogEnabled = not state.pwrLogEnabled
        state.pwrLogLastCastAt = 0
        state.pwrLogLastPredicted = 0
        if state.pwrLogEnabled then
            NHA.Msg("|cff00ff00PWR cast-gap log: ON|r")
        else
            NHA.Msg("|cffffd700PWR cast-gap log: OFF|r")
        end
    end,

    ["mblog"] = function(self, db)
        state.mbLogEnabled = not state.mbLogEnabled
        state.mbLogLastCastAt = 0
        state.mbLogLastPredicted = 0
        if state.mbLogEnabled then
            NHA.Msg("|cff00ff00MB cast-gap log: ON|r")
        else
            NHA.Msg("|cffffd700MB cast-gap log: OFF|r")
        end
    end,

    -- Phase A1 diagnostic: event-driven trace of every candidate "MB ready"
    -- signal on UNIT_SPELLCAST_SUCCEEDED / SPELL_UPDATE_CHARGES / SPELL_UPDATE_COOLDOWN.
    -- Enable, cast Mind Blast once in combat, watch through a full recharge.
    ["mbtrace"] = function(self, db)
        state.mbTraceEnabled = not state.mbTraceEnabled
        if state.mbTraceEnabled then
            NHA.Msg("|cff00ff00MB trace: ON|r — cast Mind Blast once in combat; watch CAST/CHARGES/COOLDOWN lines.")
            MBTraceDump("NOW")
        else
            NHA.Msg("|cffffd700MB trace: OFF|r")
        end
    end,
}

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