-- 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 allocTotal, allocPeak = 0, 0   -- Lua heap growth over the window / worst single frame (KB).
                                     -- Whole client (all addons), but paired with the per-frame GC
                                     -- flag + comm stats it exposes allocation-burst stutter — the
                                     -- "memory balloons for a second, then GC hitches" pattern.
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) ──
-- Self, a trivially-hot logger, and the two ultra-hot one-line gates (called on every message /
-- event; the wrapper costs more than they do, and a 0.0 ms "top: InInstance" line is pure noise).
local EXCLUDE = { Profiler = true, Print = true, InInstance = true, IsSecret = true }

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.
    -- A frame that GROWS it a lot is the thing that CAUSES those GC passes, so track both sides.
    local mem = collectgarbage("count")
    local memDelta = lastMem and (mem - lastMem) or 0
    lastMem = mem
    if memDelta > 0 then
        allocTotal = allocTotal + memDelta
        if memDelta > allocPeak then allocPeak = memDelta end
    end

    -- 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
        if topMs < 0.5 then topLabel, topMs = nil, 0 end    -- a sub-half-ms "top" is noise, not a culprit

        hitchLog[#hitchLog + 1] = {
            clock = time(), frameMs = frameMs, ourMs = ourMs, addonMs = addonMs,
            top = topLabel, topMs = topMs, gc = gc,
            alloc = (memDelta > 0) and memDelta or nil,     -- KB the Lua heap grew during this frame
        }
        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 ""
                local al  = (memDelta >= 1024) and ((" |cffff9955+%.1f MB alloc|r"):format(memDelta / 1024)) or ""
                print(("%s |cffff5555HITCH %s ms|r \226\128\148 WarBoard ~%s ms%s%s%s"):format(
                    WB.TAG, fmt(frameMs), fmt(attributed), who, al, 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
        allocTotal, allocPeak = 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
        if WB.Comm and WB.Comm.ResetPerfStats then WB.Comm.ResetPerfStats() 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
    allocTotal, allocPeak = 0, 0
    wipe(fnStats); wipe(pending); wipe(hitchLog)
    startStamp, startClock = debugprofilestop(), time()
    if WB.Ledger and WB.Ledger.ResetAddStats then WB.Ledger.ResetAddStats() end
    if WB.Comm and WB.Comm.ResetPerfStats then WB.Comm.ResetPerfStats() 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
    print(("  lua heap churn: ~%.1f MB allocated over the window \226\128\148 worst single frame +%.2f MB |cff888888(whole client)|r"):format(
        allocTotal / 1024, allocPeak / 1024))
    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

    -- Comm traffic — the UNWRAPPED hot path. Decode/encode/serialize and the ledger-dump serving
    -- live in local functions and embedded libraries, so they can never appear in the function
    -- table above: a hitch reading "ours X ms, top: —" is usually THIS. Dump serving is the big
    -- allocator (serialize+compress of the ledger window) — if "dumps served" is non-zero next to
    -- alloc-heavy hitches, that's the smoking gun.
    if WB.Comm and WB.Comm.GetPerfStats then
        local c = WB.Comm.GetPerfStats()
        print(("  comm: rx %d msg (%.0f KB, %.0f ms decode, %d dup-dropped)  \226\128\148  tx %d msg (%.0f KB)"):format(
            c.rxN or 0, (c.rxBytes or 0) / 1024, c.decodeMs or 0, c.rxDup or 0, c.txN or 0, (c.txBytes or 0) / 1024))
        local ta = {}
        for t, e in pairs(c.byType or {}) do ta[#ta + 1] = { t = t, e = e } end
        table.sort(ta, function(a, b) return a.e.b > b.e.b end)
        if #ta > 0 then
            local parts = {}
            for i = 1, math.min(6, #ta) do
                parts[#parts + 1] = ("%s \195\151%d (%.0f KB)"):format(ta[i].t, ta[i].e.n, ta[i].e.b / 1024)
            end
            print("    rx by type: " .. table.concat(parts, ",  "))
        end
        if (c.dumpServes or 0) > 0 then
            print(("    ledger dumps served: |cffffd100%d|r serve(s), %d batch(es) encoded, %.0f ms encode time"):format(
                c.dumpServes, c.dumpBatches or 0, c.dumpEncodeMs or 0))
        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"
            local al  = (h.alloc and h.alloc >= 1024) and ((", +%.1f MB alloc"):format(h.alloc / 1024)) or ""
            print(("    %s  %s ms frame, ours ~%s ms, top: %s%s%s"):format(
                date("%H:%M:%S", h.clock), fmt(h.frameMs), fmt(math.max(h.ourMs, h.addonMs or 0)),
                who, al, 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
