-- ============================================================================
-- pom/modules/specials.lua
-- POM Specials (12.0-safe)
-- DB: PlateOMaticDB.alerts (POM standard). EVERYTHING OFF by default.
--
-- RULES:
--  - NO compares on secret values
--  - NO if-token logic (alpha driven by SetAlphaFromBoolean only)  [IMPORTANT FINDER ONLY]
--  - NO tostring/concat on spellName
-- ============================================================================

local ADDON_NAME, ns = ...
ns = _G[ADDON_NAME] or ns or {}
_G[ADDON_NAME] = ns
_G.PlateOMatic = _G.PlateOMatic or ns

ns.Specials = ns.Specials or {}
local M = ns.Specials

-- =========================================================
-- Defaults (ALL OFF)
-- =========================================================
local DEFAULTS = {
    enabled = false,
    onlyInGroup = true,
    roleMode = "AUTO",

    castSound = {
        enabled = false,
        hostileOnly = true,
        cooldown = 0.35,
        soundTargetFile = "Interface\\AddOns\\pom\\sound\\targetcast.mp3",
        soundOtherFile  = "Interface\\AddOns\\pom\\sound\\offtargetcast.mp3",
    },

    -- IMPORTANT FINDER (KEEP SEPARATE)
    importantFind = {
        enabled = false,
        showKeyDebug = false,
        scanInterval = 0.05,
        offsetY = 240,
    },

    -- CAST NAME TEXT OVER NAMEPLATES (ALL casts, NOT important)
    nameplateCastText = {
        enabled = false,
        offsetY = 18,
        scale = 1.00,
        showCaster = true,
        scanInterval = 0.05,
    },

    -- WARNING + SPELL over nameplates (ALL casts)
    nameplateCastWarning = {
        enabled = false,
        offsetY = 30,
        scale = 1.00,
        blink = false,
        blinkSpeed = 0.50,
        scanInterval = 0.05,
    },

    -- Threat warning = DPS/Healer only (auto hard-off for tank spec)
    threat = {
        enabled = false,
        highThreat = false,

        blinkEnabled = true,
        blinkSpeed = 0.50,

        soundEnabled = true,
        soundInterval = 2.0,
    },

    sounds = {
        highThreat = "Interface\\AddOns\\pom\\sound\\agrohigh.mp3",
        frontal   = "Interface\\AddOns\\pom\\sound\\frontal.ogg",
        channel   = "Master",
    },

    ui = {
        showSettingsOnLogin = false,
    }
}

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

local function GetDB()
    if not PlateOMaticDB then PlateOMaticDB = {} end
    PlateOMaticDB.alerts = PlateOMaticDB.alerts or {}
    ApplyDefaults(PlateOMaticDB.alerts, DEFAULTS)

    PlateOMaticDB.alerts.sounds = PlateOMaticDB.alerts.sounds or {}
    if PlateOMaticDB.alerts.sounds.frontal == nil then
        PlateOMaticDB.alerts.sounds.frontal = DEFAULTS.sounds.frontal
    end

    return PlateOMaticDB.alerts
end

local DB = GetDB()

-- =========================================================
-- Helpers
-- =========================================================
local function SafeNow()
    local ok, t = pcall(GetTime)
    if ok and t then return t end
    return 0
end

local function SafeSetText(fs, value)
    if not fs then return end
    pcall(fs.SetText, fs, value or "")
end

local function SafeSetSpellText(fs, spellName, fallback)
    if not (fs and fs.SetText) then return end
    local ok = pcall(fs.SetText, fs, spellName) -- NO tostring/concat
    if not ok then
        pcall(fs.SetText, fs, fallback or "")
    end
end

-- same “boring but reliable” behavior as your test addon
local function SafePlaySoundFile(path)
    if type(path) ~= "string" or path == "" then return end
    pcall(PlaySoundFile, path, "Master")
    pcall(PlaySoundFile, path, "SFX")
end

local function InGroup()
    return IsInGroup() or IsInRaid()
end

-- SPEC ROLE is the reliable one. (Group role lies a lot.)
local function GetSpecRole()
    local spec = GetSpecialization and GetSpecialization()
    if spec and GetSpecializationRole then
        local srole = GetSpecializationRole(spec)
        if srole and srole ~= "NONE" then
            return srole
        end
    end
    return nil
end

local function GetEffectiveRole()
    DB = GetDB()
    local mode = (DB.roleMode or "AUTO")

    -- If user forces a role, respect it for general logic
    if mode ~= "AUTO" then return mode end

    -- AUTO: prefer spec role
    local srole = GetSpecRole()
    if srole then return srole end

    -- fallback: group assignment
    local r = UnitGroupRolesAssigned and UnitGroupRolesAssigned("player")
    if r and r ~= "NONE" then return r end

    return "DPS"
end

local function IsTargetHostile()
    return UnitExists("target") and UnitCanAttack("player", "target")
end

-- ------------------------------------------------------------
-- token-safe alpha helper (EXACT behavior from your earlier code)
-- ------------------------------------------------------------
local function SafeAlphaFromToken(frame, token, aTrue, aFalse)
    if not frame then return false end
    if aTrue == nil then aTrue = 1 end
    if aFalse == nil then aFalse = 0 end

    if frame.SetAlphaFromBoolean then
        local ok = pcall(frame.SetAlphaFromBoolean, frame, token, aTrue, aFalse)
        if ok then return true end
    end

    pcall(frame.SetAlpha, frame, aFalse)
    return false
end

local function GetBlizzCastBarByUnit(unit)
    local plate = C_NamePlate and C_NamePlate.GetNamePlateForUnit and C_NamePlate.GetNamePlateForUnit(unit)
    local uf = plate and (plate.UnitFrame or plate.unitFrame)
    if not uf then return nil end
    return uf.castBar or uf.CastBar or uf.castbar
