<#
.SYNOPSIS
    Windows Update Monitoring Script for Prometheus Windows Exporter (Microsoft Update Direct)

.DESCRIPTION
    This script monitors Windows Updates and exports metrics in Prometheus format.
    It checks for available updates directly from Microsoft Update (not WSUS),
    their severity levels, and installation status.
    Can optionally install updates when specified.

.PARAMETER MetricNames
    Custom metric names hashtable for overriding default metric names
.PARAMETER AutoInstall
    Enable automatic installation of available updates
.PARAMETER ScheduleDaily
    Create a scheduled task to run this script daily at 3 AM
.PARAMETER MetricsFilePath
    Path where Prometheus metrics will be written (must be accessible by windows_exporter)

.NOTES
    Version: 1.6.2
    Author: Phil Connor contact@mylinux.work
    License: MIT
    
    Features:
    - Monitors Windows Updates status via Microsoft Update (direct, no WSUS)
    - Exports metrics in Prometheus format
    - Supports automatic update installation
    - Graceful shutdown support
    - Retry mechanisms and timeout handling
    - Memory management and garbage collection
    - Comprehensive logging
    - Error handling and metrics
#>

param(
    # Custom metric names hashtable for overriding default metric names
    [hashtable]$MetricNames,
    # Enable automatic installation of available updates
    [switch]$AutoInstall = $false,                                                                  
    # Create a scheduled task to run this script daily at 3 AM
    [switch]$ScheduleDaily,                                                                 
    # Validate that the parent directory exists for the metrics file
    [ValidateScript({Test-Path (Split-Path $_ -Parent) -PathType Container})]                       
    # Path where Prometheus metrics will be written (must be accessible by windows_exporter)
    [string]$MetricsFilePath = "C:\Program Files\windows_exporter\textfile_inputs\updates.prom"     
)

# Check if script is already running to prevent multiple instances
$scriptName = $MyInvocation.MyCommand.Name
$currentProcess = Get-Process -Id $PID
$runningInstances = Get-WmiObject Win32_Process | 
    Where-Object { $_.CommandLine -like "*$scriptName*" -and $_.ProcessId -ne $currentProcess.Id }

if ($runningInstances) {
    Write-Host "Script is already running (PID: $($runningInstances.ProcessId)). Exiting to prevent conflicts."
    exit 0
}

