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
    Module Version: 1.0.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 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() }
                            }
                            # 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 in a background runspace 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.
 
    Cross-Runspace Execution Model:
    The scriptblock retains its creation SessionState, so when invoked via & in the
    background runspace, PowerShell executes it in the ORIGINAL session state. This
    gives the scriptblock access to the caller's variables and imported modules
    (e.g., ConfigMgr cmdlets). For explicit variable passing, use the -Variables
    parameter to inject variables directly into the runspace.
.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 explicitly inject into the background runspace.
    Use this when session state binding is insufficient or for improved clarity.
.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
    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
    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]
        $FrameIndex = 0
        $Success = $true
        $ErrorMessage = $null
        $Result = $null
    }

    process {

        ## If console output disabled, just run without animation
        if (-not $Script:LogToConsole) {
            Write-Log -Message "$Message..." -Console:$false
            try {
                $Result = & $ScriptBlock
            }
            catch {
                $Success = $false
                $ErrorMessage = $PSItem.Exception.Message
                throw
            }
            return $Result
        }

        ## Write initial message without newline
        Write-Host " - $Message " -NoNewline -ForegroundColor 'Yellow'

        ## Save cursor position for animation
        $AnimationLeft = [Console]::CursorLeft
        $AnimationTop = [Console]::CursorTop
        Write-Host "$($Frames[0])" -NoNewline -ForegroundColor 'Cyan'
        $FrameIndex = 1

        ## Start background runspace
        $Runspace = [runspacefactory]::CreateRunspace()
        $Runspace.Open()
        $Runspace.SessionStateProxy.SetVariable('ScriptBlock', $ScriptBlock)

        if ($Variables -and $Variables.Count -gt 0) {
            foreach ($Entry in $Variables.GetEnumerator()) {
                $Runspace.SessionStateProxy.SetVariable($Entry.Key, $Entry.Value)
            }
        }

        $PowerShell = [powershell]::Create()
        $PowerShell.Runspace = $Runspace
        $null = $PowerShell.AddScript({
            try {
                $Result = & $ScriptBlock
                @{ Success = $true; Result = $Result; Error = $null }
            }
            catch {
                @{ Success = $false; Result = $null; Error = $PSItem.Exception.Message }
            }
        })

        ## Use input/output buffers to capture streams and prevent console interference
        $InputBuffer = [System.Management.Automation.PSDataCollection[PSObject]]::new()
        $OutputBuffer = [System.Management.Automation.PSDataCollection[PSObject]]::new()
        $AsyncResult = $PowerShell.BeginInvoke($InputBuffer, $OutputBuffer)

        ## Animate while waiting for completion - use .NET methods to avoid cmdlet resolution issues
        try {
            while (-not $AsyncResult.IsCompleted) {
                [Console]::SetCursorPosition($AnimationLeft, $AnimationTop)
                [Console]::ForegroundColor = 'Cyan'
                [Console]::Write($Frames[$FrameIndex])
                [Console]::ResetColor()
                $FrameIndex = ($FrameIndex + 1) % $Frames.Count
                [System.Threading.Thread]::Sleep($RefreshRate)
            }

            ## Get the result from output buffer
            $null = $PowerShell.EndInvoke($AsyncResult)
            if ($OutputBuffer -and $OutputBuffer.Count -gt 0) {
                $Success = $OutputBuffer[0].Success
                $Result = $OutputBuffer[0].Result
                $ErrorMessage = $OutputBuffer[0].Error
            }

            ## Show completion indicator BEFORE runspace disposal - use .NET methods
            [Console]::SetCursorPosition($AnimationLeft, $AnimationTop)
            [Console]::Write(' ')
            [Console]::SetCursorPosition($AnimationLeft, $AnimationTop)
            if ($Success) {
                [Console]::ForegroundColor = 'Green'
                [Console]::WriteLine($SuccessIndicator)
                [Console]::ResetColor()
            }
            else {
                [Console]::ForegroundColor = 'Red'
                [Console]::WriteLine($FailureIndicator)
                [Console]::ResetColor()
            }
        }
        finally {
            ## Clean up runspace
            [System.Threading.Thread]::Sleep(50)
            $PowerShell.Dispose()
            $Runspace.Close()
            $Runspace.Dispose()
        }

        ## Log to file AFTER runspace cleanup (Write-Log is safe now)
        if ($Success) {
            Write-Log -Message "$Message" -Console:$false
        }
        else {
            Write-Log -Message "$Message - Failed: $ErrorMessage" -Severity 'Error' -Console:$false
        }

        ## Return result or throw error
        if (-not $Success -and $ErrorMessage) {
            throw $ErrorMessage
        }

        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 in the CURRENT session (not a runspace) and displays
    a success or failure indicator. Use this for operations that must run in the main
    session (e.g., Import-Module, New-PSDrive, Set-Location for ConfigMgr).
.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
    Use this instead of Invoke-WithAnimation when the operation must run in the main session.
.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 disabled, just run without status display
        if (-not $Script:LogToConsole) {
            Write-Log -Message "$Message" -Console:$false
            try {
                $Result = & $ScriptBlock
                return $Result
            }
            catch {
                throw
            }
        }

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

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

        try {
            $Result = & $ScriptBlock
        }
        catch {
            $Success = $false
            $ErrorMessage = $PSItem.Exception.Message
        }

        ## 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: $ErrorMessage" -Severity 'Error' -Console:$false
            throw $ErrorMessage
        }

        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).
.PARAMETER LogFile
    Specifies the path to the log file.
.PARAMETER MaxSizeMB
    Specifies the maximum size in MB before the log file is rotated.
.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
    )

    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
            }
        }
        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 ($null -ne $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
##*=============================================