end

-- ============================================================================
-- IMPORTANT CAST FINDER (CENTER FRAME)
-- NOTE: This stays token-driven and separate. Not used for anything else.
-- ============================================================================

local importantTickerHandle
local importantKey -- cached once found

local function ApplyWarnPosition(f)
    if not f then return end
    DB = GetDB()
    local cfg = DB.importantFind or DEFAULTS.importantFind
    local oy = tonumber(cfg.offsetY) or 240
    if oy < 0 then oy = 0 end
    if oy > 700 then oy = 700 end
    f:ClearAllPoints()
    f:SetPoint("CENTER", UIParent, "CENTER", 0, oy)
end

local function GetWarnFrame()
    if ns._POMWarnFrame then
        ApplyWarnPosition(ns._POMWarnFrame)
        return ns._POMWarnFrame
    end

    local f = CreateFrame("Frame", "POM_AutoImportantWarn", UIParent, "BackdropTemplate")
    f:SetSize(420, 92)
    f:SetPoint("CENTER", UIParent, "CENTER", 0, 240)
    f:SetFrameStrata("HIGH")
    f:SetAlpha(0)
    f:Show()

    f:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
        insets   = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    f:SetBackdropColor(0, 0, 0, 0.80)
    f:SetBackdropBorderColor(1, 0.2, 0.2, 1)

    local warn = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    warn:SetPoint("TOP", f, "TOP", 0, -10)
    warn:SetText("WARNING")
    warn:SetTextColor(1, 0.25, 0.25, 1)

    local txt = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    txt:SetPoint("TOP", warn, "BOTTOM", 0, -2)
    txt:SetText("IMPORTANT CAST")
    txt:SetTextColor(1, 0.45, 0.15, 1)

    local spell = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    spell:SetPoint("TOP", txt, "BOTTOM", 0, -2)
    spell:SetText("")
    spell:SetTextColor(1, 1, 1, 1)

    local small = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    small:SetPoint("TOP", spell, "BOTTOM", 0, -2)
    small:SetText("")

    f._spell = spell
    f._small = small

    ns._POMWarnFrame = f
    ApplyWarnPosition(f)
    return f
end

local function GetProbe()
    if ns._POMProbe then return ns._POMProbe end
    local p = CreateFrame("Frame", nil, UIParent)
    p:SetAlpha(0)
    p:Show()
    ns._POMProbe = p
    return p
end

local function TryDetectImportantKey(cb)
    if importantKey then return importantKey end
    if not cb then return nil end

    local probe = GetProbe()

    local candidates = {
        "isHighlightedImportantCast",
        "highlightedImportantCast",
        "isImportantCast",
        "importantCast",
        "important",
        "isImportant",
        "Important",
        "IsImportant",
    }

    for _, k in ipairs(candidates) do
        local token = rawget(cb, k)
        if token ~= nil then
            if SafeAlphaFromToken(probe, token, 1, 0) then
                importantKey = k
                return importantKey
            end
        end
    end

    for k, v in pairs(cb) do
        if type(k) == "string" then
            local lk = k:lower()
            if lk:find("important", 1, true) or lk:find("highlight", 1, true) then
                if v ~= nil then
                    if SafeAlphaFromToken(probe, v, 1, 0) then
                        importantKey = k
                        return importantKey
                    end
                end
            end
        end
    end

    return nil
end

-- IMPORTANT FIX:
-- Do NOT return early on the first plate that has a castbar but no key.
-- Scan all plates each tick; only return when we applied token-alpha successfully.
local function ScanImportant()
    DB = GetDB()
    local cfg = DB.importantFind or DEFAULTS.importantFind
    local wf = GetWarnFrame()
    ApplyWarnPosition(wf)

    if not (DB.enabled and cfg.enabled) then
        wf:SetAlpha(0)
        SafeSetText(wf._spell, "")
        SafeSetText(wf._small, "")
        return
    end

    if DB.onlyInGroup and not InGroup() then
        wf:SetAlpha(0)
        SafeSetText(wf._spell, "")
        SafeSetText(wf._small, "")
        return
    end

    if not (C_NamePlate and C_NamePlate.GetNamePlates) then
        wf:SetAlpha(0)
        SafeSetText(wf._spell, "")
        SafeSetText(wf._small, cfg.showKeyDebug and "no nameplates api" or "")
        return
    end

    local foundAnyPlate = false
    local foundAnyCastbar = false
    local foundKey = false

    for _, plate in ipairs(C_NamePlate.GetNamePlates()) do
        foundAnyPlate = true
        local unit = plate and (plate.unitToken or plate.namePlateUnitToken)
        if unit and type(unit) == "string" and unit:match("^nameplate%d+$") then
            local cb = GetBlizzCastBarByUnit(unit)
            if cb then
                foundAnyCastbar = true

                local key = TryDetectImportantKey(cb)
                if key then
                    foundKey = true
                    local token = rawget(cb, key)

                    SafeSetText(wf._small, cfg.showKeyDebug and key or "")

                    -- IMPORTANT: alpha driven ONLY by token (no if-token)
                    SafeAlphaFromToken(wf, token, 1, 0)

                    local name = UnitCastingInfo(unit)
                    if name == nil then
                        name = UnitChannelInfo(unit)
                    end

                    if name ~= nil then
                        SafeSetSpellText(wf._spell, name, "")
                    else
                        SafeSetText(wf._spell, "")
                    end

                    return
                end
                -- DO NOT return; keep scanning other nameplates
            end
        end
    end

    wf:SetAlpha(0)
    SafeSetText(wf._spell, "")

    if cfg.showKeyDebug then
        if not foundAnyPlate then
            SafeSetText(wf._small, "no nameplates")
        elseif not foundAnyCastbar then
            SafeSetText(wf._small, "no castbars")
        elseif not foundKey then
            SafeSetText(wf._small, "no important key found")
        else
            SafeSetText(wf._small, "")
        end
    else
        SafeSetText(wf._small, "")
    end