# Create scheduled task for daily execution at 3 AM
if ($ScheduleDaily -eq $true) {
    $taskName = "WindowsUpdateMonitoringDirect"
    # Check if the scheduled task already exists to avoid duplicates
    $existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
    
    if (-not $existingTask) {
        # Define the action: run PowerShell with this script
        $taskAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`""
        # Set the trigger to run daily at 3 AM
        $taskTrigger = New-ScheduledTaskTrigger -Daily -At 3AM
        # Run as SYSTEM account with highest privileges for update operations
        $taskPrincipal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest

        try {
            Write-Host "Creating scheduled task: $taskName"
            # Register the scheduled task with Windows Task Scheduler
            Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $taskTrigger -Principal $taskPrincipal -Description "Monitors for Windows updates directly from Microsoft Update and optionally installs them automatically"

            # Verify the task was created successfully
            $createdTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
            if (-not $createdTask) {
                throw "Failed to verify scheduled task creation"
            }
            Write-Host "Successfully created scheduled task: $taskName"
        } catch {
            Write-Error "Failed to create auto-start task: $($_.Exception.Message)"
            throw
        }
    } else {
        Write-Host "Scheduled task $taskName already exists. Skipping creation."
    }
}


# Define standard metric values used throughout the script
$script:MetricValues = @{
    Success = 1         # Indicates successful operation
    Error = 0           # Indicates error state
    NoError = 0         # Indicates no error occurred
    NoLastInstall = 0   # Indicates no previous installation timestamp
    NoInstallStatus = 0 # Indicates no installation status available
}

# Define Prometheus metric names following naming conventions
# These metrics will be exposed to Prometheus for monitoring
Set-Variable -Name MetricNames -Value @{
    # Script execution metrics - track the health and performance of this script
    Status = "windows_update_script_status"                         # Status of the script execution (1 for running, 0 for not running)
    Timestamp = "windows_update_script_timestamp_seconds"           # Timestamp of the last script execution
    Error = "windows_update_script_error"                           # Error message if script execution fails
    Runtime = "windows_update_script_runtime_seconds"               # Total runtime of the script
    
    # Update information metrics - track Windows Update status and availability
    Available = "windows_updates_available"                         # Total number of Windows updates available for installation
    Info = "windows_update_info"                                    # General update information
    Reboot = "windows_update_reboot_required"                       # Reboot requirement flag
    AutoUpdate = "windows_update_auto_install_enabled"              # Automatic update installation enabled flag (0 or 1)
    LastInstall = "windows_update_last_install_timestamp_seconds"   # Timestamp of the last successful update installation
    InstallStatus = "windows_update_install_status"                 # Status of the last update installation attempt (0 for success, 1 for failure)
    UpdateList = "windows_update_available_list"                    # List of available updates with details
    } -Option ReadOnly

# Validate all metric names conform to Prometheus naming standards
# Metric names must start with a letter or underscore, followed by letters, numbers, or underscores
$MetricNames.Values | ForEach-Object {
    if (-not ($_ -match '^[a-zA-Z_:][a-zA-Z0-9_:]*$')) {
        throw "Invalid Prometheus metric name: $_"
    }
}

# Function to write errors to log file and create error metrics for monitoring
function Write-ScriptError {
    param(
        [string]$Message,                   # Error message to log
        [string]$ErrorCode = "unknown",     # Error code for categorization
        [hashtable]$MetricNames,            # Metric names hashtable
        [string]$MetricsFilePath            # Path to write error metrics
    )
    
    # Log the error to PowerShell error stream
    Write-Error $Message
    
    # Create a Prometheus error metric with sanitized error code
    $errorMetric = "$($MetricNames.Error){error=`"$($ErrorCode -replace '"', '')`"} 1"
    try {
        # Write the error metric to the metrics file for Prometheus to collect
        $errorMetric | Out-File -FilePath $MetricsFilePath -Encoding UTF8 -Force
    }
    catch {
        Write-Warning "Failed to write error metric: $_"
    }
}

# Function to execute actions with standardized error handling
function Invoke-WithErrorHandling {
    param(
        [scriptblock]$Action,           # Script block to execute
        [string]$Operation,             # Description of the operation for error messages
        [hashtable]$MetricNames,        # Metric names hashtable
        [string]$MetricsFilePath        # Path to write error metrics
    )
    try {
        # Execute the provided script block
        & $Action
    } catch {
        # Handle any errors by writing to metrics and re-throwing
        Write-ScriptError -Message "Failed to $Operation`: $_" -ErrorCode $Operation.Replace(' ', '_') -MetricNames $MetricNames -MetricsFilePath $MetricsFilePath
        throw
    }
}

# Function to download and install Windows updates
function Install-WindowsUpdate {
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        $Update,                        # Windows Update object to install

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        $UpdateSession,                 # Windows Update session object

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [hashtable]$MetricNames,        # Metric names hashtable

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$SanitizedTitle         # Sanitized update title for metrics
    )
    # Track COM objects for proper cleanup
    $comObjects = @()
    try {
        Write-Information "Installing update: $($Update.Title)" -InformationAction Continue
        
        # Create and configure the update downloader
        $UpdateDownloader = $UpdateSession.CreateUpdateDownloader()
        $comObjects += $UpdateDownloader
        $UpdateDownloader.Updates = New-Object -ComObject Microsoft.Update.UpdateColl
        $comObjects += $UpdateDownloader.Updates
        $UpdateDownloader.Updates.Add($Update) # Add the update to the collection
        $UpdateDownloader.Download() # Download the update files

        # Create and configure the update installer
        $UpdateInstaller = $UpdateSession.CreateUpdateInstaller()
        $UpdateInstaller.Updates = $UpdateDownloader.Updates
        $InstallResult = $UpdateInstaller.Install() # Install the downloaded update
        
        # Generate Unix timestamp for the installation
        $installTimestamp = [Math]::Floor([decimal](Get-Date(Get-Date).ToUniversalTime()-uformat '%s'))
        
        # Create Prometheus metrics for the installation
        $additionalMetrics = @()
        $lastMetric = "# HELP $($MetricNames.LastInstall) Unix timestamp when updates were last installed`n# TYPE $($MetricNames.LastInstall) gauge"
        $additionalMetrics += $lastMetric
        $additionalMetrics += "$($MetricNames.LastInstall){update=`"$SanitizedTitle`"} $installTimestamp"
        $installMetric = "# HELP $($MetricNames.InstallStatus) Status of update installation`n# TYPE $($MetricNames.InstallStatus) gauge"
        $additionalMetrics += $installMetric
        $additionalMetrics += "$($MetricNames.InstallStatus){update=`"$SanitizedTitle`",result=`"$($InstallResult.ResultCode)`"} 1"
        
        Write-Verbose "Installation completed with result: $($InstallResult.ResultCode)"
        
        # Return success result object
        return [PSCustomObject]@{
            Success = $InstallResult.ResultCode -eq 2  # ResultCode 2 = successful installation
            ResultCode = $InstallResult.ResultCode
            UpdateTitle = $Update.Title
            InstallTimestamp = $installTimestamp
            ErrorMessage = $null
            Metrics = $additionalMetrics
        }
    }
    catch {
        $errorMessage = $_.Exception.Message
        Write-Host "Error installing update: $_"
        
        # Return error result object
        return [PSCustomObject]@{
            Success = $false
            ResultCode = -1
            UpdateTitle = $Update.Title
            InstallTimestamp = $null
            ErrorMessage = $errorMessage
            Metrics = @()
        }
    }
    finally {
        # Clean up COM objects to prevent memory leaks
        $comObjects | ForEach-Object {
            if ($_ -ne $null) {
                try {
                    [System.Runtime.InteropServices.Marshal]::ReleaseComObject($_) | Out-Null
                } catch {
                    Write-Warning "Failed to release COM object: $_"
                }
            }
        }
    }
}

