Nexthink-Citrix-Connector.psm1

# End of parameters definition
$env:Path = "$env:SystemRoot\system32;$env:SystemRoot;$env:SystemRoot\System32\Wbem;$env:SystemRoot\System32\WindowsPowerShell\v1.0\"

#
# Constants definition
#
New-Variable -Name 'LOG_FORMAT' -Value "[%{timestamp:+yyyy-MM-dd HH:mm:ss.fffffzzz}][%{level:-7}][%{lineno:3}] %{message}" -Option ReadOnly -Scope Script -Force
New-Variable -Name 'LOG_RETENTION_DAYS' -Value 7 -Option ReadOnly -Scope Script -Force
New-Variable -Name 'LOG_LEVEL' -Value 'INFO' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'CITRIX_ENV_CONFIG_NOT_FOUND' -Value 'Configuration for Citrix environment not found' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'MISSING_FIELDS' -Value 'Missing required fields' -Option ReadOnly -Scope Script -Force

# CITRIX REST API
New-Variable -Name 'CITRIX_DIRECTOR_HOST' -Value 'localhost' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'CITRIX_CONTROLLER_HOST' -Value 'localhost' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'FULL_ENRICHMENT_QUERY' `
    -Value ('http://{0}/citrix/monitor/odata/v4/data/Machines?$select=DnsName,HostingServerName,IsPendingUpdate' +
        '&$expand=DesktopGroup($select=Name,DesktopKind,SessionSupport),Hypervisor($select=Name),Catalog($select=ProvisioningSchemeId)' +
        '&$filter=(DnsName ne null) and (DnsName ne '''') and (DesktopGroup ne null)') `
    -Option ReadOnly -Scope Script -Force
New-Variable -Name 'DELTA_ENRICHMENT_QUERY' `
    -Value ('http://{0}/citrix/monitor/odata/v4/data/Machines?$select=DnsName,HostingServerName,IsPendingUpdate' +
        '&$expand=DesktopGroup($select=Name,DesktopKind,SessionSupport),Hypervisor($select=Name),Catalog($select=ProvisioningSchemeId)' +
        '&$filter=(DnsName ne null) and (DnsName ne '''') and (DesktopGroup ne null) and (PoweredOnDate ge {1})') `
    -Option ReadOnly -Scope Script -Force

# Buffer to cover delays in reporting data from Citrix
# Use a configurable(constant) buffer added to the last delta retrieval time to filter PoweredOnDate, compensating for unknown event/API latency and clock misalignments.
New-Variable -Name 'DELTA_BUFFER_IN_SECONDS' -Value 10 -Option ReadOnly -Scope Script -Force

# HYPERVISOR TYPE & VIRTUALIZATION TYPE
New-Variable -Name 'UNKNOWN' -Value 0 -Option ReadOnly -Scope Script -Force
New-Variable -Name 'SHARED' -Value 1 -Option ReadOnly -Scope Script -Force
New-Variable -Name 'PERSONAL' -Value 2 -Option ReadOnly -Scope Script -Force
New-Variable -Name 'POOLED' -Value 3 -Option ReadOnly -Scope Script -Force

# DESKTOP BROKER
New-Variable -Name 'CITRIX_CVAD' -Value 1 -Option ReadOnly -Scope Script -Force

# ENRICHMENT API
New-Variable -Name 'ENRICHMENT_API_HOST' -Value 'api.eu-west-3.dev.nexthink.cloud' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'TARGET_CREDENTIALS_NAME' -Value 'nxt-citrix-credentials' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'JWT_URL' -Value 'https://{0}/api/v1/token' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'ENRICHMENT_URL' -Value 'https://{0}/api/v1/enrichment/data/fields' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'LOGS_URL' -Value 'https://{0}/vdi/vdi-log-ingestion-api/api/v1/logs/entries' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'REQUEST_BATCH_SIZE' -Value 1000 -Option ReadOnly -Scope Script -Force

# IN-MEMORY LOG STORAGE
New-Variable -Name 'IN_MEMORY_LOGS' -Value ([System.Collections.ArrayList]::new()) -Option ReadOnly -Scope Script -Force

# EXIT CODES
New-Variable -Name 'EXIT_CODE_OK' -Value 0 -Option ReadOnly -Scope Script -Force
New-Variable -Name 'EXIT_CODE_ERROR' -Value 1 -Option ReadOnly -Scope Script -Force

# TRACE ID
New-Variable -Name 'TRACE_ID_HEADER' -Value 'x-enrichment-trace-id' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'TRACE_ID_VALUE' -Value ([Guid]::NewGuid()) -Option ReadOnly -Scope Script -Force
New-Variable -Name 'LOGS_TRACE_ID_HEADER' -Value 'x-nexthink-vdi-logs-trace-id' -Option ReadOnly -Scope Script -Force

# TIMESTAMP
New-Variable -Name 'TIMESTAMP' -Value '1970-01-01T00:00:00Z' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'EXECUTION_START_DATE' -Value (Get-Date) -Option ReadOnly -Scope Script -Force

#
# Invoke Main
#
function Invoke-Main {
    param(
        [Parameter(Mandatory = $true)]
        [string]$CitrixEnvironment,
        [Parameter(Mandatory = $true)]
        [string]$ScriptRootPath
    )

    $Version = Get-ModuleVersion
    $exitCode = $EXIT_CODE_OK

    try {
        if (-not (Test-RequiredModulesInstalled)) {
            $exitCode = $EXIT_CODE_ERROR
        } else {
            Initialize-EnrichmentProcess -CitrixEnvironment $CitrixEnvironment -ScriptRootPath $ScriptRootPath

            $pwshVersion = $PSVersionTable.PSVersion.ToString()
            Write-CustomLog -Message "PowerShell version: $pwshVersion" -Severity 'INFO'
            Write-CustomLog -Message "Starting Citrix connector $Version for environment $CitrixEnvironment" -Severity 'INFO'
            Write-CitrixSnapInLogs

            Start-EnrichmentProcess -CitrixEnvironment $CitrixEnvironment
        }
    } catch {
        Write-CustomLog -Message "The execution stopped unexpectedly. Details: $($_.Exception.Message)" -Severity 'ERROR'
        $exitCode = $EXIT_CODE_ERROR
    }

    Write-CustomLog -Message "Stopping Citrix connector $Version with exit code $exitCode`n" -Severity 'INFO'

    # Send in-memory logs to Logs API
    try {
        Send-Logs -CitrixEnvironment $CitrixEnvironment -Version $Version
    } catch {
        # Log error but don't fail execution if log sending fails
        Write-CustomLog -Message "[$TRACE_ID_VALUE] Failed to send logs to API: $($_.Exception.Message)" -Severity 'ERROR' -NoCache
    }

    Wait-Logging
    return $exitCode
}

Set-Alias -Name Invoke-NexthinkCitrixConnector -Value Invoke-Main

