-- ============================================================
-- FILE: Console_UI_Memory.lua
-- MODULE: MidnightUI_Console_Memory (_G.MidnightUI_Console_Memory, local M)
--
-- PURPOSE:
--   Phase 2e of the Diagnostics Console rewrite. The Memory
--   tab shows live Lua memory / addon memory metrics and can
--   produce a deep profile report on demand.
--
--   Layout (top to bottom):
--     1. Live Stats card  - current Lua heap, MidnightUI's own
--                           usage, Console store size, session
--                           and event counts.
--     2. Top Consumers card - top addons ranked by Lua memory,
--                             with MidnightUI called out.
--     3. Deep Profile card - "Run Deep Profile" button plus a
--                            scrollable read-only text area
--                            that fills with a multi-section
--                            report when the button is clicked.
--
--   Every metric refreshes when the tab is activated, and live
--   stats re-poll on a lightweight ticker while visible. The
--   deep profile is computed only on button click because it
--   iterates every loaded addon and walks the saved data.
--
-- HOW IT WORKS:
--   GetAddOnMemoryUsage requires UpdateAddOnMemoryUsage to be
--   called first. We call it once per refresh, not per addon.
--   collectgarbage("count") returns Lua heap in kilobytes and
--   is the single source of truth for the hot-stats line.
--   The deep profile generator builds a multi-section report
--   by concatenating short section bodies into one string,
--   which then renders in the scrollable EditBox for Ctrl+C
--   copy-out.
--
-- DEPENDS ON:
--   MidnightUI_Console_UI (shell) - palette, fonts, helpers,
--                                   tab registry.
--   MidnightUI_Console (engine)   - optional, for DB size.
--
-- EXPORTS: none (registers as a tab populator).
-- EVENTS REGISTERED: none.
-- FRAMES CREATED: three cards plus their internal widgets.
--
-- GOTCHAS:
--   - GetAddOnMemoryUsage readings are a snapshot at the last
--     UpdateAddOnMemoryUsage call; refreshing without calling
--     Update first returns stale data.
--   - The deep profile can take ~50ms on a heavy addon list;
--     that is acceptable for a user-initiated button click.
-- ============================================================

local _G = _G
local CreateFrame = CreateFrame
local string = _G.string
local math = _G.math
local table = _G.table
local pairs = _G.pairs
local ipairs = _G.ipairs
local pcall = _G.pcall
local tostring = _G.tostring
local collectgarbage = _G.collectgarbage
local C_Timer = _G.C_Timer
local C_AddOns = _G.C_AddOns
local GetAddOnMemoryUsage = (C_AddOns and C_AddOns.GetAddOnMemoryUsage) or _G.GetAddOnMemoryUsage
local UpdateAddOnMemoryUsage = _G.UpdateAddOnMemoryUsage
local GetNumAddOns = (C_AddOns and C_AddOns.GetNumAddOns) or _G.GetNumAddOns
local GetAddOnInfo = (C_AddOns and C_AddOns.GetAddOnInfo) or _G.GetAddOnInfo
local IsAddOnLoaded = (C_AddOns and C_AddOns.IsAddOnLoaded) or _G.IsAddOnLoaded

local M = _G.MidnightUI_Console_Memory or {}
_G.MidnightUI_Console_Memory = M

local SHELL = _G.MidnightUI_Console_UI
if not SHELL then return end

local function Engine()
    return _G.MidnightUI_Console
end

-- ============================================================
-- SECTION: Imports + Constants
-- ============================================================
local UI    = SHELL.UI
local FONT  = SHELL.FONT
local Solid = SHELL.SolidTexture
local Back  = SHELL.ApplyBackdrop
local NewFS = SHELL.NewFontString

local PAD            = 28
local GAP            = 22
local CARD_INSET     = 18
local CARD_TITLE_Y   = 22
local CARD_CONTENT_Y = 56
local STATS_CARD_H   = 114
local CONSUMERS_CARD_H = 220

local CONSUMER_ROWS  = 7
local ROW_H          = 26

local REFRESH_INTERVAL = 2.0

-- ============================================================
-- SECTION: State
-- ============================================================
local state = {
    body    = nil,
    ticker  = nil,
    deepReport = "",
}

local ui = {
    statsCard    = nil,
    heapValue    = nil, heapLabel = nil,
    muiValue     = nil, muiLabel = nil,
    storeValue   = nil, storeLabel = nil,
    sessionValue = nil, sessionLabel = nil,

    consumersCard = nil,
    consumerRows  = {},

    deepCard      = nil,
    runBtn        = nil,
    runLabel      = nil,
    deepScroll    = nil,
    deepBox       = nil,
    deepHint      = nil,
}

