PsPatchMyPC.psm1

#Requires -Version 5.1
<#
.SYNOPSIS
    PsPatchMyPC - Enterprise Application Patching Module
.DESCRIPTION
    Integrates winget package management with PatchMyPC-style orchestration
    and Nudge-inspired progressive enforcement for enterprise environments.
.NOTES
    Author: Thomas Tyson
    License: MIT
#>


$ErrorActionPreference = 'Stop'

# Module paths
$Script:ModuleRoot = $PSScriptRoot
$Script:PrivatePath = Join-Path $ModuleRoot 'Private'
$Script:PublicPath = Join-Path $ModuleRoot 'Public'
$Script:ClassesPath = Join-Path $ModuleRoot 'Classes'
$Script:ConfigPath = Join-Path $ModuleRoot 'Config'

# Load classes first
$classFiles = @(
    'Classes\PatchMyPCClasses.ps1'
)

foreach ($file in $classFiles) {
    $filePath = Join-Path $ModuleRoot $file
    if (Test-Path $filePath) {
        try {
            . $filePath
        }
        catch {
            Write-Error "Failed to load class file: $file - $_"
        }
    }
}

# Private functions (internal use only)
$privateFiles = @(
    'Private\Logging\Write-PatchLog.ps1'
    'Private\Logging\Write-EventLogEntry.ps1'
    'Private\Core\Get-InstalledApplication.ps1'
    'Private\Core\Get-AvailableUpdate.ps1'
    'Private\Core\Install-ApplicationUpdate.ps1'
    'Private\Core\Test-ConflictingProcess.ps1'
    'Private\State\Get-StateFromRegistry.ps1'
    'Private\State\Set-StateToRegistry.ps1'
    'Private\Notification\Show-WPFDialog.ps1'
    'Private\Notification\Invoke-AsCurrentUser.ps1'
)

foreach ($file in $privateFiles) {
    $filePath = Join-Path $ModuleRoot $file
    if (Test-Path $filePath) {
        try {
            . $filePath
        }
        catch {
            Write-Error "Failed to load private function: $file - $_"
        }
    }
}

# Public functions (exported)
$publicFiles = @(
    'Public\Get-PatchMyPCConfig.ps1'
    'Public\Initialize-Winget.ps1'
    'Public\Get-PatchStatus.ps1'
    'Public\Start-PatchCycle.ps1'
    'Public\Show-PatchNotification.ps1'
    'Public\Register-PatchSchedule.ps1'
    'Public\Set-PatchDeferral.ps1'
    'Public\Export-PatchReport.ps1'
)

foreach ($file in $publicFiles) {
    $filePath = Join-Path $ModuleRoot $file
    if (Test-Path $filePath) {
        try {
            . $filePath
        }
        catch {
            Write-Error "Failed to load public function: $file - $_"
        }
    }
}

# Initialize module configuration
$Script:ModuleConfig = $null

function Test-PsPatchMyPCPathWritable {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    try {
        if (-not (Test-Path $Path)) {
            New-Item -Path $Path -ItemType Directory -Force | Out-Null
        }

        $testFile = Join-Path $Path ("._writetest_{0}.tmp" -f ([guid]::NewGuid().ToString()))
        Set-Content -Path $testFile -Value 'test' -Encoding Ascii -Force
        Remove-Item -Path $testFile -Force -ErrorAction SilentlyContinue
        return $true
    }
    catch {
        return $false
    }
}

