Functions/Private/Find-EvacuationDestination.ps1

function Find-EvacuationDestination {
    <#
    .SYNOPSIS
        Chooses the best destination node for a single VM that must move off a
        specific node (e.g. entering maintenance), regardless of any happiness
        improvement threshold — a destination is required, not merely preferred.

    .DESCRIPTION
        Applies the same destination-filtering rules Find-MigrationCandidates uses
        (possible-owner constraints, the Network-Aware NIC utilization gate, the
        post-migration memory reserve, and hard/soft affinity-rule impact via
        Get-MigrationRuleImpact) but with no minimum-improvement gate, since the VM
        must be placed somewhere rather than only when it would help.

        Side effect: when a destination is found, this function updates the chosen
        destination node's simulated CpuUtilization/AvailableMemoryMB in place on
        -Snapshot.Nodes, and sets -VM.HostNode to the chosen destination. This lets
        a caller evacuating multiple VMs off the same node call this repeatedly in
        a loop without a second VM landing on a destination the first VM's move has
        already filled — the same greedy state-update pattern Find-MigrationCandidates
        applies after each planned migration. No state is mutated when no valid
        destination is found.

    .OUTPUTS
        PSCustomObject (VMName, VMId, SourceNode, DestinationNode, ProjectedScore,
        CpuHappinessAfter, MemHappinessAfter) if a destination is found; $null if no
        candidate satisfies every constraint.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [PSCustomObject]$VM,

        [Parameter(Mandatory)]
        [PSCustomObject]$Snapshot,

        [Parameter(Mandatory)]
        [string]$ExcludeNode,

        [PSCustomObject[]]$RuleSet = @(),

        [float]$CpuWeight    = 0.5,
        [float]$MemoryWeight = 0.5,

        [float]$MaxDestinationNetworkUtil   = 70.0,
        [int]  $DestinationMemoryReserveMB = 512,

        [float]$SoftRuleViolationPenalty = 25.0,
        [float]$RuleComplianceBonus      = 25.0,

        [Parameter(Mandatory)]
        [string]$ClusterName
    )

    $possibleOwners = try {
        (Get-ClusterOwnerNode -Cluster $ClusterName -Group "Virtual Machine $($VM.VMName)" -ErrorAction Stop).OwnerNodes.Name
    } catch {
        $Snapshot.Nodes | Select-Object -ExpandProperty NodeName
    }

    $candidates = $Snapshot.Nodes | Where-Object {
        $_.NodeName -ne $ExcludeNode -and
        ($possibleOwners -contains $_.NodeName) -and
        $_.NetworkUtilization -lt $MaxDestinationNetworkUtil -and
        ($_.AvailableMemoryMB - $VM.MemoryAssignedMB) -ge $DestinationMemoryReserveMB
    }

    $best      = $null
    $bestScore = -1

    foreach ($candidate in $candidates) {
        $impact = if ($RuleSet -and $RuleSet.Count -gt 0) {
            Get-MigrationRuleImpact -VMName $VM.VMName -DestinationNode $candidate.NodeName `
                                    -Snapshot $Snapshot -RuleSet $RuleSet
        } else {
            [PSCustomObject]@{ HasHardViolation = $false; HasSoftViolation = $false; FixesViolation = $false }
        }

        if ($impact.HasHardViolation) { continue }

        $cpuImpact = ($VM.CpuUtilization / 100.0) * ($VM.ProcessorCount / $candidate.LogicalProcessorCount) * 100.0
        $simHost = [PSCustomObject]@{
            NodeName              = $candidate.NodeName
            CpuUtilization        = [Math]::Min(100.0, $candidate.CpuUtilization + $cpuImpact)
            TotalMemoryMB         = $candidate.TotalMemoryMB
            AvailableMemoryMB     = $candidate.AvailableMemoryMB - $VM.MemoryAssignedMB
            LogicalProcessorCount = $candidate.LogicalProcessorCount
            NetworkUtilization    = $candidate.NetworkUtilization
        }

        $simPressure = $VM.MemoryPressure
        if ($VM.DynamicMemoryEnabled -and $candidate.AvailableMemoryMB -gt ($VM.MemoryAssignedMB * 1.5)) {
            $simPressure = [Math]::Min($VM.MemoryPressure, 100.0)
        }

        $simVm = [PSCustomObject]@{
            VMName               = $VM.VMName
            HostNode             = $candidate.NodeName
            CpuUtilization       = $VM.CpuUtilization
            ProcessorCount       = $VM.ProcessorCount
            MemoryAssignedMB     = $VM.MemoryAssignedMB
            MemoryDemandMB       = $VM.MemoryDemandMB
            DynamicMemoryEnabled = $VM.DynamicMemoryEnabled
            MemoryPressure       = $simPressure
        }

        $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) }

        if ($adjustedScore -gt $bestScore) {
            $bestScore = $adjustedScore
            $best = [PSCustomObject]@{
                VMName            = $VM.VMName
                VMId              = $VM.VMId
                SourceNode        = $ExcludeNode
                DestinationNode   = $candidate.NodeName
                ProjectedScore    = [Math]::Round($projected.HappinessScore, 1)
                CpuHappinessAfter = [Math]::Round($projected.CpuHappiness, 1)
                MemHappinessAfter = [Math]::Round($projected.MemHappiness, 1)
            }
        }
    }

    if (-not $best) { return $null }

    # Greedy state update — see the Side effect note above.
    $destNode = $Snapshot.Nodes | Where-Object { $_.NodeName -eq $best.DestinationNode }
    if ($destNode) {
        $cpuImpact = ($VM.CpuUtilization / 100.0) * ($VM.ProcessorCount / $destNode.LogicalProcessorCount) * 100.0
        $destNode.CpuUtilization    = [Math]::Min(100.0, $destNode.CpuUtilization + $cpuImpact)
        $destNode.AvailableMemoryMB = $destNode.AvailableMemoryMB - $VM.MemoryAssignedMB
    }
    $VM.HostNode = $best.DestinationNode

    return $best
}