Modules/Collectors/30-StorageNetworkingCollector.ps1

function ConvertTo-RangerGiBValue {
    param(
        $Value
    )

    if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) {
        return $null
    }

    $numericValue = [double]$Value
    if ($numericValue -gt 1048576) {
        return [math]::Round($numericValue / 1GB, 2)
    }

    return [math]::Round($numericValue, 2)
}

function Get-RangerStorageResiliencyEfficiencyPercent {
    param(
        [string]$ResiliencySettingName,
        [int]$NumberOfDataCopies
    )

    if ([string]::IsNullOrWhiteSpace($ResiliencySettingName)) {
        if ($NumberOfDataCopies -gt 0) {
            return [math]::Round(100 / $NumberOfDataCopies, 1)
        }

        return 50
    }

    switch ($ResiliencySettingName.ToLowerInvariant()) {
        'mirror' {
            if ($NumberOfDataCopies -ge 3) {
                return 33.3
            }

            return 50
        }
        'parity' {
            return 60
        }
        'dualparity' {
            return 72
        }
        'simple' {
            return 100
        }
        default {
            if ($NumberOfDataCopies -gt 0) {
                return [math]::Round(100 / $NumberOfDataCopies, 1)
            }

            return 50
        }
    }
}

function Get-RangerStoragePoolAnalysis {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Pool,

        [object[]]$PhysicalDisks,
        [object[]]$VirtualDisks,
        [double]$TotalVolumeUsedGiB,
        [double]$TotalUsableAcrossPoolsGiB,
        [int]$PoolCount
    )

    $poolName = [string]$Pool['friendlyName']
    $poolPhysicalDisks = @(
        $PhysicalDisks | Where-Object {
            ($_.Contains('storagePoolFriendlyName') -and $_['storagePoolFriendlyName'] -eq $poolName) -or
            ($PoolCount -eq 1 -and [string]::IsNullOrWhiteSpace([string]$_['storagePoolFriendlyName']))
        }
    )
    $poolVirtualDisks = @(
        $VirtualDisks | Where-Object {
            ($_.Contains('storagePoolFriendlyName') -and $_['storagePoolFriendlyName'] -eq $poolName) -or
            ($PoolCount -eq 1 -and [string]::IsNullOrWhiteSpace([string]$_['storagePoolFriendlyName']))
        }
    )

    $rawCapacityGiB = [double](ConvertTo-RangerGiBValue -Value ($Pool['sizeGiB'] ?? $Pool['size'] ?? 0))
    $allocatedCapacityGiB = [double](ConvertTo-RangerGiBValue -Value ($Pool['allocatedSizeGiB'] ?? $Pool['allocatedSize'] ?? 0))
    $provisionedCapacityGiB = ConvertTo-RangerGiBValue -Value ($Pool['provisionedCapacityGiB'] ?? $Pool['provisionedCapacity'])
    if ($null -eq $provisionedCapacityGiB) {
        $provisionedCapacityGiB = [math]::Round((@($poolVirtualDisks | ForEach-Object { [double](ConvertTo-RangerGiBValue -Value $_['sizeGiB']) }) | Measure-Object -Sum).Sum, 2)
    }

    $usableCapacityGiB = [math]::Round((@($poolVirtualDisks | ForEach-Object { [double](ConvertTo-RangerGiBValue -Value $_['sizeGiB']) }) | Measure-Object -Sum).Sum, 2)
    if ($usableCapacityGiB -le 0) {
        $efficiencyPercent = Get-RangerStorageResiliencyEfficiencyPercent -ResiliencySettingName ([string]$Pool['resiliencySettingName']) -NumberOfDataCopies $(if ($Pool['numberOfDataCopies']) { [int]$Pool['numberOfDataCopies'] } else { 0 })
        $usableCapacityGiB = [math]::Round($rawCapacityGiB * $efficiencyPercent / 100, 2)
    }

    $largestDiskGiB = [double]((@($poolPhysicalDisks | ForEach-Object { [double](ConvertTo-RangerGiBValue -Value $_['sizeGiB']) }) | Measure-Object -Maximum).Maximum)
    $maintenanceReserveGiB = [math]::Round($usableCapacityGiB * 0.1, 2)
    $reserveFloorGiB = [math]::Round($usableCapacityGiB * 0.2, 2)
    $recommendedReserveGiB = [math]::Round([math]::Max([math]::Max($largestDiskGiB, $maintenanceReserveGiB), $reserveFloorGiB), 2)
    $usedUsableCapacityGiB = if ($PoolCount -le 1) {
        [math]::Round($TotalVolumeUsedGiB, 2)
    }
    elseif ($TotalUsableAcrossPoolsGiB -gt 0) {
        [math]::Round($TotalVolumeUsedGiB * ($usableCapacityGiB / $TotalUsableAcrossPoolsGiB), 2)
    }
    else {
        0
    }
    $freeUsableCapacityGiB = [math]::Round([math]::Max($usableCapacityGiB - $usedUsableCapacityGiB, 0), 2)
    $projectedSafeAllocatableCapacityGiB = [math]::Round([math]::Max($freeUsableCapacityGiB - $recommendedReserveGiB, 0), 2)
    $thinProvisioningRatio = if ($usableCapacityGiB -gt 0) { [math]::Round($provisionedCapacityGiB / $usableCapacityGiB, 2) } else { $null }
    $thinProvisionedVirtualDisks = @($poolVirtualDisks | Where-Object { $_['provisioningType'] -eq 'Thin' }).Count
    $reserveStatus = if ($freeUsableCapacityGiB -lt $recommendedReserveGiB) {
        'below-threshold'
    }
    elseif ($freeUsableCapacityGiB -lt [math]::Round($recommendedReserveGiB * 1.2, 2)) {
        'near-threshold'
    }
    else {
        'healthy'
    }
    $posture = if ($reserveStatus -eq 'below-threshold' -or ($thinProvisioningRatio -and $thinProvisioningRatio -gt 1.1)) {
        'over-provisioned'
    }
    elseif ($projectedSafeAllocatableCapacityGiB -gt [math]::Round($usableCapacityGiB * 0.35, 2)) {
        'under-provisioned'
    }
    else {
        'within safe range'
    }

    [ordered]@{
        friendlyName                         = $poolName
        healthStatus                         = [string]$Pool['healthStatus']
        operationalStatus                    = [string]$Pool['operationalStatus']
        resiliencySettingName                = [string]$Pool['resiliencySettingName']
        numberOfDataCopies                   = if ($Pool['numberOfDataCopies']) { [int]$Pool['numberOfDataCopies'] } else { $null }
        diskCount                            = $poolPhysicalDisks.Count
        diskCountByMediaType                 = @(Get-RangerGroupedCount -Items $poolPhysicalDisks -PropertyName 'mediaType')
        rawCapacityGiB                       = $rawCapacityGiB
        usableCapacityGiB                    = $usableCapacityGiB
        resiliencyOverheadGiB                = [math]::Round([math]::Max($rawCapacityGiB - $usableCapacityGiB, 0), 2)
        allocatedCapacityGiB                 = $allocatedCapacityGiB
        unallocatedCapacityGiB               = [math]::Round([math]::Max($rawCapacityGiB - $allocatedCapacityGiB, 0), 2)
        provisionedCapacityGiB               = $provisionedCapacityGiB
        usedUsableCapacityGiB                = $usedUsableCapacityGiB
        freeUsableCapacityGiB                = $freeUsableCapacityGiB
        recommendedReserveGiB                = $recommendedReserveGiB
        rebuildReserveRequirementGiB         = $largestDiskGiB
        maintenanceReserveRequirementGiB     = $maintenanceReserveGiB
        projectedSafeAllocatableCapacityGiB  = $projectedSafeAllocatableCapacityGiB
        thinProvisioningRatio                = $thinProvisioningRatio
        thinProvisionedVirtualDiskCount      = $thinProvisionedVirtualDisks
        cacheDeviceCount                     = @($poolPhysicalDisks | Where-Object { $_['usage'] -match 'Journal|Cache' }).Count
        capacityDeviceCount                  = @($poolPhysicalDisks | Where-Object { $_['usage'] -notmatch 'Journal|Cache' }).Count
        reserveStatus                        = $reserveStatus
        posture                              = $posture
        assumptions                          = 'Reserve model uses the greater of one-disk rebuild reserve, 10% maintenance reserve, and 20% free usable capacity.'
    }
}

