modules/normalizers/Normalize-Infracost.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Normalizer for Infracost wrapper output.
.DESCRIPTION
    Converts v1 Infracost wrapper output to v2 FindingRow objects.

    Design:
    - One finding per estimated IaC resource (more actionable than one summary).
    - EntityType is AzureResource for report consistency.
    - Because IaC pre-deploy estimates do not have deployed ARM IDs yet, this
      normalizer generates a deterministic synthetic ARM-style resource ID.
    - Severity heuristic is monthly-cost based:
        > 1000 => High
        > 100 => Medium
        else => Low
#>

[CmdletBinding()]
param ()

. "$PSScriptRoot\..\shared\Schema.ps1"
. "$PSScriptRoot\..\shared\Canonicalize.ps1"

function ConvertTo-InfracostSlug {
    param([string]$Value)
    $slug = if ($Value) { $Value.ToLowerInvariant() } else { 'unknown' }
    $slug = $slug -replace '[^a-z0-9\-]+', '-'
    $slug = $slug.Trim('-')
    if ([string]::IsNullOrWhiteSpace($slug)) { return 'unknown' }
    return $slug
}

function Resolve-InfracostSeverity {
    param([double]$MonthlyCost)
    if ($MonthlyCost -gt 1000) { return 'High' }
    if ($MonthlyCost -gt 100) { return 'Medium' }
    return 'Low'
}

function ConvertTo-InfracostDouble {
    param(
        [AllowNull()][object]$Value,
        [Nullable[double]]$Default = $null
    )
    if ($null -eq $Value) { return $Default }
    $parsed = 0.0
    if ([double]::TryParse([string]$Value, [System.Globalization.NumberStyles]::Any, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)) {
        return [double]$parsed
    }
    return $Default
}

function Resolve-InfracostImpact {
    param(
        [double]$MonthlyCost,
        [double]$ProjectTotalMonthlyCost
    )
    if ($ProjectTotalMonthlyCost -le 0) { return 'Low' }
    $percentage = ($MonthlyCost / $ProjectTotalMonthlyCost) * 100
    if ($percentage -ge 30) { return 'High' }
    if ($percentage -ge 10) { return 'Medium' }
    return 'Low'
}

function Resolve-InfracostEffort {
    param([string]$ResourceType)
    $normalized = if ($ResourceType) { $ResourceType.ToLowerInvariant() } else { '' }
    if ($normalized -match 'resource_group|tag|diagnostic') { return 'Low' }
    if ($normalized -match 'storage|app_service_plan|public_ip|disk|redis|servicebus') { return 'Low' }
    if ($normalized -match 'kubernetes|aks|sql|postgres|cosmos|firewall|application_gateway|frontdoor') { return 'Medium' }
    return 'Low'
}

function Convert-ToHashtableArray {
    param([object[]]$Items)
    $result = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($item in @($Items)) {
        if (-not $item) { continue }
        if ($item -is [hashtable]) {
            $result.Add($item) | Out-Null
            continue
        }
        if ($item -is [System.Collections.IDictionary]) {
            $table = @{}
            foreach ($key in $item.Keys) { $table[[string]$key] = $item[$key] }
            $result.Add($table) | Out-Null
            continue
        }
        $props = $item.PSObject.Properties
        if ($props) {
            $table = @{}
            foreach ($prop in $props) { $table[[string]$prop.Name] = $prop.Value }
            $result.Add($table) | Out-Null
        }
    }
    return @($result)
}

function Convert-ToStringArray {
    param([object[]]$Items)
    $result = [System.Collections.Generic.List[string]]::new()
    foreach ($item in @($Items)) {
        if ([string]::IsNullOrWhiteSpace([string]$item)) { continue }
        $result.Add([string]$item) | Out-Null
    }
    return @($result)
}

