helpers/geolocation/GeolocationHelpers.ps1

# =============================================================================
# Geolocation Helpers for WhatsUpGoldPS
# Queries the WhatsUp Gold REST API for devices/groups with latitude/longitude
# data and generates a Leaflet-based interactive HTML map.
#
# Devices : lat/lng stored in a device attribute named "LatLong" (format: "lat,lng")
# OR built-in attributes "Latitude" and "Longitude"
# Groups : lat/lng stored in the group description/note field (format: "lat,lng")
#
# Reference: https://github.com/jayyx2/wug-geolocation
# =============================================================================

# --- Internal: SSL bypass for self-signed certs ---
function Initialize-GeoSSLBypass {
    if ($PSVersionTable.PSEdition -eq 'Core') {
        $PSDefaultParameterValues["Invoke-RestMethod:SkipCertificateCheck"] = $true
        $PSDefaultParameterValues["Invoke-WebRequest:SkipCertificateCheck"] = $true
    }
    else {
        if (-not ([System.Management.Automation.PSTypeName]'GeoSSLValidator').Type) {
            Add-Type -TypeDefinition @"
using System.Net;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
public static class GeoSSLValidator {
    private static bool OnValidateCertificate(
        object sender,
        X509Certificate certificate,
        X509Chain chain,
        SslPolicyErrors sslPolicyErrors) {
        return true;
    }
    public static void OverrideValidation() {
        ServicePointManager.ServerCertificateValidationCallback =
            new RemoteCertificateValidationCallback(OnValidateCertificate);
        ServicePointManager.Expect100Continue = false;
        ServicePointManager.DefaultConnectionLimit = 64;
        ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;
    }
}
"@

        }
        [GeoSSLValidator]::OverrideValidation()
    }
    Write-Warning "Ignoring SSL certificate validation errors. Use this option with caution."
}

