-- =============================================================
-- NemHotAlerts.lua  v12.0.1.1
-- Nem: HoT Alerts — Restoration Druid HoT pandemic text alerts
-- Part of the Nem addon suite.  Single-file build.
--
-- 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.NemHotAlerts = NHA

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

-- =============================================================
-- Asset Paths  (all self-contained under NemHotAlerts\)
-- =============================================================
local NHA_FONTS  = "Interface\\AddOns\\NemHotAlerts\\Fonts\\"
local NHA_MEDIA  = "Interface\\AddOns\\NemHotAlerts\\Media\\"

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

-- Nem brand colour: #FF8000 orange
local TR, TG, TB = 1.0, 0.502, 0.0

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

-- =============================================================
-- LibCustomGlow (proc glow on buff frame icons)
-- =============================================================
local LCG = LibStub and LibStub("LibCustomGlow-1.0", true)

-- =============================================================
-- Spell / Aura IDs
-- =============================================================
local LIFEBLOOM_IDS = {
    [33763]  = true,   -- Lifebloom
    -- [188550] = true, -- Undergrowth (Midnight hopium)
}

-- Rejuvenation spell IDs (used for empowered-icon glow detection)
local REJUV_IDS = {
    [774]    = true,   -- Rejuvenation
    [155777] = true,   -- Germination
}
-- Icon IDs that flag an empowered Rejuv/Germination cast (SOTF proc consumed).
-- LifebloomGlow uses this same technique: the icon changes when empowered.
local SOTF_EMPOWERED_ICONS = {
    [7195165] = true,  -- Rejuvenation (empowered)
    [1033486] = true,  -- Germination  (empowered)
}

-- Soul of the Forest proc buff on the player (fires after Swiftmend).
-- Verify in-game: /run for i=1,40 do local a=C_UnitAuras.GetAuraDataByIndex("player",i,"HELPFUL")
--                 if a then print(a.name, a.spellId) end end
local SPELL_SOTF = 114108

-- C_UnitAuras is localized as a module upvalue at line 47 (top of file).
-- This captures the TABLE reference at load time. The table may be empty
-- at file-load but gets populated before PLAYER_LOGIN. Function calls
-- through the local table reference work correctly at runtime, and the
-- local is immune to global taint in combat (same pattern as LBG).

-- Pandemic window: alert fires when LB has 4.5s or less remaining.
-- Anchored to LB's fixed base duration (15s) so Swiftmend extensions
-- never change the trigger window.
local LB_PANIC_THRESHOLD = 4.5

-- =============================================================
-- Sounds
-- =============================================================
local BUNDLED_SOUNDS = {
    ["Bleeper"]          = NHA_MEDIA .. "Bleeper.ogg",
    ["Drums"]            = NHA_MEDIA .. "Drums.ogg",
    ["HeartbeatSingle"]  = NHA_MEDIA .. "HeartbeatSingle.ogg",
    ["MetalGearSpotted"] = NHA_MEDIA .. "MetalGearSpotted.ogg",
    ["OhNo"]             = NHA_MEDIA .. "OhNo.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",
}

-- Register with LSM so other addons can see our sounds
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)
        -- LSM:Fetch returns a number (default constant) when not found, not nil.
        -- Guard with type check before calling string methods.
        if type(p) == "string" then return p:gsub("/", "\\") end
    end
    return nil
end

-- Sorted sound list for dropdown (bundled sounds only).
-- LSM registration (above) still exposes our sounds to other addons.
-- We do not pull external LSM sounds into the dropdown to keep the list
-- predictable and avoid entries that point at files we do not ship.
local SOUND_NAMES = {}
do
    for k in pairs(BUNDLED_SOUNDS) do SOUND_NAMES[#SOUND_NAMES+1] = k end
    table.sort(SOUND_NAMES)
end

-- =============================================================
-- Fonts  (matches NemPetAlerts font list exactly)
-- =============================================================
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",
}

-- Register fonts with LSM
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

