Action.Logging.psm1

# ========================================
# Action.Logging Module
# ========================================
#
# Module Version : 1.2.0
# Date Created: 06-21-2025
# Last Revised: 08-19-2025
# Created by : Gonzalo More
# Required files: None
# Required folders: None
#
# Licensed under the MIT License.
# See LICENSE file in the project root for full license information.
#
# ========================================

<#
.SYNOPSIS
    Action.Logging - Enhanced PowerShell Logging Module with async support.
 
.LINK
    For full documentation, see README.md
#>


# Enhanced logging structure with severity levels
enum LogLevel {
    ERROR = 1
    WARN = 2
    INFO = 3
    DEBUG = 4
    TRACE = 5
}

# Module-level variables for async logging
$script:LogQueue = [System.Collections.Concurrent.ConcurrentQueue[PSObject]]::new()
$script:LoggerRunspace = $null
$script:LoggerPowerShell = $null
$script:CurrentLogFile = $null
$script:AsyncLoggingEnabled = $false
$script:LoggingStopRequested = $false
$script:ConsoleOutputEnabled = $true
$script:DefaultLogRetentionCount = 10
$script:VerifiedPaths = @{}
$script:SessionFiles = @{}

<#
.SYNOPSIS
    Writes enhanced log entries with severity levels and color coding.
 
.DESCRIPTION
    Creates timestamped log entries with severity levels, saves to log files,
    and displays color-coded output to the console. Automatically creates
    log directories if they don't exist.
 
.PARAMETER Message
    The message to log.
 
.PARAMETER Level
    The severity level of the log entry. Default is INFO.
 
.PARAMETER LogPath
    The path where log files will be stored. Default is based on script name.
 
.EXAMPLE
    Write-EnhancedLog -Message "Script started" -Level INFO
 
.EXAMPLE
    Write-EnhancedLog -Message "Critical error occurred" -Level ERROR
 
.EXAMPLE
    Write-EnhancedLog -Message "Debug information" -Level DEBUG -LogPath "C:\custom\logs"
#>

function Write-EnhancedLog {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Message,

        [Parameter(Mandatory = $false)]
        [LogLevel]$Level = [LogLevel]::INFO,

        [Parameter(Mandatory = $false)]
        [string]$LogPath
    )

    # If no LogPath specified, use calling script name
    if (-not $LogPath) {
        $callingScript = (Get-PSCallStack)[1].ScriptName
        if ($callingScript) {
            $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($callingScript)
            $LogPath = "C:\temp\logs\$scriptName"
        } else {
            $LogPath = "C:\temp\logs\Action.Logging"
        }
    }

    try {
        # Only verify path once per session (optimization)
        if (-not $script:VerifiedPaths.ContainsKey($LogPath)) {
            if (-not (Test-Path $LogPath)) {
                New-Item -ItemType Directory -Path $LogPath -Force | Out-Null
            }
            $script:VerifiedPaths[$LogPath] = $true

            # Initialize session file for this path with script name prefix
            $folderName = Split-Path $LogPath -Leaf
            if ($folderName -eq "Action.Logging") {
                $logFileName = "ActionLogging_$(Get-Date -Format 'MMddyy-HHmmss').log"
            } else {
                $logFileName = "${folderName}_$(Get-Date -Format 'MMddyy-HHmmss').log"
            }
            $script:SessionFiles[$LogPath] = "$LogPath\$logFileName"
            Clear-OldLog -LogPath $LogPath
        }

        # Use path-specific session file
        $currentLogFile = $script:SessionFiles[$LogPath]

        $timestamp = Get-Date -Format 'MM-dd-yyyy HH:mm:ss'
        $logEntry = "$timestamp [$Level] $Message"

        # Write to log file
        Add-Content -Path $currentLogFile -Value $logEntry -Encoding UTF8 -ErrorAction SilentlyContinue

        # Output to console with color coding if enabled
        if ($script:ConsoleOutputEnabled) {
            switch ($Level) {
                ([LogLevel]::ERROR) { Write-Host $logEntry -ForegroundColor Red }
                ([LogLevel]::WARN) { Write-Host $logEntry -ForegroundColor Yellow }
                ([LogLevel]::INFO) { Write-Host $logEntry -ForegroundColor Green }
                ([LogLevel]::DEBUG) { Write-Host $logEntry -ForegroundColor Cyan }
                ([LogLevel]::TRACE) { Write-Host $logEntry -ForegroundColor Gray }
            }
        }
    }
    catch {
        if ($script:ConsoleOutputEnabled) {
            Write-Warning "Failed to write to log: $($_.Exception.Message)"
            # Fallback to console output only
            Write-Host "$timestamp [$Level] $Message" -ForegroundColor White
        }
    }
}

