Private/Inventory.ps1

# Copyright (c) 2026 Broadcom. All Rights Reserved.
# Broadcom Confidential. The term "Broadcom" refers to Broadcom Inc.
# and/or its subsidiaries.
#
# =============================================================================
#
# SOFTWARE LICENSE AGREEMENT
#
# Copyright (c) CA, Inc. All rights reserved.
#
# You are hereby granted a non-exclusive, worldwide, royalty-free license
# under CA, Inc.'s copyrights to use, copy, modify, and distribute this
# software in source code or binary form for use in connection with CA, Inc.
# products.
#
# This copyright notice shall be included in all copies or substantial
# portions of the software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
#
# =============================================================================

#region Live Inventory Collection

function Get-VcenterBuildMap {

    <#
        .SYNOPSIS
        Load the vCenter version-to-MOB-build-number lookup table from a companion JSON file.

        .DESCRIPTION
        Reads vcenterBuildMap.json written by Convert-BroadcomAdvisoriesToSchema.ps1 (which
        scrapes Broadcom KB 326316 for every vCenter version and its matching MOB build number)
        and returns two lookup directions:
          VersionToBuild — forward map: "8.0.3.00900" → "25413364" (advisory version → MOB build)
          BuildToVersion — reverse map: "25413364" → "8.0.3.00900" (MOB build → advisory version)

        Returns empty maps when the file is absent so scans proceed without build enrichment
        rather than failing. The enrichment is purely display — advisory comparison is unaffected.

        .PARAMETER BuildMapPath
        Full path to vcenterBuildMap.json (generated alongside securityAdvisory.json by the
        advisory conversion script).

        .EXAMPLE
        $maps = Get-VcenterBuildMap -BuildMapPath '/home/user/Data/vcenterBuildMap.json'
        $mob = $maps.VersionToBuild['8.0.3.00900'] # returns "25413364"
        $ver = $maps.BuildToVersion['25413364'] # returns "8.0.3.00900"

        .OUTPUTS
        [Hashtable] with keys VersionToBuild and BuildToVersion (each a [Hashtable]).

        .NOTES
        Does not throw on missing file — returns empty maps so scans proceed without build enrichment.
    #>


    [CmdletBinding()]
    [OutputType([Hashtable])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$BuildMapPath
    )

    $empty = @{ VersionToBuild = @{}; BuildToVersion = @{} }

    if (-not (Test-Path -LiteralPath $BuildMapPath -PathType Leaf)) {
        Write-LogMessage -Type DEBUG -Message "vCenter build map not found at '$BuildMapPath' — build numbers will not be enriched."
        return $empty
    }

    try {
        $data           = Get-Content -LiteralPath $BuildMapPath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
        $versionToBuild = @{}
        $buildToVersion = @{}

        # $data.versionToBuild is a PSCustomObject from JSON deserialization; PSObject.Properties
        # is the correct enumeration method for PSCustomObject (not GetEnumerator, which is for hashtables).
        if ($null -ne $data.versionToBuild) {
            foreach ($prop in $data.versionToBuild.PSObject.Properties) {
                $ver   = [String]$prop.Name
                $build = [String]$prop.Value
                if (-not [String]::IsNullOrWhiteSpace($ver) -and -not [String]::IsNullOrWhiteSpace($build)) {
                    $versionToBuild[$ver] = $build
                    if (-not $buildToVersion.ContainsKey($build)) {
                        $buildToVersion[$build] = $ver
                    }
                }
            }
        }

        Write-LogMessage -Type DEBUG -Message "Loaded vCenter build map: $($versionToBuild.Count) entries from '$BuildMapPath'."
        return @{ VersionToBuild = $versionToBuild; BuildToVersion = $buildToVersion }
    }
    catch {
        Write-LogMessage -Type WARNING -Message "Failed to load vCenter build map from '$BuildMapPath': $($_.Exception.Message)"
        return $empty
    }
}
function Get-StandaloneNsxManagerInventory {

    <#
        .SYNOPSIS
        Collect NSX Manager version for a standalone (non-SDDC-managed) environment.

        .DESCRIPTION
        Queries GET /api/v1/node on the NSX Manager using Basic auth (admin account and
        NSX_MANAGER_PASSWORD environment variable) to retrieve the installed product version.

        Used for vsphere8 and vvf9 environments where NSX is not managed by SDDC Manager.
        For vcf5 and vcf9 environments, NSX version is discovered via SDDC Manager inventory.

        Returns an empty hashtable when NSX_MANAGER_PASSWORD is absent, when the FQDN is not
        configured, or when the API call fails. Failure is non-fatal — the caller continues.

        .PARAMETER DomainName
        Domain label to attach to the returned inventory entry (default: "N/A").

        .PARAMETER NsxManagerFqdn
        FQDN or IP address of the NSX Manager cluster VIP.

        .PARAMETER TimeoutSeconds
        Per-request timeout in seconds (1-300, default 30).

        .EXAMPLE
        $nsxInv = Get-StandaloneNsxManagerInventory -NsxManagerFqdn "nsx.corp.example.com"
        if ($nsxInv.Count -gt 0) {
            Write-LogMessage -Type INFO -Message "NSX Manager: $($nsxInv['NSX'][0].Fqdn) v$($nsxInv['NSX'][0].Version)"
        }

        .OUTPUTS
        [Hashtable] @{ "NSX" = @([PSCustomObject]) } or empty hashtable when unavailable.

        .NOTES
        Reads NSX_MANAGER_PASSWORD from the environment via Get-InventoryPassword. Skips
        silently when the variable is absent. NSX version format "4.2.1.0.0.24105824" is
        normalised to "4.2.1.0.0-24105824" so ConvertTo-NormalizedVersion can strip the
        build suffix consistently.
    #>


    [CmdletBinding()]
    [OutputType([Hashtable])]
    Param (
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [ValidateNotNull()] [String]$DomainName = "",
        [Parameter(Mandatory = $true)]  [ValidateNotNullOrEmpty()] [String]$NsxManagerFqdn,
        [Parameter(Mandatory = $false)] [ValidateRange(1, 300)]    [Int]$TimeoutSeconds = 30
    )

    $password = Get-InventoryPassword -ComponentName "NSX Manager" -EnvVarName "NSX_MANAGER_PASSWORD"
    if ($null -eq $password) { return @{} }

    $basicAuth = [Convert]::ToBase64String(
        [System.Text.Encoding]::ASCII.GetBytes("admin:$password")
    )
    $headers = @{ "Authorization" = "Basic $basicAuth"; "Accept" = "application/json" }

    try {
        $nodeInfo = Invoke-RestMethod -Uri "https://$NsxManagerFqdn/api/v1/node" `
            -Headers $headers -Method GET `
            -SkipCertificateCheck -TimeoutSec $TimeoutSeconds -ErrorAction Stop

        $rawVersion = [String]$nodeInfo.product_version
        $version = if ([String]::IsNullOrWhiteSpace($rawVersion)) {
            "Unknown"
        } else {
            # Normalize "4.2.1.0.0.24105824" → "4.2.1.0.0-24105824" so the build
            # suffix can be stripped by ConvertTo-NormalizedVersion consistently.
            $rawVersion -replace '\.(\d{7,})$', '-$1'
        }

        Write-LogMessage -Type INFO -Message "Collected NSX Manager (standalone): $NsxManagerFqdn v$version"
        return @{
            "NSX" = @([PSCustomObject]@{
                Fqdn       = $NsxManagerFqdn
                Version    = $version
                DomainName = $DomainName
            })
        }
    }
    catch {
        Write-LogMessage -Type DEBUG -Message "Standalone NSX Manager inventory failed for $NsxManagerFqdn : $($_.Exception.Message)"
        return @{}
    }
}
function Get-NsxAdminPasswordFromSddc {

    <#
        .SYNOPSIS
        Retrieve the NSX Manager admin password from the SDDC Manager credentials API.

        .DESCRIPTION
        Calls Invoke-VcfGetCredentials -ResourceType NSXT_MANAGER -AccountType USER to
        retrieve NSX Manager credentials managed by SDDC Manager, then returns the password
        for the "admin" account.

        This is the canonical approach for VCF 5.x environments — the NSX admin password is
        stored and managed by SDDC Manager and should never be prompted from the user. See
        https://knowledge.broadcom.com/external/article/434886 for the API reference.

        Returns $null when no matching credential is found or when the API call fails.
        The caller must treat a $null return as a non-fatal condition.

        .EXAMPLE
        $nsxPassword = Get-NsxAdminPasswordFromSddc
        if ($null -ne $nsxPassword) {
            $edges = Get-NsxEdgeInventory -NsxManagerFqdn "nsx.example.com" -Password $nsxPassword
        }

        .OUTPUTS
        [String] The admin account password, or $null when unavailable.

        .NOTES
        Must be called after Connect-VcfSddcManagerServer has established an active session.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param ()

    try {
        $credPage = Invoke-VcfGetCredentials -ResourceType "NSXT_MANAGER" -AccountType "USER" -ErrorAction Stop
        foreach ($cred in @($credPage.Elements)) {
            if ($null -eq $cred) { continue }
            if ([String]$cred.Username -ieq "admin" -and -not [String]::IsNullOrWhiteSpace($cred.Password)) {
                Write-LogMessage -Type DEBUG -Message "NSX Manager admin credential retrieved from SDDC Manager."
                return [String]$cred.Password
            }
        }
        Write-LogMessage -Type DEBUG -Message "NSX Manager admin credential not found in SDDC Manager credentials store."
        return $null
    }
    catch {
        Write-LogMessage -Type DEBUG -Message "Could not retrieve NSX Manager admin credential from SDDC Manager: $($_.Exception.Message)"
        return $null
    }
}
function Get-NsxEdgeInventory {

    <#
        .SYNOPSIS
        Collect NSX Edge node inventory from an NSX Manager REST API.

        .DESCRIPTION
        Queries GET /api/v1/transport-nodes?node_types=EdgeNode to enumerate edge nodes, then
        queries GET /api/v1/transport-nodes/{id}/status for each node to retrieve its
        software_version. Returns an array of inventory objects suitable for inclusion in the
        "NSX Edge" inventory key. Authenticates with Basic auth using the NSX Manager admin
        account and the NSX_MANAGER_PASSWORD environment variable.

        Returns an empty array when the password is not configured or when no edge nodes are
        found — the caller must treat this as a non-fatal best-effort step.

        .PARAMETER NsxManagerFqdn
        FQDN or IP address of the NSX Manager cluster VIP.

        .PARAMETER DomainName
        Workload domain name to attach to each edge node entry (e.g. "vcf-pd-m01").

        .PARAMETER Password
        NSX Manager admin account password. When provided, takes precedence over the
        NSX_MANAGER_PASSWORD environment variable. Pass the value returned by
        Get-NsxAdminPasswordFromSddc for VCF 5.x environments. For vsphere8/vvf9
        environments the env var is used when this parameter is omitted.

        .PARAMETER TimeoutSeconds
        Per-request timeout in seconds (1-300, default 30).

        .EXAMPLE
        $nsxPassword = Get-NsxAdminPasswordFromSddc
        $edges = Get-NsxEdgeInventory -NsxManagerFqdn "nsx.example.com" -DomainName "mgmt-domain" -Password $nsxPassword
        foreach ($e in $edges) { Write-LogMessage -Type INFO -Message "Edge: $($e.Fqdn) v$($e.Version)" }

        .OUTPUTS
        [PSCustomObject[]] Array of edge node inventory objects with Fqdn, Version, DomainName.

        .NOTES
        For VCF 5.x environments the password is supplied via -Password (retrieved from SDDC
        Manager via Get-NsxAdminPasswordFromSddc). For vsphere8/vvf9 environments the
        NSX_MANAGER_PASSWORD environment variable is the fallback when -Password is omitted.
        Returns an empty array on failure rather than throwing — edge inventory is best-effort
        and must not abort the main scan.
    #>


    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    Param (
        [Parameter(Mandatory = $true)]  [ValidateNotNullOrEmpty()] [String]$NsxManagerFqdn,
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [ValidateNotNull()] [String]$DomainName = "",
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [AllowNull()]       [String]$Password = $null,
        [Parameter(Mandatory = $false)] [ValidateRange(1, 300)]                  [Int]$TimeoutSeconds = 30
    )

    # Prefer the explicitly supplied password; fall back to the environment variable for
    # vsphere8/vvf9 callers that set NSX_MANAGER_PASSWORD without using this parameter.
    $nsxPass = $Password
    if ([String]::IsNullOrWhiteSpace($nsxPass)) {
        $nsxPass = [System.Environment]::GetEnvironmentVariable("NSX_MANAGER_PASSWORD")
    }
    if ([String]::IsNullOrWhiteSpace($nsxPass)) {
        return @()
    }

    $basicAuth = [Convert]::ToBase64String(
        [System.Text.Encoding]::ASCII.GetBytes("admin:$nsxPass")
    )
    $headers = @{ "Authorization" = "Basic $basicAuth"; "Accept" = "application/json" }
    $results = [System.Collections.Generic.List[PSCustomObject]]::new()

    try {
        $transportNodes = Invoke-RestMethod `
            -Uri "https://$NsxManagerFqdn/api/v1/transport-nodes?node_types=EdgeNode&page_size=100" `
            -Headers $headers -Method GET `
            -SkipCertificateCheck -TimeoutSec $TimeoutSeconds -ErrorAction Stop

        foreach ($node in @($transportNodes.results)) {
            if ($null -eq $node) { continue }
            $nodeId   = [String]$node.node_id
            $hostname = [String]$node.node_deployment_info.node_settings.hostname
            if ([String]::IsNullOrWhiteSpace($hostname)) {
                $hostname = [String]$node.display_name
            }

            $version = "Unknown"
            try {
                $status  = Invoke-RestMethod `
                    -Uri "https://$NsxManagerFqdn/api/v1/transport-nodes/$nodeId/status" `
                    -Headers $headers -Method GET `
                    -SkipCertificateCheck -TimeoutSec $TimeoutSeconds -ErrorAction Stop
                $rawVer = [String]$status.node_status.software_version
                if (-not [String]::IsNullOrWhiteSpace($rawVer)) {
                    # NSX Edge reports version as "4.2.0.0.0.24105824" — dot-separated with
                    # the build number as the sixth segment. Normalize to a dash form so
                    # ConvertTo-NormalizedVersion can strip the build suffix consistently.
                    $version = $rawVer -replace '\.(\d{7,})$', '-$1'
                }
            }
            catch {
                Write-LogMessage -Type DEBUG -Message "NSX Edge version query failed for $hostname ($nodeId): $($_.Exception.Message)"
            }

            $results.Add([PSCustomObject]@{
                Fqdn       = $hostname
                Version    = $version
                DomainName = $DomainName
            })
        }
    }
    catch {
        Write-LogMessage -Type DEBUG -Message "NSX Edge inventory query failed for $NsxManagerFqdn : $($_.Exception.Message)"
    }

    return $results.ToArray()
}
function Get-SddcManagerInventory {

    <#
        .SYNOPSIS
        Collect infrastructure inventory from SDDC Manager.

        .DESCRIPTION
        Connects to SDDC Manager via VCF PowerCLI (Connect-VcfSddcManagerServer) and retrieves
        versions of SDDC Manager itself, all managed vCenter servers, NSX Manager clusters, and
        NSX Edge nodes registered with each NSX Manager.

        .PARAMETER Server
        SDDC Manager FQDN or IP address.

        .PARAMETER User
        Username for SDDC Manager authentication.

        .PARAMETER TimeoutSeconds
        Connection timeout in seconds (1-300, default 30).

        .PARAMETER VcenterBuildMaps
        Optional lookup table from Get-VcenterBuildMap. When provided, each vCenter entry is
        enriched with a BuildVersion property (e.g. "8.0.3.25413364") derived from the MOB
        build number that corresponds to the advisory-compatible version ("8.0.3.00900").

        .EXAMPLE
        $inventory = Get-SddcManagerInventory -Server "sddc.example.com" -User "administrator@vsphere.local"

        .OUTPUTS
        [Hashtable] Inventory keyed by component name: @{ "SDDC Manager" = @(...), "vCenter" = @(...), "NSX" = @(...), "NSX Edge" = @(...) }

        .NOTES
        Reads SDDC_MANAGER_PASSWORD from the environment via Get-InventoryPassword. Returns an empty hashtable on connectivity or authentication failure (logs WARNING).
        NSX Edge inventory is collected via Get-NsxEdgeInventory. The NSX admin password is
        retrieved from the SDDC Manager credentials API (GET /v1/credentials) via
        Get-NsxAdminPasswordFromSddc — no separate NSX password is required from the user.
    #>


    [CmdletBinding()]
    [OutputType([Hashtable])]
    Param (
        [Parameter(Mandatory = $true)]  [ValidateNotNullOrEmpty()] [String]$Server,
        [Parameter(Mandatory = $false)] [ValidateRange(1, 300)]    [Int]$TimeoutSeconds = 30,
        [Parameter(Mandatory = $true)]  [ValidateNotNullOrEmpty()] [String]$User,
        [Parameter(Mandatory = $false)] [ValidateNotNull()]        [Hashtable]$VcenterBuildMaps = @{ VersionToBuild = @{}; BuildToVersion = @{} }
    )

    Write-LogMessage -Type INFO -Message "Collecting SDDC Manager inventory from: $Server..."

    $inventory = @{}
    $conn = $null
    $securePassword = $null

    try {
        $password = Get-InventoryPassword -ComponentName "SDDC Manager" -EnvVarName "SDDC_MANAGER_PASSWORD"
        if ($null -eq $password) { return $inventory }

        $securePassword = ConvertTo-SecureString -String $password -AsPlainText -Force
        $credential = New-Object System.Management.Automation.PSCredential($User, $securePassword)

        Write-LogMessage -Type INFO -Message "Connecting to SDDC Manager `"$Server`"..."
        # -IgnoreInvalidCertificate: SDDC Manager uses a self-signed certificate in most deployments.
        $conn = Connect-VcfSddcManagerServer -Server $Server -Credential $credential -IgnoreInvalidCertificate -ErrorAction Stop

        # Retrieve the NSX Manager admin password from the SDDC Manager credentials store.
        # VCF manages this password internally; users must not be prompted for it separately.
        $nsxAdminPassword = Get-NsxAdminPasswordFromSddc

        $sddcResponse = Invoke-VcfGetSddcManagers -ErrorAction Stop
        $sddcElements = @($sddcResponse.Elements)
        $sddc = if ($sddcElements.Count -gt 0) { $sddcElements[0] } else { $null }
        $sddcFqdn    = if ($null -ne $sddc -and -not [String]::IsNullOrWhiteSpace($sddc.Fqdn))    { [String]$sddc.Fqdn }    else { $Server }
        $sddcVersion = if ($null -ne $sddc -and -not [String]::IsNullOrWhiteSpace($sddc.Version)) { [String]$sddc.Version } else { "Unknown" }

        # Retrieve workload domain information to associate components with their VCF domain.
        # SDDC Manager's own domain.name is the VCF instance identifier (management domain).
        $vcenterFqdnToDomainName = @{}
        # Domain ID → name map: used as a fallback for ESX hosts whose DomainReference.Name
        # may be absent in some API versions (only the ID is guaranteed to be populated).
        $domainIdToName = @{}
        $sddcDomainName = if ($null -ne $sddc -and -not [String]::IsNullOrWhiteSpace($sddc.Domain.Name)) { [String]$sddc.Domain.Name } else { "" }
        try {
            $domainsResponse = Invoke-VcfGetDomains -PageSize 100 -ErrorAction Stop
            $managementDomainName = ""
            foreach ($domain in @($domainsResponse.Elements)) {
                if ([String]::IsNullOrWhiteSpace($domain.Name)) { continue }
                if ([String]$domain.DomainType -ieq "MANAGEMENT") {
                    $managementDomainName = [String]$domain.Name
                }
                if (-not [String]::IsNullOrWhiteSpace($domain.Id)) {
                    $domainIdToName[[String]$domain.Id] = [String]$domain.Name
                }
                foreach ($vcRef in @($domain.Vcenters)) {
                    if (-not [String]::IsNullOrWhiteSpace($vcRef.Fqdn)) {
                        $vcenterFqdnToDomainName[$vcRef.Fqdn.ToLower()] = [String]$domain.Name
                    }
                }
            }
            # $sddc.Domain.Name is not always populated by the API; resolve from the domain ID
            # map first, then fall back to whichever domain carries DomainType = MANAGEMENT.
            if ([String]::IsNullOrWhiteSpace($sddcDomainName)) {
                $sddcDomainId = if ($null -ne $sddc -and $null -ne $sddc.Domain) { [String]$sddc.Domain.Id } else { "" }
                if (-not [String]::IsNullOrWhiteSpace($sddcDomainId) -and $domainIdToName.ContainsKey($sddcDomainId)) {
                    $sddcDomainName = $domainIdToName[$sddcDomainId]
                } elseif (-not [String]::IsNullOrWhiteSpace($managementDomainName)) {
                    $sddcDomainName = $managementDomainName
                }
            }
            Write-LogMessage -Type INFO -Message "Retrieved domain-to-vCenter mappings for $($vcenterFqdnToDomainName.Count) vCenter(s), $($domainIdToName.Count) domain(s)"
        } catch {
            Write-LogMessage -Type WARNING -Message "Could not retrieve workload domain info from SDDC Manager: $($_.Exception.Message)"
        }

        # Cluster ID → name map: used as a fallback for ESX hosts whose Cluster reference
        # carries only an ID in VCF 9.x (the Name field is absent from the hosts API response).
        $clusterIdToName = @{}
        try {
            $clusterPage = 0
            $clusterTotalPages = 1
            do {
                $clusterResponse = Invoke-VcfGetClusters -PageNumber $clusterPage -PageSize 100 -ErrorAction Stop
                foreach ($clusterObj in @($clusterResponse.Elements)) {
                    if ($null -eq $clusterObj) { continue }
                    $cId   = [String]$clusterObj.Id
                    $cName = [String]$clusterObj.Name
                    if (-not [String]::IsNullOrWhiteSpace($cId) -and -not [String]::IsNullOrWhiteSpace($cName)) {
                        $clusterIdToName[$cId] = $cName
                    }
                }
                if ($null -ne $clusterResponse.PageMetadata -and $null -ne $clusterResponse.PageMetadata.TotalPages) {
                    $clusterTotalPages = [Int]$clusterResponse.PageMetadata.TotalPages
                }
                $clusterPage++
            } while ($clusterPage -lt $clusterTotalPages)
            Write-LogMessage -Type INFO -Message "Retrieved cluster ID-to-name mappings for $($clusterIdToName.Count) cluster(s)"
        } catch {
            Write-LogMessage -Type WARNING -Message "Could not retrieve cluster info from SDDC Manager: $($_.Exception.Message)"
        }

        $inventory["SDDC Manager"] = @([PSCustomObject]@{
            Fqdn       = $sddcFqdn
            Version    = $sddcVersion
            DomainName = $sddcDomainName
        })
        Write-LogMessage -Type INFO  -Message "Collected SDDC Manager: $sddcFqdn (domain: $sddcDomainName)"
        Write-LogMessage -Type DEBUG -Message "SDDC Manager version: $sddcVersion"

        $vcResponse = Invoke-VcfGetVcenters -PageNumber 0 -PageSize 100 -ErrorAction Stop
        $vcElements = @($vcResponse.Elements)
        if ($vcElements.Count -gt 0) {
            $inventory["vCenter"] = @($vcElements | ForEach-Object {
                $vcFqdn  = [String]$_.Fqdn
                $rawVcVer = if (-not [String]::IsNullOrWhiteSpace($_.Version)) { [String]$_.Version } else { "Unknown" }
                $vcVer   = $rawVcVer
                $vcDomain = if ($vcenterFqdnToDomainName.ContainsKey($vcFqdn.ToLower())) { $vcenterFqdnToDomainName[$vcFqdn.ToLower()] } else { "" }
                $vcEntry  = [PSCustomObject]@{ Fqdn = $vcFqdn; Version = $vcVer; DomainName = $vcDomain }

                # VCF 8: SDDC Manager reports "8.0.3.00100-24091160" but advisories use the MOB
                # build number as the 4th dotted segment (e.g. "8.0.3.24853646"). Extract the build
                # from the dash suffix and set BuildVersion so advisory comparison uses it.
                if ($rawVcVer -match '^(\d+\.\d+\.\d+)\.\d+-(\d{6,})$') {
                    $vcEntry | Add-Member -NotePropertyName 'BuildVersion' -NotePropertyValue "$($Matches[1]).$($Matches[2])"
                } elseif ($VcenterBuildMaps.VersionToBuild.ContainsKey($rawVcVer)) {
                    # Fallback for 4-part VCF 8 versions without a dash suffix: map update level → MOB.
                    $mob    = $VcenterBuildMaps.VersionToBuild[$rawVcVer]
                    $parts  = $rawVcVer.Split('.')
                    $prefix = ($parts[0..[Math]::Min(2, $parts.Count - 1)] -join '.')
                    $vcEntry | Add-Member -NotePropertyName 'BuildVersion' -NotePropertyValue "$prefix.$mob"
                }
                $vcEntry
            })
            Write-LogMessage -Type INFO -Message "Collected $($inventory['vCenter'].Count) vCenter(s): $(($inventory['vCenter'] | ForEach-Object { $_.Fqdn }) -join ', ')"
        }

        $nsxResponse = Invoke-VcfGetNsxClusters -PageNumber 0 -PageSize 100 -ErrorAction Stop
        $nsxElements = @($nsxResponse.Elements)
        if ($nsxElements.Count -gt 0) {
            $nsxEdgeList = [System.Collections.Generic.List[PSCustomObject]]::new()
            $inventory["NSX"] = @($nsxElements | ForEach-Object {
                $nsxVipFqdn = if (-not [String]::IsNullOrWhiteSpace($_.VipFqdn)) { [String]$_.VipFqdn } else { [String]$_.Vip }
                $nsxDomains = @($_.Domains)
                $nsxDomainName = if ($nsxDomains.Count -gt 0 -and -not [String]::IsNullOrWhiteSpace($nsxDomains[0].Name)) {
                    [String]$nsxDomains[0].Name
                } else { "" }

                # Collect NSX Edge nodes registered with this NSX Manager.
                # -Password: supplies the admin credential retrieved from SDDC Manager above;
                # skipped silently when the password could not be retrieved.
                $edgeNodes = Get-NsxEdgeInventory -NsxManagerFqdn $nsxVipFqdn -DomainName $nsxDomainName -Password $nsxAdminPassword -TimeoutSeconds $TimeoutSeconds
                foreach ($edgeNode in $edgeNodes) { $nsxEdgeList.Add($edgeNode) }

                [PSCustomObject]@{
                    Fqdn       = $nsxVipFqdn
                    Version    = if (-not [String]::IsNullOrWhiteSpace($_.Version)) { [String]$_.Version } else { "Unknown" }
                    DomainName = $nsxDomainName
                }
            })
            Write-LogMessage -Type INFO -Message "Collected $($inventory['NSX'].Count) NSX cluster(s): $(($inventory['NSX'] | ForEach-Object { $_.Fqdn }) -join ', ')"

            if ($nsxEdgeList.Count -gt 0) {
                $inventory["NSX Edge"] = $nsxEdgeList.ToArray()
                Write-LogMessage -Type INFO -Message "Collected $($nsxEdgeList.Count) NSX Edge node(s): $(($nsxEdgeList | ForEach-Object { $_.Fqdn }) -join ', ')"
            }
        }

        $hostList = [System.Collections.Generic.List[PSCustomObject]]::new()
        $hostPage = 0
        $hostTotalPages = 1
        do {
            $hostResponse = Invoke-VcfGetHosts -PageNumber $hostPage -PageSize 100 -ErrorAction Stop
            if ($null -eq $hostResponse -or $null -eq $hostResponse.Elements) { break }
            foreach ($hostObj in @($hostResponse.Elements)) {
                if ($null -eq $hostObj) { continue }
                $esxVer = [String]$hostObj.EsxiVersion
                # VCF 9.x image-based hosts may have a null EsxiVersion field; fall back to
                # SoftwareInfo.BaseImage.Version which is populated for lifecycle-image deployments.
                if ([String]::IsNullOrWhiteSpace($esxVer) -and $null -ne $hostObj.SoftwareInfo -and $null -ne $hostObj.SoftwareInfo.BaseImage) {
                    $esxVer = [String]$hostObj.SoftwareInfo.BaseImage.Version
                }
                # Prefer the inline domain name; fall back to the ID-keyed map when the
                # DomainReference carries only an ID (common in VCF 9.x image-based deployments).
                $hostDomainName = if (-not [String]::IsNullOrWhiteSpace($hostObj.Domain.Name)) {
                    [String]$hostObj.Domain.Name
                } elseif ($null -ne $hostObj.Domain -and -not [String]::IsNullOrWhiteSpace($hostObj.Domain.Id) -and $domainIdToName.ContainsKey([String]$hostObj.Domain.Id)) {
                    $domainIdToName[[String]$hostObj.Domain.Id]
                } else { "" }
                # Prefer the inline cluster name; fall back to the ID-keyed map when the
                # Cluster reference carries only an ID (common in VCF 9.x deployments).
                $hostClusterName = if ($null -ne $hostObj.Cluster -and -not [String]::IsNullOrWhiteSpace($hostObj.Cluster.Name)) {
                    [String]$hostObj.Cluster.Name
                } elseif ($null -ne $hostObj.Cluster -and -not [String]::IsNullOrWhiteSpace($hostObj.Cluster.Id) -and $clusterIdToName.ContainsKey([String]$hostObj.Cluster.Id)) {
                    $clusterIdToName[[String]$hostObj.Cluster.Id]
                } else { "" }
                $hostList.Add([PSCustomObject]@{
                    Fqdn        = [String]$hostObj.Fqdn
                    Version     = if ([String]::IsNullOrWhiteSpace($esxVer)) { "Unknown" } else { $esxVer.Trim() }
                    DomainName  = $hostDomainName
                    ClusterName = $hostClusterName
                })
            }
            if ($null -ne $hostResponse.PageMetadata -and $null -ne $hostResponse.PageMetadata.TotalPages) {
                $hostTotalPages = [Int]$hostResponse.PageMetadata.TotalPages
            }
            $hostPage++
        } while ($hostPage -lt $hostTotalPages)

        if ($hostList.Count -gt 0) {
            $inventory["ESXi"] = @($hostList.ToArray())
            Write-LogMessage -Type INFO -Message "Collected $($inventory['ESXi'].Count) ESXi host(s)"
        }

        Write-LogMessage -Type INFO -Message "SDDC Manager inventory collection complete: $($inventory.Count) component type(s)"
    }
    catch {
        Write-LogMessage -Type WARNING -Message "SDDC Manager inventory collection failed: $(Resolve-HtmlAwareErrorMessage -ExceptionMessage $_.Exception.Message -Server $Server -Context 'SDDC Manager')"
    }
    finally {
        if ($null -ne $conn) { Disconnect-VcfSddcManagerServer -Server $conn -Force -ErrorAction SilentlyContinue | Out-Null }
        if ($null -ne $securePassword) { $securePassword.Dispose() }
    }

    return $inventory
}
function ConvertTo-VcfOpsAuthParts {

    <#
        .SYNOPSIS
        Split a VCF Operations username into bare username and auth source.

        .DESCRIPTION
        Splits a username in user@authsource format into its two components. If no '@' is
        present the auth source defaults to 'Local', which is the VCF Operations default.

        .PARAMETER User
        VCF Operations username, e.g. admin@local or admin.

        .EXAMPLE
        $parts = ConvertTo-VcfOpsAuthParts -User "admin@local"
        Connect-VcfOpsServer -User $parts.BareUser -AuthSource $parts.AuthSource ...

        .OUTPUTS
        [PSCustomObject] Object with BareUser and AuthSource string properties.

        .NOTES
        Pure utility function. Does not mutate any module-scope variables.
    #>


    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$User
    )

    if ($User -match "@(.+)$") {
        # Normalize "local" → "Local": Connect-VcfOpsServer is case-sensitive for this token.
        $rawSource = $Matches[1]
        $authSource = if ($rawSource -ieq "local") { "Local" } else { $rawSource }
        return [PSCustomObject]@{
            BareUser   = $User.Substring(0, $User.LastIndexOf('@'))
            AuthSource = $authSource
        }
    }
    return [PSCustomObject]@{ BareUser = $User; AuthSource = "Local" }
}
function Get-VspBearerToken {

    <#
        .SYNOPSIS
        Acquire a VSP bearer token from POST /api/v1/identity/token.

        .DESCRIPTION
        Submits a password-grant form to the VSP Fleet Controller identity endpoint and
        extracts the access token from the response. Returns an empty string if the
        request fails or the response contains no recognisable token property.

        .PARAMETER Server
        VSP Fleet Controller FQDN or IP.

        .PARAMETER User
        Username (e.g. admin@vsp.local).

        .PARAMETER Password
        Plain-text password.

        .PARAMETER TimeoutSeconds
        Request timeout in seconds (1-300, default 30).

        .EXAMPLE
        $token = Get-VspBearerToken -Server "flt-fc01.sfo.example.com" -User "admin@vsp.local" -Password $pw
        if (-not [String]::IsNullOrWhiteSpace($token)) { ... }

        .OUTPUTS
        [String] Bearer token or empty string if unavailable.

        .NOTES
        Returns an empty string on failure rather than throwing. Callers must check for empty/whitespace before using the token.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Server,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$User,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Password,
        [Parameter(Mandatory = $false)] [ValidateRange(1, 300)] [Int]$TimeoutSeconds = 30
    )

    try {
        $tokenBody = "grant_type=password&username=$([System.Uri]::EscapeDataString($User))&password=$([System.Uri]::EscapeDataString($Password))"
        $tokenResponse = Invoke-RestMethod -Uri "https://$Server/api/v1/identity/token" `
            -Method POST -Body $tokenBody -ContentType "application/x-www-form-urlencoded" `
            -SkipCertificateCheck -TimeoutSec $TimeoutSeconds -ErrorAction Stop

        foreach ($prop in @('access_token', 'AccessToken', 'token')) {
            $candidate = $tokenResponse.$prop
            if (-not [String]::IsNullOrWhiteSpace($candidate)) {
                return [String]$candidate
            }
        }
    }
    catch {
        Write-LogMessage -Type DEBUG -Message "VSP bearer token request failed for $Server — $($_.Exception.Message)"
    }

    return ""
}
function Get-VcfOpsRestToken {

    <#
        .SYNOPSIS
        Acquire a vRealizeOpsToken from the VCF Operations REST API.

        .DESCRIPTION
        Submits credentials to POST /suite-api/api/auth/token/acquire and returns the token
        string. Used as a prerequisite for calling VCF Operations internal REST endpoints that
        require vRealizeOpsToken authentication (distinct from the VSP bearer token used by
        Fleet Manager).

        Returns an empty string when authentication fails rather than throwing, so callers
        can distinguish an authentication failure from a connectivity failure.

        .PARAMETER Password
        Plain-text password.

        .PARAMETER Server
        VCF Operations FQDN or IP address.

        .PARAMETER TimeoutSeconds
        Request timeout in seconds (1-300, default 30).

        .PARAMETER User
        Username, e.g. admin@local.

        .EXAMPLE
        $token = Get-VcfOpsRestToken -Server "ops.example.com" -User "admin@local" -Password $plainTextPassword
        if (-not [String]::IsNullOrWhiteSpace($token)) { ... }

        .OUTPUTS
        [String] vRealizeOpsToken or empty string if authentication fails.

        .NOTES
        Returns an empty string on failure rather than throwing. Callers must check for empty/whitespace before using the token.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Password,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Server,
        [Parameter(Mandatory = $false)] [ValidateRange(1, 300)] [Int]$TimeoutSeconds = 30,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$User
    )

    try {
        $authParts = ConvertTo-VcfOpsAuthParts -User $User
        $body = [PSCustomObject]@{
            authSource = $authParts.AuthSource
            password   = $Password
            username   = $authParts.BareUser
        } | ConvertTo-Json -Depth 2 -Compress

        $response = Invoke-RestMethod -Uri "https://$Server/suite-api/api/auth/token/acquire" `
            -Method POST -Body $body -ContentType "application/json" `
            -Headers @{ "Accept" = "application/json" } `
            -SkipCertificateCheck -TimeoutSec $TimeoutSeconds -ErrorAction Stop

        if (-not [String]::IsNullOrWhiteSpace($response.token)) {
            return [String]$response.token
        }
    }
    catch {
        Write-LogMessage -Type DEBUG -Message "VCF Ops REST token request failed for $Server — $($_.Exception.Message)"
    }

    return ""
}
function Get-VcfOpsInventory {

    <#
        .SYNOPSIS
        Collect VCF Operations inventory.

        .DESCRIPTION
        Connects to VCF Operations via VCF PowerCLI (Connect-VcfOpsServer) and retrieves
        the appliance version using Invoke-VcfOpsGetCurrentVersionOfServer.

        .PARAMETER Server
        VCF Operations FQDN or IP address.

        .PARAMETER User
        Username for VCF Operations authentication (format: user@AuthSource).

        .PARAMETER TimeoutSeconds
        Connection timeout in seconds (1-300, default 30).

        .EXAMPLE
        $inventory = Get-VcfOpsInventory -Server "ops.example.com" -User "admin@local"

        .OUTPUTS
        [Hashtable] Inventory keyed by component name: @{ "VCF Operations" = @(...) }

        .NOTES
        Reads VCF_OPS_PASSWORD from the environment via Get-InventoryPassword. Returns an empty hashtable on connectivity or authentication failure (logs WARNING).
    #>


    [CmdletBinding()]
    [OutputType([Hashtable])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Server,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$User,
        [Parameter(Mandatory = $false)] [ValidateRange(1, 300)] [Int]$TimeoutSeconds = 30
    )

    Write-LogMessage -Type INFO -Message "Collecting VCF Operations inventory from: $Server..."

    $inventory = @{}
    $conn = $null

    try {
        $password = Get-InventoryPassword -ComponentName "VCF Operations" -EnvVarName "VCF_OPS_PASSWORD"
        if ($null -eq $password) { return $inventory }

        $authParts = ConvertTo-VcfOpsAuthParts -User $User
        Write-LogMessage -Type INFO -Message "Connecting to VCF Operations `"$Server`"..."
        # -IgnoreInvalidCertificate: VCF Operations uses a self-signed certificate; without
        # this flag the cmdlet rejects the cert and the server responds with an HTML error page.
        $conn = Connect-VcfOpsServer -Server $Server -User $authParts.BareUser -Password $password -AuthSource $authParts.AuthSource -IgnoreInvalidCertificate -ErrorAction Stop

        $verDoc = Invoke-VcfOpsGetCurrentVersionOfServer -ErrorAction Stop
        $version = "Unknown"
        if ($null -ne $verDoc) {
            foreach ($prop in @('Version', 'version', 'ReleaseName', 'releaseName')) {
                $candidate = $verDoc.$prop
                if (-not [String]::IsNullOrWhiteSpace($candidate)) {
                    $candidateStr = $candidate.Trim()
                    # Extract bare version number — the property may include a product name prefix
                    # (e.g. "VCF Operations 9.1.0.0"); capture the first dotted-decimal sequence.
                    if ($candidateStr -match '\b(\d+(?:\.\d+)+)\b') {
                        $version = $Matches[1]
                    } else {
                        $version = $candidateStr
                    }
                    break
                }
            }
        }

        $inventory["VCF Operations"] = @([PSCustomObject]@{
            Fqdn       = $Server
            Version    = $version
            DomainName = "VCF Fleet"
        })
        Write-LogMessage -Type INFO  -Message "Collected VCF Operations: $Server"
        Write-LogMessage -Type DEBUG -Message "VCF Operations version: $version"

        $standaloneVcFqdns = [System.Collections.Generic.List[String]]::new()
        try {
            $vmwareAdapters = Invoke-VcfOpsEnumerateAdapterInstances -AdapterKindKey "VMWARE" -ErrorAction Stop
            foreach ($dto in @($vmwareAdapters.AdapterInstancesInfoDto)) {
                if ($null -eq $dto) { continue }
                $vcUrl = ($dto.ResourceKey.ResourceIdentifiers |
                    Where-Object { $_.IdentifierType.Name -ieq "VCURL" }).Value
                if ([String]::IsNullOrWhiteSpace($vcUrl)) {
                    $adapterName = [String]$dto.ResourceKey.Name
                    if ($adapterName -match '(?i)\bfor\s+(\S+)\s*$') { $vcUrl = $Matches[1] }
                }
                if ([String]::IsNullOrWhiteSpace($vcUrl)) { continue }
                $standaloneVcFqdns.Add([String]$vcUrl.Trim())
            }
            # Logged at DEBUG so the caller (EntryPoint.ps1) can emit an appropriate INFO
            # message only for the environment types that actually scan these endpoints.
            if ($standaloneVcFqdns.Count -gt 0) {
                Write-LogMessage -Type DEBUG -Message "Discovered $($standaloneVcFqdns.Count) standalone vCenter(s) from VCF Operations: $(($standaloneVcFqdns | Sort-Object) -join ', ')"
            } else {
                Write-LogMessage -Type DEBUG -Message "No standalone vCenters registered with VCF Operations."
            }
        }
        catch {
            Write-LogMessage -Type DEBUG -Message "Standalone vCenter enumeration failed: $($_.Exception.Message)"
        }
        $inventory['_StandaloneVcenterFqdns'] = $standaloneVcFqdns.ToArray()
    }
    catch {
        Write-LogMessage -Type WARNING -Message "VCF Operations inventory collection failed: $(Resolve-HtmlAwareErrorMessage -ExceptionMessage $_.Exception.Message -Server $Server -Context 'VCF Operations')"
    }
    finally {
        if ($null -ne $conn) { Disconnect-VcfOpsServer -Server $conn -Force -ErrorAction SilentlyContinue | Out-Null }
    }

    return $inventory
}
function Get-VcenterInventory {

    <#
        .SYNOPSIS
        Collect ESXi and vCenter inventory from vCenter Server.

        .DESCRIPTION
        Queries vCenter API to retrieve ESXi host versions and vCenter version.
        Returns inventory in scannable format.

        .PARAMETER Server
        vCenter FQDN or IP address.

        .PARAMETER User
        Username for vCenter authentication.

        .PARAMETER TimeoutSeconds
        Timeout for API calls (1-300, default 30).

        .PARAMETER VcenterBuildMaps
        Optional lookup table from Get-VcenterBuildMap. When provided:
          - Forward map resolves advisory-compatible version to MOB build number
            (e.g. "8.0.3.00900" → "25413364") to construct BuildVersion.
          - Reverse map resolves MOB build number to full advisory-compatible version
            when $connection.Version returns only a 3-part string (e.g. "8.0.3").

        .EXAMPLE
        $inventory = Get-VcenterInventory -Server "vcenter.example.com" -User "administrator@vsphere.local"

        .OUTPUTS
        [Hashtable] Inventory keyed by component: @{ "ESXi" = @(...), "vCenter" = @(...) }

        .NOTES
        Reads VCENTER_PASSWORD from the environment via Get-InventoryPassword. Disconnects VIServer in a finally block to prevent session leaks.
    #>


    [CmdletBinding()]
    [OutputType([Hashtable])]
    Param (
        [Parameter(Mandatory = $true)]  [ValidateNotNullOrEmpty()] [String]$Server,
        [Parameter(Mandatory = $false)] [ValidateRange(1, 300)]    [Int]$TimeoutSeconds = 30,
        [Parameter(Mandatory = $true)]  [ValidateNotNullOrEmpty()] [String]$User,
        [Parameter(Mandatory = $false)] [ValidateNotNull()]        [Hashtable]$VcenterBuildMaps = @{ VersionToBuild = @{}; BuildToVersion = @{} }
    )

    Write-LogMessage -Type INFO -Message "Collecting vCenter inventory from: $Server..."

    $inventory = @{}
    $connection = $null

    try {
        $password = Get-InventoryPassword -ComponentName "vCenter" -EnvVarName "VCENTER_PASSWORD"
        if ($null -eq $password) { return $inventory }

        $connection = Connect-VIServer -Server $Server -User $User -Password $password `
            -Force -ErrorAction Stop

        $esxiHosts = @(Get-VMHost -Server $connection -ErrorAction Stop |
            Where-Object { $_.ConnectionState -eq 'Connected' })

        # Build a hostname → cluster-name map from all clusters in vCenter. This is done
        # in a single pass so the per-host lookup is O(1) rather than one API call each.
        $clusterMap = @{}
        foreach ($cluster in @(Get-Cluster -Server $connection -ErrorAction SilentlyContinue)) {
            $cName = [String]$cluster.Name
            foreach ($h in @(Get-VMHost -Location $cluster -Server $connection -ErrorAction SilentlyContinue)) {
                $clusterMap[[String]$h.Name] = $cName
            }
        }

        if ($esxiHosts.Count -gt 0) {
            $inventory["ESXi"] = @($esxiHosts | ForEach-Object {
                $hostName   = [String]$_.Name
                $rawHostVersion = [String]$_.Version
                $hostVersion    = $rawHostVersion
                $hostBuild      = if ($_.Build -and [String]$_.Build -match '^\d{5,}$') { [String]$_.Build } else { $null }
                # Use the major.minor.patch prefix from the version string regardless of how
                # many parts it has (e.g. "8.0.3" → "8.0.3", "8.0.3.0" → "8.0.3").
                $vParts   = $rawHostVersion.Split('.')
                $prefix   = ($vParts[0..[Math]::Min(2, $vParts.Count - 1)] -join '.')
                $entry = [PSCustomObject]@{
                    Fqdn        = $hostName
                    Version     = $hostVersion
                    DomainName  = ""
                    ClusterName = if ($clusterMap.ContainsKey($hostName)) { $clusterMap[$hostName] } else { "" }
                }
                if ($null -ne $hostBuild) {
                    $entry | Add-Member -NotePropertyName 'BuildVersion' -NotePropertyValue "$prefix.$hostBuild"
                }
                $entry
            })
            Write-LogMessage -Type INFO -Message "Collected $($esxiHosts.Count) ESXi hosts from vCenter"
        }

        $vcenterVersion = if ($connection.Version) { [String]$connection.Version } else { "Unknown" }
        # $connection.Build is the raw MOB/vpxd.log build number (e.g. "25413364"). It is present
        # on the VIServer object in VCF PowerCLI 9 and used as a fallback when the version string
        # is 3-part (e.g. "8.0.3") so we can still construct BuildVersion and resolve the full
        # advisory-compatible 4-part version via the reverse lookup.
        $rawBuild = if ($connection.Build -and [String]$connection.Build -match '^\d{6,}$') { [String]$connection.Build } else { $null }

        # Forward lookup: version → MOB (works when $connection.Version is 4-part, e.g. "8.0.3.00900")
        if ($VcenterBuildMaps.VersionToBuild.ContainsKey($vcenterVersion)) {
            $mob    = $VcenterBuildMaps.VersionToBuild[$vcenterVersion]
            $parts  = $vcenterVersion.Split('.')
            $prefix = ($parts[0..[Math]::Min(2, $parts.Count - 1)] -join '.')
            $vcEntry = [PSCustomObject]@{ Fqdn = $Server; Version = $vcenterVersion; DomainName = "" }
            $vcEntry | Add-Member -NotePropertyName 'BuildVersion' -NotePropertyValue "$prefix.$mob"
            $inventory["vCenter"] = @($vcEntry)
        } elseif ($null -ne $rawBuild) {
            # Fallback when $connection.Version is 3-part: use raw build number directly and
            # try to resolve the full advisory-compatible version via the reverse map.
            $parts     = $vcenterVersion.Split('.')
            $prefix    = ($parts[0..[Math]::Min(2, $parts.Count - 1)] -join '.')
            $buildVer  = "$prefix.$rawBuild"
            $fullVer   = if ($VcenterBuildMaps.BuildToVersion.ContainsKey($rawBuild)) { $VcenterBuildMaps.BuildToVersion[$rawBuild] } else { $vcenterVersion }
            $vcEntry   = [PSCustomObject]@{ Fqdn = $Server; Version = $fullVer; DomainName = "" }
            $vcEntry | Add-Member -NotePropertyName 'BuildVersion' -NotePropertyValue $buildVer
            $inventory["vCenter"] = @($vcEntry)
        } else {
            $inventory["vCenter"] = @([PSCustomObject]@{ Fqdn = $Server; Version = $vcenterVersion; DomainName = "" })
        }
        Write-LogMessage -Type INFO -Message "Collected vCenter: $Server v$vcenterVersion"
    }
    catch {
        Write-LogMessage -Type WARNING -Message "vCenter inventory collection failed: $(Resolve-HtmlAwareErrorMessage -ExceptionMessage $_.Exception.Message -Server $Server -Context 'vCenter')"
    }
    finally {
        if ($null -ne $connection) {
            Disconnect-VIServer -Server $connection -Confirm:$false -ErrorAction SilentlyContinue
        }
    }

    return $inventory
}
function Get-FleetManagerInventory {

    <#
        .SYNOPSIS
        Collect Fleet Lifecycle Manager inventory.

        .DESCRIPTION
        Supports both VCF 9.1.x (VSP Fleet LCM) and VCF 9.0.x (Fleet Manager) endpoints.

        VCF 9.1.x path: acquires a bearer token from POST /api/v1/identity/token (form-encoded
        grant_type=password), then fetches GET /fleet-lcm/v1/components to read the installed
        version of the Fleet Lifecycle component.

        VCF 9.0.x fallback: uses Basic auth (base64(user:password)) and calls
        GET /lcm/lcops/api/v2/settings/system-details to read the appliance version.

        When AllowVspUserFallback is set and the initial VSP attempt fails with the provided User,
        a second VSP attempt is made with admin@vsp.local. Use this for VVF 9 environments where
        the wizard defaults to admin@local but the server may be VCF 9.1+.

        .PARAMETER Server
        Fleet Controller (VCF 9.1.x) or Fleet Manager (VCF 9.0.x) FQDN or IP address.

        .PARAMETER User
        Username for authentication. VCF 9.1.x expects admin@vsp.local (VSP bearer token auth);
        VCF 9.0.x expects admin@local (lcops Basic auth). Both are tried automatically.

        .PARAMETER TimeoutSeconds
        Request timeout in seconds (1-300, default 30).

        .EXAMPLE
        $inventory = Get-FleetManagerInventory -Server "flt-fc01.example.com" -User "admin@vsp.local"

        .OUTPUTS
        [Hashtable] Inventory keyed by component: @{ "Fleet Lifecycle" = @(...) }

        .NOTES
        Reads VCF_FM_PASSWORD from the environment via Get-InventoryPassword. Sets _FleetApiPath sentinel key in the returned hashtable so EntryPoint can determine the VCF minor version.
    #>


    [CmdletBinding()]
    [OutputType([Hashtable])]
    Param (
        [Parameter(Mandatory = $false)] [Switch]$AllowVspUserFallback,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Server,
        [Parameter(Mandatory = $false)] [ValidateRange(1, 300)] [Int]$TimeoutSeconds = 30,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$User
    )

    Write-LogMessage -Type INFO -Message "Connecting to Fleet Lifecycle Manager `"$Server`"..."

    $inventory = @{}

    $password = Get-InventoryPassword -ComponentName "Fleet Manager" -EnvVarName "VCF_FM_PASSWORD"
    if ($null -eq $password) { return $inventory }

    $inventory = Get-VspFleetLcmInventory -Server $Server -User $User -Password $password -TimeoutSeconds $TimeoutSeconds
    if ($inventory.Count -gt 0) {
        # Sentinel consumed by EntryPoint to determine VCF minor version (9.1) and skip the
        # native VCF Operations API call, which is redundant when Fleet is authoritative.
        $inventory['_FleetApiPath'] = 'vsp'
        return $inventory
    }

    if ($AllowVspUserFallback -and $User -ine 'admin@vsp.local') {
        $inventory = Get-VspFleetLcmInventory -Server $Server -User 'admin@vsp.local' -Password $password -TimeoutSeconds $TimeoutSeconds
        if ($inventory.Count -gt 0) {
            $inventory['_FleetApiPath'] = 'vsp'
            return $inventory
        }
    }

    $inventory = Get-LcopsFleetManagerInventory -Server $Server -User $User -Password $password -TimeoutSeconds $TimeoutSeconds
    if ($inventory.Count -eq 0) {
        Write-LogMessage -Type WARNING -Message "Fleet Manager inventory unavailable on $Server — neither Fleet Lifecycle (VCF 9.1) nor Fleet Management (VCF 9.0) path responded. Check credentials and server reachability."
    } else {
        $inventory['_FleetApiPath'] = 'lcops'
    }
    return $inventory
}
function Get-VspFleetLcmInventory {

    <#
        .SYNOPSIS
        Collect Fleet Lifecycle Manager inventory from the VSP fleet-lcm API (VCF 9.1.x).

        .DESCRIPTION
        Acquires a bearer token from POST /api/v1/identity/token, then fetches:
          - GET /fleet-lcm/v1/system — reads the Fleet Controller's own currentVersion.
          - GET /fleet-lcm/v1/components (paginated) — reads all managed fleet components
            (e.g. VCF Automation, Identity Broker, Salt Master) and their versions.

        Component types already collected from native APIs (VCF Operations via
        Connect-VcfOpsServer; Fleet Lifecycle itself from /v1/system) are
        excluded to avoid duplicate inventory entries. All other component types are
        mapped to advisory names via VSP_FLEET_LCM_COMPONENT_TYPE_TO_ADVISORY_NAME
        and added with DomainName = "VCF Fleet".

        .PARAMETER Server
        VSP Fleet Controller FQDN or IP.

        .PARAMETER User
        Username (e.g. admin@vsp.local).

        .PARAMETER Password
        Plain-text password.

        .PARAMETER TimeoutSeconds
        Request timeout (1-300, default 30).

        .EXAMPLE
        $inv = Get-VspFleetLcmInventory -Server "flt-fc01.sfo.rainpole.io" -User "admin@vsp.local" -Password $plainTextPw

        .OUTPUTS
        [Hashtable] Inventory or empty hashtable if not applicable.

        .NOTES
        Returns an empty hashtable when the VSP bearer token cannot be acquired, allowing the caller to fall back to the lcops path silently.
    #>


    [CmdletBinding()]
    [OutputType([Hashtable])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Server,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$User,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Password,
        [Parameter(Mandatory = $false)] [ValidateRange(1, 300)] [Int]$TimeoutSeconds = 30
    )

    $inventory = @{}

    try {
        $bearerToken = Get-VspBearerToken -Server $Server -User $User -Password $Password -TimeoutSeconds $TimeoutSeconds

        if ([String]::IsNullOrWhiteSpace($bearerToken)) {
            return $inventory
        }

        Write-LogMessage -Type INFO -Message "Acquired VSP bearer token from: $Server"

        $headers = @{ "Authorization" = "Bearer $bearerToken"; "Accept" = "application/json" }
        $systemResponse = Invoke-RestMethod -Uri "https://$Server/fleet-lcm/v1/system" `
            -Method GET -Headers $headers -SkipCertificateCheck -TimeoutSec $TimeoutSeconds -ErrorAction Stop

        $version = ""
        foreach ($prop in @('currentVersion', 'version', 'Version')) {
            $candidate = $systemResponse.$prop
            if (-not [String]::IsNullOrWhiteSpace($candidate)) {
                $version = ([String]$candidate).Trim()
                break
            }
        }

        Write-LogMessage -Type DEBUG -Message "Fleet-lcm /system currentVersion=$version from $Server"

        if ([String]::IsNullOrWhiteSpace($version)) {
            $rawJson = $systemResponse | ConvertTo-Json -Depth 2 -Compress -ErrorAction SilentlyContinue
            Write-LogMessage -Type DEBUG -Message "Fleet-lcm /system raw response: $rawJson"
        }

        $fmVersion = if ([String]::IsNullOrWhiteSpace($version)) { "Unknown" } else { $version }
        $inventory['Fleet Lifecycle'] = @([PSCustomObject]@{
            Fqdn       = $Server
            Version    = $fmVersion
            DomainName = "VCF Fleet"
        })
        Write-LogMessage -Type INFO  -Message "Collected Fleet Lifecycle: $Server"
        Write-LogMessage -Type DEBUG -Message "Fleet Lifecycle version: $fmVersion (VSP path)"

        $componentLists      = @{}
        $pageNumber          = 0
        $totalPages          = 1
        $opsFqdnFromFleet    = $null
        $opsVersionFromFleet = $null

        while ($pageNumber -lt $totalPages -and $pageNumber -lt $Script:VSP_FLEET_LCM_INVENTORY_MAX_PAGES) {
            $compUri = "https://$Server/fleet-lcm/v1/components?pageNumber=$pageNumber&pageSize=$Script:VSP_FLEET_LCM_INVENTORY_PAGE_SIZE"
            $compResponse = Invoke-RestMethod -Uri $compUri `
                -Method GET -Headers $headers -SkipCertificateCheck -TimeoutSec $TimeoutSeconds -ErrorAction Stop

            if ($null -ne $compResponse.pageMetadata -and $null -ne $compResponse.pageMetadata.totalPages) {
                $totalPages = [Int]$compResponse.pageMetadata.totalPages
            }

            foreach ($comp in $compResponse.components) {
                $typeKey = ([String]$comp.componentType).ToLower().Trim()
                if ($typeKey -eq 'vcf_fleet_lcm') { continue }
                if ($typeKey -eq 'ops') {
                    # VCF Operations is collected via Get-VcfOpsInventory (native API), but
                    # that API returns only the base version (e.g. "9.1.0.0"). Fleet LCM
                    # carries the full build number. Capture it here; the EntryPoint will
                    # patch the native version after both inventories have been merged.
                    $fqdn = [String]$comp.fqdn
                    if ([String]::IsNullOrWhiteSpace($fqdn) -and $null -ne $comp.nodes -and $comp.nodes.Count -gt 0) {
                        $fqdn = [String]$comp.nodes[0].fqdn
                    }
                    $ver = if (-not [String]::IsNullOrWhiteSpace($comp.version)) { [String]$comp.version } else { "" }
                    if (-not [String]::IsNullOrWhiteSpace($ver)) {
                        $opsFqdnFromFleet    = $fqdn
                        $opsVersionFromFleet = $ver
                        Write-LogMessage -Type DEBUG -Message "Fleet LCM ops component: fqdn=$fqdn version=$ver"
                    }
                    continue
                }

                $advisoryName = $Script:VSP_FLEET_LCM_COMPONENT_TYPE_TO_ADVISORY_NAME[$typeKey]
                if ([String]::IsNullOrWhiteSpace($advisoryName)) {
                    Write-LogMessage -Type DEBUG -Message "Fleet component type '$typeKey' has no advisory mapping — skipping"
                    continue
                }

                $compFqdn = [String]$comp.fqdn
                if ([String]::IsNullOrWhiteSpace($compFqdn) -and $null -ne $comp.nodes -and $comp.nodes.Count -gt 0) {
                    $compFqdn = [String]$comp.nodes[0].fqdn
                }
                if ([String]::IsNullOrWhiteSpace($compFqdn)) {
                    Write-LogMessage -Type DEBUG -Message "Fleet component '$advisoryName' (type: $typeKey) has no FQDN — skipping"
                    continue
                }

                $compVersion = if (-not [String]::IsNullOrWhiteSpace($comp.version)) { [String]$comp.version } else { "Unknown" }

                if (-not $componentLists.ContainsKey($advisoryName)) {
                    $componentLists[$advisoryName] = [System.Collections.Generic.List[PSCustomObject]]::new()
                }
                $componentLists[$advisoryName].Add([PSCustomObject]@{
                    Fqdn       = $compFqdn
                    Version    = $compVersion
                    DomainName = "VCF Fleet"
                })
                Write-LogMessage -Type DEBUG -Message "Fleet LCM component: $advisoryName at $compFqdn version=$compVersion"
            }

            $pageNumber++
        }

        foreach ($key in @($componentLists.Keys)) {
            $inventory[$key] = $componentLists[$key].ToArray()
        }

        # Expose the VCF Operations full build number as a sentinel key so the caller
        # (EntryPoint) can patch the native-API version after the inventory is merged.
        if (-not [String]::IsNullOrWhiteSpace($opsVersionFromFleet)) {
            $inventory['_OpsVersionFromFleet'] = [PSCustomObject]@{
                Fqdn    = $opsFqdnFromFleet
                Version = $opsVersionFromFleet
            }
        }
    }
    catch {
        Write-LogMessage -Type DEBUG -Message "VSP fleet-lcm path not available on $Server — $($_.Exception.Message)"
    }

    return $inventory
}
function Resolve-ProductNodeFqdn {

    <#
        .SYNOPSIS
        Resolve an FQDN from a Fleet Manager or vRSLCM product node properties bag.

        .DESCRIPTION
        Iterates the supplied node collection and probes each node's properties for the
        keys in $Script:FQDN_PROBE_KEYS, returning the first non-whitespace value found.
        Returns an empty string when no FQDN is resolvable.

        Call twice for products that have a clusterVIP fallback — once with the node
        collection, once with the clusterVIP.clusterVips collection if the first call
        returns empty.

        .PARAMETER Nodes
        Product node collection from the Fleet Manager or vRSLCM environments API.
        Accepts an empty collection (returns empty string).

        .EXAMPLE
        $fqdn = Resolve-ProductNodeFqdn -Nodes @($product.nodes)
        if ([String]::IsNullOrWhiteSpace($fqdn) -and $null -ne $product.clusterVIP) {
            $fqdn = Resolve-ProductNodeFqdn -Nodes @($product.clusterVIP.clusterVips)
        }

        .OUTPUTS
        [String] Resolved FQDN or empty string when not resolvable.

        .NOTES
        Pure utility function. Does not mutate any module-scope variables. Returns an empty string (not null) for safe string checks.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [ValidateNotNull()] [Object[]]$Nodes
    )

    foreach ($node in $Nodes) {
        foreach ($key in $Script:FQDN_PROBE_KEYS) {
            $candidate = $node.properties.$key
            if (-not [String]::IsNullOrWhiteSpace($candidate)) { return [String]$candidate }
        }
    }
    return ""
}
function Get-LcopsFleetManagerInventory {

    <#
        .SYNOPSIS
        Collect Fleet Lifecycle Manager inventory via Basic auth (VCF 9.0.x).

        .DESCRIPTION
        Builds a Basic auth header (base64(user:password)) and calls:
          - GET /lcm/lcops/api/v2/settings/system-details — reads the appliance version.
          - GET /lcm/lcops/api/v2/environments — reads all managed products (e.g. VCF
            Automation, VCF Identity) and their versions.

        The VCF Operations product (type "vrops") is not added to the inventory directly —
        it is collected via the native VCF Operations API — but its version and FQDN are
        captured under the sentinel key _OpsVersionFromFleet (same pattern as the 9.1 path)
        so EntryPoint can patch the base version returned by the native API with the full
        build number provided by Fleet Manager. Products with no resolvable FQDN are skipped.

        .PARAMETER Server
        Fleet Manager FQDN or IP.

        .PARAMETER User
        Username (e.g. admin@local).

        .PARAMETER Password
        Plain-text password.

        .PARAMETER TimeoutSeconds
        Request timeout (1-300, default 30).

        .EXAMPLE
        $inv = Get-LcopsFleetManagerInventory -Server "flt-lcm01.sfo.rainpole.io" -User "admin@local" -Password $plainTextPw

        .OUTPUTS
        [Hashtable] Inventory or empty hashtable on failure.

        .NOTES
        Returns an empty hashtable on failure (logs DEBUG). Sets _OpsVersionFromFleet sentinel key when Fleet Manager carries a full VCF Operations build number.
    #>


    [CmdletBinding()]
    [OutputType([Hashtable])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Server,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$User,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Password,
        [Parameter(Mandatory = $false)] [ValidateRange(1, 300)] [Int]$TimeoutSeconds = 30
    )

    $inventory = @{}

    try {
        $encoded = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("${User}:${Password}"))
        $headers  = @{ "Authorization" = "Basic $encoded"; "Accept" = "application/json" }

        $systemDetails = Invoke-RestMethod -Uri "https://$Server/lcm/lcops/api/v2/settings/system-details" `
            -Method GET -Headers $headers -SkipCertificateCheck -TimeoutSec $TimeoutSeconds -ErrorAction Stop

        $version = ""
        foreach ($prop in @('version', 'Version')) {
            $candidate = $systemDetails.$prop
            if (-not [String]::IsNullOrWhiteSpace($candidate)) {
                $version = ([String]$candidate).Trim()
                break
            }
        }

        $versionFinal = if ([String]::IsNullOrWhiteSpace($version)) { "Unknown" } else { $version }
        $inventory["Fleet Lifecycle"] = @([PSCustomObject]@{ Fqdn = $Server; Version = $versionFinal; DomainName = "VCF Fleet" })
        Write-LogMessage -Type INFO  -Message "Collected Fleet Lifecycle: $Server"
        Write-LogMessage -Type DEBUG -Message "Fleet Lifecycle version: $versionFinal (9.0 path)"

        # Resolve FQDNs from node property bags using the shared probe-key list.
        $environments = Invoke-RestMethod -Uri "https://$Server/lcm/lcops/api/v2/environments" `
            -Method GET -Headers $headers -SkipCertificateCheck -TimeoutSec $TimeoutSeconds -ErrorAction Stop

        $componentLists      = @{}
        $opsFqdnFromFleet    = $null
        $opsVersionFromFleet = $null

        foreach ($env in $environments) {
            foreach ($product in $env.products) {
                $typeKey = ([String]$product.id).ToLower().Trim()

                if ($typeKey -eq 'vrops') {
                    # VCF Operations is collected via its native API, which returns only the base
                    # version (e.g. "9.0.0.0"). The lcops environments API carries the full build
                    # number. Capture it here under the same _OpsVersionFromFleet sentinel used by
                    # the VCF 9.1 path so EntryPoint can patch the native version after the merge.
                    $ver = if (-not [String]::IsNullOrWhiteSpace($product.version)) { [String]$product.version } else { "" }
                    if (-not [String]::IsNullOrWhiteSpace($ver)) {
                        $opsFqdnFromFleet    = Resolve-ProductNodeFqdn -Nodes @($product.nodes)
                        $opsVersionFromFleet = $ver
                        Write-LogMessage -Type DEBUG -Message "Fleet 9.0 ops component: fqdn=$opsFqdnFromFleet version=$ver"
                    }
                    continue
                }

                if ($typeKey -eq 'vrslcm') { continue }

                $advisoryName = $Script:VCF_FLEET_MANAGER_COMPONENT_TYPE_TO_ADVISORY_NAME[$typeKey]
                if ([String]::IsNullOrWhiteSpace($advisoryName)) {
                    Write-LogMessage -Type DEBUG -Message "Fleet 9.0 product type '$typeKey' has no advisory mapping — skipping"
                    continue
                }

                $productVersion = if (-not [String]::IsNullOrWhiteSpace($product.version)) { [String]$product.version } else { "Unknown" }

                $resolvedFqdn = Resolve-ProductNodeFqdn -Nodes @($product.nodes)
                if ([String]::IsNullOrWhiteSpace($resolvedFqdn) -and $null -ne $product.clusterVIP) {
                    $resolvedFqdn = Resolve-ProductNodeFqdn -Nodes @($product.clusterVIP.clusterVips)
                }

                if ([String]::IsNullOrWhiteSpace($resolvedFqdn)) {
                    Write-LogMessage -Type DEBUG -Message "Fleet 9.0 product '$advisoryName' (type: $typeKey) has no resolvable FQDN — skipping"
                    continue
                }

                if (-not $componentLists.ContainsKey($advisoryName)) {
                    $componentLists[$advisoryName] = [System.Collections.Generic.List[PSCustomObject]]::new()
                }
                $componentLists[$advisoryName].Add([PSCustomObject]@{
                    Fqdn       = $resolvedFqdn
                    Version    = $productVersion
                    DomainName = "VCF Fleet"
                })
                Write-LogMessage -Type DEBUG -Message "Fleet LCM component: $advisoryName at $resolvedFqdn"
            }
        }

        foreach ($key in @($componentLists.Keys)) {
            $inventory[$key] = $componentLists[$key].ToArray()
        }

        # Expose the VCF Operations full build number as the same sentinel used by the 9.1 path.
        if (-not [String]::IsNullOrWhiteSpace($opsVersionFromFleet)) {
            $inventory['_OpsVersionFromFleet'] = [PSCustomObject]@{
                Fqdn    = $opsFqdnFromFleet
                Version = $opsVersionFromFleet
            }
        }
    }
    catch {
        Write-LogMessage -Type DEBUG -Message "Fleet Manager (9.0 lcops path) not available on $Server — $($_.Exception.Message)"
    }

    return $inventory
}
function Get-VrslcmInventory {

    <#
        .SYNOPSIS
        Collect vRealize Suite Lifecycle Manager (vRSLCM) inventory and managed product versions.

        .DESCRIPTION
        Connects to a vRSLCM appliance using Basic authentication against the
        /lcm/lcops/api/v2 REST API and retrieves:
        - The vRSLCM appliance version (from /settings/system-details).
        - All products deployed through vRSLCM (from /environments), keyed by their
          advisory component name so they can be matched against security advisories.

        Products are returned under the key "Fleet Lifecycle" for the vRSLCM appliance itself,
        and under their individual advisory names (e.g. "VCF Automation", "VCF Operations
        for Logs") for managed products — consistent with the advisory database schema.

        .PARAMETER Server
        vRSLCM appliance FQDN or IP address.

        .PARAMETER User
        Username for vRSLCM authentication (e.g. admin@local).

        .PARAMETER Password
        Plain-text password for authentication.

        .PARAMETER TimeoutSeconds
        HTTP request timeout in seconds (1-300, default 30).

        .EXAMPLE
        $pw = [System.Environment]::GetEnvironmentVariable("VRSLCM_PASSWORD")
        $inv = Get-VrslcmInventory -Server "vrslcm.example.com" -User "admin@local" -Password $pw
        $inv.Keys | ForEach-Object { Write-Output "$_ : $($inv[$_][0].Version)" }

        .OUTPUTS
        [Hashtable] Inventory keyed by advisory component name. Each value is an array of
        [PSCustomObject] with Fqdn, Version, and DomainName ("vRSLCM").
        Returns empty hashtable on connection or authentication failure.

        .NOTES
        Uses /lcm/lcops/api/v2/settings/system-details for the vRSLCM appliance version.
        Uses /lcm/lcops/api/v2/environments to enumerate managed product nodes and versions.
        Product IDs returned by the environments API are mapped via
        $Script:VCF_FLEET_MANAGER_COMPONENT_TYPE_TO_ADVISORY_NAME for advisory matching.
        TLS certificate validation is skipped (lab environments).
    #>


    [CmdletBinding()]
    [OutputType([Hashtable])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Password,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Server,
        [Parameter(Mandatory = $false)] [ValidateRange(1, 300)] [Int]$TimeoutSeconds = 30,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$User
    )

    $inventory = @{}

    Write-LogMessage -Type INFO -Message "Collecting vRSLCM inventory from: $Server..."

    try {
        $encoded = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("${User}:${Password}"))
        $headers = @{ "Authorization" = "Basic $encoded"; "Accept" = "application/json" }

        # Collect vRSLCM appliance version.
        $systemDetails = Invoke-RestMethod -Uri "https://$Server/lcm/lcops/api/v2/settings/system-details" `
            -Method GET -Headers $headers -SkipCertificateCheck -TimeoutSec $TimeoutSeconds -ErrorAction Stop

        $versionRaw   = [String]$systemDetails.version
        $versionFinal = if ([String]::IsNullOrWhiteSpace($versionRaw)) { "Unknown" } else { $versionRaw.Trim() }

        $inventory["Fleet Lifecycle"] = @(
            [PSCustomObject]@{ Fqdn = $Server; Version = $versionFinal; DomainName = "vRSLCM" }
        )
        Write-LogMessage -Type INFO -Message "Collected vRSLCM appliance: $Server v$versionFinal"

        # Collect managed products from each environment.
        $environments = Invoke-RestMethod -Uri "https://$Server/lcm/lcops/api/v2/environments" `
            -Method GET -Headers $headers -SkipCertificateCheck -TimeoutSec $TimeoutSeconds -ErrorAction Stop

        $componentLists = @{}

        foreach ($env in $environments) {
            foreach ($product in $env.products) {
                $typeKey = ([String]$product.id).ToLower().Trim()

                $advisoryName = $Script:VCF_FLEET_MANAGER_COMPONENT_TYPE_TO_ADVISORY_NAME[$typeKey]
                if ([String]::IsNullOrWhiteSpace($advisoryName)) {
                    Write-LogMessage -Type DEBUG -Message "vRSLCM product type '$typeKey' has no advisory mapping — skipping"
                    continue
                }

                $productVersion2 = if (-not [String]::IsNullOrWhiteSpace($product.version)) { [String]$product.version } else { "Unknown" }

                $resolvedFqdn = Resolve-ProductNodeFqdn -Nodes @($product.nodes)
                $fqdnFinal    = if ([String]::IsNullOrWhiteSpace($resolvedFqdn)) { $Server } else { $resolvedFqdn }

                if (-not $componentLists.ContainsKey($advisoryName)) {
                    $componentLists[$advisoryName] = [System.Collections.Generic.List[PSCustomObject]]::new()
                }
                $componentLists[$advisoryName].Add([PSCustomObject]@{
                    Fqdn       = $fqdnFinal
                    Version    = $productVersion2
                    DomainName = "vRSLCM"
                })
                Write-LogMessage -Type INFO -Message "Collected vRSLCM managed product: $advisoryName ($fqdnFinal v$productVersion)"
            }
        }

        foreach ($key in $componentLists.Keys) {
            $inventory[$key] = $componentLists[$key].ToArray()
        }
    }
    catch {
        Write-LogMessage -Type WARNING -Message "vRSLCM inventory collection failed for $Server — $(Resolve-HtmlAwareErrorMessage -ExceptionMessage $_.Exception.Message -Server $Server -Context 'vRSLCM')"
    }

    return $inventory
}

#endregion