helpers/lansweeper/LansweeperHelpers.ps1

# ---------------------------------------------------------------------------
# LansweeperHelpers.ps1 -- Lansweeper Data API helper functions for WhatsUpGoldPS
# ---------------------------------------------------------------------------
# Lansweeper Data API (GraphQL) -- https://developer.lansweeper.com/docs/data-api/
# Endpoint: https://api.lansweeper.com/api/v2/graphql
# Auth: Personal Access Token (PAT) or OAuth 2.0
# ---------------------------------------------------------------------------

# ---- Global session state ----
$script:LansweeperSession = @{
    BaseUri      = 'https://api.lansweeper.com/api/v2/graphql'
    TokenUri     = 'https://api.lansweeper.com/api/integrations/oauth/token'
    AuthType     = $null   # 'PAT' or 'OAuth'
    AccessToken  = $null
    RefreshToken = $null
    ClientId     = $null
    ClientSecret = $null
    ExpiresAt    = $null
    Connected    = $false
}

# ========================== Authentication ==========================

function Connect-LansweeperPAT {
    <#
    .SYNOPSIS
        Connects to the Lansweeper Data API using a Personal Access Token.
    .PARAMETER Token
        The Personal Access Token generated in Lansweeper developer tools.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Token
    )
    try {
        $script:LansweeperSession.AuthType     = 'PAT'
        $script:LansweeperSession.AccessToken  = $Token
        $script:LansweeperSession.RefreshToken = $null
        $script:LansweeperSession.ClientId     = $null
        $script:LansweeperSession.ClientSecret = $null
        $script:LansweeperSession.ExpiresAt    = $null

        # Validate connectivity with a simple query
        $result = Invoke-LansweeperGraphQL -Query '{ me { id username email } }'
        if ($result.data.me) {
            $script:LansweeperSession.Connected = $true
            Write-Host "Connected to Lansweeper as $($result.data.me.username) ($($result.data.me.email))" -ForegroundColor Green
            return $result.data.me
        } else {
            throw "Authentication failed -- unable to retrieve user info."
        }
    }
    catch {
        $script:LansweeperSession.Connected = $false
        Write-Error "Failed to connect to Lansweeper with PAT: $_"
    }
}

function Connect-LansweeperOAuth {
    <#
    .SYNOPSIS
        Connects to the Lansweeper Data API using OAuth 2.0 client credentials.
    .PARAMETER ClientId
        OAuth client ID from the downloaded credentials file.
    .PARAMETER ClientSecret
        OAuth client secret from the downloaded credentials file.
    .PARAMETER AuthorizationCode
        The one-time authorization code received via callback URL redirect.
    .PARAMETER RedirectUri
        The allowed callback URL configured for the OAuth client.
    .PARAMETER RefreshToken
        An existing refresh token to skip the authorization code flow.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ClientId,
        [Parameter(Mandatory)][string]$ClientSecret,
        [string]$AuthorizationCode,
        [string]$RedirectUri,
        [string]$RefreshToken
    )
    try {
        $script:LansweeperSession.ClientId     = $ClientId
        $script:LansweeperSession.ClientSecret = $ClientSecret
        $script:LansweeperSession.AuthType     = 'OAuth'

        if ($RefreshToken) {
            $script:LansweeperSession.RefreshToken = $RefreshToken
            Update-LansweeperOAuthToken
        }
        elseif ($AuthorizationCode -and $RedirectUri) {
            $body = @{
                client_id     = $ClientId
                client_secret = $ClientSecret
                grant_type    = 'authorization_code'
                code          = $AuthorizationCode
                redirect_uri  = $RedirectUri
            }
            $response = Invoke-RestMethod -Uri $script:LansweeperSession.TokenUri `
                -Method POST -ContentType 'application/json' `
                -Body ($body | ConvertTo-Json -Compress) -ErrorAction Stop

            $script:LansweeperSession.AccessToken  = $response.access_token
            $script:LansweeperSession.RefreshToken = $response.refresh_token
            $script:LansweeperSession.ExpiresAt    = (Get-Date).AddSeconds($response.expires_in - 60)
        }
        else {
            throw "Provide either -RefreshToken or both -AuthorizationCode and -RedirectUri."
        }

        # Validate connectivity
        $result = Invoke-LansweeperGraphQL -Query '{ me { id username email } }'
        if ($result.data.me) {
            $script:LansweeperSession.Connected = $true
            Write-Host "Connected to Lansweeper (OAuth) as $($result.data.me.username)" -ForegroundColor Green
            return $result.data.me
        } else {
            throw "OAuth authentication succeeded but user info query failed."
        }
    }
    catch {
        $script:LansweeperSession.Connected = $false
        Write-Error "Failed to connect to Lansweeper via OAuth: $_"
    }
}