end

local function StartImportantTicker()
    if importantTickerHandle then return end
    local cfg = (DB.importantFind or DEFAULTS.importantFind)
    local interval = tonumber(cfg.scanInterval) or 0.05
    importantTickerHandle = C_Timer.NewTicker(interval, ScanImportant)
end

local function StopImportantTicker()
    if importantTickerHandle then
        importantTickerHandle:Cancel()
        importantTickerHandle = nil
    end
    local wf = GetWarnFrame()
    wf:SetAlpha(0)
    SafeSetText(wf._spell, "")
    SafeSetText(wf._small, "")
end

-- ============================================================================
-- NAMEPLATE CAST TEXT (ALL casts/channels)
-- ============================================================================

local castTextTickerHandle

local function GetOrCreatePlateCastText(plate)
    if not plate then return nil end
    plate._POM_CastText = plate._POM_CastText or {}
    if plate._POM_CastText.f then return plate._POM_CastText.f end

    local uf = plate.UnitFrame or plate.unitFrame or plate
    local f = CreateFrame("Frame", nil, uf)
    f:SetFrameStrata("HIGH")
    f:SetFrameLevel((uf.GetFrameLevel and (uf:GetFrameLevel() + 60)) or 60)
    f:SetSize(260, 40)
    f:SetAlpha(0)
    f:Show()

    local spell = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    spell:SetPoint("CENTER", 0, 6)
    spell:SetText("")
    spell:SetTextColor(1, 1, 1, 1)

    local caster = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    caster:SetPoint("TOP", spell, "BOTTOM", 0, -2)
    caster:SetText("")
    caster:SetTextColor(0.85, 0.85, 0.85, 1)

    f._spell = spell
    f._caster = caster

    plate._POM_CastText.f = f
    return f
end

local function ApplyPlateCastTextLayout(plate, f)
    if not (plate and f) then return end
    DB = GetDB()
    local cfg = DB.nameplateCastText or DEFAULTS.nameplateCastText

    local oy = tonumber(cfg.offsetY) or 18
    if oy < -60 then oy = -60 end
    if oy > 180 then oy = 180 end

    local sc = tonumber(cfg.scale) or 1.0
    if sc < 0.50 then sc = 0.50 end
    if sc > 2.00 then sc = 2.00 end

    f:SetScale(sc)

    local uf = plate.UnitFrame or plate.unitFrame or plate
    f:ClearAllPoints()
    f:SetPoint("BOTTOM", uf, "TOP", 0, oy)
end

local function ClearAllPlateCastText()
    if not (C_NamePlate and C_NamePlate.GetNamePlates) then return end
    for _, plate in ipairs(C_NamePlate.GetNamePlates()) do
        if plate and plate._POM_CastText and plate._POM_CastText.f then
            local f = plate._POM_CastText.f
            f:SetAlpha(0)
            SafeSetText(f._spell, "")
            SafeSetText(f._caster, "")
        end
    end
end

local function ScanNameplateCastText()
    DB = GetDB()
    local cfg = DB.nameplateCastText or DEFAULTS.nameplateCastText

    if not (DB.enabled and cfg.enabled) then
        ClearAllPlateCastText()
        return
    end

    if DB.onlyInGroup and not InGroup() then
        ClearAllPlateCastText()
        return
    end

    if not (C_NamePlate and C_NamePlate.GetNamePlates) then
        ClearAllPlateCastText()
        return
    end

    for _, plate in ipairs(C_NamePlate.GetNamePlates()) do
        local unit = plate and (plate.unitToken or plate.namePlateUnitToken)
        if unit and type(unit) == "string" and unit:match("^nameplate%d+$") then
            local f = GetOrCreatePlateCastText(plate)
            ApplyPlateCastTextLayout(plate, f)

            local spellName = UnitCastingInfo(unit)
            if spellName == nil then
                spellName = UnitChannelInfo(unit)
            end

            if spellName ~= nil then
                f:SetAlpha(1)
                SafeSetSpellText(f._spell, spellName, "")
                if cfg.showCaster then
                    local uname = UnitName(unit)
                    SafeSetText(f._caster, uname or "")
                else
                    SafeSetText(f._caster, "")
                end
            else
                f:SetAlpha(0)
                SafeSetText(f._spell, "")
                SafeSetText(f._caster, "")
            end
        end
    end
end

local function StartCastTextTicker()
    if castTextTickerHandle then return end
    local cfg = (DB.nameplateCastText or DEFAULTS.nameplateCastText)
    local interval = tonumber(cfg.scanInterval) or 0.05
    castTextTickerHandle = C_Timer.NewTicker(interval, ScanNameplateCastText)
end

local function StopCastTextTicker()
    if castTextTickerHandle then
        castTextTickerHandle:Cancel()
        castTextTickerHandle = nil
    end
    ClearAllPlateCastText()
end

-- ============================================================================
-- NAMEPLATE CAST WARNING (WARNING + spell)
-- ============================================================================

local castWarnTickerHandle

