modules/normalizers/Normalize-AzureCost.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Normalizer for Azure Cost (Consumption API) wrapper output.
.DESCRIPTION
    Converts v1 azure-cost wrapper output to v2 FindingRows.
    - Subscription roll-up -> EntityType=Subscription, Platform=Azure.
    - Top-N resources -> EntityType=AzureResource. MonthlyCost / Currency populated
      on the finding so EntityStore folds the cost onto the existing entity.
    All emitted findings are Severity=Info, Compliant=$true — azure-cost is an
    enrichment source, not a pass/fail tool.
#>

[CmdletBinding()]
param ()

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

function Resolve-CostImpact {
    param ([double] $MonthlyCost)

    if ($MonthlyCost -gt 1000) { return 'High' }
    if ($MonthlyCost -ge 200) { return 'Medium' }
    return 'Low'
}

function Resolve-CostEffort {
    param (
        [string] $EntityType,
        [string] $CostCategory
    )

    if ($EntityType -eq 'Subscription' -or $CostCategory -eq 'SubscriptionSpend') { return 'Medium' }
    return 'Low'
}

function Resolve-CostManagementDeepLink {
    param (
        [string] $SubscriptionId,
        [string] $ResourceGroup,
        [string] $ResourceId
    )

    if ([string]::IsNullOrWhiteSpace($SubscriptionId) -and $ResourceId -match '/subscriptions/([^/]+)') {
        $SubscriptionId = [string]$Matches[1]
    }
    if ([string]::IsNullOrWhiteSpace($ResourceGroup) -and $ResourceId -match '/resourceGroups/([^/]+)') {
        $ResourceGroup = [string]$Matches[1]
    }
    if ([string]::IsNullOrWhiteSpace($SubscriptionId)) { return '' }

    $base = 'https://portal.azure.com/#view/Microsoft_Azure_CostManagement/Menu/~/costanalysis'
    $params = [System.Collections.Generic.List[string]]::new()
    $params.Add("subscriptionId=$([uri]::EscapeDataString($SubscriptionId))") | Out-Null
    if (-not [string]::IsNullOrWhiteSpace($ResourceGroup)) {
        $params.Add("resourceGroup=$([uri]::EscapeDataString($ResourceGroup))") | Out-Null
    }
    if (-not [string]::IsNullOrWhiteSpace($ResourceId)) {
        $params.Add("resourceId=$([uri]::EscapeDataString($ResourceId))") | Out-Null
    }

    return "${base}?$($params -join '&')"
}

function Convert-ToRemediationSnippets {
    param ([string] $Remediation)

    if ([string]::IsNullOrWhiteSpace($Remediation)) { return @() }
    return @(@{
            language = 'text'
            code     = $Remediation.Trim()
        })
}

