--- Castbars/MSUF_InterruptReady.lua
--- Optional interrupt-readiness indicator for target, focus, and boss castbars.
---
--- This module answers two questions: "is my interrupt cooldown ready?" and
--- "how should the indicator look for the current cast's interruptibility?" It
--- must not decide castbar ownership or spellcast state; it decorates frames
--- that the castbar drivers already own.

local _, MSUF = ...
MSUF = MSUF or _G.MSUF_NS or _G.MSUF or {}
local ExportPublic = MSUF.ExportPublic or function(name, value)
    _G[name] = value
    return value
end

local SpellAPI = _G.C_Spell
local TimerAPI = _G.C_Timer

local INTERRUPT_SPELLS = {
    DEATHKNIGHT = { DEFAULT = 47528 },
    DEMONHUNTER = { DEFAULT = 183752 },
    DRUID = { DEFAULT = 106839, BALANCE = 78675 },
    EVOKER = { DEFAULT = 351338 },
    HUNTER = { DEFAULT = 147362, SURVIVAL = 187707 },
    MAGE = { DEFAULT = 2139 },
    MONK = { DEFAULT = 116705 },
    PALADIN = { DEFAULT = 96231 },
    PRIEST = { DEFAULT = 15487 },
    ROGUE = { DEFAULT = 1766 },
    SHAMAN = { DEFAULT = 57994 },
    WARLOCK = { DEFAULT = 19647, DEMONOLOGY = 119914 },
    WARRIOR = { DEFAULT = 6552 },
}

local SPECIALIZATION_KEYS = {
    [102] = "BALANCE",
    [255] = "SURVIVAL",
    [266] = "DEMONOLOGY",
}

local state = {}
local cooldownTimerGeneration = 0
local cooldownTimerEndTime
local eventFrame
local cooldownEventRegistered = false
local activeIndicatorFrames = {}
local activeIndicatorFrameCount = 0
local fillActiveFrames = {}
local fillActiveFrameCount = 0
local refreshActiveFrames = {}
local UpdateCooldownEventRegistration

local function GeneralDB()
    if type(_G.MSUF_EnsureDB) == "function" then
        _G.MSUF_EnsureDB()
    elseif type(EnsureDB) == "function" then
        EnsureDB()
    end

    return (_G.MSUF_DB and _G.MSUF_DB.general) or {}
end

local plainIsSecret = _G.issecretvalue or function(_) return false end
local plainHuge = math.huge

local function PlainNumber(value)
    if value == nil then
        return nil
    end

    -- PERF fast path: a plain finite number needs no tostring/tonumber
    -- round-trip (that round-trip only exists to redact secrets and to map
    -- nan/inf to nil, which the guards below preserve exactly).
    if type(value) == "number" and plainIsSecret(value) ~= true
        and value == value and value ~= plainHuge and value ~= -plainHuge then
        return value
    end

    local toPlain = _G.ToPlain
    if type(toPlain) == "function" then
        local plain = tonumber(tostring(toPlain(value)))
        if plain ~= nil then
            return plain
        end
    end

    local valueType = type(value)
    if valueType == "number" or valueType == "string" then
        return tonumber(tostring(value))
    end

    return nil
end

local function Now()
    return (GetTimePreciseSec and GetTimePreciseSec()) or GetTime()
end

--- Resolve the player's current interrupt spell once per class/spec change.
--- Some classes swap interrupt IDs by specialization, so cache both class/spec
--- metadata with the selected spell.
local function ResolveInterruptSpellID()
    local classToken
    if UnitClass then
        local _, token = UnitClass("player")
        classToken = token
    end
    state.classToken = classToken

    local classSpells = classToken and INTERRUPT_SPELLS[classToken]
    local spellID = classSpells and classSpells.DEFAULT

    if classSpells and GetSpecialization and GetSpecializationInfo then
        local specID = select(1, GetSpecializationInfo(GetSpecialization()))
        local specKey = SPECIALIZATION_KEYS[specID]

        if specKey and classSpells[specKey] then
            spellID = classSpells[specKey]
        end

        state.specID = specID
    end

    state.spellID = spellID
    return spellID
