Private/AzStackHci.Utility.Helpers.ps1

# ////////////////////////////////////////////////////////////////////////////
# Module-level silent mode flag, controlled by -NoOutput switch on public functions
$script:SilentMode = $false
# Module-level verbose mode flag — set to $true only when caller passes -Verbose
# Required because Azure Local nodes often have $VerbosePreference = 'Continue' at machine level,
# and local $VerbosePreference in the calling function does NOT propagate to module-scoped helpers.
$script:VerboseMode = $false

# ////////////////////////////////////////////////////////////////////////////
# Returns $true when running inside a real Windows console host (conhost / Windows Terminal).
# Returns $false in VSCode integrated terminal, ISE, remoting, or other non-console hosts.
# Used to guard [Console]::SetCursorPosition and spinner animations which break in non-console hosts.
function Test-IsConsoleHost {
    return ($host.Name -eq 'ConsoleHost') -and ([Environment]::UserInteractive)
}

# ////////////////////////////////////////////////////////////////////////////
# Wrapper function for Write-Host that respects $script:SilentMode.
# Also emits the same message via Write-Verbose when $script:VerboseMode is true.
# Uses the module-level $script:VerboseMode flag instead of Write-Verbose (whose
# $VerbosePreference resolution walks module scope, not caller scope — causing
# unwanted verbose output on Azure Local nodes where machine-level preference is 'Continue').
# Use -SkipVerbose for ephemeral messages (spinner frames) that should not flood verbose output.
function Write-HostAzS {
    param(
        [Parameter(Position=0)]
        [object]$Object = '',
        [ConsoleColor]$ForegroundColor,
        [switch]$NoNewLine,
        [switch]$SkipVerbose
    )
    # Emit via Write-Verbose for pipeline/transcript capture (only when caller passed -Verbose)
    if ($script:VerboseMode -and -not $SkipVerbose -and $Object -and $Object.ToString().Trim()) {
        Write-Verbose $Object.ToString()
    }
    if ($script:SilentMode) { return }
    $params = @{}
    if ($PSBoundParameters.ContainsKey('Object')) { $params['Object'] = $Object }
    if ($PSBoundParameters.ContainsKey('ForegroundColor')) { $params['ForegroundColor'] = $ForegroundColor }
    if ($NoNewLine) { $params['NoNewline'] = $true }
    Write-Host @params
}

# ////////////////////////////////////////////////////////////////////////////
# Standardized error output helper.
# Use for recoverable errors that should be visible to users.
# For fatal precondition failures use 'throw' instead.
function Write-AzSError {
    param(
        [Parameter(Mandatory, Position=0)]
        [string]$Message,
        [string]$Category = 'OperationError'
    )
    Write-Error -Message $Message -Category $Category
    # Also emit via Write-HostAzS so it appears in console output / transcripts
    Write-HostAzS "Error: $Message" -ForegroundColor Red
}

# ////////////////////////////////////////////////////////////////////////////
# This function checks if the script is running with elevated privileges
function Test-Elevation
{

    begin {
        # Write-Debug "Test-Elevation: Beginning elevation check"
    }

    process {
        # Check if the script is running with elevated privileges
        # Return true if running as administrator, false otherwise
        Return ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')
    }

    end {
        # Write-Debug "Test-Elevation: Elevation check completed"
    }
}

# This function invokes a job with an animation in the console
# It takes a job name and a script block as parameters

