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 public
# entry points can snapshot and restore deterministic module output state.
$script:VerbosePreference = 'SilentlyContinue'
$script:DebugPreference   = '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.
# - Every call receives its own token. Nested entry points therefore restore the outer
# entry point's state rather than overwriting one shared saved-value slot.
# - Silent mode is inherited by nested calls and can be enabled by the current caller.
# - 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()]
    [OutputType([pscustomobject])]
    param(
        [bool]$VerboseRequested,
        [bool]$DebugRequested,
        [bool]$SilentModeRequested
    )

    $scopeToken = [pscustomobject]@{
        VerbosePreference = $script:VerbosePreference
        DebugPreference   = $script:DebugPreference
        VerboseMode       = $script:VerboseMode
        SilentMode        = $script:SilentMode
        Restored          = $false
    }

    $script:SilentMode        = [bool]($script:SilentMode -or $SilentModeRequested)
    $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 }
    }
    return $scopeToken
}

# Restores the exact module output state captured by Enter-AzSPreferenceScope. The token is
# caller-owned and idempotent so a defensive second cleanup cannot corrupt an outer scope.
function Exit-AzSPreferenceScope {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [psobject]$ScopeToken
    )
    if ($ScopeToken.Restored) { return }

    $script:VerbosePreference = $ScopeToken.VerbosePreference
    $script:DebugPreference   = $ScopeToken.DebugPreference
    $script:VerboseMode       = [bool]$ScopeToken.VerboseMode
    $script:SilentMode        = [bool]$ScopeToken.SilentMode
    $ScopeToken.Restored      = $true
}

# ////////////////////////////////////////////////////////////////////////////
# 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 background job with a visual animation indicating progress.
function Invoke-JobWithAnimation {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [string]$JobName,

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

        [object[]]$ArgumentList = @(),

        [string]$Activity = 'Download',

        [datetime]$Deadline = [datetime]::UtcNow.AddSeconds($script:JOB_ANIMATION_TIMEOUT_SEC)
    )

    $ownedJob = $null
    $snapshot = [pscustomobject]@{ State = 'Failed'; Output = @(); Error = @(); Reason = $null; TimedOut = $false }
    try {
        if ([datetime]::UtcNow -ge $Deadline) {
            $snapshot.TimedOut = $true
            return $snapshot
        }
        $ownedJob = Start-Job -Name $JobName -ScriptBlock $JobScriptBlock -ArgumentList $ArgumentList -ErrorAction Stop
        $nextProgress = [datetime]::UtcNow
        while ($ownedJob.State -in @('NotStarted', 'Running')) {
            if ([datetime]::UtcNow -ge $Deadline) {
                $snapshot.TimedOut = $true
                return $snapshot
            }
            if ([datetime]::UtcNow -ge $nextProgress) {
                Write-HostAzS "$Activity in progress..."
                $nextProgress = [datetime]::UtcNow.AddSeconds(5)
            }
            $null = Wait-Job -Job $ownedJob -Timeout 1 -ErrorAction Stop
        }
        $snapshot.State = [string]$ownedJob.State
        $snapshot.Reason = $ownedJob.JobStateInfo.Reason
        $snapshot.Output = @(Receive-Job -Job $ownedJob -ErrorAction Stop)
    } catch {
        $snapshot.State = 'Failed'
        $snapshot.Error = @($_)
        $snapshot.Reason = $_.Exception
    } finally {
        if ($ownedJob) {
            try {
                if (Get-Job -InstanceId $ownedJob.InstanceId -ErrorAction SilentlyContinue) {
                    Remove-Job -Job $ownedJob -Force -ErrorAction Stop
                }
            } catch {
                Write-Warning "$Activity job cleanup failed: $($_.Exception.Message)" -WarningAction Continue
            }
        }
    }
    return $snapshot
} # End Function Invoke-JobWithAnimation



# ////////////////////////////////////////////////////////////////////////////
function Test-DownloadTransientError {
    param([Parameter(Mandatory)]$ErrorRecord, [switch]$TransportOnly)
    if ([string]$ErrorRecord.CategoryInfo.Category -in @('PermissionDenied', 'SecurityError', 'AuthenticationError', 'InvalidArgument')) { return $false }
    $exception = $ErrorRecord.Exception
    while ($exception) {
        if ($exception -is [System.UnauthorizedAccessException] -or $exception -is [System.Security.SecurityException]) { return $false }
        if ($exception -is [System.Net.WebException]) {
            return ([string]$exception.Status -in @('Timeout', 'ConnectFailure', 'ConnectionClosed', 'ReceiveFailure', 'SendFailure', 'NameResolutionFailure'))
        }
        if ($exception -is [System.Management.Automation.Remoting.PSRemotingTransportException]) {
            return ($exception.ErrorCode -notin @(5, 1326, -2147024891, -2147023570))
        }
        $exception = $exception.InnerException
    }
    return $false
}