function Update-LansweeperOAuthToken {
    <#
    .SYNOPSIS
        Refreshes the OAuth access token using the stored refresh token.
    #>

    [CmdletBinding()]
    param()
    if (-not $script:LansweeperSession.RefreshToken) {
        throw "No refresh token available. Re-authenticate with Connect-LansweeperOAuth."
    }
    $body = @{
        client_id     = $script:LansweeperSession.ClientId
        client_secret = $script:LansweeperSession.ClientSecret
        grant_type    = 'refresh_token'
        refresh_token = $script:LansweeperSession.RefreshToken
    }
    $response = Invoke-RestMethod -Uri $script:LansweeperSession.TokenUri `
        -Method POST -ContentType 'application/json' `
        -Body ($body | ConvertTo-Json -Compress) -ErrorAction Stop

    $script:LansweeperSession.AccessToken  = $response.access_token
    $script:LansweeperSession.RefreshToken = $response.refresh_token
    $script:LansweeperSession.ExpiresAt    = (Get-Date).AddSeconds($response.expires_in - 60)
    Write-Verbose "Lansweeper OAuth token refreshed. Expires at $($script:LansweeperSession.ExpiresAt)"
}

function Disconnect-Lansweeper {
    <#
    .SYNOPSIS
        Clears the Lansweeper session state.
    #>

    [CmdletBinding()]
    param()
    $script:LansweeperSession.AuthType     = $null
    $script:LansweeperSession.AccessToken  = $null
    $script:LansweeperSession.RefreshToken = $null
    $script:LansweeperSession.ClientId     = $null
    $script:LansweeperSession.ClientSecret = $null
    $script:LansweeperSession.ExpiresAt    = $null
    $script:LansweeperSession.Connected    = $false
    Write-Host "Disconnected from Lansweeper." -ForegroundColor Yellow
}

# ========================== Core GraphQL Executor ==========================