function Invoke-JobWithAnimation {
    
    Param (
        [Parameter(Mandatory=$true)]
        [string]$JobName,

        [Parameter(Mandatory=$true)]
        [scriptblock]$JobScriptBlock
    )

    begin {
        # Write-Debug "Invoke-JobWithAnimation: Beginning job execution with animation for '$JobName'"
        $isConsole = Test-IsConsoleHost
        if ($isConsole) { $cursorTop = [Console]::CursorTop }
    }

    process {
    
        try {
            if ($isConsole) { [Console]::CursorVisible = $false }
            
            $counter = 0
            $frames = '|', '/', '-', '\' 
            $StartTime = (Get-Date)
            $script:job = Start-Job -Name $JobName -ScriptBlock $JobScriptBlock
        
            while($job.JobStateInfo.State -eq "Running") {
                # Timeout guard: stop spinning after JOB_ANIMATION_TIMEOUT_SEC
                if (((Get-Date) - $StartTime).TotalSeconds -ge $script:JOB_ANIMATION_TIMEOUT_SEC) {
                    Write-HostAzS "Job '$JobName' timed out after $($script:JOB_ANIMATION_TIMEOUT_SEC) seconds." -ForegroundColor Red
                    $job | Stop-Job -ErrorAction SilentlyContinue
                    break
                }

                $frame = $frames[$counter % $frames.Length]
                $RunningDuration = ((Get-Date) - $StartTime).ToString("hh\:mm\:ss")
                if ($isConsole) {
                    Write-HostAzS -ForegroundColor Green "Download in progress: $frame Time elapsed: $RunningDuration" -NoNewLine -SkipVerbose
                    [Console]::SetCursorPosition(0, $cursorTop)
                } elseif ($counter % 40 -eq 0) {
                    # Non-console host: emit a progress line every ~5 seconds instead of every frame
                    Write-HostAzS -ForegroundColor Green "Download in progress... Time elapsed: $RunningDuration"
                }
                
                $counter += 1
                Start-Sleep -Milliseconds $script:ANIMATION_FRAME_MS
            }
    
        } finally {
            if ($isConsole) {
                [Console]::SetCursorPosition(0, $cursorTop)
                [Console]::CursorVisible = $true
            }
        }
        # Wait for the job to complete and capture the output from the child jobs
        $JobOutput = $job | Receive-Job -Wait -WriteJobInResults
        if(($JobOutput).Output){
            # Do nothing
        } else {
            # If the job output is empty, check the child jobs
            # and receive their output
            $JobOutput = $job.ChildJobs | Receive-Job -Wait -WriteJobInResults
        }
    
    } # End of process block

    end {
        # Write-Debug "Invoke-JobWithAnimation: Job execution with animation completed"
        Write-HostAzS ""
        Return $JobOutput
    }
} # End Function Invoke-JobWithAnimation



