Functions/Private/Find-MigrationCandidates.ps1

function Find-MigrationCandidates {
    <#
    .SYNOPSIS
        Identifies VMs that would benefit from Live Migration and selects optimal destination nodes,
        now with full affinity / anti-affinity rule awareness.

    .DESCRIPTION
        Two-pass algorithm:

        Pass 1 — Compliance (hard-rule violations in the current placement)
          For each enforced rule that is currently violated, Find-MigrationCandidates selects
          the best (VM, destination) pair that resolves the violation without introducing any
          new hard-rule violations. These migrations are added to the plan first, ahead of any
          happiness-based recommendations, and the simulated cluster state is updated so that
          subsequent decisions account for them.

        Pass 2 — Happiness (load balancing)
          Each VM below the aggression-level happiness threshold is evaluated against every
          candidate destination node. The rule impact of each proposed move is checked:
            • Hard violation → destination excluded.
            • Soft violation → configurable score penalty applied to the projected happiness.
            • Fixes a violation → configurable score bonus applied.
          Only moves whose net improvement (post-adjustment) meets the aggression threshold
          are included in the plan.

    .PARAMETER RuleSet
        Array of affinity / anti-affinity rule objects returned by Get-AffinityRuleSet.
        Pass an empty array or omit to disable rule checking entirely.

    .PARAMETER SoftRuleViolationPenalty
        Points subtracted from a candidate destination's projected happiness score when the
        move would break a soft (non-enforced) rule (default: 25).

    .PARAMETER RuleComplianceBonus
        Points added to a candidate's projected score when the move fixes an existing
        soft-rule violation (default: 25). Hard-rule compliance migrations are always
        recommended regardless of the happiness improvement.

    .PARAMETER ExcludedVMs
        VM names pinned to Manual automation (see Set-HvDRSVMAutomationLevel) that
        must never be chosen as the VM to move, in either pass. Invoke-HvDRS never
        executes a migration for one of these anyway, so letting the planner select
        one — as the compliance fix for a hard-rule violation, or as the happiness
        pick — only produces a recommendation that will be silently skipped at
        execution time while a different VM that actually could have moved is
        never considered. They remain fully present in -Snapshot for scoring,
        compliance-violation detection, and destination-capacity accounting; they
        are only excluded from being a move's *source* VM.
    #>

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

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

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

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

        [PSCustomObject[]]$RuleSet                  = @(),
        [float]           $SoftRuleViolationPenalty = 25.0,
        [float]           $RuleComplianceBonus      = 25.0,

        [string[]]$ExcludedVMs = @(),

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

    $excluded = [System.Collections.Generic.HashSet[string]]::new([string[]]$ExcludedVMs)

    # Aggression level → [happiness threshold, minimum improvement to trigger migration]
    $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

    # Score all running VMs (needed by both passes)
    $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
    }

    # Mutable simulated node state — updated as migrations are planned
    $simNodes = @{}
    foreach ($node in $Snapshot.Nodes) {
        $simNodes[$node.NodeName] = [PSCustomObject]@{
            NodeName              = $node.NodeName
            CpuUtilization        = $node.CpuUtilization
            TotalMemoryMB         = $node.TotalMemoryMB
            AvailableMemoryMB     = $node.AvailableMemoryMB
            LogicalProcessorCount = $node.LogicalProcessorCount
            NetworkUtilization    = $node.NetworkUtilization
        }
    }

    # Mutable simulated placement (VMName → HostNode) — updated as migrations are
    # planned and passed to Get-MigrationRuleImpact, so each rule check sees every
    # move already planned in this pass rather than the original snapshot placement.
    $simPlacement = @{}
    foreach ($vm in $Snapshot.VMs) { $simPlacement[$vm.VMName] = $vm.HostNode }

    $scheduledVMs = [System.Collections.Generic.HashSet[string]]::new()
    $migrations   = [System.Collections.Generic.List[PSCustomObject]]::new()

    # ── Helper: simulate a VM on a candidate node and score it ────────────────
    $simulateAndScore = {
        param($vm, $candidate)

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

        Measure-VmHappiness -VmMetrics $simVm -HostMetrics $simHost `
                            -CpuWeight $CpuWeight -MemoryWeight $MemoryWeight
    }

    # ── Helper: update simulated node state after a planned migration ─────────
    $applySimulatedMove = {
        param($vm, $srcName, $dstName)
        $src = $simNodes[$srcName]
        $dst = $simNodes[$dstName]
        $srcRelief = ($vm.CpuUtilization/100.0) * ($vm.ProcessorCount/$src.LogicalProcessorCount) * 100.0
        $dstLoad   = ($vm.CpuUtilization/100.0) * ($vm.ProcessorCount/$dst.LogicalProcessorCount) * 100.0
        $src.CpuUtilization    = [Math]::Max(0.0,   $src.CpuUtilization   - $srcRelief)
        $src.AvailableMemoryMB = $src.AvailableMemoryMB + $vm.MemoryAssignedMB
        $dst.CpuUtilization    = [Math]::Min(100.0, $dst.CpuUtilization   + $dstLoad)
        $dst.AvailableMemoryMB = $dst.AvailableMemoryMB - $vm.MemoryAssignedMB
        $simPlacement[$vm.VMName] = $dstName
    }

    # ── Helper: get cluster possible-owners for a VM ──────────────────────────
    # Possible owners live on the VM *resource* ("Virtual Machine <name>"), not on
    # the role/group (named after the VM itself), whose owner list is the
    # *preferred* owners — usually empty. An empty possible-owner list means no
    # restriction, as does a lookup failure (e.g. a non-default resource name).
    $getPossibleOwners = {
        param($vmName)
        $allNodes = @($Snapshot.Nodes | Select-Object -ExpandProperty NodeName)
        try {
            $owners = @((Get-ClusterOwnerNode -Cluster $ClusterName `
                                              -Resource "Virtual Machine $vmName" `
                                              -ErrorAction Stop).OwnerNodes |
                        ForEach-Object { $_.Name })
            if ($owners.Count -gt 0) { $owners } else { $allNodes }
        } catch {
            Write-Verbose " Possible-owner lookup failed for '$vmName' ($_) — treating all nodes as eligible."
            $allNodes
        }
    }

    # ── Helper: basic destination filter (network, memory, ownership) ─────────
    $basicFilter = {
        param($vm, $possibleOwners, $excludeNode)
        $simNodes.Values | Where-Object {
            $_.NodeName -ne $excludeNode -and
            ($possibleOwners -contains $_.NodeName) -and
            $_.NetworkUtilization -lt $MaxDestinationNetworkUtil -and
            ($_.AvailableMemoryMB - $vm.MemoryAssignedMB) -ge $DestinationMemoryReserveMB
        }
    }

    # ── Helper: how badly a single enforced rule is currently violated ─────────
    # 0 = satisfied. Used by Pass 1 to recognize a move that only partially
    # resolves a rule spanning 3+ VMs (e.g. three VMs sharing one host under a
    # hard anti-affinity rule — no single move can fully separate all three),
    # so that move is still taken instead of being rejected outright the way a
    # strictly binary "is it fixed yet?" check would.
    $ruleSeverity = {
        param($rule, $placement)
        switch ($rule.Type) {
            'VmVmAffinity' {
                $hosts = @($rule.VMs | Where-Object { $placement.ContainsKey($_) } | ForEach-Object { $placement[$_] })
                if ($hosts.Count -eq 0) { return 0 }
                # Excess distinct hosts beyond the one they should all share
                return [Math]::Max(0, (@($hosts | Select-Object -Unique)).Count - 1)
            }
            'VmVmAntiAffinity' {
                $hosts = @($rule.VMs | Where-Object { $placement.ContainsKey($_) } | ForEach-Object { $placement[$_] })
                if ($hosts.Count -eq 0) { return 0 }
                # VMs "doubled up" beyond one-per-host — 0 when every member has its own host
                return [Math]::Max(0, $hosts.Count - (@($hosts | Select-Object -Unique)).Count)
            }
            'VmHostAffinity' {
                return @($rule.VMs | Where-Object {
                    $placement.ContainsKey($_) -and ($rule.Hosts -notcontains $placement[$_])
                }).Count
            }
            'VmHostAntiAffinity' {
                return @($rule.VMs | Where-Object {
                    $placement.ContainsKey($_) -and ($rule.Hosts -contains $placement[$_])
                }).Count
            }
            default { return 0 }
        }
    }

    # ════════════════════════════════════════════════════════════════════════════
    # PASS 1 — Compliance migrations (fix enforced-rule violations first)
    # ════════════════════════════════════════════════════════════════════════════
    # Iterates over the enforced rules themselves (via $ruleSeverity), not a
    # one-shot list of Test-AffinityCompliance violations: a rule spanning 3+
    # VMs (e.g. a hard anti-affinity group of three VMs sharing one host) can
    # need more than one move to fully satisfy, and no single move may resolve
    # it outright. Each iteration re-evaluates every violated rule's severity
    # against the current simulated placement and takes whichever move reduces
    # some rule's severity the most (ties broken by projected happiness),
    # repeating until every enforced rule is satisfied or no move helps at all.
    if ($RuleSet -and $RuleSet.Count -gt 0) {
        $enforcedRules = @($RuleSet | Where-Object {
            $_.Enforced -and $_.Type -in @('VmVmAffinity', 'VmVmAntiAffinity', 'VmHostAffinity', 'VmHostAntiAffinity')
        })

        # Generous, non-load-bearing safety cap — see Find-StorageMigrationCandidates'
        # matching per-source loop for the same reasoning. Each accepted iteration
        # strictly reduces some rule's severity, which is bounded by VM count, so
        # this always terminates well before the cap.
        $maxComplianceIterations = $Snapshot.VMs.Count + $enforcedRules.Count + 1

        for ($iter = 0; $iter -lt $maxComplianceIterations; $iter++) {
            $violatedRules = @($enforcedRules | Where-Object { (& $ruleSeverity $_ $simPlacement) -gt 0 })
            if ($violatedRules.Count -eq 0) { break }

            $bestFix             = $null
            $bestFixSeverityDrop = 0
            $bestFixScore        = -1

            foreach ($rule in $violatedRules) {
                $currentSeverity = & $ruleSeverity $rule $simPlacement
                $movable = @($rule.VMs | Where-Object {
                    $simPlacement.ContainsKey($_) -and -not $scheduledVMs.Contains($_) -and -not $excluded.Contains($_)
                })

                foreach ($vmName in $movable) {
                    $vm = $Snapshot.VMs | Where-Object { $_.VMName -eq $vmName }
                    if (-not $vm) { continue }

                    $possibleOwners = & $getPossibleOwners $vmName
                    $candidates     = & $basicFilter $vm $possibleOwners $simPlacement[$vmName]

                    foreach ($candidate in $candidates) {
                        $impact = Get-MigrationRuleImpact -VMName $vmName `
                                                          -DestinationNode $candidate.NodeName `
                                                          -Snapshot $Snapshot -RuleSet $RuleSet `
                                                          -Placement $simPlacement

                        # Never accept a move that breaks a DIFFERENT enforced rule
                        if ($impact.HasHardViolation) { continue }

                        $hypothetical = $simPlacement.Clone()
                        $hypothetical[$vmName] = $candidate.NodeName
                        $severityDrop = $currentSeverity - (& $ruleSeverity $rule $hypothetical)
                        if ($severityDrop -le 0) { continue }   # no progress on this rule

                        $projected = & $simulateAndScore $vm $candidate
                        if ($severityDrop -gt $bestFixSeverityDrop -or
                            ($severityDrop -eq $bestFixSeverityDrop -and $projected.HappinessScore -gt $bestFixScore)) {
                            $bestFixSeverityDrop = $severityDrop
                            $bestFixScore        = $projected.HappinessScore
                            $currentScoreObj     = $vmScores | Where-Object { $_.VMName -eq $vmName }
                            $newSeverity         = $currentSeverity - $severityDrop
                            $bestFix = [PSCustomObject]@{
                                VMName             = $vmName
                                VMId               = $vm.VMId
                                SourceNode         = $simPlacement[$vmName]
                                DestinationNode    = $candidate.NodeName
                                CurrentScore       = $currentScoreObj.HappinessScore
                                ProjectedScore     = [Math]::Round($projected.HappinessScore, 1)
                                Improvement        = [Math]::Round($projected.HappinessScore - $currentScoreObj.HappinessScore, 1)
                                CpuHappinessBefore = $currentScoreObj.CpuHappiness
                                MemHappinessBefore = $currentScoreObj.MemHappiness
                                CpuHappinessAfter  = [Math]::Round($projected.CpuHappiness, 1)
                                MemHappinessAfter  = [Math]::Round($projected.MemHappiness, 1)
                                ComplianceReason   = if ($newSeverity -eq 0) {
                                    "Satisfies enforced $($rule.Type) rule '$($rule.Name)'"
                                } else {
                                    "Partially satisfies enforced $($rule.Type) rule '$($rule.Name)' ($newSeverity violation(s) remaining)"
                                }
                            }
                        }
                    }
                }
            }

            if (-not $bestFix) {
                Write-Verbose (" No move improves compliance for: {0}" -f
                    (($violatedRules | ForEach-Object { $_.Name }) -join ', '))
                break
            }

            $migrations.Add($bestFix)
            [void]$scheduledVMs.Add($bestFix.VMName)
            $fixVm = $Snapshot.VMs | Where-Object { $_.VMName -eq $bestFix.VMName }
            & $applySimulatedMove $fixVm $bestFix.SourceNode $bestFix.DestinationNode
        }
    }

    # ════════════════════════════════════════════════════════════════════════════
    # PASS 2 — Happiness-based migrations (load balancing)
    # ════════════════════════════════════════════════════════════════════════════
    $unhappyVMs = $vmScores |
                  Where-Object { $_.HappinessScore -lt $happinessThreshold } |
                  Sort-Object HappinessScore   # most unhappy first

    foreach ($score in $unhappyVMs) {
        if ($scheduledVMs.Contains($score.VMName) -or $excluded.Contains($score.VMName)) { continue }

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

        $possibleOwners  = & $getPossibleOwners $score.VMName
        $candidates      = & $basicFilter $vm $possibleOwners $score.HostNode

        if (-not $candidates) { continue }

        $bestMigration   = $null
        $bestImprovement = 0.0

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

            if ($impact.HasHardViolation) { continue }

            $projected = & $simulateAndScore $vm $candidate

            # Apply rule-aware score adjustments
            $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 -gt $bestImprovement) {
                $bestImprovement = $improvement
                $bestMigration = [PSCustomObject]@{
                    VMName             = $vm.VMName
                    VMId               = $vm.VMId
                    SourceNode         = $score.HostNode
                    DestinationNode    = $candidate.NodeName
                    CurrentScore       = $score.HappinessScore
                    ProjectedScore     = [Math]::Round($projected.HappinessScore, 1)
                    Improvement        = [Math]::Round($improvement, 1)
                    CpuHappinessBefore = $score.CpuHappiness
                    MemHappinessBefore = $score.MemHappiness
                    CpuHappinessAfter  = [Math]::Round($projected.CpuHappiness, 1)
                    MemHappinessAfter  = [Math]::Round($projected.MemHappiness, 1)
                    ComplianceReason   = $null
                }
            }
        }

        if ($null -eq $bestMigration -or $bestImprovement -lt $improvementThreshold) { continue }

        $migrations.Add($bestMigration)
        [void]$scheduledVMs.Add($bestMigration.VMName)
        & $applySimulatedMove $vm $bestMigration.SourceNode $bestMigration.DestinationNode
    }

    # Leading comma only on the empty case — see Get-AffinityRuleSet.ps1 for why
    # it must NOT be applied unconditionally (it would break single-recommendation
    # callers that expect the bare migration object, not a 1-element array).
    if ($migrations.Count -eq 0) { return ,@() }
    return $migrations
}