function Invoke-LansweeperGraphQL {
    <#
    .SYNOPSIS
        Executes a GraphQL query or mutation against the Lansweeper Data API.
    .PARAMETER Query
        The GraphQL query or mutation string.
    .PARAMETER Variables
        Optional hashtable of GraphQL variables.
    .PARAMETER OperationName
        Optional operation name when the query contains multiple operations.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Query,
        [hashtable]$Variables,
        [string]$OperationName
    )

    if (-not $script:LansweeperSession.AccessToken) {
        throw "Not connected to Lansweeper. Call Connect-LansweeperPAT or Connect-LansweeperOAuth first."
    }

    # Auto-refresh OAuth token if near expiry
    if ($script:LansweeperSession.AuthType -eq 'OAuth' -and $script:LansweeperSession.ExpiresAt) {
        if ((Get-Date) -ge $script:LansweeperSession.ExpiresAt) {
            Write-Verbose "Access token expired or near expiry -- refreshing..."
            Update-LansweeperOAuthToken
        }
    }

    # Build authorization header
    $authHeader = if ($script:LansweeperSession.AuthType -eq 'PAT') {
        "Token $($script:LansweeperSession.AccessToken)"
    } else {
        "Bearer $($script:LansweeperSession.AccessToken)"
    }

    $headers = @{
        'Content-Type'  = 'application/json'
        'Authorization' = $authHeader
    }

    $bodyObj = @{ query = $Query }
    if ($Variables)     { $bodyObj.variables     = $Variables }
    if ($OperationName) { $bodyObj.operationName = $OperationName }

    $bodyJson = $bodyObj | ConvertTo-Json -Depth 10 -Compress

    $response = Invoke-RestMethod -Uri $script:LansweeperSession.BaseUri `
        -Method POST -Headers $headers -Body $bodyJson -ErrorAction Stop

    if ($response.errors) {
        $errMsg = ($response.errors | ForEach-Object { $_.message }) -join '; '
        Write-Warning "Lansweeper API returned errors: $errMsg"
    }

    return $response
}

# ========================== Site & Account Queries ==========================

function Get-LansweeperCurrentUser {
    <#
    .SYNOPSIS
        Returns the currently authenticated user's profile info and site list.
    #>

    [CmdletBinding()]
    param()
    $query = @'
{
  me {
    id
    username
    email
    name
    surname
    fullName
    language
    profiles {
      id
      site {
        id
        name
      }
    }
  }
}
'@

    $result = Invoke-LansweeperGraphQL -Query $query
    return $result.data.me
}

function Get-LansweeperSites {
    <#
    .SYNOPSIS
        Returns the sites the API client is authorized to access.
    #>

    [CmdletBinding()]
    param()
    $query = @'
{
  authorizedSites {
    sites {
      id
      name
    }
  }
}
'@

    $result = Invoke-LansweeperGraphQL -Query $query
    return $result.data.authorizedSites.sites
}

function Get-LansweeperSiteInfo {
    <#
    .SYNOPSIS
        Returns basic information about a specific site.
    .PARAMETER SiteId
        The Lansweeper site GUID.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SiteId
    )
    $query = @"
{
  site(id: "$SiteId") {
    id
    name
    companyName
    logoUrl
  }
}
"@

    $result = Invoke-LansweeperGraphQL -Query $query
    return $result.data.site
}

# ========================== Asset Type & Group Queries ==========================

function Get-LansweeperAssetTypes {
    <#
    .SYNOPSIS
        Returns all asset type names for a site.
    .PARAMETER SiteId
        The Lansweeper site GUID.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SiteId
    )
    $query = @"
query getAssetTypes {
  site(id: "$SiteId") {
    id
    assetTypes
  }
}
"@

    $result = Invoke-LansweeperGraphQL -Query $query
    return $result.data.site.assetTypes
}

function Get-LansweeperAssetGroups {
    <#
    .SYNOPSIS
        Returns all asset groups for a site.
    .PARAMETER SiteId
        The Lansweeper site GUID.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SiteId
    )
    $query = @"
query getAssetGroups {
  site(id: "$SiteId") {
    id
    assetGroups {
      name
      assetGroupKey
    }
  }
}
"@

    $result = Invoke-LansweeperGraphQL -Query $query
    return $result.data.site.assetGroups
}

# ========================== Asset Queries ==========================

function Get-LansweeperAssets {
    <#
    .SYNOPSIS
        Queries assets from a Lansweeper site with pagination, field selection, and filtering.
    .PARAMETER SiteId
        The Lansweeper site GUID.
    .PARAMETER Fields
        Array of field paths to request (max 30). Defaults to common asset fields.
    .PARAMETER Limit
        Number of results per page (default 100, max 500).
    .PARAMETER Page
        Pagination page: FIRST, NEXT, PREV, LAST.
    .PARAMETER Cursor
        Pagination cursor from a previous response.
    .PARAMETER Filters
        Hashtable representing filter conditions.
    .PARAMETER All
        When set, auto-paginates to retrieve all assets.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SiteId,
        [string[]]$Fields = @(
            'assetBasicInfo.name',
            'assetBasicInfo.type',
            'assetBasicInfo.ipAddress',
            'assetBasicInfo.mac',
            'assetBasicInfo.domain',
            'assetBasicInfo.description',
            'assetBasicInfo.firstSeen',
            'assetBasicInfo.lastSeen',
            'assetCustom.manufacturer',
            'assetCustom.model',
            'assetCustom.serialNumber',
            'assetCustom.dnsName',
            'networks.ipAddressV4',
            'url'
        ),
        [ValidateRange(1, 500)][int]$Limit = 100,
        [ValidateSet('FIRST','NEXT','PREV','LAST')][string]$Page = 'FIRST',
        [string]$Cursor,
        [hashtable]$Filters,
        [switch]$All
    )

    $fieldsJson = ($Fields | ForEach-Object { "`"$_`"" }) -join ', '

    # Build pagination block
    $paginationParts = @("limit: $Limit", "page: $Page")
    if ($Cursor) { $paginationParts += "cursor: `"$Cursor`"" }
    $paginationBlock = $paginationParts -join ', '

    # Build filter block
    $filterBlock = ''
    if ($Filters) {
        $filterBlock = New-LansweeperFilterBlock -Filters $Filters
    }

    $query = @"
query getAssetResources {
  site(id: "$SiteId") {
    assetResources(
      assetPagination: { $paginationBlock }
      fields: [$fieldsJson]
      $filterBlock
    ) {
      total
      pagination {
        limit
        current
        next
        page
      }
      items
    }
  }
}
"@


    if ($All) {
        $allItems = @()
        $currentPage = 'FIRST'
        $currentCursor = $null
        $totalReported = $null

        do {
            $pagParts = @("limit: $Limit", "page: $currentPage")
            if ($currentCursor) { $pagParts += "cursor: `"$currentCursor`"" }
            $pagBlock = $pagParts -join ', '

            $iterQuery = @"
query getAssetResources {
  site(id: "$SiteId") {
    assetResources(
      assetPagination: { $pagBlock }
      fields: [$fieldsJson]
      $filterBlock
    ) {
      total
      pagination {
        limit
        current
        next
        page
      }
      items
    }
  }
}
"@

            $result = Invoke-LansweeperGraphQL -Query $iterQuery
            $assetData = $result.data.site.assetResources

            if ($null -eq $totalReported -and $null -ne $assetData.total) {
                $totalReported = $assetData.total
                Write-Verbose "Total assets reported: $totalReported"
            }

            if ($assetData.items) {
                $allItems += $assetData.items
                Write-Verbose "Retrieved $($allItems.Count) of $totalReported assets..."
            }

            $currentCursor = $assetData.pagination.next
            $currentPage = 'NEXT'

        } while ($assetData.items -and $assetData.items.Count -eq $Limit -and $currentCursor)

        return $allItems
    }
    else {
        $result = Invoke-LansweeperGraphQL -Query $query
        return $result.data.site.assetResources
    }
}