# ////////////////////////////////////////////////////////////////////////////
Function Test-DownloadSpeed {
    <#
    .SYNOPSIS
        Test download speed from a specified URL and measure the time taken to download a file.
    .DESCRIPTION
        This function downloads a file from a specified URL and measures the download speed in Mbits/sec.
        It uses the Invoke-WebRequest cmdlet to download the file and a stopwatch to measure the elapsed time.
        The script also includes a function to display a processing animation while the download is in progress.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$false)]
        [string]$DownloadFileUrl = 'https://aka.ms/WACDownload',

        [Parameter(Mandatory=$false)]
        [string]$DestinationFolder = 'C:\ProgramData\AzStackHci.DiagnosticSettings',

        [Parameter(Mandatory=$false)]
        [string]$DestinationPath = "$DestinationFolder\WindowsAdminCenter_DownloadSpeedTest.exe"
    )

    begin {
        # Write-Debug "Test-DownloadSpeed: Beginning download speed test from '$DownloadFileUrl'"
    }

    process {
    
        # Linux:
        # t=$(date +"%s"); wget https://aka.ms/WACDownload -O ->/dev/null ; echo -n "MBit/s: "; expr 8 \* 100 / $(($(date +"%s")-$t))

        # Windows, download the file using Invoke-WebRequest
        # Windows Admin Center download speed test
        # smaller file
        # https://aka.ms/WACDownload

        # the aka.ms url, points to this direct file download (as of March 2026), the file is approximately 126 MB in size:
        # https://download.microsoft.com/download/5e854024-dcf1-4e86-9546-7389fd08a34b/WindowsAdminCenter2511.exe


        # This downloads a file from a specified URL and measures the download speed in Mbits/sec.
        # It uses the Invoke-WebRequest cmdlet to download the file and a stopwatch to measure the elapsed time.
        # The script also includes a function to display a processing animation while the download is in progress.

        # Create the destination folder if it doesn't exist
        if (-not (Test-Path -Path $destinationFolder)) {
            New-Item -ItemType Directory -Path $destinationFolder | Out-Null
        }

        # Script block to download the file
        # This script block is executed in a separate job to allow for progress animation
        [scriptblock]$DownloadFileSpeedTest = {

            # Hashtable of parameters for the Invoke-WebRequest cmdlet
            $startWebRequestSplat = @{
                Uri = $using:DownloadFileUrl
                OutFile = $using:destinationPath
                UseBasicParsing = $true
                ErrorAction = 'SilentlyContinue'
                ErrorVariable = 'webRequestError'
                TimeoutSec = 600
            }
            # Set the progress preference to 'SilentlyContinue' to suppress progress output
            # This prevents the progress bar from impacting download speed
            $ProgressPreference = 'SilentlyContinue'

            try {
                # Invoke-WebRequest to download the file, use -PassThru to get the result
                $iwrResult = Invoke-WebRequest @startWebRequestSplat -PassThru
            } catch {
                # Handle any exceptions that occur during the web request
                Write-Output "Error: $($_.Exception.Message.ToString())"
            }

            # Check if the download was successful
            if($iwrResult){
                if ($iwrResult.StatusCode -eq 200) {
                    Write-Output "Download completed with Status Code: $($iwrResult.StatusCode)."
                } else {
                    Write-Output "Download failed status code $($iwrResult.StatusCode)."
                    Write-Output "Error: Status Description: $($iwrResult.StatusDescription)"
                }
            } elseif($webRequestError) {
                Write-Output "Error: $($webRequestError.Exception.Message.ToString())"
            } else {
                Write-Output "Error: Unknown error occurred during download."
            }

        }
        
        # Stopwatch object to measure download time
        $StopWatch = [System.Diagnostics.Stopwatch]::new()
        # Start the stopwatch
        $StopWatch.Start()
        # Start the download with animation
        Write-HostAzS "Starting download of 126 MB test file..."
        $DownloadResult = Invoke-JobWithAnimation -JobName 'DownloadFileSpeedTest' -JobScriptBlock $DownloadFileSpeedTest
        # Check if the download was successful
        if($DownloadResult.State -eq 'Completed') {
            # Write-Host "Result: $($DownloadResult.Output)"
            # Check if the download was successful
            if($DownloadResult.Output) {
                if($DownloadResult.Output -like "*Status Code: 200*"){
                    Write-HostAzS "Download completed successfully." -ForegroundColor Green
                } else {
                    Write-HostAzS "Download error: $($DownloadResult.Output)"
                }
            }
        # Job not completed
        } elseif ($DownloadResult.State -eq 'Failed') {
            Write-HostAzS "Download test failed to complete." -ForegroundColor Red
            Write-HostAzS "Download error! Information: $($DownloadResult.Output) Error: $($DownloadResult.Error)"
        } else {
            Write-HostAzS "Download test failed to complete." -ForegroundColor Red
            Write-HostAzS "Download error! Information: $($DownloadResult.Output) Error: $($DownloadResult.Error)"
        }
        # clean up the job
        $job | Remove-Job -Force

        # Calculate the elapsed time
        [double]$downloadTime = $StopWatch.ElapsedMilliseconds/1000
        # Stop the stopwatch
        $StopWatch.Stop()
        # Output the elapsed time
        Write-HostAzS "Download duration time: $($downloadTime) seconds"

        # Get the file, then get the file size in bytes
        try {
            $DownloadedFile = (Get-Item $destinationPath -ErrorAction SilentlyContinue)
            if($DownloadedFile) {
                $fileSizeInBytes = $DownloadedFile.Length
            } else {
                Write-HostAzS "Error: Failed to find the download test file. Download failed?" -ForegroundColor Red
                $fileSizeInBytes = 0
            }
        } catch {
            Write-HostAzS "Error: Failed to find the download test file. Download failed?" -ForegroundColor Red
            $fileSizeInBytes = 0
        }

        # Output the file size
        Write-HostAzS "Download test file size: $([math]::Round($fileSizeInBytes / 1Mb, 2)) MB"
        Write-HostAzS "Calculating download speed..."
        # Calculate the download speed in Mbps
        # The formula for Mbits/sec is (file size in bytes * 8) / download time in seconds / 1,000,000
        # Breakdown of the formula:
        # File Size (bytes): The size of the file you are downloading.
        # Multiplying by 8: This is the conversion factor to convert file size from bytes to bits (1 byte = 8 bits).
        # Dividing by 1,000,000: This is the conversion factor to convert bits to megabits (1 megabit = 1,000,000 bits).
        # The formula calculates the download speed in megabits per second (Mbps).
        # The file size is multiplied by 8 to convert it from bytes to bits.
        # The download time is used to calculate the speed in seconds.
        # The result is divided by 1,000,000 to convert bits to megabits.
        # The result is rounded to 2 decimal places for better readability.
        # The final result is the download speed in megabits per second (Mbps).
        # The formula for Mbits/sec is (file size in bytes * 8) / download time in seconds / 1,000,000
        [double]$DownloadSpeed = $([math]::Round($fileSizeInBytes * 8 / $downloadTime / 1000000, 2))
        
        # Clean up the downloaded file
        if (Test-Path -Path $destinationPath) {
            try { 
                # Remove the downloaded file
                Remove-Item -Path $destinationPath -Force
            } catch {
                Write-HostAzS "Error: Failed to delete the file: $($destinationPath)."
                Write-HostAzS "Error: $($_.Exception.Message)"
            }
            # Check if the file was deleted successfully
            if (-not (Test-Path -Path $destinationPath)) {
                # Do nothing, successfully deleted
            } else {
                Write-HostAzS "Error: File cleanup failed for file: $($destinationPath)."
            }
        } else {
            # Do nothing, no file to clean up
        }

    } # End of process block

    end {
        # Write-Debug "Test-DownloadSpeed: Download speed test completed"
        
        # Return the download speed
        # The download speed is returned as a double value
        Return $DownloadSpeed
    }
}