-- ============================================================
-- SECTION: Helpers
-- ============================================================
local function FormatKB(kb)
    if type(kb) ~= "number" then return "--" end
    if kb >= 1024 then
        return string.format("%.2f MB", kb / 1024)
    end
    return string.format("%.1f KB", kb)
end

local function CountTableKeys(t)
    if type(t) ~= "table" then return 0 end
    local n = 0
    for _ in pairs(t) do n = n + 1 end
    return n
end

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

-- Returns the top-N addons by Lua memory usage, {name=, kb=}.
local function TopAddonsByMemory(limit)
    limit = limit or 10
    if not (GetNumAddOns and GetAddOnInfo and GetAddOnMemoryUsage) then return {} end
    if UpdateAddOnMemoryUsage then pcall(UpdateAddOnMemoryUsage) end
    local list = {}
    local ok, count = pcall(GetNumAddOns)
    if not ok or not count then return list end
    for i = 1, count do
        local okI, name = pcall(GetAddOnInfo, i)
        local loaded = false
        if okI and name and IsAddOnLoaded then
            local okL, ld = pcall(IsAddOnLoaded, i)
            loaded = okL and ld
        end
        if loaded and name then
            local okM, kb = pcall(GetAddOnMemoryUsage, i)
            if okM and type(kb) == "number" then
                list[#list + 1] = { name = name, kb = kb }
            end
        end
    end
    table.sort(list, function(a, b) return a.kb > b.kb end)
    if #list > limit then
        for i = limit + 1, #list do list[i] = nil end
    end
    return list
end

local function GetLuaHeapKB()
    if collectgarbage then
        local ok, kb = pcall(collectgarbage, "count")
        if ok and type(kb) == "number" then return kb end
    end
    return 0
end

local function GetMidnightUIMemoryKB()
    if not (GetAddOnMemoryUsage and UpdateAddOnMemoryUsage) then return 0 end
    pcall(UpdateAddOnMemoryUsage)
    local ok, kb = pcall(GetAddOnMemoryUsage, "MidnightUI")
    if ok and type(kb) == "number" then return kb end
    return 0
end

-- ============================================================
-- SECTION: Live Stats Card
-- ============================================================
local function BuildStatsCard(body)
    local card = CreateFrame("Frame", nil, body, "BackdropTemplate")
    card:SetPoint("TOPLEFT",  body, "TOPLEFT",  PAD, -PAD)
    card:SetPoint("TOPRIGHT", body, "TOPRIGHT", -PAD, -PAD)
    card:SetHeight(STATS_CARD_H)
    Back(card, UI.card, UI.border, 1, 1)

    local topEdge = Solid(card, "OVERLAY", UI.accent, 0.85)
    topEdge:SetHeight(2)
    topEdge:SetPoint("TOPLEFT",  card, "TOPLEFT",  1, -1)
    topEdge:SetPoint("TOPRIGHT", card, "TOPRIGHT", -1, -1)

    ui.statsCard = card

    -- Four equal-width stat cells spread across the card.
    local cells = {
        { key = "heap",    label = "Lua Heap Total",       valueColor = UI.accent },
        { key = "mui",     label = "MidnightUI Memory",    valueColor = UI.text },
        { key = "store",   label = "Console Store Size",   valueColor = UI.text },
        { key = "session", label = "Session Incidents",    valueColor = UI.text },
    }

    local cellCount = #cells
    for i, cell in ipairs(cells) do
        -- Offsets based on equal-width cells, with padding on the sides.
        local labelOffsetX = 20 + (i - 1) * (260)
        local labelFS = NewFS(card, "OVERLAY", FONT.small, UI.muted)
        labelFS:SetPoint("TOPLEFT", card, "TOPLEFT", labelOffsetX, -26)
        labelFS:SetText(cell.label)

        local valFS = NewFS(card, "OVERLAY", FONT.display, cell.valueColor)
        valFS:SetPoint("TOPLEFT", labelFS, "BOTTOMLEFT", 0, -8)
        valFS:SetText("--")

        if cell.key == "heap" then
            ui.heapLabel, ui.heapValue = labelFS, valFS
        elseif cell.key == "mui" then
            ui.muiLabel, ui.muiValue = labelFS, valFS
        elseif cell.key == "store" then
            ui.storeLabel, ui.storeValue = labelFS, valFS
        elseif cell.key == "session" then
            ui.sessionLabel, ui.sessionValue = labelFS, valFS
        end
    end
end

local function RefreshStatsCard()
    if not ui.statsCard then return end

    local heapKB = GetLuaHeapKB()
    local muiKB  = GetMidnightUIMemoryKB()

    if ui.heapValue then ui.heapValue:SetText(FormatKB(heapKB)) end
    if ui.muiValue  then ui.muiValue:SetText(FormatKB(muiKB)) end

    local engine = Engine()
    local stats = (engine and engine.GetStats) and engine.GetStats() or {}
    local storeCount = (stats.activeIncidents or 0) + (stats.archivedIncidents or 0)
    if ui.storeValue then
        ui.storeValue:SetText(string.format("%d entries", storeCount))
    end
    if ui.sessionValue then
        ui.sessionValue:SetText(string.format("%d / %d active",
            stats.totalCaptured or 0, stats.activeIncidents or 0))
    end
end

-- ============================================================
-- SECTION: Top Consumers Card
-- ============================================================
local function BuildConsumerRow(parent, index)
    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(ROW_H)
    row:SetPoint("TOPLEFT",  parent, "TOPLEFT",  CARD_INSET, -(CARD_CONTENT_Y + (index - 1) * (ROW_H + 4)))
    row:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -CARD_INSET, -(CARD_CONTENT_Y + (index - 1) * (ROW_H + 4)))

    local bg = Solid(row, "BACKGROUND", UI.inset, 0.4)
    bg:SetAllPoints()
    row.bg = bg

    local rankFS = NewFS(row, "OVERLAY", FONT.small, UI.muted)
    rankFS:SetPoint("LEFT", row, "LEFT", 12, 0)
    rankFS:SetText("")
    row.rank = rankFS

    local nameFS = NewFS(row, "OVERLAY", FONT.body, UI.text)
    nameFS:SetPoint("LEFT", row, "LEFT", 54, 0)
    nameFS:SetText("")
    row.name = nameFS

    local barBG = Solid(row, "ARTWORK", UI.border, 0.6)
    barBG:SetPoint("TOPLEFT",     row, "TOPLEFT",     360, -10)
    barBG:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -140, 10)
    row.barBG = barBG

    local barFill = Solid(row, "OVERLAY", UI.accent, 0.7)
    barFill:SetPoint("TOPLEFT",    barBG, "TOPLEFT",    0, 0)
    barFill:SetPoint("BOTTOMLEFT", barBG, "BOTTOMLEFT", 0, 0)
    barFill:SetWidth(0)
    row.barFill = barFill

    local kbFS = NewFS(row, "OVERLAY", FONT.body, UI.text)
    kbFS:SetPoint("RIGHT", row, "RIGHT", -14, 0)
    kbFS:SetJustifyH("RIGHT")
    kbFS:SetText("")
    row.kb = kbFS

    row:Hide()
    return row
