Private/Write-TbLog.ps1

function Write-TbLog {
    <#
    .SYNOPSIS
        Writes structured log entries to the Toolbox log file.
     
    .DESCRIPTION
        Internal logging function that writes JSON Lines formatted log entries.
        Handles log rotation, sensitive data masking, and correlation tracking.
     
    .PARAMETER Message
        Log message content.
     
    .PARAMETER Level
        Log level (Verbose, Info, Warning, Error).
     
    .PARAMETER RunId
        Execution run identifier for correlation.
     
    .PARAMETER Computer
        Target computer name for the log entry.
     
    .PARAMETER TaskName
        Task name being executed.
     
    .PARAMETER Data
        Additional structured data to include in the log.
     
    .PARAMETER ErrorRecord
        PowerShell ErrorRecord to log.
     
    .EXAMPLE
        Write-TbLog -Message "Task started" -Level Info -RunId $runId -TaskName "File.TestPathExists"
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Message,
        
        [Parameter()]
        [ValidateSet('Verbose', 'Info', 'Warning', 'Error')]
        [string]$Level = 'Info',
        
        [Parameter()]
        [string]$RunId,
        
        [Parameter()]
        [string]$Computer,
        
        [Parameter()]
        [string]$TaskName,
        
        [Parameter()]
        [hashtable]$Data,
        
        [Parameter()]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )
    
    # Get configuration
    $config = Get-TbConfig -Section Logging
    
    # Check if logging is enabled
    if (-not $config.Enabled) {
        return
    }
    
    # Check log level filtering
    $levelPriority = @{
        'Verbose' = 0
        'Info' = 1
        'Warning' = 2
        'Error' = 3
    }
    
    if ($levelPriority[$Level] -lt $levelPriority[$config.LogLevel]) {
        return
    }
    
    try {
        # Ensure log directory exists
        if (-not (Test-Path $config.LogPath)) {
            New-Item -ItemType Directory -Path $config.LogPath -Force | Out-Null
        }
        
        # Determine log file path (current date)
        $logFileName = "Toolbox_$(Get-Date -Format 'yyyyMMdd').log"
        $logFilePath = Join-Path $config.LogPath $logFileName
        
        # Check for log rotation
        if (Test-Path $logFilePath) {
            $logFile = Get-Item $logFilePath
            $logSizeMB = [math]::Round($logFile.Length / 1MB, 2)
            
            if ($logSizeMB -ge $config.MaxLogFileSizeMB) {
                Invoke-LogRotation -LogPath $config.LogPath -MaxFiles $config.MaxLogFiles
                $logFileName = "Toolbox_$(Get-Date -Format 'yyyyMMdd').log"
                $logFilePath = Join-Path $config.LogPath $logFileName
            }
        }
        
        # Build log entry
        $logEntry = [ordered]@{
            Timestamp = (Get-Date).ToString('o')
            Level = $Level
            Message = $Message
        }
        
        if ($RunId) { $logEntry.RunId = $RunId }
        if ($Computer) { $logEntry.Computer = $Computer }
        if ($TaskName) { $logEntry.TaskName = $TaskName }
        if ($Data) {
            # Mask sensitive data if enabled
            if ($config.MaskSensitiveData) {
                $logEntry.Data = Hide-SensitiveData -Data $Data -SensitiveProperties $config.SensitiveProperties
            }
            else {
                $logEntry.Data = $Data
            }
        }
        
        # Add error information if provided
        if ($ErrorRecord) {
            $logEntry.Error = @{
                Message = $ErrorRecord.Exception.Message
                Type = $ErrorRecord.Exception.GetType().FullName
                Category = $ErrorRecord.CategoryInfo.Category.ToString()
                TargetObject = $ErrorRecord.TargetObject
                ScriptStackTrace = $ErrorRecord.ScriptStackTrace
            }
        }
        
        # Add process context
        $logEntry.ProcessId = $PID
        $logEntry.User = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
        
        # Convert to JSON and write (JSON Lines format - one JSON object per line)
        $jsonLine = $logEntry | ConvertTo-Json -Compress -Depth 10
        Add-Content -Path $logFilePath -Value $jsonLine -Encoding UTF8
        
        Write-Verbose "Log entry written: $Level - $Message"
    }
    catch {
        # Logging failure should not break execution
        Write-Warning "Failed to write log entry: $_"
    }
}

function Invoke-LogRotation {
    <#
    .SYNOPSIS
        Rotates log files when size limit is reached.
    #>

    [CmdletBinding()]
    param(
        [string]$LogPath,
        [int]$MaxFiles
    )
    
    try {
        # Get all log files sorted by creation time
        $logFiles = Get-ChildItem -Path $LogPath -Filter "Toolbox_*.log" | 
            Sort-Object CreationTime -Descending
        
        # If we have more files than allowed, remove the oldest
        if ($logFiles.Count -ge $MaxFiles) {
            $filesToRemove = $logFiles | Select-Object -Skip ($MaxFiles - 1)
            foreach ($file in $filesToRemove) {
                Remove-Item -Path $file.FullName -Force
                Write-Verbose "Removed old log file: $($file.Name)"
            }
        }
        
        # Rename current log file with timestamp
        $currentLog = Join-Path $LogPath "Toolbox_$(Get-Date -Format 'yyyyMMdd').log"
        if (Test-Path $currentLog) {
            $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
            $newName = "Toolbox_$timestamp.log"
            $newPath = Join-Path $LogPath $newName
            Move-Item -Path $currentLog -Destination $newPath -Force
            Write-Verbose "Rotated log file to: $newName"
        }
    }
    catch {
        Write-Warning "Failed to rotate log files: $_"
    }
}

function Hide-SensitiveData {
    <#
    .SYNOPSIS
        Masks sensitive data in log entries.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Data,
        
        [Parameter(Mandatory)]
        [array]$SensitiveProperties
    )
    
    $maskedData = @{}
    
    foreach ($key in $Data.Keys) {
        $value = $Data[$key]
        
        # Check if key matches sensitive property names (case-insensitive)
        $isSensitive = $false
        foreach ($sensitiveKey in $SensitiveProperties) {
            if ($key -like "*$sensitiveKey*") {
                $isSensitive = $true
                break
            }
        }
        
        if ($isSensitive -and $null -ne $value) {
            # Mask the value
            if ($value -is [string] -and $value.Length -gt 0) {
                $maskedData[$key] = "***MASKED***"
            }
            elseif ($value -is [System.Security.SecureString]) {
                $maskedData[$key] = "***MASKED_SECURE_STRING***"
            }
            elseif ($value -is [PSCredential]) {
                $maskedData[$key] = @{
                    UserName = $value.UserName
                    Password = "***MASKED***"
                }
            }
            else {
                $maskedData[$key] = "***MASKED***"
            }
        }
        else {
            $maskedData[$key] = $value
        }
    }
    
    return $maskedData
}