src/public/Logging/Get-AitherLog.ps1

#Requires -Version 7.0

<#
.SYNOPSIS
    Get and filter log entries from AitherZero logs

.DESCRIPTION
    Retrieves log entries from AitherZero log files with filtering, searching,
    and formatting options. Supports both structured JSON logs and text logs.

    This cmdlet is essential for troubleshooting and monitoring. It can filter logs
    by level, source, time range, and message content. Supports both human-readable
    and machine-readable output formats.

.PARAMETER Level
    Filter by log level(s). You can specify multiple levels. Only entries matching
    the specified levels will be returned.

    Valid values: Trace, Debug, Information, Warning, Error, Critical

    Examples:
    - "Error" - Only error entries
    - "Warning", "Error" - Warning and error entries
    - "Error", "Critical" - Error and critical entries

.PARAMETER Source
    Filter by log source. Uses pattern matching (wildcards supported).
    This helps find all log entries from a specific function, module, or component.

    Examples:
    - "Invoke-AitherScript" - All entries from Invoke-AitherScript
    - "*Script*" - All entries from sources containing "Script"
    - "Config" - All entries from Config-related sources

.PARAMETER Message
    Search for text in log messages. Uses regular expression matching.
    This is useful for finding specific events or error messages.

    Examples:
    - "failed" - Find all entries containing "failed"
    - "^Error" - Find entries starting with "Error"
    - "timeout|timed out" - Find entries containing "timeout" or "timed out"

.PARAMETER Since
    Get logs since this date/time. Only entries with timestamps after this time
    will be returned. Useful for filtering recent logs.

    Examples:
    - (Get-Date).AddHours(-1) - Last hour
    - (Get-Date).AddDays(-1) - Last 24 hours
    - "2025-01-15 10:00:00" - Since a specific date/time

.PARAMETER Until
    Get logs until this date/time. Only entries with timestamps before this time
    will be returned. Use together with Since to define a time range.

.PARAMETER Last
    Get the last N log entries. Returns the most recent entries matching other filters.
    Useful for quick checks of recent activity.

    Example: -Last 50 returns the 50 most recent matching entries.

.PARAMETER Tail
    Tail/follow log file (like tail -f). Continuously monitors the log file and
    displays new entries as they are written. Press Ctrl+C to stop.

    Useful for real-time monitoring of script execution or troubleshooting.

.PARAMETER LogFile
    Specific log file path. If not specified, defaults to today's log file.
    Can be a relative path (relative to logs directory) or absolute path.

    Examples:
    - "aitherzero-2025-01-15.log" - Specific date's log
    - "structured/structured-2025-01-15.jsonl" - Specific structured log
    - "C:\Logs\aitherzero.log" - Absolute path

.PARAMETER Format
    Output format for log entries. Default is Table for human-readable output.

    - Table: Formatted table (default, human-readable)
    - List: Detailed list format with all properties
    - Json: JSON format (machine-readable, preserves all data)
    - Raw: Raw log lines as they appear in the file

.PARAMETER Count
    Count matching entries instead of returning them. Returns only the number
    of entries matching the filters. Useful for quick statistics.

.PARAMETER LogType
    Type of logs to read. Default is Auto which detects available log types.

    - Text: Plain text logs only
    - Structured: JSON-structured logs only (JSONL format)
    - Both: Read from both text and structured logs
    - Auto: Automatically detect and use available log type (default)

.PARAMETER Structured
    Alias for LogType Structured. Shortcut to read only structured JSON logs.

.INPUTS
    System.String
    You can pipe log file paths to Get-AitherLog.

.OUTPUTS
    PSCustomObject
    Returns log entry objects with properties: Timestamp, Level, Source, Message, Data, Exception, etc.

    When -Count is used, returns System.Int32 (the count).

.EXAMPLE
    Get-AitherLog -Level Error -Last 50

    Gets the 50 most recent error log entries.

.EXAMPLE
    Get-AitherLog -Source 'Invoke-AitherScript' -Since (Get-Date).AddHours(-1)

    Gets all log entries from Invoke-AitherScript in the last hour.

.EXAMPLE
    Get-AitherLog -Message 'failed' -Level Warning,Error

    Searches for entries containing "failed" at Warning or Error level.

.EXAMPLE
    Get-AitherLog -Structured -Level Error

    Gets error entries from structured JSON logs only.

.EXAMPLE
    Get-AitherLog -LogType Both -Since (Get-Date).AddHours(-1)

    Gets all log entries from both text and structured logs in the last hour.

.EXAMPLE
    Get-AitherLog -Tail

    Monitors the log file in real-time, displaying new entries as they are written.

.EXAMPLE
    Get-AitherLog -Count

    Counts total log entries (or matching entries if filters are applied).

.EXAMPLE
    "aitherzero-2025-01-15.log", "aitherzero-2025-01-16.log" | Get-AitherLog -Level Error

    Gets error entries from multiple log files by piping file paths.

