helpers/vmware/VMwareHelpers.ps1

# =============================================================================
# VMware vSphere Helpers for WhatsUpGoldPS
#
# Two collection methods supported:
# [1] VMware.PowerCLI module (Connect-VIServer, Get-VM, etc.)
# [2] vCenter REST API direct (zero external dependencies -- uses Invoke-RestMethod)
#
# Functions suffixed with "REST" use the vCenter REST API directly.
# =============================================================================

function Connect-VMware {
    <#
    .SYNOPSIS
        Connects to a VMware vCenter Server or ESXi host.
    .DESCRIPTION
        Wraps Connect-VIServer to establish a session with a vCenter Server
        or standalone ESXi host. The session is stored in PowerCLI's default
        connection state and used by all subsequent helper functions.
    .PARAMETER Server
        The hostname or IP address of the vCenter Server or ESXi host.
    .PARAMETER Credential
        PSCredential for authentication.
    .PARAMETER Port
        The port number. Defaults to 443.
    .PARAMETER IgnoreSSLErrors
        Skip SSL certificate validation.
    .EXAMPLE
        $cred = Get-Credential -UserName "administrator@vsphere.local"
        Connect-VMware -Server "vcenter01.lab.local" -Credential $cred
        Connects to vCenter Server using the specified credentials.
    .EXAMPLE
        Connect-VMware -Server "192.168.1.100" -Credential $cred -IgnoreSSLErrors
        Connects to a vCenter Server with self-signed certificates.
    #>

    param(
        [Parameter(Mandatory)][string]$Server,
        [Parameter(Mandatory)][PSCredential]$Credential,
        [int]$Port = 443,
        [switch]$IgnoreSSLErrors
    )

    if ($IgnoreSSLErrors) {
        Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Confirm:$false -Scope Session -ErrorAction SilentlyContinue | Out-Null
    }

    try {
        $connection = Connect-VIServer -Server $Server -Credential $Credential -Port $Port -ErrorAction Stop
        Write-Verbose "Connected to VMware: $Server ($($connection.ProductLine) $($connection.Version))"
        return $connection
    }
    catch {
        throw "Failed to connect to VMware server $Server : $($_.Exception.Message)"
    }
}

function Get-VMwareClusters {
    <#
    .SYNOPSIS
        Returns all vSphere clusters.
    .DESCRIPTION
        Wraps Get-Cluster and returns a collection of cluster objects with
        HA, DRS, and EVC configuration details.
    .EXAMPLE
        Get-VMwareClusters
        Returns all clusters in the connected vCenter.
    .EXAMPLE
        Get-VMwareClusters | Where-Object { $_.HAEnabled -eq $true }
        Returns only HA-enabled clusters.
    #>


    foreach ($cluster in (Get-Cluster -ErrorAction Stop)) {
        [PSCustomObject]@{
            Name               = "$($cluster.Name)"
            HAEnabled          = "$($cluster.HAEnabled)"
            HAFailoverLevel    = "$($cluster.HAFailoverLevel)"
            DrsEnabled         = "$($cluster.DrsEnabled)"
            DrsAutomationLevel = "$($cluster.DrsAutomationLevel)"
            EVCMode            = "$($cluster.EVCMode)"
        }
    }
}

function Get-VMwareDatastores {
    <#
    .SYNOPSIS
        Returns all datastores in the connected vSphere environment.
    .DESCRIPTION
        Wraps Get-Datastore and returns capacity, free space, and usage information.
    .EXAMPLE
        Get-VMwareDatastores
        Returns all datastores with capacity and usage details.
    .EXAMPLE
        Get-VMwareDatastores | Where-Object { [double]$_.PercentFree -lt 20 }
        Returns datastores with less than 20% free space.
    #>


    foreach ($ds in (Get-Datastore -ErrorAction Stop)) {
        [PSCustomObject]@{
            Name        = "$($ds.Name)"
            CapacityGB  = "$([math]::Round($ds.CapacityGB, 2))"
            FreeSpaceGB = "$([math]::Round($ds.FreeSpaceGB, 2))"
            PercentFree = "$([math]::Round(($ds.FreeSpaceGB / $ds.CapacityGB) * 100, 1))"
            Type        = "$($ds.Type)"
            State       = "$($ds.State)"
        }
    }
}

function Get-VMwareHosts {
    <#
    .SYNOPSIS
        Returns all ESXi hosts in the connected vSphere environment.
    .DESCRIPTION
        Wraps Get-VMHost and returns a simplified collection of ESXi host objects.
    .EXAMPLE
        Get-VMwareHosts
        Returns all ESXi hosts in the connected vCenter.
    .EXAMPLE
        Get-VMwareHosts | Where-Object { $_.ConnectionState -eq 'Connected' }
        Returns only connected ESXi hosts.
    #>


    Get-VMHost -ErrorAction Stop
}