end

local function InterruptCooldown()
    local spellID = state.spellID or ResolveInterruptSpellID()
    if not (spellID and SpellAPI and SpellAPI.GetSpellCooldownDuration) then
        return nil
    end

    return SpellAPI.GetSpellCooldownDuration(spellID)
end

local function CooldownRemaining(cooldown)
    if not cooldown then
        return nil
    end

    local remaining
    if cooldown.GetRemainingDuration then
        remaining = cooldown:GetRemainingDuration()
    elseif cooldown.GetRemaining then
        remaining = cooldown:GetRemaining()
    end

    return PlainNumber(remaining)
end

local function InterruptStatus()
    local remaining = CooldownRemaining(InterruptCooldown())
    if remaining == nil then
        return false, nil
    end

    return remaining <= 0.05, remaining
end

local function ResolveStatus(status)
    if type(status) == "table" then
        if not status.resolved then
            status.ready, status.remaining = InterruptStatus()
            status.resolved = true
        end

        return status.ready
    end

    local ready = InterruptStatus()
    return ready
end

local function InterruptReady()
    local ready = InterruptStatus()
    return ready
end

local function InterruptRemaining()
    return CooldownRemaining(InterruptCooldown())
end

local function InterruptReadyBoolForTint()
    local cooldown = InterruptCooldown()
    if cooldown and cooldown.IsZero then
        return cooldown:IsZero()
    end

    return InterruptReady()
end

local function ColorFromDB(general, key, defaultR, defaultG, defaultB)
    local color = general[key]
    if type(color) == "table" then
        defaultR = tonumber(color[1] or color["1"]) or defaultR
        defaultG = tonumber(color[2] or color["2"]) or defaultG
        defaultB = tonumber(color[3] or color["3"]) or defaultB
    end

    return defaultR, defaultG, defaultB, 1
end

--- Color objects are cached because indicator refreshes can happen from both
--- spellcast events and cooldown timers.
local function ReadyColors(general)
    general = general or GeneralDB()

    local readyR, readyG, readyB, readyA = ColorFromDB(general, "kickReadyColor", 0, 1, 0)
    local notReadyR, notReadyG, notReadyB, notReadyA = ColorFromDB(general, "kickNotReadyColor", 1, 0, 0)

    if state.readyR == readyR
        and state.readyG == readyG
        and state.readyB == readyB
        and state.readyA == readyA
        and state.notReadyR == notReadyR
        and state.notReadyG == notReadyG
        and state.notReadyB == notReadyB
        and state.notReadyA == notReadyA
        and state.readyColor
        and state.notReadyColor
    then
        return state.readyColor, state.notReadyColor
    end

    state.readyR, state.readyG, state.readyB, state.readyA = readyR, readyG, readyB, readyA
    state.notReadyR, state.notReadyG, state.notReadyB, state.notReadyA = notReadyR, notReadyG, notReadyB, notReadyA

    if _G.CreateColor then
        state.readyColor = _G.CreateColor(readyR, readyG, readyB, readyA)
        state.notReadyColor = _G.CreateColor(notReadyR, notReadyG, notReadyB, notReadyA)
        return state.readyColor, state.notReadyColor
    end

    state.readyColor = {
        GetRGBA = function()
            return readyR, readyG, readyB, readyA
        end,
    }
    state.notReadyColor = {
        GetRGBA = function()
            return notReadyR, notReadyG, notReadyB, notReadyA
        end,
    }

    return state.readyColor, state.notReadyColor
end

local function ColorForReady(isReady, general)
    local readyColor, notReadyColor = ReadyColors(general)
    return isReady and readyColor or notReadyColor
end

local function ShouldShow(general, unit)
    if unit == "target" then
        return general.kickReadyShowTarget == true
    end

    if unit == "focus" then
        return general.kickReadyShowFocus == true or general.enableFocusKickIcon == true
    end

    if unit == "boss" or (type(unit) == "string" and unit:match("^boss%d+$")) then
        return general.kickReadyShowBoss == true
    end

    return false
