Private/Resolve-InforcerReportTypeSchema.ps1

function Resolve-InforcerReportTypeSchema {
    <#
    .SYNOPSIS
        Validates a (ReportType, OutputFormat, parameters) tuple against the report types catalog
        and returns the normalized POST /reports/runs entry (Private helper).
    .DESCRIPTION
        Uses the module-scoped $script:InforcerReportTypeCache. If the cache is empty, lazy-fetches
        the catalog from GET /beta/reports/types via Invoke-InforcerApiRequest.

        Validates client-side (so the user gets a clear error before the API does):
          * ReportType is in the catalog (case-insensitive)
          * OutputFormat is in the type's supportedFormats (when discoverable)
          * Collate switch is rejected on types where the catalog reports collatable:false
          * AssessmentId is mandatory for the Assessment report type
          * Smart defaults applied: CopilotAdoption / ShadowAiDetection → report-period=30 when omitted

        On invalid input, throws a terminating error. The catalog response shape isn't fully
        contractual yet — the helper introspects common property name variants and degrades to
        "skip validation, defer to server" when the shape isn't recognized.
    .PARAMETER ReportType
        Single report type key (e.g. 'CopilotAdoption'). Case-insensitive.
    .PARAMETER OutputFormat
        Single output format (e.g. 'csv', 'pdf'). Case-insensitive.
    .PARAMETER Parameter
        Optional hashtable of additional parameters passed straight to the API.
    .PARAMETER ReportPeriod
        Optional integer days. When set, merged into parameters as 'report-period'=<value>.
    .PARAMETER AssessmentId
        Optional opaque alphanumeric string (NOT a GUID — e.g. 'l1f8wd29pl44pp1j66r9'). When
        set, merged into parameters as 'assessment-id'=<value>. Required for
        ReportType='Assessment'.
    .PARAMETER Collate
        Request a single cross-tenant output. Only valid on types with collatable:true.
    .PARAMETER Force
        Bypass the cache and refetch the catalog.
    .OUTPUTS
        PSCustomObject with members:
          Entry — the [ordered]@{...} hashtable ready to drop into reports[] array
          TypeKey — canonical type key
          OutputFormat — canonical output format
          Collate — boolean
          Parameters — final flattened parameters hashtable
          CatalogEntry — the matching catalog item (or $null when catalog unavailable)
          IsCollatable — boolean / $null
          SupportedFormats — list of supported formats (or $null)
    #>

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

        [Parameter(Mandatory = $true)]
        [string]$OutputFormat,

        [Parameter(Mandatory = $false)]
        [hashtable]$Parameter,

        [Parameter(Mandatory = $false)]
        [Nullable[int]]$ReportPeriod,

        [Parameter(Mandatory = $false)]
        [string]$AssessmentId,

        [Parameter(Mandatory = $false)]
        [switch]$Collate,

        [Parameter(Mandatory = $false)]
        [switch]$Force
    )

    # 1. Ensure catalog is available. Cache lives for 15 minutes — long-running sessions
    # auto-refresh so newly-shipped report types don't require disconnect/reconnect.
    $catalogFetchError = $null
    $cacheTtlMinutes = 15
    $cacheIsStale = $false
    if ($script:InforcerReportTypeCache -and $script:InforcerReportTypeCacheStamp) {
        $age = (Get-Date) - $script:InforcerReportTypeCacheStamp
        if ($age.TotalMinutes -gt $cacheTtlMinutes) {
            Write-Verbose ("Reports catalog cache is {0:N1} minutes old (TTL {1}m) — refreshing." -f $age.TotalMinutes, $cacheTtlMinutes)
            $cacheIsStale = $true
        }
    }
    if ($Force -or $null -eq $script:InforcerReportTypeCache -or $cacheIsStale) {
        try {
            $catalog = Invoke-InforcerApiRequest -Endpoint '/beta/reports/types' -Method GET -ErrorAction Stop
        } catch {
            $catalog = $null
            # Defensive re-scrub: even though Invoke-InforcerApiRequest already redacts API
            # keys, surfacing $_.Exception.Message in our own Write-Warning is safer with one
            # more pass through Protect-InforcerApiKeyInText.
            $rawMsg = $_.Exception.Message
            $catalogFetchError = if ($script:InforcerSession -and $script:InforcerSession.ApiKey) {
                $maskedKey = ConvertFrom-InforcerSecureString -SecureString $script:InforcerSession.ApiKey
                Protect-InforcerApiKeyInText -Text $rawMsg -ApiKey $maskedKey
            } else { $rawMsg }
        }
        if ($catalog) {
            $script:InforcerReportTypeCache = @($catalog)
            $script:InforcerReportTypeCacheStamp = Get-Date
        }
    }
    $catalog = $script:InforcerReportTypeCache

    # If catalog fetch failed (vs. genuinely-empty endpoint), surface a single warning so
    # callers know their input wasn't validated client-side. Without this, a transient
    # network failure / rate limit / scope issue would let bogus -ReportType / -OutputFormat
    # / unknown -Parameter keys pass through to the API as confusing 400s.
    if ($null -eq $catalog -and $null -ne $catalogFetchError) {
        Write-Warning ("Report types catalog could not be fetched ({0}). Client-side validation of -ReportType / -OutputFormat / -Parameter is disabled for this call; errors will surface from the server." -f $catalogFetchError)
    }

    # 2. Look up the type entry (or proceed without when catalog isn't available)
    $typeEntry = $null
    if ($catalog) {
        $typeEntry = $catalog | Where-Object {
            $keyProp = $_.PSObject.Properties['key']
            $keyProp -and (($keyProp.Value -as [string]) -ieq $ReportType)
        } | Select-Object -First 1

        if (-not $typeEntry -and -not $Force) {
            # Cache may be stale (Inforcer ships new report types regularly). Refetch once before
            # surfacing "Unknown report type" — auto-recovery beats forcing the user to disconnect.
            Write-Verbose "Type '$ReportType' not found in cached catalog; refetching once before failing."
            try {
                $refreshed = Invoke-InforcerApiRequest -Endpoint '/beta/reports/types' -Method GET -ErrorAction Stop
            } catch {
                $refreshed = $null
            }
            if ($refreshed) {
                $script:InforcerReportTypeCache = @($refreshed)
                $catalog = $script:InforcerReportTypeCache
                $typeEntry = $catalog | Where-Object {
                    $keyProp = $_.PSObject.Properties['key']
                    $keyProp -and (($keyProp.Value -as [string]) -ieq $ReportType)
                } | Select-Object -First 1
            }
        }

        if (-not $typeEntry) {
            $availableKeys = @($catalog | ForEach-Object { $_.PSObject.Properties['key'].Value -as [string] } | Where-Object { $_ }) -join ', '
            throw "Unknown report type '$ReportType'. Available: $availableKeys"
        }
    } else {
        Write-Verbose "Report types catalog unavailable; skipping catalog validation for '$ReportType'."
    }

    # 3. Canonicalize ReportType / OutputFormat using catalog casing when present
    if ($typeEntry -and $typeEntry.PSObject.Properties['key']) {
        $ReportType = ($typeEntry.PSObject.Properties['key'].Value -as [string])
    }

    # 4. Validate OutputFormat against catalog (when discoverable)
    $supportedFormats = $null
    if ($typeEntry) {
        foreach ($candidate in 'supportedOutputFormats','outputFormats','supportedFormats','formats') {
            if ($typeEntry.PSObject.Properties[$candidate]) {
                $supportedFormats = $typeEntry.PSObject.Properties[$candidate].Value
                break
            }
        }
    }
    if ($supportedFormats) {
        $matchedFormat = $null
        foreach ($candidate in @($supportedFormats)) {
            if (($candidate -as [string]) -ieq $OutputFormat) { $matchedFormat = $candidate -as [string]; break }
        }
        if (-not $matchedFormat) {
            $list = ($supportedFormats -join ', ')
            throw "Report type '$ReportType' does not support output format '$OutputFormat'. Supported: $list"
        }
        $OutputFormat = $matchedFormat
    }

    # 5. Validate Collate against catalog
    $isCollatable = $null
    if ($typeEntry -and $typeEntry.PSObject.Properties['collatable']) {
        $isCollatable = [bool]$typeEntry.PSObject.Properties['collatable'].Value
    }
    if ($Collate.IsPresent -and $null -ne $isCollatable -and -not $isCollatable) {
        throw "Report type '$ReportType' does not support collation (collatable:false). Remove -Collate or choose a different type."
    }

    # 6. Build the final flattened parameters bag (string values only — matches API contract).
    # Reject non-scalar values up-front — passing an array or hashtable would otherwise get
    # silently coerced via `-as [string]` ("System.Object[]" or "1 2 3"), producing a request
    # the server can't act on without any clear error.
    $finalParams = @{}
    if ($Parameter) {
        foreach ($k in $Parameter.Keys) {
            $v = $Parameter[$k]
            # Scriptblocks would silently stringify to "{...}" via `-as [string]` — reject explicitly
            # so the user finds out their callable wasn't sent as a value.
            if ($v -is [scriptblock]) {
                throw "Parameter '$k' is a [scriptblock]; the API only accepts scalar string-coercible values. Did you mean to invoke the scriptblock first?"
            }
            # Arrays / lists / dictionaries silently flatten to "1 2 3" or "System.Object[]".
            # Exclude [string] (IEnumerable<char>) and [DateTime] (PowerShell decorates date types
            # with PSCustomObject markers in some hosts — keep them scalar).
            if ($null -ne $v -and $v -isnot [string] -and $v -isnot [datetime] -and $v -is [System.Collections.IEnumerable]) {
                throw "Parameter '$k' must be a scalar value; arrays and collections are not supported by the API. Got: $($v.GetType().FullName)."
            }
            # Use the literal PSCustomObject type — `-is [PSCustomObject]` is too broad in PS7 and
            # incorrectly matches scalars like [DateTime].
            if ($v -is [hashtable] -or $v -is [System.Management.Automation.PSCustomObject]) {
                throw "Parameter '$k' must be a scalar value; got nested object of type $($v.GetType().FullName)."
            }
            $finalParams[($k -as [string])] = ($v -as [string])
        }
    }
    if ($PSBoundParameters.ContainsKey('ReportPeriod')) {
        $finalParams['report-period'] = ($ReportPeriod -as [string])
    }
    if ($PSBoundParameters.ContainsKey('AssessmentId')) {
        # Public AssessmentId has no [ValidateNotNullOrEmpty]; an empty value would otherwise
        # bypass the Assessment-requires-id check below and reach the server with assessment-id=''
        # producing a confusing "invalid assessment" failure instead of a clear local error.
        if ([string]::IsNullOrWhiteSpace($AssessmentId)) {
            throw "AssessmentId was provided but is empty. Provide a non-empty -AssessmentId value (run Get-InforcerAssessment to list available IDs)."
        }
        $finalParams['assessment-id'] = $AssessmentId
    }

    # 7. Validate parameter keys against catalog (defensive — bug #6: server silently ignores unknowns)
    if ($typeEntry -and $finalParams.Count -gt 0) {
        $catalogParams = $null
        foreach ($candidate in 'requiredParameters','parameters','params') {
            if ($typeEntry.PSObject.Properties[$candidate]) {
                $catalogParams = $typeEntry.PSObject.Properties[$candidate].Value
                break
            }
        }
        if ($catalogParams) {
            $allowedKeys = @(
                foreach ($p in @($catalogParams)) {
                    if ($p -is [string]) {
                        $p
                    } else {
                        foreach ($keyProp in 'key','name','id') {
                            if ($p.PSObject.Properties[$keyProp]) {
                                $p.PSObject.Properties[$keyProp].Value -as [string]
                                break
                            }
                        }
                    }
                }
            ) | Where-Object { $_ }

            if ($allowedKeys.Count -gt 0) {
                $allowedSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
                foreach ($a in $allowedKeys) { [void]$allowedSet.Add($a) }
                foreach ($k in @($finalParams.Keys)) {
                    if (-not $allowedSet.Contains($k)) {
                        $allowedList = ($allowedKeys -join ', ')
                        throw "Unknown parameter '$k' for report type '$ReportType'. Allowed: $allowedList"
                    }
                }
            }
        }
    }

    # 8. Apply smart auto-defaults for known types
    if (-not $finalParams.ContainsKey('report-period')) {
        if ($ReportType -ieq 'CopilotAdoption' -or $ReportType -ieq 'ShadowAiDetection') {
            $finalParams['report-period'] = '30'
        }
    }

    # 9. Assessment type requires assessment-id with no sensible default
    if ($ReportType -ieq 'Assessment' -and -not $finalParams.ContainsKey('assessment-id')) {
        throw "Report type 'Assessment' requires -AssessmentId. Run Get-InforcerAssessment to list available IDs."
    }

    # 10. Build the per-request entry shape that Invoke-InforcerReport will drop into reports[]
    $entry = [ordered]@{
        type         = $ReportType
        outputFormat = $OutputFormat
    }
    if ($Collate.IsPresent) {
        $entry['collate'] = $true
    }
    if ($finalParams.Count -gt 0) {
        $entry['parameters'] = $finalParams
    }

    [PSCustomObject]@{
        Entry            = $entry
        TypeKey          = $ReportType
        OutputFormat     = $OutputFormat
        Collate          = [bool]$Collate.IsPresent
        Parameters       = $finalParams
        CatalogEntry     = $typeEntry
        IsCollatable     = $isCollatable
        SupportedFormats = $supportedFormats
    }
}