# --- Internal: REST wrapper that handles token refresh ---
function Invoke-GeoAPI {
    <#
    .SYNOPSIS
        Sends a request to the WhatsUp Gold REST API with automatic token refresh.
    .DESCRIPTION
        When the WhatsUpGoldPS module is loaded and connected (global WUG bearer
        headers exist), calls are routed through Get-WUGAPIResponse for unified
        logging, token management, and integrity. When the module is not available
        (e.g. standalone scheduled-task execution), the function falls back to
        direct Invoke-RestMethod with its own token refresh logic.
    .PARAMETER Config
        The configuration hashtable (from Import-GeolocationConfig).
    .PARAMETER Endpoint
        The API path (e.g. /api/v1/devices/-).
    .PARAMETER Method
        HTTP method. Defaults to GET.
    .PARAMETER Body
        Optional string body for POST/PUT requests.
    #>

    param(
        [Parameter(Mandatory)][hashtable]$Config,
        [Parameter(Mandatory)][string]$Endpoint,
        [string]$Method = 'GET',
        [string]$Body
    )

    $uri = "$($Config.BaseUri)$Endpoint"

    # --- Prefer WhatsUpGoldPS module when loaded ---
    if ($global:WUGBearerHeaders -and $global:WhatsUpServerBaseURI -and (Get-Command 'Get-WUGAPIResponse' -ErrorAction SilentlyContinue)) {
        try {
            Write-Verbose "Invoke-GeoAPI: routing through Get-WUGAPIResponse -> $Method $Endpoint"
            $moduleParams = @{ Uri = $uri; Method = $Method }
            if ($Body) { $moduleParams.Body = $Body }
            return (Get-WUGAPIResponse @moduleParams)
        }
        catch {
            Write-Verbose "Get-WUGAPIResponse failed ($($_.Exception.Message)), falling back to direct REST."
        }
    }

    # --- Fallback: direct Invoke-RestMethod with own token refresh ---

    # Check if token needs refreshing (within 5 minutes of expiry)
    if ((Get-Date) -ge $Config._Expiry.AddMinutes(-5)) {
        Write-Verbose "Token expired or expiring soon. Refreshing..."
        $refreshBody = "grant_type=refresh_token&refresh_token=$($Config._RefreshToken)"
        $refreshHeaders = @{ "Content-Type" = "application/json" }
        try {
            $newToken = Invoke-RestMethod -Uri "$($Config.BaseUri)/api/v1/token" `
                -Method Post -Headers $refreshHeaders -Body $refreshBody -ErrorAction Stop
            $Config._AccessToken  = $newToken.access_token
            $Config._RefreshToken = $newToken.refresh_token
            $Config._TokenType    = $newToken.token_type
            $Config._Expiry       = (Get-Date).AddSeconds($newToken.expires_in)
            Write-Verbose "Token refreshed. Expires at $($Config._Expiry)."
        }
        catch {
            throw "Failed to refresh WUG auth token: $($_.Exception.Message)"
        }
    }

    $headers = @{
        "Content-Type"  = "application/json"
        "Authorization" = "$($Config._TokenType) $($Config._AccessToken)"
    }
    $params = @{ Uri = $uri; Method = $Method; Headers = $headers; ErrorAction = 'Stop' }
    if ($Body) { $params.Body = $Body }

    $maxRetries = if ($PSVersionTable.PSEdition -eq 'Core') { 0 } else { 2 }
    for ($attempt = 0; $attempt -le $maxRetries; $attempt++) {
        try {
            return (Invoke-RestMethod @params)
        }
        catch {
            $isClosed = $_.Exception.Message -match 'underlying connection was closed|unexpected error occurred on a send'
            if ($isClosed -and $attempt -lt $maxRetries) {
                try {
                    $sp = [System.Net.ServicePointManager]::FindServicePoint([System.Uri]$uri)
                    $sp.CloseConnectionGroup('')
                } catch {}
                Start-Sleep -Milliseconds (300 * ($attempt + 1))
            }
            else { throw }
        }
    }
}

# --- Connect to the WUG server and get initial auth token ---
function Connect-GeoWUGServer {
    <#
    .SYNOPSIS
        Authenticates to a WhatsUp Gold server and returns a config hashtable.
    .DESCRIPTION
        Obtains an OAuth 2.0 token using password grant type from the WUG REST API.
        Returns a hashtable containing the base URI, tokens, and expiry for use
        with other Geolocation helper functions.
    .PARAMETER ServerUri
        The hostname or IP of the WhatsUp Gold server.
    .PARAMETER Username
        The username for authentication.
    .PARAMETER Password
        The password for authentication.
    .PARAMETER Protocol
        http or https (default: https).
    .PARAMETER Port
        The WUG API port (default: 9644).
    .PARAMETER IgnoreSSLErrors
        Bypass SSL certificate validation.
    .OUTPUTS
        Hashtable with keys: BaseUri, _AccessToken, _RefreshToken, _TokenType, _Expiry
    .EXAMPLE
        $config = Connect-GeoWUGServer -ServerUri "wug.example.com" -Username "admin" -Password "pass"
    #>

    param(
        [Parameter(Mandatory)][string]$ServerUri,
        [Parameter(Mandatory)][string]$Username,
        [Parameter(Mandatory)][string]$Password,
        [ValidateSet("http","https")][string]$Protocol = "https",
        [ValidateRange(1,65535)][int]$Port = 9644,
        [switch]$IgnoreSSLErrors
    )

    if ($IgnoreSSLErrors) { Initialize-GeoSSLBypass }

    $baseUri  = "${Protocol}://${ServerUri}:${Port}"
    $tokenUri = "$baseUri/api/v1/token"
    $headers  = @{ "Content-Type" = "application/json" }
    $body     = "grant_type=password&username=${Username}&password=${Password}"

    try {
        $token = Invoke-RestMethod -Uri $tokenUri -Method Post -Headers $headers -Body $body -ErrorAction Stop
    }
    catch {
        throw "Failed to authenticate to ${baseUri}: $($_.Exception.Message)"
    }

    if (-not $token.access_token -or -not $token.refresh_token) {
        throw "Token response missing required fields."
    }

    $config = @{
        BaseUri        = $baseUri
        ServerUri      = $ServerUri
        Protocol       = $Protocol
        Port           = $Port
        IgnoreSSL      = [bool]$IgnoreSSLErrors
        _AccessToken   = $token.access_token
        _RefreshToken  = $token.refresh_token
        _TokenType     = $token.token_type
        _Expiry        = (Get-Date).AddSeconds($token.expires_in)
    }

    Write-Verbose "Connected to ${ServerUri}. Token expires at $($config._Expiry)."
    return $config
}

# --- Get devices with LatLong attribute ---
function Get-GeoDevicesWithLocation {
    <#
    .SYNOPSIS
        Retrieves devices that have geolocation data (LatLong attribute).
    .DESCRIPTION
        Queries all devices in WUG, then fetches their attributes looking for
        "LatLong" (single attribute with "lat,lng") or built-in "Latitude"/"Longitude".
        Returns an array of objects with device info and parsed coordinates.
 
        Uses a JSON location cache to avoid re-fetching coordinates for devices
        whose locations haven't changed (coordinates are static; status comes from
        the overview endpoint and is always fresh). New or uncached devices are
        fetched with a PS 5.1-compatible runspace pool for parallelism.
    .PARAMETER Config
        The configuration hashtable from Connect-GeoWUGServer.
    .PARAMETER GroupName
        Optional device group name to filter devices. Default is all devices.
    .PARAMETER UseBuiltinCoords
        If set, uses separate "Latitude" and "Longitude" attributes instead of "LatLong".
    .PARAMETER RefreshCache
        Force re-fetch all device coordinates from the API, ignoring the cache.
    .OUTPUTS
        Array of PSCustomObject: Name, DeviceId, Latitude, Longitude, State, Status
    #>

    param(
        [Parameter(Mandatory)][hashtable]$Config,
        [string]$GroupName,
        [switch]$UseBuiltinCoords,
        [switch]$RefreshCache
    )

    $devices = @()

    # Get device list - if group specified, get from group; otherwise get all
    if ($GroupName -and $GroupName -ne 'All') {
        # Find the group
        $groupResult = Invoke-GeoAPI -Config $Config -Endpoint "/api/v1/device-groups/-?search=$([uri]::EscapeDataString($GroupName))&limit=250"
        $groupId = $null
        if ($groupResult.data.groups) {
            foreach ($g in $groupResult.data.groups) {
                if ($g.name -eq $GroupName) { $groupId = $g.id; break }
            }
        }
        if (-not $groupId) {
            Write-Warning "Device group '$GroupName' not found. Falling back to all devices."
        }
        else {
            $deviceResult = Invoke-GeoAPI -Config $Config -Endpoint "/api/v1/device-groups/$groupId/devices/-?view=overview&limit=250"
            if ($deviceResult.data.devices) {
                $devices = $deviceResult.data.devices
            }
        }
    }

    if ($devices.Count -eq 0 -and (-not $GroupName -or $GroupName -eq 'All' -or -not $groupId)) {
        # Get all devices from root group (id=0) with overview view for inline status
        $pageId = $null
        do {
            $endpoint = "/api/v1/device-groups/0/devices/-?view=overview&limit=250"
            if ($pageId) { $endpoint += "&pageId=$pageId" }
            $result = Invoke-GeoAPI -Config $Config -Endpoint $endpoint
            if ($result.data.devices) { $devices += $result.data.devices }
            $pageId = $result.paging.nextPageId
        } while ($pageId)
    }

    if ($devices.Count -eq 0) {
        Write-Warning "No devices found."
        return @()
    }

    Write-Verbose "Found $($devices.Count) devices. Checking for location attributes..."

    # --- Location cache: device coordinates rarely change ---
    $cacheDir      = Join-Path $env:LOCALAPPDATA 'WhatsUpGoldPS\DiscoveryHelpers\Cache'
    $cacheFile     = Join-Path $cacheDir 'geolocation-coords.json'
    $noCoordsFile  = Join-Path $cacheDir 'geolocation-no-coords.json'
    $cache         = @{}
    $noCoordsCache = @{}
    if (-not $RefreshCache -and (Test-Path $cacheFile)) {
        try {
            $cacheRaw = Get-Content -Path $cacheFile -Raw -Encoding UTF8 | ConvertFrom-Json
            foreach ($entry in $cacheRaw) {
                $cache["$($entry.Id)"] = @{ Lat = [double]$entry.Lat; Lng = [double]$entry.Lng }
            }
            Write-Verbose "Loaded $($cache.Count) cached device coordinates."
        }
        catch { Write-Verbose "Cache read failed, will re-fetch all: $_" }
    }
    if (-not $RefreshCache -and (Test-Path $noCoordsFile)) {
        try {
            $noCoordsRaw = Get-Content -Path $noCoordsFile -Raw -Encoding UTF8 | ConvertFrom-Json
            foreach ($id in $noCoordsRaw) { $noCoordsCache["$id"] = $true }
            Write-Verbose "Loaded $($noCoordsCache.Count) negative-cached device IDs (no coordinates)."
        }
        catch { Write-Verbose "Negative cache read failed: $_" }
    }

    # Separate devices into cached (coords known), negative-cached (no coords), vs uncached
    $cachedDevices   = @()
    $uncachedDevices = @()
    $skippedNoCoords = 0
    foreach ($device in $devices) {
        $deviceId = if ($device.id) { $device.id } else { $device.deviceId }
        if (-not $deviceId) { continue }
        if ($cache.ContainsKey("$deviceId")) {
            $cachedDevices += $device
        }
        elseif ($noCoordsCache.ContainsKey("$deviceId")) {
            $skippedNoCoords++
        }
        else {
            $uncachedDevices += $device
        }
    }
    Write-Verbose "Cache hit: $($cachedDevices.Count), no-coords: $skippedNoCoords, miss: $($uncachedDevices.Count)"

    # --- Fetch coordinates for uncached devices via runspace pool (PS 5.1 parallel) ---
    $fetchedCoords = @{}  # deviceId -> @{Lat;Lng}

    if ($uncachedDevices.Count -gt 0) {
        $totalUncached = $uncachedDevices.Count
        $poolSize = [Math]::Min(8, $totalUncached)
        $baseUri      = $Config.BaseUri
        $accessToken  = $Config._AccessToken
        $tokenType    = $Config._TokenType
        $useBuiltin   = [bool]$UseBuiltinCoords

        $pool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1, $poolSize)
        $pool.Open()

        $scriptBlock = {
            param($BaseUri, $DeviceId, $TokenType, $AccessToken, $UseBuiltin)
            $headers = @{
                "Content-Type"  = "application/json"
                "Authorization" = "$TokenType $AccessToken"
            }
            $lat = $null; $lng = $null; $apiOk = $true
            try {
                if ($UseBuiltin) {
                    $uri = "$BaseUri/api/v1/devices/$DeviceId/attributes/-?names=Latitude&names=Longitude&limit=10"
                    $result = Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 15 -ErrorAction Stop
                    if ($result.data) {
                        foreach ($attr in $result.data) {
                            if ($attr.name -eq 'Latitude')  { $lat = [double]$attr.value }
                            if ($attr.name -eq 'Longitude') { $lng = [double]$attr.value }
                        }
                    }
                }
                else {
                    $uri = "$BaseUri/api/v1/devices/$DeviceId/attributes/-?names=LatLong&limit=10"
                    $result = Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 15 -ErrorAction Stop
                    if ($result.data) {
                        foreach ($attr in $result.data) {
                            if ($attr.name -eq 'LatLong' -and $attr.value) {
                                $parts = $attr.value -split ','
                                if ($parts.Count -ge 2) {
                                    $lat = [double]$parts[0].Trim()
                                    $lng = [double]$parts[1].Trim()
                                }
                            }
                        }
                    }
                }
            }
            catch { $apiOk = $false }
            return @{ DeviceId = $DeviceId; Lat = $lat; Lng = $lng; ApiOk = $apiOk }
        }

        $jobs = [System.Collections.Generic.List[object]]::new()
        foreach ($device in $uncachedDevices) {
            $deviceId = if ($device.id) { $device.id } else { $device.deviceId }
            $ps = [System.Management.Automation.PowerShell]::Create()
            $ps.RunspacePool = $pool
            [void]$ps.AddScript($scriptBlock)
            [void]$ps.AddArgument($baseUri)
            [void]$ps.AddArgument($deviceId)
            [void]$ps.AddArgument($tokenType)
            [void]$ps.AddArgument($accessToken)
            [void]$ps.AddArgument($useBuiltin)
            $handle = $ps.BeginInvoke()
            $jobs.Add(@{ PS = $ps; Handle = $handle; DeviceId = $deviceId })
        }

        $completed = 0
        $apiFailedIds = @{}
        $completedJobs = [System.Collections.Generic.List[object]]::new()
        foreach ($job in $jobs) {
            $completed++
            $pct = [Math]::Round(($completed / $totalUncached) * 100, 0)
            Write-Progress -Activity "Fetching device locations (uncached)" `
                -Status "$completed of $totalUncached" -PercentComplete $pct
            if (-not $job.Handle.AsyncWaitHandle.WaitOne(20000)) {
                $apiFailedIds["$($job.DeviceId)"] = $true
                try { $job.PS.Stop() } catch { }
                continue
            }
            try {
                $resultCollection = $job.PS.EndInvoke($job.Handle)
                $r = if ($resultCollection -and $resultCollection.Count -gt 0) { $resultCollection[0] } else { $null }
                if ($r -and $null -ne $r.Lat -and $null -ne $r.Lng) {
                    $fetchedCoords["$($r.DeviceId)"] = @{ Lat = $r.Lat; Lng = $r.Lng }
                }
                elseif ($r -and $r.ApiOk) {
                    $noCoordsCache["$($r.DeviceId)"] = $true
                }
                else {
                    $apiFailedIds["$($job.DeviceId)"] = $true
                }
            }
            catch {
                Write-Verbose "Runspace error for device $($job.DeviceId): $_"
                $apiFailedIds["$($job.DeviceId)"] = $true
            }
            $completedJobs.Add($job)
        }
        # Close pool first (kills hung runspaces), then dispose completed jobs
        $pool.Close()
        $pool.Dispose()
        foreach ($j in $completedJobs) { try { $j.PS.Dispose() } catch { } }
        Write-Progress -Activity "Fetching device locations (uncached)" -Completed
        Write-Verbose "Fetched $($fetchedCoords.Count) new coordinates via runspace pool."

        # --- Sequential retry for devices where the runspace API call failed ---
        if ($apiFailedIds.Count -gt 0) {
            Write-Verbose "Retrying $($apiFailedIds.Count) API-failed devices sequentially..."
            foreach ($deviceId in $apiFailedIds.Keys) {
                try {
                    if ($useBuiltin) {
                        $endpoint = "/api/v1/devices/$deviceId/attributes/-?names=Latitude&names=Longitude&limit=10"
                        $attrResult = Invoke-GeoAPI -Config $Config -Endpoint $endpoint
                        $fLat = $null; $fLng = $null
                        if ($attrResult.data) {
                            foreach ($attr in $attrResult.data) {
                                if ($attr.name -eq 'Latitude')  { $fLat = [double]$attr.value }
                                if ($attr.name -eq 'Longitude') { $fLng = [double]$attr.value }
                            }
                        }
                        if ($null -ne $fLat -and $null -ne $fLng) {
                            $fetchedCoords["$deviceId"] = @{ Lat = $fLat; Lng = $fLng }
                        }
                        else {
                            $noCoordsCache["$deviceId"] = $true
                        }
                    }
                    else {
                        $endpoint = "/api/v1/devices/$deviceId/attributes/-?names=LatLong&limit=10"
                        $attrResult = Invoke-GeoAPI -Config $Config -Endpoint $endpoint
                        $foundCoord = $false
                        if ($attrResult.data) {
                            foreach ($attr in $attrResult.data) {
                                if ($attr.name -eq 'LatLong' -and $attr.value) {
                                    $parts = $attr.value -split ','
                                    if ($parts.Count -ge 2) {
                                        $fetchedCoords["$deviceId"] = @{
                                            Lat = [double]$parts[0].Trim()
                                            Lng = [double]$parts[1].Trim()
                                        }
                                        $foundCoord = $true
                                    }
                                }
                            }
                        }
                        if (-not $foundCoord) {
                            $noCoordsCache["$deviceId"] = $true
                        }
                    }
                }
                catch { Write-Verbose "Sequential retry error for device ${deviceId}: $_" }
            }
            Write-Verbose "After sequential retry: $($fetchedCoords.Count) total coordinates found."
        }
    }

    # --- Merge cached + freshly fetched coordinates ---
    $allCoords = @{}
    foreach ($k in $cache.Keys)         { $allCoords[$k] = $cache[$k] }
    foreach ($k in $fetchedCoords.Keys) { $allCoords[$k] = $fetchedCoords[$k] }

    # --- Build result objects (status from overview, coords from cache/fetch) ---
    $geoDevices = @()
    foreach ($device in $devices) {
        $deviceId = if ($device.id) { $device.id } else { $device.deviceId }
        if (-not $deviceId) { continue }
        $coords = $allCoords["$deviceId"]
        if (-not $coords) { continue }

        $bestState  = if ($device.bestState)  { $device.bestState }  else { 'Unknown' }
        $worstState = if ($device.worstState) { $device.worstState } else { 'Unknown' }

        $geoDevices += [PSCustomObject]@{
            Type                  = 'Device'
            Name                  = if ($device.name) { $device.name } elseif ($device.displayName) { $device.displayName } else { "Device $deviceId" }
            DeviceId              = $deviceId
            Latitude              = $coords.Lat
            Longitude             = $coords.Lng
            BestState             = $bestState
            WorstState            = $worstState
            TotalMonitors         = if ($device.totalActiveMonitors) { $device.totalActiveMonitors } else { 0 }
            DownMonitors          = if ($device.totalActiveMonitorsDown) { $device.totalActiveMonitorsDown } else { 0 }
            DownMonitorDetails    = if ($device.downActiveMonitors) { $device.downActiveMonitors } else { @() }
        }
    }

    # --- Update cache on disk ---
    try {
        if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null }
        if ($allCoords.Count -gt 0) {
            $cacheData = $allCoords.Keys | ForEach-Object {
                [PSCustomObject]@{ Id = $_; Lat = $allCoords[$_].Lat; Lng = $allCoords[$_].Lng }
            }
            $cacheData | ConvertTo-Json -Depth 3 | Set-Content -Path $cacheFile -Encoding UTF8 -Force
            Write-Verbose "Saved $($allCoords.Count) coordinates to cache."
        }
        if ($noCoordsCache.Count -gt 0) {
            @($noCoordsCache.Keys) | ConvertTo-Json -Depth 1 | Set-Content -Path $noCoordsFile -Encoding UTF8 -Force
            Write-Verbose "Saved $($noCoordsCache.Count) negative-cached device IDs."
        }
    }
    catch { Write-Verbose "Failed to write location cache: $_" }

    Write-Progress -Activity "Fetching device locations" -Completed
    Write-Verbose "Found $($geoDevices.Count) devices with location data."
    return $geoDevices
}