function Normalize-Infracost {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [PSCustomObject] $ToolResult
    )

    if ($ToolResult.Status -ne 'Success' -or -not $ToolResult.Findings) {
        return @()
    }

    $runId = [guid]::NewGuid().ToString()
    $normalized = [System.Collections.Generic.List[PSCustomObject]]::new()
    $syntheticSub = '00000000-0000-0000-0000-000000000000'

    foreach ($finding in @($ToolResult.Findings)) {
        if (-not $finding) { continue }

        $findingId = if ($finding.PSObject.Properties['Id'] -and $finding.Id) {
            [string]$finding.Id
        } else {
            [guid]::NewGuid().ToString()
        }

        $resourceType = if ($finding.PSObject.Properties['ResourceType'] -and $finding.ResourceType) {
            [string]$finding.ResourceType
        } else {
            'unknown'
        }
        $resourceName = if ($finding.PSObject.Properties['ResourceName'] -and $finding.ResourceName) {
            [string]$finding.ResourceName
        } else {
            $findingId
        }
        $projectName = if ($finding.PSObject.Properties['ProjectName'] -and $finding.ProjectName) {
            [string]$finding.ProjectName
        } else {
            'project'
        }

        $monthlyCost = 0.0
        if ($finding.PSObject.Properties['MonthlyCost'] -and $null -ne $finding.MonthlyCost) {
            try {
                $monthlyCost = [double]$finding.MonthlyCost
            } catch {
                $monthlyCost = 0.0
            }
        }
        $currency = if ($finding.PSObject.Properties['Currency'] -and $finding.Currency) {
            [string]$finding.Currency
        } else {
            'USD'
        }
        $projectTotalMonthlyCost = if ($finding.PSObject.Properties['ProjectTotalMonthlyCost']) {
            ConvertTo-InfracostDouble -Value $finding.ProjectTotalMonthlyCost -Default $monthlyCost
        } elseif ($ToolResult.PSObject.Properties['ToolSummary'] -and $ToolResult.ToolSummary -and $ToolResult.ToolSummary.PSObject.Properties['TotalMonthlyCost']) {
            ConvertTo-InfracostDouble -Value $ToolResult.ToolSummary.TotalMonthlyCost -Default $monthlyCost
        } else {
            $monthlyCost
        }
        $baselineMonthlyCost = if ($finding.PSObject.Properties['BaselineMonthlyCost']) {
            ConvertTo-InfracostDouble -Value $finding.BaselineMonthlyCost -Default 0
        } elseif ($ToolResult.PSObject.Properties['ToolSummary'] -and $ToolResult.ToolSummary -and $ToolResult.ToolSummary.PSObject.Properties['BaselineMonthlyCost']) {
            ConvertTo-InfracostDouble -Value $ToolResult.ToolSummary.BaselineMonthlyCost -Default 0
        } else {
            0
        }
        $scoreDelta = if ($finding.PSObject.Properties['DiffMonthlyCost']) {
            ConvertTo-InfracostDouble -Value $finding.DiffMonthlyCost -Default $null
        } elseif ($ToolResult.PSObject.Properties['ToolSummary'] -and $ToolResult.ToolSummary -and $ToolResult.ToolSummary.PSObject.Properties['DiffMonthlyCost']) {
            ConvertTo-InfracostDouble -Value $ToolResult.ToolSummary.DiffMonthlyCost -Default $null
        } elseif ($baselineMonthlyCost -gt 0) {
            [double]$monthlyCost - [double]$baselineMonthlyCost
        } else {
            $null
        }
        $impact = if ($finding.PSObject.Properties['Impact'] -and $finding.Impact) {
            [string]$finding.Impact
        } else {
            Resolve-InfracostImpact -MonthlyCost $monthlyCost -ProjectTotalMonthlyCost $projectTotalMonthlyCost
        }
        $effort = if ($finding.PSObject.Properties['Effort'] -and $finding.Effort) {
            [string]$finding.Effort
        } else {
            Resolve-InfracostEffort -ResourceType $resourceType
        }
        $pillar = if ($finding.PSObject.Properties['Pillar'] -and $finding.Pillar) { [string]$finding.Pillar } else { 'Cost' }
        $deepLinkUrl = if ($finding.PSObject.Properties['DeepLinkUrl'] -and $finding.DeepLinkUrl) { [string]$finding.DeepLinkUrl } else { '' }
        $remediationSnippets = if ($finding.PSObject.Properties['RemediationSnippets'] -and $finding.RemediationSnippets) { Convert-ToHashtableArray -Items @($finding.RemediationSnippets) } else { @() }
        $evidenceUris = if ($finding.PSObject.Properties['EvidenceUris'] -and $finding.EvidenceUris) { Convert-ToStringArray -Items @($finding.EvidenceUris) } else { @() }
        $entityRefs = if ($finding.PSObject.Properties['EntityRefs'] -and $finding.EntityRefs) { Convert-ToStringArray -Items @($finding.EntityRefs) } else { @() }
        $toolVersion = if ($finding.PSObject.Properties['ToolVersion'] -and $finding.ToolVersion) {
            [string]$finding.ToolVersion
        } elseif ($ToolResult.PSObject.Properties['ToolVersion'] -and $ToolResult.ToolVersion) {
            [string]$ToolResult.ToolVersion
        } else {
            ''
        }
        $frameworks = @(
            @{
                kind      = 'WAF'
                controlId = 'Cost'
            }
        )
        $baselineTags = @()
        if ($baselineMonthlyCost -gt 0) {
            $baselineTags = @('infracost:baseline')
        }

        $resourceSlug = ConvertTo-InfracostSlug -Value "$projectName-$resourceType-$resourceName"
        $syntheticArmId = "/subscriptions/$syntheticSub/resourceGroups/infracost-iac/providers/Microsoft.Infracost/iacResources/$resourceSlug"
        $canonicalId = $syntheticArmId.ToLowerInvariant()
        try {
            $canonicalId = (ConvertTo-CanonicalEntityId -RawId $syntheticArmId -EntityType 'AzureResource').CanonicalId
        } catch {
            # Keep synthetic ARM id as fallback.
        }

        $severity = Resolve-InfracostSeverity -MonthlyCost $monthlyCost
        $title = "Estimated monthly cost: $([string]::Format([System.Globalization.CultureInfo]::InvariantCulture, '{0:0.00}', $monthlyCost)) for $resourceType"
        $detail = if ($finding.PSObject.Properties['ProjectPath'] -and $finding.ProjectPath) {
            "Resource $resourceName from $($finding.ProjectPath). Static IaC estimate generated before deployment."
        } else {
            "Resource $resourceName from project $projectName. Static IaC estimate generated before deployment."
        }

        $row = New-FindingRow -Id $findingId `
            -Source 'infracost' -EntityId $canonicalId -EntityType 'AzureResource' `
            -Title $title -Compliant ($monthlyCost -le 100) -ProvenanceRunId $runId `
            -Platform 'Azure' -Category 'WAF Cost Optimization' -Severity $severity `
            -Detail $detail `
            -Remediation 'Review right-sizing, SKU selection, and environment count before deployment.' `
            -LearnMoreUrl 'https://www.infracost.io/docs/' `
            -ResourceId $syntheticArmId `
            -SubscriptionId $syntheticSub `
            -ResourceGroup 'infracost-iac' `
            -Pillar $pillar -Impact $impact -Effort $effort -DeepLinkUrl $deepLinkUrl `
            -Frameworks $frameworks -RemediationSnippets $remediationSnippets `
            -EvidenceUris $evidenceUris -BaselineTags $baselineTags -ScoreDelta $scoreDelta `
            -EntityRefs $entityRefs -ToolVersion $toolVersion
        if ($null -eq $row) { continue }

        $row | Add-Member -NotePropertyName MonthlyCost -NotePropertyValue ([math]::Round($monthlyCost, 2)) -Force
        $row | Add-Member -NotePropertyName Currency -NotePropertyValue $currency -Force
        $normalized.Add($row)
    }

    return @($normalized)
}