-- =============================================================
-- Specs/PriestHoly.lua
-- Nem: Healer Alerts — Holy 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 POM_ID          = 41635   -- SpellID, whitelisted (buff aura)
local POM_CAST_ID     = 33076   -- SpellID, cast spell (charges API)
local RENEW_ID        = 139     -- SpellID, whitelisted
local HW_SERENITY_ID  = 2050    -- SpellID; Node 82638, Entry 103775 (live-verified 2026-05-27)
local HW_SANCTIFY_ID  = 34861   -- SpellID; Node 82631, Entry 103766 (live-verified 2026-05-27)

-- Hero subtree IDs (live-verified 2026-05-27 via C_ClassTalents.GetActiveHeroTalentSpec())
local ORACLE_SUBTREE_ID   = 20  -- Oracle hero tree
local ARCHON_SUBTREE_ID   = 19  -- Archon hero tree
-- HW node/entry IDs (live-verified 2026-05-27; not consumed by charge/cooldown
-- APIs but required for any future C_Traits talent-gating on these spells)
local HW_SERENITY_NODE_ID  = 82638   -- TalentID
local HW_SERENITY_ENTRY_ID = 103775  -- EntryID
local HW_SANCTIFY_NODE_ID  = 82631   -- TalentID
local HW_SANCTIFY_ENTRY_ID = 103766  -- EntryID

local POM_BASE_CD_SEC          = 12.0    -- tooltip base, haste-reduced at runtime
local POM_WASTE_NO_TIME_REDUCE = 1.5     -- talent's CD reduction (subtracted before haste)
local POM_DEFAULT_MAX_CHARGES  = 2
local POM_CAST_SUPPRESS        = 0.5     -- min suppress window when recover defers near cast
local POM_API_MIN_TRUSTED_DUR  = 3.0     -- API values below this are GCD masking, not POM CD
local HW_BASE_CD_SEC           = 45.0    -- Serenity/Sanctify recharge per charge (in-game confirmed 45.00s, 2026-05-31)
local HW_DEFAULT_MAX_CHGS      = 2       -- two-charge base (Miracle Worker)
-- Serenity manual CDR (tooltip-confirmed 2026-05-31; rationale in wow-addon-learnings)
local FLASH_HEAL_ID            = 2061     -- SpellID
local SER_CDR_FLASH_HEAL       = 6.0      -- Serendipity: Flash Heal -6s Serenity
local BENEDICTION_ID           = 1262763  -- SpellID; upgraded Flash Heal (Serenity -6s + Surge consume)
local SER_CDR_POM              = 4.0      -- Voice of Harmony: PoM -4s Serenity (Serenity only)
local PRAYER_OF_HEALING_ID     = 596      -- SpellID
local SAN_CDR_PRAYER_OF_HEALING = 6.0     -- Serendipity: Prayer of Healing -6s Sanctify (Sanctify only)
local HALO_ID                  = 120517   -- SpellID (Archon); "creating a Halo" -4s Sanctify
local SAN_CDR_HALO             = 4.0
local SAN_CDR_ENERGY_CYCLE     = 4.0      -- Archon Energy Cycle: instant Flash Heal / PoH (Surge consume) -4s Sanctify
local POWER_SURGE_NODE_ID          = 94681     -- TalentID (Archon hero)
local POWER_SURGE_ENTRY_ID         = 117284    -- EntryID
local POWER_SURGE_SPELL_ID         = 453109    -- SpellID (Archon hero; Halo cast auto-Halos every 5s)
local POWER_SURGE_BASE_SEC         = 15.0      -- auto-Halo window; auto-Halo fires every 5s
local POWER_SURGE_HALO_INTERVAL    = 5.0
local ENERGY_CONSERVATION_NODE_ID   = 94680    -- TalentID (Archon hero)
local ENERGY_CONSERVATION_ENTRY_ID  = 117283   -- EntryID
local ENERGY_CONSERVATION_SPELL_ID  = 1272308  -- SpellID (Archon hero; +5s Power Surge window)
local ENERGY_CONSERVATION_BONUS_SEC = 5.0
local SPIRIT_WELL_NODE_ID      = 109775   -- TalentID (Archon hero, choice node)
local SPIRIT_WELL_ENTRY_ID     = 136688   -- EntryID
local SPIRIT_WELL_SPELL_ID     = 1247178  -- SpellID; lets Prayer of Healing consume Surge of Light (gates PoH Energy Cycle -4s)
local SUSTAINED_POTENCY_NODE_ID  = 94678  -- TalentID (Archon hero, choice node)
local SUSTAINED_POTENCY_ENTRY_ID = 125085 -- EntryID
local SUSTAINED_POTENCY_SPELL_ID = 454001 -- SpellID; Halo extends Apotheosis +1s (banks 6s if down; pauses 20s OOC/CC)
local SUSTAINED_POTENCY_EXTEND_SEC    = 1.0
local SUSTAINED_POTENCY_STORE_CAP_SEC = 6.0
local SUSTAINED_POTENCY_PAUSE_CAP_SEC = 20.0
local ULTIMATE_SERENITY_NODE_ID  = 103900 -- TalentID (class tree)
local ULTIMATE_SERENITY_ENTRY_ID = 103733 -- EntryID
local ULTIMATE_SERENITY_SPELL_ID = 1246517 -- SpellID; replaces Sanctify, redirects Sanctify-affecting talents onto Serenity
local APOTHEOSIS_ID            = 200183   -- SpellID (buff; triples HW CDR)
local APOTHEOSIS_CDR_MULT      = 3.0
local APOTHEOSIS_DURATION_SEC  = 20.0
local ETERNAL_SANCTITY_NODE_ID   = 104097    -- TalentID
local ETERNAL_SANCTITY_ENTRY_ID  = 128612    -- EntryID
local ETERNAL_SANCTITY_SPELL_ID  = 1215245   -- SpellID (passive; +12s Apotheosis)
local ETERNAL_SANCTITY_BONUS_SEC = 12.0
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 PROPHETS_INSIGHT_NODE_ID  = 109783   -- TalentID (Oracle hero)
local PROPHETS_INSIGHT_ENTRY_ID = 136041   -- EntryID
local PROPHETS_INSIGHT_SPELL_ID = 1272359  -- SpellID; flat -5s Holy Word base recharge
local PROPHETS_INSIGHT_REDUCE   = 5.0      -- seconds shaved off each Holy Word base CD
local BENEDICTION_NODE_ID         = 110409    -- TalentID
local BENEDICTION_ENTRY_ID        = 136994    -- EntryID
local BENEDICTION_TALENT_SPELL_ID = 1262758   -- SpellID (talent passive; gate fallback)
local VOICE_OF_HARMONY_NODE_ID  = 82620    -- TalentID (class tree)
local VOICE_OF_HARMONY_ENTRY_ID = 103754   -- EntryID
local VOICE_OF_HARMONY_SPELL_ID = 390994   -- SpellID; gates PoM -4s Serenity + Halo -4s Sanctify
local LIGHT_OF_NAARU_NODE_ID    = 82629    -- TalentID (class tree, 2 ranks)
local LIGHT_OF_NAARU_SPELL_ID   = 196985   -- SpellID
local LIGHT_OF_NAARU_PER_RANK   = 0.10     -- +10% Holy Word CDR per rank (rank 2 = 20%)
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 _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
local function _chargesAtMax(info)  return info.currentCharges == info.maxCharges end

