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.

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

    $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 → current CSV-name mapping (used for rule evaluation)
    $vmCsvName = @{}
    foreach ($vm in $Snapshot.VMs) { $vmCsvName[$vm.VMName] = $pathToName[$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
    }

    $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) {
        $hardViolations = @(Test-StorageAffinityCompliance -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 }

                $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

                    if ($impact.HasHardViolation -or -not $impact.FixesViolation) { continue }

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

                    if ($projectedDstScore -gt $bestScore) {
                        $bestScore = $projectedDstScore

                        $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   = $violation.Description
                        }
                    }
                }
            }

            if ($bestFix) {
                $migrations.Add($bestFix)
                [void]$scheduledVMs.Add($bestFix.VMName)
                $simCsvs[$bestFix.SourceCSVName].FreeGB      += $bestFix.TotalVhdGB
                $simCsvs[$bestFix.DestinationCSVName].FreeGB -= $bestFix.TotalVhdGB
            } else {
                Write-Verbose " No valid CSV destination found to resolve: $($violation.Description)"
            }
        }
    }

    # ════════════════════════════════════════════════════════════════════════════
    # 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) {
        $simSrc = $simCsvs[$srcName]
        if (-not $simSrc) { continue }

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

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

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

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

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

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

    return $migrations
}