MEMZone.WriteLog.psm1

<#
.SYNOPSIS
    MEMZone.WriteLog - PowerShell Logging, Formatting, and Animation Module.
.DESCRIPTION
    Provides structured logging with dual console/file output, rich text formatting
    (tables, blocks, timelines, headers), animated progress indicators via background
    runspaces, and debug-level function tracing.
.NOTES
    Author: Ioan Popovici
    Creation Date: 2025-01-14
    Last Modified: 2026-07-29
    Module Version: 2.1.0
.LINK
    https://MEM.Zone
.LINK
    https://MEMZ.one/WriteLog
.LINK
    https://MEMZ.one/WriteLog-GIT
.LINK
    https://MEMZ.one/WriteLog-ISSUES
#>


##*=============================================
##* MODULE STATE
##*=============================================
#region Module State

[string]$Script:LogName           = ''
[string]$Script:LogPath           = ''
[string]$Script:LogFullName       = ''
[bool]$Script:LogDebugMessages    = $false
[bool]$Script:LogToConsole        = $true
[int]$Script:LogMaxSizeMB         = 5
$Script:LogBuffer                 = [System.Collections.ArrayList]::new()

#endregion Module State
##*=============================================
##* END MODULE STATE
##*=============================================

##*=============================================
##* FUNCTION LISTINGS
##*=============================================
#region FunctionListings

#region function Test-ConsoleInteractive
function Test-ConsoleInteractive {
<#
.SYNOPSIS
    Checks whether the current host supports cursor-based console output.
.DESCRIPTION
    Checks whether the current host supports cursor positioning, which is required for animated
    or in-place console output. Returns $false when output is redirected (piped, captured, or
    running non-interactively), when no console is attached, or when running in the PowerShell ISE.
.EXAMPLE
    if (Test-ConsoleInteractive) { [Console]::SetCursorPosition(0, 0) }
.INPUTS
    None
.OUTPUTS
    System.Boolean
.NOTES
    This is an internal module function and should typically not be called directly.
.LINK
    https://MEM.Zone
.LINK
    https://MEMZ.one/WriteLog
.LINK
    https://MEMZ.one/WriteLog-GIT
.LINK
    https://MEMZ.one/WriteLog-ISSUES
.COMPONENT
    Script Utilities
.FUNCTIONALITY
    Console Capability Detection
#>

    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param ()

    process {
        try {

            ## The ISE host has no real console and throws on cursor positioning
            if ($Host.Name -eq 'Windows PowerShell ISE Host') { return $false }

            ## Redirected output has no cursor to position
            if ([Console]::IsOutputRedirected) { return $false }

            ## Probe the cursor - throws when no console is attached (services, task sequences)
            $null = [Console]::CursorLeft
            return $true
        }
        catch {
            return $false
        }
    }
}

#endregion function Test-ConsoleInteractive

