modules/normalizers/Normalize-AksKarpenterCost.ps1

#Requires -Version 7.4
[CmdletBinding()]
param ()

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

function ConvertTo-AksKarpenterSeverity {
    param([string]$RawSeverity)
    switch -Regex (($RawSeverity ?? '').ToLowerInvariant()) {
        '^critical$' { 'Critical' }
        '^high$'     { 'High' }
        '^medium$'   { 'Medium' }
        '^low$'      { 'Low' }
        '^info'      { 'Info' }
        default      { 'Info' }
    }
}

function Convert-ToStringArray {
    param ([object]$Value)
    if ($null -eq $Value) { return @() }
    $items = [System.Collections.Generic.List[string]]::new()
    if ($Value -is [string]) {
        if (-not [string]::IsNullOrWhiteSpace($Value)) { $items.Add($Value.Trim()) | Out-Null }
    } else {
        foreach ($item in @($Value)) {
            if ($null -eq $item) { continue }
            $text = [string]$item
            if (-not [string]::IsNullOrWhiteSpace($text)) { $items.Add($text.Trim()) | Out-Null }
        }
    }
    return @($items)
}

function Convert-ToHashtableArray {
    param ([object]$Value)
    $items = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($entry in @($Value)) {
        if ($null -eq $entry) { continue }
        if ($entry -is [System.Collections.IDictionary]) {
            $map = @{}
            foreach ($k in $entry.Keys) { $map[[string]$k] = $entry[$k] }
            $items.Add($map) | Out-Null
            continue
        }
        if ($entry.PSObject) {
            $props = @($entry.PSObject.Properties)
            if ($props.Count -gt 0) {
                $map = @{}
                foreach ($p in $props) { $map[$p.Name] = $p.Value }
                $items.Add($map) | Out-Null
            }
        }
    }
    return @($items)
}

function Resolve-AksKarpenterPillar {
    param ([pscustomobject]$Finding)
    $raw = if ($Finding.PSObject.Properties['Pillar']) { [string]$Finding.Pillar } else { '' }
    if (-not [string]::IsNullOrWhiteSpace($raw)) { return $raw.Trim() }
    if ($Finding.RuleId -eq 'karpenter.consolidation-disabled') { return 'Cost Optimization; Reliability' }
    return 'Cost Optimization'
}

function Resolve-AksKarpenterImpact {
    param ([pscustomobject]$Finding)
    $raw = if ($Finding.PSObject.Properties['Impact']) { [string]$Finding.Impact } else { '' }
    if (-not [string]::IsNullOrWhiteSpace($raw)) { return $raw.Trim() }

    if ($Finding.RuleId -eq 'karpenter.no-node-limit') { return 'High' }
    if ($Finding.RuleId -eq 'karpenter.consolidation-disabled') { return 'Medium' }

    if ($Finding.PSObject.Properties['NodeHours'] -and $null -ne $Finding.NodeHours) {
        $hours = [double]$Finding.NodeHours
        if ($hours -ge 500.0) { return 'High' }
        if ($hours -ge 150.0) { return 'Medium' }
        return 'Low'
    }

    if ($Finding.PSObject.Properties['ObservedPercent'] -and $null -ne $Finding.ObservedPercent) {
        $pct = [double]$Finding.ObservedPercent
        if ($pct -le 10.0) { return 'High' }
        if ($pct -le 35.0) { return 'Medium' }
        return 'Low'
    }

    return 'Low'
}

function Resolve-AksKarpenterEffort {
    param ([pscustomobject]$Finding)
    $raw = if ($Finding.PSObject.Properties['Effort']) { [string]$Finding.Effort } else { '' }
    if (-not [string]::IsNullOrWhiteSpace($raw)) { return $raw.Trim() }
    if ($Finding.RuleId -like 'karpenter.*') { return 'Medium' }
    return 'Low'
}

