Private/AzStackHci.Utility.Helpers.ps1

# ////////////////////////////////////////////////////////////////////////////
# Strict Mode v1 (PS 5.1 safe) - surfaces reads of uninitialised variables at runtime.
Set-StrictMode -Version 1.0

# 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

# Module-level verbose/debug preference state. Initialised here (default: silent) so the
# save/restore logic in the public functions never READS an uninitialised script-scoped
# variable under Set-StrictMode -Version 1.0. Public functions snapshot these into
# $script:SavedVerbosePref / $script:SavedDebugPref, override to 'SilentlyContinue' (or
# 'Continue' when -Verbose/-Debug is passed), and restore them in their end block.
$script:VerbosePreference = 'SilentlyContinue'
$script:DebugPreference   = 'SilentlyContinue'
$script:SavedVerbosePref  = 'SilentlyContinue'
$script:SavedDebugPref    = 'SilentlyContinue'

# ////////////////////////////////////////////////////////////////////////////
# Centralised verbose/debug preference state management for public entry-point functions.
#
# Why this exists:
# Azure Local nodes frequently have machine-level $VerbosePreference = 'Continue', which
# leaks verbose output from every cmdlet. The public functions snapshot the module-scope
# preference, force it silent, optionally re-enable it when the caller passes -Verbose/-Debug,
# and restore the snapshot when they finish. That save -> override -> restore block was
# duplicated across every public entry point; these two helpers centralise it.
#
# Scope notes (PS 5.1):
# - These write $script:-scoped (module-scope) variables, which ARE shared across every
# nested module file at runtime, so the override genuinely takes effect module-wide.
# - $script:SilentMode is intentionally NOT touched here. Each caller manages SilentMode
# itself (some reset it, some don't, to preserve a parent's -NoOutput across nested
# child calls), and Enter-AzSPreferenceScope only READS it to decide whether -Verbose/
# -Debug should re-enable output.
# - The function-LOCAL $VerbosePreference / $DebugPreference assignment (needed so
# CmdletBinding propagates the resolved preference to child advanced functions) CANNOT
# live in a helper — a helper setting $VerbosePreference would only set its own local.
# Callers must copy $script:VerbosePreference / $script:DebugPreference into their local
# scope immediately after calling Enter-AzSPreferenceScope.
function Enter-AzSPreferenceScope {
    [CmdletBinding()]
    param(
        [bool]$VerboseRequested,
        [bool]$DebugRequested
    )
    $script:SavedVerbosePref  = $script:VerbosePreference
    $script:SavedDebugPref    = $script:DebugPreference
    $script:VerbosePreference = 'SilentlyContinue'
    $script:DebugPreference   = 'SilentlyContinue'
    $script:VerboseMode       = $false
    if (-not $script:SilentMode) {
        if ($DebugRequested)   { $script:DebugPreference   = 'Continue' }
        if ($VerboseRequested) { $script:VerbosePreference = 'Continue'; $script:VerboseMode = $true }
    }
}

# Restores the module-scope verbose/debug preference snapshot captured by
# Enter-AzSPreferenceScope. Call from the end block of each public entry-point function.
# Does NOT touch $script:SilentMode (caller-managed — see Enter-AzSPreferenceScope notes).
function Exit-AzSPreferenceScope {
    [CmdletBinding()]
    param()
    $script:VerbosePreference = $script:SavedVerbosePref
    $script:DebugPreference   = $script:SavedDebugPref
    $script:VerboseMode       = $false
}