<#
.SYNOPSIS
    Cleans up old log files, keeping only the most recent ones.
 
.DESCRIPTION
    Removes old log files from the specified path, keeping only the last
    10 log files to manage disk space. Files are sorted by creation time.
 
.PARAMETER LogPath
    The path containing log files to clean up.
 
.PARAMETER KeepCount
    Number of recent log files to keep. Default is 10.
 
.EXAMPLE
    Clear-OldLogs -LogPath "C:\temp\logs\MyScript"
 
.EXAMPLE
    Clear-OldLogs -LogPath "C:\temp\logs\MyScript" -KeepCount 5
#>

function Clear-OldLog {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$LogPath,

        [Parameter(Mandatory = $false)]
        [int]$KeepCount = $script:DefaultLogRetentionCount
    )

    try {
        if (Test-Path $LogPath) {
            $logFiles = Get-ChildItem -Path $LogPath -Filter "*.log" |
                        Sort-Object CreationTime -Descending

            if ($logFiles.Count -gt $KeepCount) {
                $filesToDelete = $logFiles[$KeepCount..($logFiles.Count - 1)]
                $filesToDelete | Remove-Item -Force -ErrorAction SilentlyContinue
                Write-Verbose "Cleaned up $($filesToDelete.Count) old log files from $LogPath"
            }
        }
    }
    catch {
        Write-Warning "Failed to clean up old logs in ${LogPath}: $($_.Exception.Message)"
    }
}

<#
.SYNOPSIS
    Starts an asynchronous logging background worker for high-performance logging.
 
.DESCRIPTION
    Initializes a background PowerShell runspace that handles log writing asynchronously.
    This provides better performance for high-volume logging scenarios by queuing
    log entries and writing them in batches to reduce I/O overhead.
 
.PARAMETER LogPath
    Optional path where log files will be stored. If not specified, uses calling
    script name detection to determine the appropriate log directory.
 
.EXAMPLE
    Start-AsyncLogger
    Starts async logger using automatic path detection based on calling script.
 
.EXAMPLE
    Start-AsyncLogger -LogPath "C:\temp\logs\MyApplication"
    Starts async logger with a specific log directory path.
 
.NOTES
    - Only one async logger can run at a time per PowerShell session
    - Use Write-EnhancedLogAsync to write to the async logger
    - Use Stop-AsyncLogger to properly shutdown the background worker
    - Automatically creates log directories if they don't exist
#>

