Private/Kinds/StabilityHardware.ps1

# What the logs say of the hardware: disk errors, hardware errors, live kernel events and
# blue screens, with what their codes mean. A part of the Stability Kind, and no Kind.

function Get-DiskErrorDevice {
    <#
    .SYNOPSIS
        Which device a disk or file system event is about, read out of its message:
        a disk by number, a volume by letter, or unknown. Pure.
    .DESCRIPTION
        By what does not change with the language of Windows: the device path
        (\Device\Harddisk1\DR3), the number before "(PDO", and a drive letter in
        quotation marks or before a colon. A controller event names a port and no disk,
        and is unknown.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyString()][string]$Message)

    $text = "$Message"
    if ($text -match '\\Device\\Harddisk(\d+)\\')      { return [pscustomobject]@{ Kind = 'Disk';   Id = $Matches[1] } }
    if ($text -match '"?(\d+)"?\s*\(PDO')              { return [pscustomobject]@{ Kind = 'Disk';   Id = $Matches[1] } }
    if ($text -match '\\Device\\HarddiskVolume\d+')    { return [pscustomobject]@{ Kind = 'Unknown'; Id = '' } }
    if ($text -match '(?<![A-Za-z])([A-Za-z]):(?!\\\\|//)') { return [pscustomobject]@{ Kind = 'Volume'; Id = ('{0}:' -f $Matches[1].ToUpperInvariant()) } }
    [pscustomobject]@{ Kind = 'Unknown'; Id = '' }
}

function Group-DiskError {
    <#
    .SYNOPSIS
        The disk errors by the device they are about: how many, from when to when,
        whether the machine still has that device, and where it belongs. Pure.
    .DESCRIPTION
        -Disks is what Get-StabilityCurrentDisk gathered. A disk whose number, or a
        volume whose letter, the machine does not have now is gone - unplugged. Data from
        before that was gathered knows no device to be gone.
 
        Place is where the errors are reported:
          Internal a drive the machine has, and that is not on USB or removable
          External a drive on USB or removable, and one that is gone: a built-in drive
                    does not leave
          Unplaced the event names no device, or nothing says which drives there are.
                    It may be a built-in drive, and is graded as one.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$ErrorRow, [AllowNull()]$Disks)

    $known   = [bool](Get-DataProperty $Disks 'Gathered')
    $numbers = @((Get-DataCollection $Disks 'Numbers') | ForEach-Object { "$_" })
    $volumes = @((Get-DataCollection $Disks 'Volumes') | ForEach-Object { "$_".ToUpperInvariant() })
    $externalNumbers = @((Get-DataCollection $Disks 'External') | ForEach-Object { "$_" })
    $externalVolumes = @((Get-DataCollection $Disks 'ExternalVolumes') | ForEach-Object { "$_".ToUpperInvariant() })

    $order  = [System.Collections.Generic.List[string]]::new()
    $groups = @{}
    foreach ($row in @($ErrorRow | Where-Object { $_ })) {
        $device = Get-DiskErrorDevice -Message "$(Get-DataProperty $row 'Message')"
        $key = '{0}|{1}' -f $device.Kind, $device.Id
        if (-not $groups.ContainsKey($key)) {
            $gone = $false
            if ($known -and $device.Kind -eq 'Disk')   { $gone = $numbers -notcontains $device.Id }
            if ($known -and $device.Kind -eq 'Volume') { $gone = $volumes -notcontains $device.Id }

            $place = 'Unplaced'
            if ($known -and $device.Kind -ne 'Unknown') {
                $place = 'Internal'
                if ($gone) { $place = 'External' }
                elseif ($device.Kind -eq 'Disk'   -and $externalNumbers -contains $device.Id) { $place = 'External' }
                elseif ($device.Kind -eq 'Volume' -and $externalVolumes -contains $device.Id) { $place = 'External' }
            }
            $order.Add($key)
            $groups[$key] = [pscustomobject]@{ Kind = $device.Kind; Id = $device.Id; Count = 0; First = $null; Last = $null; Gone = [bool]$gone; Place = $place }
        }
        $group = $groups[$key]
        $group.Count++
        $time = ConvertTo-DataTime (Get-DataProperty $row 'Time')
        if ($null -ne $time) {
            if ($null -eq $group.First -or $time -lt $group.First) { $group.First = $time }
            if ($null -eq $group.Last  -or $time -gt $group.Last)  { $group.Last  = $time }
        }
    }
    foreach ($key in $order) { $groups[$key] }
}

