Private/Kinds/Printing.ps1

# The Printing Kind: the print spooler, the default printer, and what is stuck in between.
#
# Printing is where "Word is slow" most often turns out not to be Word. Office asks the
# default printer for its capabilities when a document opens, so a default printer on a
# network that is no longer there - the office printer, from home - holds every document
# open for as long as the connection takes to time out. A spooler crashing on a bad driver
# takes every printer with it, and one job stuck at the head of a queue blocks the rest.
#
# Gathered in the Technician's own session, never the Elevated Part: printer connections
# and the default printer belong to the user, and an administrator's session would see a
# different set.

# How often the default printer is connected to. Not a Check Definition parameter, for the
# reason Private/Sampling.ps1 gives about measurements: one connect is not evidence, and a
# Customer may disagree about a threshold, not about how many attempts make a reading.
$script:PrintingConnectAttempts = 3

# Where the print system records what went wrong. Its Admin channel is enabled on every
# Windows and readable without admin rights.
$script:PrintingLog = 'Microsoft-Windows-PrintService/Admin'

# The port a printer shared from a print server is reached through.
$script:PrintingServerPort = 445

function Get-PrintingData {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $days  = [int](Get-Parameter $Parameters 'Days' 30)
    $since = (Get-Date).AddDays(-$days)

    $spooler = Get-CimInstance Win32_Service -Filter "Name='Spooler'" -ErrorAction SilentlyContinue |
        Select-Object -First 1

    $printers = @(Get-CimInstance Win32_Printer -ErrorAction SilentlyContinue | ForEach-Object {
        [pscustomobject]@{
            Name                  = "$($_.Name)"
            Default               = [bool]$_.Default
            Network               = [bool]$_.Network
            WorkOffline           = [bool]$_.WorkOffline
            PrinterStatus         = $_.PrinterStatus
            ExtendedPrinterStatus = $_.ExtendedPrinterStatus
            DetectedErrorState    = $_.DetectedErrorState
            PortName              = "$($_.PortName)"
            ServerName            = "$($_.ServerName)"
            ShareName             = "$($_.ShareName)"
            DriverName            = "$($_.DriverName)"
        }
    })

    $ports = @(Get-CimInstance Win32_TCPIPPrinterPort -ErrorAction SilentlyContinue | ForEach-Object {
        [pscustomobject]@{ Name = "$($_.Name)"; HostAddress = "$($_.HostAddress)"; PortNumber = $_.PortNumber }
    })

    $default = $printers | Where-Object { $_.Default } | Select-Object -First 1
    $reach   = $null
    if ($default) { $reach = Measure-PrinterReachability -Printer $default -Port $ports }

    $now  = Get-Date
    $jobs = @(Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue | ForEach-Object {
        $age = $null
        if ($_.TimeSubmitted -is [datetime]) { $age = [math]::Round(($now - $_.TimeSubmitted).TotalMinutes, 1) }
        [pscustomobject]@{
            # Win32_PrintJob names a job "<printer>, <id>"; the printer is what matters.
            Printer    = ("$($_.Name)" -replace ',\s*\d+$', '')
            # Not what the job is called: that is what a User printed (ADR-0006).
            JobStatus  = "$($_.JobStatus)"
            AgeMinutes = $age
        }
    })

    $refused = @{}
    $errors  = @()
    if (Test-EventLogReadable -Log $script:PrintingLog -Unreadable $refused) {
        try {
            # Level 1 and 2 are Critical and Error. The Admin channel logs little else, but
            # the warnings it does log are about drivers being replaced, which is no problem.
            $errors = @(Get-WinEvent -FilterHashtable @{ LogName = $script:PrintingLog; Level = 1, 2; StartTime = $since } -ErrorAction SilentlyContinue |
                ForEach-Object { ConvertTo-PrintErrorRow -LogEntry $_ })
        }
        catch { }   # "No events matched" arrives as an error too; the log was readable.
    }
    $unreadable = $refused.ContainsKey($script:PrintingLog)

    [pscustomobject]@{
        PSTypeName    = 'Gutcheck.Data.Printing'
        Days          = $days
        SpoolerFound  = $null -ne $spooler
        SpoolerState  = "$($spooler.State)"
        SpoolerMode   = "$($spooler.StartMode)"
        Printers      = $printers
        Reachability  = $reach
        Jobs          = $jobs
        Errors        = $errors
        LogUnreadable = $unreadable
        # The machine's own addresses with their prefix lengths, for the Zone of what the
        # default printer is reached through. See Get-OwnPrefix.
        Prefixes      = @(Get-OwnPrefix)
    }
}

