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's host 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

# How often every other host is: once, and the Measurement says so in its Samples. No
# Finding judges those, and Measure-TcpConnect waits three seconds for a host that does not
# answer: three attempts each would cost a Run up to a minute and a half for a picture.
# With MaxHosts at five, the default printer's host among them, a Run waits at most
# 9 + 4 x 3 = 21 seconds on hosts that do not answer.
$script:PrintingOtherConnectAttempts = 1

# How many hosts a Run connects to at most, unless the Check Definition says MaxHosts: the
# Gatherer stops there, and the Judge says so of the rest.
$script:PrintingMaxHosts = 5

# 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

# What Get-PrintingData hands its Judge. See Private/DataShape.ps1.
$script:PrintingDataShape = @{
    Days          = 'Answered'
    SpoolerFound  = 'Answered'
    SpoolerState  = 'Value'
    SpoolerMode   = 'Value'
    Printers      = 'List'
    Reachability  = 'Value'
    Hosts         = 'List'
    Jobs          = 'List'
    Errors        = 'List'
    LogUnreadable = 'Value'
    Prefixes      = 'List'
}

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 }
    })

    # Each host once, however many printers it serves, and no more of them than MaxHosts:
    # a machine can list dozens of printers, and a host that does not answer costs each
    # attempt its whole timeout. The default printer's host comes first and is always
    # measured - its Finding is what Office waits on. Those beyond are listed unmeasured.
    $maxHosts = [int](Get-Parameter $Parameters 'MaxHosts' $script:PrintingMaxHosts)
    $hosts    = @(Get-PrintingHost -Printer $printers -Port $ports)
    $hosts    = @(for ($i = 0; $i -lt $hosts.Count; $i++) {
        $one = $hosts[$i]
        if ($one.Default -or $i -lt $maxHosts) { Measure-PrinterReachability -Target $one }
        else {
            [pscustomobject]@{ Via = $one.Via; HostName = $one.HostName; Port = $one.Port; Default = $one.Default; Measured = $false
                               Resolved = $null; Attempts = 0; Successes = 0; AverageMs = $null; Address = $null; Route = $null }
        }
    })

    # The default printer's own reach, for its Finding: its host as measured above, or
    # nothing to connect to.
    $default = $printers | Where-Object { $_.Default } | Select-Object -First 1
    $reach   = $null
    if ($default) {
        $reach = @($hosts | Where-Object { $_.Default }) | Select-Object -First 1
        if (-not $reach) { $reach = [pscustomobject]@{ Via = 'None'; HostName = $null; Port = $null; Resolved = $null; Attempts = 0; Successes = 0; AverageMs = $null } }
    }

    $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
        Hosts         = $hosts
        Jobs          = $jobs
        Errors        = $errors
        LogUnreadable = $unreadable
        # The machine's own addresses with their prefix lengths, for the Zone of what the
        # printers are reached through. See Get-OwnPrefix.
        Prefixes      = @(Get-OwnPrefix)
    }
}

function Get-PrintingHost {
    <#
    .SYNOPSIS
        Every host the printers are reached through, each host once: the default printer's
        first, the rest by name. Pure.
    .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 - 9100 for RAW,
        515 for LPR, as the port says. Anything else - USB, WSD, a PDF writer - has no
        address this can connect to and is not listed. Four queues of one print server,
        or two ports of one printer, are one host.
    .PARAMETER Printer
        The printers as Get-PrintingData reads them.
    .PARAMETER Port
        The TCP/IP printer ports: Name, HostAddress, PortNumber.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Printer,
        [AllowNull()][AllowEmptyCollection()]$Port
    )

    $found = [ordered]@{}
    foreach ($one in @($Printer | Where-Object { $_ })) {
        $hostName = ''
        $number   = $null
        $via      = ''
        $server   = "$(Get-DataProperty $one 'ServerName')".TrimStart('\').Trim()
        if ($server) {
            $hostName = $server
            $number   = $script:PrintingServerPort
            $via      = 'PrintServer'
        }
        else {
            $tcp = @($Port | Where-Object { $_ -and "$($_.Name)" -eq "$(Get-DataProperty $one 'PortName')" }) | Select-Object -First 1
            if ($tcp -and "$($tcp.HostAddress)".Trim()) {
                $hostName = "$($tcp.HostAddress)".Trim()
                $number   = [int]$tcp.PortNumber
                $via      = 'TcpPort'
            }
        }
        if (-not $hostName) { continue }

        # One host, one connect: on the default printer's port where it is the default
        # printer's host - that is the way its Finding is about - else on the lowest, so
        # that every Run asks the same port.
        $key = $hostName.ToLowerInvariant()
        $isDefault = [bool](Get-DataProperty $one 'Default')
        if (-not $found.Contains($key)) {
            $found[$key] = [pscustomobject]@{ Via = $via; HostName = $hostName; Port = $number; Default = $false }
        }
        $known = $found[$key]
        if ($isDefault -and -not $known.Default) { $known.Via = $via; $known.Port = $number; $known.Default = $true }
        elseif (-not $known.Default -and $number -lt $known.Port) { $known.Via = $via; $known.Port = $number }
    }

    # In an order that does not change from one Run to the next, so that the hosts beyond
    # MaxHosts are the same ones each time.
    @($found.Values | Sort-Object @{ Expression = { -not $_.Default } },
        @{ Expression = { $_.HostName.ToLowerInvariant() } }, @{ Expression = { $_.Port } })
}

