local pluginName, pluginShared = ...
-- Measures every method the addon owns, not a chosen few: a hot path that is not on a hand-kept list is
-- exactly the one nobody ever finds. The list is built from the shared module table when a run starts.
local Profiler = {}
pluginShared.Profiler = Profiler

local L = LibStub("AceLocale-3.0"):GetLocale("AppearanceMatic")

-- Never wrapped: the profiler would measure itself into a loop, the settings database belongs to Ace,
-- and the addon object carries Ace's own embedded methods rather than this addon's.
local SKIP_MODULE = { Profiler = true, db = true, Core = true }

-- How many lines the report prints. Everything past that is summed into a single line.
local REPORT_ROWS = 20

local records, wraps = {}, {}
-- Nesting. A measured method that calls another measured method would count the inner time twice, so
-- each level keeps what its children spent and the outer one takes that back off its own figure.
local depth, childSpent = 0, {}

local function now()
    return type(debugprofilestop) == "function" and debugprofilestop() or 0
end

function Profiler:Running()
    return #wraps > 0
end

-- Stops the clock, then hands back every value the real method returned, however many there are and
-- without building a table to carry them.
local function finish(rec, started, ...)
    local spent = now() - started
    local kids = childSpent[depth] or 0
    depth = depth - 1
    if depth > 0 then
        childSpent[depth] = (childSpent[depth] or 0) + spent
    end
    rec.count = rec.count + 1
    rec.total = rec.total + spent
    rec.own = rec.own + (spent - kids)
    if spent > rec.worst then
        rec.worst = spent
    end
    return ...
end