function Get-VMwareHostDetail {
    <#
    .SYNOPSIS
        Gathers detailed information about a single ESXi host.
    .DESCRIPTION
        Takes an ESXi host object and returns comprehensive details including
        hardware, CPU, memory, networking, and real-time performance metrics.
    .PARAMETER VMHost
        An ESXi host object returned by Get-VMwareHosts or Get-VMHost.
    .EXAMPLE
        $hosts = Get-VMwareHosts
        Get-VMwareHostDetail -VMHost $hosts[0]
        Returns detailed information for the first ESXi host.
    .EXAMPLE
        Get-VMwareHosts | ForEach-Object { Get-VMwareHostDetail -VMHost $_ }
        Returns detailed information for every ESXi host.
    #>

    param(
        [Parameter(Mandatory)]$VMHost
    )

    $mgmtIP = (Get-VMHostNetworkAdapter -VMHost $VMHost -VMKernel -ErrorAction SilentlyContinue |
        Where-Object { $_.ManagementTrafficEnabled } | Select-Object -First 1).IP
    if (-not $mgmtIP) { $mgmtIP = "N/A" }

    $clusterName = if ($VMHost.Parent -and $VMHost.Parent.GetType().Name -eq 'ClusterImpl') {
        "$($VMHost.Parent.Name)"
    } else { "N/A" }

    $cpuAvg = (Get-Stat -Entity $VMHost -Stat "cpu.usage.average" -Realtime -MaxSamples 5 -ErrorAction SilentlyContinue |
        Measure-Object -Property Value -Average).Average
    $memAvg = (Get-Stat -Entity $VMHost -Stat "mem.usage.average" -Realtime -MaxSamples 5 -ErrorAction SilentlyContinue |
        Measure-Object -Property Value -Average).Average
    $netAvg = (Get-Stat -Entity $VMHost -Stat "net.usage.average" -Realtime -MaxSamples 5 -ErrorAction SilentlyContinue |
        Measure-Object -Property Value -Average).Average
    $diskAvg = (Get-Stat -Entity $VMHost -Stat "disk.usage.average" -Realtime -MaxSamples 5 -ErrorAction SilentlyContinue |
        Measure-Object -Property Value -Average).Average

    $hostDatastores = ($VMHost | Get-Datastore -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name) -join ", "

    [PSCustomObject]@{
        Type            = "ESXi Host"
        Name            = "$($VMHost.Name)"
        IPAddress       = $mgmtIP
        Cluster         = $clusterName
        ConnectionState = "$($VMHost.ConnectionState)"
        PowerState      = "$($VMHost.PowerState)"
        Version         = "$($VMHost.Version)"
        Build           = "$($VMHost.Build)"
        Manufacturer    = "$($VMHost.Manufacturer)"
        Model           = "$($VMHost.Model)"
        CpuSockets      = "$($VMHost.ExtensionData.Hardware.CpuInfo.NumCpuPackages)"
        CpuCores        = "$($VMHost.ExtensionData.Hardware.CpuInfo.NumCpuCores)"
        CpuThreads      = "$($VMHost.ExtensionData.Hardware.CpuInfo.NumCpuThreads)"
        CpuTotalMHz     = "$($VMHost.CpuTotalMhz)"
        CpuUsageMHz     = "$($VMHost.CpuUsageMhz)"
        MemoryTotalGB   = "$([math]::Round($VMHost.MemoryTotalGB, 2))"
        MemoryUsageGB   = "$([math]::Round($VMHost.MemoryUsageGB, 2))"
        CpuUsagePct     = "$(if ($null -ne $cpuAvg) { [math]::Round($cpuAvg, 2) } else { 'N/A' })"
        MemUsagePct     = "$(if ($null -ne $memAvg) { [math]::Round($memAvg, 2) } else { 'N/A' })"
        NetUsageKBps    = "$(if ($null -ne $netAvg) { [math]::Round($netAvg, 2) } else { 'N/A' })"
        DiskUsageKBps   = "$(if ($null -ne $diskAvg) { [math]::Round($diskAvg, 2) } else { 'N/A' })"
        Datastores      = $hostDatastores
    }
}

function Get-VMwareVMs {
    <#
    .SYNOPSIS
        Returns all virtual machines in the connected vSphere environment.
    .DESCRIPTION
        Wraps Get-VM and returns a simplified collection of VM objects.
    .EXAMPLE
        Get-VMwareVMs
        Returns all VMs in the connected vCenter.
    .EXAMPLE
        Get-VMwareVMs | Where-Object { $_.PowerState -eq 'PoweredOn' }
        Returns only powered-on VMs.
    #>


    Get-VM -ErrorAction Stop
}