local function GetOrCreatePlateCastWarning(plate)
    if not plate then return nil end
    plate._POM_CastWarn = plate._POM_CastWarn or {}
    if plate._POM_CastWarn.f then return plate._POM_CastWarn.f end

    local uf = plate.UnitFrame or plate.unitFrame or plate
    local f = CreateFrame("Frame", nil, uf)
    f:SetFrameStrata("HIGH")
    f:SetFrameLevel((uf.GetFrameLevel and (uf:GetFrameLevel() + 70)) or 70)
    f:SetSize(260, 44)
    f:SetAlpha(0)
    f:Show()

    local warn = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    warn:SetPoint("CENTER", f, "CENTER", 0, 8)
    warn:SetText("WARNING")
    warn:SetTextColor(1, 0.25, 0.25, 1)

    local spell = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
    spell:SetPoint("TOP", warn, "BOTTOM", 0, -2)
    spell:SetText("")
    spell:SetTextColor(1, 1, 1, 1)

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

    f._warn = warn
    f._spell = spell
    f._blink = ag
    f._a1 = a1
    f._a2 = a2

    plate._POM_CastWarn.f = f
    return f
end

local function ApplyPlateCastWarningLayout(plate, f)
    if not (plate and f) then return end
    DB = GetDB()
    local cfg = DB.nameplateCastWarning or DEFAULTS.nameplateCastWarning

    local oy = tonumber(cfg.offsetY) or 30
    if oy < -60 then oy = -60 end
    if oy > 220 then oy = 220 end

    local sc = tonumber(cfg.scale) or 1.0
    if sc < 0.50 then sc = 0.50 end
    if sc > 2.00 then sc = 2.00 end

    f:SetScale(sc)

    local uf = plate.UnitFrame or plate.unitFrame or plate
    f:ClearAllPoints()
    f:SetPoint("BOTTOM", uf, "TOP", 0, oy)

    local sp = tonumber(cfg.blinkSpeed) or 0.50
    if sp < 0.10 then sp = 0.10 end
    if sp > 1.50 then sp = 1.50 end
    f._a1:SetDuration(sp)
    f._a2:SetDuration(sp)
end

local function ClearAllPlateCastWarnings()
    if not (C_NamePlate and C_NamePlate.GetNamePlates) then return end
    for _, plate in ipairs(C_NamePlate.GetNamePlates()) do
        if plate and plate._POM_CastWarn and plate._POM_CastWarn.f then
            local f = plate._POM_CastWarn.f
            if f._blink and f._blink:IsPlaying() then f._blink:Stop() end
            f:SetAlpha(0)
            SafeSetText(f._spell, "")
        end
    end
end

local function ScanNameplateCastWarnings()
    DB = GetDB()
    local cfg = DB.nameplateCastWarning or DEFAULTS.nameplateCastWarning

    if not (DB.enabled and cfg.enabled) then
        ClearAllPlateCastWarnings()
        return
    end

    if DB.onlyInGroup and not InGroup() then
        ClearAllPlateCastWarnings()
        return
    end

    if not (C_NamePlate and C_NamePlate.GetNamePlates) then
        ClearAllPlateCastWarnings()
        return
    end

    for _, plate in ipairs(C_NamePlate.GetNamePlates()) do
        local unit = plate and (plate.unitToken or plate.namePlateUnitToken)
        if unit and type(unit) == "string" and unit:match("^nameplate%d+$") then
            local f = GetOrCreatePlateCastWarning(plate)
            ApplyPlateCastWarningLayout(plate, f)

            local spellName = UnitCastingInfo(unit)
            if spellName == nil then
                spellName = UnitChannelInfo(unit)
            end

            if spellName ~= nil then
                f:SetAlpha(1)
                SafeSetSpellText(f._spell, spellName, "")

                if cfg.blink then
                    if f._blink and not f._blink:IsPlaying() then f._blink:Play() end
                else
                    if f._blink and f._blink:IsPlaying() then f._blink:Stop() end
                    f:SetAlpha(1)
                end
            else
                if f._blink and f._blink:IsPlaying() then f._blink:Stop() end
                f:SetAlpha(0)
                SafeSetText(f._spell, "")
            end
        end
    end
end

local function StartCastWarnTicker()
    if castWarnTickerHandle then return end
    local cfg = (DB.nameplateCastWarning or DEFAULTS.nameplateCastWarning)
    local interval = tonumber(cfg.scanInterval) or 0.05
    castWarnTickerHandle = C_Timer.NewTicker(interval, ScanNameplateCastWarnings)
end

local function StopCastWarnTicker()
    if castWarnTickerHandle then
        castWarnTickerHandle:Cancel()
        castWarnTickerHandle = nil
    end
    ClearAllPlateCastWarnings()
end

-- ============================================================================
-- CAST SOUND (target/offtarget)
-- ============================================================================
local CastSound = { lastAt = 0 }

local function ShouldPlayCastSoundForUnit(unit)
    if not (DB.enabled and DB.castSound and DB.castSound.enabled) then return false end
    if DB.onlyInGroup and not InGroup() then return false end
    if DB.castSound.hostileOnly and not UnitCanAttack("player", unit) then return false end
    return true
end

local function TryPlayCastStartSound(unit)
    if not unit or not ShouldPlayCastSoundForUnit(unit) then return end

    local now = SafeNow()
    local cd = tonumber(DB.castSound.cooldown) or 0.35
    if cd < 0.05 then cd = 0.05 end

    if (now - (CastSound.lastAt or 0)) < cd then return end
    CastSound.lastAt = now

    if UnitIsUnit(unit, "target") then
        SafePlaySoundFile(DB.castSound.soundTargetFile)
    else
        SafePlaySoundFile(DB.castSound.soundOtherFile)
    end
end

-- ============================================================================
-- THREAT SPECIALS (HARD AUTO-OFF FOR TANK SPEC)
-- ============================================================================
local Threat = { active = false, lastSoundAt = 0 }

local threatTextFrame = CreateFrame("Frame", "POM_SpecialsThreatTextFrame", UIParent)
threatTextFrame:SetSize(800, 140)
threatTextFrame:SetPoint("CENTER", 0, 150)
threatTextFrame:Hide()

