Public/Write-EnvkLog.ps1

function Write-EnvkLog {
    <#
    .SYNOPSIS
        Writes a timestamped, severity-tagged log entry to both the console and a log file.
 
    .DESCRIPTION
        Formats a log message with an ISO-style timestamp, severity level tag, caller function
        name, and PowerShell process PID, then writes it to the module log file (Add-Content)
        and to the console (Write-Host with ANSI color codes). DEBUG entries are suppressed
        entirely when $script:Config.EnableDebug is not $true.
 
        Log file format:
          2026-03-01 05:36:12 [INFO] [Start-EnvkAppSequence] [PID:1234] Message text here
 
        Console output uses ANSI escape codes for color (PS 5.1 compatible via [char]27):
          DEBUG = dark gray ([char]27 + [90m)
          INFO = terminal default (no color codes injected)
          WARNING = yellow ([char]27 + [33m)
          ERROR = red ([char]27 + [31m)
 
        The log file always receives plain text — no ANSI escape codes are written to disk.
 
        File write resilience: Add-Content is wrapped in a 3-attempt retry loop with 50ms
        between attempts to handle transient IOException from cross-process file locks
        (e.g., a concurrent parallel run, a log viewer, or Backup-LogFile briefly holding
        the file). The -Encoding utf8 parameter is specified explicitly for consistency.
 
        When an ErrorRecord is supplied, the script name, line number, and exception message are
        appended to the log entry for quick error triage.
 
    .PARAMETER Level
        The severity level of the message. Must be one of: DEBUG, INFO, WARNING, ERROR.
        Defaults to 'INFO'.
 
    .PARAMETER Message
        The text to log. Mandatory.
 
    .PARAMETER ErrorRecord
        Optional. A System.Management.Automation.ErrorRecord captured from a catch block.
        When provided, appends " (Line: <n> in <script>): <exception message>" to the entry.
 
    .NOTES
        Author: Aaron AlAnsari
        Created: 2026-02-24
 
        Caller resolution: Get-PSCallStack[1] returns the immediate caller of Write-EnvkLog.
        Index 0 is Write-EnvkLog itself; index 1 is the function that called it. If the call
        stack is shallow or the caller is a script block, falls back to 'Unknown'.
 
        ANSI codes: [char]27 is the ESC character, compatible with PS 5.1 (backtick-e is PS 6+).
        ANSI codes are injected into Write-Host output only; Add-Content receives plain text.
    #>


    [CmdletBinding()]
    [OutputType([void])]
    param (
        [Parameter()]
        [ValidateSet('DEBUG', 'INFO', 'WARNING', 'ERROR')]
        [string]$Level = 'INFO',

        [Parameter(Mandatory)]
        [string]$Message,

        [Parameter()]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )

    # Suppress DEBUG entries when EnableDebug is not set.
    if ($Level -eq 'DEBUG' -and $script:Config.EnableDebug -ne $true) {
        return
    }

    # Resolve the immediate caller's function name.
    # Get-PSCallStack[0] = Write-EnvkLog itself; [1] = the calling function.
    # Placed after the DEBUG-suppression early return to avoid overhead for suppressed entries.
    $caller = 'Unknown'
    try {
        $frame = (Get-PSCallStack)[1]
        if ($null -ne $frame -and -not [string]::IsNullOrEmpty($frame.FunctionName) -and $frame.FunctionName -ne '<ScriptBlock>') {
            $caller = $frame.FunctionName
        }
    }
    catch {
        $caller = 'Unknown'
    }

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

    if ($null -ne $ErrorRecord) {
        $lineNumber  = $ErrorRecord.InvocationInfo.ScriptLineNumber
        $scriptName  = $ErrorRecord.InvocationInfo.ScriptName
        $errorMsg    = $ErrorRecord.Exception.Message
        $logMessage  = "$logMessage (Line: $lineNumber in $scriptName): $errorMsg"
    }

    # Ensure the log directory exists before writing.
    $logDir = Split-Path -Path $script:LogFilePath -Parent
    if (-not (Test-Path -Path $logDir)) {
        New-Item -ItemType Directory -Path $logDir -Force | Out-Null
    }

    # Write plain text (no ANSI codes) to the log file.
    # Retry loop guards against transient IOException when another process (a prior
    # parallel run, log viewer, or Backup-LogFile) holds a brief exclusive lock.
    # Five attempts with 100ms between them (400ms total window) is sufficient for
    # cross-process contention scenarios (e.g., a concurrent PS 7 parallel run holding
    # the file briefly) without meaningfully delaying sequential execution.
    # After all retries are exhausted, Write-Host is used as a fallback so the message
    # is not silently lost (Write-EnvkLog cannot be called recursively).
    $writeAttempt = 0
    $writeSuccess = $false
    while (-not $writeSuccess -and $writeAttempt -lt 5) {
        try {
            Add-Content -Path $script:LogFilePath -Value $logMessage -Encoding utf8 -ErrorAction Stop
            $writeSuccess = $true
        }
        catch [System.IO.IOException] {
            $writeAttempt++
            if ($writeAttempt -lt 5) {
                Start-Sleep -Milliseconds 100
            }
        }
    }
    if (-not $writeSuccess) {
        # All retry attempts exhausted. Write-Host is the fallback so the message is
        # not silently dropped. Cannot call Write-EnvkLog here (would recurse).
        Write-Host "WARNING: Write-EnvkLog could not write to log file after 5 attempts -- $logMessage"
    }

    # Build ANSI color sequences for console output.
    # [char]27 is the ESC character — PS 5.1 compatible (backtick-e requires PS 6+).
    $esc   = [char]27
    $reset = "$esc[0m"

    $colorCode = switch ($Level) {
        'DEBUG'   { "$esc[90m" }   # dark gray / bright black
        'INFO'    { '' }           # terminal default — no color codes for INFO
        'WARNING' { "$esc[33m" }   # yellow
        'ERROR'   { "$esc[31m" }   # red
    }

    if ($colorCode -eq '') {
        # INFO: no color injection — use plain text to respect terminal theme.
        Write-Host $logMessage
    }
    else {
        Write-Host "${colorCode}${logMessage}${reset}"
    }
}