function Get-VMwareVMDetail {
    <#
    .SYNOPSIS
        Gathers detailed information about a single virtual machine.
    .DESCRIPTION
        Takes a VM object and returns comprehensive details including guest OS,
        CPU, memory, disk, network, and real-time performance metrics.
    .PARAMETER VM
        A VM object returned by Get-VMwareVMs or Get-VM.
    .EXAMPLE
        $vms = Get-VMwareVMs
        Get-VMwareVMDetail -VM $vms[0]
        Returns detailed information for the first VM.
    .EXAMPLE
        Get-VMwareVMs | ForEach-Object { Get-VMwareVMDetail -VM $_ }
        Returns detailed information for every VM.
    #>

    param(
        [Parameter(Mandatory)]$VM
    )

    $guest = $VM | Get-VMGuest -ErrorAction SilentlyContinue
    $ipAddr = $guest.IPAddress | Where-Object { $_ -match '^\d{1,3}(\.\d{1,3}){3}$' } | Select-Object -First 1
    if (-not $ipAddr) { $ipAddr = "N/A" }

    $nics = $VM | Get-NetworkAdapter -ErrorAction SilentlyContinue
    $disks = $VM | Get-HardDisk -ErrorAction SilentlyContinue

    # Parse datastore names from hard disk file paths ([DatastoreName] path/to.vmdk)
    $vmDatastores = @()
    if ($disks) {
        $vmDatastores = @($disks | ForEach-Object {
            if ($_.Filename -match '^\[([^\]]+)\]') { $Matches[1] }
        } | Select-Object -Unique)
    }
    $datastoreStr = if ($vmDatastores.Count -gt 0) { $vmDatastores -join ', ' } else { 'N/A' }

    $cpuAvg = $null; $memAvg = $null; $netAvg = $null; $diskAvg = $null; $diskLat = $null
    if ($VM.PowerState -eq 'PoweredOn') {
        $cpuAvg = (Get-Stat -Entity $VM -Stat "cpu.usage.average" -Realtime -MaxSamples 5 -ErrorAction SilentlyContinue |
            Measure-Object -Property Value -Average).Average
        $memAvg = (Get-Stat -Entity $VM -Stat "mem.usage.average" -Realtime -MaxSamples 5 -ErrorAction SilentlyContinue |
            Measure-Object -Property Value -Average).Average
        $netAvg = (Get-Stat -Entity $VM -Stat "net.usage.average" -Realtime -MaxSamples 5 -ErrorAction SilentlyContinue |
            Measure-Object -Property Value -Average).Average
        $diskAvg = (Get-Stat -Entity $VM -Stat "disk.usage.average" -Realtime -MaxSamples 5 -ErrorAction SilentlyContinue |
            Measure-Object -Property Value -Average).Average
        $diskLat = (Get-Stat -Entity $VM -Stat "disk.totalLatency.average" -Realtime -MaxSamples 5 -ErrorAction SilentlyContinue |
            Measure-Object -Property Value -Average).Average

        # Fallback to 5-minute interval stats if realtime returned nothing
        if ($null -eq $diskAvg) {
            $diskAvg = (Get-Stat -Entity $VM -Stat "disk.usage.average" -IntervalSecs 300 -MaxSamples 1 -ErrorAction SilentlyContinue |
                Measure-Object -Property Value -Average).Average
        }
        if ($null -eq $diskLat) {
            $diskLat = (Get-Stat -Entity $VM -Stat "disk.totalLatency.average" -IntervalSecs 300 -MaxSamples 1 -ErrorAction SilentlyContinue |
                Measure-Object -Property Value -Average).Average
        }
    }

    $hostName = "$($VM.VMHost.Name)"
    $clusterName = if ($VM.VMHost.Parent -and $VM.VMHost.Parent.GetType().Name -eq 'ClusterImpl') {
        "$($VM.VMHost.Parent.Name)"
    } else { "N/A" }

    [PSCustomObject]@{
        Name              = "$($VM.Name)"
        IPAddress         = $ipAddr
        PowerState        = "$($VM.PowerState)"
        ESXiHost          = $hostName
        Cluster           = $clusterName
        GuestOS           = "$($VM.ExtensionData.Config.GuestFullName)"
        GuestFamily       = "$($guest.GuestFamily)"
        ToolsStatus       = "$($VM.ExtensionData.Guest.ToolsStatus)"
        NumCPU            = "$($VM.NumCpu)"
        MemoryGB          = "$($VM.MemoryGB)"
        ProvisionedSpaceGB = "$([math]::Round($VM.ProvisionedSpaceGB, 2))"
        UsedSpaceGB       = "$([math]::Round($VM.UsedSpaceGB, 2))"
        NicCount          = "$(@($nics).Count)"
        NicTypes          = "$(($nics.Type | Select-Object -Unique) -join ', ')"
        NetworkNames      = "$(($nics.NetworkName | Select-Object -Unique) -join ', ')"
        DiskCount         = "$(@($disks).Count)"
        DiskTotalGB       = "$([math]::Round(($disks | Measure-Object -Property CapacityGB -Sum).Sum, 2))"
        Datastores        = $datastoreStr
        CpuUsagePct       = "$(if ($null -ne $cpuAvg) { [math]::Round($cpuAvg, 2) } else { 'N/A' })"
        MemUsagePct       = "$(if ($null -ne $memAvg) { [math]::Round($memAvg, 2) } else { 'N/A' })"
        NetUsageKBps      = "$(if ($null -ne $netAvg) { [math]::Round($netAvg, 2) } else { 'N/A' })"
        DiskUsageKBps     = "$(if ($null -ne $diskAvg) { [math]::Round($diskAvg, 2) } else { 'N/A' })"
        DiskLatencyMs     = "$(if ($null -ne $diskLat) { [math]::Round($diskLat, 2) } else { 'N/A' })"
    }
}