function Get-DiskErrorModel {
    <#
    .SYNOPSIS
        What the drive with a disk number was when the errors happened: model and serial
        number, from what the Elevated Part read. Pure; an empty string when nothing says.
    .DESCRIPTION
        A disk number is given to whatever is plugged in next. So of the drives seen
        under that number, the one seen last before the errors ended is taken, and where
        none was seen before, the first after.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()]$History, [string]$DiskNumber, [AllowNull()]$Until)

    $mine = @(@($History | Where-Object { $_ }) | Where-Object { "$(Get-DataProperty $_ 'DiskNumber')" -eq "$DiskNumber" } | ForEach-Object {
        [pscustomobject]@{ Row = $_; Time = ConvertTo-DataTime (Get-DataProperty $_ 'Time') }
    } | Where-Object { $null -ne $_.Time })
    if (-not $mine.Count) { return '' }

    $picked = $null
    foreach ($entry in $mine) {
        if ($null -ne $Until -and $entry.Time -gt ([datetime]$Until).AddMinutes(1)) { continue }
        if ($null -eq $picked -or $entry.Time -gt $picked.Time) { $picked = $entry }
    }
    if ($null -eq $picked) {
        foreach ($entry in $mine) { if ($null -eq $picked -or $entry.Time -lt $picked.Time) { $picked = $entry } }
    }
    $name = ('{0} {1}' -f (Get-DataProperty $picked.Row 'Manufacturer'), (Get-DataProperty $picked.Row 'Model')).Trim()
    $serial = "$(Get-DataProperty $picked.Row 'SerialNumber')".Trim()
    if ($name -and $serial) { return (Get-Text 'Value.Stability.DiskError.ModelSerial') -f $name, $serial }
    $name
}

function Get-DiskErrorGroupName {
    <#
    .SYNOPSIS
        What a group of disk errors is about, as a Finding and the table name it: the
        disk or volume, with model and serial number where the Elevated Part saw the
        drive attached, and that it is gone where it is. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Group, [AllowNull()][AllowEmptyCollection()]$History)

    $name = switch ($Group.Kind) {
        'Disk'   { (Get-Text 'Value.Stability.DiskError.Disk') -f $Group.Id }
        'Volume' { (Get-Text 'Value.Stability.DiskError.Volume') -f $Group.Id }
        default  { Get-Text 'Value.Stability.DiskError.Unknown' }
    }
    if ($Group.Kind -eq 'Disk') {
        $model = Get-DiskErrorModel -History $History -DiskNumber $Group.Id -Until $Group.Last
        if ($model) { $name = (Get-Text 'Value.Stability.DiskError.Named') -f $name, $model }
    }
    if ($Group.Gone) { $name = (Get-Text 'Value.Stability.DiskError.Gone') -f $name }
    $name
}

function Get-DiskErrorValue {
    <#
    .SYNOPSIS
        The Value of a disk error Finding: how many events in the period, and per device
        how many and when. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Data, [AllowNull()][AllowEmptyCollection()]$Group, [AllowNull()][AllowEmptyCollection()]$History)

    $groups = @($Group | Where-Object { $_ })
    $total  = 0
    foreach ($one in $groups) { $total += [int]$one.Count }
    $lines = @(Get-EventCountText -Count $total -Data $Data)
    foreach ($one in $groups) {
        $when = ''
        if ($null -ne $one.First -and $null -ne $one.Last) {
            if ($one.First.Date -eq $one.Last.Date) { $when = (Get-Text 'Value.Stability.DiskError.On') -f $one.First }
            else { $when = (Get-Text 'Value.Stability.DiskError.Between') -f $one.First, $one.Last }
        }
        $lines += ((Get-Text 'Value.Stability.DiskError.Group') -f (Get-DiskErrorGroupName -Group $one -History $History), $one.Count, $when).Trim().TrimEnd(',')
    }
    $lines -join $script:StabilityLineSeparator
}

