-- ============================================================
-- FILE: Console_UI_Stream.lua
-- MODULE: MidnightUI_Console_Stream (_G.MidnightUI_Console_Stream, local M)
--
-- PURPOSE:
--   Phase 2c of the Diagnostics Console rewrite. Populates the
--   Stream tab with a live-updating, scrollable feed of every
--   active incident sorted by most-recent-first, plus a
--   search bar, a pause/resume toggle, and a right-side
--   inspector panel that shows full detail for the selected
--   incident.
--
-- HOW IT WORKS:
--   Registers a build/refresh pair with Console_UI.lua.
--   build() lays out three elements: a toolbar card along the
--   top (search box, pause button, result count), a feed card
--   on the left that hosts a FauxScrollFrame plus a pool of
--   reusable row frames, and an inspector card on the right.
--   refresh() queries the engine's incidents, applies the
--   current search filter, sorts by lastSeen desc, and calls
--   UpdateRows() which repositions and re-labels the pooled
--   row frames according to the scroll offset. The inspector
--   updates whenever a row is clicked or the selected incident
--   changes on disk.
--
--   A C_Timer ticker refreshes the feed every 1s while the
--   Stream tab is active and not paused, so new events appear
--   live without flickering the UI every frame.
--
--   Row pooling: only POOL_SIZE row frames are ever created.
--   They are re-assigned to different incidents as the user
--   scrolls, so the feed performs identically with 10
--   incidents or 10,000.
--
-- DEPENDS ON:
--   MidnightUI_Console (engine) — GetAllIncidents, GetStats.
--   MidnightUI_Console_UI (shell) — palette, fonts, helpers,
--                                   tab registry.
--
-- EXPORTS: none (registers as a tab populator).
-- EVENTS REGISTERED: none (timer-driven).
-- FRAMES CREATED: toolbar card, feed card, inspector card,
--                 pooled rows, scrollbar.
--
-- GOTCHAS:
--   - The ticker must detect the tab is actually visible before
--     refreshing, otherwise we waste CPU on hidden tabs.
--   - Row pool reuses buttons. Clearing the selection highlight
--     must be done explicitly in UpdateRows — a stale row might
--     still be glowing from its previous content otherwise.
--   - FauxScrollFrame_Update expects row height including gap
--     as the stride; incident index offsets are in those stride
--     units. Mixing "visible rows" with "total rows" is easy to
--     get wrong; the two tracking variables are clearly named.
-- ============================================================

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 time = _G.time
local date = _G.date
local C_Timer = _G.C_Timer
local FauxScrollFrame_OnVerticalScroll = _G.FauxScrollFrame_OnVerticalScroll
local FauxScrollFrame_Update = _G.FauxScrollFrame_Update
local FauxScrollFrame_GetOffset = _G.FauxScrollFrame_GetOffset

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

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

local function Engine()
    return _G.MidnightUI_Console
end

-- ============================================================
-- SECTION: Imports from Shell
-- ============================================================
local UI     = SHELL.UI
local FONT   = SHELL.FONT
local LAYOUT = SHELL.LAYOUT
local Solid  = SHELL.SolidTexture
local Back   = SHELL.ApplyBackdrop
local NewFS  = SHELL.NewFontString

-- ============================================================
-- SECTION: Local Palette + Layout Constants
-- ============================================================
local SEV = {
    critical = {0.92, 0.30, 0.30},
    high     = {1.00, 0.55, 0.15},
    medium   = {1.00, 0.80, 0.20},
    low      = {0.65, 0.45, 0.90},
    info     = {0.35, 0.75, 0.95},
    unknown  = {0.55, 0.55, 0.55},
}

local KIND_LABELS = {
    LuaError = "Lua Error",
    Taint    = "Taint",
    Warning  = "Warning",
    UIError  = "UI Error",
    Debug    = "Debug",
}

local PAD        = 28
local GAP        = 22
local CARD_INSET = 18
local CARD_TITLE_Y = 22
local CARD_CONTENT_Y = 64

local TOOLBAR_H  = 56
local ROW_H      = 42
local ROW_GAP    = 6
local ROW_STRIDE = ROW_H + ROW_GAP

local POOL_SIZE  = 9             -- row frames; tuned to scroll frame height
                                 -- so rows never overflow the feed card.
local INSPECTOR_W = 440

local SPARK_BARS   = 20          -- trend sparkline bar count
local SPARK_HEIGHT = 20          -- trend sparkline pixel height

local REFRESH_INTERVAL = 1.0     -- seconds between live refreshes

-- ============================================================
-- SECTION: State
-- ============================================================
local state = {
    searchText   = "",
    paused       = false,
    selectedFP   = nil,
    filtered     = {},
    ticker       = nil,
    bodyFrame    = nil,
}

local feed = {
    card       = nil,
    scroll     = nil,
    rows       = {},
    countLabel = nil,
    emptyLabel = nil,
}

local inspector = {
    card         = nil,
    empty        = nil,
    kindBadge    = nil,
    kindText     = nil,
    sourceLabel  = nil,
    countLabel   = nil,
    firstLabel   = nil,
    lastLabel    = nil,
    sessionLabel = nil,
    messageScroll = nil,
    messageBox   = nil,
    stackScroll  = nil,
    stackBox     = nil,
    metaLabel    = nil,
    contextLabel = nil,
}

local toolbar = {
    card       = nil,
    searchBox  = nil,
    pauseBtn   = nil,
    pauseLabel = nil,
}

-- Forward declarations so nested functions can reference each other.
local SelectIncident
local UpdateRows
local RefreshStream

-- ============================================================
-- SECTION: Formatting Helpers
-- ============================================================
local function Truncate(s, maxLen)
    if type(s) ~= "string" then return "" end
    if #s <= maxLen then return s end
    return s:sub(1, maxLen) .. "..."
end

local function FormatRelTime(ts)
    if type(ts) ~= "number" or ts <= 0 then return "--" end
    local now = (time and time()) or 0
    local delta = now - ts
    if delta < 5    then return "just now" end
    if delta < 60   then return string.format("%ds ago", delta) end
    if delta < 3600 then return string.format("%dm ago", math.floor(delta / 60)) end
    if delta < 86400 then return string.format("%dh ago", math.floor(delta / 3600)) end
    return (date and date("%m/%d %H:%M", ts)) or "earlier"
end

local function FormatAbsTime(ts)
    if type(ts) ~= "number" or ts <= 0 then return "--" end
    return (date and date("%H:%M:%S", ts)) or tostring(ts)
end

local function ColorForKind(kind)
    return SEV[(kind == "LuaError" and "critical")
        or (kind == "Taint" and "high")
        or (kind == "Warning" and "medium")
        or (kind == "UIError" and "low")
        or "info"]
end

local function SetText(fs, text)
    if fs then fs:SetText(text or "") end
end