function Get-AksKarpenterBaselineTags {
    param ([pscustomobject]$Finding)
    if ($Finding.PSObject.Properties['BaselineTags'] -and $Finding.BaselineTags) {
        return @(Convert-ToStringArray $Finding.BaselineTags)
    }
    $ruleTag = switch ($Finding.RuleId) {
        'aks.idle-node' { 'Karpenter-IdleNodes' }
        'karpenter.consolidation-disabled' { 'Karpenter-Consolidation' }
        'karpenter.no-node-limit' { 'Karpenter-ProvisionerLimits' }
        'karpenter.over-provisioned' { 'Karpenter-IdleNodes' }
        default { 'Karpenter-NodeHours' }
    }
    $rbac = if ($Finding.PSObject.Properties['RbacTier']) { [string]$Finding.RbacTier } else { 'Reader' }
    $rbacTag = if ($rbac -eq 'Reader') { 'RBAC-Reader' } else { 'RBAC-ClusterAdmin' }
    return @($ruleTag, $rbacTag)
}

function Get-AksKarpenterEntityRefs {
    param ([pscustomobject]$Finding, [string]$ClusterArm)
    if ($Finding.PSObject.Properties['EntityRefs'] -and $Finding.EntityRefs) {
        return @(Convert-ToStringArray $Finding.EntityRefs)
    }
    $refs = [System.Collections.Generic.List[string]]::new()
    if (-not [string]::IsNullOrWhiteSpace($ClusterArm)) { $refs.Add($ClusterArm) | Out-Null }
    if ($Finding.PSObject.Properties['ProvisionerName'] -and -not [string]::IsNullOrWhiteSpace([string]$Finding.ProvisionerName)) {
        $refs.Add([string]$Finding.ProvisionerName) | Out-Null
    }
    return @($refs)
}

