-- ============================================================
-- FILE: Console.lua
-- MODULE: MidnightUI_Console  (_G.MidnightUI_Console, local M)
--
-- PURPOSE:
--   The addon's unified diagnostic capture engine. Funnels every
--   Lua error, taint event, UI error, warning, chat print, and
--   debug message into a single in-memory store keyed by
--   fingerprint. Sole owner of diagnostic capture and the
--   _G.MidnightUI_Diagnostics API surface that other modules log
--   through.
--
-- HOW IT WORKS:
--   Three-stage pipeline:
--     1. Ingest (Capture): always succeeds, never blocks. Hashes
--        the incoming signal to a fingerprint (kind + primary
--        file:line + normalized message template). If the
--        fingerprint already exists, increments a counter —
--        repeats are free. Otherwise creates a new incident
--        record and enqueues it for background enrichment.
--     2. Enrich: a C_Timer ticker pulls from the enrichment queue
--        in batches every 0.1s and fills in expensive fields
--        (addon attribution, context snapshot, severity
--        classifier). Missing enrichment does NOT prevent an
--        entry from being counted or searched.
--     3. Store: the engine's in-memory tables ARE its saved data
--        (MidnightUIConsoleDB). WoW writes them out on logout.
--        No separate persist step needed.
--
--   Signal sources (the fail-safe net — designed so nothing that
--   could reasonably be considered an error or debug signal can
--   silently escape capture):
--     - Lua errors: a CHAINED seterrorhandler() handler. Ours
--       captures, then calls the previous handler so BugSack (if
--       installed) still sees the error. A 10-second integrity
--       ticker re-chains us on top if another addon replaces the
--       handler without chaining.
--     - Taint / warnings: an event frame registered for
--       ADDON_ACTION_FORBIDDEN / ADDON_ACTION_BLOCKED /
--       LUA_WARNING. Blizzard UI error messages
--       (UI_ERROR_MESSAGE — "Ability not ready", "Not enough
--       rage", etc.) are gameplay feedback, not bug signal, and
--       are intentionally not captured here.
--     - Chat-frame output: hooksecurefunc on every chat frame's
--       AddMessage (DEFAULT + ChatFrame1..NUM_CHAT_WINDOWS).
--       Filtered by a Lua-specific heuristic (MidnightUI markers,
--       "attempt to call/index/concatenate/...", Interface\AddOns
--       paths, [string "..."] chunks, file.lua:line fragments)
--       so ordinary player chat never triggers a capture.
--     - print() tee: the global print function is wrapped so every
--       call to print(...) is captured before being forwarded to
--       the original, regardless of line content or target frame.
--       This closes the historical gap where an ad-hoc debug line
--       without a recognised marker was lost.
--     - Debug API: _G.MidnightUI_Diagnostics.{LogDebug,
--       LogDebugSource, LogExternal} and _G.MidnightUI_DebugSource
--       are defined here and route directly into Capture.
--     - Startup queues: MidnightUI_DebugQueue and
--       MidnightUI_DiagnosticsQueue are drained into the engine
--       on first load so messages logged before the engine was
--       ready are preserved. The queues are wiped on a slow
--       ticker afterwards since their writes are duplicated by
--       the shim callers.
--
--   Zero-drop guarantee:
--     - The ingest call never refuses a signal. No per-second
--       throttle at this layer. Repeats cost one counter bump.
--     - If unique incident count in a session exceeds 5000, the
--       oldest-age entries spill into MidnightUIConsoleDB.archive
--       rather than being dropped.
--     - Enrichment and persistence run off the hot path, so even
--       catastrophic floods do not back-pressure ingest.
--     - A self-recursion guard stops our own code from infinitely
--       re-entering the capture path if something inside our code
--       errors and triggers the error handler.
--
-- DEPENDS ON:
--   Bootstrap.lua — relies on the existing MidnightUI_DebugQueue
--     and MidnightUI_DiagnosticsQueue tables existing (Bootstrap
--     creates them at load position 1; Console is position 2, so
--     this is guaranteed).
--   Nothing else; the engine is otherwise self-contained.
--
-- EXPORTS (globals other code uses):
--   _G.MidnightUI_Console                    — module table
--   _G.MidnightUI_Console_Capture(...)       — native ingest API
--   _G.MidnightUI_Diagnostics                — compat API surface
--   _G.MidnightUI_DebugSource(source, msg)   — preferred debug entry
--   _G.MidnightUI_ReportError(...)           — external error report
--   _G.MidnightUI_ReportWarning(...)         — external warning report
--   _G.MidnightUI_DiagnosticsActive          — true sentinel
--   _G.MidnightUI_DiagnosticsHasEntries()    — bool
--
-- OWNED SAVEDVARIABLES:
--   MidnightUIConsoleDB:
--     .meta       — schema version and cumulative totals
--     .session    — session counter (+1 on each login)
--     .sessions   — per-session metadata (start time, context)
--     .incidents  — map of fingerprint -> incident record (active)
--     .archive    — overflow store when incidents exceeds 5000
--
-- EVENTS REGISTERED:
--   ADDON_LOADED, PLAYER_LOGIN, ADDON_ACTION_BLOCKED,
--   ADDON_ACTION_FORBIDDEN, LUA_WARNING
--
-- FRAMES CREATED:
--   One event frame (MidnightUI_Console_Events). UI frames live
--   in Console_UI*.lua.
--
-- SLASH COMMANDS:
--   /muiconsole status             — print capture stats
--   /muiconsole flood <N>          — fire N identical events (dedup test)
--   /muiconsole flood <N> u        — fire N unique events (zero-drop test)
--   /muiconsole dump [N]           — print last N incidents (default 20)
--   /muiconsole clear              — wipe the engine store
--   /muiconsole help               — list commands
--   (aliases: /muic, /muibugs, /midnightbugs, /muimemory)
--
-- GOTCHAS:
--   - MUST load AFTER Bootstrap.lua (for the queues it drains).
--   - The debug-global tee shims are installed on PLAYER_LOGIN,
--     NOT at file scope. Intentional: by PLAYER_LOGIN Messenger
--     has finished redefining MidnightUI_Debug. Wrapping earlier
--     would just get overwritten by Messenger and lost.
--   - The error handler install is deferred one frame via
--     C_Timer.After(0) so we chain as the outermost handler on
--     top of whatever BugSack or similar tools installed. The
--     chain runs: WoW error -> Console handler -> prior -> done.
--   - Capture() is guarded against re-entry. If enrichment code
--     itself errors and triggers the handler, the nested call
--     queues to a re-entry list and is replayed safely next tick,
--     preserving zero-drop.
-- ============================================================

local _G = _G
local CreateFrame = CreateFrame
local C_Timer = _G.C_Timer
local debugstack = _G.debugstack
local debuglocals = _G.debuglocals
local debugprofilestop = _G.debugprofilestop
local geterrorhandler = _G.geterrorhandler
local seterrorhandler = _G.seterrorhandler
local InCombatLockdown = _G.InCombatLockdown
local GetRealZoneText = _G.GetRealZoneText
local GetSubZoneText = _G.GetSubZoneText
local IsInInstance = _G.IsInInstance
local UnitClass = _G.UnitClass
local UnitLevel = _G.UnitLevel
local UnitName = _G.UnitName
local GetSpecialization = _G.GetSpecialization
local GetSpecializationInfo = _G.GetSpecializationInfo
local GetNumGroupMembers = _G.GetNumGroupMembers
local GetBuildInfo = _G.GetBuildInfo
local GetLocale = _G.GetLocale
local time = _G.time
local date = _G.date
local pcall = _G.pcall
local tostring = _G.tostring
local type = _G.type
local select = _G.select
local pairs = _G.pairs
local ipairs = _G.ipairs
local table = _G.table
local string = _G.string
local math = _G.math
local wipe = _G.wipe
local print = _G.print  -- safe local capture; engine uses this only for slash output

-- ============================================================
-- SECTION: Module Init + Constants + State
-- Creates the module table and declares every piece of local
-- state the engine uses. All tuning knobs live here.
-- ============================================================
local M = _G.MidnightUI_Console or {}
_G.MidnightUI_Console = M

local SCHEMA_VERSION       = 1
local MAX_ACTIVE_INCIDENTS = 5000      -- soft cap; past this, oldest spill to archive
local MAX_ARCHIVE_SIZE     = 20000     -- hard cap on archive so storage cannot grow unbounded
local ENRICH_TICK_SEC      = 0.1       -- enrichment worker cadence
local ENRICH_BATCH_SIZE    = 20        -- entries enriched per tick at baseline rate
local ENRICH_BATCH_BOOST   = 200       -- entries enriched per tick when queue is deep
local ENRICH_DEEP_QUEUE    = 500       -- queue depth that triggers boost mode
local MAX_MESSAGE_LEN      = 4000      -- trim overly long messages before storing
local MAX_STACK_LEN        = 8000      -- trim overly long stacks before storing
local MAX_LOCALS_LEN       = 8000      -- trim overly long locals before storing

-- Live state (not persisted)
local _inCapture          = false   -- re-entry guard
local _preloadQueue       = {}      -- captures that happened before EnsureDB
local _reentryQueue       = {}      -- nested captures detected by the guard
local _enrichQueue        = {}      -- entries awaiting enrichment
local _activeIncidentCount = 0      -- cached count of M.db.incidents
local _archivedCount      = 0       -- cached count of M.db.archive
local _totalCaptured      = 0       -- cumulative events seen this session (all kinds)
local _engineErrors       = 0       -- count of errors inside engine code itself
local _shimsInstalled     = false   -- prevent double-wrap of debug globals
local _errorHandlerChain  = nil     -- reference to our installed handler
local _enrichTicker       = nil     -- C_Timer ticker handle
local _eventFrame         = nil     -- event listener frame
local _combatDeferredOpen = false   -- Phase 1 placeholder for UI open deferral

-- ============================================================
-- SECTION: Safe String Conversion
-- Canonical strictest taint-safe tostring. Catches Blizzard
-- secret values that would crash naive tostring paths. Used
-- everywhere the engine touches a value of unknown origin.
--
-- Three guards:
--   1. Can tostring() succeed at all?
--   2. Can the resulting string be concatenated?
--   3. Can the string be used as a table key?
-- Any failure -> a safe placeholder is returned.
-- ============================================================
local function SafeToString(value)
    if value == nil then return "nil" end
    if type(value) == "string" then
        -- even plain strings can be secret-tainted
        local okA = pcall(function() return value .. "" end)
        if not okA then return "[Restricted]" end
        local okB = pcall(function() local t = {}; t[value] = true end)
        if not okB then return "[Restricted Secret Value]" end
        return value
    end
    local ok, s = pcall(tostring, value)
    if not ok or type(s) ~= "string" then return "[Restricted]" end
    local okA = pcall(function() return s .. "" end)
    if not okA then return "[Restricted]" end
    local okB = pcall(function() local t = {}; t[s] = true end)
    if not okB then return "[Restricted Secret Value]" end
    return s
end

local function Trim(s, max)
    if type(s) ~= "string" then return "" end
    if #s <= max then return s end
    return s:sub(1, max) .. "...[truncated]"
end

-- Filter / classification helpers used by the logging wrappers
-- and the chat-frame hook.
local function IsMsgSkippable(msg)
    local ok, result = pcall(function()
        return msg == "nil" or msg == "[Restricted]" or msg == "[Restricted Message Hidden]"
    end)
    return ok and result
end

local function IsTaintTrace(msg)
    if type(msg) ~= "string" then return false end
    local ok, result = pcall(function()
        return msg:find("%[TaintTrace%]") ~= nil
    end)
    return ok and result or false
end

local function IsDiagnosticsEnabled()
    local s = _G.MidnightUISettings
    if s and s.General and s.General.diagnosticsEnabled ~= nil then
        return s.General.diagnosticsEnabled and true or false
    end
    return true
end

-- ============================================================
-- SECTION: Fingerprinting
-- Produces a stable key for dedup. A fingerprint is:
--   kind + "|" + source + "|" + primaryFileLine + "|" + normalizedMessage
-- The normalized message replaces numbers, hex addresses, and
-- table/function pointers with placeholders so two logically
-- identical messages with different numeric values collapse
-- into one incident. Much stronger dedup than today's system.
-- ============================================================
local function IsConsoleFrame(path)
    if type(path) ~= "string" then return false end
    if path:find("MidnightUI/Console.lua", 1, true) then return true end
    if path:find("MidnightUI\\Console.lua", 1, true) then return true end
    return false
end

local function ExtractPrimaryFileLine(stack, message)
    if type(stack) == "string" and stack ~= "" then
        local fallback
        local ok = pcall(function()
            for file, line in stack:gmatch("([^%s]+%.lua):(%d+)") do
                local sig = file .. ":" .. line
                if not fallback then fallback = sig end
                if not IsConsoleFrame(file) then
                    fallback = sig
                    return
                end
            end
        end)
        if ok and fallback then return fallback end
    end
    if type(message) == "string" then
        local ok, file, line = pcall(function()
            return string.match(message, "([^%s]+%.lua):(%d+)")
        end)
        if ok and file and line then return file .. ":" .. line end
    end
    return ""
end

local function NormalizeMessage(msg)
    if type(msg) ~= "string" or msg == "" then return "" end
    local s = msg
    -- hex addresses
    s = s:gsub("0[xX][%x]+", "#x#")
    -- table: / function: pointers
    s = s:gsub("table:%s*#x#", "table:@")
    s = s:gsub("function:%s*#x#", "function:@")
    -- runs of digits
    s = s:gsub("%d+", "#n#")
    return s
end

local function ComputeFingerprint(kind, source, message, stack)
    local primary = ExtractPrimaryFileLine(stack, message)
    local normMsg = NormalizeMessage(message)
    return (kind or "Unknown")
        .. "|" .. (source or "")
        .. "|" .. primary
        .. "|" .. normMsg
end

-- ============================================================
-- SECTION: Context Snapshot + Addon Attribution
-- Built during enrichment (deferred off the hot path).
-- ============================================================
local function BuildContext()
    local ctx = {}
    if UnitName then ctx.player = UnitName("player") end
    if GetRealmName then ctx.realm = GetRealmName() end
    if InCombatLockdown then ctx.combat = InCombatLockdown() and true or false end
    if GetRealZoneText then
        local z = GetRealZoneText()
        if z and z ~= "" then ctx.zone = z end
    end
    if GetSubZoneText then
        local sz = GetSubZoneText()
        if sz and sz ~= "" then ctx.subzone = sz end
    end
    if IsInInstance then
        local inInst, instType = IsInInstance()
        ctx.inInstance = inInst and true or false
        ctx.instanceType = instType or "none"
    end
    if UnitClass then
        local _, classTag = UnitClass("player")
        ctx.class = classTag
    end
    if UnitRace then
        local raceName, raceTag = UnitRace("player")
        ctx.race = raceName
        ctx.raceTag = raceTag
    end
    if UnitLevel then ctx.level = UnitLevel("player") end
    if GetSpecialization and GetSpecializationInfo then
        local idx = GetSpecialization()
        if idx then
            local _, specName = GetSpecializationInfo(idx)
            ctx.spec = specName
        end
    end
    if GetNumGroupMembers then ctx.groupSize = GetNumGroupMembers() or 0 end
    if UIParent and UIParent.GetScale then ctx.uiScale = UIParent:GetScale() end
    if GetScreenWidth  then ctx.screenW = GetScreenWidth()  end
    if GetScreenHeight then ctx.screenH = GetScreenHeight() end
    return ctx
end

local function BuildLoadedAddonsList()
    local result = {}
    local getNum  = (C_AddOns and C_AddOns.GetNumAddOns)  or _G.GetNumAddOns
    local getInfo = (C_AddOns and C_AddOns.GetAddOnInfo)  or _G.GetAddOnInfo
    local isLoaded = (C_AddOns and C_AddOns.IsAddOnLoaded) or _G.IsAddOnLoaded
    local getMeta  = (C_AddOns and C_AddOns.GetAddOnMetadata) or _G.GetAddOnMetadata
    if not getNum or not getInfo or not isLoaded then return result end
    local ok, count = pcall(getNum)
    if not ok or not count then return result end
    for i = 1, count do
        local okI, name = pcall(getInfo, i)
        if okI and name then
            local okL, loaded = pcall(isLoaded, i)
            if okL and loaded then
                local ver = getMeta and getMeta(name, "Version") or ""
                if ver and ver ~= "" then
                    result[#result + 1] = name .. ":" .. ver
                else
                    result[#result + 1] = name
                end
            end
        end
    end
    return result
end

local function BuildSessionContext()
    local ctx = BuildContext()
    if UnitName then ctx.player = UnitName("player") end
    if GetBuildInfo then
        local version, build, dateStr, toc = GetBuildInfo()
        ctx.client    = version
        ctx.build     = build
        ctx.buildDate = dateStr
        ctx.toc       = toc
    end
    if GetLocale then ctx.locale = GetLocale() end
    ctx.loadedAddons = BuildLoadedAddonsList()
    return ctx
end

local function DetectAddon(message, stack)
    local function scan(text)
        if type(text) ~= "string" or text == "" then return nil end
        local name = text:match("Interface\\AddOns\\([^\\]+)")
            or text:match("Interface/AddOns/([^/]+)")
        if name then return name end
        if text:find("Interface\\FrameXML", 1, true) then return "Blizzard" end
        if text:find("Interface/FrameXML", 1, true) then return "Blizzard" end
        if text:find("Interface\\SharedXML", 1, true) then return "Blizzard" end
        if text:find("Interface/SharedXML", 1, true) then return "Blizzard" end
        if text:find("Blizzard_", 1, true) then return "Blizzard" end
        return nil
    end
    return scan(message) or scan(stack)
end

local function ClassifySeverity(entry)
    local kind = entry.kind
    if kind == "LuaError" then return "critical" end
    if kind == "Taint"    then return "high" end
    if kind == "Warning"  then return "medium" end
    if kind == "UIError"  then return "low" end
    if kind == "Debug"    then return "info" end
    return "info"
end

-- ============================================================
-- SECTION: Storage Layer
-- Initialises MidnightUIConsoleDB, increments session counter,
-- records session metadata, and rebuilds the cached incident
-- counter from the persisted store on load.
-- ============================================================
local function EnsureDB()
    if type(_G.MidnightUIConsoleDB) ~= "table" then
        _G.MidnightUIConsoleDB = {}
    end
    local db = _G.MidnightUIConsoleDB

    db.meta = db.meta or {}
    db.meta.version = SCHEMA_VERSION
    db.meta.totalCaptured = db.meta.totalCaptured or 0
    db.meta.totalIncidents = db.meta.totalIncidents or 0

    db.session = (type(db.session) == "number" and db.session or 0) + 1
    db.incidents = db.incidents or {}
    db.archive   = db.archive   or {}
    db.sessions  = db.sessions  or {}
    db.sessions[db.session] = {
        startTime = time and time() or 0,
        context   = BuildSessionContext(),
    }

    M.db = db

    -- Rebuild cached counts from persisted store
    _activeIncidentCount = 0
    for _ in pairs(db.incidents) do
        _activeIncidentCount = _activeIncidentCount + 1
    end
    _archivedCount = 0
    for _ in pairs(db.archive) do
        _archivedCount = _archivedCount + 1
    end
end

local function SpillOldestToArchive()
    -- Move ~10% of oldest-age active incidents into archive.
    -- Oldest = smallest lastSeen.
    local target = math.max(1, math.floor(MAX_ACTIVE_INCIDENTS * 0.1))
    local list = {}
    for fp, e in pairs(M.db.incidents) do
        list[#list + 1] = { fp = fp, lastSeen = e.lastSeen or 0 }
    end
    table.sort(list, function(a, b) return a.lastSeen < b.lastSeen end)

    local moved = 0
    for i = 1, math.min(target, #list) do
        local fp = list[i].fp
        local e = M.db.incidents[fp]
        if e then
            M.db.incidents[fp] = nil
            _activeIncidentCount = _activeIncidentCount - 1
            if _archivedCount < MAX_ARCHIVE_SIZE then
                M.db.archive[fp] = e
                _archivedCount = _archivedCount + 1
            end
            -- if archive is full, the entry simply drops out of the
            -- working set here. It was still counted in meta.totalCaptured
            -- so the cumulative signal is preserved; only detail is lost.
            moved = moved + 1
        end
    end
    return moved
end

-- ============================================================
-- SECTION: Ingest Pipeline (Stage 1)
-- The single entry point every signal funnels through. Never
-- blocks, never refuses, never throws. Repeats are an integer
-- increment. New fingerprints enqueue for background enrichment.
-- ============================================================
local function DoCapture(kind, source, message, stack, locals, meta)
    if not M.db then
        -- DB not ready yet — park in preload queue for later drain
        _preloadQueue[#_preloadQueue + 1] = {
            kind = kind, source = source, message = message,
            stack = stack, locals = locals, meta = meta,
        }
        return
    end

    local safeMsg    = Trim(SafeToString(message),  MAX_MESSAGE_LEN)
    local safeStack  = Trim(SafeToString(stack or ""),  MAX_STACK_LEN)
    local safeLocals = Trim(SafeToString(locals or ""), MAX_LOCALS_LEN)
    local safeMeta   = SafeToString(meta or "")
    local safeKind   = SafeToString(kind or "Unknown")
    local safeSource = SafeToString(source or "")

    _totalCaptured = _totalCaptured + 1
    M.db.meta.totalCaptured = (M.db.meta.totalCaptured or 0) + 1

    local now = time and time() or 0
    local fp  = ComputeFingerprint(safeKind, safeSource, safeMsg, safeStack)

    -- Existing in active set? repeat = free
    local existing = M.db.incidents[fp]
    if existing then
        existing.count = (existing.count or 0) + 1
        existing.lastSeen = now

        -- Refresh "last seen context" at most once every 5 seconds so
        -- BuildContext's WoW API calls don't land on the hot path
        -- when a single fingerprint fires thousands of times per sec.
        if not existing.lastContextAt or (now - existing.lastContextAt) >= 5 then
            existing.lastContext   = BuildContext()
            existing.lastContextAt = now
        end

        -- Append to rolling hits buffer for the trend sparkline.
        -- Capped so a runaway incident cannot grow this indefinitely.
        -- 200 gives chart visualisations real density to work with
        -- while staying well under any practical memory concern.
        if not existing.hits then existing.hits = {} end
        existing.hits[#existing.hits + 1] = now
        if #existing.hits > 200 then
            table.remove(existing.hits, 1)
        end
        return
    end

    -- Existing in archive? promote and count
    local archived = M.db.archive[fp]
    if archived then
        archived.count = (archived.count or 0) + 1
        archived.lastSeen = now
        -- promote back to active if there is room
        if _activeIncidentCount < MAX_ACTIVE_INCIDENTS then
            M.db.archive[fp] = nil
            _archivedCount = _archivedCount - 1
            M.db.incidents[fp] = archived
            _activeIncidentCount = _activeIncidentCount + 1
            -- may need re-enrichment if its stored detail was lost
            if not archived.enriched then
                _enrichQueue[#_enrichQueue + 1] = archived
            end
        end
        return
    end

    -- New incident
    if _activeIncidentCount >= MAX_ACTIVE_INCIDENTS then
        SpillOldestToArchive()
    end

    local entry = {
        fingerprint   = fp,
        kind          = safeKind,
        source        = safeSource,
        message       = safeMsg,
        rawStack      = safeStack,
        rawLocals     = safeLocals,
        meta          = safeMeta,
        count         = 1,
        firstSeen     = now,
        lastSeen      = now,
        session       = M.db.session,
        enriched      = false,
        hits          = { now },
        lastContextAt = now,
    }
    M.db.incidents[fp] = entry
    _activeIncidentCount = _activeIncidentCount + 1
    M.db.meta.totalIncidents = (M.db.meta.totalIncidents or 0) + 1
    _enrichQueue[#_enrichQueue + 1] = entry
end

function M.Capture(kind, source, message, stack, locals, meta)
    -- Re-entry guard: if our own code is already inside a capture
    -- and something triggered a second one (e.g., our error handler
    -- running because enrichment errored), defer the nested call.
    if _inCapture then
        _reentryQueue[#_reentryQueue + 1] = {
            kind = kind, source = source, message = message,
            stack = stack, locals = locals, meta = meta,
        }
        return
    end
    _inCapture = true
    local ok = pcall(DoCapture, kind, source, message, stack, locals, meta)
    _inCapture = false
    if not ok then
        _engineErrors = _engineErrors + 1
    end
end

_G.MidnightUI_Console_Capture = M.Capture

-- ============================================================
-- SECTION: External Reporting Hooks (Primary Definitions)
-- These globals are the contract other addons (or MidnightUI
-- modules) use to push signals into the diagnostic pipeline.
--
-- Supported signatures:
--
--   MidnightUI_DebugSource(source, message)
--   MidnightUI_ReportError(addon, message, stack, locals, meta)
--   MidnightUI_ReportError{ addon=, message=, stack=, locals=, meta= }
--   MidnightUI_ReportWarning(addon, message, stack, locals, meta)
--   MidnightUI_ReportWarning{ addon=, message=, stack=, ... }
--
-- Defined at file scope so they are callable immediately after
-- Console.lua loads - before any other addon's initialization
-- code runs. M.Capture handles the "engine not yet ready" case
-- by parking the entry in a pre-load queue.
-- ============================================================
_G.MidnightUI_DebugSource = function(source, message)
    if not IsDiagnosticsEnabled() then return end
    local msg = SafeToString(message or "")
    if IsMsgSkippable(msg) then return end
    local src = SafeToString(source or "DebugSource")
    local kind, meta = "Debug", "source=" .. src
    if IsTaintTrace(msg) then kind, meta = "Taint", "source=TaintTrace" end
    M.Capture(kind, src, msg, "", "", meta)
    if _G.MidnightUI_SetDebugNotify then pcall(_G.MidnightUI_SetDebugNotify) end
end

_G.MidnightUI_ReportError = function(addon, message, stack, locals, meta)
    if not IsDiagnosticsEnabled() then return end
    local a, m, s, l, mt = addon, message, stack, locals, meta
    if type(a) == "table" then
        local t = a
        a  = t.addon or t.source or t.name
        m  = t.message or t.msg or t.error
        s  = t.stack
        l  = t.locals
        mt = t.meta
    end
    local safeMsg = SafeToString(m or "")
    if IsMsgSkippable(safeMsg) then return end
    M.Capture("LuaError",
              SafeToString(a or "external"),
              safeMsg,
              SafeToString(s or ""),
              SafeToString(l or ""),
              SafeToString(mt or ""))
end

_G.MidnightUI_ReportWarning = function(addon, message, stack, locals, meta)
    if not IsDiagnosticsEnabled() then return end
    local a, m, s, l, mt = addon, message, stack, locals, meta
    if type(a) == "table" then
        local t = a
        a  = t.addon or t.source or t.name
        m  = t.message or t.msg or t.warning
        s  = t.stack
        l  = t.locals
        mt = t.meta
    end
    local safeMsg = SafeToString(m or "")
    if IsMsgSkippable(safeMsg) then return end
    M.Capture("Warning",
              SafeToString(a or "external"),
              safeMsg,
              SafeToString(s or ""),
              SafeToString(l or ""),
              SafeToString(mt or ""))
end

-- ============================================================
-- SECTION: MidnightUI_Diagnostics API Surface
-- Most MidnightUI modules (AchievementsPanel, ConditionBorder,
-- CooldownManager, Core, FocusFrame, PartyFrames, Settings, ...)
-- call into _G.MidnightUI_Diagnostics.<method> directly. This is
-- the canonical definition of that table - every method routes
-- into Console.Capture.
-- ============================================================
_G.MidnightUI_Diagnostics = {
    LogDebug = function(message)
        if not IsDiagnosticsEnabled() then return end
        local msg = SafeToString(message or "")
        if IsMsgSkippable(msg) then return end
        local kind, meta = "Debug", ""
        if IsTaintTrace(msg) then kind, meta = "Taint", "source=TaintTrace" end
        M.Capture(kind, "MidnightUI_Diagnostics", msg, "", "", meta)
        if _G.MidnightUI_SetDebugNotify then pcall(_G.MidnightUI_SetDebugNotify) end
    end,
    LogDebugSource = function(source, message)
        if not IsDiagnosticsEnabled() then return end
        local msg = SafeToString(message or "")
        if IsMsgSkippable(msg) then return end
        local src = SafeToString(source or "DebugSource")
        local kind, meta = "Debug", "source=" .. src
        if IsTaintTrace(msg) then kind, meta = "Taint", "source=TaintTrace" end
        M.Capture(kind, src, msg, "", "", meta)
        if _G.MidnightUI_SetDebugNotify then pcall(_G.MidnightUI_SetDebugNotify) end
    end,
    LogExternal = function(addon, message, stack, locals, meta, kind)
        if not IsDiagnosticsEnabled() then return end
        local msg = SafeToString(message or "")
        if IsMsgSkippable(msg) then return end
        M.Capture(kind or "LuaError",
                  SafeToString(addon or "external"),
                  msg,
                  SafeToString(stack or ""),
                  SafeToString(locals or ""),
                  SafeToString(meta or ""))
        if _G.MidnightUI_SetDebugNotify then pcall(_G.MidnightUI_SetDebugNotify) end
    end,
    IsEnabled = function() return IsDiagnosticsEnabled() end,
    HasEntries = function()
        return (M.db and M.db.incidents and next(M.db.incidents) ~= nil) or false
    end,
    Open = function()
        if _G.MidnightUI_Console_UI and _G.MidnightUI_Console_UI.Open then
            _G.MidnightUI_Console_UI.Open()
        end
    end,
    Refresh = function()
        -- No-op: the new Console refreshes itself on timers.
    end,
}

_G.MidnightUI_DiagnosticsActive = true
_G.MidnightUI_DiagnosticsHasEntries = function()
    return _G.MidnightUI_Diagnostics.HasEntries()
end

-- ============================================================
-- SECTION: Enrichment Pipeline (Stage 2)
-- Runs off the hot path on a C_Timer ticker. Fills in expensive
-- fields on each unenriched entry. Also replays the re-entry
-- queue if any nested captures accumulated during a burst.
-- Adaptive: boosts batch size when the queue is deep.
-- ============================================================
local function EnrichEntry(entry)
    if entry.enriched then return end
    entry.addon    = DetectAddon(entry.message, entry.rawStack)
    entry.primary  = ExtractPrimaryFileLine(entry.rawStack, entry.message)
    entry.context  = BuildContext()
    -- lastContext mirrors the first context until a repeat capture
    -- refreshes it, so the inspector always has both sides populated.
    if not entry.lastContext then
        entry.lastContext = entry.context
    end
    entry.severity = ClassifySeverity(entry)
    entry.enriched = true
end

local function EnrichmentTick()
    local budget = (#_enrichQueue >= ENRICH_DEEP_QUEUE) and ENRICH_BATCH_BOOST or ENRICH_BATCH_SIZE
    while budget > 0 and #_enrichQueue > 0 do
        local entry = table.remove(_enrichQueue, 1)
        if entry then pcall(EnrichEntry, entry) end
        budget = budget - 1
    end

    -- Replay any nested captures the re-entry guard deferred.
    if #_reentryQueue > 0 then
        local replay = _reentryQueue
        _reentryQueue = {}
        for i = 1, #replay do
            local r = replay[i]
            M.Capture(r.kind, r.source, r.message, r.stack, r.locals, r.meta)
        end
    end
end

-- ============================================================
-- SECTION: Signal Source — Lua Errors
-- Chains our handler after whatever handler is currently
-- installed. Our handler captures, then forwards to the prior
-- handler so any other installed handler (e.g. BugSack) still
-- receives the signal.
-- ============================================================
local function GetStackAndLocals(skip)
    local stack, locals = "", ""
    if debugstack then
        local ok, ds = pcall(debugstack, skip or 3, 12, 12)
        if ok and ds then stack = ds end
    end
    if debuglocals then
        local ok, dl = pcall(debuglocals, skip or 3)
        if ok and dl then locals = dl end
    end
    return stack, locals
end

-- ============================================================
-- SECTION: Blizzard Red Popup Suppression
-- Silent capture is non-negotiable: the Console must capture
-- every Lua error into its store WITHOUT Blizzard's red popup
-- stealing focus and interrupting the player. We disable the
-- popup frame on ADDON_LOADED via an idempotent flag so repeat
-- calls (and third-party error tools) cannot re-open it.
-- ============================================================
local function SuppressScriptErrors()
    local errFrame = _G.ScriptErrorsFrame
    if not errFrame then return end
    if errFrame._muiSuppressed then return end
    errFrame._muiSuppressed = true
    errFrame:Hide()
    -- Replace Show with a no-op so nothing can re-open it.
    errFrame.Show = function() return end
    -- Belt-and-braces: if something bypasses the Show override
    -- and manipulates the frame directly, the OnShow hook still
    -- hides it on the next frame.
    if errFrame.HookScript then
        errFrame:HookScript("OnShow", function(self)
            self:Hide()
        end)
    end
end

local function InstallErrorHandler()
    if not (seterrorhandler and geterrorhandler) then return end
    local prior = geterrorhandler()
    if prior == _errorHandlerChain then return end

    local function handler(err)
        -- Try to avoid stack capture on stack-overflow errors
        local msg = SafeToString(err)
        local stack, locals = "", ""
        if not (type(msg) == "string" and msg:find("stack overflow", 1, true)) then
            stack, locals = GetStackAndLocals(3)
        end
        M.Capture("LuaError", "seterrorhandler", msg, stack, locals, "")
        if prior and prior ~= handler then
            pcall(prior, err)
        end
    end
    _errorHandlerChain = handler
    seterrorhandler(handler)
end

-- ============================================================
-- SECTION: Signal Source — Taint / Warnings / UI Errors
-- Event listeners on our own frame. WoW delivers each event to
-- every registered frame, so third-party error tools (BugSack,
-- etc.) continue to see the same events independently.
-- ============================================================
local function OnTaint(event, addonName, action)
    local aName = SafeToString(addonName)
    local aAction = SafeToString(action)
    local msg = SafeToString(event) .. " addon=" .. aName .. " action=" .. aAction
    local meta = "addon=" .. aName .. " action=" .. aAction
    M.Capture("Taint", aName, msg, "", "", meta)
end

local function OnWarning(_, warningText, legacyText)
    local txt = legacyText or warningText
    local msg = SafeToString(txt)
    if msg == "" or msg == "nil" then return end
    local stack, locals = GetStackAndLocals(3)
    M.Capture("Warning", "LUA_WARNING", msg, stack, locals, "")
end

-- ============================================================
-- SECTION: Signal Source — Chat-Frame Capture
-- Hooks DEFAULT_CHAT_FRAME.AddMessage so any line MidnightUI
-- prints to chat is captured into the engine as a Debug entry
-- (or Taint if the line carries a [TaintTrace] marker).
--
-- The _muiConsoleChatHooked flag on the chat frame prevents
-- double-hooking on repeat PLAYER_LOGIN events.
-- ============================================================
-- Patterns that strongly suggest a line is error / warning /
-- stack-trace output rather than ordinary player chat. Kept
-- Lua-specific on purpose so normal conversation doesn't
-- trigger a capture.
local function LooksLikeDebugOutput(s)
    if s:find("MidnightUI") or s:find("%[MUI%]")
       or s:find("%[FHT%]") or s:find("%[HealPred%]")
       or s:find("%[StandaloneChat%]")
       or s:find("%[TaintTrace%]") then
        return true
    end
    if s:find("attempt to call") or s:find("attempt to index")
       or s:find("attempt to concatenate")
       or s:find("attempt to perform arithmetic")
       or s:find("attempt to compare") then
        return true
    end
    if s:find("Interface\\AddOns\\") or s:find("Interface/AddOns/") then
        return true
    end
    if s:find("%[string \"") then return true end
    if s:find("%.lua:%d+:") then return true end
    return false
end

local function OnChatMessage(_, msg)
    if not msg then return end
    if not IsDiagnosticsEnabled() then return end
    if type(issecretvalue) == "function" and issecretvalue(msg) then return end
    local s = SafeToString(msg)
    if IsMsgSkippable(s) then return end
    if type(issecretvalue) == "function" and issecretvalue(s) then return end
    -- Exclude explicit player / channel / item link messages so
    -- ordinary chatter never lands in the capture.
    if s:find("|Hplayer:") or s:find("|Hchannel:") or s:find("|Hitem:") then return end
    if not LooksLikeDebugOutput(s) then return end
    local clean = s:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
    local kind, meta = "Debug", "source=ChatPrint"
    if IsTaintTrace(clean) then kind, meta = "Taint", "source=TaintTrace" end
    M.Capture(kind, "ChatPrint", clean, "", "", meta)
end

-- Hooks every known chat frame (DEFAULT + ChatFrame1..N) so a
-- direct :AddMessage call to any frame still flows into capture.
-- Idempotent: each frame is flagged so repeat calls are free.
local function InstallChatHook()
    local function hookFrame(cf)
        if not cf or not cf.AddMessage or cf._muiConsoleChatHooked then return end
        cf._muiConsoleChatHooked = true
        hooksecurefunc(cf, "AddMessage", OnChatMessage)
    end
    hookFrame(_G.DEFAULT_CHAT_FRAME)
    local n = _G.NUM_CHAT_WINDOWS or 10
    for i = 1, n do
        hookFrame(_G["ChatFrame" .. i])
    end
end

-- Try immediately; if the chat frames aren't ready yet the hook
-- retry on PLAYER_LOGIN picks them up.
InstallChatHook()

-- ============================================================
-- SECTION: Signal Source — print() Tee
-- print() is the most common ad-hoc debug call. Wrapping the
-- global guarantees every print() lands in capture regardless
-- of target chat frame, line content, or any tagging convention.
-- This closes the historical gap where Claude-added debug prints
-- went to chat without a MidnightUI marker and were lost.
--
-- The original print is ALWAYS called afterwards, so chat output
-- remains visible. Our capture side is pcall-wrapped so a failure
-- in the engine can't silence print.
-- ============================================================
local function InstallPrintTee()
    if _G.MidnightUI_Console_PrintTeed then return end
    local origPrint = _G.print
    if type(origPrint) ~= "function" then return end
    _G.MidnightUI_Console_PrintTeed = true
    _G.print = function(...)
        pcall(function(...)
            if not IsDiagnosticsEnabled() then return end
            local n = select("#", ...)
            if n == 0 then return end
            local parts = {}
            for i = 1, n do
                parts[i] = SafeToString((select(i, ...)))
            end
            local line = table.concat(parts, " ")
            if line == "" or IsMsgSkippable(line) then return end
            local kind, meta = "Debug", "source=print"
            if IsTaintTrace(line) then kind, meta = "Taint", "source=TaintTrace" end
            M.Capture(kind, "print", line, "", "", meta)
        end, ...)
        return origPrint(...)
    end
end

InstallPrintTee()

-- ============================================================
-- SECTION: Signal Source — Debug Global Tee Shims
-- Wraps every existing MidnightUI debug-reporting global so
-- calls are captured into the engine AND forwarded to the
-- original. Installed on PLAYER_LOGIN after Messenger's final
-- redefinition of MidnightUI_Debug has settled.
-- ============================================================
local function JoinArgs(...)
    local n = select("#", ...)
    if n == 0 then return "" end
    local parts = {}
    for i = 1, n do
        parts[i] = SafeToString((select(i, ...)))
    end
    return table.concat(parts, " ")
end

local function InstallShims()
    if _shimsInstalled then return end
    _shimsInstalled = true

    -- MidnightUI_Debug: the most-used logging entry point.
    local priorDebug = _G.MidnightUI_Debug
    _G.MidnightUI_Debug = function(...)
        local msg = JoinArgs(...)
        if msg ~= "" then
            M.Capture("Debug", "MidnightUI_Debug", msg, "", "", "")
        end
        if priorDebug then
            local ok = pcall(priorDebug, ...)
            if not ok then _engineErrors = _engineErrors + 1 end
        end
    end
    _G.MidnightUI_LogDebug = _G.MidnightUI_Debug  -- alias

    -- MidnightUI_DebugSource, MidnightUI_ReportError, and
    -- MidnightUI_ReportWarning are primary-owned by Console now
    -- (defined at file scope earlier in this file). No shim
    -- wrapping needed - they route directly to M.Capture.

    -- Signals to other modules (notably Messenger's MidnightUI_Debug
    -- override) that Console is now capturing MidnightUI_Debug calls
    -- directly. Legacy forwards to Diagnostics.LogDebug should stop
    -- to avoid double-capture.
    _G.MidnightUI_DebugShimActive = true
end

-- ============================================================
-- SECTION: Startup Queue Drain
-- Bootstrap creates two queues (MidnightUI_DebugQueue and
-- MidnightUI_DiagnosticsQueue) that collect log lines logged
-- before the engine is ready. This drains both into Capture on
-- ADDON_LOADED. A periodic wiper (below) bounds the queues
-- afterwards since their writers also route through the shim.
-- ============================================================
local function DrainLegacyQueues()
    local dq = _G.MidnightUI_DebugQueue
    if type(dq) == "table" then
        for i = 1, #dq do
            local msg = dq[i]
            if msg ~= nil then
                M.Capture("Debug", "BootstrapQueue",
                          SafeToString(msg), "", "", "queued=true")
            end
        end
    end

    local diq = _G.MidnightUI_DiagnosticsQueue
    if type(diq) == "table" then
        for i = 1, #diq do
            local raw = diq[i]
            if raw ~= nil then
                local text = SafeToString(raw)
                -- lines often of the form "[Source] body"
                local src, body = text:match("^%[([^%]]+)%]%s+(.+)$")
                if src and body then
                    M.Capture("Debug", src, body, "", "", "queued=true")
                else
                    M.Capture("Debug", "DiagnosticsQueue",
                              text, "", "", "queued=true")
                end
            end
        end
    end
end

local function DrainPreloadQueue()
    if #_preloadQueue == 0 then return end
    local pq = _preloadQueue
    _preloadQueue = {}
    for i = 1, #pq do
        local r = pq[i]
        M.Capture(r.kind, r.source, r.message, r.stack, r.locals, r.meta)
    end
end

-- ============================================================
-- SECTION: Native Query / Stats API
-- Lightweight read-only accessors. UI work in later phases
-- will consume these rather than touching the store directly.
-- ============================================================
function M.GetStats()
    return {
        session           = M.db and M.db.session or 0,
        totalCaptured     = _totalCaptured,
        cumulativeCaptured = (M.db and M.db.meta and M.db.meta.totalCaptured) or 0,
        activeIncidents   = _activeIncidentCount,
        archivedIncidents = _archivedCount,
        enrichQueueDepth  = #_enrichQueue,
        reentryQueueDepth = #_reentryQueue,
        engineErrors      = _engineErrors,
        shimsInstalled    = _shimsInstalled,
    }
end

function M.GetSessionInfo()
    if not M.db then return nil end
    local info = {
        session = M.db.session,
        startTime = 0,
        context = {},
    }
    if M.db.sessions and M.db.sessions[M.db.session] then
        local s = M.db.sessions[M.db.session]
        info.startTime = s.startTime or 0
        info.context = s.context or {}
    end
    return info
end

function M.GetSeverityCounts()
    local out = { critical = 0, high = 0, medium = 0, low = 0, info = 0, unknown = 0 }
    if not (M.db and M.db.incidents) then return out end
    for _, e in pairs(M.db.incidents) do
        local sev = e.severity
        if sev and out[sev] ~= nil then
            out[sev] = out[sev] + 1
        else
            out.unknown = out.unknown + 1
        end
    end
    return out
end

function M.GetAddonSummary()
    local byAddon = {}
    if not (M.db and M.db.incidents) then return {} end
    for _, e in pairs(M.db.incidents) do
        local name = e.addon or "Unknown"
        local bucket = byAddon[name]
        if not bucket then
            bucket = {
                addon = name,
                incidentCount = 0,
                eventCount = 0,
                worstSeverity = "info",
                lastSeen = 0,
            }
            byAddon[name] = bucket
        end
        bucket.incidentCount = bucket.incidentCount + 1
        bucket.eventCount = bucket.eventCount + (e.count or 1)
        if (e.lastSeen or 0) > bucket.lastSeen then
            bucket.lastSeen = e.lastSeen or 0
        end
        -- severity rank for "worst"
        local rank = { critical = 5, high = 4, medium = 3, low = 2, info = 1 }
        local cur = rank[bucket.worstSeverity] or 0
        local newRank = rank[e.severity or "info"] or 0
        if newRank > cur then
            bucket.worstSeverity = e.severity or "info"
        end
    end
    local list = {}
    for _, v in pairs(byAddon) do list[#list + 1] = v end
    table.sort(list, function(a, b)
        if a.eventCount ~= b.eventCount then return a.eventCount > b.eventCount end
        return (a.addon or "") < (b.addon or "")
    end)
    return list
end

function M.GetTopIncidents(n)
    n = n or 5
    local list = {}
    if not (M.db and M.db.incidents) then return list end
    for _, e in pairs(M.db.incidents) do
        list[#list + 1] = e
    end
    table.sort(list, function(a, b) return (a.count or 0) > (b.count or 0) end)
    if #list > n then
        for i = n + 1, #list do list[i] = nil end
    end
    return list
end

function M.GetAllIncidents()
    local list = {}
    if not (M.db and M.db.incidents) then return list end
    for _, e in pairs(M.db.incidents) do
        list[#list + 1] = e
    end
    return list
end

function M.GetIncidentByFingerprint(fp)
    if not (M.db and M.db.incidents) then return nil end
    return M.db.incidents[fp]
end

function M.GetArchivedIncidents()
    local list = {}
    if not (M.db and M.db.archive) then return list end
    for _, e in pairs(M.db.archive) do
        list[#list + 1] = e
    end
    return list
end

function M.GetSessionIds()
    local list = {}
    if not (M.db and M.db.sessions) then return list end
    for id, _ in pairs(M.db.sessions) do
        if type(id) == "number" then
            list[#list + 1] = id
        end
    end
    table.sort(list, function(a, b) return a > b end)
    return list
end

function M.GetRecentIncidents(n)
    n = n or 20
    local list = {}
    if not (M.db and M.db.incidents) then return list end
    for _, e in pairs(M.db.incidents) do
        list[#list + 1] = e
    end
    table.sort(list, function(a, b)
        return (a.lastSeen or 0) > (b.lastSeen or 0)
    end)
    if #list > n then
        for i = n + 1, #list do list[i] = nil end
    end
    return list
end

function M.ClearStore()
    if not M.db then return end
    M.db.incidents = {}
    M.db.archive   = {}

    -- Full session reset: renumber the current session as #1
    -- with a fresh context snapshot and wipe all history. The
    -- Archive dropdown will show only "Session #1", and
    -- subsequent logins count up from there (EnsureDB
    -- increments on each load).
    M.db.session  = 1
    M.db.sessions = {}
    M.db.sessions[1] = {
        startTime = (time and time()) or 0,
        context   = BuildSessionContext(),
    }

    -- Reset cumulative counters in the persisted meta block so
    -- the "Clear All" action feels like a real clean slate.
    if M.db.meta then
        M.db.meta.totalCaptured  = 0
        M.db.meta.totalIncidents = 0
    end

    -- Reset session-local tallies and internal queues.
    _activeIncidentCount = 0
    _archivedCount       = 0
    _totalCaptured       = 0
    _engineErrors        = 0
    _enrichQueue         = {}
    _reentryQueue        = {}
end

-- ============================================================
-- SECTION: Slash Commands
-- Verification tooling only. These produce chat output because
-- they are user-initiated queries, not asynchronous alerts —
-- this respects the "silent capture" rule (no uninvited chat).
-- ============================================================
local PREFIX = "[MUI Console] "

-- All Console output routes through the MidnightUI_Debug pipeline
-- so it appears in the Messenger Debug tab. DEFAULT_CHAT_FRAME is
-- not visible when Messenger replaces the Blizzard chat UI, so
-- writing there directly would be invisible. Going through
-- MidnightUI_Debug uses the queue when called early and delivers
-- straight to the debug router once Messenger is loaded.
local function Say(text)
    local line = PREFIX .. tostring(text or "")
    if _G.MidnightUI_Debug then
        _G.MidnightUI_Debug(line)
    else
        _G.MidnightUI_DebugQueue = _G.MidnightUI_DebugQueue or {}
        table.insert(_G.MidnightUI_DebugQueue, line)
    end
end

local function CmdStatus()
    local s = M.GetStats()
    Say(string.format(
        "session=%d events=%d (cumulative=%d) incidents=%d archive=%d enrichQueue=%d engineErrors=%d shims=%s",
        s.session, s.totalCaptured, s.cumulativeCaptured,
        s.activeIncidents, s.archivedIncidents,
        s.enrichQueueDepth, s.engineErrors,
        s.shimsInstalled and "yes" or "no"
    ))
end

local function CmdFlood(rest)
    local countStr, modeStr = rest:match("^(%d+)%s*(.*)$")
    local n = tonumber(countStr) or 100
    local mode = (modeStr or ""):lower()

    local startIncidents = _activeIncidentCount
    local startCaptured  = _totalCaptured
    local t0 = debugprofilestop and debugprofilestop() or 0

    if mode == "u" or mode == "unique" then
        -- Use random ALPHABETIC tags so the fingerprint normalizer
        -- (which collapses digit runs to a placeholder) cannot fold
        -- these into one incident. Letters are left alone by the
        -- normalizer, so each tag yields a genuinely distinct
        -- fingerprint and the per-session soft cap / archive spill
        -- paths are exercised honestly.
        local alpha = "abcdefghijklmnopqrstuvwxyz"
        local alphaLen = #alpha
        for _ = 1, n do
            local parts = {}
            for k = 1, 10 do
                local ix = math.random(1, alphaLen)
                parts[k] = alpha:sub(ix, ix)
            end
            local tag = table.concat(parts)
            M.Capture("Debug", "StressTest",
                      "unique synthetic event tag=" .. tag,
                      "", "", "test=flood-unique")
        end
    else
        for _ = 1, n do
            M.Capture("Debug", "StressTest",
                      "identical synthetic event",
                      "", "", "test=flood-repeat")
        end
    end

    local t1 = debugprofilestop and debugprofilestop() or 0
    local elapsedMs = t1 - t0
    local capturedDelta = _totalCaptured - startCaptured
    local incidentDelta = _activeIncidentCount - startIncidents

    local rate = (elapsedMs > 0) and (n / (elapsedMs / 1000)) or 0
    Say(string.format(
        "flood n=%d mode=%s elapsed=%.2fms rate=%.0f/s captured=+%d incidents=+%d drops=%d",
        n,
        (mode == "" and "repeat" or mode),
        elapsedMs,
        rate,
        capturedDelta,
        incidentDelta,
        math.max(0, n - capturedDelta)  -- should always be 0 under zero-drop guarantee
    ))
    if (n - capturedDelta) == 0 then
        Say("|cff44dd88zero-drop verified|r (all " .. n .. " signals accounted for)")
    else
        Say("|cffff5555warning: drop detected|r (" .. (n - capturedDelta) .. " lost)")
    end
end

local function CmdDump(rest)
    local n = tonumber(rest) or 20
    local list = M.GetRecentIncidents(n)
    Say(string.format("last %d of %d incidents:", #list, _activeIncidentCount))
    for i = 1, #list do
        local e = list[i]
        local msg = e.message or ""
        if #msg > 100 then msg = msg:sub(1, 100) .. "..." end
        Say(string.format("  %d. [%s] (x%d) %s :: %s",
            i,
            SafeToString(e.kind or "?"),
            e.count or 1,
            SafeToString(e.source or "?"),
            msg))
    end
end

local function CmdClear()
    M.ClearStore()
    Say("store cleared")
end

local function CmdHelp()
    Say("commands:")
    Say("  /muiconsole               - open the Console UI")
    Say("  /muiconsole open          - open the Console UI")
    Say("  /muiconsole close         - close the Console UI")
    Say("  /muiconsole toggle        - toggle the Console UI")
    Say("  /muiconsole status        - show capture stats")
    Say("  /muiconsole flood <N>     - fire N identical events (dedup test)")
    Say("  /muiconsole flood <N> u   - fire N unique events (zero-drop test)")
    Say("  /muiconsole dump [N]      - show last N incidents (default 20)")
    Say("  /muiconsole clear         - wipe store")
    Say("  /muiconsole help          - this message")
    Say("aliases: /muic")
end

local function DispatchSlash(arg)
    arg = arg or ""
    local cmd, rest = arg:match("^(%S*)%s*(.*)$")
    cmd  = (cmd or ""):lower()
    rest = rest or ""
    if     cmd == "" or cmd == "open"    then
        if _G.MidnightUI_Console_UI and _G.MidnightUI_Console_UI.Open then
            _G.MidnightUI_Console_UI.Open()
        else
            Say("UI module not loaded")
        end
    elseif cmd == "close" then
        if _G.MidnightUI_Console_UI and _G.MidnightUI_Console_UI.Close then
            _G.MidnightUI_Console_UI.Close()
        end
    elseif cmd == "toggle" then
        if _G.MidnightUI_Console_UI and _G.MidnightUI_Console_UI.Toggle then
            _G.MidnightUI_Console_UI.Toggle()
        end
    elseif cmd == "status" then CmdStatus()
    elseif cmd == "flood"  then CmdFlood(rest)
    elseif cmd == "dump"   then CmdDump(rest)
    elseif cmd == "clear"  then CmdClear()
    elseif cmd == "help"   then CmdHelp()
    else   Say("unknown command: " .. cmd .. " (try /muiconsole help)")
    end
end

-- Slash registration. The dispatcher is wrapped in pcall so any
-- runtime error in a command surfaces visibly in chat instead of
-- being swallowed by the suppressed error popup.
SlashCmdList["MIDNIGHTUI_CONSOLE"] = function(arg)
    local ok, err = pcall(DispatchSlash, arg)
    if not ok then
        Say("ERROR in slash handler: " .. tostring(err))
    end
end
SLASH_MIDNIGHTUI_CONSOLE1 = "/muiconsole"
SLASH_MIDNIGHTUI_CONSOLE2 = "/muic"

-- Short aliases. /muibugs and /midnightbugs open the Debug
-- Console on its default tab; /muimemory jumps straight to the
-- Memory tab.
SlashCmdList["MIDNIGHTUI_CONSOLE_LEGACY_BUGS"] = function()
    if _G.MidnightUI_Console_UI and _G.MidnightUI_Console_UI.Open then
        _G.MidnightUI_Console_UI.Open()
    end
end
SLASH_MIDNIGHTUI_CONSOLE_LEGACY_BUGS1 = "/muibugs"
SLASH_MIDNIGHTUI_CONSOLE_LEGACY_BUGS2 = "/midnightbugs"

SlashCmdList["MIDNIGHTUI_CONSOLE_LEGACY_MEMORY"] = function()
    local ui = _G.MidnightUI_Console_UI
    if ui and ui.Open then ui.Open() end
    if ui and ui.SetActiveTab then pcall(ui.SetActiveTab, "memory") end
end
SLASH_MIDNIGHTUI_CONSOLE_LEGACY_MEMORY1 = "/muimemory"

-- ============================================================
-- SECTION: Event Loop + Initialisation
-- Creates a single event frame, wires up ADDON_LOADED for DB
-- and queue init, PLAYER_LOGIN for shim/handler install, and
-- the three Blizzard signal events.
-- ============================================================
_eventFrame = CreateFrame("Frame", "MidnightUI_Console_Events")
_eventFrame:RegisterEvent("ADDON_LOADED")
_eventFrame:RegisterEvent("PLAYER_LOGIN")
_eventFrame:RegisterEvent("LUA_WARNING")

_eventFrame:SetScript("OnEvent", function(_, event, ...)
    if event == "ADDON_LOADED" then
        local addon = ...
        if addon ~= "MidnightUI" then return end
        EnsureDB()
        DrainLegacyQueues()
        DrainPreloadQueue()
        SuppressScriptErrors()
        if C_Timer and C_Timer.NewTicker then
            _enrichTicker = C_Timer.NewTicker(ENRICH_TICK_SEC, EnrichmentTick)
        end
        -- Long-interval wipe of MidnightUI_DiagnosticsQueue.
        -- Several modules (ConditionBorder, FocusFrame, PartyFrames,
        -- PlayerFrame, RaidFrames, TargetFrame, MainTankFrames)
        -- push into that queue as a belt-and-suspenders fallback
        -- alongside their regular LogDebugSource call. The queue
        -- writes are therefore duplicates of entries the shim has
        -- already captured; periodic wipe prevents unbounded growth.
        if C_Timer and C_Timer.NewTicker then
            C_Timer.NewTicker(5.0, function()
                local q = _G.MidnightUI_DiagnosticsQueue
                if type(q) == "table" and #q > 0 then
                    wipe(q)
                end
            end)
        end
        -- Error-handler integrity check. Another addon loading
        -- later (or a /reload of an ill-behaved addon) can replace
        -- the error handler without chaining. Every 10 seconds we
        -- verify our handler is still the active one and re-chain
        -- on top if something swapped us out, so Lua errors can
        -- never silently bypass capture.
        if C_Timer and C_Timer.NewTicker
           and _G.geterrorhandler and _G.seterrorhandler then
            C_Timer.NewTicker(10.0, function()
                local current = _G.geterrorhandler()
                if _errorHandlerChain and current ~= _errorHandlerChain then
                    InstallErrorHandler()
                end
            end)
        end
    elseif event == "PLAYER_LOGIN" then
        -- Defer one frame so we chain as the outermost error
        -- handler on top of whatever (BugSack, etc.) installed earlier.
        if C_Timer and C_Timer.After then
            C_Timer.After(0, function()
                InstallShims()
                InstallErrorHandler()
                InstallChatHook()
                InstallPrintTee()
            end)
        else
            InstallShims()
            InstallErrorHandler()
            InstallChatHook()
            InstallPrintTee()
        end
    elseif event == "LUA_WARNING" then
        OnWarning(event, ...)
    end
end)

-- End of Console.lua
