--[[
    Cerebro - Diagnostics Module
    
    PURPOSE:
    Provides diagnostic status snapshots for /cb status and self-test runner.
    Encapsulates cross-cutting concerns: RBAC, deps, db stats, sync freshness.
    
    CONTRACTS:
    - GetStatusSnapshot() -> table: Returns structured status data
    - ShowStatus(args) -> void: Prints formatted status to chat
    - RegisterHealthCheck(check) -> void: Registers a health check
    - RunSelfTest(args) -> void: Runs all registered checks with PASS/FAIL summary
    
    SECURITY:
    Read-only module. No mutations. No RBAC required (all users can view status).
    Officer-only checks are SKIPPED (not executed) for non-officers.
    
    STATUS VALUES:
    - OK: Normal operation
    - WARNING: Missing dependencies, stale sync, or roster issues
    - ERROR: Reserved (not implemented)
    
    CHECK SCHEMA:
    Each health check must have:
    - id: string (MUST be UPPERCASE; rejected otherwise)
    - name: string (human-readable name)
    - officerOnly: boolean (if true, skipped for non-officers; defaults to false)
    - fn: function() -> (pass: boolean, detail: string)
    
    HEALTH CHECK PURITY CONTRACT:
    Health check functions MUST be pure (read-only). They MUST NOT:
    - Write to SavedVariables or database
    - Send network messages (Communication module)
    - Mutate session state
    - Call officer-only APIs (unless check.officerOnly = true)
]]--

local AddonName, PrivateTable = ...
local Cerebro = LibStub("AceAddon-3.0"):GetAddon(AddonName)

-- NOTE: Do NOT manually assign Cerebro.modules.Diagnostics; AceAddon handles registry
local Diagnostics = Cerebro:NewModule("Diagnostics")

--[[=============================================
    Helpers
==============================================]]

-- Safe debug call that never throws
local function SafeDebug(msg)
    if Cerebro and Cerebro.Debug and Cerebro.db and Cerebro.db.global and Cerebro.db.global.config and Cerebro.db.global.config.debug then
        Cerebro:Debug(msg)
    end
end

-- Sanitize value for deterministic key=value output
-- Converts nil -> "null", escapes newlines and carriage returns
local function SanitizeKV(value)
    if value == nil then
        return "null"
    end
    local str = tostring(value)
    -- Escape newlines and carriage returns for single-line output
    str = str:gsub("\r", "\\r")
    str = str:gsub("\n", "\\n")
    return str
end

--[[=============================================
    Check Registry
==============================================]]

-- Registered health checks (fixed order, explicit registration only)
Diagnostics.registeredChecks = {}
Diagnostics.registeredIDs = {} -- For duplicate detection

-- Mockable time function (for deterministic tests)
Diagnostics._timeFn = time

function Diagnostics:OnEnable()
    -- Reset registry at start of OnEnable (idempotent reload)
    self.registeredChecks = {}
    self.registeredIDs = {}
    
    -- Register health checks from participating modules in FIXED order
    -- Order: Core Infrastructure -> Models -> Modules
    self:RegisterCoreChecks()
    self:RegisterFromProvider("Player", Cerebro.Player)
    self:RegisterFromProvider("Ledger", Cerebro.Ledger)
    self:RegisterFromModule("Session")
    self:RegisterFromModule("EPGP")
    self:RegisterFromModule("Communication")
    self:RegisterFromModule("RCLC_Bridge")
end