function Initialize-EnrichmentProcess {
    param(
        [Parameter(Mandatory = $true)]
        [string]$CitrixEnvironment,
        [Parameter(Mandatory = $true)]
        [string]$ScriptRootPath
    )

    # Clear in-memory logs from previous run
    $script:IN_MEMORY_LOGS.Clear()

    # Initialize path-dependent variables
    $storeFolder = Join-Path -Path (Join-Path -Path $ScriptRootPath -ChildPath "Storage") -ChildPath $CitrixEnvironment
    $logsFolder = Join-Path -Path (Join-Path -Path $ScriptRootPath -ChildPath "Logs") -ChildPath $CitrixEnvironment
    $configFileFolder = Join-Path -Path (Join-Path -Path $ScriptRootPath -ChildPath "Config") -ChildPath "config.json"

    New-Variable -Name 'SCRIPT_FOLDER' -Value $ScriptRootPath -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'LOGS_FOLDER' -Value $logsFolder -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'STORE_TIMESTAMPS_FILENAME' -Value (Join-Path -Path $storeFolder -ChildPath "timestamps.json") -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'LOGFILE_NAME' -Value (Join-Path -Path $logsFolder -ChildPath "CitrixConnector-%{+yyyyMMdd}.log") -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'ZIPFILE_NAME' -Value (Join-Path -Path $logsFolder -ChildPath "RotatedLogs.zip") -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'CONFIG_FILE_NAME' -Value $configFileFolder -Option ReadOnly -Scope Script -Force

    Initialize-Folder -Path $logsFolder
    Initialize-Folder -Path $storeFolder
    Initialize-Logger
    Get-ConfigData -CitrixEnvironment $CitrixEnvironment -ConfigFilePath $configFileFolder
    # Needed to update log level after reading it from the configuration
    Initialize-Logger
    New-Variable -Name 'TIMESTAMP' -Value $(Get-Date -Format o) -Option ReadOnly -Scope Script -Force
}

function Test-ShouldRunFullScan {
    param(
        [Parameter(Mandatory = $true)]
        [string]$LastFullRun
    )

    try {
        $lastRunTime = Get-StringToDate -DateString $LastFullRun
        $hourAgo = (Get-NowDateInUTC).AddHours(-1)
        $isOlderThanHour = $lastRunTime -lt $hourAgo
        Write-CustomLog -Message "Last full run ($lastRunTime) was $(if ($isOlderThanHour) { 'more' } else { 'less' }) than an hour ago ($hourAgo)" -Severity 'INFO'
        return $isOlderThanHour
    }
    catch {
        Write-CustomLog -Message "Error checking last full run timestamp: $($_.Exception.Message)" -Severity 'ERROR'
        # If there's an error reading/parsing the timestamp, consider it as older than an hour
        return $true
    }
}

function Start-EnrichmentProcess {
    param(
        [Parameter(Mandatory = $true)]
        [string]$CitrixEnvironment
    )
    $nextLink = $null
    $hypervisorTypes = Get-HypervisorTypes
    $diskImages = Get-DiskImages
    $timestampsFromFileStore = Read-TimestampFile

    $isFullScan = $true
    if ($null -ne $timestampsFromFileStore -and $null -ne $timestampsFromFileStore.last_full_run) {
        $isFullScan = Test-ShouldRunFullScan -LastFullRun $timestampsFromFileStore.last_full_run
    }

    $citrixInitialQuery = if ($isFullScan) {
        [String]::Format($FULL_ENRICHMENT_QUERY, $CITRIX_DIRECTOR_HOST)
    } else {
        Get-MachineDeltaQuery -LastDeltaRun $timestampsFromFileStore.last_delta_run
    }

    $devices = @()
    do {
        $citrixQuery = if ($nextLink) { $nextLink } else { $citrixInitialQuery }

        $citrixResponse = Read-ApiCitrixData -CitrixQuery $citrixQuery
        $nextLink = $citrixResponse.nextLink
        $devices = $devices + $citrixResponse.devices
    } while ($nextLink)
    $totalDevicesToSend = $devices.Count

    if (!$totalDevicesToSend) {
        return
    }

    Write-CustomLog -Message "$totalDevicesToSend device(s) retrieved in total from Citrix API." -Severity 'INFO'
    for ($devicesOffset = 0; $devicesOffset -lt $totalDevicesToSend; $devicesOffset += $REQUEST_BATCH_SIZE) {
        $deviceLimit = [Math]::Min($totalDevicesToSend - 1, $devicesOffset + $REQUEST_BATCH_SIZE - 1)
        $fullCitrixData = Expand-CitrixDataToSend -CitrixData $devices[$devicesOffset..$deviceLimit] -HypervisorTypes $hypervisorTypes -DiskImages $diskImages
        Send-EnrichmentRequest -FullCitrixData $fullCitrixData -FullScan $isFullScan -CitrixEnvironment $CitrixEnvironment
    }

    $lastFullRun = $timestampsFromFileStore.last_full_run;
    $now = Get-NowDateInUTCAsString
    if ($isFullScan) {
        $lastFullRun = $now;
    }
    # Always set lastDeltaRun to now, if it was a full run means the delta data is included
    # Should write all timestamps in Universal Time
    Write-TimestampFile -LastFullRun $lastFullRun -LastDeltaRun $now
}

function Get-MachineDeltaQuery {
    param(
        [Parameter(Mandatory = $true)]
        [string]$LastDeltaRun
    )
    $LastDeltaRunDate = Get-StringToDate -DateString $LastDeltaRun
    $lastDeltaRunWithBuffer = Get-DateAsString -Date ($LastDeltaRunDate.AddSeconds(-$DELTA_BUFFER_IN_SECONDS))
    Write-CustomLog -Message "Retrieving delta data only. Powered on Machines since [$lastDeltaRunWithBuffer]" -Severity 'INFO'
    return [String]::Format($DELTA_ENRICHMENT_QUERY, $CITRIX_DIRECTOR_HOST, $lastDeltaRunWithBuffer)
}

function Read-ApiCitrixData ([String]$CitrixQuery) {
    try {
        $citrixUserCredentials = Get-CitrixCredentials

        $citrixResponse = Invoke-WebRequest -Credential $citrixUserCredentials -Uri $CitrixQuery -UseBasicParsing
        $citrixResponseContent = ConvertFrom-Json $([String]::new($citrixResponse.Content))
        $nextLink = $citrixResponseContent.'@odata.nextLink'
        $listCitrixDevices = $citrixResponseContent.value
    } catch [Net.WebException], [IO.IOException] {
        Write-CustomLog "Error sending request to Citrix API. Details: [$($_.Exception.response.StatusCode.value__)] $($_.ErrorDetails)" -Severity "ERROR"
        throw "Unable to access Citrix API. Details: $($_.Exception.Message)"
    } catch {
        throw "Error retrieving devices from Citrix API. Details: $($_.toString())"
    }
    Write-CustomLog -Message "$($listCitrixDevices.Count) device(s) retrieved from Citrix API." -Severity 'DEBUG'
    return @{ devices = $listCitrixDevices; nextLink = $nextLink }
}