function Measure-PrinterReachability {
    <#
    .SYNOPSIS
        Connects to one host the printers are reached through, as Get-PrintingHost
        listed it.
    #>

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

    $hostName = $Target.HostName
    $number   = $Target.Port
    $via      = $Target.Via
    $attempts = $script:PrintingOtherConnectAttempts
    if ($Target.Default) { $attempts = $script:PrintingConnectAttempts }

    $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..$attempts | ForEach-Object {
            Measure-TcpConnect -HostName $hostName -Port $number
        } | Where-Object { $null -ne $_ })
    }

    [pscustomobject]@{
        Via       = $via
        HostName  = $hostName
        Port      = $number
        Default   = [bool]$Target.Default
        Measured  = $true
        Resolved  = $resolved
        Attempts  = $attempts
        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 host the printers are reached through, each host and port once.
        Pure.
    .DESCRIPTION
        Each print Server, and each printer with its own TCP/IP port - that is what this
        machine prints to, and is drawn as its print Server. The default printer's host is
        measured for the Finding about the default printer; no Finding judges the others,
        and they name none. A host beyond MaxHosts was not connected to and says so, so
        that the picture shows it and does not show it fast.
 
        Data gathered before every host was measured has the default printer's alone, as
        Reachability; every other print Server the printers name is then not measured,
        with no reason the Gatherer gave.
    .PARAMETER Finding
        The Finding about the default printer.
    #>

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

    $vpnPattern = Get-Parameter $Parameters 'VpnAdapterPattern' $script:MeasurementVpnAdapterPattern
    $maxHosts   = [int](Get-Parameter $Parameters 'MaxHosts' $script:PrintingMaxHosts)

    $hosts   = Get-DataCollection $Data 'Hosts'
    $default = $false
    if ($null -eq (Get-DataProperty $Data 'Hosts')) {
        $reach = Get-DataProperty $Data 'Reachability'
        $hosts = @()
        if ("$(Get-DataProperty $reach 'HostName')" -and "$(Get-DataProperty $reach 'Via')" -ne 'None') { $hosts = @($reach) }
        $default = $true
    }

    $seen = @{}
    foreach ($one in $hosts) {
        $hostName = "$(Get-DataProperty $one 'HostName')"
        if (-not $hostName) { continue }
        $seen[$hostName.ToLowerInvariant()] = $true

        $common = @{ Subject = $hostName; Role = 'PrintServer'; Port = (Get-DataProperty $one 'Port') }
        # The Reachability of older data is the default printer's.
        if ($default -or (Get-DataProperty $one 'Default')) { $common.Finding = $Finding }

        if ($false -eq (Get-DataProperty $one 'Measured')) {
            New-Measurement @common -Metric TcpConnect -State NotMeasured -ErrorText (Get-Text 'Measurement.Error.PrintingCapped' $maxHosts)
            continue
        }
        if (-not [bool](Get-DataProperty $one 'Resolved')) {
            New-Measurement @common -Metric TcpConnect -State Failed -ErrorText (Get-Text 'Measurement.Error.NotResolved')
            continue
        }
        $common.Address = "$(Get-DataProperty $one 'Address')"
        $common.Zone    = Get-GatheredAddressZone -Data $Data -Address $common.Address -Route (Get-DataProperty $one 'Route') -VpnPattern $vpnPattern
        ConvertTo-TcpConnectMeasurement -Common $common -Result $one
    }

    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')
}