end

local function BuildConsumersCard(body)
    local topOffset = PAD + STATS_CARD_H + GAP
    local card = CreateFrame("Frame", nil, body, "BackdropTemplate")
    card:SetPoint("TOPLEFT",  body, "TOPLEFT",  PAD, -topOffset)
    card:SetPoint("TOPRIGHT", body, "TOPRIGHT", -PAD, -topOffset)
    card:SetHeight(CONSUMERS_CARD_H)
    Back(card, UI.panel, UI.border, 1, 1)
    ui.consumersCard = card

    local title = NewFS(card, "OVERLAY", FONT.heading, UI.text)
    title:SetPoint("TOPLEFT", card, "TOPLEFT", CARD_INSET, -CARD_TITLE_Y)
    title:SetText("Top Memory Consumers")

    for i = 1, CONSUMER_ROWS do
        ui.consumerRows[i] = BuildConsumerRow(card, i)
    end
end

local function RefreshConsumers()
    if not ui.consumersCard then return end
    local list = TopAddonsByMemory(CONSUMER_ROWS)
    if #list == 0 then
        for i = 1, CONSUMER_ROWS do ui.consumerRows[i]:Hide() end
        return
    end
    local maxKB = list[1] and list[1].kb or 1
    if maxKB <= 0 then maxKB = 1 end

    for i = 1, CONSUMER_ROWS do
        local row = ui.consumerRows[i]
        local entry = list[i]
        if entry then
            row.rank:SetText(string.format("#%d", i))
            row.name:SetText(Truncate(entry.name or "", 34))
            row.kb:SetText(FormatKB(entry.kb or 0))

            -- Bar fill width: bar background width * (kb / maxKB).
            -- We use a runtime calculation on layout.
            row:SetScript("OnSizeChanged", function(self, w, h)
                local barW = self.barBG:GetWidth() or 100
                local frac = math.min(1, (entry.kb or 0) / maxKB)
                self.barFill:SetWidth(math.max(0, barW * frac))
            end)
            -- Force one-time width evaluation.
            C_Timer.After(0, function()
                if row:IsShown() then
                    local barW = row.barBG:GetWidth() or 100
                    local frac = math.min(1, (entry.kb or 0) / maxKB)
                    row.barFill:SetWidth(math.max(0, barW * frac))
                end
            end)

            if entry.name == "MidnightUI" then
                row.name:SetTextColor(UI.accent[1], UI.accent[2], UI.accent[3])
            else
                row.name:SetTextColor(UI.text[1], UI.text[2], UI.text[3])
            end
            row:Show()
        else
            row:Hide()
        end
    end