end

local function UnitSupportsFillStyle(general, unit)
    if unit == "target" then
        return general.kickReadyShowTarget == true
    end

    if unit == "focus" then
        return general.kickReadyShowFocus == true
    end

    if unit == "boss" or (type(unit) == "string" and unit:match("^boss%d+$")) then
        return general.kickReadyShowBoss == true
    end

    return false
end

local function IndicatorStyle(general)
    if general.kickReadyStyle == "fill" then
        return "fill"
    end

    return (general.kickReadyStyle == "border") and "border" or "box"
end

local function EnsureBox(frame)
    local box = frame.kickReadyBox
    if box then
        return box
    end

    box = CreateFrame("Frame", nil, frame)
    box.fill = box:CreateTexture(nil, "OVERLAY")
    box.fill:SetAllPoints()
    box.fill:SetTexture("Interface\\Buttons\\WHITE8x8")
    box:Hide()

    frame.kickReadyBox = box
    return box
end

local function ApplyBoxLayout(frame, general)
    general = general or GeneralDB()
    local box = EnsureBox(frame)
    local castbarHeight = frame.statusBar and frame.statusBar:GetHeight() or frame:GetHeight() or 16
    local boxSize = general.kickReadyAutoSize == false and tonumber(general.kickReadySize) or castbarHeight

    boxSize = math.max(8, math.min(boxSize or 16, 80))

    local anchor = general.kickReadyAnchor or "RIGHT"
    local offsetX = tonumber(general.kickReadyOffsetX) or 4
    local offsetY = tonumber(general.kickReadyOffsetY) or 0
    local relativePoint = anchor == "RIGHT" and "LEFT"
        or anchor == "LEFT" and "RIGHT"
        or anchor == "TOP" and "BOTTOM"
        or anchor == "BOTTOM" and "TOP"
        or anchor

    box:SetSize(boxSize, boxSize)
    box:ClearAllPoints()
    box:SetPoint(relativePoint, frame.statusBar or frame, anchor, offsetX, offsetY)
    return box
end

local function OutlineTextures(frame)
    local outline = frame and frame._msufOutline
    return outline and outline.top, outline and outline.bottom, outline and outline.left, outline and outline.right
end

local function TintOutline(frame, red, green, blue, alpha)
    local top, bottom, left, right = OutlineTextures(frame)
    if not top then
        return
    end

    top:SetVertexColor(red, green, blue, alpha)
    bottom:SetVertexColor(red, green, blue, alpha)
    left:SetVertexColor(red, green, blue, alpha)
    right:SetVertexColor(red, green, blue, alpha)
    frame._kickReadyBorderTinted = true
end

local function RestoreOutline(frame)
    if not (frame and frame._kickReadyBorderTinted) then
        return
    end

    frame._kickReadyBorderTinted = nil

    if type(_G.MSUF_ApplyCastbarOutline) == "function" then
        _G.MSUF_ApplyCastbarOutline(frame, true)
    end
end

local function HasKnownValue(value)
    local isSecretValue = _G.issecretvalue
    if type(isSecretValue) == "function" and isSecretValue(value) == true then
        return true
    end

    return value ~= nil
end

--- Raw interruptibility can be nil, false, true, or a wrapped/secret value
--- depending on which castbar path produced the state. Preserve "known false"
--- instead of collapsing it with "unknown".
local function ResolveRawNotInterruptible(frame, castState)
    if castState then
        local rawValue = castState.apiNotInterruptibleRaw
        if HasKnownValue(rawValue) then
            return rawValue
        end
    end

    if frame then
        local rawValue = frame._msufApiNotInterruptibleRaw
        if HasKnownValue(rawValue) then
            return rawValue
        end

        return frame.MSUF_apiNotInterruptibleRaw
    end

    return nil
end

local function NotInterruptibleColor()
    if not state.notInterruptibleColor and _G.CreateColor then
        state.notInterruptibleColor = _G.CreateColor(0.6, 0.6, 0.6, 1)
    end

    return state.notInterruptibleColor
