Private/radar-functions.ps1

function Get-KubeBuddyRadarConfig {
    $defaults = @{
        enabled                = $false
        api_base_url           = "https://radar.kubebuddy.io/api/kb-radar/v1"
        environment            = "prod"
        api_user               = ""
        api_password           = ""
        api_user_env           = "KUBEBUDDY_RADAR_API_USER"
        api_password_env       = "KUBEBUDDY_RADAR_API_PASSWORD"
        upload_timeout_seconds = 30
        upload_retries         = 2
    }

    $configPath = Get-KubeBuddyConfigPath
    if (-not (Test-Path $configPath)) {
        return $defaults
    }

    try {
        $config = Get-Content -Raw $configPath | ConvertFrom-Yaml
        $radar = $config.radar
        if (-not $radar) {
            return $defaults
        }

        return @{
            enabled                = [bool]($radar.enabled ?? $defaults.enabled)
            api_base_url           = [string]($radar.api_base_url ?? $defaults.api_base_url)
            environment            = [string]($radar.environment ?? $defaults.environment)
            api_user               = [string]($radar.api_user ?? $defaults.api_user)
            api_password           = [string]($radar.api_password ?? $defaults.api_password)
            api_user_env           = [string]($radar.api_user_env ?? $defaults.api_user_env)
            api_password_env       = [string]($radar.api_password_env ?? $defaults.api_password_env)
            upload_timeout_seconds = [int]($radar.upload_timeout_seconds ?? $defaults.upload_timeout_seconds)
            upload_retries         = [int]($radar.upload_retries ?? $defaults.upload_retries)
        }
    }
    catch {
        return $defaults
    }
}

function Resolve-KubeBuddyRadarSettings {
    param(
        [switch]$RadarUpload,
        [switch]$RadarCompare,
        [string]$RadarApiBaseUrl,
        [string]$RadarEnvironment,
        [string]$RadarApiUserEnv,
        [string]$RadarApiSecretEnv
    )

    $config = Get-KubeBuddyRadarConfig

    $enabled = [bool]$config.enabled
    if ($RadarUpload -or $RadarCompare) {
        $enabled = $true
    }

    return @{
        enabled          = $enabled
        compare_enabled  = [bool]$RadarCompare
        upload_enabled   = [bool]$RadarUpload
        api_base_url     = if ($RadarApiBaseUrl) { $RadarApiBaseUrl } else { $config.api_base_url }
        environment      = if ($RadarEnvironment) { $RadarEnvironment } else { $config.environment }
        api_user         = [string]$config.api_user
        api_password     = [string]$config.api_password
        api_user_env     = if ($RadarApiUserEnv) { $RadarApiUserEnv } else { $config.api_user_env }
        api_password_env = if ($RadarApiSecretEnv) { $RadarApiSecretEnv } else { $config.api_password_env }
        upload_timeout_seconds = [int]$config.upload_timeout_seconds
        upload_retries   = [int]$config.upload_retries
    }
}

function Invoke-KubeBuddyRadarGetConfig {
    param(
        [hashtable]$RadarSettings,
        [string]$ConfigId
    )

    if (-not $RadarSettings.enabled) {
        throw "Radar config fetch requires Radar to be enabled."
    }

    if ([string]::IsNullOrWhiteSpace($ConfigId)) {
        throw "Radar config fetch requires -RadarConfigId."
    }

    $headers = Get-KubeBuddyRadarAuthHeaders -RadarSettings $RadarSettings
    $baseUrl = [string]$RadarSettings.api_base_url
    if ([string]::IsNullOrWhiteSpace($baseUrl)) {
        throw "Radar API base URL is empty. Set radar.api_base_url or pass -RadarApiBaseUrl."
    }

    $endpoint = "{0}/cluster-configs/{1}" -f $baseUrl.Trim().TrimEnd('/'), [Uri]::EscapeDataString($ConfigId)
    $configUri = $null
    if (-not [Uri]::TryCreate($endpoint, [UriKind]::Absolute, [ref]$configUri)) {
        throw "Invalid Radar config URI: $endpoint"
    }

    return Invoke-RestMethod -Uri $configUri -Method Get -Headers $headers -TimeoutSec ([Math]::Max([int]$RadarSettings.upload_timeout_seconds, 5))
}