# ////////////////////////////////////////////////////////////////////////////
# Parallel chunked download speed test - overcomes per-connection CDN throttling
# by downloading the same file in multiple parallel HTTP Range streams.
# This enables accurate speed measurement on connections faster than ~160 Mbps.
Function Test-DownloadSpeedParallel {
    <#
    .SYNOPSIS
        Test download speed using parallel HTTP Range requests to overcome per-connection CDN throttling.
    .DESCRIPTION
        The standard single-connection download from download.microsoft.com is throttled to ~160 Mbps
        per connection, making it unsuitable for testing higher-bandwidth links (e.g. 1 Gbps).
        This function uses multiple parallel HTTP Range requests to download different byte ranges
        of the same file simultaneously, then calculates aggregate throughput.
 
        Falls back to a single-stream download if the server does not support HTTP Range requests.
    .NOTES
        Requires PowerShell 5.0+. Uses Start-Job for parallelism (compatible with Windows PowerShell 5.1).
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$false)]
        [string]$DownloadFileUrl = 'https://aka.ms/WACDownload',

        [Parameter(Mandatory=$false)]
        [string]$DestinationFolder = 'C:\ProgramData\AzStackHci.DiagnosticSettings',

        [Parameter(Mandatory=$false)]
        [ValidateRange(1, 16)]
        [int]$ParallelStreams = 8
    )

    begin {
        # Write-Debug "Test-DownloadSpeedParallel: Beginning parallel download speed test"
    }

    process {

        # Create the destination folder if it doesn't exist
        if (-not (Test-Path -Path $DestinationFolder)) {
            New-Item -ItemType Directory -Path $DestinationFolder | Out-Null
        }

        # Clean up any leftover chunk files from a previous failed download attempt, if found in the destination folder
        Get-ChildItem -Path $DestinationFolder -Filter "speedtest_chunk_*.tmp" -ErrorAction SilentlyContinue |
            Remove-Item -Force -ErrorAction SilentlyContinue

        # ----------------------------------------------------------------
        # Step 1: HEAD request to get Content-Length and check Range support
        # ----------------------------------------------------------------
        Write-HostAzS "Resolving download URL and checking server capabilities..."
        $contentLength = 0
        $supportsRange = $false
        $resolvedUrl = $DownloadFileUrl

        try {
            # Follow redirects (aka.ms -> download.microsoft.com) and get headers
            # Use Invoke-WebRequest with -Method Head; some CDNs don't return Content-Length
            # on HEAD so we fall back to a small GET with Range if needed.
            $headResponse = Invoke-WebRequest -Uri $DownloadFileUrl -Method Head -UseBasicParsing -ErrorAction Stop -MaximumRedirection 10
            $resolvedUrl = $headResponse.BaseResponse.ResponseUri.AbsoluteUri
            if (-not $resolvedUrl) { $resolvedUrl = $DownloadFileUrl }

            if ($headResponse.Headers['Content-Length']) {
                $contentLength = [long]$headResponse.Headers['Content-Length']
            }
            if ($headResponse.Headers['Accept-Ranges'] -eq 'bytes') {
                $supportsRange = $true
            }
        } catch {
            Write-HostAzS "Warning: HEAD request failed ($($_.Exception.Message)). Attempting GET fallback..." -ForegroundColor Yellow
        }

        # If HEAD didn't give us Content-Length, try a small Range GET to probe
        # Note: 'Range' is a restricted header in .NET Framework / PS 5.1,
        # so we must use HttpWebRequest.AddRange() instead of Invoke-WebRequest -Headers.
        if ($contentLength -eq 0) {
            try {
                $probeRequest = [System.Net.HttpWebRequest]::Create($resolvedUrl)
                $probeRequest.Method = 'GET'
                $probeRequest.AddRange('bytes', 0, 0)
                $probeRequest.Timeout = 30000
                $probeHttpResponse = $probeRequest.GetResponse()
                $contentRangeHeader = $probeHttpResponse.Headers['Content-Range']
                if ($contentRangeHeader -match '/(\d+)') {
                    $contentLength = [long]$Matches[1]
                    $supportsRange = $true
                }
                $probeHttpResponse.Close()
            } catch {
                Write-HostAzS "Warning: Range probe failed. Will attempt single-stream download." -ForegroundColor Yellow
            }
        }

        # ----------------------------------------------------------------
        # Step 2: Decide parallel vs single-stream
        # ----------------------------------------------------------------
        if ($contentLength -eq 0) {
            Write-HostAzS "Could not determine file size. Falling back to single-stream download." -ForegroundColor Yellow
            $fallbackSpeed = Test-DownloadSpeed -DownloadFileUrl $DownloadFileUrl -DestinationFolder $DestinationFolder
            return $fallbackSpeed
        }

        $fileSizeMB = [math]::Round($contentLength / 1MB, 2)
        # Write-Host "File size: $fileSizeMB MB | Resolved URL: $resolvedUrl"
        Write-HostAzS "File size: $fileSizeMB MB"

        if (-not $supportsRange) {
            Write-HostAzS "Server does not support HTTP Range requests. Falling back to single-stream download." -ForegroundColor Yellow
            $fallbackSpeed = Test-DownloadSpeed -DownloadFileUrl $DownloadFileUrl -DestinationFolder $DestinationFolder
            return $fallbackSpeed
        }

        Write-HostAzS "Server supports HTTP Range requests. Using $ParallelStreams parallel streams."

        # ----------------------------------------------------------------
        # Step 3: Calculate byte ranges for each chunk
        # ----------------------------------------------------------------
        $chunkSize = [math]::Floor($contentLength / $ParallelStreams)
        $chunks = @()
        for ($i = 0; $i -lt $ParallelStreams; $i++) {
            $start = $i * $chunkSize
            if ($i -eq ($ParallelStreams - 1)) {
                # Last chunk gets the remainder
                $end = $contentLength - 1
            } else {
                $end = (($i + 1) * $chunkSize) - 1
            }
            $chunkFile = Join-Path $DestinationFolder "speedtest_chunk_$i.tmp"
            $chunks += [PSCustomObject]@{
                Index    = $i
                Start    = $start
                End      = $end
                Size     = ($end - $start + 1)
                FilePath = $chunkFile
            }
        }

        # ----------------------------------------------------------------
        # Step 4: Launch parallel download jobs
        # ----------------------------------------------------------------
        $StopWatch = [System.Diagnostics.Stopwatch]::new()
        $StopWatch.Start()

        Write-HostAzS "Starting parallel download of $fileSizeMB MB across $ParallelStreams streams..."

        $jobs = @()
        foreach ($chunk in $chunks) {
            $jobParams = @{
                Name        = "SpeedTestChunk_$($chunk.Index)"
                ScriptBlock = {
                    param($Url, $RangeStart, $RangeEnd, $OutFile)
                    # 'Range' is a restricted header in .NET Framework / PowerShell 5.1.
                    # Invoke-WebRequest -Headers @{ Range = ... } throws:
                    # "The 'Range' header must be modified using the appropriate property or method."
                    # Use System.Net.HttpWebRequest.AddRange() instead.
                    try {
                        $request = [System.Net.HttpWebRequest]::Create($Url)
                        $request.Method = 'GET'
                        $request.AddRange('bytes', [long]$RangeStart, [long]$RangeEnd)
                        $request.Timeout = 600000  # 10 minutes in milliseconds

                        $response = $request.GetResponse()
                        $responseStream = $response.GetResponseStream()
                        $fileStream = [System.IO.File]::Create($OutFile)

                        try {
                            $buffer = New-Object byte[] 65536  # 64 KB buffer
                            $bytesRead = 0
                            do {
                                $bytesRead = $responseStream.Read($buffer, 0, $buffer.Length)
                                if ($bytesRead -gt 0) {
                                    $fileStream.Write($buffer, 0, $bytesRead)
                                }
                            } while ($bytesRead -gt 0)
                        } finally {
                            $fileStream.Close()
                            $responseStream.Close()
                            $response.Close()
                        }
                        Write-Output "OK"
                    } catch {
                        Write-Output "Error: $($_.Exception.Message)"
                    }
                }
                ArgumentList = @($resolvedUrl, $chunk.Start, $chunk.End, $chunk.FilePath)
            }
            $jobs += Start-Job @jobParams
        }

        # ----------------------------------------------------------------
        # Step 5: Monitor progress with animation
        # ----------------------------------------------------------------
        $isConsole = Test-IsConsoleHost
        try {
            if ($isConsole) {
                $cursorTop = [Console]::CursorTop
                [Console]::CursorVisible = $false
            }
            $counter = 0
            $frames = '|', '/', '-', '\'
            $startTime = Get-Date

            while ($jobs | Where-Object { $_.State -eq 'Running' }) {
                # Timeout guard
                if (((Get-Date) - $startTime).TotalSeconds -ge $script:JOB_ANIMATION_TIMEOUT_SEC) {
                    Write-HostAzS "Parallel download timed out after $($script:JOB_ANIMATION_TIMEOUT_SEC) seconds." -ForegroundColor Red
                    $jobs | Where-Object { $_.State -eq 'Running' } | Stop-Job -ErrorAction SilentlyContinue
                    break
                }

                $runningCount = @($jobs | Where-Object { $_.State -eq 'Running' }).Count
                $completedCount = $ParallelStreams - $runningCount
                $frame = $frames[$counter % $frames.Length]
                $elapsed = ((Get-Date) - $startTime).ToString("hh\:mm\:ss")
                if ($isConsole) {
                    Write-HostAzS -ForegroundColor Green "Parallel download: $frame Streams: $completedCount/$ParallelStreams complete Time: $elapsed" -NoNewLine -SkipVerbose
                    [Console]::SetCursorPosition(0, $cursorTop)
                } elseif ($counter % 50 -eq 0) {
                    # Non-console host: emit a progress line every ~5 seconds
                    Write-HostAzS -ForegroundColor Green "Parallel download: Streams: $completedCount/$ParallelStreams complete Time: $elapsed"
                }
                $counter++
                Start-Sleep -Milliseconds 100
            }
        } finally {
            if ($isConsole) {
                [Console]::SetCursorPosition(0, $cursorTop)
                [Console]::CursorVisible = $true
            }
            Write-HostAzS ""
        }

        # Stop timing
        [double]$downloadTime = $StopWatch.ElapsedMilliseconds / 1000
        $StopWatch.Stop()

        # ----------------------------------------------------------------
        # Step 6: Collect results and calculate speed
        # ----------------------------------------------------------------
        $totalBytesDownloaded = [long]0
        $allSucceeded = $true

        foreach ($chunk in $chunks) {
            $job = $jobs | Where-Object { $_.Name -eq "SpeedTestChunk_$($chunk.Index)" }
            $jobOutput = Receive-Job -Job $job -Wait

            if ($jobOutput -like "OK*") {
                # Verify chunk file exists and get actual size
                if (Test-Path -Path $chunk.FilePath) {
                    $actualSize = (Get-Item $chunk.FilePath).Length
                    $totalBytesDownloaded += $actualSize
                } else {
                    Write-HostAzS "Warning: Chunk $($chunk.Index) file not found." -ForegroundColor Yellow
                    $allSucceeded = $false
                }
            } else {
                Write-HostAzS "Warning: Chunk $($chunk.Index) failed: $jobOutput" -ForegroundColor Yellow
                $allSucceeded = $false
            }
        }

        # Clean up jobs
        $jobs | Remove-Job -Force

        if ($allSucceeded) {
            Write-HostAzS "All $ParallelStreams streams completed successfully." -ForegroundColor Green
        } else {
            Write-HostAzS "Some streams failed. Speed calculation based on successfully downloaded data." -ForegroundColor Yellow
        }

        Write-HostAzS "Download duration: $downloadTime seconds"
        Write-HostAzS "Total data downloaded: $([math]::Round($totalBytesDownloaded / 1MB, 2)) MB"

        # Calculate speed: (bytes * 8) / seconds / 1,000,000 = Mbps
        if ($downloadTime -gt 0 -and $totalBytesDownloaded -gt 0) {
            [double]$DownloadSpeed = [math]::Round($totalBytesDownloaded * 8 / $downloadTime / 1000000, 2)
        } else {
            [double]$DownloadSpeed = 0
        }

        Write-HostAzS "Calculating download speed..."

        # ----------------------------------------------------------------
        # Step 7: Clean up chunk files
        # ----------------------------------------------------------------
        foreach ($chunk in $chunks) {
            if (Test-Path -Path $chunk.FilePath) {
                try {
                    Remove-Item -Path $chunk.FilePath -Force
                } catch {
                    Write-HostAzS "Warning: Failed to delete chunk file: $($chunk.FilePath)" -ForegroundColor Yellow
                }
            }
        }

    } # End of process block

    end {
        # Return the aggregate download speed in Megabits/second
        Return $DownloadSpeed
    }
}