-- ============================================================
-- SECTION: Copy-out Formatters
-- Generate plain-text payloads for the shared copy dialog.
-- FormatIncidentForCopy serializes a single incident for the
-- "Copy" button in the inspector; FormatFilteredForCopy dumps
-- the entire currently-filtered feed for "Copy All".
-- ============================================================
local function FormatIncidentForCopy(e)
    if not e then return "(no incident selected)" end
    local L = {}
    L[#L + 1] = "[incident]"
    L[#L + 1] = "kind=" .. tostring(e.kind or "?")
    L[#L + 1] = "count=" .. tostring(e.count or 1)
    L[#L + 1] = "severity=" .. tostring(e.severity or "info")
    L[#L + 1] = "source=" .. tostring(e.source or e.addon or "?")
    L[#L + 1] = "session=#" .. tostring(e.session or 0)
    L[#L + 1] = "first_seen=" .. FormatAbsTime(e.firstSeen)
    L[#L + 1] = "last_seen=" .. FormatAbsTime(e.lastSeen)
    if e.meta and e.meta ~= "" then
        L[#L + 1] = "meta=" .. tostring(e.meta)
    end
    if e.fingerprint then
        L[#L + 1] = "fingerprint=" .. tostring(e.fingerprint)
    end
    L[#L + 1] = ""
    L[#L + 1] = "msg<<<"
    L[#L + 1] = tostring(e.message or "")
    L[#L + 1] = ">>>"
    if e.rawStack and e.rawStack ~= "" then
        L[#L + 1] = ""
        L[#L + 1] = "stack<<<"
        L[#L + 1] = tostring(e.rawStack)
        L[#L + 1] = ">>>"
    end
    return table.concat(L, "\n")
end

-- Full date+time for the timeline dialog (distinct from the
-- terser FormatAbsTime used in the inspector's labels).
local function FormatFullTime(ts)
    if not ts or ts == 0 then return "unknown" end
    return (date and date("%Y-%m-%d  %H:%M:%S", ts)) or tostring(ts)
end

local function FormatContextLines(ctx, indent)
    indent = indent or "  "
    local L = {}
    if not ctx then
        L[#L + 1] = indent .. "(context not captured)"
        return L
    end
    local function add(label, value)
        if value ~= nil and value ~= "" then
            L[#L + 1] = string.format("%s%-14s %s", indent, label, tostring(value))
        end
    end
    add("Character:", ctx.player)
    add("Class:",     ctx.class)
    add("Spec:",      ctx.spec)
    add("Level:",     ctx.level)
    add("Zone:",      ctx.zone)
    add("Subzone:",   ctx.subzone)
    if ctx.inInstance ~= nil then
        add("In instance:", ctx.inInstance
            and ("yes (" .. tostring(ctx.instanceType or "unknown") .. ")")
            or  "no")
    end
    if ctx.combat ~= nil then
        add("In combat:", ctx.combat and "yes" or "no")
    end
    add("Group size:", ctx.groupSize)
    return L
end

local function FormatTimelineSummary(entry)
    if not entry then return "(no incident selected)" end
    local L = {}
    L[#L + 1] = "Incident Timeline"
    L[#L + 1] = string.rep("=", 52)
    L[#L + 1] = ""
    L[#L + 1] = "Kind:         " .. tostring(entry.kind or "?")
    L[#L + 1] = "Source:       " .. tostring(entry.source or entry.addon or "?")
    L[#L + 1] = "Severity:     " .. tostring(entry.severity or "info")
    L[#L + 1] = "Occurrences:  " .. tostring(entry.count or 1)
    L[#L + 1] = "Session:      #" .. tostring(entry.session or 0)
    L[#L + 1] = ""
    L[#L + 1] = "[First Seen]"
    L[#L + 1] = "  Time:        " .. FormatFullTime(entry.firstSeen)
    local firstLines = FormatContextLines(entry.context, "  ")
    for _, line in ipairs(firstLines) do L[#L + 1] = line end
    L[#L + 1] = ""
    L[#L + 1] = "[Last Seen]"
    L[#L + 1] = "  Time:        " .. FormatFullTime(entry.lastSeen)
    local lastLines = FormatContextLines(entry.lastContext or entry.context, "  ")
    for _, line in ipairs(lastLines) do L[#L + 1] = line end
    L[#L + 1] = ""
    -- Comparison summary: call out what changed between first and last
    local first = entry.context
    local last  = entry.lastContext or entry.context
    if first and last and first ~= last then
        L[#L + 1] = "[Changes Between First and Last]"
        local function diff(label, a, b)
            if a ~= b then
                L[#L + 1] = string.format("  %s  %s  ->  %s",
                    label, tostring(a or "?"), tostring(b or "?"))
            end
        end
        diff("Zone:       ", first.zone,     last.zone)
        diff("Subzone:    ", first.subzone,  last.subzone)
        diff("Spec:       ", first.spec,     last.spec)
        diff("In combat:  ",
            first.combat and "yes" or "no",
            last.combat  and "yes" or "no")
        diff("In instance:",
            first.inInstance and "yes" or "no",
            last.inInstance  and "yes" or "no")
        diff("Group size: ", first.groupSize, last.groupSize)
        L[#L + 1] = ""
    end
    L[#L + 1] = "[Message]"
    L[#L + 1] = tostring(entry.message or "")
    return table.concat(L, "\n")
end

local function FormatFilteredForCopy()
    local L = {}
    L[#L + 1] = "@@MIDNIGHTUI_STREAM_EXPORT@@"
    L[#L + 1] = "Filtered live stream export from the MidnightUI Diagnostics Console."
    L[#L + 1] = "count=" .. tostring(#state.filtered)
    if state.searchText and state.searchText ~= "" then
        L[#L + 1] = 'search="' .. state.searchText .. '"'
    end
    if date then
        L[#L + 1] = "generated=" .. date("%Y-%m-%d %H:%M:%S")
    end
    L[#L + 1] = ""
    for i = 1, #state.filtered do
        local e = state.filtered[i]
        L[#L + 1] = string.format(
            "[%d/%d] kind=%s count=%d source=%s session=#%d",
            i, #state.filtered,
            tostring(e.kind or "?"),
            e.count or 1,
            tostring(e.source or e.addon or "?"),
            e.session or 0)
        L[#L + 1] = "msg<<<"
        L[#L + 1] = tostring(e.message or "")
        L[#L + 1] = ">>>"
        if e.rawStack and e.rawStack ~= "" then
            L[#L + 1] = "stack<<<"
            L[#L + 1] = tostring(e.rawStack)
            L[#L + 1] = ">>>"
        end
        L[#L + 1] = ""
    end
    L[#L + 1] = "@@END@@"
    return table.concat(L, "\n")
end

-- ============================================================
-- SECTION: Filter + Sort Pipeline
-- ============================================================
local function ApplyFilter(all, searchText)
    if type(searchText) ~= "string" or searchText == "" then
        local out = {}
        for i = 1, #all do out[i] = all[i] end
        return out
    end
    local needle = string.lower(searchText)
    local out = {}
    for i = 1, #all do
        local e = all[i]
        local hit = false
        if e.message and string.find(string.lower(e.message), needle, 1, true) then
            hit = true
        elseif e.source and string.find(string.lower(e.source), needle, 1, true) then
            hit = true
        elseif e.addon and string.find(string.lower(e.addon), needle, 1, true) then
            hit = true
        elseif e.kind and string.find(string.lower(e.kind), needle, 1, true) then
            hit = true
        end
        if hit then out[#out + 1] = e end
    end
    return out
end

local function RecomputeFiltered()
    local engine = Engine()
    local all = (engine and engine.GetAllIncidents and engine.GetAllIncidents()) or {}
    local filtered = ApplyFilter(all, state.searchText)
    table.sort(filtered, function(a, b)
        return (a.lastSeen or 0) > (b.lastSeen or 0)
    end)
    state.filtered = filtered
end

-- ============================================================
-- SECTION: Row Construction (pooled)
-- Each row is a reusable Button anchored inside the feed card.
-- UpdateRows repositions and re-labels these row frames based
-- on the current scroll offset.
-- ============================================================
local function BuildRow(parent, index)
    local row = CreateFrame("Button", nil, parent)
    row:SetHeight(ROW_H)
    row:EnableMouse(true)

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

    local sevBar = Solid(row, "ARTWORK", SEV.info, 1)
    sevBar:SetWidth(4)
    sevBar:SetPoint("TOPLEFT",    row, "TOPLEFT",    0, 0)
    sevBar:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0)
    row.sevBar = sevBar

    local kindBadge = CreateFrame("Frame", nil, row, "BackdropTemplate")
    kindBadge:SetSize(58, 22)
    kindBadge:SetPoint("LEFT", row, "LEFT", 14, 0)
    Back(kindBadge, UI.bg, UI.border, 1, 0.7)
    local kindText = NewFS(kindBadge, "OVERLAY", FONT.tiny, UI.text)
    kindText:SetPoint("CENTER")
    kindText:SetText("?")
    row.kindBadge = kindBadge
    row.kindText  = kindText

    local countPill = CreateFrame("Frame", nil, row, "BackdropTemplate")
    countPill:SetSize(52, 22)
    countPill:SetPoint("LEFT", kindBadge, "RIGHT", 8, 0)
    Back(countPill, UI.bg, UI.border, 1, 0.7)
    local countText = NewFS(countPill, "OVERLAY", FONT.tiny, UI.accent)
    countText:SetPoint("CENTER")
    countText:SetText("x0")
    row.countText = countText

    local msg = NewFS(row, "OVERLAY", FONT.body, UI.text)
    msg:SetPoint("LEFT", countPill, "RIGHT", 14, 0)
    msg:SetJustifyH("LEFT")
    msg:SetPoint("RIGHT", row, "RIGHT", -190, 0)
    msg:SetText("")
    row.msg = msg

    local src = NewFS(row, "OVERLAY", FONT.small, UI.muted)
    src:SetPoint("RIGHT", row, "RIGHT", -82, 0)
    src:SetJustifyH("RIGHT")
    src:SetText("")
    row.src = src

    local tm = NewFS(row, "OVERLAY", FONT.small, UI.muted)
    tm:SetPoint("RIGHT", row, "RIGHT", -14, 0)
    tm:SetJustifyH("RIGHT")
    tm:SetText("")
    row.tm = tm

    row:SetScript("OnEnter", function(self)
        if self.fp == state.selectedFP then return end
        self.bg:SetVertexColor(UI.rowHover[1], UI.rowHover[2], UI.rowHover[3], 1)
    end)
    row:SetScript("OnLeave", function(self)
        if self.fp == state.selectedFP then return end
        self.bg:SetVertexColor(UI.inset[1], UI.inset[2], UI.inset[3], 1)
    end)
    row:SetScript("OnClick", function(self)
        if self.fp then
            SelectIncident(self.fp)
        end
    end)

    row:Hide()
    return row
end

-- ============================================================
-- SECTION: Feed Card + Scrollbar
-- ============================================================
local function BuildFeed(body)
    local card = CreateFrame("Frame", nil, body, "BackdropTemplate")
    card:SetPoint("TOPLEFT",     body, "TOPLEFT",     PAD, -(PAD + TOOLBAR_H + GAP))
    card:SetPoint("BOTTOMLEFT",  body, "BOTTOMLEFT",  PAD, PAD)
    card:SetPoint("RIGHT",       body, "RIGHT",       -(PAD + INSPECTOR_W + GAP), 0)
    Back(card, UI.panel, UI.border, 1, 1)
    feed.card = card

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

    feed.countLabel = NewFS(card, "OVERLAY", FONT.small, UI.muted)
    feed.countLabel:SetPoint("TOPRIGHT", card, "TOPRIGHT", -CARD_INSET, -(CARD_TITLE_Y + 3))
    feed.countLabel:SetJustifyH("RIGHT")
    feed.countLabel:SetText("")

    -- Scroll frame (faux) along the right edge of the feed card
    local scroll = CreateFrame("ScrollFrame", "MidnightUI_Console_StreamScroll",
                               card, "FauxScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT",     card, "TOPLEFT",     CARD_INSET, -CARD_CONTENT_Y)
    scroll:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -32,         CARD_INSET)
    scroll:SetScript("OnVerticalScroll", function(self, offset)
        FauxScrollFrame_OnVerticalScroll(self, offset, ROW_STRIDE, UpdateRows)
    end)
    scroll:SetScript("OnShow", function() UpdateRows() end)
    feed.scroll = scroll

    -- Pool of row frames anchored inside the feed card (not the
    -- scroll child) because FauxScrollFrame has no real scroll
    -- child; we reposition rows manually via UpdateRows.
    for i = 1, POOL_SIZE do
        local row = BuildRow(card, i)
        feed.rows[i] = row
    end

    -- Empty state
    feed.emptyLabel = NewFS(card, "OVERLAY", FONT.body, UI.muted)
    feed.emptyLabel:SetPoint("CENTER", card, "CENTER", 0, 0)
    feed.emptyLabel:SetText("No Incidents Yet")
    feed.emptyLabel:Hide()
end

-- UpdateRows: called on every scroll and every refresh tick.
-- Positions the pool frames, assigns them to filtered incidents
-- based on FauxScrollFrame's offset, and updates labels.
function UpdateRows()
    if not feed.scroll then return end

    local total = #state.filtered
    local visible = POOL_SIZE

    FauxScrollFrame_Update(feed.scroll, total, visible, ROW_STRIDE)

    local offset = FauxScrollFrame_GetOffset(feed.scroll) or 0

    if total == 0 then
        feed.emptyLabel:Show()
        for i = 1, POOL_SIZE do feed.rows[i]:Hide() end
        SetText(feed.countLabel, "0")
        return
    else
        feed.emptyLabel:Hide()
    end

    SetText(feed.countLabel, tostring(total) .. " shown")

    for i = 1, POOL_SIZE do
        local row = feed.rows[i]
        local incidentIndex = offset + i
        local e = state.filtered[incidentIndex]
        if e then
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT",  feed.scroll, "TOPLEFT",  0, -((i - 1) * ROW_STRIDE))
            row:SetPoint("TOPRIGHT", feed.scroll, "TOPRIGHT", 0, -((i - 1) * ROW_STRIDE))
            row.fp = e.fingerprint

            local sevColor = ColorForKind(e.kind)
            row.sevBar:SetVertexColor(sevColor[1], sevColor[2], sevColor[3], 1)

            SetText(row.kindText, KIND_LABELS[e.kind or ""] or (e.kind or "?"))
            SetText(row.countText, "x" .. tostring(e.count or 1))
            SetText(row.msg, Truncate(tostring(e.message or ""), 80))
            SetText(row.src, Truncate(tostring(e.source or e.addon or "?"), 18))
            SetText(row.tm,  FormatRelTime(e.lastSeen))

            if e.fingerprint == state.selectedFP then
                row.bg:SetVertexColor(UI.rowHover[1] * 1.5, UI.rowHover[2] * 1.5,
                                       UI.rowHover[3] * 1.5, 1)
            else
                row.bg:SetVertexColor(UI.inset[1], UI.inset[2], UI.inset[3], 1)
            end
            row:Show()
        else
            row:Hide()
            row.fp = nil
        end
    end
end

-- ============================================================
-- SECTION: Inspector Card
-- Right-hand panel that shows the selected incident's full
-- detail. For Phase 2c we keep the layout minimal: summary
-- header, message body, stack body.
-- ============================================================
local SEV = {
    critical = {0.92, 0.30, 0.30},
    high     = {1.00, 0.55, 0.15},
    medium   = {1.00, 0.80, 0.20},
    low      = {0.65, 0.45, 0.90},
    info     = {0.35, 0.75, 0.95},
    unknown  = {0.55, 0.55, 0.55},
}

local function SeverityOf(entry)
    if not entry then return SEV.unknown end
    return SEV[entry.severity or "info"] or SEV.unknown
end

local function FormatDurationShort(sec)
    if type(sec) ~= "number" or sec < 0 then return "--" end
    if sec < 60 then return string.format("%ds", sec) end
    if sec < 3600 then
        return string.format("%dm %02ds", math.floor(sec / 60), sec % 60)
    end
    local h = math.floor(sec / 3600)
    local m = math.floor((sec % 3600) / 60)
    return string.format("%dh %02dm", h, m)
end

local OpenHistoryPopup  -- forward-declared; defined after BuildInspector

local function BuildInspector(body)
    local card = CreateFrame("Frame", nil, body, "BackdropTemplate")
    card:SetPoint("TOPRIGHT",    body, "TOPRIGHT",    -PAD, -(PAD + TOOLBAR_H + GAP))
    card:SetPoint("BOTTOMRIGHT", body, "BOTTOMRIGHT", -PAD, PAD)
    card:SetWidth(INSPECTOR_W)
    Back(card, UI.panel, UI.border, 1, 1)
    inspector.card = card

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

    -- Copy button (top-right of inspector card). Only visible when
    -- an incident is selected; toggled by inspector._toggle().
    local copyInc = CreateFrame("Button", nil, card, "BackdropTemplate")
    copyInc:SetSize(80, 26)
    copyInc:SetPoint("TOPRIGHT", card, "TOPRIGHT", -CARD_INSET, -(CARD_TITLE_Y - 2))
    Back(copyInc, UI.accent, UI.border, 0.22, 1)
    local copyIncLabel = NewFS(copyInc, "OVERLAY", FONT.body, UI.accent)
    copyIncLabel:SetPoint("CENTER")
    copyIncLabel:SetText("Copy")
    copyInc:SetScript("OnEnter", function(self)
        self:SetBackdropColor(UI.accent[1], UI.accent[2], UI.accent[3], 0.42)
    end)
    copyInc:SetScript("OnLeave", function(self)
        self:SetBackdropColor(UI.accent[1], UI.accent[2], UI.accent[3], 0.22)
    end)
    copyInc:SetScript("OnClick", function()
        local engine = Engine()
        local entry = state.selectedFP and engine and engine.GetIncidentByFingerprint
            and engine.GetIncidentByFingerprint(state.selectedFP) or nil
        if entry and SHELL and SHELL.ShowCopyDialog then
            SHELL.ShowCopyDialog(FormatIncidentForCopy(entry), "Incident")
        end
    end)
    inspector.copyBtn = copyInc

    -- Empty state
    inspector.empty = NewFS(card, "OVERLAY", FONT.body, UI.muted)
    inspector.empty:SetPoint("CENTER", card, "CENTER", 0, 0)
    inspector.empty:SetText("Select An Incident")

    -- ============================================================
    -- Hero Header: severity ribbon + kind crest + massive count
    -- ============================================================
    local HEAD_Y = 52
    local HEAD_H = 82

    -- Outer head frame with layered background tint
    local head = CreateFrame("Frame", nil, card, "BackdropTemplate")
    head:SetPoint("TOPLEFT",  card, "TOPLEFT",  CARD_INSET,  -HEAD_Y)
    head:SetPoint("TOPRIGHT", card, "TOPRIGHT", -CARD_INSET, -HEAD_Y)
    head:SetHeight(HEAD_H)
    Back(head, UI.inset, UI.border, 1, 0.9)
    inspector.headCard = head

    -- Severity ribbon along the left edge (full height, 6px)
    local ribbon = Solid(head, "OVERLAY", SEV.info, 1)
    ribbon:SetWidth(6)
    ribbon:SetPoint("TOPLEFT",    head, "TOPLEFT",    1, -1)
    ribbon:SetPoint("BOTTOMLEFT", head, "BOTTOMLEFT", 1, 1)
    inspector.sevBand = ribbon   -- keep existing name; now a full-height ribbon

    -- Soft vertical tint strip just inside the ribbon so the
    -- severity color washes into the card instead of being a harsh cut
    local ribbonBleed = Solid(head, "BACKGROUND", SEV.info, 0.12)
    ribbonBleed:SetWidth(40)
    ribbonBleed:SetPoint("TOPLEFT",    head, "TOPLEFT",    7, -1)
    ribbonBleed:SetPoint("BOTTOMLEFT", head, "BOTTOMLEFT", 7, 1)
    inspector.sevBleed = ribbonBleed

    -- Thin cyan rule along the bottom edge for visual anchor
    local headFoot = Solid(head, "OVERLAY", UI.accent, 0.6)
    headFoot:SetHeight(1)
    headFoot:SetPoint("BOTTOMLEFT",  head, "BOTTOMLEFT",  1, 1)
    headFoot:SetPoint("BOTTOMRIGHT", head, "BOTTOMRIGHT", -1, 1)

    -- Kind crest: a small square chip next to the ribbon with a
    -- single severity-tinted letter inside.
    local crest = CreateFrame("Frame", nil, head, "BackdropTemplate")
    crest:SetSize(30, 30)
    crest:SetPoint("TOPLEFT", head, "TOPLEFT", 18, -14)
    Back(crest, UI.bg, UI.border, 1, 1)
    local crestInner = Solid(crest, "ARTWORK", SEV.info, 0.28)
    crestInner:SetPoint("TOPLEFT",     crest, "TOPLEFT",     1, -1)
    crestInner:SetPoint("BOTTOMRIGHT", crest, "BOTTOMRIGHT", -1, 1)
    local crestLetter = NewFS(crest, "OVERLAY", 18, UI.text)
    crestLetter:SetPoint("CENTER")
    crestLetter:SetText("?")
    inspector.crest        = crest
    inspector.crestInner   = crestInner
    inspector.crestLetter  = crestLetter

    -- Reserve a fixed column on the right for the hero count so
    -- long numbers cannot crash into the kind/source text on the
    -- left. The count column is anchored to the head's right edge
    -- with an explicit left bound.
    local COUNT_COL_W = 150

    -- Kind title in display font, severity-tinted. Right-bounded
    -- so long names (e.g. "LUA ERROR") cannot flow under the count.
    inspector.kindText = NewFS(head, "OVERLAY", FONT.display, UI.text)
    inspector.kindText:SetPoint("TOPLEFT", crest, "TOPRIGHT", 12, -2)
    inspector.kindText:SetPoint("RIGHT",   head,  "RIGHT",    -(COUNT_COL_W + 12), 0)
    inspector.kindText:SetJustifyH("LEFT")
    inspector.kindText:SetText("")

    -- Single sub-line below the kind title combining source AND
    -- session (e.g. "MidnightUI_Debug  -  Session #4"). One line
    -- instead of two stacked widgets prevents them from colliding
    -- at the bottom-left of the header.
    inspector.sourceText = NewFS(head, "OVERLAY", FONT.small, UI.muted)
    inspector.sourceText:SetPoint("BOTTOMLEFT", head, "BOTTOMLEFT", 18, 14)
    inspector.sourceText:SetPoint("RIGHT",      head, "RIGHT",      -(COUNT_COL_W + 12), 0)
    inspector.sourceText:SetJustifyH("LEFT")
    inspector.sourceText:SetText("")

    -- Hero count: right-anchored AND left-bounded so long numbers
    -- cannot extend leftward past the reserved column. Uses
    -- JustifyH "RIGHT" so text hugs the right edge.
    inspector.countText = NewFS(head, "OVERLAY", 38, UI.accent)
    inspector.countText:SetPoint("TOPRIGHT", head, "TOPRIGHT", -18, -10)
    inspector.countText:SetPoint("TOPLEFT",  head, "TOPRIGHT", -(COUNT_COL_W + 18), -10)
    inspector.countText:SetJustifyH("RIGHT")
    inspector.countText:SetText("")

    -- Subtle cyan glow behind the count, strictly contained inside
    -- the head frame so it cannot overflow horizontally.
    local countGlow = Solid(head, "ARTWORK", UI.accent, 0.10)
    countGlow:SetHeight(48)
    countGlow:SetPoint("RIGHT", head, "RIGHT", -10, -6)
    countGlow:SetWidth(COUNT_COL_W - 16)
    inspector.countGlow = countGlow

    -- Small "Times Fired" caption under the count (WoW-player
    -- friendlier than "occurrences").
    inspector.countCaption = NewFS(head, "OVERLAY", FONT.tiny, UI.muted)
    inspector.countCaption:SetPoint("TOPRIGHT", inspector.countText, "BOTTOMRIGHT", -2, -4)
    inspector.countCaption:SetJustifyH("RIGHT")
    inspector.countCaption:SetText("Times Fired")

    -- (Session info is merged into sourceText above; no separate
    -- sessionText widget is needed. inspector.sessionText stays
    -- nil so RefreshInspector's SetText guard no-ops cleanly.)

    -- ============================================================
    -- Timeline Card: visual hero element, clickable for history.
    -- Chart with gridlines + time markers + impact meter + trend pill.
    -- ============================================================
    local TL_Y = HEAD_Y + HEAD_H + 10
    local TL_H = 198
    local timeline = CreateFrame("Button", nil, card, "BackdropTemplate")
    timeline:SetPoint("TOPLEFT",  card, "TOPLEFT",  CARD_INSET,  -TL_Y)
    timeline:SetPoint("TOPRIGHT", card, "TOPRIGHT", -CARD_INSET, -TL_Y)
    timeline:SetHeight(TL_H)
    Back(timeline, UI.inset, UI.border, 1, 1)
    inspector.timeline = timeline

    local tlAccent = Solid(timeline, "OVERLAY", UI.accent, 0.75)
    tlAccent:SetHeight(2)
    tlAccent:SetPoint("TOPLEFT",  timeline, "TOPLEFT",  1, -1)
    tlAccent:SetPoint("TOPRIGHT", timeline, "TOPRIGHT", -1, -1)
    inspector.tlAccent = tlAccent

    local tlHeader = NewFS(timeline, "OVERLAY", FONT.heading, UI.text)
    tlHeader:SetPoint("TOPLEFT", timeline, "TOPLEFT", 16, -10)
    tlHeader:SetText("Lifetime")

    inspector.tlDuration = NewFS(timeline, "OVERLAY", FONT.body, UI.accent)
    inspector.tlDuration:SetPoint("TOPRIGHT", timeline, "TOPRIGHT", -16, -12)
    inspector.tlDuration:SetJustifyH("RIGHT")

    -- Chart area (main visual)
    local CHART_TOP = 38
    local CHART_H  = 68
    local chart = CreateFrame("Frame", nil, timeline, "BackdropTemplate")
    chart:SetPoint("TOPLEFT",  timeline, "TOPLEFT",  16, -CHART_TOP)
    chart:SetPoint("TOPRIGHT", timeline, "TOPRIGHT", -16, -CHART_TOP)
    chart:SetHeight(CHART_H)
    Back(chart, UI.bg, UI.border, 1, 0.6)

    -- Gridlines at 25% / 50% / 75%
    for _, frac in ipairs({ 0.25, 0.50, 0.75 }) do
        local grid = Solid(chart, "ARTWORK", UI.border, 0.35)
        grid:SetHeight(1)
        grid:SetPoint("BOTTOMLEFT",  chart, "BOTTOMLEFT",  1, math.floor(CHART_H * frac))
        grid:SetPoint("BOTTOMRIGHT", chart, "BOTTOMRIGHT", -1, math.floor(CHART_H * frac))
    end
    -- Baseline
    local baseline = Solid(chart, "OVERLAY", UI.border, 0.9)
    baseline:SetHeight(1)
    baseline:SetPoint("BOTTOMLEFT",  chart, "BOTTOMLEFT",  1, 1)
    baseline:SetPoint("BOTTOMRIGHT", chart, "BOTTOMRIGHT", -1, 1)

    chart.bars = {}
    chart.barGlows = {}
    for i = 1, SPARK_BARS do
        -- Two-layer bar: glow sprite behind + main bar on top
        local glow = chart:CreateTexture(nil, "ARTWORK", nil, -1)
        glow:SetTexture("Interface\\Buttons\\WHITE8X8")
        glow:SetVertexColor(UI.accent[1], UI.accent[2], UI.accent[3], 0.22)
        glow:Hide()
        chart.barGlows[i] = glow

        local bar = chart:CreateTexture(nil, "ARTWORK")
        bar:SetTexture("Interface\\Buttons\\WHITE8X8")
        bar:SetVertexColor(UI.accent[1], UI.accent[2], UI.accent[3], 0.9)
        bar:Hide()
        chart.bars[i] = bar
    end
    chart:SetScript("OnSizeChanged", function(self, w, _)
        local gap  = 1
        local barW = math.max(1, (w - (SPARK_BARS - 1) * gap - 4) / SPARK_BARS)
        for i, bar in ipairs(self.bars) do
            bar:ClearAllPoints()
            bar:SetWidth(barW)
            bar:SetPoint("BOTTOMLEFT", self, "BOTTOMLEFT",
                2 + (i - 1) * (barW + gap), 1)

            local glow = self.barGlows[i]
            if glow then
                glow:ClearAllPoints()
                glow:SetWidth(barW + 4)
                glow:SetPoint("BOTTOMLEFT", self, "BOTTOMLEFT",
                    (i - 1) * (barW + gap), 0)
            end
        end
    end)
    inspector.spark = chart   -- keep the name; now a full chart widget

    -- Time markers below the chart (First / Mid / Last)
    inspector.tlFirst = NewFS(timeline, "OVERLAY", FONT.tiny, UI.muted)
    inspector.tlFirst:SetPoint("TOPLEFT", chart, "BOTTOMLEFT", 2, -4)
    inspector.tlFirst:SetJustifyH("LEFT")

    inspector.tlMid = NewFS(timeline, "OVERLAY", FONT.tiny, UI.muted)
    inspector.tlMid:SetPoint("TOP", chart, "BOTTOM", 0, -4)
    inspector.tlMid:SetJustifyH("CENTER")

    inspector.tlLast = NewFS(timeline, "OVERLAY", FONT.tiny, UI.muted)
    inspector.tlLast:SetPoint("TOPRIGHT", chart, "BOTTOMRIGHT", -2, -4)
    inspector.tlLast:SetJustifyH("RIGHT")

    -- Impact Tier Meter (5 segments, light up based on count tier)
    local IMPACT_Y = CHART_TOP + CHART_H + 22
    local tierLabel = NewFS(timeline, "OVERLAY", FONT.tiny, UI.muted)
    tierLabel:SetPoint("TOPLEFT", timeline, "TOPLEFT", 16, -IMPACT_Y)
    tierLabel:SetText("Impact")

    local tierName = NewFS(timeline, "OVERLAY", FONT.small, UI.text)
    tierName:SetPoint("TOPRIGHT", timeline, "TOPRIGHT", -16, -IMPACT_Y)
    tierName:SetJustifyH("RIGHT")
    tierName:SetText("")
    inspector.tierName = tierName

    local meterFrame = CreateFrame("Frame", nil, timeline)
    meterFrame:SetPoint("TOPLEFT",  timeline, "TOPLEFT",  16, -(IMPACT_Y + 12))
    meterFrame:SetPoint("TOPRIGHT", timeline, "TOPRIGHT", -16, -(IMPACT_Y + 12))
    meterFrame:SetHeight(10)

    local NUM_SEGMENTS = 5
    meterFrame.segments = {}
    for i = 1, NUM_SEGMENTS do
        local seg = CreateFrame("Frame", nil, meterFrame, "BackdropTemplate")
        Back(seg, UI.bg, UI.border, 1, 0.8)
        local fill = Solid(seg, "OVERLAY", UI.muted, 0.4)
        fill:SetPoint("TOPLEFT",     seg, "TOPLEFT",     1, -1)
        fill:SetPoint("BOTTOMRIGHT", seg, "BOTTOMRIGHT", -1, 1)
        seg.fill = fill
        meterFrame.segments[i] = seg
    end
    meterFrame:SetScript("OnSizeChanged", function(self, w, _)
        local gap = 4
        local segW = math.max(1, (w - (NUM_SEGMENTS - 1) * gap) / NUM_SEGMENTS)
        for i, seg in ipairs(self.segments) do
            seg:ClearAllPoints()
            seg:SetSize(segW, 10)
            seg:SetPoint("LEFT", self, "LEFT", (i - 1) * (segW + gap), 0)
        end
    end)
    inspector.tierMeter = meterFrame

    -- Trend pill stretches across the bottom of the card, full width.
    -- Visual: colored left bar (trend color) + full-width text. No
    -- icon character - the color bar does the work.
    local trendPill = CreateFrame("Frame", nil, timeline, "BackdropTemplate")
    trendPill:SetPoint("BOTTOMLEFT",  timeline, "BOTTOMLEFT",  16, 12)
    trendPill:SetPoint("BOTTOMRIGHT", timeline, "BOTTOMRIGHT", -16, 12)
    trendPill:SetHeight(24)
    Back(trendPill, UI.bg, UI.border, 1, 0.9)
    local trendInner = Solid(trendPill, "ARTWORK", UI.accent, 0.16)
    trendInner:SetPoint("TOPLEFT",     trendPill, "TOPLEFT",     1, -1)
    trendInner:SetPoint("BOTTOMRIGHT", trendPill, "BOTTOMRIGHT", -1, 1)
    inspector.trendInner = trendInner

    -- Colored left bar: full height of pill, 4px wide
    local trendBar = Solid(trendPill, "OVERLAY", UI.accent, 1)
    trendBar:SetWidth(4)
    trendBar:SetPoint("TOPLEFT",    trendPill, "TOPLEFT",    1, -1)
    trendBar:SetPoint("BOTTOMLEFT", trendPill, "BOTTOMLEFT", 1, 1)
    inspector.trendBar = trendBar

    inspector.tlTrend = NewFS(trendPill, "OVERLAY", FONT.small, UI.text)
    inspector.tlTrend:SetPoint("LEFT",  trendPill, "LEFT",   16, 0)
    inspector.tlTrend:SetPoint("RIGHT", trendPill, "RIGHT", -14, 0)
    inspector.tlTrend:SetJustifyH("LEFT")
    inspector.tlTrend:SetText("")
    inspector.trendPill = trendPill

    timeline:SetScript("OnEnter", function(self)
        self:SetBackdropColor(UI.rowHover[1], UI.rowHover[2], UI.rowHover[3], 1)
        tlAccent:SetVertexColor(UI.accent[1], UI.accent[2], UI.accent[3], 1)
    end)
    timeline:SetScript("OnLeave", function(self)
        self:SetBackdropColor(UI.inset[1], UI.inset[2], UI.inset[3], 1)
        tlAccent:SetVertexColor(UI.accent[1], UI.accent[2], UI.accent[3], 0.75)
    end)
    timeline:SetScript("OnClick", function()
        if OpenHistoryPopup then OpenHistoryPopup(state.selectedFP) end
    end)

    -- ============================================================
    -- Message + Stack panels (compact, below timeline)
    -- ============================================================
    local msgHeaderY = TL_Y + TL_H + 14
    local msgHeader = NewFS(card, "OVERLAY", FONT.small, UI.muted)
    msgHeader:SetPoint("TOPLEFT", card, "TOPLEFT", CARD_INSET, -msgHeaderY)
    msgHeader:SetText("Message")
    inspector.msgHeader = msgHeader

    local MSG_H = 56
    local msgScroll = CreateFrame("ScrollFrame", nil, card, "UIPanelScrollFrameTemplate")
    msgScroll:SetPoint("TOPLEFT",  card, "TOPLEFT",  CARD_INSET, -(msgHeaderY + 16))
    msgScroll:SetPoint("TOPRIGHT", card, "TOPRIGHT", -36,          -(msgHeaderY + 16))
    msgScroll:SetHeight(MSG_H)
    local msgBox = CreateFrame("EditBox", nil, msgScroll)
    msgBox:SetMultiLine(true)
    msgBox:SetAutoFocus(false)
    msgBox:SetFontObject("ChatFontNormal")
    msgBox:SetWidth(INSPECTOR_W - CARD_INSET - 42)
    msgBox:SetScript("OnEscapePressed", msgBox.ClearFocus)
    msgBox:SetText("")
    msgScroll:SetScrollChild(msgBox)
    inspector.messageScroll = msgScroll
    inspector.messageBox    = msgBox

    local stackHeaderY = msgHeaderY + 16 + MSG_H + 10
    local stackHeader = NewFS(card, "OVERLAY", FONT.small, UI.muted)
    stackHeader:SetPoint("TOPLEFT", card, "TOPLEFT", CARD_INSET, -stackHeaderY)
    stackHeader:SetText("Stack")
    inspector.stackHeader = stackHeader

    local stackScroll = CreateFrame("ScrollFrame", nil, card, "UIPanelScrollFrameTemplate")
    stackScroll:SetPoint("TOPLEFT",     card, "TOPLEFT",     CARD_INSET, -(stackHeaderY + 16))
    stackScroll:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -36,          CARD_INSET)
    local stackBox = CreateFrame("EditBox", nil, stackScroll)
    stackBox:SetMultiLine(true)
    stackBox:SetAutoFocus(false)
    stackBox:SetFontObject("ChatFontNormal")
    stackBox:SetWidth(INSPECTOR_W - CARD_INSET - 42)
    stackBox:SetScript("OnEscapePressed", stackBox.ClearFocus)
    stackBox:SetText("")
    stackScroll:SetScrollChild(stackBox)
    inspector.stackScroll = stackScroll
    inspector.stackBox    = stackBox

    -- Hide everything except the empty label until a selection exists
    local function ToggleDetailWidgets(show)
        local widgets = {
            inspector.headCard, inspector.timeline,
            inspector.msgHeader, inspector.messageScroll,
            inspector.stackHeader, inspector.stackScroll,
            inspector.copyBtn,
        }
        for _, w in ipairs(widgets) do
            if show then w:Show() else w:Hide() end
        end
    end
    inspector._toggle = ToggleDetailWidgets
    ToggleDetailWidgets(false)
end

-- ============================================================
-- SECTION: Trend Classification + Sparkline Update
-- ComputeTrend returns { kind, label, color, buckets, maxBucket }
-- so the inspector AND the history popup can paint the same
-- visual story from a single source of truth.
-- ============================================================
local TREND_ESCALATE = {0.95, 0.55, 0.30}
local TREND_DEESCALATE = {0.35, 0.85, 0.55}

local function ComputeTrend(entry, numBars)
    numBars = numBars or SPARK_BARS
    local buckets = {}
    for i = 1, numBars do buckets[i] = 0 end

    if not entry or not entry.hits or #entry.hits == 0 then
        return { kind = "empty", label = "No Activity Yet",
                 color = UI.muted, buckets = buckets, maxBucket = 0 }
    end
    local hits = entry.hits
    if #hits < 2 then
        buckets[numBars] = 1
        return { kind = "single", label = "Fired Once",
                 color = UI.muted, buckets = buckets, maxBucket = 1 }
    end

    local first, last = hits[1], hits[#hits]
    local span = math.max(1, last - first)
    for _, t in ipairs(hits) do
        local idx = 1 + math.floor(((t - first) / span) * (numBars - 1))
        if idx < 1 then idx = 1 end
        if idx > numBars then idx = numBars end
        buckets[idx] = buckets[idx] + 1
    end
    local maxB = 1
    for _, n in ipairs(buckets) do if n > maxB then maxB = n end end

    local half = math.floor(numBars / 2)
    local firstHalf, secondHalf = 0, 0
    for i = 1, half do firstHalf = firstHalf + buckets[i] end
    for i = half + 1, numBars do secondHalf = secondHalf + buckets[i] end

    local ratio = secondHalf / math.max(1, firstHalf)
    local kind, label, color
    if secondHalf == 0 and firstHalf > 0 then
        kind, label, color = "stopped",    "Stopped Firing",  TREND_DEESCALATE
    elseif ratio > 1.5 then
        kind, label, color = "escalating", "Getting Worse",   TREND_ESCALATE
    elseif ratio < 0.67 then
        kind, label, color = "easing",     "Getting Better",  TREND_DEESCALATE
    else
        kind, label, color = "stable",     "Holding Steady",  UI.text
    end
    return { kind = kind, label = label, color = color,
             buckets = buckets, maxBucket = maxB }
end

-- Normalizes a bucket count (0..N) to a 0..1 bar-height fraction
-- using a gentle log scale. Single hits stay short; only genuinely
-- dense buckets climb toward full height. Prevents the "barcode of
-- full-height bars" look on sparse data where maxBucket == 1.
local function BarHeightFraction(count)
    if count <= 0 then return 0 end
    local norm = math.log(count + 1) / math.log(30)  -- 30+ hits -> 1.0
    if norm > 1 then norm = 1 end
    if norm < 0.18 then norm = 0.18 end               -- keep tiny bars visible
    return norm
end

local function PaintSpark(spark, trend)
    if not spark or not spark.bars then return end
    for _, bar in ipairs(spark.bars) do bar:Hide() end
    if spark.barGlows then
        for _, g in ipairs(spark.barGlows) do g:Hide() end
    end
    if not trend or trend.maxBucket <= 0 then return end
    local h = spark:GetHeight()
    if not h or h < 1 then h = SPARK_HEIGHT end
    for i, bar in ipairs(spark.bars) do
        local n = trend.buckets[i] or 0
        if n > 0 then
            local barH = BarHeightFraction(n) * (h - 4)
            if barH < 2 then barH = 2 end
            bar:SetHeight(barH)
            bar:SetVertexColor(trend.color[1], trend.color[2], trend.color[3], 0.92)
            bar:Show()
            local glow = spark.barGlows and spark.barGlows[i]
            if glow then
                glow:SetHeight(barH + 2)
                glow:SetVertexColor(trend.color[1], trend.color[2], trend.color[3], 0.22)
                glow:Show()
            end
        end
    end
end

-- Maps raw count to a discrete impact tier and its visual color.
local function CountToTier(count)
    count = count or 0
    if count >= 1000 then return 5, "Critical",  {0.95, 0.30, 0.30} end
    if count >= 100  then return 4, "High",      {0.95, 0.52, 0.22} end
    if count >= 25   then return 3, "Elevated",  {0.95, 0.78, 0.22} end
    if count >= 5    then return 2, "Moderate",  {0.75, 0.80, 0.40} end
    return                     1, "Low",        {0.55, 0.65, 0.72}
end

local function FormatBigInt(n)
    if type(n) ~= "number" then return tostring(n) end
    if n >= 1000000 then return string.format("%.1fM", n / 1000000) end
    if n >= 10000   then return string.format("%.1fk", n / 1000) end
    if n >= 1000    then
        -- 1,234 style with comma separator for small thousands
        local s = tostring(math.floor(n))
        return s:sub(1, -4) .. "," .. s:sub(-3)
    end
    return tostring(math.floor(n))
end

local function RefreshInspector()
    if not inspector.card then return end
    local engine = Engine()
    local fp = state.selectedFP
    local e = fp and engine and engine.GetIncidentByFingerprint
        and engine.GetIncidentByFingerprint(fp) or nil

    if not e then
        inspector._toggle(false)
        inspector.empty:Show()
        return
    end
    inspector.empty:Hide()
    inspector._toggle(true)

    -- ============ Hero Header ============
    local sevColor = SeverityOf(e)
    local kindLabel = KIND_LABELS[e.kind or ""] or (e.kind or "Unknown")

    -- Severity ribbon + bleed
    inspector.sevBand:SetVertexColor(sevColor[1], sevColor[2], sevColor[3], 1)
    inspector.sevBleed:SetVertexColor(sevColor[1], sevColor[2], sevColor[3], 0.12)

    -- Crest
    inspector.crestInner:SetVertexColor(sevColor[1], sevColor[2], sevColor[3], 0.32)
    SetText(inspector.crestLetter, kindLabel:sub(1, 1):upper())
    inspector.crestLetter:SetTextColor(sevColor[1], sevColor[2], sevColor[3])

    -- Title + identity. Source and session sit on one sub-line
    -- separated by a centered dash for readability.
    SetText(inspector.kindText, kindLabel:upper())
    inspector.kindText:SetTextColor(sevColor[1], sevColor[2], sevColor[3])
    SetText(inspector.sourceText,
        tostring(e.source or e.addon or "?")
        .. "   -   Session #" .. tostring(e.session or 0))

    -- Hero count stat (massive)
    SetText(inspector.countText, FormatBigInt(e.count or 1))

    -- ============ Timeline Card ============
    local now = (time and time()) or 0
    local firstT = e.firstSeen or now
    local lastT  = e.lastSeen  or firstT
    local midT   = math.floor((firstT + lastT) / 2)
    local dur    = math.max(0, lastT - firstT)

    SetText(inspector.tlDuration, FormatDurationShort(dur) .. " lifetime")
    SetText(inspector.tlFirst, FormatAbsTime(firstT))
    SetText(inspector.tlMid,   FormatAbsTime(midT))
    SetText(inspector.tlLast,  FormatAbsTime(lastT))

    local trend = ComputeTrend(e, SPARK_BARS)
    PaintSpark(inspector.spark, trend)

    -- Trend pill (colored left bar + label + inner tint)
    inspector.trendInner:SetVertexColor(trend.color[1], trend.color[2], trend.color[3], 0.20)
    if inspector.trendBar then
        inspector.trendBar:SetVertexColor(trend.color[1], trend.color[2], trend.color[3], 1)
    end
    SetText(inspector.tlTrend, trend.label)
    inspector.tlTrend:SetTextColor(trend.color[1], trend.color[2], trend.color[3])

    if inspector.tlAccent then
        inspector.tlAccent:SetVertexColor(trend.color[1], trend.color[2], trend.color[3], 0.85)
    end

    -- Impact tier meter
    local tier, tierName, tierColor = CountToTier(e.count or 1)
    SetText(inspector.tierName, tierName)
    inspector.tierName:SetTextColor(tierColor[1], tierColor[2], tierColor[3])
    for i, seg in ipairs(inspector.tierMeter.segments) do
        if i <= tier then
            seg.fill:SetVertexColor(tierColor[1], tierColor[2], tierColor[3], 0.95)
            seg:SetBackdropBorderColor(tierColor[1], tierColor[2], tierColor[3], 0.8)
        else
            seg.fill:SetVertexColor(UI.muted[1], UI.muted[2], UI.muted[3], 0.15)
            seg:SetBackdropBorderColor(UI.border[1], UI.border[2], UI.border[3], 0.7)
        end
    end

    -- ============ Message + Stack ============
    inspector.messageBox:SetText(tostring(e.message or ""))
    local stackText = tostring(e.rawStack or "")
    if stackText == "" then stackText = "(no stack captured)" end
    inspector.stackBox:SetText(stackText)
end

-- ============================================================
-- SECTION: Incident History Popup
-- A dedicated visual window that shows the selected incident's
-- full occurrence timeline as a large bar chart, followed by
-- side-by-side "When First Seen" and "When Last Seen" context
-- cards. Opened by clicking the timeline card in the inspector.
-- Singleton - reused across selections.
-- ============================================================
local HIST_BARS = 50

local function BuildInfoRow(parent, yOff, label)
    local row = CreateFrame("Frame", nil, parent)
    row:SetPoint("TOPLEFT",  parent, "TOPLEFT",  0, -yOff)
    row:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, -yOff)
    row:SetHeight(16)
    local lbl = NewFS(row, "OVERLAY", FONT.small, UI.muted)
    lbl:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0)
    lbl:SetWidth(80)
    lbl:SetJustifyH("LEFT")
    lbl:SetText(label)
    local val = NewFS(row, "OVERLAY", FONT.small, UI.text)
    val:SetPoint("TOPLEFT", lbl, "TOPRIGHT", 8, 0)
    val:SetPoint("TOPRIGHT", row, "TOPRIGHT", 0, 0)
    val:SetJustifyH("LEFT")
    row.value = val
    return row
end

local function BuildContextBlock(parent, title)
    local block = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    Back(block, UI.inset, UI.border, 1, 0.9)
    block:SetHeight(172)

    local edge = Solid(block, "OVERLAY", UI.accent, 0.7)
    edge:SetHeight(2)
    edge:SetPoint("TOPLEFT",  block, "TOPLEFT",  1, -1)
    edge:SetPoint("TOPRIGHT", block, "TOPRIGHT", -1, -1)

    local titleFS = NewFS(block, "OVERLAY", FONT.heading, UI.text)
    titleFS:SetPoint("TOPLEFT", block, "TOPLEFT", 14, -12)
    titleFS:SetText(title)

    local timeLine = NewFS(block, "OVERLAY", FONT.small, UI.muted)
    timeLine:SetPoint("TOPLEFT", titleFS, "BOTTOMLEFT", 0, -6)
    block.timeLabel = timeLine

    local info = CreateFrame("Frame", nil, block)
    info:SetPoint("TOPLEFT",  block, "TOPLEFT",  14, -60)
    info:SetPoint("TOPRIGHT", block, "TOPRIGHT", -14, -60)
    info:SetHeight(110)
    block.info = info

    block.rows = {
        character = BuildInfoRow(info, 0,   "Character"),
        class     = BuildInfoRow(info, 18,  "Class"),
        spec      = BuildInfoRow(info, 36,  "Spec"),
        zone      = BuildInfoRow(info, 54,  "Zone"),
        combat    = BuildInfoRow(info, 72,  "In Combat"),
        instance  = BuildInfoRow(info, 90,  "Instance"),
    }
    return block
end

local function FillContextBlock(block, when, ctx)
    if not block then return end
    SetText(block.timeLabel, FormatFullTime(when))
    local zone = ctx and ctx.zone or "?"
    if ctx and ctx.subzone and ctx.subzone ~= ctx.zone then
        zone = zone .. "  /  " .. ctx.subzone
    end

    -- Character name falls back to the engine's session context so
    -- incidents captured before BuildContext recorded the player
    -- name still show a readable character instead of "?". Class
    -- and spec get the same treatment for consistency.
    local engine = Engine()
    local sessionCtx = (engine and engine.GetSessionInfo
                          and engine.GetSessionInfo()) or nil
    sessionCtx = sessionCtx and sessionCtx.context or nil

    local function pick(field)
        if ctx and ctx[field] ~= nil and ctx[field] ~= "" then
            return ctx[field]
        end
        if sessionCtx and sessionCtx[field] ~= nil and sessionCtx[field] ~= "" then
            return sessionCtx[field]
        end
        return "?"
    end

    SetText(block.rows.character.value, pick("player"))
    local classRaw = pick("class")
    local ui = _G.MidnightUI_Console_UI
    local classDisplay = (ui and ui.FormatClassTag and classRaw and classRaw ~= "?" and ui.FormatClassTag(classRaw)) or classRaw
    SetText(block.rows.class.value, classDisplay)
    SetText(block.rows.spec.value,      pick("spec"))
    SetText(block.rows.zone.value,      zone)
    SetText(block.rows.combat.value,    (ctx and ctx.combat) and "Yes" or "No")
    if ctx and ctx.inInstance then
        SetText(block.rows.instance.value, "Yes (" .. tostring(ctx.instanceType or "unknown") .. ")")
    else
        SetText(block.rows.instance.value, "No")
    end
end

local historyPopup

local function BuildHistoryPopup()
    local f = CreateFrame("Frame", "MidnightUI_Console_HistoryPopup",
                          _G.UIParent, "BackdropTemplate")
    f:SetSize(760, 580)
    f:SetPoint("CENTER")
    f:SetFrameStrata("TOOLTIP")
    f:SetToplevel(true)
    f:SetMovable(true)
    f:EnableMouse(true)
    f:SetClampedToScreen(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop",  f.StopMovingOrSizing)
    Back(f, UI.bg, UI.border, 0.98, 1)
    if type(_G.UISpecialFrames) == "table" then
        table.insert(_G.UISpecialFrames, "MidnightUI_Console_HistoryPopup")
    end

    local accent = Solid(f, "OVERLAY", UI.accent, 1)
    accent:SetHeight(3)
    accent:SetPoint("TOPLEFT",  f, "TOPLEFT",  0, 0)
    accent:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 0)

    f.title = NewFS(f, "OVERLAY", FONT.display, UI.text)
    f.title:SetPoint("TOPLEFT", f, "TOPLEFT", 22, -18)
    f.title:SetText("Incident History")

    f.subtitle = NewFS(f, "OVERLAY", FONT.small, UI.muted)
    f.subtitle:SetPoint("TOPLEFT", f, "TOPLEFT", 22, -44)
    f.subtitle:SetText("")

    local close = CreateFrame("Button", nil, f, "BackdropTemplate")
    close:SetSize(28, 28)
    close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -14, -14)
    Back(close, UI.inset, UI.border, 1, 1)
    local closeLbl = NewFS(close, "OVERLAY", FONT.body, UI.text)
    closeLbl:SetPoint("CENTER")
    closeLbl:SetText("X")
    close:SetScript("OnClick", function() f:Hide() end)

    -- Chart area
    local chartCard = CreateFrame("Frame", nil, f, "BackdropTemplate")
    chartCard:SetPoint("TOPLEFT",  f, "TOPLEFT",  22, -76)
    chartCard:SetPoint("TOPRIGHT", f, "TOPRIGHT", -22, -76)
    chartCard:SetHeight(214)
    Back(chartCard, UI.inset, UI.border, 1, 1)
    f.chartCard = chartCard

    local chartTitle = NewFS(chartCard, "OVERLAY", FONT.heading, UI.text)
    chartTitle:SetPoint("TOPLEFT", chartCard, "TOPLEFT", 16, -14)
    chartTitle:SetText("Occurrence Density Over Time")

    f.chartTrend = NewFS(chartCard, "OVERLAY", FONT.body, UI.text)
    f.chartTrend:SetPoint("TOPRIGHT", chartCard, "TOPRIGHT", -16, -14)
    f.chartTrend:SetJustifyH("RIGHT")
    f.chartTrend:SetText("")

    local chart = CreateFrame("Frame", nil, chartCard)
    chart:SetPoint("TOPLEFT",  chartCard, "TOPLEFT",  16, -46)
    chart:SetPoint("TOPRIGHT", chartCard, "TOPRIGHT", -16, -46)
    chart:SetHeight(130)
    local chartBG = Solid(chart, "BACKGROUND", UI.bg, 1)
    chartBG:SetAllPoints()

    -- Gridlines at 25% / 50% / 75% of chart height for reference
    for _, frac in ipairs({ 0.25, 0.50, 0.75 }) do
        local grid = Solid(chart, "BACKGROUND", UI.border, 0.3)
        grid:SetHeight(1)
        grid:SetPoint("BOTTOMLEFT",  chart, "BOTTOMLEFT",  0, math.floor(130 * frac))
        grid:SetPoint("BOTTOMRIGHT", chart, "BOTTOMRIGHT", 0, math.floor(130 * frac))
    end

    local chartBase = Solid(chart, "OVERLAY", UI.border, 0.9)
    chartBase:SetHeight(1)
    chartBase:SetPoint("BOTTOMLEFT",  chart, "BOTTOMLEFT",  0, 0)
    chartBase:SetPoint("BOTTOMRIGHT", chart, "BOTTOMRIGHT", 0, 0)

    -- Line chart pieces: (N-1) segment textures connecting N data
    -- points, plus a dot at each point. Rotated rectangular textures
    -- act as the line segments; SetRotation(angle) gives us proper
    -- sloped lines like a stock price chart.
    chart.lineSegments = {}
    for i = 1, HIST_BARS - 1 do
        local seg = chart:CreateTexture(nil, "ARTWORK", nil, 1)
        seg:SetTexture("Interface\\Buttons\\WHITE8X8")
        seg:SetVertexColor(UI.accent[1], UI.accent[2], UI.accent[3], 0.9)
        seg:Hide()
        chart.lineSegments[i] = seg
    end

    chart.dots = {}
    for i = 1, HIST_BARS do
        local dot = chart:CreateTexture(nil, "OVERLAY")
        dot:SetTexture("Interface\\Buttons\\WHITE8X8")
        dot:SetSize(5, 5)
        dot:SetVertexColor(UI.accent[1], UI.accent[2], UI.accent[3], 1)
        dot:Hide()
        chart.dots[i] = dot
    end

    f.chart = chart

    f.chartFirstTime = NewFS(chartCard, "OVERLAY", FONT.tiny, UI.muted)
    f.chartFirstTime:SetPoint("BOTTOMLEFT", chartCard, "BOTTOMLEFT", 16, 10)
    f.chartLastTime = NewFS(chartCard, "OVERLAY", FONT.tiny, UI.muted)
    f.chartLastTime:SetPoint("BOTTOMRIGHT", chartCard, "BOTTOMRIGHT", -16, 10)
    f.chartLastTime:SetJustifyH("RIGHT")

    -- Two side-by-side context blocks below the chart
    local CTX_Y = 76 + 214 + 14
    local gap = 14
    local colW = (760 - 22 - 22 - gap) / 2

    f.firstBlock = BuildContextBlock(f, "When First Seen")
    f.firstBlock:SetPoint("TOPLEFT", f, "TOPLEFT", 22, -CTX_Y)
    f.firstBlock:SetWidth(colW)

    f.lastBlock = BuildContextBlock(f, "When Last Seen")
    f.lastBlock:SetPoint("TOPRIGHT", f, "TOPRIGHT", -22, -CTX_Y)
    f.lastBlock:SetWidth(colW)

    -- Copy + Close actions at the bottom
    local copyBtn = CreateFrame("Button", nil, f, "BackdropTemplate")
    copyBtn:SetSize(160, 30)
    copyBtn:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -22, 20)
    Back(copyBtn, UI.accent, UI.border, 0.22, 1)
    local copyLbl = NewFS(copyBtn, "OVERLAY", FONT.body, UI.accent)
    copyLbl:SetPoint("CENTER")
    copyLbl:SetText("Copy Timeline")
    copyBtn:SetScript("OnEnter", function(self)
        self:SetBackdropColor(UI.accent[1], UI.accent[2], UI.accent[3], 0.42)
    end)
    copyBtn:SetScript("OnLeave", function(self)
        self:SetBackdropColor(UI.accent[1], UI.accent[2], UI.accent[3], 0.22)
    end)
    copyBtn:SetScript("OnClick", function()
        if f._currentEntry and SHELL and SHELL.ShowCopyDialog then
            SHELL.ShowCopyDialog(FormatTimelineSummary(f._currentEntry), "Incident Timeline")
        end
    end)
    f.copyBtn = copyBtn

    f:Hide()
    return f
end

OpenHistoryPopup = function(fp)
    if not fp then return end
    if not historyPopup then historyPopup = BuildHistoryPopup() end
    local engine = Engine()
    local e = engine and engine.GetIncidentByFingerprint
        and engine.GetIncidentByFingerprint(fp) or nil
    if not e then return end

    historyPopup._currentEntry = e
    SetText(historyPopup.subtitle,
        string.format("%s  -  x%d  -  session #%d",
            tostring(e.source or e.addon or "?"),
            e.count or 1, e.session or 0))

    -- Paint the line chart. Raw bucket counts are smoothed with a
    -- rolling-window average so the line reads like a stock-market
    -- trend chart instead of a sharp sawtooth of baseline->peak->
    -- baseline spikes. A single hit becomes a gentle hump; a cluster
    -- of hits becomes a clear rising region. Shaded area under the
    -- line reinforces the stock-chart feel.
    local trend = ComputeTrend(e, HIST_BARS)
    local chart = historyPopup.chart
    for _, seg in ipairs(chart.lineSegments) do seg:Hide() end
    for _, dot in ipairs(chart.dots)         do dot:Hide() end

    if trend.maxBucket > 0 then
        local chartW = chart:GetWidth()  or 680
        local chartH = chart:GetHeight() or 130

        -- Rolling-window smoothing. Each point's value is the mean
        -- of itself and its 3 neighbours on each side (7-wide window).
        -- With sparse data (1 hit scattered) this spreads each hit
        -- over 7 buckets, producing a smooth hump instead of a spike.
        local WINDOW_HALF = 3
        local smoothed = {}
        local maxSmooth = 0
        for i = 1, HIST_BARS do
            local sum, count = 0, 0
            for j = math.max(1, i - WINDOW_HALF),
                    math.min(HIST_BARS, i + WINDOW_HALF) do
                sum   = sum + (trend.buckets[j] or 0)
                count = count + 1
            end
            local v = sum / count
            smoothed[i] = v
            if v > maxSmooth then maxSmooth = v end
        end
        if maxSmooth <= 0 then maxSmooth = 1 end

        -- Compute (x, y) for every smoothed point. y is normalised
        -- so the tallest smoothed value lands at ~90% of chart
        -- height; empty stretches sit on the baseline.
        local points = {}
        local stepX  = chartW / HIST_BARS
        for i = 1, HIST_BARS do
            local v = smoothed[i] / maxSmooth
            points[i] = {
                x = (i - 0.5) * stepX,
                y = v * (chartH - 10) + 3,
            }
        end

        -- Draw smooth segments between consecutive points
        for i = 1, HIST_BARS - 1 do
            local a = points[i]
            local b = points[i + 1]
            local dx = b.x - a.x
            local dy = b.y - a.y
            local len = math.sqrt(dx * dx + dy * dy)
            if len > 0.5 then
                local angle = math.atan2(dy, dx)
                local seg = chart.lineSegments[i]
                seg:ClearAllPoints()
                seg:SetSize(len, 2)
                seg:SetPoint("CENTER", chart, "BOTTOMLEFT",
                    (a.x + b.x) / 2, (a.y + b.y) / 2)
                if seg.SetRotation then seg:SetRotation(angle) end
                seg:SetVertexColor(trend.color[1], trend.color[2], trend.color[3], 0.95)
                seg:Show()
            end
        end

        -- Subtle "area fill" effect: each dot slot becomes a short
        -- vertical bar from baseline up to the smoothed line,
        -- rendered at low alpha for the classic shaded-area look.
        local stepPx = stepX
        for i, p in ipairs(points) do
            if p.y > 4 then
                local dot = chart.dots[i]
                dot:ClearAllPoints()
                dot:SetSize(math.max(1, stepPx + 1), p.y)
                dot:SetPoint("BOTTOM", chart, "BOTTOMLEFT", p.x, 1)
                dot:SetVertexColor(trend.color[1], trend.color[2], trend.color[3], 0.15)
                dot:Show()
            end
        end
    end
    SetText(historyPopup.chartTrend, trend.label)
    historyPopup.chartTrend:SetTextColor(trend.color[1], trend.color[2], trend.color[3])
    SetText(historyPopup.chartFirstTime, FormatFullTime(e.firstSeen))
    SetText(historyPopup.chartLastTime,  FormatFullTime(e.lastSeen))

    FillContextBlock(historyPopup.firstBlock, e.firstSeen, e.context)
    FillContextBlock(historyPopup.lastBlock,  e.lastSeen,  e.lastContext or e.context)

    historyPopup:Show()
    historyPopup:Raise()
end

SelectIncident = function(fp)
    state.selectedFP = fp
    UpdateRows()
    RefreshInspector()
end

-- Public navigate-to entry point for external callers (e.g.,
-- the Overview tab's Top Incidents rows). Unlike the internal
-- SelectIncident used for in-feed clicks, this one:
--   - Clears the search filter if the target is currently hidden
--     by it, so the incident is guaranteed to be visible.
--   - Scrolls the feed so the target row is roughly centered
--     instead of wherever the user last scrolled to.
function M.SelectIncident(fp)
    if not fp then return end

    local idx
    for i, e in ipairs(state.filtered) do
        if e.fingerprint == fp then idx = i; break end
    end

    if not idx then
        -- Target not in current filtered view — likely hidden
        -- by an active search. Clear search and recompute.
        if toolbar.searchBox then
            toolbar.searchBox:SetText("")
        end
        state.searchText = ""
        RecomputeFiltered()
        for i, e in ipairs(state.filtered) do
            if e.fingerprint == fp then idx = i; break end
        end
    end

    state.selectedFP = fp

    if idx and feed.scroll and feed.scroll.SetVerticalScroll then
        local halfPool = math.floor(POOL_SIZE / 2)
        local row = math.max(0, (idx - 1) - halfPool)
        feed.scroll:SetVerticalScroll(row * ROW_STRIDE)
    end

    UpdateRows()
    RefreshInspector()
end

-- ============================================================
-- SECTION: Toolbar (search + pause + result count)
-- ============================================================
local function BuildToolbar(body)
    local card = CreateFrame("Frame", nil, body, "BackdropTemplate")
    card:SetPoint("TOPLEFT",  body, "TOPLEFT",  PAD, -PAD)
    card:SetPoint("TOPRIGHT", body, "TOPRIGHT", -PAD, -PAD)
    card:SetHeight(TOOLBAR_H)
    Back(card, UI.panel, UI.border, 1, 1)
    toolbar.card = card

    -- Search box (left)
    local searchWrap = CreateFrame("Frame", nil, card, "BackdropTemplate")
    searchWrap:SetSize(360, 30)
    searchWrap:SetPoint("LEFT", card, "LEFT", CARD_INSET, 0)
    Back(searchWrap, UI.bg, UI.border, 1, 0.9)

    local searchLabel = NewFS(searchWrap, "OVERLAY", FONT.small, UI.muted)
    searchLabel:SetPoint("LEFT", searchWrap, "LEFT", 10, 0)
    searchLabel:SetText("Search:")

    local searchBox = CreateFrame("EditBox", nil, searchWrap)
    searchBox:SetPoint("LEFT",  searchLabel, "RIGHT", 8, 0)
    searchBox:SetPoint("RIGHT", searchWrap,  "RIGHT", -8, 0)
    searchBox:SetHeight(24)
    searchBox:SetFontObject("ChatFontNormal")
    searchBox:SetAutoFocus(false)
    searchBox:SetTextColor(UI.text[1], UI.text[2], UI.text[3])
    searchBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    searchBox:SetScript("OnEnterPressed",  function(self) self:ClearFocus() end)
    searchBox:SetScript("OnTextChanged",   function(self)
        state.searchText = self:GetText() or ""
        RefreshStream()
    end)
    toolbar.searchBox = searchBox

    -- Pause button (right)
    local pauseBtn = CreateFrame("Button", nil, card, "BackdropTemplate")
    pauseBtn:SetSize(120, 30)
    pauseBtn:SetPoint("RIGHT", card, "RIGHT", -CARD_INSET, 0)

    -- Copy All button (to the left of Pause). Dumps the entire
    -- currently-filtered feed into the shared copy dialog.
    local copyAllBtn = CreateFrame("Button", nil, card, "BackdropTemplate")
    copyAllBtn:SetSize(110, 30)
    copyAllBtn:SetPoint("RIGHT", pauseBtn, "LEFT", -10, 0)
    Back(copyAllBtn, UI.accent, UI.border, 0.22, 1)
    local copyAllLabel = NewFS(copyAllBtn, "OVERLAY", FONT.body, UI.accent)
    copyAllLabel:SetPoint("CENTER")
    copyAllLabel:SetText("Copy All")
    copyAllBtn:SetScript("OnEnter", function(self)
        self:SetBackdropColor(UI.accent[1], UI.accent[2], UI.accent[3], 0.42)
    end)
    copyAllBtn:SetScript("OnLeave", function(self)
        self:SetBackdropColor(UI.accent[1], UI.accent[2], UI.accent[3], 0.22)
    end)
    copyAllBtn:SetScript("OnClick", function()
        RecomputeFiltered()
        local text = FormatFilteredForCopy()
        if SHELL and SHELL.ShowCopyDialog then
            SHELL.ShowCopyDialog(text,
                "Event Stream  -  " .. tostring(#state.filtered) .. " incidents")
        end
    end)
    toolbar.copyAllBtn = copyAllBtn
    Back(pauseBtn, UI.bg, UI.border, 1, 0.9)
    local pauseLabel = NewFS(pauseBtn, "OVERLAY", FONT.body, UI.text)
    pauseLabel:SetPoint("CENTER")
    pauseLabel:SetText("Pause")
    toolbar.pauseBtn   = pauseBtn
    toolbar.pauseLabel = pauseLabel

    pauseBtn:SetScript("OnEnter", function(self)
        self:SetBackdropColor(UI.rowHover[1], UI.rowHover[2], UI.rowHover[3], 1)
    end)
    pauseBtn:SetScript("OnLeave", function(self)
        self:SetBackdropColor(UI.bg[1], UI.bg[2], UI.bg[3], 0.9)
    end)
    pauseBtn:SetScript("OnClick", function()
        state.paused = not state.paused
        SetText(pauseLabel, state.paused and "Resume" or "Pause")
        if state.paused then
            pauseLabel:SetTextColor(UI.accent[1], UI.accent[2], UI.accent[3])
        else
            pauseLabel:SetTextColor(UI.text[1], UI.text[2], UI.text[3])
            RefreshStream()
        end
    end)

end

local function RefreshToolbar()
    -- Pause/Resume label on the button itself communicates state;
    -- no separate status text needed.
end

-- ============================================================
-- SECTION: Build + Refresh Entry Points
-- ============================================================
local function BuildStream(body)
    state.bodyFrame = body
    BuildToolbar(body)
    BuildFeed(body)
    BuildInspector(body)
end

RefreshStream = function()
    if not feed.card then return end
    if state.paused then
        RefreshToolbar()
        return
    end
    RecomputeFiltered()
    UpdateRows()
    RefreshInspector()
    RefreshToolbar()
end

-- ============================================================
-- SECTION: Live Update Ticker
-- Fires every REFRESH_INTERVAL seconds while the Stream tab's
-- body is visible and the user has not paused.
-- ============================================================
local function TickerStep()
    if not state.bodyFrame then return end
    if not state.bodyFrame:IsShown() then return end
    if state.paused then return end
    RefreshStream()
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

-- ============================================================
-- SECTION: Registration
-- ============================================================
SHELL.RegisterTabContent("stream", BuildStream, function()
    EnsureTicker()
    RefreshStream()
end)

-- End of Console_UI_Stream.lua