#
# Local Store
#
function Write-TimestampFile {
    param(
        [Parameter(Mandatory = $true)]
        [string]$LastFullRun,
        [Parameter(Mandatory = $true)]
        [string]$LastDeltaRun
    )
    try {
        $timestamps = @{
            last_full_run = $LastFullRun
            last_delta_run = $LastDeltaRun
        }

        # Convert to JSON and write to file
        $jsonContent = $timestamps | ConvertTo-Json
        Write-CustomLog -Message "Updating timestamps in store file - Last Full Run: [$($LastFullRun)], Last Delta Run: [$($LastDeltaRun)]" -Severity 'INFO'
        $jsonContent | Out-File -FilePath $STORE_TIMESTAMPS_FILENAME -Force -Encoding UTF8

        Write-CustomLog -Message "Successfully wrote timestamps to $STORE_TIMESTAMPS_FILENAME" -Severity 'INFO'
    }
    catch {
        Write-CustomLog -Message "Failed to write timestamps to file: $($_.Exception.Message)" -Severity 'ERROR'
        throw "Failed to write timestamps to file: $($_.Exception.Message)"
    }
}

function Read-TimestampFile {
    try {
        if (Test-Path $STORE_TIMESTAMPS_FILENAME) {
            $content = Get-Content -Path $STORE_TIMESTAMPS_FILENAME -Raw | ConvertFrom-Json
            return @{
                last_full_run = $content.last_full_run
                last_delta_run = $content.last_delta_run
            }
        }
        else {
            Write-CustomLog -Message "Timestamp file not found at $STORE_TIMESTAMPS_FILENAME. If this is the first run, this is expected and the file will be created." -Severity 'INFO'
            return $null
        }
    }
    catch {
        Write-CustomLog -Message "Failed to read timestamps from file: $($_.Exception.Message)" -Severity 'ERROR'
        throw "Failed to read timestamps from file: $($_.Exception.Message)"
    }
}

#
# Date functions
#
function Get-StringToDate {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DateString
    )
    return ([DateTime]::ParseExact($DateString, "yyyy-MM-ddTHH:mm:ssZ", [System.Globalization.CultureInfo]::InvariantCulture)).ToUniversalTime()
}

function Get-NowDateInUTC {
    return (Get-Date).ToUniversalTime()
}

function Get-DateAsString {
    param(
        [Parameter(Mandatory = $true)]
        [DateTime]$Date
    )
    return $Date.ToString("yyyy-MM-ddTHH:mm:ssZ")
}

function Get-NowDateInUTCAsString {
    return Get-DateAsString -Date $(Get-NowDateInUTC)
}

#
# Credentials
#
function Get-CitrixCredentials {
    $storedCredentials = Get-StoredCredential -Target $CITRIX_CREDENTIALS_NAME

    if (-not $storedCredentials) {
        throw "Citrix credentials not found for target: $CITRIX_CREDENTIALS_NAME"
    }

    return @($storedCredentials)
}

function Get-ClientCredentials () {
    $storedCredentials = Get-StoredCredential -Target $TARGET_CREDENTIALS_NAME
    if ($storedCredentials -and $null -ne $storedCredentials.UserName -and $null -ne $storedCredentials.Password ) {
        $userName = $storedCredentials.UserName
        $securePassword = $storedCredentials.Password
        $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword)
        $unsecurePassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)

        return @{ clientId = $userName; clientSecret = $unsecurePassword }
    } else {
        throw "Credentials not found or they are empty for Target: $TARGET_CREDENTIALS_NAME"
    }
}

#
# Logging
#
function Write-CustomLog {
    param(
        [Parameter(Mandatory = $true)]
        [String]$Message,
        [Parameter(Mandatory = $false)]
        [String]$Severity = 'INFO',
        [Parameter(Mandatory = $false)]
        [Switch]$NoCache
    )

    Write-Log -Message $Message -Level $Severity

    # Store log in memory for later transmission to logs API (unless NoCache is specified)
    if (-not $NoCache) {
        $logTimestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
        $logEntry = @{
            timestamp = $logTimestamp
            level = $Severity.ToUpper()
            message = $Message
        }
        [void]$script:IN_MEMORY_LOGS.Add($logEntry)
    }
}

function Initialize-Folder ([String]$Path) {
    try {
        if (-not (Test-Path -Path $Path)) {
            [Void](New-Item -Path $Path -ItemType 'Directory' -Force -ErrorAction Stop)
        }
    } catch {
        throw "Error creating folder at $Path."
    }
}

function Initialize-Logger {
    Add-LoggingTarget -Name File -Configuration @{
        Path              = $LOGFILE_NAME
        Encoding          = 'unicode'
        Level             = $LOG_LEVEL
        Format            = $LOG_FORMAT
        RotateAfterAmount = $LOG_RETENTION_DAYS
        RotateAmount      = 1
        CompressionPath   = $ZIPFILE_NAME
    }
    Set-LoggingCallerScope 2
}

function Get-ModuleVersion() {
    param (
        [Object]$Module = $MyInvocation.MyCommand.Module
    )

    try {
        return $Module.Version.ToString()
    } catch {
        Write-CustomLog -Message "Could not determine module version: $_" -Severity 'WARNING'
        return '-'
    }
}

function Send-Logs {
    param(
        [Parameter(Mandatory = $true)]
        [String]$CitrixEnvironment,
        [Parameter(Mandatory = $true)]
        [String]$Version
    )

    if ($script:IN_MEMORY_LOGS.Count -eq 0) {
        return
    }

    try {
        # Get JWT token
        $jwt = Get-Jwt

        # Build logs endpoint URL
        $logsEndpoint = [String]::Format($LOGS_URL, $ENRICHMENT_API_HOST)

        # Build logs payload
        $logsPayload = @{
            source = "citrix-cvad-on-prem-connector"
            customerEnvironment = $CitrixEnvironment
            version = $Version
            logs = $script:IN_MEMORY_LOGS
        }

        $logsJsonPayload = $logsPayload | ConvertTo-Json -Depth 10 -Compress

        # Send logs to API
        $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
        $headers.Add('Content-Type', 'application/json')
        $headers.Add('Authorization', "Bearer $jwt")
        $headers.Add($LOGS_TRACE_ID_HEADER, $TRACE_ID_VALUE)

        $response = Invoke-WebRequest -Uri $logsEndpoint -Method 'POST' -Headers $headers -Body $logsJsonPayload -UseBasicParsing
        Write-CustomLog -Message "[$TRACE_ID_VALUE] Successfully sent $($script:IN_MEMORY_LOGS.Count) log entries to Logs API. Response code: $($response.StatusCode)" -Severity 'INFO' -NoCache
    } catch {
        # Use NoCache to avoid adding to IN_MEMORY_LOGS and creating infinite recursion
        Write-CustomLog -Message "[$TRACE_ID_VALUE] Error sending logs to Logs API. Details: $($_.Exception.Message)" -Severity 'ERROR' -NoCache
        # Don't throw - we don't want log sending failures to break the main execution
    }
}

