Private/PortfolioHelpers.ps1

# PortfolioHelpers.ps1
# Internal helpers for declarative portfolio sync

function Read-PortfolioFile {
    <#
    .SYNOPSIS
    Parses and validates an Intune portfolio YAML file.
    .PARAMETER Path
    Path to the portfolio YAML file.
    .OUTPUTS
    Array of PSCustomObjects with resolved app entries (Id, Name, AvailableInstall, InstallGroupName, UninstallGroupName).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateScript({ Test-Path $_ -PathType Leaf })]
        [string]$Path
    )

    Install-RequiredModule -ModuleName 'powershell-yaml'

    $raw = Get-Content -Path $Path -Raw -ErrorAction Stop
    $portfolio = ConvertFrom-Yaml -Yaml $raw -ErrorAction Stop

    if (-not $portfolio.apps -or $portfolio.apps.Count -eq 0) {
        throw "Portfolio file contains no apps. Add an 'apps' list with at least one entry."
    }

    # Resolve defaults
    $defaultAvailableInstall = 'User'
    if ($portfolio.defaults -and $portfolio.defaults.availableInstall) {
        $valid = @('User', 'Device', 'Both', 'None')
        if ($portfolio.defaults.availableInstall -notin $valid) {
            throw "Invalid defaults.availableInstall '$($portfolio.defaults.availableInstall)'. Must be one of: $($valid -join ', ')"
        }
        $defaultAvailableInstall = $portfolio.defaults.availableInstall
    }

    $defaultRemediation = $true
    if ($portfolio.defaults -and $null -ne $portfolio.defaults.remediation) {
        $defaultRemediation = [bool]$portfolio.defaults.remediation
    }

    $entries = [System.Collections.Generic.List[PSCustomObject]]::new()
    $seenIds = @{}

    foreach ($app in $portfolio.apps) {
        if (-not $app.id) {
            throw "Each app entry must have an 'id' field (WinGet package ID)."
        }

        $appId = $app.id.Trim()
        if ($seenIds.ContainsKey($appId.ToLower())) {
            throw "Duplicate app ID in portfolio: '$appId'"
        }
        $seenIds[$appId.ToLower()] = $true

        # Per-app availableInstall overrides default
        $availableInstall = $defaultAvailableInstall
        if ($app.availableInstall) {
            $valid = @('User', 'Device', 'Both', 'None')
            if ($app.availableInstall -notin $valid) {
                throw "Invalid availableInstall '$($app.availableInstall)' for app '$appId'. Must be one of: $($valid -join ', ')"
            }
            $availableInstall = $app.availableInstall
        }

        $entry = [PSCustomObject]@{
            Id                 = $appId
            Name               = if ($app.name) { $app.name.Trim() } else { $null }
            AvailableInstall   = $availableInstall
            InstallGroupName   = if ($app.groups -and $app.groups.install) { $app.groups.install } else { $null }
            UninstallGroupName = if ($app.groups -and $app.groups.uninstall) { $app.groups.uninstall } else { $null }
            Force              = if ($app.force) { [bool]$app.force } else { $false }
            Remediation        = if ($null -ne $app.remediation) { [bool]$app.remediation } else { $defaultRemediation }
        }

        $entries.Add($entry)
    }

    return $entries.ToArray()
}