# Function to check if a system reboot is required after updates
function Get-RebootStatus {
    try {
        # Check if Windows OS indicates a reboot is pending
        $osRebootPending = (Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop).RebootPending
        # Check Component Based Servicing registry key for pending operations
        $cbsRebootPending = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing' -Name 'RebootPending' -ErrorAction Stop
        # Return 1 if either check indicates reboot required, 0 otherwise
        return [int]($osRebootPending -or $cbsRebootPending)
    }
    catch {
        Write-Warning "Could not determine reboot status: $($_.Exception.Message)"
        return -1  # Return -1 to indicate indeterminate status
    }
}

# Check current reboot status for inclusion in metrics
$rebootRequired = Get-RebootStatus

# Function to sanitize strings for safe use as Prometheus labels
function ConvertTo-SafePrometheusLabel {
    param(
        [Parameter(Mandatory=$false)]
        [AllowEmptyString()]
        [string]$Value        # String value to sanitize
    )
    
    # Return empty string if input is null or empty
    if ([string]::IsNullOrEmpty($Value)) {
        return ""
    }
    
    # Sanitize special characters that could break Prometheus format
    # Replace backslashes, quotes, newlines, tabs, and non-printable characters
    $sanitized = $Value -replace '[\\"\r\n\t]|[^\x20-\x7E]', { 
        switch ($_.Value) {
            '\' { '\\' }     # Escape backslashes
            '"' { '\"' }     # Escape double quotes
            default { if ($_.Value -match '[\r\n\t]') { ' ' } else { '_' } }  # Replace whitespace/unprintable with underscore
        }
    }
    
    # Limit length to prevent excessively long labels (Prometheus best practice)
    return $sanitized.Substring(0, [Math]::Min($sanitized.Length, 256))
}