function Invoke-KubeBuddyRadarGetConfigFile {
    param(
        [hashtable]$RadarSettings,
        [string]$ConfigId
    )

    if (-not $RadarSettings.enabled) {
        throw "Radar config fetch requires Radar to be enabled."
    }

    if ([string]::IsNullOrWhiteSpace($ConfigId)) {
        throw "Radar config fetch requires -RadarConfigId."
    }

    $headers = Get-KubeBuddyRadarAuthHeaders -RadarSettings $RadarSettings
    $baseUrl = [string]$RadarSettings.api_base_url
    if ([string]::IsNullOrWhiteSpace($baseUrl)) {
        throw "Radar API base URL is empty. Set radar.api_base_url or pass -RadarApiBaseUrl."
    }

    $endpoint = "{0}/cluster-configs/{1}/config-file" -f $baseUrl.Trim().TrimEnd('/'), [Uri]::EscapeDataString($ConfigId)
    $configUri = $null
    if (-not [Uri]::TryCreate($endpoint, [UriKind]::Absolute, [ref]$configUri)) {
        throw "Invalid Radar config-file URI: $endpoint"
    }

    return Invoke-RestMethod -Uri $configUri -Method Get -Headers $headers -TimeoutSec ([Math]::Max([int]$RadarSettings.upload_timeout_seconds, 5))
}

function Get-KubeBuddyRadarAuthHeaders {
    param([hashtable]$RadarSettings)

    $user = [string]$RadarSettings.api_user
    $password = [string]$RadarSettings.api_password

    if (-not $user -or -not $password) {
        $userEnv = [string]$RadarSettings.api_user_env
        $passwordEnv = [string]$RadarSettings.api_password_env
        $user = [Environment]::GetEnvironmentVariable($userEnv)
        $password = [Environment]::GetEnvironmentVariable($passwordEnv)
    }

    if (-not $user -or -not $password) {
        $userEnv = [string]$RadarSettings.api_user_env
        $passwordEnv = [string]$RadarSettings.api_password_env
        throw "Radar credentials missing. Set radar.api_user/api_password in config or env vars '$userEnv' and '$passwordEnv'."
    }

    $raw = "${user}:${password}"
    $encoded = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($raw))

    return @{
        Authorization = "Basic $encoded"
        "Content-Type" = "application/json"
    }
}

function New-KubeBuddyRadarUploadPayload {
    param(
        [string]$ReportPath,
        [string]$ModuleVersion,
        [hashtable]$RadarSettings
    )

    if (-not (Test-Path $ReportPath)) {
        throw "JSON report path not found: $ReportPath"
    }

    $report = Get-Content -Raw $ReportPath | ConvertFrom-Json -Depth 30
    $metadata = $report.metadata
    $startedAt = [DateTime]::UtcNow
    $clusterName = [string]($metadata.clusterName ?? "")
    if ([string]::IsNullOrWhiteSpace($clusterName)) {
        $clusterName = [string]($metadata.aks.clusterName ?? "")
    }
    if ([string]::IsNullOrWhiteSpace($clusterName)) {
        $clusterName = "unknown"
    }
    return @{
        source = "kubebuddy-cli"
        source_version = $ModuleVersion
        environment = $RadarSettings.environment
        cluster = @{
            name = $clusterName
            provider = if ($metadata.aks) { "aks" } else { "kubernetes" }
            region = [string]($metadata.aks.location ?? "")
        }
        run = @{
            started_at = $startedAt.ToString("yyyy-MM-ddTHH:mm:ssZ")
            finished_at = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
            duration_seconds = 0
        }
        report = $report
    }
}