function Compare-PortfolioState {
    <#
    .SYNOPSIS
    Compares desired portfolio entries against current Intune state.
    .PARAMETER DesiredApps
    Array of portfolio entries from Read-PortfolioFile.
    .PARAMETER CurrentApps
    Array of Intune app objects from Get-IntuneApplication.
    .OUTPUTS
    PSCustomObject with ToDeploy, UpToDate, and Unmanaged arrays.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [PSCustomObject[]]$DesiredApps,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [array]$CurrentApps
    )

    # Filter to only apps managed by this module
    $managedApps = $CurrentApps | Where-Object {
        $_.description -and $_.description -match [regex]::Escape($script:PublisherTag)
    }

    # Build lookup of managed app display names (lowercase) for matching
    $managedLookup = @{}
    foreach ($app in $managedApps) {
        $managedLookup[$app.displayName.ToLower()] = $app
    }

    $toDeploy = [System.Collections.Generic.List[PSCustomObject]]::new()
    $upToDate = [System.Collections.Generic.List[PSCustomObject]]::new()
    $matchedNames = @{}

    foreach ($desired in $DesiredApps) {
        $displayName = if ($desired.Name) { $desired.Name } else { $desired.Id }
        $found = $false

        # Try matching by display name
        if ($managedLookup.ContainsKey($displayName.ToLower())) {
            $found = $true
            $matchedNames[$displayName.ToLower()] = $true
        }

        # Also try matching by app ID in display name (some apps use the ID as name)
        if (-not $found -and $managedLookup.ContainsKey($desired.Id.ToLower())) {
            $found = $true
            $matchedNames[$desired.Id.ToLower()] = $true
        }

        # Search managed apps for partial match in displayName or description containing the app ID
        if (-not $found) {
            foreach ($mApp in $managedApps) {
                if ($mApp.displayName -like "*$($desired.Id)*" -or
                    ($mApp.description -and $mApp.description -like "*$($desired.Id)*")) {
                    $found = $true
                    $matchedNames[$mApp.displayName.ToLower()] = $true
                    break
                }
            }
        }

        if ($found -and -not $desired.Force) {
            $upToDate.Add($desired)
        } else {
            $toDeploy.Add($desired)
        }
    }

    # Unmanaged: managed apps in Intune not present in the portfolio
    $toRemove = [System.Collections.Generic.List[PSCustomObject]]::new()
    foreach ($mApp in $managedApps) {
        if (-not $matchedNames.ContainsKey($mApp.displayName.ToLower())) {
            $toRemove.Add([PSCustomObject]@{
                IntuneId    = $mApp.id
                DisplayName = $mApp.displayName
            })
        }
    }

    return [PSCustomObject]@{
        ToDeploy  = $toDeploy.ToArray()
        UpToDate  = $upToDate.ToArray()
        ToRemove  = $toRemove.ToArray()
    }
}

function Format-PortfolioReport {
    <#
    .SYNOPSIS
    Displays a formatted sync plan to the console.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [PSCustomObject]$DiffResult,

        [Parameter(Mandatory = $false)]
        [switch]$RemoveAbsent
    )

    $totalDesired = $DiffResult.ToDeploy.Count + $DiffResult.UpToDate.Count

    Write-Host "`n═══════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host " Intune Portfolio Sync Plan" -ForegroundColor Cyan
    Write-Host "═══════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host " Portfolio apps: $totalDesired | Deploy: $($DiffResult.ToDeploy.Count) | Up to date: $($DiffResult.UpToDate.Count) | Orphaned: $($DiffResult.ToRemove.Count)" -ForegroundColor Gray
    Write-Host ""

    if ($DiffResult.ToDeploy.Count -gt 0) {
        Write-Host " TO DEPLOY:" -ForegroundColor Green
        foreach ($app in $DiffResult.ToDeploy) {
            $label = if ($app.Force) { "(force)" } else { "(new)" }
            $name = if ($app.Name) { "$($app.Id) ($($app.Name))" } else { $app.Id }
            Write-Host " + $name $label" -ForegroundColor Green
        }
        Write-Host ""
    }

    if ($DiffResult.UpToDate.Count -gt 0) {
        Write-Host " UP TO DATE:" -ForegroundColor DarkGray
        foreach ($app in $DiffResult.UpToDate) {
            $name = if ($app.Name) { "$($app.Id) ($($app.Name))" } else { $app.Id }
            Write-Host " = $name" -ForegroundColor DarkGray
        }
        Write-Host ""
    }

    if ($DiffResult.ToRemove.Count -gt 0) {
        $color = if ($RemoveAbsent) { 'Red' } else { 'Yellow' }
        $label = if ($RemoveAbsent) { 'TO REMOVE:' } else { 'ORPHANED (use -RemoveAbsent to remove):' }
        Write-Host " $label" -ForegroundColor $color
        foreach ($app in $DiffResult.ToRemove) {
            Write-Host " - $($app.DisplayName)" -ForegroundColor $color
        }
        Write-Host ""
    }

    Write-Host "═══════════════════════════════════════════`n" -ForegroundColor Cyan
}