#region function Format-Message
function Format-Message {
<#
.SYNOPSIS
    Formats a text header or table.
.DESCRIPTION
    Formats a header block, centered block, section header, sub-header, inline log, table, or adds separator rows.
.PARAMETER Message
    Specifies the message or data to display. For tables, this contains the data to format.
.PARAMETER FormatData
    Specifies the formatting options as a hashtable:
    - Mode:
        - 'Block'
        - 'CenteredBlock'
        - 'Line'
        - 'InlineHeader'
        - 'InlineSubHeader'
        - 'Timeline'
        - 'TimelineHeader'
        - 'List'
        - 'Table'
            - Title : Table title. Default is: 'Data Table'.
            - Footer : Table footer text displayed below the table. Default is: $null.
            - NewHeaders : Mapping of display headers to property names. Default is: (Auto-Detected).
            - ColumnWidths : Custom column widths. Default is: 0.
            - CellPadding : Amount of horizontal padding to add within cells. Default is: 0.
            - VerticalPadding: Amount of padding to add above and below the table. Default is: 0.
            - ShowRowNumbers : Whether to show row numbers as the first column. Default is: $true.
    Default is: 'Timeline'.
    - AddEmptyRow:
        - 'No'
        - 'Before'
        - 'After'
        - 'BeforeAndAfter'
        Default is: 'No'.
.EXAMPLE
    Format-Message -Message 'IMPORT APPLICATION' -FormatData @{ Mode = 'InlineHeader' }
.EXAMPLE
    Format-Message -Message $Data -FormatData @{
        Mode = 'Table'
        Title = 'MEM.Zone'
        NewHeaders = [ordered]@{
            'Location' = 'City'
            'Status' = 'OperationStatus'
            'Compliance' = 'ComplianceStatus'
        }
        CellPadding = 1
        VerticalPadding = 1
        AddEmptyRow = 'BeforeAndAfter'
    }
.INPUTS
    System.Object
    System.Collections.Hashtable
.OUTPUTS
    System.String[]
.NOTES
    This is an internal script function and should typically not be called directly.
.LINK
    https://MEM.Zone
.LINK
    https://MEMZ.one/WriteLog
.LINK
    https://MEMZ.one/WriteLog-GIT
.LINK
    https://MEMZ.one/WriteLog-ISSUES
.COMPONENT
    Script Utilities
.FUNCTIONALITY
    Message Formatting
#>

    [CmdletBinding()]
    [OutputType([System.String[]])]
    param (
        [Parameter(Mandatory, Position = 0)]
        [AllowNull()]
        [object]$Message,

        [Parameter(Position = 1)]
        [hashtable]$FormatData = @{}
    )

    begin {

        ## Initialize variables
        If ($null -eq $Message) { $Message = @() }
        [int]$LineWidth        = 80
        [string]$Separator     = '=' * $LineWidth
        [string[]]$OutputLines = @()
        $Mode            = if ($FormatData.ContainsKey('Mode'))            { $FormatData['Mode']            } else { 'Default'    }
        $AddEmptyRow     = if ($FormatData.ContainsKey('AddEmptyRow'))     { $FormatData['AddEmptyRow']     } else { 'No'         }
        $Title           = if ($FormatData.ContainsKey('Title'))           { $FormatData['Title']           } else { 'Data Table' }
        $Footer          = if ($FormatData.ContainsKey('Footer'))          { $FormatData['Footer']          } else { $null        }
        $NewHeaders      = if ($FormatData.ContainsKey('NewHeaders'))      { $FormatData['NewHeaders']      } else { $null        }
        $ColumnWidths    = if ($FormatData.ContainsKey('ColumnWidths'))    { $FormatData['ColumnWidths']    } else { @()          }
        $CellPadding     = if ($FormatData.ContainsKey('CellPadding'))     { $FormatData['CellPadding']     } else { 0            }
        $VerticalPadding = if ($FormatData.ContainsKey('VerticalPadding')) { $FormatData['VerticalPadding'] } else { 0            }
        $ShowRowNumbers  = if ($FormatData.ContainsKey('ShowRowNumbers'))  { $FormatData['ShowRowNumbers']  } else { $true        }
    }

    process {
        try {

            ## Format the message based on the specified mode
            switch ($Mode) {

                ## LINE MODE
                'Line' {
                    $OutputLines = @($Separator)
                }

                ## BLOCK MODE
                'Block' {
                    # Add prefix and format the message
                    [string]$Prefix = [char]0x25B6 + ' '
                    [int]$MaxMessageLength = $LineWidth - $Prefix.Length
                    [string]$FormattedMessage = $Prefix + $Message.ToString().Trim()
                    # Truncate message if it exceeds the maximum length
                    if ($FormattedMessage.Length -gt $LineWidth) {
                        $FormattedMessage = $Prefix + $Message.ToString().Trim().Substring(0, $MaxMessageLength - 3) + '...'
                    }
                    # Add separator lines
                    $OutputLines = @($Separator, $FormattedMessage, $Separator)
                }

                ## CENTERED BLOCK MODE
                'CenteredBlock' {
                    # Trim message
                    [string]$CleanMessage = $Message.ToString().Trim()
                    # Truncate message if it exceeds the maximum length
                    if ($CleanMessage.Length -gt ($LineWidth - 4)) {
                        $CleanMessage = $CleanMessage.Substring(0, $LineWidth - 7) + '...'
                    }
                    # Center the message
                    [int]$ContentWidth = $CleanMessage.Length
                    [int]$SidePadding = [math]::Floor(($LineWidth - $ContentWidth) / 2)
                    [string]$CenteredLine = $CleanMessage.PadLeft($ContentWidth + $SidePadding).PadRight($LineWidth)
                    # Add separator lines
                    $OutputLines = @($Separator, $CenteredLine, $Separator)
                }

                ## INLINE HEADER MODE
                'InlineHeader' {
                    # Trim and truncate message
                    [string]$Trimmed = $Message.ToString().Trim()
                    if ($Trimmed.Length -gt 54) { $Trimmed = $Trimmed.Substring(0, 51) + '...' }
                    # Add padding to the message
                    $OutputLines = @("===[ $Trimmed ]===")
                }

                ## INLINE SUBHEADER MODE
                'InlineSubHeader' {
                    # Trim and truncate message
                    [string]$Trimmed = $Message.ToString().Trim()
                    if ($Trimmed.Length -gt 54) { $Trimmed = $Trimmed.Substring(0, 51) + '...' }
                    # Add padding to the message
                    $OutputLines = @("---[ $Trimmed ]---")
                }

                ## TABLE MODE
                'Table' {
                    # Set empty message to a single space if no data is provided
                    if ($null -eq $Message -or @($Message).Count -eq 0) { $Message = @(' ') }
                    # Initialize output (title added later after calculating table width)
                    $OutputLines = @()
                    # Determine headers
                    if ($null -eq $NewHeaders -or @($NewHeaders).Count -eq 0) {
                        $FirstObject = $Message[0]
                        $Headers = @($FirstObject.PSObject.Properties.Name)
                        $UseNewHeaders = $false
                    }
                    else {
                        # Use the keys from NewHeaders as our display headers
                        $Headers = @($NewHeaders.Keys)
                        $UseNewHeaders = $true
                    }
                    # Add row number column if enabled
                    if ($ShowRowNumbers) { $Headers = @('#') + $Headers }
                    # Calculate column widths if not provided
                    if ($null -eq $ColumnWidths -or @($ColumnWidths).Count -eq 0) {
                        $ColumnWidths = @()
                        for ($Counter = 0; $Counter -lt $Headers.Count; $Counter++) {
                            # Get the header width
                            $MaxWidth = $Headers[$Counter].Length
                            # Handle row number column separately
                            if ($ShowRowNumbers -and $Counter -eq 0) {
                                # Width based on max row number length
                                $MaxWidth = [Math]::Max($MaxWidth, @($Message).Count.ToString().Length)
                            }
                            else {
                                # Check each row's value width
                                foreach ($Row in $Message) {
                                    $ActualHeader = $Headers[$Counter]
                                    $Value = if ($UseNewHeaders) { $Row.($NewHeaders[$ActualHeader]) } else { $Row.($ActualHeader) }
                                    if ($null -ne $Value) { $MaxWidth = [Math]::Max($MaxWidth, $Value.ToString().Length) }
                                }
                            }
                            $ColumnWidths += $MaxWidth
                        }
                    }
                    # Create the format string for consistent column alignment
                    $FormatString = '| '
                    for ($Counter = 0; $Counter -lt $Headers.Count; $Counter++) {
                        # Apply padding to column width
                        $PaddedWidth = $ColumnWidths[$Counter] + ($CellPadding * 2)
                        $FormatString += "{$Counter,-$PaddedWidth} | "
                    }
                    # Add the header row
                    $HeaderData = @()
                    for ($Counter = 0; $Counter -lt $Headers.Count; $Counter++) {
                        # Add padding to header text if requested
                        if ($CellPadding -gt 0) { $HeaderData += (' ' * $CellPadding) + $Headers[$Counter] + (' ' * $CellPadding) }
                        else { $HeaderData += $Headers[$Counter] }
                    }
                    $HeaderLine = $FormatString -f $HeaderData
                    # Calculate table width and create centered title
                    [int]$TableWidth = $HeaderLine.TrimEnd().Length
                    [int]$TitlePadding = [Math]::Max(0, [Math]::Floor(($TableWidth - $Title.Length) / 2))
                    $OutputLines += (' ' * $TitlePadding) + $Title
                    $OutputLines += ''
                    # Add vertical padding above the table
                    if ($VerticalPadding -gt 0) { for ($i = 0; $i -lt $VerticalPadding; $i++) { $OutputLines += '' } }
                    $OutputLines += $HeaderLine
                    # Add a separator line
                    $SeparatorParts = @()
                    for ($Counter = 0; $Counter -lt $Headers.Count; $Counter++) {
                        # Create padded separator for each column
                        $SeparatorText = '-' * $ColumnWidths[$Counter]
                        if ($CellPadding -gt 0) { $SeparatorParts += (' ' * $CellPadding) + $SeparatorText + (' ' * $CellPadding) }
                        else { $SeparatorParts += $SeparatorText }
                    }
                    $OutputLines += ($FormatString -f $SeparatorParts)
                    # Add data rows
                    [int]$RowNumber = 0
                    foreach ($Row in $Message) {
                        $RowNumber++
                        $RowData = @()
                        for ($Counter = 0; $Counter -lt $Headers.Count; $Counter++) {
                            # Handle row number column
                            if ($ShowRowNumbers -and $Counter -eq 0) { $FormattedValue = $RowNumber.ToString() }
                            else {
                                # Get the value using the appropriate property name
                                $Value = if ($UseNewHeaders) { $Row.($NewHeaders[$Headers[$Counter]]) } else { $Row.($Headers[$Counter]) }
                                # Format the value
                                $FormattedValue = if ($null -eq $Value) { '' } else { $Value.ToString() }
                            }
                            # Center single-character values (e.g. status indicators) within the column
                            [int]$ColWidth = $ColumnWidths[$Counter]
                            if ($FormattedValue.Length -eq 1 -and $ColWidth -gt 1) {
                                [int]$LeftPad  = [Math]::Floor(($ColWidth - $FormattedValue.Length) / 2)
                                [int]$RightPad = $ColWidth - $FormattedValue.Length - $LeftPad
                                $FormattedValue = (' ' * $LeftPad) + $FormattedValue + (' ' * $RightPad)
                            }
                            # Add padding if requested
                            if ($CellPadding -gt 0) { $RowData += (' ' * $CellPadding) + $FormattedValue + (' ' * $CellPadding) }
                            else { $RowData += $FormattedValue }
                        }
                        $OutputLines += ($FormatString -f $RowData)
                    }
                    # Add vertical padding below the table
                    if ($VerticalPadding -gt 0) { for ($i = 0; $i -lt $VerticalPadding; $i++) { $OutputLines += '' } }
                    # Add footer if specified (divider line, content below)
                    if (-not [string]::IsNullOrEmpty($Footer)) {
                        [string]$LastDataLine = $OutputLines[$OutputLines.Count - 1]
                        [int]$TableWidth = $LastDataLine.TrimEnd().Length
                        $OutputLines += ' ' + ('-' * ($TableWidth - 2))
                        [int]$FooterPadding = [Math]::Max(0, [Math]::Floor(($TableWidth - $Footer.Length) / 2))
                        $OutputLines += (' ' * $FooterPadding) + $Footer
                    }
                }

                ## TIMELINE MODE
                'Timeline' {
                    # Add prefix to the message
                    $OutputLines = @(" - $($Message.ToString())")
                }

                ## TIMELINE HEADER MODE
                'TimelineHeader' {
                    # Add prefix to the message
                    $OutputLines = @(" $($Message.ToString())")
                }

                ## LIST MODE
                'List' {
                    # Format key-value pairs with consistent width
                    $OutputLines = @()
                    if ($Message -is [System.Collections.Specialized.OrderedDictionary] -or $Message -is [hashtable]) {
                        # Calculate max key length for alignment
                        $MaxKeyLength = ($Message.Keys | ForEach-Object { $PSItem.ToString().Length } | Measure-Object -Maximum).Maximum
                        foreach ($Entry in $Message.GetEnumerator()) {
                            $OutputLines += " $($Entry.Key.ToString().PadRight($MaxKeyLength)) : $($Entry.Value)"
                        }
                    }
                    elseif ($Message -is [PSCustomObject]) {
                        # Calculate max property name length for alignment
                        $MaxKeyLength = ($Message.PSObject.Properties.Name | ForEach-Object { $PSItem.Length } | Measure-Object -Maximum).Maximum
                        foreach ($Property in $Message.PSObject.Properties) {
                            $OutputLines += " $($Property.Name.PadRight($MaxKeyLength)) : $($Property.Value)"
                        }
                    }
                    elseif ($Message -is [array]) {
                        foreach ($Item in $Message) { $OutputLines += " $($Item.ToString())" }
                    }
                    else { $OutputLines += " $($Message.ToString())" }
                }

                ## DEFAULT MODE
                Default {
                    # Just return the trimmed message
                    $OutputLines = @($Message.ToString().Trim())
                }
            }

            ## Add spacing if requested
            switch ($AddEmptyRow) {
                'Before'         { $OutputLines = @('') + $OutputLines }
                'After'          { $OutputLines += '' }
                'BeforeAndAfter' { $OutputLines = @('') + $OutputLines + @('') }
            }
        }
        catch {
            Write-Warning -Message "Error in Format-Message: $($PSItem.Exception.Message)"
            $OutputLines = @($Message.ToString().Trim())
        }
        finally {
            Write-Output -InputObject $OutputLines -NoEnumerate
        }
    }
}