function Invoke-KubeBuddyRadarUpload {
    param(
        [string]$ReportPath,
        [string]$ModuleVersion,
        [hashtable]$RadarSettings
    )

    if (-not $RadarSettings.enabled -or -not $RadarSettings.upload_enabled) {
        return $null
    }

    $headers = Get-KubeBuddyRadarAuthHeaders -RadarSettings $RadarSettings
    $payload = New-KubeBuddyRadarUploadPayload -ReportPath $ReportPath -ModuleVersion $ModuleVersion -RadarSettings $RadarSettings
    $body = $payload | ConvertTo-Json -Depth 40

    $baseUrl = [string]$RadarSettings.api_base_url
    if ([string]::IsNullOrWhiteSpace($baseUrl)) {
        throw "Radar API base URL is empty. Set radar.api_base_url or pass -RadarApiBaseUrl."
    }
    $baseUrl = $baseUrl.Trim()
    $endpoint = "{0}/cluster-reports" -f $baseUrl.TrimEnd('/')
    $uploadUri = $null
    if (-not [Uri]::TryCreate($endpoint, [UriKind]::Absolute, [ref]$uploadUri)) {
        throw "Invalid Radar upload URI: $endpoint"
    }
    $retries = [Math]::Max([int]$RadarSettings.upload_retries, 0)
    $timeout = [Math]::Max([int]$RadarSettings.upload_timeout_seconds, 5)

    for ($attempt = 0; $attempt -le $retries; $attempt++) {
        try {
            return Invoke-RestMethod -Uri $uploadUri -Method Post -Headers $headers -Body $body -TimeoutSec $timeout
        }
        catch {
            if ($attempt -ge $retries) {
                throw
            }
            Start-Sleep -Seconds 1
        }
    }
}

function Invoke-KubeBuddyRadarCompare {
    param(
        [hashtable]$RadarSettings,
        [string]$ToRunId
    )

    if (-not $RadarSettings.enabled -or -not $RadarSettings.compare_enabled) {
        return $null
    }

    $headers = Get-KubeBuddyRadarAuthHeaders -RadarSettings $RadarSettings
    $baseUrl = [string]$RadarSettings.api_base_url
    if ([string]::IsNullOrWhiteSpace($baseUrl)) {
        throw "Radar API base URL is empty. Set radar.api_base_url or pass -RadarApiBaseUrl."
    }
    $baseUrl = $baseUrl.Trim()
    $base = "{0}/cluster-reports/compare" -f $baseUrl.TrimEnd('/')

    $params = @()
    if ($ToRunId) {
        $params += "to_run_id=$([Uri]::EscapeDataString($ToRunId))"
    }

    $uriString = "${base}?" + ($params -join '&')
    $compareUri = $null
    if (-not [Uri]::TryCreate($uriString, [UriKind]::Absolute, [ref]$compareUri)) {
        throw "Invalid Radar compare URI: $uriString"
    }

    return Invoke-RestMethod -Uri $compareUri -Method Get -Headers $headers -TimeoutSec ([Math]::Max([int]$RadarSettings.upload_timeout_seconds, 5))
}

function Invoke-KubeBuddyRadarFreshness {
    param(
        [hashtable]$RadarSettings,
        [string]$RunId
    )

    if (-not $RadarSettings.enabled -or -not $RadarSettings.upload_enabled) {
        return $null
    }

    if ([string]::IsNullOrWhiteSpace($RunId)) {
        return $null
    }

    $headers = Get-KubeBuddyRadarAuthHeaders -RadarSettings $RadarSettings
    $baseUrl = [string]$RadarSettings.api_base_url
    if ([string]::IsNullOrWhiteSpace($baseUrl)) {
        throw "Radar API base URL is empty. Set radar.api_base_url or pass -RadarApiBaseUrl."
    }
    $baseUrl = $baseUrl.Trim()
    $endpoint = "{0}/cluster-reports/{1}/freshness" -f $baseUrl.TrimEnd('/'), [Uri]::EscapeDataString($RunId)

    $freshnessUri = $null
    if (-not [Uri]::TryCreate($endpoint, [UriKind]::Absolute, [ref]$freshnessUri)) {
        throw "Invalid Radar freshness URI: $endpoint"
    }

    return Invoke-RestMethod -Uri $freshnessUri -Method Get -Headers $headers -TimeoutSec ([Math]::Max([int]$RadarSettings.upload_timeout_seconds, 5))
}