-- Sorted font name list for dropdown.
-- Only bundled fonts are listed. LSM registration (above) still makes our
-- fonts available to other addons, but we do not pull external LSM fonts
-- into the dropdown — they have no local file to render with in the picker
-- and appear as duplicates of WoW built-ins (Arial Narrow, Skurri, etc.).
local FONT_NAMES = {}
do
    for k in pairs(BUNDLED_FONTS) do FONT_NAMES[#FONT_NAMES+1] = k end
    table.sort(FONT_NAMES)
end

-- =============================================================
-- Saved-Variable Defaults
-- =============================================================
local DEFAULTS = {
    enabled          = true,
    flash            = true,
    scale            = 1.0,
    fontSize         = 25,
    fontName         = "Gotham Narrow Ultra",
    x                = 0,
    y                = 180,
    point            = "CENTER",
    relativePoint    = "CENTER",
    relativeTo       = "UIParent",
    lbEnabled        = true,
    sotfEnabled      = true,
    glowEnabled      = true,   -- icon glow on buff frames
    lbTimerShown     = true,   -- true = timer visible (default)
    sotfTimerShown   = true,
    lbSoundEnabled   = true,
    lbSoundName      = "Bell",    -- requires LSM (e.g. SharedMedia addon)
    sotfSoundEnabled = true,
    sotfSoundName    = "Ping",    -- requires LSM (e.g. SharedMedia addon)
    alertColors      = {
        lb   = { r = 0.0,   g = 1.0, b = 0.0 },   -- #00FF00 green
        sotf = { r = 0.831, g = 0.0, b = 1.0 },   -- #D400FF purple
    },
    ver = 3,
}

-- =============================================================
-- Runtime State
-- =============================================================
local state = {
    unlocked       = false,
    testMode       = false,
    testTicker     = nil,
    -- Lifebloom
    lbUnit         = nil,
    lbInstID       = nil,
    lbExpire       = 0,
    lbDuration     = 0,
    lbTimeMod      = 1,
    lbPanicActive  = false,
    lbWasActive    = false,
    -- SOTF
    sotfActive      = false,
    sotfWasActive   = false,
    sotfExpire      = 0,
    sotfTimeMod     = 1,
    -- sotfProcPending: set when Swiftmend is cast, cleared when Rejuv/
    -- Regrowth is cast. Primary SOTF detection in combat where aura
    -- queries fail due to secret values. No aura API needed.
    sotfProcPending = false,
}

-- =============================================================
-- 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(
            "|cffFF8000Nem: HoT Alerts:|r " .. tostring(text))
    end
end

-- =============================================================
-- Sound Playback
-- =============================================================
local function PlayLBSound()
    local db = NHA.db
    if not db or not db.lbSoundEnabled then return end
    local p = GetSoundPath(db.lbSoundName)
    if p then PlaySoundFile(p, "Master") end
end

local function PlaySOTFSound()
    local db = NHA.db
    if not db or not db.sotfSoundEnabled then return end
    local p = GetSoundPath(db.sotfSoundName)
    if p then PlaySoundFile(p, "Master") end
end

-- =============================================================
-- Lifebloom Scan
-- =============================================================
-- WHY GetAuraDataBySpellID:
-- In combat (M+/rated content), WoW's secret-value system can make
-- aura.spellId comparisons fail for LB even though it's 'neversecret'.
-- GetAuraDataBySpellID bypasses this by providing the ID TO the API
-- rather than reading it back -- identical to how we detect SOTF.
local function ScanUnitForLB(unit)
    if not UnitExists(unit) then return nil end
    -- Primary: direct spell ID lookup, combat-safe.
    -- Providing the ID to the API avoids reading aura.spellId back,
    -- which is a secret value in M+/rated content and throws on table index.
    if C_UnitAuras and C_UnitAuras.GetAuraDataBySpellID then
        for spellId in pairs(LIFEBLOOM_IDS) do
            local aura = C_UnitAuras.GetAuraDataBySpellID(unit, spellId, "HELPFUL")
            -- auraInstanceID == 0: secret placeholder, spell not really active
            if aura and aura.auraInstanceID ~= 0
                and aura.sourceUnit == "player" then
                return aura
            end
        end
        return nil
    end
    -- Fallback: index scan (pre-12.0 API).
    -- CRITICAL: issecretvalue check MUST come BEFORE the table index.
    -- Lua evaluates 'and' left-to-right; using a secret value as a table
    -- key throws "table index is secret" before the guard is ever reached.
    local i = 1
    while true do
        local aura = C_UnitAuras.GetAuraDataByIndex(unit, i, "HELPFUL")
        if not aura then break end
        if issecretvalue(aura.spellId) then
            -- skip secret aura, same pattern as LifebloomGlow
        elseif LIFEBLOOM_IDS[aura.spellId]
            and aura.sourceUnit == "player" then
            return aura
        end
        i = i + 1
    end
    return nil
end

local function GetGroupUnits()
    local t = { "player", "target", "focus" }
    if IsInRaid() then
        for i = 1, GetNumGroupMembers() do t[#t+1] = "raid"..i end
    elseif IsInGroup() then
        for i = 1, GetNumGroupMembers()-1 do t[#t+1] = "party"..i end
    end
    return t
end

local function FullScanForLB()
    for _, unit in ipairs(GetGroupUnits()) do
        local aura = ScanUnitForLB(unit)
        if aura then return unit, aura end
    end
    return nil, nil
end

local function StoreLBAura(unit, aura)
    state.lbUnit     = unit
    state.lbInstID   = aura.auraInstanceID
    state.lbExpire   = aura.expirationTime or 0
    state.lbDuration = aura.duration or 0
    state.lbTimeMod  = (aura.timeMod and aura.timeMod > 0) and aura.timeMod or 1
end

local function ClearLBState()
    state.lbUnit        = nil
    state.lbInstID      = nil
    state.lbExpire      = 0
    state.lbDuration    = 0
    state.lbTimeMod     = 1
    state.lbPanicActive = false
end

-- =============================================================
-- SOTF Detection
-- =============================================================
-- WHY no issecretvalue guard:
-- SOTF is a player self-buff -- not a competitive secret. WoW 12.0's
-- broad secret-value sweep can mark aura.spellId as a secret value even
-- for harmless self-buffs, causing spellId == SPELL_SOTF to silently
-- return false. We intentionally skip the issecretvalue check here.
--
-- Primary: GetAuraDataBySpellID -- targeted lookup, most reliable.
-- Fallback: index scan without issecretvalue guard.
-- Returns the SOTF aura data table if active, or nil.
-- Callers check truthiness for 'is active' and read .expirationTime/.timeMod
-- to drive the countdown timer.
local function CheckSOTF()
    if not C_UnitAuras then return nil end
    if C_UnitAuras and C_UnitAuras.GetAuraDataBySpellID then
        local aura = C_UnitAuras.GetAuraDataBySpellID("player", SPELL_SOTF, "HELPFUL")
        -- auraInstanceID == 0 is a secret placeholder returned when the API
        -- call is restricted in combat. Treat it as nil (spell not active).
        if aura and aura.auraInstanceID and aura.auraInstanceID ~= 0 then
            return aura
        end
        return nil
    end
    -- Fallback index scan -- no issecretvalue guard intentional (see above)
    local i = 1
    while true do
        local aura = C_UnitAuras.GetAuraDataByIndex("player", i, "HELPFUL")
        if not aura then break end
        if not issecretvalue(aura.spellId) and aura.spellId == SPELL_SOTF then
            return aura
        end
        i = i + 1
    end
    return nil
end

local SWIFTMEND_ID = 18562  -- used to trigger a deferred check for the SOTF proc
-- Spells that CONSUME the SOTF proc (Rejuvenation, Germination, Regrowth)
local SOTF_CONSUMER_IDS = { [774]=true, [155777]=true, [8936]=true }

-- =============================================================
-- LB Background Ticker
-- =============================================================
local Evaluate
local lbTicker = nil

local function StartLBTicker()
    if lbTicker then return end
    lbTicker = C_Timer.NewTicker(0.25, function() Evaluate() end)
end

local function StopLBTicker()
    if lbTicker then lbTicker:Cancel(); lbTicker = nil end
end

-- =============================================================
-- Icon Proc Glow
-- Mirrors LifebloomGlow's frame-hook approach: we hook into
-- CompactUnitFrame_UtilSetBuff and Target/Focus UpdateAuras so
-- we receive the actual buffFrame UI objects, then attach a
-- proc-glow animation directly to those frames.
--
-- WHY hooks instead of the event-scan used for text alerts:
--   Text alerts only need to know IF an aura exists. Glows must
--   be attached TO the frame rendering the icon, so we need the
--   hook pipeline to receive those frame references.
--
-- Proc glow visual: star4 corner sparkles + ping4 ring overlay,
-- fade-in/hold/fade-out loop — identical to WoW's own action-bar
-- proc activation glow. No external library required.
-- =============================================================

-- Frame-reference tables (keyed by buffFrame widget).
-- lbGlowFrames[buffFrame]    = safe aura copy (for pandemic re-eval)
-- rejuvGlowFrames[buffFrame] = true (empowered rejuv, always glowing)
local lbGlowFrames    = {}
local rejuvGlowFrames = {}

-- ProcGlow with startAnim=false: skips the expanding burst (ProcStart)
-- and goes straight to the looping border glow (ProcLoop). OnShow guards
-- with IsPlaying() so repeated calls never restart a running loop.
-- _nhaGlowing flag prevents any redundant ProcGlow_Start calls regardless.
local function ShowProcGlow(buffFrame, r, g, b)
    if not LCG then return end
    if buffFrame._nhaGlowing then return end
    buffFrame._nhaGlowing = true
    LCG.ProcGlow_Start(buffFrame, {
        color      = { r, g, b, 1 },
        startAnim  = false,
    })
end

local function HideProcGlow(buffFrame)
    if not LCG then return end
    if not buffFrame._nhaGlowing then return end
    buffFrame._nhaGlowing = nil
    LCG.ProcGlow_Stop(buffFrame)
end

-- Called by the lbTicker every 0.25 s to cross the pandemic threshold
-- in real-time between UNIT_AURA events.
local function RefreshLBGlows()
    local db = NHA.db
    if not db or not db.glowEnabled then return end
    local now = GetTime()
    local c   = db.alertColors.lb
    for buffFrame, aura in pairs(lbGlowFrames) do
        if buffFrame.auraInstanceID ~= aura.auraInstanceID then
            HideProcGlow(buffFrame)
            lbGlowFrames[buffFrame] = nil
        else
            local remaining = (aura.expirationTime - now) / aura.timeMod
            local threshold = LB_PANIC_THRESHOLD
            if remaining > 0 and remaining <= threshold then
                ShowProcGlow(buffFrame, c.r, c.g, c.b)
            elseif remaining <= 0 then
                HideProcGlow(buffFrame)
                lbGlowFrames[buffFrame] = nil
            else
                HideProcGlow(buffFrame)
            end
        end
    end
end

-- Entry point called from the hook pipeline.
-- Decides whether this buffFrame should glow, and shows/hides accordingly.
local function EvaluateFrameGlow(buffFrame, aura)
    local db = NHA.db
    if not db or not db.glowEnabled or not db.enabled then
        HideProcGlow(buffFrame)
        lbGlowFrames[buffFrame]    = nil
        rejuvGlowFrames[buffFrame] = nil
        return
    end

    if not aura then
        HideProcGlow(buffFrame)
        lbGlowFrames[buffFrame]    = nil
        rejuvGlowFrames[buffFrame] = nil
        return
    end

    -- Skip secret auras (though LB/Rejuv are neversecret for now)
    if issecretvalue(aura.spellId) then
        HideProcGlow(buffFrame); return
    end

    if aura.sourceUnit ~= 'player' then
        HideProcGlow(buffFrame)
        lbGlowFrames[buffFrame]    = nil
        rejuvGlowFrames[buffFrame] = nil
        return
    end

    -- issecretvalue as first gate (same pattern as LifebloomGlow).
    -- Check once before any field access; if secret, bail immediately.
    -- This is more robust than guarding each individual field use.
    if issecretvalue(aura.spellId) then
        HideProcGlow(buffFrame)
        lbGlowFrames[buffFrame]    = nil
        rejuvGlowFrames[buffFrame] = nil
        return
    end

    -- Lifebloom: watch for the pandemic window
    -- Glow is independent of db.lbEnabled (text alert toggle)
    if LIFEBLOOM_IDS[aura.spellId] then
        -- If this frame was previously showing an empowered-Rejuv glow,
        -- clear it first so the LB color starts fresh.
        if rejuvGlowFrames[buffFrame] then
            HideProcGlow(buffFrame)
            rejuvGlowFrames[buffFrame] = nil
        end
        if not buffFrame._safeNHAAura then buffFrame._safeNHAAura = {} end
        local sa = buffFrame._safeNHAAura
        sa.auraInstanceID = aura.auraInstanceID
        sa.expirationTime = aura.expirationTime or 0
        sa.duration       = aura.duration or 0
        sa.timeMod        = (aura.timeMod and aura.timeMod > 0) and aura.timeMod or 1
        lbGlowFrames[buffFrame]    = sa

        local now       = GetTime()
        local remaining = (sa.expirationTime - now) / sa.timeMod
        local threshold = LB_PANIC_THRESHOLD
        if remaining > 0 and remaining <= threshold then
            local c = db.alertColors.lb
            ShowProcGlow(buffFrame, c.r, c.g, c.b)
        else
            HideProcGlow(buffFrame)
        end
        return
    end

    -- Empowered Rejuv / Germination (SOTF proc consumed into the cast)
    -- Glow is independent of db.sotfEnabled (text alert toggle)
    -- Glow is independent of db.sotfEnabled (text alert toggle)
    if REJUV_IDS[aura.spellId]
        and SOTF_EMPOWERED_ICONS[aura.icon] then
        -- If this frame was previously showing an LB glow (e.g. LB just
        -- fell off and the frame was recycled for Rejuv), clear the old
        -- green glow first so the purple starts with a clean slate.
        if lbGlowFrames[buffFrame] then
            HideProcGlow(buffFrame)
            lbGlowFrames[buffFrame] = nil
        end
        rejuvGlowFrames[buffFrame] = true
        local c = db.alertColors.sotf
        ShowProcGlow(buffFrame, c.r, c.g, c.b)
        return
    end

    -- Not a spell we care about
    HideProcGlow(buffFrame)
    lbGlowFrames[buffFrame]    = nil
    rejuvGlowFrames[buffFrame] = nil
end

-- =============================================================
-- Display Layout
-- =============================================================
local LB_TEXT_Y   =  22
local LB_TIMER_Y  =   2
local SOTF_Y      = -24
local SOTF_TIMER_Y = -44

-- =============================================================
-- Display Construction
-- =============================================================
local function CreateDisplay()
    local db  = NHA.db
    local fp  = GetCurrentFontPath()
    local fs  = db.fontSize or 25
    local sc  = db.scale or 1.0
    local lbc = db.alertColors.lb
    local sfc = db.alertColors.sotf

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

    local f = CreateFrame("Frame", "NemHotAlertsFrame", UIParent,
        BackdropTemplateMixin and "BackdropTemplate" or nil)
    f:SetSize(900, 100)
    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 state.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)

    -- Row 1: "* LIFEBLOOM EXPIRING *"
    local lbFS = f:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge")
    lbFS:SetFont(fp, fs, "OUTLINE")
    lbFS:SetTextColor(lbc.r, lbc.g, lbc.b)
    lbFS:SetText("* LIFEBLOOM EXPIRING *")
    lbFS:SetScale(sc)
    lbFS:SetJustifyH("CENTER")
    lbFS:SetShadowOffset(2, -2)
    lbFS:SetPoint("CENTER", f, "CENTER", 0, LB_TEXT_Y)
    lbFS:Hide()

    -- Row 1b: countdown timer (soft gold, smaller)
    local timerFS = f:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge")
    timerFS:SetFont(fp, math_floor(fs * 0.68), "OUTLINE")
    timerFS:SetTextColor(1.0, 0.820, 0.0)  -- overridden each frame by OnUpdate
    timerFS:SetJustifyH("CENTER")
    timerFS:SetShadowOffset(1, -1)
    timerFS:SetPoint("CENTER", f, "CENTER", 0, LB_TIMER_Y)
    timerFS:Hide()

    -- Row 2: "* CAST REJUV *"
    local sotfFS = f:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge")
    sotfFS:SetFont(fp, fs, "OUTLINE")
    sotfFS:SetTextColor(sfc.r, sfc.g, sfc.b)
    sotfFS:SetText("* CAST REJUV *")
    sotfFS:SetScale(sc)
    sotfFS:SetJustifyH("CENTER")
    sotfFS:SetShadowOffset(2, -2)
    sotfFS:SetPoint("CENTER", f, "CENTER", 0, SOTF_Y)
    sotfFS:Hide()

    -- Row 2b: SOTF countdown timer
    local sotfTimerFS = f:CreateFontString(nil, "OVERLAY", "GameFontNormalHuge")
    sotfTimerFS:SetFont(fp, math_floor(fs * 0.68), "OUTLINE")
    sotfTimerFS:SetTextColor(0.667, 1.0, 0.0)  -- overridden each frame by OnUpdate
    sotfTimerFS:SetJustifyH("CENTER")
    sotfTimerFS:SetShadowOffset(1, -1)
    sotfTimerFS:SetPoint("CENTER", f, "CENTER", 0, SOTF_TIMER_Y)
    sotfTimerFS:Hide()

    -- Flash animation (identical to NemPetAlerts pulse)
    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 (orange, only visible when unlocked)
    if f.SetBackdrop then
        f:SetBackdrop({
            edgeFile = "Interface\\Buttons\\WHITE8x8",
            edgeSize = 1,
        })
        f:SetBackdropBorderColor(TR, TG, TB, 0)
        f.hasBorder = true
    end

    f.lbFS        = lbFS
    f.timerFS     = timerFS
    f.sotfFS      = sotfFS
    f.sotfTimerFS = sotfTimerFS
    f.flashGroup  = ag

    -- Smooth per-frame timer countdown.
    -- Evaluate() only fires every 0.25s (lbTicker), so without this the
    -- timer text updates in 0.25s jumps and appears frozen. state.lbExpire
    -- is constant between Evaluate calls, but GetTime() changes every frame,
    -- so the subtraction yields a smooth live countdown with no extra cost.
    -- OnUpdate is the single source of truth for all visual state.
    -- Driving visibility from state flags here (rather than from Evaluate
    -- via UpdateDisplay) means rapid event-driven Evaluate calls in combat
    -- cannot clobber the display — the state flags persist correctly between
    -- calls, and the display reflects them on every rendered frame.
    f:SetScript('OnUpdate', function()
        local db = NHA.db
        if not db or state.testMode then return end

        -- ── LB alert text ────────────────────────────────────────────────
        if state.lbPanicActive then
            if db.lbEnabled then f.lbFS:Show() else f.lbFS:Hide() end
        else
            f.lbFS:Hide()
        end

        -- ── LB countdown timer ───────────────────────────────────────────
        if state.lbPanicActive and db.lbTimerShown then
            local remaining = (state.lbExpire - GetTime()) / state.lbTimeMod
            if remaining > 0 then
                f.timerFS:SetText(string_format('%.1fs', remaining))
                if remaining > 2.0 then
                    f.timerFS:SetTextColor(0.667, 1.0, 0.0)  -- #aaff00
                elseif remaining > 1.0 then
                    f.timerFS:SetTextColor(1.0, 0.816, 0.0)  -- #ffd000
                else
                    f.timerFS:SetTextColor(1.0, 0.4,   0.0)  -- #ff6600
                end
                f.timerFS:Show()
            else
                f.timerFS:Hide()
            end
        else
            f.timerFS:Hide()
        end

        -- ── SOTF alert text ──────────────────────────────────────────────
        if state.sotfActive then
            if db.sotfEnabled then f.sotfFS:Show() else f.sotfFS:Hide() end
        else
            f.sotfFS:Hide()
        end

        -- ── SOTF countdown timer ─────────────────────────────────────────
        if state.sotfActive and db.sotfTimerShown and state.sotfExpire > 0 then
            local remaining = (state.sotfExpire - GetTime()) / state.sotfTimeMod
            if remaining > 0 then
                f.sotfTimerFS:SetText(string_format('%.1fs', remaining))
                if remaining > 10.0 then
                    f.sotfTimerFS:SetTextColor(0.667, 1.0, 0.0)  -- #aaff00
                elseif remaining > 5.0 then
                    f.sotfTimerFS:SetTextColor(1.0, 0.816, 0.0)  -- #ffd000
                else
                    f.sotfTimerFS:SetTextColor(1.0, 0.4,   0.0)  -- #ff6600
                end
                f.sotfTimerFS:Show()
            else
                f.sotfTimerFS:Hide()
            end
        else
            if f.sotfTimerFS then f.sotfTimerFS:Hide() end
        end

        -- ── Flash animation ──────────────────────────────────────────────
        local anyActive = state.lbPanicActive or state.sotfActive
        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)

    NHA.display = f
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
    local lbc = db.alertColors.lb
    local sfc = db.alertColors.sotf

    f.lbFS:SetFont(fp, fs, "OUTLINE")
    f.lbFS:SetScale(sc)
    f.lbFS:SetTextColor(lbc.r, lbc.g, lbc.b)

    f.timerFS:SetFont(fp, math_floor(fs * 0.68), "OUTLINE")
    f.timerFS:SetScale(sc)

    f.sotfFS:SetFont(fp, fs, "OUTLINE")
    f.sotfFS:SetScale(sc)
    f.sotfFS:SetTextColor(sfc.r, sfc.g, sfc.b)

    if f.sotfTimerFS then
        f.sotfTimerFS:SetFont(fp, math_floor(fs * 0.68), "OUTLINE")
        f.sotfTimerFS:SetScale(sc)
    end

    f:EnableMouse(state.unlocked)
end

-- =============================================================
-- UpdateDisplay
-- =============================================================
-- Visual state (text alerts, timers, flash) is now driven by OnUpdate
-- based on state flags. This function only handles explicit force-clear
-- when the addon is disabled, so state flags are correct for OnUpdate.
local function UpdateDisplay(lbPanic, sotfActive)
    if not lbPanic  then state.lbPanicActive = false end
    if not sotfActive then state.sotfActive   = false end
end

-- =============================================================
-- Evaluate
-- =============================================================
Evaluate = function()
    local db = NHA.db
    if not db or not db.enabled then
        state.lbPanicActive = false
        state.sotfActive    = false
        return
    end

    -- Test mode: cycling countdowns that loop through all color tiers.
    -- LB cycles 4.5 -> 0 over 4.5s; SOTF cycles 15 -> 0 over 15s.
    -- Test mode: set state flags so OnUpdate shows cycling preview alerts.
    if state.testMode then
        local f = NHA.display
        -- In test mode, drive state flags directly so OnUpdate shows alerts
        state.lbPanicActive = db.lbEnabled
        state.sotfActive    = db.sotfEnabled
        if f then
            local now = GetTime()

            -- LB timer: 4.5s cycle
            if db.lbEnabled and db.lbTimerShown then
                local lbRemaining = 4.5 - (now % 4.5)
                f.timerFS:SetText(string_format("%.1fs", lbRemaining))
                if lbRemaining > 2.0 then
                    f.timerFS:SetTextColor(0.667, 1.0, 0.0)   -- #aaff00
                elseif lbRemaining > 1.0 then
                    f.timerFS:SetTextColor(1.0, 0.816, 0.0)   -- #ffd000
                else
                    f.timerFS:SetTextColor(1.0, 0.4,   0.0)   -- #ff6600
                end
                f.timerFS:Show()
            else
                f.timerFS:Hide()
            end

            -- SOTF timer: 15s cycle
            if db.sotfEnabled and db.sotfTimerShown and f.sotfTimerFS then
                local sotfRemaining = 15.0 - (now % 15.0)
                f.sotfTimerFS:SetText(string_format("%.1fs", sotfRemaining))
                if sotfRemaining > 10.0 then
                    f.sotfTimerFS:SetTextColor(0.667, 1.0, 0.0)  -- #aaff00
                elseif sotfRemaining > 5.0 then
                    f.sotfTimerFS:SetTextColor(1.0, 0.816, 0.0)  -- #ffd000
                else
                    f.sotfTimerFS:SetTextColor(1.0, 0.4,   0.0)  -- #ff6600
                end
                f.sotfTimerFS:Show()
            elseif f.sotfTimerFS then
                f.sotfTimerFS:Hide()
            end
        end
        return
    end

    -- ── Lifebloom pandemic check ─────────────────────────────
    -- Aura scan always runs so icon glow works even when text alert is off.
    -- lbPanic (text alert) is only set when db.lbEnabled is true.
    local lbPanic = false
    if state.lbUnit and state.lbInstID then
        local aura = C_UnitAuras.GetAuraDataByAuraInstanceID(
            state.lbUnit, state.lbInstID)
        if aura and not issecretvalue(aura.spellId)
            and LIFEBLOOM_IDS[aura.spellId] and aura.sourceUnit == "player" then
            StoreLBAura(state.lbUnit, aura)
        else
            local u, a = FullScanForLB()
            if u and a then StoreLBAura(u, a) else ClearLBState() end
        end
    else
        local u, a = FullScanForLB()
        if u and a then StoreLBAura(u, a) else ClearLBState() end
    end

    if state.lbUnit then
        local remaining = (state.lbExpire - GetTime()) / state.lbTimeMod
        if remaining > 0 then
            if db.lbEnabled then
                lbPanic = remaining <= LB_PANIC_THRESHOLD
            end
        else
            ClearLBState()
        end
        state.lbPanicActive = lbPanic
        StartLBTicker()
    else
        state.lbPanicActive = false
        StopLBTicker()
    end

    -- ── SOTF check ───────────────────────────────────────────
    local sotfActive = false
    if db.sotfEnabled then
        -- Primary: aura query (works out of combat, gives expiry for timer)
        local sotfAura = CheckSOTF()
        if sotfAura then
            sotfActive = true
            state.sotfProcPending = false  -- aura confirmed; sync tracking
            state.sotfExpire  = sotfAura.expirationTime or 0
            state.sotfTimeMod = (sotfAura.timeMod and sotfAura.timeMod > 0)
                                and sotfAura.timeMod or 1
        elseif state.sotfProcPending then
            -- Aura query failed (M+/rated combat restricts spellId reads).
            -- Fall back to spell-cast tracking set by Swiftmend event.
            -- Text alert and sound work; timer hidden (no expiry available).
            sotfActive = true
        else
            state.sotfExpire  = 0
            state.sotfTimeMod = 1
        end
        state.sotfActive = sotfActive
    end

    -- ── Rising-edge sounds ───────────────────────────────────
    if lbPanic    and not state.lbWasActive   then PlayLBSound()   end
    if sotfActive and not state.sotfWasActive then PlaySOTFSound() end
    state.lbWasActive   = lbPanic
    state.sotfWasActive = sotfActive

    -- ── Refresh pandemic-window icon glows on tracked frames ────────────
    RefreshLBGlows()

    UpdateDisplay(lbPanic, sotfActive)
end

-- =============================================================
-- Lock / Unlock
-- =============================================================
local function SetLockState(unlocked)
    local f = NHA.display
    if not f then return end
    state.unlocked = unlocked
    f:EnableMouse(unlocked)
    if f.hasBorder then
        f:SetBackdropBorderColor(TR, TG, TB, 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

-- =============================================================
-- Init: Database
-- =============================================================
function NHA:InitDatabase()
    NemHotAlertsSV = NemHotAlertsSV or CopyTable(DEFAULTS)
    local db = NemHotAlertsSV
    if (db.ver or 0) < 3 then
        NemHotAlertsSV = CopyTable(DEFAULTS)
        db = NemHotAlertsSV
    else
        MergeDefaults(db, DEFAULTS)
    end
    self.db = db
end

-- =============================================================
-- Test Mode
-- =============================================================
function NHA:ToggleTest()
    state.testMode = not state.testMode
    if state.testMode then
        if not state.testTicker then
            state.testTicker = C_Timer.NewTicker(0.1, Evaluate)
        end
    else
        if state.testTicker then state.testTicker:Cancel(); state.testTicker = nil end
        state.lbPanicActive = false
        state.sotfActive    = false
        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(state.testMode and "Stop Test" or "Test")
    end
    Evaluate()
end

-- =============================================================
-- Init: Slash Commands
-- =============================================================
function NHA:InitCommands()
    local NHA_VERSION = "v12.0.1.2"

    local function HandleNHACommand(input)
        input = (input or ""):lower():match("^%s*(.-)%s*$")

        if input == "on" then
            NHA.db.enabled = true; Evaluate()
            Msg("Addon |cff00ff41enabled|r."); return
        end
        if input == "off" then
            NHA.db.enabled = false
            state.lbPanicActive = false; state.sotfActive = false
            if NHA.display then NHA.display:SetAlpha(1) end
            if state.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
                state.lbPanicActive = false; state.sotfActive = false
                if NHA.display then NHA.display:SetAlpha(1) end
                if state.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 = state.testMode      and "|cffffff00On|r"      or "Off"
            local lb   = NHA.db.lbEnabled   and "|cff00ff41On|r"      or "Off"
            local sotf = NHA.db.sotfEnabled and "|cff00ff41On|r"      or "Off"
            Msg(("Status: %s  |  Test: %s  |  LB: %s  |  SOTF: %s"):format(en, test, lb, sotf))
            return
        end
        if input == "version" then Msg("Nem: HoT Alerts " .. NHA_VERSION); return end
        if input == "reset" then
            NemHotAlertsSV = nil
            Msg("Saved variables reset. Reloading UI...")
            C_Timer.After(0.5, ReloadUI); return
        end
        if input == "lock"   then SetLockState(true);  return end
        if input == "unlock" then SetLockState(false); return end
        if input == "help" then
            Msg("|cffffd700Nem: HoT 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/hotalerts|r)")
            return
        end
        -- Default: open options
        if InCombatLockdown() then
            Msg("Cannot open options while in combat.")
        else
            Settings.OpenToCategory(self.optionsCategoryID)
        end
    end

    SLASH_NEMHOTALERTS1 = "/nha"
    SLASH_NEMHOTALERTS2 = "/hotalerts"
    SlashCmdList.NEMHOTALERTS = HandleNHACommand

    if AddonCompartmentFrame then
        AddonCompartmentFrame:RegisterAddon({
            text = "Nem: HoT 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
-- Mirrors LifebloomGlow's hook strategy: hook into the buff-frame
-- update pipeline so we receive the actual frame UI objects.
-- =============================================================
function NHA:InitGlowHooks()
    -- Compact unit frames (party, raid, self in compact layout)
    hooksecurefunc("CompactUnitFrame_UtilSetBuff", function(buffFrame, aura)
        EvaluateFrameGlow(buffFrame, aura)
    end)

    -- Target frame (uses auraPools pattern, same as LifebloomGlow)
    hooksecurefunc(TargetFrame, "UpdateAuras", function(s)
        if not s.auraPools then return end
        for buffFrame in s.auraPools:EnumerateActive() do
            local aura = C_UnitAuras.GetAuraDataByAuraInstanceID(
                s.unit, buffFrame.auraInstanceID)
            EvaluateFrameGlow(buffFrame, aura)
        end
    end)

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

-- =============================================================
-- PLAYER_LOGIN
-- =============================================================
function NHA:OnLogin()
    local _, class = UnitClass("player")
    self.isDruid = (class == "DRUID")

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

    if not self.isDruid then
        Msg("Functionality disabled — character is not a |cff00ff00Druid|r.")
        return
    end

    self:InitGlowHooks()
    Evaluate()
    Msg("Loaded. Type |cffFF8000/nha|r for options.")
end

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

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

    -- ── Helpers ────────────────────────────────────────────────

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

    local function SectionBox(x, y, w, h)
        local box = CreateFrame("Frame", nil, panel,
            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(TR, TG, TB, 0.3)
        end
        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(TR, TG, TB)
        return fs
    end

    local checkboxes = {}
    local function MakeCheckbox(labelText, x, y, getter, setter)
        local cb = CreateFrame("CheckButton", nil, panel, "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 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 c = db.alertColors[colorKey]
        swTex:SetColorTexture(c.r, c.g, c.b, 1)
        cb.swatchTex = swTex

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

        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)
                local f = NHA.display
                if f then
                    if colorKey == "lb"   and f.lbFS   then f.lbFS:SetTextColor(r,g,b) end
                    if colorKey == "sotf" and f.sotfFS then f.sotfFS:SetTextColor(r,g,b) 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)

        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())
        -- Set Low/High endpoint labels explicitly (OptionsSliderTemplate defaults to "Low"/"High")
        slider.Low:SetText(tostring(minVal))
        slider.High:SetText(tostring(maxVal))

        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(TR, TG, TB)
        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", 162, 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: HoT Alerts")
    title:SetTextColor(TR, TG, TB)

    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(
        "Lifebloom pandemic & Soul of the Forest alerts for Restoration Druid. "
        .. "Type /nha to open this panel.")
    subtitle:SetTextColor(0.75, 0.75, 0.75)

    -- ================================================================
    -- DISPLAY section  (h=200 — extra row for Icon Proc Glow)
    -- ================================================================
    local displayBox = SectionBox(12, -62, PW - 24, 175)
    SectionHeader(displayBox, "Display", 8, -6)

    checkboxes.enabled = MakeCheckbox("Enable Addon", 20, -90,
        function() return db.enabled end,
        function(v)
            db.enabled = v and true or false
            if not db.enabled then
                state.lbPanicActive = false
                state.sotfActive    = false
                if NHA.display then NHA.display:SetAlpha(1) end
                if state.testMode then NHA:ToggleTest() end
            end
        end)

    checkboxes.flash = MakeCheckbox("Flash Animation", 20, -114,
        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",
        20, -138,
        function() return db.glowEnabled end,
        function(v)
            db.glowEnabled = v and true or false
            if not db.glowEnabled then
                for bf in pairs(lbGlowFrames)    do HideProcGlow(bf) end
                for bf in pairs(rejuvGlowFrames) do HideProcGlow(bf) end
                wipe(lbGlowFrames); wipe(rejuvGlowFrames)
            end
        end)

    -- Font dropdown (pushed down 25px to make room for glow checkbox)
    do
        local fontLabel = displayBox:CreateFontString(nil, "OVERLAY")
        fontLabel:SetPoint("TOPLEFT", 8, -119)
        SetUIFont(fontLabel, 13, "OUTLINE")
        fontLabel:SetText("Alert Font")
        fontLabel:SetTextColor(TR, TG, TB)

        local fontPool = {}
        local dd = CreateFrame("DropdownButton", "NemHotAlertsFontDrop",
                               displayBox, "WowStyle1DropdownTemplate")
        dd:SetPoint("TOPLEFT", fontLabel, "BOTTOMLEFT", 0, -6)
        dd:SetWidth(220)
        dd:SetDefaultText(db.fontName or "Gotham Narrow Ultra")

        local function SetupFontMenu()
            dd: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
                            dd:SetDefaultText(n)
                            ApplyDisplaySettings()
                        end)
                    if path then
                        btn:AddInitializer(function(b)
                            local fs = fontPool[idx]
                            if not fs then
                                fs = dd:CreateFontString(nil, "BACKGROUND")
                                fontPool[idx] = fs
                            end
                            fs:SetParent(b)
                            fs:ClearAllPoints()
                            fs:SetPoint("LEFT", b, "LEFT", 5, 0)
                            fs:SetFont(path, 12)
                            fs:SetText(n)
                            fs:Show()
                        end)
                    end
                end
            end)
        end
        -- Hook registered once at creation, not inside SetupFontMenu,
        -- so repeated Refresh() calls do not stack additional hooks.
        hooksecurefunc(dd, "OnMenuClosed", function()
            for _, fs in pairs(fontPool) do fs:Hide() end
        end)
        C_Timer.After(0, SetupFontMenu)

        dd.Refresh = function()
            dd:SetDefaultText(db.fontName or "Gotham Narrow Ultra")
            SetupFontMenu()
        end
        NHA.fontDropdown = dd
    end

    -- Scale slider
    sliders.scale = MakeSlider(panel, "Alert Text Scale", 50, 200, 10, 370, -86, 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)
    do
        for _, child in next, { sliders.scale:GetChildren() } do
            if child.Low then child.Low:SetText("0.5x"); child.High:SetText("2.0x") end
        end
    end

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

    -- ================================================================
    -- SOUNDS section  (h=130)
    -- ================================================================
    local soundBox = SectionBox(12, -245, PW - 24, 150)
    SectionHeader(soundBox, "Sounds", 8, -6)

    local cbLB, ddLB = MakeSoundRow(soundBox,
        "Lifebloom Expiring", -28,
        function() return db.lbSoundEnabled end,
        function(v) db.lbSoundEnabled = v end,
        function() return db.lbSoundName end,
        function(v) db.lbSoundName = v end)
    checkboxes.lbSound = cbLB

    local cbSOTF, ddSOTF = MakeSoundRow(soundBox,
        "Soul of the Forest", -68,
        function() return db.sotfSoundEnabled end,
        function(v) db.sotfSoundEnabled = v end,
        function() return db.sotfSoundName end,
        function(v) db.sotfSoundName = v end)
    checkboxes.sotfSound = cbSOTF

    soundDropdowns.lb   = ddLB
    soundDropdowns.sotf = ddSOTF

    -- ================================================================
    -- ALERTS section
    -- ================================================================
    local alertBox = SectionBox(12, -403, PW - 24, 130)
    SectionHeader(alertBox, "Alerts", 8, -6)

    checkboxes.lb = MakeAlertRow(panel, "Lifebloom Expiring Alert",
        20, -431,
        function() return db.lbEnabled end,
        function(v) db.lbEnabled = v end,
        "lb")

    -- Show Timer checkbox — right side of LB alert row
    checkboxes.lbTimerShow = MakeCheckbox("Show Timer",
        330, -431,
        function() return db.lbTimerShown end,
        function(v)
            db.lbTimerShown = v and true or false
            if not db.lbTimerShown then
                local f = NHA.display
                if f then f.timerFS:Hide() end
            end
        end)

    checkboxes.sotf = MakeAlertRow(panel, "Soul of the Forest Alert",
        20, -455,
        function() return db.sotfEnabled end,
        function(v) db.sotfEnabled = v end,
        "sotf")

    -- Show Timer checkbox — right side of SOTF alert row
    checkboxes.sotfTimerShow = MakeCheckbox("Show Timer",
        330, -455,
        function() return db.sotfTimerShown end,
        function(v)
            db.sotfTimerShown = v and true or false
            if not db.sotfTimerShown then
                local f = NHA.display
                if f and f.sotfTimerFS then f.sotfTimerFS:Hide() end
            end
        end)

    -- ================================================================
    -- BUTTONS  (Test · Unlock Frame · Reset Position)
    -- ================================================================
    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(140, 26)
    lockBtn:SetText("Unlock Frame")
    lockBtn:SetScript("OnClick", function() SetLockState(not state.unlocked) end)
    NHA.lockBtn = lockBtn

    local resetBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    resetBtn:SetSize(140, 26)
    resetBtn:SetText("Reset Position")
    resetBtn:SetScript("OnClick", function()
        db.x = DEFAULTS.x; db.y = DEFAULTS.y
        db.point = DEFAULTS.point; db.relativePoint = DEFAULTS.relativePoint
        db.relativeTo = DEFAULTS.relativeTo
        ApplyDisplaySettings()
        Msg("Position reset.")
    end)

    local spacing = 12
    local totalW  = 110 + 140 + 140 + spacing * 2
    local startX  = math_floor((PW - totalW) / 2)
    testBtn:SetPoint("TOPLEFT", startX, -550)
    lockBtn:SetPoint("LEFT",  testBtn, "RIGHT", spacing, 0)
    resetBtn:SetPoint("LEFT", lockBtn, "RIGHT", spacing, 0)

    -- ================================================================
    -- VERSION FOOTER
    -- ================================================================
    local versionFS = panel:CreateFontString(nil, "OVERLAY")
    versionFS:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -16, 8)
    SetUIFont(versionFS, 10)
    versionFS:SetText("v12.0.1.2")
    versionFS:SetTextColor(TR, TG, TB, 0.6)

    -- ================================================================
    -- OnShow refresh
    -- ================================================================
    panel:SetScript("OnShow", function()
        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
        if soundDropdowns.lb             then soundDropdowns.lb.Refresh()   end
        if soundDropdowns.sotf           then soundDropdowns.sotf.Refresh() end
        lockBtn:SetText(state.unlocked and "Lock Frame" or "Unlock Frame")
        testBtn:SetText(state.testMode and "Stop Test"  or "Test")
    end)

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

    -- WoW 12.0 Escape-key fix (same as NemPetAlerts)
    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

