Private/Get-OSDAppCacheHealthState.ps1

function Get-OSDAppCacheHealthState {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$CachePath,

        [object[]]$DesiredPackage,

        [bool]$DesiredStateAvailable,

        [string]$CatalogSource,

        [string]$CatalogError,

        [string[]]$PinnedId
    )

    function Get-CacheItemSizeMB {
        param([Parameter(Mandatory)][string]$Path)

        if (-not (Test-Path -LiteralPath $Path)) { return 0.0 }
        $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
        if (-not $item) { return 0.0 }
        $bytes = if ($item.PSIsContainer) {
            (Get-ChildItem -LiteralPath $Path -File -Recurse -Force -ErrorAction SilentlyContinue |
                Measure-Object -Property Length -Sum).Sum
        }
        else {
            $item.Length
        }
        if ($null -eq $bytes) { $bytes = 0 }
        [math]::Round([double]$bytes / 1MB, 1)
    }

    function New-CacheHealthEntry {
        param(
            [string]$Id,
            [string]$CanonicalId,
            [string]$DisplayName,
            [string]$Source,
            [string]$Version,
            [string]$Architecture,
            [string]$State,
            [string]$Integrity,
            [string]$Action,
            [string]$Reason,
            [string]$Path,
            [bool]$Desired,
            [bool]$Pinned
        )

        [pscustomobject]@{
            PSTypeName   = 'OSDApps.CacheHealthEntry'
            Id           = $Id
            CanonicalId  = if ($CanonicalId) { $CanonicalId } else { $Id }
            DisplayName  = if ($DisplayName) { $DisplayName } else { $Id }
            Source       = $Source
            Version      = $Version
            Architecture = $Architecture
            State        = $State
            Integrity    = $Integrity
            Action       = $Action
            Reason       = $Reason
            Desired      = $Desired
            Pinned       = $Pinned
            SizeMB       = Get-CacheItemSizeMB -Path $Path
            CachePath    = $Path
        }
    }

    $pins = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($pin in @($PinnedId)) {
        if (-not [string]::IsNullOrWhiteSpace([string]$pin)) {
            [void]$pins.Add(([string]$pin).Trim())
        }
    }

    $entries = [System.Collections.Generic.List[object]]::new()
    $packagesRoot = Join-Path $CachePath 'Packages'
    $cacheCatalogPath = Join-Path $CachePath 'CacheCatalog.json'
    $existingPackages = @()
    $cacheCatalogErrors = [System.Collections.Generic.List[string]]::new()
    $invalidExistingIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    if (Test-Path -LiteralPath $cacheCatalogPath -PathType Leaf) {
        try {
            $existingCatalog = Get-OSDAppManifest -Path $cacheCatalogPath
            if (-not ($existingCatalog.PSObject.Properties.Name -contains 'Packages')) {
                throw 'Cache catalog is missing Packages.'
            }
            $existingPackages = @($existingCatalog.Packages)
        }
        catch {
            $cacheCatalogErrors.Add($_.Exception.Message)
        }
    }

    $safeExistingPackages = [System.Collections.Generic.List[object]]::new()
    $existingCanonicalIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($package in $existingPackages) {
        $existingId = [string]$package.Id
        if ([string]::IsNullOrWhiteSpace($existingId) -or
            $existingId -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or
            $existingId -match '\.\.') {
            $cacheCatalogErrors.Add("Invalid package Id '$existingId'.")
            continue
        }
        [void]$existingCanonicalIds.Add($existingId)
        if (-not $package.Archive -or
            [string]$package.Archive.Sha256 -notmatch '^[A-Fa-f0-9]{64}$') {
            [void]$invalidExistingIds.Add($existingId)
            $cacheCatalogErrors.Add("Package '$existingId' has an invalid SHA-256 archive definition.")
        }
        $safeExistingPackages.Add($package)
    }
    $existingPackages = @($safeExistingPackages)

    $existingAliasOwners = @{}
    foreach ($package in $existingPackages) {
        $packageAliases = if ($package.PSObject.Properties.Name -contains 'Aliases') {
            @($package.Aliases)
        }
        else { @() }
        foreach ($aliasValue in $packageAliases) {
            $alias = ([string]$aliasValue).Trim()
            $owner = [string]$package.Id
            if ([string]::IsNullOrWhiteSpace($alias) -or
                $alias -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or
                $alias -match '\.\.') {
                $cacheCatalogErrors.Add("Package '$owner' has invalid alias '$alias'.")
                [void]$invalidExistingIds.Add($owner)
                continue
            }
            if ($existingCanonicalIds.Contains($alias)) {
                $cacheCatalogErrors.Add("Alias '$alias' for '$owner' conflicts with a cached canonical Id.")
                [void]$invalidExistingIds.Add($owner)
                continue
            }
            $aliasKey = $alias.ToLowerInvariant()
            if ($existingAliasOwners.ContainsKey($aliasKey) -and
                [string]$existingAliasOwners[$aliasKey] -ine $owner) {
                $cacheCatalogErrors.Add("Alias '$alias' has multiple cached owners.")
                [void]$invalidExistingIds.Add($owner)
                [void]$invalidExistingIds.Add([string]$existingAliasOwners[$aliasKey])
                continue
            }
            $existingAliasOwners[$aliasKey] = $owner
        }
    }

    $cacheCatalogError = if ($cacheCatalogErrors.Count -gt 0) {
        @($cacheCatalogErrors | Select-Object -Unique) -join ' '
    }
    else { $null }

    if ($cacheCatalogError) {
        $entries.Add((New-CacheHealthEntry `
            -Id 'CacheCatalog.json' -CanonicalId 'CacheCatalog.json' `
            -DisplayName 'Repository cache catalog' -Source 'Repository' `
            -State 'Corrupt' -Integrity 'Corrupt' -Action 'Repair' `
            -Reason "CacheCatalog.json cannot be read safely: $cacheCatalogError" `
            -Path $cacheCatalogPath -Desired:$false -Pinned:$false))
    }

    $desiredById = @{}
    $repositoryAliasOwners = @{}
    foreach ($package in @($DesiredPackage)) {
        $desiredById[([string]$package.Id).ToLowerInvariant()] = $package
        $packageAliases = if ($package.PSObject.Properties.Name -contains 'Aliases') {
            @($package.Aliases)
        }
        else { @() }
        foreach ($aliasValue in $packageAliases) {
            $alias = ([string]$aliasValue).Trim()
            if ($alias) {
                $repositoryAliasOwners[$alias.ToLowerInvariant()] = [string]$package.Id
            }
        }
    }
    foreach ($aliasKey in $existingAliasOwners.Keys) {
        if (-not $repositoryAliasOwners.ContainsKey($aliasKey) -and
            -not $desiredById.ContainsKey($aliasKey)) {
            $repositoryAliasOwners[$aliasKey] = [string]$existingAliasOwners[$aliasKey]
        }
    }

    $existingById = @{}
    $duplicateExistingIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($group in @($existingPackages | Group-Object Id)) {
        $existingById[$group.Name.ToLowerInvariant()] = $group.Group[0]
        if ($group.Count -gt 1) { [void]$duplicateExistingIds.Add($group.Name) }
    }

    $repositoryIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($package in @($DesiredPackage)) { [void]$repositoryIds.Add([string]$package.Id) }
    foreach ($package in @($existingPackages)) { [void]$repositoryIds.Add([string]$package.Id) }
    if (Test-Path -LiteralPath $packagesRoot -PathType Container) {
        foreach ($directory in @(Get-ChildItem -LiteralPath $packagesRoot -Directory -Force -ErrorAction SilentlyContinue)) {
            [void]$repositoryIds.Add($directory.Name)
        }
    }

    foreach ($id in @($repositoryIds | Sort-Object)) {
        $key = $id.ToLowerInvariant()
        $desired = if ($desiredById.ContainsKey($key)) { $desiredById[$key] } else { $null }
        $existing = if ($existingById.ContainsKey($key)) { $existingById[$key] } else { $null }
        $canonicalId = if ($repositoryAliasOwners.ContainsKey($key)) { [string]$repositoryAliasOwners[$key] } else { $id }
        $isAlias = $repositoryAliasOwners.ContainsKey($key)
        $isPinned = $pins.Contains($id)
        $root = Join-Path $packagesRoot $id
        $archivePath = Join-Path $root 'Package.zip'
        $archiveExists = Test-Path -LiteralPath $archivePath -PathType Leaf
        $expectedHash = if ($desired -and $desired.Archive) {
            [string]$desired.Archive.Sha256
        }
        elseif ($existing -and $existing.Archive) {
            [string]$existing.Archive.Sha256
        }
        else { $null }
        $integrity = if (-not $archiveExists) {
            'Missing'
        }
        elseif ($expectedHash -match '^[A-Fa-f0-9]{64}$') {
            if (Test-OSDAppFileHash -Path $archivePath -ExpectedSha256 $expectedHash) { 'Valid' } else { 'Corrupt' }
        }
        else {
            'Unknown'
        }

        $state = $null
        $action = $null
        $reason = $null
        if ($duplicateExistingIds.Contains($id) -or $invalidExistingIds.Contains($id)) {
            $state = 'Corrupt'
            $action = if ($desired) { 'Synchronize' } else { 'Remove' }
            $reason = 'CacheCatalog.json contains duplicate or invalid metadata for this Id.'
        }
        elseif ($isAlias) {
            $state = 'Renamed'
            $action = 'RemoveAfterReplacement'
            $reason = "'$id' is an alias of canonical Id '$canonicalId'."
        }
        elseif ($desired) {
            $metadataCurrent = (
                $existing -and
                [string]$existing.Version -eq [string]$desired.Version -and
                [string]$existing.Architecture -eq [string]$desired.Architecture -and
                [string]$existing.Archive.Sha256 -eq [string]$desired.Archive.Sha256
            )
            if ($integrity -eq 'Missing') {
                $state = 'Incomplete'
                $action = 'Synchronize'
                $reason = 'The desired package archive is missing.'
            }
            elseif ($integrity -eq 'Corrupt') {
                $state = 'Corrupt'
                $action = 'Synchronize'
                $reason = 'The package archive does not match the desired SHA-256.'
            }
            elseif (-not $existing) {
                $state = 'Orphaned'
                $action = 'Adopt'
                $reason = 'The desired archive exists but CacheCatalog.json does not reference it.'
            }
            elseif (-not $metadataCurrent) {
                $state = 'Obsolete'
                $action = 'Synchronize'
                $reason = 'Cached metadata does not match the desired package version or variant.'
            }
            else {
                $state = 'Active'
                $action = 'None'
                $reason = 'The desired package and cache metadata are current.'
            }
        }
        elseif ($existing) {
            if ($integrity -eq 'Missing') {
                $state = 'Incomplete'
                $action = if ($isPinned) { 'Refresh' } elseif ($DesiredStateAvailable) { 'Remove' } else { 'Repair' }
                $reason = 'CacheCatalog.json references an archive that is missing.'
            }
            elseif ($integrity -eq 'Corrupt') {
                $state = 'Corrupt'
                $action = if ($isPinned) { 'Refresh' } elseif ($DesiredStateAvailable) { 'Remove' } else { 'Repair' }
                $reason = 'The cached archive does not match CacheCatalog.json.'
            }
            elseif ($isPinned) {
                $state = 'Pinned'
                $action = 'Keep'
                $reason = 'The package is outside desired state but explicitly pinned.'
            }
            elseif ($DesiredStateAvailable) {
                $state = 'Obsolete'
                $action = 'Remove'
                $reason = 'The package is no longer present in the desired catalog.'
            }
            else {
                $state = 'Active'
                $action = 'None'
                $reason = 'The locally cataloged package passes its recorded SHA-256; desired state was not requested.'
            }
        }
        else {
            $state = 'Orphaned'
            $action = if ($DesiredStateAvailable) { 'Remove' } else { 'Review' }
            $reason = 'A physical package folder exists without a CacheCatalog.json entry.'
        }

        $displayName = if ($desired -and $desired.DisplayName) { [string]$desired.DisplayName } elseif ($existing -and $existing.DisplayName) { [string]$existing.DisplayName } else { $id }
        $version = if ($desired) { [string]$desired.Version } elseif ($existing) { [string]$existing.Version } else { $null }
        $architecture = if ($desired) { [string]$desired.Architecture } elseif ($existing) { [string]$existing.Architecture } else { $null }
        $entries.Add((New-CacheHealthEntry `
            -Id $id -CanonicalId $canonicalId -DisplayName $displayName `
            -Source 'Repository' -Version $version -Architecture $architecture `
            -State $state -Integrity $integrity -Action $action -Reason $reason `
            -Path $root -Desired:([bool]$desired) -Pinned:$isPinned))
    }

    # Surface pins that do not map to any desired, cataloged or physical
    # repository entry. Sync rejects these; the read-only plan must not imply
    # that the same prune request would be healthy.
    foreach ($pin in @($pins | Sort-Object)) {
        if ($repositoryIds.Contains($pin)) { continue }
        $key = $pin.ToLowerInvariant()
        $isAliasPin = $repositoryAliasOwners.ContainsKey($key)
        $canonicalId = if ($isAliasPin) { [string]$repositoryAliasOwners[$key] } else { $pin }
        $state = if ($isAliasPin) { 'Renamed' } else { 'Incomplete' }
        $reason = if ($isAliasPin) {
            "Pinned Id '$pin' is a retired alias. Pin canonical Id '$canonicalId' instead."
        }
        else {
            "Pinned Id '$pin' is not present in desired or cached repository state."
        }
        $entries.Add((New-CacheHealthEntry `
            -Id $pin -CanonicalId $canonicalId -DisplayName $pin `
            -Source 'Repository' -State $state -Integrity 'Missing' -Action 'Review' `
            -Reason $reason -Path (Join-Path $packagesRoot $pin) `
            -Desired:$false -Pinned:$true))
    }

    $moduleRoot = Split-Path -Path $PSScriptRoot -Parent
    $builtInMetadataPath = Join-Path $moduleRoot 'metadata\builtins.json'
    $builtInCanonical = @{}
    $builtInAliasOwners = @{}
    if (Test-Path -LiteralPath $builtInMetadataPath -PathType Leaf) {
        $builtInMetadata = Get-Content -LiteralPath $builtInMetadataPath -Raw -Encoding UTF8 | ConvertFrom-Json
        foreach ($application in @($builtInMetadata.Applications)) {
            $builtInCanonical[([string]$application.Id).ToLowerInvariant()] = $application
            foreach ($aliasValue in @($application.Aliases)) {
                $alias = ([string]$aliasValue).Trim()
                if ($alias) { $builtInAliasOwners[$alias.ToLowerInvariant()] = [string]$application.Id }
            }
        }
    }

    # CacheCatalog diagnostics are already represented above; suppress the
    # inventory command's duplicate warning while still collecting built-ins.
    $inventory = @(Get-OSDAppCache -CachePath $CachePath -WarningAction SilentlyContinue)
    $builtInInventory = @($inventory | Where-Object Source -eq 'BuiltIn')
    $builtInRoot = Join-Path $CachePath 'BuiltIn'
    $representedRoots = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    foreach ($item in $builtInInventory) {
        $relative = ([string]$item.CachePath).Substring($builtInRoot.Length).TrimStart([char[]]@('\','/'))
        $topLevelId = @($relative -split '[\\/]')[0]
        [void]$representedRoots.Add($topLevelId)
        $key = $topLevelId.ToLowerInvariant()
        $metadataId = [string]$item.Id
        $metadataKey = $metadataId.ToLowerInvariant()
        $folderIsAlias = $builtInAliasOwners.ContainsKey($key)
        $folderIsCanonical = $builtInCanonical.ContainsKey($key)
        $metadataIsAlias = $builtInAliasOwners.ContainsKey($metadataKey)
        $canonicalId = if ($folderIsAlias) {
            [string]$builtInAliasOwners[$key]
        }
        elseif ($folderIsCanonical) {
            [string]$builtInCanonical[$key].Id
        }
        elseif ($metadataIsAlias) {
            [string]$builtInAliasOwners[$metadataKey]
        }
        else {
            $metadataId
        }
        $isPinned = $false

        if ($folderIsAlias) {
            $state = 'Renamed'; $action = 'MigrateOrRemove'; $reason = "'$topLevelId' is an alias of canonical Id '$canonicalId'."
        }
        elseif (-not $folderIsCanonical) {
            $state = 'Orphaned'; $action = 'Remove'; $reason = 'The folder does not match a current built-in Id.'
        }
        elseif ($metadataIsAlias -and [string]$builtInAliasOwners[$metadataKey] -ieq $canonicalId) {
            $state = 'Renamed'; $action = 'Refresh'; $reason = "CacheInfo.json uses retired Id '$metadataId' for canonical folder '$canonicalId'."
        }
        elseif ($metadataId -ine $canonicalId) {
            $state = 'Corrupt'; $action = 'Refresh'; $reason = "CacheInfo.json Id '$metadataId' does not match canonical folder '$canonicalId'."
        }
        elseif (-not $item.Valid) {
            $state = 'Incomplete'; $action = 'Refresh'; $reason = 'Required built-in payload files are missing.'
        }
        else {
            $state = 'Active'; $action = 'None'; $reason = 'The built-in cache entry uses a canonical Id and has its required files.'
        }

        $entries.Add((New-CacheHealthEntry `
            -Id $topLevelId -CanonicalId $canonicalId -DisplayName $item.DisplayName `
            -Source 'BuiltIn' -Version $item.Version -Architecture $item.Architecture `
            -State $state -Integrity $(if ($item.Valid) { 'Valid' } else { 'Missing' }) `
            -Action $action -Reason $reason -Path $item.CachePath `
            -Desired:$folderIsCanonical -Pinned:$isPinned))
    }

    if (Test-Path -LiteralPath $builtInRoot -PathType Container) {
        foreach ($directory in @(Get-ChildItem -LiteralPath $builtInRoot -Directory -Force -ErrorAction SilentlyContinue)) {
            if ($representedRoots.Contains($directory.Name)) { continue }
            $key = $directory.Name.ToLowerInvariant()
            $isPinned = $false
            $canonicalId = if ($builtInAliasOwners.ContainsKey($key)) { [string]$builtInAliasOwners[$key] } else { $directory.Name }
            if ($builtInAliasOwners.ContainsKey($key)) {
                $state = 'Renamed'; $action = 'Review'; $reason = "'$($directory.Name)' is an alias of canonical Id '$canonicalId', but its CacheInfo.json is missing or unreadable."
            }
            elseif ($builtInCanonical.ContainsKey($key)) {
                $state = 'Incomplete'; $action = 'Refresh'; $reason = 'The canonical built-in folder has no readable CacheInfo.json.'
            }
            else {
                $state = 'Orphaned'; $action = 'Remove'; $reason = 'The folder is not registered as a current built-in application.'
            }
            $entries.Add((New-CacheHealthEntry `
                -Id $directory.Name -CanonicalId $canonicalId -DisplayName $directory.Name `
                -Source 'BuiltIn' -State $state -Integrity 'Unknown' -Action $action `
                -Reason $reason -Path $directory.FullName `
                -Desired:($builtInCanonical.ContainsKey($canonicalId.ToLowerInvariant())) -Pinned:$isPinned))
        }
    }

    $stagingRoot = Join-Path $CachePath '.staging'
    if (Test-Path -LiteralPath $stagingRoot -PathType Container) {
        foreach ($directory in @(Get-ChildItem -LiteralPath $stagingRoot -Directory -Force -ErrorAction SilentlyContinue)) {
            $entries.Add((New-CacheHealthEntry `
                -Id $directory.Name -CanonicalId $directory.Name `
                -DisplayName 'Unfinished cache transaction' -Source 'Transaction' `
                -State 'Orphaned' -Integrity 'Unknown' -Action 'Review' `
                -Reason 'A cache transaction directory remains. Inspect its logs and recovery data before removing it.' `
                -Path $directory.FullName -Desired:$false -Pinned:$false))
        }
    }

    $allEntries = @($entries | Sort-Object Source, CanonicalId, Id, Architecture, CachePath)
    $actionableStates = @('Obsolete','Orphaned','Renamed','Incomplete','Corrupt')
    $removable = @($allEntries | Where-Object { $_.Action -in @('Remove','RemoveAfterReplacement','MigrateOrRemove') })

    [pscustomobject]@{
        PSTypeName            = 'OSDApps.CacheHealth'
        CachePath             = $CachePath
        CacheAvailable        = $true
        DesiredStateAvailable = $DesiredStateAvailable
        CatalogSource         = $CatalogSource
        CatalogError          = $CatalogError
        CacheCatalogError     = $cacheCatalogError
        EntryCount            = $allEntries.Count
        ActiveCount           = @($allEntries | Where-Object State -eq 'Active').Count
        PinnedCount           = @($allEntries | Where-Object { $_.Pinned -or $_.State -eq 'Pinned' }).Count
        ObsoleteCount         = @($allEntries | Where-Object State -eq 'Obsolete').Count
        OrphanedCount         = @($allEntries | Where-Object State -eq 'Orphaned').Count
        RenamedCount          = @($allEntries | Where-Object State -eq 'Renamed').Count
        IncompleteCount       = @($allEntries | Where-Object State -eq 'Incomplete').Count
        CorruptCount          = @($allEntries | Where-Object { $_.State -eq 'Corrupt' -or $_.Integrity -eq 'Corrupt' }).Count
        RemovableSizeMB       = [math]::Round([double](($removable | Measure-Object SizeMB -Sum).Sum), 1)
        Entries               = $allEntries
        Healthy               = (
            -not $CatalogError -and
            @($allEntries | Where-Object {
                ($_.State -in $actionableStates -and -not ($_.State -eq 'Obsolete' -and $_.Pinned)) -or
                $_.Integrity -in @('Missing','Corrupt','Unknown')
            }).Count -eq 0
        )
    }
}