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.
    #>

    [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,

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

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

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

    # ── Helper: get cluster possible-owners for a VM ──────────────────────────
    $getPossibleOwners = {
        param($vmName)
        try {
            (Get-ClusterOwnerNode -Cluster $ClusterName `
                                  -Group "Virtual Machine $vmName" `
                                  -ErrorAction Stop).OwnerNodes.Name
        } catch {
            $Snapshot.Nodes | Select-Object -ExpandProperty NodeName
        }
    }

    # ── 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
        }
    }

    # ════════════════════════════════════════════════════════════════════════════
    # PASS 1 — Compliance migrations (fix enforced-rule violations first)
    # ════════════════════════════════════════════════════════════════════════════
    if ($RuleSet -and $RuleSet.Count -gt 0) {
        $hardViolations = @(Test-AffinityCompliance -Snapshot $Snapshot -RuleSet $RuleSet |
                            Where-Object { $_.Enforced })

        foreach ($violation in $hardViolations) {
            $movable = @($violation.VMs | Where-Object { -not $scheduledVMs.Contains($_) })
            if ($movable.Count -eq 0) { continue }

            $bestFix   = $null
            $bestScore = -1

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

                $possibleOwners = & $getPossibleOwners $vmName
                $candidates     = & $basicFilter $vm $possibleOwners $vm.HostNode

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

                    # Skip destinations that break another hard rule or don't fix this one
                    if ($impact.HasHardViolation -or -not $impact.FixesViolation) { continue }

                    $projected = & $simulateAndScore $vm $candidate
                    if ($projected.HappinessScore -gt $bestScore) {
                        $bestScore = $projected.HappinessScore
                        $currentScoreObj = $vmScores | Where-Object { $_.VMName -eq $vmName }
                        $bestFix = [PSCustomObject]@{
                            VMName             = $vmName
                            VMId               = $vm.VMId
                            SourceNode         = $vm.HostNode
                            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   = $violation.Description
                        }
                    }
                }
            }

            if ($bestFix) {
                $migrations.Add($bestFix)
                [void]$scheduledVMs.Add($bestFix.VMName)
                $fixVm = $Snapshot.VMs | Where-Object { $_.VMName -eq $bestFix.VMName }
                & $applySimulatedMove $fixVm $bestFix.SourceNode $bestFix.DestinationNode
            } else {
                Write-Verbose " No valid destination found to resolve: $($violation.Description)"
            }
        }
    }

    # ════════════════════════════════════════════════════════════════════════════
    # 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)) { 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
            } 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
    }

    return $migrations
}