function Measure-PrinterReachability {
    <#
    .SYNOPSIS
        Connects to whatever the default printer is reached through, if anything.
    .DESCRIPTION
        A printer shared from a print server is reached through the server, over SMB. A
        printer with its own TCP/IP port is reached directly, on that port. Anything else -
        USB, WSD, a PDF writer - has no address this can connect to, and says so rather
        than reporting a connection nobody made.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$Printer,
        [AllowEmptyCollection()][object[]]$Port = @()
    )

    $hostName = $null
    $number   = $null
    $via      = 'None'

    if ($Printer.ServerName) {
        $hostName = $Printer.ServerName.TrimStart('\')
        $number   = $script:PrintingServerPort
        $via      = 'PrintServer'
    }
    else {
        $tcp = @($Port | Where-Object { $_.Name -eq $Printer.PortName }) | Select-Object -First 1
        if ($tcp -and $tcp.HostAddress) {
            $hostName = $tcp.HostAddress
            $number   = [int]$tcp.PortNumber
            $via      = 'TcpPort'
        }
    }

    if (-not $hostName) {
        return [pscustomobject]@{ Via = $via; HostName = $null; Port = $null; Resolved = $null; Attempts = 0; Successes = 0; AverageMs = $null }
    }

    $resolved = $true
    $address  = $hostName
    if ($hostName -notmatch '^\d{1,3}(\.\d{1,3}){3}$') {
        $resolution = Resolve-HostAddress -HostName $hostName
        $resolved = [bool]$resolution.Resolved
        $address  = "$($resolution.Address)"
    }

    $times = @()
    $route = $null
    if ($resolved) {
        # The adapter the way leaves through, which the Judge decides the Zone by.
        $route = Get-AddressRoute -Address $address
        $times = @(1..$script:PrintingConnectAttempts | ForEach-Object {
            Measure-TcpConnect -HostName $hostName -Port $number
        } | Where-Object { $null -ne $_ })
    }

    [pscustomobject]@{
        Via       = $via
        HostName  = $hostName
        Port      = $number
        Resolved  = $resolved
        Attempts  = $script:PrintingConnectAttempts
        Successes = $times.Count
        AverageMs = $(if ($times.Count) { [math]::Round(($times | Measure-Object -Average).Average, 1) } else { $null })
        Address   = $address
        Route     = $route
    }
}