# Function to build Prometheus metric strings and add them to StringBuilder
function Add-PrometheusMetric {
    param(
        [Parameter(Mandatory)]
        [System.Text.StringBuilder]$StringBuilder,    # StringBuilder to append metrics to
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$MetricName,                         # Name of the Prometheus metric
        [Parameter(Mandatory)]
        [string]$HelpText,                           # Help text describing the metric
        [Parameter(Mandatory)]
        $Value,                                      # Metric value
        [hashtable]$Labels = @{}                     # Optional labels for the metric
    )
    
    try {
        # Add Prometheus HELP comment explaining what the metric measures
        $StringBuilder.AppendLine("# HELP $MetricName $HelpText") | Out-Null
        # Add Prometheus TYPE comment (assuming gauge type for all metrics)
        $StringBuilder.AppendLine("# TYPE $MetricName gauge") | Out-Null
        
        if ($null -ne $Value) {
            # Build label string if labels are provided
            $labelString = ""
            if ($Labels.Count -gt 0) {
                $labelPairs = $Labels.GetEnumerator() | ForEach-Object { 
                    "$($_.Key)=`"$($_.Value)`"" 
                }
                $labelString = "{$($labelPairs -join ',')}"
            }
            # Add the actual metric line with name, labels, and value
            $StringBuilder.AppendLine("$MetricName$labelString $Value") | Out-Null
        }
    }
    catch {
        Write-Error "Failed to add Prometheus metric '$MetricName': $_"
        throw
    }
}

# Function to generate complete Prometheus metrics output
function Get-PrometheusMetrics {
    param(
        [int]$UpdateCount,              # Number of available updates
        [double]$ScriptRunTime,         # Time taken to run the script
        [bool]$rebootRequired,          # Whether a system reboot is required
        [bool]$AutoUpdateEnabled,       # Whether automatic updates are enabled
        [array]$AvailableUpdates = @()  # Array of available update details
    )
    
    # Validate input parameters
    if ($UpdateCount -lt 0) {
        throw "UpdateCount must be non-negative"
    }
    if ($ScriptRunTime -lt 0) {
        throw "ScriptRunTime must be non-negative"
    }

    try {
        # Get current Unix timestamp for the script execution time
        $currentTimestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
    } catch {
        Write-Error "Failed to initialize metrics generation: $_"
        return $null
    }

    # Convert boolean values to integers for Prometheus (0 or 1)
    $rebootValue = [int]$rebootRequired
    $autoUpdateValue = [int]$AutoUpdateEnabled

    # Define all metrics to be generated with their properties
    $metrics = @(
        @{ Name = $MetricNames.Available; Help = "Number of Windows updates available from Microsoft Update"; Value = $UpdateCount }
        @{ Name = $MetricNames.Status; Help = "Indicates if the update script has run successfully"; Value = 1; Labels = @{status="success"; source="microsoft_update"} }
        @{ Name = $MetricNames.Timestamp; Help = "Unix timestamp when the script last ran"; Value = $currentTimestamp }
        @{ Name = $MetricNames.Error; Help = "Information about script errors"; Value = 0 }
        @{ Name = $MetricNames.Reboot; Help = "Indicates if a reboot is required after updates"; Value = $rebootValue }
        @{ Name = $MetricNames.Runtime; Help = "Time taken to execute the update script in seconds"; Value = $ScriptRunTime }
        @{ Name = $MetricNames.AutoUpdate; Help = "Indicates if automatic update installation is enabled"; Value = $autoUpdateValue }
        @{ Name = $MetricNames.LastInstall; Help = "Unix timestamp when updates were last installed"; Value = 0 }
        @{ Name = $MetricNames.InstallStatus; Help = "Status of update installation"; Value = 0 }
    )

    # Build the complete metrics string
    $metricsBuilder = [System.Text.StringBuilder]::new()
    foreach ($metric in $metrics) {
        try {
            Add-PrometheusMetric -StringBuilder $metricsBuilder -MetricName $metric.Name -HelpText $metric.Help -Value $metric.Value -Labels $metric.Labels
        } catch {
            Write-Warning "Failed to add metric '$($metric.Name)': $_"
        }
    }
    
    # Add individual update metrics - one per available update
    if ($AvailableUpdates.Count -gt 0) {
        $metricsBuilder.AppendLine("# HELP $($MetricNames.UpdateList) Individual Windows updates available for installation") | Out-Null
        $metricsBuilder.AppendLine("# TYPE $($MetricNames.UpdateList) gauge") | Out-Null
        
        foreach ($update in $AvailableUpdates) {
            $labels = @{
                title = $update.Title
                severity = $update.Severity
                downloaded = $update.IsDownloaded
                size_bytes = $update.Size
            }
            $labelString = ($labels.GetEnumerator() | ForEach-Object { "$($_.Key)=`"$($_.Value)`"" }) -join ','
            $metricsBuilder.AppendLine("$($MetricNames.UpdateList){$labelString} 1") | Out-Null
        }
    }
    
    return $metricsBuilder.ToString()
}

# Validate that metric names configuration is available
if (-not $MetricNames) {
    Write-Error "MetricNames configuration is not available"
    return $null
}

# Graceful shutdown flag
$global:shutdown = $false

# Register event handler for graceful shutdown
Register-EngineEvent -SourceIdentifier "PowerShell.Exiting" -Action {
    Write-Host "Initiating graceful shutdown..."
    $global:shutdown = $true
}

# Trap Ctrl+C and other termination signals
[Console]::TreatControlCAsInput = $false
Register-ObjectEvent -InputObject ([Console]) -EventName CancelKeyPress -Action {
    param($Sender, $CancelEventArgs)
    Write-Host "Shutdown signal received. Cleaning up..."
    $CancelEventArgs.Cancel = $true
    $global:shutdown = $true
}

# Function to check for shutdown signal during long operations
function Test-ShutdownSignal {
    if ($global:shutdown) {
        Write-Host "Shutdown requested. Exiting gracefully..."
        exit 0
    }
}

# Main execution: Check for Windows Updates from Microsoft Update (direct)
Write-Host "Checking for Windows Updates from Microsoft Update..."
$StartTime = Get-Date
$comObjects = @()
$availableUpdates = @()
try {
    # Check for shutdown before starting
    Test-ShutdownSignal
    
    # Create Windows Update session and searcher COM objects
    $UpdateSession = New-Object -ComObject Microsoft.Update.Session
    $comObjects += $UpdateSession
    $UpdateSearcher = $UpdateSession.CreateUpdateSearcher()
    $comObjects += $UpdateSearcher

    # Register Microsoft Update service to include driver and non-OS updates
    # ServiceID 7971f918-a847-4430-9279-4a52d1efe18d is the Microsoft Update online service
    $ServiceManager = New-Object -ComObject Microsoft.Update.ServiceManager
    $comObjects += $ServiceManager
    try {
        $ServiceManager.AddService2("7971f918-a847-4430-9279-4a52d1efe18d", 7, "") | Out-Null
        Write-Host "Microsoft Update service registered successfully."
    } catch {
        Write-Warning "Microsoft Update service may already be registered: $($_.Exception.Message)"
    }

    # Set the searcher to use Microsoft Update online instead of WSUS
    # ServerSelection: 0 = default, 1 = managed (WSUS), 2 = Windows Update, 3 = others
    $UpdateSearcher.ServerSelection = 2
    # ServiceID for Microsoft Update (includes drivers and other Microsoft products)
    $UpdateSearcher.ServiceID = "7971f918-a847-4430-9279-4a52d1efe18d"

    # Check for shutdown before search
    Test-ShutdownSignal
    
    # Search for updates that are not yet installed
    Write-Host "Searching Microsoft Update (this may take a few minutes)..."
    $SearchResult = $UpdateSearcher.Search("IsInstalled=0")
    
    # Process individual updates for detailed metrics
    foreach ($Update in $SearchResult.Updates) {
        $sanitizedTitle = ConvertTo-SafePrometheusLabel -Value $Update.Title
        $sanitizedDescription = ConvertTo-SafePrometheusLabel -Value $Update.Description
        $updateSize = if ($Update.MaxDownloadSize) { $Update.MaxDownloadSize } else { 0 }
        
        $availableUpdates += [PSCustomObject]@{
            Title = $sanitizedTitle
            Description = $sanitizedDescription
            Size = $updateSize
            Severity = if ($Update.MsrcSeverity) { $Update.MsrcSeverity } else { "Unknown" }
            IsDownloaded = [int]$Update.IsDownloaded
        }
    }
} catch {
    # Handle connection failures and write error metric
    Write-Error "Failed to connect to Microsoft Update service: $_"
    $errorMetric = "$($MetricNames.Error){error=`"microsoft_update_connection_failed`"} 1"
    $errorMetric | Out-File -FilePath $MetricsFilePath -Encoding UTF8 -Force
    exit 1
} finally {
    # Clean up COM objects to prevent memory leaks
    $comObjects | ForEach-Object {
        if ($_ -and [System.Runtime.InteropServices.Marshal]::IsComObject($_)) {
            try {
                [System.Runtime.InteropServices.Marshal]::ReleaseComObject($_) | Out-Null
            } catch {
                Write-Warning "Failed to release COM object: $_"
            }
        }
    }
}

# Calculate script execution time and generate base metrics
$EndTime = Get-Date
$ScriptRunTime = ($EndTime - $StartTime).TotalSeconds
# Safely get update count even if SearchResult is null
$updateCount = if ($SearchResult -and $SearchResult.Updates) { $SearchResult.Updates.Count } else { 0 }
$prometheusMetric = Get-PrometheusMetrics -UpdateCount $updateCount -ScriptRunTime $ScriptRunTime -AutoUpdateEnabled $AutoInstall -AvailableUpdates $availableUpdates

# Initialize array for additional metrics from update installations
$additionalMetrics = @()

# Process the search results
if ($SearchResult.Updates.Count -eq 0) {
    Write-Host "No updates available from Microsoft Update."
} else {
    Write-Host "Found $($SearchResult.Updates.Count) update(s) available:"
    $updateList = [System.Collections.ArrayList]::new()
    
    # Iterate through each available update
    foreach ($Update in $SearchResult.Updates) {
        # Check for shutdown signal between updates
        Test-ShutdownSignal
        
        Write-Host "- $($Update.Title)"
        $sanitizedTitle = ConvertTo-SafePrometheusLabel -Value $Update.Title
        [void]$updateList.Add("title=`"$sanitizedTitle`"")
        
        # Install the update if AutoInstall is enabled
        if ($AutoInstall) {
            # Check for shutdown before installing
            Test-ShutdownSignal
            
            # Accept EULA if required for direct Microsoft Update downloads
            if (-not $Update.EulaAccepted) {
                $Update.AcceptEula()
            }
            
            $result = Install-WindowsUpdate -Update $Update -UpdateSession $UpdateSession -MetricNames $MetricNames -SanitizedTitle $sanitizedTitle
            if ($result.Metrics) {
                $additionalMetrics += $result.Metrics
            }
        }
    }
}

# Write all metrics to the output file for Prometheus collection
try {
    # Final shutdown check before writing metrics
    Test-ShutdownSignal
    
    $allMetrics = @($prometheusMetric) + $additionalMetrics
    ($allMetrics -join "`n") | Out-File -FilePath "$MetricsFilePath" -Encoding UTF8 -Force
    Write-Host "Metrics successfully written to $MetricsFilePath"
} catch {
    # Handle file write errors and create error metric
    Write-Error "Failed to write metrics to file: $_"
    $errorCode = $_.Exception.HResult -replace '"', '\"'
    "$($MetricNames.Error){error=`"file_write_failed`",error_code=`"$errorCode`"} 1" | Out-File -FilePath "$MetricsFilePath" -Encoding UTF8 -Force
}

Write-Host "Script completed successfully."