end

local function MarkActiveIndicatorFrame(frame)
    if not frame or activeIndicatorFrames[frame] then return end
    activeIndicatorFrames[frame] = true
    activeIndicatorFrameCount = activeIndicatorFrameCount + 1
    if UpdateCooldownEventRegistration then
        UpdateCooldownEventRegistration()
    end
end

local function MarkInactiveIndicatorFrame(frame)
    if not frame or not activeIndicatorFrames[frame] then return end
    activeIndicatorFrames[frame] = nil
    activeIndicatorFrameCount = activeIndicatorFrameCount - 1
    if activeIndicatorFrameCount < 0 then activeIndicatorFrameCount = 0 end
    if UpdateCooldownEventRegistration then
        UpdateCooldownEventRegistration()
    end
end

local function MarkActiveFillFrame(frame)
    if not frame or fillActiveFrames[frame] then return end
    fillActiveFrames[frame] = true
    fillActiveFrameCount = fillActiveFrameCount + 1
    if UpdateCooldownEventRegistration then
        UpdateCooldownEventRegistration()
    end
end

local function MarkInactiveFillFrame(frame)
    if not frame or not fillActiveFrames[frame] then return end
    fillActiveFrames[frame] = nil
    fillActiveFrameCount = fillActiveFrameCount - 1
    if fillActiveFrameCount < 0 then fillActiveFrameCount = 0 end
    if UpdateCooldownEventRegistration then
        UpdateCooldownEventRegistration()
    end
end

local function EvaluateIndicatorRGBA(isReady, rawNotInterruptible, general)
    local color = ColorForReady(isReady, general)
    local rawSecret = plainIsSecret(rawNotInterruptible) == true

    if not rawSecret and rawNotInterruptible == true then
        color = NotInterruptibleColor()
    elseif (rawSecret or rawNotInterruptible ~= false)
        and HasKnownValue(rawNotInterruptible)
        and _G.CreateColor
        and _G.C_CurveUtil
        and _G.C_CurveUtil.EvaluateColorFromBoolean
    then
        color = _G.C_CurveUtil.EvaluateColorFromBoolean(rawNotInterruptible, NotInterruptibleColor(), color)
    end

    if color and color.GetRGBA then
        return color:GetRGBA()
    end

    return isReady and 0 or 1, isReady and 1 or 0, 0, 1
end

local function RawInterruptibleKey(value)
    if value == nil then
        return ""
    end
    if plainIsSecret(value) == true then
        return nil
    end
    if value == true then
        return "1"
    end
    if value == false then
        return "0"
    end
    return tostring(value)
end

local function HideIndicatorVisual(frame)
    if not frame then
        return
    end

    frame._msufKickReadyVisualKey = nil

    if frame.kickReadyBox then
        frame.kickReadyBox:Hide()
        frame.kickReadyBox._kickReadyShown = nil
    end

    RestoreOutline(frame)
end

local function HideIndicator(frame)
    MarkInactiveIndicatorFrame(frame)
    MarkInactiveFillFrame(frame)
    HideIndicatorVisual(frame)
end

