-- =============================================================
-- NemHealerAlerts.lua  v12.0.27
-- Nem: Healer Alerts — core engine.
--
-- MIT License
-- Copyright (c) 2026 Nemreaper
--
-- Permission is hereby granted, free of charge, to any person
-- obtaining a copy of this software and associated documentation
-- files (the "Software"), to deal in the Software without
-- restriction, including without limitation the rights to use,
-- copy, modify, merge, publish, distribute, sublicense, and/or
-- sell copies of the Software, and to permit persons to whom the
-- Software is furnished to do so, subject to the following
-- conditions:
--
-- The above copyright notice and this permission notice shall be
-- included in all copies or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
-- OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
-- HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-- WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
-- OTHER DEALINGS IN THE SOFTWARE.

-- =============================================================

local ADDON_NAME, ns = ...
local NHA = CreateFrame("Frame")
_G.NemHealerAlerts = NHA

local L = ns.L
NHA.L = L

-- =============================================================
-- Upvalues
-- =============================================================
local pairs, ipairs           = pairs, ipairs
local math_floor, math_max, math_min = math.floor, math.max, math.min
local math_abs                = math.abs
local string_format           = string.format
local string_match            = string.match
local CreateFrame             = CreateFrame
local GetTime                 = GetTime
local UnitClass               = UnitClass
local IsInGroup, IsInRaid     = IsInGroup, IsInRaid
local GetNumGroupMembers      = GetNumGroupMembers
local C_UnitAuras             = C_UnitAuras
local CopyTable               = CopyTable
local issecretvalue           = issecretvalue or function() return false end

local NHA_VERSION = "v12.0.27"

-- =============================================================
-- Error Isolation
-- =============================================================
-- Wrap spec callbacks so a single bad path doesn't break sibling alerts.
-- label is kept in the signature for call-site readability; nothing consumes it.
local function SafeCall(label, func, ...)
    return (pcall(func, ...))
end
NHA.SafeCall = SafeCall

-- =============================================================
-- Asset Paths
-- =============================================================
local NHA_FONTS  = "Interface\\AddOns\\NemHealerAlerts\\Fonts\\"
local NHA_MEDIA  = "Interface\\AddOns\\NemHealerAlerts\\Media\\"

local ALERT_FONT = NHA_FONTS .. "GothamNarrow-Ultra.ttf"
local UI_FONT    = NHA_FONTS .. "Prototype.ttf"

-- =============================================================
-- Class Detection
-- =============================================================
local activeClass = nil

local function GetActiveClass()
    local _, class = UnitClass("player")
    return class
end


-- =============================================================
-- Spec Module Registry
-- =============================================================
NHA.specModules     = {}
NHA.activeModule    = nil
NHA.activeModuleKey = nil

local SPEC_CONTRACT = { "class", "specID", "specName", "alerts", "defaults", "state", "Evaluate" }

function NHA:RegisterSpec(key, mod)
    for _, field in ipairs(SPEC_CONTRACT) do
        if mod[field] == nil then
            error(("NHA spec %q is missing required field/method %q"):format(tostring(key), field), 2)
        end
    end
    self.specModules[key] = mod
end

local function FindActiveModule()
    if not activeClass then return nil, nil end
    local currentSpecID = NHA.GetSpecID()
    if not currentSpecID then return nil, nil end
    for key, mod in pairs(NHA.specModules) do
        -- currentSpecID comes from GetSpecID(), which already rejects secret values.
        if mod.class == activeClass and mod.specID == currentSpecID then
            return key, mod
        end
    end
    return nil, nil
end

local function IsFullyImplemented()
    return NHA.activeModule ~= nil
end

-- =============================================================
-- Class Theme Colors
-- =============================================================
-- Class theme colors are sourced live from Blizzard's master class-color table
-- (C_ClassColor.GetClassColor → RAID_CLASS_COLORS) rather than hardcoded, so they
-- self-correct if Blizzard ever retunes a class color. Verified ✅ in the API
-- ledger (2026-05-27). CLASS_THEME_DEFAULT is the fallback when class is unknown.
local CLASS_THEME_DEFAULT = { r=0, g=0.8, b=1.0 }  -- #00CCFF

local function GetClassTheme()
    local cls = activeClass
    if not cls and UnitClass then
        local _, c = UnitClass("player")
        cls = c
    end
    if cls and C_ClassColor and C_ClassColor.GetClassColor then
        local col = C_ClassColor.GetClassColor(cls)
        if col then return col.r, col.g, col.b end
    end
    return CLASS_THEME_DEFAULT.r, CLASS_THEME_DEFAULT.g, CLASS_THEME_DEFAULT.b
end

-- =============================================================
-- LibSharedMedia
-- =============================================================
local LSM = LibStub and LibStub("LibSharedMedia-3.0", true)

-- =============================================================
-- LibCustomGlow
-- =============================================================
local LCG = LibStub and LibStub("LibCustomGlow-1.0", true)

-- =============================================================
-- Sounds
-- =============================================================
local BUNDLED_SOUNDS = {
    ["Bell"]                        = NHA_MEDIA .. "Bell.ogg",
    ["Bleeper"]                     = NHA_MEDIA .. "Bleeper.ogg",
    ["Drums"]                       = NHA_MEDIA .. "Drums.ogg",
    ["Heartbeat Single"]            = NHA_MEDIA .. "HeartbeatSingle.ogg",
    ["Lamp"]                        = NHA_MEDIA .. "Lamp.ogg",
    ["Metal Gear Spotted"]          = NHA_MEDIA .. "MetalGearSpotted.ogg",
    ["Oh No"]                       = NHA_MEDIA .. "OhNo.ogg",
    ["Ping"]                        = NHA_MEDIA .. "Ping.ogg",
    ["Redfox"]                      = NHA_MEDIA .. "Redfox.ogg",
    ["Robot Blip"]                  = NHA_MEDIA .. "RobotBlip.ogg",
    ["Sharp Punch"]                 = NHA_MEDIA .. "SharpPunch.ogg",
    ["Shotgun"]                     = NHA_MEDIA .. "Shotgun.ogg",
    ["Sonar"]                       = NHA_MEDIA .. "Sonar.ogg",
    ["Sonarr"]                      = NHA_MEDIA .. "Sonarr.ogg",
    ["Water Drop"]                  = NHA_MEDIA .. "WaterDrop.ogg",

    ["Druid: Abundance"]            = NHA_MEDIA .. "druid_resto_abundance.ogg",
    ["Druid: Lifebloom"]            = NHA_MEDIA .. "druid_resto_lifebloom.ogg",
    ["Druid: Lifebloom Expiring"]   = NHA_MEDIA .. "druid_resto_lifebloom_expiring.ogg",
    ["Druid: Lifebloom Missing"]    = NHA_MEDIA .. "druid_resto_lifebloom_missing.ogg",
    ["Druid: Soul of the Forest"]   = NHA_MEDIA .. "druid_resto_sotf.ogg",
    ["Druid: Swiftmend"]            = NHA_MEDIA .. "druid_resto_swiftmend.ogg",
    ["Druid: Tree of Life"]         = NHA_MEDIA .. "druid_resto_tree_of_life.ogg",
    ["Druid: Wild Growth"]          = NHA_MEDIA .. "druid_resto_wild_growth.ogg",

    ["Monk: Enveloping Mist"]       = NHA_MEDIA .. "monk_mw_enveloping_mist.ogg",
    ["Monk: Renewing Mist"]         = NHA_MEDIA .. "monk_mw_renewing_mist.ogg",
    ["Monk: Spread Renewing Mist"]  = NHA_MEDIA .. "monk_mw_spread_renewing_mist.ogg",
    ["Monk: Thunder Focus Tea"]     = NHA_MEDIA .. "monk_mw_thunder_focus_tea.ogg",

    ["Priest: Atonement"]           = NHA_MEDIA .. "priest_disc_atonement.ogg",
    ["Priest: Entropic Rift"]       = NHA_MEDIA .. "priest_disc_entropic_rift.ogg",
    ["Priest: Mind Blast"]          = NHA_MEDIA .. "priest_disc_mind_blast.ogg",
    ["Priest: Penance"]             = NHA_MEDIA .. "priest_disc_penance.ogg",
    ["Priest: Power Word Shield"]   = NHA_MEDIA .. "priest_disc_power_word_shield.ogg",
    ["Priest: Prayer of Mending"]   = NHA_MEDIA .. "priest_holy_prayer_of_mending.ogg",
    ["Priest: Radiance"]            = NHA_MEDIA .. "priest_disc_radiance.ogg",
    ["Priest: Sanctify"]            = NHA_MEDIA .. "priest_holy_sanctify.ogg",
    ["Priest: Serenity"]            = NHA_MEDIA .. "priest_holy_serenity.ogg",
    ["Priest: Spread Atonement"]    = NHA_MEDIA .. "priest_disc_spread_atonement.ogg",
    ["Priest: Void Shield"]         = NHA_MEDIA .. "priest_disc_void_shield.ogg",

    ["Shaman: Earth Shield Missing"] = NHA_MEDIA .. "shaman_resto_earth_shield_missing.ogg",
    ["Shaman: Place Healing Stream"] = NHA_MEDIA .. "shaman_resto_place_healing_stream.ogg",
    ["Shaman: Spread Riptide"]       = NHA_MEDIA .. "shaman_resto_spread_riptide.ogg",
    ["Shaman: Surging Totem Expiring"] = NHA_MEDIA .. "shaman_resto_surging_totem_expiring.ogg",
    ["Shaman: Unleash Life"]         = NHA_MEDIA .. "shaman_resto_unleash_life.ogg",
    ["Shaman: Unleash Life Ready"]   = NHA_MEDIA .. "shaman_resto_unleash_life_ready.ogg",
    ["Shaman: Unleash Riptide"]      = NHA_MEDIA .. "shaman_resto_unleash_riptide.ogg",

    ["Paladin: Beacon of Faith"]    = NHA_MEDIA .. "paladin_holy_beacon_of_faith.ogg",
    ["Paladin: Beacon of Light"]    = NHA_MEDIA .. "paladin_holy_beacon_of_light.ogg",
    ["Paladin: Beacon of Virtue"]   = NHA_MEDIA .. "paladin_holy_beacon_of_virtue.ogg",
    ["Paladin: Holy Shock"]         = NHA_MEDIA .. "paladin_holy_holy_shock.ogg",
    ["Paladin: Spend Holy Power"]   = NHA_MEDIA .. "paladin_holy_spend_holy_power.ogg",

    ["Evoker: Dream Breath"]        = NHA_MEDIA .. "evoker_pres_dream_breath.ogg",
    ["Evoker: Spread Reversion"]    = NHA_MEDIA .. "evoker_pres_spread_reversion.ogg",
}

if LSM then
    for name, path in pairs(BUNDLED_SOUNDS) do
        LSM:Register("sound", name, path)
    end
end

local function GetSoundPath(name)
    if not name then return BUNDLED_SOUNDS["Bell"] end
    local override = L:GetSoundOverride(name)
    if override then return override end
    if BUNDLED_SOUNDS[name] then return BUNDLED_SOUNDS[name] end
    if LSM then
        local p = LSM:Fetch("sound", name)
        if type(p) == "string" then return p:gsub("/", "\\") end
    end
    return BUNDLED_SOUNDS["Bell"]
end

