Public/Start-PatchCycle.ps1

function Start-PatchCycle {
    <#
    .SYNOPSIS
        Starts a patch cycle to install available updates
    .DESCRIPTION
        Orchestrates the installation of pending updates with deferral handling,
        notifications, and logging. This is the main entry point for update installation.
        Can also install missing applications from the catalog.
    .PARAMETER Interactive
        Run in interactive mode with user notifications
    .PARAMETER Force
        Force installation without deferral options
    .PARAMETER NoReboot
        Suppress automatic reboots
    .PARAMETER Priority
        Only install updates of specified priority or higher
    .PARAMETER AppId
        Only update specific application(s)
    .PARAMETER InstallMissing
        Install applications from catalog that are not currently installed
    .EXAMPLE
        Start-PatchCycle
        Runs a full patch cycle for all managed applications
    .EXAMPLE
        Start-PatchCycle -Interactive
        Runs with user notifications and deferral dialogs
    .EXAMPLE
        Start-PatchCycle -InstallMissing
        Installs missing applications and updates existing ones
    .EXAMPLE
        spc -Force -NoReboot
        Uses alias to force updates without rebooting
    #>

    [CmdletBinding()]
    [Alias('spc')]
    param(
        [Parameter()]
        [switch]$Interactive,
        
        [Parameter()]
        [switch]$Force,
        
        [Parameter()]
        [switch]$NoReboot,
        
        [Parameter()]
        [ValidateSet('Critical', 'High', 'Normal', 'Low')]
        [string]$Priority,
        
        [Parameter()]
        [string[]]$AppId,
        
        [Parameter()]
        [switch]$InstallMissing
    )
    
    $result = [PatchCycleResult]::new()
    Write-PatchLog "Starting patch cycle (CorrelationId: $($result.CorrelationId))" -Type Info
    
    try {
        # Ensure winget is available
        if (-not (Test-WingetAvailable -AutoInstall)) {
            $result.Success = $false
            $result.Message = "Winget not available"
            Write-PatchLog $result.Message -Type Error
            $result.Complete()
            return $result
        }
        
        # Get configuration and managed apps
        $config = Get-PatchMyPCConfig
        $managedApps = Get-ManagedApplicationsInternal
        
        # Handle missing applications first if requested
        if ($InstallMissing) {
            Write-PatchLog "Checking for missing applications to install" -Type Info
            $missingApps = Get-MissingApplication
            
            # Filter by priority if specified
            if ($Priority) {
                $priorityLevel = [UpdatePriority]$Priority
                $missingApps = $missingApps | Where-Object { $_.Priority -ge $priorityLevel }
            }
            
            # Filter by AppId if specified
            if ($AppId) {
                $missingApps = $missingApps | Where-Object { $AppId -contains $_.AppId }
            }
            
            foreach ($app in $missingApps) {
                Write-PatchLog "Processing missing application: $($app.AppName) ($($app.AppId))" -Type Info
                
                $appConfig = $app.AppConfig
                
                # Check if deferral is configured for initial install
                if ($appConfig.DeferInitialInstall -and -not $Force) {
                    if ($Interactive) {
                        # Create a pseudo PatchStatus for the deferral dialog
                        $pseudoStatus = [PatchStatus]::new()
                        $pseudoStatus.AppId = $app.AppId
                        $pseudoStatus.AppName = $app.AppName
                        $pseudoStatus.InstalledVersion = 'Not Installed'
                        $pseudoStatus.AvailableVersion = $app.TargetVersion
                        $pseudoStatus.UpdateAvailable = $true
                        $pseudoStatus.Priority = $app.Priority
                        
                        $userChoice = Show-DeferralDialogFull -Updates @($pseudoStatus) -Config $config -Timeout 60
                        
                        if ($userChoice -eq 'Defer') {
                            $installResult = [InstallationResult]::new()
                            $installResult.AppId = $app.AppId
                            $installResult.AppName = $app.AppName
                            $installResult.Status = [InstallationStatus]::Deferred
                            $installResult.Message = "Initial installation deferred by user"
                            $installResult.ExitCode = 1602
                            
                            $result.Results += $installResult
                            $result.Deferred++
                            $result.TotalUpdates++
                            
                            Write-PatchLog "Initial installation deferred for $($app.AppName)" -Type Info
                            continue
                        }
                    }
                    else {
                        # Non-interactive with deferral enabled - skip
                        Write-PatchLog "Skipping $($app.AppName) - initial install deferral enabled (use -Force or -Interactive)" -Type Info
                        continue
                    }
                }
                
                # Install the missing application
                $installResult = Install-MissingApplication -AppId $app.AppId -AppConfig $appConfig -Force:$Force
                $result.Results += $installResult
                $result.TotalUpdates++
                
                if ($installResult.Status -eq [InstallationStatus]::Success) {
                    $result.Installed++
                    
                    if ($installResult.RebootRequired) {
                        $result.RebootRequired = $true
                    }
                }
                elseif ($installResult.Status -eq [InstallationStatus]::Deferred) {
                    $result.Deferred++
                }
                else {
                    $result.Failed++
                }
            }
        }
        
        # Get available updates
        $updates = Get-PatchStatus -ManagedOnly
        
        # Filter by priority if specified
        if ($Priority) {
            $priorityLevel = [UpdatePriority]$Priority
            $updates = $updates | Where-Object { $_.Priority -ge $priorityLevel }
        }
        
        # Filter by AppId if specified
        if ($AppId) {
            $updates = $updates | Where-Object { $AppId -contains $_.AppId }
        }
        
        $result.TotalUpdates += $updates.Count
        
        if ($updates.Count -eq 0 -and $result.TotalUpdates -eq 0) {
            $result.Success = $true
            if ($InstallMissing) {
                $result.Message = "No updates available and no missing applications to install"
            } else {
                $result.Message = "No updates available (use -InstallMissing to install missing catalog apps)"
            }
            Write-PatchLog $result.Message -Type Info
            $result.Complete()
            return $result
        }
        
        Write-PatchLog "Found $($updates.Count) updates to process" -Type Info
        
        # Process each update
        foreach ($update in $updates) {
            Write-PatchLog "Processing update for $($update.AppName) ($($update.AppId))" -Type Info
            
            # Get or initialize deferral state
            $targetVersion = [string]$update.AvailableVersion
            if ([string]::IsNullOrWhiteSpace($targetVersion)) {
                $targetVersion = 'Latest'
            }
            $deferralState = Initialize-DeferralState -AppId $update.AppId -TargetVersion $targetVersion -Config $config
            
            # Update deferral phase based on time
            $deferralState.Phase = Get-DeferralPhaseInternal -Deadline $deferralState.DeadlineDate -Config $config
            Set-StateToRegistry -State $deferralState
            
            # Check if deferral is allowed (unless Force)
            if (-not $Force -and $deferralState.CanDefer()) {
                # In interactive mode, show notification/dialog
                if ($Interactive) {
                    $userChoice = Show-DeferralDialogFull -Updates @($update) -Config $config -Timeout 60
                    
                    if ($userChoice -eq 'Defer') {
                        # Record deferral
                        $deferralState.DeferralCount++
                        $deferralState.LastDeferral = [datetime]::UtcNow
                        Set-StateToRegistry -State $deferralState
                        
                        $installResult = [InstallationResult]::new()
                        $installResult.AppId = $update.AppId
                        $installResult.AppName = $update.AppName
                        $installResult.Status = [InstallationStatus]::Deferred
                        $installResult.Message = "Deferred by user ($($deferralState.GetRemainingDeferrals()) remaining)"
                        $installResult.ExitCode = 1602
                        
                        $result.Results += $installResult
                        $result.Deferred++
                        
                        Write-PatchLog "Update deferred for $($update.AppName)" -Type Info
                        continue
                    }
                }
                elseif (-not $Force) {
                    # Non-interactive mode with deferrals still available - check processes
                    if ($update.ProcessesRunning) {
                        $installResult = [InstallationResult]::new()
                        $installResult.AppId = $update.AppId
                        $installResult.AppName = $update.AppName
                        $installResult.Status = [InstallationStatus]::Deferred
                        $installResult.Message = "Conflicting processes running"
                        $installResult.ExitCode = 1602
                        
                        $result.Results += $installResult
                        $result.Deferred++
                        
                        Write-PatchLog "Skipping $($update.AppName) - conflicting processes running" -Type Info
                        continue
                    }
                }
            }
            
            # Get app configuration
            $appConfig = $managedApps | Where-Object { $_.Id -eq $update.AppId } | Select-Object -First 1
            
            # Install the update
            $installResult = Install-ApplicationUpdate -AppId $update.AppId -AppConfig $appConfig -Force:$Force
            $result.Results += $installResult
            
            if ($installResult.Status -eq [InstallationStatus]::Success) {
                $result.Installed++
                
                # Clear deferral state on success
                Remove-StateFromRegistry -AppId $update.AppId
                
                if ($installResult.RebootRequired) {
                    $result.RebootRequired = $true
                }
            }
            elseif ($installResult.Status -eq [InstallationStatus]::Deferred) {
                $result.Deferred++
            }
            else {
                $result.Failed++
            }
        }
        
        # Determine overall success
        $result.Success = ($result.Failed -eq 0)
        $result.Message = "Patch cycle complete: $($result.Installed) installed, $($result.Failed) failed, $($result.Deferred) deferred"
        
        Write-PatchLog $result.Message -Type $(if ($result.Success) { 'Info' } else { 'Warning' })
        
        # Handle reboot if required and not suppressed
        if ($result.RebootRequired -and -not $NoReboot) {
            Write-PatchLog "Reboot required - scheduling restart" -Type Warning
            # Note: In production, this would schedule a reboot with user notification
        }
    }
    catch {
        $result.Success = $false
        $result.Message = "Patch cycle failed: $_"
        Write-PatchLog $result.Message -Type Error
    }
    finally {
        $result.Complete()
        
        # Write compliance status for MDM systems
        Export-ComplianceStatus -Result $result
    }
    
    return $result
}