function Get-LansweeperAssetDetails {
    <#
    .SYNOPSIS
        Retrieves detailed information for a single asset by key.
    .PARAMETER SiteId
        The Lansweeper site GUID.
    .PARAMETER AssetKey
        The unique asset key.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SiteId,
        [Parameter(Mandatory)][string]$AssetKey
    )
    $query = @"
{
  site(id: "$SiteId") {
    assetDetails(key: "$AssetKey") {
      key
      url
      siteId
      assetBasicInfo {
        name
        type
        subType
        typeGroup
        domain
        description
        ipAddress
        mac
        firstSeen
        lastSeen
        lastTried
        lastUpdated
        upTime
        userName
        userDomain
      }
      assetCustom {
        manufacturer
        model
        serialNumber
        dnsName
        stateName
        purchaseDate
        warrantyDate
      }
      operatingSystem {
        caption
        version
        buildNumber
        serialNumber
      }
      processors {
        name
        family
        manufacturer
        numberOfCores
        numberOfLogicalProcessors
        maxClockSpeed
      }
      memoryModules {
        caption
        size
      }
      logicalDisks {
        name
        size
        freeSpace
        fileSystem
      }
      networkAdapters {
        name
        macAddress
        speed
      }
      networks {
        ipAddressV4
        ipAddressV6
        macAddress
        subnetMask
        defaultGateway
        configuration {
          name
          state
        }
      }
      softwares {
        name
        version
        publisher
        type
      }
      warranties {
        purchaseCountry
        details {
          startDate
          endDate
        }
      }
    }
  }
}
"@

    $result = Invoke-LansweeperGraphQL -Query $query
    return $result.data.site.assetDetails
}

# ========================== Sources & Accounts ==========================

function Get-LansweeperSources {
    <#
    .SYNOPSIS
        Lists discovery sources (installations) for a site.
    .PARAMETER SiteId
        The Lansweeper site GUID.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SiteId
    )
    $query = @"
{
  site(id: "$SiteId") {
    sources(pagination: { limit: 500, page: FIRST }) {
      total
      pagination { limit current next page }
      items {
        id
        type
        state { value unlinkedOnDate deletedOnDate firstSyncCompletedOn }
        createdAt
      }
    }
  }
}
"@

    $result = Invoke-LansweeperGraphQL -Query $query
    return $result.data.site.sources
}

function Get-LansweeperAccounts {
    <#
    .SYNOPSIS
        Lists authorized accounts for a site.
    .PARAMETER SiteId
        The Lansweeper site GUID.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SiteId
    )
    $query = @"
{
  site(id: "$SiteId") {
    accounts {
      username
      email
      status
      createdAt
      joinedAt
      lastTimeAccess
    }
  }
}
"@

    $result = Invoke-LansweeperGraphQL -Query $query
    return $result.data.site.accounts
}

# ========================== Vulnerabilities ==========================

function Get-LansweeperVulnerabilities {
    <#
    .SYNOPSIS
        Queries vulnerability data for a Lansweeper site. Requires Pro or Enterprise plan.
    .PARAMETER SiteId
        The Lansweeper site GUID.
    .PARAMETER Limit
        Results per page (default 100).
    .PARAMETER Severity
        Optional filter by severity (Critical, High, Medium, Low).
    .PARAMETER All
        Auto-paginate to retrieve all vulnerabilities.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SiteId,
        [ValidateRange(1, 500)][int]$Limit = 100,
        [ValidateSet('Critical','High','Medium','Low')][string]$Severity,
        [switch]$All
    )

    $filterBlock = ''
    if ($Severity) {
        $filterBlock = @"
      filters: {
        conjunction: AND
        conditions: [
          { operator: EQUAL, path: "severity", value: "$Severity" }
        ]
      }
"@

    }

    if ($All) {
        $allItems = @()
        $currentPage = 'FIRST'
        $currentCursor = ''

        do {
            $query = @"
{
  site(id: "$SiteId") {
    vulnerabilities(
      pagination: { limit: $Limit, cursor: "$currentCursor", page: $currentPage }
      $filterBlock
    ) {
      total
      pagination { limit current next page }
      items {
        cve
        riskScore
        severity
        assetKeys
        attackVector
        attackComplexity
        source
        updatedOn
        publishedOn
        baseScore
        isActive
        cause {
          category
          affectedProduct
          vendor
        }
      }
    }
  }
}
"@

            $result = Invoke-LansweeperGraphQL -Query $query
            $vulnData = $result.data.site.vulnerabilities

            if ($vulnData.items) {
                $allItems += $vulnData.items
                Write-Verbose "Retrieved $($allItems.Count) vulnerabilities..."
            }

            $currentCursor = $vulnData.pagination.next
            $currentPage = 'NEXT'

        } while ($vulnData.items -and $vulnData.items.Count -eq $Limit -and $currentCursor)

        return $allItems
    }
    else {
        $query = @"
{
  site(id: "$SiteId") {
    vulnerabilities(
      pagination: { limit: $Limit, cursor: "", page: FIRST }
      $filterBlock
    ) {
      total
      pagination { limit current next page }
      items {
        cve
        riskScore
        severity
        assetKeys
        attackVector
        attackComplexity
        source
        updatedOn
        publishedOn
        baseScore
        isActive
        cause {
          category
          affectedProduct
          vendor
        }
      }
    }
  }
}
"@

        $result = Invoke-LansweeperGraphQL -Query $query
        return $result.data.site.vulnerabilities
    }
}