# ////////////////////////////////////////////////////////////////////////////////////////////
# Function to perform NTP test for time.windows.com or custom NTP server using w32tm command
Function Test-NTPConnectivity {
    param (
        [string]$ntpServer
    )

    begin {
        # Write-Verbose "Starting Test-NTPConnectivity function"
    }

    process {

        Write-HostAzS "Testing NTP connectivity to '$ntpServer' on UDP port 123"
        # Run the w32tm command to test NTP connectivity, with 2 samples
        $ntpResult = & "$env:windir\System32\w32tm.exe" /stripchart /computer:$ntpServer /dataonly /samples:2
        # Check if the result contains "error:"
        if ($ntpResult -match "error:") {
            Write-HostAzS "NTP Test: Failed" -ForegroundColor Red
            # Write the error message, first item, as there could be two lines
            Write-HostAzS "Diagnostic info: $($ntpResult | Where-Object { $_ -like '*error*'} | Select-Object -First 1)" -ForegroundColor Red
            $status = "Failed"
            $ipAddress = ""
        } else {
            Write-HostAzS "NTP Test: Success" -ForegroundColor Green
            $status = "Success"
            if ($ntpResult -match "\[(.*?)\]") {
                # find the IP address in the square brackets of the result, first row, zero
                $ipAddress = $ntpResult[0].Substring($ntpResult[0].IndexOf("[")+1,$ntpResult[0].IndexOf("]")-$ntpResult[0].IndexOf("[")-1)
            } else {
                $ipAddress = ""
            }
        }

    } # End of process block

    end {
        # Write-Debug "Completed Test-NTPConnectivity function"
        
        # Return the status and IP address
        return $status, $ipAddress

    }

}


