Functions/Private/Find-StorageMigrationCandidates.ps1

function Find-StorageMigrationCandidates {
    <#
    .SYNOPSIS
        Identifies VMs whose VHDs should be moved to a different CSV to balance
        storage space and I/O load across the cluster.

    .DESCRIPTION
        Two-pass algorithm, mirroring Find-MigrationCandidates:

        Pass 1 — Storage rule compliance (hard-rule violations in current placement)
          For each enforced storage affinity rule that is currently violated, selects
          the best (VM, destination CSV) pair that resolves the violation without
          introducing any new hard storage-rule violation. These migrations are added
          to the plan first, and the simulated CSV state is updated accordingly.

        Pass 2 — Happiness (space/IO load balancing)
          1. Score every CSV with Measure-CsvHappiness.
          2. Identify CSVs whose current (simulated) score is below the
             aggression-level happiness threshold, sorted most→least unhappy.
          3. For each unhappy source CSV, evaluate every combination of
             (unscheduled VM on source, candidate destination CSV):
               • Candidate must have enough headroom after receiving the VM's
                 VHDs: (FreeGB – vm.TotalVhdGB) >= MinFreeGBReserve.
               • Storage rule impact of the move is checked:
                   - Hard violation → destination excluded.
                   - Soft violation → configurable score penalty applied.
                   - Fixes a violation → configurable score bonus applied.
               • Simulate source after VM departs → projectedSrcScore (rule-adjusted).
               • Simulate destination after VM arrives → projectedDstScore.
               • improvement = adjustedSrcScore − currentSimSrcScore.
          4. Pick the (VM, destination) pair with the highest improvement.
          5. If improvement meets the aggression-level minimum, add to the plan.
          6. Update simulated CSV state (FreeGB) before moving to the next source.

        The simulated state ensures the greedy planner does not over-commit a
        single destination CSV across multiple planned moves.

    .PARAMETER RuleSet
        Array of storage affinity / anti-affinity rule objects (VmVmCsvAffinity,
        VmVmCsvAntiAffinity, VmCsvAffinity, VmCsvAntiAffinity) returned by
        Get-AffinityRuleSet. Pass an empty array or omit to disable rule checking.

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

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

    .PARAMETER ExcludedVMs
        VM names pinned to Manual automation that must never be chosen as the VM to
        move, in either pass — see Find-MigrationCandidates' -ExcludedVMs for why.
        They remain fully present in -Snapshot for scoring, compliance-violation
        detection, and destination-capacity accounting.

    .OUTPUTS
        List of PSCustomObjects: VMName, VMId, HostNode, SourceCSV, SourceCSVName,
        DestinationCSV, DestinationCSVName, VHDCount, TotalVhdGB,
        SourceFreeGBBefore, SourceFreeGBAfter, DestFreeGBBefore, DestFreeGBAfter,
        SourceScoreBefore, SourceScoreAfter, DestScoreBefore, DestScoreAfter,
        Improvement, ComplianceReason.
    #>

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

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

        [ValidateRange(0.0, 1.0)]
        [float] $SpaceWeight       = 0.7,

        [ValidateRange(0.0, 1.0)]
        [float] $IoWeight          = 0.3,

        [int]   $MinFreeGBReserve  = 50,

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

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

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

    $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

    # Initial scores — used for SourceScoreBefore / DestScoreBefore in output
    $initialScores = @{}
    foreach ($csv in $Snapshot.CSVs) {
        $s = Measure-CsvHappiness -CsvMetrics $csv -SpaceWeight $SpaceWeight -IoWeight $IoWeight
        $initialScores[$csv.Name] = $s.HappinessScore
    }

    # Mutable simulated CSV state keyed by CSV Name
    $simCsvs = @{}
    foreach ($csv in $Snapshot.CSVs) {
        $simCsvs[$csv.Name] = [PSCustomObject]@{
            Name      = $csv.Name
            Path      = $csv.Path
            TotalGB   = $csv.TotalGB
            FreeGB    = $csv.FreeGB
            LatencyMs = $csv.LatencyMs
            ReadIOPS  = $csv.ReadIOPS
            WriteIOPS = $csv.WriteIOPS
        }
    }

    # Path → Name mapping (VMs reference CSVs by path)
    $pathToName = @{}
    foreach ($csv in $Snapshot.CSVs) { $pathToName[$csv.Path] = $csv.Name }

    # VM → CSV-name mapping, updated as moves are planned and passed to
    # Get-StorageMigrationRuleImpact so each rule check sees every move already
    # planned in this pass. Unmapped paths are kept as-is, matching that function.
    $vmCsvName = @{}
    foreach ($vm in $Snapshot.VMs) {
        $vmCsvName[$vm.VMName] = if ($vm.PrimaryCSV -and $pathToName.ContainsKey($vm.PrimaryCSV)) { $pathToName[$vm.PrimaryCSV] } else { $vm.PrimaryCSV }
    }

    # Helper: score a simulated CSV object
    $scoreSimCsv = {
        param($sim)
        $proxy = [PSCustomObject]@{
            Name      = $sim.Name
            TotalGB   = $sim.TotalGB
            FreeGB    = $sim.FreeGB
            LatencyMs = $sim.LatencyMs
        }
        (Measure-CsvHappiness -CsvMetrics $proxy -SpaceWeight $SpaceWeight -IoWeight $IoWeight).HappinessScore
    }

    # Helper: how badly a single enforced storage-affinity rule is currently
    # violated (0 = satisfied). Mirrors Find-MigrationCandidates' $ruleSeverity
    # — a VmVmCsvAffinity/VmVmCsvAntiAffinity rule spanning 3+ VMs can need more
    # than one move to fully satisfy, so this lets Pass 1 recognize and accept a
    # move that only partially resolves it.
    $csvRuleSeverity = {
        param($rule, $placement)
        switch ($rule.Type) {
            'VmVmCsvAffinity' {
                $csvs = @($rule.VMs | Where-Object { $placement.ContainsKey($_) } | ForEach-Object { $placement[$_] })
                if ($csvs.Count -eq 0) { return 0 }
                return [Math]::Max(0, (@($csvs | Select-Object -Unique)).Count - 1)
            }
            'VmVmCsvAntiAffinity' {
                $csvs = @($rule.VMs | Where-Object { $placement.ContainsKey($_) } | ForEach-Object { $placement[$_] })
                if ($csvs.Count -eq 0) { return 0 }
                return [Math]::Max(0, $csvs.Count - (@($csvs | Select-Object -Unique)).Count)
            }
            'VmCsvAffinity' {
                return @($rule.VMs | Where-Object {
                    $placement.ContainsKey($_) -and ($rule.CSVs -notcontains $placement[$_])
                }).Count
            }
            'VmCsvAntiAffinity' {
                return @($rule.VMs | Where-Object {
                    $placement.ContainsKey($_) -and ($rule.CSVs -contains $placement[$_])
                }).Count
            }
            default { return 0 }
        }
    }

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

    # ════════════════════════════════════════════════════════════════════════════
    # PASS 1 — Storage rule compliance (fix enforced-rule violations first)
    # ════════════════════════════════════════════════════════════════════════════
    if ($RuleSet -and $RuleSet.Count -gt 0) {
        $enforcedStorageRules = @($RuleSet | Where-Object {
            $_.Enforced -and $_.Type -in @('VmVmCsvAffinity', 'VmVmCsvAntiAffinity', 'VmCsvAffinity', 'VmCsvAntiAffinity')
        })

        # See Find-MigrationCandidates' matching loop for why this iterates
        # rules (via $csvRuleSeverity) rather than a one-shot violation list,
        # and for why this cap is generous but non-load-bearing.
        $maxComplianceIterations = $Snapshot.VMs.Count + $enforcedStorageRules.Count + 1

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

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

            foreach ($rule in $violatedRules) {
                $currentSeverity = & $csvRuleSeverity $rule $vmCsvName
                $movable = @($rule.VMs | Where-Object {
                    $vmCsvName.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 }

                    $srcName = $vmCsvName[$vmName]
                    $simSrcForVm = $simCsvs[$srcName]
                    if (-not $simSrcForVm) { continue }

                    $candidates = $simCsvs.Values | Where-Object {
                        $_.Name -ne $srcName -and
                        ($_.FreeGB - $vm.TotalVhdGB) -ge $MinFreeGBReserve
                    }

                    foreach ($dst in $candidates) {
                        $impact = Get-StorageMigrationRuleImpact -VMName $vmName -DestinationCsvName $dst.Name `
                                                                  -Snapshot $Snapshot -RuleSet $RuleSet `
                                                                  -Placement $vmCsvName

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

                        $hypothetical = $vmCsvName.Clone()
                        $hypothetical[$vmName] = $dst.Name
                        $severityDrop = $currentSeverity - (& $csvRuleSeverity $rule $hypothetical)
                        if ($severityDrop -le 0) { continue }   # no progress on this rule

                        $dstFreeAfter = $dst.FreeGB - $vm.TotalVhdGB
                        $dstSimCopy   = [PSCustomObject]@{
                            Name = $dst.Name; TotalGB = $dst.TotalGB
                            FreeGB = $dstFreeAfter; LatencyMs = $dst.LatencyMs
                        }
                        $projectedDstScore = & $scoreSimCsv $dstSimCopy

                        if ($severityDrop -gt $bestFixSeverityDrop -or
                            ($severityDrop -eq $bestFixSeverityDrop -and $projectedDstScore -gt $bestFixScore)) {
                            $bestFixSeverityDrop = $severityDrop
                            $bestFixScore        = $projectedDstScore
                            $newSeverity         = $currentSeverity - $severityDrop

                            $srcFreeAfter = $simSrcForVm.FreeGB + $vm.TotalVhdGB
                            $srcSimCopy   = [PSCustomObject]@{
                                Name = $simSrcForVm.Name; TotalGB = $simSrcForVm.TotalGB
                                FreeGB = $srcFreeAfter; LatencyMs = $simSrcForVm.LatencyMs
                            }
                            $projectedSrcScore = & $scoreSimCsv $srcSimCopy

                            $bestFix = [PSCustomObject]@{
                                VMName             = $vmName
                                VMId               = $vm.VMId
                                HostNode           = $vm.HostNode
                                SourceCSV          = $simSrcForVm.Path
                                SourceCSVName      = $simSrcForVm.Name
                                DestinationCSV     = $dst.Path
                                DestinationCSVName = $dst.Name
                                VHDCount           = $vm.VHDs.Count
                                TotalVhdGB         = $vm.TotalVhdGB
                                SourceFreeGBBefore = [Math]::Round($simSrcForVm.FreeGB, 1)
                                SourceFreeGBAfter  = [Math]::Round($srcFreeAfter, 1)
                                DestFreeGBBefore   = [Math]::Round($dst.FreeGB, 1)
                                DestFreeGBAfter    = [Math]::Round($dstFreeAfter, 1)
                                SourceScoreBefore  = $initialScores[$srcName]
                                SourceScoreAfter   = [Math]::Round($projectedSrcScore, 1)
                                DestScoreBefore    = $initialScores[$dst.Name]
                                DestScoreAfter     = [Math]::Round($projectedDstScore, 1)
                                Improvement        = [Math]::Round($projectedSrcScore - $initialScores[$srcName], 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 storage compliance for: {0}" -f
                    (($violatedRules | ForEach-Object { $_.Name }) -join ', '))
                break
            }

            $migrations.Add($bestFix)
            [void]$scheduledVMs.Add($bestFix.VMName)
            $simCsvs[$bestFix.SourceCSVName].FreeGB      += $bestFix.TotalVhdGB
            $simCsvs[$bestFix.DestinationCSVName].FreeGB -= $bestFix.TotalVhdGB
            $vmCsvName[$bestFix.VMName] = $bestFix.DestinationCSVName
        }
    }

    # ════════════════════════════════════════════════════════════════════════════
    # PASS 2 — Happiness-based migrations (space / IO load balancing)
    # ════════════════════════════════════════════════════════════════════════════
    # Collect unhappy CSVs — re-evaluated each outer iteration via simulated state
    $unhappyCsvNames = $initialScores.GetEnumerator() |
                       Where-Object { $_.Value -lt $happinessThreshold } |
                       Sort-Object Value |
                       Select-Object -ExpandProperty Key

    foreach ($srcName in $unhappyCsvNames) {
        # A single move rarely brings a large, badly-unhappy CSV all the way up
        # to the aggression threshold on its own. Keep planning moves off this
        # SAME source — each iteration re-scores against the simulated state
        # left by the previous one — until it's fixed or no VM on it can be
        # beneficially moved anywhere. $vmsOnSrc strictly shrinks every
        # iteration (a scheduled VM is never reconsidered), so this always
        # terminates; the explicit cap is defense-in-depth, not a load-bearing
        # guard.
        for ($guard = 0; $guard -lt $Snapshot.VMs.Count; $guard++) {
            $simSrc = $simCsvs[$srcName]
            if (-not $simSrc) { break }

            # Re-score against current simulated state; stop if already fixed
            $currentSrcScore = & $scoreSimCsv $simSrc
            if ($currentSrcScore -ge $happinessThreshold) { break }

            # VMs whose primary storage is on this CSV, not yet scheduled, and not
            # pinned to Manual automation
            $vmsOnSrc = $Snapshot.VMs | Where-Object {
                $pathToName[$_.PrimaryCSV] -eq $srcName -and
                -not $scheduledVMs.Contains($_.VMName) -and
                -not $excluded.Contains($_.VMName)
            }
            if (-not $vmsOnSrc) { break }

            $bestMigration   = $null
            $bestImprovement = 0.0

            foreach ($vm in $vmsOnSrc) {
                # Candidate destinations: enough headroom after receiving this VM
                $candidates = $simCsvs.Values | Where-Object {
                    $_.Name -ne $srcName -and
                    ($_.FreeGB - $vm.TotalVhdGB) -ge $MinFreeGBReserve
                }

                foreach ($dst in $candidates) {
                    # Storage rule impact check
                    $impact = if ($RuleSet -and $RuleSet.Count -gt 0) {
                        Get-StorageMigrationRuleImpact -VMName $vm.VMName -DestinationCsvName $dst.Name `
                                                       -Snapshot $Snapshot -RuleSet $RuleSet `
                                                       -Placement $vmCsvName
                    } else {
                        [PSCustomObject]@{ HasHardViolation=$false; HasSoftViolation=$false; FixesViolation=$false }
                    }

                    if ($impact.HasHardViolation) { continue }

                    # Simulate source after VM departs
                    $srcFreeAfter = $simSrc.FreeGB + $vm.TotalVhdGB
                    $srcSimCopy   = [PSCustomObject]@{
                        Name = $simSrc.Name; TotalGB = $simSrc.TotalGB
                        FreeGB = $srcFreeAfter; LatencyMs = $simSrc.LatencyMs
                    }
                    $projectedSrcScore = (Measure-CsvHappiness -CsvMetrics $srcSimCopy -SpaceWeight $SpaceWeight -IoWeight $IoWeight).HappinessScore

                    # Simulate destination after VM arrives
                    $dstFreeAfter = $dst.FreeGB - $vm.TotalVhdGB
                    $dstSimCopy   = [PSCustomObject]@{
                        Name = $dst.Name; TotalGB = $dst.TotalGB
                        FreeGB = $dstFreeAfter; LatencyMs = $dst.LatencyMs
                    }
                    $projectedDstScore = (Measure-CsvHappiness -CsvMetrics $dstSimCopy -SpaceWeight $SpaceWeight -IoWeight $IoWeight).HappinessScore

                    # Never plan a happiness-based move that pushes the DESTINATION
                    # below the aggression threshold — whether it was already unhappy
                    # (piling more onto a struggling CSV) or currently healthy (this
                    # move alone would make it the next CSV needing relief). Pass 1's
                    # rule-compliance fixes are exempt from this (see that pass's own
                    # comment) since a hard-rule violation must be resolved regardless
                    # of the happiness cost.
                    if ($projectedDstScore -lt $happinessThreshold) { continue }

                    # Apply rule-aware adjustment to the source-relief score used for selection
                    $adjustedSrcScore = $projectedSrcScore
                    if ($impact.HasSoftViolation) { $adjustedSrcScore = [Math]::Max(0,   $adjustedSrcScore - $SoftRuleViolationPenalty) }
                    if ($impact.FixesViolation)   { $adjustedSrcScore = [Math]::Min(100, $adjustedSrcScore + $RuleComplianceBonus) }

                    $improvement = $adjustedSrcScore - $currentSrcScore

                    if ($improvement -gt $bestImprovement) {
                        $bestImprovement = $improvement
                        $bestMigration = [PSCustomObject]@{
                            VMName             = $vm.VMName
                            VMId               = $vm.VMId
                            HostNode           = $vm.HostNode
                            SourceCSV          = $simSrc.Path
                            SourceCSVName      = $simSrc.Name
                            DestinationCSV     = $dst.Path
                            DestinationCSVName = $dst.Name
                            VHDCount           = $vm.VHDs.Count
                            TotalVhdGB         = $vm.TotalVhdGB
                            SourceFreeGBBefore = [Math]::Round($simSrc.FreeGB, 1)
                            SourceFreeGBAfter  = [Math]::Round($srcFreeAfter, 1)
                            DestFreeGBBefore   = [Math]::Round($dst.FreeGB, 1)
                            DestFreeGBAfter    = [Math]::Round($dstFreeAfter, 1)
                            SourceScoreBefore  = $initialScores[$srcName]
                            SourceScoreAfter   = [Math]::Round($projectedSrcScore, 1)
                            DestScoreBefore    = $initialScores[$dst.Name]
                            DestScoreAfter     = [Math]::Round($projectedDstScore, 1)
                            Improvement        = [Math]::Round($improvement, 1)
                            ComplianceReason   = $null
                        }
                    }
                }
            }

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

            $migrations.Add($bestMigration)
            [void]$scheduledVMs.Add($bestMigration.VMName)

            # Greedy state update
            $simCsvs[$bestMigration.SourceCSVName].FreeGB      += $bestMigration.TotalVhdGB
            $simCsvs[$bestMigration.DestinationCSVName].FreeGB -= $bestMigration.TotalVhdGB
            $vmCsvName[$bestMigration.VMName] = $bestMigration.DestinationCSVName
        }
    }

    return $migrations
}