function Get-KubeBuddyRadarSemverCompare {
    param(
        [string]$Current,
        [string]$Latest
    )

    $a = [string]$Current
    $b = [string]$Latest
    if ([string]::IsNullOrWhiteSpace($a) -or [string]::IsNullOrWhiteSpace($b)) {
        return $null
    }

    $a = $a.Trim().TrimStart('v', 'V')
    $b = $b.Trim().TrimStart('v', 'V')
    if ($a -notmatch '^\d+(\.\d+){0,2}$' -or $b -notmatch '^\d+(\.\d+){0,2}$') {
        return $null
    }

    $aParts = $a -split '\.'
    $bParts = $b -split '\.'
    while ($aParts.Count -lt 3) { $aParts += '0' }
    while ($bParts.Count -lt 3) { $bParts += '0' }

    $aNorm = "{0}.{1}.{2}" -f $aParts[0], $aParts[1], $aParts[2]
    $bNorm = "{0}.{1}.{2}" -f $bParts[0], $bParts[1], $bParts[2]

    try {
        $aVer = [System.Version]::Parse($aNorm)
        $bVer = [System.Version]::Parse($bNorm)
        return $aVer.CompareTo($bVer)
    }
    catch {
        return $null
    }
}

function Convert-KubeBuddyRadarNormalizedText {
    param([string]$Value)
    if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
    return ([regex]::Replace($Value.ToLowerInvariant(), '[^a-z0-9]+', ' ')).Trim()
}

function Get-KubeBuddyRadarTokens {
    param([string]$Value)
    $norm = Convert-KubeBuddyRadarNormalizedText -Value $Value
    if (-not $norm) { return @() }
    return @($norm.Split(' ') | Where-Object { $_ -and $_.Length -ge 3 } | Sort-Object -Unique)
}

function Get-KubeBuddyRadarArtifactSearchTerms {
    param([pscustomobject]$Artifact)

    $terms = @()
    $key = [string]$Artifact.artifact_key
    $display = [string]$Artifact.display_name
    $workloadName = [string]$Artifact.workload_name
    $namespace = [string]$Artifact.namespace
    $helmChartName = [string]$Artifact.helm_chart_name
    $managedBy = [string]$Artifact.managed_by
    $controllerOwnerName = [string]$Artifact.controller_owner_name
    $partOf = [string]$Artifact.part_of

    if ($Artifact.artifact_type -eq 'image' -and $key) {
        $imageName = $key
        if ($imageName.Contains('@')) { $imageName = $imageName.Split('@', 2)[0] }
        if ($imageName.Contains(':')) { $imageName = $imageName.Substring(0, $imageName.LastIndexOf(':')) }
        $parts = $imageName.Split('/')
        $terms += $parts[$parts.Count - 1]
        if ($parts.Count -gt 1) { $terms += $parts[$parts.Count - 2] }
    }

    $terms += @($key, $display, $workloadName, $namespace, $helmChartName, $managedBy, $controllerOwnerName, $partOf)
    $expanded = @()
    foreach ($t in $terms) {
        if ([string]::IsNullOrWhiteSpace($t)) { continue }
        $expanded += $t
        $expanded += ($t -replace '[-_]+helm$', '')
        $expanded += ($t -replace '[-_]+chart$', '')
        $expanded += ($t -replace '[-_]+operator$', '')
    }

    return @($expanded | Where-Object { $_ -and $_.Trim() -ne '' } | Sort-Object -Unique)
}