function Start-AsyncLogger {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory = $false)]
        [string]$LogPath
    )

    # Don't start if already running
    if ($script:AsyncLoggingEnabled) {
        Write-Warning "Async logging is already running"
        return
    }

    if ($PSCmdlet.ShouldProcess("Async Logger", "Start background logging")) {

    # Initialize log path
    if (-not $LogPath) {
        $callingScript = (Get-PSCallStack)[1].ScriptName
        if ($callingScript) {
            $scriptName = [System.IO.Path]::GetFileNameWithoutExtension($callingScript)
            $LogPath = "C:\temp\logs\$scriptName"
        } else {
            $LogPath = "C:\temp\logs\Action.Logging"
        }
    }

    # Ensure log directory exists (once)
    if (-not $script:VerifiedPaths.ContainsKey($LogPath)) {
        if (-not (Test-Path $LogPath)) {
            New-Item -ItemType Directory -Path $LogPath -Force | Out-Null
        }
        $script:VerifiedPaths[$LogPath] = $true
    }

    # Create session log file with script name prefix
    $folderName = Split-Path $LogPath -Leaf
    if ($folderName -eq "Action.Logging") {
        $logFileName = "ActionLogging_$(Get-Date -Format 'MMddyy-HHmmss').log"
    } else {
        $logFileName = "${folderName}_$(Get-Date -Format 'MMddyy-HHmmss').log"
    }
    $script:CurrentLogFile = "$LogPath\$logFileName"
    Clear-OldLog -LogPath $LogPath

    # Create runspace for background logging
    $script:LoggerRunspace = [RunspaceFactory]::CreateRunspace()
    $script:LoggerRunspace.ApartmentState = "STA"
    $script:LoggerRunspace.ThreadOptions = "ReuseThread"
    $script:LoggerRunspace.Open()

    # Create synchronized hashtable for shared state
    $syncHash = [hashtable]::Synchronized(@{
        LogQueue = $script:LogQueue
        CurrentLogFile = $script:CurrentLogFile
        StopRequested = $false
        ConsoleOutputEnabled = $script:ConsoleOutputEnabled
    })

    # Share synchronized hashtable with runspace
    $script:LoggerRunspace.SessionStateProxy.SetVariable("syncHash", $syncHash)

    # Create PowerShell instance for background work
    $script:LoggerPowerShell = [PowerShell]::Create()
    $script:LoggerPowerShell.Runspace = $script:LoggerRunspace

    # Define background worker script
    $workerScript = {
        $logBuffer = [System.Collections.Generic.List[string]]::new()
        $lastFlush = [DateTime]::Now
        $flushInterval = [TimeSpan]::FromMilliseconds(500)

        while (-not $syncHash.StopRequested) {
            $hasMessages = $false
            $message = $null

            # Collect messages from queue
            while ($syncHash.LogQueue.TryDequeue([ref]$message)) {
                $hasMessages = $true
                $logBuffer.Add($message.Entry)

                # Console output based on level if enabled
                if ($syncHash.ConsoleOutputEnabled) {
                    switch ($message.Level) {
                        'ERROR' { Write-Host $message.Entry -ForegroundColor Red }
                        'WARN' { Write-Host $message.Entry -ForegroundColor Yellow }
                        'INFO' { Write-Host $message.Entry -ForegroundColor Green }
                        'DEBUG' { Write-Host $message.Entry -ForegroundColor Cyan }
                        'TRACE' { Write-Host $message.Entry -ForegroundColor Gray }
                    }
                }
            }

            # Flush buffer to file periodically or when buffer is large
            $shouldFlush = $logBuffer.Count -gt 0 -and (
                $logBuffer.Count -ge 50 -or
                ([DateTime]::Now - $lastFlush) -gt $flushInterval
            )

            if ($shouldFlush) {
                try {
                    Add-Content -Path $syncHash.CurrentLogFile -Value $logBuffer -Encoding UTF8
                    $logBuffer.Clear()
                    $lastFlush = [DateTime]::Now
                }
                catch {
                    Write-Warning "Async logger failed to write: $_"
                }
            }

            # Sleep briefly if no messages
            if (-not $hasMessages) {
                Start-Sleep -Milliseconds 50
            }
        }

        # Final flush on shutdown
        if ($logBuffer.Count -gt 0) {
            try {
                Add-Content -Path $syncHash.CurrentLogFile -Value $logBuffer -Encoding UTF8
            }
            catch {
                Write-Warning "Final flush failed: $_"
            }
        }
    }

    # Start background worker
    $null = $script:LoggerPowerShell.AddScript($workerScript)
    $null = $script:LoggerPowerShell.BeginInvoke()

    # Store sync hash reference
    $script:SyncHash = $syncHash

    $script:AsyncLoggingEnabled = $true
    Write-Host "Async logging started for $LogPath" -ForegroundColor Green
    }
}

<#
.SYNOPSIS
    Stops the asynchronous logging background worker gracefully.
 
.DESCRIPTION
    Properly shuts down the background PowerShell runspace used for async logging.
    Ensures all queued log entries are written before stopping and cleans up
    all resources including runspaces and PowerShell instances.
 
.EXAMPLE
    Stop-AsyncLogger
    Stops the currently running async logger.
 
.NOTES
    - Waits up to 5 seconds for remaining log entries to be processed
    - Properly disposes of PowerShell runspace and instances
    - Safe to call even if async logger is not running
    - Should always be called before script termination if async logging was used
#>

function Stop-AsyncLogger {
    [CmdletBinding(SupportsShouldProcess)]
    param()

    if (-not $script:AsyncLoggingEnabled) {
        return
    }

    if ($PSCmdlet.ShouldProcess("Async Logger", "Stop background logging")) {

    Write-Host "Stopping async logger..." -ForegroundColor Yellow

    # Signal stop
    $script:SyncHash.StopRequested = $true

    # Wait for queue to empty (max 5 seconds)
    $timeout = [DateTime]::Now.AddSeconds(5)
    while ($script:LogQueue.Count -gt 0 -and [DateTime]::Now -lt $timeout) {
        Start-Sleep -Milliseconds 100
    }

    # Clean up resources
    if ($script:LoggerPowerShell) {
        $script:LoggerPowerShell.Stop()
        $script:LoggerPowerShell.Dispose()
        $script:LoggerPowerShell = $null
    }

    if ($script:LoggerRunspace) {
        $script:LoggerRunspace.Close()
        $script:LoggerRunspace.Dispose()
        $script:LoggerRunspace = $null
    }

    $script:AsyncLoggingEnabled = $false
    $script:LoggingStopRequested = $false
    Write-Host "Async logger stopped" -ForegroundColor Green
    }
}

