-- TOG Tools — Diagnostics log (storage + UI engine) [developer tool]
--
-- The STORAGE + display side of the dev-relevant signals WoW exposes to addons
-- in memory. The actual CAPTURE lives in the !TOGT companion (loaded before any
-- lettered addon), which buffers into TOGToolsEarlyData; this module drains that
-- buffer on enable and then receives live captures via a callback. Capture is in
-- the companion, not here, because most of these fire during the loading screen
-- — before TOGTools (a lettered addon) initializes. The on-disk Logs\*.log files
-- (combat.log, taint.log, ...) are NOT readable from the addon sandbox at all,
-- so the companion reconstructs what it can from live events/handlers:
--
--   errors   — Lua errors, via a chained error handler in !TOGT. We keep ONLY
--              the error message (the "path:line: reason" summary) — never the
--              stack or locals — so the log stays scannable. That summary is
--              exactly what Blizzard's own ScriptErrorsFrame shows as its top
--              line. Blocked/forbidden addon-action events
--              (ADDON_ACTION_BLOCKED/_FORBIDDEN + the MACRO_ variants) are ALSO
--              recorded as errors — they're scriptErrors output, distinguished
--              by a "blocked:"/"forbidden:" detail. NOTE: these are NOT "taint".
--              True taint is the propagation logged only to the unreadable
--              taint.log; these are just the addon-action errors scriptErrors
--              surfaces, and they fire only on an ACTUAL blocked/forbidden call.
--   warnings — LUA_WARNING events. These only fire while the `scriptWarnings`
--              CVar is on.
--
-- Identical messages are de-duplicated: a repeat bumps `count` and `last`
-- instead of appending a row, so a spammy combat-loop error becomes ONE line
-- with count=N rather than N lines (same approach as ScriptErrorsFrame.lua).
--
-- Surfaces beyond the tab: a BugSack-style toast on a new distinct error, and a
-- LibDataBroker "data source" (Titan Panel / any LDB display) showing live
-- counts — both defined below.
--
-- Dev tool: registered as a Logs sub-tab that appears only when Settings >
-- General > Developer Tools is on (via the category's `gate`, independent of the
-- Logs master toggle and the per-category user toggles). Recording is gated on
-- that same switch, so end users with dev tools off pay zero cost and store
-- nothing.
--
-- This is deliberately NOT a BugSack replacement — there is no stack trace. It
-- is a compact "what's firing, and how often" triage view; reach for BugSack
-- when you need the full trace + locals for a specific error.

local _, addon = ...

local Diagnostics = {}
addon.logCategories = addon.logCategories or {}
addon.logCategories["diagnostics"] = Diagnostics

-- Registered as a Logs sub-tab (NOT a top-level addon.modules entry). subKey /
-- label / configKey follow the log sub-category contract; `order` (high) sorts
-- it last in the strip since it's a dev tool. `gate` is what makes it appear:
-- it tracks ONLY the Developer Tools switch, decoupled from the Logs master
-- toggle and the per-category user toggles — a dev who turns off gameplay
-- logging still wants Diagnostics. LogsTab honours `gate` in place of the
-- standard IsLogCategoryEnabled check.
Diagnostics.subKey    = "diagnostics"
Diagnostics.label     = "Diagnostics"
Diagnostics.configKey = "diagnostics"
Diagnostics.order     = 900
Diagnostics.gate      = function()
    return addon.DB and addon.DB.global and addon.DB.global.devTools and true or false
end

-- API shims — Classic Era keeps the bare CVar globals; some flavours also
-- expose them under C_CVar. Feature-detect so one file runs everywhere.
local _GetCVar = (C_CVar and C_CVar.GetCVar) or GetCVar
local _SetCVar = (C_CVar and C_CVar.SetCVar) or SetCVar

-- Distinct-entry cap. Dedup keeps the real count tiny; this only bounds SV
-- against a pathological flood of UNIQUE messages. Oldest-by-last-seen are
-- pruned when exceeded.
local MAX_ENTRIES = 500

