Modules/businessdev.ALbuild.Apps/Private/ConvertTo-BcAppDiagnosticBlocker.ps1

function ConvertTo-BcAppDiagnosticBlocker {
    <#
    .SYNOPSIS
        Turns raw container app state into named blockers with runnable remedies.
 
    .DESCRIPTION
        Separated from Get-BcContainerAppDiagnostic so the judgement calls below can be tested without a
        Business Central container. They are worth testing: both of the defects found when this was first
        run against a live container were here, not in the data collection.
 
          * "Published but not installed" and "not synced" are NORMAL for most of a container's inventory
            - a BC image ships dozens of optional apps (language packs, e-document connectors) published
            and deliberately never installed. They are blockers only relative to an app you are about to
            publish, so they are raised only when the caller named one. Unscoped, a healthy container
            produced 82 blockers: a census, not a diagnosis.
 
          * Only Error/Critical events count as sync failures. A healthy container logs routine
            Warning-level chatter that a broad keyword match swallows ('CacheSynchronization' cache
            clears, and the benign 'AppAlreadyChangedWarning' that re-publishing an already-synced
            version always produces). A false blocker is worse than no blocker: it invents work and
            devalues the real ones.
 
        DATA_UPGRADE_REQUIRED is reported either way - an installed app whose tenant data lags a newer
        published version is a genuinely pending state regardless of what the caller asked about.
 
    .PARAMETER Raw
        The parsed in-container payload: an object with 'apps' and 'eventLog'.
 
    .PARAMETER ContainerName
        Container name, so each remedy is a command that can be run verbatim.
 
    .PARAMETER AppName
        The app the caller asked about; empty means "the whole container" (unscoped).
 
    .OUTPUTS
        PSCustomObject with Blockers and SyncErrors.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [AllowNull()] $Raw,
        [Parameter(Mandatory)] [string] $ContainerName,
        [string] $AppName
    )

    $apps = @($Raw.apps)
    $blockers = @()
    $syncErrors = @()
    $scoped = -not [string]::IsNullOrWhiteSpace($AppName)

    foreach ($app in $apps) {
        $versions = @($app.published)
        if ($versions.Count -eq 0) { continue }

        $appArg = '"' + $app.name + '"'
        # Sort defensively: a version string that will not parse must not take the whole diagnosis down.
        # This runs precisely when something is already wrong, so it has to survive malformed input and
        # still report what it can - an unparseable version sorts last rather than throwing.
        $newest = @($versions | Sort-Object {
                $parsed = $null
                if ([version]::TryParse("$($_.version)", [ref]$parsed)) { $parsed } else { [version]'0.0.0.0' }
            } -Descending)[0]

        # A published version the tenant has NOT installed is exactly the leftover that makes the next
        # publish of the same version fail with "same App ID and Version as a previously published
        # Extension" - the failure a re-run inherits from an earlier aborted run.
        if ($scoped) {
            foreach ($v in $versions) {
                if (-not $v.installed) {
                    $blockers += [PSCustomObject]@{
                        code   = 'VERSION_ALREADY_PUBLISHED'
                        app    = $app.name
                        detail = "Version $($v.version) of '$($app.name)' is published but not installed; publishing that version again will be refused."
                        remedy = "albuild app unpublish --container $ContainerName --name $appArg --purge --yes"
                    }
                }
            }
        }

        # The tenant holds data for an older version: an INSTALL cannot proceed, a data UPGRADE must.
        if ($app.tenantDataVersion) {
            $dataBehind = $false
            try { $dataBehind = ([version]$app.tenantDataVersion) -lt ([version]$newest.version) } catch { $dataBehind = $false }
            if ($dataBehind) {
                $blockers += [PSCustomObject]@{
                    code   = 'DATA_UPGRADE_REQUIRED'
                    app    = $app.name
                    detail = "Tenant holds data for $($app.tenantDataVersion); $($newest.version) cannot be installed directly."
                    remedy = "albuild app publish --container $ContainerName --file <app.app> --if-exists upgrade --mode upgrade"
                }
            }
        }

        foreach ($v in $versions) {
            if ($v.needsUpgrade) {
                $blockers += [PSCustomObject]@{
                    code   = 'DATA_UPGRADE_REQUIRED'
                    app    = $app.name
                    detail = "Version $($v.version) of '$($app.name)' is flagged NeedsUpgrade by the tenant."
                    remedy = "albuild app publish --container $ContainerName --file <app.app> --if-exists upgrade --mode upgrade"
                }
            }
        }

        # 'Synced' is the only state an install can proceed from. This is the case where sync reported OK
        # and install then said the extension was not synchronized: the state, not the message. Only the
        # NEWEST published version matters - that is the one being installed; an old NotSynced version is
        # history, not an obstacle.
        if ($scoped -and $newest.syncState -and $newest.syncState -notin @('Synced', 'Unknown')) {
            $blockers += [PSCustomObject]@{
                code   = 'NOT_SYNCHRONIZED'
                app    = $app.name
                detail = "Version $($newest.version) of '$($app.name)' is in sync state '$($newest.syncState)'; it cannot be installed until it is Synced."
                remedy = "albuild app publish --container $ContainerName --file <app.app> --if-exists force-sync"
            }
        }
    }

    # Schema-sync FAILURES recorded in the server log - the per-object detail no cmdlet exposes.
    foreach ($e in @($Raw.eventLog)) {
        if (-not $e.message) { continue }
        if ($e.level -notin @('Error', 'Critical')) { continue }
        if ($e.message -match 'AppAlreadyChangedWarning|CacheSynchronization') { continue }
        if ($e.message -notmatch 'DatabaseSync|NavApp|schema|synchroniz') { continue }
        $syncErrors += [PSCustomObject]@{
            timeAtUtc = $e.timeAtUtc
            level     = $e.level
            message   = $e.message
        }
    }
    if (@($syncErrors).Count -gt 0) {
        $blockers += [PSCustomObject]@{
            code   = 'SCHEMA_CONFLICT'
            app    = $AppName
            detail = "The server log records $(@($syncErrors).Count) sync/schema error(s); see syncErrors for the offending object."
            remedy = "albuild app publish --container $ContainerName --file <app.app> --if-exists force-sync"
        }
    }

    return [PSCustomObject]@{
        Blockers   = @($blockers)
        SyncErrors = @($syncErrors)
    }
}