Public/Get-FlightRecorderReport.ps1

# Copyright (c) 2026 Jeffrey Snover. All rights reserved.
# Licensed under the MIT License. See LICENSE file in the project root.

function Get-FlightRecorderReport {
    <#
    .SYNOPSIS
        Parses a flight recorder JSONL dump and produces a summary report.

    .DESCRIPTION
        Reads a flight recorder JSONL file, analyzes the events, and builds
        a report including phase timeline, current state, error/warning
        summary, and component coverage.

    .PARAMETER Path
        Path to the JSONL dump file. Can be piped from Request-FlightRecorderDump
        or Get-FlightRecorderDump.

    .PARAMETER Detailed
        Include full event listing in the report.

    .PARAMETER AsObject
        Return structured output instead of formatted text.

    .EXAMPLE
        Get-FlightRecorderReport -Path ./flight-recorder-dump.jsonl

    .EXAMPLE
        Get-FlightRecorderDump -Last 1 | Get-FlightRecorderReport

    .EXAMPLE
        Get-TaxonomyProcess | Request-FlightRecorderDump | Get-FlightRecorderReport -AsObject
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('FullName')]
        [string]$Path,

        [Parameter()]
        [switch]$Detailed,

        [Parameter()]
        [switch]$AsObject
    )

    process {
        if (-not (Test-Path $Path)) {
            throw (New-ActionableError `
                -Goal 'Generate flight recorder report' `
                -Problem "File not found: $Path" `
                -Location 'Get-FlightRecorderReport' `
                -NextSteps @(
                    'Verify the dump file path is correct'
                    'Run Get-FlightRecorderDump to list available dumps'
                ))
        }

        $header = $null
        $context = $null
        $dictionary = @{}
        $events = [System.Collections.Generic.List[object]]::new()
        $phases = [System.Collections.Generic.List[object]]::new()
        $errors = [System.Collections.Generic.List[object]]::new()
        $warnings = [System.Collections.Generic.List[object]]::new()
        $componentCounts = @{}
        $levelCounts = @{ debug = 0; info = 0; warn = 0; error = 0; fatal = 0 }
        $typeCounts = @{}
        $lastSpeaker = $null
        $lastRound = $null
        $lastPhase = $null
        $firstWall = $null
        $lastWall = $null

        foreach ($line in [System.IO.File]::ReadLines($Path)) {
            if (-not $line.Trim()) { continue }

            try {
                $obj = $line | ConvertFrom-Json
            } catch {
                continue
            }

            $recType = $null
            if ($obj.PSObject.Properties['_type']) {
                $recType = $obj._type
            }

            switch ($recType) {
                'header' {
                    $header = $obj
                }
                'dictionary' {
                    if ($obj.PSObject.Properties['entries']) {
                        foreach ($entry in $obj.entries) {
                            $dictionary["$($entry.handle)"] = $entry.value
                        }
                    }
                }
                'context' {
                    $context = $obj
                }
                'event' {
                    $events.Add($obj)

                    # Resolve dictionary handles
                    $componentName = $obj.component
                    $handleKey = "$componentName"
                    if ($dictionary.ContainsKey($handleKey)) {
                        $componentName = $dictionary[$handleKey]
                    }

                    # Track component coverage
                    $compKey = [string]$componentName
                    if ($componentCounts.ContainsKey($compKey)) {
                        $componentCounts[$compKey]++
                    } else {
                        $componentCounts[$compKey] = 1
                    }

                    # Track event types
                    $evtType = ''
                    if ($obj.PSObject.Properties['type']) {
                        $evtType = [string]$obj.type
                    }
                    if ($typeCounts.ContainsKey($evtType)) {
                        $typeCounts[$evtType]++
                    } else {
                        $typeCounts[$evtType] = 1
                    }

                    # Track levels
                    $level = 'info'
                    if ($obj.PSObject.Properties['level']) {
                        $level = [string]$obj.level
                    }
                    if ($levelCounts.ContainsKey($level)) {
                        $levelCounts[$level]++
                    }

                    # Collect errors and warnings
                    if ($level -eq 'error' -or $level -eq 'fatal') {
                        $errors.Add($obj)
                    } elseif ($level -eq 'warn') {
                        $warnings.Add($obj)
                    }

                    # Track phase transitions
                    if ($evtType -eq 'debate.phase') {
                        $phaseName = ''
                        if ($obj.PSObject.Properties['data'] -and $obj.data.PSObject.Properties['phase']) {
                            $phaseName = $obj.data.phase
                        } elseif ($obj.PSObject.Properties['message']) {
                            $phaseName = $obj.message
                        }
                        $phases.Add([PSCustomObject]@{
                            Phase = $phaseName
                            Seq   = if ($obj.PSObject.Properties['_seq']) { $obj._seq } else { 0 }
                            Wall  = if ($obj.PSObject.Properties['_wall']) { $obj._wall } else { 0 }
                        })
                        $lastPhase = $phaseName
                    }

                    # Track speaker/round
                    if ($obj.PSObject.Properties['speaker']) {
                        $spk = $obj.speaker
                        $spkKey = "$spk"
                        if ($dictionary.ContainsKey($spkKey)) {
                            $spk = $dictionary[$spkKey]
                        }
                        $lastSpeaker = [string]$spk
                    }
                    if ($evtType -eq 'debate.round' -and $obj.PSObject.Properties['data']) {
                        if ($obj.data.PSObject.Properties['round']) {
                            $lastRound = $obj.data.round
                        }
                    }

                    # Track time range
                    if ($obj.PSObject.Properties['_wall']) {
                        $wall = $obj._wall
                        if ($null -eq $firstWall) { $firstWall = $wall }
                        $lastWall = $wall
                    }
                }
            }
        }

        # Build phase timeline with event counts
        $phaseTimeline = [System.Collections.Generic.List[object]]::new()
        for ($i = 0; $i -lt $phases.Count; $i++) {
            $startSeq = $phases[$i].Seq
            $endSeq = if ($i + 1 -lt $phases.Count) { $phases[$i + 1].Seq } else { [int]::MaxValue }
            $count = 0
            foreach ($evt in $events) {
                $seq = 0
                if ($evt.PSObject.Properties['_seq']) { $seq = $evt._seq }
                if ($seq -ge $startSeq -and $seq -lt $endSeq) { $count++ }
            }
            $phaseTimeline.Add([PSCustomObject]@{
                Phase      = $phases[$i].Phase
                EventCount = $count
            })
        }

        # Build current state
        $currentState = [PSCustomObject]@{
            Phase   = if ($context -and $context.PSObject.Properties['debate']) {
                          if ($context.debate.PSObject.Properties['phase']) { $context.debate.phase } else { $lastPhase }
                      } else { $lastPhase }
            Round   = $lastRound
            Speaker = $lastSpeaker
        }

        # Build error summary
        $errorCategories = @{}
        foreach ($err in $errors) {
            $cat = 'uncategorized'
            if ($err.PSObject.Properties['error_category']) {
                $cat = [string]$err.error_category
            } elseif ($err.PSObject.Properties['type']) {
                $cat = [string]$err.type
            }
            if ($errorCategories.ContainsKey($cat)) {
                $errorCategories[$cat]++
            } else {
                $errorCategories[$cat] = 1
            }
        }

        # Time range
        $timeRangeStart = $null
        $timeRangeEnd = $null
        if ($null -ne $firstWall) {
            $timeRangeStart = [DateTimeOffset]::FromUnixTimeMilliseconds([long]$firstWall).DateTime.ToString('o')
        }
        if ($null -ne $lastWall) {
            $timeRangeEnd = [DateTimeOffset]::FromUnixTimeMilliseconds([long]$lastWall).DateTime.ToString('o')
        }

        # Top components sorted by count
        $topComponents = $componentCounts.GetEnumerator() |
            Sort-Object Value -Descending |
            Select-Object @{N='Component';E={$_.Key}}, @{N='Events';E={$_.Value}}

        $report = [PSCustomObject]@{
            DumpFile       = $Path
            Header         = if ($header) {
                                 [PSCustomObject]@{
                                     SchemaVersion = if ($header.PSObject.Properties['schema_version']) { $header.schema_version } else { $null }
                                     Capacity      = if ($header.PSObject.Properties['ring_buffer_capacity']) { $header.ring_buffer_capacity } else { 0 }
                                     Retained      = if ($header.PSObject.Properties['ring_buffer_events_retained']) { $header.ring_buffer_events_retained } else { 0 }
                                     Total         = if ($header.PSObject.Properties['ring_buffer_events_total']) { $header.ring_buffer_events_total } else { 0 }
                                     Lost          = if ($header.PSObject.Properties['events_lost']) { $header.events_lost } else { 0 }
                                 }
                             } else { $null }
            TimeRange      = [PSCustomObject]@{
                                 Start = $timeRangeStart
                                 End   = $timeRangeEnd
                             }
            EventCount     = $events.Count
            LevelCounts    = [PSCustomObject]$levelCounts
            PhaseTimeline  = @($phaseTimeline)
            CurrentState   = $currentState
            ErrorCount     = $errors.Count
            WarningCount   = $warnings.Count
            ErrorSummary   = $errorCategories
            Components     = @($topComponents)
            DebateId       = if ($context -and $context.PSObject.Properties['debate'] -and
                                 $context.debate.PSObject.Properties['id']) {
                                 $context.debate.id
                             } else { $null }
        }

        if ($Detailed) {
            $report | Add-Member -NotePropertyName Events -NotePropertyValue @($events) -Force
            $report | Add-Member -NotePropertyName Errors -NotePropertyValue @($errors) -Force
            $report | Add-Member -NotePropertyName Warnings -NotePropertyValue @($warnings) -Force
        }

        if ($AsObject) {
            $report
        } else {
            _Format-FlightRecorderReport $report
        }
    }
}