function Get-AksKarpenterScoreDelta {
    param ([pscustomobject]$Finding)
    if ($Finding.PSObject.Properties['ScoreDelta'] -and $null -ne $Finding.ScoreDelta) {
        return [double]$Finding.ScoreDelta
    }
    if ($Finding.PSObject.Properties['NodeHours'] -and $null -ne $Finding.NodeHours) {
        return [double]$Finding.NodeHours
    }
    if ($Finding.PSObject.Properties['ObservedPercent'] -and $null -ne $Finding.ObservedPercent) {
        return [double]$Finding.ObservedPercent
    }
    return $null
}

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

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

    $runId = [guid]::NewGuid().ToString()
    $normalized = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($f in $ToolResult.Findings) {
        $entityType = if ($f.PSObject.Properties['EntityType'] -and $f.EntityType) { [string]$f.EntityType } else { 'AzureResource' }
        if ($entityType -notin @('AzureResource', 'KarpenterProvisioner')) { $entityType = 'AzureResource' }

        $rawId = if ($f.PSObject.Properties['EntityRawId'] -and $f.EntityRawId) {
            [string]$f.EntityRawId
        } elseif ($f.PSObject.Properties['ResourceId'] -and $f.ResourceId) {
            [string]$f.ResourceId
        } else {
            ''
        }
        if ([string]::IsNullOrWhiteSpace($rawId)) { continue }

        try {
            $canonical = ConvertTo-CanonicalEntityId -RawId $rawId -EntityType $entityType
            $canonicalId = $canonical.CanonicalId
            $platform    = $canonical.Platform
        } catch {
            $canonicalId = $rawId.ToLowerInvariant()
            $platform    = 'Azure'
        }

        $clusterArm = if ($f.PSObject.Properties['ResourceId'] -and $f.ResourceId) { [string]$f.ResourceId } else { '' }
        $subId = ''
        $rg    = ''
        if ($clusterArm -match '/subscriptions/([^/]+)') { $subId = $Matches[1] }
        if ($clusterArm -match '/resourceGroups/([^/]+)') { $rg = $Matches[1] }

        $severity    = ConvertTo-AksKarpenterSeverity -RawSeverity ([string]$f.Severity)
        $findingId   = if ($f.PSObject.Properties['Id'] -and $f.Id) { [string]$f.Id } else { [guid]::NewGuid().ToString() }
        $title       = if ($f.PSObject.Properties['Title'] -and $f.Title) { [string]$f.Title } else { 'AKS Karpenter cost signal' }
        $detail      = if ($f.PSObject.Properties['Detail'] -and $f.Detail) { [string]$f.Detail } else { '' }
        $remediation = if ($f.PSObject.Properties['Remediation'] -and $f.Remediation) { [string]$f.Remediation } else { '' }
        $category    = if ($f.PSObject.Properties['Category'] -and $f.Category) { [string]$f.Category } else { 'Cost' }
        $learnMore   = if ($f.PSObject.Properties['LearnMoreUrl']) { [string]$f.LearnMoreUrl } else { '' }
        $source      = if ($f.PSObject.Properties['Source'] -and $f.Source) { [string]$f.Source } else { 'aks-karpenter-cost' }
        $ruleId      = if ($f.PSObject.Properties['RuleId']     -and $f.RuleId)     { [string]$f.RuleId } else { '' }
        $compliant   = if ($f.PSObject.Properties['Compliant'])               { [bool]$f.Compliant } else { $false }
        $pillar      = Resolve-AksKarpenterPillar -Finding $f
        $impact      = Resolve-AksKarpenterImpact -Finding $f
        $effort      = Resolve-AksKarpenterEffort -Finding $f
        $deepLinkUrl = if ($f.PSObject.Properties['DeepLinkUrl']) { [string]$f.DeepLinkUrl } else { '' }
        $remediationSnippets = if ($f.PSObject.Properties['RemediationSnippets']) {
            @(Convert-ToHashtableArray $f.RemediationSnippets)
        } else { @() }
        $evidenceUris = if ($f.PSObject.Properties['EvidenceUris']) {
            @(Convert-ToStringArray $f.EvidenceUris)
        } else { @() }
        $baselineTags = @(Get-AksKarpenterBaselineTags -Finding $f)
        $scoreDelta = Get-AksKarpenterScoreDelta -Finding $f
        $entityRefs = @(Get-AksKarpenterEntityRefs -Finding $f -ClusterArm $clusterArm)
        $toolVersion = if ($f.PSObject.Properties['ToolVersion'] -and -not [string]::IsNullOrWhiteSpace([string]$f.ToolVersion)) {
            [string]$f.ToolVersion
        } elseif ($ToolResult.PSObject.Properties['ToolVersion']) {
            [string]$ToolResult.ToolVersion
        } else {
            ''
        }

        $row = New-FindingRow -Id $findingId `
            -Source $source -EntityId $canonicalId -EntityType $entityType `
            -Title $title -RuleId $ruleId -Compliant $compliant -ProvenanceRunId $runId `
            -Platform $platform -Category $category -Severity $severity `
            -Detail $detail -Remediation $remediation `
            -LearnMoreUrl $learnMore -ResourceId $clusterArm `
            -SubscriptionId $subId -ResourceGroup $rg `
            -Pillar $pillar -Impact $impact -Effort $effort `
            -DeepLinkUrl $deepLinkUrl -RemediationSnippets $remediationSnippets `
            -EvidenceUris $evidenceUris -BaselineTags $baselineTags `
            -ScoreDelta $scoreDelta -EntityRefs $entityRefs `
            -ToolVersion $toolVersion

        if ($null -eq $row) { continue }

        foreach ($extra in @(
                'ClusterName', 'ClusterResourceGroup', 'ProvisionerName',
                'NodeName', 'NodeCount', 'NodeHours',
                'ObservedPercent', 'RbacTier'
            )) {
            if ($f.PSObject.Properties[$extra] -and $null -ne $f.$extra -and [string]$f.$extra -ne '') {
                $row | Add-Member -NotePropertyName $extra -NotePropertyValue $f.$extra -Force
            }
        }

        $normalized.Add($row)
    }

    return @($normalized)
}