-- WarBoard_Profiler.lua
-- Micro-stutter / FPS-spike diagnostic (opt-in, off by default, zero overhead when off).
-- Answers the question "is WarBoard causing my stutter, and if so WHICH part?" with four signals,
-- cheapest first:
--
--   1) Frame-hitch watchdog (no reload). Times every rendered frame from the OnUpdate `elapsed`
--      arg. A frame far longer than the running baseline is a hitch — we log WHEN it happened and
--      how bad. This alone measures render/other-addon hitches too, so on its own it can't blame us.
--   2) Function blame (no reload). At enable-time we wrap WarBoard's own functions (WB.*) so each
--      self-times with debugprofilestop(). Any single call that stalls the frame prints the culprit
--      by name LIVE ("stutter WB.UI.RefreshLeaderboard took 22.4 ms"), and it all rolls up into a
--      leaderboard for /wb perf report. This is the part that actually names the cause.
--   3) GC-pause detection (no reload). We sample the Lua heap each frame; a frame where the heap
--      shrinks is a garbage-collection pass — a very common source of periodic micro-stutter, often
--      from an addon allocating tables in a hot path. Hitches that coincide with a GC are flagged.
--   4) Whole-addon CPU share (needs the scriptProfile CVar + a reload). When active we read
--      GetAddOnCPUUsage each frame, so a hitch reports exactly how many ms of it were WarBoard —
--      catching even un-wrapped anonymous timers / event handlers that signal #2 misses.
--
-- Commands live in the /wb dispatcher (UI/WarBoard_Frame.lua): /wb perf [on|off|report|reset|
-- scriptprofile|hitch N|spike N].

local addonName, WB = ...
WB.Profiler = WB.Profiler or {}
local P = WB.Profiler

-- ── Tunables (adjustable at runtime via /wb perf hitch|spike) ───────────────────────
P.hitchMs = 20     -- a frame at/over this many ms is a hitch candidate
P.hitchMul = 2     -- ...or over baseline*this (catches micro-hitches on an otherwise smooth baseline)
P.spikeMs = 5      -- a single wrapped WarBoard call at/over this many ms is surfaced immediately
P.live = true      -- print culprit lines live while enabled (report is always available regardless)