function Get-VMwareDashboard {
    <#
    .SYNOPSIS
        Builds a flat dashboard view combining ESXi hosts and their VMs.
    .DESCRIPTION
        Gathers host and VM details from the connected vCenter, then returns
        a unified collection of objects suitable for rendering in an interactive
        Bootstrap Table dashboard. Each row is either an ESXi host or a VM,
        distinguished by a Type column. Columns that apply to only one type
        show "N/A" for the other, following the Proxmox dashboard pattern.
    .EXAMPLE
        Connect-VMware -Server "vcenter01" -Credential $cred
        Get-VMwareDashboard
        Returns a flat dashboard view of all hosts and VMs.
    .EXAMPLE
        $data = Get-VMwareDashboard
        Export-VMwareDashboardHtml -DashboardData $data -OutputPath "C:\Reports\vmware.html"
        Start-Process "C:\Reports\vmware.html"
        End-to-end: connect, gather, export, and open in browser.
    #>


    $results = @()
    $esxiHosts = Get-VMwareHosts

    foreach ($esxi in $esxiHosts) {
        $hostDetail = Get-VMwareHostDetail -VMHost $esxi

        # -- Host row --
        $results += [PSCustomObject]@{
            Type              = "Host"
            Name              = $hostDetail.Name
            PowerState        = $hostDetail.PowerState
            IPAddress         = $hostDetail.IPAddress
            Cluster           = $hostDetail.Cluster
            ESXiHost          = $hostDetail.Name
            GuestOS           = "VMware ESXi $($hostDetail.Version)"
            ToolsStatus       = "N/A"
            CPU               = "$($hostDetail.CpuUsagePct)% ($($hostDetail.CpuSockets)s/$($hostDetail.CpuCores)c/$($hostDetail.CpuThreads)t)"
            Memory            = "$($hostDetail.MemUsagePct)% ($($hostDetail.MemoryUsageGB) / $($hostDetail.MemoryTotalGB) GB)"
            CpuUsagePct       = $hostDetail.CpuUsagePct
            MemUsagePct       = $hostDetail.MemUsagePct
            NetUsageKBps      = $hostDetail.NetUsageKBps
            DiskUsageKBps     = $hostDetail.DiskUsageKBps
            Hardware          = "$($hostDetail.Manufacturer) $($hostDetail.Model)".Trim()
            VersionBuild      = "ESXi $($hostDetail.Version) Build $($hostDetail.Build)"
            Datastores        = $hostDetail.Datastores
            ProvisionedSpaceGB = "N/A"
            UsedSpaceGB       = "N/A"
            NicCount          = "N/A"
            DiskCount         = "N/A"
            DiskLatencyMs     = "N/A"
        }

        # -- VM rows --
        $vms = Get-VM -Location $esxi -ErrorAction SilentlyContinue
        foreach ($vm in $vms) {
            $vmDetail = Get-VMwareVMDetail -VM $vm

            $results += [PSCustomObject]@{
                Type              = "VM"
                Name              = $vmDetail.Name
                PowerState        = $vmDetail.PowerState
                IPAddress         = $vmDetail.IPAddress
                Cluster           = $vmDetail.Cluster
                ESXiHost          = $vmDetail.ESXiHost
                GuestOS           = $vmDetail.GuestOS
                ToolsStatus       = $vmDetail.ToolsStatus
                CPU               = "$($vmDetail.CpuUsagePct)% ($($vmDetail.NumCPU) vCPU)"
                Memory            = "$($vmDetail.MemUsagePct)% ($($vmDetail.MemoryGB) GB)"
                CpuUsagePct       = $vmDetail.CpuUsagePct
                MemUsagePct       = $vmDetail.MemUsagePct
                NetUsageKBps      = $vmDetail.NetUsageKBps
                DiskUsageKBps     = $vmDetail.DiskUsageKBps
                Hardware          = "N/A"
                VersionBuild      = "N/A"
                Datastores        = $vmDetail.Datastores
                ProvisionedSpaceGB = $vmDetail.ProvisionedSpaceGB
                UsedSpaceGB       = $vmDetail.UsedSpaceGB
                NicCount          = $vmDetail.NicCount
                DiskCount         = $vmDetail.DiskCount
                DiskLatencyMs     = $vmDetail.DiskLatencyMs
            }
        }
    }

    return $results
}