-- Swaps one method for a wrapper that records how long the real one takes. The real method is kept so
-- Stop can put it back; a method already wrapped is left alone.
function Profiler:Wrap(owner, key, label)
    if type(owner) ~= "table" or type(owner[key]) ~= "function" then
        return false
    end
    for _, entry in ipairs(wraps) do
        if entry.owner == owner and entry.key == key then
            return false
        end
    end
    local real = owner[key]
    local rec = records[label]
    if not rec then
        rec = { label = label, count = 0, total = 0, own = 0, worst = 0 }
        records[label] = rec
    end
    wraps[#wraps + 1] = { owner = owner, key = key, real = real }
    owner[key] = function(...)
        depth = depth + 1
        childSpent[depth] = 0
        return finish(rec, now(), real(...))
    end
    return true
end

-- Every method on every module this addon put in the shared table, named "Module:Method" and sorted so
-- two runs line up against each other.
function Profiler:Targets()
    local found = {}
    for moduleName, module in pairs(pluginShared) do
        if type(moduleName) == "string" and type(module) == "table" and not SKIP_MODULE[moduleName] then
            for key, value in pairs(module) do
                if type(key) == "string" and type(value) == "function" then
                    found[#found + 1] = { module, key, moduleName .. ":" .. key }
                end
            end
        end
    end
    table.sort(found, function(a, b) return a[3] < b[3] end)
    return found
end

-- A method that throws leaves the nesting counter where it stood. A call stack never crosses a frame,
-- so the counter is put back to nothing at the start of every frame while a run is going.
function Profiler:EnsureUnwind(on)
    if not on then
        if self.unwind then
            self.unwind:Hide()
        end
        return
    end
    if not self.unwind then
        if type(CreateFrame) ~= "function" then
            return
        end
        self.unwind = CreateFrame("Frame")
        self.unwind:SetScript("OnUpdate", function()
            depth = 0
        end)
    end
    self.unwind:Show()
end

function Profiler:Reset()
    for _, rec in pairs(records) do
        rec.count, rec.total, rec.own, rec.worst = 0, 0, 0, 0
    end
    -- a run that is going measures from here; a stopped one keeps the time it froze at, so the
    -- report after a reset does not show a clock still running against nothing
    if self:Running() then
        self.startedAt = type(GetTime) == "function" and GetTime() or self.startedAt
        self.stoppedAt = nil
    end
end

function Profiler:Stop()
    if self.startedAt and not self.stoppedAt and #wraps > 0 then
        self.stoppedAt = type(GetTime) == "function" and GetTime() or self.startedAt
    end
    for i = #wraps, 1, -1 do
        local entry = wraps[i]
        entry.owner[entry.key] = entry.real
        wraps[i] = nil
    end
    depth = 0
    self:EnsureUnwind(false)
end

function Profiler:Start()
    self:Stop()
    self:Reset()
    local wrapped = 0
    for _, target in ipairs(self:Targets()) do
        if self:Wrap(target[1], target[2], target[3]) then
            wrapped = wrapped + 1
        end
    end
    self:EnsureUnwind(true)
    if pluginShared.Lockouts then
        pluginShared.Lockouts.rebuildAsks = nil
    end
    self.startedAt = type(GetTime) == "function" and GetTime() or 0
    self.stoppedAt = nil
    return wrapped
end

-- Ordered by the time a method spent in its own body, which is the one that names the culprit: a total
-- is inflated by everything the method called. A tie reads alphabetically so two runs line up.
function Profiler:Rows()
    local out = {}
    for _, rec in pairs(records) do
        if rec.count > 0 then
            out[#out + 1] = { label = rec.label, count = rec.count, total = rec.total, own = rec.own,
                worst = rec.worst, mean = rec.total / rec.count }
        end
    end
    table.sort(out, function(a, b)
        if a.own == b.own then
            return a.label < b.label
        end
        return a.own > b.own
    end)
    return out
end

-- The addon's own memory, in megabytes to one place
function Profiler:Memory()
    if type(UpdateAddOnMemoryUsage) == "function" then
        pcall(UpdateAddOnMemoryUsage)
    end
    if type(GetAddOnMemoryUsage) ~= "function" then
        return 0
    end
    local ok, kb = pcall(GetAddOnMemoryUsage, pluginName)
    if not (ok and type(kb) == "number") then
        return 0
    end
    return math.floor(kb / 1024 * 10 + 0.5) / 10
end

-- The game's own count for this addon, in milliseconds. The client only keeps it while script profiling
-- is switched on, so it is nothing on a normal client and the line is left out.
function Profiler:GameCPU()
    if type(UpdateAddOnCPUUsage) ~= "function" or type(GetAddOnCPUUsage) ~= "function" then
        return nil
    end
    pcall(UpdateAddOnCPUUsage)
    local ok, ms = pcall(GetAddOnCPUUsage, pluginName)
    if not (ok and type(ms) == "number") or ms <= 0 then
        return nil
    end
    return ms
end

-- How long the run actually measured for: real time while it runs, frozen where it stopped after
function Profiler:Elapsed()
    if not (self.startedAt and type(GetTime) == "function") then
        return 0
    end
    return (self.stoppedAt or GetTime()) - self.startedAt
end

function Profiler:Report()
    local rows = self:Rows()
    print(string.format(L["profile_header_fmt"], self:Memory(), self:Elapsed()))
    if #rows == 0 then
        print(L["profile_nothing"])
        return rows
    end
    local restCount, restOwn = 0, 0
    for index, row in ipairs(rows) do
        if index <= REPORT_ROWS then
            print(string.format(L["profile_row_fmt"], row.label, row.count, row.own, row.total, row.mean, row.worst))
        else
            restCount = restCount + 1
            restOwn = restOwn + row.own
        end
    end
    if restCount > 0 then
        print(string.format(L["profile_more_fmt"], restCount, restOwn))
    end
    local cpu = self:GameCPU()
    if cpu then
        print(string.format(L["profile_cpu_fmt"], cpu))
    end
    self:ReportAsks()
    print(L["profile_overhead"])
    return rows
end

-- What drove the panel draws: the askers by file and line, most first
local ASK_ROWS = 6
function Profiler:ReportAsks()
    local asks = pluginShared.Lockouts and pluginShared.Lockouts.rebuildAsks
    if type(asks) ~= "table" then
        return {}
    end
    local rows, total = {}, 0
    for key, count in pairs(asks) do
        rows[#rows + 1] = { key = key, count = count }
        total = total + count
    end
    if total == 0 then
        return rows
    end
    table.sort(rows, function(a, b)
        if a.count ~= b.count then return a.count > b.count end
        return a.key < b.key
    end)
    print(string.format(L["profile_asks_fmt"], total))
    for index, row in ipairs(rows) do
        if index > ASK_ROWS then break end
        print(string.format(L["profile_ask_row_fmt"], row.key, row.count))
    end
    return rows
end