#endregion function Format-Message

#region function Initialize-WriteLog
function Initialize-WriteLog {
<#
.SYNOPSIS
    Initializes the MEMZone.WriteLog module state.
.DESCRIPTION
    Configures logging parameters including log file path, console output preference,
    debug message logging, and maximum log file size. Automatically creates the log
    directory and file, and handles size-based rotation.
.PARAMETER LogName
    The base name for the log file (without extension).
.PARAMETER LogPath
    The directory path where the log file will be created.
.PARAMETER LogToConsole
    Whether to output messages to the console. Default: $true.
.PARAMETER LogDebugMessages
    Whether to write debug messages to the log file. Default: $false.
.PARAMETER LogMaxSizeMB
    Maximum log file size in MB before rotation. Default: 5.
.EXAMPLE
    Initialize-WriteLog -LogName 'MyScript' -LogPath 'C:\Logs\MyScript'
.EXAMPLE
    Initialize-WriteLog -LogName 'MyScript' -LogPath 'C:\Logs\MyScript' -LogToConsole $false -LogDebugMessages $true
.INPUTS
    None
.OUTPUTS
    None
.NOTES
    This is an internal script function and should typically not be called directly.
.LINK
    https://MEM.Zone
.LINK
    https://MEMZ.one/WriteLog
.LINK
    https://MEMZ.one/WriteLog-GIT
.LINK
    https://MEMZ.one/WriteLog-ISSUES
.COMPONENT
    Script Logging
.FUNCTIONALITY
    Log Initialization
#>

    [CmdletBinding()]
    [OutputType([void])]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string]$LogName,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [string]$LogPath,

        [Parameter(Position = 2)]
        [bool]$LogToConsole = $true,

        [Parameter(Position = 3)]
        [bool]$LogDebugMessages = $false,

        [Parameter(Position = 4)]
        [ValidateRange(1, 100)]
        [int]$LogMaxSizeMB = 5
    )

    process {

        ## Set module state variables
        $Script:LogName           = $LogName
        $Script:LogPath           = $LogPath
        $Script:LogFullName       = [System.IO.Path]::Combine($LogPath, "$LogName.log")
        $Script:LogToConsole      = $LogToConsole
        $Script:LogDebugMessages  = $LogDebugMessages
        $Script:LogMaxSizeMB      = $LogMaxSizeMB
        $Script:LogBuffer         = [System.Collections.ArrayList]::new()

        ## Create log directory and file, rotate if oversized
        Test-LogFile -LogFile $Script:LogFullName -MaxSizeMB $Script:LogMaxSizeMB
    }
}

