modules/normalizers/Normalize-AppInsights.ps1

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

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

function Get-AppInsightsSeverity {
    param([pscustomobject] $Finding)

    $queryType = if ($Finding.PSObject.Properties['QueryType']) { [string]$Finding.QueryType } else { '' }
    if ($queryType -eq 'requests') {
        $avg = 0.0
        if ($Finding.PSObject.Properties['AvgDurationSeconds']) { $avg = [double]$Finding.AvgDurationSeconds }
        if ($avg -gt 30) { return 'High' }
        if ($avg -gt 5) { return 'Medium' }
        return 'Low'
    }
    if ($queryType -eq 'exceptions') { return 'High' }
    if ($queryType -eq 'dependencies') { return 'Medium' }

    switch -Regex ([string]$Finding.Severity) {
        '^(?i)critical$' { 'Critical' }
        '^(?i)high$'     { 'High' }
        '^(?i)medium$'   { 'Medium' }
        '^(?i)low$'      { 'Low' }
        default          { 'Info' }
    }
}

function Get-AppInsightsTitle {
    param([pscustomobject] $Finding)

    $queryType = if ($Finding.PSObject.Properties['QueryType']) { [string]$Finding.QueryType } else { '' }
    $count = if ($Finding.PSObject.Properties['Count']) { [int]$Finding.Count } else { 0 }

    switch ($queryType) {
        'requests' {
            $name = if ($Finding.PSObject.Properties['RequestName']) { [string]$Finding.RequestName } else { 'unknown-request' }
            $avg = if ($Finding.PSObject.Properties['AvgDurationSeconds']) { [math]::Round([double]$Finding.AvgDurationSeconds, 3) } else { 0 }
            return "Slow request: $name avg $avg`s over $count calls"
        }
        'dependencies' {
            $name = if ($Finding.PSObject.Properties['DependencyName']) { [string]$Finding.DependencyName } else { 'unknown-dependency' }
            $type = if ($Finding.PSObject.Properties['DependencyType']) { [string]$Finding.DependencyType } else { 'unknown' }
            return "Dependency failures: $name ($type) failed $count times"
        }
        'exceptions' {
            $problem = if ($Finding.PSObject.Properties['ProblemId']) { [string]$Finding.ProblemId } else { 'unknown-problem' }
            return "Exception cluster: $problem hit $count times"
        }
        default {
            if ($Finding.PSObject.Properties['Title'] -and $Finding.Title) { return [string]$Finding.Title }
            return 'Application Insights finding'
        }
    }
}

function Get-AppInsightsLearnMoreUrl {
    param([pscustomobject] $Finding)

    if ($Finding.PSObject.Properties['LearnMoreUrl'] -and ([string]$Finding.LearnMoreUrl).StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)) {
        return [string]$Finding.LearnMoreUrl
    }

    $resourceId = if ($Finding.PSObject.Properties['ResourceId']) { [string]$Finding.ResourceId } else { '' }
    if ([string]::IsNullOrWhiteSpace($resourceId)) { return 'https://portal.azure.com/' }

    return "https://portal.azure.com/#@/resource$resourceId/overview"
}

function Get-AppInsightsDeepLinkUrl {
    param([pscustomobject] $Finding)

    if ($Finding.PSObject.Properties['DeepLinkUrl'] -and ([string]$Finding.DeepLinkUrl).StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)) {
        return [string]$Finding.DeepLinkUrl
    }

    $resourceId = if ($Finding.PSObject.Properties['ResourceId']) { [string]$Finding.ResourceId } else { '' }
    if ([string]::IsNullOrWhiteSpace($resourceId)) { return '' }

    $timeRangeHours = if ($Finding.PSObject.Properties['TimeRangeHours']) { [int]$Finding.TimeRangeHours } else { 24 }
    $query = switch ([string]$Finding.QueryType) {
        'requests'     { "requests | where timestamp > ago($($timeRangeHours)h) | where duration > 5s" }
        'dependencies' { "dependencies | where timestamp > ago($($timeRangeHours)h) | where success == false" }
        'exceptions'   { "exceptions | where timestamp > ago($($timeRangeHours)h)" }
        default        { "traces | where timestamp > ago($($timeRangeHours)h)" }
    }
    $resourceIdEncoded = [System.Uri]::EscapeDataString($resourceId)
    $queryEncoded = [System.Uri]::EscapeDataString($query)
    return "https://portal.azure.com/#blade/Microsoft_OperationsManagementSuite_Workspace/AnalyticsBlade/resourceId/$resourceIdEncoded/query/$queryEncoded/timespan/PT$($timeRangeHours)H"
}

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 Resolve-AppInsightsPillar {
    param([pscustomobject] $Finding)

    if ($Finding.PSObject.Properties['Pillar'] -and -not [string]::IsNullOrWhiteSpace([string]$Finding.Pillar)) {
        return [string]$Finding.Pillar
    }

    if ([string]$Finding.QueryType -eq 'exceptions') { return 'Reliability' }
    return 'PerformanceEfficiency'
}