<#
.SYNOPSIS
    Writes log entries asynchronously using the background logging worker.
 
.DESCRIPTION
    Queues log messages for asynchronous processing by the background worker.
    Provides high-performance logging by avoiding blocking I/O operations.
    Falls back to synchronous logging if async logger is not running.
 
.PARAMETER Message
    The message to log. This parameter is mandatory.
 
.PARAMETER Level
    The severity level of the log entry. Default is INFO.
    Valid values: ERROR, WARN, INFO, DEBUG, TRACE
 
.EXAMPLE
    Write-EnhancedLogAsync -Message "Process completed successfully"
    Writes an INFO level message asynchronously.
 
.EXAMPLE
    Write-EnhancedLogAsync -Message "Critical error occurred" -Level ERROR
    Writes an ERROR level message asynchronously.
 
.NOTES
    - Requires Start-AsyncLogger to be called first
    - Falls back to Write-EnhancedLog if async logger not running
    - Messages are queued and written in batches for performance
    - Use Stop-AsyncLogger to ensure all messages are written before exit
#>

function Write-EnhancedLogAsync {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Message,

        [Parameter(Mandatory = $false)]
        [LogLevel]$Level = [LogLevel]::INFO
    )

    # Fall back to sync logging if async not enabled
    if (-not $script:AsyncLoggingEnabled) {
        Write-EnhancedLog -Message $Message -Level $Level
        return
    }

    $timestamp = Get-Date -Format 'MM-dd-yyyy HH:mm:ss'
    $logEntry = "$timestamp [$Level] $Message"

    # Queue the message
    $logMessage = [PSCustomObject]@{
        Entry = $logEntry
        Level = $Level.ToString()
    }

    $script:LogQueue.Enqueue($logMessage)
}

<#
.SYNOPSIS
    Controls whether log entries are displayed in the console.
 
.DESCRIPTION
    Enables or disables console output for all logging functions in the module.
    When disabled, log entries are still written to files but not displayed
    in the console. Affects both synchronous and asynchronous logging.
 
.PARAMETER Enabled
    Boolean value to enable (True) or disable (False) console output.
    This parameter is mandatory.
 
.EXAMPLE
    Set-LoggingConsoleOutput -Enabled $false
    Disables console output for all logging functions.
 
.EXAMPLE
    Set-LoggingConsoleOutput -Enabled $true
    Enables console output for all logging functions.
 
.NOTES
    - Affects both Write-EnhancedLog and Write-EnhancedLogAsync functions
    - Changes are applied immediately to running async logger if active
    - Log files are still created regardless of console output setting
    - Useful for silent automation scenarios or reducing console noise
#>

function Set-LoggingConsoleOutput {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory = $true)]
        [bool]$Enabled
    )

    if ($PSCmdlet.ShouldProcess("Console Output", "Set logging console output to $Enabled")) {
        $script:ConsoleOutputEnabled = $Enabled

    # Update async logger if running
    if ($script:AsyncLoggingEnabled -and $script:SyncHash) {
        $script:SyncHash.ConsoleOutputEnabled = $Enabled
    }

        $status = if ($Enabled) { "enabled" } else { "disabled" }
        Write-Verbose "Console output $status for logging module"
    }
}

<#
.SYNOPSIS
    Sets the default log retention count for automatic cleanup.
 
.DESCRIPTION
    Configures how many log files to keep when automatic cleanup occurs.
    This setting applies to all logging functions in the module.
 
.PARAMETER Count
    Number of log files to keep. Must be between 5 and 100. Default is 10.
 
.EXAMPLE
    Set-LogRetention -Count 25
    Sets log retention to keep 25 most recent log files.
 
.EXAMPLE
    Set-LogRetention -Count 5
    Sets minimal log retention to keep only 5 most recent log files.
#>

function Set-LogRetention {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory = $false)]
        [ValidateRange(5, 100)]
        [int]$Count = 10
    )

    if ($PSCmdlet.ShouldProcess("Log Retention", "Set default log retention count to $Count")) {
        $script:DefaultLogRetentionCount = $Count
        Write-Verbose "Log retention count set to $Count files"
    }
}

# Export module functions and enum
Export-ModuleMember -Function Write-EnhancedLog, Clear-OldLog, Start-AsyncLogger, Stop-AsyncLogger, Write-EnhancedLogAsync, Set-LoggingConsoleOutput, Set-LogRetention

# Make LogLevel enum available in global scope for external access
$global:LogLevel = [LogLevel]