function Get-DeferralPhaseInternal {
    <#
    .SYNOPSIS
        Determines the current deferral phase based on deadline
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [datetime]$Deadline,
        
        [Parameter(Mandatory)]
        [PsPatchMyPCConfig]$Config
    )
    
    $hoursRemaining = ($Deadline - [datetime]::UtcNow).TotalHours
    
    if ($hoursRemaining -le 0) { return [DeferralPhase]::Elapsed }
    if ($hoursRemaining -le $Config.Deferrals.ImminentWindowHours) { return [DeferralPhase]::Imminent }
    if ($hoursRemaining -le $Config.Deferrals.ApproachingWindowHours) { return [DeferralPhase]::Approaching }
    return [DeferralPhase]::Initial
}

function Show-DeferralDialogInternal {
    <#
    .SYNOPSIS
        Internal function to show deferral dialog (placeholder for full WPF implementation)
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [PatchStatus]$Update,
        
        [Parameter(Mandatory)]
        [DeferralState]$DeferralState,
        
        [Parameter(Mandatory)]
        [PsPatchMyPCConfig]$Config
    )
    
    # This is a simplified implementation - full WPF dialog is in Show-PatchNotification.ps1
    # In aggressive mode (Elapsed phase), return 'Install'
    if ($DeferralState.Phase -eq [DeferralPhase]::Elapsed) {
        return 'Install'
    }
    
    # Default to defer in non-WPF scenarios (actual WPF shows dialog)
    return 'Defer'
}