function Get-DownloadTransferScript {
    return {
        param($Url, $OutFile, [long]$RangeStart, [long]$RangeEnd, [long]$TotalLength, [bool]$UseRange, [datetime]$Deadline, [bool]$ReservedFile = $false)
        $ErrorActionPreference = 'Stop'
        $request = $null
        $response = $null
        $inputStream = $null
        $outputStream = $null
        $ownsFile = $false
        $result = [pscustomobject]@{ Success = $false; Bytes = [long]0; Retryable = $false; ErrorId = ''; ExceptionType = ''; ErrorRecord = $null }
        try {
            $remaining = ($Deadline - [datetime]::UtcNow).TotalMilliseconds
            if ($remaining -le 0) { throw [System.TimeoutException]::new('Download deadline exceeded.') }
            $request = [System.Net.HttpWebRequest]::Create($Url)
            $request.Timeout = [int][math]::Min(600000, [math]::Max(1, $remaining))
            $request.ReadWriteTimeout = $request.Timeout
            if ($UseRange) { $request.AddRange('bytes', $RangeStart, $RangeEnd) }
            $response = $request.GetResponse()
            $expected = [long]$response.ContentLength
            if ($UseRange) {
                $expectedRange = 'bytes {0}-{1}/{2}' -f $RangeStart, $RangeEnd, $TotalLength
                if ([int]$response.StatusCode -ne 206 -or $response.Headers['Content-Range'] -ne $expectedRange) {
                    throw [System.IO.InvalidDataException]::new('Server did not honor the requested byte range.')
                }
                $expected = $RangeEnd - $RangeStart + 1
                if ($response.ContentLength -ge 0 -and $response.ContentLength -ne $expected) {
                    throw [System.IO.InvalidDataException]::new('Range content length does not match the requested bytes.')
                }
            } elseif ([int]$response.StatusCode -ne 200) {
                throw [System.IO.InvalidDataException]::new('Unexpected download response status.')
            }
            $inputStream = $response.GetResponseStream()
            $fileMode = [System.IO.FileMode]::CreateNew
            if ($ReservedFile) { $fileMode = [System.IO.FileMode]::Truncate }
            $outputStream = [System.IO.File]::Open($OutFile, $fileMode, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
            $ownsFile = $true
            $buffer = New-Object byte[] 65536
            do {
                $remaining = ($Deadline - [datetime]::UtcNow).TotalMilliseconds
                if ($remaining -le 0) { throw [System.TimeoutException]::new('Download deadline exceeded.') }
                $inputStream.ReadTimeout = [int][math]::Min(30000, [math]::Max(1, $remaining))
                $bytesRead = $inputStream.Read($buffer, 0, $buffer.Length)
                if ($bytesRead -gt 0) {
                    $outputStream.Write($buffer, 0, $bytesRead)
                    $result.Bytes += $bytesRead
                    if ($expected -ge 0 -and $result.Bytes -gt $expected) { throw [System.IO.InvalidDataException]::new('Download exceeded the expected length.') }
                }
            } while ($bytesRead -gt 0)
            if ($result.Bytes -le 0 -or ($expected -ge 0 -and $result.Bytes -ne $expected)) {
                throw [System.IO.InvalidDataException]::new('Download is empty or incomplete.')
            }
            $result.Success = $true
        } catch {
            $result.ErrorRecord = $_
            $result.ErrorId = $_.FullyQualifiedErrorId
            $exception = $_.Exception
            $webException = $null
            while ($exception) {
                if ($exception -is [System.Net.WebException]) { $webException = $exception }
                if (-not $exception.InnerException) { break }
                $exception = $exception.InnerException
            }
            $result.ExceptionType = $exception.GetType().FullName
            $result.Retryable = ($null -ne $webException -and [string]$webException.Status -in @('Timeout', 'ConnectFailure', 'ConnectionClosed', 'ReceiveFailure', 'SendFailure', 'NameResolutionFailure'))
        } finally {
            foreach ($resource in @($outputStream, $inputStream, $response)) {
                if ($resource) { try { $resource.Dispose() } catch { } }
            }
            if ($request) { try { $request.Abort() } catch { } }
            if ($ownsFile -and -not $ReservedFile) { try { [System.IO.File]::Delete($OutFile) } catch { } }
        }
        return $result
    }
}

function Invoke-DownloadMeasurement {
    [CmdletBinding()]
    param(
        [string]$DownloadFileUrl,
        [string]$DestinationFolder,
        [ValidateRange(1, 16)][int]$ParallelStreams = 1,
        [string]$DestinationPath,
        [datetime]$Deadline = [datetime]::UtcNow.AddSeconds($script:JOB_ANIMATION_TIMEOUT_SEC),
        [ValidateRange(1, 2)][int]$MaxAttempts = 2
    )
    $jobs = [System.Collections.ArrayList]::new()
    $workPath = $null
    $reservedPath = $null
    $transportFailure = $false
    $permanentFailure = $false
    $timer = [System.Diagnostics.Stopwatch]::StartNew()
    try {
        if ([datetime]::UtcNow -ge $Deadline) { return [double]0 }
        if ($DestinationPath -and (Test-Path -LiteralPath $DestinationPath)) {
            throw [System.IO.IOException]::new('The requested download destination already exists; it will not be overwritten.')
        }
        $workPath = Join-Path $DestinationFolder ('SpeedTest_' + [guid]::NewGuid().ToString('N'))
        $null = New-Item -ItemType Directory -Path $workPath -ErrorAction Stop
        if ($DestinationPath) {
            if ($ParallelStreams -ne 1) { throw [System.ArgumentException]::new('Explicit destination is only supported for one stream.') }
            $DestinationPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($DestinationPath)
            $reservation = [System.IO.File]::Open($DestinationPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
            $reservedPath = $DestinationPath
            $reservation.Dispose()
        }
        $resolvedUrl = $DownloadFileUrl
        $length = [long]0
        $useRange = $false
        if ($ParallelStreams -gt 1) {
            $probe = Invoke-JobWithAnimation -JobName 'SpeedTestProbe' -Deadline $Deadline -ArgumentList @($DownloadFileUrl) -JobScriptBlock {
                param($Url)
                $ProgressPreference = 'SilentlyContinue'
                $head = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -TimeoutSec 30 -MaximumRedirection 10 -ErrorAction Stop
                try {
                    [pscustomobject]@{ Url = $head.BaseResponse.ResponseUri.AbsoluteUri; Length = [long]$head.Headers['Content-Length']; Ranges = ($head.Headers['Accept-Ranges'] -eq 'bytes') }
                } finally { if ($head.BaseResponse) { $head.BaseResponse.Close() } }
            }
            if ($probe.State -eq 'Completed' -and $probe.Output.Count -eq 1) {
                $length = [long]$probe.Output[0].Length
                $useRange = ($length -gt 0 -and $probe.Output[0].Ranges)
                if ($probe.Output[0].Url) { $resolvedUrl = $probe.Output[0].Url }
            }
            if (-not $useRange) { Write-HostAzS 'Range capability unavailable; using a single download stream.' }
        }
        $streamCount = 1
        if ($useRange) { $streamCount = [int][math]::Min($ParallelStreams, $length) }
        $chunkSize = [long][math]::Floor($length / $streamCount)
        $pending = @()
        for ($index = 0; $index -lt $streamCount; $index++) {
            $rangeStart = $index * $chunkSize
            $rangeEnd = ($index + 1) * $chunkSize - 1
            if ($index -eq $streamCount - 1) { $rangeEnd = $length - 1 }
            $pending += [pscustomobject]@{ Index = $index; Start = $rangeStart; End = $rangeEnd; Job = $null; StartError = $null }
        }
        $totalBytes = [long]0
        $completed = 0
        $worker = Get-DownloadTransferScript
        for ($attempt = 1; $attempt -le $MaxAttempts -and $pending.Count -gt 0; $attempt++) {
            foreach ($chunk in $pending) {
                if ([datetime]::UtcNow -ge $Deadline) { throw [System.TimeoutException]::new('Download deadline exceeded.') }
                $filePath = Join-Path $workPath ("chunk_{0}_attempt_{1}.tmp" -f $chunk.Index, $attempt)
                if ($DestinationPath) { $filePath = $DestinationPath }
                $chunk.Job = $null
                $chunk.StartError = $null
                try {
                    $chunk.Job = Start-Job -Name ('SpeedTest_' + [guid]::NewGuid().ToString('N')) -ScriptBlock $worker -ArgumentList @($resolvedUrl, $filePath, $chunk.Start, $chunk.End, $length, $useRange, $Deadline, [bool]$reservedPath) -ErrorAction Stop
                    [void]$jobs.Add($chunk.Job)
                } catch { $chunk.StartError = $_ }
            }
            $retry = @()
            foreach ($chunk in $pending) {
                $job = $chunk.Job
                $output = @()
                $retryable = $false
                try {
                    if ($chunk.StartError) { throw $chunk.StartError }
                    $remainingSeconds = [int][math]::Ceiling(($Deadline - [datetime]::UtcNow).TotalSeconds)
                    if ($remainingSeconds -le 0) { throw [System.TimeoutException]::new('Download deadline exceeded.') }
                    $null = Wait-Job -Job $job -Timeout $remainingSeconds -ErrorAction Stop
                    if ($job.State -in @('NotStarted', 'Running')) { throw [System.TimeoutException]::new('Download deadline exceeded.') }
                    $output = @(Receive-Job -Job $job -ErrorAction Stop)
                    if ($job.State -eq 'Completed' -and $output.Count -eq 1 -and $output[0].Success) {
                        $bytes = [long]$output[0].Bytes
                        if ($bytes -le 0 -or ($useRange -and $bytes -ne ($chunk.End - $chunk.Start + 1))) { throw [System.IO.InvalidDataException]::new('Invalid completed chunk length.') }
                        $totalBytes += $bytes
                        $completed++
                        continue
                    }
                    if ($output.Count -eq 1 -and $output[0].PSObject.Properties['Retryable']) {
                        $retryable = [bool]$output[0].Retryable
                        Write-Warning ("Download stream {0} attempt {1} failed [{2}; {3}]." -f $chunk.Index, $attempt, $output[0].ExceptionType, $output[0].ErrorId) -WarningAction Continue
                    } else {
                        Write-Warning "Download stream $($chunk.Index) attempt $attempt ended in state $($job.State) without valid evidence." -WarningAction Continue
                    }
                } catch {
                    $retryable = Test-DownloadTransientError -ErrorRecord $_
                    if (Test-DownloadTransientError -ErrorRecord $_ -TransportOnly) { $transportFailure = $true }
                    Write-Warning ("Download stream {0} attempt {1} failed [{2}; {3}]." -f $chunk.Index, $attempt, $_.Exception.GetType().FullName, $_.FullyQualifiedErrorId) -WarningAction Continue
                }
                if (-not $retryable) { $permanentFailure = $true }
                if ($retryable -and $attempt -lt $MaxAttempts -and [datetime]::UtcNow -lt $Deadline) { $retry += $chunk }
            }
            $pending = @($retry)
        }
        if ($completed -ne $streamCount -or [datetime]::UtcNow -ge $Deadline) {
            if ($useRange -and $transportFailure -and -not $permanentFailure -and [datetime]::UtcNow -lt $Deadline) {
                Write-Warning 'Parallel worker recovery exhausted; attempting one fresh single-stream measurement within the original deadline.' -WarningAction Continue
                return (Invoke-DownloadMeasurement -DownloadFileUrl $DownloadFileUrl -DestinationFolder $DestinationFolder -ParallelStreams 1 -Deadline $Deadline -MaxAttempts 1)
            }
            Write-Warning "Download speed unavailable: $completed of $streamCount streams completed within the deadline." -WarningAction Continue
            return [double]0
        }
        return [math]::Round($totalBytes * 8 / [math]::Max(0.001, $timer.Elapsed.TotalSeconds) / 1000000, 2)
    } catch {
        Write-Warning ("Download speed unavailable [{0}; {1}]." -f $_.Exception.GetType().FullName, $_.FullyQualifiedErrorId) -WarningAction Continue
        return [double]0
    } finally {
        $timer.Stop()
        foreach ($ownedJob in $jobs) {
            try {
                if (Get-Job -InstanceId $ownedJob.InstanceId -ErrorAction SilentlyContinue) { Remove-Job -Job $ownedJob -Force -ErrorAction Stop }
            } catch { Write-Warning 'An owned download job could not be removed.' -WarningAction Continue }
        }
        if ($reservedPath) {
            try { [System.IO.File]::Delete($reservedPath) } catch { Write-Warning 'The owned single-stream download file could not be removed.' -WarningAction Continue }
        }
        if ($workPath -and (Test-Path -LiteralPath $workPath)) {
            try { Remove-Item -LiteralPath $workPath -Recurse -Force -ErrorAction Stop } catch { Write-Warning 'An owned download directory could not be removed.' -WarningAction Continue }
        }
    }
}

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,

        [datetime]$Deadline = [datetime]::UtcNow.AddSeconds($script:JOB_ANIMATION_TIMEOUT_SEC)
    )

    Invoke-DownloadMeasurement -DownloadFileUrl $DownloadFileUrl -DestinationFolder $DestinationFolder -DestinationPath $DestinationPath -ParallelStreams 1 -Deadline $Deadline
}


