--- **AceTimer-3.0** provides a central facility for registering timers.
-- AceTimer supports one-shot timers and repeating timers. All timers are stored in an efficient
-- data structure that allows easy dispatching and fast rescheduling. Timers can be registered
-- or canceled at any time, even from within a running timer, without conflict or large overhead.\\
-- AceTimer is currently limited to firing timers at a frequency of 0.01s as this is what the WoW timer API
-- restricts us to.
--
-- All `:Schedule` functions will return a handle to the current timer, which you will need to store if you
-- need to cancel the timer you just registered.
--
-- **AceTimer-3.0** can be embeded into your addon, either explicitly by calling AceTimer:Embed(MyAddon) or by
-- specifying it as an embeded library in your AceAddon. All functions will be available on your addon object
-- and can be accessed directly, without having to explicitly call AceTimer itself.\\
-- It is recommended to embed AceTimer, otherwise you'll have to specify a custom `self` on all calls you
-- make into AceTimer.
-- @class file
-- @name AceTimer-3.0
-- @release $Id: AceTimer-3.0.lua 1284 2022-09-25 09:15:30Z nevcairiel $

local MAJOR, MINOR = "AceTimer-3.0", 17 -- Bump minor on changes
local AceTimer, oldminor = LibStub:NewLibrary(MAJOR, MINOR)

if not AceTimer then
    return
end -- No upgrade needed
AceTimer.activeTimers = AceTimer.activeTimers or {} -- Active timer list
local activeTimers = AceTimer.activeTimers -- Upvalue our private data

-- Lua APIs
local type, unpack, next, error, select = type, unpack, next, error, select
-- WoW APIs
local GetTime, C_TimerAfter = GetTime, C_Timer.After