function ConvertTo-PrintingFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{}
    )

    if (-not (Get-DataProperty $Data 'SpoolerFound')) {
        return New-UnavailableFinding -Category System -Check (Get-Text 'Check.Printing.Spooler') `
            -Hint (Get-Text 'Hint.Printing.SpoolerNotFound')
    }

    if ((Get-DataProperty $Data 'SpoolerState') -ne 'Running') {
        # Nothing below means anything without the spooler: every printer reads as absent.
        return New-Finding -Category System -Check (Get-Text 'Check.Printing.Spooler') -Severity FAIL `
            -Value ((Get-Text 'Value.Printing.SpoolerState') -f (Get-ServiceStateText -State (Get-DataProperty $Data 'SpoolerState')),
                    (ConvertTo-ServiceStartModeText -StartMode (Get-DataProperty $Data 'SpoolerMode'))) `
            -Hint (Get-Text 'Hint.Printing.SpoolerNotRunning')
    }
    New-Finding -Category System -Check (Get-Text 'Check.Printing.Spooler') -Severity OK -Value (Get-Text 'Value.Printing.Running')

    $default = New-DefaultPrinterFinding -Data $Data -Parameters $Parameters
    $default
    New-OfflinePrinterFinding  -Data $Data -Parameters $Parameters
    New-StuckPrintJobFinding   -Data $Data -Parameters $Parameters
    New-PrintErrorFinding      -Data $Data -Parameters $Parameters

    # And the time to the print Servers, for the Report's picture of the network. ADR-0009.
    ConvertTo-PrintingMeasurement -Data $Data -Parameters $Parameters -Finding $default
}

function ConvertTo-PrintingMeasurement {
    <#
    .SYNOPSIS
        The time to each print Server, as the Finding about the default printer measured
        it. Pure.
    .DESCRIPTION
        Only what the default printer is reached through is connected to: its print
        Server, or the printer itself where it has its own TCP/IP port - that is what this
        machine prints to, and is drawn as its print Server. Every other print Server the
        printers name is said as not measured, with no Finding, so that the picture shows
        it and does not show it fast.
    .PARAMETER Finding
        The Finding about the default printer.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [hashtable]$Parameters = @{}, [AllowNull()]$Finding)

    $reach    = Get-DataProperty $Data 'Reachability'
    $measured = ''
    $hostName = "$(Get-DataProperty $reach 'HostName')"
    if ($hostName -and "$(Get-DataProperty $reach 'Via')" -ne 'None') {
        $measured = $hostName
        $resolved = [bool](Get-DataProperty $reach 'Resolved')
        $address  = ''
        $zone     = ''
        if ($resolved) {
            $address = "$(Get-DataProperty $reach 'Address')"
            $vpnPattern = Get-Parameter $Parameters 'VpnAdapterPattern' $script:MeasurementVpnAdapterPattern
            $zone = Get-GatheredAddressZone -Data $Data -Address $address -Route (Get-DataProperty $reach 'Route') -VpnPattern $vpnPattern
        }
        $common = @{ Subject = $hostName; Address = $address; Zone = $zone; Role = 'PrintServer'
                     Port = (Get-DataProperty $reach 'Port'); Finding = $Finding }
        if (-not $resolved) { New-Measurement @common -Metric TcpConnect -State Failed -ErrorText (Get-Text 'Measurement.Error.NotResolved') }
        else { ConvertTo-TcpConnectMeasurement -Common $common -Result $reach }
    }

    $seen = @{}
    if ($measured) { $seen[$measured.ToLowerInvariant()] = $true }
    foreach ($printer in (Get-DataCollection $Data 'Printers')) {
        $server = "$(Get-DataProperty $printer 'ServerName')".TrimStart('\').Trim()
        if (-not $server -or $seen.ContainsKey($server.ToLowerInvariant())) { continue }
        $seen[$server.ToLowerInvariant()] = $true
        New-Measurement -Subject $server -Role PrintServer -Metric TcpConnect -Port $script:PrintingServerPort -State NotMeasured
    }
}

function Test-PrinterOffline {
    <#
    .SYNOPSIS
        Whether a printer says it cannot print, by any of the three ways Windows says it.
    .DESCRIPTION
        PrinterStatus and ExtendedPrinterStatus 7 are Offline; ExtendedPrinterStatus 9 is
        Error. WorkOffline is the "use printer offline" switch a user or a driver flipped.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)]$Printer)

    if (Get-DataProperty $Printer 'WorkOffline') { return $true }
    $status   = ConvertTo-Number (Get-DataProperty $Printer 'PrinterStatus')
    $extended = ConvertTo-Number (Get-DataProperty $Printer 'ExtendedPrinterStatus')
    ($status -eq 7) -or ($extended -eq 7) -or ($extended -eq 9)
}

function New-DefaultPrinterFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warnMs = Get-Parameter $Parameters 'PrinterConnectWarnMs' 200
    $failMs = Get-Parameter $Parameters 'PrinterConnectFailMs' ([double]::MaxValue)

    $check   = Get-Text 'Check.Printing.DefaultPrinter'
    $default = @((Get-DataCollection $Data 'Printers') | Where-Object { $_.Default }) | Select-Object -First 1

    if (-not $default) {
        return New-Finding -Category System -Check $check -Severity INFO -Value (Get-Text 'Value.Shared.None') `
            -Hint (Get-Text 'Hint.Printing.NoDefaultPrinter')
    }

    $offline = 'OK'
    if (Test-PrinterOffline -Printer $default) { $offline = 'WARN' }

    $reach = Get-DataProperty $Data 'Reachability'
    $via   = Get-DataProperty $reach 'Via'

    if (-not $reach -or $via -eq 'None') {
        # USB, WSD, a PDF writer: nothing to connect to, so the status is all there is.
        $value = (Get-Text 'Value.Printing.DefaultLocal') -f $default.Name, $default.PortName
        if ($offline -ne 'OK') { $value = '{0} | {1}' -f $value, (Get-Text 'Value.Printing.Reason.Offline') }
        return New-Finding -Category System -Check $check -Severity $offline -Value $value `
            -Hint (Get-Text 'Hint.Printing.DefaultOffline')
    }

    $target    = '{0}:{1}' -f (Get-DataProperty $reach 'HostName'), (Get-DataProperty $reach 'Port')
    $successes = ConvertTo-Number (Get-DataProperty $reach 'Successes')
    $attempts  = ConvertTo-Number (Get-DataProperty $reach 'Attempts')

    if (-not (Get-DataProperty $reach 'Resolved') -or -not $successes) {
        # The case this Check exists for: every document Office opens waits on this.
        return New-Finding -Category System -Check $check -Severity FAIL `
            -Value ((Get-Text 'Value.Printing.DefaultUnreachable') -f $default.Name, $target) `
            -Hint (Get-Text 'Hint.Printing.DefaultUnreachable')
    }

    $average = ConvertTo-Number (Get-DataProperty $reach 'AverageMs')
    $missed  = 'OK'
    if ($successes -lt $attempts) { $missed = 'WARN' }
    $slow = 'OK'
    if ($null -ne $average) { $slow = Get-Severity $average $warnMs $failMs }

    # What is wrong with it, said. The Value used to read "... Verbindung Mittel 1 ms (3/3
    # ok)" under a WARN, and the Hint "slow, unreliable or offline": a Technician could not
    # tell which, and here it was none of the first two.
    $reasons = @()
    $hints   = @()
    if ($offline -ne 'OK') {
        $reasons += Get-Text 'Value.Printing.Reason.Offline'
        $hints   += Get-Text 'Hint.Printing.DefaultOffline'
    }
    if ($missed -ne 'OK') { $reasons += Get-Text 'Value.Printing.Reason.Missed' ($attempts - $successes) $attempts }
    if ($slow -ne 'OK')   { $reasons += Get-Text 'Value.Printing.Reason.Slow' $average }
    if ($missed -ne 'OK' -or $slow -ne 'OK') { $hints += Get-Text 'Hint.Printing.DefaultSlow' }

    $value = (Get-Text 'Value.Printing.DefaultReachable') -f $default.Name, $target, $average, $successes, $attempts
    if ($reasons.Count) { $value = '{0} | {1}' -f $value, ($reasons -join ' | ') }

    New-Finding -Category System -Check $check -Severity (Get-WorstSeverity $offline $missed $slow) `
        -Value $value -Hint ($hints -join ' | ')
}