# --- Get groups with lat/lng in description/note ---
function Get-GeoGroupsWithLocation {
    <#
    .SYNOPSIS
        Retrieves device groups that have geolocation data in their description.
    .DESCRIPTION
        Scans all device groups and parses their description for a "lat,lng" pattern.
        If the description field contains a valid coordinate pair, the group is included.
    .PARAMETER Config
        The configuration hashtable from Connect-GeoWUGServer.
    .OUTPUTS
        Array of PSCustomObject: Name, GroupId, Latitude, Longitude, State
    #>

    param(
        [Parameter(Mandatory)][hashtable]$Config
    )

    $groups = @()
    $pageId = $null
    do {
        $endpoint = "/api/v1/device-groups/-?limit=250&view=detail"
        if ($pageId) { $endpoint += "&pageId=$pageId" }
        $result = Invoke-GeoAPI -Config $Config -Endpoint $endpoint
        if ($result.data.groups) { $groups += $result.data.groups }
        $pageId = $result.paging.nextPageId
    } while ($pageId)

    if ($groups.Count -eq 0) {
        Write-Warning "No device groups found."
        return @()
    }

    Write-Verbose "Found $($groups.Count) groups. Checking descriptions for coordinates..."

    $geoGroups = @()

    foreach ($group in $groups) {
        $groupId = $group.id
        $groupName = $group.name

        # Get group definition to read description
        try {
            $defResult = Invoke-GeoAPI -Config $Config -Endpoint "/api/v1/device-groups/$groupId/definition"
        }
        catch {
            Write-Verbose "Error fetching definition for group $groupId : $($_.Exception.Message)"
            continue
        }

        $description = if ($defResult.data) { $defResult.data.description } else { $null }
        if (-not $description) { continue }

        # Try to parse "lat,lng" from the description
        if ($description -match '^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$') {
            $lat = [double]$Matches[1]
            $lng = [double]$Matches[2]

            # Validate coordinate ranges
            if ($lat -ge -90 -and $lat -le 90 -and $lng -ge -180 -and $lng -le 180) {
                $monitorState = if ($group.monitorState) { $group.monitorState } else { 'Unknown' }
                $geoGroups += [PSCustomObject]@{
                    Type       = 'Group'
                    Name       = $groupName
                    GroupId    = $groupId
                    Latitude   = $lat
                    Longitude  = $lng
                    State      = $monitorState
                }
            }
        }
    }

    Write-Verbose "Found $($geoGroups.Count) groups with location data."
    return $geoGroups
}