--- Per-frame decorator entry. It never starts or stops casts; it only shows,
--- hides, or recolors the indicator on frames that are already active.
local function RefreshFrame(frame, castState, status, general, updateFillColor)
    if not frame then
        return
    end

    if not frame.statusBar then
        HideIndicator(frame)
        return
    end

    general = general or GeneralDB()

    local castStateTable = type(castState) == "table" and castState or nil
    local active = frame.MSUF_castActive or (castStateTable and castStateTable.active)
    local style = IndicatorStyle(general)

    if style == "fill" then
        MarkInactiveIndicatorFrame(frame)
        HideIndicatorVisual(frame)

        if not UnitSupportsFillStyle(general, frame.unit) or not active then
            MarkInactiveFillFrame(frame)
            return
        end

        if frame.isNotInterruptible == true
            or frame.MSUF_kickInterruptibleConfirmed == false
            or (castStateTable and castStateTable.isNotInterruptible == true)
        then
            MarkInactiveFillFrame(frame)
            return
        end

        MarkActiveFillFrame(frame)
        if updateFillColor == true and frame.UpdateColorForInterruptible then
            frame:UpdateColorForInterruptible()
        end
        return
    end

    MarkInactiveFillFrame(frame)
    if not ShouldShow(general, frame.unit) or not active then
        HideIndicator(frame)
        return
    end

    if frame.isNotInterruptible == true
        or frame.MSUF_kickInterruptibleConfirmed == false
        or (castStateTable and castStateTable.isNotInterruptible == true)
    then
        HideIndicator(frame)
        return
    end

    MarkActiveIndicatorFrame(frame)

    local isReady = ResolveStatus(status)
    local rawNotInterruptible = ResolveRawNotInterruptible(frame, castStateTable)
    local red, green, blue, alpha = EvaluateIndicatorRGBA(isReady, rawNotInterruptible, general)
    local rawKey = RawInterruptibleKey(rawNotInterruptible)
    local visualKey
    if rawKey ~= nil then
        visualKey = style .. "|"
            .. (isReady and "1" or "0") .. "|"
            .. rawKey .. "|"
            .. tostring(red) .. "|"
            .. tostring(green) .. "|"
            .. tostring(blue) .. "|"
            .. tostring(alpha)
        if frame._msufKickReadyVisualKey == visualKey then
            return
        end
    end

    if style == "border" then
        if frame.kickReadyBox then
            frame.kickReadyBox:Hide()
            frame.kickReadyBox._kickReadyShown = nil
        end

        TintOutline(frame, red, green, blue, alpha)
        frame._msufKickReadyVisualKey = visualKey
        return
    end

    RestoreOutline(frame)

    local box = ApplyBoxLayout(frame, general)
    box.fill:SetVertexColor(red, green, blue, alpha)

    if HasKnownValue(rawNotInterruptible) and box.SetAlphaFromBoolean then
        box:SetAlphaFromBoolean(rawNotInterruptible, 0, 1)
    else
        box:SetAlpha(1)
    end

    box:Show()
    box._kickReadyShown = true
    frame._msufKickReadyVisualKey = visualKey
end

local function ForEachCastbar(callback)
    callback(_G.MSUF_TargetCastbar or _G.MSUF_TargetCastBar
        or ((_G.TargetCastBar and _G.TargetCastBar._msufCastbarDriver == true) and _G.TargetCastBar))
    callback(_G.MSUF_FocusCastbar or _G.MSUF_FocusCastBar
        or ((_G.FocusCastBar and _G.FocusCastBar._msufCastbarDriver == true) and _G.FocusCastBar))

    local bossCastbars = _G.MSUF_BossCastbars
    if type(bossCastbars) == "table" then
        for index = 1, #bossCastbars do
            callback(bossCastbars[index])
        end
    end
end

--- PERF: RefreshAll/RefreshActive/KickReady_RefreshFrame run per cooldown or
--- castbar event; a fresh status table per call is steady combat garbage. The
--- shared scratch is safe because the status never escapes these calls and the
--- three entry points never nest into each other.
local scratchStatus = {}
local function AcquireScratchStatus()
    scratchStatus.resolved = nil
    scratchStatus.ready = nil
    scratchStatus.remaining = nil
    return scratchStatus
end

local function RefreshAll(updateFillColor)
    local status = AcquireScratchStatus()
    local general = GeneralDB()

    ForEachCastbar(function(frame)
        RefreshFrame(frame, nil, status, general, updateFillColor)
    end)

    if not status.resolved and fillActiveFrameCount > 0 then
        status.remaining = InterruptRemaining()
        status.resolved = status.remaining ~= nil
    end

    if status.resolved == true then
        if status.ready ~= nil then
            state.cooldownDisplayReady = status.ready == true
        else
            state.cooldownDisplayReady = status.remaining ~= nil and status.remaining <= 0.05
        end
    end

    return status.remaining, status.resolved == true
end