function Update-RangerStorageDomainAnalysis {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$StorageDomain
    )

    $pools = @($StorageDomain.pools)
    $physicalDisks = @($StorageDomain.physicalDisks)
    $virtualDisks = @($StorageDomain.virtualDisks)
    $volumes = @($StorageDomain.volumes)

    foreach ($pool in $pools) {
        $pool['sizeGiB'] = ConvertTo-RangerGiBValue -Value ($pool['sizeGiB'] ?? $pool['size'])
        $pool['allocatedSizeGiB'] = ConvertTo-RangerGiBValue -Value ($pool['allocatedSizeGiB'] ?? $pool['allocatedSize'])
        $pool['provisionedCapacityGiB'] = ConvertTo-RangerGiBValue -Value ($pool['provisionedCapacityGiB'] ?? $pool['provisionedCapacity'])
    }

    foreach ($disk in $physicalDisks) {
        $disk['sizeGiB'] = ConvertTo-RangerGiBValue -Value ($disk['sizeGiB'] ?? $disk['size'])
        if (-not $disk.Contains('storagePoolFriendlyName')) {
            $disk['storagePoolFriendlyName'] = $disk['StoragePoolFriendlyName']
        }
    }

    foreach ($virtualDisk in $virtualDisks) {
        $virtualDisk['sizeGiB'] = ConvertTo-RangerGiBValue -Value ($virtualDisk['sizeGiB'] ?? $virtualDisk['size'])
        $virtualDisk['footprintOnPoolGiB'] = ConvertTo-RangerGiBValue -Value ($virtualDisk['footprintOnPoolGiB'] ?? $virtualDisk['footprintOnPool'])
        if (-not $virtualDisk.Contains('storagePoolFriendlyName')) {
            $virtualDisk['storagePoolFriendlyName'] = $virtualDisk['StoragePoolFriendlyName']
        }
    }

    foreach ($volume in $volumes) {
        $volume['sizeGiB'] = ConvertTo-RangerGiBValue -Value ($volume['sizeGiB'] ?? $volume['size'])
        $volume['sizeRemainingGiB'] = ConvertTo-RangerGiBValue -Value ($volume['sizeRemainingGiB'] ?? $volume['sizeRemaining'])
    }

    $totalVolumeUsedGiB = [math]::Round((@($volumes | ForEach-Object { [math]::Max(([double]($_['sizeGiB'] ?? 0) - [double]($_['sizeRemainingGiB'] ?? 0)), 0) }) | Measure-Object -Sum).Sum, 2)
    $totalUsableAcrossPoolsGiB = [math]::Round((@($virtualDisks | ForEach-Object { [double]($_['sizeGiB'] ?? 0) }) | Measure-Object -Sum).Sum, 2)
    $poolAnalysis = @(
        foreach ($pool in $pools) {
            Get-RangerStoragePoolAnalysis -Pool $pool -PhysicalDisks $physicalDisks -VirtualDisks $virtualDisks -TotalVolumeUsedGiB $totalVolumeUsedGiB -TotalUsableAcrossPoolsGiB $totalUsableAcrossPoolsGiB -PoolCount $pools.Count
        }
    )

    $summary = if ($StorageDomain.summary) { ConvertTo-RangerHashtable -InputObject $StorageDomain.summary } else { [ordered]@{} }
    $summary.poolCount = $pools.Count
    $summary.physicalDiskCount = $physicalDisks.Count
    $summary.virtualDiskCount = $virtualDisks.Count
    $summary.volumeCount = $volumes.Count
    $summary.csvCount = @($StorageDomain.csvs).Count
    $summary.totalRawCapacityGiB = [math]::Round((@($poolAnalysis | ForEach-Object { [double]$_['rawCapacityGiB'] }) | Measure-Object -Sum).Sum, 2)
    $summary.totalUsableCapacityGiB = [math]::Round((@($poolAnalysis | ForEach-Object { [double]$_['usableCapacityGiB'] }) | Measure-Object -Sum).Sum, 2)
    $summary.totalAllocatedCapacityGiB = [math]::Round((@($poolAnalysis | ForEach-Object { [double]$_['allocatedCapacityGiB'] }) | Measure-Object -Sum).Sum, 2)
    $summary.totalProvisionedCapacityGiB = [math]::Round((@($poolAnalysis | ForEach-Object { [double]$_['provisionedCapacityGiB'] }) | Measure-Object -Sum).Sum, 2)
    $summary.totalUsedUsableCapacityGiB = [math]::Round((@($poolAnalysis | ForEach-Object { [double]$_['usedUsableCapacityGiB'] }) | Measure-Object -Sum).Sum, 2)
    $summary.totalFreeUsableCapacityGiB = [math]::Round((@($poolAnalysis | ForEach-Object { [double]$_['freeUsableCapacityGiB'] }) | Measure-Object -Sum).Sum, 2)
    $summary.totalReserveTargetGiB = [math]::Round((@($poolAnalysis | ForEach-Object { [double]$_['recommendedReserveGiB'] }) | Measure-Object -Sum).Sum, 2)
    $summary.totalSafeAllocatableCapacityGiB = [math]::Round((@($poolAnalysis | ForEach-Object { [double]$_['projectedSafeAllocatableCapacityGiB'] }) | Measure-Object -Sum).Sum, 2)
    $summary.diskMediaTypes = @(Get-RangerGroupedCount -Items $physicalDisks -PropertyName 'mediaType')
    $summary.unhealthyDisks = @($physicalDisks | Where-Object { $_['healthStatus'] -and $_['healthStatus'] -ne 'Healthy' }).Count
    $summary.canPoolDisks = @($physicalDisks | Where-Object { $_['canPool'] }).Count
    $summary.retiredDisks = @($physicalDisks | Where-Object { [string]$_['operationalStatus'] -match 'Retiring|PredictiveFailure' }).Count
    $summary.resiliencyModes = @(Get-RangerGroupedCount -Items $virtualDisks -PropertyName 'resiliencySettingName')
    $summary.tierCount = @($StorageDomain.tiers).Count
    $summary.storageJobCount = @($StorageDomain.jobs).Count
    $summary.activeHealthFaultCount = @($StorageDomain.healthFaults).Count
    $summary.replicaGroupCount = @($StorageDomain.replicaDepth).Count
    $summary.qosFlowCount = @($StorageDomain.qosFlows).Count
    $summary.cacheEnabled = if ($StorageDomain.cacheConfig) { $StorageDomain.cacheConfig.CacheState -eq 'Enabled' } else { $null }
    $summary.dedupVolumes = @($StorageDomain.dedupStatus | Where-Object { $_.Status -ne 'Disabled' }).Count
    $summary.thinProvisioningRatio = if ($summary.totalUsableCapacityGiB -gt 0) { [math]::Round($summary.totalProvisionedCapacityGiB / $summary.totalUsableCapacityGiB, 2) } else { $null }
    $summary.poolPostureCounts = @($poolAnalysis | Group-Object posture | Sort-Object Name | ForEach-Object { [ordered]@{ name = $_.Name; count = $_.Count } })

    $StorageDomain.pools = ConvertTo-RangerHashtable -InputObject $pools
    $StorageDomain.physicalDisks = ConvertTo-RangerHashtable -InputObject $physicalDisks
    $StorageDomain.virtualDisks = ConvertTo-RangerHashtable -InputObject $virtualDisks
    $StorageDomain.volumes = ConvertTo-RangerHashtable -InputObject $volumes
    $StorageDomain.poolAnalysis = ConvertTo-RangerHashtable -InputObject $poolAnalysis
    $StorageDomain.summary = $summary
    return $StorageDomain
}

function New-RangerStorageAnalysisFindings {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$StorageDomain
    )

    $findings = New-Object System.Collections.ArrayList
    foreach ($poolAnalysis in @($StorageDomain.poolAnalysis)) {
        if ($poolAnalysis.reserveStatus -eq 'below-threshold') {
            [void]$findings.Add((New-RangerFinding -Severity warning -Title "Storage reserve is below the recommended threshold for pool '$($poolAnalysis.friendlyName)'" -Description 'The pool does not currently retain the recommended amount of free usable capacity for rebuild and maintenance safety.' -CurrentState "free usable $($poolAnalysis.freeUsableCapacityGiB) GiB; reserve target $($poolAnalysis.recommendedReserveGiB) GiB" -Recommendation 'Reduce allocation pressure, expand the pool, or reclaim capacity before additional workload placement.'))
        }
        elseif ($poolAnalysis.reserveStatus -eq 'near-threshold') {
            [void]$findings.Add((New-RangerFinding -Severity informational -Title "Storage reserve is near the threshold for pool '$($poolAnalysis.friendlyName)'" -Description 'The pool is operating close to its recommended reserve target.' -CurrentState "free usable $($poolAnalysis.freeUsableCapacityGiB) GiB; reserve target $($poolAnalysis.recommendedReserveGiB) GiB" -Recommendation 'Plan capacity expansion or cleanup before the next maintenance cycle or failure event.'))
        }

        if ($poolAnalysis.thinProvisionedVirtualDiskCount -gt 0 -and $poolAnalysis.thinProvisioningRatio -gt 1) {
            [void]$findings.Add((New-RangerFinding -Severity warning -Title "Thin provisioning exceeds current usable capacity for pool '$($poolAnalysis.friendlyName)'" -Description 'Thin-provisioned virtual disks currently expose more provisioned capacity than the pool can safely back with usable capacity.' -CurrentState "thin provisioning ratio $($poolAnalysis.thinProvisioningRatio)x" -Recommendation 'Review virtual disk growth controls and keep additional free reserve before consuming more capacity.'))
        }

        if ($poolAnalysis.posture -eq 'over-provisioned' -and $poolAnalysis.reserveStatus -ne 'below-threshold') {
            [void]$findings.Add((New-RangerFinding -Severity warning -Title "Storage pool '$($poolAnalysis.friendlyName)' is operating in an over-provisioned posture" -Description 'Pool allocation and provisioned capacity reduce the remaining safe allocatable headroom.' -CurrentState "provisioned $($poolAnalysis.provisionedCapacityGiB) GiB; safe allocatable $($poolAnalysis.projectedSafeAllocatableCapacityGiB) GiB" -Recommendation 'Rebalance growth expectations against usable capacity and reserve targets before additional workload placement.'))
        }
    }

    return @($findings)
}