# ////////////////////////////////////////////////////////////////////////////
# Write a string to a file as UTF-8 WITHOUT a byte-order mark (BOM).
# Why this exists:
# On Windows PowerShell 5.1, `Set-Content -Encoding UTF8` and `Out-File -Encoding UTF8`
# prepend a 3-byte BOM (EF BB BF). PS 5.1's own ConvertFrom-Json tolerates it, but strict
# external JSON consumers (jq, Python json, .NET JsonDocument, Log Analytics ingestion)
# reject a leading BOM and it surfaces as a mojibake glyph (´╗┐) on line 1.
# [System.IO.File]::WriteAllText with a UTF8Encoding($false) writes BOM-less UTF-8.
# Path handling:
# .NET resolves relative paths against [Environment]::CurrentDirectory, which can differ
# from PowerShell's $PWD. GetUnresolvedProviderPathFromPSPath resolves the path the same
# way PowerShell would, so callers can pass either absolute or PS-relative paths safely.
function Write-Utf8NoBom {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        # Accept an array as well as a single string: ConvertTo-Html (and ConvertTo-Xml)
        # return an Object[] of lines, which would otherwise fail to bind to a scalar
        # [string] parameter with "Cannot convert value to type System.String". Array
        # content is joined with the platform newline, matching the old Out-File output.
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [AllowEmptyCollection()]
        [string[]]$Content
    )
    $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
    $text = $Content -join [Environment]::NewLine
    [System.IO.File]::WriteAllText($resolvedPath, $text, (New-Object System.Text.UTF8Encoding($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)
}

# ////////////////////////////////////////////////////////////////////////////
# Get the highest-version loaded copy of a module by name.
# Why this exists:
# When a module's NestedModules entries each load via `Import-Module`, calling
# `Get-Module -Name X` returns an ARRAY of PSModuleInfo objects (one per nested
# module file). `(Get-Module -Name X).Version` is then `Object[]`, not [version],
# and `.ToString()` produces literally "System.Object[]" instead of a version
# string. This is a recurring footgun (see user-memory: powershell-patterns.md).
# Centralising the dedupe + sort here keeps callers from having to remember it.
# Returns $null if the module is not loaded.
function Get-LoadedModuleVersion {
    [CmdletBinding()]
    [OutputType([version])]
    param(
        [Parameter(Mandatory)][string]$Name
    )
    $entries = @(Get-Module -Name $Name -ErrorAction SilentlyContinue)
    if ($entries.Count -eq 0) { return $null }
    return ($entries | Sort-Object Version -Descending | Select-Object -First 1).Version
}

# ////////////////////////////////////////////////////////////////////////////
# 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
    )
    # When a parallel worker (Invoke-Layer7BucketWorker) sets $script:WorkerLogPrefix
    # to e.g. '[bucket-3] ', every line emitted from inside that worker is tagged so
    # the interleaved transcript can be grep'd / filtered by bucket. Empty when
    # not in a worker process. Skip prefixing blank lines (preserves spacing).
    if ($script:WorkerLogPrefix -and $Object -and $Object.ToString().Trim()) {
        $Object = "$($script:WorkerLogPrefix)$Object"
    }
    # 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
        try {
            $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
            }
        } finally {
            if ($script:job) {
                $script:job | Remove-Job -Force -ErrorAction SilentlyContinue
            }
        }
    
    } # 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
        if ($downloadTime -le 0) { $downloadTime = 0.001 } # Prevent division by zero if download completes instantly
        [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
                }
            } catch {
                Write-HostAzS "Warning: Range probe failed. Will attempt single-stream download." -ForegroundColor Yellow
            } finally {
                if ($probeHttpResponse) { $probeHttpResponse.Close() }
            }
        }

        # ----------------------------------------------------------------
        # 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 ""
        }

        try {
            # 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
                }
            }

            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..."
        } finally {
            # Clean up jobs on all exit paths (success or exception)
            if ($jobs) {
                $jobs | Remove-Job -Force -ErrorAction SilentlyContinue
            }

            # Clean up chunk files on all exit paths
            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"

        # UDP/123 can drop a single probe transiently, so retry once after a short delay
        # before declaring failure. Each attempt runs w32tm with 2 stratum samples.
        $maxAttempts       = 2
        $retryDelaySeconds = 2
        $attempt           = 0
        $ntpResult         = $null
        while ($attempt -lt $maxAttempts) {
            $attempt++
            # Run the w32tm command to test NTP connectivity, with 2 samples
            $ntpResult = & "$env:windir\System32\w32tm.exe" /stripchart /computer:$ntpServer /dataonly /samples:2
            # -match on an array returns matching elements; [bool] of a non-empty array is $true
            if (-not [bool]($ntpResult -match "error:")) {
                break
            }
            if ($attempt -lt $maxAttempts) {
                Write-HostAzS " NTP attempt $attempt of $maxAttempts failed; retrying in $retryDelaySeconds second(s)..." -ForegroundColor Yellow
                Start-Sleep -Seconds $retryDelaySeconds
            }
        }

        # Check if the (final) 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 -join "`n") -match '\[([^\]]+)\]') {
                # Extract the IP address from the square brackets using the regex match
                # Note: -match on an array filters elements but does NOT populate $Matches;
                # joining into a single string ensures $Matches is populated.
                $ipAddress = $Matches[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,

        # Human-readable label identifying what kind of data is being uploaded. Interpolated
        # into the console messages so OS-config callers don't see "Connectivity Test"
        # wording and vice versa. Keep short (<= ~50 chars) and sentence-case.
        [Parameter(Mandatory=$false)]
        [ValidateNotNullOrEmpty()]
        [string]$Context = 'Connectivity Test results'
    )

    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 $Context 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

# SIG # Begin signature block
# MIIncQYJKoZIhvcNAQcCoIInYjCCJ14CAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBKkOz0HuhyC9Sn
# Q73WJM1iPCa8yToGBH2b4QwujCLlA6CCDMkwggYEMIID7KADAgECAhMzAAACHPrN
# xZvoL37EAAAAAAIcMA0GCSqGSIb3DQEBCwUAMFcxCzAJBgNVBAYTAlVTMR4wHAYD
# VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBD
# b2RlIFNpZ25pbmcgUENBIDIwMjQwHhcNMjYwNDE2MTg1OTQxWhcNMjcwNDE1MTg1
# OTQxWjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE
# BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYD
# VQQDExVNaWNyb3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IB
# DwAwggEKAoIBAQDVsZfgOKmM31HPfoWOoNEiw0SlCiIxUMC0I9NMWbucKOw/e9lP
# oAoehQVu6SG65V4EPzrYsnBnFPNoi4/HoOdjhz1qkrEt4I6tEcxXU6oOeY9zGveC
# /3iBeuhLYxM3M/PkcUoebF+Nednm8OkdSPoDu8imViHPQq/8CQUu0WRR4rE+dMRf
# rpVqfmNi2qWCX94T4MsepijGVkwE//tJg0ryAiYdHT34LSnlG/RSBZmQRGWZ5g8j
# qnKjRParSqMft1gvjuUTVgtWNZfgcLFSK5Wa0myrq8OPcgTGGsRgun+tnSS+IxDT
# xVsAPH1OzvPjwomguByhUe/OcvUN0D5Wmp7xAgMBAAGjggGqMIIBpjAOBgNVHQ8B
# Af8EBAMCB4AwHwYDVR0lBBgwFgYKKwYBBAGCN0wIAQYIKwYBBQUHAwMwHQYDVR0O
# BBYEFNoH7a2YDjOSwpkp6DHcmUS7J+0yMFQGA1UdEQRNMEukSTBHMS0wKwYDVQQL
# EyRNaWNyb3NvZnQgSXJlbGFuZCBPcGVyYXRpb25zIExpbWl0ZWQxFjAUBgNVBAUT
# DTIzMDAxMis1MDc1NjkwHwYDVR0jBBgwFoAUf1k/VCHarU/vBeXmo9ctBpQSCDEw
# YAYDVR0fBFkwVzBVoFOgUYZPaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9w
# cy9jcmwvTWljcm9zb2Z0JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNy
# bDBtBggrBgEFBQcBAQRhMF8wXQYIKwYBBQUHMAKGUWh0dHA6Ly93d3cubWljcm9z
# b2Z0LmNvbS9wa2lvcHMvY2VydHMvTWljcm9zb2Z0JTIwQ29kZSUyMFNpZ25pbmcl
# MjBQQ0ElMjAyMDI0LmNydDAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IC
# AQAUnEqhaRXe0T3hIJjvdQErEkrA/7bByjn6t5IArODkkRjzkYwtKMc2yYj2quaN
# rLutWw2YZcngKPy1b71YyDJQTy4NDRwaSh9Tw5thrk3NmcPrAHia5vtcBJ1CgtKK
# 7mQbIcQ22d/N3813ayCDDFewu1+jsZmX+r/aTEqaOM4TVxVtRSkuCy8nAXKuChOK
# Li/zA4XuH8iEYqIsj2YoNaeSxVmeGiERXpKdo3dDmYi0kO5w2D8VS4c3+9h6gElY
# BaAAg/dYErBg27qT3vv0zRDJhJufvCNylA8S7/+8H5E/PV5cng6na9VV/w9OV3qu
# uND6zdGa2EX38Glp50F9AIQk3p2xXmcvorDeM4XJ7UlWYBi6g80J1SSOQnInCYFE
# msfUNn3+1AaTJKSJL83quKArTac2pKhu0Yzzzrzo6HrsRiQKzpnRBb1/dMa6P3hz
# 75XbMRBctNsFhZC07WCmjExdLg2eHW5uV0TY8D5+6wozJf7vF3+WHkYPO85Z+BC6
# U4FkNbYNycZ9cE4j1tXRdyDCfml6c0HWPHjNVDObrv9lKt3qUqFpX38VCqVCyNOO
# 1UcXfQiVjJw32U2WUKZjt/neJKHEBsm9kFsLuWzkQ53+qcaSaytmsCnk2gOglrlD
# 5d3kKyvvAw+rzm0lT8K38P6PLxfZQHhu4W8dV7Av8N2ZmDCCBr0wggSloAMCAQIC
# EzMAAAA5O7Y3Gb8GHWcAAAAAADkwDQYJKoZIhvcNAQEMBQAwgYgxCzAJBgNVBAYT
# AlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYD
# VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29mdCBS
# b290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDExMB4XDTI0MDgwODIwNTQxOFoX
# DTM2MDMyMjIyMTMwNFowVzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29m
# dCBDb3Jwb3JhdGlvbjEoMCYGA1UEAxMfTWljcm9zb2Z0IENvZGUgU2lnbmluZyBQ
# Q0EgMjAyNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANgBnB7jOMeq
# lRYHNa265v4IY9fH8TKhemHfPINe1gpLaV3dhg324WwH06LcHbpnsBukCDNitryo
# 0dtS/EW6I/yEL/bLSY8hKpbfQuWusBPr9qazYcDxCW/qnjb5JsI1s8bNOg3bVATv
# QVL4tcf03aTycsz8QeCdM0l/yHRObJ9QqazM1r6VPEOJ7LL+uEEb73w6QCuhs89a
# 1uv1zerOYMnsneRRwCbpyW11IcggU0cRKDDq1pjVJzIbIF6+oiXXbReOsgeI8zu1
# FyQfK0fVkaya8SmVHQ/tOf23mZ4W9k0Ri22QW9p3UgSC5OUDktKxxcCmGL6tXLfO
# GSWHIIV4YrTJTT6PNty5REojHJuZHArkF9VnHTERWoTjAzfI3kP+5b4alUdhgAZ7
# ttOu1bVnXfHaqPYl2rPs20ji03LOVWsh/radgE17es5hL+t6lV0eVHrVhsssROWJ
# uz2MXMCt7iw7lFPG9LXKGjsmonn2gotGdHIuEg5JnJMJVmixd5LRlkmgYRZKzhxS
# CwyoGIq0PhaA7Y+VPct5pCHkijcIIDm0nlkK+0KyepolcqGm0T/GYQRMhHJlGOOm
# VQop36wUVUYklUy++vDWeEgEo4s7hxN6mIbf2MSIQ/iIfMZgJxC69oukMUXCrOC3
# SkE/xIkgpfl22MM1itkZ35nNXkMolU1lAgMBAAGjggFOMIIBSjAOBgNVHQ8BAf8E
# BAMCAYYwEAYJKwYBBAGCNxUBBAMCAQAwHQYDVR0OBBYEFH9ZP1Qh2q1P7wXl5qPX
# LQaUEggxMBkGCSsGAQQBgjcUAgQMHgoAUwB1AGIAQwBBMA8GA1UdEwEB/wQFMAMB
# Af8wHwYDVR0jBBgwFoAUci06AjGQQ7kUBU7h6qfHMdEjiTQwWgYDVR0fBFMwUTBP
# oE2gS4ZJaHR0cDovL2NybC5taWNyb3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMv
# TWljUm9vQ2VyQXV0MjAxMV8yMDExXzAzXzIyLmNybDBeBggrBgEFBQcBAQRSMFAw
# TgYIKwYBBQUHMAKGQmh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvY2VydHMv
# TWljUm9vQ2VyQXV0MjAxMV8yMDExXzAzXzIyLmNydDANBgkqhkiG9w0BAQwFAAOC
# AgEAFJQfOChP7onn6fLIMKrSlN1WYKwDFgAddymOUO3FrM8d7B/W/iQ6DxXsDn7D
# 5W4wMwYeLystcEqfkjz4NURRgazyMu5yRzQh4LqjA4tStTcJh1opExo7nn5PuPBY
# nbu0+THSuVHTe0VTTPVhily/piFrDo3axQ9P4C+Ol5yet+2gTfekICS5xS+cYfSI
# vgn0JksVBVMYVI5QFu/qhnLhsEFEUzG8fvv0hjgkO+lkpV9ty6GkN4vdnd7ya6Q6
# aR9y34aiM1qmxaxBi6OUnyNl6fkuun/diTFnYDLTppOkr/mg5WSfCiDVMNCxtj4w
# PKC5OmHm1DQIt/MNokbbH3UGsFP1QbzsLocuSqLCvH09Io3fDPTmscR9Y75G4qX7
# RTX8AdBPo0I6OEojf39zuFZt0qOHm65YWQE69cZM2ueE1MB05dNNgHK9gTE7zKvK
# /fg8B2qjW88MT/WF5V5uvZGtqa9FSL2RazArA+rDPuf6JGYz4HpgMZHB4S6szWSK
# YBv0VisCzfxgeU+dquXW9bd0auYlOB58DPcOYKdc3Se94g+xL4pcEhbB54JOgAkw
# YTu/9dLeH2pDqeJZAABVDWRQCaXfO5LgyKwKCLYXpigrZYCjUSBcr+Ve8PFWMhVT
# Ql0v4q8J/AUmQN5W4n101cY2L4A7GTQG1h32HHAvfQESWP0xghn+MIIZ+gIBATBu
# MFcxCzAJBgNVBAYTAlVTMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24x
# KDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMjQCEzMAAAIc
# +s3Fm+gvfsQAAAAAAhwwDQYJYIZIAWUDBAIBBQCgga4wGQYJKoZIhvcNAQkDMQwG
# CisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwGCisGAQQBgjcCARUwLwYJKoZI
# hvcNAQkEMSIEIKRgpyOOmBm02guAqKTC9osnOGNFIf6JBaHOTp5Pu5vDMEIGCisG
# AQQBgjcCAQwxNDAyoBSAEgBNAGkAYwByAG8AcwBvAGYAdKEagBhodHRwOi8vd3d3
# Lm1pY3Jvc29mdC5jb20wDQYJKoZIhvcNAQEBBQAEggEAv6hkQV7ZFmWFNX+ys2jo
# l+MhFsKS8suayR/QsNrmQMxcqz/FY1mCLbw+KwpzCOzSF27YTTC8eM/rTYfWuDrw
# 6/a3Qh8mCbFEX87bSdVcLlq+Lg971a5FxcZEPNGZiVjqY4HlA4ezIEHVvqWh7UgF
# oOc67qWpCwaknMFdTXHbnp6QL5YWHdTL0gss4YCj8UNe6smsJvxYabKwxaDEhfFP
# J321bINDPxI+FoEaK3Kon+epxTZAplNVDhuBr0gl9O88opwnESAqvyXoSllobYfQ
# XATJ8RBgqDRDqhs3bHcPdBZWba1b2Xee0rS48rBwDrPNoTozogSJd9Akln++ItvL
# bqGCF7AwghesBgorBgEEAYI3AwMBMYIXnDCCF5gGCSqGSIb3DQEHAqCCF4kwgheF
# AgEDMQ8wDQYJYIZIAWUDBAIBBQAwggFaBgsqhkiG9w0BCRABBKCCAUkEggFFMIIB
# QQIBAQYKKwYBBAGEWQoDATAxMA0GCWCGSAFlAwQCAQUABCCjK48pGeHWcYqYeJp8
# FhfB/xCJVgn1j6lCwylbWNA4mAIGajWSCRu4GBMyMDI2MDcwNzE2NTYzMi4wNDRa
# MASAAgH0oIHZpIHWMIHTMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3Rv
# bjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0
# aW9uMS0wKwYDVQQLEyRNaWNyb3NvZnQgSXJlbGFuZCBPcGVyYXRpb25zIExpbWl0
# ZWQxJzAlBgNVBAsTHm5TaGllbGQgVFNTIEVTTjo1MjFBLTA1RTAtRDk0NzElMCMG
# A1UEAxMcTWljcm9zb2Z0IFRpbWUtU3RhbXAgU2VydmljZaCCEf4wggcoMIIFEKAD
# AgECAhMzAAACF3H7LqWvAR3qAAEAAAIXMA0GCSqGSIb3DQEBCwUAMHwxCzAJBgNV
# BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4w
# HAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29m
# dCBUaW1lLVN0YW1wIFBDQSAyMDEwMB4XDTI1MDgxNDE4NDgyM1oXDTI2MTExMzE4
# NDgyM1owgdMxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYD
# VQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xLTAr
# BgNVBAsTJE1pY3Jvc29mdCBJcmVsYW5kIE9wZXJhdGlvbnMgTGltaXRlZDEnMCUG
# A1UECxMeblNoaWVsZCBUU1MgRVNOOjUyMUEtMDVFMC1EOTQ3MSUwIwYDVQQDExxN
# aWNyb3NvZnQgVGltZS1TdGFtcCBTZXJ2aWNlMIICIjANBgkqhkiG9w0BAQEFAAOC
# Ag8AMIICCgKCAgEAwM82sEw+39vYR7iGCIFDnYNhRM+BzF2AYiq5dUpZpJFPRjCc
# ipQ6RUbI+RAYNRApExx5ygrXbaWtuwvqsqAVSWbU/W6fecujjILkPqn9pngtWRkf
# QgbYgvaXALl6PY2yOH9f72MD+6AyxQenSpAMdUzY/Qk/jtjsHdFXVBe+tshlIkSJ
# 3GZw8VVKqTg3GZElztwbJWNtrhBEvhf6anxMegQMJP7tO8/BJ7ITs4/AV3D2bv8e
# Hk81Y+fOmQ8mQ61WLq2wItvlzIT5bzelK9LvEycf5x1lXxAwEw5a7dpS+CKTanht
# v+Q2mwebAybjf9io4k48stTaq1rtcrOiDwddqVm1S9e8h1TszXFzjLLvE9EmjnNf
# IewsY+RChUaHnY4FFwwJEnEv/JS76oHT0oGdy7+J60fGOl7A1UoUyAkhpb2Bja+S
# wSIiHbQ4FDyJiLlZ6drZZ84MoJ852JSxM0hBjGO6FZlPO8iuNyk680Di8VnbSNpI
# dJN+DhlepeTUMBDHqCmd0mVWRWZPm1pvgty93asNt/Ng6o4m2dnooWOdM3yKsJaW
# jyHqic9gfTrZBM+PCXqeTaO1oEiaQ+h4w0nHVdV+XSvI2m1yN4iibqjm5HPaAO3O
# J+OmNLftNVmr4Z6U2T6pIcLBysoKcDUvCqycXj4C/+n1KFBpDGdDMw9gmu8CAwEA
# AaOCAUkwggFFMB0GA1UdDgQWBBRQrN9jlwNOoeE5ZQqnF5x8S1bJQzAfBgNVHSME
# GDAWgBSfpxVdAF5iXYP05dJlpxtTNRnpcjBfBgNVHR8EWDBWMFSgUqBQhk5odHRw
# Oi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NybC9NaWNyb3NvZnQlMjBUaW1l
# LVN0YW1wJTIwUENBJTIwMjAxMCgxKS5jcmwwbAYIKwYBBQUHAQEEYDBeMFwGCCsG
# AQUFBzAChlBodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NlcnRzL01p
# Y3Jvc29mdCUyMFRpbWUtU3RhbXAlMjBQQ0ElMjAyMDEwKDEpLmNydDAMBgNVHRMB
# Af8EAjAAMBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMA4GA1UdDwEB/wQEAwIHgDAN
# BgkqhkiG9w0BAQsFAAOCAgEARmgFdhB7xIAIHEEg5I/5S+gx67aR6RiW8ZAwtE3m
# z8o0dyn+pIP+lidNR1IKQQ0r+RjYgI9cZ6mbvAyvh3e2q/BV8rjHE3ud9PyYyq32
# euFgdZ3vX4b5QXePWlpBAYrdziR27rHz6WwpH5dZsSypbXDBbQkWkNl6g82yTy3A
# bBbKDXBdzxZsEauaOplatK7Er4dhglKBex8JQ2dMSkSZweCNDXqd9r/9W2VdRZsD
# JKP/Xc4UyQlVsboBotKtYESXFkjwR1HVsH+Q0C69/N5CP/Tq3YgI1ub4b9+3MJFK
# WhJXCcJGFZkcLwUmYwoFg1XLo7DLJdGjrIH1jsI2NFXJFQHef6AdRe1ERvYQeqty
# rBvxIvR+P/83FNYyzx04inUT9TF2AwTOuqCC6Z67oNwR4pEEJyAIEREvkdhjjfWc
# gsk/nGTlfahvNY/SOHrNRKo49KDlccNzRCJQyQ+D59r7/qebNSyQPTfwI9++jEY0
# Q/UWKVNLhio55GYBseJ99s7NzkdxOr9Uftp597HEovbA69qGlZ3OpUE3H1RBGDVp
# /FvM2uXTum8LrMkPXx5Ap/kbPASsC9ju9oMCe2IEXO2SeD1aD3IqvAOdHFKHg1vp
# bPUQSWb6g2xfBV30wFcqaPYgzcbxPWPyZqK+S8l7zw64aO5hmJ7eQwoMfTu0Vay6
# r48wggdxMIIFWaADAgECAhMzAAAAFcXna54Cm0mZAAAAAAAVMA0GCSqGSIb3DQEB
# CwUAMIGIMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE
# BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTIwMAYD
# VQQDEylNaWNyb3NvZnQgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgMjAxMDAe
# Fw0yMTA5MzAxODIyMjVaFw0zMDA5MzAxODMyMjVaMHwxCzAJBgNVBAYTAlVTMRMw
# EQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVN
# aWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0
# YW1wIFBDQSAyMDEwMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA5OGm
# TOe0ciELeaLL1yR5vQ7VgtP97pwHB9KpbE51yMo1V/YBf2xK4OK9uT4XYDP/XE/H
# ZveVU3Fa4n5KWv64NmeFRiMMtY0Tz3cywBAY6GB9alKDRLemjkZrBxTzxXb1hlDc
# wUTIcVxRMTegCjhuje3XD9gmU3w5YQJ6xKr9cmmvHaus9ja+NSZk2pg7uhp7M62A
# W36MEBydUv626GIl3GoPz130/o5Tz9bshVZN7928jaTjkY+yOSxRnOlwaQ3KNi1w
# jjHINSi947SHJMPgyY9+tVSP3PoFVZhtaDuaRr3tpK56KTesy+uDRedGbsoy1cCG
# MFxPLOJiss254o2I5JasAUq7vnGpF1tnYN74kpEeHT39IM9zfUGaRnXNxF803RKJ
# 1v2lIH1+/NmeRd+2ci/bfV+AutuqfjbsNkz2K26oElHovwUDo9Fzpk03dJQcNIIP
# 8BDyt0cY7afomXw/TNuvXsLz1dhzPUNOwTM5TI4CvEJoLhDqhFFG4tG9ahhaYQFz
# ymeiXtcodgLiMxhy16cg8ML6EgrXY28MyTZki1ugpoMhXV8wdJGUlNi5UPkLiWHz
# NgY1GIRH29wb0f2y1BzFa/ZcUlFdEtsluq9QBXpsxREdcu+N+VLEhReTwDwV2xo3
# xwgVGD94q0W29R6HXtqPnhZyacaue7e3PmriLq0CAwEAAaOCAd0wggHZMBIGCSsG
# AQQBgjcVAQQFAgMBAAEwIwYJKwYBBAGCNxUCBBYEFCqnUv5kxJq+gpE8RjUpzxD/
# LwTuMB0GA1UdDgQWBBSfpxVdAF5iXYP05dJlpxtTNRnpcjBcBgNVHSAEVTBTMFEG
# DCsGAQQBgjdMg30BATBBMD8GCCsGAQUFBwIBFjNodHRwOi8vd3d3Lm1pY3Jvc29m
# dC5jb20vcGtpb3BzL0RvY3MvUmVwb3NpdG9yeS5odG0wEwYDVR0lBAwwCgYIKwYB
# BQUHAwgwGQYJKwYBBAGCNxQCBAweCgBTAHUAYgBDAEEwCwYDVR0PBAQDAgGGMA8G
# A1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU1fZWy4/oolxiaNE9lJBb186aGMQw
# VgYDVR0fBE8wTTBLoEmgR4ZFaHR0cDovL2NybC5taWNyb3NvZnQuY29tL3BraS9j
# cmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0XzIwMTAtMDYtMjMuY3JsMFoGCCsGAQUF
# BwEBBE4wTDBKBggrBgEFBQcwAoY+aHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3Br
# aS9jZXJ0cy9NaWNSb29DZXJBdXRfMjAxMC0wNi0yMy5jcnQwDQYJKoZIhvcNAQEL
# BQADggIBAJ1VffwqreEsH2cBMSRb4Z5yS/ypb+pcFLY+TkdkeLEGk5c9MTO1OdfC
# cTY/2mRsfNB1OW27DzHkwo/7bNGhlBgi7ulmZzpTTd2YurYeeNg2LpypglYAA7AF
# vonoaeC6Ce5732pvvinLbtg/SHUB2RjebYIM9W0jVOR4U3UkV7ndn/OOPcbzaN9l
# 9qRWqveVtihVJ9AkvUCgvxm2EhIRXT0n4ECWOKz3+SmJw7wXsFSFQrP8DJ6LGYnn
# 8AtqgcKBGUIZUnWKNsIdw2FzLixre24/LAl4FOmRsqlb30mjdAy87JGA0j3mSj5m
# O0+7hvoyGtmW9I/2kQH2zsZ0/fZMcm8Qq3UwxTSwethQ/gpY3UA8x1RtnWN0SCyx
# TkctwRQEcb9k+SS+c23Kjgm9swFXSVRk2XPXfx5bRAGOWhmRaw2fpCjcZxkoJLo4
# S5pu+yFUa2pFEUep8beuyOiJXk+d0tBMdrVXVAmxaQFEfnyhYWxz/gq77EFmPWn9
# y8FBSX5+k77L+DvktxW/tM4+pTFRhLy/AsGConsXHRWJjXD+57XQKBqJC4822rpM
# +Zv/Cuk0+CQ1ZyvgDbjmjJnW4SLq8CdCPSWU5nR0W2rRnj7tfqAxM328y+l7vzhw
# RNGQ8cirOoo6CGJ/2XBjU02N7oJtpQUQwXEGahC0HVUzWLOhcGbyoYIDWTCCAkEC
# AQEwggEBoYHZpIHWMIHTMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3Rv
# bjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0
# aW9uMS0wKwYDVQQLEyRNaWNyb3NvZnQgSXJlbGFuZCBPcGVyYXRpb25zIExpbWl0
# ZWQxJzAlBgNVBAsTHm5TaGllbGQgVFNTIEVTTjo1MjFBLTA1RTAtRDk0NzElMCMG
# A1UEAxMcTWljcm9zb2Z0IFRpbWUtU3RhbXAgU2VydmljZaIjCgEBMAcGBSsOAwIa
# AxUAabKAFaKt2haUdqkHfFYzAzfgSMuggYMwgYCkfjB8MQswCQYDVQQGEwJVUzET
# MBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMV
# TWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1T
# dGFtcCBQQ0EgMjAxMDANBgkqhkiG9w0BAQsFAAIFAO33IaAwIhgPMjAyNjA3MDcw
# NjU2MzJaGA8yMDI2MDcwODA2NTYzMlowdzA9BgorBgEEAYRZCgQBMS8wLTAKAgUA
# 7fchoAIBADAKAgEAAgID1QIB/zAHAgEAAgISNjAKAgUA7fhzIAIBADA2BgorBgEE
# AYRZCgQCMSgwJjAMBgorBgEEAYRZCgMCoAowCAIBAAIDB6EgoQowCAIBAAIDAYag
# MA0GCSqGSIb3DQEBCwUAA4IBAQBir+Efkpf+6UF27NVpQhWSI7s4dXH3e8qKQB8W
# ldtNvQwEwXp/jPGwwjhJ2HgONbLehqmC9mEKoy/B17DkCOAvTo/1xzeGp3W8uuci
# NmuAxpBFCLCQCW2IyMgPBkNY15ERTFO3eBXafR7k1sbK38rYGHDB/YBEHA/5ua1G
# 4A5JRn0nlSgKljJcMfacvksJOJNm9X49gk+vS9PhB2vkCNkhhZIg5f+IIoNmSsL5
# mc+0Dpe9MHbSnYJPylt5BWlhYkK7eJ3ohA8BsjYC7+rhuJivRv4N2A3HrBbnUy0j
# Kw6J5pX+ozfgyhQpn1zHQxAqrKxXubEkUEYgosfpiIqIZ9uCMYIEDTCCBAkCAQEw
# gZMwfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcT
# B1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UE
# AxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTACEzMAAAIXcfsupa8BHeoA
# AQAAAhcwDQYJYIZIAWUDBAIBBQCgggFKMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0B
# CRABBDAvBgkqhkiG9w0BCQQxIgQge990COEfXgeZakbs90iafI9y5nkIdlrtVEVx
# SczkxcowgfoGCyqGSIb3DQEJEAIvMYHqMIHnMIHkMIG9BCDQ8lBgPl23yZ0SzUSt
# 5phOIegHPywrkNwevxe2k+RaWzCBmDCBgKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYD
# VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy
# b3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1w
# IFBDQSAyMDEwAhMzAAACF3H7LqWvAR3qAAEAAAIXMCIEIO+Zydl6Uhf9xFsf72nn
# ZCkBcHB6Nu7Ft/hiv7f326QQMA0GCSqGSIb3DQEBCwUABIICACfFVYndK+KSL2Z8
# srSRPKwBUbzIjASmhcJL5YIhL6xodPfn58wcugEL3Q/1EMx58iHD9ioqFyoNUqvy
# 7otThQX+CMBrWomPToqQQLi4U5rZ1ItiyDcqiO+Wti0CzbrP+R17udOQsB3u1Y8n
# KbAffRwtBrt7gG80I31uechjW0XafclMsZ//o+G/BfD/kvk4KW7YWsSYJnx5O7fH
# cXIaeu6o45K6U1AKRCVWd9xydhOOmMKePJ97XoZsaehx1pG8Luc9LTtOf+s1/rmW
# nh3cURWZjcJzBG7oSuB3ve/Cru84HUHXgMe4F4Sq7bD6GtSoC/YpIZEVUXIxMJs+
# 4XQtjyHQj8SKlK8cnQbeWvBFIfV4dTApqciUi+2qyPQkQK/DggJluAFTgBGrBOsQ
# QJetxeuTFszVvsuwBlo0k53wNOTRVBa99scmkq6Fxdrjrk8H5CJCYGT3b5nvr1RR
# ZJz2Qt9Ti6eP1t9d0Gg/PbAaNqkwM2saRFgRNIDGLxzUAfa3cLwLIBI+wtN8LC+m
# wXFc6GsxXDyifWWWTmhJ1Q9HVyc9si7L2nYIAZ3ZnZOu+aMCWhoR41UlhkXJcd/T
# pc6oNQ/+7E6oyPD0OgSR8AvhCbb3zije4K67zA819t5G4lh9ZIsu6LOFKDWqG3CL
# iE6m7YR7wcYq3IE8tebJATeS77Mh
# SIG # End signature block