Modules/Private/Get-S2DExpansionHeadroom.ps1

# Get-S2DExpansionHeadroom — calculates remaining room to expand or create volumes
# at 70 / 80 / 90 / 100% fill thresholds against AvailableForVolumes (footprint basis).
#
# CANONICAL MATH (docs/capacity-model.md):
# A = CapacityWaterfall.AvailableForVolumes.Bytes (excludes pool meta, reserve, infra)
# U = sum of FootprintOnPool.Bytes for non-infra volumes
# copies = prevailing NumberOfDataCopies among non-infra volumes (assumed for new volumes)
# currentUtilizationPct = U / A * 100
# For X in {0.70, 0.80, 0.90, 1.00}:
# footprintBudgetBytes = X * A
# remainingFootprintBytes = max(0, X*A - U)
# remainingNewUsableBytes = remainingFootprintBytes / copies
# isPastLine = (U > X*A)

function Get-S2DExpansionHeadroom {
    <#
    .SYNOPSIS
        Calculates expansion headroom at 70/80/90/100% fill thresholds.

    .DESCRIPTION
        Given a completed capacity waterfall and the current volume map, computes
        how much footprint space and new usable data capacity remain before each
        fill threshold is crossed. Uses the prevailing (most-common)
        NumberOfDataCopies among non-infrastructure volumes to estimate new-volume
        usable data from remaining footprint.

        All capacity values are returned as [S2DCapacity] objects carrying both
        TiB (binary) and TB (decimal) representations.

    .PARAMETER Waterfall
        The S2DCapacityWaterfall object from Get-S2DCapacityWaterfall.
        AvailableForVolumes.Bytes is the denominator (A).

    .PARAMETER Volumes
        Array of S2DVolume objects from Get-S2DVolumeMap.
        Only non-infrastructure volumes contribute to the used footprint (U).

    .OUTPUTS
        PSCustomObject with CurrentUtilizationPct, PrevalentDataCopies,
        AvailableForVolumesBytes, UsedFootprintBytes, HasThinVolumes, and a
        Thresholds array. Each threshold row includes SizeToEnterTiB — the
        value to type into New-Volume -Size or WAC (PowerShell/WAC read size
        suffixes as binary, so 1 TB = 1 TiB; value is floor-rounded to 2
        decimals so the new volume always fits).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object] $Waterfall,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]] $Volumes
    )

    # A = available for volumes (footprint basis, excludes meta + reserve + infra)
    $availBytes = if ($Waterfall -and $Waterfall.AvailableForVolumes -and
                      $Waterfall.AvailableForVolumes.Bytes -gt 0) {
        [int64]$Waterfall.AvailableForVolumes.Bytes
    } else {
        [int64]0
    }

    # Non-infrastructure volumes only
    $workloadVols = @($Volumes | Where-Object { -not $_.IsInfrastructureVolume })

    # Thin provisioning presence flag — used by report renderers for Part A:
    # the 70% amber warning is only shown when thin volumes are present.
    # When all volumes are fixed, 70% is rendered as advisory/neutral.
    # Null-safe: -eq against $null returns $false, so missing ProvisioningType defaults to non-thin.
    $hasThinVolumes = [bool]($workloadVols | Where-Object { $_.ProvisioningType -eq 'Thin' })

    # U = sum of footprint bytes for non-infra volumes
    $usedBytes = [int64](
        ($workloadVols | ForEach-Object {
            if ($_.FootprintOnPool -and $_.FootprintOnPool.Bytes -gt 0) {
                [int64]$_.FootprintOnPool.Bytes
            } else { [int64]0 }
        } | Measure-Object -Sum).Sum
    )

    # Prevailing data copies — most common NumberOfDataCopies among non-infra volumes.
    # Defaults to 2 (two-way mirror) when volumes list is empty.
    $prevalentCopies = if ($workloadVols.Count -gt 0) {
        $grouped = $workloadVols |
            Group-Object -Property NumberOfDataCopies |
            Sort-Object -Property Count -Descending
        [int]$grouped[0].Name
    } else {
        2
    }

    $currentPct = if ($availBytes -gt 0) {
        [math]::Round($usedBytes / $availBytes * 100, 1)
    } else { 0.0 }

    $thresholds = foreach ($pct in @(70, 80, 90, 100)) {
        $fraction              = $pct / 100.0
        $budgetBytes           = [int64][math]::Round($fraction * $availBytes)
        $remainingFootprint    = [int64][math]::Max([int64]0, $budgetBytes - $usedBytes)
        $remainingUsable       = if ($prevalentCopies -gt 0) {
            [int64][math]::Round($remainingFootprint / $prevalentCopies)
        } else { [int64]0 }
        $isPast                = ($usedBytes -gt $budgetBytes)

        # SizeToEnterTiB: the exact number to type into New-Volume -Size or WAC.
        # PowerShell and WAC parse size suffixes as binary (1 TB = 1 TiB), so
        # this equals NewUsableData.TiB, floor-rounded to 2 decimals so the new
        # volume always fits. Past-line rows set to 0.
        $sizeToEnterTiB = if ($isPast) {
            [double]0
        } else {
            [Math]::Floor(($remainingUsable / 1099511627776.0) * 100) / 100
        }

        [PSCustomObject]@{
            FillTargetPct               = $pct
            FootprintBudget             = [S2DCapacity]::new($budgetBytes)
            RemainingFootprint          = [S2DCapacity]::new($remainingFootprint)
            NewUsableData               = [S2DCapacity]::new($remainingUsable)
            SizeToEnterTiB              = $sizeToEnterTiB
            IsPastLine                  = $isPast
            IsRecommendedPlanningLine   = ($pct -eq 70)
        }
    }

    [PSCustomObject]@{
        CurrentUtilizationPct     = $currentPct
        PrevalentDataCopies       = $prevalentCopies
        PrevalentCopiesNote       = "Assumed for new-volume estimates. Actual resiliency can differ."
        AvailableForVolumesBytes  = $availBytes
        UsedFootprintBytes        = $usedBytes
        HasThinVolumes            = $hasThinVolumes
        Thresholds                = @($thresholds)
    }
}