function _Format-FlightRecorderReport {
    param([object]$Report)

    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine("=== Flight Recorder Report ===")
    [void]$sb.AppendLine("File: $($Report.DumpFile)")

    if ($Report.Header) {
        [void]$sb.AppendLine("Events: $($Report.Header.Retained) retained / $($Report.Header.Total) total ($($Report.Header.Lost) lost)")
    } else {
        [void]$sb.AppendLine("Events: $($Report.EventCount)")
    }

    if ($Report.TimeRange.Start) {
        [void]$sb.AppendLine("Time range: $($Report.TimeRange.Start) .. $($Report.TimeRange.End)")
    }

    if ($Report.DebateId) {
        [void]$sb.AppendLine("Debate ID: $($Report.DebateId)")
    }

    [void]$sb.AppendLine("")
    [void]$sb.AppendLine("--- Level Summary ---")
    [void]$sb.AppendLine(" debug: $($Report.LevelCounts.debug) info: $($Report.LevelCounts.info) warn: $($Report.LevelCounts.warn) error: $($Report.LevelCounts.error) fatal: $($Report.LevelCounts.fatal)")

    if ($Report.PhaseTimeline.Count -gt 0) {
        [void]$sb.AppendLine("")
        [void]$sb.AppendLine("--- Phase Timeline ---")
        foreach ($phase in $Report.PhaseTimeline) {
            [void]$sb.AppendLine(" $($phase.Phase): $($phase.EventCount) events")
        }
    }

    [void]$sb.AppendLine("")
    [void]$sb.AppendLine("--- Current State ---")
    [void]$sb.AppendLine(" Phase: $($Report.CurrentState.Phase ?? 'n/a') Round: $($Report.CurrentState.Round ?? 'n/a') Speaker: $($Report.CurrentState.Speaker ?? 'n/a')")

    if ($Report.ErrorCount -gt 0) {
        [void]$sb.AppendLine("")
        [void]$sb.AppendLine("--- Errors ($($Report.ErrorCount)) ---")
        foreach ($cat in $Report.ErrorSummary.GetEnumerator()) {
            [void]$sb.AppendLine(" $($cat.Key): $($cat.Value)")
        }
    }

    if ($Report.WarningCount -gt 0) {
        [void]$sb.AppendLine(" Warnings: $($Report.WarningCount)")
    }

    if ($Report.Components.Count -gt 0) {
        [void]$sb.AppendLine("")
        [void]$sb.AppendLine("--- Component Coverage ---")
        foreach ($comp in $Report.Components | Select-Object -First 15) {
            [void]$sb.AppendLine(" $($comp.Component): $($comp.Events)")
        }
        if ($Report.Components.Count -gt 15) {
            [void]$sb.AppendLine(" ... and $($Report.Components.Count - 15) more")
        }
    }

    $sb.ToString()
}