function Get-LansweeperPatches {
    <#
    .SYNOPSIS
        Returns Microsoft KB patch articles for a specific CVE.
    .PARAMETER SiteId
        The Lansweeper site GUID.
    .PARAMETER CVE
        The CVE identifier (e.g., CVE-2023-36397).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SiteId,
        [Parameter(Mandatory)][string]$CVE
    )
    $query = @"
{
  site(id: "$SiteId") {
    kbPatches(cve: "$CVE") {
      kb
    }
  }
}
"@

    $result = Invoke-LansweeperGraphQL -Query $query
    return $result.data.site.kbPatches
}

# ========================== Bulk Export ==========================

function Start-LansweeperExport {
    <#
    .SYNOPSIS
        Initiates a bulk asset export (mutation) and returns the exportId.
    .PARAMETER SiteId
        The Lansweeper site GUID.
    .PARAMETER FilterName
        Optional filter by asset name (LIKE match).
    .PARAMETER FilterType
        Optional exact match filter by asset type.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SiteId,
        [string]$FilterName,
        [string]$FilterType
    )

    $filterBlock = ''
    $conditions = @()
    if ($FilterName) {
        $conditions += "{ operator: LIKE, path: `"assetBasicInfo.name`", value: `"$FilterName`" }"
    }
    if ($FilterType) {
        $conditions += "{ operator: EQUAL, path: `"assetBasicInfo.type`", value: `"$FilterType`" }"
    }
    if ($conditions.Count -gt 0) {
        $condBlock = $conditions -join "`n "
        $filterBlock = @"
      filters: {
        conjunction: AND
        conditions: [
          $condBlock
        ]
      }
"@

    }

    $mutation = @"
mutation export {
  site(id: "$SiteId") {
    exportFilteredAssets$(if ($filterBlock) { "(`n$filterBlock`n )" } else { '' }) {
      assetBasicInfo {
        name
        type
        subType
        typeGroup
        domain
        description
        ipAddress
        mac
        firstSeen
        lastSeen
      }
      assetCustom {
        manufacturer
        model
        serialNumber
        dnsName
        stateName
      }
      operatingSystem {
        caption
        version
      }
      networks {
        ipAddressV4
        macAddress
        configuration { name state }
      }
      processors {
        name
        numberOfCores
      }
      memoryModules {
        size
      }
      exportId
    }
  }
}
"@

    $result = Invoke-LansweeperGraphQL -Query $mutation
    $exportId = $result.data.site.exportFilteredAssets.exportId
    Write-Verbose "Bulk export started with exportId: $exportId"
    return $exportId
}

function Get-LansweeperExportStatus {
    <#
    .SYNOPSIS
        Checks the status of a bulk export and returns the download URL when ready.
    .PARAMETER SiteId
        The Lansweeper site GUID.
    .PARAMETER ExportId
        The export ID returned by Start-LansweeperExport.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SiteId,
        [Parameter(Mandatory)][string]$ExportId
    )
    $query = @"
{
  site(id: "$SiteId") {
    exportStatus(exportId: "$ExportId") {
      exportId
      progress
      url
    }
  }
}
"@

    $result = Invoke-LansweeperGraphQL -Query $query
    return $result.data.site.exportStatus
}

function Wait-LansweeperExport {
    <#
    .SYNOPSIS
        Polls export status until complete, then returns the download URL.
    .PARAMETER SiteId
        The Lansweeper site GUID.
    .PARAMETER ExportId
        The export ID returned by Start-LansweeperExport.
    .PARAMETER PollIntervalSeconds
        Seconds between status checks (default 10).
    .PARAMETER TimeoutMinutes
        Maximum minutes to wait (default 30).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$SiteId,
        [Parameter(Mandatory)][string]$ExportId,
        [int]$PollIntervalSeconds = 10,
        [int]$TimeoutMinutes = 30
    )
    $deadline = (Get-Date).AddMinutes($TimeoutMinutes)
    do {
        $status = Get-LansweeperExportStatus -SiteId $SiteId -ExportId $ExportId
        if ($status.url) {
            Write-Host "Export complete. Download URL available." -ForegroundColor Green
            return $status
        }
        Write-Verbose "Export progress: $($status.progress) -- polling again in ${PollIntervalSeconds}s..."
        Start-Sleep -Seconds $PollIntervalSeconds
    } while ((Get-Date) -lt $deadline)

    Write-Warning "Export did not complete within $TimeoutMinutes minutes."
    return $status
}

# ========================== IP Resolution ==========================