-- Seconds the new-error alert toast stays fully visible before it fades out.
local ALERT_DURATION = 5

local KIND_ERROR   = "error"
local KIND_WARNING = "warning"

-- Maps a raw record's kind string to a known constant. Blocked/forbidden
-- addon-action events (ADDON_ACTION_BLOCKED/_FORBIDDEN) are folded into errors —
-- they're scriptErrors output, not "taint" (true taint is the unreadable
-- taint.log propagation). `taint`/`blocked` are mapped for backward-compat with
-- older companion buffers / saved entries.
local KIND_BY_NAME = {
    error   = KIND_ERROR,
    warning = KIND_WARNING,
    taint   = KIND_ERROR,
    blocked = KIND_ERROR,
}

-- US (\31) never appears in an error message or function name, so it's a safe
-- separator for the dedup key.
local SEP = "\31"

local function Store()
    return addon.DB and addon.DB.global and addon.DB.global.diagnostics
end

-- Recording is dev-only: no capture and no SV growth unless Developer Tools is
-- on. A user investigating an issue flips that switch; only events AFTER that
-- are recorded (the same trade-off BugGrabber-style late loaders accept).
local function recordingEnabled()
    return addon.DB and addon.DB.global and addon.DB.global.devTools
end

-- Open the main window to the Diagnostics sub-tab (Logs > Diagnostics). The tab
-- lives under the Logs nexus, so "open diagnostics" = deep-link the Logs tab
-- with pendingSubTab — the same path the slash commands use. The toast and the
-- LDB/Titan broker both route through here.
local function openDiagnosticsTab()
    if not addon.MainWindow then return end
    local logs = addon.modules and addon.modules["logs"]
    if logs then logs.pendingSubTab = "diagnostics" end
    addon.MainWindow:Open("logs")
end

-- ---------------------------------------------------------------------------
-- Dedup index — keyed by kind+source+detail → the entry table itself (the same
-- table stored in the SV `entries` array, so a hit mutates SV in place). Built
-- lazily from SV on first use, like ItemDB's ensureIndex.
-- ---------------------------------------------------------------------------
local _index
local _indexBuilt = false

local function keyOf(kind, source, detail)
    return (kind or "") .. SEP .. (source or "") .. SEP .. (detail or "")
end

local function ensureIndex()
    if _indexBuilt then return _index end
    _index = {}
    local store = Store()
    if store and store.entries then
        for _, e in ipairs(store.entries) do
            _index[keyOf(e.kind, e.source, e.detail)] = e
        end
    end
    _indexBuilt = true
    return _index
end

-- Trim to MAX_ENTRIES, dropping the least-recently-seen first. Only runs when a
-- NEW distinct entry pushes past the cap, so the sort cost is rare.
local function prune(store)
    local entries = store.entries
    if #entries <= MAX_ENTRIES then return end
    table.sort(entries, function(a, b) return (a.last or 0) < (b.last or 0) end)
    while #entries > MAX_ENTRIES do
        local removed = table.remove(entries, 1)
        if removed then _index[keyOf(removed.kind, removed.source, removed.detail)] = nil end
    end
end

-- ---------------------------------------------------------------------------
-- Message parsing — split a Lua error/warning message into (source, reason):
--   "Interface\AddOns\Foo\Bar.lua:42: attempt to index nil value 'x'"
--      -> source = "Foo/Bar.lua:42", reason = "attempt to index nil value 'x'"
-- Non-standard forms (runtime [string "..."] chunks, messages with no path)
-- fall back to source "-" with the whole message as the reason, so nothing is
-- ever dropped — it just lands in the Detail column unparsed.
-- ---------------------------------------------------------------------------
local function splitMessage(msg)
    msg = tostring(msg or "")
    local path, line, reason = msg:match("^(.-):(%d+): (.*)$")
    if not path then
        return "-", msg
    end
    local norm = path:gsub("\\", "/")
    local addonName = norm:match("[Aa]dd[Oo]ns/([^/]+)/")
    local file = norm:match("([^/]+)$") or norm
    local source = addonName and (addonName .. "/" .. file .. ":" .. line)
                              or (file .. ":" .. line)
    return source, reason