function Export-VMwareDashboardHtml {
    <#
    .SYNOPSIS
        Renders VMware dashboard data into a self-contained HTML file.
    .DESCRIPTION
        Takes the output of Get-VMwareDashboard and generates a Bootstrap-based
        HTML report with sortable, searchable, and exportable tables.
    .PARAMETER DashboardData
        Array of PSCustomObject from Get-VMwareDashboard.
    .PARAMETER OutputPath
        File path for the output HTML file.
    .PARAMETER ReportTitle
        Title shown in the report header. Defaults to "VMware vSphere Dashboard".
    .PARAMETER TemplatePath
        Optional path to a custom HTML template. If omitted, uses the
        VMware-Dashboard-Template.html in the same directory as this script.
    .EXAMPLE
        $data = Get-VMwareDashboard
        Export-VMwareDashboardHtml -DashboardData $data -OutputPath "C:\Reports\vmware.html"
        Exports the dashboard data to an HTML file using the default template.
    .EXAMPLE
        Export-VMwareDashboardHtml -DashboardData $data -OutputPath "$env:TEMP\vmware.html" -ReportTitle "Production vSphere"
        Exports with a custom report title.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$DashboardData,
        [Parameter(Mandatory)][string]$OutputPath,
        [string]$ReportTitle = "VMware vSphere Dashboard",
        [string]$TemplatePath
    )

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

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

    $firstObj = $DashboardData | Select-Object -First 1
    $titleMap = @{
        'Type'              = 'Type'
        'Name'              = 'Name'
        'PowerState'        = 'Power State'
        'IPAddress'         = 'IP Address'
        'Cluster'           = 'Cluster'
        'ESXiHost'          = 'ESXi Host'
        'GuestOS'           = 'Guest OS'
        'ToolsStatus'       = 'Tools Status'
        'CPU'               = 'CPU'
        'Memory'            = 'Memory'
        'CpuUsagePct'       = 'CPU %'
        'MemUsagePct'       = 'Mem %'
        'NetUsageKBps'      = 'Net KBps'
        'DiskUsageKBps'     = 'Disk KBps'
        'Hardware'          = 'Hardware'
        'VersionBuild'      = 'Version / Build'
        'Datastores'        = 'Datastores'
        'ProvisionedSpaceGB' = 'Provisioned GB'
        'UsedSpaceGB'       = 'Used GB'
        'NicCount'          = 'NICs'
        'DiskCount'         = 'Disks'
        'DiskLatencyMs'     = 'Disk Latency ms'
    }
    $columns = @()
    foreach ($prop in $firstObj.PSObject.Properties) {
        $title = if ($titleMap.ContainsKey($prop.Name)) { $titleMap[$prop.Name] } else { ($prop.Name -creplace '([A-Z])', ' $1').Trim() }
        $col = @{
            field      = $prop.Name
            title      = $title
            sortable   = $true
            searchable = $true
        }
        if ($prop.Name -eq 'PowerState')  { $col.formatter = 'formatPowerState' }
        if ($prop.Name -eq 'ToolsStatus') { $col.formatter = 'formatToolsStatus' }
        if ($prop.Name -eq 'Type')        { $col.formatter = 'formatType' }
        $columns += $col
    }

    $columnsJson = $columns | ConvertTo-Json -Depth 5 -Compress
    $dataJson    = ConvertTo-Json -InputObject @($DashboardData) -Depth 5 -Compress

    $tableConfig = @"
        columns: $columnsJson,
        data: $dataJson
"@


    $html = Get-Content -Path $TemplatePath -Raw
    $html = $html -replace 'replaceThisHere', $tableConfig
    $html = $html -replace 'ReplaceYourReportNameHere', $ReportTitle
    $html = $html -replace 'ReplaceUpdateTimeHere', (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")

    Set-Content -Path $OutputPath -Value $html -Encoding UTF8
    Write-Verbose "VMware Dashboard HTML written to $OutputPath"
}

function Disconnect-VMware {
    <#
    .SYNOPSIS
        Disconnects from the VMware vCenter Server or ESXi host.
    .DESCRIPTION
        Wraps Disconnect-VIServer to cleanly close the vSphere session.
    .PARAMETER Server
        Optional server name to disconnect from. If omitted, disconnects all.
    .EXAMPLE
        Disconnect-VMware
        Disconnects from all connected vSphere servers.
    .EXAMPLE
        Disconnect-VMware -Server "vcenter01"
        Disconnects from a specific vCenter server.
    #>

    param(
        [string]$Server
    )

    try {
        if ($Server) {
            Disconnect-VIServer -Server $Server -Confirm:$false -ErrorAction Stop
        } else {
            Disconnect-VIServer -Confirm:$false -Force -ErrorAction Stop
        }
        Write-Verbose "Disconnected from VMware"
    }
    catch {
        Write-Warning "VMware disconnect: $($_.Exception.Message)"
    }
}

# =============================================================================
# REST API Collection Method (zero external dependencies)
# Uses vCenter REST API via Invoke-RestMethod (PS 5.1 built-in)
# vSphere 6.5+ required for REST API support
# =============================================================================

# Script-scoped session cache
if (-not (Get-Variable -Name '_VMwareRESTSession' -Scope Script -ErrorAction SilentlyContinue)) {
    $script:_VMwareRESTSession = $null
    $script:_VMwareRESTServer = $null
    $script:_VMwareRESTPort = 443
}