local function new(self, loop, func, delay, ...)
    if delay < 0.01 then
        delay = 0.01 -- Restrict to the lowest time that the C_Timer API allows us
    end

    local timer = {
        object = self,
        func = func,
        looping = loop,
        argsCount = select("#", ...),
        delay = delay,
        ends = GetTime() + delay,
        ...
    }

    activeTimers[timer] = timer

    -- Create new timer closure to wrap the "timer" object
    timer.callback = function()
        if not timer.cancelled then
            if type(timer.func) == "string" then
                -- We manually set the unpack count to prevent issues with an arg set that contains nil and ends with nil
                -- e.g. local t = {1, 2, nil, 3, nil} print(#t) will result in 2, instead of 5. This fixes said issue.
                timer.object[timer.func](timer.object, unpack(timer, 1, timer.argsCount))
            else
                timer.func(unpack(timer, 1, timer.argsCount))
            end

            if timer.looping and not timer.cancelled then
                -- Compensate delay to get a perfect average delay, even if individual times don't match up perfectly
                -- due to fps differences
                local time = GetTime()
                local ndelay = timer.delay - (time - timer.ends)
                -- Ensure the delay doesn't go below the threshold
                if ndelay < 0.01 then
                    ndelay = 0.01
                end
                C_TimerAfter(ndelay, timer.callback)
                timer.ends = time + ndelay
            else
                activeTimers[timer.handle or timer] = nil
            end
        end
    end

    C_TimerAfter(delay, timer.callback)
    return timer
end

--- Schedule a new one-shot timer.
-- The timer will fire once in `delay` seconds, unless canceled before.
-- @param callback Callback function for the timer pulse (funcref or method name).
-- @param delay Delay for the timer, in seconds.
-- @param ... An optional, unlimited amount of arguments to pass to the callback function.
-- @usage
-- MyAddOn = LibStub("AceAddon-3.0"):NewAddon("MyAddOn", "AceTimer-3.0")
--
-- function MyAddOn:OnEnable()
--   self:ScheduleTimer("TimerFeedback", 5)
-- end
--
-- function MyAddOn:TimerFeedback()
--   print("5 seconds passed")
-- end
function AceTimer:ScheduleTimer(func, delay, ...)
    if not func or not delay then
        error(MAJOR .. ": ScheduleTimer(callback, delay, args...): 'callback' and 'delay' must have set values.", 2)
    end
    if type(func) == "string" then
        if type(self) ~= "table" then
            error(MAJOR .. ": ScheduleTimer(callback, delay, args...): 'self' - must be a table.", 2)
        elseif not self[func] then
            error(MAJOR .. ": ScheduleTimer(callback, delay, args...): Tried to register '" .. func .. "' as the callback, but it doesn't exist in the module.", 2)
        end
    end
    return new(self, nil, func, delay, ...)
end

--- Schedule a repeating timer.
-- The timer will fire every `delay` seconds, until canceled.
-- @param callback Callback function for the timer pulse (funcref or method name).
-- @param delay Delay for the timer, in seconds.
-- @param ... An optional, unlimited amount of arguments to pass to the callback function.
-- @usage
-- MyAddOn = LibStub("AceAddon-3.0"):NewAddon("MyAddOn", "AceTimer-3.0")
--
-- function MyAddOn:OnEnable()
--   self.timerCount = 0
--   self.testTimer = self:ScheduleRepeatingTimer("TimerFeedback", 5)
-- end
--
-- function MyAddOn:TimerFeedback()
--   self.timerCount = self.timerCount + 1
--   print(("%d seconds passed"):format(5 * self.timerCount))
--   -- run 30 seconds in total
--   if self.timerCount == 6 then
--     self:CancelTimer(self.testTimer)
--   end
-- end
function AceTimer:ScheduleRepeatingTimer(func, delay, ...)
    if not func or not delay then
        error(MAJOR .. ": ScheduleRepeatingTimer(callback, delay, args...): 'callback' and 'delay' must have set values.", 2)
    end
    if type(func) == "string" then
        if type(self) ~= "table" then
            error(MAJOR .. ": ScheduleRepeatingTimer(callback, delay, args...): 'self' - must be a table.", 2)
        elseif not self[func] then
            error(MAJOR .. ": ScheduleRepeatingTimer(callback, delay, args...): Tried to register '" .. func .. "' as the callback, but it doesn't exist in the module.", 2)
        end
    end
    return new(self, true, func, delay, ...)
end

--- Cancels a timer with the given id, registered by the same addon object as used for `:ScheduleTimer`
-- Both one-shot and repeating timers can be canceled with this function, as long as the `id` is valid
-- and the timer has not fired yet or was canceled before.
-- @param id The id of the timer, as returned by `:ScheduleTimer` or `:ScheduleRepeatingTimer`
function AceTimer:CancelTimer(id)
    local timer = activeTimers[id]

    if not timer then
        return false
    else
        timer.cancelled = true
        activeTimers[id] = nil
        return true
    end
end

--- Cancels all timers registered to the current addon object ('self')
function AceTimer:CancelAllTimers()
    for k, v in next, activeTimers do
        if v.object == self then
            AceTimer.CancelTimer(self, k)
        end
    end
end

--- Returns the time left for a timer with the given id, registered by the current addon object ('self').
-- This function will return 0 when the id is invalid.
-- @param id The id of the timer, as returned by `:ScheduleTimer` or `:ScheduleRepeatingTimer`
-- @return The time left on the timer.
function AceTimer:TimeLeft(id)
    local timer = activeTimers[id]
    if not timer then
        return 0
    else
        return timer.ends - GetTime()
    end
end


-- ---------------------------------------------------------------------
-- Upgrading

-- Upgrade from old hash-bucket based timers to C_Timer.After timers.
if oldminor and oldminor < 10 then
    -- disable old timer logic
    AceTimer.frame:SetScript("OnUpdate", nil)
    AceTimer.frame:SetScript("OnEvent", nil)
    AceTimer.frame:UnregisterAllEvents()
    -- convert timers
    for object, timers in next, AceTimer.selfs do
        for handle, timer in next, timers do
            if type(timer) == "table" and timer.callback then
                local newTimer
                if timer.delay then
                    newTimer = AceTimer.ScheduleRepeatingTimer(timer.object, timer.callback, timer.delay, timer.arg)
                else
                    newTimer = AceTimer.ScheduleTimer(timer.object, timer.callback, timer.when - GetTime(), timer.arg)
                end
                -- Use the old handle for old timers
                activeTimers[newTimer] = nil
                activeTimers[handle] = newTimer
                newTimer.handle = handle
            end
        end
    end
    AceTimer.selfs = nil
    AceTimer.hash = nil
    AceTimer.debug = nil
elseif oldminor and oldminor < 17 then
    -- Upgrade from old animation based timers to C_Timer.After timers.
    AceTimer.inactiveTimers = nil
    AceTimer.frame = nil
    local oldTimers = AceTimer.activeTimers
    -- Clear old timer table and update upvalue
    AceTimer.activeTimers = {}
    activeTimers = AceTimer.activeTimers
    for handle, timer in next, oldTimers do
        local newTimer
        -- Stop the old timer animation
        local duration, elapsed = timer:GetDuration(), timer:GetElapsed()
        timer:GetParent():Stop()
        if timer.looping then
            newTimer = AceTimer.ScheduleRepeatingTimer(timer.object, timer.func, duration, unpack(timer.args, 1, timer.argsCount))
        else
            newTimer = AceTimer.ScheduleTimer(timer.object, timer.func, duration - elapsed, unpack(timer.args, 1, timer.argsCount))
        end
        -- Use the old handle for old timers
        activeTimers[newTimer] = nil
        activeTimers[handle] = newTimer
        newTimer.handle = handle
    end

    -- Migrate transitional handles
    if oldminor < 13 and AceTimer.hashCompatTable then
        for handle, id in next, AceTimer.hashCompatTable do
            local t = activeTimers[id]
            if t then
                activeTimers[id] = nil
                activeTimers[handle] = t
                t.handle = handle
            end
        end
        AceTimer.hashCompatTable = nil
    end
end

-- ---------------------------------------------------------------------
-- Embed handling

AceTimer.embeds = AceTimer.embeds or {}

local mixins = {
    "ScheduleTimer", "ScheduleRepeatingTimer",
    "CancelTimer", "CancelAllTimers",
    "TimeLeft"
}

function AceTimer:Embed(target)
    AceTimer.embeds[target] = true
    for _, v in next, mixins do
        target[v] = AceTimer[v]
    end
    return target
end

-- AceTimer:OnEmbedDisable(target)
-- target (object) - target object that AceTimer is embedded in.
--
-- cancel all timers registered for the object
function AceTimer:OnEmbedDisable(target)
    target:CancelAllTimers()
end

for addon in next, AceTimer.embeds do
    AceTimer:Embed(addon)
end