function Resolve-LansweeperAssetIP {
    <#
    .SYNOPSIS
        Extracts the best available IP address from a Lansweeper asset object.
    .PARAMETER Asset
        A Lansweeper asset item (from Get-LansweeperAssets or Get-LansweeperAssetDetails).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Asset
    )
    try {
        # Priority 1: assetBasicInfo.ipAddress
        if ($Asset.assetBasicInfo -and $Asset.assetBasicInfo.ipAddress) {
            $ip = "$($Asset.assetBasicInfo.ipAddress)"
            if ($ip -and $ip -ne '' -and $ip -ne 'N/A') { return $ip }
        }

        # Priority 2: networks[].ipAddressV4 -- first non-link-local
        if ($Asset.networks) {
            foreach ($net in $Asset.networks) {
                $ipv4 = "$($net.ipAddressV4)"
                if ($ipv4 -and $ipv4 -ne '' -and -not $ipv4.StartsWith('169.254.') -and -not $ipv4.StartsWith('127.')) {
                    return $ipv4
                }
            }
        }

        # Priority 3: DNS resolution from dnsName or name
        $dnsName = $null
        if ($Asset.assetCustom -and $Asset.assetCustom.dnsName) {
            $dnsName = "$($Asset.assetCustom.dnsName)"
        }
        elseif ($Asset.assetBasicInfo -and $Asset.assetBasicInfo.name) {
            $dnsName = "$($Asset.assetBasicInfo.name)"
        }

        if ($dnsName) {
            $addresses = [System.Net.Dns]::GetHostAddresses($dnsName)
            $v4 = $addresses | Where-Object { $_.AddressFamily -eq 'InterNetwork' } | Select-Object -First 1
            if ($v4) { return "$($v4.IPAddressToString)" }
        }
    }
    catch {
        Write-Verbose "Could not resolve IP for asset: $_"
    }

    return $null
}

# ========================== Filter Builder ==========================

function New-LansweeperFilterBlock {
    <#
    .SYNOPSIS
        Builds a GraphQL filter block from a hashtable.
    .PARAMETER Filters
        Hashtable with keys: conjunction (AND/OR), conditions (array of hashtables with operator, path, value).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$Filters
    )
    $conjunction = if ($Filters.conjunction) { $Filters.conjunction } else { 'AND' }
    $condLines = @()
    foreach ($cond in $Filters.conditions) {
        $condLines += "{ operator: $($cond.operator), path: `"$($cond.path)`", value: `"$($cond.value)`" }"
    }
    $condBlock = $condLines -join "`n "
    return @"
      filters: {
        conjunction: $conjunction
        conditions: [
          $condBlock
        ]
      }
"@

}

# ========================== Dashboard Aggregation ==========================