local threatText = threatTextFrame:CreateFontString(nil, "OVERLAY")
threatText:SetPoint("CENTER", 0, 0)
pcall(threatText.SetFont, threatText, "Fonts\\FRIZQT__.TTF", 48, "OUTLINE")
threatText:SetTextColor(1, 0, 0, 1)
threatText:SetText("")

local anim = threatText:CreateAnimationGroup()
local aFade = anim:CreateAnimation("Alpha")
aFade:SetFromAlpha(1); aFade:SetToAlpha(0.2); aFade:SetSmoothing("IN_OUT"); aFade:SetOrder(1)
local aShow = anim:CreateAnimation("Alpha")
aShow:SetFromAlpha(0.2); aShow:SetToAlpha(1); aShow:SetSmoothing("IN_OUT"); aShow:SetOrder(2)
anim:SetLooping("REPEAT")

local function ThreatApplyBlinkSpeed()
    local tdb = DB.threat or DEFAULTS.threat
    local sp = tonumber(tdb.blinkSpeed) or 0.50
    aFade:SetDuration(sp)
    aShow:SetDuration(sp)
end

local function ThreatClearInternal()
    Threat.active = false
    Threat.lastSoundAt = 0
    threatTextFrame:Hide()
    SafeSetText(threatText, "")
    if anim:IsPlaying() then anim:Stop() end
    threatText:SetAlpha(1)
end

local function ThreatStart(text, wantBlink)
    Threat.active = true
    threatTextFrame:Show()
    SafeSetText(threatText, text or "")

    local tdb = DB.threat or DEFAULTS.threat
    if wantBlink and tdb.blinkEnabled then
        ThreatApplyBlinkSpeed()
        if not anim:IsPlaying() then anim:Play() end
    else
        if anim:IsPlaying() then anim:Stop() end
        threatText:SetAlpha(1)
    end
end

local function CanRunThreat()
    if not (DB.enabled and DB.threat and DB.threat.enabled) then return false end
    if DB.onlyInGroup and not InGroup() then return false end
    return true
end

local function CheckHighThreatDPS()
    if not IsTargetHostile() then return false end
    local status = UnitThreatSituation("player", "target")
    return (status and status >= 3) or false
end

local function ThreatTick(now)
    if not CanRunThreat() then
        if Threat.active then ThreatClearInternal() end
        return
    end

    -- HARD AUTO-OFF: if you are tank spec, threat warning never runs (no text, no blink, no sound).
    local srole = GetSpecRole()
    if srole == "TANK" then
        if Threat.active then ThreatClearInternal() end
        return
    end

    local tdb = DB.threat
    local alertText, soundFile, wantBlink

    if tdb.highThreat and CheckHighThreatDPS() then
        alertText = "THREAT HIGH WARNING"
        soundFile = (DB.sounds and DB.sounds.highThreat) or DEFAULTS.sounds.highThreat
        wantBlink = true
    end

    if not alertText then
        if Threat.active then ThreatClearInternal() end
        return
    end

    if not Threat.active then
        ThreatStart(alertText, wantBlink)
        Threat.lastSoundAt = now
        if tdb.soundEnabled and soundFile then
            SafePlaySoundFile(soundFile)
        end
        return
    end

    SafeSetText(threatText, alertText)

    if tdb.soundEnabled and soundFile then
        local interval = tonumber(tdb.soundInterval) or 2.0
        if interval < 0.25 then interval = 0.25 end
        local last = Threat.lastSoundAt or 0
        if (now - last) >= interval then
            Threat.lastSoundAt = now
            SafePlaySoundFile(soundFile)
        end
    end
end

-- ============================================================================
-- Runtime refresh
-- ============================================================================
local function RefreshRuntime()
    DB = GetDB()

    ApplyWarnPosition(GetWarnFrame())

    if DB.enabled and DB.importantFind and DB.importantFind.enabled then
        StartImportantTicker()
    else
        StopImportantTicker()
    end

    if DB.enabled and DB.nameplateCastText and DB.nameplateCastText.enabled then
        StartCastTextTicker()
    else
        StopCastTextTicker()
    end

    if DB.enabled and DB.nameplateCastWarning and DB.nameplateCastWarning.enabled then
        StartCastWarnTicker()
    else
        StopCastWarnTicker()
    end

    ThreatApplyBlinkSpeed()

    if not DB.enabled then
        StopImportantTicker()
        StopCastTextTicker()
        StopCastWarnTicker()
        ThreatClearInternal()
    end
end

-- ============================================================================
-- Driver
-- ============================================================================
local driver = CreateFrame("Frame")
driver:RegisterEvent("PLAYER_LOGIN")
driver:RegisterEvent("PLAYER_ENTERING_WORLD")
driver:RegisterEvent("PLAYER_REGEN_ENABLED")
driver:RegisterEvent("PLAYER_DEAD")
driver:RegisterEvent("UNIT_SPELLCAST_START")
driver:RegisterEvent("UNIT_SPELLCAST_CHANNEL_START")

driver:SetScript("OnEvent", function(_, event, unit)
    if event == "PLAYER_LOGIN" or event == "PLAYER_ENTERING_WORLD" then
        RefreshRuntime()
        if DB.ui and DB.ui.showSettingsOnLogin and _G.POM_SpecialsSettings then
            _G.POM_SpecialsSettings:Show()
        end
        return
    end

    if event == "PLAYER_REGEN_ENABLED" or event == "PLAYER_DEAD" then
        ThreatClearInternal()
        return
    end

    if event == "UNIT_SPELLCAST_START" or event == "UNIT_SPELLCAST_CHANNEL_START" then
        if unit and type(unit) == "string" and unit:match("^nameplate%d+$") then
            TryPlayCastStartSound(unit)
        end
        return
    end
end)