# ////////////////////////////////////////////////////////////////////////////
Function Invoke-UploadDiagnosticResults {

    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true, Position=0)]
        [ValidateScript( { Test-Path $_ } )]
        [string]$FolderToUpload,

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

    begin {
        # Write-Debug "Invoke-UploadDiagnosticResults: Beginning diagnostic results upload process"
    }

    process {
        # In silent mode (-NoOutput), default to uploading without prompting the user
        if ($script:SilentMode) {
            $UploadResults = "Y"
        } else {
            # Ask the user if they want to upload the results using Send-DiagnosticData
            Write-HostAzS ""
            $UploadResults = Read-Host -Prompt $MessageToDisplay
        }
        if($UploadResults -eq "Y"){
            # Call the Send-DiagnosticData function to upload the results
            Write-HostAzS "`n`tUploading data / test results to Microsoft..." -ForegroundColor Green
            # Upload the results using Send-DiagnosticData
            # Send the diagnostic information to Microsoft, using: " -BypassObsAgent -NoLogCollection -SupplementaryLogs <PathToFolder>" parameters
            # https://learn.microsoft.com/en-us/azure/azure-local/manage/collect-logs?view=azloc-24113&tabs=powershell#send-diagnosticdata-command-reference
            try {
                Write-HostAzS "Sending the Connectivity Test results information to Microsoft...`n"
                # Script block to execute the Send-DiagnosticData function, to find unwanted output
                [scriptblock]$scriptblock = { $VerbosePreference = 'SilentlyContinue'; Send-DiagnosticData -BypassObsAgent -NoLogCollection -SupplementaryLogs $using:FolderToUpload -ErrorAction Continue }
                # Execute the script block in a new PowerShell process, to avoid unwanted output
                $UploadResults = Invoke-Command -ComputerName localhost -ScriptBlock $scriptblock -ErrorAction SilentlyContinue -ErrorVariable UploadError
            } catch {
                Write-Error "Failed to send data / test results to Microsoft using Send-DiagnosticData $($_.Exception.Message)"
            } finally {
                # Check if the upload was successful
                if($UploadError){
                    Write-HostAzS "`nUpload error: $($UploadError.Exception.Message)`n`n" -ForegroundColor Red
                } else {
                    Write-HostAzS "`nUpload complete.`n" -ForegroundColor Green
                    Write-HostAzS "`nNote: If you are working with Microsoft CSS support, please share the text output above to help the engineer identify your cluster details.`n`n"
                }
            }
        } else {
            Write-HostAzS "`nUser requested to skip uploading data / test results to Microsoft.`n" -ForegroundColor Green
        }
    } # End of process block

    end {
        # Write-Debug "Invoke-UploadDiagnosticResults: Diagnostic results upload process completed"
    }
} # End Function Invoke-UploadDiagnosticResults


