local MAJOR, MINOR = "LibMatic-CountUp-1.0", 8
assert(LibStub, MAJOR .. " requires LibStub")
local lib = LibStub:NewLibrary(MAJOR, MINOR)
if not lib then return end

-- Driver frames are reused via a free list. WoW frames are never garbage
-- collected, so creating one per Attach and merely hiding it on Stop leaked a
-- frame for every alert.
lib._driverPool = lib._driverPool or {}

local function acquireDriver()
    local d = table.remove(lib._driverPool)
    if not d then d = CreateFrame("Frame") end
    d:Hide()
    return d
end

local function releaseDriver(d)
    if not d then return end
    d:SetScript("OnUpdate", nil)
    d:Hide()
    lib._driverPool[#lib._driverPool + 1] = d
end

local EASINGS = {
    linear = function(t) return t end,
    outCubic = function(t) local f = t - 1; return f * f * f + 1 end,
    outQuad = function(t) return 1 - (1 - t) * (1 - t) end,
}

local function resolveEasing(e)
    if type(e) == "function" then return e end
    return EASINGS[e] or EASINGS.outCubic
end

function lib._newState(opts)
    opts = opts or {}
    return {
        value = 0,
        startValue = 0,
        target = 0,
        duration = opts.duration or 0.6,
        elapsed = 0,
        tweening = false,
        easing = resolveEasing(opts.easing),
        liveRate = opts.liveRate or 0,
        live = (opts.live or (opts.liveRate ~= nil and opts.liveRate ~= 0)) and true or false,
        minValue = opts.minValue,
        formatter = opts.formatter,
    }
end

function lib._setTarget(state, target)
    state.startValue = state.value
    state.target = target
    state.elapsed = 0
    state.tweening = state.duration > 0 and state.startValue ~= target
    if not state.tweening then state.value = target end
end

local function clampMin(state)
    if state.minValue ~= nil and state.value < state.minValue then
        state.value = state.minValue
    end
end

function lib._advance(state, dt)
    if state.tweening then
        state.elapsed = state.elapsed + dt
        local t = state.duration > 0 and (state.elapsed / state.duration) or 1
        if t >= 1 then
            t = 1
            state.tweening = false
        end
        local p = state.easing(t)
        state.value = state.startValue + (state.target - state.startValue) * p
    elseif state.live and state.liveRate ~= 0 then
        state.value = state.value + state.liveRate * dt
        state.target = state.value
    end
    clampMin(state)
    return state.value
end

local controllerMethods = {}

local function render(ctrl)
    local s = ctrl._state
    local fs = ctrl._fs
    if not fs or not fs.SetText then return end
    if fs.GetFont and not fs:GetFont() then return end
    local text = s.formatter and s.formatter(s.value) or tostring(math.floor(s.value + 0.5))
    fs:SetText(text)
    if fs.ScaleToFit then fs:ScaleToFit() end
end

lib._render = render

-- Acquire a pooled driver and bind its OnUpdate to this controller. A non-live
-- controller releases its driver when a tween completes, so To()/SetLive() must
-- be able to re-arm one when starting another animation later.
local function armDriver(ctrl)
    local driver = acquireDriver()
    ctrl._driver = driver
    driver:SetScript("OnUpdate", function(_, dt)
        -- The driver may have been released and reused by another controller.
        if ctrl._driver ~= driver then return end
        local s = ctrl._state
        if not s.tweening and not s.live then
            ctrl._driver = nil
            releaseDriver(driver)
            return
        end
        lib._advance(s, dt)
        render(ctrl)
    end)
    return driver
end

function controllerMethods:To(target)
    lib._setTarget(self._state, target)
    if not self._driver then armDriver(self) end
    if not self._driver:IsShown() then self._driver:Show() end
    render(self)
    return self
end

function controllerMethods:Set(value)
    self._state.value = value
    self._state.target = value
    self._state.startValue = value
    self._state.tweening = false
    render(self)
    return self
end

function controllerMethods:SetDuration(duration)
    if duration ~= nil then self._state.duration = duration end
    return self
end

function controllerMethods:SetLive(enabled)
    self._state.live = enabled and true or false
    if self._state.live then
        if not self._driver then armDriver(self) end
        if not self._driver:IsShown() then self._driver:Show() end
    end
    return self
end

function controllerMethods:SetLiveRate(rate)
    self._state.liveRate = tonumber(rate) or 0
    return self
end

function controllerMethods:Stop()
    self._state.tweening = false
    self._state.live = false
    local d = self._driver
    if d then
        self._driver = nil
        releaseDriver(d)
    end
    return self
end

function lib:Attach(fontString, opts)
    local ctrl = setmetatable({}, { __index = controllerMethods })
    ctrl._fs = fontString
    ctrl._state = lib._newState(opts)
    local driver = armDriver(ctrl)
    if ctrl._state.live then driver:Show() end
    return ctrl
end

function lib._splitLine(lineText, amount)
    if type(lineText) ~= "string" then return nil end
    if type(amount) ~= "number" or amount <= 0 then return nil end
    local numStr = tostring(math.floor(amount + 0.5))
    local nStart, nEnd = string.find(lineText, numStr, 1, true)
    if not nStart then return nil end
    return string.sub(lineText, 1, nStart - 1), string.sub(lineText, nEnd + 1), numStr
end

function lib:AttachLineCounter(anchor, fs, opts)
    if type(opts) ~= "table" then return nil end
    if not fs or not fs.SetText then return nil end
    local prefix, suffix, numStr = lib._splitLine(opts.lineText, opts.amount)
    if not prefix then return nil end

    local centerOffsetX = opts.centerOffsetX or 0
    local yOffset = opts.yOffset or 0
    fs:SetText(opts.lineText)
    local finalWidth = (fs.GetStringWidth and fs:GetStringWidth()) or 0
    if finalWidth > 0 and anchor and fs.SetPoint then
        fs:ClearAllPoints()
        fs:SetPoint("LEFT", anchor, "CENTER", centerOffsetX - finalWidth / 2, yOffset)
        if fs.SetWidth then fs:SetWidth(finalWidth + math.max(8, #numStr * 4)) end
        if fs.SetJustifyH then fs:SetJustifyH("LEFT") end
        if fs.SetWordWrap then fs:SetWordWrap(false) end
    end

    local numberColor = opts.numberColor
    local counter = self:Attach(fs, {
        duration = opts.duration or 3,
        easing = opts.easing or "linear",
        minValue = 0,
        formatter = function(v)
            local n = tostring(math.floor(v + 0.5))
            if numberColor then
                local unit, rest = "", suffix
                if suffix:sub(1, 1) == "%" then unit, rest = "%", suffix:sub(2) end
                return numberColor .. prefix .. n .. unit .. "|r" .. rest
            end
            return prefix .. n .. suffix
        end,
    })
    counter:Set(0)
    counter:To(opts.amount)
    return counter
end