local function IsTTSSound(name)
    return name:find("^%w+:") ~= nil
end

-- Maps the active module's class token to the prefix used in TTS sound keys.
-- Drives the per-spec filter that hides other classes' TTS clips in sound dropdowns.
local CLASS_VOICE_PREFIX = {
    DRUID   = "Druid",
    EVOKER  = "Evoker",
    MONK    = "Monk",
    PALADIN = "Paladin",
    PRIEST  = "Priest",
    SHAMAN  = "Shaman",
}

local function GetActiveClassVoicePrefix()
    local mod = NHA.activeModule
    if not mod then return nil end
    return CLASS_VOICE_PREFIX[mod.class]
end

local SOUND_NAMES = {}
do
    for k in pairs(BUNDLED_SOUNDS) do SOUND_NAMES[#SOUND_NAMES+1] = k end
    table.sort(SOUND_NAMES, function(a, b)
        local aTTS, bTTS = IsTTSSound(a), IsTTSSound(b)
        if aTTS ~= bTTS then return aTTS end
        return a < b
    end)
end

-- =============================================================
-- Fonts
-- =============================================================
local BUNDLED_FONTS = {
    ["Accidental Presidency"]   = NHA_FONTS .. "Accidental Presidency.ttf",
    ["Action Man"]              = NHA_FONTS .. "ActionMan.ttf",
    ["Alba Super"]              = NHA_FONTS .. "ALBAS___.ttf",
    ["Arm Wrestler"]            = NHA_FONTS .. "ArmWrestler.ttf",
    ["Avant Garde LT Book"]     = NHA_FONTS .. "AvantGarde_LT_Book_Regular.ttf",
    ["Baars"]                   = NHA_FONTS .. "BAARS___.TTF",
    ["Blazed"]                  = NHA_FONTS .. "Blazed.ttf",
    ["Boris Black Bloxx"]       = NHA_FONTS .. "BorisBlackBloxx.ttf",
    ["Boris Black Bloxx Dirty"] = NHA_FONTS .. "BorisBlackBloxxDirty.ttf",
    ["Cabin"]                   = NHA_FONTS .. "Cabin-Regular.ttf",
    ["Celestia Medium Redux"]   = NHA_FONTS .. "CelestiaMediumRedux1.55.ttf",
    ["Century Gothic"]          = NHA_FONTS .. "centurygothic.ttf",
    ["Collegia"]                = NHA_FONTS .. "COLLEGIA.ttf",
    ["Continuum Medium"]        = NHA_FONTS .. "ContinuumMedium.ttf",
    ["DejaVu Sans"]             = NHA_FONTS .. "DejaVuSans.ttf",
    ["DejaVu Sans Bold"]        = NHA_FONTS .. "DejaVuSans-Bold.ttf",
    ["Die Die Die"]             = NHA_FONTS .. "DieDieDie.ttf",
    ["Diogenes"]                = NHA_FONTS .. "DIOGENES.ttf",
    ["Disko"]                   = NHA_FONTS .. "Disko.ttf",
    ["Expressway Bold"]         = NHA_FONTS .. "Expressway-Bold.ttf",
    ["Fraks"]                   = NHA_FONTS .. "FRAKS___.ttf",
    ["Fritz Quadrata"]          = NHA_FONTS .. "FRIZQUAD.TTF",
    ["Gotham Narrow Ultra"]     = NHA_FONTS .. "GothamNarrow-Ultra.ttf",
    ["Homespun"]                = NHA_FONTS .. "Homespun.ttf",
    ["Impact"]                  = NHA_FONTS .. "impact.ttf",
    ["JetBrains Mono"]          = NHA_FONTS .. "JetBrainsMono-Regular.ttf",
    ["Liberation Sans"]         = NHA_FONTS .. "LiberationSans-Regular.ttf",
    ["Liberation Serif"]        = NHA_FONTS .. "LiberationSerif-Regular.ttf",
    ["Mystik Orbs"]             = NHA_FONTS .. "MystikOrbs.ttf",
    ["Nanum Gothic"]            = NHA_FONTS .. "NanumGothic-Regular.ttf",
    ["Nunito"]                  = NHA_FONTS .. "Nunito-Regular.ttf",
    ["Pokemon Solid"]           = NHA_FONTS .. "Pokemon Solid.ttf",
    ["Prototype"]               = NHA_FONTS .. "Prototype.ttf",
    ["PT Sans Narrow Bold"]     = NHA_FONTS .. "PTSansNarrow-Bold.ttf",
    ["Roboto Condensed Bold"]   = NHA_FONTS .. "RobotoCondensed-Bold.ttf",
    ["Rock Show Whiplash"]      = NHA_FONTS .. "Rock Show Whiplash.ttf",
    ["SF Diego Sans"]           = NHA_FONTS .. "SF Diego Sans.ttf",
    ["Solange"]                 = NHA_FONTS .. "Solange.ttf",
    ["Starcine"]                = NHA_FONTS .. "starcine.ttf",
    ["Trashco"]                 = NHA_FONTS .. "trashco.ttf",
    ["Ubuntu Condensed"]        = NHA_FONTS .. "Ubuntu-C.ttf",
    ["Ubuntu Light"]            = NHA_FONTS .. "Ubuntu-L.ttf",
    ["Verdana"]                 = NHA_FONTS .. "Verdana.ttf",
    ["Waltograph UI"]           = NHA_FONTS .. "waltographUI.ttf",
    ["X360"]                    = NHA_FONTS .. "X360.ttf",
    ["Yanone Kaffeesatz"]       = NHA_FONTS .. "YanoneKaffeesatz-Regular.ttf",
}

if LSM then
    for name, path in pairs(BUNDLED_FONTS) do
        LSM:Register("font", name, path)
    end
end

-- SetFont has no glyph fallback; probe the client's CJK fonts so non-CJK clients render CJK too.
local CJK_LOCALES = { zhCN = true, zhTW = true, koKR = true }
local CJK_FONT_CANDIDATES = {
    zhTW = { "Fonts\\ARKai_T.ttf", "Fonts\\bKAI00M.ttf", "Fonts\\ARHei.ttf", "Fonts\\bHEI00M.ttf", "Fonts\\ARKai_C.ttf" },
    zhCN = { "Fonts\\ARHei.ttf", "Fonts\\ARKai_C.ttf", "Fonts\\bHEI00M.ttf", "Fonts\\bKAI00M.ttf" },
    koKR = { "Fonts\\2002.ttf", "Fonts\\K_Pagetext.TTF", "Fonts\\bHEI00M.ttf" },
}
local cjkFontResolved, cjkProbeFS = {}, nil
local function ResolveCJKFont(locale)
    if cjkFontResolved[locale] ~= nil then return cjkFontResolved[locale] or nil end
    cjkProbeFS = cjkProbeFS or NHA:CreateFontString(nil, "ARTWORK")
    for _, path in ipairs(CJK_FONT_CANDIDATES[locale] or {}) do
        if cjkProbeFS:SetFont(path, 12, "") then
            cjkFontResolved[locale] = path
            return path
        end
    end
    cjkFontResolved[locale] = false
    return nil
end
local function LocaleFontOverride()
    local loc = L:GetLocale()
    if CJK_LOCALES[loc] then return ResolveCJKFont(loc) or STANDARD_TEXT_FONT end
    return nil
end

local function GetCurrentFontPath()
    local override = LocaleFontOverride()
    if override then return override end
    local db = NHA.db
    local name = db and db.fontName or "Gotham Narrow Ultra"
    if BUNDLED_FONTS[name] then return BUNDLED_FONTS[name] end
    if LSM then
        local p = LSM:Fetch("font", name)
        if type(p) == "string" then return p:gsub("/", "\\") end
    end
    return ALERT_FONT
end

local FONT_NAMES = {}
do
    for k in pairs(BUNDLED_FONTS) do FONT_NAMES[#FONT_NAMES+1] = k end
    table.sort(FONT_NAMES)
end

-- =============================================================
-- Saved-Variable Defaults
-- =============================================================
local CORE_DEFAULTS = {
    enabled          = true,
    flash            = true,
    flashStyle       = "pulse",
    fontSize         = 25,
    fontName         = "Gotham Narrow Ultra",
    fontOutline      = "OUTLINE",
    x                = 0,
    y                = 180,
    point            = "CENTER",
    relativePoint    = "CENTER",
    relativeTo       = "UIParent",
    cufGlowEnabled   = true,
    soundChannel     = "Master",
    localeOverride   = "auto",
    ver              = 10,
}

local coreState = {
    unlocked       = false,
    testMode       = false,
    testTicker     = nil,
}

-- =============================================================
-- Utility
-- =============================================================
local function MergeDefaults(dst, src)
    for k, v in pairs(src) do
        if type(v) == "table" then
            if type(dst[k]) ~= "table" then dst[k] = {} end
            MergeDefaults(dst[k], v)
        elseif dst[k] == nil then
            dst[k] = v
        end
    end
end

local function Msg(text)
    if DEFAULT_CHAT_FRAME then
        DEFAULT_CHAT_FRAME:AddMessage(
            "|cff66ccffNem: Healer Alerts:|r " .. tostring(text))
    end
end

local function ResolveRelativeFrame(db)
    if db.relativeTo and db.relativeTo ~= "UIParent" then
        return _G[db.relativeTo] or UIParent
    end
    return UIParent
end

NHA.Msg           = Msg
NHA.issecretvalue = issecretvalue

-- =============================================================
-- Shared spec helpers
--   AlertHelpers — charge-cap / power-cap / spell-ready detection.
-- Formerly Core/AlertHelpers.lua.
-- =============================================================
NHA.AlertHelpers = NHA.AlertHelpers or {}
do
    local AH = NHA.AlertHelpers
    local _issecretvalue = issecretvalue

    local function _readNumber(v) return v + 0 end
    local function SafeNum(val, fallback)
        if val == nil then return fallback end
        if _issecretvalue and _issecretvalue(val) then return fallback end
        local ok, result = pcall(_readNumber, val)
        if ok then return result end
        return fallback
    end

    -- Helpers return the current visual-state boolean (isCapped / isReady)
    -- so callers can assign it directly to state[<key>Active], which is what
    -- the alert renderer reads (state[alert.key..'Active']). Returning the
    -- prior `fired` flag suppressed the visual entirely. See Phase A bug
    -- audit 2026-05-26.
    function AH.EvalChargeCap(stateSlot, spellID, soundKey)
        if not C_Spell or not C_Spell.GetSpellCharges then return false end
        local ok, info = pcall(C_Spell.GetSpellCharges, spellID)
        if not ok or not info then return false end
        local cur = SafeNum(info.currentCharges, nil)
        local max = SafeNum(info.maxCharges,     nil)
        if cur == nil or max == nil or max < 1 then return false end
        local isCapped = (cur >= max)
        if isCapped and not stateSlot.wasCapped then
            NHA:PlayAlertSound(soundKey)
        end
        stateSlot.current   = cur
        stateSlot.max       = max
        stateSlot.wasCapped = isCapped
        return isCapped
    end

    function AH.EvalPowerCap(stateSlot, powerTypeIndex, soundKey)
        if not UnitPower or not UnitPowerMax then return false end
        local okC, rawCur = pcall(UnitPower, "player", powerTypeIndex)
        local okM, rawMax = pcall(UnitPowerMax, "player", powerTypeIndex)
        if not okC or not okM then return false end
        local cur = SafeNum(rawCur, nil)
        local max = SafeNum(rawMax, nil)
        if cur == nil or max == nil or max < 1 then return false end
        local isCapped = (cur >= max)
        if isCapped and not stateSlot.wasCapped then
            NHA:PlayAlertSound(soundKey)
        end
        stateSlot.current   = cur
        stateSlot.max       = max
        stateSlot.wasCapped = isCapped
        return isCapped
    end

    function AH.EvalSpellReady(stateSlot, spellID, soundKey)
        if not C_Spell or not C_Spell.GetSpellCooldown then return false end
        local ok, info = pcall(C_Spell.GetSpellCooldown, spellID)
        if not ok or not info then return false end
        local start    = SafeNum(info.startTime, 0)
        local duration = SafeNum(info.duration,  0)
        -- Off CD: both fields zero. On CD: ready when start+duration has elapsed.
        -- Do NOT shortcut on duration <= 1.5 — that misreads the GCD-residue race
        -- post-cast (~1.36s value at UNIT_SPELLCAST_SUCCEEDED) as "ready" and keeps
        -- the alert active while the spell is actually on its full cooldown.
        local isReady
        if start == 0 and duration == 0 then
            isReady = true
        else
            isReady = (start + duration - GetTime()) <= 0
        end
        if isReady and not stateSlot.wasReady then
            NHA:PlayAlertSound(soundKey)
        end
        stateSlot.wasReady = isReady
        return isReady
    end
end

-- =============================================================
-- Sound Playback
-- =============================================================
-- Voice cues (sound name contains ":") are routed through a single-slot
-- player with priority preemption, a post-cue gap, a combat-start grace
-- window, and a per-alert cooldown. Generic tones bypass entirely.

local VOICE_PRIO_LOW      = 1
local VOICE_PRIO_NORMAL   = 2
local VOICE_PRIO_HIGH     = 3
local VOICE_PRIO_CRITICAL = 4
NHA.VOICE_PRIO_LOW      = VOICE_PRIO_LOW
NHA.VOICE_PRIO_NORMAL   = VOICE_PRIO_NORMAL
NHA.VOICE_PRIO_HIGH     = VOICE_PRIO_HIGH
NHA.VOICE_PRIO_CRITICAL = VOICE_PRIO_CRITICAL

local VOICE_GAP_SECONDS         = 0.7
local VOICE_COMBAT_START_WINDOW = 1.5
local VOICE_COOLDOWN_SECONDS    = 4.0
local VOICE_CUE_DURATION_GUESS  = 1.5

local voice = {
    handle          = nil,
    priority        = 0,
    expectedEnd     = 0,
    lastEnd         = 0,
    combatStart     = 0,
    combatStartUsed = false,
    perAlertLast    = {},
}

local _voicePrioCaches = setmetatable({}, { __mode = "k" })

local function _voiceTick()
    if voice.handle and GetTime() >= voice.expectedEnd then
        voice.lastEnd  = voice.expectedEnd
        voice.handle   = nil
        voice.priority = 0
    end
end

local function _voiceStopCurrent()
    if voice.handle and StopSound then
        pcall(StopSound, voice.handle)
    end
    voice.lastEnd  = GetTime()
    voice.handle   = nil
    voice.priority = 0
end

local function _voiceIsCueName(name)
    return type(name) == "string" and name:find(":", 1, true) ~= nil
end

local function _voicePriorityFor(alertKey)
    local mod = NHA.activeModule
    if not mod or not mod.alerts then return VOICE_PRIO_NORMAL end
    local cache = _voicePrioCaches[mod]
    if not cache then
        cache = {}
        for _, a in ipairs(mod.alerts) do
            cache[a.key] = a.priority or VOICE_PRIO_NORMAL
        end
        _voicePrioCaches[mod] = cache
    end
    return cache[alertKey] or VOICE_PRIO_NORMAL
end

function NHA:OnVoiceCombatStart()
    voice.combatStart     = GetTime()
    voice.combatStartUsed = false
end

function NHA:OnVoiceCombatEnd()
    voice.combatStart     = 0
    voice.combatStartUsed = false
end

local function _voiceTryPlay(path, alertKey, priority)
    local now = GetTime()
    _voiceTick()

    local last = voice.perAlertLast[alertKey]
    if last and (now - last) < VOICE_COOLDOWN_SECONDS then return false end

    local inWindow = voice.combatStart > 0
        and (now - voice.combatStart) < VOICE_COMBAT_START_WINDOW

    if voice.handle then
        if priority > voice.priority then
            _voiceStopCurrent()
        else
            return false
        end
    else
        if inWindow and voice.combatStartUsed then return false end
        if (now - voice.lastEnd) < VOICE_GAP_SECONDS and priority < VOICE_PRIO_CRITICAL then
            return false
        end
    end

    local willPlay, handle = PlaySoundFile(path, (NHA.db and NHA.db.soundChannel) or "Master")
    if willPlay and handle then
        voice.handle      = handle
        voice.priority    = priority
        voice.expectedEnd = now + VOICE_CUE_DURATION_GUESS
        voice.perAlertLast[alertKey] = now
        if inWindow then voice.combatStartUsed = true end
        return true
    end
    return false
end

function NHA:PlayAlertSound(alertKey)
    local db = self.db
    if not db then return end
    local enabledKey = alertKey .. "SoundEnabled"
    local nameKey    = alertKey .. "SoundName"
    if not db[enabledKey] then return end
    local soundName = db[nameKey]
    local path = GetSoundPath(soundName)
    if not path then return end
    if _voiceIsCueName(soundName) then
        _voiceTryPlay(path, alertKey, _voicePriorityFor(alertKey))
    else
        PlaySoundFile(path, db.soundChannel or "Master")
    end
end

-- =============================================================
-- Frame Pixel Glow
-- =============================================================
local FRAME_GLOW_KEY    = "NHA_CUF"
local FRAME_GLOW_LINES  = 8
local FRAME_GLOW_LENGTH = 48
local FRAME_GLOW_TH     = 4
local FRAME_GLOW_FREQ   = 0.15
local FRAME_GLOW_XOFF   = 0
local FRAME_GLOW_YOFF   = 0
local FRAME_GLOW_BORDER = true
local _frameGlowColor   = { 0, 0, 0, 1 }

function NHA:ShowFrameGlow(frame, r, g, b)
    if not LCG or not frame then return end
    if frame:IsForbidden() then return end
    if frame._nhaFrameGlowing
       and frame._nhaFrameGlowR == r
       and frame._nhaFrameGlowG == g
       and frame._nhaFrameGlowB == b then
        return
    end
    if frame._nhaFrameGlowing then
        LCG.PixelGlow_Stop(frame, FRAME_GLOW_KEY)
    end
    _frameGlowColor[1] = r
    _frameGlowColor[2] = g
    _frameGlowColor[3] = b
    frame._nhaFrameGlowing = true
    frame._nhaFrameGlowR   = r
    frame._nhaFrameGlowG   = g
    frame._nhaFrameGlowB   = b
    LCG.PixelGlow_Start(frame, _frameGlowColor,
        FRAME_GLOW_LINES, FRAME_GLOW_FREQ, FRAME_GLOW_LENGTH,
        FRAME_GLOW_TH, FRAME_GLOW_XOFF, FRAME_GLOW_YOFF,
        FRAME_GLOW_BORDER, FRAME_GLOW_KEY)
end

function NHA:HideFrameGlow(frame)
    if not LCG or not frame then return end
    if not frame._nhaFrameGlowing then return end
    frame._nhaFrameGlowing = nil
    frame._nhaFrameGlowR   = nil
    frame._nhaFrameGlowG   = nil
    frame._nhaFrameGlowB   = nil
    LCG.PixelGlow_Stop(frame, FRAME_GLOW_KEY)
end

-- =============================================================
-- Cached Group Unit List
-- =============================================================
local groupUnitsCache = {}
local groupUnitsDirty = true

local function InvalidateGroupUnitsCache()
    groupUnitsDirty = true
end

local function GetGroupUnits()
    if not groupUnitsDirty then return groupUnitsCache end
    groupUnitsDirty = false
    wipe(groupUnitsCache)
    groupUnitsCache[1] = "player"
    groupUnitsCache[2] = "target"
    groupUnitsCache[3] = "focus"
    if IsInRaid() then
        for i = 1, GetNumGroupMembers() do
            groupUnitsCache[#groupUnitsCache+1] = "raid"..i
        end
    elseif IsInGroup() then
        for i = 1, GetNumGroupMembers()-1 do
            groupUnitsCache[#groupUnitsCache+1] = "party"..i
        end
    end
    return groupUnitsCache
end

NHA.GetGroupUnits = GetGroupUnits

-- =============================================================
-- Spec Helpers
-- =============================================================
function NHA.GetSpecID()
    local idx = GetSpecialization()
    if not idx then return nil end

    local ok, specID = pcall(GetSpecializationInfo, idx)
    if ok and specID and not issecretvalue(specID) then
        return specID
    end

    for i = 1, 4 do
        local sOk, sID = pcall(GetSpecializationInfo, i)
        if sOk and sID and not issecretvalue(sID) then
            for _, mod in pairs(NHA.specModules) do
                if mod.class == activeClass and mod.specID == sID then
                    if GetSpecialization() == i then
                        return sID
                    end
                end
            end
        end
    end

    return nil
end

-- =============================================================
-- Forward-declare Evaluate
-- =============================================================
local Evaluate

-- =============================================================
-- ScheduleEvaluate
-- =============================================================
local evaluatePending = false
local function ScheduleEvaluate(delay)
    if evaluatePending then return end
    evaluatePending = true
    C_Timer.After(delay or 0, function()
        evaluatePending = false
        Evaluate()
    end)
end

NHA.ScheduleEvaluate = ScheduleEvaluate

-- =============================================================
-- Display Frame
-- =============================================================
local function CreateDisplay()
    local db = NHA.db

    local f = CreateFrame("Frame", "NemHealerAlertsFrame", UIParent,
        BackdropTemplateMixin and "BackdropTemplate" or nil)
    f:SetSize(900, 160)
    f:SetPoint(db.point or "CENTER", ResolveRelativeFrame(db),
               db.relativePoint or "CENTER", db.x or 0, db.y or 180)
    f:SetMovable(true)
    f:RegisterForDrag("LeftButton")

    f:SetScript("OnDragStart", function(self)
        if coreState.unlocked then self:StartMoving() end
    end)
    f:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        local point, relativeTo, relativePoint, x, y = self:GetPoint()
        NHA.db.point         = point
        NHA.db.relativeTo    = (relativeTo and relativeTo ~= UIParent) and relativeTo:GetName() or "UIParent"
        NHA.db.relativePoint = relativePoint
        NHA.db.x             = math_floor(x + 0.5)
        NHA.db.y             = math_floor(y + 0.5)
    end)

    local ag = f:CreateAnimationGroup()
    ag:SetLooping("REPEAT")
    local a1 = ag:CreateAnimation("Alpha")
    a1:SetFromAlpha(1); a1:SetToAlpha(0.15); a1:SetDuration(0.45); a1:SetOrder(1)
    local a2 = ag:CreateAnimation("Alpha")
    a2:SetFromAlpha(0.15); a2:SetToAlpha(1); a2:SetDuration(0.45); a2:SetOrder(2)

    if f.SetBackdrop then
        f:SetBackdrop({
            edgeFile = "Interface\\Buttons\\WHITE8x8",
            edgeSize = 1,
        })
        local br, bg, bb = GetClassTheme()
        f:SetBackdropBorderColor(br, bg, bb, 0)
        f.hasBorder = true
    end

    f.flashGroup = ag
    f.flashAnims = { a1, a2 }
    f.alertRows  = {}

    NHA.display = f
end

local function BuildAlertRows()
    local f   = NHA.display
    local db  = NHA.db
    local mod = NHA.activeModule
    if not f or not db or not mod then return end

    for _, row in ipairs(f.alertRows) do
        row.textFS:Hide()
        if row.timerFS then row.timerFS:Hide() end
    end
    wipe(f.alertRows)

    local fp = GetCurrentFontPath()
    local fs = db.fontSize or 25
    local scale = fs / 25

    for i, alert in ipairs(mod.alerts) do
        local col = db.alertColors and db.alertColors[alert.key]
                    or alert.defaultColor
                    or { r=1, g=1, b=1 }

        local textFS = f:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge")
        textFS:SetFont(fp, fs, db.fontOutline or "OUTLINE")
        textFS:SetTextColor(col.r, col.g, col.b)
        local customText = db[alert.key .. "AlertText"]
        textFS:SetText((customText and customText ~= "") and customText or L[alert.text])
        textFS:SetJustifyH("CENTER")
        textFS:SetShadowOffset(2, -2)
        textFS:SetPoint("CENTER", f, "CENTER", 0, (alert.yOffset or 0) * scale)
        textFS:Hide()

        local timerFS = nil
        if alert.hasTimer then
            timerFS = f:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge")
            timerFS:SetFont(fp, math_floor(fs * 0.68), db.fontOutline or "OUTLINE")
            timerFS:SetTextColor(1.0, 0.820, 0.0)
            timerFS:SetJustifyH("CENTER")
            timerFS:SetShadowOffset(1, -1)
            timerFS:SetPoint("CENTER", f, "CENTER", 0, (alert.timerYOffset or ((alert.yOffset or 0) - 20)) * scale)
            timerFS:Hide()
        end

        f.alertRows[i] = {
            textFS  = textFS,
            timerFS = timerFS,
            key     = alert.key,
            alert   = alert,
        }
    end
end

local FLASH_STYLES = {
    pulse = { from = 1, to = 0.15, dur = 0.45 },
    fade  = { from = 1, to = 0.60, dur = 0.80 },
}

local function ApplyFlashStyle()
    local f = NHA.display
    if not f or not f.flashAnims then return end
    local s = FLASH_STYLES[(NHA.db and NHA.db.flashStyle) or "pulse"] or FLASH_STYLES.pulse
    local a1, a2 = f.flashAnims[1], f.flashAnims[2]
    local playing = f.flashGroup:IsPlaying()
    if playing then f.flashGroup:Stop() end
    a1:SetFromAlpha(s.from); a1:SetToAlpha(s.to);   a1:SetDuration(s.dur)
    a2:SetFromAlpha(s.to);   a2:SetToAlpha(s.from); a2:SetDuration(s.dur)
    if playing then f.flashGroup:Play() end
end

-- =============================================================
-- ApplyDisplaySettings
-- =============================================================
local function ApplyDisplaySettings()
    local db = NHA.db
    local f  = NHA.display
    if not f or not db then return end

    f:ClearAllPoints()
    f:SetPoint(db.point or "CENTER", ResolveRelativeFrame(db),
               db.relativePoint or "CENTER", db.x or 0, db.y or 180)

    local fp = GetCurrentFontPath()
    local fs = db.fontSize or 25
    local scale = fs / 25

    for _, row in ipairs(f.alertRows) do
        local col = db.alertColors and db.alertColors[row.key]
                    or row.alert.defaultColor
                    or { r=1, g=1, b=1 }
        row.textFS:SetFont(fp, fs, db.fontOutline or "OUTLINE")
        row.textFS:SetTextColor(col.r, col.g, col.b)
        local customText = db[row.key .. "AlertText"]
        row.textFS:SetText((customText and customText ~= "") and customText or L[row.alert.text])
        row.textFS:ClearAllPoints()
        row.textFS:SetPoint("CENTER", f, "CENTER", 0, (row.alert.yOffset or 0) * scale)
        if row.timerFS then
            row.timerFS:SetFont(fp, math_floor(fs * 0.68), db.fontOutline or "OUTLINE")
            row.timerFS:ClearAllPoints()
            row.timerFS:SetPoint("CENTER", f, "CENTER", 0,
                (row.alert.timerYOffset or ((row.alert.yOffset or 0) - 20)) * scale)
        end
    end

    f:EnableMouse(coreState.unlocked)
    ApplyFlashStyle()
end

-- =============================================================
-- OnUpdate
-- =============================================================
local ArmOnUpdate  -- forward decl

local function OnUpdateBody(f)
    local db  = NHA.db
    local mod = NHA.activeModule
    if not db or not mod then return end

    local mstate  = mod.state
    local anyActive = false

    for _, row in ipairs(f.alertRows) do
        local key        = row.key
        local activeKey  = key .. "Active"
        local isActive   = mstate[activeKey]
        local enabledKey = key .. "Enabled"
        local isEnabled  = db[enabledKey]

        if isActive then
            anyActive = true
            if isEnabled then row.textFS:Show() else row.textFS:Hide() end
        else
            row.textFS:Hide()
        end

        if row.timerFS then
            local timerShownKey = key .. "TimerShown"
            local expireKey     = key .. "Expire"
            local timeModKey    = key .. "TimeMod"

            if isActive and db[timerShownKey]
               and not coreState.testMode then
                local expire  = mstate[expireKey] or 0
                local timeMod = mstate[timeModKey] or 1
                local remaining = (expire - GetTime()) / timeMod
                if remaining > 0 then
                    row.timerFS:SetText(string_format("%.1fs", remaining))
                    local thresholds = row.alert.timerThresholds
                        or { { 2.0, 0.667, 1.0, 0.0 },
                             { 1.0, 1.0, 0.816, 0.0 },
                             { 0,   1.0, 0.4,   0.0 } }
                    for _, t in ipairs(thresholds) do
                        if remaining > t[1] then
                            row.timerFS:SetTextColor(t[2], t[3], t[4])
                            break
                        end
                    end
                    row.timerFS:Show()
                else
                    row.timerFS:Hide()
                end
            elseif not coreState.testMode then
                row.timerFS:Hide()
            end
        end
    end

    if anyActive and db.flash then
        if not f.flashGroup:IsPlaying() then f.flashGroup:Play() end
    else
        if f.flashGroup:IsPlaying() then f.flashGroup:Stop() end
        f:SetAlpha(1)
    end

    if not anyActive and not coreState.testMode then
        f:SetScript("OnUpdate", nil)
        f._onUpdateArmed = false
    end
end

ArmOnUpdate = function()
    local f = NHA.display
    if not f or f._onUpdateArmed then return end
    f._onUpdateArmed = true
    f:SetScript("OnUpdate", OnUpdateBody)
end

NHA._ArmDisplayOnUpdate = ArmOnUpdate

local function SetupOnUpdate()
    ArmOnUpdate()
end

-- =============================================================
-- Evaluate
-- =============================================================
Evaluate = function()
    SafeCall("Evaluate", function()
        local db  = NHA.db
        local mod = NHA.activeModule
        if not db or not db.enabled then
            if mod then
                if mod.ClearAllState then mod:ClearAllState() end
                if mod.ClearAllGlows then mod:ClearAllGlows() end
            end
            return
        end

        if not mod then return end

        if coreState.testMode then
            local f   = NHA.display
            local now = GetTime()
            for _, alert in ipairs(mod.alerts) do
                local enabledKey = alert.key .. "Enabled"
                mod.state[alert.key .. "Active"] = db[enabledKey] and not alert.noTest
                if f and alert.hasTimer and db[enabledKey] then
                    local timerShownKey = alert.key .. "TimerShown"
                    if db[timerShownKey] then
                        local cycle = alert.timerCycleSec or 5.0
                        local remaining = cycle - (now % cycle)
                        for _, row in ipairs(f.alertRows) do
                            if row.key == alert.key and row.timerFS then
                                row.timerFS:SetText(string_format("%.1fs", remaining))
                                local thresholds = alert.timerThresholds
                                    or { { 2.0, 0.667, 1.0, 0.0 },
                                         { 1.0, 1.0, 0.816, 0.0 },
                                         { 0,   1.0, 0.4,   0.0 } }
                                for _, t in ipairs(thresholds) do
                                    if remaining > t[1] then
                                        row.timerFS:SetTextColor(t[2], t[3], t[4])
                                        break
                                    end
                                end
                                row.timerFS:Show()
                            end
                        end
                    end
                end
            end
            if NHA._ArmDisplayOnUpdate then NHA._ArmDisplayOnUpdate() end
            return
        end

        -- Single addon-wide gate. Alerts evaluate only in combat AND in a group;
        -- specs no longer carry their own UnitAffectingCombat checks. Leaving the
        -- group is a full reset. Leaving combat while still grouped PRESERVES the
        -- rising-edge latches across the boundary (per [[nha-combat-gate-rule]]) --
        -- only glows are cleared -- so the next combat's first eval fires on genuine
        -- transitions instead of re-tripping every true alert at pull start.
        if not IsInGroup() then
            if mod.ClearAllState then mod:ClearAllState() end
            if mod.ClearAllGlows then mod:ClearAllGlows() end
            return
        end
        if not UnitAffectingCombat("player") then
            -- Zero all display-active fields so alert text disappears out of combat.
            -- *WasActive latches are intentionally untouched -- preserved across the
            -- combat boundary per [[nha-combat-gate-rule]] so the first in-combat eval
            -- only re-announces genuine state transitions, not already-shown alerts.
            if mod.alerts and mod.state then
                for _, alert in ipairs(mod.alerts) do
                    mod.state[alert.key .. "Active"] = false
                end
            end
            if mod.ClearAllGlows then mod:ClearAllGlows() end
            return
        end

        SafeCall("Evaluate:" .. (mod.specName or mod.key or "?"), function()
            mod:Evaluate(db)
        end)

        if mod.RefreshGlows then
            SafeCall("RefreshGlows:" .. (mod.specName or mod.key or "?"), function()
                mod:RefreshGlows(db)
            end)
        end

        if NHA._ArmDisplayOnUpdate then NHA._ArmDisplayOnUpdate() end
    end)
end

NHA.Evaluate = function() Evaluate() end

-- =============================================================
-- Lock / Unlock
-- =============================================================
local function SetLockState(unlocked)
    coreState.unlocked = unlocked
    local f = NHA.display
    if f then
        f:EnableMouse(unlocked)
        if f.hasBorder then
            local br, bg, bb = GetClassTheme()
            f:SetBackdropBorderColor(br, bg, bb, unlocked and 0.75 or 0)
        end
    end
    if NHA.lockBtn then
        NHA.lockBtn:SetText(unlocked and L["Lock Frame"] or L["Unlock Frame"])
    end
    Msg(unlocked
        and L["Frame |cff00ff00unlocked|r — drag to reposition."]
         or L["Frame |cffff4444locked|r."])
end

-- =============================================================
-- Module Activation / Deactivation
-- =============================================================
local registeredExtraEvents = {}

local function DeactivateCurrentModule()
    local mod = NHA.activeModule
    if not mod then return end

    if mod.OnDeactivate then mod:OnDeactivate(NHA.db) end
    if mod.ClearAllState then mod:ClearAllState() end
    if mod.ClearAllGlows then mod:ClearAllGlows() end

    for _, ev in ipairs(registeredExtraEvents) do
        NHA:UnregisterEvent(ev)
    end
    wipe(registeredExtraEvents)
    wipe(voice.perAlertLast)

    NHA.activeModule    = nil
    NHA.activeModuleKey = nil
end

local function ActivateModule(key, mod)
    DeactivateCurrentModule()

    NHA.activeModule    = mod
    NHA.activeModuleKey = key

    if mod.defaults then
        MergeDefaults(NHA.db, mod.defaults)
    end

    NHA.db.alertColors = NHA.db.alertColors or {}
    for _, alert in ipairs(mod.alerts) do
        if not NHA.db.alertColors[alert.key] then
            NHA.db.alertColors[alert.key] = CopyTable(alert.defaultColor
                or { r=1, g=1, b=1 })
        end
    end

    if mod.extraEvents then
        for _, ev in ipairs(mod.extraEvents) do
            registeredExtraEvents[#registeredExtraEvents+1] = ev
            NHA:RegisterEvent(ev)
        end
    end

    BuildAlertRows()
    SetupOnUpdate()
    ApplyDisplaySettings()

    if mod.OnActivate then mod:OnActivate(NHA.db) end
    Evaluate()
end

-- =============================================================
-- Init: Database
-- =============================================================
function NHA:InitDatabase()
    if NemHotAlertsSV and not NemHealerAlertsSV then
        NemHealerAlertsSV = NemHotAlertsSV
        NemHotAlertsSV = nil
    end

    local sv = NemHealerAlertsSV or {}
    NemHealerAlertsSV = sv

    -- One-time wrap into a profile container: pre-profile saves stored settings flat at the SV
    -- root. Fold the whole thing into a "Default" profile so nothing is lost; later loads see
    -- sv.profiles and skip this. The ver-migrations below then run on the active profile table.
    if not sv.profiles then
        if next(sv) ~= nil then
            local old = {}
            for k, v in pairs(sv) do old[k] = v; sv[k] = nil end
            sv.profiles = { Default = old }
        else
            sv.profiles = {}
        end
    end

    local charKey = (UnitName("player") or "?") .. " - " .. (GetRealmName() or "?")
    local UI = ns.UI
    local db
    if UI and UI.MakeProfileDB then
        self.profiles = UI.MakeProfileDB(sv, charKey, "Default")
        db = self.profiles:ActiveTable()
    else
        sv.profiles.Default = sv.profiles.Default or {}
        db = sv.profiles.Default
    end

    if (db.ver or 0) < 5 then
        local DISC_KEYS = {
            "atonEnabled", "atonSoundEnabled", "atonSoundName", "atonTimerShown",
            "vsEnabled",   "vsSoundEnabled",   "vsSoundName",   "vsGlowEnabled",
        }
        for _, k in ipairs(DISC_KEYS) do
            db[k] = nil
        end
        if db.alertColors then
            db.alertColors.aton = nil
            db.alertColors.vs   = nil
        end
        db.ver = 5
    end

    if (db.ver or 0) < 6 then
        if db.glowEnabled == false then
            db.cufGlowEnabled = false
        end
        db.glowEnabled        = nil
        db.empRegrowthEnabled = nil
        if db.alertColors then
            db.alertColors.sm = nil
        end
        db.ver = 6
    end

    if (db.ver or 0) < 7 then
        db.scale = nil
        db.ver = 7
    end

    if (db.ver or 0) < 8 then
        db.lbSoundName        = nil
        db.sotfSoundName      = nil
        db.wgSoundName        = nil
        db.abundanceSoundName = nil
        db.rmSoundName        = nil
        db.envSoundName       = nil
        db.vsSoundName        = nil
        db.atonSoundName      = nil
        db.pwsSoundName       = nil
        db.ver = 8
    end

    if (db.ver or 0) < 9 then
        db.lbTimerShown   = nil
        db.sotfTimerShown = nil
        db.ver = 9
    end

    if (db.ver or 0) < 10 then
        db.esSoundName  = nil
        db.rtSoundName  = nil
        db.hstSoundName = nil
        db.ulSoundName  = nil
        db.ulbSoundName = nil
        db.ver = 10
    end

    MergeDefaults(db, CORE_DEFAULTS)
    self.db = db

    local loc = db.localeOverride
    if not loc or loc == "auto" then loc = GetLocale() end
    L:ApplyLocale(loc)
end

-- =============================================================
-- Test Mode
-- =============================================================
function NHA:ToggleTest()
    if not IsFullyImplemented() then return end
    coreState.testMode = not coreState.testMode
    if coreState.testMode then
        if not coreState.testTicker then
            coreState.testTicker = C_Timer.NewTicker(0.1, Evaluate)
        end
    else
        if coreState.testTicker then coreState.testTicker:Cancel(); coreState.testTicker = nil end
        local mod = self.activeModule
        if mod then
            for _, alert in ipairs(mod.alerts) do
                mod.state[alert.key .. "Active"] = false
            end
        end
        local f = self.display
        if f then
            f:SetAlpha(1)
            if f.flashGroup:IsPlaying() then f.flashGroup:Stop() end
        end
    end
    if NHA.testBtn then
        NHA.testBtn:SetText(coreState.testMode and L["Stop Test"] or L["Test"])
    end
    Evaluate()
end

-- =============================================================
-- Init: Slash Commands
-- =============================================================
function NHA:InitCommands()
    local function HandleNHACommand(input)
        input = (input or ""):lower():match("^%s*(.-)%s*$")

        local NEEDS_SUPPORT = {
            on=true, off=true, toggle=true, test=true,
            lock=true, unlock=true, reset=true,
        }
        if NEEDS_SUPPORT[input] and not IsFullyImplemented() then
            Msg(L["Your current spec is not supported."])
            return
        end

        local mod = self.activeModule
        if mod and mod.slashCommands and mod.slashCommands[input] then
            mod.slashCommands[input](mod, self.db)
            return
        end

        if input == "on" then
            self.db.enabled = true
            Evaluate()
            Msg(L["Addon |cff00ff41enabled|r."])
            return
        end

        if input == "off" then
            self.db.enabled = false
            if mod and mod.ClearAllState then mod:ClearAllState() end
            if NHA.display then NHA.display:SetAlpha(1) end
            if coreState.testMode then NHA:ToggleTest() end
            Evaluate()
            Msg(L["Addon |cffff4040disabled|r."])
            return
        end

        if input == "toggle" then
            if self.db.enabled then
                self.db.enabled = false
                if mod and mod.ClearAllState then mod:ClearAllState() end
                if NHA.display then NHA.display:SetAlpha(1) end
                if coreState.testMode then NHA:ToggleTest() end
                Msg(L["Addon |cffff4040disabled|r."])
            else
                self.db.enabled = true
                Msg(L["Addon |cff00ff41enabled|r."])
            end
            Evaluate()
            return
        end

        if input == "test" then
            if not self.db.enabled then
                Msg(L["Addon is disabled. Use |cffffd700/nha on|r first."])
                return
            end
            NHA:ToggleTest()
            Msg(coreState.testMode and L["Test mode |cff00ff41on|r."] or L["Test mode |cffff4040off|r."])
            return
        end

        if input == "status" then
            local enabledStr = self.db.enabled
                and L["|cff00ff41Enabled|r"] or L["|cffff4040Disabled|r"]
            local testStr = coreState.testMode
                and L["|cffffff00On|r"] or L["Off"]
            local classStr = activeClass or L["Unknown"]
            Msg(string_format(L["Status: %s  |  Test Mode: %s  |  Class: %s"],
                enabledStr, testStr, classStr))
            return
        end

        if input == "help" then
            Msg(L["|cffffd700Nem: Healer Alerts — Commands:|r"])
            Msg(L["  |cffffd700/nha|r  —  Open options"])
            Msg(L["  |cffffd700/nha on|r  —  Enable addon"])
            Msg(L["  |cffffd700/nha off|r  —  Disable addon"])
            Msg(L["  |cffffd700/nha toggle|r  —  Toggle on/off"])
            Msg(L["  |cffffd700/nha test|r  —  Toggle test mode"])
            Msg(L["  |cffffd700/nha status|r  —  Show current status"])
            Msg(L["  |cffffd700/nha reset|r  —  Reset all settings"])
            Msg(L["  |cffffd700/nha version|r  —  Show addon version"])
            Msg(L["  |cffffd700/nha help|r  —  Show this list"])
            Msg(L["  (All commands also work with |cffffd700/healeralerts|r)"])
            if mod and mod.slashCommands then
                for cmd, _ in pairs(mod.slashCommands) do
                    Msg("  |cffffd700/nha " .. cmd .. "|r" .. L["  —  (spec-specific)"])
                end
            end
            return
        end

        if input == "version" then
            Msg("Nem: Healer Alerts " .. NHA_VERSION)
            return
        end

        if input == "reset" then
            NemHealerAlertsSV = nil
            NemHotAlertsSV    = nil
            Msg(L["Saved variables reset. Reloading UI..."])
            C_Timer.After(0.5, ReloadUI)
            return
        end

        if input == "unlock" then SetLockState(true);  return end
        if input == "lock"   then SetLockState(false); return end

        if input == "debug" then
            NHA:RunDebug()
            return
        end

        if NHA.optionsCategoryID then
            if InCombatLockdown() then
                Msg(L["Can't open options during combat."])
                return
            end
            if Settings and Settings.OpenToCategory then
                NHA._panelWasOpen = true
                Settings.OpenToCategory(NHA.optionsCategoryID)
            end
        end
    end

    SLASH_NEMHEALERALERTS1 = "/nha"
    SLASH_NEMHEALERALERTS2 = "/healeralerts"
    SlashCmdList.NEMHEALERALERTS = HandleNHACommand

    if AddonCompartmentFrame then
        AddonCompartmentFrame:RegisterAddon({
            text = "Nem: Healer Alerts", notCheckable = true,
            func = function()
                if InCombatLockdown() then
                    Msg(L["Can't open options during combat."])
                else
                    if NHA.optionsCategoryID and Settings and Settings.OpenToCategory then
                        NHA._panelWasOpen = true
                        Settings.OpenToCategory(NHA.optionsCategoryID)
                    end
                end
            end,
        })
    end
end

-- =============================================================
-- CompactUnitFrame Whole-Frame Glow Hooks
-- =============================================================
NHA._cufPool   = {}
NHA._unitCUFs  = {}

local _cufEventFrame

local function NHA_UpdateCUFFrame(frame)
    local mod = NHA.activeModule
    if not mod or not mod.EvaluateCUFGlow then
        if frame._nhaFrameGlowing then NHA:HideFrameGlow(frame) end
        return
    end
    local db = NHA.db
    if not db or not db.enabled or not db.cufGlowEnabled then
        if frame._nhaFrameGlowing then NHA:HideFrameGlow(frame) end
        return
    end
    -- Same addon-wide gate as Evaluate: glows only show in combat AND in a group.
    if not IsInGroup() or not UnitAffectingCombat("player") then
        if frame._nhaFrameGlowing then NHA:HideFrameGlow(frame) end
        return
    end
    local unit = frame.displayedUnit or frame.unit
    if not unit or not UnitExists(unit) then
        if frame._nhaFrameGlowing then NHA:HideFrameGlow(frame) end
        return
    end
    mod:EvaluateCUFGlow(db, frame, unit)
end

NHA._UpdateCUFFrame = NHA_UpdateCUFFrame

local function NHA_TrackCUF(frame)
    NHA._cufPool[frame] = true
    local unit = frame.displayedUnit or frame.unit
    if frame._nhaCUFUnit ~= unit then
        if frame._nhaCUFUnit and NHA._unitCUFs[frame._nhaCUFUnit] then
            NHA._unitCUFs[frame._nhaCUFUnit][frame] = nil
        end
        frame._nhaCUFUnit = unit
        if unit then
            NHA._unitCUFs[unit] = NHA._unitCUFs[unit] or {}
            NHA._unitCUFs[unit][frame] = true
        end
    end
    NHA_UpdateCUFFrame(frame)
end

local function NHA_UntrackCUF(frame)
    NHA._cufPool[frame] = nil
    if frame._nhaCUFUnit and NHA._unitCUFs[frame._nhaCUFUnit] then
        NHA._unitCUFs[frame._nhaCUFUnit][frame] = nil
    end
    frame._nhaCUFUnit = nil
    if frame._nhaFrameGlowing then NHA:HideFrameGlow(frame) end
end

function NHA:RefreshAllCUFs()
    for frame in pairs(self._cufPool) do
        NHA_UpdateCUFFrame(frame)
    end
end

function NHA:RefreshCUFsForUnit(unit)
    local set = self._unitCUFs[unit]
    if not set then return end
    for frame in pairs(set) do
        NHA_UpdateCUFFrame(frame)
    end
end

function NHA:HideAllCUFGlows()
    for frame in pairs(self._cufPool) do
        if frame._nhaFrameGlowing then self:HideFrameGlow(frame) end
    end
end

local _unitAuraIndex = {}

local function _AuraIndexFor(unit)
    local idx = _unitAuraIndex[unit]
    if not idx then idx = {}; _unitAuraIndex[unit] = idx end
    return idx
end

local function CUFDeltaIsRelevant(unit, updateInfo)
    local mod = NHA.activeModule
    if not mod then return false end
    local relevant = mod.cufRelevantSpells
    if not relevant then return true end

    if not updateInfo or updateInfo.isFullUpdate then
        _unitAuraIndex[unit] = nil
        return true
    end

    local idx = _AuraIndexFor(unit)
    local hit = false

    if updateInfo.addedAuras then
        for _, aura in ipairs(updateInfo.addedAuras) do
            local sid = aura.spellId
            local iid = aura.auraInstanceID
            if sid and not issecretvalue(sid) then
                if iid and not issecretvalue(iid) then
                    idx[iid] = sid
                end
                if relevant[sid] then hit = true end
            elseif iid and not issecretvalue(iid) then
                idx[iid] = true
                hit = true
            end
        end
    end

    if updateInfo.updatedAuraInstanceIDs then
        for _, iid in ipairs(updateInfo.updatedAuraInstanceIDs) do
            local cached = idx[iid]
            if cached == nil then
                hit = true
            elseif cached == true or relevant[cached] then
                hit = true
            end
        end
    end

    if updateInfo.removedAuraInstanceIDs then
        for _, iid in ipairs(updateInfo.removedAuraInstanceIDs) do
            local cached = idx[iid]
            if cached == nil then
                hit = true
            elseif cached == true or relevant[cached] then
                hit = true
            end
            idx[iid] = nil
        end
    end

    return hit
end

local function _PurgeUnitAuraIndex(unit)
    if unit then
        _unitAuraIndex[unit] = nil
    else
        wipe(_unitAuraIndex)
    end
end
NHA._PurgeUnitAuraIndex = _PurgeUnitAuraIndex

function NHA:InitCUFHooks()
    if self._cufHooksInstalled then return end

    local hookOk, hookErr = pcall(hooksecurefunc,
        "CompactUnitFrame_UpdateAll", function(frame)
            if frame:IsForbidden() then
                if NHA._cufPool[frame] then NHA_UntrackCUF(frame) end
                return
            end
            local frameName = frame:GetName()
            if type(frameName) == "string"
               and string_match(frameName, "^Compact") then
                NHA_TrackCUF(frame)
            else
                if NHA._cufPool[frame] then NHA_UntrackCUF(frame) end
            end
        end)
    if not hookOk then
        Msg("|cffff4040CUF hook install failed:|r " .. tostring(hookErr))
        return
    end

    _cufEventFrame = CreateFrame("Frame")
    _cufEventFrame:RegisterEvent("UNIT_AURA")
    _cufEventFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
    _cufEventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
    _cufEventFrame:SetScript("OnEvent", function(_, event, ...)
        if event == "UNIT_AURA" then
            local unit, updateInfo = ...
            if not NHA._unitCUFs[unit] then return end
            if not CUFDeltaIsRelevant(unit, updateInfo) then return end
            NHA:RefreshCUFsForUnit(unit)
        elseif event == "GROUP_ROSTER_UPDATE" then
            _PurgeUnitAuraIndex()
            for frame in pairs(NHA._cufPool) do
                NHA_TrackCUF(frame)
            end
        elseif event == "PLAYER_ENTERING_WORLD" then
            _PurgeUnitAuraIndex()
            NHA:ScanExistingCUFs()
        end
    end)

    self:ScanExistingCUFs()

    self._cufHooksInstalled = true
end

-- =============================================================
-- ScanExistingCUFs
-- =============================================================
function NHA:ScanExistingCUFs()
    local f = EnumerateFrames()
    while f do
        local ok, isForbidden = pcall(f.IsForbidden, f)
        if ok and not isForbidden then
            local nameOk, name = pcall(f.GetName, f)
            if nameOk and type(name) == "string"
               and string_match(name, "^Compact")
               and (f.unit or f.displayedUnit) then
                NHA_TrackCUF(f)
            end
        end
        f = EnumerateFrames(f)
    end
end

-- =============================================================
-- Debug Command
-- =============================================================
local function _dbgClassLine(activeCls, modKey, enabled)
    Msg("class=" .. tostring(activeCls)
        .. "  module=" .. tostring(modKey)
        .. "  enabled=" .. tostring(enabled))
end
local function _dbgGroupLine()
    Msg("inGroup=" .. tostring(IsInGroup())
        .. "  inRaid=" .. tostring(IsInRaid())
        .. "  members=" .. tostring(GetNumGroupMembers()))
end
local function _dbgLCGLine(lcg, cufGlowEnabled)
    local lcgStr = lcg and "loaded" or "not loaded"
    Msg("LibCustomGlow=" .. lcgStr
        .. "  cufGlowEnabled=" .. tostring(cufGlowEnabled))
end
local function _dbgCUFLine(installed, total, glowing)
    Msg("cufHooks=" .. (installed and "installed" or "not installed")
        .. "  tracked=" .. tostring(total)
        .. "  glowing=" .. tostring(glowing))
end

function NHA:RunDebug()
    local db  = self.db
    local mod = self.activeModule
    local ok, err

    Msg("--- NemHealerAlerts Debug ---")

    ok, err = pcall(_dbgClassLine, activeClass, self.activeModuleKey, db and db.enabled)
    if not ok then Msg("ERROR(1): " .. tostring(err)) end

    ok, err = pcall(_dbgGroupLine)
    if not ok then Msg("ERROR(2): " .. tostring(err)) end

    ok, err = pcall(_dbgLCGLine, LCG, db and db.cufGlowEnabled)
    if not ok then Msg("ERROR(3): " .. tostring(err)) end

    local cufCount = 0
    if self._cufPool then
        for _ in pairs(self._cufPool) do cufCount = cufCount + 1 end
    end
    local glowingCount = 0
    if self._cufPool then
        for f in pairs(self._cufPool) do
            if f._nhaFrameGlowing then glowingCount = glowingCount + 1 end
        end
    end
    ok, err = pcall(_dbgCUFLine, self._cufHooksInstalled, cufCount, glowingCount)
    if not ok then Msg("ERROR(4): " .. tostring(err)) end

    if mod and mod.Debug then
        mod:Debug(db)
    end
end

-- =============================================================
-- Options Panel (built on NemFramework / ns.UI)
-- =============================================================
function NHA:BuildOptionsPanel()
    local db = self.db
    local UI = ns.UI
    if not UI then return end

    local panel = UI:CreatePanel({
        frameName   = ADDON_NAME .. "OptionsPanel",
        name        = "Nem: Healer Alerts",
        title       = "Nem: Healer Alerts",
        version     = NHA_VERSION,
        description = L["Healer alerts, procs, and countdown timers. Type /nha to open this panel."],
        theme       = GetClassTheme,
        uiFont      = function() return LocaleFontOverride() or UI_FONT end,
    })
    NHA.uiPanel      = panel
    NHA.optionsPanel = panel.root
    panel:Register()
    NHA.optionsCategoryID = panel.categoryID

    -- Close the GameMenu after the Settings panel hides, but only when we opened it (via /nha).
    if SettingsPanel then
        SettingsPanel:HookScript("OnHide", function()
            if NHA._panelWasOpen then
                NHA._panelWasOpen = false
                C_Timer.After(0, function()
                    if GameMenuFrame and GameMenuFrame:IsShown() then
                        HideUIPanel(GameMenuFrame)
                    end
                end)
            end
        end)
    end

    local _, genT  = panel:AddTab(L["General"])
    local _, audT  = panel:AddTab(L["Audio"])
    local _, alrtT = panel:AddTab(L["Visual"])
    local _, proT  = panel:AddTab(L["Profiles"])

    local genL  = panel:Layout(genT)
    local audL  = panel:Layout(audT)
    local alrtL = panel:Layout(alrtT)

    -- ============================================================
    -- General (static)
    -- ============================================================
    genL:Section(L["Display"])
    local cbEnable = genL:Checkbox({
        label = L["Enable Addon"],
        get   = function() return db.enabled end,
        set   = function(v)
            db.enabled = v and true or false
            if not db.enabled then
                local mod = NHA.activeModule
                if mod and mod.ClearAllState then mod:ClearAllState() end
                if NHA.display then NHA.display:SetAlpha(1) end
                if coreState.testMode then NHA:ToggleTest() end
            end
        end,
    })
    local cbGlow = genL:Checkbox({
        label = L["Glow Raid Frames"],
        get   = function() return db.cufGlowEnabled end,
        set   = function(v)
            db.cufGlowEnabled = v and true or false
            if not db.cufGlowEnabled then
                local mod = NHA.activeModule
                if mod and mod.ClearAllGlows then mod:ClearAllGlows() end
                NHA:HideAllCUFGlows()
            elseif NHA.RefreshAllCUFs then
                NHA:RefreshAllCUFs()
            end
        end,
    })

    genL:Section(L["Typography"])
    local fsSlider = genL:Slider({
        label = L["Font Size"] .. ":", min = 1, max = 100, step = 1,
        get   = function() return db.fontSize or 25 end,
        set   = function(v) db.fontSize = v; ApplyDisplaySettings() end,
    })

    local fontPool = {}
    local fontDD = genL:Dropdown({ name = "NemHealerAlertsFontDrop", label = L["Alert Font"] .. ":" })
    fontDD:SetDefaultText(db.fontName or "Gotham Narrow Ultra")
    NHA.fontDropdown = fontDD
    local function BuildFontMenu()
        fontDD:SetupMenu(function(_, root)
            root:SetScrollMode(12 * 16)
            for _, name in ipairs(FONT_NAMES) do
                local path = BUNDLED_FONTS[name]
                if not path and LSM then
                    local p = LSM:Fetch("font", name)
                    if p then path = p:gsub("/", "\\") end
                end
                local n = name
                local btn = root:CreateButton("                                        ", function()
                    db.fontName = n; fontDD:SetDefaultText(n); ApplyDisplaySettings()
                end)
                if path then
                    btn:AddInitializer(function(b)
                        local fs = fontPool[n]
                        if not fs then fs = fontDD:CreateFontString(nil, "BACKGROUND"); fontPool[n] = fs end
                        fs:SetParent(b); fs:ClearAllPoints(); fs:SetPoint("LEFT", b, "LEFT", 5, 0)
                        fs:SetFont(path, 12); fs:SetText(n); fs:Show()
                    end)
                end
            end
        end)
    end
    hooksecurefunc(fontDD, "OnMenuClosed", function()
        for _, fs in pairs(fontPool) do fs:Hide() end
    end)
    C_Timer.After(0, BuildFontMenu)
    fontDD.Refresh = function()
        fontDD:SetDefaultText(db.fontName or "Gotham Narrow Ultra")
        BuildFontMenu()
    end

    local OUTLINE_OPTS = {
        { text = L["None"],          value = "" },
        { text = L["Outline"],       value = "OUTLINE" },
        { text = L["Thick Outline"], value = "THICKOUTLINE" },
    }
    local function OutlineDisplayText()
        local v = db.fontOutline or "OUTLINE"
        for _, o in ipairs(OUTLINE_OPTS) do if o.value == v then return o.text end end
        return L["Outline"]
    end
    local outlineDD = genL:Dropdown({ name = "NemHealerAlertsOutlineDrop", label = L["Font Outline"] .. ":" })
    outlineDD:SetDefaultText(OutlineDisplayText())
    outlineDD:SetupMenu(function(_, root)
        for _, o in ipairs(OUTLINE_OPTS) do
            local opt = o
            root:CreateRadio(opt.text,
                function() return (db.fontOutline or "OUTLINE") == opt.value end,
                function() db.fontOutline = opt.value; outlineDD:SetDefaultText(opt.text); ApplyDisplaySettings() end)
        end
    end)

    genL:Section(L["Localization"])
    local function LangDisplayText()
        local ov = db.localeOverride
        if not ov or ov == "auto" then return L["Auto (client default)"] end
        return L:GetDisplayName(ov)
    end
    local langDD = genL:Dropdown({ name = "NemHealerAlertsLangDrop", label = L["Language"] .. ":" })
    langDD:SetDefaultText(LangDisplayText())
    langDD:SetupMenu(function(_, root)
        root:CreateButton(L["Auto (client default)"], function() db.localeOverride = "auto"; ReloadUI() end)
        for _, info in ipairs(L:GetAvailableLocales()) do
            local key = info.Key
            root:CreateButton(info.Name, function() db.localeOverride = key; ReloadUI() end)
        end
    end)
    genL:Finish()

    -- ============================================================
    -- Profiles
    -- ============================================================
    panel:BuildProfilesTab(proT, {
        mgr    = NHA.profiles,
        reload = ReloadUI,
        wrap   = function(active) return { _nha = true, v = NHA_VERSION, data = active } end,
        unwrap = function(p)
            if type(p) ~= "table" or p._nha ~= true or type(p.data) ~= "table" then return nil end
            return p.data
        end,
        text = {
            current       = L["Current Profile:"],
            new           = L["New Profile"],
            add           = L["Add"],
            existing      = L["Existing Profiles"],
            copyFrom      = L["Copy From"],
            delete        = L["Delete a Profile"],
            export        = L["Export"],
            import        = L["Import"],
            exported      = L["Profile exported. Press Ctrl+C to copy."],
            imported      = L["Settings imported. Reloading UI..."],
            invalid       = L["That import string is not valid."],
            notMine       = L["That string is not a Nem: Healer Alerts profile."],
            confirmImport = L["Overwrite the active profile with the imported settings?"],
            confirmCopy   = L["Overwrite the active profile with \"%s\"?"],
            confirmDelete = L["Delete the profile \"%s\"?"],
        },
    })

    -- ============================================================
    -- Shared builders for the dynamic tabs
    -- ============================================================
    -- Call ThemedEdit only inside the Rebuild/Rewind cycle: each call registers a TrackColor
    -- entry that the next panel:Rewind() drops, so they don't accumulate across rebuilds.
    local function ThemedEdit(parent, w, h, maxLetters)
        local box = CreateFrame("EditBox", nil, parent, "InputBoxTemplate")
        box:SetSize(w, h)
        box:SetAutoFocus(false)
        box:SetFontObject("GameFontWhite")
        panel:Reface(box)
        if maxLetters then box:SetMaxLetters(maxLetters) end
        panel:TrackColor(function(r, g, b) box:SetTextColor(r, g, b) end)
        return box
    end

    -- Sound-trigger dropdown: the active class's TTS cues first, then generic tones.
    local function SetupSoundMenu(dd, getSound, setSound)
        local function build()
            dd:SetupMenu(function(_, root)
                root:SetScrollMode(12 * 16)
                local activePrefix = GetActiveClassVoicePrefix()
                local ttsMatch     = activePrefix and ("^" .. activePrefix .. ":") or nil
                local function ShowName(name)
                    if IsTTSSound(name) then return ttsMatch and name:find(ttsMatch) ~= nil end
                    return true
                end
                local hasTTS, hasGeneric = false, false
                for _, name in ipairs(SOUND_NAMES) do
                    if ShowName(name) then
                        if IsTTSSound(name) then hasTTS = true else hasGeneric = true end
                    end
                end
                local showHeaders = hasTTS and hasGeneric
                local ttsHeader, genericHeader = false, false
                for _, name in ipairs(SOUND_NAMES) do
                    if ShowName(name) then
                        if IsTTSSound(name) then
                            if showHeaders and not ttsHeader then root:CreateTitle(L["Class TTS"]); ttsHeader = true end
                        else
                            if showHeaders and not genericHeader then root:CreateTitle(L["Generic"]); genericHeader = true end
                        end
                        local n = name
                        root:CreateRadio(L[n], function() return getSound() == n end,
                            function() setSound(n); dd:SetDefaultText(L[n]) end, n)
                    end
                end
            end)
        end
        C_Timer.After(0, build)
        dd:SetDefaultText(L[getSound() or ""])
    end

    -- Unsupported specs gate the WHOLE panel behind one centered message (tabs, tab content, and
    -- bottom buttons all hidden) — matching the pre-framework UX. Built once before Mark() so the
    -- tracked fontstrings are never Rewound; Track recolors them with the class theme and uiFontPath
    -- keeps them CJK-correct.
    local gate = CreateFrame("Frame", nil, panel.root)
    gate:SetPoint("TOPLEFT",     panel.root, "TOPLEFT",      16, -100)
    gate:SetPoint("BOTTOMRIGHT", panel.root, "BOTTOMRIGHT", -16,   44)
    gate:Hide()
    local gateHeader = gate:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    gateHeader:SetFont(panel.uiFontPath or gateHeader:GetFont(), 18, "OUTLINE")
    gateHeader:SetPoint("CENTER", gate, "CENTER", 0, 12)
    gateHeader:SetText(L["Spec Not Supported"])
    panel:Track(gateHeader)
    local gateSub = gate:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    gateSub:SetFont(panel.uiFontPath or gateSub:GetFont(), 12, "")
    gateSub:SetPoint("TOP", gateHeader, "BOTTOM", 0, -8)
    gateSub:SetWidth(420); gateSub:SetJustifyH("CENTER")
    gateSub:SetText(L["Only Healer Specs Supported"])
    panel:Track(gateSub)

    local function ShowGate()
        panel.tabBar:Hide()
        for _, t in ipairs(panel.tabs)    do t.container:Hide() end
        for _, b in ipairs(panel.buttons) do b:Hide() end
        gate:Show()
    end
    local function HideGate()
        gate:Hide()
        panel.tabBar:Show()
        for _, b in ipairs(panel.buttons) do b:Show() end
        -- Restores the container ShowGate cleared; safe because Rebuild only calls this after
        -- confirming a supported, tab-populated spec (SelectTab on zero tabs would show nothing).
        panel:SelectTab(panel.activeTab or 1)
    end

    -- ============================================================
    -- Dynamic rebuild (per active spec): Audio + Alerts tabs
    -- ============================================================
    -- aoWidgets holds the spec's BuildAlertOptions children (raw frames the layout can't track);
    -- they are hidden + dropped each rebuild. One Mark covers both dynamic tabs; Rewind drops the
    -- theme entries their widgets registered after the mark.
    local aoWidgets = {}
    local baseFS, baseBox = panel:Mark()

    local function Rebuild()
        local mod = NHA.activeModule
        if not IsFullyImplemented() or not mod or not mod.alerts then
            ShowGate()
            panel:RefreshTheme()
            return
        end
        HideGate()

        cbEnable.Refresh(); cbGlow.Refresh()
        fsSlider.Refresh()
        fontDD:SetDefaultText(db.fontName or "Gotham Narrow Ultra")
        outlineDD:SetDefaultText(OutlineDisplayText())
        langDD:SetDefaultText(LangDisplayText())
        panel:RefreshButtons()

        for _, w in ipairs(aoWidgets) do if w.Hide then w:Hide() end end
        wipe(aoWidgets)
        panel:Rewind(baseFS, baseBox)
        audL:Reset(); alrtL:Reset()

        -- Audio tab: global output channel, then one sound row per alert.
        audL:Section(L["Global Audio Settings"])
        local CHANNELS = {
            { text = L["Master"],   value = "Master" },
            { text = L["Music"],    value = "Music" },
            { text = L["Effects"],  value = "SFX" },
            { text = L["Ambience"], value = "Ambience" },
            { text = L["Dialog"],   value = "Dialog" },
        }
        local function ChannelText()
            local v = db.soundChannel or "Master"
            for _, c in ipairs(CHANNELS) do if c.value == v then return c.text end end
            return L["Master"]
        end
        local chanDD = audL:Dropdown({ label = L["Output Channel"] .. ":", width = 160 })
        chanDD:SetupMenu(function(_, root)
            for _, c in ipairs(CHANNELS) do
                local item = c
                root:CreateRadio(item.text,
                    function() return (db.soundChannel or "Master") == item.value end,
                    function() db.soundChannel = item.value; chanDD:SetDefaultText(item.text) end)
            end
        end)
        chanDD:SetDefaultText(ChannelText())

        audL:Section(L["Event Audio Triggers"])
        for _, alert in ipairs(mod.alerts) do
            if not alert.noSound then
                local k = alert.key
                local _, ddS = audL:SoundRow({
                    label       = L[alert.label],
                    previewText = L["Preview"],
                    getEnabled  = function() return db[k .. "SoundEnabled"] end,
                    setEnabled  = function(v) db[k .. "SoundEnabled"] = v end,
                    onPreview   = function()
                        local p = GetSoundPath(db[k .. "SoundName"])
                        if p then PlaySoundFile(p, db.soundChannel or "Master") end
                    end,
                })
                SetupSoundMenu(ddS,
                    function() return db[k .. "SoundName"] end,
                    function(v) db[k .. "SoundName"] = v end)
            end
        end
        audL:Finish()

        -- Visual tab: flash style, then spec-specific options, then alert text & colors.
        alrtL:Section(L["Flash Animations"])
        local flashDD = alrtL:Dropdown({ label = L["Flash Style"] .. ":", width = 160 })
        flashDD:SetupMenu(function(_, root)
            local STYLES = {
                { text = L["Static"],        value = "static" },
                { text = L["Subtle Fade"],   value = "fade" },
                { text = L["Intense Pulse"], value = "pulse" },
            }
            local function current() return (not db.flash) and "static" or (db.flashStyle or "pulse") end
            for _, s in ipairs(STYLES) do
                local item = s
                root:CreateRadio(item.text,
                    function() return current() == item.value end,
                    function()
                        if item.value == "static" then
                            db.flash = false
                            local f = NHA.display
                            if f then
                                if f.flashGroup:IsPlaying() then f.flashGroup:Stop() end
                                f:SetAlpha(1)
                            end
                        else
                            db.flash = true
                            db.flashStyle = item.value
                            ApplyFlashStyle()
                        end
                        flashDD:SetDefaultText(item.text)
                    end)
            end
        end)
        local function FlashText()
            local v = (not db.flash) and "static" or (db.flashStyle or "pulse")
            if v == "static" then return L["Static"] end
            if v == "fade"   then return L["Subtle Fade"] end
            return L["Intense Pulse"]
        end
        flashDD:SetDefaultText(FlashText())

        -- Spec-specific options (preserves the mod:BuildAlertOptions(box, db, helpers) contract).
        if mod.BuildAlertOptions then
            alrtL:Section(L["Spec Options"])
            local content = alrtL.content
            local cursor  = { val = alrtL.y }
            local function Track(w) aoWidgets[#aoWidgets + 1] = w; return w end
            local helpers = {
                alertOptionsBox = content,
                db              = db,
                Spacer = function(px) cursor.val = cursor.val - (px or 8) end,
                MakeNumericInput = function(labelText, minVal, maxVal, getter, setter)
                    local lbl = content:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
                    lbl:SetPoint("TOPLEFT", 10, cursor.val - 4)
                    panel:Reface(lbl); lbl:SetText(L[labelText]); lbl:SetTextColor(1, 1, 1)
                    Track(lbl)
                    local box = ThemedEdit(content, 40, 18, 4)
                    box:SetPoint("LEFT", lbl, "RIGHT", 8, 0)
                    box:SetJustifyH("CENTER")
                    box:SetText(tostring(getter())); box:SetCursorPosition(0)
                    box:SetScript("OnEnterPressed", function(s) s:ClearFocus() end)
                    box:SetScript("OnEditFocusLost", function(s)
                        local v = tonumber(s:GetText())
                        if v then
                            v = math_max(minVal, math_min(maxVal, math_floor(v + 0.5)))
                            setter(v); s:SetText(tostring(v))
                        else
                            s:SetText(tostring(getter()))
                        end
                        s:SetCursorPosition(0)
                    end)
                    Track(box)
                    box._nhaLabel = lbl
                    cursor.val = cursor.val - 28
                    return box
                end,
            }
            mod:BuildAlertOptions(content, db, helpers)
            alrtL.y = cursor.val
        end

        alrtL:Section(L["Alert Text & Colors (Click color box to change)"])
        for _, alert in ipairs(mod.alerts) do
            local k = alert.key
            local _, eb = alrtL:ColorRow({
                getEnabled = function() return db[k .. "Enabled"] end,
                setEnabled = function(v) db[k .. "Enabled"] = v end,
                color      = (db.alertColors and db.alertColors[k]) and {
                    get = function() return db.alertColors[k] end,
                    set = function(r, g, b)
                        db.alertColors[k] = { r = r, g = g, b = b }
                        local f = NHA.display
                        if f then
                            for _, row in ipairs(f.alertRows) do
                                if row.key == k then row.textFS:SetTextColor(r, g, b) end
                            end
                        end
                    end,
                } or nil,
                label    = L[alert.label],
                makeEdit = ThemedEdit,
            })
            local saved = db[k .. "AlertText"]
            eb:SetText((saved and saved ~= "") and saved or L[alert.text]); eb:SetCursorPosition(0)
            eb:SetScript("OnEnterPressed", function(s) s:ClearFocus() end)
            eb:SetScript("OnEditFocusLost", function(s)
                local t = s:GetText()
                if t == "" or t == L[alert.text] then db[k .. "AlertText"] = nil; s:SetText(L[alert.text])
                else db[k .. "AlertText"] = t end
                s:SetCursorPosition(0); ApplyDisplaySettings()
            end)
        end
        alrtL:Finish()
        panel:RefreshTheme()
    end

    panel.onShow = Rebuild
    NHA.RefreshOptionsPanel = function()
        if panel.root:IsShown() then Rebuild() end
    end

    -- ============================================================
    -- Bottom buttons
    -- ============================================================
    panel:SetButtons({
        { text = L["Test"],
          getText = function() return coreState.testMode and L["Stop Test"] or L["Test"] end,
          onClick = function() NHA:ToggleTest() end },
        { text = L["Unlock Frame"],
          getText = function() return coreState.unlocked and L["Lock Frame"] or L["Unlock Frame"] end,
          onClick = function() SetLockState(not coreState.unlocked) end },
        { text = L["Center Position"], onClick = function()
            db.x, db.y = CORE_DEFAULTS.x, CORE_DEFAULTS.y
            db.point, db.relativePoint, db.relativeTo =
                CORE_DEFAULTS.point, CORE_DEFAULTS.relativePoint, CORE_DEFAULTS.relativeTo
            ApplyDisplaySettings(); Msg(L["Position centered."])
        end },
        { text = L["Restore Defaults"], onClick = function()
            local mod = NHA.activeModule
            db.fontSize, db.fontName = CORE_DEFAULTS.fontSize, CORE_DEFAULTS.fontName
            db.x, db.y = CORE_DEFAULTS.x, CORE_DEFAULTS.y
            db.point, db.relativePoint, db.relativeTo =
                CORE_DEFAULTS.point, CORE_DEFAULTS.relativePoint, CORE_DEFAULTS.relativeTo
            if mod then
                for _, alert in ipairs(mod.alerts) do
                    local k = alert.key
                    if alert.defaultColor and db.alertColors then
                        db.alertColors[k] = {
                            r = alert.defaultColor.r, g = alert.defaultColor.g, b = alert.defaultColor.b,
                        }
                    end
                    db[k .. "AlertText"] = nil
                end
            end
            ApplyDisplaySettings()
            if panel.root:IsShown() then Rebuild() end
            Msg(L["Defaults restored."])
        end },
    })
    NHA.testBtn    = panel.buttons[1]
    NHA.lockBtn    = panel.buttons[2]
    NHA.restoreBtn = panel.buttons[4]
end

-- =============================================================
-- PLAYER_LOGIN
-- =============================================================
function NHA:OnLogin()
    activeClass = GetActiveClass()

    CreateDisplay()
    self:BuildOptionsPanel()
    self:InitCommands()

    local key, mod = FindActiveModule()
    if key and mod then
        self:InitCUFHooks()
        ActivateModule(key, mod)
    end
    ApplyDisplaySettings()
end

-- =============================================================
-- Event Handler
-- =============================================================
NHA:SetScript("OnEvent", function(self, event, ...)
    if event == "ADDON_LOADED" then
        local addonName = ...
        if addonName == ADDON_NAME then
            self:InitDatabase()
        end
        return
    end

    if event == "PLAYER_LOGIN" then
        self:OnLogin()
        return
    end

    local mod = self.activeModule
    local db  = self.db

    if event == "PLAYER_SPECIALIZATION_CHANGED"
    or event == "PLAYER_TALENT_UPDATE"
    or event == "TRAIT_CONFIG_UPDATED" then
        local newKey, newMod = FindActiveModule()
        if newKey ~= self.activeModuleKey then
            if newKey and newMod then
                self:InitCUFHooks()
                ActivateModule(newKey, newMod)
            else
                DeactivateCurrentModule()
                local f = self.display
                if f then
                    for _, row in ipairs(f.alertRows) do
                        row.textFS:Hide()
                        if row.timerFS then row.timerFS:Hide() end
                    end
                    f:SetAlpha(1)
                    if f.flashGroup:IsPlaying() then f.flashGroup:Stop() end
                end
            end
            if self.RefreshOptionsPanel then self:RefreshOptionsPanel() end
        else
            if mod and mod.OnEvent then
                mod:OnEvent(db, event, ...)
            end
        end
        ScheduleEvaluate()
        return
    end

    if not mod then return end

    if event == "PLAYER_ENTERING_WORLD" then
        InvalidateGroupUnitsCache()
        ApplyDisplaySettings()
        if mod.OnEvent then mod:OnEvent(db, event, ...) end
        ScheduleEvaluate()
        return
    end

    if event == "PLAYER_TARGET_CHANGED"
    or event == "PLAYER_FOCUS_CHANGED"
    or event == "GROUP_ROSTER_UPDATE" then
        InvalidateGroupUnitsCache()
        if mod.OnEvent then mod:OnEvent(db, event, ...) end
        ScheduleEvaluate()
        return
    end

    if event == "PLAYER_DEAD" then
        if mod.ClearAllState then mod:ClearAllState() end
        if mod.ClearAllGlows then mod:ClearAllGlows() end
        return
    end
    if event == "PLAYER_ALIVE" then
        ScheduleEvaluate()
        return
    end

    if event == "PLAYER_REGEN_DISABLED" then
        self:OnVoiceCombatStart()
    elseif event == "PLAYER_REGEN_ENABLED" then
        self:OnVoiceCombatEnd()
        -- CUF glows are combat-only (EvaluateCUFGlow refuses to apply them out
        -- of combat). Nothing else tears down already-lit glows on combat exit,
        -- so a glow lit during combat would persist. Clear them suite-wide here.
        self:HideAllCUFGlows()
    end

    if mod.OnEvent then
        local handled = mod:OnEvent(db, event, ...)
        if not handled then
            ScheduleEvaluate()
        end
    else
        ScheduleEvaluate()
    end
end)

-- =============================================================
-- Core Event Registration
-- =============================================================
NHA:RegisterEvent("ADDON_LOADED")
NHA:RegisterEvent("PLAYER_LOGIN")
NHA:RegisterEvent("PLAYER_ENTERING_WORLD")
NHA:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED")
NHA:RegisterEvent("PLAYER_TALENT_UPDATE")
NHA:RegisterEvent("TRAIT_CONFIG_UPDATED")
NHA:RegisterEvent("PLAYER_TARGET_CHANGED")
NHA:RegisterEvent("PLAYER_FOCUS_CHANGED")
NHA:RegisterEvent("GROUP_ROSTER_UPDATE")
NHA:RegisterEvent("PLAYER_REGEN_DISABLED")
NHA:RegisterEvent("PLAYER_REGEN_ENABLED")
NHA:RegisterEvent("PLAYER_DEAD")
NHA:RegisterEvent("PLAYER_ALIVE")