function Get-ModuleConfiguration {
    if ($null -eq $Script:ModuleConfig) {
        # Initialize with defaults (expand ProgramData path at runtime)
        $programData = $env:ProgramData
        if (-not $programData) { $programData = 'C:\ProgramData' }

        $tempBase = $env:TEMP
        if (-not $tempBase) { $tempBase = $env:TMP }
        if (-not $tempBase) { $tempBase = 'C:\Windows\Temp' }
        
        $defaultLogPath = Join-Path $programData 'PsPatchMyPC\Logs'
        $defaultStatePath = Join-Path $programData 'PsPatchMyPC\State'
        $defaultConfigPath = Join-Path $programData 'PsPatchMyPC\Config'

        # If ProgramData is not writable (common when not elevated), fall back to a user-writable temp location.
        if (-not (Test-PsPatchMyPCPathWritable -Path (Split-Path $defaultLogPath -Parent))) {
            $defaultLogPath = Join-Path $tempBase 'PsPatchMyPC\Logs'
        }
        if (-not (Test-PsPatchMyPCPathWritable -Path (Split-Path $defaultStatePath -Parent))) {
            $defaultStatePath = Join-Path $tempBase 'PsPatchMyPC\State'
        }
        if (-not (Test-PsPatchMyPCPathWritable -Path (Split-Path $defaultConfigPath -Parent))) {
            $defaultConfigPath = Join-Path $tempBase 'PsPatchMyPC\Config'
        }

        $Script:ModuleConfig = @{
            LogPath          = $defaultLogPath
            StatePath        = $defaultStatePath
            ConfigPath       = $defaultConfigPath
            EventLogName     = 'Application'
            EventLogSource   = 'WSH'  # Use existing Windows Script Host source (no admin required)
            StateRegistryKey = 'HKLM:\SOFTWARE\PsPatchMyPC\State'
        }
        
        # Apply environment variable overrides
        if ($env:PSPMPC_LOG_PATH) {
            $Script:ModuleConfig.LogPath = $env:PSPMPC_LOG_PATH
        }
        if ($env:PSPMPC_STATE_PATH) {
            $Script:ModuleConfig.StatePath = $env:PSPMPC_STATE_PATH
        }
        if ($env:PSPMPC_CONFIG_PATH) {
            $Script:ModuleConfig.ConfigPath = $env:PSPMPC_CONFIG_PATH
        }
        
        # Ensure directories exist
        foreach ($path in @($Script:ModuleConfig.LogPath, $Script:ModuleConfig.StatePath, $Script:ModuleConfig.ConfigPath)) {
            if (-not (Test-PsPatchMyPCPathWritable -Path $path)) {
                # As a final fallback, move to temp (best effort). This prevents "deferrals never expire" scenarios
                # caused by failing to persist state when running non-elevated.
                $fallback = Join-Path $tempBase (Split-Path $path -Leaf)
                $null = Test-PsPatchMyPCPathWritable -Path $fallback

                if ($path -eq $Script:ModuleConfig.LogPath) { $Script:ModuleConfig.LogPath = $fallback }
                elseif ($path -eq $Script:ModuleConfig.StatePath) { $Script:ModuleConfig.StatePath = $fallback }
                elseif ($path -eq $Script:ModuleConfig.ConfigPath) { $Script:ModuleConfig.ConfigPath = $fallback }
            }
        }
        
        # Event Log uses pre-existing WSH source (no creation needed)
        # Users can override with $env:PSPMPC_EVENT_LOG = 'false' to disable
    }
    return $Script:ModuleConfig
}

# Initialize on module load
$null = Get-ModuleConfiguration

# Set up aliases
Set-Alias -Name 'gpst' -Value 'Get-PatchStatus' -Scope Global -Force
Set-Alias -Name 'spc' -Value 'Start-PatchCycle' -Scope Global -Force

# Module cleanup
$ExecutionContext.SessionState.Module.OnRemove = {
    Remove-Item -Path Alias:gpst -Force -ErrorAction SilentlyContinue
    Remove-Item -Path Alias:spc -Force -ErrorAction SilentlyContinue
}

# Export public functions
Export-ModuleMember -Function @(
    'Get-PatchStatus'
    'Start-PatchCycle'
    'Get-PatchMyPCConfig'
    'Initialize-Winget'
    'Test-WingetAvailable'
    'Get-WingetUpdates'
    'Install-WingetUpdate'
    'Show-PatchNotification'
    'Show-DeferralDialog'
    'Show-ToastNotification'
    'Register-PatchSchedule'
    'Unregister-PatchSchedule'
    'Get-PatchSchedule'
    'Get-DeferralState'
    'Set-PatchDeferral'
    'Reset-DeferralState'
    'Test-DeferralAllowed'
    'Get-DeferralPhase'
    'Export-PatchReport'
    'Get-PatchCompliance'
    'Get-ManagedApplications'
    'Add-ManagedApplication'
    'Remove-ManagedApplication'
    'Get-PatchMyPCLogs'
) -Alias @('gpst', 'spc')