-- =============================================================
-- 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
    if not self.isDruid then return end
    if not self.db or not self.db.enabled then return end

    if event == "UNIT_AURA" then
        local unit = ...
        -- Defer like NPA does: ensures Evaluate runs in a clean (non-tainted)
        -- execution context, important in combat where the secure frame pipeline
        -- is active. Also gives aura data time to settle after the event fires.
        if unit == state.lbUnit or unit == "player" or not state.lbUnit then
            C_Timer.After(0, Evaluate)
        end
        return
    end

    if event == "PLAYER_TARGET_CHANGED"
    or event == "PLAYER_FOCUS_CHANGED"
    or event == "GROUP_ROSTER_UPDATE"
    or event == "PLAYER_SPECIALIZATION_CHANGED"
    or event == "PLAYER_TALENT_UPDATE" then
        C_Timer.After(0, Evaluate)
        return
    end

    if event == "PLAYER_ENTERING_WORLD" then
        state.sotfProcPending = false  -- stale on zone/reload
        ApplyDisplaySettings()
        C_Timer.After(0, Evaluate)
        return
    end

    -- Re-evaluate when entering/leaving combat so state is fresh.
    if event == "PLAYER_REGEN_DISABLED" then
        -- Entering combat: if SOTF was active with time remaining, carry it
        -- forward as sotfProcPending so CheckSOTF() failure in combat does
        -- not wipe the alert. sotfExpire already holds the correct deadline.
        if state.sotfActive and state.sotfExpire > GetTime() then
            state.sotfProcPending = true
        end
        C_Timer.After(0, Evaluate)
        return
    end
    if event == "PLAYER_REGEN_ENABLED" then
        C_Timer.After(0, Evaluate)
        return
    end

    -- UNIT_SPELLCAST_SUCCEEDED: track SOTF proc via spell casts.
    -- Swiftmend always triggers SOTF. Rejuv/Regrowth always consumes it.
    -- This event is NOT restricted by secret values (it is about YOUR own
    -- spell casts), so it works reliably in all combat contexts including M+.
    if event == "UNIT_SPELLCAST_SUCCEEDED" then
        local unit, _, spellID = ...
        if unit == "player" then
            if spellID == SWIFTMEND_ID then
                -- SOTF proc: mark pending immediately before any deferred eval.
                -- Also seed sotfExpire from GetTime() so the timer starts at
                -- full duration right away, rather than waiting for CheckSOTF()
                -- to return a real aura (which may fail in combat).
                -- SOTF buff lasts 15s; CheckSOTF() overwrites with exact value
                -- if/when the aura query succeeds later.
                local SOTF_BUFF_DURATION = 15.0
                state.sotfProcPending = true
                state.sotfExpire  = GetTime() + SOTF_BUFF_DURATION
                state.sotfTimeMod = 1
                C_Timer.After(0.1, Evaluate)
                C_Timer.After(0.3, Evaluate)
            elseif SOTF_CONSUMER_IDS[spellID] then
                -- Rejuv/Regrowth consumed SOTF; clear immediately
                state.sotfProcPending = false
                C_Timer.After(0, Evaluate)
            end
        end
        return
    end
end)

-- =============================================================
-- Event Registration
-- =============================================================
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("UNIT_AURA")
NHA:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED")
NHA:RegisterEvent("PLAYER_REGEN_DISABLED")
NHA:RegisterEvent("PLAYER_REGEN_ENABLED")