#
# Config functions
#
function Get-ConfigData {
    param(
        [Parameter(Mandatory = $true)]
        [string]$CitrixEnvironment,
        [Parameter(Mandatory = $true)]
        [string]$ConfigFilePath
    )

    $configData = Read-ConfigFile -ConfigFilePath $ConfigFilePath
    $citrixEnvironmentConfigData = Get-CitrixEnvironmentConfigData -ConfigData $configData -CitrixEnvName $CitrixEnvironment

    if ($null -eq $citrixEnvironmentConfigData) {
        throw "$CITRIX_ENV_CONFIG_NOT_FOUND"
    }

    if (-not (Test-ConfigData -ConfigData $configData -CitrixEnvConfigData $citrixEnvironmentConfigData)) {
        throw "$MISSING_FIELDS"
    }

    New-Variable -Name 'LOG_RETENTION_DAYS' -Value $configData.Logging.LogRetentionDays -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'LOG_LEVEL' -Value $configData.Logging.LogLevel -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'CITRIX_DIRECTOR_HOST' -Value $citrixEnvironmentConfigData.CitrixDirectorFQDN -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'CITRIX_CONTROLLER_HOST' -Value $citrixEnvironmentConfigData.CitrixControllerFQDN -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'CITRIX_CREDENTIALS_NAME' -Value $citrixEnvironmentConfigData.WindowsCredentialEntry -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'ENRICHMENT_API_HOST' -Value $configData.NexthinkAPI.HostFQDN -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'TARGET_CREDENTIALS_NAME' -Value $configData.NexthinkAPI.WindowsCredentialEntry -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'REQUEST_BATCH_SIZE' -Value $configData.NexthinkAPI.RequestBatchSize -Option ReadOnly -Scope Script -Force
}

function Read-ConfigFile {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ConfigFilePath
    )
    try {
        return (Get-Content "$ConfigFilePath" -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop)
    } catch {
        throw "Error loading config file. Details: $($_.toString())"
    }
}

function Get-CitrixEnvironmentConfigData($ConfigData, $CitrixEnvName) {
    return $ConfigData.CitrixEnvironments | Where-Object { $_.Name -eq $CitrixEnvName }
}