function Normalize-AzureCost {
    [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()

    foreach ($f in $ToolResult.Findings) {
        $rawId = if ($f.PSObject.Properties['ResourceId'] -and $f.ResourceId) { [string]$f.ResourceId } else { '' }
        if (-not $rawId) { continue }

        $subId = ''
        $rg    = ''
        if ($rawId -match '^[0-9a-fA-F-]{36}$') { $subId = $rawId }
        elseif ($rawId -match '/subscriptions/([^/]+)') { $subId = $Matches[1] }
        if ($rawId -match '/resourceGroups/([^/]+)') { $rg = $Matches[1] }

        $isSubscription = ($rawId -match '^[0-9a-fA-F-]{36}$') -or `
                          ($rawId -match '^/subscriptions/[^/]+/?$') -or `
                          ($f.PSObject.Properties['ResourceType'] -and $f.ResourceType -eq 'Microsoft.Resources/subscriptions')

        if ($isSubscription) {
            $entityType  = 'Subscription'
            $canonicalId = $subId.ToLowerInvariant()
        } else {
            $entityType = 'AzureResource'
            try   { $canonicalId = (ConvertTo-CanonicalEntityId -RawId $rawId -EntityType 'AzureResource').CanonicalId }
            catch { $canonicalId = $rawId.ToLowerInvariant() }
        }

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

        $monthlyCost = 0.0
        if ($f.PSObject.Properties['MonthlyCost'] -and $null -ne $f.MonthlyCost) {
            try { $monthlyCost = [double]$f.MonthlyCost } catch { $monthlyCost = 0.0 }
        }
        $currency = if ($f.PSObject.Properties['Currency'] -and $f.Currency) { [string]$f.Currency } else { '' }
        $costCategory = if ($f.PSObject.Properties['CostCategory'] -and $f.CostCategory) { [string]$f.CostCategory } else { 'Cost' }
        $impact = Resolve-CostImpact -MonthlyCost $monthlyCost
        $effort = Resolve-CostEffort -EntityType $entityType -CostCategory $costCategory
        $deepLinkUrl = Resolve-CostManagementDeepLink -SubscriptionId $subId -ResourceGroup $rg -ResourceId $rawId
        $frameworkControl = if ($entityType -eq 'Subscription') { 'Inform' } else { 'Optimize' }
        $frameworks = @(@{ kind = 'FinOps Foundation'; controlId = $frameworkControl })
        $remediation = if ($f.PSObject.Properties['Remediation'] -and $f.Remediation) {
            [string]$f.Remediation
        } elseif ($entityType -eq 'Subscription') {
            'Review Cost Analysis for high-spend services and enforce subscription budgets with alerts.'
        } else {
            'Use Cost Analysis and Advisor to rightsize this resource or apply autoscale and schedule controls.'
        }
        $remediationSnippets = @(Convert-ToRemediationSnippets -Remediation $remediation)
        $baselineTags = @('cost', 'finops', 'azure-cost', $costCategory.ToLowerInvariant())
        if ($entityType -eq 'Subscription') {
            $baselineTags += 'subscription-spend'
        } else {
            $baselineTags += 'resource-spend'
        }
        $evidenceUris = @()
        if ($f.PSObject.Properties['LearnMoreUrl'] -and $f.LearnMoreUrl) { $evidenceUris += [string]$f.LearnMoreUrl }
        if (-not [string]::IsNullOrWhiteSpace($deepLinkUrl)) { $evidenceUris += $deepLinkUrl }
        $entityRefs = @()
        if (-not [string]::IsNullOrWhiteSpace($subId)) { $entityRefs += $subId.ToLowerInvariant() }
        if ($rawId -match '^/subscriptions/') { $entityRefs += $rawId.ToLowerInvariant() }
        [Nullable[double]]$scoreDelta = $null
        if ($monthlyCost -gt 0) { $scoreDelta = [double]$monthlyCost }
        $toolVersion = if ($f.PSObject.Properties['ToolVersion'] -and $f.ToolVersion) {
            [string]$f.ToolVersion
        } elseif ($ToolResult.PSObject.Properties['ToolVersion'] -and $ToolResult.ToolVersion) {
            [string]$ToolResult.ToolVersion
        } else {
            ''
        }
        $ruleId = if ($f.PSObject.Properties['RuleId'] -and $f.RuleId) { [string]$f.RuleId } else { '' }

        $row = New-FindingRow -Id $findingId `
            -Source 'azure-cost' -EntityId $canonicalId -EntityType $entityType `
            -Title ([string]$f.Title) -Compliant $true -ProvenanceRunId $runId `
            -Platform 'Azure' -Category 'Cost' -Severity 'Info' `
            -Detail ([string]$f.Detail) -Remediation $remediation `
            -LearnMoreUrl ([string]$f.LearnMoreUrl) -ResourceId $rawId `
            -SubscriptionId $subId -ResourceGroup $rg `
            -RuleId $ruleId -Pillar 'CostOptimization' -Frameworks $frameworks `
            -Impact $impact -Effort $effort -DeepLinkUrl $deepLinkUrl `
            -RemediationSnippets $remediationSnippets -EvidenceUris $evidenceUris `
            -BaselineTags $baselineTags -ScoreDelta $scoreDelta -EntityRefs $entityRefs `
            -ToolVersion $toolVersion
        # MonthlyCost / Currency are entity-level, not part of New-FindingRow's signature.
        # Attach them to the finding so the orchestrator can fold them onto the entity.
        $row | Add-Member -NotePropertyName MonthlyCost -NotePropertyValue $monthlyCost -Force
        $row | Add-Member -NotePropertyName Currency    -NotePropertyValue $currency    -Force
        # Skip null rows (validation failed)
        if ($null -ne $row) {
            $normalized.Add($row)
        }
    }

    return @($normalized)
}