local function QueueActiveRefreshFrame(frame)
    if not frame or frame._msufKickReadyQueuedRefresh == true then
        return
    end
    frame._msufKickReadyQueuedRefresh = true
    refreshActiveFrames[#refreshActiveFrames + 1] = frame
end

local function QueueActiveRefreshFrames(frames)
    for frame in pairs(frames) do
        QueueActiveRefreshFrame(frame)
    end
end

local function RefreshActive(updateFillColor)
    local status = AcquireScratchStatus()
    local general = GeneralDB()

    QueueActiveRefreshFrames(activeIndicatorFrames)
    QueueActiveRefreshFrames(fillActiveFrames)

    for index = 1, #refreshActiveFrames do
        local frame = refreshActiveFrames[index]
        refreshActiveFrames[index] = nil
        if frame then
            frame._msufKickReadyQueuedRefresh = nil
            RefreshFrame(frame, nil, status, general, updateFillColor)
        end
    end

    if not status.resolved and fillActiveFrameCount > 0 then
        status.remaining = InterruptRemaining()
        status.resolved = status.remaining ~= nil
    end

    if status.resolved == true then
        if status.ready ~= nil then
            state.cooldownDisplayReady = status.ready == true
        else
            state.cooldownDisplayReady = status.remaining ~= nil and status.remaining <= 0.05
        end
    end

    return status.remaining, status.resolved == true
end

--- When the interrupt is on cooldown, schedule one refresh near the expected
--- ready time instead of polling every frame.
local function ScheduleCooldownRefresh(remaining, remainingResolved)
    if cooldownTimerEndTime then
        if remaining and remaining > 0.05 then
            local delay = math.min(remaining + 0.05, 90)
            local fireAt = Now() + delay
            local drift = fireAt - cooldownTimerEndTime
            if drift < 0 then
                drift = -drift
            end

            if drift <= 0.10 then
                return
            end
        end
        cooldownTimerGeneration = cooldownTimerGeneration + 1
    end

    cooldownTimerEndTime = nil

    if remaining == nil and not remainingResolved then
        remaining = InterruptRemaining()
    end

    if not (remaining and remaining > 0.05) then
        return
    end

    cooldownTimerGeneration = cooldownTimerGeneration + 1
    local generation = cooldownTimerGeneration
    local delay = math.min(remaining + 0.05, 90)
    cooldownTimerEndTime = Now() + delay

    TimerAPI.After(delay, function()
        if generation == cooldownTimerGeneration then
            cooldownTimerEndTime = nil
            local remaining, resolved = RefreshAll(true)
            if resolved then
                ScheduleCooldownRefresh(remaining, true)
            end
        end
    end)
end

local function KickReady_Init()
    ResolveInterruptSpellID()
    return state.spellID
end

local function KickReady_IsReady()
    local ready, remaining = InterruptStatus()
    ScheduleCooldownRefresh(remaining, true)
    return ready
end

local function KickReady_GetSpellID()
    return state.spellID or ResolveInterruptSpellID()
end

local function KickReady_GetReadyBoolForTint()
    return InterruptReadyBoolForTint()
end

local function KickReady_EvaluateColor(ready)
    return ColorForReady(ready)
end

local function KickReady_ApplyLayout(frame)
    local general = GeneralDB()
    if IndicatorStyle(general) == "fill" then
        HideIndicatorVisual(frame)
        return
    end

    if frame and ShouldShow(general, frame.unit) then
        ApplyBoxLayout(frame, general)
    end
end

local function KickReady_RefreshFrame(frame, castState)
    local status = AcquireScratchStatus()
    RefreshFrame(frame, castState, status)
    if status.resolved then
        ScheduleCooldownRefresh(status.remaining, true)
    elseif frame and fillActiveFrames[frame] then
        ScheduleCooldownRefresh(InterruptRemaining(), true)
    end
end

local function KickReady_RefreshAll()
    ResolveInterruptSpellID()
    local remaining, resolved = RefreshAll(true)
    if resolved then
        ScheduleCooldownRefresh(remaining, true)
    end
    if UpdateCooldownEventRegistration then
        UpdateCooldownEventRegistration()
    end
    return remaining, resolved
end

local function CooldownEventAlreadyDisplayed()
    local remaining = InterruptRemaining()
    local ready
    if remaining ~= nil then
        ready = remaining <= 0.05
    else
        -- Secret-cooldown fallback: the numeric remaining is unreadable, but
        -- IsZero can still yield a plain boolean readiness. Without this the
        -- guard exits before recording state and every SPELL_UPDATE_COOLDOWN
        -- (i.e. every player ability press) runs a full refresh pass.
        local cooldown = InterruptCooldown()
        if cooldown and cooldown.IsZero then
            local zero = cooldown:IsZero()
            if zero ~= nil and plainIsSecret(zero) ~= true then
                ready = zero == true
            end
        end
    end
    if ready == nil then
        return false
    end

    if state.cooldownDisplayReady ~= ready then
        -- Record the ready state we are about to display before running the
        -- refresh. RefreshActive/RefreshAll only resolve a status when some
        -- indicator actually paints; without this write the stored state goes
        -- stale whenever no cast is active.
        state.cooldownDisplayReady = ready
        return false
    end

    if not ready then
        ScheduleCooldownRefresh(remaining, true)
    end
    return true
end

ExportPublic("MSUF_KickReady_Init", KickReady_Init)
ExportPublic("MSUF_KickReady_IsReady", KickReady_IsReady)
ExportPublic("MSUF_KickReady_GetSpellID", KickReady_GetSpellID)
ExportPublic("MSUF_KickReady_GetReadyBoolForTint", KickReady_GetReadyBoolForTint)
ExportPublic("MSUF_KickReady_EvaluateColor", KickReady_EvaluateColor)
ExportPublic("MSUF_KickReady_ApplyLayout", KickReady_ApplyLayout)
ExportPublic("MSUF_KickReady_RefreshFrame", KickReady_RefreshFrame)
ExportPublic("MSUF_KickReady_RefreshAll", KickReady_RefreshAll)

UpdateCooldownEventRegistration = function()
    if not eventFrame then
        return
    end

    local shouldRegister = activeIndicatorFrameCount > 0 or fillActiveFrameCount > 0
    if shouldRegister and not cooldownEventRegistered then
        eventFrame:RegisterEvent("SPELL_UPDATE_COOLDOWN")
        cooldownEventRegistered = true
    elseif not shouldRegister and cooldownEventRegistered then
        eventFrame:UnregisterEvent("SPELL_UPDATE_COOLDOWN")
        cooldownEventRegistered = false
    end
end

eventFrame = CreateFrame("Frame", "MSUF_InterruptReady_EventFrame")
eventFrame:RegisterEvent("PLAYER_LOGIN")
eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
eventFrame:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED")
eventFrame:SetScript("OnEvent", function(_, event)
    if event ~= "SPELL_UPDATE_COOLDOWN" then
        ResolveInterruptSpellID()
    elseif CooldownEventAlreadyDisplayed() then
        UpdateCooldownEventRegistration()
        return
    else
        -- PERF: one ability press fires SPELL_UPDATE_COOLDOWN several times in
        -- the same frame, and with secret cooldowns the ready-state guard above
        -- cannot dedupe. Cooldown state is constant within a frame (WoW settles
        -- game state before Lua events fire), so refreshing once per frame is
        -- lossless. Cast-driven repaints bypass this handler entirely.
        -- GetTime (not GetTimePreciseSec) is frame-constant by design.
        local now = _G.GetTime and _G.GetTime()
        if now ~= nil and state.cooldownRefreshFrameStamp == now then
            UpdateCooldownEventRegistration()
            return
        end
        state.cooldownRefreshFrameStamp = now
    end

    local remaining, resolved
    if event == "SPELL_UPDATE_COOLDOWN" then
        remaining, resolved = RefreshActive(true)
    else
        remaining, resolved = RefreshAll(true)
    end
    if resolved then
        ScheduleCooldownRefresh(remaining, true)
    end
    UpdateCooldownEventRegistration()
end)
