Private/Invoke-InforcerRawDownload.ps1

function Invoke-InforcerRawDownload {
    <#
    .SYNOPSIS
        Performs a binary-safe GET against an Inforcer API endpoint (Private helper).
    .DESCRIPTION
        Uses Invoke-WebRequest under the hood so the response body is captured as raw bytes
        rather than UTF-8-decoded text. Returns the bytes alongside the server-suggested
        filename (parsed from Content-Disposition) and the x-correlation-id for support.

        Used by Save-InforcerReportOutput to fetch /beta/reports/runs/{id}/outputs/{id} —
        which can be CSV, JSON, HTML, PDF, or other binary content.

        Error envelope handling mirrors Invoke-InforcerApiRequest: app-layer, APIM, and
        RFC 9110 ProblemDetails shapes are all parsed for a usable error message.
    .PARAMETER Endpoint
        API path (e.g. /beta/reports/runs/.../outputs/...). Leading slash optional.
    .PARAMETER DefaultFileName
        Suggested filename when the server doesn't return a usable Content-Disposition.
        Defaults to 'output'.
    .PARAMETER DestinationDirectory
        When set, response body is streamed directly to a temp file in this directory rather
        than buffered into memory, then renamed to the Content-Disposition filename. Use this
        for large downloads (>100MB) to avoid a 2GB+ in-memory allocation on multi-GB reports.
        The returned object has a `FilePath` instead of `Bytes`. The directory must already exist.
    .OUTPUTS
        PSCustomObject with members:
          Bytes — [byte[]] response body (omitted when -DestinationDirectory is used)
          FilePath — final on-disk path (only when -DestinationDirectory is used)
          FileName — sanitized filename (from Content-Disposition or DefaultFileName)
          ContentType — value of Content-Type header (or $null)
          CorrelationId — x-correlation-id of the response (or $null)
          StatusCode — int HTTP status code
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Endpoint,

        [Parameter(Mandatory = $false)]
        [string]$DefaultFileName = 'output',

        [Parameter(Mandatory = $false)]
        [string]$DestinationDirectory
    )

    if (-not (Test-InforcerSession)) {
        Write-Error -Message 'Not connected. Run Connect-Inforcer first.' `
            -ErrorId 'NotConnected' -Category ConnectionError
        return
    }

    $endpoint = $Endpoint.Trim()
    if (-not $endpoint.StartsWith('/')) {
        $endpoint = '/' + $endpoint
    }

    $uri = $script:InforcerSession.BaseUrl + $endpoint

    $apiKey = ConvertFrom-InforcerSecureString -SecureString $script:InforcerSession.ApiKey
    if ([string]::IsNullOrWhiteSpace($apiKey)) {
        Write-Error -Message 'API key is empty or invalid. Please reconnect.' `
            -ErrorId 'EmptyApiKey' -Category AuthenticationError
        return
    }

    $headers = @{
        'Inf-Api-Key' = $apiKey
        'Accept'      = '*/*'
    }

    Write-Verbose "Downloading: GET $uri"

    # When -DestinationDirectory is set, stream the body straight to a temp file via
    # `-OutFile` so we don't materialize a multi-GB body in memory. The body is renamed
    # to the Content-Disposition filename after the response headers are inspected.
    $tempStreamPath = $null
    if ($PSBoundParameters.ContainsKey('DestinationDirectory')) {
        if (-not (Test-Path -LiteralPath $DestinationDirectory -PathType Container)) {
            Write-Error -Message "DestinationDirectory '$DestinationDirectory' does not exist." `
                -ErrorId 'BadDestination' -Category InvalidArgument
            return
        }
        $tempStreamPath = Join-Path -Path $DestinationDirectory -ChildPath ('inforcer-download-' + [guid]::NewGuid().ToString('N') + '.partial')
    }

    try {
        # -SkipHttpErrorCheck lets us inspect status code and body without exception-based flow.
        if ($tempStreamPath) {
            $response = Invoke-WebRequest -Uri $uri -Method GET -Headers $headers -SkipHttpErrorCheck `
                -UseBasicParsing -OutFile $tempStreamPath -PassThru -ErrorAction Stop
        } else {
            $response = Invoke-WebRequest -Uri $uri -Method GET -Headers $headers -SkipHttpErrorCheck `
                -UseBasicParsing -ErrorAction Stop
        }
    } catch {
        if ($tempStreamPath -and (Test-Path -LiteralPath $tempStreamPath)) {
            Remove-Item -LiteralPath $tempStreamPath -Force -ErrorAction SilentlyContinue
        }
        $msg = Protect-InforcerApiKeyInText -Text $_.Exception.Message -ApiKey $apiKey
        Write-Error -Message "Download request failed: $msg" `
            -ErrorId 'DownloadFailed' -Category ConnectionError
        return
    }

    $statusCode    = [int]$response.StatusCode
    $correlationId = Get-InforcerHeaderValue -Headers $response.Headers -Name 'x-correlation-id'
    if ($correlationId) {
        Write-Verbose "x-correlation-id: $correlationId"
    }

    if ($statusCode -lt 200 -or $statusCode -ge 300) {
        # Parse error body for a friendly message — same envelope shapes as Invoke-InforcerApiRequest.
        # If we streamed to disk, the error body is in the temp file, not $response.Content.
        $detail = $null
        $bodyText = $null
        if ($tempStreamPath -and (Test-Path -LiteralPath $tempStreamPath)) {
            try { $bodyText = Get-Content -LiteralPath $tempStreamPath -Raw -ErrorAction Stop } catch { $bodyText = $null }
            Remove-Item -LiteralPath $tempStreamPath -Force -ErrorAction SilentlyContinue
        } elseif ($response.Content) {
            $bodyText = if ($response.Content -is [byte[]]) {
                [System.Text.Encoding]::UTF8.GetString($response.Content)
            } else {
                $response.Content -as [string]
            }
        }
        if ($bodyText) {
            $detail = $bodyText
            try {
                $json = $bodyText | ConvertFrom-Json -ErrorAction Stop
                $apiMessage = $null
                $traceId    = $null
                if ($null -ne $json.PSObject.Properties['message']) {
                    $apiMessage = $json.PSObject.Properties['message'].Value -as [string]
                } elseif ($null -ne $json.PSObject.Properties['title']) {
                    $apiMessage = $json.PSObject.Properties['title'].Value -as [string]
                    if ($null -ne $json.PSObject.Properties['traceId']) {
                        $traceId = $json.PSObject.Properties['traceId'].Value -as [string]
                    }
                }
                if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { $detail = $apiMessage }
                if (-not [string]::IsNullOrWhiteSpace($traceId))    { $detail = "$detail (traceId: $traceId)" }
            } catch {
                Write-Verbose 'Error response body was not JSON; using raw content.'
            }
        }
        if ([string]::IsNullOrWhiteSpace($detail)) { $detail = "HTTP $statusCode" }
        $detail = Protect-InforcerApiKeyInText -Text $detail -ApiKey $apiKey
        $msg = "Inforcer API download failed (HTTP $statusCode): $detail"
        if ($correlationId) { $msg = "$msg [correlation-id: $correlationId]" }
        Write-Error -Message $msg -ErrorId "DownloadFailed_$statusCode" -Category ConnectionError
        return
    }

    # Extract Content-Disposition / Content-Type header values (cross-shape tolerant).
    $disposition = $null
    $contentType = $null
    if ($response.Headers) {
        $disposition = Get-InforcerHeaderValue -Headers $response.Headers -Name 'Content-Disposition'
        $contentType = Get-InforcerHeaderValue -Headers $response.Headers -Name 'Content-Type'
    }

    $fileName = Resolve-InforcerReportOutputFileName -ContentDisposition $disposition -DefaultName $DefaultFileName

    if ($tempStreamPath) {
        # Streaming mode: rename the temp file to the final Content-Disposition name. Move-Item
        # is atomic within the same volume; if the destination already exists, Force overwrites it.
        $finalPath = Join-Path -Path $DestinationDirectory -ChildPath $fileName
        try {
            Move-Item -LiteralPath $tempStreamPath -Destination $finalPath -Force -ErrorAction Stop
        } catch {
            Remove-Item -LiteralPath $tempStreamPath -Force -ErrorAction SilentlyContinue
            Write-Error -Message "Failed to finalize downloaded file at '$finalPath': $($_.Exception.Message)" `
                -ErrorId 'DownloadFinalizeFailed' -Category WriteError
            return
        }
        $size = (Get-Item -LiteralPath $finalPath -ErrorAction SilentlyContinue).Length
        return [PSCustomObject]@{
            FilePath      = $finalPath
            FileName      = $fileName
            FileSize      = $size
            ContentType   = $contentType
            CorrelationId = $correlationId
            StatusCode    = $statusCode
        }
    }

    # In-memory mode (existing behavior — backwards compatible for small payloads).
    # Invoke-WebRequest returns Content as byte[] when -UseBasicParsing and binary response.
    $bytes = if ($response.Content -is [byte[]]) {
        $response.Content
    } elseif ($response.Content -is [string]) {
        [System.Text.Encoding]::UTF8.GetBytes($response.Content)
    } else {
        @()
    }

    [PSCustomObject]@{
        Bytes         = $bytes
        FileName      = $fileName
        ContentType   = $contentType
        CorrelationId = $correlationId
        StatusCode    = $statusCode
    }
}