end

-- ---------------------------------------------------------------------------
-- Record one captured event (dedup + count + timestamps).
-- ---------------------------------------------------------------------------
function Diagnostics:_record(kind, source, detail, ts)
    if not recordingEnabled() then return end
    local store = Store()
    if not store then return end
    store.entries = store.entries or {}

    local index = ensureIndex()
    local k = keyOf(kind, source, detail)
    local now = ts or (GetServerTime and GetServerTime()) or (time and time()) or 0

    local e = index[k]
    local isNew = false
    if e then
        e.count = (e.count or 1) + 1
        e.last  = now
    else
        e = { kind = kind, source = source, detail = detail, count = 1, first = now, last = now }
        store.entries[#store.entries + 1] = e
        index[k] = e
        isNew = true
        prune(store)
    end

    -- Broker/alert/refresh are best-effort: a fault here must never propagate
    -- back up the capture path (which, for errors, runs inside !TOGT's handler).
    pcall(function()
        self:_ensureBroker()
        self:_updateBroker()
        if isNew and kind == KIND_ERROR then self:_showAlert(e) end
    end)

    if self.onUpdate then self.onUpdate() end
end

-- ---------------------------------------------------------------------------
-- Capture source — the !TOGT companion. It installs the error handler and the
-- warning / blocked-action event frame at the very start of load (before
-- lettered addons, including this one), buffering into
-- TOGToolsEarlyData.diagnostics. We drain that buffer on enable and then attach
-- a live callback so later captures come straight here. Capture lives there, not
-- here, so login-time errors — which fire before TOGTools initializes — aren't
-- missed, and so
-- only ONE error handler is installed across the pair.
-- ---------------------------------------------------------------------------

-- Normalize a raw !TOGT record into (kind, source, detail) and record it.
-- Lua errors / warnings arrive as a raw message (`msg`) we split here; blocked /
-- forbidden addon-action records arrive pre-split (`source` = culprit addon,
-- `detail` = "blocked/forbidden: function") and are folded into the error kind.
function Diagnostics:_ingestRaw(rec)
    if not rec then return end
    local kind = KIND_BY_NAME[rec.kind] or KIND_ERROR
    local source, detail
    if rec.msg ~= nil then
        source, detail = splitMessage(rec.msg)
    else
        source, detail = rec.source or "?", rec.detail or "?"
    end
    self:_record(kind, source, detail, rec.t)
end

-- Drain the early buffer and go live. Attaching the callback BEFORE iterating
-- means a capture that fires mid-drain flows straight to _ingestRaw instead of
-- the (about-to-be-replaced) buffer — no loss, no double-count.
function Diagnostics:_attachCapture()
    local early = TOGToolsEarlyData and TOGToolsEarlyData.diagnostics
    if early then
        -- Recorded so the devTools setting knows not to install the fallback
        -- capture on top of the companion's. Without it, flipping dev tools on
        -- would chain a SECOND error handler and double-count every error.
        -- Set even when we do not attach below: the companion IS capturing.
        self._companionCapturing = true

        -- DO NOT TOUCH THE BUFFER WHILE RECORDING IS OFF -- audit finding tog-AL.
        -- This used to attach and drain unconditionally, which threw away the one
        -- thing the companion exists to provide. Two separate losses, and the
        -- second is the one that is easy to miss:
        --   * `early.buffer = {}` discarded the login-time records outright, and
        --     `_record`'s own gate meant the drain that "consumed" them stored
        --     nothing. The comment at the top of this section says capture lives
        --     in the companion "so login-time errors -- which fire before
        --     TOGTools initializes -- aren't missed"; we then missed them.
        --   * Setting `cb` makes the companion STOP BUFFERING (!TOGT.lua:58-63),
        --     so the ring buffer it maintains "until TOGTools drains / attaches"
        --     was switched off permanently, for a consumer that was dropping
        --     everything anyway.
        -- The user journey this breaks is the one recordingEnabled() itself
        -- describes: someone hits an error, then flips Developer Tools on to
        -- investigate. Before this, the errors that prompted them were already
        -- gone.
        --
        -- Leaving it alone costs nothing: the companion's buffer is ring-capped
        -- at 250 with its own `dropped` counter, which is exactly the mechanism
        -- for this wait. GUI/Settings.lua calls this again on the devTools flip,
        -- and the drain happens then.
        if not recordingEnabled() then return end

        -- Companion is capturing and we can store: consume its live feed and
        -- drain the early buffer. Attaching BEFORE iterating means a capture that
        -- fires mid-drain flows straight to _ingestRaw instead of the
        -- (about-to-be-replaced) buffer -- no loss, no double-count.
        early.cb = function(rec) Diagnostics:_ingestRaw(rec) end
        local buf = early.buffer
        early.buffer = {}
        if buf then
            for _, rec in ipairs(buf) do self:_ingestRaw(rec) end
        end
    else
        -- No companion diagnostics buffer — an older !TOGT without capture, or
        -- it failed to load. Fall back to capturing here so live errors and
        -- warnings still land; we only miss the pre-load window the companion
        -- would have covered.
        self:_installSelfCapture()
    end
end

-- Fallback capture, installed only when the !TOGT companion isn't providing it.
-- Mirrors what !TOGT does (chained error handler + warning/blocked-action event
-- frame). Idempotent.
--
-- ⛔ GATED ON recordingEnabled(), AND THE GATE HAS TO BE HERE RATHER THAN INSIDE
-- _record -- audit round 4 finding E. A chained error handler has NO INVERSE:
-- seterrorhandler takes one function, we capture the previous one and call it,
-- and there is no way to unchain ourselves afterwards without clobbering whoever
-- chained on top of us. So "install only when enabled" is the ONLY design under
-- which this can ever be off. Relying on _record's own gate meant the handler and
-- six event registrations went in permanently, for every user, on every login,
-- whether or not Developer Tools was ever switched on.
--
-- It is called again from the devTools setting (GUI/Settings.lua) so flipping the
-- switch on installs it there and then, rather than only at next login. The
-- _selfCaptureInstalled latch keeps that idempotent; flipping the switch back off
-- leaves the handler in place, which is exactly the no-inverse problem and is why
-- not installing in the first place is the thing that matters.
local _selfCaptureInstalled = false
function Diagnostics:_installSelfCapture()
    if _selfCaptureInstalled then return end
    if not recordingEnabled() then return end
    _selfCaptureInstalled = true

    local prev = geterrorhandler()
    seterrorhandler(function(msg)
        pcall(function()
            local source, reason = splitMessage(msg)
            Diagnostics:_record(KIND_ERROR, source, reason)
        end)
        if prev then return prev(msg) end
    end)

    local f = CreateFrame("Frame")
    f:RegisterEvent("LUA_WARNING")
    f:RegisterEvent("ADDON_ACTION_BLOCKED")
    f:RegisterEvent("ADDON_ACTION_FORBIDDEN")
    f:RegisterEvent("MACRO_ACTION_BLOCKED")
    f:RegisterEvent("MACRO_ACTION_FORBIDDEN")
    f:RegisterEvent("SAVED_VARIABLES_TOO_LARGE")
    f:SetScript("OnEvent", function(_, event, arg1, arg2)
        if event == "LUA_WARNING" then
            local source, reason = splitMessage(arg1)
            Diagnostics:_record(KIND_WARNING, source, reason)
        elseif event == "SAVED_VARIABLES_TOO_LARGE" then
            Diagnostics:_record(KIND_ERROR, arg1 or "?", "SavedVariables too large — not saved (data lost)")
        elseif event == "ADDON_ACTION_BLOCKED" then
            Diagnostics:_record(KIND_ERROR, arg1 or "?", "blocked: " .. tostring(arg2 or "?"))
        elseif event == "ADDON_ACTION_FORBIDDEN" then
            Diagnostics:_record(KIND_ERROR, arg1 or "?", "forbidden: " .. tostring(arg2 or "?"))
        elseif event == "MACRO_ACTION_BLOCKED" then
            Diagnostics:_record(KIND_ERROR, "macro", "blocked: " .. tostring(arg1 or "?"))
        elseif event == "MACRO_ACTION_FORBIDDEN" then
            Diagnostics:_record(KIND_ERROR, "macro", "forbidden: " .. tostring(arg1 or "?"))
        end
    end)
end

-- ---------------------------------------------------------------------------
-- New-error alert toast (BugSack-style). Shown when a NEW distinct error is
-- recorded; left-click jumps to the tab, right-click dismisses. Auto-fades.
-- Gated by the `alert` setting (default on).
-- ---------------------------------------------------------------------------
local _alert
local function ensureAlert()
    if _alert then return _alert end
    local f = CreateFrame("Button", "TOGToolsDiagAlert", UIParent,
        BackdropTemplateMixin and "BackdropTemplate" or nil)
    f:SetSize(360, 66)
    f:SetPoint("TOP", UIParent, "TOP", 0, -140)
    f:SetFrameStrata("HIGH")
    f:SetClampedToScreen(true)
    if f.SetBackdrop then
        f:SetBackdrop({
            bgFile   = "Interface\\DialogFrame\\UI-DialogBox-Background",
            edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
            tile = true, tileSize = 16, edgeSize = 16,
            insets = { left = 4, right = 4, top = 4, bottom = 4 },
        })
        f:SetBackdropColor(0, 0, 0, 0.92)
    end
    f:EnableMouse(true)
    f:RegisterForClicks("LeftButtonUp", "RightButtonUp")

    local icon = f:CreateTexture(nil, "ARTWORK")
    icon:SetSize(32, 32)
    icon:SetPoint("LEFT", 14, 0)
    icon:SetTexture("Interface\\AddOns\\TOGTools\\textures\\ToGTools_PH_MMB")

    -- Three lines stacked top-down from a common left edge (right of the icon).
    -- The previous layout anchored the hint to the icon's BOTTOM, which collided
    -- with the message in the short frame — that was the overlap.
    local title = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    title:SetPoint("TOPLEFT", f, "TOPLEFT", 56, -10)
    title:SetPoint("RIGHT", f, "RIGHT", -12, 0)
    title:SetJustifyH("LEFT")
    title:SetWordWrap(false)
    title:SetText("|cffFF8000TOG Tools|r — new error")

    local msg = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    msg:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -5)
    msg:SetPoint("RIGHT", f, "RIGHT", -12, 0)
    msg:SetJustifyH("LEFT")
    msg:SetWordWrap(false)
    f.msg = msg

    local hint = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    hint:SetPoint("TOPLEFT", msg, "BOTTOMLEFT", 0, -5)
    hint:SetJustifyH("LEFT")
    hint:SetText("Click to view  ·  right-click to dismiss")

    local ag = f:CreateAnimationGroup()
    local a = ag:CreateAnimation("Alpha")
    a:SetFromAlpha(1); a:SetToAlpha(0)
    a:SetDuration(0.8); a:SetStartDelay(ALERT_DURATION)
    ag:SetScript("OnFinished", function() f:Hide() end)
    f.fade = ag

    f:SetScript("OnClick", function(_, button)
        f.fade:Stop(); f:Hide()
        if button == "LeftButton" then
            openDiagnosticsTab()
        end
    end)
    f:SetScript("OnEnter", function() f.fade:Stop(); f:SetAlpha(1) end)
    f:SetScript("OnLeave", function() if f:IsShown() then f.fade:Play() end end)
    f:Hide()
    _alert = f
    return f
end

function Diagnostics:_showAlert(entry)
    local store = Store()
    if not (store and store.alert ~= false) then return end   -- default on
    local f = ensureAlert()
    local c = self:Counts()
    local detail = (entry and entry.detail) or ""
    if #detail > 64 then detail = detail:sub(1, 61) .. "..." end
    f.msg:SetText(string.format("|cffff5555%d|r error(s) captured  •  %s", c.error or 0, detail))
    f:Show(); f:SetAlpha(1)
    f.fade:Stop(); f.fade:Play()
end

-- ---------------------------------------------------------------------------
-- LibDataBroker data source — Titan Panel (and any LDB display) shows the live
-- error / warning counts; click to open the tab. type = "data source"
-- (not "launcher") so Titan auto-adds it as a plugin — per Grouper's setup.
-- Created only once Developer Tools is on, so end users get no broker clutter;
-- Titan picks it up live via LibDataBroker_DataObjectCreated (no reload needed).
-- ---------------------------------------------------------------------------
local _broker
local function brokerText()
    local c = Diagnostics:Counts()
    if (c.total or 0) == 0 then return "|cff888888no issues|r" end
    return string.format("|cffff5555%d|re |cffffcc00%d|rw",
        c.error or 0, c.warning or 0)
end

function Diagnostics:_ensureBroker()
    if _broker then return _broker end
    local LDB = LibStub("LibDataBroker-1.1", true)
    if not LDB then return nil end
    _broker = LDB:NewDataObject("TOGToolsDiagnostics", {
        type    = "data source",
        label   = "TOG Tools Errors",
        tocname = "TOGTools",
        text    = brokerText(),
        icon    = "Interface\\AddOns\\TOGTools\\textures\\ToGTools_PH_MMB",
        OnClick = function(_, button)
            if button == "RightButton" then
                StaticPopup_Show("TOGTOOLS_DIAG_CLEAR")
            else
                openDiagnosticsTab()
            end
        end,
        -- Every AddLine passes wrapText (the 5th arg): GameTooltip is one frame
        -- shared with Blizzard and every other addon, so an unwrapped line
        -- stretches it and drags their content out too.
        -- The title's separator is a plain hyphen, not an em dash -- the client's
        -- fonts stop at Latin-1 and render anything past it as a box.
        OnTooltipShow = function(tt)
            local c = Diagnostics:Counts()
            tt:AddLine("|cffFF8000TOG Tools - Diagnostics|r", nil, nil, nil, true)
            tt:AddLine(" ", nil, nil, nil, true)
            tt:AddLine(string.format("|cffff5555%d|r errors", c.error or 0), 1, 1, 1, true)
            tt:AddLine(string.format("|cffffcc00%d|r warnings", c.warning or 0), 1, 1, 1, true)
            tt:AddLine(" ", nil, nil, nil, true)
            tt:AddLine("|cffffd100Left-click|r to open the Diagnostics tab", 1, 1, 1, true)
            tt:AddLine("|cffffd100Right-click|r to clear", 1, 1, 1, true)
        end,
    })
    return _broker
end

function Diagnostics:_updateBroker()
    if _broker then _broker.text = brokerText() end
end

function Diagnostics:IsAlertOn()
    local s = Store()
    return not s or s.alert ~= false   -- default on
end

function Diagnostics:SetAlert(on)
    local s = Store()
    if s then s.alert = on and true or false end
end

-- ---------------------------------------------------------------------------
-- Capture gating (CVars). Errors are always captured via the handler, so they
-- need no CVar. Two optional event streams have CVar gates:
--   * Warnings — `scriptWarnings` enables the LUA_WARNING event.
--   * scriptErrors ("Display Lua Errors") — toggles Blizzard's built-in Lua
--     error popup AND is the gate that makes the ADDON_ACTION_BLOCKED /
--     _FORBIDDEN events fire to addons (we record those as errors). It's the
--     same CVar for both, so turning it on for the blocked-action errors also
--     brings the popup back. (This is NOT "taint" — true taint propagation goes
--     only to the unreadable taint.log via `taintLog`, which we don't touch.)
--     The events fire only on an ACTUAL blocked/forbidden action.
-- SetCVar is wrapped — if a client refuses the write the tab reads the live
-- state back and shows the /console fallback.
-- ---------------------------------------------------------------------------
function Diagnostics:IsWarningCaptureOn()
    return _GetCVar ~= nil and _GetCVar("scriptWarnings") == "1"
end

function Diagnostics:SetWarningCapture(on)
    if not _SetCVar then return false end
    return (pcall(_SetCVar, "scriptWarnings", on and "1" or "0")) and self:IsWarningCaptureOn() == (on and true or false)
end

-- scriptErrors: Blizzard's "Display Lua Errors" toggle. Also the gate that lets
-- the blocked/forbidden addon-action events reach addons. Pure scriptErrors 1/0.
function Diagnostics:IsScriptErrorsOn()
    return _GetCVar ~= nil and _GetCVar("scriptErrors") == "1"
end

function Diagnostics:SetScriptErrors(on)
    if not _SetCVar then return false end
    return (pcall(_SetCVar, "scriptErrors", on and "1" or "0")) and self:IsScriptErrorsOn() == (on and true or false)
end

-- ---------------------------------------------------------------------------
-- Read API
-- ---------------------------------------------------------------------------
function Diagnostics:GetEntries(kind)
    local store = Store()
    local out = {}
    if not (store and store.entries) then return out end
    for _, e in ipairs(store.entries) do
        if not kind or e.kind == kind then
            out[#out + 1] = e
        end
    end
    return out
end

function Diagnostics:Counts()
    local c = { error = 0, warning = 0, total = 0 }
    local store = Store()
    if store and store.entries then
        for _, e in ipairs(store.entries) do
            c[e.kind] = (c[e.kind] or 0) + 1
            c.total = c.total + 1
        end
    end
    return c
end

function Diagnostics:ClearAll()
    local store = Store()
    if store then store.entries = {} end
    _index = {}
    _indexBuilt = true
    self:_updateBroker()   -- refresh the Titan/LDB count, else it stays stale
    if self.onUpdate then self.onUpdate() end
end

-- Throw REAL errors on command — one per live capture path — for an honest
-- end-to-end test (not synthetic rows). Both are deferred to the next frame so
-- they fire OUTSIDE the click's AceGUI-pcall context and reach the global
-- handler / events:
--   1. a genuine Lua error via `error()` → the seterrorhandler path (tests our
--      handler + the !!TOGT wrap that lets us capture alongside BugGrabber).
--   2. a forbidden protected-function call (CopyToClipboard) → an
--      ADDON_ACTION_FORBIDDEN event (tests the event-frame path).
-- Both are intentional and harmless; both also land in BugSack.
function Diagnostics:FireTestError()
    C_Timer.After(0, function()
        error("TOG Tools Diagnostics test (intentional Lua error)")
    end)
    C_Timer.After(0, function()
        CopyToClipboard("TOG Tools Diagnostics test")
    end)
end

function Diagnostics:OnEnable()
    -- Capture itself lives in the !TOGT companion; here we drain its early
    -- buffer (login-time errors) and attach a live callback for the rest of the
    -- session. _record self-gates on the Developer Tools switch, so when dev
    -- tools are off this just clears the buffer without storing.
    self:_attachCapture()
    if recordingEnabled() then self:_ensureBroker() end
end