function New-DiskErrorFinding {
    <#
    .SYNOPSIS
        The disk, controller and file system errors, by where the device belongs: the
        built-in drives, the external ones, and what names no drive. Pure.
    .DESCRIPTION
        Seen on three machines in one afternoon: "Datentraeger-, Controller- oder
        NTFS-Fehler: 11", FAIL, with the advice to check the SSD - for errors on a disk 1
        that none of the machines had. Each had one disk, number 0, in good health; the
        errors were a drive somebody had plugged in for a morning.
 
        One line for both made the worse of the two the answer about the machine, and
        left the reader of "11" to find out that the SSD was meant by none of them. So
        there are three Findings, in the area of the drives:
 
          built-in always. "None" there is the sentence a reader looks for, and what
                     it is graded on is the machine.
          external where there were any. A note for a drive that is gone; a warning,
                     and no more, for one that is attached now - a backup disk with
                     read errors is worth one.
          unplaced where there were any: an event that names no drive may be about a
                     built-in one, and is graded as one.
 
        With admin rights a drive is named by model and serial number.
    #>

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

    # Read with Get-Parameter and a literal name: that is what the published reference finds.
    $warn = ConvertTo-Number (Get-Parameter $Parameters 'DiskErrorWarnAbove' 0)
    $fail = ConvertTo-Number (Get-Parameter $Parameters 'DiskErrorFailAbove' 5)
    if ($null -eq $warn) { $warn = 0 }
    if ($null -eq $fail) { $fail = 5 }

    $rows    = Get-DataCollection $Data 'DiskErrors'
    $groups  = @(Group-DiskError -ErrorRow $rows -Disks (Get-DataProperty $Data 'Disks'))
    $history = Get-DataCollection $Data 'DiskHistory'
    $table   = @()
    if ($rows.Count) { $table = @(New-Reference -Section 'Title.Stability.DiskErrors') }

    $internal = @($groups | Where-Object { $_.Place -eq 'Internal' })
    $external = @($groups | Where-Object { $_.Place -eq 'External' })
    $unplaced = @($groups | Where-Object { $_.Place -eq 'Unplaced' })

    # ---- the built-in drives
    $count = 0
    foreach ($group in $internal) { $count += [int]$group.Count }
    $check = Get-Text 'Check.Stability.DiskErrors.Internal'
    if (-not $count) {
        # Cleared only where nothing is left that might be a built-in drive after all.
        $cleared = @()
        if (-not $unplaced.Count) { $cleared = @('disk-errors') }
        New-Finding -Category Storage -Check $check -Severity OK -Value (Get-EventCountText -Count 0 -Data $Data) `
            -Cleared $cleared -Reference @(New-Reference -Signal 'drive-health-warning')
    }
    else {
        New-Finding -Category Storage -Check $check -Severity (Get-Severity $count $warn $fail) `
            -Value (Get-DiskErrorValue -Data $Data -Group $internal -History $history) `
            -Meaning (Get-Text 'Meaning.Stability.DiskErrors') -Hint (Get-Text 'Hint.Stability.DiskResetsBadBlocksOr') `
            -Signal 'disk-errors' -Reference (@(New-Reference -Signal 'drive-health-warning') + $table)
    }

    # ---- what names no drive
    if ($unplaced.Count) {
        $count = 0
        foreach ($group in $unplaced) { $count += [int]$group.Count }
        New-Finding -Category Storage -Check (Get-Text 'Check.Stability.DiskErrors.Unplaced') -Severity (Get-Severity $count $warn $fail) `
            -Value (Get-DiskErrorValue -Data $Data -Group $unplaced -History $history) `
            -Meaning (Get-Text 'Meaning.Stability.DiskErrors.Unplaced') `
            -Hint ((Get-Text 'Hint.Stability.DiskResetsBadBlocksOr'), (Get-Text 'Hint.Stability.DiskErrors.Unplaced') -join $script:StabilityLineSeparator) `
            -Signal 'disk-errors' -Reference (@(New-Reference -Signal 'drive-health-warning') + $table)
    }

    # ---- the external drives
    if ($external.Count) {
        $attached = 0
        foreach ($group in @($external | Where-Object { -not $_.Gone })) { $attached += [int]$group.Count }
        $gone = @($external | Where-Object { $_.Gone })

        $severity = Get-Severity $attached $warn ([double]::MaxValue)
        if ($severity -eq 'OK') { $severity = 'INFO' }

        $means = @()
        $hints = @()
        if ($attached) {
            $means += Get-Text 'Meaning.Stability.DiskErrors.ExternalAttached'
            $hints += Get-Text 'Hint.Stability.DiskErrors.ExternalAttached'
        }
        if ($gone.Count) {
            $means += Get-Text 'Meaning.Stability.DiskErrorGone'
            $hints += Get-Text 'Hint.Stability.DiskErrorGone'
            # A drive that is gone and that nobody saw attached has no name but a number.
            $unnamed = @($gone | Where-Object { $_.Kind -eq 'Disk' -and -not (Get-DiskErrorModel -History $history -DiskNumber $_.Id -Until $_.Last) })
            if ($unnamed.Count) { $hints += Get-Text 'Hint.Stability.DiskErrorWhich' }
        }
        New-Finding -Category Storage -Check (Get-Text 'Check.Stability.DiskErrors.External') -Severity $severity `
            -Value (Get-DiskErrorValue -Data $Data -Group $external -History $history) `
            -Meaning ($means -join $script:StabilityLineSeparator) -Hint ($hints -join $script:StabilityLineSeparator) `
            -Reference $table
    }
}

function New-DiskErrorSection {
    <#
    .SYNOPSIS
        The disk, controller and file system errors one by one, each with the drive it
        was about and where that belongs. Nothing where there were none. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    $rows = @((Get-DataCollection $Data 'DiskErrors') | Sort-Object { Get-DataProperty $_ 'Time' } -Descending)
    if (-not $rows.Count) { return }

    $history = Get-DataCollection $Data 'DiskHistory'
    $names   = @{}
    foreach ($group in @(Group-DiskError -ErrorRow $rows -Disks (Get-DataProperty $Data 'Disks'))) {
        $place = switch ($group.Place) {
            'Internal' { Get-Text 'Value.Stability.DiskError.Place.Internal' }
            'External' { Get-Text 'Value.Stability.DiskError.Place.External' }
            default    { Get-Text 'Value.Stability.DiskError.Place.Unplaced' }
        }
        $names['{0}|{1}' -f $group.Kind, $group.Id] = [pscustomobject]@{ Name = (Get-DiskErrorGroupName -Group $group -History $history); Place = $place }
    }

    New-Section -Title (Get-Text 'Title.Stability.DiskErrors') -Help (Get-Text 'Help.Stability.DiskErrors') -Row @(
        $rows | Select-Object -First 200 | ForEach-Object {
            $device = Get-DiskErrorDevice -Message "$(Get-DataProperty $_ 'Message')"
            $known  = $names['{0}|{1}' -f $device.Kind, $device.Id]
            $row = [ordered]@{}
            $row[(Get-Text 'Column.NetworkDisruption.Time')]      = Get-DataProperty $_ 'Time'
            $row[(Get-Text 'Column.Stability.DiskError.Drive')]   = "$($known.Name)"
            $row[(Get-Text 'Column.Stability.DiskError.Place')]   = "$($known.Place)"
            $row[(Get-Text 'Column.NetworkDisruption.Provider')]  = Get-DataProperty $_ 'Provider'
            $row[(Get-Text 'Column.NetworkDisruption.Id')]        = Get-DataProperty $_ 'Id'
            $row[(Get-Text 'Column.NetworkDisruption.Message')]   = Get-DataProperty $_ 'Message'
            [pscustomobject]$row
        }
    )
}

# Which component a WHEA event is of, by its id. Read off the provider's own list of
# events (Get-WinEvent -ListProvider Microsoft-Windows-WHEA-Logger, German Windows 11
# 26300, 2026-10-11), whose texts name the component: memory, the processor core or the
# AMD northbridge, a PCI bus or device. 16 and 17 name it in their payload and are of PCI
# Express by what else they carry (bus, device, function). 1, 2, 40 and 41 name it in
# their payload only, and are not told apart here.
$script:HardwareErrorComponents = @{
    22 = 'Memory'; 23 = 'Memory'; 46 = 'Memory'; 47 = 'Memory'; 48 = 'Memory'; 49 = 'Memory'
    18 = 'Processor'; 19 = 'Processor'; 20 = 'Processor'; 21 = 'Processor'; 28 = 'Processor'; 29 = 'Processor'
    16 = 'Bus'; 17 = 'Bus'; 24 = 'Bus'; 25 = 'Bus'; 26 = 'Bus'; 27 = 'Bus'
    42 = 'Bus'; 43 = 'Bus'; 44 = 'Bus'; 45 = 'Bus'
}

function Get-HardwareErrorComponent {
    <#
    .SYNOPSIS
        Which component a hardware error Windows logged is of: Memory, Processor, Bus, or
        Other where the event does not say by its id. Pure.
    #>

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

    $number = ConvertTo-Number $Id
    if ($null -ne $number -and $script:HardwareErrorComponents.ContainsKey([int]$number)) {
        return $script:HardwareErrorComponents[[int]$number]
    }
    'Other'
}

function New-HardwareErrorFinding {
    <#
    .SYNOPSIS
        The hardware errors Windows logged, counted, and by the component they are of.
    .DESCRIPTION
        Graded on how many there are, as it was. The Value says of which component, and
        an error of the memory is declared as that beside the hardware error it is: what
        reads Findings together takes only those for a sign of the memory.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-NumberParameter $Parameters 'HardwareErrorWarnAbove' 0
    $fail = Get-NumberParameter $Parameters 'HardwareErrorFailAbove' 10
    $rows = @((Get-DataCollection $Data 'HardwareErrors') | ForEach-Object { $_ })

    $of = @{ Memory = 0; Processor = 0; Bus = 0; Other = 0 }
    foreach ($row in $rows) { $of[(Get-HardwareErrorComponent -Id (Get-DataProperty $row 'Id'))]++ }

    $value  = Get-EventCountText -Count $rows.Count -Data $Data
    $signal = @()
    if ($rows.Count) {
        $parts = @(
            if ($of.Memory)    { (Get-Text 'Value.Stability.Hardware.Memory')    -f $of.Memory }
            if ($of.Processor) { (Get-Text 'Value.Stability.Hardware.Processor') -f $of.Processor }
            if ($of.Bus)       { (Get-Text 'Value.Stability.Hardware.Bus')       -f $of.Bus }
            if ($of.Other)     { (Get-Text 'Value.Stability.Hardware.Other')     -f $of.Other }
        )
        $value  = (Get-Text 'Value.Stability.Hardware.Counted') -f $value, ($parts -join ', ')
        $signal = @('hardware-errors')
        if ($of.Memory) { $signal += 'memory-errors' }
    }

    New-Finding -Category Stability -Check (Get-Text 'Check.Stability.WHEAHardwareErrors') `
        -Severity (Get-Severity $rows.Count $warn $fail) -Value $value `
        -Meaning (Get-Text 'Meaning.Stability.HardwareErrors') `
        -Hint (Get-Text 'Hint.Stability.CPURAMPCIeErrorsReported') -Signal $signal
}

function Get-LiveKernelCodeMeaning {
    <#
    .SYNOPSIS
        What a live kernel event's code means, in a Technician's words. Pure.
    .DESCRIPTION
        Windows writes a live dump and carries on where it could have stopped with a blue
        screen; the code is the bug check it would have been. Only the ones a Customer's
        PC produces are named - a display driver, the window system, standby, USB. A code
        that is not known here gets no meaning and is shown as it is.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Code)

    switch ("$Code" -replace '^0x0*', '') {
        '117' { return Get-Text 'Value.Stability.LiveKernel.117' }
        '141' { return Get-Text 'Value.Stability.LiveKernel.141' }
        '193' { return Get-Text 'Value.Stability.LiveKernel.193' }
        '1B0' { return Get-Text 'Value.Stability.LiveKernel.1B0' }
        '1B8' { return Get-Text 'Value.Stability.LiveKernel.1B8' }
        '190' { return Get-Text 'Value.Stability.LiveKernel.190' }
        '1A1' { return Get-Text 'Value.Stability.LiveKernel.1A1' }
        '19C' { return Get-Text 'Value.Stability.LiveKernel.19C' }
        '15F' { return Get-Text 'Value.Stability.LiveKernel.15F' }
        '144' { return Get-Text 'Value.Stability.LiveKernel.144' }
        '124' { return Get-Text 'Value.Stability.LiveKernel.124' }
        '1D4' { return Get-Text 'Value.Stability.LiveKernel.1D4' }
    }
    ''
}

