Private/Write-MetricOutput.ps1

function Write-MetricOutput {
    <#
    .SYNOPSIS
        Writes VMPerformance rows to disk as CSV (default) or Parquet, plus a sidecar
        manifest describing the run and the memory proxy.

    .DESCRIPTION
        CSV is the default and is LightIngest-compatible (UTF-8, no type info). Parquet
        is only produced when the Parquet.Net assembly is loadable; otherwise the
        function warns and falls back to CSV.

        A '<basename>.manifest.json' is always written next to the data so downstream
        consumers can see the source, grain, window and -- importantly -- that
        '% Committed Bytes In Use' is a host-available-memory PROXY, not guest-committed.

    .PARAMETER Row
        The VMPerformance rows to write.

    .PARAMETER Path
        Output file path. Extension is normalised to match -Format.

    .PARAMETER Format
        'CSV' (default) or 'Parquet'.

    .PARAMETER Manifest
        Hashtable of run metadata to embed in the sidecar manifest.

    .OUTPUTS
        [string] the path of the data file written.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Row,
        [Parameter(Mandatory)] [string] $Path,
        [ValidateSet('CSV', 'Parquet')] [string] $Format = 'CSV',
        [hashtable] $Manifest = @{}
    )

    $dir = Split-Path -Parent $Path
    if ($dir -and -not (Test-Path $dir)) {
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
    }

    $base = [System.IO.Path]::Combine(
        (Split-Path -Parent $Path),
        [System.IO.Path]::GetFileNameWithoutExtension($Path))

    $effectiveFormat = $Format
    $dataPath = "$base.csv"

    if ($Format -eq 'Parquet') {
        $parquetAvailable = $null -ne (Get-Module -ListAvailable -Name 'Parquet.Net' -ErrorAction SilentlyContinue) `
            -or ([System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'Parquet' })
        if ($parquetAvailable) {
            $dataPath = "$base.parquet"
        }
        else {
            Write-Warning "Parquet.Net not available; falling back to CSV. Install Parquet.Net to enable parquet output."
            $effectiveFormat = 'CSV'
            $dataPath = "$base.csv"
        }
    }

    if ($PSCmdlet.ShouldProcess($dataPath, "Write $($Row.Count) rows ($effectiveFormat)")) {
        if ($effectiveFormat -eq 'CSV') {
            # Sort for deterministic, append-safe output (resourceId is the unique key).
            $sorted = $Row | Sort-Object resourceId, vmName, Date, CounterName
            $sorted | Export-Csv -Path $dataPath -NoTypeInformation -Encoding UTF8
        }
        else {
            # Parquet path: reserved for when Parquet.Net is wired in. Not reached today
            # because the availability check above downgrades to CSV.
            throw [System.NotImplementedException]::new('Parquet writer not yet implemented.')
        }

        # Sidecar manifest -- always written.
        $manifestPath = "$base.manifest.json"
        $full = [ordered]@{
            generatedBy   = 'FinOpsVMMetrics'
            dataFile      = Split-Path -Leaf $dataPath
            format        = $effectiveFormat
            rowCount      = $Row.Count
            source        = 'Azure Monitor platform metrics (host, no guest agent)'
            counters      = [ordered]@{
                '% Processor Time'         = 'Percentage CPU (per-interval average)'
                'Available MBytes'         = 'Available Memory Bytes / 1MB (exact host metric)'
                '% Committed Bytes In Use' = 'PROXY: 100 - (Available Memory Bytes / SKU total RAM * 100). Host-available derived, NOT guest committed bytes.'
            }
        }
        foreach ($k in $Manifest.Keys) { $full[$k] = $Manifest[$k] }
        ($full | ConvertTo-Json -Depth 6) | Set-Content -Path $manifestPath -Encoding UTF8
    }

    return $dataPath
}