driver._acc = 0
driver:SetScript("OnUpdate", function(self, elapsed)
    self._acc = (self._acc or 0) + (elapsed or 0)
    if self._acc < 0.10 then return end
    self._acc = 0

    DB = GetDB()
    if not DB.enabled then
        ThreatClearInternal()
        return
    end
    if DB.onlyInGroup and not InGroup() then
        ThreatClearInternal()
        return
    end

    ThreatTick(SafeNow())
end)

-- ============================================================================
-- Settings UI: /pomspecial
-- UI VISUALS FIX ONLY:
--  - ScrollFrame so nothing runs off-screen
--  - Consistent spacing + widths
--  - No logic changes anywhere else
-- ============================================================================

local Settings = CreateFrame("Frame", "POM_SpecialsSettings", UIParent, "BasicFrameTemplateWithInset")
Settings:SetSize(820, 640)
Settings:SetPoint("CENTER")
Settings:Hide()
Settings:SetFrameStrata("DIALOG")
Settings:SetFrameLevel(100)

Settings:SetMovable(true)
Settings:EnableMouse(true)
Settings:RegisterForDrag("LeftButton")
Settings:SetClampedToScreen(true)
Settings:SetScript("OnDragStart", function(self) self:StartMoving() end)
Settings:SetScript("OnDragStop",  function(self) self:StopMovingOrSizing() end)

Settings.title = Settings:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
Settings.title:SetPoint("LEFT", Settings.TitleBg, "LEFT", 10, 0)
Settings.title:SetText("POM Specials")

-- Scroll container
local Scroll = CreateFrame("ScrollFrame", "POM_SpecialsScroll", Settings, "UIPanelScrollFrameTemplate")
Scroll:SetPoint("TOPLEFT", Settings.InsetBg, "TOPLEFT", 6, -6)
Scroll:SetPoint("BOTTOMRIGHT", Settings.InsetBg, "BOTTOMRIGHT", -26, 6)

local Content = CreateFrame("Frame", nil, Scroll)
Content:SetSize(760, 980) -- tall enough for everything
Scroll:SetScrollChild(Content)

-- Layout constants
local L_X, R_X = 16, 392
local COL_W = 340
local ROW = 24
local HDR_GAP = 18

local function MakeSep(parent, x, y, w)
    local t = parent:CreateTexture(nil, "ARTWORK")
    t:SetTexture("Interface\\Buttons\\WHITE8x8")
    t:SetVertexColor(1, 1, 1, 0.08)
    t:SetPoint("TOPLEFT", x, y)
    t:SetSize(w, 1)
    return t
end

local function MakeHeader(parent, text, x, y)
    local fs = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    fs:SetPoint("TOPLEFT", x, y)
    fs:SetText(text)
    return fs
end

local function MakeCheck(parent, label, x, y, get, set)
    local b = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate")
    b:SetPoint("TOPLEFT", x, y)
    b.Text:SetText(label)
    b:SetScript("OnClick", function(self)
        set(self:GetChecked() and true or false)
        RefreshRuntime()
    end)
    b.Refresh = function()
        b:SetChecked(get() and true or false)
    end
    b:Refresh()
    return b
end

local SLIDER_ID = 0
local function MakeSlider(parent, label, x, y, minVal, maxVal, step, get, set)
    SLIDER_ID = SLIDER_ID + 1
    local name = "POM_SpecialsSlider" .. SLIDER_ID

    local s = CreateFrame("Slider", name, parent, "OptionsSliderTemplate")
    s:SetPoint("TOPLEFT", x, y)
    s:SetMinMaxValues(minVal, maxVal)
    s:SetValueStep(step)
    s:SetObeyStepOnDrag(true)
    s:SetWidth(COL_W - 24)

    local text = s.Text or _G[name .. "Text"]
    local low  = s.Low  or _G[name .. "Low"]
    local high = s.High or _G[name .. "High"]

    if text and text.SetText then text:SetText(label) end
    if low  and low.SetText  then low:SetText(tostring(minVal)) end
    if high and high.SetText then high:SetText(tostring(maxVal)) end

    s:SetScript("OnValueChanged", function(_, val)
        set(val)
        RefreshRuntime()
    end)

    s.Refresh = function()
        local v = get()
        if v == nil then v = minVal end
        s:SetValue(v)
    end

    s:Refresh()
    return s
end

local function MakeButton(parent, text, x, y, w, onClick)
    local b = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
    b:SetSize(w or (COL_W - 24), 22)
    b:SetPoint("TOPLEFT", x, y)
    b:SetText(text)
    b:SetScript("OnClick", onClick)
    return b
end

-- LEFT COLUMN (inside Content)
local y = -10

MakeHeader(Content, "General", L_X, y); y = y - HDR_GAP
MakeSep(Content, L_X, y + 6, COL_W); y = y - 10

Settings.master = MakeCheck(Content, "Enable Specials (master)", L_X, y,
    function() DB = GetDB(); return DB.enabled end,
    function(v) DB = GetDB(); DB.enabled = v end
); y = y - ROW

Settings.onlyGroup = MakeCheck(Content, "Only in group/raid", L_X, y,
    function() DB = GetDB(); return DB.onlyInGroup end,
    function(v) DB = GetDB(); DB.onlyInGroup = v end
); y = y - (ROW + 10)

MakeHeader(Content, "Cast Sounds (Target / Off-target)", L_X, y); y = y - HDR_GAP
MakeSep(Content, L_X, y + 6, COL_W); y = y - 10

Settings.csEnabled = MakeCheck(Content, "Enable cast sounds", L_X, y,
    function() DB = GetDB(); return DB.castSound.enabled end,
    function(v) DB = GetDB(); DB.castSound.enabled = v end
); y = y - ROW