end

-- ============================================================
-- SECTION: Deep Profile Card + Generator
-- ============================================================
-- ============================================================
-- Deep-Profile Analyzer
-- Recursive table walker with cycle detection, depth limit,
-- byte estimation, and per-type tallies. Single shared helper
-- so every saved-data block gets the same treatment and the
-- final attribution summary can compare apples to apples.
-- ============================================================
local ITER_CAP   = 250000   -- hard iteration cap to prevent freezes
local MAX_DEPTH  = 14
local STR_TRACK_CAP = 15000 -- max unique strings tracked for freq analysis

local function EstimateValueBytes(v, vtype)
    vtype = vtype or type(v)
    if vtype == "number"   then return 8 end
    if vtype == "boolean"  then return 4 end
    if vtype == "string"   then return 40 + #v end
    if vtype == "function" then return 80 end
    return 16
end

local function NewStats()
    return {
        tableCount       = 0,
        keyCount         = 0,
        nestedTableCount = 0,
        stringCount      = 0,
        stringBytes      = 0,
        numberCount      = 0,
        booleanCount     = 0,
        functionCount    = 0,
        otherCount       = 0,
        maxDepth         = 0,
        arrayKeys        = 0,
        hashKeys         = 0,
        stringFreq       = {},
        stringLen        = {},
        uniqueStrings    = 0,
        depthCapHit      = false,
        iterCapHit       = false,
        totalBytes       = 0,
        iters            = 0,
    }
end

local function DeepAnalyze(t, visited, depth, stats)
    if type(t) ~= "table" then return 0 end
    if visited[t] then return 0 end
    if depth > MAX_DEPTH then stats.depthCapHit = true; return 0 end
    if stats.iters >= ITER_CAP then stats.iterCapHit = true; return 0 end

    visited[t] = true
    stats.tableCount = stats.tableCount + 1
    if depth > stats.maxDepth then stats.maxDepth = depth end

    local selfBytes = 40
    for k, v in pairs(t) do
        stats.iters = stats.iters + 1
        if stats.iters >= ITER_CAP then stats.iterCapHit = true; break end

        stats.keyCount = stats.keyCount + 1

        local ktype = type(k)
        if ktype == "number" and k >= 1 and k == math.floor(k) then
            stats.arrayKeys = stats.arrayKeys + 1
        else
            stats.hashKeys = stats.hashKeys + 1
        end
        selfBytes = selfBytes + EstimateValueBytes(k, ktype)
        -- Track key strings for freq too (saved data often keys by string)
        if ktype == "string" and stats.uniqueStrings < STR_TRACK_CAP then
            if not stats.stringFreq[k] then
                stats.uniqueStrings = stats.uniqueStrings + 1
            end
            stats.stringFreq[k] = (stats.stringFreq[k] or 0) + 1
            stats.stringLen[k]  = #k
        end

        local vtype = type(v)
        if vtype == "table" then
            stats.nestedTableCount = stats.nestedTableCount + 1
            selfBytes = selfBytes + DeepAnalyze(v, visited, depth + 1, stats)
        elseif vtype == "string" then
            stats.stringCount = stats.stringCount + 1
            local b = 40 + #v
            stats.stringBytes = stats.stringBytes + b
            selfBytes = selfBytes + b
            if stats.uniqueStrings < STR_TRACK_CAP then
                if not stats.stringFreq[v] then
                    stats.uniqueStrings = stats.uniqueStrings + 1
                end
                stats.stringFreq[v] = (stats.stringFreq[v] or 0) + 1
                stats.stringLen[v]  = #v
            end
        elseif vtype == "number" then
            stats.numberCount = stats.numberCount + 1
            selfBytes = selfBytes + 8
        elseif vtype == "boolean" then
            stats.booleanCount = stats.booleanCount + 1
            selfBytes = selfBytes + 4
        elseif vtype == "function" then
            stats.functionCount = stats.functionCount + 1
            selfBytes = selfBytes + 80
        else
            stats.otherCount = stats.otherCount + 1
            selfBytes = selfBytes + 16
        end
    end
    return selfBytes
end