.NOTES
    Supports both JSON-structured logs (JSONL format) and plain text logs.

    Log file locations:
    - Structured logs: library/logs/structured/structured-YYYY-MM-DD.jsonl
    - Text logs: library/logs/aitherzero-YYYY-MM-DD.log

    Structured logs contain more detailed information including:
    - Full exception details
    - Structured data objects
    - Correlation IDs
    - Operation IDs
    - Performance metrics

    Text logs are simpler but still contain all essential information.

.LINK
    Write-AitherLog
    Search-AitherLog
    Clear-AitherLog
    Get-AitherErrorLog
#>

function Get-AitherLog {
[OutputType([PSCustomObject], [System.Int32])]
[CmdletBinding(DefaultParameterSetName = 'Filter')]
param(
    [Parameter()]
    [ValidateSet('Trace', 'Debug', 'Information', 'Warning', 'Error', 'Critical')]
    [string[]]$Level,

    [Parameter()]
    [string]$Source,

    [Parameter()]
    [string]$Message,

    [Parameter()]
    [datetime]$Since,

    [Parameter()]
    [datetime]$Until,

    [Parameter(ParameterSetName = 'Last')]
    [int]$Last,

    [Parameter(ParameterSetName = 'Tail')]
    [switch]$Tail,

    [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
    [string]$LogFile,

    [Parameter()]
    [ValidateSet('Table', 'List', 'Json', 'Raw')]
    [string]$Format = 'Table',

    [Parameter()]
    [switch]$Count,

    [Parameter()]
    [ValidateSet('Text', 'Structured', 'Both', 'Auto')]
    [string]$LogType = 'Auto',

    [Parameter()]
    [Alias('Structured')]
    [switch]$StructuredOnly,

    [switch]$ShowOutput
)

begin {
    $moduleRoot = Get-AitherModuleRoot
    $logsPath = Join-Path $moduleRoot 'AitherZero/library/logs'

    # Determine log type
    if ($StructuredOnly) {
        $LogType = 'Structured'
    }

    if ($LogType -eq 'Auto' -and -not $LogFile) {
        # Auto-detect: prefer structured if available, fallback to text
        $structuredPath = Join-Path $logsPath 'structured' "structured-$(Get-Date -Format 'yyyy-MM-dd').jsonl"
        $textPath = Join-Path $logsPath "aitherzero-$(Get-Date -Format 'yyyy-MM-dd').log"

        if (Test-Path $structuredPath) {
            $LogType = 'Structured'
        }
        elseif (Test-Path $textPath) {
            $LogType = 'Text'
        }
        else {
            $LogType = 'Text'  # Default to text
        }
    }

    if (-not $LogFile) {
        if ($LogType -eq 'Structured') {
            $LogFile = Join-Path $logsPath 'structured' "structured-$(Get-Date -Format 'yyyy-MM-dd').jsonl"
        }
        else {
            $LogFile = Join-Path $logsPath "aitherzero-$(Get-Date -Format 'yyyy-MM-dd').log"
        }
    }
    elseif (-not [System.IO.Path]::IsPathRooted($LogFile)) {
        $LogFile = Join-Path $logsPath $LogFile
    }

    function Parse-LogEntry {
        param([string]$Line)

        # Try to parse structured log format: [timestamp] [LEVEL] [Source] message
        if ($Line -match '^\[(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]\s+\[(\w+)\]\s+\[([^\]]+)\]\s+(.+)$') {
            return [PSCustomObject]@{
                Timestamp = [datetime]::Parse($matches[1])
                Level = $matches[2]
                Source = $matches[3]
                Message = $matches[4]
                Raw = $Line
            }
        }

        # Fallback: return raw line
        return [PSCustomObject]@{
            Timestamp = Get-Date
            Level = 'Information'
            Source = 'Unknown'
            Message = $Line
            Raw = $Line
        }
    }

    function Parse-StructuredLogEntry {
        param([string]$JsonLine)

        try {
            $entry = $JsonLine | ConvertFrom-Json

            # Handle different structured log formats
            if ($entry.'@timestamp') {
                # Structured log format from Write-StructuredLog
                return [PSCustomObject]@{
                    Timestamp = [datetime]::Parse($entry.'@timestamp')
                    Level = $entry.level
                    Source = $entry.source
                    Message = $entry.message
                    Data = $entry.properties
                    Tags = $entry.tags
                    CorrelationId = $entry.correlation_id
                    OperationId = $entry.operation_id
                    Metrics = $entry.metrics
                    Environment = $entry.environment
                    Raw = $JsonLine
                }
            }
            elseif ($entry.Timestamp) {
                # Standard log entry format
                return [PSCustomObject]@{
                    Timestamp = if ($entry.Timestamp -is [string]) { [datetime]::Parse($entry.Timestamp) } else { $entry.Timestamp }
                    Level = $entry.Level
                    Source = $entry.Source
                    Message = $entry.Message
                    Data = $entry.Data
                    Exception = $entry.Exception
                    ProcessId = $entry.ProcessId
                    ThreadId = $entry.ThreadId
                    User = $entry.User
                    Computer = $entry.Computer
                    Raw = $JsonLine
                }
            }
        }
        catch {
            Write-AitherLog -Level Warning -Message "Failed to parse structured log entry: $_" -Source 'Get-AitherLog' -Exception $_
            return $null
        }
    }

    function Get-LogEntriesFromFile {
        param(
            [string]$FilePath,
            [string]$Type
        )

        if (-not (Test-Path $FilePath)) {
            return @()
        }

        if ($Type -eq 'Structured') {
            # Read JSONL file (one JSON object per line)
            $entries = Get-Content -Path $FilePath -ErrorAction SilentlyContinue |
                Where-Object { $_ -and $_.Trim() } |
                ForEach-Object {
                    $entry = Parse-StructuredLogEntry -JsonLine $_
                    if ($entry) { $entry }
                }
            return $entries
        }
        else {
            # Read text log file
            $entries = Get-Content -Path $FilePath -ErrorAction Stop | ForEach-Object {
                Parse-LogEntry -Line $_
            }
            return $entries
        }
    }
}

process {
    # Save original log targets
    $originalLogTargets = $script:AitherLogTargets

    # Set log targets based on ShowOutput parameter
    if ($ShowOutput) {
        # Ensure Console is in the log targets
        if ($script:AitherLogTargets -notcontains 'Console') {
            $script:AitherLogTargets += 'Console'
        }
    }
    else {
        # Remove Console from log targets if present (default behavior)
        if ($script:AitherLogTargets -contains 'Console') {
            $script:AitherLogTargets = $script:AitherLogTargets | Where-Object { $_ -ne 'Console' }
        }
    }

    try {
        $allEntries = @()

        # Determine which log files to read
        if ($LogType -eq 'Both' -and -not $LogFile) {
            # Read from both text and structured logs (auto-detect today's files)
            $textLogFile = Join-Path $logsPath "aitherzero-$(Get-Date -Format 'yyyy-MM-dd').log"
            $structuredLogFile = Join-Path $logsPath 'structured' "structured-$(Get-Date -Format 'yyyy-MM-dd').jsonl"

            if (Test-Path $textLogFile) {
                $allEntries += Get-LogEntriesFromFile -FilePath $textLogFile -Type 'Text'
            }
        if (Test-Path $structuredLogFile) {
                $allEntries += Get-LogEntriesFromFile -FilePath $structuredLogFile -Type 'Structured'
            }
        }
    else {
            # Read from single log file
            if (-not (Test-Path $LogFile)) {
                Write-AitherLog -Level Warning -Message "Log file not found: $LogFile" -Source 'Get-AitherLog'
                return @()
            }

            # Determine log type from file extension if not specified
            $detectedType = $LogType
            if ($LogType -eq 'Auto' -or $LogType -eq 'Both') {
                if ($LogFile -match '\.jsonl?$') {
                    $detectedType = 'Structured'
                }
    else {
                    $detectedType = 'Text'
                }
            }

            # Tail mode
            if ($Tail) {
                if ($detectedType -eq 'Structured') {
                    Get-Content -Path $LogFile -Wait -Tail 0 | ForEach-Object {
                        if ($_ -and $_.Trim()) {
                            $entry = Parse-StructuredLogEntry -JsonLine $_
                            if ($entry) { Write-Output $entry }
                        }
                    }
                }
    else {
                    Get-Content -Path $LogFile -Wait -Tail 0 | ForEach-Object {
                        $entry = Parse-LogEntry -Line $_
                        Write-Output $entry
                    }
                }
                return
            }

            # Read log file
            $allEntries = Get-LogEntriesFromFile -FilePath $LogFile -Type $detectedType
        }

        # Apply filters
        if ($Level) {
            $allEntries = $allEntries | Where-Object { $_.Level -in $Level }
        }
        if ($Source) {
            $allEntries = $allEntries | Where-Object { $_.Source -like "*$Source*" }
        }
        if ($Message) {
            $allEntries = $allEntries | Where-Object { $_.Message -match $Message }
        }
        if ($Since) {
            $allEntries = $allEntries | Where-Object { $_.Timestamp -ge $Since }
        }
        if ($Until) {
            $allEntries = $allEntries | Where-Object { $_.Timestamp -le $Until }
        }

        # Sort by timestamp (newest first)
        $allEntries = $allEntries | Sort-Object Timestamp -Descending

        # Apply Last filter
        if ($Last -gt 0) {
            $allEntries = $allEntries | Select-Object -First $Last
        }

        # Count mode
        if ($Count) {
            return $allEntries.Count
        }

        # Format output
        switch ($Format) {
            'Table' {
                $allEntries | Select-Object Timestamp, Level, Source, Message | Format-Table -AutoSize
            }
            'List' {
                $allEntries | Format-List
            }
            'Json' {
                $allEntries | ConvertTo-Json -Depth 10
            }
            'Raw' {
                $allEntries | ForEach-Object { $_.Raw }
            }
            default {
                $allEntries
            }
        }
    }
    catch {
        Invoke-AitherErrorHandler -ErrorRecord $_ -Operation "Getting log entries" -Parameters $PSBoundParameters -ThrowOnError
    }
    finally {
        # Restore original log targets
        $script:AitherLogTargets = $originalLogTargets
    }
}

}