-- Entombed Sentinels Alarm
-- Standing inside the range of BOTH boss1 and boss2 at once is the thing you must
-- never do on this fight, so the alert is a continuous alarm rather than a one-off
-- notification: it repeats every db.repeatInterval seconds for as long as both are
-- in range, and stops as soon as either one leaves. A single boss closing on you
-- never alerts on its own.
--
-- Range detection uses C_Item.IsItemInRange(itemID, unit). The item does NOT need
-- to be in the player's bags -- the check only needs the item's data to be cached
-- client-side, which is why RequestItemData() is called at login. The default item,
-- Gunpowder Charge (132510), resolves to spell 200285 whose SpellMisc.RangeIndex is
-- 5 ("Long Range" = RangeMin 0, RangeMax 40), giving a clean binary 40-yard check
-- with no minimum-range dead zone.
--
-- Note: range checks are edge-to-edge against the unit's hitbox, so a large boss
-- registers as "in range" slightly further out than its center point suggests.

local ADDON_NAME = ...

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

-- ── Constants ────────────────────────────────────────────────────────────────

local DEFAULT_RANGE_ITEM = 132510  -- Gunpowder Charge — 40 yd
local DEFAULT_ENCOUNTER  = 3445    -- Entombed Sentinels (The Venomous Abyss)

-- Entombed Sentinels is a two-golem encounter; the alert is about being caught
-- between both of them, so only these two units are ever checked.
local UNIT_A = "boss1"
local UNIT_B = "boss2"

-- ── Stasis pause ─────────────────────────────────────────────────────────────
--
-- Fight shape: roughly every 100s the two golems collide, become immune, and
-- channel Vitriolic Stasis while their energy drains 100 -> 0. The raid clears
-- Helical Toxins during that window, then the golems separate and energy climbs
-- again at 1/sec.
--
-- While collided the two bosses occupy the same spot, so "both within 40 yards"
-- is trivially and permanently true -- the alarm would blare continuously and
-- tell you nothing. It is also harmless to stand there, since they are immune.
-- So the alarm is suppressed for the duration of the stasis and resumes when
-- they separate. The stasis length is variable (it ends when the raid finishes
-- the mechanic), so this is driven by events, never by a timer.
--
-- Every named variant of each spell is listed: the client fires different IDs
-- for the cast, the channel, and the per-difficulty aura, and a set lookup costs
-- the same regardless of size.

local STASIS_SPELLS = {   -- Vitriolic Stasis
    [1284588] = true, [1284606] = true, [1284610] = true, [1284626] = true,
    [1284635] = true, [1284719] = true, [1284721] = true, [1284735] = true,
    [1284736] = true,
}

-- Safety valve for the pause above. If the resume signal is never seen (missed
-- event, secret spellID, boss-mod conflict) the alarm un-pauses itself rather
-- than staying silent for the rest of the pull -- silence is the dangerous
-- failure here. Not user-configurable: a stasis never runs anywhere near this
-- long, so the only thing a lower value could do is un-pause mid-intermission.
local MAX_PAUSE = 60

local TOXIN_SPELLS = {    -- Helical Toxins
    [1284590] = true, [1284799] = true, [1284801] = true, [1284802] = true,
    [1284810] = true, [1284813] = true, [1284940] = true, [1288336] = true,
    [1288341] = true, [1301774] = true, [1301776] = true, [1301777] = true,
    [1301779] = true, [1301781] = true, [1301782] = true, [1301853] = true,
    [1301854] = true, [1301899] = true, [1311488] = true,
}

-- The full set of SetFont flag combinations. Bold and italic are NOT flags --
-- they need a separate font file -- so these six are the whole vocabulary.
local OUTLINE_ENTRIES = {
    { text = "None",                value = ""                        },
    { text = "Outline",             value = "OUTLINE"                 },
    { text = "Thick Outline",       value = "THICKOUTLINE"            },
    { text = "Monochrome",          value = "MONOCHROME"              },
    { text = "Outline, Monochrome", value = "OUTLINE,MONOCHROME"      },
    { text = "Thick Outline, Mono", value = "THICKOUTLINE,MONOCHROME" },
}

local C_ACCENT = "|cff7272f2"
local C_GREEN  = "|cff40ff40"
local C_RED    = "|cffff5555"
local C_GRAY   = "|cff808080"
local C_RESET  = "|r"

local IsItemInRange = C_Item.IsItemInRange
local GetItemInfo   = C_Item.GetItemInfo

-- Midnight can hand back "secret" values that cannot be compared or used as a
-- table key -- doing so errors. Every spellID arriving from an event is screened
-- through this before it is matched against a set.
local issecretvalue = issecretvalue

local function IsSecret(v)
    return issecretvalue and issecretvalue(v)
end

-- Returns true / false, or nil when the id could not be inspected at all.
local function SpellInSet(set, spellID)
    if spellID == nil or IsSecret(spellID) then return nil end
    return set[spellID] == true
end

-- ── Debug log ────────────────────────────────────────────────────────────────

local LOG_MAX      = 1000
local logBuffer    = {}
local debugEnabled = false