function Get-StabilityCodeAdvice {
    <#
    .SYNOPSIS
        What to do about a stop code, or about a live kernel event's code. Pure.
    .DESCRIPTION
        Empty for a code nothing particular is known about. The same number can call for
        something else as a live kernel event than as a blue screen: Windows carried on,
        so it is rarely the hardware and usually a driver.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Code, [switch]$LiveKernel)

    $number = ("$Code" -replace '^0x0*', '').ToUpperInvariant()
    if ($LiveKernel) {
        switch ($number) {
            { $_ -in '117', '141', '193', '1B0', '1B8' } { return Get-Text 'Hint.Stability.Advice.LiveGraphics' }
            { $_ -in '190', '1A1' }                      { return Get-Text 'Hint.Stability.Advice.WindowSystem' }
            { $_ -in '19C', '15F' }                      { return Get-Text 'Hint.Stability.Advice.Standby' }
            { $_ -in '144', '1D4' }                      { return Get-Text 'Hint.Stability.Advice.Usb' }
            '124'                                        { return Get-Text 'Hint.Stability.Advice.Hardware' }
        }
        return ''
    }
    switch ($number) {
        { $_ -in 'A', 'D1', '1E', '3B', '7E' }    { return Get-Text 'Hint.Stability.Advice.Driver' }
        '133'                                     { return Get-Text 'Hint.Stability.Advice.Dpc' }
        { $_ -in '19', '1A', '4E', '50', '139' }  { return Get-Text 'Hint.Stability.Advice.Memory' }
        { $_ -in '24', '7A', '154' }              { return Get-Text 'Hint.Stability.Advice.Disk' }
        '7B'                                      { return Get-Text 'Hint.Stability.Advice.BootDisk' }
        'EF'                                      { return Get-Text 'Hint.Stability.Advice.SystemFiles' }
        { $_ -in '7F', '101', '124' }             { return Get-Text 'Hint.Stability.Advice.Hardware' }
        '9F'                                      { return Get-Text 'Hint.Stability.Advice.Power' }
        '116'                                     { return Get-Text 'Hint.Stability.Advice.Graphics' }
    }
    ''
}