#endregion function Initialize-WriteLog

#region function Invoke-WithAnimation
function Invoke-WithAnimation {
<#
.SYNOPSIS
    Executes a scriptblock while displaying an animated progress indicator.
.DESCRIPTION
    Runs the specified scriptblock and displays an animated spinner/progress indicator on the console.
    When the operation completes, shows a success or failure indicator. Also logs the operation to the
    log file via Write-Log.
 
    Execution Model:
    The scriptblock runs on the CALLING thread, in the caller's session state, so it resolves the
    caller's variables, functions and modules exactly as inline code would. Only the animation itself
    is pushed to a background runspace, where it touches nothing but the console and its own state.
 
    This split matters. A scriptblock keeps the SessionState it was created in, so running it in a
    background runspace would execute it in the caller's session state from a second thread - two
    threads sharing one scope stack, which corrupts variable lookups in whichever one loses the race.
    Animating in the background instead of the work keeps the shared session state single-threaded.
 
    The scriptblock should not write console output itself - the animation stops as soon as it
    detects another writer, but the in-place indicator may then finish on a different line.
.PARAMETER Message
    The message to display alongside the animation.
.PARAMETER ScriptBlock
    The scriptblock to execute while showing the animation.
.PARAMETER Variables
    Optional hashtable of variables to make available to the scriptblock (injected via
    InvokeWithContext, so it works from both script and module contexts). The scriptblock already
    sees the caller's variables, so this is only needed to pass values it would not otherwise
    resolve, or to state the inputs explicitly for readability.
.PARAMETER Animation
    The animation style to use. Valid values: Spinner, Dots, Braille, Bounce, Box.
    Default: Dots
.PARAMETER SuccessIndicator
    Character to show on success. Default: checkmark
.PARAMETER FailureIndicator
    Character to show on failure. Default: X
.PARAMETER RefreshRate
    Milliseconds between animation frames. Default: 100
.EXAMPLE
    Invoke-WithAnimation -Message 'Creating application' -ScriptBlock { New-CMApplication -Name 'Test' }
.EXAMPLE
    Invoke-WithAnimation -Message 'Copying file' -Variables @{ Source = $src; Dest = $dst } -ScriptBlock {
        Copy-Item -Path $Source -Destination $Dest
    }
.INPUTS
    None
.OUTPUTS
    Returns the output of the ScriptBlock.
.NOTES
    Session-bound operations (Import-Module, New-PSDrive, Set-Location) work inside the scriptblock
    because it runs on the calling thread in the caller's session state.
.LINK
    https://MEM.Zone
.LINK
    https://MEMZ.one/WriteLog
.LINK
    https://MEMZ.one/WriteLog-GIT
.LINK
    https://MEMZ.one/WriteLog-ISSUES
.COMPONENT
    Script Utilities
.FUNCTIONALITY
    Animated Progress
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$Message,

        [Parameter(Mandatory = $true, Position = 1)]
        [scriptblock]$ScriptBlock,

        [Parameter()]
        [hashtable]$Variables,

        [Parameter(Position = 2)]
        [ValidateSet('Spinner', 'Dots', 'Braille', 'Bounce', 'Box')]
        [string]$Animation = 'Dots',

        [Parameter()]
        [string]$SuccessIndicator = [char]0x2713,

        [Parameter()]
        [string]$FailureIndicator = [char]0x2717,

        [Parameter()]
        [int]$RefreshRate = 100
    )

    begin {

        ## Define animation frames
        $AnimationFrames = @{
            Spinner = @('|', '/', '-', '\')
            Dots    = @('. ', '.. ', '...', ' ..', ' .', ' ')
            Braille = @([char]0x280B, [char]0x2819, [char]0x2839, [char]0x2838, [char]0x283C, [char]0x2834, [char]0x2826, [char]0x2827, [char]0x2807, [char]0x280F)
            Bounce  = @([char]0x2801, [char]0x2802, [char]0x2804, [char]0x2802)
            Box     = @([char]0x2596, [char]0x2598, [char]0x259D, [char]0x2597)
        }

        $Frames = $AnimationFrames[$Animation]
        $Success = $true
        $ErrorRecord = $null
        $Result = $null
    }

    process {

        ## Make the requested variables available to the scriptblock via InvokeWithContext, which
        ## injects them into the scriptblock's own session state. Unlike Set-Variable -Scope Local,
        ## this also works from inside a module - a module function's local scope is invisible to a
        ## scriptblock bound to the caller's session state.
        [System.Collections.Generic.List[psvariable]]$VariableList = [System.Collections.Generic.List[psvariable]]::new()
        if ($Variables -and $Variables.Count -gt 0) {
            foreach ($Entry in $Variables.GetEnumerator()) {
                $VariableList.Add([psvariable]::new($Entry.Key, $Entry.Value))
            }
        }

        ## If console output is disabled or the host cannot position the cursor, run without animation
        if (-not $Script:LogToConsole -or -not (Test-ConsoleInteractive)) {
            Write-Log -Message "$Message..." -Console:$false
            try {
                $Result = if ($VariableList.Count -gt 0) { $ScriptBlock.InvokeWithContext($null, $VariableList, $null) } else { & $ScriptBlock }
            }
            catch {
                ## Log the outcome so non-interactive runs keep failure records too, then rethrow
                Write-Log -Message "$Message - Failed: $($PSItem.Exception.Message)" -Severity 'Error' -Console:$false
                throw
            }
            return $Result
        }

        ## Write initial message without newline and save the cursor position for the animation
        Write-Host " - $Message " -NoNewline -ForegroundColor 'Yellow'
        $AnimationLeft = [Console]::CursorLeft
        $AnimationTop = [Console]::CursorTop

        ## Start the animation in a background runspace. It is handed only value types and the frame
        ## list, so it never reaches into the caller's session state.
        $StopSignal = [System.Threading.ManualResetEventSlim]::new($false)
        $Runspace = [runspacefactory]::CreateRunspace()
        $Runspace.Open()
        $Runspace.SessionStateProxy.SetVariable('Frames', $Frames)
        $Runspace.SessionStateProxy.SetVariable('AnimationLeft', $AnimationLeft)
        $Runspace.SessionStateProxy.SetVariable('AnimationTop', $AnimationTop)
        $Runspace.SessionStateProxy.SetVariable('RefreshRate', $RefreshRate)
        $Runspace.SessionStateProxy.SetVariable('StopSignal', $StopSignal)

        $PowerShell = [powershell]::Create()
        $PowerShell.Runspace = $Runspace
        $null = $PowerShell.AddScript({
            $FrameIndex = 0
            $ExpectedLeft = -1
            $ExpectedTop = -1
            while (-not $StopSignal.IsSet) {
                try {

                    ## If another writer moved the cursor since the last frame, the saved coordinates
                    ## are stale - stop animating instead of painting frames over foreign output
                    if ($ExpectedTop -ge 0 -and ([Console]::CursorTop -ne $ExpectedTop -or [Console]::CursorLeft -ne $ExpectedLeft)) { break }

                    ## Save and restore the foreground color instead of resetting it, so a color set
                    ## by the main thread between frames is not clobbered
                    $PreviousColor = [Console]::ForegroundColor
                    [Console]::SetCursorPosition($AnimationLeft, $AnimationTop)
                    [Console]::ForegroundColor = 'Cyan'
                    [Console]::Write($Frames[$FrameIndex])
                    [Console]::ForegroundColor = $PreviousColor
                    $ExpectedLeft = [Console]::CursorLeft
                    $ExpectedTop = [Console]::CursorTop
                }
                catch {
                    ## The console went away (resized, redirected, closed) - stop animating quietly
                    break
                }
                $FrameIndex = ($FrameIndex + 1) % $Frames.Count
                [System.Threading.Thread]::Sleep($RefreshRate)
            }
        })
        $AsyncResult = $PowerShell.BeginInvoke()

        ## Suppress progress bars for the duration. Cmdlets like Invoke-WebRequest render progress
        ## through the host, which repaints and scrolls the console and would move the animation off
        ## its saved cursor position. Set at global scope (and restored in finally) so it also
        ## reaches the scriptblock's own scope chain when this function runs from inside a module.
        $PreviousProgressPreference = $global:ProgressPreference
        $global:ProgressPreference = 'SilentlyContinue'

        ## Run the actual work on THIS thread, in the caller's session state. Nothing is marshalled
        ## across a runspace boundary, so results, errors and variable lookups all behave normally.
        try {
            $Result = if ($VariableList.Count -gt 0) { $ScriptBlock.InvokeWithContext($null, $VariableList, $null) } else { & $ScriptBlock }
        }
        catch {
            $Success = $false
            $ErrorRecord = $PSItem

            ## InvokeWithContext surfaces scriptblock errors wrapped in a MethodInvocationException -
            ## unwrap so the caller gets the original ErrorRecord back
            if ($PSItem.Exception -is [System.Management.Automation.MethodInvocationException] -and $PSItem.Exception.InnerException -is [System.Management.Automation.IContainsErrorRecord]) {
                $ErrorRecord = $PSItem.Exception.InnerException.ErrorRecord
            }
        }
        finally {

            ## Restore progress rendering and stop the animation
            $global:ProgressPreference = $PreviousProgressPreference
            $StopSignal.Set()
            try { $null = $PowerShell.EndInvoke($AsyncResult) } catch { Write-Debug -Message "Animation runspace cleanup: $($PSItem.Exception.Message)" }
            $PowerShell.Dispose()
            $Runspace.Close()
            $Runspace.Dispose()
            $StopSignal.Dispose()
        }

        ## Show the completion indicator in place of the last animation frame. If the scriptblock
        ## wrote console output (or scrolled the buffer), the saved coordinates are stale - finish
        ## on the current line instead of overpainting whatever is there now.
        try {
            if ([Console]::CursorTop -eq $AnimationTop -and [Console]::CursorLeft -ge $AnimationLeft) {
                [Console]::SetCursorPosition($AnimationLeft, $AnimationTop)
                [Console]::Write(' ')
                [Console]::SetCursorPosition($AnimationLeft, $AnimationTop)
            }
            if ($Success) {
                [Console]::ForegroundColor = 'Green'
                [Console]::WriteLine($SuccessIndicator)
            }
            else {
                [Console]::ForegroundColor = 'Red'
                [Console]::WriteLine($FailureIndicator)
            }
            [Console]::ResetColor()
        }
        catch {
            Write-Host ''
        }

        ## Log the outcome
        if ($Success) {
            Write-Log -Message "$Message" -Console:$false
        }
        else {
            Write-Log -Message "$Message - Failed: $($ErrorRecord.Exception.Message)" -Severity 'Error' -Console:$false
        }

        ## Rethrow the original ErrorRecord so the caller keeps the exception type and stack
        if (-not $Success) { throw $ErrorRecord }

        return $Result
    }
}

