Modules/Collectors/10-TopologyClusterCollector.ps1

function Invoke-RangerTopologyClusterCollector {
    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) {
        return ConvertTo-RangerHashtable -InputObject $fixture
    }

    # Issue #110: Arc-first node inventory resolution
    $nodeInventory = Resolve-RangerNodeInventory -Config $Config -ClusterCredential $CredentialMap.cluster
    if ($nodeInventory.Nodes.Count -gt 0) {
        # Update config so all subsequent Invoke-RangerClusterCommand calls use the resolved list
        $Config.targets.cluster.nodes = @($nodeInventory.Nodes)
    }
    if ($nodeInventory.Discrepancies.Count -gt 0) {
        Write-RangerLog -Level warn -Message "Topology collector: Arc and direct node lists differ — discrepancies: $($nodeInventory.Discrepancies -join ', ')"
    }

    $clusterSnapshot = Invoke-RangerSafeAction -Label 'Cluster foundation snapshot' -DefaultValue $null -ScriptBlock {
        Invoke-RangerClusterCommand -Config $Config -Credential $CredentialMap.cluster -SingleTarget -ScriptBlock {
            $cluster = if (Get-Command -Name Get-Cluster -ErrorAction SilentlyContinue) {
                Get-Cluster | Select-Object Name, Id, Domain, ClusterFunctionalLevel, S2DEnabled, CrossSubnetDelay, CrossSiteDelay, DynamicQuorum
            }

            $quorum = if (Get-Command -Name Get-ClusterQuorum -ErrorAction SilentlyContinue) {
                $value = Get-ClusterQuorum
                # Issue #54: per-node vote assignments and witness detail
                $nodeVotes = if (Get-Command -Name Get-ClusterNode -ErrorAction SilentlyContinue) {
                    @(Get-ClusterNode -ErrorAction SilentlyContinue | ForEach-Object {
                        [ordered]@{ name = $_.Name; nodeWeight = $_.NodeWeight; dynamicWeight = $_.DynamicWeight }
                    })
                } else { @() }
                $witnessDetail = if ($value.QuorumType -match 'Cloud') {
                    try {
                        $witnessParams = @($value.QuorumResource | Get-ClusterParameter -ErrorAction Stop | ForEach-Object { [ordered]@{ name = $_.Name; value = [string]$_.Value } })
                        [ordered]@{ type = 'CloudWitness'; parameters = $witnessParams }
                    } catch { [ordered]@{ type = 'CloudWitness'; parameters = @() } }
                } elseif ($value.QuorumType -match 'Disk') {
                    [ordered]@{ type = 'DiskWitness'; diskId = $value.QuorumResourcePath }
                } elseif ($value.QuorumType -match 'FileShare') {
                    [ordered]@{ type = 'FileShareWitness'; sharePath = $value.QuorumResourcePath }
                } else { $null }
                [ordered]@{
                    quorumType           = $value.QuorumType
                    quorumResource       = $value.QuorumResource
                    quorumResourcePath   = $value.QuorumResourcePath
                    dynamicQuorumEnabled = [bool]((Get-Cluster -ErrorAction SilentlyContinue).DynamicQuorum -eq 1)
                    nodeVotes            = @($nodeVotes)
                    witnessDetail        = $witnessDetail
                }
            }

            $faultDomains = if (Get-Command -Name Get-ClusterFaultDomain -ErrorAction SilentlyContinue) {
                Get-ClusterFaultDomain | Select-Object Name, FaultDomainType, Location
            }
            else {
                @()
            }

            $networks = if (Get-Command -Name Get-ClusterNetwork -ErrorAction SilentlyContinue) {
                Get-ClusterNetwork | Select-Object Name, Role, Address, AddressMask, State, Metric, AutoMetric
            }
            else {
                @()
            }

            # Issue #55: CSV capacity, free space, redirected/maintenance state
            $csvs = if (Get-Command -Name Get-ClusterSharedVolume -ErrorAction SilentlyContinue) {
                @(Get-ClusterSharedVolume -ErrorAction SilentlyContinue | ForEach-Object {
                    $csv = $_
                    $stateInfo = try { $csv | Get-ClusterSharedVolumeState -ErrorAction Stop } catch { $null }
                    $volPath = if ($stateInfo -and $stateInfo.VolumeLocalPath) { $stateInfo.VolumeLocalPath } else { $null }
                    $volInfo = if ($volPath) { Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $volPath } | Select-Object -First 1 Size, SizeRemaining } else { $null }
                    [ordered]@{
                        name              = $csv.Name
                        state             = [string]$csv.State
                        ownerNode         = if ($csv.OwnerNode) { $csv.OwnerNode.Name } else { $null }
                        volumeFriendlyName = if ($stateInfo) { $stateInfo.VolumeFriendlyName } else { $null }
                        volumeLocalPath   = $volPath
                        inMaintenanceMode = if ($stateInfo) { [bool]$stateInfo.IsInMaintenance } else { $null }
                        isBlockRedirected = if ($stateInfo) { [bool]$stateInfo.IsBlockRedirected } else { $null }
                        redirectReason    = if ($stateInfo) { [string]$stateInfo.BlockRedirectedIOReason } else { $null }
                        totalSizeGiB      = if ($volInfo -and $volInfo.Size) { [math]::Round($volInfo.Size / 1GB, 2) } else { $null }
                        freeSpaceGiB      = if ($volInfo -and $volInfo.SizeRemaining) { [math]::Round($volInfo.SizeRemaining / 1GB, 2) } else { $null }
                        percentFree       = if ($volInfo -and $volInfo.Size -and $volInfo.Size -gt 0) { [math]::Round(($volInfo.SizeRemaining / $volInfo.Size) * 100, 1) } else { $null }
                    }
                })
            } else { @() }

            $groups = if (Get-Command -Name Get-ClusterGroup -ErrorAction SilentlyContinue) {
                Get-ClusterGroup | Select-Object Name, GroupType, State, OwnerNode
            }
            else {
                @()
            }

            $cau = if (Get-Command -Name Get-CauClusterRole -ErrorAction SilentlyContinue) {
                Get-CauClusterRole | Select-Object ClusterName, MaxRetriesPerNode, MaxFailedNodes, RequireAllNodesOnline, StartDate, DaysOfWeek, EnableFirewallRules, RebootMode, SelfUpdating, CauPluginName, CauPluginArguments
            }

            $cauRunHistory = if (Get-Command -Name Get-CauReport -ErrorAction SilentlyContinue) {
                @(try { Get-CauReport -ErrorAction Stop | Sort-Object -Property RunDate -Descending | Select-Object -First 5 Status, LastNodeCompleted, NodeResults, RunDate, Description } catch { @() })
            } else { @() }

            $solutionUpdateEnv = if (Get-Command -Name Get-SolutionUpdateEnvironment -ErrorAction SilentlyContinue) {
                try { Get-SolutionUpdateEnvironment -ErrorAction Stop | Select-Object State, Version, SbeVersion, HardwareModel, LastCheckedForUpdates, LastUpdated, LifecycleUri } catch { $null }
            } else { $null }

            $solutionUpdateHistory = if (Get-Command -Name Get-SolutionUpdateRun -ErrorAction SilentlyContinue) {
                @(try { Get-SolutionUpdateRun -ErrorAction Stop | Sort-Object -Property StartTimeUtc -Descending | Select-Object -First 5 RunId, State, StartTimeUtc, LastUpdatedTimeUtc, Version, Description } catch { @() })
            } elseif (Get-Command -Name Get-SolutionUpdate -ErrorAction SilentlyContinue) {
                @(try { Get-SolutionUpdate -ErrorAction Stop | Where-Object { $_.State -notin @('ReadyToInstall', 'Staged', 'NotApplicable') } | Select-Object -First 5 Version, State, Description, PreparedTime, InstalledTime } catch { @() })
            } else { @() }

            $pendingSolutionUpdates = if (Get-Command -Name Get-SolutionUpdate -ErrorAction SilentlyContinue) {
                @(try { Get-SolutionUpdate -ErrorAction Stop | Where-Object { $_.State -in @('ReadyToInstall', 'Staged') } | Select-Object -First 10 Version, State, Description, PackagePath } catch { @() })
            } else { @() }

            $arcRegistration = if (Get-Command -Name Get-AzureLocalRegistration -ErrorAction SilentlyContinue) {
                try { Get-AzureLocalRegistration -ErrorAction Stop | Select-Object RegistrationStatus, AzureSubscriptionId, AzureResourceName, AzureResourceGroupName, AzureTenantId, HybridSKU, BillingModel, RegistrationDate } catch { $null }
            } elseif (Get-Command -Name Get-AzStackHciRegistration -ErrorAction SilentlyContinue) {
                try { Get-AzStackHciRegistration -ErrorAction Stop | Select-Object RegistrationStatus, AzureSubscriptionId, AzureResourceName, AzureResourceGroupName, AzureTenantId } catch { $null }
            } else { $null }

            $events = if (Get-Command -Name Get-WinEvent -ErrorAction SilentlyContinue) {
                Get-WinEvent -LogName 'Microsoft-Windows-FailoverClustering/Operational' -MaxEvents 20 -ErrorAction SilentlyContinue |
                    Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message
            }
            else {
                @()
            }

            $clusterCreationEvent = if (Get-Command -Name Get-WinEvent -ErrorAction SilentlyContinue) {
                Get-WinEvent -LogName 'Microsoft-Windows-FailoverClustering/Operational' -MaxEvents 1 -Oldest -ErrorAction SilentlyContinue |
                    Select-Object -First 1 TimeCreated
            }

            $release = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction SilentlyContinue
            $licensing = Get-CimInstance -ClassName SoftwareLicensingProduct -ErrorAction SilentlyContinue |
                Where-Object { $_.PartialProductKey -and $_.Name -match 'Windows' } |
                Select-Object -First 5 Name, Description, LicenseStatus, GracePeriodRemaining
            $validationReports = Get-ChildItem -Path (Join-Path $env:SystemRoot 'Cluster\Reports') -Filter '*Test*.htm*' -ErrorAction SilentlyContinue |
                Sort-Object -Property LastWriteTime -Descending |
                Select-Object -First 5 Name, FullName, LastWriteTime, Length
            $lifecycleServices = @(
                foreach ($serviceName in @('CauService', '*Lifecycle*', '*LCM*', 'MocGateway', 'WacsService')) {
                    Get-Service -Name $serviceName -ErrorAction SilentlyContinue | Select-Object Name, DisplayName, Status, StartType
                }
            )

            [ordered]@{
                cluster      = $cluster
                quorum       = $quorum
                faultDomains = @($faultDomains)
                networks     = @($networks)
                csvs         = @($csvs)
                groups       = @($groups)
                cau          = $cau
                cauRunHistory = @($cauRunHistory)
                solutionUpdateEnv = $solutionUpdateEnv
                solutionUpdateHistory = @($solutionUpdateHistory)
                pendingSolutionUpdates = @($pendingSolutionUpdates)
                arcRegistration = $arcRegistration
                events       = @($events)
                clusterCreationEvent = $clusterCreationEvent
                release      = $release
                licensing    = @($licensing)
                validationReports = @($validationReports)
                lifecycleServices = @($lifecycleServices)
            }
        }
    }

    $nodeSnapshots = @(
        Invoke-RangerSafeAction -Label 'Cluster node inventory' -DefaultValue @() -ScriptBlock {
            Invoke-RangerClusterCommand -Config $Config -Credential $CredentialMap.cluster -ScriptBlock {
                $operatingSystem = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue
                $computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue
                $bios = Get-CimInstance -ClassName Win32_BIOS -ErrorAction SilentlyContinue
                $processors = Get-CimInstance -ClassName Win32_Processor -ErrorAction SilentlyContinue
                $clusterNodeState = if (Get-Command -Name Get-ClusterNode -ErrorAction SilentlyContinue) {
                    (Get-ClusterNode -Name $env:COMPUTERNAME -ErrorAction SilentlyContinue).State
                }

                $nodePendingUpdates = if (Get-Command -Name Get-SolutionUpdate -ErrorAction SilentlyContinue) {
                    @(try { Get-SolutionUpdate -ErrorAction Stop | Where-Object { $_.State -in @('ReadyToInstall', 'Staged') } | Select-Object -First 10 Version, State, Description } catch { @() })
                } else {
                    @(try {
                        $wuSession = New-Object -ComObject Microsoft.Update.Session -ErrorAction Stop
                        $wuSearcher = $wuSession.CreateUpdateSearcher()
                        $wuResult = $wuSearcher.Search('IsInstalled=0')
                        @($wuResult.Updates | Select-Object -First 20 | ForEach-Object {
                            [ordered]@{ title = $_.Title; kbArticleIds = @($_.KBArticleIDs); classification = @($_.Categories | ForEach-Object { $_.Name }) -join ',' }
                        })
                    } catch { @() })
                }

                # Issue #53: OS detail depth, OU location, roles/features, SSH, workgroup
                $ouDistinguishedName = if ($computerSystem.PartOfDomain) {
                    try { (Get-ADComputer -Identity $env:COMPUTERNAME -Properties DistinguishedName -ErrorAction Stop).DistinguishedName } catch { $null }
                } else { $null }
                $installedRoles = @(try { Get-WindowsFeature -ErrorAction Stop | Where-Object { $_.Installed } | Select-Object Name, DisplayName } catch { @() })
                $sshEnabled = try {
                    $sshdSvc = Get-Service -Name 'sshd' -ErrorAction SilentlyContinue
                    if ($sshdSvc) { [bool]($sshdSvc.Status -eq 'Running') }
                    else { $cap = Get-WindowsCapability -Online -Name 'OpenSSH.Server*' -ErrorAction SilentlyContinue; if ($cap) { $cap.State -eq 'Installed' } else { $false } }
                } catch { $false }

                # Issue #56: Reboot pending detection
                $rebootPending = try {
                    $rb = $false
                    if (Get-Item 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' -ErrorAction SilentlyContinue) { $rb = $true }
                    if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' -ErrorAction SilentlyContinue) { $rb = $true }
                    if ((Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name 'PendingFileRenameOperations' -ErrorAction SilentlyContinue).PendingFileRenameOperations) { $rb = $true }
                    $rb
                } catch { $null }

                # Issue #74: 7-day event log severity aggregation per node
                $eventLogAggregation = @(foreach ($logSpec in @('System', 'Application', 'Microsoft-Windows-Health/Operational', 'Microsoft-Windows-FailoverClustering/Operational')) {
                    try {
                        $cutoff = (Get-Date).AddDays(-7)
                        $evts = @(Get-WinEvent -FilterHashtable @{ LogName = $logSpec; Level = @(1,2); StartTime = $cutoff } -ErrorAction Stop)
                        [ordered]@{
                            logName       = $logSpec
                            criticalCount = @($evts | Where-Object { $_.Level -eq 1 }).Count
                            errorCount    = @($evts | Where-Object { $_.Level -eq 2 }).Count
                            totalCount    = $evts.Count
                            topEventIds   = @($evts | Group-Object -Property Id | Sort-Object Count -Descending | Select-Object -First 5 | ForEach-Object {
                                $smp = $_.Group[0].Message; [ordered]@{ eventId = [string]$_.Name; count = $_.Count; level = $_.Group[0].LevelDisplayName; sample = if ($smp) { $smp.Substring(0, [Math]::Min(200, $smp.Length)) } else { $null } }
                            })
                        }
                    } catch {
                        [ordered]@{ logName = $logSpec; criticalCount = 0; errorCount = 0; totalCount = 0; topEventIds = @() }
                    }
                })

                [ordered]@{
                    name                = $env:COMPUTERNAME
                    fqdn                = if ($computerSystem.DNSHostName -and $computerSystem.Domain) { "{0}.{1}" -f $computerSystem.DNSHostName, $computerSystem.Domain } else { $computerSystem.DNSHostName }
                    state               = if ($clusterNodeState) { [string]$clusterNodeState } else { 'Up' }
                    uptimeHours         = if ($operatingSystem.LastBootUpTime) { [math]::Round(((Get-Date) - $operatingSystem.LastBootUpTime).TotalHours, 2) } else { $null }
                    osCaption           = $operatingSystem.Caption
                    osVersion           = $operatingSystem.Version
                    osBuildNumber       = $operatingSystem.BuildNumber
                    osEditionSku        = $operatingSystem.OperatingSystemSKU
                    lastBootUpTime      = $operatingSystem.LastBootUpTime
                    manufacturer        = $computerSystem.Manufacturer
                    model               = $computerSystem.Model
                    totalMemoryGiB      = if ($computerSystem.TotalPhysicalMemory) { [math]::Round(([double]$computerSystem.TotalPhysicalMemory / 1GB), 2) } else { $null }
                    logicalProcessorCount = @($processors | ForEach-Object { $_.NumberOfLogicalProcessors } | Measure-Object -Sum).Sum
                    partOfDomain        = [bool]$computerSystem.PartOfDomain
                    domain              = $computerSystem.Domain
                    workgroupName       = if (-not $computerSystem.PartOfDomain) { $computerSystem.Workgroup } else { $null }
                    ouDistinguishedName = $ouDistinguishedName
                    installedRoles      = @($installedRoles | ForEach-Object { [ordered]@{ name = $_.Name; displayName = $_.DisplayName } })
                    installedRoleCount  = @($installedRoles).Count
                    sshEnabled          = $sshEnabled
                    biosVersion         = if ($bios) { @($bios.SMBIOSBIOSVersion) -join ', ' } else { $null }
                    rebootPending       = $rebootPending
                    pendingUpdates      = @($nodePendingUpdates)
                    pendingUpdateCount  = @($nodePendingUpdates).Count
                    eventLogAggregation = @($eventLogAggregation)
                }
            }
        }
    )

    # Issue #106: Detect nodes listed in config that did not respond to WinRM
    $respondedNodeNames = @($nodeSnapshots | ForEach-Object { ($_.name -split '\.')[0].ToUpperInvariant() })
    $unreachableNodes = @()
    if ($Config.targets.cluster.nodes) {
        $unreachableNodes = @($Config.targets.cluster.nodes | Where-Object {
            ($_ -split '\.')[0].ToUpperInvariant() -notin $respondedNodeNames
        })
    }

    if (-not $clusterSnapshot -and $nodeSnapshots.Count -eq 0) {
        throw 'Cluster topology collector could not gather any usable data.'
    }

    if (-not $clusterSnapshot) {
        $clusterSnapshot = [ordered]@{
            cluster      = [ordered]@{}
            quorum       = [ordered]@{}
            faultDomains = @()
            networks     = @()
            csvs         = @()
            groups       = @()
            cau          = $null
            events       = @()
            release      = [ordered]@{}
            licensing    = @()
            validationReports = @()
            lifecycleServices = @()
        }
    }

    $deploymentType = if (-not [string]::IsNullOrWhiteSpace((Get-RangerHintValue -Config $Config -Name 'deploymentType'))) { [string](Get-RangerHintValue -Config $Config -Name 'deploymentType') } elseif (@($clusterSnapshot.networks).Count -le 1) { 'switchless' } else { 'hyperconverged' }
    $identityMode = if (-not [string]::IsNullOrWhiteSpace((Get-RangerHintValue -Config $Config -Name 'identityMode'))) { [string](Get-RangerHintValue -Config $Config -Name 'identityMode') } elseif (@($nodeSnapshots | Where-Object { -not $_.partOfDomain }).Count -gt 0) { 'local-key-vault' } else { 'ad' }
    $controlPlaneMode = if (-not [string]::IsNullOrWhiteSpace((Get-RangerHintValue -Config $Config -Name 'controlPlaneMode'))) { [string](Get-RangerHintValue -Config $Config -Name 'controlPlaneMode') } elseif ([string]::IsNullOrWhiteSpace($Config.targets.azure.subscriptionId)) { 'disconnected' } else { 'connected' }
    $storageArchitecture = if ($clusterSnapshot.cluster.S2DEnabled) { 'storage-spaces-direct' } else { 'shared-storage' }
    $networkArchitecture = if (@($clusterSnapshot.networks).Count -le 1) { 'switchless' } else { 'switched' }

    # Issue #71: Topology classification — rack/site counts, connectivity model, variant prerequisites
    $rackCount = @($clusterSnapshot.faultDomains | Where-Object { [string]$_.FaultDomainType -match 'Rack' }).Count
    $siteCount = @($clusterSnapshot.faultDomains | Where-Object { [string]$_.FaultDomainType -match 'Site' }).Count
    $nodeCount = @($nodeSnapshots).Count
    $azureConnectivityModel = if (-not [string]::IsNullOrWhiteSpace((Get-RangerHintValue -Config $Config -Name 'azureConnectivityModel'))) {
        [string](Get-RangerHintValue -Config $Config -Name 'azureConnectivityModel')
    } elseif ($controlPlaneMode -eq 'disconnected') { 'disconnected' } else { 'direct' }
    $variantPrerequisites = [ordered]@{
        customLocationRequired    = ($deploymentType -ne 'switchless')
        arcResourceBridgeRequired = ($deploymentType -ne 'switchless')
        keyVaultRequired          = ($identityMode -eq 'local-key-vault')
        multiRackIndicators       = ($rackCount -gt 1)
        switchlessIndicators      = ($deploymentType -eq 'switchless' -or @($clusterSnapshot.networks).Count -le 1)
        disconnectedIndicators    = ($controlPlaneMode -eq 'disconnected')
    }

    $variantMarkers = @()
    if ($deploymentType -eq 'switchless') { $variantMarkers += 'switchless' }
    if ($identityMode -eq 'local-key-vault') { $variantMarkers += 'local-identity' }
    if ($controlPlaneMode -eq 'disconnected') { $variantMarkers += 'disconnected' }
    if ($controlPlaneMode -eq 'connected') { $variantMarkers += 'connected' }
    if ($rackCount -gt 1) { $variantMarkers += 'multi-rack' }
    if ($siteCount -gt 1) { $variantMarkers += 'multi-site' }

    $healthSummary = [ordered]@{
        totalNodes   = @($nodeSnapshots).Count
        healthyNodes = @($nodeSnapshots | Where-Object { $_.state -eq 'Up' }).Count
        unhealthy    = @($nodeSnapshots | Where-Object { $_.state -ne 'Up' }).Count
    }

    $nodeSummary = [ordered]@{
        manufacturers       = @(Get-RangerGroupedCount -Items $nodeSnapshots -PropertyName 'manufacturer')
        models              = @(Get-RangerGroupedCount -Items $nodeSnapshots -PropertyName 'model')
        totalMemoryGiB      = [math]::Round((@($nodeSnapshots | Where-Object { $null -ne $_.totalMemoryGiB } | Measure-Object -Property totalMemoryGiB -Sum).Sum), 2)
        totalLogicalCpu     = @($nodeSnapshots | Where-Object { $null -ne $_.logicalProcessorCount } | Measure-Object -Property logicalProcessorCount -Sum).Sum
        domainJoinedNodes   = @($nodeSnapshots | Where-Object { $_.partOfDomain }).Count
        localIdentityNodes  = @($nodeSnapshots | Where-Object { -not $_.partOfDomain }).Count
        rebootPendingNodes  = @($nodeSnapshots | Where-Object { $_.rebootPending -eq $true }).Count
        sshEnabledNodes     = @($nodeSnapshots | Where-Object { $_.sshEnabled -eq $true }).Count
    }

    $faultDomainSummary = [ordered]@{
        count     = @($clusterSnapshot.faultDomains).Count
        byType    = @(Get-RangerGroupedCount -Items $clusterSnapshot.faultDomains -PropertyName 'FaultDomainType')
        locations = @(Get-RangerGroupedCount -Items $clusterSnapshot.faultDomains -PropertyName 'Location')
    }

    $networkSummary = [ordered]@{
        clusterNetworkCount = @($clusterSnapshot.networks).Count
        byRole              = @(Get-RangerGroupedCount -Items $clusterSnapshot.networks -PropertyName 'Role')
        switched            = @($clusterSnapshot.networks).Count -gt 1
    }

    $findings = New-Object System.Collections.ArrayList
    if ($healthSummary.unhealthy -gt 0) {
        [void]$findings.Add((New-RangerFinding -Severity warning -Title 'One or more cluster nodes are not up' -Description 'The cluster foundation collector detected nodes that were not in the Up state.' -AffectedComponents (@($nodeSnapshots | Where-Object { $_.state -ne 'Up' } | ForEach-Object { $_.name })) -CurrentState "$($healthSummary.unhealthy) nodes not up" -Recommendation 'Review cluster membership, maintenance state, and failover clustering health.'))
    }

    if (@($clusterSnapshot.faultDomains).Count -eq 0) {
        [void]$findings.Add((New-RangerFinding -Severity informational -Title 'No cluster fault domains were discovered' -Description 'Ranger did not find rack or site-oriented fault domain metadata in the cluster snapshot.' -CurrentState 'fault-domain metadata absent' -Recommendation 'Confirm whether fault domains are intentionally not modeled or whether additional cluster foundation metadata should be configured.'))
    }

    if (-not $clusterSnapshot.cau) {
        [void]$findings.Add((New-RangerFinding -Severity informational -Title 'Cluster-Aware Updating role was not detected' -Description 'The collector did not find a Cluster-Aware Updating role in the sampled cluster state.' -CurrentState 'cau not detected' -Recommendation 'Confirm whether Cluster-Aware Updating is intentionally unmanaged or whether update orchestration data needs to be collected another way.'))
    }

    if (@($clusterSnapshot.validationReports).Count -eq 0) {
        [void]$findings.Add((New-RangerFinding -Severity informational -Title 'No recent cluster validation reports were discovered' -Description 'The collector did not find recent Test-Cluster style report artifacts in the cluster reports folder.' -CurrentState 'validation history not discovered' -Recommendation 'If validation reports exist elsewhere, record that evidence path; otherwise consider capturing a fresh validation run for formal handoff material.'))
    }

    if ($controlPlaneMode -eq 'disconnected') {
        [void]$findings.Add((New-RangerFinding -Severity informational -Title 'Azure connectivity context is disconnected or undefined' -Description 'The cluster target does not currently advertise an Azure subscription context in the Ranger configuration.' -CurrentState $controlPlaneMode -Recommendation 'Confirm whether this deployment should operate disconnected or if Azure registration metadata is missing from the config.'))
    }

    if (@($clusterSnapshot.pendingSolutionUpdates).Count -gt 0) {
        [void]$findings.Add((New-RangerFinding -Severity warning -Title 'Pending solution updates detected on the cluster' -Description "The collector found $(@($clusterSnapshot.pendingSolutionUpdates).Count) solution update(s) in ReadyToInstall or Staged state." -AffectedComponents @($clusterSnapshot.cluster.Name) -CurrentState "$(@($clusterSnapshot.pendingSolutionUpdates).Count) pending updates" -Recommendation 'Review pending solution updates and schedule an appropriate maintenance window to apply them.'))
    }

    # Issue #106: emit finding for unreachable cluster nodes
    if ($unreachableNodes.Count -gt 0) {
        [void]$findings.Add((New-RangerFinding -Severity warning `
            -Title 'One or more cluster nodes did not respond' `
            -Description "Ranger attempted to collect data from $($unreachableNodes.Count) node(s) that did not respond via WinRM. These nodes are listed in the configuration but returned no inventory data." `
            -AffectedComponents @($unreachableNodes) `
            -CurrentState "Unreachable: $($unreachableNodes -join ', ')" `
            -Recommendation 'Verify that each node is online, that the WinRM service is running, and that the cluster credential has permission to connect. Check firewall rules allowing port 5985/5986.'))
    }

    # Issue #110: emit finding when Arc and direct node lists disagree
    if ($nodeInventory.Discrepancies.Count -gt 0) {
        [void]$findings.Add((New-RangerFinding -Severity warning `
            -Title 'Arc and cluster node lists disagree' `
            -Description 'Ranger detected nodes present in one source (Azure Arc or direct cluster scan) but missing in the other. This may indicate a recently decommissioned node or an Arc registration sync lag.' `
            -AffectedComponents @($nodeInventory.Discrepancies) `
            -CurrentState "Discrepant nodes: $($nodeInventory.Discrepancies -join ', ')" `
            -Recommendation 'Verify that all registered nodes are Arc-connected and participating in the cluster. Investigate any nodes not present in both sources.'))
    }

    return @{
        Status        = if ($healthSummary.unhealthy -gt 0) { 'partial' } else { 'success' }
        Topology      = [ordered]@{
            deploymentType      = $deploymentType
            identityMode        = $identityMode
            controlPlaneMode    = $controlPlaneMode
            storageArchitecture = $storageArchitecture
            networkArchitecture = $networkArchitecture
            variantMarkers      = @($variantMarkers)
            rackCount           = $rackCount
            siteCount           = $siteCount
            nodeCount           = $nodeCount
            azureConnectivityModel = $azureConnectivityModel
            variantPrerequisites = $variantPrerequisites
        }
        Domains       = @{
            clusterNode = [ordered]@{
                cluster       = [ordered]@{
                    name                  = $clusterSnapshot.cluster.Name
                    id                    = $clusterSnapshot.cluster.Id
                    domain                = $clusterSnapshot.cluster.Domain
                    clusterFunctionalLevel = $clusterSnapshot.cluster.ClusterFunctionalLevel
                    s2dEnabled            = $clusterSnapshot.cluster.S2DEnabled
                    dynamicQuorum         = $clusterSnapshot.cluster.DynamicQuorum
                    registrationConfigured = -not [string]::IsNullOrWhiteSpace($Config.targets.azure.subscriptionId)
                    productName           = $clusterSnapshot.release.ProductName
                    displayVersion        = $clusterSnapshot.release.DisplayVersion
                    releaseId             = $clusterSnapshot.release.ReleaseId
                    currentBuild          = $clusterSnapshot.release.CurrentBuild
                    editionId             = $clusterSnapshot.release.EditionID
                    installationType      = $clusterSnapshot.release.InstallationType
                    operatingModel        = [ordered]@{ deploymentType = $deploymentType; identityMode = $identityMode; controlPlaneMode = $controlPlaneMode }
                    registration          = [ordered]@{ subscriptionId = $Config.targets.azure.subscriptionId; resourceGroup = $Config.targets.azure.resourceGroup; tenantId = $Config.targets.azure.tenantId }
                    licensing             = ConvertTo-RangerHashtable -InputObject $clusterSnapshot.licensing
                }
                nodes         = @($nodeSnapshots)
                quorum        = ConvertTo-RangerHashtable -InputObject $clusterSnapshot.quorum
                faultDomains  = ConvertTo-RangerHashtable -InputObject $clusterSnapshot.faultDomains
                networks      = ConvertTo-RangerHashtable -InputObject $clusterSnapshot.networks
                roles         = ConvertTo-RangerHashtable -InputObject $clusterSnapshot.groups
                csvSummary    = [ordered]@{
                    count             = @($clusterSnapshot.csvs).Count
                    items             = ConvertTo-RangerHashtable -InputObject $clusterSnapshot.csvs
                    redirectedCount   = @($clusterSnapshot.csvs | Where-Object { $_.isBlockRedirected }).Count
                    maintenanceCount  = @($clusterSnapshot.csvs | Where-Object { $_.inMaintenanceMode }).Count
                    totalCapacityGiB  = [math]::Round((@($clusterSnapshot.csvs | Where-Object { $null -ne $_.totalSizeGiB } | Measure-Object -Property totalSizeGiB -Sum).Sum), 2)
                    totalFreeSpaceGiB = [math]::Round((@($clusterSnapshot.csvs | Where-Object { $null -ne $_.freeSpaceGiB } | Measure-Object -Property freeSpaceGiB -Sum).Sum), 2)
                }
                updatePosture = [ordered]@{
                    clusterAwareUpdating   = ConvertTo-RangerHashtable -InputObject $clusterSnapshot.cau
                    cauRunHistory          = ConvertTo-RangerHashtable -InputObject $clusterSnapshot.cauRunHistory
                    solutionUpdateEnv      = ConvertTo-RangerHashtable -InputObject $clusterSnapshot.solutionUpdateEnv
                    solutionUpdateHistory  = ConvertTo-RangerHashtable -InputObject $clusterSnapshot.solutionUpdateHistory
                    pendingSolutionUpdates = ConvertTo-RangerHashtable -InputObject $clusterSnapshot.pendingSolutionUpdates
                    lifecycleServices      = ConvertTo-RangerHashtable -InputObject $clusterSnapshot.lifecycleServices
                    validationReports      = ConvertTo-RangerHashtable -InputObject $clusterSnapshot.validationReports
                    pendingSolutionUpdateCount = @($clusterSnapshot.pendingSolutionUpdates).Count
                    rebootPendingNodes         = @($nodeSnapshots | Where-Object { $_.rebootPending -eq $true } | ForEach-Object { $_.name })
                    rebootPendingCount         = @($nodeSnapshots | Where-Object { $_.rebootPending -eq $true }).Count
                }
                registration  = [ordered]@{
                    subscriptionId        = $Config.targets.azure.subscriptionId
                    resourceGroup         = $Config.targets.azure.resourceGroup
                    tenantId              = $Config.targets.azure.tenantId
                    arcRegistrationDetail = ConvertTo-RangerHashtable -InputObject $clusterSnapshot.arcRegistration
                    clusterCreationHint   = if ($clusterSnapshot.clusterCreationEvent) { $clusterSnapshot.clusterCreationEvent.TimeCreated.ToString('o') } else { $null }
                }
                eventSummary      = ConvertTo-RangerHashtable -InputObject $clusterSnapshot.events
                eventAggregation  = ConvertTo-RangerHashtable -InputObject @($nodeSnapshots | ForEach-Object { [ordered]@{ node = $_.name; logs = $_.eventLogAggregation } })
                healthSummary     = $healthSummary
                nodeSummary       = $nodeSummary
                faultDomainSummary = $faultDomainSummary
                networkSummary    = $networkSummary
                topologyClassification = [ordered]@{
                    rackCount           = $rackCount
                    siteCount           = $siteCount
                    nodeCount           = $nodeCount
                    azureConnectivityModel = $azureConnectivityModel
                    variantPrerequisites = $variantPrerequisites
                }
            }
        }
        Findings      = @($findings)
        Relationships = @()
        RawEvidence   = [ordered]@{
            cluster       = ConvertTo-RangerHashtable -InputObject $clusterSnapshot
            nodes         = ConvertTo-RangerHashtable -InputObject $nodeSnapshots
            nodeInventory = [ordered]@{
                resolvedNodes = @($nodeInventory.Nodes)
                sources       = @($nodeInventory.Sources)
                discrepancies = @($nodeInventory.Discrepancies)
                arcResourceId = if ($nodeInventory.ArcResource) { $nodeInventory.ArcResource.ResourceId } else { $null }
            }
        }
    }
}