-- ── State ───────────────────────────────────────────────────────────────────────────
local enabled = false
local spActive = false          -- scriptProfile confirmed active this session (set at enable)
local startStamp                -- debugprofilestop() at enable
local startClock                -- time() at enable
local frames = 0
local baseline                  -- EMA of "normal" frame ms (hitches excluded so they can't inflate it)
local lastMem                   -- collectgarbage("count") previous frame, KB
local lastAddonCPU              -- GetAddOnCPUUsage cumulative at the last sample, ms
local cpuSampleAt = 0           -- GetTime() of the last addon-CPU sample (throttled to 4×/sec)
local hitchCount, hitchOurs, gcHitches = 0, 0, 0
local hitchLog = {}             -- recent hitches (trimmed ring)
local HITCH_LOG_MAX = 40

local fnStats = {}              -- label -> { count, total, max, spikes }
local pending = {}              -- label -> ms accumulated during the in-progress frame
local wrapped = {}              -- { tbl, key, orig } captured so we can restore on disable

local function fmt(ms) return ("%.1f"):format(ms or 0) end

local function StatFor(label)
    local s = fnStats[label]
    if not s then s = { count = 0, total = 0, max = 0, spikes = 0 }; fnStats[label] = s end
    return s
end

-- ── scriptProfile / addon-usage helpers (all defensive; APIs no-op without the CVar) ──
local function ScriptProfileOn()
    return GetCVar and GetCVar("scriptProfile") == "1"
end

local function AddonCPUCumulative()
    if not (spActive and UpdateAddOnCPUUsage and GetAddOnCPUUsage) then return nil end
    UpdateAddOnCPUUsage()
    local ok, ms = pcall(GetAddOnCPUUsage, addonName)
    if ok and type(ms) == "number" then return ms end
    return nil
end

local function AddonMemoryKB()
    if not (UpdateAddOnMemoryUsage and GetAddOnMemoryUsage) then return nil end
    UpdateAddOnMemoryUsage()
    local ok, kb = pcall(GetAddOnMemoryUsage, addonName)
    if ok and type(kb) == "number" then return kb end
    return nil
end

-- ── Wrap one function so it self-times ────────────────────────────────────────────────
-- Forwards up to 6 return values (no WarBoard function returns more) with no per-call allocation,
-- so wrapping getters is safe as well as callbacks. Note: this instruments calls that go THROUGH
-- the table (tbl.key(...)); a function reference captured elsewhere before we wrap (e.g. a local
-- passed into C_Timer.NewTicker at load) keeps running un-instrumented — signal #4 covers those.
function P.Wrap(tbl, key, label)
    if type(tbl) ~= "table" then return false end
    local orig = rawget(tbl, key)
    if type(orig) ~= "function" then return false end
    label = label or tostring(key)
    tbl[key] = function(...)
        local t0 = debugprofilestop()
        local a, b, c, d, e, f = orig(...)
        local dt = debugprofilestop() - t0
        local s = StatFor(label)
        s.count = s.count + 1
        s.total = s.total + dt
        if dt > s.max then s.max = dt end
        pending[label] = (pending[label] or 0) + dt
        if dt >= P.spikeMs then
            s.spikes = s.spikes + 1
            if enabled and P.live then
                print(("%s |cffff5555stutter|r %s took |cffffd100%s ms|r"):format(WB.TAG, label, fmt(dt)))
            end
        end
        return a, b, c, d, e, f
    end
    wrapped[#wrapped + 1] = { tbl = tbl, key = key, orig = orig }
    return true
end

-- ── Auto-discover: wrap every function reachable on WB two levels deep (WB.fn, WB.Mod.fn) ──
local EXCLUDE = { Profiler = true, Print = true }   -- self, and a trivially-hot logger

local function WrapTree(tbl, prefix, depth, seen, count)
    if type(tbl) ~= "table" or seen[tbl] then return count end
    seen[tbl] = true
    if tbl.GetObjectType then return count end       -- a Blizzard widget (methods live on a metatable); skip
    for k, v in pairs(tbl) do
        if type(k) == "string" and not EXCLUDE[k] then
            local label = prefix and (prefix .. "." .. k) or k
            if type(v) == "function" then
                if P.Wrap(tbl, k, label) then count = count + 1 end
            elseif type(v) == "table" and depth > 0 then
                count = WrapTree(v, label, depth - 1, seen, count)
            end
        end
    end
    return count
end

-- ── Per-frame watchdog ────────────────────────────────────────────────────────────────
local driver = CreateFrame("Frame")
driver:Hide()
driver:SetScript("OnUpdate", function(_, elapsed)
    frames = frames + 1
    local frameMs = elapsed * 1000

    -- Sum the wrapped-call time attributed to the frame that just ended, and find its top contributor.
    local ourMs, topLabel, topMs = 0, nil, 0
    for label, ms in pairs(pending) do
        ourMs = ourMs + ms
        if ms > topMs then topMs = ms; topLabel = label end
        pending[label] = nil
    end

    -- Whole-addon CPU (scriptProfile only) — catches timers/handlers we didn't wrap. Sampled at most
    -- 4×/sec, NOT per frame: UpdateAddOnCPUUsage walks every loaded addon, and calling it every frame
    -- made the profiler itself shave FPS while measuring. A hitch frame takes an immediate sample
    -- below, so its attribution window stays ≤0.25s.
    local addonMs
    if spActive then
        local nowG = GetTime()
        if (nowG - cpuSampleAt) >= 0.25 then
            cpuSampleAt = nowG
            lastAddonCPU = AddonCPUCumulative() or lastAddonCPU
        end
    end

    -- Lua heap: a frame where it shrinks means a GC pass ran — a classic periodic-stutter cause.
    local mem = collectgarbage("count")
    local memDelta = lastMem and (mem - lastMem) or 0
    lastMem = mem

    -- Baseline tracks only normal frames, so a hitch can't drag the threshold up with it.
    if frameMs < P.hitchMs then
        baseline = baseline and (baseline * 0.95 + frameMs * 0.05) or frameMs
    end

    local threshold = math.max(P.hitchMs, (baseline or 0) * P.hitchMul)
    if frameMs >= threshold then
        hitchCount = hitchCount + 1
        -- Hitch frame: take an immediate CPU sample so this hitch's addon share covers at most the
        -- last 0.25s (slight overstatement possible — it errs toward blaming us, never exonerating).
        if spActive then
            local cum = AddonCPUCumulative()
            if cum and lastAddonCPU then addonMs = cum - lastAddonCPU end
            lastAddonCPU = cum or lastAddonCPU
            cpuSampleAt = GetTime()
        end
        local attributed = math.max(ourMs, addonMs or 0)   -- best estimate of our share of this frame
        local gc = memDelta <= -8                           -- freed >= 8 KB this frame -> a GC ran
        if gc then gcHitches = gcHitches + 1 end

        hitchLog[#hitchLog + 1] = {
            clock = time(), frameMs = frameMs, ourMs = ourMs, addonMs = addonMs,
            top = topLabel, topMs = topMs, gc = gc,
        }
        if #hitchLog > HITCH_LOG_MAX then table.remove(hitchLog, 1) end

        if attributed >= P.spikeMs then
            hitchOurs = hitchOurs + 1
            if enabled and P.live then
                local who = topLabel and (" \226\128\148 top: " .. topLabel .. " " .. fmt(topMs) .. " ms") or ""
                print(("%s |cffff5555HITCH %s ms|r \226\128\148 WarBoard ~%s ms%s%s"):format(
                    WB.TAG, fmt(frameMs), fmt(attributed), who, gc and " |cff888888(GC ran)|r" or ""))
            end
        end
    end
end)

-- ── Enable / disable ──────────────────────────────────────────────────────────────────
function P.IsEnabled() return enabled end

function P.SetEnabled(on)
    on = not not on
    if on == enabled then
        print(("%s stutter profiler is already %s."):format(WB.TAG, on and "|cff44ff44ON|r" or "|cffff6060OFF|r"))
        return
    end
    enabled = on
    if on then
        frames, baseline, lastMem, lastAddonCPU, cpuSampleAt = 0, nil, nil, nil, 0
        hitchCount, hitchOurs, gcHitches = 0, 0, 0
        wipe(fnStats); wipe(pending); wipe(hitchLog)
        spActive = ScriptProfileOn()
        startStamp, startClock = debugprofilestop(), time()
        if WB.Ledger and WB.Ledger.ResetAddStats then WB.Ledger.ResetAddStats() end
        local n = WrapTree(WB, nil, 1, {}, 0)
        driver:Show()
        print(("%s |cff44ff44stutter profiler ON|r \226\128\148 watching frames; instrumented %d WarBoard function(s)."):format(WB.TAG, n))
        print(("%s Reproduce the stutter, then run |cffffd100/wb perf report|r. (hitch \226\137\165 %s ms, spike \226\137\165 %s ms)"):format(
            WB.TAG, fmt(P.hitchMs), fmt(P.spikeMs)))
        if not spActive then
            print(("%s Tip: for exact whole-addon attribution run |cffffd100/wb perf scriptprofile|r (one-time, needs a reload)."):format(WB.TAG))
        end
    else
        driver:Hide()
        for i = #wrapped, 1, -1 do
            local w = wrapped[i]
            w.tbl[w.key] = w.orig
            wrapped[i] = nil
        end
        print(("%s stutter profiler |cffff6060OFF|r."):format(WB.TAG))
        P.Report()
    end
end

function P.Reset()
    frames, baseline, lastMem, lastAddonCPU, cpuSampleAt = 0, nil, nil, nil, 0
    hitchCount, hitchOurs, gcHitches = 0, 0, 0
    wipe(fnStats); wipe(pending); wipe(hitchLog)
    startStamp, startClock = debugprofilestop(), time()
    if WB.Ledger and WB.Ledger.ResetAddStats then WB.Ledger.ResetAddStats() end
    print(("%s stutter counters reset."):format(WB.TAG))
end

-- ── Report ────────────────────────────────────────────────────────────────────────────
function P.Report()
    if not startClock then
        print(("%s nothing recorded yet \226\128\148 run |cffffd100/wb perf on|r first."):format(WB.TAG))
        return
    end
    local durS = math.max(0.001, (debugprofilestop() - startStamp) / 1000)
    print(("%s |cffffd100stutter report|r \226\128\148 %s, %d frames, ~%.0f fps avg over %.0fs"):format(
        WB.TAG, enabled and "running" or "stopped", frames, frames / durS, durS))
    print(("  baseline frame %s ms  \226\128\148  hitch threshold %s ms"):format(
        fmt(baseline or 0), fmt(math.max(P.hitchMs, (baseline or 0) * P.hitchMul))))
    print(("  hitches: |cffffd100%d|r total  \226\128\148  |cffff5555%d|r WarBoard-implicated  \226\128\148  |cff888888%d|r during a GC pass"):format(
        hitchCount, hitchOurs, gcHitches))

    local mem = AddonMemoryKB()
    if mem then print(("  WarBoard memory: %.0f KB in use"):format(mem)) end
    if not spActive then
        print("  |cff888888(whole-addon CPU share unavailable \226\128\148 run /wb perf scriptprofile + reload for exact numbers)|r")
    end

    -- Top offenders by worst single call — the direct stutter culprits.
    local arr = {}
    for label, s in pairs(fnStats) do
        if s.count > 0 and s.max >= 0.2 then arr[#arr + 1] = { label = label, s = s } end
    end
    table.sort(arr, function(a, b) return a.s.max > b.s.max end)
    if #arr == 0 then
        print("  no WarBoard function took measurable time \226\128\148 the stutter is likely NOT from this addon.")
    else
        print("  worst WarBoard calls (max ms / total ms / calls / spikes):")
        for i = 1, math.min(10, #arr) do
            local e = arr[i]
            print(("    |cffffd100%s|r  \226\128\148  %s / %s / %d%s"):format(
                e.label, fmt(e.s.max), fmt(e.s.total), e.s.count,
                e.s.spikes > 0 and ("  |cffff5555(" .. e.s.spikes .. " spike)|r") or ""))
        end
    end

    -- Ledger throughput — the scaling cost at population. Records/min (and which atypes dominate)
    -- tells you whether the ledger is the pressure point; trims are the O(nlogn) 10k-cap sort spikes.
    if WB.Ledger and WB.Ledger.GetAddStats then
        local total, byType, elapsed, trims = WB.Ledger.GetAddStats()
        print(("  ledger inflow: |cffffd100%d|r records in %.0fs (%.1f/min), %d trim-sort(s)"):format(
            total, elapsed, total / elapsed * 60, trims or 0))
        local tarr = {}
        for ty, c in pairs(byType) do tarr[#tarr + 1] = { ty = ty, c = c } end
        table.sort(tarr, function(a, b) return a.c > b.c end)
        for i = 1, math.min(6, #tarr) do
            print(("    %s \195\151 %d  (%.1f/min)"):format(tarr[i].ty, tarr[i].c, tarr[i].c / elapsed * 60))
        end
    end

    -- Most recent hitches with timestamps, so you can line them up against what you were doing.
    if #hitchLog > 0 then
        print("  recent hitches:")
        for i = #hitchLog, math.max(1, #hitchLog - 9), -1 do
            local h = hitchLog[i]
            local who = h.top and (h.top .. " " .. fmt(h.topMs) .. "ms") or "\226\128\148"
            print(("    %s  %s ms frame, ours ~%s ms, top: %s%s"):format(
                date("%H:%M:%S", h.clock), fmt(h.frameMs), fmt(math.max(h.ourMs, h.addonMs or 0)),
                who, h.gc and "  (GC)" or ""))
        end
    end
end

-- ── scriptProfile toggle (whole-addon CPU attribution; costs a reload) ─────────────────
function P.EnableScriptProfile()
    if ScriptProfileOn() then
        print(("%s scriptProfile is already |cff44ff44ON|r \226\128\148 |cffffd100/wb perf on|r now gives exact whole-addon CPU per hitch."):format(WB.TAG))
        return
    end
    if SetCVar then SetCVar("scriptProfile", "1") end
    print(("%s scriptProfile enabled \226\128\148 type |cffffd100/reload|r to activate, then |cffffd100/wb perf on|r."):format(WB.TAG))
    print(("%s (It adds a tiny always-on cost to the whole UI; turn it back off later with |cffffd100/console scriptProfile 0|r + /reload.)"):format(WB.TAG))
end

function P.SetHitch(n)
    n = tonumber(n)
    if not n or n < 1 then print(("%s usage: |cffffd100/wb perf hitch <ms>|r (current %s)."):format(WB.TAG, fmt(P.hitchMs))); return end
    P.hitchMs = n
    print(("%s hitch threshold set to %s ms."):format(WB.TAG, fmt(n)))
end

function P.SetSpike(n)
    n = tonumber(n)
    if not n or n < 0.5 then print(("%s usage: |cffffd100/wb perf spike <ms>|r (current %s)."):format(WB.TAG, fmt(P.spikeMs))); return end
    P.spikeMs = n
    print(("%s per-call spike threshold set to %s ms."):format(WB.TAG, fmt(n)))
end

function P.Help()
    print(WB.TAG .. " stutter profiler:")
    print("  |cffffd100/wb perf|r \226\128\148 toggle it on/off (prints a report when it stops)")
    print("  |cffffd100/wb perf report|r \226\128\148 print current findings without stopping")
    print("  |cffffd100/wb perf reset|r \226\128\148 clear counters and keep watching")
    print("  |cffffd100/wb perf scriptprofile|r \226\128\148 enable exact whole-addon CPU (needs /reload)")
    print("  |cffffd100/wb perf hitch <ms>|r / |cffffd100spike <ms>|r \226\128\148 tune the thresholds")
end