#endregion function Invoke-WithAnimation

#region function Invoke-WithStatus
function Invoke-WithStatus {
<#
.SYNOPSIS
    Executes a scriptblock synchronously with status indicator (no animation).
.DESCRIPTION
    Runs the specified scriptblock synchronously and displays a success or failure indicator.
    Like Invoke-WithAnimation, the scriptblock runs on the calling thread in the caller's
    session state - the only difference is that no animation thread is started. Use it for
    operations that are too quick to animate or where a spinner is unwanted.
.PARAMETER Message
    The message to display alongside the status.
.PARAMETER ScriptBlock
    The scriptblock to execute.
.PARAMETER SuccessIndicator
    Character to show on success. Default: checkmark
.PARAMETER FailureIndicator
    Character to show on failure. Default: X
.EXAMPLE
    Invoke-WithStatus -Message 'Loading ConfigMgr module' -ScriptBlock { Import-Module ... }
.INPUTS
    None
.OUTPUTS
    Returns the output of the ScriptBlock.
.NOTES
    Both this function and Invoke-WithAnimation run the scriptblock in the main session, so
    session-bound operations (Import-Module, New-PSDrive, Set-Location) work in either.
    Prefer this one for near-instant operations where animation frames would only flicker.
.LINK
    https://MEM.Zone
.LINK
    https://MEMZ.one/WriteLog
.LINK
    https://MEMZ.one/WriteLog-GIT
.LINK
    https://MEMZ.one/WriteLog-ISSUES
.COMPONENT
    Script Utilities
.FUNCTIONALITY
    Status Indicator
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$Message,

        [Parameter(Mandatory = $true, Position = 1)]
        [scriptblock]$ScriptBlock,

        [Parameter()]
        [string]$SuccessIndicator = [char]0x2713,

        [Parameter()]
        [string]$FailureIndicator = [char]0x2717
    )

    process {

        ## If console output is disabled or the host has no console, just run without status display
        if (-not $Script:LogToConsole -or -not (Test-ConsoleInteractive)) {
            Write-Log -Message "$Message" -Console:$false
            try {
                $Result = & $ScriptBlock
                return $Result
            }
            catch {
                ## Log the outcome so non-interactive runs keep failure records too, then rethrow
                Write-Log -Message "$Message - Failed: $($PSItem.Exception.Message)" -Severity 'Error' -Console:$false
                throw
            }
        }

        ## Write message without newline
        [Console]::ForegroundColor = 'Yellow'
        [Console]::Write(" - $Message ")
        [Console]::ResetColor()

        ## Execute scriptblock synchronously in current session
        $Success = $true
        $ErrorRecord = $null
        $Result = $null

        try {
            $Result = & $ScriptBlock
        }
        catch {
            $Success = $false
            $ErrorRecord = $PSItem
        }

        ## Show result indicator
        if ($Success) {
            [Console]::ForegroundColor = 'Green'
            [Console]::WriteLine($SuccessIndicator)
            [Console]::ResetColor()
            Write-Log -Message "$Message" -Console:$false
        }
        else {
            [Console]::ForegroundColor = 'Red'
            [Console]::WriteLine($FailureIndicator)
            [Console]::ResetColor()
            Write-Log -Message "$Message - Failed: $($ErrorRecord.Exception.Message)" -Severity 'Error' -Console:$false

            ## Rethrow the original ErrorRecord so the caller keeps the exception type and stack
            throw $ErrorRecord
        }

        return $Result
    }
}