# ////////////////////////////////////////////////////////////////////////////

# Function to check if Domain GPOs are applied to OS:
function Test-DomainGPOsApplied {
    <#
    .SYNOPSIS
        Parses gpresult output to identify applied Group Policy Objects.
     
    .DESCRIPTION
        Extracts all applied GPOs from gpresult output and identifies whether domain GPOs are applied.
     
    .PARAMETER GPResultOutput
        The output from gpresult command as a string or array of strings.
     
    .EXAMPLE
        $gpOutput = & gpresult /scope computer /z
        $appliedGPOs = Test-DomainGPOsApplied -GPResultOutput $gpOutput
     
    .NOTES
        Author: Neil Bird, MSFT
        Version: 1.1
        Updated: December 4th 2025
    #>

    
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$false)]
        [AllowNull()]
        [AllowEmptyString()]
        [AllowEmptyCollection()]
        [object]$GPResultOutput
    )
    
    begin {
        Write-Debug "Test-DomainGPOsApplied: Starting GPO parsing"
        if ($GPResultOutput) {
            Write-Debug "Input type: $($GPResultOutput.GetType().Name)"
            if ($GPResultOutput -is [array]) {
                Write-Debug "Input is array with $($GPResultOutput.Count) elements"
            }
        } else {
            Write-Debug "Input is null or empty"
        }
    }
    
    process {
        try {
            # Validate we have content to parse first
            if (-not $GPResultOutput) {
                Write-Warning "GPResult output is null"
                return @()
            }
            
            # Convert to array of lines if it's a single string
            if ($GPResultOutput -is [string]) {
                Write-Debug "Converting string input to array of lines"
                $GPResultOutput = $GPResultOutput -split "`r?`n"
            }
            
            # Check if array is empty or contains only whitespace
            if ($GPResultOutput.Count -eq 0) {
                Write-Warning "GPResult output array is empty"
                return @()
            }
            
            # Filter out completely empty elements
            $nonEmptyLines = @($GPResultOutput | Where-Object { $null -ne $_ })
            if ($nonEmptyLines.Count -eq 0) {
                Write-Warning "GPResult output contains no valid content"
                return @()
            }
            
            Write-Debug "Parsing $($GPResultOutput.Count) lines of gpresult output"
            
            # Find the "Applied Group Policy Objects" section
            $inAppliedGPOSection = $false
            $appliedGPOs = @()
            $lineNumber = 0
            
            foreach ($line in $GPResultOutput) {
                $lineNumber++
                
                # Check if we're entering the Applied Group Policy Objects section
                if ($line -match '^\s*Applied Group Policy Objects\s*$') {
                    Write-Debug "Found 'Applied Group Policy Objects' section at line $lineNumber"
                    $inAppliedGPOSection = $true
                    continue
                }
                
                # Check if we've exited the section (next section starts)
                if ($inAppliedGPOSection -and $line -match '^\s*The computer is a part of') {
                    Write-Debug "Exiting GPO section at line $lineNumber (next section detected)"
                    break
                }
                
                # If we're in the section and the line has content (not just dashes or empty)
                $trimmedLine = $line.Trim()
                if ($inAppliedGPOSection -and $trimmedLine -and $trimmedLine -notmatch '^-+$') {
                    Write-Debug "Found GPO at line $lineNumber : '$trimmedLine'"
                    $appliedGPOs += $trimmedLine
                }
            }
            
            # Validate we found the section
            if (-not $inAppliedGPOSection) {
                Write-Warning "Could not locate 'Applied Group Policy Objects' section in gpresult output"
                return @()
            }
            
            if($($appliedGPOs.Count) -eq 1) {
                Write-Debug "Found $($appliedGPOs.Count) applied GPO: '$appliedGPOs'"
            } elseif($($appliedGPOs.Count) -gt 1) {
                Write-Debug "Found $($appliedGPOs.Count) applied GPOs`n$($appliedGPOs -join "`n")"
            } elseif ($($appliedGPOs.Count) -eq 0) {
                Write-Debug "No applied GPOs found in the section"
            } else {
                Write-Debug "Unexpected count of applied GPOs: $($appliedGPOs.Count)"
            }
            
            # Return all applied GPO names
            return $appliedGPOs
            
        } catch {
            Write-Error "Error parsing GPResult output: $($_.Exception.Message)"
            Write-Debug "Error details: $($_.Exception)"
            return @()
        }
    }
    
    end {
        Write-Debug "Test-DomainGPOsApplied: Completed"
    }
} # End of Test-DomainGPOsApplied function