function New-OfflinePrinterFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'OfflinePrinterWarnAbove' 2
    $fail = Get-Parameter $Parameters 'OfflinePrinterFailAbove' ([double]::MaxValue)

    # The default printer has its own Finding; counting it here too would say it twice.
    $offline = @((Get-DataCollection $Data 'Printers') |
        Where-Object { -not $_.Default -and (Test-PrinterOffline -Printer $_) })

    $check = Get-Text 'Check.Printing.OfflinePrinters'
    if (-not $offline.Count) {
        return New-Finding -Category System -Check $check -Severity OK -Value (Get-Text 'Value.Shared.None')
    }

    # Counted as devices. One printer is often several queues on its print server - one
    # for each tray, one for duplex - whose ports are the printer's address with _1, _2
    # behind it, and four queues of one printer are one printer that reports offline.
    $devices = @($offline | Group-Object { '{0}|{1}' -f "$($_.ServerName)".ToLowerInvariant(), ("$($_.PortName)" -replace '_\d{1,2}$', '').ToLowerInvariant() } |
        ForEach-Object {
            $name = @($_.Group | ForEach-Object { "$($_.Name)" } | Sort-Object { $_.Length }, { $_ })[0]
            [pscustomobject]@{ Name = $name; Queues = $_.Count; OnServer = [bool]"$($_.Group[0].ServerName)" }
        } | Sort-Object Name)

    $severity = Get-Severity $devices.Count $warn $fail
    if ($severity -eq 'OK') { $severity = 'INFO' }

    $named = @($devices | ForEach-Object {
        if ($_.Queues -gt 1) { (Get-Text 'Value.Printing.OfflineQueues') -f $_.Name, $_.Queues } else { $_.Name }
    })

    # "Offline" is what Windows was told, and by whom differs: a print server says it of
    # a printer that does not answer its SNMP query, whether or not the printer prints.
    # That a printer is offline does not make it one nobody uses.
    $meaning = @()
    if (@($devices | Where-Object { $_.OnServer }).Count)      { $meaning += Get-Text 'Meaning.Printing.OfflineOnServer' }
    if (@($devices | Where-Object { -not $_.OnServer }).Count) { $meaning += Get-Text 'Meaning.Printing.OfflineLocal' }

    New-Finding -Category System -Check $check -Severity $severity `
        -Value ((Get-Text 'Value.Printing.OfflinePrinters') -f $devices.Count, ($named -join ', ')) `
        -Meaning ($meaning -join ' | ') -Hint (Get-Text 'Hint.Printing.OfflinePrinters') `
        -Signal 'printer-offline' `
        -Reference @((New-Reference -Signal 'print-job-stuck'), (New-Reference -Section 'Title.Printing.Printers'))
}