local function Log(msg)
    if not debugEnabled then return end
    logBuffer[#logBuffer + 1] = string.format("[%.1f] %s", GetTime(), msg)
    if #logBuffer > LOG_MAX then
        table.remove(logBuffer, 1)
    end
end

local function Print(msg)
    print(C_ACCENT .. "Entombed Sentinels Alarm" .. C_RESET .. ": " .. msg)
end

-- ── Saved variables ──────────────────────────────────────────────────────────

local db

local defaults = {
    enabled        = true,
    soundEnabled   = true,
    sound          = "Raid Warning",
    textEnabled    = true,
    text           = "Too Close!!!",
    textFont       = "Friz Quadrata TT",
    textOutline    = "THICKOUTLINE",
    textColor      = { r = 1, g = 0.1, b = 0.1 },
    textPos        = { point = "CENTER", relPoint = "CENTER", x = 0, y = 160 },
    encounterID    = DEFAULT_ENCOUNTER,
    rangeItemID    = DEFAULT_RANGE_ITEM,
    interval       = 0.25,
    repeatInterval = 2,
    postStasisDelay = 5,
}

local function InitDB()
    EntombedSentinelsAlarmDB = EntombedSentinelsAlarmDB or {}
    db = EntombedSentinelsAlarmDB
    -- Migration: `cooldown` was a one-shot re-alert guard back when the alert was
    -- edge-triggered. It is now the repeat interval of a continuous alarm.
    if db.repeatInterval == nil and db.cooldown ~= nil then
        db.repeatInterval = db.cooldown
    end
    db.cooldown  = nil
    db.alertOnce = nil
    for k, v in pairs(defaults) do
        if db[k] == nil then db[k] = v end
    end
end

-- ── Range tracking ───────────────────────────────────────────────────────────
--
-- The tracked condition is a single combined predicate: BOTH boss1 and boss2 are
-- inside range on the same poll. Individual units are not tracked -- a boss on
-- its own never alerts, no matter how close it gets.
--
-- bothInRange — last known value of that predicate (logging / state only)
-- lastFire    — GetTime() of the last alert
--
-- Level-triggered, not edge-triggered: while the predicate holds, the sound
-- repeats every db.repeatInterval seconds. That interval is the only debounce --
-- it doubles as the flicker guard, since lastFire is deliberately never cleared
-- when the pair breaks.

local armed       = false
local ticker      = nil
local bothInRange = false
local lastFire    = nil

-- Pause state. `paused` suppresses the alarm without stopping the poll, so the
-- log keeps recording range transitions through the stasis window.
local paused       = false
local pausedAt     = nil
local pauseReason  = nil
local toxinCount   = 0    -- live Helical Toxins applications seen via CLEU
local graceUntil   = nil  -- alerts held off until this time after an intermission

local function ResetTracking()
    bothInRange = false
    lastFire    = nil
    paused      = false
    pausedAt    = nil
    pauseReason = nil
    toxinCount  = 0
    graceUntil  = nil
end

-- Defined further down, next to the event handlers they register; forward
-- declared here so Arm/Disarm can drive them.
local StartStasisWatch, StopStasisWatch

-- Forward declared: PauseAlarm below needs it, but it is defined with the rest
-- of the warning-text code further down.
local ShowWarnText

local function PauseAlarm(reason)
    if paused then return end
    paused      = true
    pausedAt    = GetTime()
    pauseReason = reason
    ShowWarnText(false)
    Log("PAUSED (" .. reason .. ")")
end

local function ResumeAlarm(reason)
    if not paused then return end
    local now = GetTime()
    Log(string.format("RESUMED (%s) after %.1fs", reason, now - (pausedAt or now)))
    paused      = false
    pausedAt    = nil
    pauseReason = nil

    -- The golems separate over a moment rather than instantly, so for the first
    -- few seconds after the intermission they are still on top of each other and
    -- "both in range" is still trivially true. Alerting there would just be the
    -- stasis alarm arriving late. Hold off until they have actually spread out.
    if db.postStasisDelay > 0 then
        graceUntil = now + db.postStasisDelay
        Log(string.format("post-intermission grace: %.1fs", db.postStasisDelay))
    else
        graceUntil = nil
    end

    -- Do not clear lastFire: an alarm that was already sounding before the
    -- stasis should not double-fire the instant the pause lifts.
end

-- ── On-screen warning text ───────────────────────────────────────────────────
--
-- Unlike the sound, the text is level-driven: it stays up for as long as you are
-- inside both bosses and disappears the moment you are clear. Flashing it once
-- per repeat would make it a worse version of the sound; held steady it answers
-- "am I still standing in it?" at a glance.

local TEXT_SIZE = 48

-- Blizzard's own Edit Mode selection nine-slice, copied verbatim from
-- Blizzard_EditMode/Shared/EditModeSystemTemplates.lua (EditModeSystemSelectionLayout).
-- Applying it with the "editmode-actionbar-selected" texture kit reproduces the
-- exact blue haze the game draws around a movable frame, rather than an
-- approximation of it.
local EDIT_SELECTION_LAYOUT = {
    ["TopRightCorner"]   = { atlas = "%s-NineSlice-Corner", mirrorLayout = true, x =  8, y =  8 },
    ["TopLeftCorner"]    = { atlas = "%s-NineSlice-Corner", mirrorLayout = true, x = -8, y =  8 },
    ["BottomLeftCorner"] = { atlas = "%s-NineSlice-Corner", mirrorLayout = true, x = -8, y = -8 },
    ["BottomRightCorner"]= { atlas = "%s-NineSlice-Corner", mirrorLayout = true, x =  8, y = -8 },
    ["TopEdge"]          = { atlas = "_%s-NineSlice-EdgeTop" },
    ["BottomEdge"]       = { atlas = "_%s-NineSlice-EdgeBottom" },
    ["LeftEdge"]         = { atlas = "!%s-NineSlice-EdgeLeft" },
    ["RightEdge"]        = { atlas = "!%s-NineSlice-EdgeRight" },
    ["Center"]           = { atlas = "%s-NineSlice-Center", x = -8, y = 8, x1 = 8, y1 = -8 },
}

-- The blue haze is the *highlight* kit drawn with ADD blending at 0.4 alpha --
-- exactly what EditModeSystemSelectionBaseMixin:OnLoad does for MouseOverHighlight.
-- The "selected" kit is the gold outline Blizzard uses for the frame you have
-- actually clicked, which is a different (and much yellower) look.
local EDIT_TEXTURE_KIT   = "editmode-actionbar-highlight"
local EDIT_HAZE_ALPHA    = 0.4

-- RUI.Dialog sits at HIGH. The warning must outrank normal UI while it is doing
-- its job, but while you are positioning it the options dialog has to stay on
-- top -- otherwise the thing you are configuring covers the controls you are
-- configuring it with. So the strata drops for the duration of edit mode.
local STRATA_ALARM = "HIGH"
local STRATA_EDIT  = "MEDIUM"

local warnFrame, warnText, warnEdit
local textEditMode = false   -- true while the frame is being positioned

local function SaveTextPosition()
    local point, _, relPoint, x, y = warnFrame:GetPoint(1)
    db.textPos = {
        point    = point,
        relPoint = relPoint,
        x        = math.floor(x + 0.5),
        y        = math.floor(y + 0.5),
    }
    Log(string.format("text moved to %s %s %d,%d", point, relPoint, db.textPos.x, db.textPos.y))
end

local function ApplyTextPosition()
    local p = db.textPos or {}
    warnFrame:ClearAllPoints()
    warnFrame:SetPoint(p.point or "CENTER", UIParent, p.relPoint or "CENTER",
                       p.x or 0, p.y or 160)
end

local function EnsureWarnFrame()
    if warnFrame then return end

    warnFrame = CreateFrame("Frame", "EntombedSentinelsAlarmText", UIParent)
    warnFrame:SetSize(600, 80)
    warnFrame:SetFrameStrata(STRATA_ALARM)
    warnFrame:SetClampedToScreen(true)
    warnFrame:Hide()
    ApplyTextPosition()

    warnText = warnFrame:CreateFontString(nil, "OVERLAY")
    warnText:SetAllPoints()
    warnText:SetJustifyH("CENTER")
    warnText:SetJustifyV("MIDDLE")

    -- Drag overlay. Kept as a separate child so the alarm frame itself never has
    -- mouse enabled during combat -- only this overlay does, and only while the
    -- player is deliberately positioning it.
    warnEdit = CreateFrame("Frame", nil, warnFrame, "NineSliceCodeTemplate")
    warnEdit:SetAllPoints()
    -- No explicit strata: it inherits warnFrame's, so the two move together when
    -- the strata is lowered for edit mode.
    warnEdit:SetFrameLevel(warnFrame:GetFrameLevel() + 10)
    warnEdit:SetAlpha(EDIT_HAZE_ALPHA)
    warnEdit:EnableMouse(true)
    warnEdit:RegisterForDrag("LeftButton")
    warnEdit:Hide()

    if NineSliceUtil and NineSliceUtil.ApplyLayout then
        -- Guarded: a missing atlas must not break positioning entirely.
        local ok = pcall(NineSliceUtil.ApplyLayout, warnEdit, EDIT_SELECTION_LAYOUT, EDIT_TEXTURE_KIT)
        -- ADD blending is what turns the atlas into a haze rather than a flat
        -- panel. SetBlendMode comes from NineSlicePanelMixin and walks each piece.
        if ok and warnEdit.SetBlendMode then
            pcall(warnEdit.SetBlendMode, warnEdit, "ADD")
        end
    else
        local tint = warnEdit:CreateTexture(nil, "BACKGROUND")
        tint:SetAllPoints()
        tint:SetColorTexture(0.2, 0.4, 1, 0.25)
    end

    warnEdit:SetScript("OnDragStart", function()
        warnFrame:SetMovable(true)
        warnFrame:StartMoving()
    end)
    warnEdit:SetScript("OnDragStop", function()
        warnFrame:StopMovingOrSizing()
        warnFrame:SetMovable(false)
        SaveTextPosition()
    end)
end

local function ApplyWarnStyle()
    EnsureWarnFrame()
    local path = LSM and RUI.Media.GetFontPath(LSM, db.textFont) or "Fonts\\FRIZQT__.TTF"
    -- "" is a valid flags string (no decoration); only nil needs defaulting.
    local flags = db.textOutline or "THICKOUTLINE"
    if not warnText:SetFont(path, TEXT_SIZE, flags) then
        warnText:SetFont("Fonts\\FRIZQT__.TTF", TEXT_SIZE, flags)
    end
    local c = db.textColor or { r = 1, g = 0.1, b = 0.1 }
    warnText:SetTextColor(c.r, c.g, c.b)
    -- Never render empty in edit mode -- an invisible frame cannot be dragged.
    local msg = db.text
    if msg == nil or msg == "" then
        msg = textEditMode and "Too Close!!!" or ""
    end
    warnText:SetText(msg)
end

function ShowWarnText(show)
    -- Edit mode pins the frame visible; the alarm must not hide it mid-drag.
    if textEditMode then return end

    if show and not db.textEnabled then show = false end
    if not warnFrame then
        if not show then return end   -- never built one; nothing to hide
        ApplyWarnStyle()
    end
    if show then
        if not warnFrame:IsShown() then
            ApplyWarnStyle()
            warnFrame:Show()
        end
    elseif warnFrame:IsShown() then
        warnFrame:Hide()
    end
end

-- Turns the blue-haze drag overlay on or off. Called from the options dialog
-- (while it is open) and from Blizzard's Edit Mode.
local function SetTextEditMode(on)
    on = on and db.textEnabled and true or false
    if on == textEditMode then return end
    textEditMode = on

    if on then
        ApplyWarnStyle()
        ApplyTextPosition()
        warnFrame:SetFrameStrata(STRATA_EDIT)
        warnFrame:Show()
        warnEdit:Show()
        Log("text edit mode ON")
    else
        if warnEdit then warnEdit:Hide() end
        if warnFrame then
            warnFrame:SetFrameStrata(STRATA_ALARM)
            -- Leave it up only if the alarm itself still wants it on screen.
            warnFrame:Hide()
        end
        Log("text edit mode OFF")
    end
end

local function FireAlert()
    Log(string.format("ALERT both bosses in range (%s / %s)",
        UnitName(UNIT_A) or UNIT_A, UnitName(UNIT_B) or UNIT_B))
    if db.soundEnabled and LSM then
        RUI.Media.PlaySound(LSM, db.sound)
    end
end

-- true = in range, false = out of range, nil = indeterminate (unit absent, or
-- item data not cached). Only an explicit true counts toward the pair.
local function UnitRangeState(itemID, unit)
    if not UnitExists(unit) then return nil end
    return IsItemInRange(itemID, unit)
end

local function CheckRange()
    local itemID = db.rangeItemID
    local now    = GetTime()

    local a = UnitRangeState(itemID, UNIT_A)
    local b = UnitRangeState(itemID, UNIT_B)

    local both = (a == true) and (b == true)

    -- Log only the transitions. The alarm itself repeats several times a second's
    -- worth of polls, so logging every suppressed poll would flood the buffer.
    if both ~= bothInRange then
        bothInRange = both
        Log(string.format("pair -> %s (%s=%s %s=%s)",
            tostring(both), UNIT_A, tostring(a), UNIT_B, tostring(b)))
    end

    -- Safety valve -- see MAX_PAUSE.
    if paused and pausedAt and (now - pausedAt) >= MAX_PAUSE then
        ResumeAlarm(string.format("max pause %ds elapsed", MAX_PAUSE))
    end

    -- Every early return below is a "not warning right now" state, so the text is
    -- cleared on each of them rather than only when the pair breaks.
    if not both or paused then
        ShowWarnText(false)
        return
    end

    -- Post-intermission grace: the golems are still overlapping right after they
    -- separate, so hold alerts until they have had time to spread apart.
    if graceUntil then
        if now < graceUntil then
            ShowWarnText(false)
            return
        end
        Log("post-intermission grace expired")
        graceUntil = nil
    end

    -- Text is level-driven, so it goes up as soon as the condition is real --
    -- ahead of the repeat-interval gate, which only paces the sound.
    ShowWarnText(true)

    -- The repeat interval is the whole debounce. lastFire is not reset when the
    -- pair breaks, so flickering across the range boundary cannot bypass it.
    if lastFire and (now - lastFire) < db.repeatInterval then return end

    lastFire = now
    FireAlert()
end

local function Arm(reason)
    if armed then return end
    armed = true
    ResetTracking()
    ticker = C_Timer.NewTicker(db.interval, CheckRange)
    if StartStasisWatch then StartStasisWatch() end
    Log(string.format("ARMED (%s) — item %d, poll %.2fs, repeat %.1fs",
        reason, db.rangeItemID, db.interval, db.repeatInterval))
end

local function Disarm(reason)
    if not armed then return end
    armed = false
    if ticker then
        ticker:Cancel()
        ticker = nil
    end
    if StopStasisWatch then StopStasisWatch() end
    ShowWarnText(false)
    ResetTracking()
    Log("DISARMED (" .. reason .. ")")
end

-- Re-arm with fresh settings if the ticker interval changed while running.
local function Rearm()
    if armed then
        Disarm("settings changed")
        Arm("settings changed")
    end
end

-- ── Item data caching ────────────────────────────────────────────────────────
--
-- IsItemInRange returns nil until the client has the item's data. Ask for it at
-- login so the very first pull of the night is not silently skipped.

local itemReady = false

local function RequestItemData()
    local id = db.rangeItemID
    if GetItemInfo(id) then
        itemReady = true
        Log(string.format("item %d data already cached", id))
        return
    end
    itemReady = false
    Log(string.format("item %d data not cached — requesting", id))
    if C_Item.RequestLoadItemDataByID then
        C_Item.RequestLoadItemDataByID(id)
    end
end

-- ── Options ──────────────────────────────────────────────────────────────────

-- Greys a control and its label together and blocks interaction. Fading only the
-- control while its label stays bright reads as a rendering glitch, which is why
-- the RUI widgets hand back their label as a third return value.
local function SetRowEnabled(ctrl, lbl, enabled)
    if not ctrl then return end
    local alpha = enabled and 1 or 0.35
    ctrl:SetAlpha(alpha)
    if lbl then lbl:SetAlpha(alpha) end
    if ctrl.SetEnabled then ctrl:SetEnabled(enabled) end
    if ctrl.EnableMouse then ctrl:EnableMouse(enabled) end
    if not enabled and ctrl.ClearFocus then ctrl:ClearFocus() end
end

local optionsCategory
local configDialog
local logFrame

local function BuildLogFrame()
    local LOG_W, LOG_H = 620, 420
    local PAD = RUI.PAD
    local BTN = RUI.BTN_H
    local GAP = RUI.CONTROL_GAP
    local scW = LOG_W - PAD * 2 - 18

    local dlg = RUI.Dialog("EntombedSentinelsAlarmLogFrame", "Entombed Sentinels Alarm — Debug Log", LOG_W, LOG_H)
    tinsert(UISpecialFrames, "EntombedSentinelsAlarmLogFrame")

    local C = dlg.content

    local sf = CreateFrame("ScrollFrame", nil, C, "UIPanelScrollFrameTemplate")
    sf:SetPoint("TOPLEFT",     C, "TOPLEFT",     PAD,         -PAD)
    sf:SetPoint("BOTTOMRIGHT", C, "BOTTOMRIGHT", -(PAD + 16), PAD + BTN + GAP + 1)

    local sc = CreateFrame("Frame", nil, sf)
    sc:SetWidth(scW)
    sc:SetHeight(60)
    sf:SetScrollChild(sc)

    -- EditBox rather than FontString so Ctrl+A / Ctrl+C works. OnChar is swallowed
    -- to make it read-only; height is set explicitly so the outer ScrollFrame pans.
    local eb = CreateFrame("EditBox", nil, sc)
    eb:SetMultiLine(true)
    eb:SetAutoFocus(false)
    eb:SetFontObject(GameFontNormalSmall)
    eb:SetPoint("TOPLEFT", sc, "TOPLEFT", 0, 0)
    eb:SetWidth(scW)
    eb:SetTextColor(RUI.C.TEXT.r, RUI.C.TEXT.g, RUI.C.TEXT.b)
    eb:SetScript("OnChar",          function() end)
    eb:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)

    local botDiv = C:CreateTexture(nil, "ARTWORK")
    botDiv:SetPoint("BOTTOMLEFT",  C, "BOTTOMLEFT",  PAD,  PAD + BTN + GAP)
    botDiv:SetPoint("BOTTOMRIGHT", C, "BOTTOMRIGHT", -PAD, PAD + BTN + GAP)
    botDiv:SetHeight(1)
    botDiv:SetColorTexture(RUI.C.BORDER.r, RUI.C.BORDER.g, RUI.C.BORDER.b, 0.5)

    local function SetBody(text, lineCount)
        eb:SetText(text)
        local _, fontH = eb:GetFont()
        fontH = fontH or 12
        sc:SetHeight(math.max(60, lineCount * (fontH + 2) + 10))
    end

    local closeBtn = RUI.Widget.Button(C, "Close", 100, BTN, function() dlg:Hide() end)
    closeBtn:SetPoint("BOTTOMRIGHT", C, "BOTTOMRIGHT", -PAD, PAD)

    local clearBtn = RUI.Widget.Button(C, "Clear", 100, BTN, function()
        wipe(logBuffer)
        SetBody(C_GRAY .. "(Log cleared.)" .. C_RESET, 1)
    end)
    clearBtn:SetPoint("BOTTOMLEFT", C, "BOTTOMLEFT", PAD, PAD)

    dlg:SetScript("OnShow", function()
        if #logBuffer == 0 then
            local hint = debugEnabled
                and "(No entries yet — reproduce the issue, then open /esa log again.)"
                or  "(Debug is OFF — /esa debug to enable, then reproduce the issue.)"
            SetBody(C_GRAY .. hint .. C_RESET, 1)
        else
            SetBody(table.concat(logBuffer, "\n"), #logBuffer)
        end
        C_Timer.After(0, function()
            sf:UpdateScrollChildRect()
            sf:SetVerticalScroll(sf:GetVerticalScrollRange())
        end)
    end)

    return dlg
end

local function OpenLogFrame()
    if not logFrame then logFrame = BuildLogFrame() end
    logFrame:Show()
    logFrame:Raise()
end

local function BuildConfigDialog()
    local DLG_W, DLG_H = 500, 560
    local dlg = RUI.Dialog("EntombedSentinelsAlarmConfig", "Entombed Sentinels Alarm", DLG_W, DLG_H)
    tinsert(UISpecialFrames, "EntombedSentinelsAlarmConfig")

    -- The panel is taller than any sensible window once every section is open, so
    -- the content scrolls rather than the dialog growing off-screen.
    local sf = RUI.ScrollFrame(dlg.content, DLG_W - RUI.PAD * 2, DLG_H - 30 - RUI.PAD * 2)
    sf.scroll:SetPoint("TOPLEFT", dlg.content, "TOPLEFT", RUI.PAD, -RUI.PAD)

    local b = RUI:Builder(sf.child, LSM)

    -- Content width inside a section box: child width, less the box margin and
    -- the widget indent on both sides.
    local ABOUT_W = (DLG_W - RUI.PAD * 2) - 20 - (RUI.PAD + 8) - 16
    -- Same, for hint text sitting at the panel's own margin rather than in a box.
    local HINT_W  = (DLG_W - RUI.PAD * 2) - 20 - RUI.PAD * 2

    b:BeginSection("About")

    local about = RUI.Widget.Label(sf.child,
        "Warns you via text and/or audio alerts if you are within 40 yards of "
        .. "both Sentinels at the same time. You can configure how often the "
        .. "check happens to reduce CPU load, but the longer the interval, the "
        .. "less precise the check will be.", "small")
    about:SetPoint("TOPLEFT", sf.child, "TOPLEFT", RUI.PAD + 8, b:GetY())
    about:SetWidth(ABOUT_W)
    about:SetJustifyH("LEFT")
    about:SetJustifyV("TOP")
    about:SetWordWrap(true)
    -- Advance the cursor by the measured wrapped height rather than a guessed
    -- constant, so editing the copy above never needs a matching magic number.
    -- (Same call DF makes in GUI/Popup.lua to size an alert to its message.)
    -- The floor is a guard for the case where the string has not laid out yet
    -- and reports a single line.
    b:SetY(b:GetY() - math.max(math.ceil(about:GetStringHeight()), 32) - RUI.CONTROL_GAP)

    b:EndSection()

    b:Header("Alert")

    b:MakeCheckbox("Enabled",
        function() return db.enabled end,
        function(v)
            db.enabled = v
            if not v then Disarm("disabled in options") end
        end)

    -- ── Sound ────────────────────────────────────────────────────────────────
    -- Declared before the checkbox that gates them so the toggle can grey them.
    local soundDD, soundLbl

    local function RefreshSoundEnabled()
        SetRowEnabled(soundDD, soundLbl, db.soundEnabled)
    end

    b:BeginSection("Sound")

    b:MakeCheckbox("Enable sound",
        function() return db.soundEnabled end,
        function(v) db.soundEnabled = v; RefreshSoundEnabled() end)

    soundDD, _, soundLbl = b:MakeSoundDropdown("Alert Sound", RUI.PAD + 8, RUI.DD_W,
        function() return db.sound end,
        function(v) db.sound = v end,
        "EntombedSentinelsAlarmSoundDD")

    b:EndSection()
    RefreshSoundEnabled()

    -- ── Text ─────────────────────────────────────────────────────────────────
    local textInput, textLbl, fontDD, fontLbl, outlineDD, outlineLbl, colorBtn

    local function RefreshTextEnabled()
        SetRowEnabled(textInput, textLbl, db.textEnabled)
        SetRowEnabled(fontDD,    fontLbl,    db.textEnabled)
        SetRowEnabled(outlineDD, outlineLbl, db.textEnabled)
        -- ColorButton parents its own label, so fading the button covers both.
        SetRowEnabled(colorBtn,  nil,     db.textEnabled)
    end

    b:BeginSection("Text")

    b:MakeCheckbox("Enable on-screen text",
        function() return db.textEnabled end,
        function(v)
            db.textEnabled = v
            if not v then ShowWarnText(false) end
            RefreshTextEnabled()
            -- Reflect the toggle immediately: the preview appears or vanishes
            -- while the dialog stays open.
            SetTextEditMode(dlg:IsShown() and v)
        end)

    textInput, _, textLbl = b:MakeTextInput("Warning Text", RUI.PAD + 8, RUI.DD_W,
        function() return db.text end,
        function(v)
            db.text = (v ~= nil and v ~= "") and v or "Too Close!!!"
            ApplyWarnStyle()
        end)

    fontDD, _, fontLbl = b:MakeFontDropdown("Font Face", RUI.PAD + 8, RUI.DD_W,
        function() return db.textFont end,
        function(v) db.textFont = v; ApplyWarnStyle() end,
        nil,
        "EntombedSentinelsAlarmFontDD")

    outlineDD, _, outlineLbl = b:MakeDropdown("Font Outline", RUI.PAD + 8, RUI.DD_W,
        function() return OUTLINE_ENTRIES end,
        function() return db.textOutline or "THICKOUTLINE" end,
        function(v) db.textOutline = v; ApplyWarnStyle() end,
        nil,
        "EntombedSentinelsAlarmOutlineDD")

    colorBtn = b:MakeColorButton("Text Color", RUI.PAD + 8, RUI.DD_W,
        function()
            local c = db.textColor
            return c.r, c.g, c.b
        end,
        function(r, g, b2)
            db.textColor = { r = r, g = g, b = b2 }
            ApplyWarnStyle()
        end)

    b:EndSection()
    RefreshTextEnabled()

    b:Header("Timing")

    b:MakeSlider("Check Interval (seconds)", RUI.PAD, RUI.SLD_W, 0.1, 1.0, 0.05,
        function() return db.interval end,
        function(v) db.interval = v; Rearm() end)

    b:MakeSlider("Repeat Interval (seconds)", RUI.PAD, RUI.SLD_W, 0.5, 10, 0.5,
        function() return db.repeatInterval end,
        function(v) db.repeatInterval = v end)

    local repHint = RUI.Widget.Label(sf.child,
        "How often the alarm repeats while both bosses are in range.", "small")
    repHint:SetPoint("TOPLEFT", sf.child, "TOPLEFT", RUI.PAD, b:GetY())
    b:Spacer(20)

    b:Header("Vitriolic Stasis Pause")

    local stasisHint = RUI.Widget.Label(sf.child,
        "Vitriolic Stasis is when the Sentinels collide and become immune to "
        .. "damage and you play the math game. Briefly at the end of this phase, "
        .. "you will be in range of both Sentinels. Use the below delay to allow "
        .. "time for you and your tanks to adjust positioning without being "
        .. "warned.", "small")
    stasisHint:SetPoint("TOPLEFT", sf.child, "TOPLEFT", RUI.PAD, b:GetY())
    stasisHint:SetWidth(HINT_W)
    stasisHint:SetJustifyH("LEFT")
    stasisHint:SetJustifyV("TOP")
    stasisHint:SetWordWrap(true)
    b:SetY(b:GetY() - math.max(math.ceil(stasisHint:GetStringHeight()), 32) - 10)

    b:MakeSlider("Alert Delay", RUI.PAD, RUI.SLD_W, 0, 15, 0.5,
        function() return db.postStasisDelay end,
        function(v) db.postStasisDelay = v end)

    b:Spacer(20)

    -- Height must be set after every widget is placed; b:GetY() is negative.
    sf.child:SetHeight(-b:GetY() + RUI.PAD)

    dlg:HookScript("OnShow", function()
        SetTextEditMode(true)
    end)
    dlg:HookScript("OnHide", function()
        -- Only drop out of edit mode if Blizzard's Edit Mode is not also holding
        -- it open, otherwise closing this dialog would cancel that session.
        if not (EditModeManagerFrame and EditModeManagerFrame:IsEditModeActive()) then
            SetTextEditMode(false)
        end
    end)

    return dlg
end

local function OpenConfig()
    if not configDialog then configDialog = BuildConfigDialog() end
    if SettingsPanel and SettingsPanel:IsShown() then
        HideUIPanel(SettingsPanel)
    end
    configDialog:Show()
    configDialog:Raise()
end

-- Blizzard settings stub — a single button that opens the RUI dialog. RUI widgets
-- are dark-themed and look wrong embedded directly in Blizzard's light canvas.
local function SetupOptions()
    local panel = CreateFrame("Frame")

    local btn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    btn:SetSize(200, 24)
    btn:SetPoint("TOPLEFT", panel, "TOPLEFT", 16, -16)
    btn:SetText("Open Configuration")
    btn:SetScript("OnClick", OpenConfig)

    optionsCategory = Settings.RegisterCanvasLayoutCategory(panel, "Entombed Sentinels Alarm")
    Settings.RegisterAddOnCategory(optionsCategory)
end

-- ── Slash commands ───────────────────────────────────────────────────────────

-- Reports whether the client will actually let us read the values this addon
-- depends on. Secrecy is a runtime property, so this can only be answered in
-- game -- run it once inside the encounter to confirm which signal path is live.
local function PrintSecrecy()
    local S = C_Secrets
    if not S then
        Print(C_RED .. "C_Secrets is unavailable on this build." .. C_RESET)
        return
    end

    local LEVEL = { [0] = C_GREEN .. "NeverSecret" .. C_RESET,
                    [1] = C_RED   .. "AlwaysSecret" .. C_RESET,
                    [2] = C_GRAY  .. "ContextuallySecret" .. C_RESET }
    local function fmt(v) return LEVEL[v] or ("unknown(" .. tostring(v) .. ")") end

    Print("secrecy report:")

    if S.GetSpellCastSecrecy then
        print("  Vitriolic Stasis (cast) 1284588: " .. fmt(S.GetSpellCastSecrecy(1284588)))
    end
    if S.GetSpellAuraSecrecy then
        print("  Vitriolic Stasis (aura) 1284588: " .. fmt(S.GetSpellAuraSecrecy(1284588)))
        print("  Helical Toxins   (aura) 1284590: " .. fmt(S.GetSpellAuraSecrecy(1284590)))
    end
    if S.GetPowerTypeSecrecy and Enum and Enum.PowerType then
        print("  Power: Energy = " .. fmt(S.GetPowerTypeSecrecy(Enum.PowerType.Energy)))
        print("  Power: Alternate = " .. fmt(S.GetPowerTypeSecrecy(Enum.PowerType.Alternate)))
        print("  Power: AlternateEncounter = " .. fmt(S.GetPowerTypeSecrecy(Enum.PowerType.AlternateEncounter)))
    end

    -- Live probe: only meaningful while the bosses actually exist.
    if UnitExists(UNIT_A) then
        local pt = UnitPowerType(UNIT_A)
        print("  " .. UNIT_A .. " power type: " .. tostring(pt))
        local pwr = UnitPower(UNIT_A)
        print("  " .. UNIT_A .. " UnitPower secret: " ..
            (IsSecret(pwr) and (C_RED .. "yes" .. C_RESET) or (C_GREEN .. "no, = " .. tostring(pwr) .. C_RESET)))
    else
        print("  " .. C_GRAY .. "(no boss units present - rerun during the encounter)" .. C_RESET)
    end
end

local function PrintHelp()
    Print("commands:")
    print("  " .. C_ACCENT .. "/esa config"  .. C_RESET .. " — open the settings window")
    print("  " .. C_ACCENT .. "/esa test"    .. C_RESET .. " — play the alert sound")
    print("  " .. C_ACCENT .. "/esa status"  .. C_RESET .. " — show current state")
    print(C_GRAY .. "  testing and diagnostics:" .. C_RESET)
    print("  " .. C_ACCENT .. "/esa arm"     .. C_RESET .. " — start checking now (any fight)")
    print("  " .. C_ACCENT .. "/esa disarm"  .. C_RESET .. " — stop checking")
    print("  " .. C_ACCENT .. "/esa pause"   .. C_RESET .. " — manually pause the alarm")
    print("  " .. C_ACCENT .. "/esa resume"  .. C_RESET .. " — manually resume the alarm")
    print("  " .. C_ACCENT .. "/esa debug"   .. C_RESET .. " — toggle debug logging")
    print("  " .. C_ACCENT .. "/esa log"     .. C_RESET .. " — open the debug log")
    print("  " .. C_ACCENT .. "/esa secrecy" .. C_RESET .. " — report what the client lets us read")
end

local function HandleSlash(msg)
    msg = string.lower(msg or "")
    msg = string.match(msg, "^%s*(.-)%s*$")

    if msg == "" or msg == "help" then
        PrintHelp()
    elseif msg == "config" or msg == "options" then
        OpenConfig()
    elseif msg == "test" then
        if LSM then
            RUI.Media.PlaySound(LSM, db.sound)
            Print("played " .. C_ACCENT .. tostring(db.sound) .. C_RESET)
        else
            Print(C_RED .. "LibSharedMedia-3.0 not available." .. C_RESET)
        end
    elseif msg == "status" then
        local name = GetItemInfo(db.rangeItemID)
        Print(string.format("enabled=%s  armed=%s  paused=%s  encounter=%d  item=%d (%s)  poll=%.2fs  repeat=%.1fs  postDelay=%.1fs",
            db.enabled and (C_GREEN .. "yes" .. C_RESET) or (C_RED .. "no" .. C_RESET),
            armed and (C_GREEN .. "yes" .. C_RESET) or (C_GRAY .. "no" .. C_RESET),
            paused and (C_RED .. "yes (" .. tostring(pauseReason) .. ")" .. C_RESET) or (C_GRAY .. "no" .. C_RESET),
            db.encounterID,
            db.rangeItemID,
            name and (C_GREEN .. name .. C_RESET) or (C_RED .. "not cached" .. C_RESET),
            db.interval,
            db.repeatInterval,
            db.postStasisDelay))
    elseif msg == "arm" then
        Arm("manual /esa arm")
        Print("Armed.")
    elseif msg == "disarm" then
        Disarm("manual /esa disarm")
        Print("Disarmed.")
    elseif msg == "debug" then
        debugEnabled = not debugEnabled
        if debugEnabled then
            Print("Debug logging " .. C_GREEN .. "ON" .. C_RESET .. ". Use /esa log to view.")
        else
            Print("Debug logging " .. C_GRAY .. "OFF" .. C_RESET .. ".")
        end
    elseif msg == "pause" then
        PauseAlarm("manual /esa pause")
        Print("Alarm paused.")
    elseif msg == "resume" then
        ResumeAlarm("manual /esa resume")
        Print("Alarm resumed.")
    elseif msg == "secrecy" then
        PrintSecrecy()
    elseif msg == "log" then
        OpenLogFrame()
    else
        PrintHelp()
    end
end

-- ── Stasis detection ─────────────────────────────────────────────────────────
--
-- Two independent signal sources, because neither is guaranteed to be readable:
--
--   1. UNIT_SPELLCAST_CHANNEL_START/STOP on boss1/boss2. The spellID is
--      SecretWhenUnitSpellCastRestricted, so for a boss unit it may come back as
--      a secret value that cannot be matched. When that happens the handler
--      falls back to treating ANY long boss channel as the stasis -- during this
--      fight the golems have nothing else to channel.
--   2. COMBAT_LOG_EVENT_UNFILTERED for Helical Toxins. Counting applications and
--      removals gives the literal "once those are all gone" condition, which is
--      the most faithful resume signal when the combat log is readable.
--
-- Whichever fires first wins; both are idempotent (PauseAlarm/ResumeAlarm early
-- out if already in that state).

local castFrame = CreateFrame("Frame")

local function OnBossChannelStart(spellID)
    local match = SpellInSet(STASIS_SPELLS, spellID)
    if match == true then
        PauseAlarm("Vitriolic Stasis channel")
    elseif match == nil then
        -- spellID unreadable (secret). Treat any boss channel as the stasis.
        PauseAlarm("boss channel (spellID not readable)")
    end
end

local function OnBossChannelStop(spellID)
    if not paused then return end

    local match = SpellInSet(STASIS_SPELLS, spellID)
    if match == true then
        ResumeAlarm("Vitriolic Stasis ended")
    elseif match == nil then
        ResumeAlarm("boss channel ended (spellID not readable)")
    end
end

local function OnCombatLog()
    if not armed then return end

    local _, subEvent, _, _, _, _, _, _, _, _, _, spellID = CombatLogGetCurrentEventInfo()
    if IsSecret(subEvent) or IsSecret(spellID) then return end

    if SpellInSet(STASIS_SPELLS, spellID) then
        if subEvent == "SPELL_CAST_START" or subEvent == "SPELL_AURA_APPLIED" then
            PauseAlarm("Vitriolic Stasis (combat log)")
        elseif subEvent == "SPELL_AURA_REMOVED" or subEvent == "SPELL_CAST_SUCCESS" then
            ResumeAlarm("Vitriolic Stasis ended (combat log)")
        end
        return
    end

    -- Helical Toxins: the raid is done when every application has been removed.
    if SpellInSet(TOXIN_SPELLS, spellID) then
        if subEvent == "SPELL_AURA_APPLIED" then
            toxinCount = toxinCount + 1
            Log("Helical Toxins applied (" .. toxinCount .. " active)")
        elseif subEvent == "SPELL_AURA_REMOVED" then
            toxinCount = math.max(0, toxinCount - 1)
            Log("Helical Toxins removed (" .. toxinCount .. " active)")
            if toxinCount == 0 then
                ResumeAlarm("all Helical Toxins cleared")
            end
        end
    end
end

castFrame:SetScript("OnEvent", function(_, event, ...)
    if event == "UNIT_SPELLCAST_CHANNEL_START" then
        local _, _, spellID = ...
        OnBossChannelStart(spellID)
    elseif event == "UNIT_SPELLCAST_CHANNEL_STOP" then
        local _, _, spellID = ...
        OnBossChannelStop(spellID)
    elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then
        OnCombatLog()
    end
end)

-- Registered only while armed: no combat-log parsing outside the encounter.
function StartStasisWatch()
    castFrame:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_START", UNIT_A, UNIT_B)
    castFrame:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_STOP",  UNIT_A, UNIT_B)
    castFrame:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
end

function StopStasisWatch()
    castFrame:UnregisterEvent("UNIT_SPELLCAST_CHANNEL_START")
    castFrame:UnregisterEvent("UNIT_SPELLCAST_CHANNEL_STOP")
    castFrame:UnregisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
end

-- ── Blizzard Edit Mode ───────────────────────────────────────────────────────
--
-- EDIT_MODE_ENTER / EDIT_MODE_EXIT were removed in Midnight, so the manager
-- frame's methods are hooked directly. Entering the game's own Edit Mode puts
-- the warning text into the same drag state the options dialog uses, so it can
-- be positioned alongside every other UI element.

local function SetupEditModeHooks()
    if not EditModeManagerFrame then return end

    hooksecurefunc(EditModeManagerFrame, "EnterEditMode", function()
        SetTextEditMode(true)
    end)
    hooksecurefunc(EditModeManagerFrame, "ExitEditMode", function()
        -- The options dialog may still be open and wanting the preview.
        SetTextEditMode(configDialog and configDialog:IsShown() or false)
    end)

    -- Already in Edit Mode when the addon loaded (/reload while it is open).
    if EditModeManagerFrame:IsEditModeActive() then
        SetTextEditMode(true)
    end
end

-- ── Events ───────────────────────────────────────────────────────────────────

local f = CreateFrame("Frame")
f:RegisterEvent("ADDON_LOADED")
f:RegisterEvent("PLAYER_LOGIN")
f:RegisterEvent("ENCOUNTER_START")
f:RegisterEvent("ENCOUNTER_END")
f:RegisterEvent("GET_ITEM_INFO_RECEIVED")
f:RegisterEvent("PLAYER_REGEN_ENABLED")

f:SetScript("OnEvent", function(_, event, ...)
    if event == "ADDON_LOADED" then
        local name = ...
        if name ~= ADDON_NAME then return end
        InitDB()
        if LSM then
            RUI.Media.RegisterBuiltinSounds(LSM)
            LSM.RegisterCallback(f, "LibSharedMedia_Registered", function(_, mediatype)
                if mediatype == "sound" then RUI.Media.InvalidateSoundCache() end
                if mediatype == "font"  then RUI.Media.InvalidateFontCache()  end
            end)
        end
        SetupOptions()
        SLASH_ENTOMBEDSENTINELSALARM1 = "/esa"
        SLASH_ENTOMBEDSENTINELSALARM2 = "/entombedsentinelsalarm"
        SlashCmdList["ENTOMBEDSENTINELSALARM"] = HandleSlash

    elseif event == "PLAYER_LOGIN" then
        RequestItemData()
        SetupEditModeHooks()

    elseif event == "GET_ITEM_INFO_RECEIVED" then
        local id = ...
        if db and id == db.rangeItemID then
            itemReady = GetItemInfo(id) ~= nil
            Log(string.format("item %d data received (ready=%s)", id, tostring(itemReady)))
        end

    elseif event == "ENCOUNTER_START" then
        local encounterID, encounterName = ...
        Log(string.format("ENCOUNTER_START id=%s name=%s", tostring(encounterID), tostring(encounterName)))
        if db.enabled and encounterID == db.encounterID then
            Arm("ENCOUNTER_START " .. tostring(encounterName))
        end

    elseif event == "ENCOUNTER_END" then
        local encounterID = ...
        Log("ENCOUNTER_END id=" .. tostring(encounterID))
        Disarm("ENCOUNTER_END")

    elseif event == "PLAYER_REGEN_ENABLED" then
        -- Safety net: a wipe or release can leave the ticker running if
        -- ENCOUNTER_END never arrives.
        if armed and not UnitExists(UNIT_A) then
            Disarm("combat ended, no boss units")
        end
    end
end)