# ////////////////////////////////////////////////////////////////////////////
# 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,

        [datetime]$Deadline = [datetime]::UtcNow.AddSeconds($script:JOB_ANIMATION_TIMEOUT_SEC)
    )

    Invoke-DownloadMeasurement -DownloadFileUrl $DownloadFileUrl -DestinationFolder $DestinationFolder -ParallelStreams $ParallelStreams -Deadline $Deadline
}


# ////////////////////////////////////////////////////////////////////////////////////////////
# 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
        $probeSucceeded    = $false
        $probeReason       = ''
        while ($attempt -lt $maxAttempts) {
            $attempt++
            $ntpResult = ''
            $probeExitCode = $null
            $nativeProcess = $null
            try {
                $nativeProcess = New-Object System.Diagnostics.Process
                $nativeProcess.StartInfo.FileName = "$env:windir\System32\w32tm.exe"
                $nativeProcess.StartInfo.Arguments = '/stripchart /computer:"{0}" /dataonly /samples:2' -f $ntpServer
                $nativeProcess.StartInfo.UseShellExecute = $false
                $nativeProcess.StartInfo.CreateNoWindow = $true
                $nativeProcess.StartInfo.RedirectStandardOutput = $true
                $nativeProcess.StartInfo.RedirectStandardError = $true
                if (-not $nativeProcess.Start()) { throw 'Could not start w32tm.' }
                $stdoutTask = $nativeProcess.StandardOutput.ReadToEndAsync()
                $stderrTask = $nativeProcess.StandardError.ReadToEndAsync()
                if (-not $nativeProcess.WaitForExit(15000)) { throw 'w32tm exceeded the 15-second probe timeout.' }
                $probeExitCode = $nativeProcess.ExitCode
                $ntpResult = $stdoutTask.GetAwaiter().GetResult()
                $stderrText = $stderrTask.GetAwaiter().GetResult()
                $sampleCount = [regex]::Matches($ntpResult, '(?im)^\s*(?:[01]?\d|2[0-3]):[0-5]\d:[0-5]\d(?:\s*[AP]M)?\s*,\s*[+-]\d+(?:[.,]\d+)?s\s*$').Count
                $probeSucceeded = $null -ne $probeExitCode -and $probeExitCode -eq 0 -and $sampleCount -ge 2 -and $ntpResult -notmatch 'error:' -and [string]::IsNullOrWhiteSpace($stderrText)
                $probeReason = "w32tm exit code: $probeExitCode; valid timing samples: $sampleCount/2."
                $nativeError = $ntpResult -split '\r?\n' | Where-Object { $_ -match 'error:|0x[0-9a-f]{8}' } | Select-Object -First 1
                if ($nativeError) { $probeReason += " $nativeError" }
                if (-not [string]::IsNullOrWhiteSpace($stderrText)) { $probeReason += " $($stderrText.Trim())" }
            } catch {
                $probeSucceeded = $false
                $probeReason = $_.Exception.Message
            } finally {
                if ($null -ne $nativeProcess) {
                    try { if (-not $nativeProcess.HasExited) { $nativeProcess.Kill() } } catch { Write-Verbose "NTP process cleanup: $($_.Exception.Message)" }
                    $nativeProcess.Dispose()
                }
            }
            if ($probeSucceeded) {
                break
            }
            if ($attempt -lt $maxAttempts) {
                Write-HostAzS " NTP attempt $attempt of $maxAttempts failed; retrying in $retryDelaySeconds second(s)..." -ForegroundColor Yellow
                Start-Sleep -Seconds $retryDelaySeconds
            }
        }

        if (-not $probeSucceeded) {
            Write-HostAzS "NTP Test: Failed" -ForegroundColor Red
            Write-HostAzS "Diagnostic info: $probeReason" -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
            $UploadError = $null
            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
# MIInKAYJKoZIhvcNAQcCoIInGTCCJxUCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAdk0oieJk/UOhx
# PRsUI5so30RMdsgYhgXkuliRl1HrO6CCDLowggX1MIID3aADAgECAhMzAAACHU0Z
# yE7XD1dIAAAAAAIdMA0GCSqGSIb3DQEBCwUAMFcxCzAJBgNVBAYTAlVTMR4wHAYD
# VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBD
# b2RlIFNpZ25pbmcgUENBIDIwMjQwHhcNMjYwNDE2MTg1OTQzWhcNMjcwNDE1MTg1
# OTQzWjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE
# BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYD
# VQQDExVNaWNyb3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IB
# DwAwggEKAoIBAQDQvewXxx9gZZFC6Ys1WBay8BJ8kGA4JQnH5CMafqOASlTpK9H8
# o5ZXTXt0caVQTNMUPt445wXYD+dFtaKWTwDn1I52oUSrC9vJin1Gsqt+zyKJL5Dg
# 3eQXbQNR61DmMy20GLTIO3SFed9Rfi/ophgCLGFLDR3r0KvHjwMb/jYWS0celV/4
# Lz27LfAekm8v9E5IXaeiXbAUYZKK090n4CVl3JBtbN+9DtI9SNu/yjvozW52/u7R
# X/Ttpa/KDlpuokZ+Zcbvmtd9ur9gFLvZzh41o9MsE/clQtdaFWGvuo6Jua/ntpgk
# ey3E5/vBFe+MJPG6phdnuo6r57ZudCudiI1bAgMBAAGjggGbMIIBlzAOBgNVHQ8B
# Af8EBAMCB4AwHwYDVR0lBBgwFgYKKwYBBAGCN0wIAQYIKwYBBQUHAwMwHQYDVR0O
# BBYEFH6QuMwqcPG0hQlQ6c5jCtTTLrVeMEUGA1UdEQQ+MDykOjA4MR4wHAYDVQQL
# ExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xFjAUBgNVBAUTDTIzMDAxMis1MDc1NTkw
# HwYDVR0jBBgwFoAUf1k/VCHarU/vBeXmo9ctBpQSCDEwYAYDVR0fBFkwVzBVoFOg
# UYZPaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9jcmwvTWljcm9zb2Z0
# JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNybDBtBggrBgEFBQcBAQRh
# MF8wXQYIKwYBBQUHMAKGUWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMv
# Y2VydHMvTWljcm9zb2Z0JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNy
# dDAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4ICAQBKTbYOjzwTG/DXGaz9
# s6+fQeaTtDcFmMY+5UyVFCyj7Pv+5i37qfX8lSL/tBIfYQfWsMuBQlfZurJD6r4H
# VJ2CeH+1fgiq8dcHdVKoZ3Sa2qXoX3cq9iS8cVb06B7+5/XJ7I0OxHH9fDsvJ3T3
# w5V/ZtAIFmLrl+P0CtG+92uzRsn0nTbdFjOkLMLWPLAU3THohKRlSEMgFJpPkm5n
# 5UAZ35xX6FWCrDLsSKb555bTifwa8mJBwdlof0bmfYidH+dxZ1FdDxvLnNl9zeKs
# A4kejaaIqqIPguhwAti5Ql7BlTNoJNwxCvBmqW2MQLnCkYN/VVUsR3V2x/rcTNzo
# Bf/Z/SpROvdaA2ZOOd1uioXJt3tdLQ7vHpqpib0KfWr/FWXW10q38VxfCnRQBqzb
# SuztR7nEMuzX7Ck+B/XaPDXd1qh72+QYyB0Z2VzWmO9zsnb9Uq/dwu8LGeQqnyu6
# 7SDGACvnXii2fb9+US492VTnXSnFKyqwgzUyFMtZK1/sHYTv6bG4TtQUygQxTN+Z
# V+aJIlKO2MqZ7bKrAnOzS9m6NgoTdWOq11bTOZwKlIEV/EhV9SWkDmdpR/hPPT2v
# 6TEj4F8PT/zHjRezIU5c/DGlt/VhY/pK0XkJtEyMmmS1BMtjU/rqBZVMIm3dnxQs
# /TBByr+Cf8Z1r7aifQVQ+WSqzjCCBr0wggSloAMCAQICEzMAAAA5O7Y3Gb8GHWcA
# AAAAADkwDQYJKoZIhvcNAQEMBQAwgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpX
# YXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQg
# Q29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29mdCBSb290IENlcnRpZmljYXRl
# IEF1dGhvcml0eSAyMDExMB4XDTI0MDgwODIwNTQxOFoXDTM2MDMyMjIyMTMwNFow
# VzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEo
# MCYGA1UEAxMfTWljcm9zb2Z0IENvZGUgU2lnbmluZyBQQ0EgMjAyNDCCAiIwDQYJ
# KoZIhvcNAQEBBQADggIPADCCAgoCggIBANgBnB7jOMeqlRYHNa265v4IY9fH8TKh
# emHfPINe1gpLaV3dhg324WwH06LcHbpnsBukCDNitryo0dtS/EW6I/yEL/bLSY8h
# KpbfQuWusBPr9qazYcDxCW/qnjb5JsI1s8bNOg3bVATvQVL4tcf03aTycsz8QeCd
# M0l/yHRObJ9QqazM1r6VPEOJ7LL+uEEb73w6QCuhs89a1uv1zerOYMnsneRRwCbp
# yW11IcggU0cRKDDq1pjVJzIbIF6+oiXXbReOsgeI8zu1FyQfK0fVkaya8SmVHQ/t
# Of23mZ4W9k0Ri22QW9p3UgSC5OUDktKxxcCmGL6tXLfOGSWHIIV4YrTJTT6PNty5
# REojHJuZHArkF9VnHTERWoTjAzfI3kP+5b4alUdhgAZ7ttOu1bVnXfHaqPYl2rPs
# 20ji03LOVWsh/radgE17es5hL+t6lV0eVHrVhsssROWJuz2MXMCt7iw7lFPG9LXK
# Gjsmonn2gotGdHIuEg5JnJMJVmixd5LRlkmgYRZKzhxSCwyoGIq0PhaA7Y+VPct5
# pCHkijcIIDm0nlkK+0KyepolcqGm0T/GYQRMhHJlGOOmVQop36wUVUYklUy++vDW
# eEgEo4s7hxN6mIbf2MSIQ/iIfMZgJxC69oukMUXCrOC3SkE/xIkgpfl22MM1itkZ
# 35nNXkMolU1lAgMBAAGjggFOMIIBSjAOBgNVHQ8BAf8EBAMCAYYwEAYJKwYBBAGC
# NxUBBAMCAQAwHQYDVR0OBBYEFH9ZP1Qh2q1P7wXl5qPXLQaUEggxMBkGCSsGAQQB
# gjcUAgQMHgoAUwB1AGIAQwBBMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU
# ci06AjGQQ7kUBU7h6qfHMdEjiTQwWgYDVR0fBFMwUTBPoE2gS4ZJaHR0cDovL2Ny
# bC5taWNyb3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0MjAx
# MV8yMDExXzAzXzIyLmNybDBeBggrBgEFBQcBAQRSMFAwTgYIKwYBBQUHMAKGQmh0
# dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvY2VydHMvTWljUm9vQ2VyQXV0MjAx
# MV8yMDExXzAzXzIyLmNydDANBgkqhkiG9w0BAQwFAAOCAgEAFJQfOChP7onn6fLI
# MKrSlN1WYKwDFgAddymOUO3FrM8d7B/W/iQ6DxXsDn7D5W4wMwYeLystcEqfkjz4
# NURRgazyMu5yRzQh4LqjA4tStTcJh1opExo7nn5PuPBYnbu0+THSuVHTe0VTTPVh
# ily/piFrDo3axQ9P4C+Ol5yet+2gTfekICS5xS+cYfSIvgn0JksVBVMYVI5QFu/q
# hnLhsEFEUzG8fvv0hjgkO+lkpV9ty6GkN4vdnd7ya6Q6aR9y34aiM1qmxaxBi6OU
# nyNl6fkuun/diTFnYDLTppOkr/mg5WSfCiDVMNCxtj4wPKC5OmHm1DQIt/MNokbb
# H3UGsFP1QbzsLocuSqLCvH09Io3fDPTmscR9Y75G4qX7RTX8AdBPo0I6OEojf39z
# uFZt0qOHm65YWQE69cZM2ueE1MB05dNNgHK9gTE7zKvK/fg8B2qjW88MT/WF5V5u
# vZGtqa9FSL2RazArA+rDPuf6JGYz4HpgMZHB4S6szWSKYBv0VisCzfxgeU+dquXW
# 9bd0auYlOB58DPcOYKdc3Se94g+xL4pcEhbB54JOgAkwYTu/9dLeH2pDqeJZAABV
# DWRQCaXfO5LgyKwKCLYXpigrZYCjUSBcr+Ve8PFWMhVTQl0v4q8J/AUmQN5W4n10
# 1cY2L4A7GTQG1h32HHAvfQESWP0xghnEMIIZwAIBATBuMFcxCzAJBgNVBAYTAlVT
# MR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jv
# c29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMjQCEzMAAAIdTRnITtcPV0gAAAAAAh0w
# DQYJYIZIAWUDBAIBBQCggZAwGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwLwYJ
# KoZIhvcNAQkEMSIEIAnfVXRxtkC5iGh/y8q4pivSwybWJK1gfQjr9GobCmQQMEIG
# CisGAQQBgjcCAQwxNDAyoBSAEgBNAGkAYwByAG8AcwBvAGYAdKEagBhodHRwOi8v
# d3d3Lm1pY3Jvc29mdC5jb20wDQYJKoZIhvcNAQEBBQAEggEAyu4VhK4zM+R3JVp7
# zrAF3xAVwqA9AzQg6SsMNL+M4LCNjk2jYIr4WyUxbLWChpcdZk9bNNVJ3YwtFFxM
# XqGwSam59JDtQE8pFy3BoXD8AfhJNJuCHreoMbKfe9Wt++MRS3VKp/YF5cbLXec/
# lvPmRcyLfUB0mO31wLJhXkVnZTxtsyi27kXHkufk6D2JM1m0rd+GrdP5yP3eqWvD
# 4xkc92eFJi/uln3FcrDCrO0eJMljVkiApsi9cmuRUSjbCfhuNWn9hyyfArX4jxnN
# OpniUuLoLVv/9f04fi+aqHgk7yLM9GUDkB481NasbL+vV5QobFhr5KJnVQ6OwDps
# MnX0H6GCF5QwgheQBgorBgEEAYI3AwMBMYIXgDCCF3wGCSqGSIb3DQEHAqCCF20w
# ghdpAgEDMQ8wDQYJYIZIAWUDBAIBBQAwggFSBgsqhkiG9w0BCRABBKCCAUEEggE9
# MIIBOQIBAQYKKwYBBAGEWQoDATAxMA0GCWCGSAFlAwQCAQUABCB3iS9y9I5EK7Yt
# fHdC63dmEP1F64Qw98hrKzEr+zN4/wIGaqpoSLC/GBMyMDI2MDkyOTIwNDU1Mi44
# NDhaMASAAgH0oIHRpIHOMIHLMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu
# Z3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv
# cmF0aW9uMSUwIwYDVQQLExxNaWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25zMScw
# JQYDVQQLEx5uU2hpZWxkIFRTUyBFU046OTYwMC0wNUUwLUQ5NDcxJTAjBgNVBAMT
# HE1pY3Jvc29mdCBUaW1lLVN0YW1wIFNlcnZpY2WgghHqMIIHIDCCBQigAwIBAgIT
# MwAAAiY1tD5nQ5P2HwABAAACJjANBgkqhkiG9w0BAQsFADB8MQswCQYDVQQGEwJV
# UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UE
# ChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGlt
# ZS1TdGFtcCBQQ0EgMjAxMDAeFw0yNjAyMTkxOTQwMDJaFw0yNzA1MTcxOTQwMDJa
# MIHLMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMH
# UmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQL
# ExxNaWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25zMScwJQYDVQQLEx5uU2hpZWxk
# IFRTUyBFU046OTYwMC0wNUUwLUQ5NDcxJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1l
# LVN0YW1wIFNlcnZpY2UwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/
# /w+ZZIL5RFFpVI8D3ZyuNu8IzcAEOD30OLYjh337rXjcrIlOSzpJc4ZeUxEyli6x
# 6F6zm4NR8dbPb9diDp/hOUzHWGxiA1Z3RXKBb/4F/ojyvN43SEGWqSfVc3I3BlsY
# T35ecVAJ9kVf90YOv29tFjJBBZkYvrT/DwwyRLscOyP4p+9/lyJjD+ULs3YXBhVr
# fZ+MbQB+BYKLqRvBKbj/wR9akNrMxQINoGaD5jZO/N/nSsmG2P1zv/cv4gSoMBnW
# eQIBkjd2I5w1DeXupp2vSiNmR5sA2ZkBK3yiQWaJvRxODlkfiyHk9Mkk/TrYTjmj
# PCbhe+uqhHNRy8UlbOvWsCq0tRtUykHv39DgqAfJNrE8OSt835rBzDprrcAhwmgf
# hoVi4AKeqwikY0nUa48K0Qy80XT4fiEA3ExEZNaRFo9Nq/GwbfgqKqGmc9xhKuRF
# cjtua4KHZvnAvpWgEFSOCkovXs/BcLnkEHM9xZ8iUag5CyhNqXYYE/z0pcXdYaNI
# kQ68EWmuvLm7g9oofV2vOm5GVNoghnkWG6nGPo/JwEgmA9oSS0EfvFRMWPA/gpSv
# F3shArKHnaEpVSSi3DNbyiuYiEs9Ko0IkZc8xKFeQRaqGRxrB+2r/7B3X81Tps99
# KhFwg+wD87od22F2MUg1x7twt3gaVnFk0IZIwUPCGwIDAQABo4IBSTCCAUUwHQYD
# VR0OBBYEFF3hn9fYJN2Y/Z9LVbBPIxAzXHsQMB8GA1UdIwQYMBaAFJ+nFV0AXmJd
# g/Tl0mWnG1M1GelyMF8GA1UdHwRYMFYwVKBSoFCGTmh0dHA6Ly93d3cubWljcm9z
# b2Z0LmNvbS9wa2lvcHMvY3JsL01pY3Jvc29mdCUyMFRpbWUtU3RhbXAlMjBQQ0El
# MjAyMDEwKDEpLmNybDBsBggrBgEFBQcBAQRgMF4wXAYIKwYBBQUHMAKGUGh0dHA6
# Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY2VydHMvTWljcm9zb2Z0JTIwVGlt
# ZS1TdGFtcCUyMFBDQSUyMDIwMTAoMSkuY3J0MAwGA1UdEwEB/wQCMAAwFgYDVR0l
# AQH/BAwwCgYIKwYBBQUHAwgwDgYDVR0PAQH/BAQDAgeAMA0GCSqGSIb3DQEBCwUA
# A4ICAQA2Ux0tr9sYCjsq0FRyiVpx15OurNXv6Qk7iX+ArVPlz3w4tqjcTNm1dt3t
# Tua2wJMpJhPH8n7UXhmT98d5Du44Ll4adnse4SQfVg3QL6aRkXHnJUn8y9iftB/P
# y22n9xnwPFfj3QlDOSgLuHleu97U0iH2ZaluYabWXJihdiYpK8cPHFlqZOAiot0+
# GD8dP+RMuvpxt/F2LmYelpoZwriiFOUmlxEUV7xJHyZZlDquskeyuq01DTv91N4q
# M8cfPPhl/2pc4HeMf/nd2HouifJbDQFNd4WPhLzn0Sy3u1Zh3+S3tjQdqN+dyw60
# RaV+RXCoOLgFZ3MAg/GoDl+fvb5hy/1a71ctX8wEad1Pf6def2pqfl3wFc++hkF8
# DXXTZofJN4YVaN3InwbAGQDDkNK4lqecCixxmSKwidPynGeE5OtvNoK1pkLsm/i8
# F1RjGczZ/kSF2VDkqG866iQ+jVbGOQ6Du3eyyFcFKZoDJ4B5mEAS9aT2SKqllLey
# bOboH6r67siR5B/2Hnu7+KYuYZy0BEadtA6ngG4cnSR9JsrkhhsKmb11ujqwgJyN
# x92MsoGGwNgN1aI0QID8CsjCFwpfmMzlA44xHKYv3hmjxeqBS4uU5rQeiAnVgpJe
# aVGKm/lzPDtnppGV+7XhRp5b1ZxT/Z7Xxc+I7H7/jCtQDZoaZTCCB3EwggVZoAMC
# AQICEzMAAAAVxedrngKbSZkAAAAAABUwDQYJKoZIhvcNAQELBQAwgYgxCzAJBgNV
# BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4w
# HAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29m
# dCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDEwMB4XDTIxMDkzMDE4MjIy
# NVoXDTMwMDkzMDE4MzIyNVowfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hp
# bmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jw
# b3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTAw
# ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDk4aZM57RyIQt5osvXJHm9
# DtWC0/3unAcH0qlsTnXIyjVX9gF/bErg4r25PhdgM/9cT8dm95VTcVrifkpa/rg2
# Z4VGIwy1jRPPdzLAEBjoYH1qUoNEt6aORmsHFPPFdvWGUNzBRMhxXFExN6AKOG6N
# 7dcP2CZTfDlhAnrEqv1yaa8dq6z2Nr41JmTamDu6GnszrYBbfowQHJ1S/rboYiXc
# ag/PXfT+jlPP1uyFVk3v3byNpOORj7I5LFGc6XBpDco2LXCOMcg1KL3jtIckw+DJ
# j361VI/c+gVVmG1oO5pGve2krnopN6zL64NF50ZuyjLVwIYwXE8s4mKyzbnijYjk
# lqwBSru+cakXW2dg3viSkR4dPf0gz3N9QZpGdc3EXzTdEonW/aUgfX782Z5F37Zy
# L9t9X4C626p+Nuw2TPYrbqgSUei/BQOj0XOmTTd0lBw0gg/wEPK3Rxjtp+iZfD9M
# 269ewvPV2HM9Q07BMzlMjgK8QmguEOqEUUbi0b1qGFphAXPKZ6Je1yh2AuIzGHLX
# pyDwwvoSCtdjbwzJNmSLW6CmgyFdXzB0kZSU2LlQ+QuJYfM2BjUYhEfb3BvR/bLU
# HMVr9lxSUV0S2yW6r1AFemzFER1y7435UsSFF5PAPBXbGjfHCBUYP3irRbb1Hode
# 2o+eFnJpxq57t7c+auIurQIDAQABo4IB3TCCAdkwEgYJKwYBBAGCNxUBBAUCAwEA
# ATAjBgkrBgEEAYI3FQIEFgQUKqdS/mTEmr6CkTxGNSnPEP8vBO4wHQYDVR0OBBYE
# FJ+nFV0AXmJdg/Tl0mWnG1M1GelyMFwGA1UdIARVMFMwUQYMKwYBBAGCN0yDfQEB
# MEEwPwYIKwYBBQUHAgEWM2h0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMv
# RG9jcy9SZXBvc2l0b3J5Lmh0bTATBgNVHSUEDDAKBggrBgEFBQcDCDAZBgkrBgEE
# AYI3FAIEDB4KAFMAdQBiAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB
# /zAfBgNVHSMEGDAWgBTV9lbLj+iiXGJo0T2UkFvXzpoYxDBWBgNVHR8ETzBNMEug
# SaBHhkVodHRwOi8vY3JsLm1pY3Jvc29mdC5jb20vcGtpL2NybC9wcm9kdWN0cy9N
# aWNSb29DZXJBdXRfMjAxMC0wNi0yMy5jcmwwWgYIKwYBBQUHAQEETjBMMEoGCCsG
# AQUFBzAChj5odHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpL2NlcnRzL01pY1Jv
# b0NlckF1dF8yMDEwLTA2LTIzLmNydDANBgkqhkiG9w0BAQsFAAOCAgEAnVV9/Cqt
# 4SwfZwExJFvhnnJL/Klv6lwUtj5OR2R4sQaTlz0xM7U518JxNj/aZGx80HU5bbsP
# MeTCj/ts0aGUGCLu6WZnOlNN3Zi6th542DYunKmCVgADsAW+iehp4LoJ7nvfam++
# Kctu2D9IdQHZGN5tggz1bSNU5HhTdSRXud2f8449xvNo32X2pFaq95W2KFUn0CS9
# QKC/GbYSEhFdPSfgQJY4rPf5KYnDvBewVIVCs/wMnosZiefwC2qBwoEZQhlSdYo2
# wh3DYXMuLGt7bj8sCXgU6ZGyqVvfSaN0DLzskYDSPeZKPmY7T7uG+jIa2Zb0j/aR
# AfbOxnT99kxybxCrdTDFNLB62FD+CljdQDzHVG2dY3RILLFORy3BFARxv2T5JL5z
# bcqOCb2zAVdJVGTZc9d/HltEAY5aGZFrDZ+kKNxnGSgkujhLmm77IVRrakURR6nx
# t67I6IleT53S0Ex2tVdUCbFpAUR+fKFhbHP+CrvsQWY9af3LwUFJfn6Tvsv4O+S3
# Fb+0zj6lMVGEvL8CwYKiexcdFYmNcP7ntdAoGokLjzbaukz5m/8K6TT4JDVnK+AN
# uOaMmdbhIurwJ0I9JZTmdHRbatGePu1+oDEzfbzL6Xu/OHBE0ZDxyKs6ijoIYn/Z
# cGNTTY3ugm2lBRDBcQZqELQdVTNYs6FwZvKhggNNMIICNQIBATCB+aGB0aSBzjCB
# yzELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1Jl
# ZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjElMCMGA1UECxMc
# TWljcm9zb2Z0IEFtZXJpY2EgT3BlcmF0aW9uczEnMCUGA1UECxMeblNoaWVsZCBU
# U1MgRVNOOjk2MDAtMDVFMC1EOTQ3MSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1T
# dGFtcCBTZXJ2aWNloiMKAQEwBwYFKw4DAhoDFQCi/fMxFtkqr7XMXdsRyWU0lSKH
# Z6CBgzCBgKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAw
# DgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24x
# JjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwMA0GCSqGSIb3
# DQEBCwUAAgUA7mYIRjAiGA8yMDI2MDkyOTA5NDk1OFoYDzIwMjYwOTMwMDk0OTU4
# WjB0MDoGCisGAQQBhFkKBAExLDAqMAoCBQDuZghGAgEAMAcCAQACAhvIMAcCAQAC
# AhN+MAoCBQDuZ1nGAgEAMDYGCisGAQQBhFkKBAIxKDAmMAwGCisGAQQBhFkKAwKg
# CjAIAgEAAgMHoSChCjAIAgEAAgMBhqAwDQYJKoZIhvcNAQELBQADggEBACu1brmZ
# HXJtT8aW4q/JTITw49BF8mLCvFuIRWPNIgqpJTf/ZFoWFv1FBTOIukboLeJiPml7
# lXGB7FiDWOUH843gWgyuwjNAAPdaOUlE7wcpJoeFvTcXvNrEltseV9GAyUcXCHxh
# 9mclTmXVAE/yuDOpBPo/JDJwJum2w1g/yk2s6cg8QZ22hUjgvgzrcR0Mm34itJEQ
# ljbxwcubpbjko5xmTLS0lJ8IuJULOFjJbyFel8BJ7BXPT3QRXsNLrIwTddYp2H4k
# j8OL+fgzFdsERo1zHAX5BkaOeBzJWqcFXonREcrZW5U7aUDxeDUL4t620VN94tsd
# HiaY8aCKfVB6RmgxggQNMIIECQIBATCBkzB8MQswCQYDVQQGEwJVUzETMBEGA1UE
# CBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9z
# b2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQ
# Q0EgMjAxMAITMwAAAiY1tD5nQ5P2HwABAAACJjANBglghkgBZQMEAgEFAKCCAUow
# GgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMC8GCSqGSIb3DQEJBDEiBCDLWzr4
# V2kcS4nC16iTxsNjL4SdZkPLyxxUhGIFYthKfzCB+gYLKoZIhvcNAQkQAi8xgeow
# gecwgeQwgb0EIMwyXGFnTNsZRBrs6GN/BbV0okaNP3VBYqLFjUsFnbgqMIGYMIGA
# pH4wfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcT
# B1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UE
# AxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTACEzMAAAImNbQ+Z0OT9h8A
# AQAAAiYwIgQgHa9Lf8ZZBGT12IOR/xG5iWTLFMfGUknOlyH/dFtfBeIwDQYJKoZI
# hvcNAQELBQAEggIAqH+36aCJ4/cPXGa7rMkc9s3Nq5PXmvBYAQnzZc/ByzbFUNpO
# pmM8kvG1xX6FDaDSbFY9z1X9lWk1g/wOQJUulETcXzCvGyGMfcRyvMA5sOtV1Luf
# VLkQo8cTntqTN9Oyb5E9orkOeMTcVeRzTedjFJt5ytGX7pujX48Nr3we2V09dFdC
# Y0e3Z2zEEACv2M8Iy4lx6sWI/VoLiBHk5dh/6VH2TRtBW8iCfiuaR173WjUXMJkP
# twi203BP4yWLLzcw9FboE7Ja5UoQ3tbx4A/0PiGJDW7ZMVVvQtrRfcpnNeObPI2L
# ZDjf9CYF3YTGh/bWAVK73RxLuXSLRxJkp/0NB1kDp6h3LwL3VRiL7IUR+E1SkNG0
# 6I8oLVIX8bOZ80jaAQpt+gMAuWnBo4W6SCc4PTo85Jwup9Lheb1BK2voqzD15qaZ
# T8Kz30Ygt2b0PLiS9laorLUcYToZMHyuAu/Lp2PKQoOGNg7F0vg4X3QVdQuqgRwM
# AaUqC/+TGGy/53MKz+yyzI0kOMx/zKS+0WW6Heop8MB4OjlF0LadPG9NhCbeOpbT
# LuIzoYBwabpPj/ym6kGXZrJw8GbxZeiE2prpdrIjFas8dlxqdeAT21lZI9hMOBsd
# 1YsLdZI5+XYiAH8PQEI0F8EYRb3vOQ0z6EfhAouSUiIHYNIJkYpzDMTZY3c=
# SIG # End signature block