--[[
    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
    })
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",
        
        -- 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
    
    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("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

return Diagnostics