function Test-ConfigData($ConfigData, $CitrixEnvConfigData) {
    return ($ConfigData.Logging.LogRetentionDays -and $ConfigData.Logging.LogLevel -and $ConfigData.NexthinkAPI.HostFQDN -and `
            $ConfigData.NexthinkAPI.WindowsCredentialEntry -and $ConfigData.NexthinkAPI.RequestBatchSize -and `
            $CitrixEnvConfigData.Name -and $CitrixEnvConfigData.CitrixDirectorFQDN -and $CitrixEnvConfigData.CitrixControllerFQDN -and `
            $CitrixEnvConfigData.WindowsCredentialEntry)
}

#
# Citrix functions
#
function Write-CitrixSnapInLogs {
   $registeredSnapins = Get-PSSnapin -Registered | Where-Object Name -like 'Citrix*'

   if ($registeredSnapins.Count -eq 0) {
       throw "No Citrix snap-ins are registered. Ensure that the Citrix SDK is installed."
   }

   foreach ($snapin in $registeredSnapins) {
        # We should be able to correlate the snap-in version to the CVAD marketing version
        Write-CustomLog -Message "Registered Citrix snap-in: $($snapin.Name), Version: $($snapin.Version)" -Severity 'DEBUG'
    }
}

function Set-HypervisorType([Object[]]$CitrixData, [hashtable]$HypervisorTypes) {
    if ($HypervisorTypes.Count) {
        foreach ($device in $CitrixData) {
            $hypervisorType = ""
            if ($null -eq $device.Hypervisor -or $null -eq $device.Hypervisor.Name) {
                $device.Hypervisor = [PSCustomObject]@{}
            } else {
                $hypervisorType = $HypervisorTypes[$device.Hypervisor.Name]
            }
            $device.Hypervisor | Add-Member -NotePropertyName Type -NotePropertyValue $hypervisorType
        }
    }

    return $CitrixData
}

function Get-HypervisorTypes() {
    [hashtable]$hypervisorTypes = @{}
    try {
        $hypervisorTypesList = Get-HypervisorTypesFromBroker
        if (-not $hypervisorTypesList) {
            Write-CustomLog -Message "No hypervisor types found in broker. Hypervisor name won't be enriched." -Severity 'WARNING'
            return $hypervisorTypes
        }
        foreach ($hypervisorType in $hypervisorTypesList) {
            $hypervisorTypes[$hypervisorType.Name] = $hypervisorType.HypHypervisorType
            if ('' -eq $hypervisorType.HypHypervisorType) {
                Write-CustomLog -Message "Empty hypervisor type for hypervisor '$($hypervisorType.Name)'" -Severity 'WARNING'
            }
        }
    } catch {
        Write-CustomLog -Message "Error retrieving hypervisors. Hypervisor name won't be enriched. Details: $($_.Exception.Message)" -Severity 'ERROR'
    }

    return $hypervisorTypes
}

function Get-HypervisorTypesFromBroker() {
    $scriptBlock = { Add-PSSnapin -Name Citrix.Broker* -ErrorAction SilentlyContinue;
        (Get-BrokerHypervisorConnection -AdminAddress $Using:CITRIX_CONTROLLER_HOST | Select-Object -Property Name, HypHypervisorType) }

    [PSCredential]$citrixUserCredentials = Get-CitrixCredentials

    $job = Start-Job -ScriptBlock $scriptBlock -Credential $citrixUserCredentials
    [Void](Wait-Job -Job $job)
    $output = Receive-Job -Job $job -ErrorAction SilentlyContinue
    $jobErrors = ($job.ChildJobs[0].Error | ForEach-Object { $_.ToString() }) -join "`n"
    $jobState = $job.State
    # cleanup job following best practices to avoid memory leaks
    Remove-Job -Job $job -Force -ErrorAction SilentlyContinue

    if ($jobErrors) {
        throw "Hypervisor types job returned errors. Details: $jobErrors"
    }

    if ($jobState -ne 'Completed') {
        Write-CustomLog -Message "Hypervisor types job did not complete. Job state: $jobState" -Severity 'WARNING'
        return $null
    }

    return $output
}

function Set-DiskImage([Object[]]$CitrixData, [hashtable]$DiskImages) {
    if ($DiskImages.Count) {
        foreach ($device in $CitrixData) {
            $diskImage = ""
            if ($null -eq $device.Catalog -or $null -eq $device.Catalog.ProvisioningSchemeId) {
                $device.Catalog = [PSCustomObject]@{}
            } else {
                $fullDiskImage = $DiskImages[$device.Catalog.ProvisioningSchemeId]
                $diskImage = if ($null -ne $fullDiskImage) { $fullDiskImage.Split('\')[-1] } else { $null }
            }
            $device.Catalog | Add-Member -NotePropertyName DiskImage -NotePropertyValue $diskImage
        }
    }

    return $CitrixData
}

function Get-DiskImages() {
    [hashtable]$diskImages = @{}
    try {
        $provSchemesList = Get-DiskImagesFromProvScheme
        if (-not $provSchemesList) {
            Write-CustomLog -Message "No provisioning schemes found in broker. Disk image won't be enriched." -Severity 'WARNING'
            return $diskImages
        }
        foreach ($provScheme in $provSchemesList) {
            $diskImages[[String]$provScheme.ProvisioningSchemeUid] = $provScheme.MasterImageVM
            if ('' -eq $provScheme.MasterImageVM) {
                Write-CustomLog -Message "Empty disk image for prov scheme '$($provScheme.ProvisioningSchemeUid)'" -Severity 'WARNING'
            }
        }
    } catch {
        Write-CustomLog -Message "Error retrieving disk images. Disk image won't be enriched. Details: $($_.Exception.Message)" -Severity 'ERROR'
    }

    return $diskImages
}

function Get-DiskImagesFromProvScheme() {
    $scriptBlock = { Add-PSSnapin -Name Citrix.* -ErrorAction SilentlyContinue;
    (Get-ProvScheme -AdminAddress $Using:CITRIX_CONTROLLER_HOST | Select-Object -Property ProvisioningSchemeUid, MasterImageVM) }

    [PSCredential]$citrixUserCredentials = Get-CitrixCredentials

    $job = Start-Job -ScriptBlock $scriptBlock -Credential $citrixUserCredentials
    [Void](Wait-Job -Job $job)
    $output = Receive-Job -Job $job -ErrorAction SilentlyContinue
    $jobErrors = ($job.ChildJobs[0].Error | ForEach-Object { $_.ToString() }) -join "`n"
    $jobState = $job.State
    Remove-Job -Job $job -Force -ErrorAction SilentlyContinue

    if ($jobErrors) {
        throw "Disk images job returned errors. Details: $jobErrors"
    }

    if ($jobState -ne 'Completed') {
        Write-CustomLog -Message "Disk images job did not complete. Job state: $jobState" -Severity 'WARNING'
        return $null
    }

    return $output
}

function Expand-CitrixDataToSend ([Object[]]$CitrixData, [hashtable]$HypervisorTypes, [hashtable]$DiskImages) {
    $citrixDataWithHypervisor = Set-HypervisorType -CitrixData $CitrixData -HypervisorTypes $HypervisorTypes
    return Set-DiskImage -CitrixData $citrixDataWithHypervisor -DiskImages $DiskImages
}

function Send-EnrichmentRequest ([Object[]]$FullCitrixData, [Boolean]$FullScan, [String]$CitrixEnvironment) {
    # Time in milliseconds since the script started up to when the enrichment request is sent
    $executionDuration = [Int](((Get-Date) - $EXECUTION_START_DATE).TotalMilliseconds)
    $clientTelemetry = Get-ClientTelemetry -ExecutionDuration $executionDuration -FullScan $FullScan

    $jwt = Get-Jwt
    $enrichmentEndpoint = [String]::Format($ENRICHMENT_URL, $ENRICHMENT_API_HOST)

    $enrichmentBody = Get-JsonFromCitrixData -CitrixData $FullCitrixData -ClientTelemetry $clientTelemetry -CitrixEnvironment $CitrixEnvironment
    $response = Invoke-SendDataToEnrichmentAPI -EndpointURL $enrichmentEndpoint -JsonPayload $enrichmentBody -Jwt $jwt
    switch ( $response.statusCode ) {
        '200' {
            Write-CustomLog -Message "[$TRACE_ID_VALUE] Batch with $($FullCitrixData.Count) devices successfully processed by Enrichment API." -Severity 'INFO'
        }
        '207' {
            Write-CustomLog -Message "[$TRACE_ID_VALUE] Batch with $($FullCitrixData.Count) devices partially processed by Enrichment API." -Severity 'INFO'
            Write-CustomLog -Message "[$TRACE_ID_VALUE] Partial success response: $($response.content)" -Severity 'INFO'
        }
        default {
            $message = "[$TRACE_ID_VALUE] Error sending request to Enrichment API with status code: $($response.statusCode)"
            Write-CustomLog -Message $message -Severity 'ERROR'
            throw $message
        }
    }
}

function Get-Jwt () {
    $jwtUrl = [String]::Format($JWT_URL, $ENRICHMENT_API_HOST)
    if (-not (Test-ValidWebUrl($jwtUrl))) {
        throw "Invalid URL to retrieve the token: $jwtUrl"
    }

    try {
        $credentials = Get-ClientCredentials

        $basicHeader = Get-StringAsBase64 -InputString "$($credentials.clientId):$($credentials.clientSecret)"
        $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
        $headers.Add('Authorization', "Basic $basicHeader")
        $response = Invoke-WebRequest -Uri $jwtUrl -Method 'POST' -Headers $headers -UseBasicParsing
        $parsedResponse = ConvertFrom-Json $([String]::new($response.Content))
        return $parsedResponse.access_token
    } catch [Net.WebException], [IO.IOException] {
        Write-CustomLog "Error sending request to get the JWT token. Details: [$($_.Exception.response.StatusCode.value__)] $($_.ErrorDetails)" -Severity "ERROR"
        throw "Unable to access token endpoint. Details: $($_.Exception.Message)"
    } catch {
        throw "An error occurred that could not be resolved. Details: $($_.Exception.Message)"
    }
}

function Test-ValidWebUrl($UrlToValidate) {
    $uri = $UrlToValidate -as [System.Uri]
    $null -ne $uri.AbsoluteURI -and $uri.Scheme -match '[http|https]'
}

function Get-StringAsBase64 ([String]$InputString) {
    $Bytes = [System.Text.Encoding]::UTF8.GetBytes($InputString)
    $EncodedText = [Convert]::ToBase64String($Bytes)
    return $EncodedText
}

function Get-JsonFromCitrixData ([Object[]]$CitrixData, [PSCustomObject]$ClientTelemetry, [String]$CitrixEnvironment) {
    $jsonResult = '{"enrichments": ['
    foreach ($device in $CitrixData) {
        try {
            if (Test-MachineIsValid($device)) {
                $deviceName = Get-MachineName($device.DnsName)

                $virtualizationType = Get-VirtualizationType -DesktopKind $device.DesktopGroup.DesktopKind -SessionSupport $device.DesktopGroup.SessionSupport

                $currentRow = '{"identification":[{"name":"device/device/name","value":"' + $deviceName + '"}],'
                $currentRow = $currentRow + '"fields":[{"name":"device/device/virtualization/desktop_pool","value":"' + $device.DesktopGroup.Name + '"}'
                $currentRow = $currentRow + ',{"name":"device/device/virtualization/type","value": ' + $virtualizationType + '}'
                $currentRow = $currentRow + ',{"name":"device/device/virtualization/hostname","value":"' + $device.HostingServerName + '"}'
                if ($null -ne $device.Hypervisor.Type) {
                    $currentRow = $currentRow + ',{"name":"device/device/virtualization/hypervisor_name","value":"' + $device.Hypervisor.Type + '"}'
                }
                $currentRow = $currentRow + ',{"name":"device/device/virtualization/environment_name","value":"' + $CitrixEnvironment +'"}'
                $currentRow = $currentRow + ',{"name":"device/device/virtualization/desktop_broker","value": ' + $CITRIX_CVAD + '}'
                if ($false -eq $device.IsPendingUpdate -and $null -ne $device.Catalog.DiskImage) {
                    $currentRow = $currentRow + ',{"name":"device/device/virtualization/disk_image","value":"' + $device.Catalog.DiskImage + '"}'
                }
                $currentRow = $currentRow + ',{"name":"device/device/virtualization/last_update","value":"' + $TIMESTAMP + '"}'
                $currentRow = $currentRow + ']},'
                $jsonResult = $jsonResult + $currentRow
            } else {
                Write-CustomLog -Message "Invalid device: '$($device | ConvertTo-Json -Compress)'" -Severity 'DEBUG'
            }
        } catch {
            Write-CustomLog -Message "Error processing device '$deviceName'. Details: $($_.Exception.Message)" -Severity 'ERROR'
        }
    }
    if ($jsonResult.EndsWith(',')) {
        $jsonResult = $jsonResult.Substring(0, $jsonResult.Length - 1)
    }

    $clientTelemetryJson = $clientTelemetry | ConvertTo-Json -Compress

    return $jsonResult + '], "domain":"citrix", "clientTelemetry":' +  $clientTelemetryJson + '}'
}

function Get-ClientTelemetry([Int]$ExecutionDuration, [Boolean]$FullScan) {
    $Version = Get-ModuleVersion

    return [PSCustomObject]@{
        version = $Version
        executionDurationInMs = $ExecutionDuration
        fullScan = $FullScan
    }
}

function Test-MachineIsValid([PSCustomObject]$Device) {
   if ($null -ne $Device.DnsName -and
        $null -ne $Device.DesktopGroup -and
        $null -ne $Device.DesktopGroup.Name -and
        '' -ne $Device.DesktopGroup.Name -and
        $null -ne $Device.DesktopGroup.DesktopKind -and
        $null -ne $Device.DesktopGroup.SessionSupport
    ) {
        $deviceName = Get-MachineName($Device.DnsName)
        if ('' -ne $deviceName) {
            return $true
        }
    }
    return $false
}

function Get-MachineName([String]$DnsName) {
    return $DnsName.Split('.')[0].ToUpper()
}

function Get-VirtualizationType([Int]$DesktopKind, [Int]$SessionSupport) {
    $separator = '@'
    $switchKey = '' + $DesktopKind + $separator + $SessionSupport

    $result = switch ( $switchKey ) {
        '1@2' { $SHARED }
        '0@1' { $PERSONAL }
        '1@1' { $POOLED }
        default { $UNKNOWN }
    }
    return $result
}

function Invoke-SendDataToEnrichmentAPI ([String]$EndpointUrl, [String]$JsonPayload, [String]$Jwt) {
    try {
        $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
        $headers.Add('Content-Type', 'application/json')
        $headers.Add('Authorization', "Bearer $Jwt")
        $headers.Add($TRACE_ID_HEADER, $TRACE_ID_VALUE)

        $response = Invoke-WebRequest -Uri $EndpointUrl -Method 'POST' -Headers $headers -Body $JsonPayload -UseBasicParsing
        $statusCode = $response.StatusCode
        $content = $response.Content
    } catch {
        Write-CustomLog -Message "[$TRACE_ID_VALUE] Error sending request to Enrichment API. Details: $($_.Exception.Message)" -Severity 'ERROR'
        Write-CustomLog -Message "[$TRACE_ID_VALUE] Error message: $_" -Severity 'ERROR'
        $statusCode = $_.Exception.response.StatusCode.value__
    }
    return @{ statusCode = $statusCode; content = $content }
}

#
# Prerequisite checks
#
function Test-RequiredModulesInstalled() {
    $requiredModules = @('CredentialManager', 'Logging')
    $missingModules = @()

    foreach ($module in $requiredModules) {
        if (-not (Get-Module -ListAvailable -Name $module)) {
            $missingModules += $module
        }
    }

    if ($missingModules.Count -gt 0) {
         $formattedMissingModules = $missingModules -join ', '
         Write-Error "Missing required modules: $formattedMissingModules"
        return $false
    }

    return $true
}

#
# Module exports - Only export when running as a module
#
try {
    Export-ModuleMember -Function * -Alias Invoke-NexthinkCitrixConnector
} catch [System.InvalidOperationException] {
    # Expected when dot-sourced as script during testing
} catch {
    # Silently ignore any other export errors
}

# SIG # Begin signature block
# MIIohQYJKoZIhvcNAQcCoIIodjCCKHICAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCC9nwsuxxoyaY6v
# OhE6k7onZcR2iNB1z84n4n9AOgmTBqCCIZ0wggWNMIIEdaADAgECAhAOmxiO+dAt
# 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV
# BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa
# Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy
# dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD
# ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
# ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E
# MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy
# unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF
# xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1
# 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB
# MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR
# WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6
# nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB
# YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S
# UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x
# q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB
# NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP
# TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC
# AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp
# Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv
# bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0
# aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB
# LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc
# Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov
# Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy
# oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW
# juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF
# mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z
# twGpn1eqXijiuZQwggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0GCSqG
# SIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx
# GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy
# dXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTlaMGkx
# CzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4
# RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEzODQg
# MjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C0Cit
# eLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce2vnS
# 1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0daE6ZM
# swEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6TSXBC
# Mo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoAFdE3
# /hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7OhD26j
# q22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM1bL5
# OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z8ujo
# 7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05huzU
# tw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNYmtwm
# KwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP/2NP
# TLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0TAQH/
# BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYDVR0j
# BBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1Ud
# JQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0
# cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0
# cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8E
# PDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVz
# dGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATANBgkq
# hkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95RysQDK
# r2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HLIvda
# qpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5BtfQ/g+
# lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnhOE7a
# brs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIhdXNS
# y0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV9zeK
# iwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/jwVYb
# KyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYHKi8Q
# xAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmCXBVm
# zGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l/aCn
# HwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZWeE4w
# gga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0GCSqGSIb3DQEBCwUAMGIx
# CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3
# dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH
# NDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTlaMGkxCzAJBgNVBAYTAlVT
# MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1
# c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwggIi
# MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHTCphBcr48RsAcrHXbo0Zo
# dLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPhof6pvF4uGjwjqNjfEvUi
# 6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mAxAHeHYNnQxqXmRinvuNg
# xVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBvMgEdgkFiDNYiOTx4OtiF
# cMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps0wjUjsZvkgFkriK9tUKJ
# m/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF83bRVFLeGkuAhHiGPMvS
# GmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXiUOeSLsJygoLPp66bkDX1
# ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOMCZIVNSaz7BX8VtYGqLt9
# MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydPpOjL6s36czwzsucuoKs7
# Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrUG2ZdSoQbU2rMkpLiQ6bG
# RinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+sdFUeEY0qVjPKOWug/G6
# X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAd
# BgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYDVR0jBBgwFoAU7NfjgtJx
# XWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUF
# BwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGln
# aWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5j
# b20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJo
# dHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNy
# bDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQEL
# BQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WIGjB/T8ObXAZz8OjuhUxj
# aaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+IQhQE7jU/kXjjytJgnn0
# hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8Myb9rEVKChHyfpzee5kH0
# F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2th9y1IsA0QF8dTXqvcnT
# mpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjajV/gxdEkMx1NKU4uHQcKf
# ZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2Lr3ty9qIijanrUR3anzE
# wlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFzeGxcytL5TTLL4ZaoBdqbh
# OhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG7uEBYTptMSbhdhGQDpOX
# gpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+NJpud/v4+7RWsWCiKi9EO
# LLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckTetiSuEtQvLsNz3Qbp7wG
# WqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszWkPZPubdcMIIG7TCCBNWg
# AwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0BAQsFADBpMQswCQYDVQQG
# EwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0
# IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUgQ0Ex
# MB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVowYzELMAkGA1UEBhMCVVMx
# FzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBTSEEy
# NTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIwMjUgMTCCAiIwDQYJKoZI
# hvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5gVrMrV7pvUf+GcAoB38o3
# zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN+vo8dE2/pPvOx/Vj8Tch
# TySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qome7MrxVyfQO9sMx6ZAWj
# FDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ//nBZZREr4h/GI6Dxb2Uo
# yrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouTMYFOnHoRh6+86Ltc5zjP
# KHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8DD+nigNJFmt6LAHvH3KS
# uNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnpJeItK/DhKbPxTTuGoX7w
# JNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP51ho1zBp+xUIZkpSFA8vW
# doUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49kPmk8iyyizNDIXj//cOg
# rY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5PWPsWeupWs7NpChUk555K
# 096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7YufAkprxMiXAJQ1XCmnCf
# gPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTkO/zy
# Me39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK6eQGfHrK4pBW9i/USezL
# TjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwgZUGCCsG
# AQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5j
# b20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdp
# Q2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNy
# dDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGln
# aUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2U0hBMjU2MjAyNUNBMS5j
# cmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9bAcBMA0GCSqGSIb3DQEB
# CwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP2zWLpQq1b4URGnwWBdEZ
# D9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O6Lgjg8K8elC4+oWCqnU/
# ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskgiC3QYIUP2S3HQvHG1FDu
# +WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMUBaB5bdrPbF6MRYs03h4o
# bEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDFkxUGtMTaiLR9wjxUxu2h
# ECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+zJNEsuEB7O7/cuvTQasn
# M9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/lwd6UArb+BOVAkg2oOvol
# /DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxlRcGG0LIhp6GvReQGgMgY
# xQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2zRWVFjF7mcr4C34Mj3oc
# CVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJgbaP5t2nGj/ULLi49xTcB
# ZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOCIUjsarfNZzCCB6swggWT
# oAMCAQICEA20PGu9jXp4I/Dy4yL8Si8wDQYJKoZIhvcNAQELBQAwaTELMAkGA1UE
# BhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2Vy
# dCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENB
# MTAeFw0yNTExMTMwMDAwMDBaFw0yODExMTQyMzU5NTlaMIGzMRMwEQYLKwYBBAGC
# NzwCAQMTAkNIMRUwEwYLKwYBBAGCNzwCAQITBFZhdWQxHTAbBgNVBA8MFFByaXZh
# dGUgT3JnYW5pemF0aW9uMRgwFgYDVQQFEw9DSEUtMTEyLjAwMC41NzkxCzAJBgNV
# BAYTAkNIMQ8wDQYDVQQHEwZQcmlsbHkxFjAUBgNVBAoTDU5FWFRoaW5rIFMuQS4x
# FjAUBgNVBAMTDU5FWFRoaW5rIFMuQS4wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw
# ggIKAoICAQDIbNJpFq6aDbvQP7zFPymmt3WRPkvbmnRL4g0imzFNbaj+U/RIGPL0
# 3l9TtYxzraPbwDdidmT5P50mK1JqjF5FzcZEC4kZfMMojY4J/gOylo8jOdLjBscL
# SYwsY+85ZRs2bvxibPXIu4AcZttxBx5XdbVyt2Bd1vtuJGj+wfU1vJu7NB3GHjPy
# 4QE1BFrgidqQTL+1HASQaWwNMetW8B1U1POZjr6uWp6Ga3DF/MAKxpbvU3iVFI8b
# YFpKiVIQ9Namj3DQ7Xj26+fSEP959+bukG8LyObHN5DDkRdpnCSVVMT6qxZ2QGri
# DFkyjOVaGkjJTI6mbKY1JPUJhYVo+7Jqhu+2EFGRy6wSj9wZBubnbwfuvgr/XlH9
# /xQQuX9i/EJvxJRQQyz9IzLtB/DBSoEQvtyCh8Po87k9x9CGYn7k1HxUg1F6oBKa
# kemv3G474RWKZwSEIbwUlygFydEpTDkRTqBDZKOQf3VaUvfUgGmBmEfq1m+cljHj
# fLDBtkDIGaZ7SvyO72q9Uj0HrIWvJDHHIYK84gqhe58vKgaQ0uH9V5BCUT5cLvau
# +1w93iq5AMCTtG96SEyjI/T9Jzh+2sM+PoUwlLNLqYFUvMI+l19X+5UtQnjU5k1c
# ooxqCebatsS642gd3X1VoeNyfJ5FB67b6KGNfrDOXBt5FXgKZAUOHwIDAQABo4IC
# AjCCAf4wHwYDVR0jBBgwFoAUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHQYDVR0OBBYE
# FICGOh06CZAfMG3M4l2zy9590p0OMD0GA1UdIAQ2MDQwMgYFZ4EMAQMwKTAnBggr
# BgEFBQcCARYbaHR0cDovL3d3dy5kaWdpY2VydC5jb20vQ1BTMA4GA1UdDwEB/wQE
# AwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzCBtQYDVR0fBIGtMIGqMFOgUaBPhk1o
# dHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2ln
# bmluZ1JTQTQwOTZTSEEzODQyMDIxQ0ExLmNybDBToFGgT4ZNaHR0cDovL2NybDQu
# ZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2
# U0hBMzg0MjAyMUNBMS5jcmwwgZQGCCsGAQUFBwEBBIGHMIGEMCQGCCsGAQUFBzAB
# hhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wXAYIKwYBBQUHMAKGUGh0dHA6Ly9j
# YWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5n
# UlNBNDA5NlNIQTM4NDIwMjFDQTEuY3J0MAkGA1UdEwQCMAAwDQYJKoZIhvcNAQEL
# BQADggIBALSLCU45H6xjr2Ce9mmr6WtqdnykOcc2s1iqd/UN88f4GlmzVmtA9Dts
# 9j8X77hxHYIAJwWM3NqKi8m55CB5HQBuOC3hOI8bWFdQLBsrAbBAApFh3cjRruGn
# r0mu7wlZo9HLgpM1PEy+hf90+cANYRBITrRkfVT4o2rTc8mmJyYzZM8WVwxGx+wm
# PAuvyjYKsjZNTeJ+miYqwJ/zHjc8LQP+/hYPNmP9ubYpMMkgC49vP9Uir0E4YI2C
# VTbUuhxao4Pmy2MnqhK20lDu50ZCNDNFurYhzXWBhK1cel6Ku4yuT6TlsjNye0AY
# v92FDyAaYH9VZuub7qZBNv/RwmcLUDqqh5rnEdeGMH7KFkUyN63RMEr7hLXV/Tav
# 7K0kWlXKPLIFenfqJnPYceni6PdJS/7WlxnBE0UwgoSoe4HawnEF0YvYBU3uyK+v
# TJWVau5Jptjp94uEQmW6PidWwUWNzVl4LQ7eEZe4ykZ0JLErbGnOeoUoZCeVzpx4
# uUEFiZv1KLeLEkB/8yFIrQp03sVMItqhNJ5mbDOoWW4y3Z8/jduhxVPwJMHhgglK
# 3z3sZr5xrpY3n1Qcqn1xFsI6U7rOrkzyQBw+d/yF82MW3LldZ7tPyU8LTEO89oOC
# EsGFwOfEb/wB7UJ1NulZ1R2nf5erYEnVsOsrGV0X1fYh2OoRQyxWMYIGPjCCBjoC
# AQEwfTBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/
# BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBTaWduaW5nIFJTQTQwOTYg
# U0hBMzg0IDIwMjEgQ0ExAhANtDxrvY16eCPw8uMi/EovMA0GCWCGSAFlAwQCAQUA
# oGowGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwG
# CisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEIAB5YKkR4FiqCzDTQ8UTbEQ2C7Jn
# WBmJzBC6MY3ANOJKMA0GCSqGSIb3DQEBAQUABIICAJK8NtYMcRJdd2KJza6eEQI5
# zNqWbRaxaijyIy12EPRuqbGtmE4P69oaTdEUFxH2pQ3PCVwbiSMA831GaGVMfpci
# 7BcJ8uY39Z8CWcB5ELMj8+8n+exhtfIqGFDaSRgMYakKE9/L3axihQtXBtm0tWdV
# 1sJ7uDAtpJJdgU8HrIeUoxZhFsa9JKlrjAPDR/UZKegd3W4Dkfyp0SGWsNdPSIxC
# SmuhpzuEL0nYFJRRRdkUYPaSbbUaxYvWGsmpTwDF2PoawsSNtcP5WqmwkQurCZW0
# 08kgkW6Nu9K9WYLAQ4ZFeqAIS/XI9DXLriW4Zp4Nl/mztHsprSOogvn1yh2m7ZjA
# dsSG/ksQTEiQ927Dj6OFkfJne5N7FpSjvQbpwQqcRB2DjHkCWp+Oy7KJuZEH2s9T
# jn4ZCFCWa//CU+bipWO05qMyt72EMA9DHF7qsoHNS6GAtP5NouGkLylxa8LixNuo
# lkYzHzT2YDqAUqYXBd/JoRGFdU9MHc3JMl5Js8oa/LP4rStFWkwyq/UzbkDt0dcI
# Zj0eHMyojRev0KSyceDT5gOrxABnup0JfMwQdJIWkInRkc/fTdH9WHMsmC3h6LRH
# iAz91QQ30P8Q7FG+qDVnBYQLLYFZvpwjgi7quBVq6lWj9faKKO1lRfdH6L1aReOY
# +NlDYf6tnRdoos21Ehr8oYIDJjCCAyIGCSqGSIb3DQEJBjGCAxMwggMPAgEBMH0w
# aTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQD
# EzhEaWdpQ2VydCBUcnVzdGVkIEc0IFRpbWVTdGFtcGluZyBSU0E0MDk2IFNIQTI1
# NiAyMDI1IENBMQIQCoDvGEuN8QWC0cR2p5V0aDANBglghkgBZQMEAgEFAKBpMBgG
# CSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTI2MDMxOTEy
# MzkzNlowLwYJKoZIhvcNAQkEMSIEIAw8nr//uO69GOzzN+HZchKsSD2bxK0thzeY
# ApVGW9YHMA0GCSqGSIb3DQEBAQUABIICAAjQg880afGl6JLok87if9G+stfqVESJ
# PsrG6me5fdeya6Y1aq+W7VVhvTCMA0hKjESngxMKMMSrliiGp7jbytaomQ+6ZmET
# ybyvUAqoHod/VMeMayHre7QFjf2A3prZP30jBPmsKEG6S3aKZf7DeqHlIAovmfpy
# y0uaar+Lc+MkOD9T/eTJWZBaiT32f1tgnU81t6pT3ep0a09adkc2Gw+BobHe3W7h
# TGU9E2NGpzyB1rP5t4vSXUcDbSDNg3CH+hrVJ7zZAREl87auDLnbOXHtyp8MoCAq
# STk6Xknq2620kb9OJIOLXP9N1cCTdTbNP8QPIGmvuIl4t7IbUC2NAzb5c236b+Ph
# hrs6+6ic6MVw56qz8Els6/xIaq5Vxm5wJb2DH9dZ9nc6p3zRWCrlgp0ug+hrEfix
# 8mhK3SO3FH/2Izf+dfWa+rmlUp3mju5cJYtsVVYIwEbgN4ncRuRyb3ClpQr/bWMu
# hrckjF7tG7rWZaNqWY13LUSRoBWzJjpRW+FWAnTcKJ9SfSaOhwQCyt2jWT/fV83L
# Z81YLbF9uJ0AVwQTu9+zEwGV7YQ/gLj0qyjEXrz2uk7djHds2166aQrCvDETOSAm
# qmKv79G7YRCByoLW+a8kz1Llerio45XWM40/U6jPkv6eI+xY+K5sB4jCPhtN2NUy
# K9dfRj39BsM1
# SIG # End signature block