function Format-PrintJobAge {
    <#
    .SYNOPSIS
        How long a job has been lying in a queue, in the unit that says it best. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([double]$Minutes)

    if ($Minutes -ge 2880) { return (Get-Text 'Value.Printing.Age.Days') -f [math]::Floor($Minutes / 1440) }
    if ($Minutes -ge 120)  { return (Get-Text 'Value.Printing.Age.Hours') -f [math]::Floor($Minutes / 60) }
    (Get-Text 'Value.Printing.Age.Minutes') -f [math]::Floor($Minutes)
}

function New-StuckPrintJobFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $minutes = Get-Parameter $Parameters 'PrintJobStuckMinutes'  10
    $warn    = Get-Parameter $Parameters 'StuckPrintJobWarnAbove' 0
    $fail    = Get-Parameter $Parameters 'StuckPrintJobFailAbove' 10

    $stuck = @((Get-DataCollection $Data 'Jobs') | Where-Object {
        $age = ConvertTo-Number $_.AgeMinutes
        $null -ne $age -and $age -gt $minutes
    })

    $check = Get-Text 'Check.Printing.StuckJobs'
    if (-not $stuck.Count) {
        return New-Finding -Category System -Check $check -Severity OK -Value (Get-Text 'Value.Shared.None')
    }

    $printers = ($stuck | ForEach-Object { $_.Printer } | Sort-Object -Unique) -join ', '
    $oldest   = ($stuck | ForEach-Object { ConvertTo-Number $_.AgeMinutes } | Measure-Object -Maximum).Maximum

    # A job in a print server's queue is not this machine's to restart a service over.
    $hint = Get-Text 'Hint.Printing.StuckJobs'
    if (@($stuck | Where-Object { "$($_.Printer)".StartsWith('\\') }).Count) { $hint = Get-Text 'Hint.Printing.StuckJobsOnServer' }

    New-Finding -Category System -Check $check -Severity (Get-Severity $stuck.Count $warn $fail) `
        -Value ((Get-Text 'Value.Printing.StuckJobs') -f $stuck.Count, $minutes, $printers, (Format-PrintJobAge -Minutes $oldest)) `
        -Meaning (Get-Text 'Meaning.Printing.StuckJobs') -Hint $hint `
        -Signal 'print-job-stuck' `
        -Reference @((New-Reference -Signal 'printer-offline'), (New-Reference -Section 'Title.Printing.Jobs'))
}