function Get-KubeBuddyRadarProjectMatchScore {
    param(
        [object]$Project,
        [string[]]$Terms
    )

    if (-not $Project) { return -1 }
    $name = Convert-KubeBuddyRadarNormalizedText -Value ([string]($Project.name ?? ''))
    $repo = Convert-KubeBuddyRadarNormalizedText -Value ([string]($Project.repo_url ?? ''))
    $nameTokens = @(Get-KubeBuddyRadarTokens -Value $name)
    $repoTokens = @(Get-KubeBuddyRadarTokens -Value $repo)
    $projectTokens = @($nameTokens + $repoTokens | Sort-Object -Unique)

    $score = 0
    foreach ($term in @($Terms)) {
        $normTerm = Convert-KubeBuddyRadarNormalizedText -Value $term
        if (-not $normTerm) { continue }
        if ($name -eq $normTerm) { $score += 120 }
        elseif ($name.Contains($normTerm)) { $score += 70 }
        elseif ($repo.Contains($normTerm)) { $score += 50 }

        $termTokens = @(Get-KubeBuddyRadarTokens -Value $normTerm)
        $overlap = @($termTokens | Where-Object { $_ -in $projectTokens })
        $score += ($overlap.Count * 8)
    }

    return $score
}

function Convert-KubeBuddyRadarSemverInfo {
    param([string]$Version)

    $v = [string]$Version
    if ([string]::IsNullOrWhiteSpace($v)) { return $null }
    $v = $v.Trim()

    if ($v -notmatch '^[vV]?(?<maj>\d+)\.(?<min>\d+)\.(?<pat>\d+)(?<suffix>[-+].*)?$') {
        return $null
    }

    $core = "{0}.{1}.{2}" -f $matches.maj, $matches.min, $matches.pat
    try {
        $verObj = [System.Version]::Parse($core)
    }
    catch {
        return $null
    }

    return [PSCustomObject]@{
        raw = $v
        core = $core
        version_obj = $verObj
        major = [int]$matches.maj
        minor = [int]$matches.min
        patch = [int]$matches.pat
        is_stable = [string]::IsNullOrWhiteSpace([string]$matches.suffix)
    }
}

function Get-KubeBuddyRadarBestLatestVersion {
    param(
        [object[]]$ReleaseItems,
        [string]$CurrentVersion
    )

    $parsed = @()
    foreach ($r in @($ReleaseItems)) {
        if (-not $r -or -not $r.version) { continue }
        $sv = Convert-KubeBuddyRadarSemverInfo -Version ([string]$r.version)
        if ($sv) {
            $parsed += [PSCustomObject]@{
                raw = [string]$r.version
                semver = $sv
            }
        }
    }

    $stable = @($parsed | Where-Object { $_.semver.is_stable } | Sort-Object @{Expression = { $_.semver.version_obj }; Descending = $true})
    if ($stable.Count -eq 0) {
        return @{
            compare_version = ''
            global_latest_version = ''
            mode = 'none'
        }
    }

    $globalLatest = [string]$stable[0].raw
    $currentSemver = Convert-KubeBuddyRadarSemverInfo -Version $CurrentVersion
    if (-not $currentSemver) {
        return @{
            compare_version = $globalLatest
            global_latest_version = $globalLatest
            mode = 'global'
        }
    }

    $trackMatches = @($stable | Where-Object { $_.semver.major -eq $currentSemver.major -and $_.semver.minor -eq $currentSemver.minor })
    if ($trackMatches.Count -gt 0) {
        return @{
            compare_version = [string]$trackMatches[0].raw
            global_latest_version = $globalLatest
            mode = 'same_minor'
        }
    }

    return @{
        compare_version = $globalLatest
        global_latest_version = $globalLatest
        mode = 'global'
    }
}