function Get-LansweeperDashboardData {
    <#
    .SYNOPSIS
        Aggregates Lansweeper asset data into a flat array for dashboard display.
    .PARAMETER SiteId
        The Lansweeper site GUID. If omitted, queries all authorized sites.
    .PARAMETER AssetTypeFilter
        Optional array of asset type names to include (e.g., 'Windows', 'Server', 'Linux').
    .PARAMETER IncludeVulnerabilities
        Include vulnerability summary counts per asset.
    #>

    [CmdletBinding()]
    param(
        [string]$SiteId,
        [string[]]$AssetTypeFilter,
        [switch]$IncludeVulnerabilities
    )

    # Determine sites to query
    $sites = @()
    if ($SiteId) {
        $siteInfo = Get-LansweeperSiteInfo -SiteId $SiteId
        $sites += [PSCustomObject]@{ id = $SiteId; name = $siteInfo.name }
    } else {
        $sites = Get-LansweeperSites
    }

    if (-not $sites -or $sites.Count -eq 0) {
        Write-Warning "No authorized Lansweeper sites found."
        return @()
    }

    $dashboardData = @()

    foreach ($site in $sites) {
        Write-Host "Querying site: $($site.name) ($($site.id))..." -ForegroundColor Cyan

        # Build filter for asset types if specified
        $filters = $null
        if ($AssetTypeFilter -and $AssetTypeFilter.Count -gt 0) {
            # Use OR conjunction for multiple type filters
            $conditions = @()
            foreach ($typeName in $AssetTypeFilter) {
                $conditions += @{ operator = 'EQUAL'; path = 'assetBasicInfo.type'; value = $typeName }
            }
            $filters = @{ conjunction = 'OR'; conditions = $conditions }
        }

        try {
            $assets = Get-LansweeperAssets -SiteId $site.id -All -Filters $filters

            # Optionally get vulnerability data
            $vulnMap = @{}
            if ($IncludeVulnerabilities) {
                try {
                    $vulns = Get-LansweeperVulnerabilities -SiteId $site.id -All
                    foreach ($v in $vulns) {
                        foreach ($ak in $v.assetKeys) {
                            if (-not $vulnMap.ContainsKey($ak)) { $vulnMap[$ak] = @() }
                            $vulnMap[$ak] += $v
                        }
                    }
                }
                catch {
                    Write-Warning "Could not retrieve vulnerabilities for site $($site.name): $_"
                }
            }

            foreach ($asset in $assets) {
                $ip = Resolve-LansweeperAssetIP -Asset $asset

                $assetName = 'N/A'
                $assetType = 'N/A'
                $assetDomain = 'N/A'
                $assetDesc = 'N/A'
                $assetMac = 'N/A'
                $assetLastSeen = 'N/A'
                $assetManufacturer = 'N/A'
                $assetModel = 'N/A'
                $assetSerial = 'N/A'
                $assetDnsName = 'N/A'

                if ($asset.assetBasicInfo) {
                    $bi = $asset.assetBasicInfo
                    if ($bi.name)        { $assetName     = "$($bi.name)" }
                    if ($bi.type)        { $assetType     = "$($bi.type)" }
                    if ($bi.domain)      { $assetDomain   = "$($bi.domain)" }
                    if ($bi.description) { $assetDesc     = "$($bi.description)" }
                    if ($bi.mac)         { $assetMac      = "$($bi.mac)" }
                    if ($bi.lastSeen)    { $assetLastSeen = "$($bi.lastSeen)" }
                }
                if ($asset.assetCustom) {
                    $ac = $asset.assetCustom
                    if ($ac.manufacturer)  { $assetManufacturer = "$($ac.manufacturer)" }
                    if ($ac.model)         { $assetModel        = "$($ac.model)" }
                    if ($ac.serialNumber)  { $assetSerial       = "$($ac.serialNumber)" }
                    if ($ac.dnsName)       { $assetDnsName      = "$($ac.dnsName)" }
                }

                $assetKey = if ($asset.key) { "$($asset.key)" } else { 'N/A' }

                $vulnCount = 0
                $criticalCount = 0
                if ($IncludeVulnerabilities -and $vulnMap.ContainsKey($assetKey)) {
                    $assetVulns = $vulnMap[$assetKey]
                    $vulnCount = $assetVulns.Count
                    $criticalCount = ($assetVulns | Where-Object { $_.severity -eq 'Critical' }).Count
                }

                $obj = [PSCustomObject]@{
                    AssetName     = $assetName
                    AssetType     = $assetType
                    IPAddress     = if ($ip) { $ip } else { 'N/A' }
                    MACAddress    = $assetMac
                    Domain        = $assetDomain
                    Description   = $assetDesc
                    LastSeen      = $assetLastSeen
                    Manufacturer  = $assetManufacturer
                    Model         = $assetModel
                    SerialNumber  = $assetSerial
                    DnsName       = $assetDnsName
                    Site          = "$($site.name)"
                    AssetKey      = $assetKey
                    LansweeperUrl = if ($asset.url) { "$($asset.url)" } else { 'N/A' }
                }

                if ($IncludeVulnerabilities) {
                    $obj | Add-Member -NotePropertyName 'Vulnerabilities' -NotePropertyValue $vulnCount
                    $obj | Add-Member -NotePropertyName 'CriticalVulns' -NotePropertyValue $criticalCount
                }

                $dashboardData += $obj
            }

            Write-Host " Retrieved $($assets.Count) assets from $($site.name)." -ForegroundColor Green
        }
        catch {
            Write-Warning "Error querying site $($site.name): $_"
        }
    }

    return $dashboardData
}

# ========================== HTML Dashboard Export ==========================

function Export-LansweeperDashboardHtml {
    <#
    .SYNOPSIS
        Exports Lansweeper dashboard data to an interactive HTML report.
    .PARAMETER DashboardData
        Array of dashboard PSCustomObjects from Get-LansweeperDashboardData.
    .PARAMETER OutputPath
        Path for the output HTML file.
    .PARAMETER ReportTitle
        Title displayed in the report header.
    .PARAMETER TemplatePath
        Optional path to a custom HTML template. Defaults to Lansweeper-Dashboard-Template.html.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$DashboardData,
        [Parameter(Mandatory)][string]$OutputPath,
        [string]$ReportTitle = 'Lansweeper Asset Dashboard',
        [string]$TemplatePath
    )

    if (-not $TemplatePath) {
        $TemplatePath = Join-Path $PSScriptRoot 'Lansweeper-Dashboard-Template.html'
    }

    if (-not (Test-Path $TemplatePath)) {
        throw "Dashboard template not found at: $TemplatePath"
    }

    # Build column definitions from first object
    $columns = @()
    $firstObj = $DashboardData | Select-Object -First 1
    foreach ($prop in $firstObj.PSObject.Properties) {
        $col = [ordered]@{
            field      = $prop.Name
            title      = ($prop.Name -creplace '([A-Z])', ' $1').Trim()
            sortable   = $true
            searchable = $true
        }
        $columns += $col
    }

    $columnsJson = $columns | ConvertTo-Json -Depth 5 -Compress
    $dataJson    = $DashboardData | ConvertTo-Json -Depth 5 -Compress
    $updateTime  = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')

    $template = Get-Content -Path $TemplatePath -Raw
    $html = $template `
        -replace 'replaceColumnsHere', $columnsJson `
        -replace 'replaceDataHere', $dataJson `
        -replace 'ReplaceYourReportNameHere', $ReportTitle `
        -replace 'ReplaceUpdateTimeHere', $updateTime

    $utf8Bom = [System.Text.UTF8Encoding]::new($true)
    [System.IO.File]::WriteAllText($OutputPath, $html, $utf8Bom)
    Write-Host "Dashboard exported to: $OutputPath" -ForegroundColor Green
}