function Export-ComplianceStatus {
    <#
    .SYNOPSIS
        Exports compliance status for MDM integration
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [PatchCycleResult]$Result
    )
    
    try {
        $config = Get-ModuleConfiguration
        
        # Write to state file for FleetDM/Intune
        $statusPath = Join-Path $config.StatePath 'compliance.json'
        
        $status = @{
            Timestamp    = $Result.EndTime.ToString('o')
            CorrelationId = $Result.CorrelationId
            Success      = $Result.Success
            Installed    = $Result.Installed
            Failed       = $Result.Failed
            Deferred     = $Result.Deferred
            Reboot       = $Result.RebootRequired
            Duration     = $Result.Duration.TotalSeconds
        }
        
        try {
            $status | ConvertTo-Json | Out-File -FilePath $statusPath -Encoding UTF8 -Force
        }
        catch {
            # If ProgramData isn't writable (non-elevated), fall back to TEMP so reporting still works.
            $fallbackDir = Join-Path $env:TEMP 'PsPatchMyPC\State'
            if (-not (Test-Path $fallbackDir)) {
                New-Item -Path $fallbackDir -ItemType Directory -Force | Out-Null
            }
            $fallbackPath = Join-Path $fallbackDir 'compliance.json'
            $status | ConvertTo-Json | Out-File -FilePath $fallbackPath -Encoding UTF8 -Force
        }
        
        # Also write FleetDM-specific status if configured
        $fleetPath = "C:\ProgramData\FleetDM\patch_status.json"
        if (Test-Path (Split-Path $fleetPath -Parent)) {
            $status | ConvertTo-Json | Out-File -FilePath $fleetPath -Encoding UTF8 -Force
        }
    }
    catch {
        Write-PatchLog "Failed to export compliance status: $_" -Type Warning
    }
}