Private/Logging.ps1

# Copyright (c) 2026 Broadcom. All Rights Reserved.
# Broadcom Confidential. The term "Broadcom" refers to Broadcom Inc.
# and/or its subsidiaries.
#
# =============================================================================
#
# SOFTWARE LICENSE AGREEMENT
#
# Copyright (c) CA, Inc. All rights reserved.
#
# You are hereby granted a non-exclusive, worldwide, royalty-free license
# under CA, Inc.'s copyrights to use, copy, modify, and distribute this
# software in source code or binary form for use in connection with CA, Inc.
# products.
#
# This copyright notice shall be included in all copies or substantial
# portions of the software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
#
# =============================================================================

#region Logging

function Write-LogMessage {

    <#
        .SYNOPSIS
        Writes a log message to console and/or log file.

        .DESCRIPTION
        Writes a timestamped, type-prefixed message to the console and log file.
        Message types: DEBUG, INFO, WARNING, ERROR.

        Screen output is filtered by the configured log level threshold (set via
        Initialize-PatchScanLogging). Only messages at or above the configured level
        are displayed on the console.

        All messages are always written to the log file regardless of their level. This
        ensures that DEBUG context is always available in the file for troubleshooting scan
        runs, even when the screen threshold is set to INFO or higher.

        .PARAMETER Type
        Message type: DEBUG, INFO, WARNING, ERROR.

        .PARAMETER Message
        The message text.

        .EXAMPLE
        Write-LogMessage -Type INFO -Message "Scan started."

        .EXAMPLE
        Write-LogMessage -Type ERROR -Message "Connection failed: $($_.Exception.Message)"

        .NOTES
        Write-Host is the primary output mechanism in this function; all Write-Host calls are intentional interactive console output. Also appends formatted messages to the log file when logging is initialized.
    #>


    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)] [ValidateSet('DEBUG', 'INFO', 'WARNING', 'ERROR')] [String]$Type,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Message
    )

    # Screen output is filtered by the configured log level — only messages at or above the
    # threshold are displayed on the console. All messages are always written to the log file
    # regardless of level, matching VcfEdgeAtScale behaviour and ensuring DEBUG context is never
    # silently discarded when troubleshooting a scan run that produced no visible errors.
    $levelOrder = @{ 'DEBUG' = 0; 'INFO' = 1; 'WARNING' = 2; 'ERROR' = 3 }
    $configuredLevel = if ($Script:VcfPatchScannerLogLevel) { $Script:VcfPatchScannerLogLevel } else { 'INFO' }
    $aboveScreenThreshold = $levelOrder[$Type] -ge $levelOrder[$configuredLevel]

    $timestamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss.fff')
    $prefix = "[$timestamp] [$Type]"
    $formattedMessage = "$prefix $Message"

    if ($aboveScreenThreshold) {
        switch ($Type) {
            'DEBUG'   { Write-Host $formattedMessage -ForegroundColor Gray }
            'INFO'    { Write-Host $formattedMessage -ForegroundColor White }
            'WARNING' { Write-Host $formattedMessage -ForegroundColor Yellow }
            'ERROR'   { Write-Host $formattedMessage -ForegroundColor Red }
        }
    }

    if ($Script:VcfPatchScannerLogFilePath) {
        try {
            $fileExists = Test-Path -LiteralPath $Script:VcfPatchScannerLogFilePath
            Add-Content -LiteralPath $Script:VcfPatchScannerLogFilePath -Value $formattedMessage -ErrorAction Stop

            # On first write, set secure permissions (Unix-like systems only).
            if (-not $fileExists -and $PSVersionTable.Platform -ne "Win32NT") {
                & chmod 600 $Script:VcfPatchScannerLogFilePath 2>$null
            }
        }
        catch {
            Write-Host "Warning: Could not write to log file: $($_.Exception.Message)" -ForegroundColor Yellow
        }
    }
}
function Get-PatchScanLogDirectory {

    <#
        .SYNOPSIS
        Get the patch scan log directory path.

        .DESCRIPTION
        Returns the directory where patch scan logs are written. If logging has been initialized,
        returns the configured directory. Otherwise returns the default VcfPatchScanner/logs
        relative to the module installation directory.

        .EXAMPLE
        $logDir = Get-PatchScanLogDirectory
        Write-LogMessage -Type INFO -Message "Log output directory: $logDir"

        .OUTPUTS
        [String] Fully qualified path to the log directory.

        .NOTES
        This function is useful for locating logs for troubleshooting or for external tools
        (like Python servers) that need to write to the same log directory.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param ()

    if ($Script:VcfPatchScannerLogDirectory) {
        return $Script:VcfPatchScannerLogDirectory
    }

    # Default to VcfPatchScanner/logs relative to module root
    return Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath "logs"
}
function Initialize-PatchScanLogging {

    <#
        .SYNOPSIS
        Initialize logging configuration.

        .DESCRIPTION
        Sets up logging infrastructure for patch scan operations.

        When LogDirectory is omitted the log directory is resolved from
        $env:VcfPatchScannerBaseDirectory (set by Initialize-VcfPatchScanner). The function
        throws if that environment variable is not set and no explicit path is provided.

        Log entries are written to VcfPatchScannerEngine-YYYY-MM-DD.log in the resolved
        directory. All severity levels are always written to the file; only messages at or
        above the configured LogLevel threshold are echoed to the console.

        .PARAMETER LogDirectory
        Absolute or relative path to the log directory. When omitted, logs are written to
        the Logs/ sub-directory of $env:VcfPatchScannerBaseDirectory. Throws if neither is set.

        .PARAMETER LogLevel
        Minimum log level to display on the console: DEBUG, INFO, WARNING, ERROR. Default: INFO.
        All levels are always written to the log file regardless of this setting.

        .OUTPUTS
        [String] Absolute path to the initialized log directory.

        .EXAMPLE
        $logDir = Initialize-PatchScanLogging
        Resolves the log directory from $env:VcfPatchScannerBaseDirectory and returns its path.

        .EXAMPLE
        Initialize-PatchScanLogging -LogLevel DEBUG
        Initializes with DEBUG-level console output (all messages visible).

        .EXAMPLE
        Initialize-PatchScanLogging -LogDirectory "/custom/log/path"
        Writes logs to an explicit directory instead of the default base-directory path.

        .NOTES
        Mutates $Script:VcfPatchScannerLogFile — sets the active log file path for the session.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$LogDirectory = "",
        [Parameter(Mandatory = $false)] [ValidateSet('DEBUG', 'INFO', 'WARNING', 'ERROR')] [String]$LogLevel = 'INFO'
    )

    $Script:VcfPatchScannerLogLevel = $LogLevel

    # Determine log directory path — priority: explicit param > base dir env var.
    if ([String]::IsNullOrWhiteSpace($LogDirectory)) {
        if ([String]::IsNullOrWhiteSpace($env:VcfPatchScannerBaseDirectory)) {
            throw [System.InvalidOperationException]::new(
                "$($Script:VCF_PATCH_SCANNER_ENV_VAR) is not set. Run Initialize-VcfPatchScanner before using the scanner."
            )
        }
        $Script:VcfPatchScannerLogDirectory = Join-Path -Path $env:VcfPatchScannerBaseDirectory.Trim() -ChildPath $Script:SCAN_LOGS_DIR_NAME
    } else {
        # Validate against path traversal
        if ($LogDirectory -match '[/\\]\.\.[/\\]' -or $LogDirectory -match '[/\\]\.\.$') {
            throw [System.InvalidOperationException]::new("Log directory path contains invalid traversal sequences: $LogDirectory")
        }

        # Use provided path (resolve to absolute if relative)
        if ([System.IO.Path]::IsPathRooted($LogDirectory)) {
            $Script:VcfPatchScannerLogDirectory = $LogDirectory
        } else {
            # Resolve relative to the module root (VcfPatchScanner directory), not cwd
            $Script:VcfPatchScannerLogDirectory = Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath $LogDirectory
        }
    }

    # Create log directory if it doesn't exist, then restrict it to the current user on non-Windows.
    if (-not (Test-Path -Path $Script:VcfPatchScannerLogDirectory -PathType Container)) {
        try {
            New-Item -ItemType Directory -Path $Script:VcfPatchScannerLogDirectory -Force | Out-Null
        }
        catch {
            Write-Host "Warning: Could not create log directory: $($_.Exception.Message)" -ForegroundColor Yellow
        }
    }
    if ($PSVersionTable.Platform -ne "Win32NT") {
        & chmod 700 $Script:VcfPatchScannerLogDirectory 2>$null
    }

    $fileTimestamp = (Get-Date).ToString('yyyy-MM-dd')
    $Script:VcfPatchScannerLogFilePath = Join-Path -Path $Script:VcfPatchScannerLogDirectory -ChildPath "VcfPatchScannerEngine-$fileTimestamp.log"
    $isNewLogFile = -not (Test-Path -LiteralPath $Script:VcfPatchScannerLogFilePath)

    if ($PSVersionTable.Platform -ne "Win32NT") {
        try {
            # Pre-create the log file and set 0600 before the first append so it is
            # never world-readable even briefly at session start.
            if ($isNewLogFile) {
                [System.IO.File]::WriteAllText(
                    $Script:VcfPatchScannerLogFilePath,
                    "",
                    [System.Text.UTF8Encoding]::new($false)
                )
            }
            & chmod 600 $Script:VcfPatchScannerLogFilePath 2>$null
        }
        catch {
            Write-Host "Warning: Could not set log file permissions: $($_.Exception.Message)" -ForegroundColor Yellow
        }
    }

    Write-LogMessage -Type DEBUG -Message "Logging initialized: $($Script:VcfPatchScannerLogFilePath)"

    # On a new log file, record environment context — mirrors VcfEdgeAtScale New-LogFile behaviour
    # so that support bundles always contain enough context to diagnose the environment.
    if ($isNewLogFile) {
        Write-LogMessage -Type DEBUG -Message "=== Session start ==="
        Write-LogMessage -Type DEBUG -Message "PowerShell version: $($PSVersionTable.PSVersion)"
        Write-LogMessage -Type DEBUG -Message "Platform: $($PSVersionTable.Platform) / OS: $($PSVersionTable.OS)"
        Write-LogMessage -Type DEBUG -Message "Module version: $($Script:VcfPatchScannerVersion)"
        Write-LogMessage -Type DEBUG -Message "Base directory: $($env:VcfPatchScannerBaseDirectory)"
        Write-LogMessage -Type DEBUG -Message "Log level threshold (screen): $LogLevel"
        # Identify VCF PowerCLI version from the installed module manifest.
        $debugVcfMod = Get-Module -ListAvailable -Name 'VCF.PowerCLI' -ErrorAction SilentlyContinue |
            Sort-Object -Property Version -Descending | Select-Object -First 1
        if ($null -ne $debugVcfMod) {
            Write-LogMessage -Type DEBUG -Message "VCF PowerCLI: $($debugVcfMod.Version)"
        } else {
            Write-LogMessage -Type DEBUG -Message "VCF PowerCLI: not installed (VCF.PowerCLI module not found)"
        }
    }

    return $Script:VcfPatchScannerLogDirectory
}
function Test-VcfPatchScannerDependencies {

    <#
        .SYNOPSIS
        Verify that all runtime dependencies required by VcfPatchScanner are available.

        .DESCRIPTION
        Checks the following prerequisites and collects all failures before returning:
          - PowerShell 7.4 or later.
          - VCF PowerCLI 9.0 or later — detected via Get-Module -ListAvailable on the
            VCF.PowerCLI module. No per-cmdlet probing; the module version is authoritative.
          - Python 3.13 or later — runs Start-VCFPatchScannerServer.py; must be in PATH as 'python3' or 'python'.
          - pwsh — launched by the Python server for each scan subprocess; must be in PATH.

        Returns $true when all checks pass. Writes WARNING messages and returns $false
        when one or more checks fail, listing every unmet dependency in a single pass.

        .EXAMPLE
        if (-not (Test-VcfPatchScannerDependencies)) {
            Write-LogMessage -Type ERROR -Message "One or more dependencies are missing. Install them before running a scan."
            return
        }

        .OUTPUTS
        [Bool] $true when all dependencies are satisfied; $false otherwise.

        .NOTES
        Write-Host is the primary output mechanism in this function; all Write-Host calls are
        intentional interactive console output. Use Write-LogMessage for diagnostic logging.
    #>


    [CmdletBinding()]
    [OutputType([Bool])]
    Param ()

    $failures = [System.Collections.Generic.List[String]]::new()
    $pathHint = if ($IsWindows) { ' ($env:PATH)' } else { '' }

    $minPsVersion = [Version]"7.4"
    $currentPsVersion = $PSVersionTable.PSVersion
    if ($currentPsVersion -lt $minPsVersion) {
        $failures.Add("PowerShell $minPsVersion or later is required. Current: $currentPsVersion.")
    }

    # Check VCF.PowerCLI by module version — a single registry scan, not per-cmdlet probing.
    $minPowerCliVersion = [Version]"9.0"
    $vcfMod = Get-Module -ListAvailable -Name 'VCF.PowerCLI' -ErrorAction SilentlyContinue |
        Sort-Object -Property Version -Descending | Select-Object -First 1
    if ($null -eq $vcfMod) {
        $failures.Add("VCF PowerCLI 9 or later is not installed. Download it from Broadcom and ensure it is on the PowerShell module path.")
    } elseif ($vcfMod.Version -lt $minPowerCliVersion) {
        $failures.Add("VCF PowerCLI $($vcfMod.Version) is installed but version 9.0 or later is required. Update to VCF PowerCLI 9.")
    }

    $pythonCmd = Get-Command -Name python3 -ErrorAction SilentlyContinue
    if ($null -eq $pythonCmd) {
        $pythonCmd = Get-Command -Name python -ErrorAction SilentlyContinue
    }
    $minPythonMinor = 13
    if ($null -eq $pythonCmd) {
        $failures.Add("Python 3.13 or later was not found. Install Python 3.13+ from python.org and ensure it is in your PATH$pathHint.")
    } else {
        try {
            $versionOutput = & $pythonCmd.Source --version 2>&1
            if ($versionOutput -match '^Python (\d+)\.(\d+)') {
                $pyMajor = [Int]$Matches[1]
                $pyMinor = [Int]$Matches[2]
                if ($pyMajor -lt 3) {
                    $failures.Add("Python $($Matches[0]) was found at '$($pythonCmd.Source)' but Python 3.13 or later is required. Install Python 3.13+ and ensure it precedes older versions in your PATH$pathHint.")
                } elseif ($pyMajor -eq 3 -and $pyMinor -lt $minPythonMinor) {
                    $failures.Add("Python $($Matches[0]) was found at '$($pythonCmd.Source)' but Python 3.$minPythonMinor or later is required. Upgrade to Python 3.$minPythonMinor+.")
                }
            } else {
                $failures.Add("Could not parse the Python version from '$($pythonCmd.Source)' (output: $versionOutput).")
            }
        } catch {
            $failures.Add("Could not determine the Python version at '$($pythonCmd.Source)': $($_.Exception.Message)")
        }
    }

    $pwshCmd = Get-Command -Name pwsh -ErrorAction SilentlyContinue
    if ($null -eq $pwshCmd) {
        $failures.Add("'pwsh' was not found. Install PowerShell 7 and ensure 'pwsh' is in your PATH$pathHint.")
    }

    if ($failures.Count -eq 0) {
        Write-Host " Dependency check: all requirements satisfied." -ForegroundColor Green
        Write-Host " PowerShell : $currentPsVersion (required: $minPsVersion+)" -ForegroundColor Gray
        if ($null -ne $vcfMod) {
            Write-Host " VCF PowerCLI: $($vcfMod.Version) (required: 9.0+)" -ForegroundColor Gray
        }
        if ($null -ne $pythonCmd) {
            $pyVer = if ($versionOutput -match 'Python (\S+)') { $Matches[1] } else { [String]$versionOutput }
            Write-Host " Python 3.13+: $pyVer" -ForegroundColor Gray
        }
        return $true
    }

    Write-Host ""
    Write-Host " Dependency check: $($failures.Count) unmet requirement(s):" -ForegroundColor Red
    foreach ($failure in $failures) {
        Write-Host " - $failure" -ForegroundColor Yellow
    }
    Write-Host ""

    return $false
}
function Resolve-PatchScanBaseDirectory {

    <#
        .SYNOPSIS
        Resolve the VCF Patch Scanner base directory interactively.

        .DESCRIPTION
        Handles three cases in order:
          1. VcfPatchScannerBaseDirectory is set and the path does not exist — clears the stale
             value from the session (and from the Windows user environment registry if on Windows)
             and falls through to the prompt.
          2. VcfPatchScannerBaseDirectory is set and is a valid directory — offers the operator the
             choice to keep the existing directory or pick a different one.
          3. No env var set — prompts with the default path as the proposed value.

        Returns the operator-chosen (or defaulted) absolute path, or $null when the session is
        non-interactive or the operator provides no path.

        .PARAMETER DefaultBaseDirectory
        Default directory path shown to the operator at the prompt.

        .EXAMPLE
        $baseDir = Resolve-PatchScanBaseDirectory -DefaultBaseDirectory "$HOME/VcfPatchScanner"
        if ($null -eq $baseDir) { return }

        .OUTPUTS
        [String] Absolute resolved base directory path, or $null on failure.

        .NOTES
        Write-Host is the primary output mechanism in this function; all Write-Host calls are
        intentional interactive console output. Use Write-LogMessage for diagnostic logging.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$DefaultBaseDirectory
    )

    $envRaw = $env:VcfPatchScannerBaseDirectory

    if (-not [String]::IsNullOrWhiteSpace($envRaw)) {
        $trimmed = $envRaw.Trim()

        if (-not (Test-Path -LiteralPath $trimmed)) {
            Write-Host ""
            Write-Host " Note: `$env:VcfPatchScannerBaseDirectory pointed at a path that does not exist:" -ForegroundColor Yellow
            Write-Host " $trimmed" -ForegroundColor White
            $env:VcfPatchScannerBaseDirectory = $null
            if ($IsWindows) {
                try {
                    [System.Environment]::SetEnvironmentVariable($Script:VCF_PATCH_SCANNER_ENV_VAR, $null, [System.EnvironmentVariableTarget]::User)
                    Write-Host " Stale value cleared from session and user environment. Choose a folder below." -ForegroundColor Green
                } catch {
                    Write-Host " Stale value cleared from session. User-level clear failed: $($_.Exception.Message)" -ForegroundColor Yellow
                }
            } else {
                Write-Host " Stale value cleared from session. Choose a folder below." -ForegroundColor Green
            }
        } elseif (Test-Path -LiteralPath $trimmed -PathType Container) {
            Write-Host " Detected: `$env:VcfPatchScannerBaseDirectory is set to $trimmed" -ForegroundColor Green
            try {
                $resp = Read-Host " Keep this directory or set a different one? [(K)eep / (C)hange, default: K]"
            } catch {
                Write-LogMessage -Type ERROR -Message "Initialize requires an interactive session. $($_.Exception.Message)"
                return $null
            }
            if ($resp.Trim() -inotmatch '^c(hange)?$') {
                # K, Enter, or anything other than C → keep the existing directory.
                return (Resolve-Path -LiteralPath $trimmed -ErrorAction Stop).Path
            }
            # C → fall through to the path prompt so the operator can choose a new directory.
        }
    }

    Write-Host " Default base directory: $DefaultBaseDirectory" -ForegroundColor White
    Write-Host ""
    try {
        $input = Read-Host "Press Enter to use the default, or type a full directory path"
    } catch {
        Write-LogMessage -Type ERROR -Message "Initialize requires an interactive session. $($_.Exception.Message)"
        return $null
    }

    $chosen = if ([String]::IsNullOrWhiteSpace($input)) { $DefaultBaseDirectory } else { $input.Trim() }

    if (-not [System.IO.Path]::IsPathRooted($chosen)) {
        $chosen = Join-Path -Path $HOME -ChildPath $chosen
    }
    $chosen = [System.IO.Path]::GetFullPath($chosen)

    $homeFull = [System.IO.Path]::GetFullPath($HOME)
    $sep = [System.IO.Path]::DirectorySeparatorChar
    if (-not $chosen.StartsWith($homeFull + $sep, [StringComparison]::OrdinalIgnoreCase) -and
        $chosen -ine $homeFull) {
        Write-LogMessage -Type ERROR -Message "BaseDirectory must be within the home directory. Chosen: $chosen"
        return $null
    }

    return $chosen
}
function Invoke-PersistPatchScanBaseDirectory {

    <#
        .SYNOPSIS
        Persist VcfPatchScannerBaseDirectory and print the initialize summary.

        .DESCRIPTION
        Sets VcfPatchScannerBaseDirectory in the current session. On Windows, also writes it to the
        user environment registry via [System.Environment]::SetEnvironmentVariable so that new
        sessions and Explorer-launched processes inherit it. On all platforms, writes or updates
        the assignment in $PROFILE, removing any stale entry from a previous module name or path.

        Prints the initialize summary to the console after all persistence operations.

        .PARAMETER BaseDirectoryWasCreated
        True when the base directory was freshly created by this Initialize run.

        .PARAMETER ResolvedBaseDirectory
        Fully resolved absolute path that was initialized.

        .PARAMETER SubdirectoriesCreated
        Names of subdirectories created during this run (for the summary).

        .PARAMETER FilesCopied
        Display names of files copied during this run (for the summary).

        .EXAMPLE
        Invoke-PersistPatchScanBaseDirectory -BaseDirectoryWasCreated $true -ResolvedBaseDirectory "$HOME/VcfPatchScanner" -SubdirectoriesCreated $createdDirs -FilesCopied $copiedFiles

        .NOTES
        Write-Host is the primary output mechanism in this function; all Write-Host calls are
        intentional interactive console output. Use Write-LogMessage for diagnostic logging.
        Mutates $env:VcfPatchScannerBaseDirectory — sets the scanner root for the current session.
    #>


    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)] [Bool]$BaseDirectoryWasCreated,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$ResolvedBaseDirectory,
        [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [System.Collections.Generic.List[String]]$SubdirectoriesCreated,
        [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [System.Collections.Generic.List[String]]$FilesCopied
    )

    $env:VcfPatchScannerBaseDirectory = $ResolvedBaseDirectory
    $persistedEnvSucceeded = $false

    if ($IsWindows) {
        try {
            [System.Environment]::SetEnvironmentVariable($Script:VCF_PATCH_SCANNER_ENV_VAR, $ResolvedBaseDirectory, [System.EnvironmentVariableTarget]::User)
            $verifyValue = [System.Environment]::GetEnvironmentVariable($Script:VCF_PATCH_SCANNER_ENV_VAR, [System.EnvironmentVariableTarget]::User)
            $persistedEnvSucceeded = ($verifyValue -eq $ResolvedBaseDirectory)
            if (-not $persistedEnvSucceeded) {
                Write-LogMessage -Type WARNING -Message "VcfPatchScannerBaseDirectory registry write appeared to succeed but read-back returned '$verifyValue'."
            }
        } catch {
            Write-LogMessage -Type WARNING -Message "Could not persist VcfPatchScannerBaseDirectory to user environment: $($_.Exception.Message)"
        }
    }

    $profileLine   = "`$env:$($Script:VCF_PATCH_SCANNER_ENV_VAR) = `"$ResolvedBaseDirectory`""
    $profileAction = 'none'
    try {
        $profileDir = Split-Path -Path $PROFILE -Parent
        if (-not (Test-Path -LiteralPath $profileDir)) {
            New-Item -ItemType Directory -Path $profileDir -Force | Out-Null
        }
        if (-not (Test-Path -LiteralPath $PROFILE)) {
            New-Item -ItemType File -Path $PROFILE -Force | Out-Null
        }
        $existingContent = Get-Content -LiteralPath $PROFILE -Raw -ErrorAction SilentlyContinue
        if ($null -eq $existingContent) { $existingContent = "" }

        if ($existingContent -match [Regex]::Escape($profileLine)) {
            # Exact line already present with the correct path — nothing to change.
            $profileAction = 'current'
        } else {
            # Remove any stale scan base directory assignment. The pattern catches the current
            # variable name with a different path AND any previous module name (e.g. the old
            # VcfPatchScanBaseDirectory line written before the module was renamed to VcfPatchScanner).
            $stalePattern   = '(?m)^\$env:VcfPatch[A-Za-z]*BaseDirectory\s*=\s*"[^"]*"\r?\n?'
            $cleanedContent = $existingContent -replace $stalePattern, ''
            $profileAction  = if ($existingContent -ne $cleanedContent) { 'updated' } else { 'written' }
            Set-Content -LiteralPath $PROFILE -Value ($cleanedContent.TrimEnd() + "`n$profileLine") -Encoding UTF8 -NoNewline
        }
    } catch {
        Write-LogMessage -Type WARNING -Message "Could not update `$PROFILE ($PROFILE): $($_.Exception.Message)"
    }

    Write-Host ""
    Write-Host "=== Initialize summary ===" -ForegroundColor Yellow
    Write-Host " Scan root: $ResolvedBaseDirectory" -ForegroundColor White
    if ($BaseDirectoryWasCreated) {
        Write-Host " Base directory: created." -ForegroundColor Green
    } else {
        Write-Host " Base directory: already existed; existing files kept." -ForegroundColor Gray
    }
    if ($SubdirectoriesCreated.Count -gt 0) {
        Write-Host " Subdirectories created: $($SubdirectoriesCreated -join ', ')." -ForegroundColor Green
    } else {
        Write-Host " Subdirectories already present." -ForegroundColor Gray
    }
    foreach ($fileName in $FilesCopied) {
        Write-Host " Copied: $fileName" -ForegroundColor White
    }
    Write-Host ""

    if ($IsWindows) {
        if ($persistedEnvSucceeded) {
            Write-Host " $($Script:VCF_PATCH_SCANNER_ENV_VAR) -> $ResolvedBaseDirectory (session + user environment persisted)." -ForegroundColor Green
        } else {
            Write-Host " $($Script:VCF_PATCH_SCANNER_ENV_VAR) -> $ResolvedBaseDirectory (current session only; user-level persist failed — see warning above)." -ForegroundColor Yellow
            Write-Host " To set manually: [System.Environment]::SetEnvironmentVariable(`"$($Script:VCF_PATCH_SCANNER_ENV_VAR)`", `"<path>`", [System.EnvironmentVariableTarget]::User)" -ForegroundColor Yellow
        }
    } else {
        Write-Host " $($Script:VCF_PATCH_SCANNER_ENV_VAR) -> $ResolvedBaseDirectory (set for this PowerShell session)." -ForegroundColor Green
        Write-Host ""
        Write-Host " macOS / Linux note:" -ForegroundColor Yellow
        Write-Host " The variable is set for this PowerShell session and persisted to your" -ForegroundColor Gray
        Write-Host " PowerShell profile (`$PROFILE). If you also need to launch the Python server" -ForegroundColor Gray
        Write-Host " directly from bash or zsh (without going through PowerShell first), add" -ForegroundColor Gray
        Write-Host " the following line to your shell profile (~/.zshrc, ~/.bashrc, etc.):" -ForegroundColor Gray
        Write-Host ""
        Write-Host " export $($Script:VCF_PATCH_SCANNER_ENV_VAR)=`"$ResolvedBaseDirectory`"" -ForegroundColor White
        Write-Host ""
        Write-Host " The Python server will exit with a clear error if this variable is missing." -ForegroundColor Gray
    }
    Write-Host ""

    switch ($profileAction) {
        'written' {
            Write-Host " Profile line appended to: $PROFILE" -ForegroundColor Green
            Write-Host " New terminal sessions will inherit this variable automatically." -ForegroundColor Gray
        }
        'updated' {
            Write-Host " Profile updated: $PROFILE (replaced old entry)." -ForegroundColor Green
            Write-Host " New terminal sessions will inherit the updated variable." -ForegroundColor Gray
        }
        'current' {
            Write-Host " `$PROFILE already up to date — no change made." -ForegroundColor Gray
            Write-Host " Profile: $PROFILE" -ForegroundColor Gray
        }
    }

    Write-Host ""
    Write-Host " Next step: Start-VCFPatchScannerServer" -ForegroundColor Yellow
    Write-Host " Opens the browser UI at http://localhost:8765" -ForegroundColor Gray
    Write-Host ""
}
function Copy-PatchScanToolFilesFromModule {

    <#
        .SYNOPSIS
        Copy module-owned tool files to a target Tools directory.

        .DESCRIPTION
        Copies every file listed in $Script:SCAN_TOOL_FILE_NAMES from the module's Tools/
        subdirectory to TargetDirectory, overwriting any existing copies. Returns the names
        of all files that were successfully copied.

        .PARAMETER TargetDirectory
        Absolute path of the destination Tools directory.

        .OUTPUTS
        [String[]] Names of files copied from the module.

        .NOTES
        Write-Host: progress feedback; this helper is called exclusively from Initialize-VcfPatchScanner
        which is a UI-builder function. Use Write-LogMessage for any diagnostic logging.

        .EXAMPLE
        Copy-PatchScanToolFilesFromModule -TargetDirectory "$HOME/VcfPatchScanner/Tools"
    #>


    [CmdletBinding()]
    [OutputType([String[]])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$TargetDirectory
    )

    $moduleToolsPath = [System.IO.Path]::GetFullPath((Join-Path -Path $PSScriptRoot -ChildPath ".." -AdditionalChildPath "Tools"))
    $copiedFiles = [System.Collections.Generic.List[String]]::new()

    Write-Host " Tools" -ForegroundColor Yellow
    foreach ($toolFile in $Script:SCAN_TOOL_FILE_NAMES) {
        $sourceFile = Join-Path -Path $moduleToolsPath -ChildPath $toolFile
        if (Test-Path -LiteralPath $sourceFile -PathType Leaf) {
            Copy-Item -LiteralPath $sourceFile -Destination (Join-Path -Path $TargetDirectory -ChildPath $toolFile) -Force
            Write-Host " Copied: $toolFile" -ForegroundColor White
            $copiedFiles.Add($toolFile)
        } else {
            Write-Host " WARNING: source file not found in module: $toolFile" -ForegroundColor Yellow
        }
    }
    return [String[]]$copiedFiles.ToArray()
}
function Copy-PatchScanAdvisoryDataFromModule {

    <#
        .SYNOPSIS
        Copy the advisory reference JSON from the module Data/ directory to a target Data directory.

        .DESCRIPTION
        Copies securityAdvisory.json from the module's Data/ subdirectory to TargetDirectory.
        When a file already exists at the destination, it is only replaced when the bundled copy
        carries a strictly newer updatedAt timestamp. This prevents a module update from
        downgrading an advisory database that was refreshed via the UI update flow.
        Returns $true on success or when the existing file is already current, $false when the
        source file is not found in the module.

        .PARAMETER TargetDirectory
        Absolute path of the destination Data directory.

        .OUTPUTS
        [Bool] $true if the file was copied; $false if the source was not found in the module.

        .NOTES
        Write-Host: progress feedback; this helper is called exclusively from Initialize-VcfPatchScanner
        which is a UI-builder function. Use Write-LogMessage for any diagnostic logging.

        .EXAMPLE
        Copy-PatchScanAdvisoryDataFromModule -TargetDirectory "$HOME/VcfPatchScanner/Data"
    #>


    [CmdletBinding()]
    [OutputType([Bool])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$TargetDirectory
    )

    $moduleDataPath = [System.IO.Path]::GetFullPath((Join-Path -Path $PSScriptRoot -ChildPath ".." -AdditionalChildPath "Data"))
    $sourceAdvisory = Join-Path -Path $moduleDataPath -ChildPath $Script:SCAN_ADVISORY_FILE_NAME
    $targetAdvisory = Join-Path -Path $TargetDirectory -ChildPath $Script:SCAN_ADVISORY_FILE_NAME

    Write-Host " Data" -ForegroundColor Yellow
    if (-not (Test-Path -LiteralPath $sourceAdvisory -PathType Leaf)) {
        Write-Host " WARNING: $($Script:SCAN_ADVISORY_FILE_NAME) not found in module Data/." -ForegroundColor Yellow
        return $false
    }

    if (Test-Path -LiteralPath $targetAdvisory -PathType Leaf) {
        # Parse updatedAt from each file independently so a corrupt destination does not
        # accidentally block a legitimate overwrite (its $dstDate stays $null).
        $srcDate = $null
        $dstDate = $null
        try { $srcDate = [DateTime]::Parse((Get-Content -LiteralPath $sourceAdvisory -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop).updatedAt) } catch { }
        try { $dstDate = [DateTime]::Parse((Get-Content -LiteralPath $targetAdvisory -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop).updatedAt) } catch { }

        # Keep the existing file when it is parseable and the bundled copy is not strictly newer.
        if ($null -ne $dstDate -and ($null -eq $srcDate -or $srcDate -le $dstDate)) {
            Write-Host " Kept: $($Script:SCAN_ADVISORY_FILE_NAME) (existing copy is current)" -ForegroundColor Gray
            return $true
        }

        if ($null -ne $dstDate) {
            Write-Host " Updating: $($Script:SCAN_ADVISORY_FILE_NAME) (module copy is newer)" -ForegroundColor Cyan
        } else {
            Write-Host " Replacing: $($Script:SCAN_ADVISORY_FILE_NAME) (existing file could not be parsed)" -ForegroundColor Yellow
        }
    }

    Copy-Item -LiteralPath $sourceAdvisory -Destination $targetAdvisory -Force

    # Delete the ETag sidecar so the server does not incorrectly report the newly-written
    # file as "up to date". The sidecar was written by the Python server after a successful
    # upstream download; it contains the ETag for whatever file was current at that time.
    # After replacing the JSON content the sidecar is stale — the server must re-check
    # upstream on next startup or manual check to learn the real state.
    $targetEtag = "$targetAdvisory.etag"
    if (Test-Path -LiteralPath $targetEtag -PathType Leaf) {
        Remove-Item -LiteralPath $targetEtag -Force -ErrorAction SilentlyContinue
    }

    Write-Host " Copied: $($Script:SCAN_ADVISORY_FILE_NAME)" -ForegroundColor White
    return $true
}
function Initialize-VcfPatchScanner {

    <#
        .SYNOPSIS
        Initialize the VCF Patch Scanner base directory structure and persist the environment variable.

        .DESCRIPTION
        Creates the base directory structure for the VCF Patch Scanner (default: ~/VcfPatchScanner).

        Subdirectories created:
          Config/ — scan-settings.json (written on first run only, preserving configured environments)
          Data/ — securityAdvisory.json reference data
          Findings/ — scan result JSON files
          Logs/ — diagnostic log files
          Tools/ — Python server, PowerShell wrapper, and HTML UI (refreshed from module on every run)

        Files copied on each run:
          Tools/Invoke-VCFPatchScanner.ps1 (always overwritten — tools are safe to replace)
          Tools/Manage-VCFPatchScannerServer.py (always overwritten)
          Tools/Start-VCFPatchScannerServer.py (always overwritten)
          Tools/vcp-patch-ui.html (always overwritten)
          Data/securityAdvisory.json (only when the bundled copy has a newer updatedAt
                                               than the existing file — never downgrades a database
                                               refreshed via the UI update flow)

        scan-settings.json is written to Config/ only if it does not already exist there,
        preserving any environments the operator has already configured.

        Sets VcfPatchScannerBaseDirectory for the current session. On Windows, also writes it to the
        user environment registry. On all platforms, appends the assignment to $PROFILE.

        When -RefreshTools or -RefreshData is specified the function runs in partial-refresh mode:
        it skips the dependency check, the interactive base-directory prompt, and environment
        persistence, using the existing VcfPatchScannerBaseDirectory. Run partial refresh after
        installing a new version of the module to pick up updated files without repeating setup.

        .PARAMETER RefreshData
        Partial refresh: updates Data/securityAdvisory.json from the module when the bundled copy
        is newer than the existing file (same timestamp-guard as a full init). Requires
        VcfPatchScannerBaseDirectory to already be set from a prior initialization. Skips the
        dependency check, directory prompt, and environment persistence.

        .PARAMETER RefreshTools
        Partial refresh: re-copies all Tools/ files (Python server, PowerShell wrapper, HTML UI)
        from the module to the existing base directory. Requires VcfPatchScannerBaseDirectory to
        already be set. Skips the dependency check, directory prompt, and environment persistence.

        .OUTPUTS
        [String] Absolute path to the initialized base directory, or $null on failure.

        .NOTES
        Mutates $env:VcfPatchScannerBaseDirectory — sets the scanner root for the current session.
        Write-Host is the primary output mechanism in this function; all Write-Host calls are
        intentional interactive console output. Use Write-LogMessage for diagnostic logging.

        .EXAMPLE
        Initialize-VcfPatchScanner
        Creates ~/VcfPatchScanner with the standard directory layout.

        .EXAMPLE
        Initialize-VcfPatchScanner -RefreshTools
        Re-copies all Tools/ files (Python server scripts, PowerShell wrappers, HTML UI) from the
        module to the existing base directory. Run after updating the VcfPatchScanner module.

        .EXAMPLE
        Initialize-VcfPatchScanner -RefreshData
        Re-copies securityAdvisory.json from the module to Data/. Run after updating advisory data.

        .EXAMPLE
        Initialize-VcfPatchScanner -RefreshTools -RefreshData
        Re-copies both Tools/ and Data/ from the module in one call without running full interactive setup.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $false)] [Switch]$RefreshData,
        [Parameter(Mandatory = $false)] [Switch]$RefreshTools
    )

    $isPartialRefresh = $RefreshData.IsPresent -or $RefreshTools.IsPresent

    if ($RefreshData.IsPresent -and $RefreshTools.IsPresent) {
        $modeLabel = 'tools+data — refreshing Tools/ and Data/ from module'
    } elseif ($RefreshTools.IsPresent) {
        $modeLabel = 'tools — refreshing Tools/ from module'
    } elseif ($RefreshData.IsPresent) {
        $modeLabel = 'data — refreshing Data/securityAdvisory.json from module'
    } else {
        $modeLabel = 'full — Data, Logs, Tools, advisory JSON, and settings template.'
    }

    Write-Host ""
    Write-Host "VcfPatchScanner initialize" -ForegroundColor Yellow
    Write-Host " Mode: $modeLabel" -ForegroundColor Gray
    Write-Host ""

    if ($isPartialRefresh) {
        $trimmedBase = ([String]$env:VcfPatchScannerBaseDirectory).Trim()
        if ([String]::IsNullOrWhiteSpace($trimmedBase) -or -not (Test-Path -LiteralPath $trimmedBase -PathType Container)) {
            Write-Host " ERROR: VcfPatchScannerBaseDirectory is not set or does not exist on disk." -ForegroundColor Red
            Write-Host " Run Initialize-VcfPatchScanner (without switches) to perform a full setup first." -ForegroundColor Yellow
            return $null
        }

        $filesCopied = [System.Collections.Generic.List[String]]::new()

        if ($RefreshTools.IsPresent) {
            $targetToolsPath = Join-Path -Path $trimmedBase -ChildPath $Script:SCAN_TOOLS_DIR_NAME
            if (-not (Test-Path -LiteralPath $targetToolsPath -PathType Container)) {
                New-Item -ItemType Directory -Path $targetToolsPath -Force | Out-Null
            }
            foreach ($f in (Copy-PatchScanToolFilesFromModule -TargetDirectory $targetToolsPath)) {
                $filesCopied.Add($f)
            }
        }

        if ($RefreshData.IsPresent) {
            if ($RefreshTools.IsPresent) { Write-Host "" }
            $targetDataPath = Join-Path -Path $trimmedBase -ChildPath $Script:SCAN_DATA_DIR_NAME
            if (-not (Test-Path -LiteralPath $targetDataPath -PathType Container)) {
                New-Item -ItemType Directory -Path $targetDataPath -Force | Out-Null
            }
            if (Copy-PatchScanAdvisoryDataFromModule -TargetDirectory $targetDataPath) {
                $filesCopied.Add($Script:SCAN_ADVISORY_FILE_NAME)
            }
        }

        Write-Host ""
        Write-Host " Base directory : $trimmedBase"
        Write-Host " Updated : $($filesCopied.Count) file(s)"
        Write-Host ""
        return $trimmedBase
    }

    Write-Host " Checking dependencies..." -ForegroundColor Gray
    if (-not (Test-VcfPatchScannerDependencies)) {
        Write-Host " Resolve the dependency issues listed above, then run Initialize-VcfPatchScanner again." -ForegroundColor Red
        return $null
    }
    Write-Host ""

    $defaultDir = Join-Path -Path $HOME -ChildPath $Script:VCF_PATCH_SCANNER_DEFAULT_DIR
    $targetDir = Resolve-PatchScanBaseDirectory -DefaultBaseDirectory $defaultDir
    if ($null -eq $targetDir) {
        return $null
    }

    $baseWasCreated = -not (Test-Path -LiteralPath $targetDir -PathType Container)
    if ($baseWasCreated) {
        New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
    }

    $subdirectoriesCreated = [System.Collections.Generic.List[String]]::new()
    $filesCopied           = [System.Collections.Generic.List[String]]::new()

    foreach ($subName in @($Script:SCAN_CONFIG_DIR_NAME, $Script:SCAN_DATA_DIR_NAME, $Script:SCAN_FINDINGS_DIR_NAME, $Script:SCAN_LOGS_DIR_NAME, $Script:SCAN_TOOLS_DIR_NAME)) {
        $subPath = Join-Path -Path $targetDir -ChildPath $subName
        if (-not (Test-Path -LiteralPath $subPath -PathType Container)) {
            New-Item -ItemType Directory -Path $subPath -Force | Out-Null
            $subdirectoriesCreated.Add($subName)
        }
    }

    $targetToolsPath = Join-Path -Path $targetDir -ChildPath $Script:SCAN_TOOLS_DIR_NAME
    foreach ($f in (Copy-PatchScanToolFilesFromModule -TargetDirectory $targetToolsPath)) {
        $filesCopied.Add($f)
    }

    Write-Host ""
    $targetDataPath = Join-Path -Path $targetDir -ChildPath $Script:SCAN_DATA_DIR_NAME
    if (Copy-PatchScanAdvisoryDataFromModule -TargetDirectory $targetDataPath) {
        $filesCopied.Add($Script:SCAN_ADVISORY_FILE_NAME)
    }

    $targetConfigPath = Join-Path -Path $targetDir -ChildPath $Script:SCAN_CONFIG_DIR_NAME
    $targetSettings = Join-Path -Path $targetConfigPath -ChildPath $Script:SCAN_SETTINGS_FILE_NAME
    Write-Host ""
    Write-Host " Config" -ForegroundColor Yellow
    if (-not (Test-Path -LiteralPath $targetSettings -PathType Leaf)) {
        $defaultSettings = [PSCustomObject]@{
            environments             = @()
            findingsOutputDirectory  = "Findings"
            logDirectory             = "Logs"
            logLevel                 = "INFO"
            securityAdvisoryFile     = $Script:SCAN_ADVISORY_FILE_NAME
            ignoreCertificate        = $true
            connectionTimeoutSeconds = 30
            lightMode                = $true
            defaultSort              = "severity"
            hiddenCols               = @(9, 10, 11, 12, 13)
        }
        $defaultSettings | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $targetSettings -Encoding UTF8 -Force
        Write-Host " Wrote: $($Script:SCAN_SETTINGS_FILE_NAME) (new, no environments)" -ForegroundColor Green
        $filesCopied.Add($Script:SCAN_SETTINGS_FILE_NAME)
    } else {
        Write-Host " Kept: $($Script:SCAN_SETTINGS_FILE_NAME) (already exists — environments preserved)" -ForegroundColor Gray
    }

    Write-Host ""

    Invoke-PersistPatchScanBaseDirectory `
        -BaseDirectoryWasCreated $baseWasCreated `
        -ResolvedBaseDirectory   $targetDir `
        -SubdirectoriesCreated   $subdirectoriesCreated `
        -FilesCopied             $filesCopied

    return $targetDir
}
function Invoke-VcfPatchScannerCollectLogs {

    <#
        .SYNOPSIS
        Bundle the patch scanner logs directory into a timestamped zip file.

        .DESCRIPTION
        Reads the log directory from the scan-settings.json in VcfPatchScannerBaseDirectory (or the module
        Tools directory), copies all .log files into a temp staging area, compresses them into a zip, and
        saves the zip under $HOME. Designed to be called interactively or triggered by the Python UI's
        Collect Logs button.

        .OUTPUTS
        [String] Absolute path to the created zip file, or $null on failure.

        .NOTES
        Write-Host is the primary output mechanism in this function; all Write-Host calls are
        intentional interactive console output. Use Write-LogMessage for diagnostic logging.

        .EXAMPLE
        Invoke-VcfPatchScannerCollectLogs
        Zips all scan logs and writes the archive path to the console.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param ()

    if ([String]::IsNullOrWhiteSpace($env:VcfPatchScannerBaseDirectory)) {
        $err = "$($Script:VCF_PATCH_SCANNER_ENV_VAR) is not set. Run Initialize-VcfPatchScanner before collecting logs."
        Write-LogMessage -Type ERROR -Message $err
        return $null
    }

    # Prefer the active log directory set by Initialize-PatchScanLogging; fall back to the
    # base dir Logs/ subdirectory when logging has not yet been initialized in this session.
    $logsDir = if (-not [String]::IsNullOrWhiteSpace($Script:VcfPatchScannerLogDirectory)) {
        $Script:VcfPatchScannerLogDirectory
    } else {
        Join-Path -Path $env:VcfPatchScannerBaseDirectory.Trim() -ChildPath $Script:SCAN_LOGS_DIR_NAME
    }

    if (-not (Test-Path -LiteralPath $logsDir -PathType Container)) {
        Write-LogMessage -Type ERROR -Message "Log directory not found: $logsDir"
        return $null
    }

    $logFiles = @(Get-ChildItem -LiteralPath $logsDir -Filter "*.log" -ErrorAction SilentlyContinue)
    if ($logFiles.Count -eq 0) {
        Write-LogMessage -Type WARNING -Message "No .log files found in $logsDir — nothing to archive."
        return $null
    }

    $stamp = Get-Date -Format "yyyyMMdd-HHmmss"
    $zipFileName = "VcfPatchScanner-logs-$stamp.zip"
    $zipPath = Join-Path -Path $HOME -ChildPath $zipFileName
    $stagingParent = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "VcfPatchScanner-collect-$stamp"
    $stagingRoot = Join-Path -Path $stagingParent -ChildPath "archive"

    try {
        $null = New-Item -ItemType Directory -Path $stagingRoot -Force -ErrorAction Stop

        foreach ($logFile in $logFiles) {
            $destPath = Join-Path -Path $stagingRoot -ChildPath $logFile.Name
            Copy-Item -LiteralPath $logFile.FullName -Destination $destPath -Force -ErrorAction Stop
        }

        if (Test-Path -LiteralPath $zipPath -PathType Leaf) {
            Remove-Item -LiteralPath $zipPath -Force -ErrorAction Stop
        }

        Compress-Archive -Path "$stagingRoot\*" -DestinationPath $zipPath -Force -ErrorAction Stop

        Write-Host ""
        Write-Host "CollectLogs complete. Archive saved to:"
        Write-Host " $zipPath"
        Write-Host ""
    }
    catch {
        Write-LogMessage -Type ERROR -Message "CollectLogs failed: $($_.Exception.Message)"
        return $null
    }
    finally {
        if (Test-Path -LiteralPath $stagingParent) {
            Remove-Item -LiteralPath $stagingParent -Recurse -Force -ErrorAction SilentlyContinue
        }
    }

    return $zipPath
}
function Remove-AnsiEscapeCodes {

    <#
        .SYNOPSIS
        Remove ANSI escape codes from a string.

        .DESCRIPTION
        Strips ANSI control sequences that could be used for injection attacks.
        Removes sequences like ESC[...m (color codes), ESC[...H (cursor movement), etc.
        Safe to use on user-supplied display names and environment descriptions.

        .PARAMETER InputString
        String potentially containing ANSI escape codes.

        .EXAMPLE
        $cleanName = Remove-AnsiEscapeCodes -InputString $rawDisplayName

        .OUTPUTS
        [String] Cleaned string with ANSI codes removed.

        .NOTES
        Pure string transformation. Does not mutate any module-scope variables.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $true)] [AllowEmptyString()] [String]$InputString
    )

    if ([String]::IsNullOrEmpty($InputString)) {
        return $InputString
    }

    # Remove ANSI escape sequences: ESC[ followed by any sequence ending with a letter
    # Covers: color codes, cursor movement, text formatting, etc.
    return $InputString -replace '\x1b\[[0-9;]*[a-zA-Z]', ''
}

#endregion