Settings.csHostile = MakeCheck(Content, "Hostile only", L_X, y,
    function() DB = GetDB(); return DB.castSound.hostileOnly end,
    function(v) DB = GetDB(); DB.castSound.hostileOnly = v end
); y = y - (ROW + 8)

Settings.csCooldown = MakeSlider(Content, "Sound cooldown (sec)", L_X, y, 0.05, 1.50, 0.05,
    function() DB = GetDB(); return tonumber(DB.castSound.cooldown) or 0.35 end,
    function(v) DB = GetDB(); DB.castSound.cooldown = v end
); y = y - 54

MakeButton(Content, "Test Target Cast Sound", L_X, y, COL_W - 24, function()
    DB = GetDB()
    SafePlaySoundFile(DB.castSound.soundTargetFile)
end); y = y - 26

MakeButton(Content, "Test Off-target Cast Sound", L_X, y, COL_W - 24, function()
    DB = GetDB()
    SafePlaySoundFile(DB.castSound.soundOtherFile)
end); y = y - (ROW + 12)

MakeHeader(Content, "ImportantCast Finder (CENTER warning)", L_X, y); y = y - HDR_GAP
MakeSep(Content, L_X, y + 6, COL_W); y = y - 10

Settings.impEnabled = MakeCheck(Content, "Enable ImportantCast finder", L_X, y,
    function() DB = GetDB(); return DB.importantFind.enabled end,
    function(v) DB = GetDB(); DB.importantFind.enabled = v end
); y = y - ROW

Settings.impShowKey = MakeCheck(Content, "Show detected key (debug)", L_X, y,
    function() DB = GetDB(); return DB.importantFind.showKeyDebug end,
    function(v) DB = GetDB(); DB.importantFind.showKeyDebug = v end
); y = y - (ROW + 8)

Settings.impInterval = MakeSlider(Content, "Scan interval (sec)", L_X, y, 0.02, 0.30, 0.01,
    function() DB = GetDB(); return tonumber(DB.importantFind.scanInterval) or 0.05 end,
    function(v) DB = GetDB(); DB.importantFind.scanInterval = v end
); y = y - 54

Settings.impOffsetY = MakeSlider(Content, "Center warning height (Y)", L_X, y, 0, 700, 5,
    function() DB = GetDB(); return tonumber(DB.importantFind.offsetY) or 240 end,
    function(v) DB = GetDB(); DB.importantFind.offsetY = v end
); y = y - (ROW + 12)

MakeHeader(Content, "Nameplate Cast Text (ALL casts)", L_X, y); y = y - HDR_GAP
MakeSep(Content, L_X, y + 6, COL_W); y = y - 10

Settings.npctEnabled = MakeCheck(Content, "Show cast spell name over nameplates", L_X, y,
    function() DB = GetDB(); return DB.nameplateCastText.enabled end,
    function(v) DB = GetDB(); DB.nameplateCastText.enabled = v end
); y = y - ROW

Settings.npctShowCaster = MakeCheck(Content, "Show caster name under spell", L_X, y,
    function() DB = GetDB(); return DB.nameplateCastText.showCaster end,
    function(v) DB = GetDB(); DB.nameplateCastText.showCaster = v end
); y = y - (ROW + 8)

Settings.npctOffsetY = MakeSlider(Content, "Nameplate text offset Y", L_X, y, -60, 180, 1,
    function() DB = GetDB(); return tonumber(DB.nameplateCastText.offsetY) or 18 end,
    function(v) DB = GetDB(); DB.nameplateCastText.offsetY = v end
); y = y - 54

Settings.npctScale = MakeSlider(Content, "Nameplate text scale", L_X, y, 0.50, 2.00, 0.05,
    function() DB = GetDB(); return tonumber(DB.nameplateCastText.scale) or 1.0 end,
    function(v) DB = GetDB(); DB.nameplateCastText.scale = v end
); y = y - 54

Settings.npctInterval = MakeSlider(Content, "Scan interval (sec)", L_X, y, 0.02, 0.20, 0.01,
    function() DB = GetDB(); return tonumber(DB.nameplateCastText.scanInterval) or 0.05 end,
    function(v) DB = GetDB(); DB.nameplateCastText.scanInterval = v end
); y = y - (ROW + 12)

MakeHeader(Content, "Nameplate Cast WARNING (WARNING + spell)", L_X, y); y = y - HDR_GAP
MakeSep(Content, L_X, y + 6, COL_W); y = y - 10

Settings.npcwEnabled = MakeCheck(Content, "Show WARNING + spell over nameplates", L_X, y,
    function() DB = GetDB(); return DB.nameplateCastWarning.enabled end,
    function(v) DB = GetDB(); DB.nameplateCastWarning.enabled = v end
); y = y - ROW

Settings.npcwBlink = MakeCheck(Content, "Blink warning", L_X, y,
    function() DB = GetDB(); return DB.nameplateCastWarning.blink end,
    function(v) DB = GetDB(); DB.nameplateCastWarning.blink = v end
); y = y - (ROW + 8)

Settings.npcwOffsetY = MakeSlider(Content, "Warning offset Y", L_X, y, -60, 220, 1,
    function() DB = GetDB(); return tonumber(DB.nameplateCastWarning.offsetY) or 30 end,
    function(v) DB = GetDB(); DB.nameplateCastWarning.offsetY = v end
); y = y - 54

Settings.npcwScale = MakeSlider(Content, "Warning scale", L_X, y, 0.50, 2.00, 0.05,
    function() DB = GetDB(); return tonumber(DB.nameplateCastWarning.scale) or 1.0 end,
    function(v) DB = GetDB(); DB.nameplateCastWarning.scale = v end
); y = y - 54