-- =============================================================
-- DurationObject Unwrap
-- =============================================================
local function ReadPoMDurationObj(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

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

-- =============================================================
-- Waste No Time Talent
-- =============================================================
local function _entryIDEquals(entry, expected)
    return entry.entryID == expected
end

local function CheckWasteNoTimeTalent()
    if not (C_ClassTalents and C_ClassTalents.GetActiveHeroTalentSpec) then
        if IsPlayerSpell then
            local ok, has = pcall(IsPlayerSpell, WASTE_NO_TIME_SPELL_ID)
            if ok and has == true then return true end
        end
        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
    end
    return false
end

-- Prophet's Insight: Oracle hero passive, flat -5s to Holy Word base recharge.
-- Hero talent -> gate on the ACTIVE subtree, not allocated points
-- (see wow-hero-talent-active-state.md).
local function CheckProphetsInsightTalent()
    if not (C_ClassTalents and C_ClassTalents.GetActiveHeroTalentSpec) then
        if IsPlayerSpell then
            local ok, has = pcall(IsPlayerSpell, PROPHETS_INSIGHT_SPELL_ID)
            if ok and has == true then return true end
        end
        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, PROPHETS_INSIGHT_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, PROPHETS_INSIGHT_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 == PROPHETS_INSIGHT_SPELL_ID then return true end
        end
    end
    return false
end

-- Archon hero spec active? Gates Halo + Energy Cycle Sanctify CDR (Archon-only).
-- Check the ACTIVE subtree, not allocated points -- see wow-hero-talent-active-state.md.
local function IsArchonActive()
    if not (C_ClassTalents and C_ClassTalents.GetActiveHeroTalentSpec) then return false end
    local ok, activeHero = pcall(C_ClassTalents.GetActiveHeroTalentSpec)
    return ok and activeHero == ARCHON_SUBTREE_ID
end

-- Benediction apex class talent? Gates the Benediction -> Serenity CDR credit.
-- Class-tree node (not hero), so no GetActiveHeroTalentSpec gate -- just node rank.
local function CheckBenedictionTalent()
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then
        if IsPlayerSpell then
            local ok, has = pcall(IsPlayerSpell, BENEDICTION_TALENT_SPELL_ID)
            if ok and has == true then return true end
        end
        return false
    end
    local ni = C_Traits and C_Traits.GetNodeInfo
               and C_Traits.GetNodeInfo(configID, BENEDICTION_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, BENEDICTION_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 == BENEDICTION_TALENT_SPELL_ID then return true end
        end
    end
    return false
end

local function CheckEternalSanctityTalent()
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then
        if IsPlayerSpell then
            local ok, has = pcall(IsPlayerSpell, ETERNAL_SANCTITY_SPELL_ID)
            if ok and has == true then return true end
        end
        return false
    end
    local ni = C_Traits and C_Traits.GetNodeInfo
               and C_Traits.GetNodeInfo(configID, ETERNAL_SANCTITY_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, ETERNAL_SANCTITY_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 == ETERNAL_SANCTITY_SPELL_ID then return true end
        end
    end
    return false
end

local function CheckPowerSurgeTalent()
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then
        if IsPlayerSpell then
            local ok, has = pcall(IsPlayerSpell, POWER_SURGE_SPELL_ID)
            if ok and has == true then return true end
        end
        return false
    end
    local ni = C_Traits and C_Traits.GetNodeInfo
               and C_Traits.GetNodeInfo(configID, POWER_SURGE_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, POWER_SURGE_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 == POWER_SURGE_SPELL_ID then return true end
        end
    end
    return false
end

local function CheckEnergyConservationTalent()
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then
        if IsPlayerSpell then
            local ok, has = pcall(IsPlayerSpell, ENERGY_CONSERVATION_SPELL_ID)
            if ok and has == true then return true end
        end
        return false
    end
    local ni = C_Traits and C_Traits.GetNodeInfo
               and C_Traits.GetNodeInfo(configID, ENERGY_CONSERVATION_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, ENERGY_CONSERVATION_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 == ENERGY_CONSERVATION_SPELL_ID then return true end
        end
    end
    return false
end

local function CheckSpiritWellTalent()
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then
        if IsPlayerSpell then
            local ok, has = pcall(IsPlayerSpell, SPIRIT_WELL_SPELL_ID)
            if ok and has == true then return true end
        end
        return false
    end
    local ni = C_Traits and C_Traits.GetNodeInfo
               and C_Traits.GetNodeInfo(configID, SPIRIT_WELL_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, SPIRIT_WELL_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 == SPIRIT_WELL_SPELL_ID then return true end
        end
    end
    return false
end

local function CheckSustainedPotencyTalent()
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then
        if IsPlayerSpell then
            local ok, has = pcall(IsPlayerSpell, SUSTAINED_POTENCY_SPELL_ID)
            if ok and has == true then return true end
        end
        return false
    end
    local ni = C_Traits and C_Traits.GetNodeInfo
               and C_Traits.GetNodeInfo(configID, SUSTAINED_POTENCY_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, SUSTAINED_POTENCY_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 == SUSTAINED_POTENCY_SPELL_ID then return true end
        end
    end
    return false
end

local function CheckUltimateSerenityTalent()
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then
        if IsPlayerSpell then
            local ok, has = pcall(IsPlayerSpell, ULTIMATE_SERENITY_SPELL_ID)
            if ok and has == true then return true end
        end
        return false
    end
    local ni = C_Traits and C_Traits.GetNodeInfo
               and C_Traits.GetNodeInfo(configID, ULTIMATE_SERENITY_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, ULTIMATE_SERENITY_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 == ULTIMATE_SERENITY_SPELL_ID then return true end
        end
    end
    return false
end

local function CheckVoiceOfHarmonyTalent()
    local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                     and C_ClassTalents.GetActiveConfigID()
    if not configID then
        if IsPlayerSpell then
            local ok, has = pcall(IsPlayerSpell, VOICE_OF_HARMONY_SPELL_ID)
            if ok and has == true then return true end
        end
        return false
    end
    local ni = C_Traits and C_Traits.GetNodeInfo
               and C_Traits.GetNodeInfo(configID, VOICE_OF_HARMONY_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, VOICE_OF_HARMONY_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 == VOICE_OF_HARMONY_SPELL_ID then return true end
        end
    end
    return false
end

-- Ranked passive (0-2): the multiplier needs the rank, so this returns the count
-- rather than a boolean. Node read only; no IsPlayerSpell fallback can give a rank.
local function CheckLightOfNaaruRank()
    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, LIGHT_OF_NAARU_NODE_ID)
    if not ni then return 0 end
    local r = ni.activeRank
    if type(r) ~= "number" or r < 0 then return 0 end
    if r > 2 then return 2 end
    return r
end

-- =============================================================
-- Prayer of Mending Cooldown
-- =============================================================
local function ComputePoMCooldown()
    local effectiveBase = POM_BASE_CD_SEC
    if state and state.hasWasteNoTimeTalent then
        effectiveBase = effectiveBase - POM_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 GetPoMCooldownDuration()
    -- API path is unreliable at cast moment: C_Spell.GetSpellCooldown reports
    -- the active GCD (1.5s / haste) when POM has just been cast, not POM's
    -- actual recharge. Filter values below POM_API_MIN_TRUSTED_DUR — those are
    -- GCD-tier noise, not the spell CD. Fall through to the haste calc below.
    if C_Spell and C_Spell.GetSpellCooldown then
        local ok, info = pcall(C_Spell.GetSpellCooldown, POM_CAST_ID)
        if ok and info then
            local durOk, duration = pcall(_infoDuration, info)
            if durOk and duration ~= nil and not issecretvalue(duration) then
                local d = ReadPoMDurationObj(duration)
                if type(d) == "number" and not issecretvalue(d)
                   and d >= POM_API_MIN_TRUSTED_DUR then
                    return d
                end
            end
        end
    end
    local ok, cd = pcall(ComputePoMCooldown)
    if ok and type(cd) == "number" and cd > 0 then return cd end
    if state and state.pomKnownCD and state.pomKnownCD > 0 then
        return state.pomKnownCD
    end
    return POM_BASE_CD_SEC
end

-- Forward decls so _OnPoMChargeRecover can call _ArmPoMChargeRecover and
-- vice versa without dependency-ordering issues.
local _ArmPoMChargeRecover
local _OnPoMChargeRecover

-- Cast-driven suppression: if a recover timer fires inside the cast-suppression
-- window (cast landed right at predicted-ready), defer the recover until the
-- window expires rather than raising the charge mid-CD.
_OnPoMChargeRecover = function()
    state.pomChargeTimerSet = false

    local now = GetTime()
    local suppressed = (state.pomSuppressUntil or 0) > now
    if suppressed then
        local remaining = state.pomSuppressUntil - now
        if remaining < POM_CAST_SUPPRESS then remaining = POM_CAST_SUPPRESS end
        _ArmPoMChargeRecover(remaining)
        return
    end

    local cur = state.pomCurCharges or 0
    local max = state.pomMaxCharges or POM_DEFAULT_MAX_CHARGES
    if cur < max then
        state.pomCurCharges = cur + 1
    end
    if (state.pomCurCharges or 0) < max then
        _ArmPoMChargeRecover(GetPoMCooldownDuration())
    end
    NHA.ScheduleEvaluate()
end

-- Gen-counter timer arm: every arm bumps the generation so any in-flight
-- C_Timer callback from a prior arm becomes a no-op when it fires.
_ArmPoMChargeRecover = function(cdDur, readyAtOverride)
    state.pomChargeTimerSet = true
    state.pomChargeReadyAt  = readyAtOverride or (GetTime() + cdDur)
    state.pomChargeGen      = (state.pomChargeGen or 0) + 1
    local myGen = state.pomChargeGen
    C_Timer.After(cdDur, function()
        if state.pomChargeGen ~= myGen then return end
        _OnPoMChargeRecover()
    end)
end

local function TryInitPoMChargesFromAPI()
    if not (C_Spell and C_Spell.GetSpellCharges) then return end
    local ok, info = pcall(C_Spell.GetSpellCharges, POM_CAST_ID)
    if not ok or not info then return end

    if info.maxCharges ~= nil and not issecretvalue(info.maxCharges)
       and type(info.maxCharges) == "number" and info.maxCharges > 0 then
        state.pomMaxCharges = info.maxCharges
    end

    local maxC = state.pomMaxCharges or POM_DEFAULT_MAX_CHARGES

    if info.currentCharges ~= nil and not issecretvalue(info.currentCharges)
       and type(info.currentCharges) == "number" then
        state.pomCurCharges = 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 > 0 then
                if durT > 1.5 then state.pomKnownCD = durT end
                local remaining = (startT + durT) - GetTime()
                if remaining > 0 and not state.pomChargeTimerSet then
                    _ArmPoMChargeRecover(remaining)
                end
            end
        end
    else
        if state.pomCurCharges == nil then
            state.pomCurCharges = maxC
        end
    end
end

-- =============================================================
-- Holy Word: Serenity -- manual charge + CDR model
-- 2-charge spell (Miracle Worker). Count tracked by cast event, not read live
-- (currentCharges is secret in M+ combat; maxCharges is not -- wow-api-rules.md).
-- Filler casts chip the active recharge: Flash Heal -6s, PoM -4s, x3 under
-- Apotheosis. Rationale in wow-addon-learnings.md.
-- =============================================================
local function ApotheosisActive()
    -- Sustained Potency pauses the window OOC/CC (up to 20s). While paused, freeze
    -- the countdown by discounting the capped paused time from "now". Branch only
    -- engages when the talent set apoPausedAt, so the common path is unchanged.
    local pausedAt = state.apoPausedAt or 0
    if pausedAt > 0 then
        local inPause = GetTime() - pausedAt
        if inPause > SUSTAINED_POTENCY_PAUSE_CAP_SEC then inPause = SUSTAINED_POTENCY_PAUSE_CAP_SEC end
        return (GetTime() - inPause) < (state.apotheosisActiveUntil or 0)
    end
    return GetTime() < (state.apotheosisActiveUntil or 0)
end

local function _HWApiCompare(spellID, modelCur, modelRem)
    local out = ""
    if C_Spell and C_Spell.GetSpellCharges then
        local ok, info = pcall(C_Spell.GetSpellCharges, spellID)
        if ok and info then
            local c = info.currentCharges
            if c == nil or issecretvalue(c) or type(c) ~= "number" then
                out = " | API cur=<secret>"
            else
                local curStr = string_format("%d", c)
                if modelCur ~= nil and c ~= modelCur then
                    curStr = curStr .. string_format("(model %d)", modelCur)
                end
                out = " | API cur=" .. curStr
            end
        end
    end
    -- Real recharge truth: GetSpellCooldown reflects cross-spell CDR, but only
    -- while a charge is unavailable (reads 0 with one up) and above the GCD floor.
    if C_Spell and C_Spell.GetSpellCooldown then
        local ok, cd = pcall(C_Spell.GetSpellCooldown, spellID)
        if ok and cd then
            local s, d = cd.startTime, cd.duration
            if s ~= nil and d ~= nil and not issecretvalue(s) and not issecretvalue(d)
               and type(s) == "number" and type(d) == "number"
               and d >= POM_API_MIN_TRUSTED_DUR then
                local realRem = (s + d) - GetTime()
                if realRem < 0 then realRem = 0 end
                out = out .. string_format(" realCD=%.1fs |cff888888\206\148=%+.1fs|r",
                    realRem, (modelRem or 0) - realRem)
            end
        end
    end
    return out
end

local _ArmSerenityRecharge
local _OnSerenityRecharge

-- Gen-counter timer for the active charge recharge. A chip or cast re-arms and
-- bumps the gen, so any stale in-flight callback no-ops.
_ArmSerenityRecharge = function()
    state.serenityRechargeGen = (state.serenityRechargeGen or 0) + 1
    local myGen = state.serenityRechargeGen
    local delay = (state.serenityRechargeAt or 0) - GetTime()
    if delay < 0 then delay = 0 end
    C_Timer.After(delay, function()
        if state.serenityRechargeGen ~= myGen then return end
        _OnSerenityRecharge()
    end)
end

local function _HWBaseCD()
    return HW_BASE_CD_SEC - (state.hasProphetsInsight and PROPHETS_INSIGHT_REDUCE or 0)
end

_OnSerenityRecharge = function()
    local max = state.serenityMaxCharges or HW_DEFAULT_MAX_CHGS
    local cur = state.serenityCurCharges or 0
    if cur < max then cur = cur + 1 end
    state.serenityCurCharges = cur
    if cur < max then
        state.serenityRechargeAt = GetTime() + _HWBaseCD()
        _ArmSerenityRecharge()
    else
        state.serenityRechargeAt = 0
    end
    if state.hwLogEnabled then
        local nowR = GetTime()
        local modelRem = (state.serenityRechargeAt and state.serenityRechargeAt > 0)
            and (state.serenityRechargeAt - nowR) or 0
        local suppRem = (state.serenitySuppressUntil or 0) - nowR
        NHA.Msg(string_format("|cffffd700Serenity:|r +1 charge -> %d/%d supp=%.1fs%s", cur, max, suppRem,
            _HWApiCompare(HW_SERENITY_ID, cur, modelRem)))
    end
    NHA.ScheduleEvaluate()
end

-- Filler-cast CDR. Chips the active recharge only (no effect at max charges);
-- x3 while Apotheosis is up. Floors at "ready now".
local function ChipSerenityRecharge(baseReduce)
    local max = state.serenityMaxCharges or HW_DEFAULT_MAX_CHGS
    if (state.serenityCurCharges or 0) >= max then return end
    if not (state.serenityRechargeAt and state.serenityRechargeAt > 0) then return end
    local apo    = ApotheosisActive()
    local reduce = baseReduce * (1 + LIGHT_OF_NAARU_PER_RANK * (state.lightOfNaaruRank or 0))
    if apo then reduce = reduce * APOTHEOSIS_CDR_MULT end
    local now    = GetTime()
    local newAt  = state.serenityRechargeAt - reduce
    while newAt <= now and (state.serenityCurCharges or 0) < max do
        local excess = now - newAt
        state.serenityCurCharges = (state.serenityCurCharges or 0) + 1
        if (state.serenityCurCharges or 0) >= max then
            newAt = 0
        else
            newAt = now + _HWBaseCD() - excess
        end
    end
    state.serenityRechargeAt = newAt
    state.serenitySuppressUntil = (state.serenitySuppressUntil or 0) - reduce
    if state.serenitySuppressUntil < now then state.serenitySuppressUntil = now end
    if newAt > 0 then
        _ArmSerenityRecharge()
    else
        state.serenityRechargeGen = (state.serenityRechargeGen or 0) + 1
    end
    if state.hwLogEnabled then
        local modelRem = (newAt > 0) and (newAt - now) or 0
        NHA.Msg(string_format("|cffffd700Serenity:|r -%.0fs%s -> ready in %.1fs (%d/%d)%s",
            reduce, apo and " (Apo x3)" or "", modelRem,
            state.serenityCurCharges or 0, max,
            _HWApiCompare(HW_SERENITY_ID, state.serenityCurCharges or 0, modelRem)))
    end
    NHA.ScheduleEvaluate()
end

-- Opportunistic API sync. maxCharges always readable; currentCharges only when
-- not secret (out of M+ combat). issecretvalue-guarded -> no-ops under taint, so
-- in-combat readiness stays manual. Corrects drift at combat boundaries.
local function TryInitSerenityChargesFromAPI()
    if not (C_Spell and C_Spell.GetSpellCharges) then return end
    local ok, info = pcall(C_Spell.GetSpellCharges, HW_SERENITY_ID)
    if not ok or not info then return end

    if info.maxCharges ~= nil and not issecretvalue(info.maxCharges)
       and type(info.maxCharges) == "number" then
        state.serenityMaxCharges = info.maxCharges
    end
    local max = state.serenityMaxCharges or HW_DEFAULT_MAX_CHGS
    if max < 1 then return end

    if info.currentCharges == nil or issecretvalue(info.currentCharges)
       or type(info.currentCharges) ~= "number" then
        if state.serenityCurCharges == nil then state.serenityCurCharges = max end
        return
    end
    state.serenityCurCharges = info.currentCharges

    if info.currentCharges < max
       and info.cooldownStart    ~= nil and not issecretvalue(info.cooldownStart)
       and info.cooldownDuration ~= nil and not issecretvalue(info.cooldownDuration)
       and type(info.cooldownStart) == "number" and type(info.cooldownDuration) == "number"
       and info.cooldownStart > 0 and info.cooldownDuration > 0 then
        state.serenityRechargeAt = info.cooldownStart + info.cooldownDuration
        _ArmSerenityRecharge()
    elseif info.currentCharges >= max then
        state.serenityRechargeAt    = 0
        state.serenityRechargeGen   = (state.serenityRechargeGen or 0) + 1
        state.serenitySuppressUntil = 0
    end
end

-- =============================================================
-- Holy Word: Sanctify -- manual charge + CDR model (twin of Serenity)
-- 2-charge spell (Miracle Worker). Count tracked by cast event, not read live
-- (currentCharges is secret in M+ combat; maxCharges is not -- wow-api-rules.md).
-- Filler casts chip the active recharge: Prayer of Healing -6s, x3 under
-- Apotheosis. Ultimate Serenity replaces Sanctify -> maxCharges reads 0; the
-- max<1 guard zeroes charges so the (cur>=1) gate stays silent.
-- =============================================================
local _ArmSanctifyRecharge
local _OnSanctifyRecharge

_ArmSanctifyRecharge = function()
    state.sanctifyRechargeGen = (state.sanctifyRechargeGen or 0) + 1
    local myGen = state.sanctifyRechargeGen
    local delay = (state.sanctifyRechargeAt or 0) - GetTime()
    if delay < 0 then delay = 0 end
    C_Timer.After(delay, function()
        if state.sanctifyRechargeGen ~= myGen then return end
        _OnSanctifyRecharge()
    end)
end

_OnSanctifyRecharge = function()
    local max = state.sanctifyMaxCharges or HW_DEFAULT_MAX_CHGS
    local cur = state.sanctifyCurCharges or 0
    if cur < max then cur = cur + 1 end
    state.sanctifyCurCharges = cur
    if cur < max then
        state.sanctifyRechargeAt = GetTime() + _HWBaseCD()
        _ArmSanctifyRecharge()
    else
        state.sanctifyRechargeAt = 0
    end
    if state.hwLogEnabled then
        local nowR = GetTime()
        local modelRem = (state.sanctifyRechargeAt and state.sanctifyRechargeAt > 0)
            and (state.sanctifyRechargeAt - nowR) or 0
        local suppRem = (state.sanctifySuppressUntil or 0) - nowR
        NHA.Msg(string_format("|cffffd700Sanctify:|r +1 charge -> %d/%d supp=%.1fs%s", cur, max, suppRem,
            _HWApiCompare(HW_SANCTIFY_ID, cur, modelRem)))
    end
    NHA.ScheduleEvaluate()
end

-- GetSpellCooldown read in the cast frame is stale (the game applies Holy Word CDR
-- on the next server tick), so the inline realCD after a chip lies. Re-read 0.12s
-- later for ground truth. Only meaningful at 0 charges (cooldown active); hwlog only.
local function _DeferSanctifyDelta()
    if not state.hwLogEnabled then return end
    if (state.sanctifyCurCharges or 0) ~= 0 then return end
    if not (C_Spell and C_Spell.GetSpellCooldown) then return end
    C_Timer.After(0.12, function()
        if not state.hwLogEnabled then return end
        local ok, cd = pcall(C_Spell.GetSpellCooldown, HW_SANCTIFY_ID)
        if not (ok and cd) then return end
        local s, d = cd.startTime, cd.duration
        if s == nil or d == nil or issecretvalue(s) or issecretvalue(d)
           or type(s) ~= "number" or type(d) ~= "number"
           or d < POM_API_MIN_TRUSTED_DUR then return end
        local realRem = (s + d) - GetTime()
        if realRem < 0 then realRem = 0 end
        local modelRem = (state.sanctifyRechargeAt and state.sanctifyRechargeAt > 0)
            and (state.sanctifyRechargeAt - GetTime()) or 0
        NHA.Msg(string_format(
            "|cff66ccffsettle|r Sanctify model %.1fs vs real %.1fs |cff888888\206\148=%+.1fs|r",
            modelRem, realRem, modelRem - realRem))
    end)
end

local function ChipSanctifyRecharge(baseReduce)
    local max = state.sanctifyMaxCharges or HW_DEFAULT_MAX_CHGS
    if (state.sanctifyCurCharges or 0) >= max then return end
    if not (state.sanctifyRechargeAt and state.sanctifyRechargeAt > 0) then return end
    local apo    = ApotheosisActive()
    local reduce = baseReduce * (1 + LIGHT_OF_NAARU_PER_RANK * (state.lightOfNaaruRank or 0))
    if apo then reduce = reduce * APOTHEOSIS_CDR_MULT end
    local now    = GetTime()
    local newAt  = state.sanctifyRechargeAt - reduce
    while newAt <= now and (state.sanctifyCurCharges or 0) < max do
        local excess = now - newAt
        state.sanctifyCurCharges = (state.sanctifyCurCharges or 0) + 1
        if (state.sanctifyCurCharges or 0) >= max then
            newAt = 0
        else
            newAt = now + _HWBaseCD() - excess
        end
    end
    state.sanctifyRechargeAt = newAt
    state.sanctifySuppressUntil = (state.sanctifySuppressUntil or 0) - reduce
    if state.sanctifySuppressUntil < now then state.sanctifySuppressUntil = now end
    if newAt > 0 then
        _ArmSanctifyRecharge()
    else
        state.sanctifyRechargeGen = (state.sanctifyRechargeGen or 0) + 1
    end
    if state.hwLogEnabled then
        local modelRem = (newAt > 0) and (newAt - now) or 0
        NHA.Msg(string_format("|cffffd700Sanctify:|r -%.0fs%s -> ready in %.1fs (%d/%d)%s",
            reduce, apo and " (Apo x3)" or "", modelRem,
            state.sanctifyCurCharges or 0, max,
            _HWApiCompare(HW_SANCTIFY_ID, state.sanctifyCurCharges or 0, modelRem)))
    end
    _DeferSanctifyDelta()
    NHA.ScheduleEvaluate()
end

-- Ultimate Serenity (class tree) replaces Holy Word: Sanctify and makes every talent
-- that would reduce Sanctify reduce Serenity instead. These three helpers are the one
-- place that redirect knows about: route the chip, name the target, and gate on the
-- surviving Holy Word's enable toggle. Without the talent, behaviour is unchanged.
local function _SanCDRName()
    return state.hasUltimateSerenity and "Serenity(US)" or "Sanctify"
end

local function _SanctifyCDREnabled(db)
    if not db then return false end
    if state.hasUltimateSerenity then return not not db.serenityEnabled end
    return not not db.sanctifyEnabled
end

local function ChipSanctifyCDR(amount)
    if state.hasUltimateSerenity then
        ChipSerenityRecharge(amount)
    else
        ChipSanctifyRecharge(amount)
    end
end

local function _GrantSerenityCharge()
    local max = state.serenityMaxCharges or HW_DEFAULT_MAX_CHGS
    local cur = state.serenityCurCharges or 0
    if cur >= max then return end
    cur = cur + 1
    state.serenityCurCharges = cur
    if cur >= max then
        state.serenityRechargeAt    = 0
        state.serenityRechargeGen   = (state.serenityRechargeGen or 0) + 1
        state.serenitySuppressUntil = 0
    end
    if state.hwLogEnabled then
        local modelRem = (state.serenityRechargeAt and state.serenityRechargeAt > 0)
            and (state.serenityRechargeAt - GetTime()) or 0
        NHA.Msg(string_format("|cffffd700Serenity:|r Apotheosis +1 charge -> %d/%d%s", cur, max,
            _HWApiCompare(HW_SERENITY_ID, cur, modelRem)))
    end
    NHA.ScheduleEvaluate()
end

local function _GrantSanctifyCharge()
    local max = state.sanctifyMaxCharges or HW_DEFAULT_MAX_CHGS
    local cur = state.sanctifyCurCharges or 0
    if cur >= max then return end
    cur = cur + 1
    state.sanctifyCurCharges = cur
    if cur >= max then
        state.sanctifyRechargeAt    = 0
        state.sanctifyRechargeGen   = (state.sanctifyRechargeGen or 0) + 1
        state.sanctifySuppressUntil = 0
    end
    if state.hwLogEnabled then
        local modelRem = (state.sanctifyRechargeAt and state.sanctifyRechargeAt > 0)
            and (state.sanctifyRechargeAt - GetTime()) or 0
        NHA.Msg(string_format("|cffffd700Sanctify:|r Apotheosis +1 charge -> %d/%d%s", cur, max,
            _HWApiCompare(HW_SANCTIFY_ID, cur, modelRem)))
    end
    NHA.ScheduleEvaluate()
end

local function TryInitSanctifyChargesFromAPI()
    if not (C_Spell and C_Spell.GetSpellCharges) then return end
    local ok, info = pcall(C_Spell.GetSpellCharges, HW_SANCTIFY_ID)
    if not ok or not info then return end

    if info.maxCharges ~= nil and not issecretvalue(info.maxCharges)
       and type(info.maxCharges) == "number" then
        state.sanctifyMaxCharges = info.maxCharges
    end
    local max = state.sanctifyMaxCharges or HW_DEFAULT_MAX_CHGS
    if max < 1 then                       -- Ultimate Serenity: Sanctify replaced
        state.sanctifyCurCharges    = 0
        state.sanctifyRechargeAt    = 0
        state.sanctifyRechargeGen   = (state.sanctifyRechargeGen or 0) + 1
        state.sanctifySuppressUntil = 0
        return
    end

    if info.currentCharges == nil or issecretvalue(info.currentCharges)
       or type(info.currentCharges) ~= "number" then
        if state.sanctifyCurCharges == nil then state.sanctifyCurCharges = max end
        return
    end
    state.sanctifyCurCharges = info.currentCharges

    if info.currentCharges < max
       and info.cooldownStart    ~= nil and not issecretvalue(info.cooldownStart)
       and info.cooldownDuration ~= nil and not issecretvalue(info.cooldownDuration)
       and type(info.cooldownStart) == "number" and type(info.cooldownDuration) == "number"
       and info.cooldownStart > 0 and info.cooldownDuration > 0 then
        state.sanctifyRechargeAt = info.cooldownStart + info.cooldownDuration
        _ArmSanctifyRecharge()
    elseif info.currentCharges >= max then
        state.sanctifyRechargeAt    = 0
        state.sanctifyRechargeGen   = (state.sanctifyRechargeGen or 0) + 1
        state.sanctifySuppressUntil = 0
    end
end

-- =============================================================
-- Module State
-- =============================================================
state = {
    pomActive            = false,
    pomWasActive         = false,
    pomCurCharges        = POM_DEFAULT_MAX_CHARGES,
    pomMaxCharges        = POM_DEFAULT_MAX_CHARGES,
    pomChargeTimerSet    = false,
    pomChargeReadyAt     = 0,
    pomChargeGen         = 0,    -- bumped on each arm to invalidate stale C_Timer closures
    pomLastCastAt        = 0,    -- timestamp of last successful POM cast
    pomSuppressUntil     = 0,    -- hard alert gate: forced off while GetTime() < this
    pomKnownCD           = 0,
    pomCachedCount       = 0,
    hasWasteNoTimeTalent = false,
    hasProphetsInsight   = false,   -- Oracle hero: flat -5s Holy Word base recharge (45 -> 40)
    isArchon             = false,   -- active hero spec == Archon (gates Halo/Energy Cycle CDR)
    hasBenedictionTalent = false,   -- apex talent (gates Benediction -> Serenity CDR)
    hasEternalSanctityTalent = false,   -- +12s Apotheosis duration when talented
    hasPowerSurge        = false,   -- Archon: Halo cast spawns auto-Halos (Sanctify CDR)
    hasEnergyConservation = false,  -- Archon: +5s Power Surge window
    hasSpiritWell        = false,   -- Archon choice: PoH consumes Surge of Light (gates PoH Energy Cycle -4s)
    hasSustainedPotency  = false,   -- Archon choice: Halo extends Apotheosis; banks/pauses the window
    hasUltimateSerenity  = false,   -- class tree: replaces Sanctify; Sanctify CDR reroutes onto Serenity
    hasVoiceOfHarmony    = false,   -- class tree: gates PoM -4s Serenity + Halo -4s Sanctify
    lightOfNaaruRank     = 0,       -- class tree (0-2): +10% Holy Word CDR per rank
    apoStoredSec         = 0,       -- Sustained Potency: banked Apotheosis extension while Apo is down (cap 6s)
    apoPausedAt          = 0,       -- Sustained Potency: GetTime() when Apo paused OOC/CC; 0 = not paused
    lastKnownHastePct    = 0,
    playerAuraPending    = false,
    rosterUpdatePending  = false,
    -- /nha pomlog cast-gap measurement tool
    pomLogEnabled        = false,
    pomLogLastCastAt     = 0,
    pomLogLastPredicted  = 0,
    serenityActive           = false,
    serenityWasReady         = false,
    serenityCurCharges       = HW_DEFAULT_MAX_CHGS,
    serenityMaxCharges       = HW_DEFAULT_MAX_CHGS,
    serenityRechargeAt       = 0,
    serenityRechargeGen      = 0,
    serenityLastCastAt       = 0,
    serenitySuppressUntil    = 0,    -- POM-style alert gate: forced off while GetTime() < this
    sanctifyActive           = false,
    sanctifyWasReady         = false,
    sanctifyCurCharges       = HW_DEFAULT_MAX_CHGS,
    sanctifyMaxCharges       = HW_DEFAULT_MAX_CHGS,
    sanctifyRechargeAt       = 0,
    sanctifyRechargeGen      = 0,
    sanctifyLastCastAt       = 0,
    sanctifySuppressUntil    = 0,    -- POM-style alert gate: forced off while GetTime() < this
    apotheosisActiveUntil    = 0,    -- x3 HW CDR window; cast-set, active while GetTime() < this
    -- /nha hwlog toggles cast/chip/recharge + cast-gap logging for both Holy Words
    hwLogEnabled             = false,
    serenityLogLastCastAt    = 0,
    serenityLogLastPredicted = 0,
    sanctifyLogLastCastAt    = 0,
    sanctifyLogLastPredicted = 0,
    -- /nha sanlog: out-of-combat Sanctify cooldown-drop watcher
    sanLogEnabled            = false,
    sanLogLastAt             = 0,
    sanLogLastCastAt         = 0,
}

-- =============================================================
-- Sanctify CDR diagnostic (/nha sanlog): out-of-combat watcher that flags every
-- Holy Word: Sanctify cooldown drop and whether a cast explains it.
-- =============================================================
local function _SanctifyCDRead()
    if not (C_Spell and C_Spell.GetSpellCooldown) then return nil end
    local ok, info = pcall(C_Spell.GetSpellCooldown, HW_SANCTIFY_ID)
    if not ok or not info then return nil end
    local s, d = info.startTime, info.duration
    if s == nil or d == nil or issecretvalue(s) or issecretvalue(d)
       or type(s) ~= "number" or type(d) ~= "number" then return nil end
    if d <= 0 then return 0 end
    if d < POM_API_MIN_TRUSTED_DUR then return nil end
    local rem = (s + d) - GetTime()
    if rem < 0 then rem = 0 end
    return rem
end

local function _SanLogTick()
    if not state.sanLogEnabled then
        if state.sanLogTicker then state.sanLogTicker:Cancel(); state.sanLogTicker = nil end
        return
    end
    local now = GetTime()
    local rem = _SanctifyCDRead()
    if rem == nil then return end
    local lastRem, lastAt = state.sanLogLastRem, state.sanLogLastAt
    state.sanLogLastRem = rem
    state.sanLogLastAt  = now
    if lastRem == nil then return end
    local jump = (lastRem - (now - lastAt)) - rem
    if jump > 0.5 then
        local sinceCast = now - (state.sanLogLastCastAt or 0)
        local attrib
        if state.sanLogLastCastId and sinceCast <= 0.7 then
            local nm = (C_Spell.GetSpellName and C_Spell.GetSpellName(state.sanLogLastCastId))
                       or tostring(state.sanLogLastCastId)
            attrib = string_format("after %s (%.2fs)", nm, sinceCast)
        else
            attrib = "|cffff4040UNATTRIBUTED -- possible proc/talent|r"
        end
        NHA.Msg(string_format("|cff66ccffsanlog|r Sanctify -%.1fs -> %.1fs left  %s", jump, rem, attrib))
    end
end

-- Sustained Potency (Archon): every created Halo (manual or Power Surge auto-Halo)
-- extends an active Apotheosis by 1s; if Apotheosis is down the second is banked
-- (cap 6s) and applied to the next Apotheosis cast.
local function _SustainedPotencyOnHalo()
    if not state.hasSustainedPotency then return end
    if ApotheosisActive() then
        state.apotheosisActiveUntil = (state.apotheosisActiveUntil or 0) + SUSTAINED_POTENCY_EXTEND_SEC
    else
        local banked = (state.apoStoredSec or 0) + SUSTAINED_POTENCY_EXTEND_SEC
        if banked > SUSTAINED_POTENCY_STORE_CAP_SEC then banked = SUSTAINED_POTENCY_STORE_CAP_SEC end
        state.apoStoredSec = banked
    end
end

-- Power Surge (Archon): a manual Halo cast spawns an auto-Halo every 5s for the
-- window; each auto-Halo chips Sanctify (x3 under Apotheosis at fire time) and
-- emits no cast event, so it is modeled on a cast-started timer.
local function _StartPowerSurgeHalos(db)
    if not state.hasPowerSurge then return end
    if state.powerSurgeTicker then state.powerSurgeTicker:Cancel(); state.powerSurgeTicker = nil end
    local dur = POWER_SURGE_BASE_SEC + (state.hasEnergyConservation and ENERGY_CONSERVATION_BONUS_SEC or 0)
    local n = math.floor(dur / POWER_SURGE_HALO_INTERVAL) - 1
    if n < 1 then return end
    state.powerSurgeTicker = C_Timer.NewTicker(POWER_SURGE_HALO_INTERVAL, function()
        if not _SanctifyCDREnabled(db) then return end
        if state.hasVoiceOfHarmony then
            if state.hwLogEnabled then
                NHA.Msg(string_format("|cffffd700%s:|r Power Surge auto-Halo -%.0fs", _SanCDRName(), SAN_CDR_HALO))
            end
            ChipSanctifyCDR(SAN_CDR_HALO)
        end
        _SustainedPotencyOnHalo()
    end, n)
end

-- =============================================================
-- Prayer of Mending Presence Cache
-- =============================================================
local pomPresence = {}

local function RebuildPoMPresenceCache()
    wipe(pomPresence)
    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, POM_ID, true) then
                pomPresence[unit] = true
                count = count + 1
            end
            if canDedup then seen[guid] = true end
        end
    end
    state.pomCachedCount = count
end

local function UpdatePoMPresenceForUnit(unit, updateInfo)
    local had = pomPresence[unit]

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

    local touchedPoM = false

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

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

    if not touchedPoM then return end

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

-- =============================================================
-- CUF Glow State
-- =============================================================
local cufRenewCache  = {}
local cufPoMCache    = {}
local cufStateMap    = {}
local dirtyCUFFrames = {}
local drainScheduled = false

-- =============================================================
-- Alert Definitions
-- =============================================================
local POM_Y      =  50    -- Prayer of Mending Ready
local SERENITY_Y =  25    -- Holy Word: Serenity Ready
local SANCTIFY_Y =   0    -- Holy Word: Sanctify Ready

local ALERTS = {
    {
        key          = "pom",
        label        = "Prayer of Mending Ready",
        text         = "* PRAYER OF MENDING *",
        defaultColor = { r = 1.000, g = 0.961, b = 0.000 },  -- #FFF500
        hasTimer     = false,
        yOffset      = POM_Y,
        defaultSound = "Priest: Prayer of Mending",
        priority     = NHA.VOICE_PRIO_HIGH,
    },
    {
        key          = "serenity",
        label        = "Holy Word: Serenity Ready",
        text         = "* SERENITY *",
        defaultColor = { r = 0.000, g = 1.000, b = 0.878 },  -- #00FFE0
        hasTimer     = false,
        yOffset      = SERENITY_Y,
        defaultSound = "Priest: Serenity",
        priority     = NHA.VOICE_PRIO_NORMAL,
    },
    {
        key          = "sanctify",
        label        = "Holy Word: Sanctify Ready",
        text         = "* SANCTIFY *",
        defaultColor = { r = 1.000, g = 0.600, b = 0.000 },  -- #FF9900
        hasTimer     = false,
        yOffset      = SANCTIFY_Y,
        defaultSound = "Priest: Sanctify",
        priority     = NHA.VOICE_PRIO_NORMAL,
    },
}

-- =============================================================
-- Saved-Variable Defaults
-- =============================================================
local DEFAULTS = {
    pomEnabled            = true,
    pomSoundEnabled       = true,
    pomSoundName          = "Priest: Prayer of Mending",
    serenityEnabled       = true,
    serenitySoundEnabled  = true,
    serenitySoundName     = "Priest: Serenity",
    sanctifyEnabled       = true,
    sanctifySoundEnabled  = true,
    sanctifySoundName     = "Priest: Sanctify",
    alertColors           = {
        pom      = { r = 1.000, g = 0.961, b = 0.000 },  -- #FFF500
        renew    = { r = 0.550, g = 0.950, b = 0.550 },  -- light green (CUF glow only)
        serenity = { r = 0.000, g = 1.000, b = 0.878 },  -- #00FFE0
        sanctify = { r = 1.000, g = 0.600, b = 0.000 },  -- #FF9900
    },
}

-- =============================================================
-- Module Table
-- =============================================================
local PriestHoly = {
    class     = "PRIEST",
    specIndex = 2,
    specID    = 257,
    specName  = "Holy",
    alerts    = ALERTS,
    defaults  = DEFAULTS,
    state     = state,

    extraEvents = {
        "UNIT_AURA",
        "UNIT_SPELLCAST_START",
        "UNIT_SPELLCAST_SUCCEEDED",
        "SPELL_UPDATE_CHARGES",
        "SPELLS_CHANGED",
    },

    cufRelevantSpells = {
        [139]   = true,   -- SpellID Renew
        [41635] = true,   -- SpellID Prayer of Mending
    },
}

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

-- =============================================================
-- ClearAllState
-- =============================================================
function PriestHoly:ClearAllState()
    state.pomActive           = false
    state.pomWasActive        = false
    state.pomCurCharges       = state.pomMaxCharges or POM_DEFAULT_MAX_CHARGES
    state.pomChargeTimerSet   = false
    state.pomChargeReadyAt    = 0
    state.pomChargeGen        = (state.pomChargeGen or 0) + 1   -- invalidate zombie timers
    state.pomLastCastAt       = 0
    state.pomSuppressUntil    = 0
    state.pomKnownCD          = 0
    state.pomCachedCount      = 0
    state.playerAuraPending   = false
    state.rosterUpdatePending = false
    state.serenityActive         = false
    state.serenityWasReady       = false
    state.serenityCurCharges     = state.serenityMaxCharges or HW_DEFAULT_MAX_CHGS
    state.serenityRechargeAt     = 0
    state.serenityRechargeGen    = (state.serenityRechargeGen or 0) + 1
    state.serenityLastCastAt     = 0
    state.serenitySuppressUntil  = 0
    state.sanctifyActive         = false
    state.sanctifyWasReady       = false
    state.sanctifyCurCharges     = state.sanctifyMaxCharges or HW_DEFAULT_MAX_CHGS
    state.sanctifyRechargeAt     = 0
    state.sanctifyRechargeGen    = (state.sanctifyRechargeGen or 0) + 1
    state.sanctifyLastCastAt     = 0
    state.sanctifySuppressUntil  = 0
    state.apotheosisActiveUntil  = 0
    state.apoStoredSec           = 0
    state.apoPausedAt            = 0
    if state.powerSurgeTicker then state.powerSurgeTicker:Cancel(); state.powerSurgeTicker = nil end
    wipe(pomPresence)
    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
-- Renew has priority over Prayer of Mending.
-- =============================================================
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
        cufRenewCache[frame] = nil
        cufPoMCache[frame]   = nil
        _perfCUFLastPath = "disabled"
        return
    end

    local renewAura = FindAuraOnUnit(unit, RENEW_ID, true)
    if renewAura then
        local sa = cufRenewCache[frame]
        if not sa then sa = {}; cufRenewCache[frame] = sa end
        sa.auraInstanceID = renewAura.auraInstanceID
        sa.expirationTime = SafeNum(renewAura.expirationTime, 0)
        sa.duration       = SafeNum(renewAura.duration, 0)
        sa.timeMod        = SafeNum(renewAura.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.renew
                      or { r = 0.55, g = 0.95, b = 0.55 }
            NHA:ShowFrameGlow(frame, c.r, c.g, c.b)
            cufStateMap[frame] = "renew"
            cufPoMCache[frame] = nil
            _perfCUFLastPath = "renew"
            return
        end
        cufRenewCache[frame] = nil
    else
        cufRenewCache[frame] = nil
    end

    local pomAura = FindAuraOnUnit(unit, POM_ID, true)
    if pomAura then
        local sa = cufPoMCache[frame]
        if not sa then sa = {}; cufPoMCache[frame] = sa end
        sa.auraInstanceID = pomAura.auraInstanceID
        sa.expirationTime = SafeNum(pomAura.expirationTime, 0)
        sa.duration       = SafeNum(pomAura.duration, 0)
        sa.timeMod        = SafeNum(pomAura.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.pom
                      or { r = 1.0, g = 0.835, b = 0.0 }
            NHA:ShowFrameGlow(frame, c.r, c.g, c.b)
            cufStateMap[frame] = "pom"
            _perfCUFLastPath = "pom"
            return
        end
        cufPoMCache[frame] = nil
    else
        cufPoMCache[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 PriestHoly: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
        cufRenewCache[frame]  = nil
        cufPoMCache[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 PriestHoly:RefreshGlows(db)
end

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

    local now      = GetTime()
    PerfMarkBlock("CDREAD")

    -- Rolling-tracker gate: ready when any charge is available AND the
    -- cast-suppression window has expired. Cast handler sets pomSuppressUntil
    -- and SPELL_UPDATE_CHARGES refuses to raise charges inside that window,
    -- so the API can never false-positive us back into ready mid-CD.
    local pomReady = false
    if db.pomEnabled then
        local suppressed = (state.pomSuppressUntil or 0) > now
        local cur = state.pomCurCharges or 0
        pomReady = (cur >= 1) and (not suppressed)
    end
    state.pomActive = pomReady
    PerfMarkBlock("POM")

    -- Holy Words, manual model + POM-style gate: ready when a charge is up AND the
    -- post-cast suppression window has expired (reset on every cast, chipped by CDR).
    local serenityReady = false
    if db.serenityEnabled then
        local serSuppressed = (state.serenitySuppressUntil or 0) > now
        serenityReady = ((state.serenityCurCharges or 0) >= 1) and (not serSuppressed)
    end
    state.serenityActive = serenityReady

    local sanctifyReady = false
    if db.sanctifyEnabled and not state.hasUltimateSerenity then
        local sanSuppressed = (state.sanctifySuppressUntil or 0) > now
        sanctifyReady = ((state.sanctifyCurCharges or 0) >= 1) and (not sanSuppressed)
    end
    state.sanctifyActive = sanctifyReady
    PerfMarkBlock("HW")

    if pomReady and not state.pomWasActive then NHA:PlayAlertSound("pom") end
    if serenityReady and not state.serenityWasReady then NHA:PlayAlertSound("serenity") end
    if sanctifyReady and not state.sanctifyWasReady then NHA:PlayAlertSound("sanctify") end

    state.pomWasActive     = pomReady
    state.serenityWasReady = serenityReady
    state.sanctifyWasReady = sanctifyReady
    PerfMarkBlock("END")
end

-- =============================================================
-- OnActivate
-- =============================================================
function PriestHoly:OnActivate(db)
    TryInitPoMChargesFromAPI()
    TryInitSerenityChargesFromAPI()
    TryInitSanctifyChargesFromAPI()
    if NHA.RefreshAllCUFs then
        C_Timer.After(0, function()
            NHA:RefreshAllCUFs()
        end)
    end
end

-- =============================================================
-- Talent probe
-- =============================================================
local function _ProbeTalents()
    return {
        hasWasteNoTimeTalent     = CheckWasteNoTimeTalent(),
        hasProphetsInsight       = CheckProphetsInsightTalent(),
        isArchon                 = IsArchonActive(),
        hasBenedictionTalent     = CheckBenedictionTalent(),
        hasEternalSanctityTalent = CheckEternalSanctityTalent(),
        hasPowerSurge            = CheckPowerSurgeTalent(),
        hasEnergyConservation    = CheckEnergyConservationTalent(),
        hasSpiritWell            = CheckSpiritWellTalent(),
        hasSustainedPotency      = CheckSustainedPotencyTalent(),
        hasUltimateSerenity      = CheckUltimateSerenityTalent(),
        hasVoiceOfHarmony        = CheckVoiceOfHarmonyTalent(),
        lightOfNaaruRank         = CheckLightOfNaaruRank(),
    }
end

local function _ApplyTalents(t)
    for k, v in pairs(t) do state[k] = v end
end

-- =============================================================
-- Instant-cast detection (cast-time read)
-- =============================================================
local function _CastTimeMs(spellID)
    if not (spellID and C_Spell and C_Spell.GetSpellInfo) then return nil end
    local info = C_Spell.GetSpellInfo(spellID)
    if not info then return nil end
    local ct = info.castTime
    if ct == nil or issecretvalue(ct) then return nil end
    return ct
end

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

        if unit == "player" or UnitInParty(unit) or UnitInRaid(unit) then
            UpdatePoMPresenceForUnit(unit, updateInfo)
        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 == "UNIT_SPELLCAST_START" then
        local unit, castGUID, spellID = ...
        if unit == "player" and state.hwLogEnabled then
            NHA.Msg(string_format("|cff66ccffSTART|r spell=%s guid=%s",
                tostring(spellID), tostring(castGUID)))
        end
        return true
    end

    if event == "UNIT_SPELLCAST_SUCCEEDED" then
        local unit, castGUID, spellID = ...
        if unit == "player" then
            if state.sanLogEnabled then
                state.sanLogLastCastId = spellID
                state.sanLogLastCastAt = GetTime()
            end
            if spellID == FLASH_HEAL_ID or spellID == BENEDICTION_ID
               or spellID == PRAYER_OF_HEALING_ID then
                local ct = _CastTimeMs(spellID)
                local instant = (ct == 0)
                if state.hwLogEnabled then
                    NHA.Msg(string_format(
                        "|cff66ccffSUCCEEDED|r spell=%s ct=%s -> %s",
                        tostring(spellID), ct and tostring(ct) or "?",
                        instant and "INSTANT" or "hard"))
                end
                -- PoH only consumes Surge of Light (and so triggers Energy Cycle -4s)
                -- when Spirit Well is talented; the other half of that choice node
                -- leaves PoH unable to consume, so the -4s must not apply there.
                local surgeEligible = (spellID ~= PRAYER_OF_HEALING_ID) or state.hasSpiritWell
                if instant and surgeEligible and state.isArchon and _SanctifyCDREnabled(db) then
                    if state.hwLogEnabled then
                        NHA.Msg(string_format(
                            "|cffffd700%s:|r Energy Cycle -%.0fs (Surge consume: instant cast)",
                            _SanCDRName(), SAN_CDR_ENERGY_CYCLE))
                    end
                    ChipSanctifyCDR(SAN_CDR_ENERGY_CYCLE)
                end
            end

            if spellID == APOTHEOSIS_ID then
                local apoDur = APOTHEOSIS_DURATION_SEC
                if state.hasEternalSanctityTalent then
                    apoDur = apoDur + ETERNAL_SANCTITY_BONUS_SEC
                end
                if state.hasSustainedPotency and (state.apoStoredSec or 0) > 0 then
                    apoDur = apoDur + state.apoStoredSec
                end
                state.apoStoredSec = 0
                state.apoPausedAt  = 0
                state.apotheosisActiveUntil = GetTime() + apoDur
                if state.hwLogEnabled then
                    NHA.Msg(string_format("|cffffd700Apotheosis:|r window open -> x%.0f HW CDR for %.0fs",
                        APOTHEOSIS_CDR_MULT, apoDur))
                end
                if db.serenityEnabled then _GrantSerenityCharge() end
                if db.sanctifyEnabled then _GrantSanctifyCharge() end
            end

            -- Holy Word: Serenity CDR -- filler casts chip the active recharge.
            if db.serenityEnabled then
                if spellID == FLASH_HEAL_ID then
                    ChipSerenityRecharge(SER_CDR_FLASH_HEAL)
                elseif spellID == BENEDICTION_ID and state.hasBenedictionTalent then
                    -- Benediction (apex talent) is Flash Heal upgraded; fires under its own ID.
                    if state.hwLogEnabled then
                        NHA.Msg(string_format(
                            "|cffffd700Serenity:|r Benediction (Flash Heal upgrade) -%.0fs",
                            SER_CDR_FLASH_HEAL))
                    end
                    ChipSerenityRecharge(SER_CDR_FLASH_HEAL)
                elseif spellID == POM_CAST_ID and state.hasVoiceOfHarmony then
                    ChipSerenityRecharge(SER_CDR_POM)
                end
            end

            -- Holy Word: Sanctify CDR (Prayer of Healing -6s, Archon Halo -4s; Energy
            -- Cycle handled above). Ultimate Serenity reroutes all of it onto Serenity.
            if _SanctifyCDREnabled(db) then
                if spellID == PRAYER_OF_HEALING_ID then
                    ChipSanctifyCDR(SAN_CDR_PRAYER_OF_HEALING)
                elseif spellID == HALO_ID and state.isArchon then
                    if state.hasVoiceOfHarmony then
                        if state.hwLogEnabled then
                            NHA.Msg(string_format("|cffffd700%s:|r Halo created -%.0fs", _SanCDRName(), SAN_CDR_HALO))
                        end
                        ChipSanctifyCDR(SAN_CDR_HALO)
                    end
                    _SustainedPotencyOnHalo()
                    _StartPowerSurgeHalos(db)
                end
            end

            local ok, match = pcall(_spellIdEquals, spellID, POM_CAST_ID)
            if ok and match then
                if C_Spell and C_Spell.GetSpellCharges then
                    local apiOk, info = pcall(C_Spell.GetSpellCharges, POM_CAST_ID)
                    if apiOk and info and info.maxCharges ~= nil
                       and not issecretvalue(info.maxCharges)
                       and type(info.maxCharges) == "number"
                       and info.maxCharges > 0 then
                        state.pomMaxCharges = info.maxCharges
                    end
                end

                -- Cast-driven suppression: anchor the alert gate to the cast
                -- event (always reliable) rather than charge state (sometimes
                -- lies in combat). Predicted CD covers the whole window.
                local now   = GetTime()
                local cdDur = GetPoMCooldownDuration()
                state.pomLastCastAt    = now
                state.pomSuppressUntil = now + cdDur

                local cur = state.pomCurCharges or 0
                state.pomCurCharges = (cur > 0) and (cur - 1) or 0
                state.pomActive    = false
                state.pomWasActive = false

                -- Always re-arm via the gen-counter helper so any stale in-flight
                -- recover from a prior cast becomes a no-op.
                _ArmPoMChargeRecover(cdDur)

                -- Schedule a re-eval right at suppression expiry so the alert
                -- fires promptly (don't wait for the next event).
                C_Timer.After(cdDur + 0.01, function() NHA.ScheduleEvaluate() end)

                -- Optional cast-gap logger (toggle via /nha pomlog).
                -- Logs the predictor inputs (haste% + WnT talent state) alongside
                -- gap/predicted so we can tell whether a delta is from a wrong
                -- talent-detection branch vs an actual base-CD/reduction miscalibration.
                if state.pomLogEnabled then
                    local prev = state.pomLogLastCastAt or 0
                    if prev > 0 then
                        local gap       = now - prev
                        local predicted = state.pomLogLastPredicted or cdDur
                        local delta     = gap - predicted
                        local hastePct  = ReadPlayerHastePct() or 0
                        local wntFlag   = state.hasWasteNoTimeTalent and "WnT" or "noWnT"
                        NHA.Msg(string_format(
                            "POM cast gap: %.3fs (predicted %.2fs, off by %+0.3fs) [haste=%.2f%% %s]",
                            gap, predicted, delta, hastePct, wntFlag))
                    end
                    state.pomLogLastCastAt    = now
                    state.pomLogLastPredicted = cdDur
                end

                NHA.ScheduleEvaluate()
            end

            if spellID == HW_SERENITY_ID then
                local now = GetTime()
                local max = state.serenityMaxCharges or HW_DEFAULT_MAX_CHGS
                local cur = state.serenityCurCharges or 0
                local wasRecharging = (cur < max) and (state.serenityRechargeAt or 0) > 0
                cur = (cur > 0) and (cur - 1) or 0
                state.serenityCurCharges = cur
                state.serenityLastCastAt = now
                if not wasRecharging then
                    state.serenityRechargeAt = now + _HWBaseCD()
                    _ArmSerenityRecharge()
                end
                state.serenitySuppressUntil = state.serenityRechargeAt or now
                if state.hwLogEnabled then
                    local prev = state.serenityLogLastCastAt or 0
                    if prev > 0 then
                        NHA.Msg(string_format(
                            "|cffffd700Serenity cast gap:|r %.3fs since last cast [apo=%s]",
                            now - prev, tostring(ApotheosisActive())))
                    end
                    state.serenityLogLastCastAt = now
                    local modelRem = (state.serenityRechargeAt or now) - now
                    local suppRem  = (state.serenitySuppressUntil or now) - now
                    NHA.Msg(string_format(
                        "|cffffd700Serenity:|r CAST -> %d/%d, recharge %.1fs, suppress %.1fs%s",
                        cur, max, modelRem, suppRem,
                        _HWApiCompare(HW_SERENITY_ID, cur, modelRem)))
                end
                C_Timer.After(_HWBaseCD() + 0.01, function() NHA.ScheduleEvaluate() end)
                NHA.ScheduleEvaluate()
            elseif spellID == HW_SANCTIFY_ID then
                local now = GetTime()
                local max = state.sanctifyMaxCharges or HW_DEFAULT_MAX_CHGS
                local cur = state.sanctifyCurCharges or 0
                local wasRecharging = (cur < max) and (state.sanctifyRechargeAt or 0) > 0
                cur = (cur > 0) and (cur - 1) or 0
                state.sanctifyCurCharges = cur
                state.sanctifyLastCastAt = now
                if not wasRecharging then
                    state.sanctifyRechargeAt = now + _HWBaseCD()
                    _ArmSanctifyRecharge()
                end
                state.sanctifySuppressUntil = state.sanctifyRechargeAt or now
                if state.hwLogEnabled then
                    local prev = state.sanctifyLogLastCastAt or 0
                    if prev > 0 then
                        NHA.Msg(string_format(
                            "|cffffd700Sanctify cast gap:|r %.3fs since last cast [apo=%s]",
                            now - prev, tostring(ApotheosisActive())))
                    end
                    state.sanctifyLogLastCastAt = now
                    local modelRem = (state.sanctifyRechargeAt or now) - now
                    local suppRem  = (state.sanctifySuppressUntil or now) - now
                    NHA.Msg(string_format(
                        "|cffffd700Sanctify:|r CAST -> %d/%d, recharge %.1fs, suppress %.1fs%s",
                        cur, max, modelRem, suppRem,
                        _HWApiCompare(HW_SANCTIFY_ID, cur, modelRem)))
                end
                C_Timer.After(_HWBaseCD() + 0.01, function() NHA.ScheduleEvaluate() 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.2, function()
                state.rosterUpdatePending = false
                RebuildPoMPresenceCache()
                NHA.Evaluate()
            end)
        end
        return true
    end

    if event == "PLAYER_REGEN_DISABLED" then
        if (state.apoPausedAt or 0) > 0 then
            local paused = GetTime() - state.apoPausedAt
            if paused > SUSTAINED_POTENCY_PAUSE_CAP_SEC then paused = SUSTAINED_POTENCY_PAUSE_CAP_SEC end
            state.apotheosisActiveUntil = (state.apotheosisActiveUntil or 0) + paused
            state.apoPausedAt = 0
        end
        RebuildPoMPresenceCache()
        if db and db.pomEnabled then
            TryInitPoMChargesFromAPI()
        end
        local sanB, serB = state.sanctifyRechargeAt or 0, state.serenityRechargeAt or 0
        TryInitSerenityChargesFromAPI()
        TryInitSanctifyChargesFromAPI()
        if state.hwLogEnabled then
            local now = GetTime()
            NHA.Msg(string_format(
                "|cffff8800boundary ENTER:|r Sanctify cur=%d rechargeIn %.1f->%.1f | Serenity cur=%d rechargeIn %.1f->%.1f",
                state.sanctifyCurCharges or -1, (sanB > 0) and (sanB - now) or 0,
                ((state.sanctifyRechargeAt or 0) > 0) and (state.sanctifyRechargeAt - now) or 0,
                state.serenityCurCharges or -1, (serB > 0) and (serB - now) or 0,
                ((state.serenityRechargeAt or 0) > 0) and (state.serenityRechargeAt - now) or 0))
        end
        if NHA.RefreshAllCUFs then
            NHA:RefreshAllCUFs()
        end
        NHA.ScheduleEvaluate()
        return false
    end

    if event == "PLAYER_REGEN_ENABLED" then
        if state.hasSustainedPotency and (state.apoPausedAt or 0) == 0 and ApotheosisActive() then
            state.apoPausedAt = GetTime()
        end
        self:ClearAllGlows()
        wipe(pomPresence)
        state.pomCachedCount = 0
        TryInitPoMChargesFromAPI()
        local sanB, serB = state.sanctifyRechargeAt or 0, state.serenityRechargeAt or 0
        TryInitSerenityChargesFromAPI()
        TryInitSanctifyChargesFromAPI()
        if state.hwLogEnabled then
            local now = GetTime()
            NHA.Msg(string_format(
                "|cffff8800boundary EXIT:|r Sanctify cur=%d rechargeIn %.1f->%.1f | Serenity cur=%d rechargeIn %.1f->%.1f",
                state.sanctifyCurCharges or -1, (sanB > 0) and (sanB - now) or 0,
                ((state.sanctifyRechargeAt or 0) > 0) and (state.sanctifyRechargeAt - now) or 0,
                state.serenityCurCharges or -1, (serB > 0) and (serB - now) or 0,
                ((state.serenityRechargeAt or 0) > 0) and (state.serenityRechargeAt - now) or 0))
        end
        NHA.ScheduleEvaluate()
        return false
    end

    if event == "PLAYER_ENTERING_WORLD" then
        _ApplyTalents(_ProbeTalents())
        ReadPlayerHastePct()
        RebuildPoMPresenceCache()
        TryInitPoMChargesFromAPI()
        TryInitSerenityChargesFromAPI()
        TryInitSanctifyChargesFromAPI()
        NHA.ScheduleEvaluate()
        C_Timer.After(0.5, function()
            local fresh = _ProbeTalents()
            local changed = false
            for k, v in pairs(fresh) do
                if state[k] ~= v then changed = true; break end
            end
            if changed then
                _ApplyTalents(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.hasProphetsInsight   = false
        state.isArchon             = false
        state.hasBenedictionTalent = false
        state.hasEternalSanctityTalent = false
        state.hasPowerSurge        = false
        state.hasEnergyConservation = false
        state.hasSpiritWell        = false
        state.hasSustainedPotency  = false
        state.hasUltimateSerenity  = false
        state.hasVoiceOfHarmony    = false
        state.lightOfNaaruRank     = 0
        state.apoStoredSec         = 0
        state.apoPausedAt          = 0
        state.pomKnownCD           = 0
        C_Timer.After(TALENT_REPROBE_DELAY, function()
            _ApplyTalents(_ProbeTalents())
            NHA.ScheduleEvaluate()
        end)
        return false
    end

    if event == "SPELLS_CHANGED" then
        TryInitPoMChargesFromAPI()
        TryInitSerenityChargesFromAPI()
        TryInitSanctifyChargesFromAPI()
        NHA.ScheduleEvaluate()
        return false
    end

    if event == "SPELL_UPDATE_CHARGES" then
        if not (C_Spell and C_Spell.GetSpellCharges) then return false end

        local changed = false

        -- POM
        local apiOk, info = pcall(C_Spell.GetSpellCharges, POM_CAST_ID)
        if apiOk and info then
            local suppressed = (state.pomSuppressUntil or 0) > GetTime()

            if info.currentCharges ~= nil and not issecretvalue(info.currentCharges)
               and type(info.currentCharges) == "number"
               and info.currentCharges ~= state.pomCurCharges then
                -- Refuse to RAISE charges during the cast-suppression window. The
                -- API can briefly false-report a recovered charge right after a cast
                -- (single-charge talent paths, transient secret-value flips).
                -- Decreases still pass through so external desync correction works.
                if not (suppressed and info.currentCharges > (state.pomCurCharges or 0)) then
                    state.pomCurCharges = info.currentCharges
                    changed = true
                end
            end

            if info.maxCharges ~= nil and not issecretvalue(info.maxCharges)
               and type(info.maxCharges) == "number" and info.maxCharges > 0
               and info.maxCharges ~= state.pomMaxCharges then
                state.pomMaxCharges = info.maxCharges
                changed = true
            end
        end

        -- Holy Words (Serenity + Sanctify) use the manual model;
        -- SPELL_UPDATE_CHARGES not consumed here (count tracked by cast, synced
        -- via TryInit{Serenity,Sanctify}ChargesFromAPI at combat boundaries).

        if changed then NHA.ScheduleEvaluate() end
        return false
    end

    return false
end

-- =============================================================
-- Slash Commands
-- =============================================================
PriestHoly.slashCommands = {
    ["debug"] = function(self, db)
        NHA.Msg("|cffffd700Holy Priest Debug:|r")
        NHA.Msg("  enabled:              " .. tostring(db.enabled))
        NHA.Msg("  pomEnabled:           " .. tostring(db.pomEnabled))
        NHA.Msg("  cufGlowEnabled:       " .. tostring(db.cufGlowEnabled))
        NHA.Msg("  pomActive:            " .. tostring(state.pomActive))
        NHA.Msg("  pomCurCharges:        " .. tostring(state.pomCurCharges or 0))
        NHA.Msg("  pomMaxCharges:        " .. tostring(state.pomMaxCharges or POM_DEFAULT_MAX_CHARGES))
        NHA.Msg("  pomChargeTimerSet:    " .. tostring(state.pomChargeTimerSet))
        NHA.Msg("  pomChargeGen:         " .. tostring(state.pomChargeGen or 0))
        local nowDbg     = GetTime()
        local suppLeft   = (state.pomSuppressUntil or 0) - nowDbg
        NHA.Msg("  pomSuppressUntil:     " .. string_format("%.2f (%s%.2fs)",
            state.pomSuppressUntil or 0,
            suppLeft > 0 and "active, " or "expired ",
            suppLeft > 0 and suppLeft or -suppLeft))
        NHA.Msg("  pomLastCastAt:        " .. string_format("%.2f", state.pomLastCastAt or 0))
        NHA.Msg("  pomLogEnabled:        " .. tostring(state.pomLogEnabled or false))
        NHA.Msg("  pomCachedCount:       " .. tostring(state.pomCachedCount or 0))
        local pomPresenceEntries = 0
        for _ in pairs(pomPresence) do pomPresenceEntries = pomPresenceEntries + 1 end
        NHA.Msg("  pomPresence entries:  " .. pomPresenceEntries)
        NHA.Msg("  pomKnownCD:           " .. string_format("%.2fs", state.pomKnownCD or 0))
        NHA.Msg("  hasWasteNoTimeTalent: " .. tostring(state.hasWasteNoTimeTalent))
        NHA.Msg("  hasProphetsInsight: " .. tostring(state.hasProphetsInsight)
            .. "  (HW base CD " .. string_format("%.0fs", _HWBaseCD()) .. ")")
        NHA.Msg("  hasBenedictionTalent: " .. tostring(state.hasBenedictionTalent))
        NHA.Msg("  hasEternalSanctityTalent: " .. tostring(state.hasEternalSanctityTalent))
        NHA.Msg("  hasPowerSurge: " .. tostring(state.hasPowerSurge))
        NHA.Msg("  hasEnergyConservation: " .. tostring(state.hasEnergyConservation))
        NHA.Msg("  hasSpiritWell: " .. tostring(state.hasSpiritWell))
        NHA.Msg("  hasSustainedPotency: " .. tostring(state.hasSustainedPotency)
            .. "  apoStored=" .. string_format("%.0fs", state.apoStoredSec or 0)
            .. "  apoPaused=" .. tostring((state.apoPausedAt or 0) > 0))
        NHA.Msg("  hasUltimateSerenity: " .. tostring(state.hasUltimateSerenity)
            .. " (Sanctify CDR -> " .. _SanCDRName() .. ")")
        NHA.Msg("  hasVoiceOfHarmony: " .. tostring(state.hasVoiceOfHarmony))
        NHA.Msg("  lightOfNaaruRank: " .. tostring(state.lightOfNaaruRank or 0)
            .. "  (HW CDR x" .. string_format("%.2f", 1 + LIGHT_OF_NAARU_PER_RANK * (state.lightOfNaaruRank or 0)) .. ")")

        -- HW tracker dump (Serenity + Sanctify). maxCharges=0 means the spell
        -- is not in the player's spellbook (e.g. Sanctify under Ultimate Serenity).
        do
            local nowDbgHW = GetTime()
            local serRecharge = (state.serenityRechargeAt or 0) - nowDbgHW
            local serSuppress = (state.serenitySuppressUntil or 0) - nowDbgHW
            NHA.Msg(string_format(
                "  [serenity] active=%s  charges=%s/%s  recharge=%s  suppress=%s  apo=%s  lastCast=%.2f",
                tostring(state.serenityActive),
                tostring(state.serenityCurCharges or 0),
                tostring(state.serenityMaxCharges or 0),
                (state.serenityRechargeAt or 0) > 0
                    and string_format("%.1fs", serRecharge > 0 and serRecharge or 0)
                    or "ready",
                serSuppress > 0 and string_format("%.1fs", serSuppress) or "clear",
                tostring(ApotheosisActive()),
                state.serenityLastCastAt or 0))
            local sanRecharge = (state.sanctifyRechargeAt or 0) - nowDbgHW
            local sanSuppress = (state.sanctifySuppressUntil or 0) - nowDbgHW
            NHA.Msg(string_format(
                "  [sanctify] active=%s  charges=%s/%s  recharge=%s  suppress=%s  apo=%s  lastCast=%.2f",
                tostring(state.sanctifyActive),
                tostring(state.sanctifyCurCharges or 0),
                tostring(state.sanctifyMaxCharges or 0),
                (state.sanctifyRechargeAt or 0) > 0
                    and string_format("%.1fs", sanRecharge > 0 and sanRecharge or 0)
                    or "ready",
                sanSuppress > 0 and string_format("%.1fs", sanSuppress) or "clear",
                tostring(ApotheosisActive()),
                state.sanctifyLastCastAt or 0))
            NHA.Msg("  hwLogEnabled:         " .. tostring(state.hwLogEnabled or false))
        end

        -- WnT diagnostic: raw C_Traits answer for node 94679 + IsPlayerSpell
        -- for spell 440681. Helps pin down false-positive detection by showing
        -- what the API actually returns vs what the detection function decided.
        do
            local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
                             and C_ClassTalents.GetActiveConfigID()
            NHA.Msg("  [WnT probe] configID: " .. tostring(configID))
            if configID and C_Traits and C_Traits.GetNodeInfo then
                local nOk, ni = pcall(C_Traits.GetNodeInfo, configID, WASTE_NO_TIME_NODE_ID)
                if nOk and ni then
                    NHA.Msg("  [WnT probe] node 94679 ranksPurchased=" ..
                            tostring(ni.ranksPurchased) ..
                            " activeRank=" .. tostring(ni.activeRank) ..
                            " maxRanks=" .. tostring(ni.maxRanks))
                    NHA.Msg("  [WnT probe] node subTreeID=" ..
                            tostring(ni.subTreeID))
                    if ni.subTreeID and C_Traits.GetSubTreeInfo then
                        local stOk, sti = pcall(C_Traits.GetSubTreeInfo, configID, ni.subTreeID)
                        if stOk and sti then
                            NHA.Msg("  [WnT probe] subtree.isActive=" ..
                                    tostring(sti.isActive) ..
                                    " name=" .. tostring(sti.name))
                        end
                    end
                    if ni.activeEntry then
                        NHA.Msg("  [WnT probe] activeEntry.entryID=" ..
                                tostring(ni.activeEntry.entryID) ..
                                " (expected " .. tostring(WASTE_NO_TIME_ENTRY_ID) .. ")")
                    else
                        NHA.Msg("  [WnT probe] activeEntry: nil")
                    end
                else
                    NHA.Msg("  [WnT probe] GetNodeInfo: nil (node not in active tree)")
                end
            end
            if IsPlayerSpell then
                local sOk, has = pcall(IsPlayerSpell, WASTE_NO_TIME_SPELL_ID)
                NHA.Msg("  [WnT probe] IsPlayerSpell(440681): " ..
                        (sOk and tostring(has) or "throw"))
            end
        end
        NHA.Msg("  inCombat:             " .. tostring(UnitAffectingCombat("player")))

        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(ComputePoMCooldown)
        if not cdOk or type(cd) ~= "number" then cd = POM_BASE_CD_SEC end
        NHA.Msg("  haste-only CD:        " .. string_format("%.2f", cd) .. "s")
        NHA.Msg("  effective CD:         " .. string_format("%.2f", GetPoMCooldownDuration()) .. "s")

        if C_Spell and C_Spell.GetSpellCharges then
            local apiOk, info = pcall(C_Spell.GetSpellCharges, POM_CAST_ID)
            if apiOk and info then
                local function FieldStr(v)
                    if v == nil then return "nil" end
                    if issecretvalue(v) then return "tainted" end
                    if type(v) == "number" then return string_format("%.2f", v) end
                    return tostring(v)
                end
                NHA.Msg("  live API curr/max:    " .. FieldStr(info.currentCharges)
                        .. " / " .. FieldStr(info.maxCharges))
                NHA.Msg("  live API cdDur:       " .. FieldStr(info.cooldownDuration))
            else
                NHA.Msg("  live API:             unavailable")
            end
        end
    end,

    ["cufglow"] = function(self, db)
        NHA.Msg("|cffffd700CUF Frame Glow Debug (Holy 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 renewCacheCount, pomCacheCount, dirtyCount = 0, 0, 0
        for _ in pairs(cufRenewCache)  do renewCacheCount = renewCacheCount + 1 end
        for _ in pairs(cufPoMCache)    do pomCacheCount   = pomCacheCount   + 1 end
        for _ in pairs(dirtyCUFFrames) do dirtyCount      = dirtyCount      + 1 end
        local stateRenewCount, statePoMCount = 0, 0
        for _, kind in pairs(cufStateMap) do
            if     kind == "renew" then stateRenewCount = stateRenewCount + 1
            elseif kind == "pom"   then statePoMCount   = statePoMCount   + 1
            end
        end

        NHA.Msg("  tracked CUFs:    " .. trackedCount)
        NHA.Msg("  in combat:       " .. tostring(UnitAffectingCombat("player")))
        NHA.Msg("  cufRenewCache:   " .. renewCacheCount)
        NHA.Msg("  cufPoMCache:     " .. pomCacheCount)
        NHA.Msg("  dirtyCUFFrames:  " .. dirtyCount)
        NHA.Msg("  glowing (Renew/PoM): " .. stateRenewCount .. " / " .. statePoMCount)

        NHA.Msg("|cffffd700  Per-unit aura detection (Renew/PoM):|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 hasRenew = FindAuraOnUnit(unit, RENEW_ID, true) ~= nil
                local hasPoM   = FindAuraOnUnit(unit, POM_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): Renew=%s  PoM=%s  glow=%s",
                    unit,
                    (nameOk and tostring(name)) or "?",
                    tostring(hasRenew),
                    tostring(hasPoM),
                    glowKind))
                probed = probed + 1
            end
        end
    end,

    ["pomtest"] = function(self, db)
        NHA.Msg("|cffffd700PoM Charge API Test (cast spell 33076):|r")
        if not C_Spell or not C_Spell.GetSpellCharges then
            NHA.Msg("  C_Spell.GetSpellCharges unavailable")
            return
        end

        local apiOk, info = pcall(C_Spell.GetSpellCharges, 33076)
        if not apiOk then
            NHA.Msg("  API threw: " .. tostring(info))
            return
        end
        if not info then
            NHA.Msg("  returned nil - must be on a Priest with PoM trained")
            return
        end

        local function S(v)
            if v == nil then return "nil" end
            if issecretvalue and issecretvalue(v) then return "|cffff5555<secret>|r" end
            return tostring(v)
        end

        NHA.Msg("  currentCharges:   " .. S(info.currentCharges))
        NHA.Msg("  maxCharges:       " .. S(info.maxCharges))
        NHA.Msg("  cooldownStart:    " .. S(info.cooldownStart))
        NHA.Msg("  cooldownDuration: " .. S(info.cooldownDuration))
        NHA.Msg("  chargeModRate:    " .. S(info.chargeModRate))

        local cmpOk, cmpVal = pcall(_chargesAtMax, info)
        NHA.Msg("  compare(curr==max): ok=" .. tostring(cmpOk) .. "  val/err=" .. tostring(cmpVal))

        local instOk, _, instType = pcall(IsInInstance)
        NHA.Msg("  instanceType:       " .. ((instOk and tostring(instType)) or "?"))
    end,

    -- Cast-gap logger: prints actual elapsed time between POM casts vs the
    -- predicted CD. Use to tune talent-reduction constants and verify the
    -- cast-driven suppression window matches the real server CD.
    ["pomlog"] = function(self, db)
        state.pomLogEnabled = not state.pomLogEnabled
        if state.pomLogEnabled then
            state.pomLogLastCastAt    = 0
            state.pomLogLastPredicted = 0
            NHA.Msg("|cff00ff00POM cast-gap logging: ON|r — cast POM on-ready to measure real CD vs predicted. /nha pomlog to stop.")
        else
            NHA.Msg("|cffffd700POM cast-gap logging: OFF|r")
        end
    end,

    -- Holy Word logger: both Serenity and Sanctify log each charge
    -- cast / CDR chip / recharge transition (manual model).
    ["hwlog"] = function(self, db)
        state.hwLogEnabled = not state.hwLogEnabled
        if state.hwLogEnabled then
            state.serenityLogLastCastAt    = 0
            state.serenityLogLastPredicted = 0
            state.sanctifyLogLastCastAt    = 0
            state.sanctifyLogLastPredicted = 0
            NHA.Msg("|cff00ff00Holy Word logging: ON|r — Serenity + Sanctify log cast/chip/recharge + cast-gap. Cast on-ready to measure real recharge vs 45s. /nha hwlog to stop.")
        else
            NHA.Msg("|cffffd700Holy Word logging: OFF|r")
        end
    end,

    ["sanlog"] = function(self, db)
        state.sanLogEnabled = not state.sanLogEnabled
        if state.sanLogEnabled then
            state.sanLogLastRem    = nil
            state.sanLogLastAt     = 0
            state.sanLogLastCastId = nil
            state.sanLogLastCastAt = 0
            if state.sanLogTicker then state.sanLogTicker:Cancel() end
            state.sanLogTicker = C_Timer.NewTicker(0.1, _SanLogTick)
            NHA.Msg("|cff00ff00Sanctify CDR logging: ON|r -- out of combat, dump Sanctify to 0-1 charges so a recharge is active, then cast. Logs every cooldown drop; flags drops with no matching cast. /nha sanlog to stop.")
        else
            if state.sanLogTicker then state.sanLogTicker:Cancel(); state.sanLogTicker = nil end
            NHA.Msg("|cffffd700Sanctify CDR logging: OFF|r")
        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 = { renew = 0, pom = 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 = PriestHoly.Evaluate
        PriestHoly.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 = PriestHoly.RefreshGlows
        PriestHoly.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
        PriestHoly.Evaluate     = perf.origEvaluate
        PriestHoly.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 = { "CDREAD", "POM", "HW", "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: RENEW=%d  POM=%d  NONE=%d  DISABLED=%d",
                cuf.paths.renew or 0, cuf.paths.pom 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_HOLY", PriestHoly)