# SIG # Begin signature block
# MIIr+wYJKoZIhvcNAQcCoIIr7DCCK+gCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDN1n8x8qTcFolC
# xyC4DqP8NiS6hLUTteKwSbNV9bjl16CCJQ0wggVvMIIEV6ADAgECAhBI/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
# BCCg6KEtFiuF+ViTVjCEgwsuW7LjuGDFMvRBANFU2p8OljANBgkqhkiG9w0BAQEF
# AASCAgDs05KXRj6R3zte9iWq4vQ5ft+iXqbenbvUOe+dAYO/VzipKK26R5Esig0F
# mRCIXVWwpW1eT58M2b52Mk9U3rZO5lnK7SORs8gKsyPN8aJqoynCI/GAOp5eA9x+
# 5rj4sXRy6IL8FsqOxjvJ+GawnD7yd0yjqEILUoPXqik//GynzJuXqBKqTHeDweyq
# NNsHSrKBioRlIDu/EaJI9YhmKX/ZK+D/cy7zDnUXWL919zvsJXUN/nNF9+0Mqxhk
# 223/Oalxn9BkCRdMJrl1IOj+OqSQPQg1G4XNf2RadqmR/VEjVvcTOeMMMP/ExRiy
# uyYoQ2Xk8mqxgMShKQ5kuLfDirTTktNVGAKn/59sp3cEoc3+TZT9zvHb4jGP1Ogb
# oVXQByph3inETINsx8WmTaa+h9sjoQeIZc1GX8XR0yVo9S1MePWvzwtQO2HR08Ay
# Q6hJ+KP1eika87MWlWUVxCFQrob/333b8PD5qcYS/D3eek9rzC8IpbAsDIVzCY3u
# vkvslO+62PiO3J3jhaXuabuDB+4b/aUNQz/iPtv4zVrNe5/MLbUP3QKoY9CEk9cm
# Z0h6WCOXfgf3PV7GJQmoannkgJdxsQSCW2pRhiT3o30xNw3xL0eeOISglPtGiDHC
# dm+QJb0ypC2tHZfY1Tm/Xin/DRoVgjwsbwNgBLuljSJLRzj9mqGCAyYwggMiBgkq
# hkiG9w0BCQYxggMTMIIDDwIBATB9MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5E
# aWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1l
# U3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTECEAqA7xhLjfEFgtHEdqeV
# dGgwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwG
# CSqGSIb3DQEJBTEPFw0yNjA1MjQxNzQxMTBaMC8GCSqGSIb3DQEJBDEiBCCdf1+3
# rDqLlm1wiqQpsTnRe7i9vg2jHAHSDWjKkVqBizANBgkqhkiG9w0BAQEFAASCAgBD
# jajgVfJb1PAEg3zbimLup2BoS/jYKLeudUIIFpMknxokfeFZO8fnBa8c4IxY3tZf
# ZSTmgAUKQvd/WGpdRGdrMQ75JUBW0SCO5qU7LBKYsd5gTtSEVOrgOOKhcUn58zz4
# gaCQDFr4hoY2h/HnLD26P13OGbeFbZ5fbg9UMKHhpB7XcQlCYpJNoezsMAw4dgJr
# etop0O+XA2P8u+ITXHZuzHii1DewszKyExZcnkVewPBz5KzrKYHBR/0g7pgC1LYW
# ttjd5yvTXWTSXiwv9vITeJOo2nNoA/VMVT+kyGfD1q0+kUWfE1NfEopQMuvuoKfj
# lsXw5WDyZBfok7trcKzgDsBuQKuS64iHm9xa78RdQGhmkPaDmEaZ0SsuFKEOVrLH
# cfHsNRrJhYJKnGueWdH/kv0bcssm7c5t6pCjq3QfVEdUa9Hb1qdIaQXy//+LXb0f
# oFv7cH7zEBzEqMvZAXqDRffjy7vMd4YExQld4eis1QEaPSFszYxS2hiBa/ZWthKO
# wzTCBF5zjS5mM0WVdZB/YZPQSfikq3AaonW4JsxMqzmH/6T01gdVbiY/orwJqeq3
# 5Rk9JmwOshLfrSl996h4cUGGNIjKZsjZCzgT53w5o4rJwC80v0VZrtvr0Qreq7HG
# +13uIs0/h7bUrAXGB+Zh4nW3+LO7GyArfn034ofqDQ==
# SIG # End signature block