function Invoke-RangerStorageNetworkingCollector {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config,

        [Parameter(Mandatory = $true)]
        $CredentialMap,

        [Parameter(Mandatory = $true)]
        [object]$Definition,

        [Parameter(Mandatory = $true)]
        [string]$PackageRoot
    )

    $fixture = Get-RangerCollectorFixtureData -Config $Config -CollectorId $Definition.Id
    if ($fixture) {
        $fixtureResult = ConvertTo-RangerHashtable -InputObject $fixture
        if ($fixtureResult.Contains('Domains') -and $fixtureResult.Domains.Contains('storage')) {
            $fixtureResult.Domains.storage = Update-RangerStorageDomainAnalysis -StorageDomain (ConvertTo-RangerHashtable -InputObject $fixtureResult.Domains.storage)
            $fixtureStorageFindings = @(New-RangerStorageAnalysisFindings -StorageDomain $fixtureResult.Domains.storage)
            if (-not $fixtureResult.Contains('Findings')) {
                $fixtureResult['Findings'] = @()
            }

            if ($fixtureStorageFindings.Count -gt 0) {
                $fixtureResult.Findings = @($fixtureResult.Findings) + $fixtureStorageFindings
            }
        }

        return $fixtureResult
    }

    $storageSnapshot = Invoke-RangerSafeAction -Label 'Storage inventory snapshot' -DefaultValue ([ordered]@{}) -ScriptBlock {
        Invoke-RangerClusterCommand -Config $Config -Credential $CredentialMap.cluster -SingleTarget -ScriptBlock {
            $rangerDiagnostics = New-Object System.Collections.Generic.List[string]
            [ordered]@{
                # Issue #58: Expanded pool, physical disk, virtual disk, volume collections
                pools = if (Get-Command -Name Get-StoragePool -ErrorAction SilentlyContinue) {
                    @(Get-StoragePool | Where-Object { -not $_.IsPrimordial } | ForEach-Object {
                        $p = $_
                        # Derive dominant resiliency from virtual disks in this pool.
                        # Get-VirtualDisk piped from pool is the supported S2D query pattern.
                        $poolVds = @(try { $p | Get-VirtualDisk -ErrorAction SilentlyContinue } catch { @() })
                        $domResiliency = if ($poolVds.Count -gt 0) {
                            $poolVds | Group-Object ResiliencySettingName | Sort-Object Count -Descending | Select-Object -First 1 -ExpandProperty Name
                        } else { $null }
                        $domCopies = if ($poolVds.Count -gt 0) {
                            [int]($poolVds | Group-Object NumberOfDataCopies | Sort-Object Count -Descending | Select-Object -First 1 -ExpandProperty Name)
                        } else { $null }
                        [ordered]@{
                            friendlyName                = $p.FriendlyName
                            healthStatus                = [string]$p.HealthStatus
                            operationalStatus           = [string]$p.OperationalStatus
                            sizeGiB                     = [math]::Round($p.Size / 1GB, 2)
                            allocatedSizeGiB            = [math]::Round($p.AllocatedSize / 1GB, 2)
                            provisionedCapacityGiB      = if ($p.ProvisionedCapacity -gt 0) { [math]::Round($p.ProvisionedCapacity / 1GB, 2) } else { $null }
                            faultDomainAwarenessDefault = [string]$p.FaultDomainAwarenessDefault
                            retiredPhysicalDiskCount    = $p.RetiredPhysicalDiskCount
                            readCacheSize               = $p.ReadCacheSize
                            isTiered                    = $p.IsTiered
                            isPrimordial                = $p.IsPrimordial
                            resiliencySettingName       = $domResiliency
                            numberOfDataCopies          = $domCopies
                        }
                    })
                } else { @() }
                physicalDisks = if (Get-Command -Name Get-PhysicalDisk -ErrorAction SilentlyContinue) {
                    @(Get-PhysicalDisk | ForEach-Object {
                        $d = $_
                        [ordered]@{
                            friendlyName       = $d.FriendlyName
                            healthStatus       = [string]$d.HealthStatus
                            operationalStatus  = [string]$d.OperationalStatus
                            mediaType          = [string]$d.MediaType
                            sizeGiB            = [math]::Round($d.Size / 1GB, 2)
                            serialNumber       = $d.SerialNumber
                            usage              = [string]$d.Usage
                            slotNumber         = $d.SlotNumber
                            canPool            = $d.CanPool
                            cannotPoolReason   = $d.CannotPoolReason
                            storagePoolFriendlyName = $d.StoragePoolFriendlyName
                            busType            = [string]$d.BusType
                            manufacturer       = $d.Manufacturer
                            model              = $d.Model
                        }
                    })
                } else { @() }
                physicalDiskReliability = if (Get-Command -Name Get-StorageReliabilityCounter -ErrorAction SilentlyContinue) {
                    @(try { Get-PhysicalDisk -ErrorAction SilentlyContinue | Get-StorageReliabilityCounter -ErrorAction Stop | Select-Object DeviceId, Wear, Temperature, ReadErrors, WriteErrors, PowerOnHours, ReadLatencyMax, WriteLatencyMax } catch { @() })
                } else { @() }
                virtualDisks = if (Get-Command -Name Get-VirtualDisk -ErrorAction SilentlyContinue) {
                    @(Get-VirtualDisk -ErrorAction SilentlyContinue | ForEach-Object {
                        $vd = $_
                        [ordered]@{
                            friendlyName       = $vd.FriendlyName
                            healthStatus       = [string]$vd.HealthStatus
                            operationalStatus  = [string]$vd.OperationalStatus
                            resiliencySettingName = $vd.ResiliencySettingName
                            sizeGiB            = [math]::Round($vd.Size / 1GB, 2)
                            footprintOnPoolGiB = [math]::Round($vd.FootprintOnPool / 1GB, 2)
                            numberOfDataCopies = $vd.NumberOfDataCopies
                            numberOfColumns    = $vd.NumberOfColumns
                            interleave         = $vd.Interleave
                            isEnclosureAware   = $vd.IsEnclosureAware
                            provisioningType   = [string]$vd.ProvisioningType
                            writeCacheSize     = $vd.WriteCacheSize
                            storagePoolFriendlyName = $vd.StoragePoolFriendlyName
                            uniqueId           = $vd.UniqueId
                        }
                    })
                } else { @() }
                volumes = if (Get-Command -Name Get-Volume -ErrorAction SilentlyContinue) {
                    @(Get-Volume -ErrorAction SilentlyContinue | ForEach-Object {
                        $v = $_
                        [ordered]@{
                            driveLetter        = $v.DriveLetter
                            fileSystemLabel    = $v.FileSystemLabel
                            fileSystem         = $v.FileSystem
                            fileSystemType     = [string]$v.FileSystemType
                            healthStatus       = [string]$v.HealthStatus
                            sizeGiB            = [math]::Round($v.Size / 1GB, 2)
                            sizeRemainingGiB   = [math]::Round($v.SizeRemaining / 1GB, 2)
                            dedupMode          = [string]$v.DedupMode
                            driveType          = [string]$v.DriveType
                            path               = $v.Path
                        }
                    })
                } else { @() }
                dedupStatus = if (Get-Command -Name Get-DedupStatus -ErrorAction SilentlyContinue) {
                    @(try { Get-DedupStatus -ErrorAction Stop | Select-Object Volume, SavingsRate, SavedSpace, OptimizedFilesCount, LastOptimizationTime, Status } catch { @() })
                } else { @() }
                cacheConfig = if (Get-Command -Name Get-ClusterS2D -ErrorAction SilentlyContinue) {
                    $cacheWarnings = @()
                    try {
                        $cacheConfig = Get-ClusterS2D -ErrorAction Stop -WarningAction SilentlyContinue -WarningVariable +cacheWarnings | Select-Object CacheState, CacheBehavior, CacheModeSSD, CacheModeHDD, CachePageSizeKBytes, CacheMetadataReserveBytes, AutoConfig
                        foreach ($cacheWarning in @($cacheWarnings)) {
                            [void]$rangerDiagnostics.Add("Get-ClusterS2D: $([string]$(if ($cacheWarning -is [System.Management.Automation.WarningRecord]) { $cacheWarning.Message } else { $cacheWarning }))")
                        }
                        $cacheConfig
                    } catch {
                        [void]$rangerDiagnostics.Add("Get-ClusterS2D: $($_.Exception.Message)")
                        $null
                    }
                } else { $null }
                scrubSchedule = if (Get-Command -Name Get-ScheduledTask -ErrorAction SilentlyContinue) {
                    @(try { Get-ScheduledTask -TaskPath '\Microsoft\Windows\Data Integrity Scan\' -ErrorAction SilentlyContinue | Select-Object TaskName, TaskPath, State, Description } catch { @() })
                } else { @() }
                tiers = if (Get-Command -Name Get-StorageTier -ErrorAction SilentlyContinue) { @(try { Get-StorageTier -ErrorAction Stop | Select-Object FriendlyName, MediaType, ResiliencySettingName, Size, FootprintOnPool } catch { @() }) } else { @() }
                subsystems = if (Get-Command -Name Get-StorageSubSystem -ErrorAction SilentlyContinue) { @(try { Get-StorageSubSystem -ErrorAction Stop | Select-Object FriendlyName, Model, SerialNumber, HealthStatus } catch { @() }) } else { @() }
                resiliency = if (Get-Command -Name Get-ResiliencySetting -ErrorAction SilentlyContinue) { @(try { Get-ResiliencySetting -ErrorAction Stop | Select-Object Name, NumberOfDataCopiesDefault, PhysicalDiskRedundancyDefault } catch { @() }) } else { @() }
                jobs = if (Get-Command -Name Get-StorageJob -ErrorAction SilentlyContinue) { @(try { Get-StorageJob -ErrorAction Stop | Select-Object Name, JobState, PercentComplete, BytesProcessed } catch { @() }) } else { @() }
                csvs = if (Get-Command -Name Get-ClusterSharedVolume -ErrorAction SilentlyContinue) { @(try { Get-ClusterSharedVolume -ErrorAction Stop | Select-Object Name, State, OwnerNode } catch { @() }) } else { @() }
                qos = if (Get-Command -Name Get-StorageQosPolicy -ErrorAction SilentlyContinue) { @(try { Get-StorageQosPolicy -ErrorAction Stop | Select-Object Name, PolicyId, PolicyType, MinimumIops, MaximumIops, MaximumIoBandwidth } catch { @() }) } else { @() }
                qosFlows = if (Get-Command -Name Get-StorageQosFlow -ErrorAction SilentlyContinue) {
                    @(try { Get-StorageQosFlow -ErrorAction Stop | Select-Object Initiator, InitiatorName, PolicyId, FilePath, StorageNodeIops, Status } catch { @() })
                } else { @() }
                healthFaults = if (Get-Command -Name Get-HealthFault -ErrorAction SilentlyContinue) {
                    @(try { Get-HealthFault -ErrorAction Stop | Select-Object FaultType, FaultingObjectDescription, FaultingObjectLocation, FaultingObjectUniqueId, PerceivedSeverity, Reason, RecommendedActions, FaultTime } catch { @() })
                } else { @() }
                scrubSettings = if (Get-Command -Name Get-StorageSubSystem -ErrorAction SilentlyContinue) {
                    try {
                        $subsys = Get-StorageSubSystem -ErrorAction SilentlyContinue | Where-Object { $_.FriendlyName -match 'S2D|Spaces' } | Select-Object -First 1
                        if ($subsys) {
                            $scrub = try { $subsys | Get-StoragePool -ErrorAction Stop | Get-PhysicalDisk -ErrorAction Stop | Get-StorageReliabilityCounter -ErrorAction Stop | Select-Object -First 1 DeviceId, ReadErrors, WriteErrors, Temperature, Wear, PowerOnHours } catch { $null }
                            [ordered]@{
                                deviceId     = if ($scrub) { $scrub.DeviceId } else { $null }
                                readErrors   = if ($scrub) { $scrub.ReadErrors } else { $null }
                                writeErrors  = if ($scrub) { $scrub.WriteErrors } else { $null }
                                temperature  = if ($scrub) { $scrub.Temperature } else { $null }
                                wear         = if ($scrub) { $scrub.Wear } else { $null }
                                powerOnHours = if ($scrub) { $scrub.PowerOnHours } else { $null }
                            }
                        } else { $null }
                    } catch { $null }
                } else { $null }
                replicaDepth = if (Get-Command -Name Get-SRGroup -ErrorAction SilentlyContinue) {
                    @(try {
                        $srPartnerships = @(try { Get-SRPartnership -ErrorAction Stop } catch { @() })
                        Get-SRGroup -ErrorAction Stop | ForEach-Object {
                            $sg = $_
                            $partnership = $srPartnerships | Where-Object { $_.SourceRGName -eq $sg.Name -or $_.DestinationRGName -eq $sg.Name } | Select-Object -First 1
                            [ordered]@{
                                name              = $sg.Name
                                replicationMode   = [string]$sg.ReplicationMode
                                replicationState  = [string]$sg.ReplicationState
                                logSizeUsedBytes  = $sg.LogSizeUsed
                                currentSyncSpeedBytesPerSecond = $sg.CurrentSynchronizationSpeed
                                lastInSyncTime    = $sg.LastInSyncTime
                                memberCount       = @($sg.Members).Count
                                partnerGroupName  = if ($partnership) { if ($partnership.SourceRGName -eq $sg.Name) { $partnership.DestinationRGName } else { $partnership.SourceRGName } } else { $null }
                                partnerComputerName = if ($partnership) { if ($partnership.SourceComputerName) { $partnership.SourceComputerName } else { $partnership.DestinationComputerName } } else { $null }
                            }
                        }
                    } catch { @() })
                } else { @() }
                replica = if (Get-Command -Name Get-SRGroup -ErrorAction SilentlyContinue) { @(try { Get-SRGroup -ErrorAction Stop | Select-Object Name, ReplicationMode, LastInSyncTime, State } catch { @() }) } else { @() }
                clusterNetworks = if (Get-Command -Name Get-ClusterNetwork -ErrorAction SilentlyContinue) { @(try { Get-ClusterNetwork -ErrorAction Stop | Select-Object Name, Role, Address, AddressMask, State } catch { @() }) } else { @() }
                rangerDiagnostics = @($rangerDiagnostics)
            }
        }
    }

    $networkNodes = @(
        Invoke-RangerSafeAction -Label 'Networking inventory snapshot' -DefaultValue @() -ScriptBlock {
            Invoke-RangerClusterCommand -Config $Config -Credential $CredentialMap.cluster -ScriptBlock {
                $rangerDiagnostics = New-Object System.Collections.Generic.List[string]
                # Issue #59: Expand proxy to structured fields early so it can be used below
                $proxyRaw = try { netsh winhttp show proxy | Out-String } catch { $null }
                $proxyEnvHttp = [System.Environment]::GetEnvironmentVariable('HTTP_PROXY')
                $proxyEnvHttps = [System.Environment]::GetEnvironmentVariable('HTTPS_PROXY')
                $proxyEnvNoProxy = [System.Environment]::GetEnvironmentVariable('NO_PROXY')
                $proxyServer = if ($proxyRaw -match 'Proxy Server\(s\)\s*:\s*(.+)') { $Matches[1].Trim() } else { $null }
                $proxyBypassRaw = if ($proxyRaw -match 'Bypass List\s*:\s*(.+)') { $Matches[1].Trim() } else { $null }
                $proxyDirect = $proxyRaw -match 'Direct access'
                $proxyDetail = [ordered]@{
                    winhttp          = $proxyServer
                    winhttp_bypass   = $proxyBypassRaw
                    isDirect         = $proxyDirect
                    envHttpProxy     = $proxyEnvHttp
                    envHttpsProxy    = $proxyEnvHttps
                    envNoProxy       = $proxyEnvNoProxy
                    proxyConfigured  = -not $proxyDirect -or -not [string]::IsNullOrWhiteSpace($proxyEnvHttps)
                    rawWinhttp       = $proxyRaw
                }
                $dcb = [ordered]@{
                    dcbxSettings  = if (Get-Command -Name Get-NetQosDcbxSetting -ErrorAction SilentlyContinue) { @(Get-NetQosDcbxSetting -ErrorAction SilentlyContinue | Select-Object InterfaceAlias, Setting) } else { @() }
                    trafficClasses = if (Get-Command -Name Get-NetQosTrafficClass -ErrorAction SilentlyContinue) { @(Get-NetQosTrafficClass -ErrorAction SilentlyContinue | Select-Object Name, Algorithm, BandwidthPercentage, Priority) } else { @() }
                    flowControl   = if (Get-Command -Name Get-NetQosFlowControl -ErrorAction SilentlyContinue) { @(Get-NetQosFlowControl -ErrorAction SilentlyContinue | Select-Object Priority, Enabled) } else { @() }
                    policy        = if (Get-Command -Name Get-NetQosPolicy -ErrorAction SilentlyContinue) { @(Get-NetQosPolicy -ErrorAction SilentlyContinue | Select-Object Name, PriorityValue8021Action, NetDirectPortMatchCondition, IPProtocolMatchCondition) } else { @() }
                }
                $intentOverrides = if (Get-Command -Name Get-NetIntent -ErrorAction SilentlyContinue) {
                    @(Get-NetIntent -ErrorAction SilentlyContinue | ForEach-Object {
                        $intent = $_
                        [ordered]@{
                            name             = $intent.IntentName
                            adapters         = @(try { $intent.InterfaceOverride | ForEach-Object { if ($_.InterfaceDescription) { $_.InterfaceDescription } elseif ($_.InterfaceAlias) { $_.InterfaceAlias } } } catch { @() })
                            isStorageIntent  = $intent.IsStorageIntent
                            isComputeIntent  = $intent.IsComputeIntent
                            isManagementIntent = $intent.IsManagementIntent
                            storageVlans     = @(try { $intent.StorageVlans } catch { @() })
                            overrideAdapterProperty = if (Get-Command -Name Get-NetIntentAllAdapterPropertyOverrides -ErrorAction SilentlyContinue) {
                                try { Get-NetIntentAllAdapterPropertyOverrides -Name $intent.IntentName -ErrorAction Stop | Select-Object Name, AdapterName, PropertyName, PropertyValue } catch { $null }
                            }
                        }
                    })
                } else { @() }
                # Issue #313 — LLDP passive reporting: always collect neighbor data
                # using Get-NetLldpNeighbor (available on Azure Local / Windows Server
                # 2019+). Falls back to empty list gracefully when LLDP cmdlets are
                # absent. No switch credentials required — data comes from the node OS.
                $lldpNeighbors = @(
                    try {
                        if (Get-Command -Name Get-NetLldpNeighbor -ErrorAction SilentlyContinue) {
                            @(Get-NetLldpNeighbor -ErrorAction SilentlyContinue | ForEach-Object {
                                [ordered]@{
                                    interface        = $_.LocalPort
                                    neighborSystem   = $_.SystemName
                                    neighborPort     = $_.PortId
                                    neighborPortDesc = $_.PortDescription
                                    neighborMgmtAddr = $_.ManagementAddress
                                    neighborMac      = $_.ChassisId
                                    ttl              = $_.TimeToLive
                                }
                            })
                        } elseif (Get-Command -Name Get-NetAdapter -ErrorAction SilentlyContinue) {
                            # Fallback: read LLDP via WMI on older OS versions
                            @(Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq 'Up' } | ForEach-Object {
                                $adapterName = $_.Name
                                $lldpWmi = Get-CimInstance -Namespace 'root\wmi' -ClassName MSNdis_NetworkLinkDescription -Filter "InstanceName like '%$adapterName%'" -ErrorAction SilentlyContinue
                                if ($lldpWmi) { [ordered]@{ interface = $adapterName; neighborSystem = $null; lldpData = $lldpWmi.LinkDescription } }
                            } | Where-Object { $_ })
                        }
                    } catch { @() }
                )
                # Issue #59: SMB configuration depth and Live Migration settings
                $smbConfig = [ordered]@{
                    serverConfig    = if (Get-Command -Name Get-SmbServerConfiguration -ErrorAction SilentlyContinue) {
                        try { Get-SmbServerConfiguration -ErrorAction Stop | Select-Object EnableSMBQUIC, RequireSecuritySignature, EncryptData, AutoDisconnectTimeout, EnableMultiChannel } catch { $null }
                    } else { $null }
                    multichannelEnabled = if (Get-Command -Name Get-SmbMultichannelConfiguration -ErrorAction SilentlyContinue) {
                        try { (Get-SmbMultichannelConfiguration -ErrorAction Stop).EnableMultiChannel } catch { $null }
                    } else { $null }
                    smbDirectEnabled = if (Get-Command -Name Get-SmbDirectConfiguration -ErrorAction SilentlyContinue) {
                        try { (Get-SmbDirectConfiguration -ErrorAction Stop).Enabled } catch { $null }
                    } else { $null }
                    activeConnections = if (Get-Command -Name Get-SmbMultichannelConnection -ErrorAction SilentlyContinue) {
                        try { @(Get-SmbMultichannelConnection -ErrorAction Stop | Select-Object ServerName, ClientInterface, ServerInterface, Protocol, Selected, ConstrainedBy) } catch { @() }
                    } else { @() }
                }
                $liveMigration = if (Get-Command -Name Get-VMHost -ErrorAction SilentlyContinue) {
                    try {
                        $vmh = Get-VMHost -ErrorAction Stop
                        $smbBwLimit = if (Get-Command -Name Get-SmbBandwidthLimit -ErrorAction SilentlyContinue) { try { @(Get-SmbBandwidthLimit -ErrorAction Stop) } catch { @() } } else { @() }
                        [ordered]@{
                            liveMigrationEnabled = $vmh.VirtualMachineMigrationEnabled
                            maxMigrations        = $vmh.MaximumVirtualMachineMigrations
                            maxStorageMigrations = $vmh.MaximumStorageMigrations
                            performanceOption    = [string]$vmh.VirtualMachineMigrationPerformanceOption
                            useAnyNetworkForMigration = $vmh.UseAnyNetworkForMigration
                            liveMigrationNetworks = @(if (Get-Command -Name Get-VMHostSupportedVersion -ErrorAction SilentlyContinue) { @() } else { @() })
                            smbBandwidthLimits   = @($smbBwLimit)
                        }
                    } catch { $null }
                } else { $null }
                # Issue #61: DNS depth with conditional forwarders
                $dnsForwarders = if (Get-Command -Name Get-DnsServerForwarder -ErrorAction SilentlyContinue) {
                    try { @(Get-DnsServerForwarder -ErrorAction Stop | Select-Object IPAddress, UseRootHint, Timeout, EnableReordering) } catch { @() }
                } else { @() }
                $dnsZones = if (Get-Command -Name Get-DnsServerZone -ErrorAction SilentlyContinue) {
                    try { @(Get-DnsServerZone -ErrorAction Stop | Where-Object { $_.ZoneType -eq 'Forwarder' } | Select-Object ZoneName, ZoneType, MasterServers, ZoneFile) } catch { @() }
                } else { @() }
                # Issue #61: Firewall rule audit for Azure Local required ports
                $requiredFirewallRules = @(
                    @{ Name = 'WinRM-HTTP';   Port = 5985;  Protocol = 'TCP'; Description = 'WinRM HTTP' }
                    @{ Name = 'WinRM-HTTPS';  Port = 5986;  Protocol = 'TCP'; Description = 'WinRM HTTPS' }
                    @{ Name = 'SMB';          Port = 445;   Protocol = 'TCP'; Description = 'SMB file sharing' }
                    @{ Name = 'Cluster';      Port = 3343;  Protocol = 'TCP'; Description = 'Cluster heartbeat' }
                    @{ Name = 'HyperV-RPC';   Port = 2179;  Protocol = 'TCP'; Description = 'Hyper-V VM Connect' }
                    @{ Name = 'HyperV-LIVE';  Port = 6600;  Protocol = 'TCP'; Description = 'Hyper-V Live Migration' }
                    @{ Name = 'RPC-EP';       Port = 135;   Protocol = 'TCP'; Description = 'RPC Endpoint Mapper' }
                    @{ Name = 'NTP';          Port = 123;   Protocol = 'UDP'; Description = 'Network Time Protocol' }
                    @{ Name = 'Arc-HTTPS';    Port = 443;   Protocol = 'TCP'; Description = 'Arc agent and Azure services' }
                )
                $firewallRuleAudit = if (Get-Command -Name Get-NetFirewallRule -ErrorAction SilentlyContinue) {
                    @($requiredFirewallRules | ForEach-Object {
                        $req = $_
                        $matchingRules = @(try {
                            Get-NetFirewallRule -Enabled True -Direction Inbound -ErrorAction SilentlyContinue | ForEach-Object {
                                $r = $_
                                $portFilter = try { $r | Get-NetFirewallPortFilter -ErrorAction Stop } catch { $null }
                                if ($portFilter -and ($portFilter.LocalPort -eq $req.Port -or $portFilter.LocalPort -eq 'Any') -and ($portFilter.Protocol -eq $req.Protocol -or $portFilter.Protocol -eq 'Any')) { $r }
                            }
                        } catch { @() })
                        [ordered]@{
                            requiredPort    = $req.Port
                            protocol        = $req.Protocol
                            description     = $req.Description
                            rulesFound      = @($matchingRules).Count
                            ruleNames       = @($matchingRules | Select-Object -First 3 | ForEach-Object { $_.Name })
                            satisfied       = @($matchingRules).Count -gt 0
                        }
                    })
                } else { @() }
                $inboxDriverAdapters = if (Get-Command -Name Get-NetAdapter -ErrorAction SilentlyContinue) {
                    @(Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.DriverProvider -eq 'Microsoft' -and $_.Status -eq 'Up' } | Select-Object Name, InterfaceDescription, DriverProvider, DriverVersion)
                } else { @() }
                [ordered]@{
                    node          = $env:COMPUTERNAME
                    # Issue #59: Expanded adapters with driver info
                    adapters      = if (Get-Command -Name Get-NetAdapter -ErrorAction SilentlyContinue) {
                        @(Get-NetAdapter -ErrorAction SilentlyContinue | ForEach-Object {
                            $a = $_
                            $advProps = @{}
                            try { Get-NetAdapterAdvancedProperty -Name $a.Name -ErrorAction SilentlyContinue | ForEach-Object { $advProps[$_.RegistryKeyword] = $_.RegistryValue } } catch {}
                            [ordered]@{
                                name                 = $a.Name
                                interfaceDescription = $a.InterfaceDescription
                                status               = [string]$a.Status
                                linkSpeed            = $a.LinkSpeed
                                macAddress           = $a.MacAddress
                                mtuSize              = $a.MtuSize
                                mediaType            = [string]$a.MediaType
                                driverProvider       = $a.DriverProvider
                                driverFileName       = $a.DriverFileName
                                driverVersion        = $a.DriverVersionString
                                driverDate           = $a.DriverDate
                                vlanId               = $a.VlanID
                                isInboxDriver        = $a.DriverProvider -eq 'Microsoft'
                                rdmaCapable          = if ($advProps.ContainsKey('*NetworkDirect')) { $advProps['*NetworkDirect'] -ne '0' } else { $null }
                                sriovCapable         = if ($advProps.ContainsKey('*SRIOV')) { $advProps['*SRIOV'] -ne '0' } else { $null }
                            }
                        })
                    } else { @() }
                    # Issue #59: Expanded vSwitch detail with SET, SR-IOV, bandwidth, extensions
                    vSwitches     = if (Get-Command -Name Get-VMSwitch -ErrorAction SilentlyContinue) {
                        @(Get-VMSwitch -ErrorAction SilentlyContinue | ForEach-Object {
                            $sw = $_
                            $exts = @(try { $sw | Get-VMSwitchExtension -ErrorAction Stop | Select-Object Name, Vendor, Enabled, Running } catch { @() })
                            [ordered]@{
                                name                     = $sw.Name
                                switchType               = [string]$sw.SwitchType
                                allowManagementOS        = $sw.AllowManagementOS
                                netAdapterNames          = @($sw.NetAdapterInterfaceDescriptions)
                                bandwidthReservationMode = [string]$sw.BandwidthReservationMode
                                iovEnabled               = $sw.IovEnabled
                                iovQueuePairCount        = $sw.IovQueuePairCount
                                embeddedTeamingEnabled   = $sw.EmbeddedTeamingEnabled
                                extensions               = @($exts)
                            }
                        })
                    } else { @() }
                    # Issue #59: Expanded host vNIC with RDMA, IP, VLAN
                    hostVirtualNics = if (Get-Command -Name Get-VMNetworkAdapter -ErrorAction SilentlyContinue) {
                        try {
                            @(Get-VMNetworkAdapter -ManagementOS -ErrorAction SilentlyContinue | ForEach-Object {
                                $vnic = $_
                                $rdmaAdapter = if (Get-Command -Name Get-NetAdapterRdma -ErrorAction SilentlyContinue) { try { Get-NetAdapterRdma -Name $vnic.Name -ErrorAction Stop } catch { $null } } else { $null }
                                # Guard: use Get-NetIPInterface to pre-check before Get-NetIPConfiguration.
                                # PA host vNICs (PAhostVNic2 etc.) lack MSFT_NetIPInterface entries.
                                # Get-NetIPInterface -ErrorAction SilentlyContinue suppresses stream-2 output,
                                # preventing Invoke-Command -ErrorAction Stop from promoting it to terminating.
                                $ipConf = if (Get-NetIPInterface -InterfaceAlias $vnic.Name -ErrorAction SilentlyContinue) {
                                    try { Get-NetIPConfiguration -InterfaceAlias $vnic.Name -ErrorAction SilentlyContinue | Select-Object -First 1 } catch { $null }
                                } else { $null }
                                $vlan = try { Get-VMNetworkAdapterVlan -ManagementOS -VMNetworkAdapterName $vnic.Name -ErrorAction Stop | Select-Object -First 1 } catch { $null }
                                [ordered]@{
                                    name            = $vnic.Name
                                    switchName      = $vnic.SwitchName
                                    status          = [string]$vnic.Status
                                    ipAddresses     = @($vnic.IPAddresses)
                                    ipAddress       = if ($ipConf) { $ipConf.IPv4Address.IPAddress } else { $null }
                                    prefixLength    = if ($ipConf) { $ipConf.IPv4Address.PrefixLength } else { $null }
                                    vlanId          = if ($vlan) { $vlan.AccessVlanId } else { $null }
                                    rdmaEnabled     = if ($rdmaAdapter) { $rdmaAdapter.Enabled } else { $null }
                                    rdmaOperational = if ($rdmaAdapter) { $rdmaAdapter.OperationalState -eq 'Active' } else { $null }
                                    isManagementNic = $vnic.IsManagementOS
                                }
                            })
                        }
                        catch {
                            [void]$rangerDiagnostics.Add("Host vNIC discovery: $($_.Exception.Message)")
                            @()
                        }
                    } else { @() }
                    smbConfig     = $smbConfig
                    liveMigration = $liveMigration
                    intents       = if (Get-Command -Name Get-NetIntent -ErrorAction SilentlyContinue) { Get-NetIntent | Select-Object Name, ClusterName, IsStorageIntent, IsComputeIntent } else { @() }
                    intentOverrides = @($intentOverrides)
                    dcb           = $dcb
                    lldpNeighbors = @($lldpNeighbors)
                    dns           = if (Get-Command -Name Get-DnsClientServerAddress -ErrorAction SilentlyContinue) { Get-DnsClientServerAddress -AddressFamily IPv4 | Select-Object InterfaceAlias, ServerAddresses } else { @() }
                    dnsForwarders = @($dnsForwarders)
                    dnsConditionalForwarders = @($dnsZones)
                    ipAddresses   = if (Get-Command -Name Get-NetIPAddress -ErrorAction SilentlyContinue) { Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Select-Object InterfaceAlias, IPAddress, PrefixLength, AddressState } else { @() }
                    routes        = if (Get-Command -Name Get-NetRoute -ErrorAction SilentlyContinue) { Get-NetRoute -AddressFamily IPv4 -ErrorAction SilentlyContinue | Select-Object -First 50 DestinationPrefix, NextHop, InterfaceAlias, RouteMetric } else { @() }
                    vlan          = if (Get-Command -Name Get-VMNetworkAdapterVlan -ErrorAction SilentlyContinue) { Get-VMNetworkAdapterVlan -ManagementOS -ErrorAction SilentlyContinue | Select-Object VMNetworkAdapterName, OperationMode, AccessVlanId, NativeVlanId, AllowedVlanIdList } else { @() }
                    proxy         = $proxyDetail
                    proxyRaw      = $proxyRaw
                    firewall      = if (Get-Command -Name Get-NetFirewallProfile -ErrorAction SilentlyContinue) { Get-NetFirewallProfile | Select-Object Name, Enabled, DefaultInboundAction, DefaultOutboundAction } else { @() }
                    firewallRuleAudit = @($firewallRuleAudit)
                    inboxDriverAdapters = @($inboxDriverAdapters)
                    rangerDiagnostics = @($rangerDiagnostics)
                    # Issue #60: Full SDN discovery
                    sdn           = if (Get-Command -Name Get-NetworkController -ErrorAction SilentlyContinue) {
                        $ncObj = try { Get-NetworkController -ErrorAction Stop } catch { $null }
                        if ($ncObj) {
                            $ncNodes = @(try { Get-NetworkControllerNode -ErrorAction Stop | Select-Object Name, Server, Status, Fault } catch { @() })
                            $slbMuxes = @(try { Get-NetworkControllerLoadBalancerMux -ErrorAction Stop | Select-Object ResourceId, Status } catch { @() })
                            $rasGateways = @(try { Get-NetworkControllerGateway -ErrorAction Stop | Select-Object ResourceId, GatewayPool, State } catch { @() })
                            $vnets = @(try { Get-NetworkControllerVirtualNetwork -ErrorAction Stop | ForEach-Object { [ordered]@{ resourceId = $_.ResourceId; subnetCount = @($_.Properties.Subnets).Count } } } catch { @() })
                            $nsgs = @(try { Get-NetworkControllerAccessControlList -ErrorAction Stop | ForEach-Object { [ordered]@{ resourceId = $_.ResourceId; ruleCount = @($_.Properties.AclRules).Count } } } catch { @() })
                            $lbs = @(try { Get-NetworkControllerLoadBalancer -ErrorAction Stop | ForEach-Object { [ordered]@{ resourceId = $_.ResourceId; frontendCount = @($_.Properties.FrontendIPConfigurations).Count } } } catch { @() })
                            $logicalNets = @(try { Get-NetworkControllerLogicalNetwork -ErrorAction Stop | Select-Object ResourceId } catch { @() })
                            [ordered]@{
                                deployed            = $true
                                restApiEndpoint     = $ncObj.RestApiEndpoint
                                nodeCount           = @($ncNodes).Count
                                nodes               = @($ncNodes)
                                slbMuxCount         = @($slbMuxes).Count
                                rasGatewayCount     = @($rasGateways).Count
                                virtualNetworkCount = @($vnets).Count
                                nsgCount            = @($nsgs).Count
                                loadBalancerCount   = @($lbs).Count
                                logicalNetworkCount = @($logicalNets).Count
                                slbMuxes            = @($slbMuxes)
                                rasGateways         = @($rasGateways)
                                virtualNetworks     = @($vnets)
                                nsgs                = @($nsgs)
                                loadBalancers       = @($lbs)
                                logicalNetworks     = @($logicalNets)
                            }
                        } else { [ordered]@{ deployed = $false } }
                    } else { [ordered]@{ deployed = $false } }
                }
            }
        }
    )

    if ((@($storageSnapshot.pools).Count + @($storageSnapshot.physicalDisks).Count + @($networkNodes).Count) -eq 0) {
        throw 'Storage and networking collector did not return any usable data.'
    }

    foreach ($diagnostic in @($storageSnapshot.rangerDiagnostics)) {
        if (-not [string]::IsNullOrWhiteSpace($diagnostic)) {
            Write-RangerLog -Level warn -Message $diagnostic
        }
    }
    if ($storageSnapshot -is [System.Collections.IDictionary] -and $storageSnapshot.Contains('rangerDiagnostics')) {
        $storageSnapshot.Remove('rangerDiagnostics') | Out-Null
    }

    foreach ($networkNode in @($networkNodes)) {
        foreach ($diagnostic in @($networkNode.rangerDiagnostics)) {
            if (-not [string]::IsNullOrWhiteSpace($diagnostic)) {
                $nodeName = if ($networkNode.node) { $networkNode.node } else { 'unknown-node' }
                Write-RangerLog -Level warn -Message "[$nodeName] $diagnostic"
            }
        }
        if ($networkNode -is [System.Collections.IDictionary] -and $networkNode.Contains('rangerDiagnostics')) {
            $networkNode.Remove('rangerDiagnostics') | Out-Null
        }
    }

    # Import optional offline device configs provided via hints
    $deviceImport = Invoke-RangerNetworkDeviceConfigImport -Config $Config

    $flattenedAdapters = @(Get-RangerFlattenedCollection -Items @($networkNodes | ForEach-Object { $_.adapters }))
    $flattenedSwitches = @(Get-RangerFlattenedCollection -Items @($networkNodes | ForEach-Object { $_.vSwitches }))
    $flattenedHostVirtualNics = @(Get-RangerFlattenedCollection -Items @($networkNodes | ForEach-Object { $_.hostVirtualNics }))
    $flattenedIntents = @(Get-RangerFlattenedCollection -Items @($networkNodes | ForEach-Object { $_.intents }))
    $flattenedDns = @(Get-RangerFlattenedCollection -Items @($networkNodes | ForEach-Object { $_.dns }))
    $flattenedIpAddresses = @(Get-RangerFlattenedCollection -Items @($networkNodes | ForEach-Object { $_.ipAddresses }))
    $flattenedRoutes = @(Get-RangerFlattenedCollection -Items @($networkNodes | ForEach-Object { $_.routes }))
    $flattenedVlan = @(Get-RangerFlattenedCollection -Items @($networkNodes | ForEach-Object { $_.vlan }))
    $flattenedFirewall = @(Get-RangerFlattenedCollection -Items @($networkNodes | ForEach-Object { $_.firewall }))
    $sdnByNode = @($networkNodes | ForEach-Object { [ordered]@{ node = $_.node; sdn = $_.sdn } })
    $proxyByNode = @($networkNodes | ForEach-Object { [ordered]@{ node = $_.node; proxy = $_.proxy; proxyRaw = $_.proxyRaw } })
    $dcbByNode = @($networkNodes | ForEach-Object { [ordered]@{ node = $_.node; dcb = $_.dcb } })
    $intentOverridesByNode = @($networkNodes | ForEach-Object { [ordered]@{ node = $_.node; intentOverrides = $_.intentOverrides } })
    $lldpByNode = @($networkNodes | ForEach-Object { [ordered]@{ node = $_.node; neighbors = $_.lldpNeighbors } })
    $firewallAuditByNode = @($networkNodes | ForEach-Object { [ordered]@{ node = $_.node; audit = $_.firewallRuleAudit; inboxDriverAdapters = $_.inboxDriverAdapters } })
    $smbByNode = @($networkNodes | ForEach-Object { [ordered]@{ node = $_.node; smbConfig = $_.smbConfig; liveMigration = $_.liveMigration } })
    $dnsForwardersByNode = @($networkNodes | ForEach-Object { [ordered]@{ node = $_.node; forwarders = $_.dnsForwarders; conditionalForwarders = $_.dnsConditionalForwarders } })

    $storageDomain = Update-RangerStorageDomainAnalysis -StorageDomain ([ordered]@{
        pools                 = ConvertTo-RangerHashtable -InputObject $storageSnapshot.pools
        physicalDisks         = ConvertTo-RangerHashtable -InputObject $storageSnapshot.physicalDisks
        physicalDiskReliability = ConvertTo-RangerHashtable -InputObject $storageSnapshot.physicalDiskReliability
        virtualDisks          = ConvertTo-RangerHashtable -InputObject $storageSnapshot.virtualDisks
        volumes               = ConvertTo-RangerHashtable -InputObject $storageSnapshot.volumes
        dedupStatus           = ConvertTo-RangerHashtable -InputObject $storageSnapshot.dedupStatus
        cacheConfig           = ConvertTo-RangerHashtable -InputObject $storageSnapshot.cacheConfig
        scrubSchedule         = ConvertTo-RangerHashtable -InputObject $storageSnapshot.scrubSchedule
        tiers                 = ConvertTo-RangerHashtable -InputObject $storageSnapshot.tiers
        subsystems            = ConvertTo-RangerHashtable -InputObject $storageSnapshot.subsystems
        resiliency            = ConvertTo-RangerHashtable -InputObject $storageSnapshot.resiliency
        jobs                  = ConvertTo-RangerHashtable -InputObject $storageSnapshot.jobs
        csvs                  = ConvertTo-RangerHashtable -InputObject $storageSnapshot.csvs
        qos                   = ConvertTo-RangerHashtable -InputObject $storageSnapshot.qos
        qosFlows              = ConvertTo-RangerHashtable -InputObject $storageSnapshot.qosFlows
        healthFaults          = ConvertTo-RangerHashtable -InputObject $storageSnapshot.healthFaults
        replica               = ConvertTo-RangerHashtable -InputObject $storageSnapshot.replica
        replicaDepth          = ConvertTo-RangerHashtable -InputObject $storageSnapshot.replicaDepth
        summary               = [ordered]@{}
    })
    $storageSummary = $storageDomain.summary

    $networkSummary = [ordered]@{
        nodeCount         = @($networkNodes).Count
        clusterNetworkCount = @($storageSnapshot.clusterNetworks).Count
        adapterCount      = $flattenedAdapters.Count
        adapterStates     = @(Get-RangerGroupedCount -Items $flattenedAdapters -PropertyName 'status')
        vSwitchCount      = $flattenedSwitches.Count
        intentCount       = $flattenedIntents.Count
        dnsServers        = @($flattenedDns | ForEach-Object { @($_.ServerAddresses) } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
        routeCount        = $flattenedRoutes.Count
        vlanCount         = $flattenedVlan.Count
        proxyConfiguredNodes = @($proxyByNode | Where-Object { $_.proxy -and $_.proxy.proxyConfigured }).Count
        sdnDeployed       = @($sdnByNode | Where-Object { $_.sdn.deployed }).Count -gt 0
        importedSwitchConfigCount   = @($deviceImport.switchConfig).Count
        importedFirewallConfigCount = @($deviceImport.firewallConfig).Count
        dcbConfiguredNodes = @($dcbByNode | Where-Object { $_.dcb.trafficClasses -and @($_.dcb.trafficClasses).Count -gt 0 }).Count
        lldpNeighborCount  = @($lldpByNode | ForEach-Object { @($_.neighbors).Count } | Measure-Object -Sum).Sum
        inboxDriverAdapterCount = @($networkNodes | ForEach-Object { @($_.inboxDriverAdapters).Count } | Measure-Object -Sum).Sum
        firewallRulesAudited = @($networkNodes | Select-Object -First 1 | ForEach-Object { @($_.firewallRuleAudit).Count }).Sum
        firewallRequiredPortsSatisfied = @($networkNodes | Select-Object -First 1 | ForEach-Object { @($_.firewallRuleAudit | Where-Object { $_.satisfied }).Count }).Sum
    }

    $findings = New-Object System.Collections.ArrayList
    $unhealthyDisks = @($storageSnapshot.physicalDisks | Where-Object { $_.HealthStatus -and $_.HealthStatus -ne 'Healthy' })
    if ($unhealthyDisks.Count -gt 0) {
        [void]$findings.Add((New-RangerFinding -Severity warning -Title 'Physical disks report a non-healthy state' -Description 'The storage collector found one or more disks that were not healthy.' -AffectedComponents (@($unhealthyDisks | ForEach-Object { $_.FriendlyName })) -CurrentState "$($unhealthyDisks.Count) unhealthy disks" -Recommendation 'Inspect failed or degraded disks and review Storage Spaces Direct health before generating an as-built handoff.'))
    }

    if ($networkSummary.intentCount -eq 0) {
        [void]$findings.Add((New-RangerFinding -Severity informational -Title 'No Network ATC intents were detected' -Description 'The networking collector did not find Network ATC intent metadata in the sampled node inventory.' -CurrentState 'network intent metadata absent' -Recommendation 'Confirm whether Network ATC is intentionally unused or whether the networking inventory needs additional permissions or modules.'))
    }

    $publicFirewallEnabled = @($networkNodes | ForEach-Object { $_.firewall } | Where-Object { $_.Name -eq 'Public' -and $_.Enabled }).Count
    if ($publicFirewallEnabled -gt 0) {
        [void]$findings.Add((New-RangerFinding -Severity informational -Title 'Public firewall profile is enabled on one or more nodes' -Description 'Host-side firewall posture indicates the Public profile is enabled on at least one node.' -CurrentState "$publicFirewallEnabled nodes with Public firewall enabled" -Recommendation 'Review whether the enabled profile aligns with intended host networking and management access posture.'))
    }

    $criticalHealthFaults = @($storageSnapshot.healthFaults | Where-Object { $_.PerceivedSeverity -in @('Fatal', 'Critical', 'Major') })
    if ($criticalHealthFaults.Count -gt 0) {
        [void]$findings.Add((New-RangerFinding -Severity warning -Title 'Active critical or fatal Health Service storage faults detected' -Description "The storage collector found $($criticalHealthFaults.Count) active Health Service fault(s) at Critical or higher severity." -AffectedComponents (@($criticalHealthFaults | ForEach-Object { $_.FaultingObjectDescription })) -CurrentState "$($criticalHealthFaults.Count) active critical storage faults" -Recommendation 'Review active Health Service faults in Windows Admin Center or Get-HealthFault and resolve before formal handoff.'))
    }

    $activeStorageJobs = @($storageSnapshot.jobs | Where-Object { $_.JobState -notin @('Completed', 'CompletedWithWarnings') })
    if ($activeStorageJobs.Count -gt 0) {
        [void]$findings.Add((New-RangerFinding -Severity informational -Title 'Active storage jobs detected' -Description "The storage collector found $($activeStorageJobs.Count) in-progress storage job(s) such as rebuild or rebalance operations." -AffectedComponents (@($activeStorageJobs | ForEach-Object { $_.Name })) -CurrentState "$($activeStorageJobs.Count) active storage jobs" -Recommendation 'Monitor active storage jobs to completion before formal handoff. Review rebuild or rebalance progress.'))
    }

    $allInboxAdapters = @($networkNodes | ForEach-Object { @($_.inboxDriverAdapters) })
    if ($allInboxAdapters.Count -gt 0) {
        [void]$findings.Add((New-RangerFinding -Severity warning -Title 'Adapters using inbox (Microsoft) drivers detected' -Description "$($allInboxAdapters.Count) network adapter(s) are using inbox Microsoft drivers, which are not supported/qualified for Azure Local production workloads." -AffectedComponents (@($allInboxAdapters | ForEach-Object { $_.Name })) -CurrentState 'Inbox driver on production-facing NIC' -Recommendation 'Install vendor-supplied NIC drivers from the OEM or Windows Server Catalog. Inbox drivers are unsupported for Storage and compute traffic on Azure Local.'))
    }

    $retiredDisks = @($storageSnapshot.physicalDisks | Where-Object { $_.operationalStatus -match 'Retiring|PredictiveFailure' })
    if ($retiredDisks.Count -gt 0) {
        [void]$findings.Add((New-RangerFinding -Severity warning -Title 'Physical disks flagged for retirement' -Description "$($retiredDisks.Count) disk(s) have an operational status indicating predictive failure or retirement." -AffectedComponents (@($retiredDisks | ForEach-Object { $_.friendlyName })) -CurrentState "$($retiredDisks.Count) disks in Retiring/PredictiveFailure state" -Recommendation 'Replace disks flagged for retirement before formal handoff to prevent data loss.'))
    }

    foreach ($storageFinding in @(New-RangerStorageAnalysisFindings -StorageDomain $storageDomain)) {
        [void]$findings.Add($storageFinding)
    }

    return @{
        Status        = 'success'
        Domains       = @{
            storage = $storageDomain
            networking = [ordered]@{
                nodes                = ConvertTo-RangerHashtable -InputObject $networkNodes
                clusterNetworks      = ConvertTo-RangerHashtable -InputObject $storageSnapshot.clusterNetworks
                adapters             = ConvertTo-RangerHashtable -InputObject $flattenedAdapters
                vSwitches            = ConvertTo-RangerHashtable -InputObject $flattenedSwitches
                hostVirtualNics      = ConvertTo-RangerHashtable -InputObject $flattenedHostVirtualNics
                intents              = ConvertTo-RangerHashtable -InputObject $flattenedIntents
                intentOverrides      = ConvertTo-RangerHashtable -InputObject $intentOverridesByNode
                dcb                  = ConvertTo-RangerHashtable -InputObject $dcbByNode
                lldpNeighbors        = ConvertTo-RangerHashtable -InputObject $lldpByNode
                dns                  = ConvertTo-RangerHashtable -InputObject $flattenedDns
                dnsForwarders        = ConvertTo-RangerHashtable -InputObject $dnsForwardersByNode
                ipAddresses          = ConvertTo-RangerHashtable -InputObject $flattenedIpAddresses
                routes               = ConvertTo-RangerHashtable -InputObject $flattenedRoutes
                vlan                 = ConvertTo-RangerHashtable -InputObject $flattenedVlan
                proxy                = ConvertTo-RangerHashtable -InputObject $proxyByNode
                firewall             = ConvertTo-RangerHashtable -InputObject @($networkNodes | ForEach-Object { [ordered]@{ node = $_.node; profiles = $_.firewall } })
                firewallRuleAudit    = ConvertTo-RangerHashtable -InputObject $firewallAuditByNode
                sdn                  = ConvertTo-RangerHashtable -InputObject $sdnByNode
                smbConfig            = ConvertTo-RangerHashtable -InputObject $smbByNode
                switchConfig         = ConvertTo-RangerHashtable -InputObject $deviceImport.switchConfig
                firewallConfig       = ConvertTo-RangerHashtable -InputObject $deviceImport.firewallConfig
                summary              = $networkSummary
            }
        }
        Findings      = @($findings)
        Relationships = @()
        RawEvidence   = [ordered]@{
            storage = ConvertTo-RangerHashtable -InputObject $storageSnapshot
            network = ConvertTo-RangerHashtable -InputObject $networkNodes
        }
    }
}