function New-PrintErrorFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'PrintErrorWarnAbove' 10
    $fail = Get-Parameter $Parameters 'PrintErrorFailAbove' ([double]::MaxValue)

    $check = Get-Text 'Check.Printing.Errors'
    if (Get-DataProperty $Data 'LogUnreadable') {
        return New-UnavailableFinding -Category System -Check $check -Hint (Get-Text 'Hint.Printing.LogUnreadable')
    }

    $errors = Get-DataCollection $Data 'Errors'
    $days   = Get-DataProperty $Data 'Days'
    if (-not $errors.Count) {
        return New-Finding -Category System -Check $check -Severity OK -Value ((Get-Text 'Value.Printing.NoErrors') -f $days)
    }

    # The most frequent event id is the lead, and is said with what it means.
    $common = $errors | Group-Object Id | Sort-Object Count -Descending | Select-Object -First 3 |
        ForEach-Object {
            $id = Format-CodeWithMeaning -Code ('ID {0}' -f $_.Name) -Meaning (Get-PrintErrorIdText -Id $_.Name)
            (Get-Text 'Value.Printing.ErrorCount') -f $id, $_.Count
        }

    # Which printers, the one with the most errors first: that is whose driver to look at.
    $printers = @($errors | Group-Object { "$(Get-DataProperty $_ 'Printer')" } |
        Sort-Object @{ Expression = 'Count'; Descending = $true }, Name | Select-Object -First 5 | ForEach-Object {
            $name = $_.Name
            if (-not $name) { $name = Get-Text 'Value.Printing.ErrorNoPrinter' }
            '{0} {1}x' -f $name, $_.Count
        })
    $value = (Get-Text 'Value.Printing.Errors') -f $errors.Count, $days, ($common -join ', ')
    if ($printers.Count) { $value = '{0} | {1}' -f $value, ((Get-Text 'Value.Printing.ErrorPrinters') -f ($printers -join '; ')) }

    New-Finding -Category System -Check $check -Severity (Get-Severity $errors.Count $warn $fail) `
        -Value $value -Hint (Get-Text 'Hint.Printing.Errors')
}

function Get-PrintErrorIdText {
    <#
    .SYNOPSIS
        What an error event of the print service means. Nothing for one not known here,
        which is then shown as the number it is. Pure.
    .DESCRIPTION
        Not a table of Microsoft's: read off the messages the Microsoft-Windows-PrintService
        provider carries for its events ((Get-WinEvent -ListProvider ...).Events), each
        error by its own message. The numbers between them are events that are no error -
        a driver added, a printer published - and have no meaning here, as they are not
        among what is counted.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Id)

    $number = ConvertTo-Number $Id
    if ($null -eq $number) { return '' }
    switch ([int]$number) {
        808 { return (Get-Text 'Value.Printing.ErrorId.DriverLoad') }
        372 { return (Get-Text 'Value.Printing.ErrorId.DocumentFailed') }
        314 { return (Get-Text 'Value.Printing.ErrorId.Timeout') }
        315 { return (Get-Text 'Value.Printing.ErrorId.ShareFailed') }
        371 { return (Get-Text 'Value.Printing.ErrorId.UnshareFailed') }
        318 { return (Get-Text 'Value.Printing.ErrorId.SettingsReset') }
        319 { return (Get-Text 'Value.Printing.ErrorId.DriverMissing') }
        320 { return (Get-Text 'Value.Printing.ErrorId.PortMissing') }
        { $_ -in 322, 323, 325, 326, 327, 328, 329, 331, 333, 335, 337 } { return (Get-Text 'Value.Printing.ErrorId.DirectoryPublish') }
        { $_ -in 348, 351 } { return (Get-Text 'Value.Printing.ErrorId.DriverIncompatible') }
        350 { return (Get-Text 'Value.Printing.ErrorId.SpoolFileCorrupt') }
        353 { return (Get-Text 'Value.Printing.ErrorId.NoRights') }
        354 { return (Get-Text 'Value.Printing.ErrorId.InitFailed') }
        360 { return (Get-Text 'Value.Printing.ErrorId.ColorProfile') }
        361 { return (Get-Text 'Value.Printing.ErrorId.PortInit') }
        362 { return (Get-Text 'Value.Printing.ErrorId.OwnNameNotResolved') }
        363 { return (Get-Text 'Value.Printing.ErrorId.SpoolerStart') }
        { $_ -in 364, 365, 367 } { return (Get-Text 'Value.Printing.ErrorId.PrintProcessor') }
        366 { return (Get-Text 'Value.Printing.ErrorId.SecurityDescriptor') }
        { $_ -in 368, 369, 370 } { return (Get-Text 'Value.Printing.ErrorId.DriverPackage') }
        373 { return (Get-Text 'Value.Printing.ErrorId.GdiObjects') }
    }
    ''
}

# Where Windows puts the printer in the payload of a print error, by event id, counted
# from 0. Read off the texts of the provider's own events (Get-WinEvent -ListProvider
# Microsoft-Windows-PrintService, German Windows 11 26300, 2026-10-11): "Das Dokument %1
# im Besitz von %2 konnte nicht auf dem Drucker %3 gedruckt werden" is 372.
$script:PrintErrorPrinterIndex = @{
    372 = 2; 314 = 3
    318 = 0; 319 = 0; 320 = 0; 359 = 0; 360 = 0; 361 = 0; 367 = 0
    315 = 1; 371 = 1
}

# The events whose text names the document and whose it was. Their text is not kept.
$script:PrintErrorNamesDocument = @(314, 350, 372)

function ConvertTo-PrintErrorRow {
    <#
    .SYNOPSIS
        One print error, reduced to when, which event, which printer, and what Windows
        said - unless what it said names a document. Pure.
    .DESCRIPTION
        What was printed and by whom is what a User did, and nothing a Report holds
        (ADR-0006). Windows writes both into the text of a failed print; that text is
        dropped, and the printer is taken from the payload by its place.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)]$LogEntry)

    $id      = [int]$LogEntry.Id
    $printer = ''
    if ($script:PrintErrorPrinterIndex.ContainsKey($id)) {
        $printer = "$(Get-EventPayload -LogEntry $LogEntry -Index $script:PrintErrorPrinterIndex[$id])".Trim()
    }
    $row = ConvertTo-EventRow -LogEntry $LogEntry -Tag 'Print error'
    if ($script:PrintErrorNamesDocument -contains $id) { $row.Message = '' }
    $row | Add-Member -NotePropertyName 'Printer' -NotePropertyValue $printer -PassThru
}

function ConvertTo-PrintingSection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    New-Section -Title (Get-Text 'Title.Printing.Printers') -Help (Get-Text 'Help.Printing.Printers') -Row @(
        (Get-DataCollection $Data 'Printers') | ForEach-Object {
            $row = [ordered]@{}
            $row[(Get-Text 'Column.Printing.Name')]        = $_.Name
            $row[(Get-Text 'Column.Printing.Default')]     = Get-TriStateText ([bool]$_.Default)
            $row[(Get-Text 'Column.Printing.Network')]     = Get-TriStateText ([bool]$_.Network)
            $row[(Get-Text 'Column.Printing.WorkOffline')] = Get-TriStateText ([bool]$_.WorkOffline)
            # What the two numbers mean, and the numbers: a code is never shown bare.
            $row[(Get-Text 'Column.Printing.Status')]      = Get-PrinterStatusText -PrinterStatus $_.PrinterStatus -ExtendedPrinterStatus $_.ExtendedPrinterStatus
            $row[(Get-Text 'Column.Printing.Port')]        = $_.PortName
            $row[(Get-Text 'Column.Printing.Server')]      = $_.ServerName
            $row[(Get-Text 'Column.Printing.Driver')]      = $_.DriverName
            [pscustomobject]$row
        }
    )
    New-Section -Title (Get-Text 'Title.Printing.Jobs') -Help (Get-Text 'Help.Printing.Jobs') -Row @(
        (Get-DataCollection $Data 'Jobs') | ForEach-Object {
            $age = ConvertTo-Number (Get-DataProperty $_ 'AgeMinutes')
            $row = [ordered]@{}
            $row[(Get-Text 'Column.Printing.Name')]         = Get-DataProperty $_ 'Printer'
            # In Windows' own words and language: the spooler hands it over as text.
            $row[(Get-Text 'Column.Printing.Job.Status')]   = Get-DataProperty $_ 'JobStatus'
            $row[(Get-Text 'Column.Printing.Job.Age')]      = $(if ($null -ne $age) { Format-PrintJobAge -Minutes $age } else { '' })
            [pscustomobject]$row
        }
    )

    # The errors themselves, so that the Finding can point here and not at a file.
    $errors = @((Get-DataCollection $Data 'Errors') | Sort-Object { ConvertTo-DataTime (Get-DataProperty $_ 'Time') } -Descending | Select-Object -First 200)
    if ($errors.Count) {
        New-Section -Title (Get-Text 'Title.Printing.Errors') -Help (Get-Text 'Help.Printing.Errors') -Row @(
            $errors | ForEach-Object {
                $id  = Get-DataProperty $_ 'Id'
                $row = [ordered]@{}
                $row[(Get-Text 'Column.Printing.Error.Time')]    = Get-DataProperty $_ 'Time'
                # The number with what it means: a code is never shown bare.
                $row[(Get-Text 'Column.Printing.Error.Event')]   = Format-CodeWithMeaning -Code ('ID {0}' -f $id) -Meaning (Get-PrintErrorIdText -Id $id)
                $row[(Get-Text 'Column.Printing.Name')]          = Get-DataProperty $_ 'Printer'
                $row[(Get-Text 'Column.Printing.Error.Message')] = Get-DataProperty $_ 'Message'
                [pscustomobject]$row
            }
        )
    }
}

function Get-PrinterStatusText {
    <#
    .SYNOPSIS
        What Windows reports of a printer, in a word, with the two numbers behind it. Pure.
    .DESCRIPTION
        Win32_Printer has two status properties, and the extended one says more: 3 idle,
        4 printing, 7 offline, 8 paused, 9 error and so on, as Microsoft documents them
        for that class. Where the extended one is "unknown" or absent, the plain one
        (3 idle, 4 printing, 7 offline) is what there is.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$PrinterStatus, [AllowNull()]$ExtendedPrinterStatus)

    $plain    = ConvertTo-Number $PrinterStatus
    $extended = ConvertTo-Number $ExtendedPrinterStatus
    $read = $extended
    if ($null -eq $read -or $read -in 1, 2) { if ($null -ne $plain -and $plain -notin 1, 2) { $read = $plain } }

    $text = switch ($read) {
        2  { Get-Text 'Value.Printing.Status.Unknown' }
        3  { Get-Text 'Value.Printing.Status.Idle' }
        4  { Get-Text 'Value.Printing.Status.Printing' }
        5  { Get-Text 'Value.Printing.Status.WarmingUp' }
        6  { Get-Text 'Value.Printing.Status.Stopped' }
        7  { Get-Text 'Value.Printing.Status.Offline' }
        8  { Get-Text 'Value.Printing.Status.Paused' }
        9  { Get-Text 'Value.Printing.Status.Error' }
        10 { Get-Text 'Value.Printing.Status.Busy' }
        11 { Get-Text 'Value.Printing.Status.NotAvailable' }
        12 { Get-Text 'Value.Printing.Status.Waiting' }
        13 { Get-Text 'Value.Printing.Status.Processing' }
        14 { Get-Text 'Value.Printing.Status.Initializing' }
        15 { Get-Text 'Value.Printing.Status.PowerSave' }
        16 { Get-Text 'Value.Printing.Status.PendingDeletion' }
        17 { Get-Text 'Value.Printing.Status.IoActive' }
        18 { Get-Text 'Value.Printing.Status.ManualFeed' }
        default { Get-Text 'Value.Printing.Status.Other' }
    }
    (Get-Text 'Value.Printing.Status') -f $text, ('{0}/{1}' -f $PrinterStatus, $ExtendedPrinterStatus)
}

function ConvertTo-PrintingEvent {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    (Get-DataCollection $Data 'Errors')
}