Modules/Public/Invoke-S2DCapacityWhatIf.ps1

function Invoke-S2DCapacityWhatIf {
    <#
    .SYNOPSIS
        Models the capacity impact of adding nodes, adding disks, replacing disks,
        or changing resiliency — without touching the live cluster.

    .DESCRIPTION
        Takes a baseline cluster snapshot (JSON file, S2DClusterData object, or live
        cluster connection) and applies one or more scenario modifications, then
        recomputes the capacity waterfall. Returns a what-if result object containing
        both the baseline and projected waterfalls plus per-stage deltas.

        Scenarios can be combined in a single invocation (composite what-if).

    .PARAMETER BaselineSnapshot
        Path to a JSON snapshot file produced by S2DCartographer (SchemaVersion 1.0).

    .PARAMETER InputObject
        An S2DClusterData object piped from Invoke-S2DCartographer -PassThru.

    .PARAMETER ClusterName
        Connect to a live cluster, collect data, then immediately model. Re-running
        with different scenario parameters will not re-hit the cluster.

    .PARAMETER AddNodes
        Number of nodes to add. New nodes are assumed to have the same disk
        configuration as the average existing node (disk count × disk size).

    .PARAMETER AddDisksPerNode
        Number of capacity disks to add per existing node. Enforces symmetry.

    .PARAMETER NewDiskSizeTB
        Size in decimal TB of new disks added via -AddNodes or -AddDisksPerNode.
        Defaults to the existing largest disk size.

    .PARAMETER ReplaceDiskSizeTB
        Model replacing all capacity disks with disks of this size in decimal TB.
        Node and disk counts remain the same.

    .PARAMETER ChangeResiliency
        Override the resiliency factor (NumberOfDataCopies). E.g. 2 = 2-way mirror,
        3 = 3-way mirror.

    .PARAMETER OutputDirectory
        Directory to write reports to. If omitted no files are written.

    .PARAMETER Format
        Report formats to generate. Accepts Html, Json. Default: Html.

    .PARAMETER PassThru
        Return the what-if result object to the pipeline.

    .EXAMPLE
        Invoke-S2DCapacityWhatIf -BaselineSnapshot C:\snap.json -AddNodes 2 -AddDisksPerNode 4 -NewDiskSizeTB 3.84

    .EXAMPLE
        Invoke-S2DCartographer -ClusterName clus01 -PassThru |
            Invoke-S2DCapacityWhatIf -ChangeResiliency 2

    .OUTPUTS
        PSCustomObject (S2DWhatIfResult)
    #>

    [CmdletBinding(DefaultParameterSetName = 'Snapshot')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Snapshot', Position = 0)]
        [string] $BaselineSnapshot,

        [Parameter(Mandatory, ParameterSetName = 'Object', ValueFromPipeline)]
        [object] $InputObject,

        [Parameter(Mandatory, ParameterSetName = 'Live')]
        [string] $ClusterName,

        [int]    $AddNodes          = 0,
        [int]    $AddDisksPerNode   = 0,
        [double] $NewDiskSizeTB     = 0,
        [double] $ReplaceDiskSizeTB = 0,
        [int]    $ChangeResiliency  = 0,

        [string]   $OutputDirectory = '',
        [string[]] $Format          = @('Html'),
        [switch]   $PassThru
    )

    # ── Load baseline ─────────────────────────────────────────────────────────
    $baseline = switch ($PSCmdlet.ParameterSetName) {
        'Snapshot' {
            if (-not (Test-Path $BaselineSnapshot)) {
                throw "Baseline snapshot not found: $BaselineSnapshot"
            }
            $raw = Get-Content $BaselineSnapshot -Raw | ConvertFrom-Json
            if (-not $raw.SchemaVersion) {
                throw "File does not appear to be an S2DCartographer snapshot (missing SchemaVersion)."
            }
            $raw
        }
        'Object' { $InputObject }
        'Live' {
            Connect-S2DCluster -ClusterName $ClusterName
            Get-S2DPhysicalDiskInventory | Out-Null
            Get-S2DStoragePoolInfo       | Out-Null
            Get-S2DVolumeMap             | Out-Null
            Get-S2DCapacityWaterfall     | Out-Null
            $Script:S2DSession.CollectedData
        }
    }

    # ── Extract baseline inputs ───────────────────────────────────────────────
    $baseDisks = @(if ($baseline.PhysicalDisks) { $baseline.PhysicalDisks } else { @() })
    $baseCapDisks = @($baseDisks | Where-Object {
        $_.IsPoolMember -ne $false -and $_.Role -eq 'Capacity'
    })
    if (-not $baseCapDisks) {
        $baseCapDisks = @($baseDisks | Where-Object {
            $_.IsPoolMember -ne $false -and
            $_.Usage -ne 'Journal' -and $_.Usage -ne 'Retired'
        })
    }

    $baseNodeCount    = if ($baseline.NodeCount -gt 0) { [int]$baseline.NodeCount } else {
        @($baseCapDisks | Select-Object -ExpandProperty NodeName -Unique).Count
    }
    $disksPerNode     = if ($baseNodeCount -gt 0 -and $baseCapDisks.Count -gt 0) {
        [math]::Round($baseCapDisks.Count / $baseNodeCount)
    } else { 0 }

    $baseLargestDrive = [int64]($baseCapDisks | Measure-Object -Property SizeBytes -Maximum).Maximum
    $baseRawBytes     = [int64]($baseCapDisks | Measure-Object -Property SizeBytes -Sum).Sum

    $basePool = $baseline.StoragePool
    $basePoolTotalBytes = if ($basePool -and $basePool.TotalSize) {
        if ($basePool.TotalSize -is [hashtable] -or $basePool.TotalSize.PSObject.Properties['Bytes']) {
            [int64]$basePool.TotalSize.Bytes
        } else { [int64]$basePool.TotalSize }
    } else { [int64]0 }
    $basePoolFreeBytes = if ($basePool -and $basePool.RemainingSize) {
        if ($basePool.RemainingSize.PSObject.Properties['Bytes']) {
            [int64]$basePool.RemainingSize.Bytes
        } else { [int64]$basePool.RemainingSize }
    } else { [int64]0 }

    $baseVolumes   = @(if ($baseline.Volumes) { $baseline.Volumes } else { @() })
    $baseInfraVols = @($baseVolumes | Where-Object { $_.IsInfrastructureVolume })
    $baseInfraBytes = [int64]0
    foreach ($iv in $baseInfraVols) {
        $fp = if ($iv.FootprintOnPool -and $iv.FootprintOnPool.PSObject.Properties['Bytes']) {
            [int64]$iv.FootprintOnPool.Bytes
        } elseif ($iv.Size -and $iv.Size.PSObject.Properties['Bytes']) {
            [int64]$iv.Size.Bytes
        } else { [int64]0 }
        $baseInfraBytes += $fp
    }

    # Baseline resiliency factor
    $baseResFactor = 3.0
    $baseResName   = '3-way mirror'
    if ($basePool -and $basePool.ResiliencySettings) {
        $m = @($basePool.ResiliencySettings | Where-Object { $_.Name -eq 'Mirror' }) | Select-Object -First 1
        if ($m -and $m.NumberOfDataCopies -gt 0) {
            $baseResFactor = [double]$m.NumberOfDataCopies
            $baseResName   = "$($m.NumberOfDataCopies)-way mirror"
        }
    }

    # Compute baseline waterfall
    $baselineWaterfall = Invoke-S2DWaterfallCalculation `
        -RawDiskBytes         $baseRawBytes `
        -NodeCount            $baseNodeCount `
        -LargestDiskSizeBytes $baseLargestDrive `
        -PoolTotalBytes       $basePoolTotalBytes `
        -PoolFreeBytes        $basePoolFreeBytes `
        -InfraVolumeBytes     $baseInfraBytes `
        -ResiliencyFactor     $baseResFactor `
        -ResiliencyName       $baseResName

    # ── Apply scenario modifications ──────────────────────────────────────────
    $projNodeCount    = $baseNodeCount
    $projDisksPerNode = $disksPerNode
    $projDiskSize     = $baseLargestDrive
    $projResFactor    = $baseResFactor
    $projResName      = $baseResName
    $scenarioParts    = @()

    if ($ReplaceDiskSizeTB -gt 0) {
        $projDiskSize  = [int64]($ReplaceDiskSizeTB * 1000000000000)
        $scenarioParts += "Replace disks → $ReplaceDiskSizeTB TB"
    }
    if ($NewDiskSizeTB -gt 0) {
        $newDiskBytes  = [int64]($NewDiskSizeTB * 1000000000000)
        # Only override drive size for new disks if larger
        $projDiskSize  = [math]::Max($projDiskSize, $newDiskBytes)
    }
    if ($AddDisksPerNode -gt 0) {
        # Validate symmetry
        if ($projNodeCount -lt 1) { throw "Cannot add disks per node — node count unknown." }
        $projDisksPerNode += $AddDisksPerNode
        $scenarioParts    += "+$AddDisksPerNode disks/node"
    }
    if ($AddNodes -gt 0) {
        $projNodeCount += $AddNodes
        $scenarioParts += "+$AddNodes nodes"
    }
    if ($ChangeResiliency -gt 0) {
        $projResFactor = [double]$ChangeResiliency
        $projResName   = "$ChangeResiliency-way mirror"
        $scenarioParts += "Resiliency → $ChangeResiliency-way mirror"
    }

    $scenarioLabel  = if ($scenarioParts) { $scenarioParts -join ', ' } else { 'No changes (baseline)' }

    # Projected raw bytes: new node count × disks per node × disk size
    $projTotalDisks = $projNodeCount * $projDisksPerNode
    $projRawBytes   = if ($projTotalDisks -gt 0) {
        [int64]($projTotalDisks * $projDiskSize)
    } else { $baseRawBytes }

    # Projected pool: estimate from projected raw (no override — this is a new config)
    $projPoolTotalBytes = [int64]($projRawBytes * 0.99)
    # Projected pool free: scale pool free proportionally if pool grew
    $projPoolFreeBytes  = if ($basePoolTotalBytes -gt 0 -and $projPoolTotalBytes -gt $basePoolTotalBytes) {
        $basePoolFreeBytes + ($projPoolTotalBytes - $basePoolTotalBytes)
    } else { $basePoolFreeBytes }

    $projectedWaterfall = Invoke-S2DWaterfallCalculation `
        -RawDiskBytes         $projRawBytes `
        -NodeCount            $projNodeCount `
        -LargestDiskSizeBytes $projDiskSize `
        -PoolTotalBytes       $projPoolTotalBytes `
        -PoolFreeBytes        $projPoolFreeBytes `
        -InfraVolumeBytes     $baseInfraBytes `
        -ResiliencyFactor     $projResFactor `
        -ResiliencyName       $projResName

    # ── Build delta table ─────────────────────────────────────────────────────
    $deltaStages = for ($i = 0; $i -lt $baselineWaterfall.Stages.Count; $i++) {
        $b = $baselineWaterfall.Stages[$i]
        $p = $projectedWaterfall.Stages[$i]
        $deltaBytes = $p.Size.Bytes - $b.Size.Bytes
        [PSCustomObject]@{
            Stage          = $b.Stage
            Name           = $b.Name
            BaselineTiB    = $b.Size.TiB
            ProjectedTiB   = $p.Size.TiB
            DeltaTiB       = [math]::Round($deltaBytes / 1099511627776, 2)
            BaselineTB     = $b.Size.TB
            ProjectedTB    = $p.Size.TB
            DeltaTB        = [math]::Round($deltaBytes / 1000000000000, 2)
        }
    }

    $result = [PSCustomObject]@{
        PSTypeName         = 'S2DWhatIfResult'
        ScenarioLabel      = $scenarioLabel
        BaselineNodeCount  = $baseNodeCount
        ProjectedNodeCount = $projNodeCount
        BaselineWaterfall  = $baselineWaterfall
        ProjectedWaterfall = $projectedWaterfall
        DeltaStages        = $deltaStages
        DeltaUsableTiB     = $projectedWaterfall.UsableCapacity.TiB - $baselineWaterfall.UsableCapacity.TiB
        DeltaUsableTB      = [math]::Round(($projectedWaterfall.UsableCapacity.Bytes - $baselineWaterfall.UsableCapacity.Bytes) / 1000000000000, 2)
        GeneratedAt        = (Get-Date -Format 'o')
    }

    # ── Write reports ─────────────────────────────────────────────────────────
    if ($OutputDirectory) {
        $outDir = $OutputDirectory
        if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null }
        $base = Join-Path $outDir "whatif-$(Get-Date -Format 'yyyyMMdd-HHmm')"

        if ('Html' -in $Format -or 'All' -in $Format) {
            $htmlPath = Export-S2DWhatIfHtmlReport -Result $result -OutputPath "$base.html"
            Write-Verbose "What-if HTML report: $htmlPath"
        }
        if ('Json' -in $Format -or 'All' -in $Format) {
            $jsonPath = Export-S2DWhatIfJsonReport -Result $result -OutputPath "$base.json"
            Write-Verbose "What-if JSON output: $jsonPath"
        }
    }

    if ($PassThru -or -not $OutputDirectory) { $result }
}