# --- Build combined geolocation dataset ---
function Get-GeolocationData {
    <#
    .SYNOPSIS
        Gathers all geolocated devices and groups from WhatsUp Gold.
    .DESCRIPTION
        Combines the output of Get-GeoDevicesWithLocation and Get-GeoGroupsWithLocation
        into a single dataset suitable for map rendering.
    .PARAMETER Config
        The configuration hashtable from Connect-GeoWUGServer.
    .PARAMETER GroupName
        Optional device group filter for devices. Default: all.
    .PARAMETER UseBuiltinCoords
        Use separate Latitude/Longitude attributes for devices.
    .PARAMETER IncludeDevices
        Include devices on the map (default: $true).
    .PARAMETER IncludeGroups
        Include groups on the map (default: $true).
    .OUTPUTS
        Array of PSCustomObject with Type, Name, Latitude, Longitude, and state fields.
    #>

    param(
        [Parameter(Mandatory)][hashtable]$Config,
        [string]$GroupName,
        [switch]$UseBuiltinCoords,
        [bool]$IncludeDevices = $true,
        [bool]$IncludeGroups  = $true
    )

    $allData = @()

    if ($IncludeDevices) {
        $splat = @{ Config = $Config }
        if ($GroupName)       { $splat.GroupName = $GroupName }
        if ($UseBuiltinCoords) { $splat.UseBuiltinCoords = $true }
        $allData += @(Get-GeoDevicesWithLocation @splat)
    }

    if ($IncludeGroups) {
        $allData += @(Get-GeoGroupsWithLocation -Config $Config)
    }

    Write-Verbose "Geolocation dataset: $($allData.Count) total markers."
    return $allData
}