#endregion function Invoke-WithStatus

#region function Test-LogFile
function Test-LogFile {
<#
.SYNOPSIS
    Checks if the log path exists and if the log file exceeds the maximum specified size.
.DESCRIPTION
    Checks if the log path exists and creates the folder and file if needed.
    Checks if the log file exceeds the maximum specified size and rotates it by
    renaming the current file with a timestamp suffix (e.g., Application_20250114-153000.log).
    Rotated archives are pruned, keeping only the newest MaxArchives files.
.PARAMETER LogFile
    Specifies the path to the log file.
.PARAMETER MaxSizeMB
    Specifies the maximum size in MB before the log file is rotated.
.PARAMETER MaxArchives
    Specifies how many rotated archive files to keep. Older archives are deleted. Default: 10.
.EXAMPLE
    Test-LogFile -LogFile 'C:\Logs\Application.log' -MaxSizeMB 5
.INPUTS
    None
.OUTPUTS
    None
.NOTES
    This is an internal script function and should typically not be called directly.
.LINK
    https://MEM.Zone
.LINK
    https://MEMZ.one/WriteLog
.LINK
    https://MEMZ.one/WriteLog-GIT
.LINK
    https://MEMZ.one/WriteLog-ISSUES
.COMPONENT
    Script Logging
.FUNCTIONALITY
    Log File Management
#>

    [CmdletBinding()]
    [OutputType([void])]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string]$LogFile,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateRange(1, 100)]
        [int]$MaxSizeMB,

        [Parameter(Mandatory = $false, Position = 2)]
        [ValidateRange(1, 100)]
        [int]$MaxArchives = 10
    )

    process {
        try {

            ## Create log folder if it doesn't exist
            $LogPath = [System.IO.Path]::GetDirectoryName($LogFile)
            [bool]$IsLogFolderPresent = Test-Path -Path $LogPath -PathType Container
            if (-not $IsLogFolderPresent) {
                try {
                    $null = New-Item -Path $LogPath -ItemType Directory -Force -ErrorAction Stop
                }
                catch {
                    $Script:LogPath     = [System.IO.Path]::GetTempPath()
                    $Script:LogFullName = [System.IO.Path]::Combine($Script:LogPath, "$Script:LogName.log")
                    Write-Warning -Message "Failed to create log folder: $($PSItem.Exception.Message). Using temp directory instead."
                }
            }

            ## Create log file if it doesn't exist
            [bool]$IsLogFilePresent = Test-Path -Path $LogFile -PathType Leaf
            if (-not $IsLogFilePresent) {
                try {
                    $null = New-Item -Path $LogFile -ItemType File -Force -ErrorAction Stop
                }
                catch {
                    Write-Warning -Message "Failed to create log file: $($PSItem.Exception.Message)"
                }
            }

            ## Get log file information
            [System.IO.FileInfo]$LogFileInfo = Get-Item -Path $LogFile -ErrorAction Stop

            ## Convert bytes to MB
            [double]$LogFileSizeMB = $LogFileInfo.Length / 1MB

            ## If log file exceeds maximum size, rotate it by renaming with a timestamp
            if ($LogFileSizeMB -ge $MaxSizeMB) {
                Write-Verbose -Message "Log file size [$($LogFileSizeMB.ToString('0.00')) MB] exceeds maximum size [$MaxSizeMB MB]. Rotating log file..."
                [string]$FileTimestamp  = Get-Date -Format 'yyyyMMdd-HHmmss'
                [string]$BaseName       = [System.IO.Path]::GetFileNameWithoutExtension($LogFile)
                [string]$Extension      = [System.IO.Path]::GetExtension($LogFile)
                [string]$ArchiveName    = "${BaseName}_${FileTimestamp}${Extension}"
                [string]$ArchivePath    = [System.IO.Path]::Combine($LogPath, $ArchiveName)
                Rename-Item -Path $LogFile -NewName $ArchivePath -Force -ErrorAction Stop
                [string]$CurrentTimestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                [string]$RotationMessage = "$CurrentTimestamp [Information] Log file rotated. Previous log archived to [$ArchiveName]"
                $null = New-Item -Path $LogFile -ItemType File -Force -ErrorAction Stop
                Set-Content -Path $LogFile -Value $RotationMessage -Encoding UTF8 -Force -ErrorAction Stop

                ## Prune old archives so rotation cannot grow unbounded - keep the newest $MaxArchives
                [array]$Archives = @(Get-ChildItem -Path $LogPath -Filter "${BaseName}_*${Extension}" -File -ErrorAction SilentlyContinue | Sort-Object -Property LastWriteTime -Descending)
                if ($Archives.Count -gt $MaxArchives) {
                    $Archives | Select-Object -Skip $MaxArchives | Remove-Item -Force -ErrorAction SilentlyContinue
                }
            }
        }
        catch {
            Write-Warning -Message "Error checking log file size: $($PSItem.Exception.Message)"
        }
    }
}

#endregion function Test-LogFile

