--- 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 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 function PlainNumber(value)
    if value == nil then
        return nil
    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)

    if 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 HideIndicatorVisual(frame)
    if not frame then
        return
    end

    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)

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

        TintOutline(frame, red, green, blue, alpha)
        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
end

local function ForEachCastbar(callback)
    callback(_G.MSUF_TargetCastbar or _G.TargetCastBar)
    callback(_G.MSUF_FocusCastbar or _G.FocusCastBar)

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

local function RefreshAll(updateFillColor)
    local status = {}
    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

    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 = {}
    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

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()
    end

    local remaining, resolved = RefreshAll(true)
    if resolved then
        ScheduleCooldownRefresh(remaining, true)
    end
    UpdateCooldownEventRegistration()
end)
