Private/Logging/Write-StructuredLogEntry.ps1

function Write-StructuredLogEntry {
    <#
    .SYNOPSIS
        Writes structured log entries with consistent formatting
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Message,

        [Parameter(Mandatory)]
        [ValidateSet('Critical', 'Error', 'Warning', 'Information', 'Debug', 'Verbose', 'INFO')]
        [string]$Level,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$Component = 'General',

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$CorrelationId = [System.Guid]::NewGuid().ToString(),

        [Parameter()]
        [string]$LogPath,

        [Parameter()]
        [hashtable]$Details = @{}
    )

    # Normalize INFO to Information
    if ($Level -eq 'INFO') {
        $Level = 'Information'
    }

    try {
        # Create structured log entry
        $logEntry = [PSCustomObject]@{
            Timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fffK"
            Level = $Level
            Component = $Component
            CorrelationId = $CorrelationId
            Message = $Message
        }

        # Add additional details
        if ($Details.Count -gt 0) {
            foreach ($key in $Details.Keys) {
                $logEntry | Add-Member -MemberType NoteProperty -Name $key -Value $Details[$key]
            }
        }

        # Format the entry - create simple formatted strings
        $timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fff"
        $formattedEntryForFile = "[$timestamp] [$Level] [$Component] [$CorrelationId] $Message"
        if ($Details.Count -gt 0) {
            $detailsJson = $Details | ConvertTo-Json -Compress
            $formattedEntryForFile += " | Details: $detailsJson"
        }
        
        $formattedEntryForConsole = "[$Level] [$Component] $Message"

        # Determine the log path - use parameter if provided, otherwise try to get from logging system state
        $effectiveLogPath = $LogPath
        if (-not $effectiveLogPath) {
            try {
                if (Get-Command Get-LoggingSystemState -ErrorAction SilentlyContinue) {
                    $loggingState = Get-LoggingSystemState
                    $effectiveLogPath = $loggingState.LogPath
                }
            } catch {
                # If we can't get logging state, just use console output
                Write-Verbose "Could not retrieve logging system state: $($_.Exception.Message)"
            }
        }

        # Write to appropriate destination
        # Always write to console for user feedback
        Write-ConsoleLogEntry -FormattedMessage $formattedEntryForConsole -Level $Level
        
        # File output honours the configured -LogLevel; console output does not, so that warnings and
        # errors still reach an operator who lowered the file verbosity. Security entries bypass this
        # filter through Write-SecurityStructuredLogEntry.
        $writeToFile = $true
        if (Get-Command Test-LogLevel -ErrorAction SilentlyContinue) {
            $writeToFile = Test-LogLevel $Level
        }

        # Also write to file if log path is available
        if ($effectiveLogPath -and $writeToFile) {
            try {
                # -WhatIf on the public command reaches here through $WhatIfPreference; the log is not
                # what is being rehearsed, so the write is pinned
                Add-Content -Path $effectiveLogPath -Value $formattedEntryForFile -Encoding UTF8 -WhatIf:$false
            } catch {
                Write-Warning "Failed to write log entry to file: $($_.Exception.Message)"
            }
        }

        # Don't return to pipeline - just write to void to prevent output contamination
        $null = $logEntry
    } catch {
        Write-Warning "Failed to write structured log entry: $($_.Exception.Message)"
    }
}

function Write-ConsoleLogEntry {
    [CmdletBinding()]
    param(
        [string]$FormattedMessage,
        [string]$Level
    )

    # -SuppressConsoleOutput on the public commands sets this; the file still receives every entry
    if ($script:SuppressConsoleOutput) {
        return
    }

    switch ($Level) {
        'Critical' { Write-Error $FormattedMessage }
        'Error' { Write-Error $FormattedMessage }
        'Warning' { Write-Warning $FormattedMessage }
        'Information' { Write-Information $FormattedMessage -InformationAction Continue }
        'Debug' { Write-Debug $FormattedMessage }
        'Verbose' { Write-Verbose $FormattedMessage }
        default { Write-Information $FormattedMessage -InformationAction Continue }
    }
}