#region function Write-FunctionHeaderOrFooter
function Write-FunctionHeaderOrFooter {
<#
.SYNOPSIS
    Write the function header or footer to the log upon first entering or exiting a function.
.DESCRIPTION
    Write the "Function Start" message, the bound parameters the function was invoked with,
    or the "Function End" message when entering or exiting a function.
    Messages are debug messages so will only be logged if LogDebugMessage option is enabled.
.PARAMETER CmdletName
    The name of the function this function is invoked from.
.PARAMETER CmdletBoundParameters
    The bound parameters of the function this function is invoked from.
.PARAMETER Header
    Write the function header.
.PARAMETER Footer
    Write the function footer.
.EXAMPLE
    Write-FunctionHeaderOrFooter -CmdletName ${CmdletName} -CmdletBoundParameters $PSBoundParameters -Header
.EXAMPLE
    Write-FunctionHeaderOrFooter -CmdletName ${CmdletName} -Footer
.INPUTS
    None
.OUTPUTS
    None
.NOTES
    This is an internal script function and should typically not be called directly.
.LINK
    https://MEM.Zone
.LINK
    https://MEMZ.one/WriteLog
.LINK
    https://MEMZ.one/WriteLog-GIT
.LINK
    https://MEMZ.one/WriteLog-ISSUES
.COMPONENT
    Script Utilities
.FUNCTIONALITY
    Function Tracing
#>

    [CmdletBinding()]
    [OutputType([void])]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string]$CmdletName,

        [Parameter(Mandatory = $true, ParameterSetName = 'Header')]
        [AllowEmptyCollection()]
        [hashtable]$CmdletBoundParameters,

        [Parameter(Mandatory = $true, ParameterSetName = 'Header')]
        [switch]$Header,

        [Parameter(Mandatory = $true, ParameterSetName = 'Footer')]
        [switch]$Footer
    )

    process {
        if ($Header) {
            Write-Log -Message 'Function Start' -Source $CmdletName -DebugMessage

            ## Get the parameters that the calling function was invoked with
            if ($CmdletBoundParameters.Count -gt 0) {

                # Use StringBuilder for string concatenation
                $ParamStringBuilder = [System.Text.StringBuilder]::new()

                # Pre-calculate max key length for proper alignment
                [int]$MaxKeyLength = 0
                foreach ($Key in $CmdletBoundParameters.Keys) {
                    # Adding 4 for '[-' and ']'
                    [int]$KeyLength = $Key.Length + 4
                    if ($KeyLength -gt $MaxKeyLength) { $MaxKeyLength = $KeyLength }
                }

                # Add padding for readability
                $MaxKeyLength += 2

                # Process each parameter
                foreach ($Param in $CmdletBoundParameters.GetEnumerator()) {
                    [string]$ParameterName = "[-$($Param.Key)]"

                    # Handle different value types securely and informatively
                    [string]$ParameterValue = Switch ($Param.Value) {
                        { $null -eq $PSItem } { '<null>' }
                        { $PSItem -is [System.Security.SecureString] } { '<SecureString>' }
                        { $PSItem -is [System.Management.Automation.PSCredential] } { "<PSCredential: $($PSItem.UserName)>" }
                        { $PSItem -is [array] } {
                            if ($PSItem.Count -eq 0) { '@()' }
                            elseif ($PSItem.Count -gt 10) { "@($($PSItem.Count) items)" }
                            else { "@($($PSItem -join ', '))" }
                        }
                        { $PSItem -is [hashtable] } {
                            if ($PSItem.Count -eq 0) { '@{}' }
                            elseif ($PSItem.Count -gt 10) { "@{$($PSItem.Count) keys}" }
                            else { "@{$($PSItem.Keys -join ', ')}" }
                        }
                        { $PSItem -is [bool] } { $PSItem.ToString() }
                        { $PSItem -is [switch] } { $PSItem.IsPresent.ToString() }
                        default {
                            [string]$StringValue = $PSItem.ToString()
                            if ($StringValue.Length -gt 100) { "$($StringValue.Substring(0, 97))..." }
                            else { $StringValue }
                        }
                    }
                    # Format with proper alignment
                    [void]$ParamStringBuilder.AppendLine(("{0,-$MaxKeyLength} {1}" -f $ParameterName, $ParameterValue))
                }

                # Remove trailing newline and write to log
                [string]$FormattedParameters = $ParamStringBuilder.ToString().TrimEnd()
                Write-Log -Message "Function invoked with bound parameter(s):`n$FormattedParameters" -Source $CmdletName -DebugMessage
            }
            else {
                Write-Log -Message 'Function invoked without any bound parameters.' -Source $CmdletName -DebugMessage
            }
        }
        elseif ($Footer) {
            Write-Log -Message 'Function End' -Source $CmdletName -DebugMessage
        }
    }
}

#endregion function Write-FunctionHeaderOrFooter