# --- Export Leaflet HTML map ---
function Export-GeolocationMapHtml {
    <#
    .SYNOPSIS
        Generates a self-contained Leaflet HTML map from geolocation data.
    .DESCRIPTION
        Takes geolocation data and a template HTML file and produces a
        standalone HTML page with interactive Leaflet map showing device
        and group markers. The data is embedded as inline JSON so the HTML
        file can be opened directly in a browser with no server required.
    .PARAMETER Data
        Array of geolocation objects from Get-GeolocationData.
    .PARAMETER OutputPath
        Full path for the output HTML file.
    .PARAMETER TemplatePath
        Path to the Geolocation-Map-Template.html file. Defaults to same
        directory as this script.
    .PARAMETER WugBaseUrl
        The base URL of the WhatsUp Gold web console (e.g. https://wug.example.com:443).
        Used to make markers clickable - links to device/group dashboards.
    .PARAMETER DefaultLat
        Default map center latitude (default: 39.8283 - center of US).
    .PARAMETER DefaultLng
        Default map center longitude (default: -98.5795 - center of US).
    .PARAMETER DefaultZoom
        Default map zoom level (default: 5).
    .PARAMETER TileApiKeys
        Optional hashtable of tile provider API keys to embed in the HTML file.
        Supported keys: thunderforest, stadia, maptiler, here, mapbox, jawg, tomtom, openweathermap.
        NOTE: Keys passed here are written in plaintext to the output HTML (opt-in).
        For better security, omit this parameter and enter keys via the map's
        Settings > API Keys panel instead. Keys entered in-browser are stored in
        localStorage (never written to disk in the HTML file).
    .PARAMETER RefreshIntervalSeconds
        If specified, adds a meta refresh tag so the browser reloads the page
        automatically at the given interval. Useful when a scheduled task
        regenerates the HTML file. Default is 0 (disabled).
    .EXAMPLE
        Export-GeolocationMapHtml -Data $geoData -OutputPath "C:\Maps\WUG-Map.html"
    .EXAMPLE
        $keys = @{ thunderforest = 'abc123'; stadia = 'xyz789' }
        Export-GeolocationMapHtml -Data $geoData -OutputPath "C:\Maps\WUG-Map.html" -TileApiKeys $keys
    #>

    param(
        [Parameter(Mandatory)][array]$Data,
        [Parameter(Mandatory)][string]$OutputPath,
        [string]$TemplatePath,
        [string]$WugBaseUrl = '',
        [double]$DefaultLat  = 39.8283,
        [double]$DefaultLng  = -98.5795,
        [int]$DefaultZoom    = 5,
        [hashtable]$TileApiKeys,
        [int]$RefreshIntervalSeconds = 0
    )

    if (-not $TemplatePath) {
        $TemplatePath = Join-Path $PSScriptRoot 'Geolocation-Map-Template.html'
    }
    if (-not (Test-Path $TemplatePath)) {
        throw "Template not found: $TemplatePath"
    }

    $template = [System.IO.File]::ReadAllText($TemplatePath)

    # Build JSON data
    $jsonData = ConvertTo-Json -InputObject @($Data) -Depth 5 -Compress

    # Replace placeholders in template
    $html = $template
    # Use .Replace() (not -replace) to avoid regex interpretation of JSON special chars
    $safeJson = $jsonData -replace '\\', '\\\\' -replace "'", "\\'" 
    $html = $html.Replace('%%MARKER_DATA%%',  $safeJson)
    $html = $html.Replace('%%DEFAULT_LAT%%',  $DefaultLat.ToString([System.Globalization.CultureInfo]::InvariantCulture))
    $html = $html.Replace('%%DEFAULT_LNG%%',  $DefaultLng.ToString([System.Globalization.CultureInfo]::InvariantCulture))
    $html = $html.Replace('%%DEFAULT_ZOOM%%', $DefaultZoom.ToString())
    $html = $html.Replace('%%WUG_BASE_URL%%', $WugBaseUrl)
    $html = $html.Replace('%%GENERATED_AT%%', (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))

    # Auto-refresh meta tag (0 = disabled)
    if ($RefreshIntervalSeconds -gt 0) {
        $html = $html.Replace('%%AUTO_REFRESH%%', "<meta http-equiv=`"refresh`" content=`"$RefreshIntervalSeconds`" />")
    }
    else {
        $html = $html.Replace('%%AUTO_REFRESH%%', '')
    }

    # Inject tile provider API keys
    if ($TileApiKeys -and $TileApiKeys.Count -gt 0) {
        $keysObj = @{}
        foreach ($k in $TileApiKeys.Keys) {
            $keysObj[$k.ToLower()] = $TileApiKeys[$k]
        }
        $keysJson = (ConvertTo-Json -InputObject $keysObj -Compress) -replace "'", "\\'"
        $html = $html.Replace('%%API_KEYS%%', $keysJson)
    }
    else {
        $html = $html.Replace('%%API_KEYS%%', '')
    }

    # Write output
    $outputDir = Split-Path $OutputPath -Parent
    if ($outputDir -and -not (Test-Path $outputDir)) {
        New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
    }
    Set-Content -Path $OutputPath -Value $html -Encoding UTF8

    Write-Verbose "Geolocation map exported to: $OutputPath"
    return $OutputPath
}

# --- Import device locations from CSV and set attributes ---
function Set-GeoDeviceLocations {
    <#
    .SYNOPSIS
        Reads a CSV file and sets geolocation attributes on matching WhatsUp Gold devices.
    .DESCRIPTION
        Imports a CSV with columns DeviceName (or IP), Latitude, Longitude and writes
        those coordinates to each matched device as custom attributes in WhatsUp Gold.
 
        Supports two attribute modes:
        - LatLong (default): writes a single "LatLong" attribute with value "lat,lng"
        - Separate: writes individual "Latitude" and "Longitude" attributes
 
        Device matching is done by searching the WUG device list by name or IP.
        A detailed summary report is returned showing what was set and any misses.
    .PARAMETER Config
        The configuration hashtable from Connect-GeoWUGServer.
    .PARAMETER CsvPath
        Full path to the CSV file. Required columns: DeviceName, Latitude, Longitude.
        Optional column: IP (used as fallback search if DeviceName match fails).
    .PARAMETER UseSeparateAttributes
        If set, writes "Latitude" and "Longitude" as separate attributes instead of
        a single "LatLong" attribute.
    .PARAMETER WhatIf
        Show what would be changed without making any API calls.
    .OUTPUTS
        Array of PSCustomObject summarising each row: DeviceName, DeviceId, Status, Detail.
    .EXAMPLE
        $results = Set-GeoDeviceLocations -Config $geo -CsvPath "C:\Data\device-locations.csv"
    .EXAMPLE
        Set-GeoDeviceLocations -Config $geo -CsvPath ".\locations.csv" -UseSeparateAttributes -WhatIf
    #>

    param(
        [Parameter(Mandatory)][hashtable]$Config,
        [Parameter(Mandatory)][string]$CsvPath,
        [switch]$UseSeparateAttributes,
        [switch]$WhatIf
    )

    if (-not (Test-Path $CsvPath)) {
        throw "CSV file not found: $CsvPath"
    }

    $csv = @(Import-Csv -Path $CsvPath)
    if ($csv.Count -eq 0) {
        Write-Warning "CSV file is empty."
        return @()
    }

    # Validate required columns
    $headers = $csv[0].PSObject.Properties.Name
    $hasName = $headers -contains 'DeviceName'
    $hasIP   = $headers -contains 'IP'
    $hasLat  = $headers -contains 'Latitude'
    $hasLng  = $headers -contains 'Longitude'

    if (-not $hasLat -or -not $hasLng) {
        throw "CSV must have 'Latitude' and 'Longitude' columns."
    }
    if (-not $hasName -and -not $hasIP) {
        throw "CSV must have at least a 'DeviceName' or 'IP' column."
    }

    # Pre-fetch all devices for matching (avoids N search calls)
    Write-Verbose "Fetching full device list for matching..."
    $allDevices = @()
    $pageId = $null
    do {
        $endpoint = "/api/v1/device-groups/0/devices/-?view=overview&limit=250"
        if ($pageId) { $endpoint += "&pageId=$pageId" }
        $result = Invoke-GeoAPI -Config $Config -Endpoint $endpoint
        if ($result.data.devices) { $allDevices += $result.data.devices }
        $pageId = $result.paging.nextPageId
    } while ($pageId)

    Write-Verbose "Loaded $($allDevices.Count) devices for matching."

    # Build lookup tables (name -> device, IP -> device)
    $byName = @{}
    $byIP   = @{}
    foreach ($d in $allDevices) {
        $devId = if ($d.id) { $d.id } else { $d.deviceId }
        $devName = if ($d.name) { $d.name } elseif ($d.displayName) { $d.displayName } else { '' }
        $devIP = if ($d.networkAddress) { $d.networkAddress } elseif ($d.hostName) { $d.hostName } else { '' }
        if ($devName) { $byName[$devName.ToLower()] = @{ Id = $devId; Name = $devName; IP = $devIP } }
        if ($devIP)   { $byIP[$devIP.ToLower()]     = @{ Id = $devId; Name = $devName; IP = $devIP } }
    }

    $results = @()
    $totalRows = $csv.Count
    $currentRow = 0

    foreach ($row in $csv) {
        $currentRow++
        $pct = [Math]::Round(($currentRow / $totalRows) * 100, 0)
        Write-Progress -Activity "Syncing device locations" -Status "$currentRow of $totalRows" -PercentComplete $pct

        $csvName = if ($hasName -and $row.DeviceName) { $row.DeviceName.Trim() } else { '' }
        $csvIP   = if ($hasIP -and $row.IP) { $row.IP.Trim() } else { '' }
        $lat     = $row.Latitude.Trim()
        $lng     = $row.Longitude.Trim()

        # Validate coordinates
        $latNum = 0.0; $lngNum = 0.0
        if (-not [double]::TryParse($lat, [ref]$latNum) -or -not [double]::TryParse($lng, [ref]$lngNum)) {
            $results += [PSCustomObject]@{
                DeviceName = if ($csvName) { $csvName } else { $csvIP }
                DeviceId   = $null
                Status     = 'Skipped'
                Detail     = "Invalid coordinates: $lat, $lng"
            }
            continue
        }
        if ($latNum -lt -90 -or $latNum -gt 90 -or $lngNum -lt -180 -or $lngNum -gt 180) {
            $results += [PSCustomObject]@{
                DeviceName = if ($csvName) { $csvName } else { $csvIP }
                DeviceId   = $null
                Status     = 'Skipped'
                Detail     = "Coordinates out of range: $lat, $lng"
            }
            continue
        }

        # Match device
        $matched = $null
        if ($csvName) { $matched = $byName[$csvName.ToLower()] }
        if (-not $matched -and $csvIP) { $matched = $byIP[$csvIP.ToLower()] }

        if (-not $matched) {
            $results += [PSCustomObject]@{
                DeviceName = if ($csvName) { $csvName } else { $csvIP }
                DeviceId   = $null
                Status     = 'NotFound'
                Detail     = "No matching device in WUG"
            }
            continue
        }

        $deviceId = $matched.Id
        $displayLabel = if ($csvName) { $csvName } else { "$csvIP ($($matched.Name))" }

        if ($WhatIf) {
            $attrDesc = if ($UseSeparateAttributes) { "Latitude=$lat, Longitude=$lng" } else { "LatLong=$lat,$lng" }
            $results += [PSCustomObject]@{
                DeviceName = $displayLabel
                DeviceId   = $deviceId
                Status     = 'WhatIf'
                Detail     = "Would set $attrDesc"
            }
            continue
        }

        # Set attributes via PATCH (upsert)
        try {
            if ($UseSeparateAttributes) {
                $body = ConvertTo-Json -InputObject @{
                    attributesToAdd = @(
                        @{ name = 'Latitude';  value = $lat }
                        @{ name = 'Longitude'; value = $lng }
                    )
                } -Depth 5 -Compress
            }
            else {
                $body = ConvertTo-Json -InputObject @{
                    attributesToAdd = @(
                        @{ name = 'LatLong'; value = "$lat,$lng" }
                    )
                } -Depth 5 -Compress
            }

            $null = Invoke-GeoAPI -Config $Config `
                -Endpoint "/api/v1/devices/$deviceId/attributes/-" `
                -Method 'PATCH' -Body $body

            $results += [PSCustomObject]@{
                DeviceName = $displayLabel
                DeviceId   = $deviceId
                Status     = 'Updated'
                Detail     = if ($UseSeparateAttributes) { "Lat=$lat, Lng=$lng" } else { "LatLong=$lat,$lng" }
            }
        }
        catch {
            $results += [PSCustomObject]@{
                DeviceName = $displayLabel
                DeviceId   = $deviceId
                Status     = 'Error'
                Detail     = $_.Exception.Message
            }
        }
    }

    Write-Progress -Activity "Syncing device locations" -Completed

    # Summary
    $updated  = @($results | Where-Object { $_.Status -eq 'Updated' }).Count
    $notFound = @($results | Where-Object { $_.Status -eq 'NotFound' }).Count
    $skipped  = @($results | Where-Object { $_.Status -eq 'Skipped' }).Count
    $errors   = @($results | Where-Object { $_.Status -eq 'Error' }).Count
    Write-Verbose "Sync complete: $updated updated, $notFound not found, $skipped skipped, $errors errors."

    return $results
}

# --- Read geolocation config from the DPAPI vault ---
function Import-GeolocationConfig {
    <#
    .SYNOPSIS
        Reads the geolocation configuration and refresh token from the DPAPI vault.
    .DESCRIPTION
        Retrieves the Geolocation.Config bundle and Geolocation.RefreshToken
        from the discovery vault, converts string fields to their proper types,
        and returns a typed configuration hashtable.
 
        Requires DiscoveryHelpers.ps1 to be loaded first (for vault functions).
    #>

    [CmdletBinding()]
    param()

    $raw = Get-DiscoveryCredential -Name 'Geolocation.Config'
    if (-not $raw) {
        throw "Geolocation config not found in vault. Run Setup-GeolocationConfig.ps1 first."
    }

    $refreshToken = Get-DiscoveryCredential -Name 'Geolocation.RefreshToken'
    if (-not $refreshToken) {
        throw "Geolocation refresh token not found in vault. Run Setup-GeolocationConfig.ps1 first."
    }

    # Extract tile API keys from TileApiKey.* fields
    $tileApiKeys = @{}
    foreach ($key in @($raw.Keys)) {
        if ($key -like 'TileApiKey.*') {
            $provider = $key.Substring('TileApiKey.'.Length)
            $tileApiKeys[$provider] = $raw[$key]
        }
    }

    @{
        ServerUri        = $raw.ServerUri
        Protocol         = $raw.Protocol
        Port             = [int]$raw.Port
        IgnoreSSL        = [System.Convert]::ToBoolean($raw.IgnoreSSL)
        WugConsoleUrl    = $raw.WugConsoleUrl
        RefreshToken     = $refreshToken
        DefaultLat       = [double]$raw.DefaultLat
        DefaultLng       = [double]$raw.DefaultLng
        DefaultZoom      = [int]$raw.DefaultZoom
        GroupName        = $raw.GroupName
        UseBuiltinCoords = [System.Convert]::ToBoolean($raw.UseBuiltinCoords)
        IncludeDevices   = [System.Convert]::ToBoolean($raw.IncludeDevices)
        IncludeGroups    = [System.Convert]::ToBoolean($raw.IncludeGroups)
        TileApiKeys      = $tileApiKeys
    }
}

# SIG # Begin signature block
# MIIr+wYJKoZIhvcNAQcCoIIr7DCCK+gCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBbM12TTRsogXKS
# UYlPAmQwTY8j8YlxcK2c5qhk/xR3kqCCJQ0wggVvMIIEV6ADAgECAhBI/JO0YFWU
# jTanyYqJ1pQWMA0GCSqGSIb3DQEBDAUAMHsxCzAJBgNVBAYTAkdCMRswGQYDVQQI
# DBJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcMB1NhbGZvcmQxGjAYBgNVBAoM
# EUNvbW9kbyBDQSBMaW1pdGVkMSEwHwYDVQQDDBhBQUEgQ2VydGlmaWNhdGUgU2Vy
# dmljZXMwHhcNMjEwNTI1MDAwMDAwWhcNMjgxMjMxMjM1OTU5WjBWMQswCQYDVQQG
# EwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMS0wKwYDVQQDEyRTZWN0aWdv
# IFB1YmxpYyBDb2RlIFNpZ25pbmcgUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEBAQUA
# A4ICDwAwggIKAoICAQCN55QSIgQkdC7/FiMCkoq2rjaFrEfUI5ErPtx94jGgUW+s
# hJHjUoq14pbe0IdjJImK/+8Skzt9u7aKvb0Ffyeba2XTpQxpsbxJOZrxbW6q5KCD
# J9qaDStQ6Utbs7hkNqR+Sj2pcaths3OzPAsM79szV+W+NDfjlxtd/R8SPYIDdub7
# P2bSlDFp+m2zNKzBenjcklDyZMeqLQSrw2rq4C+np9xu1+j/2iGrQL+57g2extme
# me/G3h+pDHazJyCh1rr9gOcB0u/rgimVcI3/uxXP/tEPNqIuTzKQdEZrRzUTdwUz
# T2MuuC3hv2WnBGsY2HH6zAjybYmZELGt2z4s5KoYsMYHAXVn3m3pY2MeNn9pib6q
# RT5uWl+PoVvLnTCGMOgDs0DGDQ84zWeoU4j6uDBl+m/H5x2xg3RpPqzEaDux5mcz
# mrYI4IAFSEDu9oJkRqj1c7AGlfJsZZ+/VVscnFcax3hGfHCqlBuCF6yH6bbJDoEc
# QNYWFyn8XJwYK+pF9e+91WdPKF4F7pBMeufG9ND8+s0+MkYTIDaKBOq3qgdGnA2T
# OglmmVhcKaO5DKYwODzQRjY1fJy67sPV+Qp2+n4FG0DKkjXp1XrRtX8ArqmQqsV/
# AZwQsRb8zG4Y3G9i/qZQp7h7uJ0VP/4gDHXIIloTlRmQAOka1cKG8eOO7F/05QID
# AQABo4IBEjCCAQ4wHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD
# VR0OBBYEFDLrkpr/NZZILyhAQnAgNpFcF4XmMA4GA1UdDwEB/wQEAwIBhjAPBgNV
# HRMBAf8EBTADAQH/MBMGA1UdJQQMMAoGCCsGAQUFBwMDMBsGA1UdIAQUMBIwBgYE
# VR0gADAIBgZngQwBBAEwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21v
# ZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEE
# KDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZI
# hvcNAQEMBQADggEBABK/oe+LdJqYRLhpRrWrJAoMpIpnuDqBv0WKfVIHqI0fTiGF
# OaNrXi0ghr8QuK55O1PNtPvYRL4G2VxjZ9RAFodEhnIq1jIV9RKDwvnhXRFAZ/ZC
# J3LFI+ICOBpMIOLbAffNRk8monxmwFE2tokCVMf8WPtsAO7+mKYulaEMUykfb9gZ
# pk+e96wJ6l2CxouvgKe9gUhShDHaMuwV5KZMPWw5c9QLhTkg4IUaaOGnSDip0TYl
# d8GNGRbFiExmfS9jzpjoad+sPKhdnckcW67Y8y90z7h+9teDnRGWYpquRRPaf9xH
# +9/DUp/mBlXpnYzyOmJRvOwkDynUWICE5EV7WtgwggWNMIIEdaADAgECAhAOmxiO
# +dAt5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYD
# VQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAi
# BgNVBAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAw
# MDBaFw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdp
# Q2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERp
# Z2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC
# AgoCggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsb
# hA3EMB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iT
# cMKyunWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGb
# NOsFxl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclP
# XuU15zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCr
# VYJBMtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFP
# ObURWBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTv
# kpI6nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWM
# cCxBYKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls
# 5Q5SUUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBR
# a2+xq4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6
# MIIBNjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qY
# rhwPTzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8E
# BAMCAYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5k
# aWdpY2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0
# LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDig
# NoY0aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9v
# dENBLmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCg
# v0NcVec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQT
# SnovLbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh
# 65ZyoUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSw
# uKFWjuyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAO
# QGPFmCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjD
# TZ9ztwGpn1eqXijiuZQwggYaMIIEAqADAgECAhBiHW0MUgGeO5B5FSCJIRwKMA0G
# CSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExp
# bWl0ZWQxLTArBgNVBAMTJFNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBSb290
# IFI0NjAeFw0yMTAzMjIwMDAwMDBaFw0zNjAzMjEyMzU5NTlaMFQxCzAJBgNVBAYT
# AkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNlY3RpZ28g
# UHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwggGiMA0GCSqGSIb3DQEBAQUAA4IB
# jwAwggGKAoIBgQCbK51T+jU/jmAGQ2rAz/V/9shTUxjIztNsfvxYB5UXeWUzCxEe
# AEZGbEN4QMgCsJLZUKhWThj/yPqy0iSZhXkZ6Pg2A2NVDgFigOMYzB2OKhdqfWGV
# oYW3haT29PSTahYkwmMv0b/83nbeECbiMXhSOtbam+/36F09fy1tsB8je/RV0mIk
# 8XL/tfCK6cPuYHE215wzrK0h1SWHTxPbPuYkRdkP05ZwmRmTnAO5/arnY83jeNzh
# P06ShdnRqtZlV59+8yv+KIhE5ILMqgOZYAENHNX9SJDm+qxp4VqpB3MV/h53yl41
# aHU5pledi9lCBbH9JeIkNFICiVHNkRmq4TpxtwfvjsUedyz8rNyfQJy/aOs5b4s+
# ac7IH60B+Ja7TVM+EKv1WuTGwcLmoU3FpOFMbmPj8pz44MPZ1f9+YEQIQty/NQd/
# 2yGgW+ufflcZ/ZE9o1M7a5Jnqf2i2/uMSWymR8r2oQBMdlyh2n5HirY4jKnFH/9g
# Rvd+QOfdRrJZb1sCAwEAAaOCAWQwggFgMB8GA1UdIwQYMBaAFDLrkpr/NZZILyhA
# QnAgNpFcF4XmMB0GA1UdDgQWBBQPKssghyi47G9IritUpimqF6TNDDAOBgNVHQ8B
# Af8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNVHSUEDDAKBggrBgEFBQcD
# AzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQBMEsGA1UdHwREMEIwQKA+oDyG
# Omh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVTaWduaW5n
# Um9vdFI0Ni5jcmwwewYIKwYBBQUHAQEEbzBtMEYGCCsGAQUFBzAChjpodHRwOi8v
# Y3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2RlU2lnbmluZ1Jvb3RSNDYu
# cDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG
# 9w0BAQwFAAOCAgEABv+C4XdjNm57oRUgmxP/BP6YdURhw1aVcdGRP4Wh60BAscjW
# 4HL9hcpkOTz5jUug2oeunbYAowbFC2AKK+cMcXIBD0ZdOaWTsyNyBBsMLHqafvIh
# rCymlaS98+QpoBCyKppP0OcxYEdU0hpsaqBBIZOtBajjcw5+w/KeFvPYfLF/ldYp
# mlG+vd0xqlqd099iChnyIMvY5HexjO2AmtsbpVn0OhNcWbWDRF/3sBp6fWXhz7Dc
# ML4iTAWS+MVXeNLj1lJziVKEoroGs9Mlizg0bUMbOalOhOfCipnx8CaLZeVme5yE
# Lg09Jlo8BMe80jO37PU8ejfkP9/uPak7VLwELKxAMcJszkyeiaerlphwoKx1uHRz
# NyE6bxuSKcutisqmKL5OTunAvtONEoteSiabkPVSZ2z76mKnzAfZxCl/3dq3dUNw
# 4rg3sTCggkHSRqTqlLMS7gjrhTqBmzu1L90Y1KWN/Y5JKdGvspbOrTfOXyXvmPL6
# E52z1NZJ6ctuMFBQZH3pwWvqURR8AgQdULUvrxjUYbHHj95Ejza63zdrEcxWLDX6
# xWls/GDnVNueKjWUH3fTv1Y8Wdho698YADR7TNx8X8z2Bev6SivBBOHY+uqiirZt
# g0y9ShQoPzmCcn63Syatatvx157YK9hlcPmVoa1oDE5/L9Uo2bC5a4CH2RwwggY+
# MIIEpqADAgECAhAHnODk0RR/hc05c892LTfrMA0GCSqGSIb3DQEBDAUAMFQxCzAJ
# BgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNl
# Y3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwHhcNMjYwMjA5MDAwMDAw
# WhcNMjkwNDIxMjM1OTU5WjBVMQswCQYDVQQGEwJVUzEUMBIGA1UECAwLQ29ubmVj
# dGljdXQxFzAVBgNVBAoMDkphc29uIEFsYmVyaW5vMRcwFQYDVQQDDA5KYXNvbiBB
# bGJlcmlubzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPN6aN4B1yYW
# kI5b5TBj3I0VV/peETrHb6EY4BHGxt8Ap+eT+WpEpJyEtRYPxEmNJL3A38Bkg7mw
# zPE3/1NK570ZBCuBjSAn4mSDIgIuXZnvyBO9W1OQs5d67MlJLUAEufl18tOr3ST1
# DeO9gSjQSAE5Nql0QDxPnm93OZBon+Fz3CmE+z3MwAe2h4KdtRAnCqwM+/V7iBdb
# w+JOxolpx+7RVjGyProTENIG3pe/hKvPb501lf8uBAADLdjZr5ip8vIWbf857Yw1
# Bu10nVI7HW3eE8Cl5//d1ribHlzTzQLfttW+k+DaFsKZBBL56l4YAlIVRsrOiE1k
# dHYYx6IGrEA809R7+TZA9DzGqyFiv9qmJAbL4fDwetDeyIq+Oztz1LvEdy8Rcd0J
# BY+J4S0eDEFIA3X0N8VcLeAwabKb9AjulKXwUeqCJLvN79CJ90UTZb2+I+tamj0d
# n+IKMEsJ4v4Ggx72sxFr9+6XziodtTg5Luf2xd6+PhhamOxF2px9LObhBLLEMyRs
# CHZIzVZOFKu9BpHQH7ufGB+Sa80Tli0/6LEyn9+bMYWi2ttn6lLOPThXMiQaooRU
# q6q2u3+F4SaPlxVFLI7OJVMhar6nW6joBvELTJPmANSMjDSRFDfHRCdGbZsL/keE
# LJNy+jZctF6VvxQEjFM8/bazu6qYhrA7AgMBAAGjggGJMIIBhTAfBgNVHSMEGDAW
# gBQPKssghyi47G9IritUpimqF6TNDDAdBgNVHQ4EFgQU6YF0o0D5AVhKHbVocr8G
# aSIBibAwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQCMAAwEwYDVR0lBAwwCgYI
# KwYBBQUHAwMwSgYDVR0gBEMwQTA1BgwrBgEEAbIxAQIBAwIwJTAjBggrBgEFBQcC
# ARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EMAQQBMEkGA1UdHwRCMEAw
# PqA8oDqGOGh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVT
# aWduaW5nQ0FSMzYuY3JsMHkGCCsGAQUFBwEBBG0wazBEBggrBgEFBQcwAoY4aHR0
# cDovL2NydC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGljQ29kZVNpZ25pbmdDQVIz
# Ni5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMA0GCSqG
# SIb3DQEBDAUAA4IBgQAEIsm4xnOd/tZMVrKwi3doAXvCwOA/RYQnFJD7R/bSQRu3
# wXEK4o9SIefye18B/q4fhBkhNAJuEvTQAGfqbbpxow03J5PrDTp1WPCWbXKX8Oz9
# vGWJFyJxRGftkdzZ57JE00synEMS8XCwLO9P32MyR9Z9URrpiLPJ9rQjfHMb1BUd
# vaNayomm7aWLAnD+X7jm6o8sNT5An1cwEAob7obWDM6sX93wphwJNBJAstH9Ozs6
# LwISOX6sKS7CKm9N3Kp8hOUue0ZHAtZdFl6o5u12wy+zzieGEI50fKnN77FfNKFO
# WKlS6OJwlArcbFegB5K89LcE5iNSmaM3VMB2ADV1FEcjGSHw4lTg1Wx+WMAMdl/7
# nbvfFxJ9uu5tNiT54B0s+lZO/HztwXYQUczdsFon3pjsNrsk9ZlalBi5SHkIu+F6
# g7tWiEv3rtVApmJRnLkUr2Xq2a4nbslUCt4jKs5UX4V1nSX8OM++AXoyVGO+iTj7
# z+pl6XE9Gw/Td6WKKKswgga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0G
# CSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
# IFRydXN0ZWQgUm9vdCBHNDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTla
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEy
# NTYgMjAyNSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHT
# CphBcr48RsAcrHXbo0ZodLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPh
# of6pvF4uGjwjqNjfEvUi6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mA
# xAHeHYNnQxqXmRinvuNgxVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBv
# MgEdgkFiDNYiOTx4OtiFcMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps
# 0wjUjsZvkgFkriK9tUKJm/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF
# 83bRVFLeGkuAhHiGPMvSGmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXi
# UOeSLsJygoLPp66bkDX1ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOM
# CZIVNSaz7BX8VtYGqLt9MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydP
# pOjL6s36czwzsucuoKs7Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrU
# G2ZdSoQbU2rMkpLiQ6bGRinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+
# sdFUeEY0qVjPKOWug/G6X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0T
# AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYD
# VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG
# A1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV
# HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU
# cnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1s
# BwEwDQYJKoZIhvcNAQELBQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WI
# GjB/T8ObXAZz8OjuhUxjaaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+
# IQhQE7jU/kXjjytJgnn0hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8M
# yb9rEVKChHyfpzee5kH0F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2
# th9y1IsA0QF8dTXqvcnTmpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjaj
# V/gxdEkMx1NKU4uHQcKfZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2
# Lr3ty9qIijanrUR3anzEwlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFze
# GxcytL5TTLL4ZaoBdqbhOhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG
# 7uEBYTptMSbhdhGQDpOXgpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+N
# Jpud/v4+7RWsWCiKi9EOLLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckT
# etiSuEtQvLsNz3Qbp7wGWqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszW
# kPZPubdcMIIG7TCCBNWgAwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0B
# AQsFADBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/
# BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYg
# U0hBMjU2IDIwMjUgQ0ExMB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVow
# YzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQD
# EzJEaWdpQ2VydCBTSEEyNTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIw
# MjUgMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5g
# VrMrV7pvUf+GcAoB38o3zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN
# +vo8dE2/pPvOx/Vj8TchTySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qo
# me7MrxVyfQO9sMx6ZAWjFDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ/
# /nBZZREr4h/GI6Dxb2UoyrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouT
# MYFOnHoRh6+86Ltc5zjPKHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8
# DD+nigNJFmt6LAHvH3KSuNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnp
# JeItK/DhKbPxTTuGoX7wJNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP5
# 1ho1zBp+xUIZkpSFA8vWdoUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49
# kPmk8iyyizNDIXj//cOgrY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5P
# WPsWeupWs7NpChUk555K096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7Y
# ufAkprxMiXAJQ1XCmnCfgPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAA
# MB0GA1UdDgQWBBTkO/zyMe39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK
# 6eQGfHrK4pBW9i/USezLTjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYI
# KwYBBQUHAwgwgZUGCCsGAQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8v
# b2NzcC5kaWdpY2VydC5jb20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRp
# Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZT
# SEEyNTYyMDI1Q0ExLmNydDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5k
# aWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2
# U0hBMjU2MjAyNUNBMS5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9
# bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP
# 2zWLpQq1b4URGnwWBdEZD9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O
# 6Lgjg8K8elC4+oWCqnU/ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskg
# iC3QYIUP2S3HQvHG1FDu+WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMU
# BaB5bdrPbF6MRYs03h4obEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDF
# kxUGtMTaiLR9wjxUxu2hECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+
# zJNEsuEB7O7/cuvTQasnM9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/l
# wd6UArb+BOVAkg2oOvol/DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxl
# RcGG0LIhp6GvReQGgMgYxQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2
# zRWVFjF7mcr4C34Mj3ocCVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJg
# baP5t2nGj/ULLi49xTcBZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOC
# IUjsarfNZzGCBkQwggZAAgEBMGgwVDELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1Nl
# Y3RpZ28gTGltaXRlZDErMCkGA1UEAxMiU2VjdGlnbyBQdWJsaWMgQ29kZSBTaWdu
# aW5nIENBIFIzNgIQB5zg5NEUf4XNOXPPdi036zANBglghkgBZQMEAgEFAKCBhDAY
# BgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3
# AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEi
# BCAIPkfv8uT8gbl9XUtMQAWKhgvMPSUoKkBuY/Pyu9sABzANBgkqhkiG9w0BAQEF
# AASCAgDW57vQODfgUY69wjnmUiM7r3RdAPRbyaH/sHakuk30YIl1qjmrZIVNNFty
# ObNAImA2j4iVeVVPXJTYVy5qMDx4WU7S7f6EXmZfKll9WBdC7z4QJNXRIUk6pk2D
# z5T8v+Fvnpn991VHCwY84fkOeHtVfsce3eD4gnkDtYrP4syT3QonrB0RWa5i0q3l
# 6zDthIckmw/x5Ylxj4cIgTyU4Ozzt95S1TpL67dl+RHUFsNip8IAzmw2Tyw29PuZ
# idbbQeiu0XABJbnptdg/w+RPxsl1NqDPfeEpnAZXFPxi4PJKBFvuOyKs003EsPyk
# HdZcpcEfFek+PmBkx/aBa13ZJfHkwUF3swpPoGmVpG+UQMVOSnLHCgACfbxErqZc
# CF7JNfO+deKZYMk0XukvgBpwq0zb2iVF8EFhEaqXzUxNiR55u2mIvG5q3OZKxVl9
# aFgZ6dTufb58ZKRPQoUdv7EeQMXo9J5BopUDF51vZO/jpt2UYyyg7xSRHpPV4+XE
# xZPQ4kFeKmRulNRgx/p5WwI1GnlR83HAQ8b7GkcTxutg1g+jN+FIz/SdIYBsgtWJ
# TbKA5PNwV5VaqhnqQ0BBTYypE+6WE2K+twZfOme2mixzkYztdYVIJ4Hrw+5w0rlt
# MOCMmyHjWHQVjgUiFlQeFxIN9OTnKCPjtIsBTnvwwVdw8NprNKGCAyYwggMiBgkq
# hkiG9w0BCQYxggMTMIIDDwIBATB9MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5E
# aWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1l
# U3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTECEAqA7xhLjfEFgtHEdqeV
# dGgwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwG
# CSqGSIb3DQEJBTEPFw0yNjA0MjMwMTExMzdaMC8GCSqGSIb3DQEJBDEiBCDv/QU6
# qpFvP7WBd/N1H6jK+H9LbQbwaj4vKCABsSr8MDANBgkqhkiG9w0BAQEFAASCAgBk
# QXXr/eLokbGlm+tziNk3oaTzsRA3syrPmATSoWu4s7laj6cUNAQG+TmjMgvkX66c
# +ec3WFR91zQYo/gfZalekbXXFLQA+H9YocPXFdyVMUPTjEALCWkdNjjCIe1Yq4nC
# vSBbiqF1Z9Vh13DStgm6U/6S97T7jitSuImjgFpFIcrQ33bAkvjdPDC5rQIGvRuC
# iJyumEz4PIaJ48xDbAupfi2PlB/yjTe5uhm0aE9ox9oQjEKxDqZbwTyolEvtNsfb
# LA2G1SvU8PCJG8MsUMgBaqTQxI9QOHjule9c6Kqt1dzlpGz3MeLeFwBI96HYDY2C
# TsqGIEhq1HD0T5vpZ0B+c75gUbp1ITep8oR4BfrAFgiAk67yi9qSFdk3Rvap0co6
# eBLg4+mx4iB/kwUMLCePgsuUjl7mQJ+9EAwB1nYHxW1nEgNzZbdp5b9ohT8h6xUS
# 9b5/BOc4INsuw+U3Xps9WtFjlJIKC30KhgXm3FnrhKC+m+NQjl7GAXAFNw7nv0aY
# 7e1sHXaWM3VTDN00TQM+BLs1fcGEGh33Avbgap1k3Csh08L4Ggt8VabT3X/9Zmt4
# 2PZLjGKsvy0JLbhBl/vR0QTlbT/MFOUmAW6+mza6s65rf7hlNHzsfaQDM6VsulOP
# xgvi3iWSqZeqyad+ZvRuurEFrvioN4t9OL/q128fdg==
# SIG # End signature block