function Invoke-KubeBuddyRadarDirectArtifactLookup {
    param(
        [string]$ReportPath,
        [hashtable]$RadarSettings
    )

    if (-not $RadarSettings.enabled) {
        return $null
    }
    if ([string]::IsNullOrWhiteSpace($ReportPath) -or -not (Test-Path $ReportPath)) {
        return $null
    }

    $headers = Get-KubeBuddyRadarAuthHeaders -RadarSettings $RadarSettings
    $baseUrl = [string]$RadarSettings.api_base_url
    if ([string]::IsNullOrWhiteSpace($baseUrl)) {
        return $null
    }
    $baseUrl = $baseUrl.Trim().TrimEnd('/')

    $report = Get-Content -Raw -Path $ReportPath | ConvertFrom-Json -Depth 60
    if (-not $report.artifacts) {
        return $null
    }

    $candidates = @()
    foreach ($img in @($report.artifacts.images)) {
        $candidates += [PSCustomObject]@{
            artifact_type   = 'image'
            artifact_key    = [string]$img.fullRef
            display_name    = [string]$img.fullRef
            current_version = [string]$img.currentVersion
            namespace = [string]$img.namespace
            workload_name = [string]$img.workloadName
            managed_by_helm = [bool]$img.managedByHelm
            helm_chart_name = [string]$img.helmChartName
            managed_by = [string]$img.managedBy
            managed_by_controller = [bool]$img.managedByController
            controller_owner_name = [string]$img.controllerOwnerName
            controller_owner_namespace = [string]$img.controllerOwnerNamespace
            part_of = [string]$img.partOf
        }
    }
    foreach ($chart in @($report.artifacts.helmCharts)) {
        $candidates += [PSCustomObject]@{
            artifact_type   = 'helm_chart'
            artifact_key    = [string]$chart.name
            display_name    = [string]$chart.name
            current_version = [string]$chart.version
            namespace = [string]$chart.namespace
            workload_name = [string]$chart.workloadName
            managed_by_helm = $false
            helm_chart_name = [string]$chart.name
            managed_by = ''
            managed_by_controller = $false
            controller_owner_name = ''
            controller_owner_namespace = ''
            part_of = ''
        }
    }
    # App label artifacts are intentionally excluded from direct version lookup to reduce noise.

    $unique = @{}
    foreach ($c in $candidates) {
        if ([string]::IsNullOrWhiteSpace($c.artifact_key)) { continue }
        $k = ("{0}|{1}|{2}" -f $c.artifact_type, $c.artifact_key.ToLowerInvariant(), ([string]$c.current_version).ToLowerInvariant())
        if (-not $unique.ContainsKey($k)) { $unique[$k] = $c }
    }

    $projectCache = @{}
    $releaseCache = @{}
    $helmStatusByNamespaceChart = @{}
    $helmStatusByNamespaceWorkload = @{}
    $items = @()

    $orderedArtifacts = @(
        @($unique.Values | Where-Object { $_.artifact_type -eq 'helm_chart' }) +
        @($unique.Values | Where-Object { $_.artifact_type -ne 'helm_chart' })
    )

    foreach ($artifact in $orderedArtifacts) {
        $terms = Get-KubeBuddyRadarArtifactSearchTerms -Artifact $artifact
        $project = $null

        $inheritedFromHelm = $false
        $inheritedHelm = $null
        if ($artifact.artifact_type -ne 'helm_chart' -and [bool]$artifact.managed_by_helm) {
            $nsKey = Convert-KubeBuddyRadarNormalizedText -Value ([string]$artifact.namespace)
            $chartKey = Convert-KubeBuddyRadarNormalizedText -Value ([string]$artifact.helm_chart_name)
            $workloadKey = Convert-KubeBuddyRadarNormalizedText -Value ([string]$artifact.workload_name)
            if ($chartKey) {
                $lookupKey = "$nsKey|$chartKey"
                if ($helmStatusByNamespaceChart.ContainsKey($lookupKey)) {
                    $inheritedFromHelm = $true
                    $inheritedHelm = $helmStatusByNamespaceChart[$lookupKey]
                }
            }
            if (-not $inheritedFromHelm -and $workloadKey) {
                $lookupKey = "$nsKey|$workloadKey"
                if ($helmStatusByNamespaceWorkload.ContainsKey($lookupKey)) {
                    $inheritedFromHelm = $true
                    $inheritedHelm = $helmStatusByNamespaceWorkload[$lookupKey]
                }
            }
        }

        if (-not $inheritedFromHelm) {
            $best = $null
            $bestScore = -1
            foreach ($searchTerm in @($terms)) {
                if ([string]::IsNullOrWhiteSpace($searchTerm)) { continue }
                $cacheKey = $searchTerm.ToLowerInvariant()
                $projectList = @()
                if ($projectCache.ContainsKey($cacheKey)) {
                    $projectList = @($projectCache[$cacheKey])
                }
                else {
                    $searchUri = "{0}/projects?search={1}&per_page=20" -f $baseUrl, [Uri]::EscapeDataString($searchTerm)
                    $resp = Invoke-RestMethod -Uri $searchUri -Method Get -Headers $headers -TimeoutSec ([Math]::Max([int]$RadarSettings.upload_timeout_seconds, 5))
                    $projectList = @($resp.items)
                    $projectCache[$cacheKey] = $projectList
                }

                foreach ($p in $projectList) {
                    $score = Get-KubeBuddyRadarProjectMatchScore -Project $p -Terms $terms
                    if ($score -gt $bestScore) {
                        $bestScore = $score
                        $best = $p
                    }
                }
            }
            if ($bestScore -ge 20) {
                $project = $best
            }
        } else {
            $project = $null
        }

        $latest = ''
        $globalLatest = ''
        $source = ''
        $productId = 0
        $compareMode = ''
        if ($inheritedFromHelm -and $inheritedHelm) {
            $latest = [string]($inheritedHelm.latest_version ?? '')
            $globalLatest = [string]($inheritedHelm.global_latest_version ?? '')
            $source = [string]($inheritedHelm.source ?? '')
            $productId = [int]($inheritedHelm.source_product_id ?? 0)
            $compareMode = 'inherited_helm'
        }
        elseif ($project -and $project.id) {
            $productId = [int]$project.id
            $source = [string]$project.name
            $releaseType = if ($artifact.artifact_type -eq 'helm_chart') { 'helm' } else { 'app' }
            $releaseCacheKey = ("{0}|{1}" -f $productId, $releaseType)
            if ($releaseCache.ContainsKey($releaseCacheKey)) {
                $cached = $releaseCache[$releaseCacheKey]
                $latest = [string]($cached.compare_version ?? '')
                $globalLatest = [string]($cached.global_latest_version ?? '')
                $compareMode = [string]($cached.mode ?? '')
            }
            else {
                $relUri = "{0}/projects/{1}/releases?type={2}&per_page=30" -f $baseUrl, $productId, $releaseType
                $relResp = Invoke-RestMethod -Uri $relUri -Method Get -Headers $headers -TimeoutSec ([Math]::Max([int]$RadarSettings.upload_timeout_seconds, 5))
                $latestChoice = Get-KubeBuddyRadarBestLatestVersion -ReleaseItems @($relResp.items) -CurrentVersion ([string]$artifact.current_version)
                $latest = [string]($latestChoice.compare_version ?? '')
                $globalLatest = [string]($latestChoice.global_latest_version ?? '')
                $compareMode = [string]($latestChoice.mode ?? '')
                if (-not $latest -and $project.latest_version) {
                    $latest = [string]$project.latest_version
                    $globalLatest = $latest
                    $compareMode = 'global'
                }
                $releaseCache[$releaseCacheKey] = $latestChoice
            }
        }

        $current = [string]$artifact.current_version
        $status = 'unknown'
        $confidence = 0.40
        $reason = 'No matching monitored project found in Radar catalog.'

        if (-not [string]::IsNullOrWhiteSpace($latest) -and -not [string]::IsNullOrWhiteSpace($current)) {
            $cmp = Get-KubeBuddyRadarSemverCompare -Current $current -Latest $latest
            if ($null -eq $cmp) {
                $status = 'unknown'
                $confidence = 0.45
                $reason = 'Unable to semver-compare current and latest versions.'
            }
            elseif ($cmp -ge 0) {
                $status = 'up_to_date'
                $confidence = 0.90
                if ($compareMode -eq 'same_minor' -and $globalLatest -and $globalLatest -ne $latest) {
                    $reason = "Up to date in current minor track ($latest). Newer release available: $globalLatest."
                }
                elseif ($compareMode -eq 'inherited_helm') {
                    $reason = "Inherited from Helm chart status."
                }
                else {
                    $reason = 'Current version is equal to or newer than latest catalog entry.'
                }
            }
            else {
                $currParts = ($current.TrimStart('v', 'V') -split '\.') | ForEach-Object { [int]$_ }
                $latestParts = ($latest.TrimStart('v', 'V') -split '\.') | ForEach-Object { [int]$_ }
                $majorGap = ($latestParts[0] -as [int]) - ($currParts[0] -as [int])
                if ($majorGap -gt 0) {
                    $status = 'major_behind'
                    $reason = 'Latest major version is newer than current.'
                }
                else {
                    $status = 'minor_behind'
                    $reason = 'Latest minor/patch version is newer than current.'
                }
                $confidence = 0.85
            }
        }
        elseif (-not [string]::IsNullOrWhiteSpace($latest)) {
            $reason = 'Current version missing for comparison.'
        }
        elseif ([bool]$artifact.managed_by_controller) {
            $status = 'covered_by_controller'
            $confidence = 0.70
            if ([string]::IsNullOrWhiteSpace([string]$artifact.managed_by)) {
                $reason = 'Workload is controller-managed and tracked at platform/controller level.'
            }
            else {
                $reason = "Workload is controller-managed by '$($artifact.managed_by)' and tracked at platform/controller level."
            }
        }

        $items += [PSCustomObject]@{
            artifact_key = [string]$artifact.artifact_key
            artifact_type = [string]$artifact.artifact_type
            display_name = [string]$artifact.display_name
            current_version = $current
            latest_version = [string]$latest
            status = [string]$status
            confidence = [double]$confidence
            reason = [string]$reason
            source = [string]$source
            source_product_id = [int]$productId
            is_monitored = -not [string]::IsNullOrWhiteSpace($latest)
            global_latest_version = [string]$globalLatest
            compare_mode = [string]$compareMode
            inherited_from_helm = [bool]$inheritedFromHelm
        }

        if ($artifact.artifact_type -eq 'helm_chart') {
            $nsKey = Convert-KubeBuddyRadarNormalizedText -Value ([string]$artifact.namespace)
            $chartKey = Convert-KubeBuddyRadarNormalizedText -Value ([string]$artifact.artifact_key)
            $workloadKey = Convert-KubeBuddyRadarNormalizedText -Value ([string]$artifact.workload_name)
            if ($nsKey -and $chartKey) {
                $helmStatusByNamespaceChart["$nsKey|$chartKey"] = $items[-1]
            }
            if ($nsKey -and $workloadKey) {
                $helmStatusByNamespaceWorkload["$nsKey|$workloadKey"] = $items[-1]
            }
        }
    }

    $summary = @{
        up_to_date = 0
        minor_behind = 0
        major_behind = 0
        unknown = 0
    }
    foreach ($i in $items) {
        $s = [string]$i.status
        if ($s -eq 'covered_by_helm' -or $s -eq 'covered_by_controller') { continue }
        if ($summary.ContainsKey($s)) { $summary[$s]++ } else { $summary.unknown++ }
    }

    return @{
        processing_status = 'ready'
        summary = $summary
        items = $items
        source = 'direct_lookup'
    }
}

function Write-KubeBuddyRadarCompareSummary {
    param([object]$Compare)

    if (-not $Compare) {
        return
    }

    Write-Host "`n📈 Radar Compare" -ForegroundColor Cyan
    if ($null -ne $Compare.score_delta) {
        Write-Host (" Score Delta: {0}" -f $Compare.score_delta) -ForegroundColor Cyan
    }
    if ($null -ne $Compare.new_findings_count) {
        Write-Host (" New Findings: {0}" -f $Compare.new_findings_count) -ForegroundColor Yellow
    }
    if ($null -ne $Compare.resolved_findings_count) {
        Write-Host (" Resolved Findings: {0}" -f $Compare.resolved_findings_count) -ForegroundColor Green
    }
    if ($null -ne $Compare.regressed_findings_count) {
        Write-Host (" Regressed Findings: {0}" -f $Compare.regressed_findings_count) -ForegroundColor Red
    }
}