-- =============================================================
-- NemHealerAlerts.lua  v12.0.19
-- Nem: Healer Alerts — core engine.
--
-- All spec behavior lives in Specs/*.lua files which register
-- themselves via NHA:RegisterSpec(). See SPEC_CONTRACT comment
-- below for the module table contract.
--
-- 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 = ...
local NHA = CreateFrame("Frame")
_G.NemHealerAlerts = NHA

-- =============================================================
-- Upvalues
-- =============================================================
local pairs, ipairs           = pairs, ipairs
local math_floor, math_max    = math.floor, math.max
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.19"

-- =============================================================
-- 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  (cached at PLAYER_LOGIN)
-- =============================================================
local activeClass = nil

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

-- Spec support is determined entirely by registered spec modules.
-- If no module matches the player's class + specID, the panel
-- shows a "Spec Not Supported" message.

-- =============================================================
-- Spec Module Registry
-- =============================================================
-- Modules register via NHA:RegisterSpec(key, module).
-- Key format: "ClassSpec" e.g. "DruidRestoration".
--
-- Module table contract (see Specs/*.lua for examples):
--   class, specID, specName        -- identity (class & specID required)
--   alerts = { { key, label, text, defaultColor, yOffset,
--                hasTimer, timerYOffset, timerCycleSec,
--                timerThresholds, defaultSound,
--                noTest, noSound }, ... }
--   defaults                       -- merged into saved variables
--   state                          -- module-owned runtime state
--   Evaluate(db)                   -- REQUIRED: sets <key>Active /
--                                  --   <key>Expire / <key>TimeMod state
--   OnActivate(db) / OnDeactivate(db)
--   OnEvent(db, event, ...)        -- return true to suppress ScheduleEvaluate
--   NeedsTicker(db)                -- keep ticker alive when no alerts
--                                  --   are active (e.g. for glow refresh)
--   EvaluateFrameGlow(db, buffFrame, aura)
--                                  -- per-icon glow on Target / Focus
--   EvaluateCUFGlow(db, frame, unit)
--                                  -- whole-frame glow on default Blizzard
--                                  --   CompactUnitFrames (12.0.5 workaround
--                                  --   for inaccessible buff icons)
--   cufRelevantSpells = { [id]=true, ... }
--                                  -- spell IDs that should trigger a CUF
--                                  --   re-evaluate via UNIT_AURA delta;
--                                  --   omit to disable filtering (always
--                                  --   refresh on UNIT_AURA for tracked unit)
--   RefreshGlows(db)               -- called every 0.25s by ticker
--   ClearAllGlows() / ClearAllState()
--   extraEvents = { ... }          -- additional RegisterEvent calls
--   BuildAlertOptions(parent, db, helpers)
--                                  -- spec-specific widgets in Alert Options
--   slashCommands = { [cmd] = function(self, db) end }
--   Debug(db)                      -- /nha debug hook

NHA.specModules     = {}       -- key → module table
NHA.activeModule    = nil      -- currently active module (or nil)
NHA.activeModuleKey = nil      -- key string of active module

function NHA:RegisterSpec(key, mod)
    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
        if mod.class == activeClass then
            -- pcall guards against currentSpecID still being a secret value
            local ok, match = pcall(function()
                return mod.specID == currentSpecID
            end)
            if ok and match then
                return key, mod
            end
        end
    end
    return nil, nil
end

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

-- =============================================================
-- Class theme colors (used for panel borders, headers, accents)
-- =============================================================
local CLASS_THEME_COLORS = {
    DRUID       = { r=1.000, g=0.486, b=0.039 },  -- #FF7C0A
    PRIEST      = { r=1.000, g=1.000, b=1.000 },  -- #FFFFFF
    EVOKER      = { r=0.200, g=0.576, b=0.498 },  -- #33937F
    SHAMAN      = { r=0.000, g=0.439, b=0.867 },  -- #0070DD
    MONK        = { r=0.000, g=1.000, b=0.596 },  -- #00FF98
    PALADIN     = { r=0.957, g=0.549, b=0.729 },  -- #F48CBA
}
local CLASS_THEME_DEFAULT = { r=0, g=0.8, b=1.0 }  -- #00CCFF (unsupported class)

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

-- =============================================================
-- LibSharedMedia (optional — enhances font/sound dropdowns)
-- =============================================================
local LSM = LibStub and LibStub("LibSharedMedia-3.0", true)

-- =============================================================
-- LibCustomGlow (proc glow on buff frame icons)
-- =============================================================
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",
    ["HeartbeatSingle"]  = NHA_MEDIA .. "HeartbeatSingle.ogg",
    ["Lamp"]             = NHA_MEDIA .. "Lamp.ogg",
    ["MetalGearSpotted"] = NHA_MEDIA .. "MetalGearSpotted.ogg",
    ["OhNo"]             = NHA_MEDIA .. "OhNo.ogg",
    ["Ping"]             = NHA_MEDIA .. "Ping.ogg",
    ["Redfox"]           = NHA_MEDIA .. "Redfox.ogg",
    ["RobotBlip"]        = NHA_MEDIA .. "RobotBlip.ogg",
    ["SharpPunch"]       = NHA_MEDIA .. "SharpPunch.ogg",
    ["Shotgun"]          = NHA_MEDIA .. "Shotgun.ogg",
    ["Sonar"]            = NHA_MEDIA .. "Sonar.ogg",
    ["Sonarr"]           = NHA_MEDIA .. "Sonarr.ogg",
    ["WaterDrop"]        = NHA_MEDIA .. "WaterDrop.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["RobotBlip"] 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["RobotBlip"]
end

-- Sorted sound list for dropdowns
local SOUND_NAMES = {}
do
    for k in pairs(BUNDLED_SOUNDS) do SOUND_NAMES[#SOUND_NAMES+1] = k end
    table.sort(SOUND_NAMES)
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",
    ["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

local function GetCurrentFontPath()
    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  (core only — module defaults merged in)
-- =============================================================
local CORE_DEFAULTS = {
    enabled          = true,
    flash            = true,
    scale            = 1.0,
    fontSize         = 25,
    fontName         = "Gotham Narrow Ultra",
    x                = 0,
    y                = 180,
    point            = "CENTER",
    relativePoint    = "CENTER",
    relativeTo       = "UIParent",
    glowEnabled      = true,
    ver              = 5,
}

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

-- Resolve a saved relativeTo string back to a frame.
local function ResolveRelativeFrame(db)
    if db.relativeTo and db.relativeTo ~= "UIParent" then
        return _G[db.relativeTo] or UIParent
    end
    return UIParent
end

-- Exposed for spec modules
NHA.Msg           = Msg
NHA.issecretvalue = issecretvalue

-- =============================================================
-- Sound Playback
-- =============================================================
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 p = GetSoundPath(db[nameKey])
    if p then PlaySoundFile(p, "Master") end
end

-- =============================================================
-- Icon Proc Glow
-- =============================================================
-- Reusable options table for ProcGlow_Start.
local _procGlowOpts  = { color = { 0, 0, 0, 1 }, startAnim = false }
local _procGlowColor = _procGlowOpts.color

function NHA:ShowProcGlow(buffFrame, r, g, b)
    if not LCG then return end
    -- Already glowing the same color — fast no-op (matches v6 behavior
    -- on the icon path, where colors don't change for a given alert).
    if buffFrame._nhaGlowing
       and buffFrame._nhaGlowR == r
       and buffFrame._nhaGlowG == g
       and buffFrame._nhaGlowB == b then
        return
    end
    -- Color change or fresh glow. Stop first if already glowing so the
    -- new color takes effect cleanly. Needed for the CUF whole-frame
    -- path where LB pandemic can take priority over a SOTF empowered
    -- glow on the same unit frame; without this the color stays stuck
    -- on whichever was set first.
    if buffFrame._nhaGlowing then
        LCG.ProcGlow_Stop(buffFrame)
    end
    _procGlowColor[1] = r
    _procGlowColor[2] = g
    _procGlowColor[3] = b
    buffFrame._nhaGlowing = true
    buffFrame._nhaGlowR   = r
    buffFrame._nhaGlowG   = g
    buffFrame._nhaGlowB   = b
    LCG.ProcGlow_Start(buffFrame, _procGlowOpts)
end

function NHA:HideProcGlow(buffFrame)
    if not LCG then return end
    if not buffFrame._nhaGlowing then return end
    buffFrame._nhaGlowing = nil
    buffFrame._nhaGlowR   = nil
    buffFrame._nhaGlowG   = nil
    buffFrame._nhaGlowB   = nil
    LCG.ProcGlow_Stop(buffFrame)
end

-- =============================================================
-- Frame Pixel Glow (whole-frame, non-icon contexts)
-- =============================================================
-- Used by the CompactUnitFrame whole-frame glow path (the 12.0.5
-- workaround for inaccessible buff icons). LCG's PixelGlow draws
-- discrete textures along the frame perimeter and scales correctly
-- to any frame shape — unlike ProcGlow, which is icon-shaped.
--
-- The default PixelGlow parameters (N=8, length nil, th=2) produce
-- a sparse "marching dots" look. We tune the parameters here to
-- read as a solid, noticeable border:
--
--   FRAME_GLOW_LINES (N) — number of segments around the perimeter.
--                           Higher = denser, segments visually merge.
--   FRAME_GLOW_LENGTH    — length of each segment in pixels. Long
--                           enough that segments overlap each other.
--   FRAME_GLOW_TH        — thickness in pixels. Higher = bolder.
--   FRAME_GLOW_FREQ      — animation cycle time in seconds. 0 is
--                           static; small positive values produce a
--                           subtle pulse without distracting motion.
--   FRAME_GLOW_BORDER    — true: glow sits outside the frame edge,
--                           reading as a halo. false: inside the
--                           frame, useful if outside collides with
--                           neighbouring UI.
--
-- Same-color repeat calls are a fast no-op via the cached color
-- on `_nhaFrameGlowR/G/B`. Color changes (LB priority taking over
-- a SOTF glow) call PixelGlow_Stop and restart with the new color.
-- =============================================================
local FRAME_GLOW_KEY    = "NHA_CUF"
local FRAME_GLOW_LINES  = 8     -- fewer segments → larger chunks
local FRAME_GLOW_LENGTH = 48     -- chunky segment length
local FRAME_GLOW_TH     = 4      -- thicker → bolder
local FRAME_GLOW_FREQ   = 0.15    -- slow stately march
local FRAME_GLOW_XOFF   = 0
local FRAME_GLOW_YOFF   = 0
local FRAME_GLOW_BORDER = true   -- outside the frame edge
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  -- same color already showing — fast no-op
    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
-- =============================================================
-- Since 12.0.5, GetSpecialization() may return a secret value in
-- restricted execution contexts. Comparing a secret value to a
-- plain number throws, so we wrap the lookups in pcall and walk
-- spec slots 1-4 as a fallback.
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
                    local checkOk, checkResult = pcall(function()
                        return GetSpecialization() == i
                    end)
                    if checkOk and checkResult then
                        return sID
                    end
                end
            end
        end
    end

    return nil
end

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

-- =============================================================
-- Tickers
-- =============================================================
-- Main ticker: runs at 0.25s while any alert is active (or a
-- module requests it via NeedsTicker), stops when no alerts.
local mainTicker = nil

local function StartTicker()
    if mainTicker then return end
    mainTicker = C_Timer.NewTicker(0.25, function() Evaluate() end)
end

local function StopTicker()
    if mainTicker then mainTicker:Cancel(); mainTicker = nil end
end

-- Debounced Evaluate scheduler — coalesces event bursts.
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
-- =============================================================
-- Alert rows are built dynamically from the active module's alerts[].
-- alertRows[i] = { textFS, timerFS, key, alert }

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)

    -- Flash animation
    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)

    -- Drag border (visible only while unlocked)
    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.alertRows  = {}

    NHA.display = f
end

-- Build alert row FontStrings from the active module's alerts[].
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 sc = db.scale or 1.0

    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, "OUTLINE")
        textFS:SetTextColor(col.r, col.g, col.b)
        local customText = db[alert.key .. "AlertText"]
        textFS:SetText((customText and customText ~= "") and customText or alert.text)
        textFS:SetScale(sc)
        textFS:SetJustifyH("CENTER")
        textFS:SetShadowOffset(2, -2)
        textFS:SetPoint("CENTER", f, "CENTER", 0, alert.yOffset or 0)
        textFS:Hide()

        local timerFS = nil
        if alert.hasTimer then
            timerFS = f:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge")
            timerFS:SetFont(fp, math_floor(fs * 0.68), "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 - 20))
            timerFS:Hide()
        end

        f.alertRows[i] = {
            textFS  = textFS,
            timerFS = timerFS,
            key     = alert.key,
            alert   = alert,
        }
    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 sc = db.scale or 1.0

    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, "OUTLINE")
        row.textFS:SetScale(sc)
        row.textFS:SetTextColor(col.r, col.g, col.b)
        local customText = db[row.key .. "AlertText"]
        row.textFS:SetText((customText and customText ~= "") and customText or row.alert.text)
        if row.timerFS then
            row.timerFS:SetFont(fp, math_floor(fs * 0.68), "OUTLINE")
            row.timerFS:SetScale(sc)
        end
    end

    f:EnableMouse(coreState.unlocked)
end

-- =============================================================
-- OnUpdate — drives all visual state from module state flags
-- =============================================================
local function SetupOnUpdate()
    local f = NHA.display
    if not f then return end

    f:SetScript("OnUpdate", function()
        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
    end)
end

-- =============================================================
-- Evaluate
-- =============================================================
Evaluate = function()
    local db  = NHA.db
    local mod = NHA.activeModule
    if not db or not db.enabled then
        StopTicker()
        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

    -- Test mode: set all alerts active with cycling timers
    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
        return
    end

    mod:Evaluate(db)

    if mod.RefreshGlows then mod:RefreshGlows(db) end

    -- Ticker stays active when any alert is live or the module asks for it.
    local anyActive = false
    for _, alert in ipairs(mod.alerts) do
        if mod.state[alert.key .. "Active"] then
            anyActive = true
            break
        end
    end
    if mod.NeedsTicker and mod:NeedsTicker(db) then anyActive = true end

    if anyActive then StartTicker() else StopTicker() 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 "Lock Frame" or "Unlock Frame")
    end
    Msg(unlocked
        and "Frame |cff00ff00unlocked|r — drag to reposition."
         or "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)

    StopTicker()

    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
            NHA:RegisterEvent(ev)
            registeredExtraEvents[#registeredExtraEvents+1] = 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

    NemHealerAlertsSV = NemHealerAlertsSV or CopyTable(CORE_DEFAULTS)
    local db = NemHealerAlertsSV
    if (db.ver or 0) < 5 then
        -- v5: Disc Priest spell IDs changed (Void Shield split into MTD
        -- proc + absorb). Wipe Disc-specific keys so they re-merge from
        -- the module's new defaults.
        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
        MergeDefaults(db, CORE_DEFAULTS)
        db.ver = 5
    else
        MergeDefaults(db, CORE_DEFAULTS)
    end
    self.db = db
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 "Stop Test" or "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("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("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("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("Addon |cffff4040disabled|r.")
            else
                self.db.enabled = true
                Msg("Addon |cff00ff41enabled|r.")
            end
            Evaluate()
            return
        end

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

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

        if input == "help" then
            Msg("|cffffd700Nem: Healer Alerts — Commands:|r")
            Msg("  |cffffd700/nha|r  —  Open options")
            Msg("  |cffffd700/nha on|r  —  Enable addon")
            Msg("  |cffffd700/nha off|r  —  Disable addon")
            Msg("  |cffffd700/nha toggle|r  —  Toggle on/off")
            Msg("  |cffffd700/nha test|r  —  Toggle test mode")
            Msg("  |cffffd700/nha status|r  —  Show current status")
            Msg("  |cffffd700/nha reset|r  —  Reset all settings")
            Msg("  |cffffd700/nha version|r  —  Show addon version")
            Msg("  |cffffd700/nha help|r  —  Show this list")
            Msg("  (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  —  (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("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("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("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

-- =============================================================
-- InitGlowHooks
-- =============================================================
function NHA:InitGlowHooks()
    if self._glowHooksInstalled then return end

    -- Compact raid/party frame buff icons are inaccessible since 12.0.5;
    -- EvaluateFrameGlow runs only for Target and Focus frame icons. The
    -- CUF whole-frame glow workaround for default Blizzard raid/party
    -- frames is installed by InitCUFHooks and routes through
    -- EvaluateCUFGlow instead.

    -- Target frame
    hooksecurefunc(TargetFrame, "UpdateAuras", function(s)
        local mod = NHA.activeModule
        if not mod or not mod.EvaluateFrameGlow then return end
        if not s.auraPools then return end
        local _db = self.db
        for buffFrame in s.auraPools:EnumerateActive() do
            local aura = C_UnitAuras.GetAuraDataByAuraInstanceID(
                s.unit, buffFrame.auraInstanceID)
            mod:EvaluateFrameGlow(_db, buffFrame, aura)
        end
    end)

    -- Focus frame
    hooksecurefunc(FocusFrame, "UpdateAuras", function(s)
        local mod = NHA.activeModule
        if not mod or not mod.EvaluateFrameGlow then return end
        if not s.auraPools then return end
        local _db = self.db
        for buffFrame in s.auraPools:EnumerateActive() do
            local aura = C_UnitAuras.GetAuraDataByAuraInstanceID(
                s.unit, buffFrame.auraInstanceID)
            mod:EvaluateFrameGlow(_db, buffFrame, aura)
        end
    end)

    self._glowHooksInstalled = true
end

-- =============================================================
-- CompactUnitFrame Whole-Frame Glow Hooks
-- =============================================================
-- Since 12.0.5 the compact raid/party buff icons are forbidden —
-- we can't add a per-icon glow to them. The workaround mirrors
-- LifebloomGlow's `glowFrameInstead` mode: hook
-- CompactUnitFrame_UpdateAll to discover every CUF as it appears,
-- track it by unit, and put a ProcGlow halo around the entire
-- unit frame when an aura we care about is present on that unit.
-- The trade-off is that the glow can't tell you which buff is
-- panicking on that unit, only that something is — but it's the
-- only signal the API still gives us on default Blizzard CUFs.
--
-- The active spec module supplies the per-frame logic via
-- `mod:EvaluateCUFGlow(db, frame, unit)`. Modules without that
-- method don't participate; the hook is a near-noop for them.
--
-- UNIT_AURA delta filtering uses the spec's `cufRelevantSpells`
-- set when present. Without it, every UNIT_AURA on a tracked
-- unit triggers a refresh — correct but more expensive. With
-- it, we skip refreshes for irrelevant aura churn (HoT ticks
-- from other classes, debuffs, etc.) which is most of the
-- traffic in M+ combat.
-- =============================================================
NHA._cufPool   = {}    -- frame -> true (set of all tracked CUFs)
NHA._unitCUFs  = {}    -- unit token -> { frame -> true }

local _cufEventFrame   -- separate event frame so spec modules' UNIT_AURA
                       -- handling in OnEvent isn't disturbed

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.glowEnabled 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
    -- Skip target/focus units — TargetFrame/FocusFrame icon glows
    -- already handle them, and double-glowing is noisy.
    if string_match(unit, "target") or string_match(unit, "focus") then
        if frame._nhaFrameGlowing then NHA:HideFrameGlow(frame) end
        return
    end
    mod:EvaluateCUFGlow(db, frame, unit)
end

NHA._UpdateCUFFrame = NHA_UpdateCUFFrame  -- exposed for ticker / spec access

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

-- Refresh every tracked CUF — used on talent change / spec change /
-- master toggle flips, where every frame's glow state may need to
-- reconsider. Cheap: scales with active raid frame count, not
-- aura churn rate.
function NHA:RefreshAllCUFs()
    for frame in pairs(self._cufPool) do
        NHA_UpdateCUFFrame(frame)
    end
end

-- Refresh only the CUFs tracking a given unit — UNIT_AURA path.
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

-- Hide all CUF glows without untracking — used when the addon is
-- disabled or the active spec lacks EvaluateCUFGlow.
function NHA:HideAllCUFGlows()
    for frame in pairs(self._cufPool) do
        if frame._nhaFrameGlowing then self:HideFrameGlow(frame) end
    end
end

-- Returns true if the UNIT_AURA payload contains a spell the
-- active module flagged as CUF-relevant. Without `cufRelevantSpells`
-- we always say true (the conservative fallback).
local function CUFDeltaIsRelevant(updateInfo)
    local mod = NHA.activeModule
    if not mod then return false end
    local relevant = mod.cufRelevantSpells
    if not relevant then return true end  -- no filter → always refresh

    if not updateInfo or updateInfo.isFullUpdate then return true end

    if updateInfo.addedAuras then
        for _, aura in ipairs(updateInfo.addedAuras) do
            local sid = aura.spellId
            if sid and not issecretvalue(sid) and relevant[sid] then
                return true
            end
        end
    end
    if updateInfo.updatedAuraInstanceIDs then
        -- We don't know which spell each instID corresponds to without
        -- a fetch, and the fetch itself isn't free. Treat any update
        -- as potentially relevant — the EvaluateCUFGlow scan that
        -- follows will reject irrelevant unit states cheaply.
        return true
    end
    if updateInfo.removedAuraInstanceIDs then
        -- Same — a removal might be one of ours, so refresh. The cost
        -- of a single re-evaluate is two GetAuraDataBySpellID calls.
        return true
    end
    return false
end

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

    -- hooksecurefunc throws if the target global doesn't exist or is no
    -- longer hookable. Wrap so we get a clear chat error instead of a
    -- silent install failure that leaves CUF tracking permanently dead.
    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  -- leave _cufHooksInstalled false so a later retry can attempt again
    end

    -- Separate event frame: keeps CUF-only UNIT_AURA processing
    -- isolated from the spec module's UNIT_AURA handler in OnEvent.
    _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(updateInfo) then return end
            NHA:RefreshCUFsForUnit(unit)
        elseif event == "GROUP_ROSTER_UPDATE" then
            -- Roster changes can swap which unit a CUF displays;
            -- re-track every tracked frame so the unit index stays
            -- in sync. CompactUnitFrame_UpdateAll fires on roster
            -- changes too, but only for visible frames — this catches
            -- the general case.
            for frame in pairs(NHA._cufPool) do
                NHA_TrackCUF(frame)
            end
        elseif event == "PLAYER_ENTERING_WORLD" then
            -- Compact frames may be recreated on zone changes.
            -- Re-scan to catch frames Blizzard has just rebuilt.
            NHA:ScanExistingCUFs()
        end
    end)

    -- Initial scan: Blizzard's CompactUnitFrame_UpdateAll fires for every
    -- visible compact frame at UI init time, BEFORE PLAYER_LOGIN — i.e.
    -- before this hook can listen. Without this scan, the FIRST round of
    -- aura events (like the first Lifebloom cast after /reload) hits
    -- units whose CUFs aren't yet in _unitCUFs, so the cache never gets
    -- populated, the ticker has nothing to glow, and the alert sails
    -- to expiry without ever showing a glow. Subsequent Lifeblooms work
    -- because by then various other UpdateAll triggers (target change,
    -- healing events, etc.) have brought the frames into tracking.
    self:ScanExistingCUFs()

    -- Defensive second pass after a short delay — some frames are
    -- created lazily after PLAYER_LOGIN (e.g. dungeon escort NPCs that
    -- join the party slightly after world entry).
    C_Timer.After(1.0, function() self:ScanExistingCUFs() end)

    self._cufHooksInstalled = true
end

-- =============================================================
-- ScanExistingCUFs
-- =============================================================
-- Walks every loaded frame, tracking any whose name starts with
-- "Compact" and that holds a unit reference. Used by InitCUFHooks
-- (initial install + delayed retry) and PLAYER_ENTERING_WORLD to
-- backfill frames that exist before our hook can listen for them.
-- Cheap because it's only called a handful of times per session.
-- =============================================================
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
-- =============================================================
function NHA:RunDebug()
    local db  = self.db
    local mod = self.activeModule
    local ok, err

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

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

    ok, err = pcall(function()
        Msg("inGroup=" .. tostring(IsInGroup())
            .. "  inRaid=" .. tostring(IsInRaid())
            .. "  members=" .. tostring(GetNumGroupMembers()))
    end)
    if not ok then Msg("ERROR(2): " .. tostring(err)) end

    ok, err = pcall(function()
        local lcgStr = LCG and "loaded" or "not loaded"
        local hooks  = self._glowHooksInstalled and "installed" or "not installed"
        Msg("LibCustomGlow=" .. lcgStr .. "  glowHooks=" .. hooks
            .. "  glowEnabled=" .. tostring(db and db.glowEnabled))
    end)
    if not ok then Msg("ERROR(3): " .. tostring(err)) end

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

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

-- =============================================================
-- Options Panel
-- =============================================================
function NHA:BuildOptionsPanel()
    local db = self.db
    local PW = 668

    local panel = CreateFrame("Frame", ADDON_NAME .. "OptionsPanel", UIParent)
    panel.name  = "Nem: Healer Alerts"
    panel:SetSize(PW, 580)

    -- ================================================================
    -- SCROLL SYSTEM — pure Lua, ultra-slim scrollbar overlapping right margin
    -- ================================================================
    local SCROLL_TOP = -62
    local SCROLL_BTM =  56
    local SB_W       =  4
    local SB_MARGIN  = 10

    local VIEWPORT_L = 8
    local VIEWPORT_R = SB_MARGIN
    local BOX_INSET  = 2
    local CW = PW - VIEWPORT_L - VIEWPORT_R - (BOX_INSET * 2)

    local viewport = CreateFrame("Frame", nil, panel)
    viewport:SetPoint("TOPLEFT",     panel, "TOPLEFT",      VIEWPORT_L,   SCROLL_TOP)
    viewport:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -VIEWPORT_R,   SCROLL_BTM)
    viewport:SetClipsChildren(true)

    local scrollContent = CreateFrame("Frame", nil, viewport)
    scrollContent:SetWidth(CW)
    scrollContent:SetHeight(1)
    scrollContent:SetPoint("TOPLEFT", viewport, "TOPLEFT", BOX_INSET, 0)

    local scrollOffset = 0

    local function ApplyScroll(offset)
        local maxScroll = math_max(0, scrollContent:GetHeight() - viewport:GetHeight())
        if offset < 0         then offset = 0         end
        if offset > maxScroll then offset = maxScroll end
        scrollOffset = offset
        scrollContent:SetPoint("TOPLEFT", viewport, "TOPLEFT", BOX_INSET, scrollOffset)
        return scrollOffset, maxScroll
    end

    local sbBar = CreateFrame("Frame", nil, panel)
    sbBar:SetWidth(SB_W)
    sbBar:SetPoint("TOP",    panel, "TOPRIGHT",    -(SB_MARGIN - 2), SCROLL_TOP)
    sbBar:SetPoint("BOTTOM", panel, "BOTTOMRIGHT", -(SB_MARGIN - 2), SCROLL_BTM)
    sbBar:SetFrameLevel(panel:GetFrameLevel() + 10)

    local railTex = sbBar:CreateTexture(nil, "BACKGROUND")
    railTex:SetAllPoints(true)
    railTex:SetColorTexture(0.22, 0.22, 0.22, 0.85)

    local sbThumb = CreateFrame("Button", nil, sbBar)
    sbThumb:SetWidth(SB_W)
    sbThumb:SetHeight(40)
    sbThumb:SetPoint("TOP", sbBar, "TOP", 0, 0)

    local thumbTex = sbThumb:CreateTexture(nil, "ARTWORK")
    thumbTex:SetAllPoints(true)
    do local tr, tg, tb = GetClassTheme(); thumbTex:SetColorTexture(tr, tg, tb, 0.85) end

    local thumbHL = sbThumb:CreateTexture(nil, "HIGHLIGHT")
    thumbHL:SetAllPoints(true)
    thumbHL:SetColorTexture(1, 1, 1, 0.2)

    sbBar:EnableMouse(true)
    sbBar:SetScript("OnMouseDown", function(self, button)
        if button ~= "LeftButton" then return end
        local _, cursorY = GetCursorPosition()
        cursorY = cursorY / UIParent:GetEffectiveScale()
        local trackTop    = sbBar:GetTop()
        local trackBottom = sbBar:GetBottom()
        if not trackTop or trackTop == trackBottom then return end
        local ratio = (trackTop - cursorY) / (trackTop - trackBottom)
        ratio = math_max(0, math_min(1, ratio))
        local maxScroll = math_max(0, scrollContent:GetHeight() - viewport:GetHeight())
        ApplyScroll(ratio * maxScroll)
        sbBar:UpdateScrollbar()
    end)

    function sbBar:UpdateScrollbar()
        local viewH     = viewport:GetHeight()
        local childH    = scrollContent:GetHeight()
        local maxScroll = math_max(0, childH - viewH)

        if viewH <= 0 then
            C_Timer.After(0.05, function() sbBar:UpdateScrollbar() end)
            return
        end

        if maxScroll <= 0 then
            sbBar:Hide()
            return
        end
        sbBar:Show()

        local trackH = sbBar:GetHeight()
        local thumbH = math_floor(math_max(20, trackH * (viewH / childH)) + 0.5)
        sbThumb:SetHeight(thumbH)

        local travel = trackH - thumbH
        local pos    = travel > 0 and (scrollOffset / maxScroll) * travel or 0
        sbThumb:ClearAllPoints()
        sbThumb:SetPoint("TOP", sbBar, "TOP", 0, -math_floor(pos + 0.5))
    end

    local dragStartY, dragStartOffset
    sbThumb:RegisterForDrag("LeftButton")
    sbThumb:SetScript("OnDragStart", function(self)
        dragStartY      = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale()
        dragStartOffset = scrollOffset
        self:SetScript("OnUpdate", function()
            local curY      = select(2, GetCursorPosition()) / UIParent:GetEffectiveScale()
            local delta     = dragStartY - curY
            local trackH    = sbBar:GetHeight()
            local thumbH    = sbThumb:GetHeight()
            local viewH     = viewport:GetHeight()
            local childH    = scrollContent:GetHeight()
            local maxScroll = math_max(0, childH - viewH)
            local travel    = trackH - thumbH
            if travel > 0 then
                ApplyScroll(dragStartOffset + delta * (maxScroll / travel))
                sbBar:UpdateScrollbar()
            end
        end)
    end)
    sbThumb:SetScript("OnDragStop", function(self)
        self:SetScript("OnUpdate", nil)
    end)

    viewport:EnableMouseWheel(true)
    viewport:SetScript("OnMouseWheel", function(self, delta)
        ApplyScroll(scrollOffset - delta * 40)
        sbBar:UpdateScrollbar()
    end)

    local function SetContentHeight(h)
        scrollContent:SetHeight(h)
        ApplyScroll(0)
        C_Timer.After(0.05, function() sbBar:UpdateScrollbar() end)
    end

    local CPW = CW - 4

    local function SetUIFont(fs, size, flags)
        fs:SetFont(UI_FONT, size or 12, flags or "")
    end

    local THEME_R, THEME_G, THEME_B
    if IsFullyImplemented() then
        THEME_R, THEME_G, THEME_B = GetClassTheme()
    else
        THEME_R = CLASS_THEME_DEFAULT.r
        THEME_G = CLASS_THEME_DEFAULT.g
        THEME_B = CLASS_THEME_DEFAULT.b
    end

    local themeFS    = {}
    local themeBoxes = {}

    local function UpdatePanelTheme()
        local r, g, b
        if IsFullyImplemented() then
            r, g, b = GetClassTheme()
        else
            r, g, b = CLASS_THEME_DEFAULT.r, CLASS_THEME_DEFAULT.g, CLASS_THEME_DEFAULT.b
        end
        THEME_R, THEME_G, THEME_B = r, g, b
        for _, entry in ipairs(themeFS) do
            if entry.setColor then
                entry.setColor(r, g, b)
            else
                entry.fs:SetTextColor(r, g, b, entry.alpha or 1)
            end
        end
        for _, box in ipairs(themeBoxes) do
            if box.SetBackdropBorderColor then
                box:SetBackdropBorderColor(r, g, b, 0.3)
            end
        end
        thumbTex:SetColorTexture(r, g, b, 0.85)
    end

    -- ---- UI Helpers ----
    local function SectionBox(x, y, w, h)
        local box = CreateFrame("Frame", nil, scrollContent,
            BackdropTemplateMixin and "BackdropTemplate" or nil)
        box:SetPoint("TOPLEFT", x, y)
        box:SetSize(w, h)
        if box.SetBackdrop then
            box:SetBackdrop({
                bgFile   = "Interface\\Buttons\\WHITE8x8",
                edgeFile = "Interface\\Buttons\\WHITE8x8",
                edgeSize = 1,
            })
            box:SetBackdropColor(0.08, 0.08, 0.08, 0.6)
            box:SetBackdropBorderColor(THEME_R, THEME_G, THEME_B, 0.3)
        end
        themeBoxes[#themeBoxes + 1] = box
        return box
    end

    local function SectionHeader(parent, text, x, y)
        local fs = parent:CreateFontString(nil, "OVERLAY")
        fs:SetPoint("TOPLEFT", x, y)
        SetUIFont(fs, 15, "OUTLINE")
        fs:SetText(text)
        fs:SetTextColor(THEME_R, THEME_G, THEME_B)
        themeFS[#themeFS + 1] = { fs=fs, alpha=1 }
        return fs
    end

    local checkboxes = {}
    local function MakeCheckbox(labelText, x, y, getter, setter)
        local cb = CreateFrame("CheckButton", nil, scrollContent, "UICheckButtonTemplate")
        cb:SetPoint("TOPLEFT", x, y)
        cb:SetChecked(getter())
        cb:SetScript("OnClick", function(self)
            setter(self:GetChecked())
            Evaluate()
        end)
        local lbl = cb:CreateFontString(nil, "OVERLAY")
        lbl:SetPoint("LEFT", cb, "RIGHT", 2, 0)
        SetUIFont(lbl, 12)
        lbl:SetText(labelText)
        lbl:SetTextColor(1, 1, 1)
        cb.Refresh = function() cb:SetChecked(getter()) end
        return cb
    end

    local function MakeAlertRow(parent, labelText, x, y, getter, setter, colorKey)
        local cb = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate")
        cb:SetPoint("TOPLEFT", x, y)
        cb:SetChecked(getter())
        cb:SetScript("OnClick", function(self)
            setter(self:GetChecked())
            Evaluate()
        end)

        local labelAnchor = cb
        if colorKey and db.alertColors and db.alertColors[colorKey] then
            local border = cb:CreateTexture(nil, "BORDER")
            border:SetSize(14, 14)
            border:SetPoint("LEFT", cb, "RIGHT", 4, 0)
            border:SetColorTexture(0, 0, 0, 1)
            local swatch = CreateFrame("Button", nil, cb)
            swatch:SetSize(12, 12)
            swatch:SetPoint("CENTER", border, "CENTER", 0, 0)
            swatch:EnableMouse(true)
            local swTex = swatch:CreateTexture(nil, "OVERLAY")
            swTex:SetAllPoints()
            local col0 = db.alertColors[colorKey]
            swTex:SetColorTexture(col0.r, col0.g, col0.b, 1)
            cb.swatchTex = swTex
            swatch:SetScript("OnClick", function()
                local col = db.alertColors[colorKey]
                local function Apply(r, g, b)
                    db.alertColors[colorKey] = { r=r, g=g, b=b }
                    swTex:SetColorTexture(r, g, b, 1)
                    -- Update display row color
                    local f = NHA.display
                    if f then
                        for _, row in ipairs(f.alertRows) do
                            if row.key == colorKey then
                                row.textFS:SetTextColor(r, g, b)
                            end
                        end
                    end
                end
                if ColorPickerFrame.SetupColorPickerAndShow then
                    ColorPickerFrame:SetupColorPickerAndShow({
                        r=col.r, g=col.g, b=col.b,
                        swatchFunc = function()
                            local r,g,b = ColorPickerFrame:GetColorRGB(); Apply(r,g,b)
                        end,
                        cancelFunc = function(prev) Apply(prev.r, prev.g, prev.b) end,
                    })
                end
            end)
            swatch:SetScript("OnEnter", function(s) s:SetAlpha(0.65) end)
            swatch:SetScript("OnLeave", function(s) s:SetAlpha(1) end)
            labelAnchor = border
        end

        local lbl = cb:CreateFontString(nil, "OVERLAY")
        lbl:SetPoint("LEFT", labelAnchor, "RIGHT", 6, 0)
        SetUIFont(lbl, 12)
        lbl:SetText(labelText)
        lbl:SetTextColor(1, 1, 1)

        cb.Refresh = function() cb:SetChecked(getter()) end
        return cb
    end

    local sliders = {}
    local function MakeSlider(parent, labelText, minVal, maxVal, step, x, y, width,
                               getter, setter, formatFunc)
        local container = CreateFrame("Frame", nil, parent)
        container:SetPoint("TOPLEFT", x, y)
        container:SetSize(width, 60)

        local lbl = container:CreateFontString(nil, "OVERLAY")
        lbl:SetPoint("TOPLEFT", 0, 0)
        SetUIFont(lbl, 12)
        lbl:SetText(labelText)
        lbl:SetTextColor(1, 1, 1)

        local slider = CreateFrame("Slider", nil, container, "OptionsSliderTemplate")
        slider:SetPoint("TOPLEFT", 0, -18)
        slider:SetWidth(width)
        slider:SetMinMaxValues(minVal, maxVal)
        slider:SetValueStep(step)
        slider:SetObeyStepOnDrag(true)
        slider:SetValue(getter())
        slider.Low:SetText("")
        slider.High:SetText("")

        local function DisplayVal(v)
            if formatFunc then return formatFunc(v) end
            return tostring(v)
        end

        local box = CreateFrame("EditBox", nil, container, "InputBoxTemplate")
        box:SetPoint("TOP", slider, "BOTTOM", 0, -4)
        box:SetSize(60, 20)
        box:SetAutoFocus(false)
        box:SetMaxLetters(6)
        box:SetFontObject("GameFontWhite")
        box:SetTextColor(THEME_R, THEME_G, THEME_B)
        themeFS[#themeFS + 1] = { setColor = function(r, g, b) box:SetTextColor(r, g, b) end }
        box:SetJustifyH("CENTER")
        box:SetText(DisplayVal(getter()))
        box:SetCursorPosition(0)

        slider:SetScript("OnValueChanged", function(_, val, userInput)
            if userInput ~= nil and not userInput then return end
            local snapped = math_floor(val / step + 0.5) * step
            setter(snapped)
            box:SetText(DisplayVal(snapped))
            box:SetCursorPosition(0)
            ApplyDisplaySettings()
        end)

        box:SetScript("OnTextChanged", function(_, userInput)
            if not userInput then return end
            local val = tonumber(box:GetText())
            if val and formatFunc and val < minVal then
                val = math_floor(val * 100 + 0.5)
            end
            if not val then return end
            slider:SetValue(val); setter(val)
            ApplyDisplaySettings()
        end)

        box:SetScript("OnEnterPressed", function(self) self:ClearFocus() end)

        box:SetScript("OnEditFocusLost", function()
            local val = tonumber(box:GetText())
            if val and formatFunc and val < minVal then
                val = math_floor(val * 100 + 0.5)
            end
            if val then
                val = math_max(minVal, math.min(maxVal, val))
                val = math_floor(val / step + 0.5) * step
                setter(val); slider:SetValue(val)
            end
            box:SetText(DisplayVal(getter()))
            box:SetCursorPosition(0)
        end)

        container.Refresh = function()
            slider:SetValue(getter())
            box:SetText(DisplayVal(getter()))
            box:SetCursorPosition(0)
        end
        return container
    end

    local soundDropdowns = {}
    local function MakeSoundRow(parent, labelText, y, getEnabled, setEnabled, getSound, setSound)
        local cb = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate")
        cb:SetPoint("TOPLEFT", 8, y)
        cb:SetChecked(getEnabled())
        cb:SetScript("OnClick", function(self) setEnabled(self:GetChecked()) end)
        local lbl = cb:CreateFontString(nil, "OVERLAY")
        lbl:SetPoint("LEFT", cb, "RIGHT", 2, 0)
        SetUIFont(lbl, 12)
        lbl:SetText(labelText)
        lbl:SetTextColor(1, 1, 1)
        cb.Refresh = function() cb:SetChecked(getEnabled()) end

        local dd = CreateFrame("DropdownButton", nil, parent, "WowStyle1DropdownTemplate")
        dd:SetPoint("LEFT", cb, "RIGHT", 324, 0)
        dd:SetWidth(160)

        local function SetupMenu()
            dd:SetupMenu(function(_, root)
                for _, name in ipairs(SOUND_NAMES) do
                    local n = name
                    root:CreateRadio(n,
                        function() return getSound() == n end,
                        function() setSound(n); dd:SetDefaultText(n) end,
                        n)
                end
            end)
        end
        C_Timer.After(0, SetupMenu)
        dd:SetDefaultText(getSound() or "")

        local previewBtn = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
        previewBtn:SetSize(60, 20)
        previewBtn:SetPoint("LEFT", dd, "RIGHT", 6, 0)
        previewBtn:SetText("Preview")
        previewBtn:SetScript("OnClick", function()
            local p = GetSoundPath(getSound())
            if p then PlaySoundFile(p, "Master") end
        end)

        dd.Refresh = function()
            dd:SetDefaultText(getSound() or "")
            SetupMenu()
        end
        return cb, dd
    end

    -- ================================================================
    -- TITLE
    -- ================================================================
    local title = panel:CreateFontString(nil, "OVERLAY")
    title:SetPoint("TOP", panel, "TOP", 0, -16)
    SetUIFont(title, 20, "OUTLINE")
    title:SetText("Nem: Healer Alerts")
    title:SetTextColor(THEME_R, THEME_G, THEME_B)
    themeFS[#themeFS + 1] = { fs=title, alpha=1 }

    local subtitle = panel:CreateFontString(nil, "OVERLAY")
    subtitle:SetPoint("TOP", title, "BOTTOM", 0, -6)
    subtitle:SetWidth(PW - 32)
    subtitle:SetJustifyH("CENTER")
    SetUIFont(subtitle, 11)
    subtitle:SetText(
        "Healer alerts, procs, and countdown timers. "
        .. "Type /nha to open this panel.")
    subtitle:SetTextColor(0.75, 0.75, 0.75)

    -- ================================================================
    -- DISPLAY
    -- ================================================================
    local displayBox = SectionBox(0, 0, CPW, 155)
    SectionHeader(displayBox, "Display", 8, -6)

    checkboxes.enabled = MakeCheckbox("Enable Addon", 8, -28,
        function() return db.enabled end,
        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)

    checkboxes.flash = MakeCheckbox("Flash Animation", 8, -68,
        function() return db.flash end,
        function(v)
            db.flash = v and true or false
            if not db.flash then
                local f = NHA.display
                if f and f.flashGroup:IsPlaying() then f.flashGroup:Stop() end
                if f then f:SetAlpha(1) end
            end
        end)

    checkboxes.glow = MakeCheckbox("Icon Glow", 8, -108,
        function() return db.glowEnabled end,
        function(v)
            db.glowEnabled = v and true or false
            if not db.glowEnabled then
                local mod = NHA.activeModule
                if mod and mod.ClearAllGlows then mod:ClearAllGlows() end
            end
        end)

    -- Scale slider
    sliders.scale = MakeSlider(scrollContent, "Alert Text Scale", 50, 200, 10, 358, -24, 270,
        function() return math_floor((db.scale or 1.0) * 100 + 0.5) end,
        function(v) db.scale = v / 100 end,
        function(v) return string_format("%.1f", v / 100) end)

    -- Font size slider
    sliders.fontSize = MakeSlider(scrollContent, "Font Size", 1, 100, 1, 358, -84, 270,
        function() return db.fontSize or 25 end,
        function(v) db.fontSize = v end,
        nil)

    -- ================================================================
    -- SOUNDS (built dynamically from active module)
    -- ================================================================
    local soundBox = SectionBox(0, -163, CPW, 185)
    SectionHeader(soundBox, "Sounds", 8, -6)

    -- Sound rows are rebuilt in OnShow from the active module
    local soundCheckboxes = {}
    local soundDDs        = {}

    -- ================================================================
    -- ALERTS (built dynamically from active module)
    -- ================================================================
    local alertBox = SectionBox(0, -376, CPW, 100)
    SectionHeader(alertBox, "Alerts", 8, -6)

    local alertCheckboxes = {}

    -- ================================================================
    -- ALERT OPTIONS
    -- ================================================================
    local alertOptionsBox = SectionBox(0, -800, CPW, 100)   -- positioned dynamically
    SectionHeader(alertOptionsBox, "Alert Options", 8, -6)

    local alertOptionsWidgets = {}  -- all dynamic children, wiped on rebuild

    -- ================================================================
    -- SPEC-SPECIFIC OPTIONS AREA (within Alert Options)
    -- ================================================================
    local specCheckboxes = {}

    -- ================================================================
    -- BUTTONS
    -- ================================================================
    local testBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    testBtn:SetSize(110, 26)
    testBtn:SetText("Test")
    testBtn:SetScript("OnClick", function() NHA:ToggleTest() end)
    NHA.testBtn = testBtn

    local lockBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    lockBtn:SetSize(120, 26)
    lockBtn:SetText("Unlock Frame")
    lockBtn:SetScript("OnClick", function() SetLockState(not coreState.unlocked) end)
    NHA.lockBtn = lockBtn

    local resetBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    resetBtn:SetSize(120, 26)
    resetBtn:SetText("Center Position")
    resetBtn:SetScript("OnClick", function()
        db.x             = CORE_DEFAULTS.x
        db.y             = CORE_DEFAULTS.y
        db.point         = CORE_DEFAULTS.point
        db.relativePoint = CORE_DEFAULTS.relativePoint
        db.relativeTo    = CORE_DEFAULTS.relativeTo
        ApplyDisplaySettings()
        Msg("Position centered.")
    end)

    local restoreBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    restoreBtn:SetSize(130, 26)
    restoreBtn:SetText("Restore Defaults")
    restoreBtn:SetScript("OnClick", function()
        local mod = NHA.activeModule
        db.scale         = CORE_DEFAULTS.scale
        db.fontSize      = CORE_DEFAULTS.fontSize
        db.fontName      = CORE_DEFAULTS.fontName
        db.x             = CORE_DEFAULTS.x
        db.y             = CORE_DEFAULTS.y
        db.point         = CORE_DEFAULTS.point
        db.relativePoint = CORE_DEFAULTS.relativePoint
        db.relativeTo    = 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:IsShown() then panel:Hide(); panel:Show() end
        Msg("Defaults restored.")
    end)
    NHA.restoreBtn = restoreBtn

    local btnSpacing = 10
    local totalBtnW  = 110 + 120 + 120 + 130 + (btnSpacing * 3)
    local btnStartX  = math_floor((PW - totalBtnW) / 2) - 8
    testBtn:SetPoint("TOPLEFT",  btnStartX, -553)
    lockBtn:SetPoint("LEFT",    testBtn,    "RIGHT", btnSpacing, 0)
    resetBtn:SetPoint("LEFT",   lockBtn,    "RIGHT", btnSpacing, 0)
    restoreBtn:SetPoint("LEFT", resetBtn,   "RIGHT", btnSpacing, 0)

    -- ================================================================
    -- CONTENT AREA BACKGROUND (solid black behind unsupported text)
    -- ================================================================
    local contentBG = CreateFrame("Frame", nil, panel,
        BackdropTemplateMixin and "BackdropTemplate" or nil)
    contentBG:SetPoint("TOPLEFT",     panel, "TOPLEFT",     0, SCROLL_TOP)
    contentBG:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", 0, SCROLL_BTM)
    contentBG:SetFrameLevel(panel:GetFrameLevel() + 1)
    if contentBG.SetBackdrop then
        contentBG:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8" })
        contentBG:SetBackdropColor(0, 0, 0, 1)
    end

    -- ================================================================
    -- INITIAL STATE: hide all content sections at build time.
    -- OnShow will show them if a healer spec is active.
    -- ================================================================
    subtitle:Hide()
    viewport:Hide(); sbBar:Hide()
    displayBox:Hide()
    soundBox:Hide()
    alertBox:Hide()
    alertOptionsBox:Hide()
    testBtn:Hide()
    lockBtn:Hide()
    resetBtn:Hide()
    restoreBtn:Hide()
    for _, cb in pairs(checkboxes) do cb:Hide() end
    for _, sl in pairs(sliders)    do sl:Hide() end

    -- ================================================================
    -- UNSUPPORTED SPEC TEXT (parented to contentBG, auto-shows/hides)
    -- ================================================================
    local unsupportedHeader = contentBG:CreateFontString(nil, "OVERLAY")
    unsupportedHeader:SetPoint("CENTER", contentBG, "CENTER", 0, 12)
    SetUIFont(unsupportedHeader, 18, "OUTLINE")
    unsupportedHeader:SetText("Spec Not Supported")
    unsupportedHeader:SetTextColor(0, 0.8, 1.0)

    local unsupportedSub = contentBG:CreateFontString(nil, "OVERLAY")
    unsupportedSub:SetPoint("TOP", unsupportedHeader, "BOTTOM", 0, -8)
    unsupportedSub:SetWidth(PW - 32)
    unsupportedSub:SetJustifyH("CENTER")
    SetUIFont(unsupportedSub, 11)
    unsupportedSub:SetText("Only Healer Specs Supported")
    unsupportedSub:SetTextColor(0, 0.8, 1.0)

    -- ================================================================
    -- VERSION FOOTER
    -- ================================================================
    local versionFS = panel:CreateFontString(nil, "OVERLAY")
    versionFS:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -16, 8)
    SetUIFont(versionFS, 10)
    versionFS:SetText(NHA_VERSION)
    versionFS:SetTextColor(THEME_R, THEME_G, THEME_B, 0.6)
    themeFS[#themeFS + 1] = { fs=versionFS, alpha=0.6 }

    -- ================================================================
    -- OnShow: rebuild dynamic sections from active module
    -- ================================================================

    -- Helper: hide all content sections and buttons (unsupported state)
    local function HideAllContent()
        subtitle:Hide()
        viewport:Hide(); sbBar:Hide()
        displayBox:Hide()
        soundBox:Hide()
        alertBox:Hide()
        alertOptionsBox:Hide()
        testBtn:Hide()
        lockBtn:Hide()
        resetBtn:Hide()
        restoreBtn:Hide()
        for _, cb in pairs(checkboxes) do cb:Hide() end
        for _, sl in pairs(sliders)    do sl:Hide() end
        for _, cb in pairs(soundCheckboxes) do cb:Hide() end
        for _, dd in pairs(soundDDs) do dd:Hide() end
        for _, cb in pairs(alertCheckboxes) do cb:Hide() end
        for _, cb in pairs(specCheckboxes) do cb:Hide() end
        contentBG:Show()
    end

    -- Helper: show all content sections and buttons (supported state)
    local function ShowAllContent()
        subtitle:Show()
        viewport:Show()
        sbBar:Show()
        displayBox:Show()
        soundBox:Show()
        alertBox:Show()
        alertOptionsBox:Show()
        testBtn:Show()
        lockBtn:Show()
        resetBtn:Show()
        restoreBtn:Show()
        for _, cb in pairs(checkboxes) do cb:Show() end
        for _, sl in pairs(sliders)    do sl:Show() end
        contentBG:Hide()
    end

    panel:SetScript("OnShow", function()
        UpdatePanelTheme()

        if not IsFullyImplemented() then
            HideAllContent()
            return
        end

        ShowAllContent()

        local mod = NHA.activeModule
        if not mod then return end

        -- ── Rebuild Sound Rows ────────────────────────────────────────
        for _, cb in pairs(soundCheckboxes) do cb:Hide() end
        for _, dd in pairs(soundDDs) do dd:Hide() end
        wipe(soundCheckboxes)
        wipe(soundDDs)

        local soundRowIdx = 0
        for _, alert in ipairs(mod.alerts) do
            if not alert.noSound then
                local k = alert.key
                local y = -28 - soundRowIdx * 40
                soundRowIdx = soundRowIdx + 1
                local cbS, ddS = MakeSoundRow(soundBox,
                    alert.label, y,
                    function() return db[k .. "SoundEnabled"] end,
                    function(v) db[k .. "SoundEnabled"] = v end,
                    function() return db[k .. "SoundName"] end,
                    function(v) db[k .. "SoundName"] = v end)
                soundCheckboxes[k] = cbS
                soundDDs[k]        = ddS
            end
        end

        -- ── Rebuild Alert Rows ────────────────────────────────────────
        for _, cb in pairs(alertCheckboxes) do cb:Hide() end
        wipe(alertCheckboxes)

        local soundRowH = math_max(1, soundRowIdx) * 40 + 36
        soundBox:SetHeight(soundRowH)
        local alertBoxY = -(163 + soundRowH + 8)
        alertBox:ClearAllPoints()
        alertBox:SetPoint("TOPLEFT", 0, alertBoxY)

        local alertRows = math_floor((#mod.alerts + 1) / 2)
        local alertBoxH = 30 + alertRows * 28 + 12
        alertBox:SetHeight(alertBoxH)

        local alertBaseY = alertBoxY - 30

        for i, alert in ipairs(mod.alerts) do
            local k    = alert.key
            local col  = (i - 1) % 2
            local row  = math_floor((i - 1) / 2)
            local xPos = col == 0 and 20 or 330
            local yPos = alertBaseY - row * 28
            alertCheckboxes[k] = MakeAlertRow(scrollContent,
                alert.label .. " Alert", xPos, yPos,
                function() return db[k .. "Enabled"] end,
                function(v) db[k .. "Enabled"] = v end,
                k)
        end

        -- ── Rebuild Alert Options Section ─────────────────────────────
        for _, w in ipairs(alertOptionsWidgets) do
            if w.Hide then w:Hide() end
        end
        wipe(alertOptionsWidgets)
        for _, cb in pairs(specCheckboxes) do cb:Hide() end
        wipe(specCheckboxes)

        local alertOptionsBoxY = alertBoxY - alertBoxH - 8
        alertOptionsBox:ClearAllPoints()
        alertOptionsBox:SetPoint("TOPLEFT", 0, alertOptionsBoxY)

        local aoCursor = -30  -- Y cursor inside alertOptionsBox

        -- Font dropdown
        local fontPool = {}
        local fontDD = CreateFrame("DropdownButton", "NemHealerAlertsFontDrop",
                                   alertOptionsBox, "WowStyle1DropdownTemplate")
        fontDD:SetPoint("TOPLEFT", 10, aoCursor)
        fontDD:SetWidth(220)
        fontDD:SetDefaultText(db.fontName or "Gotham Narrow Ultra")
        alertOptionsWidgets[#alertOptionsWidgets + 1] = fontDD
        NHA.fontDropdown = fontDD

        local function SetupFontMenu()
            fontDD:SetupMenu(function(_, root)
                root:SetScrollMode(12 * 16)
                for idx, 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 fs2 = fontPool[idx]
                            if not fs2 then
                                fs2 = fontDD:CreateFontString(nil, "BACKGROUND")
                                fontPool[idx] = fs2
                            end
                            fs2:SetParent(b)
                            fs2:ClearAllPoints()
                            fs2:SetPoint("LEFT", b, "LEFT", 5, 0)
                            fs2:SetFont(path, 12)
                            fs2:SetText(n)
                            fs2:Show()
                        end)
                    end
                end
            end)
        end
        hooksecurefunc(fontDD, "OnMenuClosed", function()
            for _, fs2 in pairs(fontPool) do fs2:Hide() end
        end)
        C_Timer.After(0, SetupFontMenu)
        fontDD.Refresh = function()
            fontDD:SetDefaultText(db.fontName or "Gotham Narrow Ultra")
            SetupFontMenu()
        end

        aoCursor = aoCursor - 36  -- dd + gap

        -- Spec-specific options (BuildAlertOptions) — helpers below
        local specOptionsStartCursor = aoCursor

        local function TrackWidget(w)
            alertOptionsWidgets[#alertOptionsWidgets + 1] = w
            specCheckboxes[#specCheckboxes + 1] = w
            return w
        end

        -- We'll give the spec module a cursor reference so it can
        -- place items and return the Y it used, advancing aoCursor.
        -- The helpers pass absolute Y coords inside alertOptionsBox.
        local specCursorY = { val = aoCursor }

        local specHelpers = {
            -- Place a checkbox inside alertOptionsBox at the current cursor.
            -- Returns the checkbox. Advances the caller's cursor by 26px.
            MakeCheckbox = function(labelText, getter, setter)
                local cb = CreateFrame("CheckButton", nil, alertOptionsBox,
                                       "UICheckButtonTemplate")
                cb:SetPoint("TOPLEFT", 10, specCursorY.val)
                cb:SetChecked(getter())
                cb:SetScript("OnClick", function(self)
                    setter(self:GetChecked())
                    Evaluate()
                end)
                local lbl = cb:CreateFontString(nil, "OVERLAY")
                lbl:SetPoint("LEFT", cb, "RIGHT", 2, 0)
                SetUIFont(lbl, 12)
                lbl:SetText(labelText)
                lbl:SetTextColor(1, 1, 1)
                cb.Refresh = function() cb:SetChecked(getter()) end
                specCursorY.val = specCursorY.val - 26
                TrackWidget(cb)
                return cb
            end,
            -- Place a checkbox in a named column position (x offset from left).
            MakeCheckboxAt = function(labelText, xOff, getter, setter)
                local cb = CreateFrame("CheckButton", nil, alertOptionsBox,
                                       "UICheckButtonTemplate")
                cb:SetPoint("TOPLEFT", xOff, specCursorY.val)
                cb:SetChecked(getter())
                cb:SetScript("OnClick", function(self)
                    setter(self:GetChecked())
                    Evaluate()
                end)
                local lbl = cb:CreateFontString(nil, "OVERLAY")
                lbl:SetPoint("LEFT", cb, "RIGHT", 2, 0)
                SetUIFont(lbl, 12)
                lbl:SetText(labelText)
                lbl:SetTextColor(1, 1, 1)
                cb.Refresh = function() cb:SetChecked(getter()) end
                TrackWidget(cb)
                return cb
            end,
            -- Place two checkboxes side-by-side on one row, then advance cursor.
            MakeCheckboxRow = function(label1, getter1, setter1, label2, getter2, setter2)
                local cb1 = CreateFrame("CheckButton", nil, alertOptionsBox,
                                        "UICheckButtonTemplate")
                cb1:SetPoint("TOPLEFT", 10, specCursorY.val)
                cb1:SetChecked(getter1())
                cb1:SetScript("OnClick", function(self)
                    setter1(self:GetChecked()); Evaluate()
                end)
                local lbl1 = cb1:CreateFontString(nil, "OVERLAY")
                lbl1:SetPoint("LEFT", cb1, "RIGHT", 2, 0)
                SetUIFont(lbl1, 12); lbl1:SetText(label1); lbl1:SetTextColor(1,1,1)
                cb1.Refresh = function() cb1:SetChecked(getter1()) end
                TrackWidget(cb1)

                if label2 then
                    local cb2 = CreateFrame("CheckButton", nil, alertOptionsBox,
                                            "UICheckButtonTemplate")
                    cb2:SetPoint("TOPLEFT", 200, specCursorY.val)
                    cb2:SetChecked(getter2())
                    cb2:SetScript("OnClick", function(self)
                        setter2(self:GetChecked()); Evaluate()
                    end)
                    local lbl2 = cb2:CreateFontString(nil, "OVERLAY")
                    lbl2:SetPoint("LEFT", cb2, "RIGHT", 2, 0)
                    SetUIFont(lbl2, 12); lbl2:SetText(label2); lbl2:SetTextColor(1,1,1)
                    cb2.Refresh = function() cb2:SetChecked(getter2()) end
                    TrackWidget(cb2)
                end
                specCursorY.val = specCursorY.val - 26
            end,
            -- Left: plain checkbox. Right: checkbox + numeric input inline.
            -- All on one row; cursor advances once.
            MakeCheckboxWithNumeric = function(
                    label1, getter1, setter1,
                    label2, getter2, setter2,
                    numLabel, numMin, numMax, numGetter, numSetter)
                -- Left checkbox
                local cb1 = CreateFrame("CheckButton", nil, alertOptionsBox,
                                        "UICheckButtonTemplate")
                cb1:SetPoint("TOPLEFT", 10, specCursorY.val)
                cb1:SetChecked(getter1())
                cb1:SetScript("OnClick", function(self)
                    setter1(self:GetChecked()); Evaluate()
                end)
                local lbl1 = cb1:CreateFontString(nil, "OVERLAY")
                lbl1:SetPoint("LEFT", cb1, "RIGHT", 2, 0)
                SetUIFont(lbl1, 12); lbl1:SetText(label1); lbl1:SetTextColor(1,1,1)
                cb1.Refresh = function() cb1:SetChecked(getter1()) end
                TrackWidget(cb1)

                -- Right checkbox (shifted left to give more room for numeric input)
                local cb2 = CreateFrame("CheckButton", nil, alertOptionsBox,
                                        "UICheckButtonTemplate")
                cb2:SetPoint("TOPLEFT", 150, specCursorY.val)
                cb2:SetChecked(getter2())
                cb2:SetScript("OnClick", function(self)
                    setter2(self:GetChecked()); Evaluate()
                end)
                local lbl2 = cb2:CreateFontString(nil, "OVERLAY")
                lbl2:SetPoint("LEFT", cb2, "RIGHT", 2, 0)
                SetUIFont(lbl2, 12); lbl2:SetText(label2); lbl2:SetTextColor(1,1,1)
                cb2.Refresh = function() cb2:SetChecked(getter2()) end
                TrackWidget(cb2)

                -- Numeric label + edit box, anchored right of cb2's label
                local numLbl = alertOptionsBox:CreateFontString(nil, "OVERLAY")
                numLbl:SetPoint("TOPLEFT", alertOptionsBox, "TOPLEFT", 325, specCursorY.val - 10)
                SetUIFont(numLbl, 12)
                numLbl:SetText(numLabel)
                numLbl:SetTextColor(1, 1, 1)
                alertOptionsWidgets[#alertOptionsWidgets + 1] = numLbl

                local numBox = CreateFrame("EditBox", nil, alertOptionsBox,
                                           "InputBoxTemplate")
                numBox:SetPoint("LEFT", numLbl, "RIGHT", 8, 4)
                numBox:SetSize(46, 20)
                numBox:SetAutoFocus(false)
                numBox:SetMaxLetters(3)
                numBox:SetFontObject("GameFontWhite")
                numBox:SetTextColor(THEME_R, THEME_G, THEME_B)
                themeFS[#themeFS + 1] = { setColor = function(r, g, b) numBox:SetTextColor(r, g, b) end }
                numBox:SetJustifyH("CENTER")
                numBox:SetText(tostring(numGetter()))
                numBox:SetCursorPosition(0)
                numBox:SetScript("OnEnterPressed", function(self) self:ClearFocus() end)
                numBox:SetScript("OnEditFocusLost", function(self)
                    local v = tonumber(self:GetText())
                    if v then
                        v = math_floor(v + 0.5)
                        v = math_max(numMin, math.min(numMax, v))
                        numSetter(v)
                        self:SetText(tostring(v))
                    else
                        self:SetText(tostring(numGetter()))
                    end
                    self:SetCursorPosition(0)
                end)
                numBox.Refresh = function()
                    numBox:SetText(tostring(numGetter()))
                    numBox:SetCursorPosition(0)
                end
                alertOptionsWidgets[#alertOptionsWidgets + 1] = numBox

                specCursorY.val = specCursorY.val - 26
            end,
            -- minVal/maxVal enforce input bounds. Returns the editbox.
            MakeNumericInput = function(labelText, minVal, maxVal, getter, setter)
                local lbl = alertOptionsBox:CreateFontString(nil, "OVERLAY")
                lbl:SetPoint("TOPLEFT", 10, specCursorY.val - 4)
                SetUIFont(lbl, 12)
                lbl:SetText(labelText)
                lbl:SetTextColor(1, 1, 1)
                alertOptionsWidgets[#alertOptionsWidgets + 1] = lbl

                local box = CreateFrame("EditBox", nil, alertOptionsBox,
                                        "InputBoxTemplate")
                box:SetPoint("LEFT", lbl, "RIGHT", 8, 4)
                box:SetSize(46, 20)
                box:SetAutoFocus(false)
                box:SetMaxLetters(3)
                box:SetFontObject("GameFontWhite")
                box:SetJustifyH("CENTER")
                box:SetText(tostring(getter()))
                box:SetCursorPosition(0)
                box:SetScript("OnEnterPressed", function(self) self:ClearFocus() end)
                box:SetScript("OnEditFocusLost", function(self)
                    local v = tonumber(self:GetText())
                    if v then
                        v = math_floor(v + 0.5)
                        v = math_max(minVal, math.min(maxVal, v))
                        setter(v)
                        self:SetText(tostring(v))
                    else
                        self:SetText(tostring(getter()))
                    end
                    self:SetCursorPosition(0)
                end)
                box.Refresh = function()
                    box:SetText(tostring(getter()))
                    box:SetCursorPosition(0)
                end
                alertOptionsWidgets[#alertOptionsWidgets + 1] = box
                specCursorY.val = specCursorY.val - 28
                return box
            end,
            -- Advance the cursor by a given number of pixels without adding a widget.
            Spacer = function(px)
                specCursorY.val = specCursorY.val - (px or 8)
            end,
            alertOptionsBox = alertOptionsBox,
            db              = db,
        }

        if mod.BuildAlertOptions then
            mod:BuildAlertOptions(alertOptionsBox, db, specHelpers)
        end

        -- Sync cursor back after spec options — text editors start immediately below
        aoCursor = specCursorY.val - 28

        -- Two-column layout: left x=10, right x=CPW/2+4
        local halfW   = math_floor(CPW / 2) - 14
        local colR    = math_floor(CPW / 2) + 4
        local rowH    = 44  -- label(12) + gap(4) + editbox(20) + gap(8)

        for i, alert in ipairs(mod.alerts) do
            local k       = alert.key
            local col     = (i - 1) % 2
            local rowIdx  = math_floor((i - 1) / 2)
            local xPos    = col == 0 and 10 or colR
            local yPos    = aoCursor - rowIdx * rowH

            -- Label
            local lbl = alertOptionsBox:CreateFontString(nil, "OVERLAY")
            lbl:SetPoint("TOPLEFT", xPos, yPos)
            SetUIFont(lbl, 11)
            lbl:SetText(alert.label)
            lbl:SetTextColor(0.8, 0.8, 0.8)
            alertOptionsWidgets[#alertOptionsWidgets + 1] = lbl

            -- Edit box
            local editBox = CreateFrame("EditBox", nil, alertOptionsBox,
                                        "InputBoxTemplate")
            editBox:SetPoint("TOPLEFT", xPos, yPos - 16)
            editBox:SetSize(halfW, 20)
            editBox:SetAutoFocus(false)
            editBox:SetMaxLetters(80)
            editBox:SetFontObject("GameFontWhite")
            local savedText = db[k .. "AlertText"]
            editBox:SetText((savedText and savedText ~= "") and savedText or alert.text)
            editBox:SetCursorPosition(0)
            editBox:SetScript("OnEnterPressed", function(self) self:ClearFocus() end)
            editBox:SetScript("OnEditFocusLost", function(self)
                local t = self:GetText()
                if t == "" or t == alert.text then
                    db[k .. "AlertText"] = nil
                    self:SetText(alert.text)
                else
                    db[k .. "AlertText"] = t
                end
                self:SetCursorPosition(0)
                ApplyDisplaySettings()
            end)
            editBox.Refresh = function()
                local st = db[k .. "AlertText"]
                editBox:SetText((st and st ~= "") and st or alert.text)
                editBox:SetCursorPosition(0)
            end
            alertOptionsWidgets[#alertOptionsWidgets + 1] = editBox
        end

        -- Compute final alertOptionsBox height
        local textEditorRows = math_floor((#mod.alerts + 1) / 2)
        local textEditorsH   = textEditorRows * rowH
        local specOptionsH   = specOptionsStartCursor - specCursorY.val
        local aoH = 30 + 36 + specOptionsH + 28 + textEditorsH + 12
        alertOptionsBox:SetHeight(aoH)

        -- Refresh core widgets
        for _, cb in pairs(checkboxes) do cb:Refresh() end
        for _, sl in pairs(sliders)    do sl:Refresh() end
        if NHA.fontDropdown then NHA.fontDropdown.Refresh() end
        lockBtn:SetText(coreState.unlocked and "Lock Frame" or "Unlock Frame")
        testBtn:SetText(coreState.testMode and "Stop Test"  or "Test")

        local contentH = math_abs(alertOptionsBoxY) + aoH + 20
        SetContentHeight(contentH)
    end)

    -- Re-run UpdateScrollbar when panel size is finalized by the Settings system
    panel:SetScript("OnSizeChanged", function()
        C_Timer.After(0, function() sbBar:UpdateScrollbar() end)
    end)

    -- ================================================================
    -- Register
    -- ================================================================
    if Settings and Settings.RegisterCanvasLayoutCategory then
        local category = Settings.RegisterCanvasLayoutCategory(panel, "Nem: Healer Alerts")
        Settings.RegisterAddOnCategory(category)
        self.optionsCategoryID = category:GetID()
    end

    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
end

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

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

    local key, mod = FindActiveModule()
    if key and mod then
        self:InitGlowHooks()
        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

    -- Spec / talent change: re-evaluate which module should be active
    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:InitGlowHooks()
                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
        else
            -- Same module, but talents may have changed
            if mod and mod.OnEvent then
                mod:OnEvent(db, event, ...)
            end
        end
        ScheduleEvaluate()
        return
    end

    -- No active module → ignore gameplay events
    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

    -- Events that invalidate the group-units cache
    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 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")