function Join-StabilityCodeMeaning {
    <#
    .SYNOPSIS
        What the codes a Finding names mean, as its Meaning. Pure.
    .DESCRIPTION
        One code: what it means. Several: each behind its code. A code nothing is known
        about is not mentioned - the Value names it, and that is all there is to say.
    .PARAMETER Meaning
        Code to what it means, for the codes that are known.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Code, [Parameter(Mandatory)][hashtable]$Meaning)

    $known = @($Code | Where-Object { $_ -and $Meaning[$_] })
    if (-not $known.Count) { return '' }
    if ($known.Count -eq 1 -and @($Code | Where-Object { $_ }).Count -eq 1) { return "$($Meaning[$known[0]])" }
    @($known | ForEach-Object { (Get-Text 'Value.Stability.Code.Means') -f $_, $Meaning[$_] }) -join $script:StabilityLineSeparator
}

function Join-StabilityCodeAdvice {
    <#
    .SYNOPSIS
        One Hint for the codes a Finding names: what to do about each, said once. Pure.
    .DESCRIPTION
        Codes that call for the same thing share a sentence. Where they all do, the Hint
        is that and nothing else; where they differ, each part says which codes it is
        about. A code nothing is known about gets what the Finding always said.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        # The codes, the most frequent first.
        [AllowNull()][AllowEmptyCollection()][string[]]$Code,
        [Parameter(Mandatory)][string]$General,
        [switch]$LiveKernel
    )

    $parts = [ordered]@{}
    foreach ($one in @($Code | Where-Object { $_ })) {
        $advice = Get-StabilityCodeAdvice -Code $one -LiveKernel:$LiveKernel
        if (-not $advice) { $advice = $General }
        if (-not $parts.Contains($advice)) { $parts[$advice] = New-Object System.Collections.Generic.List[string] }
        $parts[$advice].Add($one)
    }
    if ($parts.Count -eq 0) { return $General }
    if ($parts.Count -eq 1) { return @($parts.Keys)[0] }
    @(foreach ($advice in $parts.Keys) { (Get-Text 'Hint.Stability.Advice.ForCodes') -f ($parts[$advice] -join ', '), $advice }) -join '. '
}

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

    $warn = Get-NumberParameter $Parameters 'LiveKernelEventWarnAbove' 0
    $fail = Get-NumberParameter $Parameters 'LiveKernelEventFailAbove' ([double]::MaxValue)

    $incidents = (Get-DataCollection $Data 'LiveKernelEvents')

    $value = $incidents.Count
    # "2: 0x193 x2" told a Technician nothing, and "2x Code 0x193" not much more. The code
    # is still named - it is what to look up - but behind what it means, where that is
    # known, as a blue screen is.
    $groups = @($incidents | Where-Object { Get-DataProperty $_ 'Code' } | Group-Object Code |
        Sort-Object @{ Expression = 'Count'; Descending = $true }, Name)
    $codes = @($groups | ForEach-Object {
            $known = Get-LiveKernelCodeMeaning -Code $_.Name
            if ($known) { (Get-Text 'Value.Stability.LiveKernel.Named') -f $_.Count, $known, $_.Name }
            else        { (Get-Text 'Value.Stability.LiveKernel.Unknown') -f $_.Count, $_.Name }
        })
    if ($codes.Count) {
        $value = if ($incidents.Count -eq 1) { (Get-Text 'Value.Stability.LiveKernel.One') -f ($codes -join ', ') }
                 else { (Get-Text 'Value.Stability.LiveKernel.Many') -f $incidents.Count, ($codes -join ', ') }
    }

    # What each code means stands beside it; the Meaning says what a live kernel event is.
    $meaning = ''
    if ($incidents.Count) { $meaning = Get-Text 'Meaning.Stability.LiveKernelGeneral' }

    # The graphics driver or the graphics system of Windows: 0x117, 0x141, 0x193, 0x1B0, 0x1B8.
    $signal = @()
    $graphics = @($groups | Where-Object { ("$($_.Name)" -replace '^0x0*', '').ToUpperInvariant() -in '117', '141', '193', '1B0', '1B8' })
    if ($graphics.Count) { $signal = @('live-kernel-graphics') }

    New-Finding -Category Stability -Check (Get-Text 'Check.Stability.LiveKernelEventsDriver') `
        -Severity (Get-Severity $incidents.Count $warn $fail) -Value $value `
        -Meaning $meaning `
        -Hint (Join-StabilityCodeAdvice -LiveKernel -Code @($groups | ForEach-Object { $_.Name }) `
            -General (Get-Text 'Hint.Stability.LiveKernelGeneral')) `
        -Signal $signal
}

function Get-BlueScreenCodeMeaning {
    <#
    .SYNOPSIS
        What a stop code means, and the name Windows gives it. Pure.
    .DESCRIPTION
        The ones a Customer's PC shows. A stop code that is not known here gets nothing,
        and the Finding shows the number as it is.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyString()][string]$Code)

    $name, $meaning = switch ("$Code" -replace '^0x0*', '') {
        'A'   { 'IRQL_NOT_LESS_OR_EQUAL',              (Get-Text 'Value.Stability.BlueScreen.A') }
        '19'  { 'BAD_POOL_HEADER',                     (Get-Text 'Value.Stability.BlueScreen.19') }
        '1A'  { 'MEMORY_MANAGEMENT',                   (Get-Text 'Value.Stability.BlueScreen.1A') }
        '1E'  { 'KMODE_EXCEPTION_NOT_HANDLED',         (Get-Text 'Value.Stability.BlueScreen.1E') }
        '24'  { 'NTFS_FILE_SYSTEM',                    (Get-Text 'Value.Stability.BlueScreen.24') }
        '3B'  { 'SYSTEM_SERVICE_EXCEPTION',            (Get-Text 'Value.Stability.BlueScreen.3B') }
        '4E'  { 'PFN_LIST_CORRUPT',                    (Get-Text 'Value.Stability.BlueScreen.4E') }
        '50'  { 'PAGE_FAULT_IN_NONPAGED_AREA',         (Get-Text 'Value.Stability.BlueScreen.50') }
        '7A'  { 'KERNEL_DATA_INPAGE_ERROR',            (Get-Text 'Value.Stability.BlueScreen.7A') }
        '7B'  { 'INACCESSIBLE_BOOT_DEVICE',            (Get-Text 'Value.Stability.BlueScreen.7B') }
        '7E'  { 'SYSTEM_THREAD_EXCEPTION_NOT_HANDLED', (Get-Text 'Value.Stability.BlueScreen.7E') }
        '7F'  { 'UNEXPECTED_KERNEL_MODE_TRAP',         (Get-Text 'Value.Stability.BlueScreen.7F') }
        '9F'  { 'DRIVER_POWER_STATE_FAILURE',          (Get-Text 'Value.Stability.BlueScreen.9F') }
        'D1'  { 'DRIVER_IRQL_NOT_LESS_OR_EQUAL',       (Get-Text 'Value.Stability.BlueScreen.D1') }
        'EF'  { 'CRITICAL_PROCESS_DIED',               (Get-Text 'Value.Stability.BlueScreen.EF') }
        '101' { 'CLOCK_WATCHDOG_TIMEOUT',              (Get-Text 'Value.Stability.BlueScreen.101') }
        '116' { 'VIDEO_TDR_FAILURE',                   (Get-Text 'Value.Stability.BlueScreen.116') }
        '124' { 'WHEA_UNCORRECTABLE_ERROR',            (Get-Text 'Value.Stability.BlueScreen.124') }
        '133' { 'DPC_WATCHDOG_VIOLATION',              (Get-Text 'Value.Stability.BlueScreen.133') }
        '139' { 'KERNEL_SECURITY_CHECK_FAILURE',       (Get-Text 'Value.Stability.BlueScreen.139') }
        '154' { 'UNEXPECTED_STORE_EXCEPTION',          (Get-Text 'Value.Stability.BlueScreen.154') }
    }
    if ($name) { [pscustomobject]@{ Name = $name; Meaning = $meaning } }
}

function Get-BlueScreenSignal {
    <#
    .SYNOPSIS
        What the stop codes of a machine's blue screens observed, as Signals. Pure.
    .DESCRIPTION
        Not every stop code says what failed. The ones named here do: 0x7A, 0x24 and
        0x154 are a drive or its file system that could not be read.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Code)

    $numbers = @($Code | ForEach-Object { ("$_" -replace '^0x0*', '').ToUpperInvariant() })
    if (@($numbers | Where-Object { $_ -in '7A', '24', '154' }).Count) { 'stop-code-disk' }
    # Corrupted memory or its bookkeeping. A driver can do that as well as a module can,
    # which is why one of these alone is no Lead.
    if (@($numbers | Where-Object { $_ -in '1A', '4E', '50', '19', '139' }).Count) { 'stop-code-memory' }
    if (@($numbers | Where-Object { $_ -eq '116' }).Count) { 'stop-code-graphics' }
}

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

    # Any blue screen at all is a FAIL, and always has been: one is a machine that stopped.
    $warn = Get-NumberParameter $Parameters 'BlueScreenWarnAbove' 0
    $fail = Get-NumberParameter $Parameters 'BlueScreenFailAbove' 0

    $screens = (Get-DataCollection $Data 'BlueScreens')

    $value = $screens.Count
    # The stop code is what a Technician looks up, so it is named: by what it means, by
    # the name Windows gives it, which is what a search finds, and by its number.
    $groups = @($screens | Where-Object { Get-DataProperty $_ 'StopCode' } |
        Group-Object { '0x{0}' -f (("$($_.StopCode)" -replace '^0x0*', '').ToUpperInvariant()) } |
        Sort-Object @{ Expression = 'Count'; Descending = $true }, Name)
    $means = @{}
    $codes = @($groups | ForEach-Object {
            $known = Get-BlueScreenCodeMeaning -Code $_.Name
            if ($known) {
                $means[$_.Name] = $known.Meaning
                (Get-Text 'Value.Stability.BlueScreen.Named') -f $_.Count, $known.Name, $_.Name
            }
            else { (Get-Text 'Value.Stability.BlueScreen.Unknown') -f $_.Count, $_.Name }
        })
    if ($codes.Count) {
        $value = if ($screens.Count -eq 1) { (Get-Text 'Value.Stability.BlueScreen.One') -f ($codes -join ', ') }
                 else { (Get-Text 'Value.Stability.BlueScreen.Many') -f $screens.Count, ($codes -join ', ') }
    }

    # Where the blue screens with one stop code began shortly after a Change, a line more
    # says so, for each such stop code. See Get-CrashChangeTie.
    $days = Get-DataProperty $Data 'Days'
    if ($null -eq $days) { $days = $script:StabilityDefaultDays }
    $ties  = @()
    $lines = @()
    foreach ($group in $groups) {
        $tie = Get-CrashChangeTie -Time @($group.Group | ForEach-Object { Get-DataProperty $_ 'Time' }) `
            -Change (Get-DataProperty $Data 'Change') -Log 'System' -Days $days -Parameters $Parameters
        if ($null -eq $tie) { continue }
        $ties += $tie

        # The stop code as the Value names it: by the name Windows gives it, and its number.
        $code  = Get-Text 'Value.Stability.Change.Code' $group.Name
        $known = Get-BlueScreenCodeMeaning -Code $group.Name
        if ($known) { $code = Get-Text 'Value.Stability.Change.CodeNamed' $known.Name $group.Name }
        $lines += Get-Text 'Value.Stability.Change.FirstBlueScreen' $code $tie.First (Get-ChangeTieText -Tie $tie)
    }
    if ($lines.Count) { $value = (@("$value") + $lines) -join $script:StabilityLineSeparator }

    $meaning = @(Join-StabilityCodeMeaning -Code @($groups | ForEach-Object { $_.Name }) -Meaning $means) + @(Get-ChangeTieMeaning -Tie $ties)
    $hint    = @(Join-StabilityCodeAdvice -Code @($groups | ForEach-Object { $_.Name }) `
            -General (Get-Text 'Hint.Stability.LookUpTheStopCodes')) + @(Get-ChangeTieHint -Tie $ties)

    New-Finding -Category Stability -Check (Get-Text 'Check.Stability.BlueScreensBugCheck') `
        -Severity (Get-Severity $screens.Count $warn $fail) -Value $value `
        -Meaning (@($meaning | Where-Object { $_ }) -join $script:StabilityLineSeparator) `
        -Hint (@($hint | Where-Object { $_ }) -join $script:StabilityLineSeparator) `
        -Signal @(Get-BlueScreenSignal -Code @($groups | ForEach-Object { $_.Name })) `
        -Reference @(Get-ChangeTieReference -Tie $ties)
}