Modules/Public/Invoke-S2DCartographer.ps1

function Invoke-S2DCartographer {
    <#
    .SYNOPSIS
        Full orchestrated S2D analysis run: connect, collect, analyze, and report.

    .DESCRIPTION
        Runs the complete S2DCartographer pipeline in a single call:
          1. Connect to the cluster (Connect-S2DCluster)
          2. Collect all data (physical disks, pool, volumes, cache tier)
          3. Compute the 8-stage capacity waterfall
          4. Run all 10 health checks
          5. Generate requested report formats (HTML, Word, PDF, Excel)
          6. Generate SVG diagrams (if -IncludeDiagrams)
          7. Disconnect from the cluster

        Output files are written to a per-run subfolder under OutputDirectory:
          <OutputDirectory>\<ClusterName>\<yyyyMMdd-HHmm>\

        A session log file is written to the same run folder capturing each
        collection step, warnings, and final output paths.
        Use -PassThru to receive the S2DClusterData object for further processing.

    .PARAMETER ClusterName
        Cluster name or FQDN. Required unless -CimSession, -PSSession, or -Local is used.

    .PARAMETER Credential
        PSCredential for cluster authentication. Resolved from Key Vault when -KeyVaultName is provided.

    .PARAMETER Authentication
        Authentication method passed through to Connect-S2DCluster / New-CimSession.
        Defaults to 'Negotiate', which works in both domain-joined and workgroup/lab environments.

    .PARAMETER CimSession
        Existing CimSession to the cluster. Skips Connect-S2DCluster.

    .PARAMETER Local
        Run locally from a cluster node.

    .PARAMETER KeyVaultName
        Azure Key Vault name to resolve credentials from.

    .PARAMETER SecretName
        Key Vault secret name containing the cluster password.

    .PARAMETER Username
        Optional explicit username for the Key Vault credential path. When not provided,
        the username is read from the secret's ContentType tag (convention: 'domain\user').
        Use this when the secret does not have a ContentType populated.

    .PARAMETER OutputDirectory
        Root folder for all output files. Created if it does not exist.
        Defaults to C:\S2DCartographer.

    .PARAMETER Format
        Report formats to generate: Html, Word, Pdf, Excel, Json, Csv, All.
        Defaults to All (= HTML + Word + PDF + Excel + JSON). CSV is opt-in
        because it produces multiple files per run.

    .PARAMETER IncludeNonPoolDisks
        Include non-pool disks (boot drives, SAN LUNs) in the Physical Disk Inventory
        tables. Default is to show pool members only. JSON and CSV outputs always
        include every disk with an IsPoolMember flag regardless of this switch.

    .PARAMETER IncludeDiagrams
        Also generate all six SVG diagram types.

    .PARAMETER PrimaryUnit
        Preferred display unit for capacity values: TiB (default) or TB.

    .PARAMETER SkipHealthChecks
        Skip the health check phase (faster runs when only capacity data is needed).

    .PARAMETER Author
        Author name embedded in generated reports.

    .PARAMETER Company
        Company or organization name embedded in generated reports.

    .PARAMETER PassThru
        Return the S2DClusterData object in addition to writing files.

    .EXAMPLE
        Invoke-S2DCartographer -ClusterName tplabs-clus01.azrl.mgmt `
            -KeyVaultName kv-tplabs-platform -SecretName lcm-deployment-password

    .EXAMPLE
        $data = Invoke-S2DCartographer -ClusterName tplabs-clus01 -Format All -IncludeDiagrams -PassThru
        $data | New-S2DReport -Format Html

    .OUTPUTS
        string[] file paths (default), or S2DClusterData when -PassThru is set.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter()]
        [string] $ClusterName,

        [Parameter()]
        [PSCredential] $Credential,

        [Parameter()]
        [CimSession] $CimSession,

        [Parameter()]
        [switch] $Local,

        [Parameter()]
        [ValidateSet('Default','Digest','Negotiate','Basic','Kerberos','ClientCertificate','CredSsp')]
        [string] $Authentication = 'Negotiate',

        [Parameter()]
        [string] $KeyVaultName,

        [Parameter()]
        [string] $SecretName,

        [Parameter()]
        [string] $Username,

        [Parameter()]
        [string] $OutputDirectory = 'C:\S2DCartographer',

        [Parameter()]
        [ValidateSet('Html', 'Word', 'Pdf', 'Excel', 'Json', 'Csv', 'All')]
        [string[]] $Format = @('All'),

        [Parameter()]
        [switch] $IncludeNonPoolDisks,

        [Parameter()]
        [switch] $IncludeDiagrams,

        [Parameter()]
        [ValidateSet('TiB', 'TB')]
        [string] $PrimaryUnit = 'TiB',

        [Parameter()]
        [switch] $SkipHealthChecks,

        [Parameter()]
        [string] $Author = '',

        [Parameter()]
        [string] $Company = '',

        [Parameter()]
        [switch] $PassThru
    )

    if (-not (Test-Path $OutputDirectory)) {
        New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
    }

    # ── Log helper (writes to file and verbose stream) ────────────────────────
    $logLines  = [System.Collections.Generic.List[string]]::new()
    $runStart  = Get-Date
    $logPath   = $null   # resolved after connect when cluster name is known

    function local:Write-Log {
        param([string]$Message, [string]$Level = 'INFO')
        $ts   = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
        $line = "[$ts] [$Level] $Message"
        $logLines.Add($line)
        if ($logPath) { $line | Out-File -FilePath $logPath -Append -Encoding utf8 }
        Write-Verbose $line
    }

    $ownedSession = $false

    try {
        Write-Log "S2DCartographer run started. PSVersion=$($PSVersionTable.PSVersion) Platform=$($PSVersionTable.Platform)"
        Write-Log "Parameters: Format=$($Format -join ',') IncludeDiagrams=$IncludeDiagrams SkipHealthChecks=$SkipHealthChecks"

        # ── Step 1: Connect ───────────────────────────────────────────────────
        # Build splat for Connect-S2DCluster with strict parameter-set discipline.
        # Each parameter set (ByName / ByKeyVault / ByCimSession / Local) accepts a
        # disjoint set of parameters — splatting one set's parameters into another
        # causes PowerShell parameter-set resolution to fall back to ByName, which
        # then demands a -Credential that was never supplied and throws a misleading
        # "Credentials are required" error. See issue #39.
        if (-not $Script:S2DSession.IsConnected) {
            $connectParams = @{}

            if ($Local) {
                $connectParams['Local'] = $Local
            }
            elseif ($CimSession) {
                $connectParams['CimSession'] = $CimSession
            }
            elseif ($KeyVaultName -and $SecretName) {
                # ByKeyVault — -Authentication is NOT a valid parameter here
                $connectParams['ClusterName']  = $ClusterName
                $connectParams['KeyVaultName'] = $KeyVaultName
                $connectParams['SecretName']   = $SecretName
                if ($Username) { $connectParams['Username'] = $Username }
            }
            else {
                # ByName — requires ClusterName; Credential + Authentication valid
                if ($ClusterName)  { $connectParams['ClusterName']    = $ClusterName }
                if ($Credential)   { $connectParams['Credential']     = $Credential }
                $connectParams['Authentication'] = $Authentication
            }

            if ($PSCmdlet.ShouldProcess($ClusterName, 'Connect to S2D cluster')) {
                Write-Log "Connecting to cluster: $ClusterName"
                Write-Log "Splat keys passed to Connect-S2DCluster: $($connectParams.Keys -join ', ')"
                Connect-S2DCluster @connectParams
                $ownedSession = $true
                Write-Log "Connected. Cluster=$($Script:S2DSession.ClusterName) Nodes=$($Script:S2DSession.Nodes.Count)"
            }
        }

        # ── Build per-run output folder ───────────────────────────────────────
        $safeName  = ($Script:S2DSession.ClusterName -replace '[^\w\-]', '_').ToLower()
        $stamp     = $runStart.ToString('yyyyMMdd-HHmm')
        $runDir    = Join-Path $OutputDirectory "$safeName\$stamp"
        $diagramDir = Join-Path $runDir 'diagrams'
        New-Item -ItemType Directory -Path $runDir -Force | Out-Null

        $baseName  = "S2DCartographer_${safeName}_${stamp}"
        $logPath   = Join-Path $runDir "$baseName.log"

        # Flush buffered pre-connect log lines now that we have a path
        $logLines | Out-File -FilePath $logPath -Encoding utf8
        Write-Log "Run folder: $runDir"

        # ── Step 2: Collect ───────────────────────────────────────────────────
        Write-Progress -Activity 'S2DCartographer' -Status 'Collecting physical disks...' -PercentComplete 10
        Write-Log "Collecting physical disks..."
        $t = Get-Date; $physDisks = @(Get-S2DPhysicalDiskInventory)
        Write-Log "Physical disks: $($physDisks.Count) disk(s) collected in $([math]::Round(((Get-Date)-$t).TotalSeconds,1))s"

        Write-Progress -Activity 'S2DCartographer' -Status 'Collecting storage pool...' -PercentComplete 25
        Write-Log "Collecting storage pool..."
        $t = Get-Date; $pool = Get-S2DStoragePoolInfo
        Write-Log "Storage pool: $(if($pool){"$($pool.FriendlyName) [$($pool.HealthStatus)]"}else{'none found'}) in $([math]::Round(((Get-Date)-$t).TotalSeconds,1))s"

        Write-Progress -Activity 'S2DCartographer' -Status 'Collecting volumes...' -PercentComplete 40
        Write-Log "Collecting volumes..."
        $t = Get-Date; $volumes = @(Get-S2DVolumeMap)
        Write-Log "Volumes: $($volumes.Count) volume(s) collected in $([math]::Round(((Get-Date)-$t).TotalSeconds,1))s"

        Write-Progress -Activity 'S2DCartographer' -Status 'Analyzing cache tier...' -PercentComplete 55
        Write-Log "Analyzing cache tier..."
        $t = Get-Date; $cacheTier = Get-S2DCacheTierInfo
        Write-Log "Cache tier: $(if($cacheTier){"$($cacheTier.CacheState) / $($cacheTier.CacheMode)"}else{'no data'}) in $([math]::Round(((Get-Date)-$t).TotalSeconds,1))s"

        Write-Progress -Activity 'S2DCartographer' -Status 'Computing capacity waterfall...' -PercentComplete 65
        Write-Log "Computing capacity waterfall..."
        $t = Get-Date; $waterfall = Get-S2DCapacityWaterfall
        Write-Log "Waterfall: $(if($waterfall){"ReserveStatus=$($waterfall.ReserveStatus) Usable=$($waterfall.UsableCapacity.TiB) TiB"}else{'no data'}) in $([math]::Round(((Get-Date)-$t).TotalSeconds,1))s"

        $healthChecks  = @()
        $overallHealth = 'Unknown'
        if (-not $SkipHealthChecks) {
            Write-Progress -Activity 'S2DCartographer' -Status 'Running health checks...' -PercentComplete 75
            Write-Log "Running health checks..."
            $t = Get-Date; $healthChecks = @(Get-S2DHealthStatus)
            $overallHealth = [string]$Script:S2DSession.CollectedData['OverallHealth']
            $failed = @($healthChecks | Where-Object { $_.Status -ne 'Pass' })
            Write-Log "Health checks: OverallHealth=$overallHealth Checks=$($healthChecks.Count) NonPass=$($failed.Count) in $([math]::Round(((Get-Date)-$t).TotalSeconds,1))s"
            foreach ($f in $failed) {
                Write-Log " [$($f.Status)] $($f.CheckName): $($f.Details)" -Level 'WARN'
            }
        } else {
            Write-Log "Health checks skipped (-SkipHealthChecks)"
        }

        # ── Step 3: Assemble S2DClusterData ───────────────────────────────────
        $clusterData = [S2DClusterData]::new()
        $clusterData.ClusterName       = $Script:S2DSession.ClusterName
        $clusterData.ClusterFqdn       = $Script:S2DSession.ClusterFqdn
        $clusterData.NodeCount         = if ($Script:S2DSession.Nodes.Count -gt 0) { $Script:S2DSession.Nodes.Count } else { 0 }
        $clusterData.Nodes             = $Script:S2DSession.Nodes
        $clusterData.CollectedAt       = Get-Date
        $clusterData.PhysicalDisks     = $physDisks
        $clusterData.StoragePool       = $pool
        $clusterData.Volumes           = $volumes
        $clusterData.CacheTier         = $cacheTier
        $clusterData.CapacityWaterfall = $waterfall
        $clusterData.HealthChecks      = $healthChecks
        $clusterData.OverallHealth     = $overallHealth

        # ── Step 4: Generate reports ──────────────────────────────────────────
        $outputFiles = @()
        if ($Format) {
            Write-Progress -Activity 'S2DCartographer' -Status 'Generating reports...' -PercentComplete 85
            Write-Log "Generating reports: $($Format -join ', ')"
            $reportParams = @{
                InputObject     = $clusterData
                Format          = $Format
                OutputDirectory = $runDir
                Author          = $Author
                Company         = $Company
            }
            if ($IncludeNonPoolDisks) { $reportParams['IncludeNonPoolDisks'] = $true }
            $generated = @(New-S2DReport @reportParams)
            $outputFiles += $generated
            foreach ($f in $generated) { Write-Log " Report: $f" }
        }

        # ── Step 5: Generate diagrams ─────────────────────────────────────────
        if ($IncludeDiagrams) {
            Write-Progress -Activity 'S2DCartographer' -Status 'Generating diagrams...' -PercentComplete 95
            Write-Log "Generating diagrams..."
            New-Item -ItemType Directory -Path $diagramDir -Force | Out-Null
            $generated = @(New-S2DDiagram -InputObject $clusterData -DiagramType All -OutputDirectory $diagramDir)
            $outputFiles += $generated
            foreach ($f in $generated) { Write-Log " Diagram: $f" }
        }

        Write-Progress -Activity 'S2DCartographer' -Completed

        $elapsed = [math]::Round(((Get-Date) - $runStart).TotalSeconds, 1)
        Write-Log "Run complete. OverallHealth=$overallHealth Files=$($outputFiles.Count) Duration=${elapsed}s"
        Write-Log "Log: $logPath"

        if ($PassThru) { return $clusterData }
        $outputFiles

    }
    catch {
        Write-Log "FATAL: $_" -Level 'ERROR'
        throw
    }
    finally {
        if ($ownedSession -and $Script:S2DSession.IsConnected) {
            Disconnect-S2DCluster
            Write-Log "Disconnected from cluster."
        }
        # Final flush if log file was never opened (connect failed before runDir was created)
        if (-not $logPath -and $logLines.Count -gt 0) {
            $fallbackLog = Join-Path $OutputDirectory "S2DCartographer_failed_$($runStart.ToString('yyyyMMdd-HHmm')).log"
            $logLines | Out-File -FilePath $fallbackLog -Encoding utf8
        }
    }
}