#region function Write-Log
function Write-Log {
<#
.SYNOPSIS
    Writes a message to the log file and/or console.
.DESCRIPTION
    Writes a timestamped log entry to the internal buffer and displays it with optional formatting.
    By default uses Write-Verbose. Use -Console to use Write-Host with color support instead.
.PARAMETER Severity
    Specifies the severity level of the message. Available options: Information, Warning, Debug, Error.
.PARAMETER Message
    The log message to write. Can be a string, array of strings, or object data for table formatting.
.PARAMETER Source
    The source of the message. This is used to identify the source of the message in the log file.
.PARAMETER FormatOptions
    Optional hashtable of formatting parameters:
        - Mode : Output style (Block, CenteredBlock, Line, InlineHeader, InlineSubHeader, Timeline, TimelineHeader, List, Table, Default)
        - AddEmptyRow : Add blank lines (No, Before, After, BeforeAndAfter)
        - Title : For table mode, specifies the table title
        - NewHeaders : Maps display headers to property names
        - ColumnWidths: Optional custom column widths
        - CellPadding : Amount of horizontal padding to add within cells. Default is: 0.
        - VerticalPadding: Amount of padding to add above and below the table. Default is: 0.
        - ForegroundColor : Color for Write-Host output (ignored in verbose mode). Default: Yellow
        - SeparatorColor : Color for separators in Write-Host output (ignored in verbose mode). Default: DarkGray
.PARAMETER Console
    If specified, uses Write-Host with color support instead of Write-Verbose for output.
.PARAMETER DebugMessage
    Specifies that the message is a debug message. Debug messages only get logged if -LogDebugMessage is set to $true.
.PARAMETER LogDebugMessages
    Whether to write debug messages to the log file (default: value of module LogDebugMessages setting).
.PARAMETER SkipLogFormatting
    Whether to skip formatting for the log message.
.EXAMPLE
    Write-Log -Message 'Successfully Installed'
.EXAMPLE
    Write-Log -Message $Message -FormatOptions @{
        Mode = 'Table'
        Title = 'MEM.Zone'
        NewHeaders = [ordered]@{
            'Location' = 'City'
            'Status' = 'OperationStatus'
            'Compliance' = 'ComplianceStatus'
        }
        AddEmptyRow = 'BeforeAndAfter'
    }
.EXAMPLE
    Write-Log -Message 'IMPORT APPLICATION' -FormatOptions @{ Mode = 'InlineHeader'; ForegroundColor = 'Cyan' }
.INPUTS
    System.Object
.OUTPUTS
    None
.NOTES
    This is an internal script function and should typically not be called directly.
.LINK
    https://MEM.Zone
.LINK
    https://MEMZ.one/WriteLog
.LINK
    https://MEMZ.one/WriteLog-GIT
.LINK
    https://MEMZ.one/WriteLog-ISSUES
.COMPONENT
    Script Logging
.FUNCTIONALITY
    Log Message
#>

    [CmdletBinding()]
    [OutputType([void])]
    param (
        [Parameter(Position = 0)]
        [Alias('Level')]
        [ValidateSet('Information', 'Warning', 'Debug', 'Error')]
        [string]$Severity = 'Information',

        [Parameter(Mandatory = $true, Position = 1)]
        [Alias('LogMessage')]
        [AllowNull()]
        [AllowEmptyString()]
        [object]$Message,

        [Parameter(Position = 2)]
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Source,

        [Parameter(Position = 3)]
        [hashtable]$FormatOptions,

        [Parameter()]
        [bool]$Console = $Script:LogToConsole,

        [Parameter()]
        [switch]$DebugMessage,

        [Parameter()]
        [switch]$LogDebugMessages = $Script:LogDebugMessages,

        [Parameter()]
        [switch]$SkipLogFormatting
    )

    begin {

        ## Initialize variables
        [string]$Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
        [string[]]$MessageLines = @()
    }

    process {
        try {

            ## Format the message or use raw message if formatting is skipped
            if ($SkipLogFormatting -or $DebugMessage) {
                if ($null -eq $Message) {
                    $MessageLines = @('(No message)')
                }
                elseif ($Message -is [array] -and $Message[0] -is [string]) {
                    $MessageLines = @($Message | ForEach-Object { if ($null -eq $PSItem) { '(null)' } else { $PSItem.ToString() } })
                }
                else {
                    try {
                        $MessageLines = @($Message.ToString())
                    }
                    catch {
                        $MessageLines = @('(Message cannot be displayed)')
                    }
                }
            }
            else {

                # Initialize FormatOptions if not provided
                if ($null -eq $FormatOptions) {
                    $FormatOptions = @{ Mode = 'Timeline' }
                }

                # Ensure Mode is set
                if (-not $FormatOptions.ContainsKey('Mode')) {
                    $FormatOptions['Mode'] = 'Timeline'
                }

                # Use Format-Message for all formatting
                $MessageLines = Format-Message -Message $Message -FormatData $FormatOptions
            }

            ## Ensure we have at least one line and MessageLines is not null
            if ($null -eq $MessageLines -or $MessageLines.Count -eq 0) {
                $MessageLines = @('(Empty message)')
            }

            ## Extract color options (only used in Console mode)
            [string]$ForegroundColor = if ($FormatOptions -and $FormatOptions.ContainsKey('ForegroundColor')) { $FormatOptions['ForegroundColor'] } else { 'Yellow' }
            [string]$SeparatorColor  = if ($FormatOptions -and $FormatOptions.ContainsKey('SeparatorColor'))  { $FormatOptions['SeparatorColor']  } else { 'DarkGray' }

            ## Write to console and log file
            foreach ($MessageLine in $MessageLines) {
                if (-not $DebugMessage) {
                    switch ($Severity) {
                        'Information' {
                            if ($Console) {
                                # Use [Console]::WriteLine for proper Unicode support
                                if ($MessageLine -match '^[=\-]+$') {
                                    [Console]::ForegroundColor = $SeparatorColor
                                }
                                else {
                                    [Console]::ForegroundColor = $ForegroundColor
                                }
                                [Console]::WriteLine($MessageLine)
                                [Console]::ResetColor()
                            }
                            else {
                                Write-Verbose -Message $MessageLine
                            }
                        }
                        'Warning' { Write-Warning -Message $MessageLine }
                        'Error'   { Write-Error   -Message " $MessageLine" -ErrorAction Continue }
                    }
                }
                else {
                    if ($Source) {
                        Write-Debug -Message "[$Source] $MessageLine"
                    }
                    else {
                        Write-Debug -Message " $MessageLine"
                    }
                }

                # Skip debug logging if disabled
                if ($DebugMessage -and -not $LogDebugMessages) { continue }

                # Add timestamp and severity to the message and add to the log buffer
                if ($DebugMessage -and $Source) {
                    [string]$LogEntry = "$Timestamp [$Source] [$Severity] $MessageLine"
                }
                else {
                    [string]$LogEntry = "$Timestamp [$Severity] $MessageLine"
                }

                # Ensure LogBuffer exists before trying to add to it
                if ($null -ne $Script:LogBuffer) {
                    $null = $Script:LogBuffer.Add($LogEntry)
                }
            }

            ## Write the log buffer to the log file if it exceeds the threshold or if the severity is Error
            if ($null -ne $Script:LogBuffer -and ($Script:LogBuffer.Count -ge 20 -or $Severity -eq 'Error')) { Write-LogBuffer }
        }
        catch {
            Write-Warning -Message "Write-Log failed: $($PSItem.Exception.Message)"
        }
    }
}

#endregion function Write-Log

#region function Write-LogBuffer
function Write-LogBuffer {
<#
.SYNOPSIS
    Writes the log buffer to the log file.
.DESCRIPTION
    Writes the log buffer to the log file and clears the buffer.
.INPUTS
    None
.OUTPUTS
    None
.EXAMPLE
    Write-LogBuffer
.NOTES
    This is an internal script function and should typically not be called directly.
.LINK
    https://MEM.Zone
.LINK
    https://MEMZ.one/WriteLog
.LINK
    https://MEMZ.one/WriteLog-GIT
.LINK
    https://MEMZ.one/WriteLog-ISSUES
.COMPONENT
    Script Logging
.FUNCTIONALITY
    Log Buffer Management
#>

    [CmdletBinding()]
    [OutputType([void])]
    param()

    process {
        if ($null -ne $Script:LogBuffer -and $Script:LogBuffer.Count -gt 0) {
            try {

                ## Convert ArrayList to string array for Add-Content
                [string[]]$LogEntries = $Script:LogBuffer.ToArray()

                ## Append to log file
                if (-not [string]::IsNullOrEmpty($Script:LogFullName) -and (Test-Path -Path (Split-Path -Path $Script:LogFullName -Parent) -PathType Container)) {
                    Add-Content -Path $Script:LogFullName -Value $LogEntries -Encoding UTF8 -ErrorAction Stop
                }

                ## Clear buffer
                $Script:LogBuffer.Clear()
            }
            catch {
                Write-Warning -Message "Failed to write to log file: $($PSItem.Exception.Message)"
            }
        }
    }
}

#endregion function Write-LogBuffer

#endregion FunctionListings
##*=============================================
##* END FUNCTION LISTINGS
##*=============================================

##*=============================================
##* MODULE CLEANUP
##*=============================================
#region Module Cleanup

$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
    if ($null -ne $Script:LogBuffer -and $Script:LogBuffer.Count -gt 0) {
        Write-LogBuffer
    }
}

#endregion Module Cleanup
##*=============================================
##* END MODULE CLEANUP
##*=============================================