local function AnalyzeSaved(name)
    local t = _G[name]
    if type(t) ~= "table" then return nil end
    local stats = NewStats()
    stats.totalBytes = DeepAnalyze(t, {}, 0, stats)
    return stats
end

local function TopByCount(stats, limit)
    local list = {}
    for s, n in pairs(stats.stringFreq) do
        if n > 1 then list[#list + 1] = { s = s, n = n } end
    end
    table.sort(list, function(a, b) return a.n > b.n end)
    if #list > limit then
        for i = limit + 1, #list do list[i] = nil end
    end
    return list
end

local function TopByLength(stats, limit)
    local list = {}
    for s, len in pairs(stats.stringLen) do
        list[#list + 1] = { s = s, len = len }
    end
    table.sort(list, function(a, b) return a.len > b.len end)
    if #list > limit then
        for i = limit + 1, #list do list[i] = nil end
    end
    return list
end

local function FrameCensus()
    local r = {
        total = 0, shown = 0, hidden = 0,
        byStrata = {}, withOnUpdate = 0, withOnEvent = 0,
        buttons = 0, frames = 0, editBoxes = 0, scrollFrames = 0,
        topLevel = 0,
    }
    if not (_G.UIParent and _G.UIParent.GetChildren) then return r end
    local function touch(f)
        if not (f and f.GetName) then return end
        local name = f:GetName() or ""
        if not (name:find("Midnight") or name:find("^MUI") or name:find("MyMessenger")) then
            return
        end
        r.total = r.total + 1
        r.topLevel = r.topLevel + 1
        if f.IsShown and f:IsShown() then r.shown = r.shown + 1
        else r.hidden = r.hidden + 1 end
        local strata = (f.GetFrameStrata and f:GetFrameStrata()) or "?"
        r.byStrata[strata] = (r.byStrata[strata] or 0) + 1
        if f.GetScript then
            if f:GetScript("OnUpdate") then r.withOnUpdate = r.withOnUpdate + 1 end
            if f:GetScript("OnEvent")  then r.withOnEvent  = r.withOnEvent  + 1 end
        end
        local t = (f.GetObjectType and f:GetObjectType()) or ""
        if     t == "Button"      then r.buttons = r.buttons + 1
        elseif t == "Frame"       then r.frames = r.frames + 1
        elseif t == "EditBox"     then r.editBoxes = r.editBoxes + 1
        elseif t == "ScrollFrame" then r.scrollFrames = r.scrollFrames + 1
        end
    end
    local ok, children = pcall(function() return { _G.UIParent:GetChildren() } end)
    if ok and type(children) == "table" then
        for _, c in ipairs(children) do pcall(touch, c) end
    end
    return r
end

-- ============================================================
-- Report Formatting Helpers
-- These enforce a single consistent visual rhythm across every
-- section: numbered section headers, rule bars of a fixed total
-- width, aligned label/value rows, indented sub-blocks, and
-- comma-grouped integers so large counts are scannable.
-- ============================================================
local WIDTH     = 64
local BAR_DOUBLE = string.rep("=", WIDTH)
local BAR_SINGLE = string.rep("-", WIDTH)

local function FormatInt(n)
    if type(n) ~= "number" then return tostring(n) end
    n = math.floor(n)
    local s = tostring(n)
    local sign = ""
    if s:sub(1, 1) == "-" then sign = "-"; s = s:sub(2) end
    local k = #s
    if k <= 3 then return sign .. s end
    local first = ((k - 1) % 3) + 1
    local out = { s:sub(1, first) }
    for i = first + 1, k, 3 do
        out[#out + 1] = s:sub(i, i + 2)
    end
    return sign .. table.concat(out, ",")
end

-- "Label (left-padded)  value (right-padded)"
local function Row(label, value)
    return string.format("  %-42s  %16s",
        tostring(label or ""), tostring(value or ""))
end

-- Sub-block row, one indentation level deeper.
local function SubRow(label, value)
    return string.format("    %-40s  %16s",
        tostring(label or ""), tostring(value or ""))
end

-- Ranked row for tabular lists (e.g., addon ranking).
local function RankRow(rank, label, value)
    return string.format("  %3d.  %-38s  %16s",
        rank, tostring(label or ""), tostring(value or ""))
end

local function SectionHeader(num, title)
    return string.format("[ %d ]  %s", num, string.upper(title or ""))
end

local function SubBlockHeader(name)
    return string.format("  %s\n  %s", name, string.rep(".", math.max(#name, 40)))
end

local function BuildDeepProfileReport()
    local L = {}
    local function Push(line) L[#L + 1] = line or "" end
    local function Blank() L[#L + 1] = "" end
    local function OpenSection(num, title)
        Blank()
        Push(SectionHeader(num, title))
        Push(BAR_SINGLE)
        Blank()
    end

    -- ============================================================
    -- Cover
    -- ============================================================
    Push(BAR_DOUBLE)
    Push("  MIDNIGHTUI DIAGNOSTICS CONSOLE  -  DEEP MEMORY PROFILE")
    Push("  " .. ((date and date("%Y-%m-%d %H:%M:%S")) or "unknown time"))
    Push(BAR_DOUBLE)

    -- ============================================================
    -- 1. Lua Heap Overview
    -- ============================================================
    OpenSection(1, "Lua Heap Overview")

    local heapBefore = GetLuaHeapKB()
    local muiKB      = GetMidnightUIMemoryKB()
    Push(Row("Current Lua heap",        FormatKB(heapBefore)))
    Push(Row("MidnightUI addon memory", FormatKB(muiKB)))

    if collectgarbage then
        pcall(collectgarbage, "collect")
        local heapAfter = GetLuaHeapKB()
        Push(Row("After manual GC",     FormatKB(heapAfter)))
        Push(Row("Reclaimed by GC",     FormatKB(heapBefore - heapAfter)))
    end

    -- ============================================================
    -- 2. Loaded Addons by Memory
    -- ============================================================
    OpenSection(2, "Loaded Addons by Memory")

    local all = TopAddonsByMemory(500)
    local totalAddonKB = 0
    for _, a in ipairs(all) do totalAddonKB = totalAddonKB + (a.kb or 0) end
    Push(Row("Total across " .. FormatInt(#all) .. " loaded addons",
             FormatKB(totalAddonKB)))
    Blank()
    for i, a in ipairs(all) do
        Push(RankRow(i, Truncate(a.name or "", 36), FormatKB(a.kb or 0)))
    end

    -- ============================================================
    -- 3. Diagnostics Console Engine
    -- ============================================================
    OpenSection(3, "Diagnostics Console Engine")

    local engine = Engine()
    if engine and engine.GetStats then
        local s = engine.GetStats()
        Push(Row("Session",              "#" .. tostring(s.session or 0)))
        Push(Row("Events this session",  FormatInt(s.totalCaptured or 0)))
        Push(Row("Cumulative events",    FormatInt(s.cumulativeCaptured or 0)))
        Push(Row("Active incidents",     FormatInt(s.activeIncidents or 0)))
        Push(Row("Archived incidents",   FormatInt(s.archivedIncidents or 0)))
        Push(Row("Enrich queue depth",   FormatInt(s.enrichQueueDepth or 0)))
        Push(Row("Re-entry queue depth", FormatInt(s.reentryQueueDepth or 0)))
        Push(Row("Engine errors",        FormatInt(s.engineErrors or 0)))
        Push(Row("Shims installed",      s.shimsInstalled and "yes" or "no"))
    else
        Push(Row("engine status", "(not available)"))
    end

    -- ============================================================
    -- 4. Saved Data Blocks - Deep Analysis
    -- ============================================================
    OpenSection(4, "Saved Data Blocks  -  Deep Analysis")

    local blocks = {
        "MidnightUIConsoleDB",
        "MidnightUISettings",
        "MidnightUISettingsPerChar",
        "MessengerDB",
        "MidnightUIAchCache",
        "MidnightUIGuildRecruit",
        "MidnightUI_MPI",
    }

    local attribution = {}
    for _, name in ipairs(blocks) do
        local st = AnalyzeSaved(name)
        if st then
            Push(SubBlockHeader(name))
            Blank()
            Push(SubRow("Tables",            FormatInt(st.tableCount)))
            Push(SubRow("Keys total",        FormatInt(st.keyCount)))
            Push(SubRow("  Array keys",      FormatInt(st.arrayKeys)))
            Push(SubRow("  Hash keys",       FormatInt(st.hashKeys)))
            Push(SubRow("Max nesting depth", FormatInt(st.maxDepth)))
            Blank()
            Push("    Value types")
            Push(SubRow("  Strings",   FormatInt(st.stringCount)
                .. "  (" .. FormatKB(st.stringBytes / 1024) .. ")"))
            Push(SubRow("  Numbers",   FormatInt(st.numberCount)))
            Push(SubRow("  Booleans",  FormatInt(st.booleanCount)))
            Push(SubRow("  Functions", FormatInt(st.functionCount)))
            Push(SubRow("  Other",     FormatInt(st.otherCount)))
            Push(SubRow("  Unique strings", FormatInt(st.uniqueStrings)))
            Blank()
            Push(SubRow("Estimated bytes", FormatKB(st.totalBytes / 1024)))

            if st.depthCapHit then
                Blank()
                Push("    (!) Depth cap reached at level " .. MAX_DEPTH)
            end
            if st.iterCapHit then
                Push("    (!) Iteration cap reached at "
                    .. FormatInt(ITER_CAP) .. " entries")
            end

            local dupes = TopByCount(st, 5)
            if #dupes > 0 then
                Blank()
                Push("    Top Duplicated Strings")
                for _, d in ipairs(dupes) do
                    Push(string.format("      x%-8s  %s",
                        FormatInt(d.n), Truncate(d.s, 52)))
                end
            end

            local longest = TopByLength(st, 3)
            if #longest > 0 then
                Blank()
                Push("    Longest Stored Strings")
                for _, l in ipairs(longest) do
                    Push(string.format("      [%s]  %s",
                        FormatInt(l.len), Truncate(l.s, 52)))
                end
            end
            Blank()

            attribution[#attribution + 1] = {
                name  = name,
                bytes = st.totalBytes,
            }
        else
            Push(SubBlockHeader(name))
            Blank()
            Push("    (not loaded)")
            Blank()
        end
    end

    -- ============================================================
    -- 5. MidnightUI Runtime Globals
    -- ============================================================
    OpenSection(5, "MidnightUI Runtime Globals  -  Top 30 by Key Count")

    local muiGlobals = {}
    for k, v in pairs(_G) do
        if type(k) == "string"
           and (k:find("^MidnightUI_") or k == "MidnightUI"
                or k:find("^MyMessenger")) then
            if type(v) == "table" then
                muiGlobals[#muiGlobals + 1] = { name = k, keys = CountTableKeys(v) }
            end
        end
    end
    table.sort(muiGlobals, function(a, b) return a.keys > b.keys end)
    for i = 1, math.min(30, #muiGlobals) do
        local g = muiGlobals[i]
        Push(RankRow(i, Truncate(g.name, 38), FormatInt(g.keys) .. " keys"))
    end

    -- ============================================================
    -- 6. Frame Census
    -- ============================================================
    OpenSection(6, "Frame Census  -  MidnightUI / Messenger Owned")

    local fc = FrameCensus()
    Push(Row("Top-level frames",  FormatInt(fc.total)))
    Push(SubRow("Shown now",      FormatInt(fc.shown)))
    Push(SubRow("Hidden now",     FormatInt(fc.hidden)))
    Blank()
    Push("  Frame Types")
    Push(SubRow("Frames",         FormatInt(fc.frames)))
    Push(SubRow("Buttons",        FormatInt(fc.buttons)))
    Push(SubRow("EditBoxes",      FormatInt(fc.editBoxes)))
    Push(SubRow("ScrollFrames",   FormatInt(fc.scrollFrames)))
    Blank()
    Push("  Scripts Registered")
    Push(SubRow("OnUpdate handlers", FormatInt(fc.withOnUpdate)))
    Push(SubRow("OnEvent handlers",  FormatInt(fc.withOnEvent)))
    Blank()
    Push("  Strata Distribution")
    for strata, count in pairs(fc.byStrata) do
        Push(SubRow(strata, FormatInt(count)))
    end
    Blank()
    Push("  Note: only top-level children of UIParent are counted.")
    Push("  Nested child frames are not walked.")

    -- ============================================================
    -- 7. Memory Attribution Summary
    -- ============================================================
    OpenSection(7, "Memory Attribution Summary")

    local reportedKB = GetMidnightUIMemoryKB()

    local totalAttributedBytes = 0
    for _, a in ipairs(attribution) do
        totalAttributedBytes = totalAttributedBytes + a.bytes
    end
    local attributedKB = totalAttributedBytes / 1024
    local implied = math.max(0, reportedKB - attributedKB)

    Push(Row("Reported by WoW",            FormatKB(reportedKB)))
    Push(Row("Attributed to saved data",   FormatKB(attributedKB)))
    Push(Row("Implied runtime footprint",  FormatKB(implied)))
    Blank()
    Push("  Breakdown by Saved Block")
    for _, a in ipairs(attribution) do
        local pct = (totalAttributedBytes > 0)
            and (a.bytes / totalAttributedBytes * 100) or 0
        Push(string.format("  %-36s  %10s  (%5.1f %%)",
            Truncate(a.name, 36),
            FormatKB(a.bytes / 1024),
            pct))
    end
    Blank()
    Push("  Note: saved-data bytes are only part of MidnightUI's total")
    Push("  footprint. Runtime frames, closures, and pooled objects")
    Push("  live outside this walk. The implied runtime footprint above")
    Push("  estimates how much lives in that layer.")

    -- ============================================================
    -- Footer
    -- ============================================================
    Blank()
    Push(BAR_DOUBLE)
    Push("  END OF PROFILE")
    Push(BAR_DOUBLE)

    return table.concat(L, "\n")
end

local function BuildDeepCard(body)
    local topOffset = PAD + STATS_CARD_H + GAP + CONSUMERS_CARD_H + GAP
    local card = CreateFrame("Frame", nil, body, "BackdropTemplate")
    card:SetPoint("TOPLEFT",     body, "TOPLEFT",     PAD, -topOffset)
    card:SetPoint("TOPRIGHT",    body, "TOPRIGHT",    -PAD, -topOffset)
    card:SetPoint("BOTTOMLEFT",  body, "BOTTOMLEFT",  PAD, PAD)
    card:SetPoint("BOTTOMRIGHT", body, "BOTTOMRIGHT", -PAD, PAD)
    Back(card, UI.panel, UI.border, 1, 1)
    ui.deepCard = card

    local title = NewFS(card, "OVERLAY", FONT.heading, UI.text)
    title:SetPoint("TOPLEFT", card, "TOPLEFT", CARD_INSET, -CARD_TITLE_Y)
    title:SetText("Deep Memory Profile")

    local runBtn = CreateFrame("Button", nil, card, "BackdropTemplate")
    runBtn:SetSize(180, 30)
    runBtn:SetPoint("TOPRIGHT", card, "TOPRIGHT", -CARD_INSET, -(CARD_TITLE_Y - 4))
    Back(runBtn, UI.accent, UI.border, 0.25, 1)
    local runLabel = NewFS(runBtn, "OVERLAY", FONT.body, UI.accent)
    runLabel:SetPoint("CENTER")
    runLabel:SetText("Run Deep Profile")
    ui.runBtn = runBtn
    ui.runLabel = runLabel

    local hint = NewFS(card, "OVERLAY", FONT.small, UI.muted)
    hint:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -4)
    hint:SetText("Click Run to snapshot Lua heap, addons, and saved data.")
    ui.deepHint = hint

    local scroll = CreateFrame("ScrollFrame", nil, card, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT",     card, "TOPLEFT",     CARD_INSET,  -(CARD_CONTENT_Y + 20))
    scroll:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -30,          CARD_INSET)

    local box = CreateFrame("EditBox", nil, scroll)
    box:SetMultiLine(true)
    box:SetAutoFocus(false)
    box:SetFontObject("ChatFontNormal")
    box:SetWidth(scroll:GetWidth() ~= 0 and scroll:GetWidth() or 1100)
    box:SetTextInsets(8, 8, 6, 6)
    box:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    box:SetText("")
    scroll:SetScrollChild(box)
    scroll:SetScript("OnSizeChanged", function(self, w, h)
        box:SetWidth(math.max(100, w - 16))
    end)
    ui.deepScroll = scroll
    ui.deepBox    = box

    runBtn:SetScript("OnEnter", function(self)
        self:SetBackdropColor(UI.accent[1], UI.accent[2], UI.accent[3], 0.45)
    end)
    runBtn:SetScript("OnLeave", function(self)
        self:SetBackdropColor(UI.accent[1], UI.accent[2], UI.accent[3], 0.25)
    end)
    runBtn:SetScript("OnClick", function()
        local ok, text = pcall(BuildDeepProfileReport)
        if ok and type(text) == "string" then
            state.deepReport = text
            box:SetText(text)
            scroll:SetVerticalScroll(0)
            hint:SetText("Profile captured. Click inside and Ctrl+A / Ctrl+C to copy.")
            hint:SetTextColor(UI.accent[1], UI.accent[2], UI.accent[3])
        else
            hint:SetText("Profile failed: " .. tostring(text))
            hint:SetTextColor(SHELL.UI.muted[1], SHELL.UI.muted[2], SHELL.UI.muted[3])
        end
    end)
end

-- ============================================================
-- SECTION: Build + Refresh + Ticker
-- ============================================================
local function BuildMemory(body)
    state.body = body
    BuildStatsCard(body)
    BuildConsumersCard(body)
    BuildDeepCard(body)
end

local function RefreshMemory()
    RefreshStatsCard()
    RefreshConsumers()
end

local function TickerStep()
    if not state.body then return end
    if not state.body:IsShown() then return end
    RefreshStatsCard()
    RefreshConsumers()
end

local function EnsureTicker()
    if state.ticker then return end
    if C_Timer and C_Timer.NewTicker then
        state.ticker = C_Timer.NewTicker(REFRESH_INTERVAL, TickerStep)
    end
end

SHELL.RegisterTabContent("memory", BuildMemory, function()
    EnsureTicker()
    RefreshMemory()
end)

-- End of Console_UI_Memory.lua