-- Register checks from an AceAddon module (via GetModule)
function Diagnostics:RegisterFromModule(moduleName)
    local mod = Cerebro:GetModule(moduleName, true)
    if mod and mod.DescribeHealthChecks then
        local checks = mod:DescribeHealthChecks()
        if type(checks) ~= "table" then
            SafeDebug("Diagnostics: Module '" .. moduleName .. "' DescribeHealthChecks() returned non-table")
            return
        end
        local count = 0
        for _, check in ipairs(checks) do
            if self:RegisterHealthCheck(check) then
                count = count + 1
            end
        end
        if count == 0 and #checks > 0 then
            SafeDebug("Diagnostics: Module '" .. moduleName .. "' returned " .. #checks .. " checks but 0 registered (validation failures)")
        end
    end
end

-- Register checks from a plain table provider (e.g., Cerebro.Player)
function Diagnostics:RegisterFromProvider(providerName, provider)
    if not provider then
        return
    end
    
    if not provider.DescribeHealthChecks then
        return
    end
    
    local checks = provider:DescribeHealthChecks()
    if type(checks) ~= "table" then
        SafeDebug("Diagnostics: Provider '" .. providerName .. "' DescribeHealthChecks() returned non-table")
        return
    end
    
    local count = 0
    for _, check in ipairs(checks) do
        if self:RegisterHealthCheck(check) then
            count = count + 1
        end
    end
    
    if count == 0 and #checks > 0 then
        SafeDebug("Diagnostics: Provider '" .. providerName .. "' returned " .. #checks .. " checks but 0 registered (validation failures)")
    end
end

-- Register a single health check with schema validation
-- Fail-closed: rejects invalid or duplicate checks
-- Creates internal normalized copy (does not mutate input)
-- ID MUST be UPPERCASE (strict enforcement, not normalized)
function Diagnostics:RegisterHealthCheck(check)
    -- Schema validation (required fields: id, name, fn)
    if type(check) ~= "table" then
        SafeDebug("Diagnostics: Rejected invalid check (not a table)")
        return false
    end
    
    if type(check.id) ~= "string" or check.id == "" then
        SafeDebug("Diagnostics: Rejected check with missing/invalid id")
        return false
    end
    
    -- Strict uppercase enforcement (reject non-uppercase IDs)
    if check.id ~= check.id:upper() then
        SafeDebug("Diagnostics: Rejected check '" .. check.id .. "' - ID must be UPPERCASE")
        return false
    end
    
    if type(check.name) ~= "string" or check.name == "" then
        SafeDebug("Diagnostics: Rejected check '" .. check.id .. "' with missing/invalid name")
        return false
    end
    
    if type(check.fn) ~= "function" then
        SafeDebug("Diagnostics: Rejected check '" .. check.id .. "' with missing/invalid fn")
        return false
    end
    
    -- Duplicate ID check (fail-closed)
    if self.registeredIDs[check.id] then
        SafeDebug("Diagnostics: Rejected duplicate check ID: " .. check.id)
        return false
    end
    
    -- Create internal normalized copy (do not mutate input)
    local normalizedCheck = {
        id = check.id,
        name = check.name,
        fn = check.fn,
        officerOnly = (check.officerOnly == true), -- Normalize to boolean
    }
    
    -- Register
    table.insert(self.registeredChecks, normalizedCheck)
    self.registeredIDs[check.id] = true
    
    return true
end

-- Core infrastructure checks (always registered)
function Diagnostics:RegisterCoreChecks()
    self:RegisterHealthCheck({
        id = "CORE_DB",
        name = "Database Initialized",
        officerOnly = false,
        fn = function()
            local ok = Cerebro.db and Cerebro.db.global and type(Cerebro.db.global.players) == "table"
            return ok, ok and "OK" or "Database not initialized"
        end
    })
    
    self:RegisterHealthCheck({
        id = "CORE_CONFIG",
        name = "Config Schema",
        officerOnly = false,
        fn = function()
            local ok = Cerebro.db and Cerebro.db.global and Cerebro.db.global.config
            local epgp = ok and Cerebro.db.global.config.epgp
            local baseGP = epgp and epgp.baseGP
            return baseGP and baseGP > 0, "baseGP=" .. tostring(baseGP or "nil")
        end
    })
    
    self:RegisterHealthCheck({
        id = "CORE_VERSION",
        name = "Version Check",
        officerOnly = false,
        fn = function()
            local v = Cerebro.version
            return type(v) == "string" and v ~= "", "version=" .. tostring(v or "nil")
        end
    })

    self:RegisterHealthCheck({
        id = "OFFICER_DETECTION_READY",
        name = "Officer Detection API",
        officerOnly = false,
        fn = function()
            -- Verify that the WoW API for officer detection is available
            local hasRetailAPI = C_GuildInfo and C_GuildInfo.GuildControlGetRankFlags
            local hasClassicAPI = GuildControlSetRank and GuildControlGetRankFlags
            
            -- If not in a guild, we fall back to PUG detection (acceptable)
            if not IsInGuild() then
                return true, "Not in guild (PUG fallback active)"
            end
            
            -- If in a guild, we MUST have one of the APIs
            local apiAvailable = hasRetailAPI or hasClassicAPI
            if not apiAvailable then
                return false, "No permission-flag API available"
            end
            
            return true, hasRetailAPI and "Retail API" or "Classic API"
        end
    })
end

--[[=============================================
    Self-Test Runner
==============================================]]

function Diagnostics:RunSelfTest(args)
    local isOfficer = Cerebro:IsOfficerSafe()
    local debugMode = args and args[1] and (args[1]:lower() == "--debug" or args[1]:lower() == "debug")
    
    local passed, failed, skipped = 0, 0, 0
    local failures = {}
    local results = {} -- For debug output
    local registryEmpty = (#self.registeredChecks == 0)
    
    Cerebro:Print("=== Cerebro Self-Test ===")
    
    -- Handle empty registry (likely provider wiring mismatch)
    if registryEmpty then
        Cerebro:Print("|cffff0000[FAIL]|r No health checks registered!")
        Cerebro:Print("|cffff8800WARNING: Check registry is empty. Possible causes:|r")
        Cerebro:Print("  - Modules missing DescribeHealthChecks()")
        Cerebro:Print("  - Provider wiring mismatch (plain table vs AceAddon)")
        Cerebro:Print("  - OnEnable() not yet called")
        Cerebro:Print("---")
        Cerebro:Print("|cffff0000=== FAIL (0 checks, registry empty) ===|r")
        
        -- Still emit debug output for empty registry
        if debugMode then
            self:PrintSelfTestMachineEmpty()
        end
        
        return
    end
    
    for _, check in ipairs(self.registeredChecks) do
        local result = { id = check.id, name = check.name }
        
        if check.officerOnly and not isOfficer then
            -- RBAC: Skip officer-only checks for non-officers
            skipped = skipped + 1
            result.status = "SKIP"
            result.detail = "Officer-only"
            Cerebro:Print("|cff888888[SKIP]|r " .. check.name .. " (Officer-only)")
        else
            -- Execute check with proper pcall handling
            -- pcall returns: callOk, returnVal1, returnVal2, ...
            local callOk, passOrErr, detail = pcall(check.fn)
            
            if not callOk then
                -- pcall failed: passOrErr contains the error message
                failed = failed + 1
                result.status = "FAIL"
                result.detail = "Error: " .. tostring(passOrErr)
                table.insert(failures, { id = check.id, name = check.name, msg = tostring(passOrErr) })
                Cerebro:Print("|cffff0000[FAIL]|r " .. check.name .. ": " .. tostring(passOrErr))
            elseif passOrErr == true then
                -- Check passed
                passed = passed + 1
                result.status = "PASS"
                result.detail = detail or "OK"
                Cerebro:Print("|cff00ff00[PASS]|r " .. check.name)
            else
                -- Check failed (passOrErr is false or nil)
                failed = failed + 1
                result.status = "FAIL"
                result.detail = detail or "Check returned false"
                table.insert(failures, { id = check.id, name = check.name, msg = detail or "Check returned false" })
                Cerebro:Print("|cffff0000[FAIL]|r " .. check.name .. ": " .. (detail or "Check returned false"))
            end
        end
        
        table.insert(results, result)
    end
    
    -- Summary
    Cerebro:Print("---")
    if failed == 0 then
        Cerebro:Print("|cff00ff00=== PASS ALL (" .. passed .. " passed, " .. skipped .. " skipped) ===|r")
    else
        Cerebro:Print("|cffff0000=== FAIL (" .. failed .. " failed, " .. passed .. " passed, " .. skipped .. " skipped) ===|r")
    end
    
    -- Debug mode: deterministic key=value output (no timestamp for determinism)
    if debugMode then
        self:PrintSelfTestMachine(passed, failed, skipped, results)
    end
    
    -- One-line hint removed - test commands only available in dev deployments
end

-- Machine output for empty registry case
function Diagnostics:PrintSelfTestMachineEmpty()
    Cerebro:Print("--- Machine Output ---")
    Cerebro:Print("selftest.passed=0")
    Cerebro:Print("selftest.failed=1")
    Cerebro:Print("selftest.skipped=0")
    Cerebro:Print("selftest.total=0")
    Cerebro:Print("selftest.reason=registry_empty")
end

function Diagnostics:PrintSelfTestMachine(passed, failed, skipped, results)
    Cerebro:Print("--- Machine Output ---")
    
    -- Deterministic output (no timestamps for test determinism)
    Cerebro:Print("selftest.passed=" .. passed)
    Cerebro:Print("selftest.failed=" .. failed)
    Cerebro:Print("selftest.skipped=" .. skipped)
    Cerebro:Print("selftest.total=" .. #results)
    
    -- Results in fixed order with sanitized name and detail
    for i, r in ipairs(results) do
        Cerebro:Print("selftest.check." .. i .. ".id=" .. r.id)
        Cerebro:Print("selftest.check." .. i .. ".name=" .. SanitizeKV(r.name))
        Cerebro:Print("selftest.check." .. i .. ".status=" .. r.status)
        Cerebro:Print("selftest.check." .. i .. ".detail=" .. SanitizeKV(r.detail))
    end
end

--[[=============================================
    Status Snapshot (existing functionality)
==============================================]]

-- Returns structured snapshot for testability
function Diagnostics:GetStatusSnapshot()
    local snapshot = {
        version = Cerebro.version or "unknown",
        status = "OK",  -- OK or WARNING only (ERROR is TODO)
        warnings = {},
        
        -- Role detection
        isOfficer = Cerebro:IsOfficerSafe(),
        role = "Member",
        guildRankName = nil,      -- Raw rank name or nil
        guildRankIndex = nil,     -- Numeric index or nil
        guildRankText = "Unknown", -- Human-friendly display
        
        -- Database stats
        playerCount = 0,
        transactionCount = 0,
        
        -- Session
        sessionActive = false,
        sessionID = nil,
        
        -- Sync freshness
        lastOfficerSync = 0,
        elapsedSinceSyncSeconds = nil,
        elapsedSinceSyncText = "Never",
        epoch = 0,  -- Sync epoch for data versioning
        
        -- Dependencies
        rclcLoaded = false,
        rclcBridgePresent = false,
        rclcBridgeEnabled = false,
    }
    
    -- Role
    snapshot.role = snapshot.isOfficer and "Officer" or "Member"
    
    -- Guild Rank (correct pcall capture)
    local ok, _, rankName, rankIndex = pcall(GetGuildInfo, "player")
    if ok and rankName then
        snapshot.guildRankName = rankName
        snapshot.guildRankIndex = rankIndex
        snapshot.guildRankText = rankName
    else
        snapshot.guildRankText = "Unknown (roster not ready)"
        table.insert(snapshot.warnings, "Guild roster not ready")
    end
    
    -- Database stats (nil-safe)
    if Cerebro.db and Cerebro.db.global then
        for _ in pairs(Cerebro.db.global.players or {}) do
            snapshot.playerCount = snapshot.playerCount + 1
        end
        
        -- Use Ledger directly (it's a plain table, not AceAddon module)
        if Cerebro.Ledger and Cerebro.Ledger.GetTransactionCount then
            snapshot.transactionCount = Cerebro.Ledger:GetTransactionCount()
        elseif Cerebro.db.global.transactions then
            local count = 0
            for _ in ipairs(Cerebro.db.global.transactions) do
                count = count + 1
            end
            snapshot.transactionCount = count
        end
    end
    
    -- Session uses GetCurrentSession(), not raw DB
    local sessionModule = Cerebro:GetModule("Session", true)
    if sessionModule and sessionModule.IsActive and sessionModule.GetCurrentSession then
        snapshot.sessionActive = sessionModule:IsActive() or false
        if snapshot.sessionActive then
            local currentSession = sessionModule:GetCurrentSession()
            if currentSession then
                snapshot.sessionID = currentSession.sessionID
            end
        end
    end
    
    -- Sync freshness (nil-safe)
    if Cerebro.db and Cerebro.db.global and Cerebro.db.global.syncStatus then
        snapshot.lastOfficerSync = Cerebro.db.global.syncStatus.lastOfficerSync or 0
    end
    
    -- Epoch (sync data versioning)
    if Cerebro.db and Cerebro.db.global then
        snapshot.epoch = Cerebro.db.global.resetEpoch or 0
    end
    
    if snapshot.lastOfficerSync > 0 then
        local elapsed = self._timeFn() - snapshot.lastOfficerSync
        snapshot.elapsedSinceSyncSeconds = elapsed
        
        if elapsed < 60 then
            snapshot.elapsedSinceSyncText = elapsed .. " seconds ago"
        elseif elapsed < 3600 then
            snapshot.elapsedSinceSyncText = math.floor(elapsed / 60) .. " minutes ago"
        elseif elapsed < 86400 then
            snapshot.elapsedSinceSyncText = math.floor(elapsed / 3600) .. " hours ago"
        else
            snapshot.elapsedSinceSyncText = math.floor(elapsed / 86400) .. " days ago"
        end
        
        -- Warning: stale sync (> 7 days = 604800 seconds)
        if elapsed > 604800 then
            table.insert(snapshot.warnings, "Sync data is stale (> 7 days)")
        end
    elseif not snapshot.isOfficer then
        -- Members with no sync should see a warning
        table.insert(snapshot.warnings, "No sync data received yet")
    end
    
    -- Dependencies - distinguish present vs enabled
    snapshot.rclcLoaded = C_AddOns.IsAddOnLoaded("RCLootCouncil") or false
    
    local bridgeModule = Cerebro:GetModule("RCLC_Bridge", true)
    snapshot.rclcBridgePresent = (bridgeModule ~= nil)
    
    -- Only check enabled if RCLC is loaded and bridge is present
    if snapshot.rclcLoaded and bridgeModule and bridgeModule.IsEnabled then
        snapshot.rclcBridgeEnabled = bridgeModule:IsEnabled() or false
    end
    
    if not snapshot.rclcLoaded then
        table.insert(snapshot.warnings, "RCLootCouncil not loaded")
    end
    
    -- Determine overall status (OK or WARNING only; ERROR is TODO)
    if #snapshot.warnings > 0 then
        snapshot.status = "WARNING"
    end
    
    return snapshot
end

-- Print human-readable status
function Diagnostics:PrintHumanSnapshot(s)
    Cerebro:Print("=== Cerebro Status ===")
    Cerebro:Print("Version: " .. s.version)
    Cerebro:Print("Status: " .. s.status)
    Cerebro:Print("Role: " .. s.role .. " (Guild Rank: " .. s.guildRankText .. ")")
    Cerebro:Print("Database: " .. s.playerCount .. " Raiders, " .. s.transactionCount .. " Transactions")
    
    local sessionText = s.sessionActive and ("Active (ID: " .. (s.sessionID or "?") .. ")") or "Inactive"
    Cerebro:Print("Session: " .. sessionText)
    Cerebro:Print("Epoch: " .. s.epoch)
    Cerebro:Print("Last Sync: " .. s.elapsedSinceSyncText)
    
    local rclcStatus = s.rclcLoaded and "[OK]" or "[Missing]"
    if s.rclcLoaded and not s.rclcBridgeEnabled then
        rclcStatus = "[Loaded, Bridge Disabled]"
    end
    Cerebro:Print("Dependencies: RCLootCouncil " .. rclcStatus)
    
    if #s.warnings > 0 then
        Cerebro:Print("|cffff8800Warnings:|r")
        for _, warn in ipairs(s.warnings) do
            Cerebro:Print("  - " .. warn)
        end
    end
end

-- Print deterministic key=value output for machine mode
function Diagnostics:PrintMachineSnapshot(s)
    Cerebro:Print("=== Cerebro Status (Machine) ===")
    
    -- Deterministic key=value format (hardcoded order for stability)
    -- NOTE: No current-time timestamps or time-derived ages/durations per SPEC §6
    -- Stored timestamps (e.g., lastOfficerSync) are allowed
    local lines = {
        "version=" .. s.version,
        "status=" .. s.status,
        "player.nameFull=" .. SanitizeKV(Cerebro:GetFullPlayerName("player")),
        "player.realm=" .. SanitizeKV(GetRealmName()),
        "role.isOfficer=" .. tostring(s.isOfficer),
        "role.guildRankName=" .. SanitizeKV(s.guildRankName),
        "role.guildRankIndex=" .. (s.guildRankIndex and tostring(s.guildRankIndex) or "null"),
        "dependencies.rclcLoaded=" .. tostring(s.rclcLoaded),
        "dependencies.rclcBridgePresent=" .. tostring(s.rclcBridgePresent),
        "dependencies.rclcBridgeEnabled=" .. tostring(s.rclcBridgeEnabled),
        "session.isActive=" .. tostring(s.sessionActive),
        "session.sessionID=" .. SanitizeKV(s.sessionID),
        "db.playersCount=" .. s.playerCount,
        "db.transactionsCount=" .. s.transactionCount,
        "sync.lastOfficerSync=" .. s.lastOfficerSync,
        "warnings.count=" .. #s.warnings,
    }
    
    -- Add warnings with stable indices (sanitized for single-line safety)
    for i, warn in ipairs(s.warnings) do
        table.insert(lines, "warnings." .. i .. "=" .. SanitizeKV(warn))
    end
    
    for _, line in ipairs(lines) do
        Cerebro:Print(line)
    end
end

function Diagnostics:ShowStatus(args)
    local s = self:GetStatusSnapshot()
    
    -- Accept only --debug or debug
    local machineMode = false
    if args and args[1] then
        local arg = args[1]:lower()
        if arg == "--debug" or arg == "debug" then
            machineMode = true
        end
    end
    
    if machineMode then
        self:PrintMachineSnapshot(s)
    else
        self:PrintHumanSnapshot(s)
    end
end

--[[=============================================
    Diagnostic Ring Buffer
    
    PURPOSE:
    Fixed-capacity circular log capturing key state transitions for
    troubleshooting. Ephemeral (in-memory only, lost on /reload).
    
    CONTRACT:
    - TraceEvent(type, details) -> void: O(1) append, bounded values
    - GetTraceLog(count) -> table: Returns last N events (newest first)
    
    SECURITY:
    Pure append. No reads of external state, no mutations to addon state.
    pcall-wrapped at all call sites — a TraceEvent failure must never
    crash the calling module.
==============================================]]

-- Ring buffer constants (hardened limits)
local RING_BUFFER_SIZE = 100        -- Max events (capped — never grows)
local MAX_EVENT_TYPE_LEN = 24       -- Truncate event type IDs
local MAX_DETAIL_KEYS = 8           -- Max key-value pairs per event
local MAX_DETAIL_VALUE_LEN = 64     -- Truncate individual values
local DEFAULT_TRACE_COUNT = 50      -- Default events in remote response

-- Pre-allocated ring buffer
Diagnostics._traceRing = {}         -- [1..RING_BUFFER_SIZE] = event or nil
Diagnostics._traceHead = 0          -- Write cursor (circular, 1-indexed)
Diagnostics._traceCount = 0         -- Total events written (for stats)
Diagnostics._traceStartTime = nil   -- GetTime() reference for relative timestamps

--- Append a diagnostic event to the ring buffer.
-- O(1), no table creation beyond the event itself.
-- @param eventType string  Event identifier (e.g., "HB_MISMATCH")
-- @param details table|nil  Optional key-value pairs (bounded)
function Diagnostics:TraceEvent(eventType, details)
    -- Lazy-init start time reference
    if not self._traceStartTime then
        self._traceStartTime = GetTime()
    end
    
    -- Truncate event type
    if type(eventType) ~= "string" then
        eventType = tostring(eventType)
    end
    if #eventType > MAX_EVENT_TYPE_LEN then
        eventType = eventType:sub(1, MAX_EVENT_TYPE_LEN)
    end
    
    -- Build bounded details
    local boundedDetails = nil
    if details and type(details) == "table" then
        boundedDetails = {}
        local keyCount = 0
        for k, v in pairs(details) do
            if keyCount >= MAX_DETAIL_KEYS then break end
            local val = tostring(v)
            if #val > MAX_DETAIL_VALUE_LEN then
                val = val:sub(1, MAX_DETAIL_VALUE_LEN)
            end
            boundedDetails[tostring(k)] = val
            keyCount = keyCount + 1
        end
    end
    
    -- Advance cursor (circular)
    self._traceHead = (self._traceHead % RING_BUFFER_SIZE) + 1
    self._traceCount = self._traceCount + 1
    
    -- Overwrite slot (reuse table if pre-existing to reduce GC pressure)
    local slot = self._traceRing[self._traceHead]
    if slot then
        slot.t = GetTime() - self._traceStartTime
        slot.type = eventType
        slot.d = boundedDetails
    else
        self._traceRing[self._traceHead] = {
            t = GetTime() - self._traceStartTime,
            type = eventType,
            d = boundedDetails,
        }
    end
end

--- Read the last N events from the ring buffer (newest first).
-- @param count number|nil  Number of events to return (default 50, max RING_BUFFER_SIZE)
-- @return table  Array of event tables {t, type, d}
function Diagnostics:GetTraceLog(count)
    count = count or DEFAULT_TRACE_COUNT
    if count > RING_BUFFER_SIZE then count = RING_BUFFER_SIZE end
    
    local total = math.min(self._traceCount, RING_BUFFER_SIZE)
    if count > total then count = total end
    
    local result = {}
    local cursor = self._traceHead
    
    for i = 1, count do
        local event = self._traceRing[cursor]
        if event then
            result[i] = event
        end
        cursor = cursor - 1
        if cursor < 1 then cursor = RING_BUFFER_SIZE end
    end
    
    return result
end

--[[=============================================
    Support Report Generation
    
    PURPOSE:
    Combines GetStatusSnapshot() + V2 sync data + ring buffer
    into a structured report for the Payload Inspector.
    
    CONTRACT:
    - GenerateSupportReport(traceCount) -> table: Full diagnostic report
    - BuildReportText(report) -> string: Plain text for Inspector EditBox
    - BuildCompactString(report) -> string: One-liner for chat agents
    
    SECURITY:
    Strictly read-only. No DB writes, no sync triggers, no UI refreshes.
==============================================]]

--- Generate a comprehensive support report.
-- Reads from GetStatusSnapshot(), V2 sync state, and ring buffer.
-- @param traceCount number|nil  Number of trace events to include
-- @return table  Structured report
function Diagnostics:GenerateSupportReport(traceCount)
    traceCount = traceCount or DEFAULT_TRACE_COUNT
    
    -- Base snapshot (existing functionality)
    local snapshot = self:GetStatusSnapshot()
    
    local report = {
        -- Basics
        version = snapshot.version,
        role = snapshot.role,
        isOfficer = snapshot.isOfficer,
        guildRankText = snapshot.guildRankText,
        playerName = Cerebro:GetPlayerFullName() or "Unknown",
        generatedAt = time(),
        
        -- Database
        playerCount = snapshot.playerCount,
        transactionCount = snapshot.transactionCount,
        coreCount = 0,
        pendingCount = 0,
        participantCount = 0,
        
        -- Session
        sessionActive = snapshot.sessionActive,
        sessionID = snapshot.sessionID,
        sessionBosses = 0,
        selectedPlayerCount = 0,
        
        -- Config (EP values)
        epFarmBoss = 0,
        epProgBoss = 0,
        baseGP = 0,
        
        -- Sync (V2-specific)
        rosterHash = "N/A",
        configHash = "N/A",
        peerCount = 0,
        divergedPeerCount = 0,
        isStale = false,
        lastSnapshotAge = "N/A",
        syncLeader = "N/A",
        
        -- Dependencies
        rclcLoaded = snapshot.rclcLoaded,
        rclcBridgeEnabled = snapshot.rclcBridgeEnabled,
        
        -- Sync freshness
        epoch = snapshot.epoch,
        lastOfficerSync = snapshot.lastOfficerSync,
        elapsedSinceSyncText = snapshot.elapsedSinceSyncText,
        
        -- Warnings
        warnings = snapshot.warnings,
        
        -- Trace log
        traceLog = {},
        traceTotal = self._traceCount,
    }
    
    -- Count Core vs Pending players
    if Cerebro.db and Cerebro.db.global and Cerebro.db.global.players then
        local baseGP = (Cerebro.db.global.config and Cerebro.db.global.config.epgp
            and Cerebro.db.global.config.epgp.baseGP) or 200
        for _, pData in pairs(Cerebro.db.global.players) do
            if pData.status == "Core" then
                report.coreCount = report.coreCount + 1
            elseif pData.status == "Pending" then
                report.pendingCount = report.pendingCount + 1
            end
            if Cerebro.IsParticipant and Cerebro:IsParticipant(pData, baseGP) then
                report.participantCount = report.participantCount + 1
            end
        end
    end
    
    -- Session details
    if report.sessionActive then
        local session = Cerebro:GetModule("Session", true)
        if session then
            local cs = session:GetCurrentSession()
            if cs and cs.bosses then
                report.sessionBosses = #cs.bosses
            end
        end
    end
    
    -- Selected players count
    if Cerebro.db and Cerebro.db.global and Cerebro.db.global.customEP
        and Cerebro.db.global.customEP.selectedPlayers then
        for _ in pairs(Cerebro.db.global.customEP.selectedPlayers) do
            report.selectedPlayerCount = report.selectedPlayerCount + 1
        end
    end
    
    -- Config EP values
    if Cerebro.db and Cerebro.db.global and Cerebro.db.global.config
        and Cerebro.db.global.config.epgp then
        local epgp = Cerebro.db.global.config.epgp
        report.epFarmBoss = epgp.epFarmBoss or 0
        report.epProgBoss = epgp.epProgBoss or 0
        report.baseGP = epgp.baseGP or 0
    end
    
    -- V2 Sync data (read-only from CommunicationV2)
    local commV2 = Cerebro:GetModule("CommunicationV2", true)
    if commV2 then
        -- Roster hash
        if commV2.ComputeRosterHash then
            local ok, hash, pCount = pcall(commV2.ComputeRosterHash, commV2)
            if ok and hash then
                report.rosterHash = tostring(hash)
                if pCount then report.participantCount = pCount end
            end
        end
        
        -- Config hash
        if commV2.ComputeConfigHash then
            local ok, hash = pcall(commV2.ComputeConfigHash, commV2)
            if ok and hash then
                report.configHash = tostring(hash)
            end
        end
        
        -- Peer status from syncStateV2
        if Cerebro.db and Cerebro.db.global and Cerebro.db.global.syncStateV2 then
            local sv2 = Cerebro.db.global.syncStateV2
            if sv2.peerHashes then
                for _, peerInfo in pairs(sv2.peerHashes) do
                    report.peerCount = report.peerCount + 1
                    if peerInfo.diverged then
                        report.divergedPeerCount = report.divergedPeerCount + 1
                    end
                end
            end
            report.isStale = sv2.isStale or false
            
            -- Last snapshot age
            if sv2.lastSnapshotReceived and sv2.lastSnapshotReceived > 0 then
                local age = time() - sv2.lastSnapshotReceived
                if age < 60 then
                    report.lastSnapshotAge = age .. "s"
                elseif age < 3600 then
                    report.lastSnapshotAge = math.floor(age / 60) .. "m"
                else
                    report.lastSnapshotAge = math.floor(age / 3600) .. "h"
                end
            end
        end
        
        -- Sync Leader
        local syncSched = Cerebro:GetModule("SyncScheduler", true)
        if syncSched and syncSched.GetSyncLeaderName then
            local ok, leader = pcall(syncSched.GetSyncLeaderName, syncSched)
            if ok and leader then
                report.syncLeader = leader
            elseif ok then
                report.syncLeader = "N/A (not in raid)"
            end
        end
    end
    
    -- Trace log (newest first, bounded by traceCount)
    report.traceLog = self:GetTraceLog(traceCount)
    
    return report
end

--- Build plain-text report for the Inspector EditBox.
-- No WoW color codes — copies cleanly to Discord/chat agents.
-- @param report table  From GenerateSupportReport()
-- @return string  Multi-line plain text
function Diagnostics:BuildReportText(report)
    local lines = {}
    
    table.insert(lines, "=== CEREBRO SUPPORT REPORT ===")
    table.insert(lines, "subject=" .. report.playerName)
    table.insert(lines, "generated=" .. date("%Y-%m-%dT%H:%M:%S", report.generatedAt))
    table.insert(lines, "")
    
    table.insert(lines, "=== BASICS ===")
    table.insert(lines, "version=" .. report.version)
    table.insert(lines, "role=" .. report.role)
    table.insert(lines, "guildRank=" .. (report.guildRankText or "Unknown"))
    table.insert(lines, "syncLeader=" .. report.syncLeader)
    table.insert(lines, "")
    
    table.insert(lines, "=== DATABASE ===")
    table.insert(lines, string.format("players=%d (core=%d, pending=%d)",
        report.playerCount, report.coreCount, report.pendingCount))
    table.insert(lines, "participants=" .. report.participantCount)
    table.insert(lines, "transactions=" .. report.transactionCount)
    table.insert(lines, "")
    
    table.insert(lines, "=== CONFIG ===")
    table.insert(lines, "baseGP=" .. report.baseGP)
    table.insert(lines, "epFarmBoss=" .. report.epFarmBoss)
    table.insert(lines, "epProgBoss=" .. report.epProgBoss)
    table.insert(lines, "")
    
    table.insert(lines, "=== SYNC ===")
    table.insert(lines, "rosterHash=" .. report.rosterHash)
    table.insert(lines, "configHash=" .. report.configHash)
    table.insert(lines, "peersOnline=" .. report.peerCount)
    table.insert(lines, "divergedPeers=" .. report.divergedPeerCount)
    table.insert(lines, "isStale=" .. tostring(report.isStale))
    table.insert(lines, "lastSnapshotAge=" .. report.lastSnapshotAge)
    table.insert(lines, "epoch=" .. report.epoch)
    table.insert(lines, "lastOfficerSync=" .. report.elapsedSinceSyncText)
    table.insert(lines, "")
    
    table.insert(lines, "=== SESSION ===")
    table.insert(lines, "active=" .. tostring(report.sessionActive))
    if report.sessionActive then
        table.insert(lines, "sessionId=" .. (report.sessionID or "?"))
        table.insert(lines, "bossesRecorded=" .. report.sessionBosses)
    end
    table.insert(lines, "selectedPlayers=" .. report.selectedPlayerCount)
    table.insert(lines, "")
    
    table.insert(lines, "=== DEPENDENCIES ===")
    table.insert(lines, "rclc=" .. (report.rclcLoaded and "loaded" or "not_loaded"))
    table.insert(lines, "rclcBridge=" .. (report.rclcBridgeEnabled and "active" or "inactive"))
    table.insert(lines, "")
    
    -- Warnings
    if report.warnings and #report.warnings > 0 then
        table.insert(lines, "=== WARNINGS ===")
        for _, warn in ipairs(report.warnings) do
            table.insert(lines, "- " .. warn)
        end
        table.insert(lines, "")
    end
    
    -- Trace log
    local traceCount = #report.traceLog
    if traceCount > 0 then
        table.insert(lines, string.format("=== TRACE LOG (%d events, %d total) ===",
            traceCount, report.traceTotal))
        for _, event in ipairs(report.traceLog) do
            local detailStr = ""
            if event.d then
                local parts = {}
                for k, v in pairs(event.d) do
                    table.insert(parts, k .. "=" .. v)
                end
                if #parts > 0 then
                    detailStr = " " .. table.concat(parts, " ")
                end
            end
            table.insert(lines, string.format("[T%+.1fs] %s%s",
                event.t, event.type, detailStr))
        end
    else
        table.insert(lines, "=== TRACE LOG (empty) ===")
    end
    
    return table.concat(lines, "\n")
end

--- Build compact one-liner for chat agent import.
-- @param report table  From GenerateSupportReport()
-- @return string  Pipe-delimited compact string
function Diagnostics:BuildCompactString(report)
    local parts = {
        "CB",
        "v" .. report.version,
        report.role,
        report.playerName,
        report.playerCount .. "p/" .. report.coreCount .. "c",
        report.transactionCount .. "tx",
        "hash=" .. report.rosterHash,
        "cfg=" .. report.configHash,
        "peers=" .. report.peerCount .. "/" .. report.divergedPeerCount .. "div",
        "stale=" .. (report.isStale and "Y" or "N"),
        "epoch=" .. report.epoch,
    }
    
    if report.sessionActive then
        table.insert(parts, "session=Y/" .. report.sessionBosses .. "boss")
        table.insert(parts, "sel=" .. report.selectedPlayerCount)
    else
        table.insert(parts, "session=N")
    end
    
    table.insert(parts, "rclc=" .. (report.rclcLoaded and "Y" or "N"))
    table.insert(parts, "leader=" .. (report.syncLeader or "?"))
    table.insert(parts, "snap=" .. (report.lastSnapshotAge or "?"))
    table.insert(parts, "gp=" .. report.baseGP)
    table.insert(parts, "warn=" .. (report.warnings and #report.warnings or 0))
    
    return table.concat(parts, ";")
end

--[[=============================================
    Payload Inspector (Standalone UI)
    
    PURPOSE:
    Displays diagnostic report in a standalone AceGUI Frame with
    scrollable text and copy functionality.
    
    PATTERN:
    Based on DiscordExport:ShowExportWindow() (tools/DiscordExport.lua).
    
    CONTRACT:
    - ShowPayloadInspector(reportText, compactString, subjectName) -> void
    - Read-only. No state changes. Single instance (previous released).
==============================================]]

-- Reference to open inspector (for single-instance enforcement)
Diagnostics._inspectorFrame = nil

--- Open the Payload Inspector with a diagnostic report.
-- @param reportText string  Full report text for the EditBox
-- @param compactString string  Compact one-liner for Copy Compact button
-- @param subjectName string  Name of the player this report is for
function Diagnostics:ShowPayloadInspector(reportText, compactString, subjectName)
    local AceGUI = LibStub("AceGUI-3.0")
    
    -- Single instance: release previous inspector
    if self._inspectorFrame then
        self._inspectorFrame:Release()
        self._inspectorFrame = nil
    end
    
    -- Remove previous global frame reference if it exists
    if _G["CerebroSupportInspector"] then
        for i = #UISpecialFrames, 1, -1 do
            if UISpecialFrames[i] == "CerebroSupportInspector" then
                table.remove(UISpecialFrames, i)
                break
            end
        end
        _G["CerebroSupportInspector"] = nil
    end
    
    local frame = AceGUI:Create("Frame")
    frame:SetTitle("Cerebro — Support Report")
    frame:SetWidth(600)
    frame:SetHeight(500)
    frame:SetLayout("Flow")
    frame:SetCallback("OnClose", function(widget)
        widget:Release()
        self._inspectorFrame = nil
        -- Clean up global reference
        _G["CerebroSupportInspector"] = nil
    end)
    
    -- Escape to close
    _G["CerebroSupportInspector"] = frame.frame
    tinsert(UISpecialFrames, "CerebroSupportInspector")
    
    -- Header label
    local header = AceGUI:Create("Label")
    header:SetText("Report for: " .. (subjectName or "Self"))
    header:SetFullWidth(true)
    header:SetFontObject(GameFontNormalLarge)
    frame:AddChild(header)
    
    -- Timestamp label
    local timestamp = AceGUI:Create("Label")
    timestamp:SetText("Generated: " .. date("%Y-%m-%d %H:%M:%S") .. " | Cerebro v" .. (Cerebro.version or "?"))
    timestamp:SetFullWidth(true)
    frame:AddChild(timestamp)
    
    -- Spacer
    local spacer = AceGUI:Create("Label")
    spacer:SetText(" ")
    spacer:SetFullWidth(true)
    frame:AddChild(spacer)
    
    -- Main content: scrollable, selectable MultiLineEditBox
    local editBox = AceGUI:Create("MultiLineEditBox")
    editBox:SetLabel("")
    editBox:SetText(reportText)
    editBox:SetFullWidth(true)
    editBox:SetNumLines(22)
    editBox:DisableButton(true)  -- Hide Accept button (read-only)
    frame:AddChild(editBox)
    
    -- Button group
    local selectBtn = AceGUI:Create("Button")
    selectBtn:SetText("Select All")
    selectBtn:SetWidth(140)
    selectBtn:SetCallback("OnClick", function()
        editBox:SetFocus()
        editBox.editBox:HighlightText()
    end)
    frame:AddChild(selectBtn)
    
    -- Store references for Copy Compact to toggle between views
    local isShowingCompact = false
    
    local compactBtn = AceGUI:Create("Button")
    compactBtn:SetText("Copy Compact")
    compactBtn:SetWidth(140)
    compactBtn:SetCallback("OnClick", function()
        if isShowingCompact then
            -- Toggle back to full report
            editBox:SetText(reportText)
            compactBtn:SetText("Copy Compact")
            isShowingCompact = false
        else
            -- Show compact string, auto-select
            editBox:SetText(compactString)
            compactBtn:SetText("Show Full Report")
            isShowingCompact = true
        end
        editBox:SetFocus()
        editBox.editBox:HighlightText()
    end)
    frame:AddChild(compactBtn)
    
    -- Status footer (sync status, matching MainFrame pattern)
    local statusParts = {"v" .. (Cerebro.version or "?")}
    
    -- Sync status from V2
    local commV2 = Cerebro:GetModule("CommunicationV2", true)
    if commV2 and commV2.GetSyncStatus then
        local ok, syncStatus = pcall(commV2.GetSyncStatus, commV2)
        if ok and syncStatus then
            statusParts[#statusParts + 1] = syncStatus
        end
    end
    
    -- Roster hash
    if commV2 and commV2.ComputeRosterHash then
        local ok, rosterHash = pcall(commV2.ComputeRosterHash, commV2)
        if ok and rosterHash then
            statusParts[#statusParts + 1] = tostring(rosterHash)
        end
    end
    
    frame:SetStatusText(table.concat(statusParts, " | "))
    
    self._inspectorFrame = frame
end

--[[=============================================
    Remote Support RPC Handlers
    
    PURPOSE:
    Handle SUPPORT_REQUEST (incoming diagnostic request from officer)
    and SUPPORT_RESPONSE (incoming response from target).
    
    SECURITY (7 layers):
    1. Sender RBAC: /cb support checks IsOfficer() before SendRPC
    2. Receiver RBAC: handler checks IsOfficer(sender)
    3. Rate limit: 1 request per sender per 60s
    4. Response size cap: traceCount capped at RING_BUFFER_SIZE
    5. No-combat gate: skip during lockdown/encounter
    6. pcall isolation: already in HandleRPC()
    7. Guild scope: only respond to guild members
    
    CONTRACT:
    - HandleSupportRequest(sender, data) -> void (sends SUPPORT_RESPONSE)
    - HandleSupportResponse(sender, data) -> void (opens Inspector)
    - Both are STRICTLY read-only. Zero side-effects.
==============================================]]

-- Rate limiting state (per-sender timestamps)
Diagnostics._lastSupportReqFrom = {}   -- [senderName] = timestamp
Diagnostics._lastSupportReqSent = 0    -- Sender-side cooldown
Diagnostics._pendingSupportTarget = nil -- Who we're waiting for a response from
Diagnostics._supportTimeout = nil      -- Timeout timer handle

local SUPPORT_REQ_COOLDOWN = 60    -- Receiver: 1 request per sender per 60s
local SUPPORT_SEND_COOLDOWN = 30   -- Sender: 30s between commands
local SUPPORT_TIMEOUT_SEC = 10     -- Timeout for remote response

--- Handle incoming SUPPORT_REQUEST from an officer.
-- Generates report and sends back via WHISPER RPC. Fully silent to the player.
-- @param sender string  Full name of the requesting officer
-- @param data table  Request data { requestId, traceCount }
function Diagnostics:HandleSupportRequest(sender, data)
    -- Layer 2: Receiver RBAC — only respond to officers
    if not Cerebro:IsOfficer(sender) then
        SafeDebug("SUPPORT_REQUEST from non-officer " .. sender .. " — rejected")
        return
    end
    
    -- Layer 7: Guild scope — only respond to guild members
    if not Cerebro:IsGuildMember(sender) then
        SafeDebug("SUPPORT_REQUEST from non-guild " .. sender .. " — rejected")
        return
    end
    
    -- Layer 5: No-combat gate
    if InCombatLockdown() then
        SafeDebug("SUPPORT_REQUEST from " .. sender .. " — in combat, skipping")
        return
    end
    local syncSched = Cerebro:GetModule("SyncScheduler", true)
    if syncSched and syncSched.encounterState and syncSched.encounterState.inEncounter then
        SafeDebug("SUPPORT_REQUEST from " .. sender .. " — in encounter, skipping")
        return
    end
    
    -- Layer 3: Rate limit (per-sender, 60s)
    local now = time()
    local lastReq = self._lastSupportReqFrom[sender] or 0
    if (now - lastReq) < SUPPORT_REQ_COOLDOWN then
        SafeDebug("SUPPORT_REQUEST from " .. sender .. " — rate limited")
        return
    end
    self._lastSupportReqFrom[sender] = now
    
    -- Trace this event
    pcall(self.TraceEvent, self, "SUPPORT_REQ", { from = sender })
    
    -- Generate report (read-only)
    local traceCount = data and data.traceCount or DEFAULT_TRACE_COUNT
    if traceCount > RING_BUFFER_SIZE then traceCount = RING_BUFFER_SIZE end
    
    local report = self:GenerateSupportReport(traceCount)
    
    -- Build response payload
    local responsePayload = {
        requestId = data and data.requestId or "?",
        snapshot = {
            version = report.version,
            role = report.role,
            playerName = report.playerName,
            guildRankText = report.guildRankText,
            playerCount = report.playerCount,
            coreCount = report.coreCount,
            pendingCount = report.pendingCount,
            participantCount = report.participantCount,
            transactionCount = report.transactionCount,
            sessionActive = report.sessionActive,
            sessionID = report.sessionID,
            sessionBosses = report.sessionBosses,
            selectedPlayerCount = report.selectedPlayerCount,
            epFarmBoss = report.epFarmBoss,
            epProgBoss = report.epProgBoss,
            baseGP = report.baseGP,
            rosterHash = report.rosterHash,
            configHash = report.configHash,
            peerCount = report.peerCount,
            divergedPeerCount = report.divergedPeerCount,
            isStale = report.isStale,
            lastSnapshotAge = report.lastSnapshotAge,
            syncLeader = report.syncLeader,
            rclcLoaded = report.rclcLoaded,
            rclcBridgeEnabled = report.rclcBridgeEnabled,
            epoch = report.epoch,
            elapsedSinceSyncText = report.elapsedSinceSyncText,
            warnings = report.warnings,
        },
        traceLog = report.traceLog,
        traceTotal = report.traceTotal,
        generatedAt = report.generatedAt,
    }
    
    -- Send response via WHISPER RPC (silent — player sees nothing)
    local commV2 = Cerebro:GetModule("CommunicationV2", true)
    if commV2 and commV2.SendRPC then
        commV2:SendRPC("SUPPORT_RESPONSE", responsePayload, "WHISPER", sender)
        pcall(self.TraceEvent, self, "SUPPORT_RESP", { to = sender })
    end
end

--- Handle incoming SUPPORT_RESPONSE from a target.
-- Reconstructs the report and opens the Payload Inspector.
-- @param sender string  Full name of the responding player
-- @param data table  Response data { requestId, snapshot, traceLog, ... }
function Diagnostics:HandleSupportResponse(sender, data)
    if not data or not data.snapshot then
        SafeDebug("SUPPORT_RESPONSE from " .. sender .. " — missing snapshot")
        return
    end
    
    -- Cancel timeout timer
    if self._supportTimeout then
        Cerebro:CancelTimer(self._supportTimeout)
        self._supportTimeout = nil
    end
    self._pendingSupportTarget = nil
    
    -- Reconstruct a report table from the payload
    local snap = data.snapshot
    local report = {
        version = snap.version or "?",
        role = snap.role or "?",
        playerName = snap.playerName or sender,
        guildRankText = snap.guildRankText or "?",
        generatedAt = data.generatedAt or time(),
        playerCount = snap.playerCount or 0,
        coreCount = snap.coreCount or 0,
        pendingCount = snap.pendingCount or 0,
        participantCount = snap.participantCount or 0,
        transactionCount = snap.transactionCount or 0,
        sessionActive = snap.sessionActive or false,
        sessionID = snap.sessionID,
        sessionBosses = snap.sessionBosses or 0,
        selectedPlayerCount = snap.selectedPlayerCount or 0,
        epFarmBoss = snap.epFarmBoss or 0,
        epProgBoss = snap.epProgBoss or 0,
        baseGP = snap.baseGP or 0,
        rosterHash = snap.rosterHash or "?",
        configHash = snap.configHash or "?",
        peerCount = snap.peerCount or 0,
        divergedPeerCount = snap.divergedPeerCount or 0,
        isStale = snap.isStale or false,
        lastSnapshotAge = snap.lastSnapshotAge or "?",
        syncLeader = snap.syncLeader or "?",
        rclcLoaded = snap.rclcLoaded or false,
        rclcBridgeEnabled = snap.rclcBridgeEnabled or false,
        epoch = snap.epoch or 0,
        elapsedSinceSyncText = snap.elapsedSinceSyncText or "?",
        warnings = snap.warnings or {},
        traceLog = data.traceLog or {},
        traceTotal = data.traceTotal or 0,
    }
    
    -- Build display text and compact string
    local reportText = self:BuildReportText(report)
    local compactString = self:BuildCompactString(report)
    
    -- Open the Inspector
    self:ShowPayloadInspector(reportText, compactString, sender)
end

--- Send a remote SUPPORT_REQUEST to a target player.
-- Officer-only. Rate-limited. Includes timeout handling.
-- @param targetName string  Full name of the target player
-- @param traceCount number|nil  Number of trace events to request
function Diagnostics:SendSupportRequest(targetName, traceCount)
    -- RBAC: sender must be officer
    if not Cerebro:IsOfficer() then
        Cerebro:Print("|cffff4444Only officers can request remote support reports.|r")
        return
    end
    
    -- Sender-side cooldown (30s)
    local now = time()
    if (now - self._lastSupportReqSent) < SUPPORT_SEND_COOLDOWN then
        local remaining = SUPPORT_SEND_COOLDOWN - (now - self._lastSupportReqSent)
        Cerebro:Print(string.format("|cffff8800Please wait %ds before sending another support request.|r", remaining))
        return
    end
    self._lastSupportReqSent = now
    
    -- Generate a simple requestId
    local requestId = string.format("%08x", math.random(0, 0x7FFFFFFF))
    
    -- Send via WHISPER RPC
    local commV2 = Cerebro:GetModule("CommunicationV2", true)
    if not commV2 or not commV2.SendRPC then
        Cerebro:Print("|cffff4444CommunicationV2 not available.|r")
        return
    end
    
    self._pendingSupportTarget = targetName
    commV2:SendRPC("SUPPORT_REQUEST", {
        requestId = requestId,
        traceCount = traceCount or DEFAULT_TRACE_COUNT,
    }, "WHISPER", targetName)
    
    Cerebro:Print(string.format("|cff00ccffRequesting support report from %s...|r",
        Cerebro:GetShortPlayerName(targetName)))
    
    -- Timeout: 10 seconds
    self._supportTimeout = Cerebro:ScheduleTimer(function()
        if self._pendingSupportTarget == targetName then
            self._pendingSupportTarget = nil
            self._supportTimeout = nil
            Cerebro:Print(string.format(
                "|cffff8800No response from %s. They may be offline, in combat, or running an older addon version.|r",
                Cerebro:GetShortPlayerName(targetName)))
        end
    end, SUPPORT_TIMEOUT_SEC)
end

--- Show self support report (no network — instant).
-- Available to all players.
-- @param traceCount number|nil  Number of trace events to include
function Diagnostics:ShowSelfSupport(traceCount)
    local report = self:GenerateSupportReport(traceCount)
    local reportText = self:BuildReportText(report)
    local compactString = self:BuildCompactString(report)
    self:ShowPayloadInspector(reportText, compactString, report.playerName)
end

return Diagnostics
