Functions/Public/Get-HvDRSCapacityForecast.ps1

function Get-HvDRSCapacityForecast {
    <#
    .SYNOPSIS
        Simulates removing or adding a cluster node and reports the projected VM
        happiness impact — a read-only, vSphere-DRS-style "what-if" capacity
        analysis. Never migrates or moves anything.

    .DESCRIPTION
        Two mutually exclusive scenarios:

        -RemoveNode <NodeName>
          Simulates draining that node right now: every VM currently on it is run
          through the same destination-selection logic Enter-HvDRSNodeMaintenance
          uses (Find-EvacuationDestination — Network-Aware gate, memory reserve,
          possible-owner constraints, hard/soft affinity-rule impact) to project
          where it would land. Reports each VM's current vs. projected happiness,
          flags any VM with no valid destination, and reports before/after
          utilization for every remaining node. Nothing on the real cluster is
          touched or queried for possible-owner data beyond the initial snapshot.

        -AddNode <NodeName> -AddNodeCpuCores <int> -AddNodeMemoryMB <int>
          Injects a synthetic idle node with the given specs into the snapshot and
          checks which of the cluster's currently-unhappy VMs (below the
          aggression-level threshold) could be absorbed by it and by how much their
          happiness would improve. This intentionally does NOT go through
          Find-MigrationCandidates' possible-owner check — a node that doesn't
          exist yet in the real cluster has no possible-owner membership to query,
          and the entire point of this scenario is to answer "would adding this
          node help" independent of the cluster-group configuration you'd still
          need to set up afterward.

    .PARAMETER ClusterName
        Target Failover Cluster. Defaults to the local cluster if omitted.

    .PARAMETER RemoveNode
        Simulate draining and removing this node. Mutually exclusive with -AddNode.

    .PARAMETER AddNode
        Name for a hypothetical new node (does not need to exist in the real
        cluster). Mutually exclusive with -RemoveNode.

    .PARAMETER AddNodeCpuCores
        Logical processor count for the hypothetical node (default: 32).

    .PARAMETER AddNodeMemoryMB
        Total/available memory (MB) for the hypothetical node, assumed fully idle
        (default: 131072 MB / 128 GB).

    .PARAMETER AddNodeNetworkUtil
        Assumed NIC utilization percentage for the hypothetical node (default: 0 — idle).

    .PARAMETER AggressionLevel
        Same happiness-threshold/minimum-improvement table as Invoke-HvDRS (default: 3).

    .PARAMETER CpuWeight / MemoryWeight / MaxDestinationNetworkUtil / DestinationMemoryReserveMB
        Same meaning and defaults as the identically-named Invoke-HvDRS parameters.

    .PARAMETER RulesPath
        Path to the JSON affinity rule store. Same default as Invoke-HvDRS.

    .PARAMETER SoftRuleViolationPenalty / RuleComplianceBonus
        Same meaning and defaults as the identically-named Invoke-HvDRS parameters.

    .PARAMETER SampleCount / SampleIntervalSeconds
        Passed through to Get-ClusterSnapshot (defaults: 5 samples / 2s interval).

    .EXAMPLE
        # Can we safely take HV-NODE3 down for hardware maintenance?
        Get-HvDRSCapacityForecast -ClusterName 'PROD-CLUSTER' -RemoveNode 'HV-NODE3'

    .EXAMPLE
        # Would a new 64-core / 256GB node meaningfully help the cluster?
        Get-HvDRSCapacityForecast -ClusterName 'PROD-CLUSTER' -AddNode 'HV-NODE9' -AddNodeCpuCores 64 -AddNodeMemoryMB 262144
    #>

    [CmdletBinding(DefaultParameterSetName = 'RemoveNode')]
    param(
        [string]$ClusterName,

        [Parameter(ParameterSetName = 'RemoveNode', Mandatory)]
        [string]$RemoveNode,

        [Parameter(ParameterSetName = 'AddNode', Mandatory)]
        [string]$AddNode,

        [Parameter(ParameterSetName = 'AddNode')]
        [int]$AddNodeCpuCores = 32,

        [Parameter(ParameterSetName = 'AddNode')]
        [int]$AddNodeMemoryMB = 131072,

        [Parameter(ParameterSetName = 'AddNode')]
        [ValidateRange(0.0, 100.0)]
        [float]$AddNodeNetworkUtil = 0.0,

        [ValidateRange(1, 5)]
        [int]$AggressionLevel = 3,

        [ValidateRange(0.0, 1.0)]
        [float]$CpuWeight = 0.5,

        [ValidateRange(0.0, 1.0)]
        [float]$MemoryWeight = 0.5,

        [ValidateRange(0.0, 100.0)]
        [float]$MaxDestinationNetworkUtil = 70.0,

        [int]$DestinationMemoryReserveMB = 512,

        [string]$RulesPath = (Join-Path (Get-HvDRSDataRoot) 'HvDRS\rules.json'),

        [ValidateRange(0.0, 100.0)]
        [float]$SoftRuleViolationPenalty = 25.0,

        [ValidateRange(0.0, 100.0)]
        [float]$RuleComplianceBonus = 25.0,

        [int]$SampleCount = 5,

        [int]$SampleIntervalSeconds = 2
    )

    # Same aggression-level table as Find-MigrationCandidates/Find-StorageMigrationCandidates
    # — duplicated rather than shared, matching how those two already duplicate it.
    $thresholds = @{
        1 = @{ Happiness = 30; Improvement = 40 }
        2 = @{ Happiness = 40; Improvement = 30 }
        3 = @{ Happiness = 50; Improvement = 20 }
        4 = @{ Happiness = 60; Improvement = 15 }
        5 = @{ Happiness = 70; Improvement = 10 }
    }
    $happinessThreshold   = $thresholds[$AggressionLevel].Happiness
    $improvementThreshold = $thresholds[$AggressionLevel].Improvement

    if (-not $ClusterName) {
        try { $ClusterName = (Get-Cluster -ErrorAction Stop).Name }
        catch { throw "No -ClusterName specified and no local cluster detected. $_" }
    }

    Write-Host "Collecting cluster snapshot..."
    $snapshot = Get-ClusterSnapshot -ClusterName $ClusterName -SampleCount $SampleCount -SampleIntervalSeconds $SampleIntervalSeconds
    $ruleSet  = Get-AffinityRuleSet -Path $RulesPath -ClusterName $ClusterName

    if ($PSCmdlet.ParameterSetName -eq 'RemoveNode') {

        if (-not ($snapshot.Nodes | Where-Object { $_.NodeName -eq $RemoveNode })) {
            throw "Node '$RemoveNode' was not found in cluster '$ClusterName'."
        }

        # Snapshot pre-simulation node state before Find-EvacuationDestination
        # mutates destination nodes in place.
        $nodesBefore = @{}
        foreach ($node in $snapshot.Nodes) {
            $nodesBefore[$node.NodeName] = [PSCustomObject]@{
                CpuUtilization    = $node.CpuUtilization
                AvailableMemoryMB = $node.AvailableMemoryMB
            }
        }

        $vmsOnNode = @($snapshot.VMs | Where-Object { $_.HostNode -eq $RemoveNode })

        Write-Host "Simulating removal of '$RemoveNode' ($($vmsOnNode.Count) VM(s) to place)..."

        $placements = foreach ($vm in $vmsOnNode) {
            $hostMetrics  = $snapshot.Nodes | Where-Object { $_.NodeName -eq $vm.HostNode }
            $currentScore = (Measure-VmHappiness -VmMetrics $vm -HostMetrics $hostMetrics `
                                                 -CpuWeight $CpuWeight -MemoryWeight $MemoryWeight).HappinessScore

            $dest = Find-EvacuationDestination -VM $vm -Snapshot $snapshot -ExcludeNode $RemoveNode `
                                               -RuleSet $ruleSet -CpuWeight $CpuWeight -MemoryWeight $MemoryWeight `
                                               -MaxDestinationNetworkUtil $MaxDestinationNetworkUtil `
                                               -DestinationMemoryReserveMB $DestinationMemoryReserveMB `
                                               -SoftRuleViolationPenalty $SoftRuleViolationPenalty `
                                               -RuleComplianceBonus $RuleComplianceBonus `
                                               -ClusterName $ClusterName

            [PSCustomObject]@{
                VMName         = $vm.VMName
                CurrentNode    = $RemoveNode
                CurrentScore   = [Math]::Round($currentScore, 1)
                ProjectedNode  = if ($dest) { $dest.DestinationNode } else { $null }
                ProjectedScore = if ($dest) { $dest.ProjectedScore } else { $null }
                Placed         = [bool]$dest
            }
        }

        $unplaced = @($placements | Where-Object { -not $_.Placed })
        $feasible = $unplaced.Count -eq 0

        $nodeImpact = foreach ($node in $snapshot.Nodes) {
            if ($node.NodeName -eq $RemoveNode) { continue }
            $before = $nodesBefore[$node.NodeName]
            [PSCustomObject]@{
                NodeName        = $node.NodeName
                CpuBefore       = $before.CpuUtilization
                CpuAfter        = [Math]::Round($node.CpuUtilization, 1)
                FreeMemBeforeMB = $before.AvailableMemoryMB
                FreeMemAfterMB  = [Math]::Round($node.AvailableMemoryMB, 0)
            }
        }

        Write-Host ''
        if ($vmsOnNode.Count -eq 0) {
            Write-Host "No running VMs on '$RemoveNode' — removal is trivially feasible."
        } elseif ($feasible) {
            Write-Host "All $($vmsOnNode.Count) VM(s) on '$RemoveNode' can be placed elsewhere. Removal is FEASIBLE."
        } else {
            Write-Host "$($unplaced.Count) of $($vmsOnNode.Count) VM(s) on '$RemoveNode' have NO valid destination. Removal is NOT feasible as-is." -ForegroundColor Yellow
        }

        if ($placements) {
            Write-Host ''
            Write-Host '── VM Placement Forecast ─────────────────────────────────────────────────────'
            $placements | Format-Table -AutoSize -Property `
                @{ N='VM';        E={ $_.VMName } },
                @{ N='From';      E={ $_.CurrentNode } },
                @{ N='Score';     E={ $_.CurrentScore } },
                @{ N='To';        E={ if ($_.ProjectedNode) { $_.ProjectedNode } else { 'NONE' } } },
                @{ N='New Score'; E={ $_.ProjectedScore } },
                @{ N='Placed';    E={ $_.Placed } } | Out-Host
        }

        Write-Host '── Node Utilization Impact ───────────────────────────────────────────────────'
        $nodeImpact | Format-Table -AutoSize -Property `
            @{ N='Node';        E={ $_.NodeName } },
            @{ N='CPU Before';  E={ '{0:N1}%' -f $_.CpuBefore } },
            @{ N='CPU After';   E={ '{0:N1}%' -f $_.CpuAfter } },
            @{ N='Free Mem Before'; E={ '{0:N0} MB' -f $_.FreeMemBeforeMB } },
            @{ N='Free Mem After';  E={ '{0:N0} MB' -f $_.FreeMemAfterMB } } | Out-Host

        return [PSCustomObject]@{
            ClusterName  = $ClusterName
            Scenario     = 'RemoveNode'
            TargetNode   = $RemoveNode
            Feasible     = $feasible
            # @()-wrapped: a foreach-as-expression with zero iterations (e.g. no
            # VMs on the node, or a single-node cluster) yields $null rather than
            # an empty array, which would otherwise make .Count throw under
            # Set-StrictMode for a caller that doesn't already know to check.
            VMPlacements = @($placements)
            NodeImpact   = @($nodeImpact)
        }
    }

    # ── AddNode scenario ───────────────────────────────────────────────────────
    if ($snapshot.Nodes | Where-Object { $_.NodeName -eq $AddNode }) {
        throw "A node named '$AddNode' already exists in cluster '$ClusterName' — choose a different hypothetical name."
    }

    $syntheticNode = [PSCustomObject]@{
        NodeName              = $AddNode
        CpuUtilization        = 0.0
        TotalMemoryMB          = $AddNodeMemoryMB
        AvailableMemoryMB     = $AddNodeMemoryMB
        UsedMemoryMB          = 0
        LogicalProcessorCount = $AddNodeCpuCores
        NetworkUtilization    = $AddNodeNetworkUtil
        VMs                   = @()
    }

    Write-Host "Simulating addition of '$AddNode' ($AddNodeCpuCores logical processors, $AddNodeMemoryMB MB memory)..."

    if ($syntheticNode.NetworkUtilization -ge $MaxDestinationNetworkUtil) {
        Write-Warning "Hypothetical node's -AddNodeNetworkUtil ($($syntheticNode.NetworkUtilization)%) is at or above -MaxDestinationNetworkUtil ($MaxDestinationNetworkUtil%) — it would never qualify as a migration target."
    }

    $vmScores = foreach ($vm in $snapshot.VMs) {
        $hostMetrics = $snapshot.Nodes | Where-Object { $_.NodeName -eq $vm.HostNode }
        if (-not $hostMetrics) { continue }
        Measure-VmHappiness -VmMetrics $vm -HostMetrics $hostMetrics -CpuWeight $CpuWeight -MemoryWeight $MemoryWeight
    }

    $unhappyVMs = $vmScores | Where-Object { $_.HappinessScore -lt $happinessThreshold } | Sort-Object HappinessScore
    $absorbed   = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($score in $unhappyVMs) {
        $vm = $snapshot.VMs | Where-Object { $_.VMName -eq $score.VMName }
        if (-not $vm) { continue }

        if ($syntheticNode.NetworkUtilization -ge $MaxDestinationNetworkUtil) { continue }
        if (($syntheticNode.AvailableMemoryMB - $vm.MemoryAssignedMB) -lt $DestinationMemoryReserveMB) { continue }

        $impact = if ($ruleSet -and $ruleSet.Count -gt 0) {
            Get-MigrationRuleImpact -VMName $vm.VMName -DestinationNode $AddNode -Snapshot $snapshot -RuleSet $ruleSet
        } else {
            [PSCustomObject]@{ HasHardViolation = $false; HasSoftViolation = $false; FixesViolation = $false }
        }
        if ($impact.HasHardViolation) { continue }

        $cpuImpact = ($vm.CpuUtilization / 100.0) * ($vm.ProcessorCount / $syntheticNode.LogicalProcessorCount) * 100.0
        $simHost = [PSCustomObject]@{
            NodeName              = $syntheticNode.NodeName
            CpuUtilization        = [Math]::Min(100.0, $syntheticNode.CpuUtilization + $cpuImpact)
            TotalMemoryMB         = $syntheticNode.TotalMemoryMB
            AvailableMemoryMB     = $syntheticNode.AvailableMemoryMB - $vm.MemoryAssignedMB
            LogicalProcessorCount = $syntheticNode.LogicalProcessorCount
            NetworkUtilization    = $syntheticNode.NetworkUtilization
        }
        $simVm = [PSCustomObject]@{
            VMName               = $vm.VMName
            HostNode             = $syntheticNode.NodeName
            CpuUtilization       = $vm.CpuUtilization
            ProcessorCount       = $vm.ProcessorCount
            MemoryAssignedMB     = $vm.MemoryAssignedMB
            MemoryDemandMB       = $vm.MemoryDemandMB
            DynamicMemoryEnabled = $vm.DynamicMemoryEnabled
            MemoryPressure       = $vm.MemoryPressure
        }
        $projected = Measure-VmHappiness -VmMetrics $simVm -HostMetrics $simHost -CpuWeight $CpuWeight -MemoryWeight $MemoryWeight

        $adjustedScore = $projected.HappinessScore
        if ($impact.HasSoftViolation) { $adjustedScore = [Math]::Max(0,   $adjustedScore - $SoftRuleViolationPenalty) }
        if ($impact.FixesViolation)   { $adjustedScore = [Math]::Min(100, $adjustedScore + $RuleComplianceBonus) }

        $improvement = $adjustedScore - $score.HappinessScore
        if ($improvement -lt $improvementThreshold) { continue }

        $absorbed.Add([PSCustomObject]@{
            VMName          = $vm.VMName
            CurrentNode     = $vm.HostNode
            CurrentScore    = $score.HappinessScore
            ProjectedScore  = [Math]::Round($projected.HappinessScore, 1)
            Improvement     = [Math]::Round($improvement, 1)
        })

        # Greedy state update on the synthetic node before evaluating the next VM
        $syntheticNode.CpuUtilization    = $simHost.CpuUtilization
        $syntheticNode.AvailableMemoryMB = $simHost.AvailableMemoryMB
    }

    Write-Host ''
    if ($absorbed.Count -eq 0) {
        Write-Host "Adding '$AddNode' would not meaningfully improve any currently-unhappy VM at aggression level $AggressionLevel."
    } else {
        Write-Host "Adding '$AddNode' could absorb $($absorbed.Count) currently-unhappy VM(s):"
        Write-Host ''
        $absorbed | Format-Table -AutoSize -Property `
            @{ N='VM';           E={ $_.VMName } },
            @{ N='Current Node'; E={ $_.CurrentNode } },
            @{ N='Score Before'; E={ $_.CurrentScore } },
            @{ N='Score After';  E={ $_.ProjectedScore } },
            @{ N='Delta';        E={ '+{0}' -f $_.Improvement } } | Out-Host
    }

    return [PSCustomObject]@{
        ClusterName          = $ClusterName
        Scenario             = 'AddNode'
        TargetNode           = $AddNode
        AbsorbedRecommendations = $absorbed.ToArray()
    }
}