Settings.npcwBlinkSpeed = MakeSlider(Content, "Blink speed (sec)", L_X, y, 0.10, 1.50, 0.05,
    function() DB = GetDB(); return tonumber(DB.nameplateCastWarning.blinkSpeed) or 0.50 end,
    function(v) DB = GetDB(); DB.nameplateCastWarning.blinkSpeed = v end
); y = y - 54

Settings.npcwInterval = MakeSlider(Content, "Scan interval (sec)", L_X, y, 0.02, 0.20, 0.01,
    function() DB = GetDB(); return tonumber(DB.nameplateCastWarning.scanInterval) or 0.05 end,
    function(v) DB = GetDB(); DB.nameplateCastWarning.scanInterval = v end
)

-- RIGHT COLUMN (inside Content)
local ry = -10

MakeHeader(Content, "Threat Specials", R_X, ry); ry = ry - HDR_GAP
MakeSep(Content, R_X, ry + 6, COL_W); ry = ry - 10

Settings.thEnabled = MakeCheck(Content, "Enable threat specials (auto-off for tank spec)", R_X, ry,
    function() DB = GetDB(); return DB.threat.enabled end,
    function(v) DB = GetDB(); DB.threat.enabled = v end
); ry = ry - ROW

Settings.thHigh = MakeCheck(Content, "DPS/Healer: High threat warning", R_X, ry,
    function() DB = GetDB(); return DB.threat.highThreat end,
    function(v) DB = GetDB(); DB.threat.highThreat = v end
); ry = ry - (ROW + 10)

MakeHeader(Content, "Threat Effects", R_X, ry); ry = ry - HDR_GAP
MakeSep(Content, R_X, ry + 6, COL_W); ry = ry - 10

Settings.thBlink = MakeCheck(Content, "Blink text", R_X, ry,
    function() DB = GetDB(); return DB.threat.blinkEnabled end,
    function(v) DB = GetDB(); DB.threat.blinkEnabled = v end
); ry = ry - ROW

Settings.thSound = MakeCheck(Content, "Threat sound repeat", R_X, ry,
    function() DB = GetDB(); return DB.threat.soundEnabled end,
    function(v) DB = GetDB(); DB.threat.soundEnabled = v end
); ry = ry - (ROW + 8)

Settings.thBlinkSpeed = MakeSlider(Content, "Blink speed (sec)", R_X, ry, 0.10, 1.00, 0.05,
    function() DB = GetDB(); return tonumber(DB.threat.blinkSpeed) or 0.50 end,
    function(v) DB = GetDB(); DB.threat.blinkSpeed = v; ThreatApplyBlinkSpeed() end
); ry = ry - 54

Settings.thSoundInt = MakeSlider(Content, "Sound interval (sec)", R_X, ry, 0.25, 5.0, 0.25,
    function() DB = GetDB(); return tonumber(DB.threat.soundInterval) or 2.0 end,
    function(v) DB = GetDB(); DB.threat.soundInterval = v end
); ry = ry - (ROW + 12)

MakeHeader(Content, "Actions", R_X, ry); ry = ry - HDR_GAP
MakeSep(Content, R_X, ry + 6, COL_W); ry = ry - 10

MakeButton(Content, "Test High Threat Sound", R_X, ry, COL_W - 24, function()
    DB = GetDB()
    SafePlaySoundFile(DB.sounds.highThreat)
end); ry = ry - 26

MakeButton(Content, "Test Frontal Sound", R_X, ry, COL_W - 24, function()
    DB = GetDB()
    SafePlaySoundFile(DB.sounds.frontal)
end); ry = ry - 26

MakeButton(Content, "Reset Important Key Cache", R_X, ry, COL_W - 24, function()
    importantKey = nil
    local wf = GetWarnFrame()
    wf:SetAlpha(0)
    SafeSetText(wf._spell, "")
    SafeSetText(wf._small, "cache reset")
    print("POM: reset important key cache")
end); ry = ry - 26

MakeButton(Content, "Reset All Defaults", R_X, ry, COL_W - 24, function()
    PlateOMaticDB.alerts = {}
    DB = GetDB()
    RefreshRuntime()
    Settings:Hide()
    Settings:Show()
end)

Settings:SetScript("OnShow", function()
    DB = GetDB()
    Settings.master:Refresh()
    Settings.onlyGroup:Refresh()

    Settings.csEnabled:Refresh()
    Settings.csHostile:Refresh()
    Settings.csCooldown:Refresh()

    Settings.impEnabled:Refresh()
    Settings.impShowKey:Refresh()
    Settings.impInterval:Refresh()
    Settings.impOffsetY:Refresh()

    Settings.npctEnabled:Refresh()
    Settings.npctShowCaster:Refresh()
    Settings.npctOffsetY:Refresh()
    Settings.npctScale:Refresh()
    Settings.npctInterval:Refresh()

    Settings.npcwEnabled:Refresh()
    Settings.npcwBlink:Refresh()
    Settings.npcwOffsetY:Refresh()
    Settings.npcwScale:Refresh()
    Settings.npcwBlinkSpeed:Refresh()
    Settings.npcwInterval:Refresh()

    Settings.thEnabled:Refresh()
    Settings.thHigh:Refresh()
    Settings.thBlink:Refresh()
    Settings.thSound:Refresh()
    Settings.thBlinkSpeed:Refresh()
    Settings.thSoundInt:Refresh()
end)

SLASH_POMSPECIAL1 = "/pomspecial"
SlashCmdList.POMSPECIAL = function()
    if Settings:IsShown() then Settings:Hide() else Settings:Show() end
end

SLASH_POMIMPFIND1 = "/pomimpfind"
SlashCmdList.POMIMPFIND = function()
    importantKey = nil
    print("POM: reset important key cache")
end

function M.Refresh()
    RefreshRuntime()
end