function Resolve-AppInsightsImpact {
    param([pscustomobject] $Finding)

    if ($Finding.PSObject.Properties['Impact'] -and -not [string]::IsNullOrWhiteSpace([string]$Finding.Impact)) {
        return [string]$Finding.Impact
    }

    $count = if ($Finding.PSObject.Properties['Count']) { [int]$Finding.Count } else { 0 }
    $avg = if ($Finding.PSObject.Properties['AvgDurationSeconds']) { [double]$Finding.AvgDurationSeconds } else { 0.0 }
    if ([string]$Finding.QueryType -eq 'exceptions') {
        if ($count -ge 150) { return 'High' }
        if ($count -ge 75) { return 'Medium' }
        return 'Low'
    }

    if ($count -ge 100 -and $avg -ge 10) { return 'High' }
    if ($count -ge 20 -or $avg -ge 5) { return 'Medium' }
    return 'Low'
}

function Resolve-AppInsightsEffort {
    param([pscustomobject] $Finding)

    if ($Finding.PSObject.Properties['Effort'] -and -not [string]::IsNullOrWhiteSpace([string]$Finding.Effort)) {
        return [string]$Finding.Effort
    }

    $avg = if ($Finding.PSObject.Properties['AvgDurationSeconds']) { [double]$Finding.AvgDurationSeconds } else { 0.0 }
    switch ([string]$Finding.QueryType) {
        'exceptions' { return 'Low' }
        'dependencies' {
            if ($avg -ge 10) { return 'High' }
            return 'Medium'
        }
        'requests' {
            if ($avg -ge 15) { return 'High' }
            return 'Medium'
        }
        default { return 'Medium' }
    }
}

function Normalize-AppInsights {
    [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 '/subscriptions/([^/]+)') { $subId = $Matches[1] }
        if ($rawId -match '/resourceGroups/([^/]+)') { $rg = $Matches[1] }

        try {
            $canonicalId = (ConvertTo-CanonicalEntityId -RawId $rawId -EntityType 'AzureResource').CanonicalId
        } catch {
            $canonicalId = $rawId.ToLowerInvariant()
        }

        $pillar = Resolve-AppInsightsPillar -Finding $f
        $impact = Resolve-AppInsightsImpact -Finding $f
        $effort = Resolve-AppInsightsEffort -Finding $f
        $deepLinkUrl = Get-AppInsightsDeepLinkUrl -Finding $f
        $evidenceUris = @(Convert-ToStringArray -Value $(if ($f.PSObject.Properties['EvidenceUris']) { $f.EvidenceUris } else { @() }))
        if ($deepLinkUrl) { $evidenceUris = @($evidenceUris + @($deepLinkUrl) | Select-Object -Unique) }
        $learnMoreUrl = Get-AppInsightsLearnMoreUrl -Finding $f
        if ($learnMoreUrl) { $evidenceUris = @($evidenceUris + @($learnMoreUrl) | Select-Object -Unique) }
        $baselineTags = @(Convert-ToStringArray -Value $(if ($f.PSObject.Properties['BaselineTags']) { $f.BaselineTags } else { @() }))
        $entityRefs = @(Convert-ToStringArray -Value $(if ($f.PSObject.Properties['EntityRefs']) { $f.EntityRefs } else { @() }))
        $toolVersion = if ($f.PSObject.Properties['ToolVersion']) { [string]$f.ToolVersion } elseif ($ToolResult.PSObject.Properties['ToolVersion']) { [string]$ToolResult.ToolVersion } else { '' }
        $scoreDelta = $null
        if ($f.PSObject.Properties['ScoreDelta'] -and $null -ne $f.ScoreDelta) {
            $scoreDelta = [Nullable[double]]([double]$f.ScoreDelta)
        } elseif ($f.PSObject.Properties['AvgDurationSeconds'] -and $null -ne $f.AvgDurationSeconds) {
            $scoreDelta = [Nullable[double]]([double]$f.AvgDurationSeconds)
        }

        $row = New-FindingRow -Id $(if ($f.PSObject.Properties['Id']) { [string]$f.Id } else { [guid]::NewGuid().ToString() }) `
            -Source $(if ($f.PSObject.Properties['Source']) { [string]$f.Source } else { 'appinsights' }) `
            -EntityId $canonicalId -EntityType 'AzureResource' `
            -Title (Get-AppInsightsTitle -Finding $f) `
            -Compliant $(if ($f.PSObject.Properties['Compliant']) { [bool]$f.Compliant } else { $false }) `
            -ProvenanceRunId $runId -Platform 'Azure' `
            -Category $(if ($f.PSObject.Properties['Category']) { [string]$f.Category } else { 'Performance' }) `
            -Severity (Get-AppInsightsSeverity -Finding $f) `
            -Detail $(if ($f.PSObject.Properties['Detail']) { [string]$f.Detail } else { '' }) `
            -Remediation $(if ($f.PSObject.Properties['Remediation']) { [string]$f.Remediation } else { '' }) `
            -LearnMoreUrl $learnMoreUrl `
            -ResourceId $rawId -SubscriptionId $subId -ResourceGroup $rg `
            -Pillar $pillar -Impact $impact -Effort $effort `
            -DeepLinkUrl $deepLinkUrl -EvidenceUris $evidenceUris `
            -BaselineTags $baselineTags -ScoreDelta $scoreDelta `
            -EntityRefs $entityRefs -ToolVersion $toolVersion

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

        foreach ($extra in @('QueryType', 'RequestName', 'DependencyName', 'DependencyType', 'ProblemId', 'Count', 'AvgDurationSeconds', 'TimeRangeHours', 'ScoreDelta')) {
            if ($f.PSObject.Properties[$extra] -and $null -ne $f.$extra) {
                $row | Add-Member -NotePropertyName $extra -NotePropertyValue $f.$extra -Force
            }
        }

        $normalized.Add($row)
    }

    return @($normalized)
}