-- =============================================================
-- NemHealerAlerts.lua  v12.0.15
-- Nem: Healer Alerts — Healer alerts with countdown timers
-- Part of the Nem addon suite.  Multi-spec modular architecture.
--
-- This file is the CORE ENGINE. It contains:
--   • Spec module registration system
--   • Display construction & layout
--   • Options panel builder (driven by spec module definitions)
--   • Event routing to the active spec module
--   • Icon glow infrastructure (ShowProcGlow / HideProcGlow)
--   • Sound playback, font/sound asset management
--   • Slash commands, lock/unlock, test mode
--
-- It contains ZERO spec-specific spell IDs, aura logic, or
-- scan functions. All spec behavior lives in Specs/*.lua files
-- which register themselves via NHA:RegisterSpec().
--
-- 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_abs = math.floor, math.max, math.abs
local string_format           = string.format
local CreateFrame             = CreateFrame
local GetTime                 = GetTime
local UnitClass               = UnitClass
local UnitExists              = UnitExists
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.15"

-- =============================================================
-- Asset Paths  (all self-contained under NemHealerAlerts\)
-- =============================================================
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"

-- Expose to spec modules
NHA.FONTS_PATH = NHA_FONTS
NHA.MEDIA_PATH = NHA_MEDIA
NHA.UI_FONT    = UI_FONT

-- =============================================================
-- 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 + specIndex, the panel
-- shows a "Spec Not Supported" message.

-- =============================================================
-- Spec Module Registry
-- =============================================================
-- Modules register via NHA:RegisterSpec(key, module)
-- key format: "CLASS_SPEC" e.g. "DRUID_RESTO", "PRIEST_DISC"
--
-- Module table contract (all functions are optional unless noted):
-- {
--   class        = "CLASS",                    -- REQUIRED: WoW class token
--   specIndex    = 1,                          -- REQUIRED: GetSpecialization() index
--   specName     = "Spec Name",                -- display name for messages
--
--   -- Alert definitions: ordered list of alerts this spec provides.
--   -- Each entry drives display rows, options checkboxes, sound rows,
--   -- color swatches, and test mode — automatically.
--   alerts = {
--     {
--       key          = "alert1",               -- unique key, used in db and state
--       label        = "Alert Label",           -- display name in options
--       text         = "* ALERT TEXT *",        -- alert text shown on screen
--       defaultColor = { r=1, g=1, b=1 },
--       hasTimer     = true,                   -- optional countdown timer
--       timerCycleSec = 5.0,                   -- test mode cycle duration
--       defaultSound = "Bell",                 -- default sound name
--       yOffset      = -76,                    -- Y offset for alert text row
--       timerYOffset = -96,                    -- Y offset for timer row
--     },
--     ...
--   },
--
--   -- Defaults merged into saved variables
--   defaults = { ... },
--
--   -- State table (module owns this entirely)
--   state = { ... },
--
--   -- REQUIRED: Main evaluation function, called by the ticker and events.
--   -- Must set module.state.<alertKey>Active and module.state.<alertKey>Expire
--   -- for each alert, plus module.state.<alertKey>TimeMod for timers.
--   Evaluate = function(self, db) end,
--
--   -- Called once at login when this spec is active.
--   OnActivate = function(self, db) end,
--
--   -- Called when switching away from this spec.
--   OnDeactivate = function(self, db) end,
--
--   -- Event handler: receives (self, db, event, ...).
--   -- Return true to suppress the core's default ScheduleEvaluate.
--   OnEvent = function(self, db, event, ...) end,
--
--   -- Glow evaluation: called from buff frame hooks.
--   -- Receives (self, db, buffFrame, aura).
--   -- Module should call NHA:ShowProcGlow / NHA:HideProcGlow.
--   EvaluateFrameGlow = function(self, db, buffFrame, aura) end,
--
--   -- Called every 0.25s by the ticker to refresh glow state.
--   RefreshGlows = function(self, db) end,
--
--   -- Called to wipe all glow state (on disable, death, spec change).
--   ClearAllGlows = function(self) end,
--
--   -- Called to fully reset all runtime state.
--   ClearAllState = function(self) end,
--
--   -- Additional events this spec needs beyond the core set.
--   extraEvents = { ... },
--
--   -- Options panel: called to build spec-specific options in the
--   -- "Alert Options" section.  Receives (self, parent, db, helpers)
--   -- where helpers is a table of UI builder functions.
--   -- Use helpers.MakeCheckbox for timer toggles, helpers.MakeNumericInput
--   -- for numeric settings (e.g. Abundance stack threshold).
--   BuildAlertOptions = function(self, parent, db, helpers) end,
--
--   -- Slash command extensions: { ["cmd"] = function(self, db) end, ... }
--   slashCommands = {},
-- }

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

-- Find the module for the current class + spec.
local function FindActiveModule()
    if not activeClass then return nil, nil end
    local specIndex = GetSpecialization and GetSpecialization()
    for key, mod in pairs(NHA.specModules) do
        if mod.class == activeClass and mod.specIndex == specIndex then
            return key, mod
        end
    end
    return nil, nil
end

-- Returns true if an active module exists and is usable.
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

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)
-- =============================================================
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 nil 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 nil
end

-- Sorted sound list for dropdown.
local SOUND_NAMES = {}
do
    for k in pairs(BUNDLED_SOUNDS) do SOUND_NAMES[#SOUND_NAMES+1] = k end
    table.sort(SOUND_NAMES)
end

-- Expose for spec modules
NHA.GetSoundPath = GetSoundPath
NHA.SOUND_NAMES  = SOUND_NAMES

-- =============================================================
-- 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 — spec 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,
}

-- =============================================================
-- Runtime State (core only)
-- =============================================================
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

-- Expose for spec modules
NHA.Msg = Msg
NHA.MergeDefaults = MergeDefaults
NHA.issecretvalue = issecretvalue

-- =============================================================
-- Sound Playback (generic — spec modules provide the key)
-- =============================================================
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 — shared infrastructure
-- =============================================================
-- 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
    if buffFrame._nhaGlowing then return end
    buffFrame._nhaGlowing = true
    _procGlowColor[1] = r
    _procGlowColor[2] = g
    _procGlowColor[3] = 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
    LCG.ProcGlow_Stop(buffFrame)
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

-- Expose for spec modules
NHA.InvalidateGroupUnitsCache = InvalidateGroupUnitsCache
NHA.GetGroupUnits = GetGroupUnits

-- =============================================================
-- Evaluate & Ticker
-- =============================================================
local Evaluate  -- forward declaration
local mainTicker = nil

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

-- Expose for spec modules
NHA.ScheduleEvaluate = ScheduleEvaluate

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

-- =============================================================
-- Display Construction
-- =============================================================
-- The display frame holds up to 4 alert rows + timer rows.
-- Alert FontStrings are created dynamically from the active
-- module's alerts[] table. Stored in display.alertRows[].
--
-- alertRows[i] = {
--   textFS   = FontString,  -- alert text
--   timerFS  = FontString,  -- countdown timer (optional)
--   key      = "lb",        -- alert key from module
-- }

-- Maximum supported alert rows (one spec should use 2–4).
local MAX_ALERT_ROWS = 6

local function CreateDisplay()
    local db  = NHA.db
    local fp  = GetCurrentFontPath()
    local fs  = db.fontSize or 25
    local sc  = db.scale or 1.0

    local relFrame = UIParent
    if db.relativeTo and db.relativeTo ~= "UIParent" then
        relFrame = _G[db.relativeTo] or UIParent
    end

    local f = CreateFrame("Frame", "NemHealerAlertsFrame", UIParent,
        BackdropTemplateMixin and "BackdropTemplate" or nil)
    f:SetSize(900, 160)
    f:SetPoint(db.point or "CENTER", relFrame,
               db.relativePoint or "CENTER", db.x or 0, db.y or 180)
    f:SetMovable(true)
    f:EnableMouse(false)
    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, _, relPoint, x, y = self:GetPoint()
        NHA.db.x             = math_floor(x + 0.5)
        NHA.db.y             = math_floor(y + 0.5)
        NHA.db.point         = point
        NHA.db.relativePoint = relPoint
    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
    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[].
-- Called when the active module changes (login, spec switch).
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

    -- Hide and release any existing rows
    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 }

        -- Alert text (use saved custom text if set, else fall back to definition)
        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()

        -- Timer (optional)
        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

    local relFrame = UIParent
    if db.relativeTo and db.relativeTo ~= "UIParent" then
        relFrame = _G[db.relativeTo] or UIParent
    end
    f:ClearAllPoints()
    f:SetPoint(db.point or "CENTER", relFrame,
               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)
        -- Refresh custom text if it has been changed
        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

-- Expose for spec modules
NHA.ApplyDisplaySettings = ApplyDisplaySettings

-- =============================================================
-- 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]

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

            -- Timer
            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))
                        -- Color tiers: use alert-specific thresholds or defaults
                        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

        -- Flash animation
        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 (core wrapper)
-- =============================================================
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)
                    -- Find the row and set timer directly
                    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

    -- Delegate to spec module
    mod:Evaluate(db)

    -- Refresh glows
    if mod.RefreshGlows then mod:RefreshGlows(db) end

    -- Ticker management: start if any alert is active, stop if none
    local anyActive = false
    for _, alert in ipairs(mod.alerts) do
        if mod.state[alert.key .. "Active"] then
            anyActive = true
            break
        end
    end
    -- Also check if the module wants the ticker running (e.g. for glow refresh)
    if mod.NeedsTicker and mod:NeedsTicker(db) then anyActive = true end

    if anyActive then StartTicker() else StopTicker() end
end

-- Expose for spec modules (they may need to call Evaluate directly)
NHA.Evaluate = function() Evaluate() end

-- =============================================================
-- Lock / Unlock
-- =============================================================
local function SetLockState(unlocked)
    local f = NHA.display
    if not f then return end
    coreState.unlocked = unlocked
    f:EnableMouse(unlocked)
    if f.hasBorder then
        local br, bg, bb = GetClassTheme()
        f:SetBackdropBorderColor(br, bg, bb, unlocked and 0.75 or 0)
    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 = {}  -- track extra events for cleanup

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

    -- Unregister extra events
    for _, ev in ipairs(registeredExtraEvents) do
        NHA:UnregisterEvent(ev)
    end
    wipe(registeredExtraEvents)

    NHA.activeModule    = nil
    NHA.activeModuleKey = nil
    StopTicker()
end

local function ActivateModule(key, mod)
    DeactivateCurrentModule()

    NHA.activeModule    = mod
    NHA.activeModuleKey = key

    -- Merge module defaults into saved variables
    if mod.defaults then
        MergeDefaults(NHA.db, mod.defaults)
    end

    -- Ensure alertColors exist for each alert
    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

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

    -- Build display rows
    BuildAlertRows()
    SetupOnUpdate()
    ApplyDisplaySettings()

    -- Activate
    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 1253591 + absorb 1253593). Wipe Disc-specific keys
        -- so they re-merge cleanly 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()
    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
        -- Clear all active states
        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_IMPLEMENTATION = {
            on=true, off=true, toggle=true, test=true,
            lock=true, unlock=true, reset=true,
        }
        if NEEDS_IMPLEMENTATION[input] and not IsFullyImplemented() then
            Msg("Your current spec is not supported. Only healer specs are supported.")
            return
        end

        -- Check for spec-specific slash commands
        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
            NHA.db.enabled = true; Evaluate()
            Msg("Addon |cff00ff41enabled|r."); return
        end

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

        if input == "toggle" then
            NHA.db.enabled = not NHA.db.enabled
            if NHA.db.enabled then
                Evaluate(); Msg("Addon |cff00ff41enabled|r.")
            else
                if mod then
                    if mod.ClearAllState then mod:ClearAllState() end
                end
                if NHA.display then NHA.display:SetAlpha(1) end
                if coreState.testMode then NHA:ToggleTest() end
                Msg("Addon |cffff4040disabled|r.")
            end
            return
        end

        if input == "test" then
            if not NHA.db.enabled then
                Msg("Addon is disabled. Use |cffffd700/nha on|r first."); return
            end
            NHA:ToggleTest(); return
        end

        if input == "status" then
            local en   = NHA.db.enabled and "|cff00ff41Enabled|r" or "|cffff4040Disabled|r"
            local test = coreState.testMode and "|cffffff00On|r"  or "Off"
            local specStr = NHA.activeModuleKey or "none"
            Msg(("Status: %s  |  Test: %s  |  Spec: %s"):format(en, test, specStr))
            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 == "lock"   then SetLockState(false); return end
        if input == "unlock" then SetLockState(true);  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 lock|r / |cffffd700unlock|r  —  Lock or unlock frame")
            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

        -- Default: open options
        if InCombatLockdown() then
            Msg("Cannot open options while in combat.")
        else
            Settings.OpenToCategory(self.optionsCategoryID)
        end
    end

    SLASH_NEMHEALERALERTS1 = "/nha"
    SLASH_NEMHEALERALERTS2 = "/healeralerts"
    SLASH_NEMHEALERALERTS3 = "/hotalerts"  -- legacy alias
    SlashCmdList.NEMHEALERALERTS = HandleNHACommand

    if AddonCompartmentFrame then
        AddonCompartmentFrame:RegisterAddon({
            text = "Nem: Healer Alerts", notCheckable = true,
            func = function()
                if InCombatLockdown() then
                    Msg("Cannot open options while in combat.")
                else
                    Settings.OpenToCategory(self.optionsCategoryID)
                end
            end,
        })
    end
end

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

    -- Compact unit frames
    hooksecurefunc("CompactUnitFrame_UtilSetBuff", function(buffFrame, aura)
        local _db = NHA.db
        local mod = NHA.activeModule
        if not _db or not _db.glowEnabled or not _db.enabled or not mod then return end
        if not mod.EvaluateFrameGlow then return end
        mod:EvaluateFrameGlow(_db, buffFrame, aura)
    end)

    -- 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 = NHA.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 = NHA.db
        for buffFrame in s.auraPools:EnumerateActive() do
            local aura = C_UnitAuras.GetAuraDataByAuraInstanceID(
                s.unit, buffFrame.auraInstanceID)
            mod:EvaluateFrameGlow(_db, buffFrame, aura)
        end
    end)
end

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

    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   -- scrollbar thumb/track width
    local SB_MARGIN  = 10   -- right margin reserved for scrollbar

    -- Viewport: 8px left margin, SB_MARGIN right margin
    -- scrollContent and boxes: 4px inset on each side inside viewport so borders can't clip
    local VIEWPORT_L = 8
    local VIEWPORT_R = SB_MARGIN
    local BOX_INSET  = 2    -- extra inset inside viewport so border pixel is never on clip edge
    local CW = PW - VIEWPORT_L - VIEWPORT_R - (BOX_INSET * 2)

    -- Viewport
    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

    -- Scrollbar sits inside the right margin, overlapping the panel border slightly
    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)

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

    -- Slim thumb
    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)

    -- Click track to jump
    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)

    -- Size and position the thumb
    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

    -- Smooth continuous drag
    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)

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

    -- Called after layout to set final content height and reset scroll
    local function SetContentHeight(h)
        scrollContent:SetHeight(h)
        ApplyScroll(0)
        C_Timer.After(0.05, function() sbBar:UpdateScrollbar() end)
    end

    -- CPW = content panel width used by SectionBox / layout
    local CPW = CW

    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 (passed to spec modules) ----
    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", 306, 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 SECTION
    -- ================================================================
    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, 340, -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, 340, -84, 270,
        function() return db.fontSize or 25 end,
        function(v) db.fontSize = v end,
        nil)

    -- ================================================================
    -- SOUNDS SECTION (built dynamically from active module's alerts)
    -- ================================================================
    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 SECTION (built dynamically from active module's alerts)
    -- ================================================================
    local alertBox = SectionBox(0, -376, CPW, 100)
    SectionHeader(alertBox, "Alerts", 8, -6)

    local alertCheckboxes = {}

    -- ================================================================
    -- ALERT OPTIONS SECTION (font, spec options, alert text editors)
    -- Built dynamically on OnShow from the active module.
    -- ================================================================
    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
        -- Reset core display settings
        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
        -- Reset alert colors and text for active module
        if mod then
            for _, alert in ipairs(mod.alerts) do
                local k = alert.key
                -- Reset color to default
                if alert.defaultColor and db.alertColors then
                    db.alertColors[k] = {
                        r = alert.defaultColor.r,
                        g = alert.defaultColor.g,
                        b = alert.defaultColor.b,
                    }
                end
                -- Reset alert text to default (nil means "use alert.text")
                db[k .. "AlertText"] = nil
            end
        end
        ApplyDisplaySettings()
        -- Refresh the panel
        if panel:IsShown() then
            panel:Hide(); panel:Show()
        end
        Msg("Defaults restored.")
    end)
    NHA.restoreBtn = restoreBtn

    local spacing = 10
    local totalW  = 110 + 120 + 120 + 130 + spacing * 3
    local startX  = math_floor((PW - totalW) / 2) - 8
    testBtn:SetPoint("TOPLEFT", startX, -553)
    lockBtn:SetPoint("LEFT",    testBtn,   "RIGHT", spacing, 0)
    resetBtn:SetPoint("LEFT",   lockBtn,   "RIGHT", spacing, 0)
    restoreBtn:SetPoint("LEFT", resetBtn,  "RIGHT", spacing, 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, -42)
    contentBG:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", 0,  22)
    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)
    -- Text is set at build time — this IS the default panel state.
    -- ================================================================
    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 visibility is managed by UpdateScrollbar (shown only when scrollable)
        -- but we need it visible so UpdateScrollbar can measure it correctly
        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)

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

        local alertCount = #mod.alerts
        local alertRows  = math_floor((alertCount + 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 ─────────────────────────────
        -- Destroy all previous dynamic children
        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)

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

        -- Cursor tracks content Y inside the box (starts below header)
        local aoCursor = -30  -- top padding after header

        -- ── 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)
                local itemH  = 16
                local maxVis = 12
                root:SetScrollMode(maxVis * itemH)
                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

        -- advance cursor past dropdown + gap
        aoCursor = aoCursor - 26 - 10  -- dd height + gap below

        -- ── Spec-specific options (BuildAlertOptions) ──────────────
        -- helpers table passed to the spec module
        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 - 4)
                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

        -- Total rows for text editors
        local textEditorRows = math_floor((#mod.alerts + 1) / 2)
        local textEditorsH   = textEditorRows * rowH

        -- Compute final alertOptionsBox height
        local specOptionsH  = specOptionsStartCursor - specCursorY.val
        local aoH = 30               -- header
                  + 26 + 10          -- font dd + gap
                  + specOptionsH
                  + 28               -- gap before text editors
                  + textEditorsH
                  + 12               -- bottom pad
        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")

        -- Total scroll content height
        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()

    -- Find and activate the correct spec module
    local key, mod = FindActiveModule()
    if key and mod then
        self:InitGlowHooks()
        ActivateModule(key, mod)
        Msg("Loaded. Type |cffffd700/nha|r for options.")
    end
    -- Non-healer specs: silent, panel shows "Spec Not Supported"
end

-- =============================================================
-- Event Handler
-- =============================================================
NHA:SetScript("OnEvent", function(self, event, ...)
    if event == "ADDON_LOADED" then
        if ... == 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 self.activeModule then
                local oldName = self.activeModule.specName or self.activeModuleKey
                DeactivateCurrentModule()
                -- Hide display
                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
                Msg("Alerts paused (switched away from " .. oldName .. ").")
            end
            if newKey and newMod then
                self:InitGlowHooks()
                ActivateModule(newKey, newMod)
                Msg("Alerts active — " .. (newMod.specName or newKey) .. " spec detected.")
            end
        elseif mod then
            -- Same module, but talent change — delegate to module
            if mod.OnEvent then
                mod:OnEvent(db, event, ...)
            end
            ScheduleEvaluate()
        end
        return
    end

    -- Guard: no active module → ignore gameplay events
    if not mod then return end
    if not db or not db.enabled then return end

    -- Common events that all modules benefit from
    if event == "PLAYER_TARGET_CHANGED"
    or event == "PLAYER_FOCUS_CHANGED" then
        InvalidateGroupUnitsCache()
        if mod.OnEvent then mod:OnEvent(db, event, ...) end
        ScheduleEvaluate()
        return
    end

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

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

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

    if event == "PLAYER_REGEN_DISABLED"
    or event == "PLAYER_REGEN_ENABLED" then
        if mod.OnEvent then mod:OnEvent(db, event, ...) end
        ScheduleEvaluate()
        return
    end

    -- All other events: delegate to the module
    if mod.OnEvent then
        local handled = mod:OnEvent(db, event, ...)
        if not handled then
            ScheduleEvaluate()
        end
    end
end)

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