function Invoke-VMwareREST {
    <#
    .SYNOPSIS
        Internal helper -- calls a vCenter REST API endpoint with the cached session token.
    .PARAMETER Path
        API path (e.g., /api/vcenter/vm).
    .PARAMETER Method
        HTTP method. Defaults to GET.
    .PARAMETER Body
        Request body for POST/PUT.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path,
        [string]$Method = 'GET',
        [string]$Body
    )

    if (-not $script:_VMwareRESTSession) {
        throw "vCenter REST session not established. Call Connect-VMwareREST first."
    }

    $uri = "https://$($script:_VMwareRESTServer):$($script:_VMwareRESTPort)$Path"
    $headers = @{ 'vmware-api-session-id' = $script:_VMwareRESTSession }

    $params = @{
        Uri     = $uri
        Method  = $Method
        Headers = $headers
        ErrorAction = 'Stop'
    }
    if ($Body) {
        $params.Body = $Body
        $params.ContentType = 'application/json'
    }

    return Invoke-RestMethod @params
}

function Connect-VMwareREST {
    <#
    .SYNOPSIS
        Authenticates to vCenter REST API and caches the session token.
    .PARAMETER Server
        vCenter server hostname or IP.
    .PARAMETER Credential
        PSCredential with vCenter username and password.
    .PARAMETER Port
        vCenter HTTPS port. Defaults to 443.
    .PARAMETER IgnoreSSLErrors
        Skip SSL certificate validation.
    .EXAMPLE
        $cred = Get-Credential
        Connect-VMwareREST -Server 'vcenter.lab.local' -Credential $cred -IgnoreSSLErrors
    #>

    param(
        [Parameter(Mandatory)][string]$Server,
        [Parameter(Mandatory)][PSCredential]$Credential,
        [int]$Port = 443,
        [switch]$IgnoreSSLErrors
    )

    if ($IgnoreSSLErrors) {
        # WARNING: On PowerShell 5.1, SSL bypass is process-wide and cannot be scoped per-request.
        # This affects all .NET HTTP connections in this process until the callback is reset.
        # On PowerShell 7+, SkipCertificateCheck is per-request via PSDefaultParameterValues.
        if ($PSVersionTable.PSEdition -eq 'Core') {
            $Script:PSDefaultParameterValues["Invoke-RestMethod:SkipCertificateCheck"] = $true
            $Script:PSDefaultParameterValues["Invoke-WebRequest:SkipCertificateCheck"] = $true
        }
        else {
            # PowerShell 5.1 SSL bypass — process-wide
            try {
                Add-Type @"
using System.Net;
using System.Security.Cryptography.X509Certificates;
public class TrustAllCertsPolicy : ICertificatePolicy {
    public bool CheckValidationResult(ServicePoint sp, X509Certificate cert, WebRequest req, int problem) { return true; }
}
"@

                [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy
            }
            catch {
                # Type may already be added
                [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
            }
        }
        Write-Warning "Ignoring SSL certificate validation errors. This affects all connections in this process."
    }

    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12

    $script:_VMwareRESTServer = $Server
    $script:_VMwareRESTPort = $Port

    $uri = "https://${Server}:${Port}/api/session"
    $authBytes = [System.Text.Encoding]::UTF8.GetBytes("$($Credential.UserName):$($Credential.GetNetworkCredential().Password)")
    $authHeader = [System.Convert]::ToBase64String($authBytes)
    $authBytes = $null
    $headers = @{ Authorization = "Basic $authHeader" }

    try {
        $session = Invoke-RestMethod -Uri $uri -Method POST -Headers $headers -ErrorAction Stop
        # vCenter 7+ returns a plain string token; 6.x returns JSON with value
        if ($session -is [string]) {
            $script:_VMwareRESTSession = $session.Trim('"')
        }
        elseif ($session.value) {
            $script:_VMwareRESTSession = $session.value
        }
        else {
            $script:_VMwareRESTSession = "$session"
        }
        Write-Verbose "Connected to vCenter REST API at ${Server}:${Port}"
        return @{ Server = $Server; Port = $Port }
    }
    catch {
        throw "Failed to authenticate to vCenter REST API: $($_.Exception.Message)"
    }
}

function Get-VMwareClustersREST {
    <#
    .SYNOPSIS
        Returns all clusters from vCenter via REST API.
    .EXAMPLE
        Get-VMwareClustersREST
    #>


    $clusters = Invoke-VMwareREST -Path '/api/vcenter/cluster'
    foreach ($c in $clusters) {
        $detail = $null
        try { $detail = Invoke-VMwareREST -Path "/api/vcenter/cluster/$($c.cluster)" } catch { }
        [PSCustomObject]@{
            Name               = "$($c.name)"
            HAEnabled          = if ($detail -and $detail.ha_enabled -ne $null) { $detail.ha_enabled } else { 'N/A' }
            DrsEnabled         = if ($detail -and $detail.drs_enabled -ne $null) { $detail.drs_enabled } else { 'N/A' }
            ClusterId          = "$($c.cluster)"
        }
    }
}

function Get-VMwareHostsREST {
    <#
    .SYNOPSIS
        Returns all ESXi hosts from vCenter via REST API.
    .EXAMPLE
        Get-VMwareHostsREST
    #>


    $hosts = Invoke-VMwareREST -Path '/api/vcenter/host'
    foreach ($h in $hosts) {
        [PSCustomObject]@{
            Name            = "$($h.name)"
            HostId          = "$($h.host)"
            ConnectionState = "$($h.connection_state)"
            PowerState      = "$($h.power_state)"
        }
    }
}

function Get-VMwareHostDetailREST {
    <#
    .SYNOPSIS
        Returns detailed ESXi host information via REST API.
    .PARAMETER HostId
        The vCenter host MoRef ID (e.g., host-10).
    .PARAMETER HostName
        The ESXi host name (for output).
    .EXAMPLE
        Get-VMwareHostDetailREST -HostId 'host-10' -HostName 'esxi01.lab.local'
    #>

    param(
        [Parameter(Mandatory)][string]$HostId,
        [string]$HostName
    )

    $ip = $null
    # Get management IP from network interfaces
    try {
        $nics = Invoke-VMwareREST -Path "/api/vcenter/host/$HostId"
        # Host detail endpoint may not have IP; try to resolve hostname
        if ($HostName) {
            try {
                $resolved = [System.Net.Dns]::GetHostAddresses($HostName) | Where-Object { $_.AddressFamily -eq 'InterNetwork' } | Select-Object -First 1
                if ($resolved) { $ip = $resolved.IPAddressToString }
            }
            catch { }
        }
    }
    catch { }

    [PSCustomObject]@{
        Type            = 'ESXi Host'
        Name            = if ($HostName) { $HostName } else { $HostId }
        IPAddress       = if ($ip) { $ip } else { '' }
        HostId          = $HostId
    }
}

function Get-VMwareVMsREST {
    <#
    .SYNOPSIS
        Returns all VMs from vCenter via REST API.
    .EXAMPLE
        Get-VMwareVMsREST
    #>


    $vms = Invoke-VMwareREST -Path '/api/vcenter/vm'
    foreach ($vm in $vms) {
        [PSCustomObject]@{
            Name       = "$($vm.name)"
            VMId       = "$($vm.vm)"
            PowerState = "$($vm.power_state)"
            CpuCount   = if ($vm.cpu_count) { $vm.cpu_count } else { 0 }
            MemorySizeMB = if ($vm.memory_size_MiB) { $vm.memory_size_MiB } else { 0 }
        }
    }
}

function Get-VMwareVMDetailREST {
    <#
    .SYNOPSIS
        Returns detailed VM information via REST API.
    .PARAMETER VMId
        The vCenter VM MoRef ID (e.g., vm-10).
    .PARAMETER VMName
        The VM name (for output).
    .EXAMPLE
        Get-VMwareVMDetailREST -VMId 'vm-10' -VMName 'MyServer'
    #>

    param(
        [Parameter(Mandatory)][string]$VMId,
        [string]$VMName
    )

    $detail = $null
    try { $detail = Invoke-VMwareREST -Path "/api/vcenter/vm/$VMId" } catch { }

    $ip = $null
    $guestOS = 'N/A'
    $guestFamily = 'N/A'
    $toolsStatus = 'N/A'

    if ($detail) {
        if ($detail.guest_OS) { $guestOS = "$($detail.guest_OS)" }

        # Try to get guest identity for IP
        try {
            $identity = Invoke-VMwareREST -Path "/api/vcenter/vm/$VMId/guest/identity"
            if ($identity.ip_address) { $ip = "$($identity.ip_address)" }
            if ($identity.family) { $guestFamily = "$($identity.family)" }
            if ($identity.name) { $guestOS = "$($identity.name)" }
        }
        catch { }

        # Try tools info
        try {
            $tools = Invoke-VMwareREST -Path "/api/vcenter/vm/$VMId/tools"
            if ($tools.run_state) { $toolsStatus = "$($tools.run_state)" }
        }
        catch { }
    }

    $numCpu = 0; $memGB = 0; $diskCount = 0; $nicCount = 0
    if ($detail) {
        if ($detail.cpu -and $detail.cpu.count) { $numCpu = $detail.cpu.count }
        if ($detail.memory -and $detail.memory.size_MiB) { $memGB = [math]::Round($detail.memory.size_MiB / 1024, 2) }
        if ($detail.disks) { $diskCount = @($detail.disks.PSObject.Properties).Count }
        if ($detail.nics) { $nicCount = @($detail.nics.PSObject.Properties).Count }
    }

    $hostName = 'N/A'
    # VM detail doesn't always include host; we pass it from the caller if needed

    [PSCustomObject]@{
        Name          = if ($VMName) { $VMName } else { $VMId }
        IPAddress     = if ($ip) { $ip } else { '' }
        PowerState    = if ($detail -and $detail.power_state) { "$($detail.power_state)" } else { 'N/A' }
        GuestOS       = $guestOS
        GuestFamily   = $guestFamily
        ToolsStatus   = $toolsStatus
        NumCPU        = $numCpu
        MemoryGB      = $memGB
        DiskCount     = $diskCount
        NicCount      = $nicCount
        VMId          = $VMId
    }
}

function Disconnect-VMwareREST {
    <#
    .SYNOPSIS
        Disconnects from vCenter REST API session.
    .EXAMPLE
        Disconnect-VMwareREST
    #>


    if ($script:_VMwareRESTSession) {
        try {
            Invoke-VMwareREST -Path '/api/session' -Method DELETE
            Write-Verbose "Disconnected from vCenter REST API"
        }
        catch {
            Write-Verbose "vCenter REST disconnect: $($_.Exception.Message)"
        }
        $script:_VMwareRESTSession = $null
    }
}

# SIG # Begin signature block
# MIIr+wYJKoZIhvcNAQcCoIIr7DCCK+gCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAN/DN7AUEAzD91
# glqFJapZt+S48qxWjMARDaPeIr3Sp6CCJQ0wggVvMIIEV6ADAgECAhBI/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
# BCDt6suBTiabCBepsfhnGTbNBpAliE0Zt8pROWuHuwYWKDANBgkqhkiG9w0BAQEF
# AASCAgBMf3zICmVTwpJVISfSMw9qA3DtacySOcl6Wxd0dybqnSdW58BKpG6rze6N
# xt5WshefSI+8amka9r+tfpAhdN+CTZkStA5w46ZXA6ytO2iLdl+Ty9BuQ90dLkax
# n1j2cZlKGoEH61pzvOz9lSRdfS+D9p4Muk5T+ldDQt4K7VnC5I3toXuylisBOAuV
# P6RsKVOQdE7Yhu7Pcsw5C5E48sEhtO3aeeG0uKP1TXCfBMi9wCBX0WreRnjhazrv
# +3+PtENHiR/gVWLAHTLB/LxYcBue3TRGGLvpy4OWs/RwAE5D0mYS0tJVUhQgmaNL
# Qd43Zi5oZ8/zCgaM/KpFF9zm1yuzRpDrWlHputHXQRkbSRMoTZjUQgrrb6VyQzEl
# AYfORyhhzvw9yc1jPOK3xGtl5deXNT1ewu5UGIaChyFK4m3exG6zz1k7RDiq2GaI
# LA2cVYxYyKtxkbj5fAa/tBZ3ESp0femZQR/gRaCyxDgSbFX+cLA2Bt2ZedowPVzw
# eydtEGEhuctLt5cYQLLzFYJjOn0PRlg0jXrNnGnDqzQWTTei3yCqQtw2q0VMEzDB
# 4/VnhfKTEQV7o8TKIBTflrUHIQC5qBK/Ac3xHFZgxoA9TeN3txCJeQ/qvD8uohiZ
# KgOPPEtm93IWay/GcFz6I+zYVBIhJpayVN0W41igCSGAMAAv+qGCAyYwggMiBgkq
# hkiG9w0BCQYxggMTMIIDDwIBATB9MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5E
# aWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1l
# U3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTECEAqA7xhLjfEFgtHEdqeV
# dGgwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwG
# CSqGSIb3DQEJBTEPFw0yNjA0MjAwMDE2NDFaMC8GCSqGSIb3DQEJBDEiBCDC5I3J
# kL95frjlaCZEGky7oZC1NuLns4HiqTlGdryb4TANBgkqhkiG9w0BAQEFAASCAgCw
# BJ76yUv6YIxx0EqPloOPoE6pPdZubLsIyoocRFgR8l9s5yXPeN3xGnlsRdVyFIZj
# MIShMpTt5RrYXpe1eZJXrJb9g3GsBknJ9W1GqwujD9gZOCn04JMbLkWeoVNvLkoU
# oCcRdQOonrIh0N2kylpMLVfnwHaa55IUmNG5lbZCcsVuNwLFB9vh57Fdn/wICZfe
# GlXlXKhpmLCjXUA3hPCJC3SM11RdzFQIvBdM98Rs/X6zwLv9ZUkFwUdcNUBtB1xK
# l0EaDCow/X1MvjxMQ5CD1c/EOwaZbYBvsl8bgl/YjRpUfa+A80hpoBaz0eCuDiOJ
# 32OchgALAOM0rytKBHTsNYtzQiHXrkbvGfSGlaQ4V9KWNXobkL9o1G1vGKnoz1lc
# zUgQtaJIZ/g8xDybw49ueTS3b0DToRTF5vV/QYVxNJrDgsbE57HhE5esLhk77Sv1
# FKC6MpXS/FXnEY9oOWdUf15f/vB4gKLLAdVJ4K7QM8rgc8a21sNOxZsTeTWurCb/
# eGszElXSGfEitSHi5KtUwmc8toZsVaT/cSTeDyMcd36cQHq5qosx5Fm0yFRjbaq7
# j2rnIcmW/zOhuea6n65pMf5Zn1c+gryeeHw7iWm6kJ0Gk1o3GUB5emU0wl1F/TS0
# CZzAHHywpRzkVO05ODLymlpSbNhOceVrwotx0t1O6A==
# SIG # End signature block