Private/Kinds/Stability.ps1

# The Stability Kind: what the event logs remember about this machine.
#
# Everything else Gutcheck does describes the machine as it is now. This describes what it
# has been doing, which is usually the Check that answers "it crashes sometimes" - a
# complaint no reading taken at rest can address.
#
# Every selection here is by provider name and numeric event id, never by message text. A
# German Windows returns German messages and invariant ids, and the entire estate this was
# built for is German. Payload fields are read positionally, the way the Windows event
# schema defines them, for the same reason.

# How far back a Run looks. A Check Definition parameter, because a Customer with a machine
# that misbehaves monthly and one that misbehaves hourly want different windows.
$script:StabilityDefaultDays = 30

# Which providers and ids make up each scan. Data rather than code so the selection can be
# read at a glance and so a provider that was renamed is one line to fix.
$script:StabilityScans = @(
    @{ Key = 'UnexpectedShutdowns'; Tag = 'Unexpected shutdown'; Log = 'System'
       Provider = @('Microsoft-Windows-Kernel-Power'); Id = @(41) }
    @{ Key = 'BlueScreenEvents';    Tag = 'Blue screen';         Log = 'System'
       Provider = @('Microsoft-Windows-WER-SystemErrorReporting'); Id = @(1001) }
    @{ Key = 'HardwareErrors';      Tag = 'WHEA hardware error'; Log = 'System'
       Provider = @('Microsoft-Windows-WHEA-Logger'); Id = @() }
    @{ Key = 'FirmwareThrottles';   Tag = 'Firmware CPU limit';  Log = 'System'
       Provider = @('Microsoft-Windows-Kernel-Processor-Power'); Id = @(37) }
    @{ Key = 'DiskDeviceErrors';    Tag = 'Disk / file system';  Log = 'System'
       Provider = @('disk', 'stornvme', 'storahci', 'iaStorAC', 'iaStorAVC', 'iaStorVD')
       Id = @(7, 11, 51, 129, 153) }
    @{ Key = 'NtfsErrors';          Tag = 'Disk / file system';  Log = 'System'
       # 98 only when it reports a volume that needs repair. See Select-NtfsError.
       Provider = @('Microsoft-Windows-Ntfs', 'Ntfs'); Id = @(55, 98) }
    @{ Key = 'DisplayResets';       Tag = 'Display driver reset'; Log = 'System'
       Provider = @('Display'); Id = @(4101) }
    @{ Key = 'ResourceExhaustion';  Tag = 'Low memory';          Log = 'System'
       Provider = @('Microsoft-Windows-Resource-Exhaustion-Detector'); Id = @(2004) }
    @{ Key = 'ServiceFailureEvents'; Tag = 'Service crash';      Log = 'System'
       Provider = @('Service Control Manager'); Id = @(7031, 7034) }
    @{ Key = 'CrashEvents';         Tag = 'Application crash';   Log = 'Application'
       Provider = @('Application Error'); Id = @(1000) }
    @{ Key = 'HangEvents';          Tag = 'Application hang';    Log = 'Application'
       Provider = @('Application Hang'); Id = @(1002) }
    @{ Key = 'RuntimeFaultEvents';  Tag = '.NET crash';          Log = 'Application'
       Provider = @('.NET Runtime'); Id = @(1026) }
    # Gathered but deliberately untagged: Windows Error Reporting 1001 covers every report
    # WER has ever filed, thousands on an ordinary machine, and only the LiveKernelEvent
    # entries are a Finding. Tagging the lot would bury the blue screens in events.csv
    # under the noise a Technician opened the file to see past.
    @{ Key = 'ErrorReports';        Tag = '';                    Log = 'Application'
       Provider = @('Windows Error Reporting'); Id = @(1001) }
)

# What Get-StabilityData hands its Judge. See Private/DataShape.ps1.
$script:StabilityDataShape = @{
    Days                = 'Answered'
    UnexpectedShutdowns = 'List'
    BlueScreens         = 'List'
    HardwareErrors      = 'List'
    FirmwareThrottles   = 'List'
    DiskErrors          = 'List'
    Disks               = 'Value'
    DiskHistory         = 'List'
    DisplayResets       = 'List'
    ResourceExhaustion  = 'List'
    Crashes             = 'List'
    Hangs               = 'List'
    RuntimeFaults       = 'List'
    CoveredPrograms     = 'List'
    Incidents           = 'List'
    LiveKernelEvents    = 'List'
    ServiceFailures     = 'List'
    ReliabilityIndex    = 'Value'
    UnreadableLogs      = 'List'
    Events              = 'List'
    NetworkDisruption   = 'Value'
    NetworkDependency   = 'Value'
    Adapters            = 'Value'
    Correlation         = 'Value'
    Change              = 'Value'
}

function Get-StabilityData {
    <#
    .SYNOPSIS
        Reads the event logs and returns what is in them. Judges none of it.
    .DESCRIPTION
        A log this Run's rights could not open is recorded by name rather than swallowed,
        because a Technician must be able to tell an empty log from a log nobody read.
    #>

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

        # What the Checks before this one gathered, keyed by Kind, and what the Elevated
        # Part gathered. The Judge is handed this Kind's data and nothing else, so what it
        # needs of the network Checks to hold a crash against is copied in here.
        [AllowNull()][hashtable]$Observed = @{}
    )

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

    $unreadable = @{}
    $gathered   = @{}
    # Not List[object]: @($x) throws on one of those. See Public/Invoke-Gutcheck.ps1.
    $events     = New-Object System.Collections.Generic.List[psobject]

    foreach ($scan in $script:StabilityScans) {
        $found = @(Get-StabilityEvent -Log $scan.Log -Provider $scan.Provider -Id $scan.Id `
                                      -Since $since -Unreadable $unreadable)
        if ($scan.Key -eq 'NtfsErrors') { $found = @($found | Select-NtfsError) }
        $gathered[$scan.Key] = $found
        if ($scan.Tag) {
            foreach ($entry in $found) { $events.Add((ConvertTo-EventRow -LogEntry $entry -Tag $scan.Tag)) }
        }
    }

    # The driver hangs Windows recovered from, picked out of everything WER reported.
    $liveKernel = @(ConvertTo-LiveKernelIncident -Since $since -LogEntry @($gathered['ErrorReports'] | Where-Object {
        @($_.Properties).Count -gt 2 -and "$($_.Properties[2].Value)" -match 'LiveKernelEvent'
    }))
    foreach ($incident in $liveKernel) {
        $events.Add([pscustomobject]@{
            Time = $incident.Time; Category = 'LiveKernelEvent'; Provider = 'Windows Error Reporting'
            Id   = 1001; Message = ('{0} {1} ({2} reports)' -f $incident.Code, $incident.Dump, $incident.Reports).Trim()
        })
    }

    # Materialised before it is sorted: piping a hashtable's live KeyCollection straight
    # into Sort-Object throws inside the enumerable binder when the hashtable is empty,
    # which is exactly the case a clean machine produces.
    $unreadableLogs = @($unreadable.Keys)

    $reliability = $null
    try {
        $metric = Get-CimInstance Win32_ReliabilityStabilityMetrics -ErrorAction Stop |
            Sort-Object TimeGenerated -Descending | Select-Object -First 1
        if ($metric) { $reliability = [math]::Round($metric.SystemStabilityIndex, 1) }
    }
    catch { }

    $crashes = @($gathered['CrashEvents']        | ForEach-Object { ConvertTo-CrashRow -LogEntry $_ })
    $hangs   = @($gathered['HangEvents']         | ForEach-Object { ConvertTo-HangRow -LogEntry $_ })
    $faults  = @($gathered['RuntimeFaultEvents'] | ForEach-Object { ConvertTo-RuntimeFaultRow -LogEntry $_ })

    # Whether a program path is on the network has to be asked of the machine - a drive
    # letter says nothing about itself - so it is asked here, once per path, the way the
    # App Kind asks, and the answer travels on the incident as data.
    $remotePaths = @($crashes | ForEach-Object { $_.AppPath } | Where-Object { $_ } | Sort-Object -Unique |
        Where-Object { Test-RemoteLocation -Path $_ })

    # How far apart two events of one program may be and still be one crash. A Check
    # Definition parameter, because it decides how many crashes a Report counts.
    #
    # Read here, on the Gatherer's side of ADR-0002, and carried in the data with the
    # incidents and the correlation settings below, because a Section builder receives no
    # parameters: the data must carry what the Section shows.
    $window = Get-IncidentWindowSetting -Parameters $Parameters

    [pscustomobject]@{
        PSTypeName          = 'Gutcheck.Data.Stability'
        Days                = $days
        UnexpectedShutdowns = @($gathered['UnexpectedShutdowns'] | ForEach-Object { ConvertTo-EventRow -LogEntry $_ })
        BlueScreens         = @($gathered['BlueScreenEvents']    | ForEach-Object { ConvertTo-BlueScreenRow -LogEntry $_ })
        HardwareErrors      = @($gathered['HardwareErrors']      | ForEach-Object { ConvertTo-EventRow -LogEntry $_ })
        FirmwareThrottles   = @($gathered['FirmwareThrottles']   | ForEach-Object { ConvertTo-EventRow -LogEntry $_ })
        DiskErrors          = @(@($gathered['DiskDeviceErrors']) + @($gathered['NtfsErrors']) |
                                Where-Object { $_ } | ForEach-Object { ConvertTo-EventRow -LogEntry $_ })
        # Which disks and volumes the machine has now, and which drives the Elevated Part
        # saw attached: a disk error is held against both, because an event says
        # "Datentraeger 1" and not that it was a memory stick somebody has taken home.
        Disks               = Get-StabilityCurrentDisk
        DiskHistory         = (Get-DataCollection (Get-ObservedKindData -Observed $Observed -Kind 'DiskHealth') 'DiskHistory')
        DisplayResets       = @($gathered['DisplayResets']       | ForEach-Object { ConvertTo-EventRow -LogEntry $_ })
        ResourceExhaustion  = @($gathered['ResourceExhaustion']  | ForEach-Object { ConvertTo-EventRow -LogEntry $_ })
        Crashes             = $crashes
        Hangs               = $hangs
        RuntimeFaults       = $faults
        # The programs an application's own Check judges in this Run, each with the
        # pattern of its processes: what the Run chose, before the first Check.
        CoveredPrograms     = @((Get-DataCollection (Get-ObservedKindData -Observed $Observed -Kind 'AppSelection') 'Selected') | ForEach-Object {
            [pscustomobject]@{ App = "$(Get-DataProperty $_ 'App')"; ProcessPattern = "$(Get-DataProperty $_ 'ProcessPattern')" } })
        # The three lists above as the crashes behind them. The lists stay: other Kinds
        # read them, and events.csv is every event, not every crash.
        Incidents           = @(ConvertTo-CrashIncident -Crash $crashes -RuntimeFault $faults -Hang $hangs `
                                    -WindowSeconds $window -RemotePath $remotePaths)
        LiveKernelEvents    = @($liveKernel)
        ServiceFailures     = @($gathered['ServiceFailureEvents'] | ForEach-Object { ConvertTo-ServiceFailureRow -LogEntry $_ })
        ReliabilityIndex    = $reliability
        UnreadableLogs      = @($unreadableLogs | Sort-Object)
        Events              = @($events)
        # What the network Checks gathered, as far as a crash is held against it. Each
        # says whether it was gathered at all: a Check that was skipped, or an Elevated
        # Part that did not come back, is not a network on which nothing happened.
        NetworkDisruption   = Copy-ObservedNetworkDisruption -Observed $Observed
        NetworkDependency   = Copy-ObservedNetworkDependency -Observed $Observed
        Adapters            = Copy-ObservedAdapter -Observed $Observed
        # The window and the events the Check Definition holds a crash against, for the
        # Section: a Section is built from data alone, and must show the disruptions the
        # Finding counted.
        Correlation         = Get-CorrelationSetting -Parameters $Parameters
        # What the Change Check before this one gathered, every Change of it: which of
        # them a crash is tied to is the Judge's to decide. See Get-CrashChangeTie.
        Change              = Copy-ObservedChange -Observed $Observed
    }
}

function Get-ObservedKindData {
    <#
    .SYNOPSIS
        What an earlier Check gathered for one Kind, or $null when none did. Pure.
    #>

    [CmdletBinding()]
    param([AllowNull()][hashtable]$Observed, [Parameter(Mandatory)][string]$Kind)

    if (-not $Observed -or -not $Observed.ContainsKey($Kind)) { return $null }
    $Observed[$Kind]
}

function Test-DataCarried {
    <#
    .SYNOPSIS
        Whether gathered data carries a property at all, and with a value. Pure.
    .DESCRIPTION
        An empty list is carried: a Check that looked and listed nothing has said
        something, and a Check older than the property has not.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()]$InputObject, [Parameter(Mandatory)][string]$Name)

    if ($null -eq $InputObject) { return $false }
    if ($InputObject -is [hashtable]) { return ($InputObject.ContainsKey($Name) -and $null -ne $InputObject[$Name]) }
    $property = $InputObject.PSObject.Properties[$Name]
    [bool]($property -and $null -ne $property.Value)
}

function Copy-ObservedList {
    <#
    .SYNOPSIS
        A list of names another Check gathered, as strings - or $null when that Check
        carried no such list. Pure, and decides nothing.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param([AllowNull()]$InputObject, [Parameter(Mandatory)][string]$Name)

    if (-not (Test-DataCarried -InputObject $InputObject -Name $Name)) { return $null }
    , [string[]]@((Get-DataCollection $InputObject $Name) | ForEach-Object { "$_" })
}

function Get-NumberParameter {
    <#
    .SYNOPSIS
        A numeric Check Definition parameter, or its default when the Definition is silent
        about it or says something that is no number. Pure.
    .DESCRIPTION
        A Definition is a document somebody edits. "x" where a number belongs must not
        make a Judge throw: the Run would lose every Finding of the Check over a typing
        mistake in one threshold. The default is what the Kind does unasked.
    #>

    [CmdletBinding()]
    [OutputType([double])]
    param(
        [Parameter(Position = 0)][AllowNull()][hashtable]$Parameters,
        [Parameter(Mandatory, Position = 1)][string]$Name,
        [Parameter(Mandatory, Position = 2)][double]$Default
    )

    $number = ConvertTo-Number (Get-Parameter $Parameters $Name $Default)
    if ($null -eq $number -or [double]::IsNaN($number)) { return $Default }
    $number
}

function Get-ListParameter {
    <#
    .SYNOPSIS
        A Check Definition parameter that is a list of names, trimmed and without blanks -
        or its default when the Definition is silent about it or names nothing. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Position = 0)][AllowNull()][hashtable]$Parameters,
        [Parameter(Mandatory, Position = 1)][string]$Name,
        [Parameter(Mandatory, Position = 2)][string[]]$Default
    )

    $named = @(@(Get-Parameter $Parameters $Name $Default) | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
    if (-not $named.Count) { $named = @($Default) }
    , [string[]]$named
}

function Get-StabilityEvent {
    <#
    .SYNOPSIS
        Reads one provider-and-id selection out of one log. Records a log it cannot open.
    .DESCRIPTION
        Get-WinEvent throws rather than returning nothing when a filter matches no events,
        which is indistinguishable from a real failure by its type alone. A log that was
        refused is recorded by name so the Judge can say so; a log that simply held nothing
        is not, because that is an answer rather than a gap.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Log,
        [AllowEmptyCollection()][string[]]$Provider = @(),
        [AllowEmptyCollection()][int[]]$Id = @(),
        [Parameter(Mandatory)][datetime]$Since,
        [Parameter(Mandatory)][hashtable]$Unreadable
    )

    if (-not (Test-EventLogReadable -Log $Log -Unreadable $Unreadable)) { return }

    $result = @()

    # One query for all of a scan's providers, and not one each. Asked for alone, a
    # provider this machine has never registered - an Intel storage driver on an AMD
    # board - makes Get-WinEvent throw "the parameter is incorrect" whatever it is told
    # about errors; asked for among others, it is passed over.
    $filter = @{ LogName = $Log; StartTime = $Since }
    if ($Provider.Count) { $filter.ProviderName = $Provider }
    if ($Id.Count)       { $filter.Id = $Id }

    # Asked not to stop, and the errors collected instead of caught. "No events matched"
    # is an error to Get-WinEvent and the ordinary answer on a clean machine; stopping on
    # it writes a TerminatingError line into the transcript for every scan that found
    # nothing, and the console log a Technician hands on then reads like a tool that is
    # broken. The catch stays for what throws regardless.
    $problems = @()
    try {
        $result = @(Get-WinEvent -FilterHashtable $filter -ErrorAction SilentlyContinue -ErrorVariable problems)
    }
    catch { $problems = @($_) }

    foreach ($problem in @($problems)) {
        if (Test-AccessDenied -ErrorRecord $problem) { $Unreadable[$Log] = $true }
        # Anything else is "no events matched" or a provider this machine does not have.
        # Reporting that as a gap would make every clean machine look unread.
    }
    $result
}

function Test-EventLogReadable {
    <#
    .SYNOPSIS
        Whether this Run's rights may read a log. Records a log it may not.
    .DESCRIPTION
        Asked by name, before any filtered query, because a filtered query never says it
        was refused: Get-WinEvent -FilterHashtable drops a log the caller may not read and
        reports that no events matched - exactly what an empty log reports. Only reading
        the log by name raises the refusal. Without this, a Run without admin rights reads
        Diagnostics-Performance or the Group Policy log as a machine with nothing in it.
 
        Asked once per log per Gatherer: the answer is recorded in $Unreadable, and a log
        already recorded there is not asked about again.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)][string]$Log,
        [Parameter(Mandatory)][hashtable]$Unreadable
    )

    if ($Unreadable.ContainsKey($Log)) { return $false }
    $problems = @()
    try { $null = Get-WinEvent -LogName $Log -MaxEvents 1 -ErrorAction SilentlyContinue -ErrorVariable problems }
    catch { $problems = @($_) }

    foreach ($problem in @($problems)) {
        # An empty log raises "no events" here too; only a refusal makes it unreadable.
        if (Test-AccessDenied -ErrorRecord $problem) { $Unreadable[$Log] = $true; return $false }
    }
    $true
}

function Test-AccessDenied {
    <#
    .SYNOPSIS
        Whether an error record says the caller was not allowed, rather than not lucky.
    #>

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

    $exception = $ErrorRecord.Exception
    while ($exception) {
        if ($exception -is [UnauthorizedAccessException]) { return $true }
        $exception = $exception.InnerException
    }

    # The message is matched only as a fallback, and in both languages the estate runs,
    # because the typed exception is not always what surfaces.
    "$($ErrorRecord.FullyQualifiedErrorId) $($ErrorRecord.Exception.Message)" -match
        'Unauthorized|unauthori|Zugriff|nicht autorisiert'
}

# How much of an event's message is kept. See ConvertTo-EventRow.
$script:StabilityEventMessageLength = 4000

function ConvertTo-EventRow {
    <#
    .SYNOPSIS
        One event, reduced to what a Report and a Technician need from it.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$LogEntry, [string]$Tag)

    # Whole, where it used to be cut at 500 characters: the cut fell in the middle of a
    # sentence, and what an SMB or a .NET event says last - which share, which method - is
    # what a Technician opened it for. The Report shortens what it shows and lets it be
    # opened; events.csv holds all of it. The limit left is against an event that carries
    # a dump, and it falls between words and says that it fell.
    $message = "$($LogEntry.Message)"
    if ($message) {
        $message = ($message -replace '\s+', ' ').Trim()
        if ($message.Length -gt $script:StabilityEventMessageLength) {
            $cut = $message.LastIndexOf(' ', $script:StabilityEventMessageLength)
            if ($cut -lt 1) { $cut = $script:StabilityEventMessageLength }
            $message = $message.Substring(0, $cut) + ' [...]'
        }
    }

    $row = [ordered]@{
        Time     = $LogEntry.TimeCreated
        Provider = "$($LogEntry.ProviderName)"
        Id       = $LogEntry.Id
        Message  = $message
    }
    # Tagged rows are the ones bound for events.csv, where the tag is the column that lets
    # a Technician find the blue screens among four hundred lines.
    if ($Tag) { $row.Insert(1, 'Category', $Tag) }
    [pscustomobject]$row
}

function Get-EventPayload {
    <#
    .SYNOPSIS
        One positional field out of an event's payload, or $null when it is not there.
    .DESCRIPTION
        Positional because the names are localised and the positions are not. Bounds are
        checked because a real machine logs events with short payloads, and a Judge handed
        a row built by blind indexing would throw on data Windows considers valid.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$LogEntry, [Parameter(Mandatory)][int]$Index)

    $properties = @($LogEntry.Properties)
    if ($Index -lt 0 -or $Index -ge $properties.Count) { return $null }
    "$($properties[$Index].Value)"
}

function Select-NtfsError {
    <#
    .SYNOPSIS
        Passes on the NTFS events that report something wrong, and drops the ones that do not.
    .DESCRIPTION
        Ntfs 98 is logged at Information level whatever it says. Its third field,
        CorruptionActionState, is 0 for "volume is healthy, no action is needed" - written
        every time a volume or a shadow copy is mounted - and anything else for a volume
        that needs a scan or chkdsk. A 98 whose state cannot be read is kept: showing one
        event too many is the cheaper mistake.
    #>

    [CmdletBinding()]
    param([Parameter(ValueFromPipeline)]$LogEntry)

    process {
        if ($null -eq $LogEntry) { return }
        if ($LogEntry.Id -eq 98 -and (Get-EventPayload -LogEntry $LogEntry -Index 2) -eq '0') { return }
        $LogEntry
    }
}

function ConvertTo-LiveKernelIncident {
    <#
    .SYNOPSIS
        Reduces Windows Error Reporting's LiveKernelEvent reports to the incidents behind them.
    .DESCRIPTION
        WER logs event 1001 again each time it retries a queued report, so one driver hang
        is reported a hundred times over the following months. The incident is the dump:
        reports are grouped by the dump file they attach, or by report id when there is
        none, and dated by the timestamp in the dump's name rather than by when WER last
        mentioned it. An incident from before the period is not in the period, however
        recently it was reported again.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()][AllowEmptyCollection()]$LogEntry,
        [Parameter(Mandatory)][datetime]$Since
    )

    $incidents = [ordered]@{}
    foreach ($entry in @($LogEntry | Where-Object { $_ })) {
        $dump = ''
        if ("$(Get-EventPayload -LogEntry $entry -Index 15)" -match '([^\\\s]+\.dmp)') { $dump = $Matches[1] }
        $reportId = "$(Get-EventPayload -LogEntry $entry -Index 19)"

        # No dump and no report id: nothing says two events are the same incident.
        $key = if ($dump) { $dump } elseif ($reportId) { $reportId } else { [guid]::NewGuid().ToString() }

        if (-not $incidents.Contains($key)) {
            $code = "$(Get-EventPayload -LogEntry $entry -Index 5)"
            $incidents[$key] = [pscustomobject]@{
                Time    = $entry.TimeCreated
                Code    = if ($code) { '0x{0}' -f $code } else { '' }
                Dump    = $dump
                Reports = 0
            }
        }
        $incident = $incidents[$key]
        $incident.Reports++
        if ($entry.TimeCreated -lt $incident.Time) { $incident.Time = $entry.TimeCreated }
    }

    foreach ($incident in $incidents.Values) {
        # WATCHDOG-20260915-1448.dmp: when the hang happened, in local time.
        if ($incident.Dump -match '(\d{8})-(\d{4})\.dmp$') {
            $taken = [datetime]::MinValue
            if ([datetime]::TryParseExact($Matches[1] + $Matches[2], 'yyyyMMddHHmm',
                    [cultureinfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$taken)) {
                $incident.Time = $taken
            }
        }
        if ($incident.Time -ge $Since) { $incident }
    }
}

function ConvertTo-BlueScreenRow {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$LogEntry)

    # The bugcheck line reads "0x00000133 (0x0000...)"; only the stop code is the lead.
    $code = Get-EventPayload -LogEntry $LogEntry -Index 0
    if ($code) { $code = $code -replace '\s*\(.*$', '' }

    [pscustomobject]@{ Time = $LogEntry.TimeCreated; StopCode = $code }
}

function ConvertTo-CrashRow {
    <#
    .SYNOPSIS
        An Application Error 1000 payload, in the order the schema defines it.
    #>

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

    [pscustomobject]@{
        Time      = $LogEntry.TimeCreated
        App       = Get-EventPayload -LogEntry $LogEntry -Index 0
        Version   = Get-EventPayload -LogEntry $LogEntry -Index 1
        Module    = Get-EventPayload -LogEntry $LogEntry -Index 3
        ModuleVer = Get-EventPayload -LogEntry $LogEntry -Index 4
        Exception = Get-EventPayload -LogEntry $LogEntry -Index 6
        AppPath   = Get-EventPayload -LogEntry $LogEntry -Index 10
    }
}

function ConvertTo-HangRow {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$LogEntry)
    [pscustomobject]@{ Time = $LogEntry.TimeCreated; App = Get-EventPayload -LogEntry $LogEntry -Index 0 }
}

function ConvertTo-RuntimeFaultRow {
    <#
    .SYNOPSIS
        A .NET Runtime 1026, whose payload is one blob of text rather than named fields.
    .DESCRIPTION
        App is absent when the text names no executable, as it always was. The framework
        version and the method are empty strings then, and the exception types an empty
        list: a later Check asks what the row says without asking first whether it does.
    #>

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

    # Read from the whole text, here, because nothing downstream has it: the row bound for
    # events.csv is cut at 500 characters, which is before most stacks reach the program's
    # own code. The payload's single field is the same text, for the event whose message
    # Windows could not render.
    $message = "$($LogEntry.Message)"
    if (-not $message.Trim()) { $message = "$(Get-EventPayload -LogEntry $LogEntry -Index 0)" }
    $read = ConvertFrom-RuntimeFaultMessage -Message $message

    [pscustomobject]@{
        Time             = $LogEntry.TimeCreated
        App              = $read.App
        FrameworkVersion = $read.FrameworkVersion
        ExceptionTypes   = $read.ExceptionTypes
        Method           = $read.Method
    }
}

# What an exception type looks like: a dotted name whose last part ends in "Exception". The
# plus sign is how the runtime writes a type nested in another.
$script:RuntimeFaultTypePattern = [regex]'(?:\w+[.+])+\w*Exception\b'

# What a stack line looks like: indented, one word ("bei", "at"), then a dotted name up to
# its parameter list. The word is whatever the language makes it, so it is only required to
# be one, and of letters - which leaves out the arrow before an inner exception.
$script:RuntimeFaultStackPattern = [regex]'^\s+[^\W\d_]+\s+([^\s(]+\.[^\s(]+)\('

function ConvertFrom-RuntimeFaultMessage {
    <#
    .SYNOPSIS
        What the text of a .NET Runtime 1026 says: application, framework, exception types
        and the first method of the stack.
    .DESCRIPTION
        The labels are localised - "Ausnahmeinformationen" on a German Windows, "Exception
        Info" on an English one - so nothing is found by its label. An exception type is
        matched on the type name itself, and a stack line by its shape.
 
        Types are taken from every line that is not a stack line, because an event names
        one per exception it unwound and a stack line names types of its own: every
        WinForms crash passes through OnThreadException(System.Exception).
 
        Reads text and nothing else, and returns every field whatever the text was: a
        message that names no exception leaves the types an empty list and the method
        empty.
    #>

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

    $lines = @("$Message" -split '\r?\n')

    # The first line is the application. Failing that, an executable named anywhere, which
    # is all this looked for before it read the rest.
    $app = $null
    if ($lines[0] -match '^[^:]+:\s*(\S.*?\.exe)\s*$') { $app = ($Matches[1] -split '[\\/]')[-1] }
    elseif ("$Message" -match '([^\\/\s:]+\.exe)')     { $app = $Matches[1] }

    $framework = ''
    $method    = ''
    $types     = New-Object System.Collections.Generic.List[string]

    for ($index = 0; $index -lt $lines.Count; $index++) {
        $line = $lines[$index]

        $frame = $script:RuntimeFaultStackPattern.Match($line)
        if ($frame.Success) {
            if (-not $method) { $method = $frame.Groups[1].Value }
            continue
        }

        # Not the line that named the application: a program may be called anything.
        if ($index -eq 0) { continue }

        # v4.0.30319 for the .NET Framework, 6.0.522.21309 for what came after it.
        if (-not $framework -and $line -match '^[^:]*:\s*(v?\d+(?:\.\d+)+)\s*$') { $framework = $Matches[1] }

        foreach ($match in $script:RuntimeFaultTypePattern.Matches($line)) {
            if (-not $types.Contains($match.Value)) { $types.Add($match.Value) }
        }
    }

    [pscustomobject]@{
        App              = $app
        FrameworkVersion = $framework
        ExceptionTypes   = [string[]]$types.ToArray()
        Method           = $method
    }
}

function ConvertTo-ServiceFailureRow {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$LogEntry)
    [pscustomobject]@{ Time = $LogEntry.TimeCreated; Service = Get-EventPayload -LogEntry $LogEntry -Index 0 }
}

function ConvertTo-StabilityFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{},
        # What a Hint may assume about the machine: what it is connected by, and whether
        # it is docked. Only the Hints of a crashing program go by it.
        [AllowNull()]$Situation
    )

    $days = Get-DataProperty $Data 'Days'
    if ($null -eq $days) { $days = $script:StabilityDefaultDays }

    New-EventCountFinding -Data $Data -Parameters $Parameters -Property 'UnexpectedShutdowns' `
        -Check (Get-Text 'Check.Stability.UnexpectedShutdownsKernelPower') -WarnParameter 'UnexpectedShutdownWarnAbove' `
        -FailParameter 'UnexpectedShutdownFailAbove' -WarnAbove 0 -FailAbove 2 `
        -Meaning (Get-Text 'Meaning.Stability.UnexpectedShutdowns') `
        -Hint (Get-Text 'Hint.Stability.UnexpectedShutdowns')

    New-BlueScreenFinding -Data $Data -Parameters $Parameters

    New-HardwareErrorFinding -Data $Data -Parameters $Parameters

    New-EventCountFinding -Data $Data -Parameters $Parameters -Property 'FirmwareThrottles' `
        -Check (Get-Text 'Check.Stability.CPUSpeedLimitedBy') -WarnParameter 'FirmwareThrottleWarnAbove' `
        -FailParameter 'FirmwareThrottleFailAbove' -WarnAbove 0 -FailAbove ([double]::MaxValue) `
        -Meaning (Get-Text 'Meaning.Stability.FirmwareThrottles') `
        -Hint (Get-Text 'Hint.Stability.ThermalPowerThrottlingHappenedIn')

    New-DiskErrorFinding -Data $Data -Parameters $Parameters

    New-EventCountFinding -Data $Data -Parameters $Parameters -Property 'DisplayResets' `
        -Check (Get-Text 'Check.Stability.GraphicsDriverResetsTDR') -WarnParameter 'DisplayResetWarnAbove' `
        -FailParameter 'DisplayResetFailAbove' -WarnAbove 0 -FailAbove ([double]::MaxValue) `
        -Hint (Get-Text 'Hint.Stability.UpdateGraphicsDriverVendorOEM')

    New-EventCountFinding -Data $Data -Parameters $Parameters -Property 'ResourceExhaustion' `
        -Check (Get-Text 'Check.Stability.LowVirtualMemoryWarnings') -WarnParameter 'ResourceExhaustionWarnAbove' `
        -FailParameter 'ResourceExhaustionFailAbove' -WarnAbove 0 -FailAbove 0 `
        -Meaning (Get-Text 'Meaning.Stability.ResourceExhaustion') `
        -Hint (Get-Text 'Hint.Stability.RAMPageFileExhaustedPrograms')

    $crashFindings = @(New-ApplicationCrashFinding -Data $Data -Parameters $Parameters -Situation $Situation)
    $crashFindings

    New-LiveKernelFinding -Data $Data -Parameters $Parameters

    New-ServiceFailureFinding  -Data $Data -Parameters $Parameters
    New-ReliabilityFinding     -Data $Data -Parameters $Parameters
    New-UnreadableLogFinding   -Data $Data -Parameters $Parameters

    # The crashes and hangs on the 30 days, beside the network's events. ADR-0010.
    ConvertTo-StabilityTimedEvent -Data $Data -Parameters $Parameters -Finding $crashFindings
}

function ConvertTo-StabilityTimedEvent {
    <#
    .SYNOPSIS
        Each crash and each hang of a program as a marker of the timeline, with the program
        and the Finding that counted it. Pure. ADR-0010.
    .DESCRIPTION
        A crash is counted by the program's own Finding where it crashed often enough for
        one, and by the Finding about the scattered crashes otherwise: the same split
        Get-CrashingProgram makes for the Findings. A program that only stopped answering
        is counted by no Finding (see Select-CrashIncident), and its hang is drawn grey.
 
        One marker per incident, not per event: one crash is logged up to three times. An
        incident without a time cannot be placed and is left out.
    .PARAMETER Finding
        What New-ApplicationCrashFinding made of the same incidents.
    #>

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

    # The Findings New-ApplicationCrashFinding made: one per program that crashes
    # repeatedly, about that program, and one about the rest.
    $scattered = @($Finding | Where-Object { $_ -and "$(Get-DataProperty $_ 'Check')" -eq (Get-Text 'Check.Stability.ScatteredCrashes') })[0]
    $byProgram = @{}
    foreach ($one in @($Finding | Where-Object { $_ -and "$(Get-DataProperty $_ 'Subject')" })) {
        $byProgram["$(Get-DataProperty $one 'Subject')".ToLowerInvariant()] = $one
    }

    $window = Get-IncidentWindowSetting -Parameters $Parameters
    foreach ($program in @(Get-CrashingProgram -Data $Data -Parameters $Parameters)) {
        $by = $scattered
        $key = "$($program.App)".ToLowerInvariant()
        if ($program.Repeated -and $byProgram.ContainsKey($key)) { $by = $byProgram[$key] }
        foreach ($incident in @($program.Incidents)) {
            $at = ConvertTo-DataTime (Get-DataProperty $incident 'Time')
            if ($null -eq $at) { continue }
            New-TimedEvent -Time $at -Lane Marker -Mark Crash -Subject "$(Get-DataProperty $incident 'App')" -Log 'Application' -Finding $by
        }
    }

    $incidents = Get-StabilityIncident -Data $Data -WindowSeconds $window
    foreach ($incident in @($incidents | Where-Object { $null -ne $_ -and (Test-HangOnlyIncident -Incident $_) })) {
        $at = ConvertTo-DataTime (Get-DataProperty $incident 'Time')
        if ($null -eq $at) { continue }
        New-TimedEvent -Time $at -Lane Marker -Mark Hang -Subject "$(Get-DataProperty $incident 'App')" -Log 'Application'
    }
}

function Get-StabilityCurrentDisk {
    <#
    .SYNOPSIS
        The numbers of the disks and the letters of the volumes this machine has now, and
        which of them are external: on USB, or removable. Readable without admin rights.
        Never throws.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param()

    $drives  = @(Get-CimInstance Win32_DiskDrive -ErrorAction SilentlyContinue)
    $numbers = @($drives | ForEach-Object { "$($_.Index)" })
    $logical = @(Get-CimInstance Win32_LogicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.DriveType -in 2, 3 })
    $volumes = @($logical | ForEach-Object { "$($_.DeviceID)".ToUpperInvariant() })

    # A USB hard disk is a fixed disk to Windows and a USB stick a removable one: the
    # disk says which bus it is on, the volume only that it can be taken out.
    $external = @($drives | Where-Object { "$($_.InterfaceType)" -eq 'USB' -or "$($_.MediaType)" -match '(?i)removable|external' } |
        ForEach-Object { "$($_.Index)" })
    $externalVolumes = @($logical | Where-Object { $_.DriveType -eq 2 } | ForEach-Object { "$($_.DeviceID)".ToUpperInvariant() })
    try {
        if ($external.Count -and (Get-Command -Name Get-Partition -ErrorAction SilentlyContinue)) {
            $externalVolumes += @(Get-Partition -ErrorAction Stop | Where-Object { $external -contains "$($_.DiskNumber)" -and $_.DriveLetter } |
                ForEach-Object { ('{0}:' -f $_.DriveLetter).ToUpperInvariant() })
        }
    }
    catch { }

    [pscustomobject]@{
        Gathered = [bool]$numbers.Count; Numbers = $numbers; Volumes = $volumes
        External = $external; ExternalVolumes = @($externalVolumes | Sort-Object -Unique)
    }
}

function New-EventCountFinding {
    <#
    .SYNOPSIS
        The Finding shape shared by every scan judged purely on how many events there were.
    .DESCRIPTION
        Eight of these Checks differ only in their name, their thresholds, their Hint and
        what a count above none means.
        Writing them out eight times would make eight places for a threshold to stop being
        a parameter, which is the thing this rebuild exists to prevent.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$Data, [hashtable]$Parameters,
        [Parameter(Mandatory)][string]$Property,
        [Parameter(Mandatory)][string]$Check,
        [Parameter(Mandatory)][string]$WarnParameter,
        [Parameter(Mandatory)][string]$FailParameter,
        [Parameter(Mandatory)][double]$WarnAbove,
        [Parameter(Mandatory)][double]$FailAbove,
        [string]$Hint = '',

        # What the events mean, where the name of the Check does not say it.
        [string]$Meaning = '',

        # What the events are, where one of them is an observation: declared from the first.
        [AllowNull()][string[]]$Signal
    )

    $warn  = Get-NumberParameter $Parameters $WarnParameter $WarnAbove
    $fail  = Get-NumberParameter $Parameters $FailParameter $FailAbove
    $count = (Get-DataCollection $Data $Property).Count

    $observed = @()
    if ($count) { $observed = @($Signal | Where-Object { $_ }) }
    New-Finding -Category Stability -Check $Check -Severity (Get-Severity $count $warn $fail) `
        -Value (Get-EventCountText -Count $count -Data $Data) -Meaning $Meaning -Hint $Hint -Signal $observed
}

function Get-EventCountText {
    <#
    .SYNOPSIS
        A number of events as a Value: how many, and in how many days. Pure.
    .DESCRIPTION
        The bare number was the whole Value, and "120" beside the name of a Check does
        not say 120 of what.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([int]$Count, [AllowNull()]$Data)

    $days = Get-DataProperty $Data 'Days'
    if ($null -eq $days) { $days = $script:StabilityDefaultDays }
    if ($Count -le 0) { return (Get-Text 'Value.Stability.EventCountNone') -f $days }
    (Get-Text 'Value.Stability.EventCount') -f $Count, $days
}

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

    $warn = Get-NumberParameter $Parameters 'ServiceFailureWarnAbove' 10

    $failures = (Get-DataCollection $Data 'ServiceFailures')
    if (-not $failures.Count) {
        return New-Finding -Category Stability -Check (Get-Text 'Check.Stability.CrashedServices') -Severity OK -Value (Get-Text 'Value.Shared.None')
    }

    $named = $failures | Where-Object { $_.Service } | Group-Object Service |
        Sort-Object Count -Descending | Select-Object -First 5 |
        ForEach-Object { '{0} x{1}' -f $_.Name, $_.Count }

    # A handful of service restarts is ordinary on Windows and the script never failed on
    # them; only an unusual number is worth a Technician's attention.
    $severity = 'INFO'
    if ($failures.Count -gt $warn) { $severity = 'WARN' }

    New-Finding -Category Stability -Check (Get-Text 'Check.Stability.CrashedServices') -Severity $severity `
        -Value ($named -join ', ') `
        -Meaning (Get-Text 'Meaning.Stability.ServiceFailures') `
        -Hint (Get-Text 'Hint.Stability.AServiceThatKeepsDying')
}

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

    $warnBelow = Get-NumberParameter $Parameters 'ReliabilityIndexWarnBelow' 6
    $failBelow = Get-NumberParameter $Parameters 'ReliabilityIndexFailBelow' 3

    $index = ConvertTo-Number (Get-DataProperty $Data 'ReliabilityIndex')
    if ($null -eq $index) {
        # Absent on a machine whose Reliability Monitor task has never run, which says
        # nothing about how stable the machine is.
        return New-UnavailableFinding -Category Stability -Check (Get-Text 'Check.Stability.ReliabilityIndex110') `
            -Hint (Get-Text 'Hint.Stability.WindowsHasNotComputedA')
    }

    New-Finding -Category Stability -Check (Get-Text 'Check.Stability.ReliabilityIndex110') `
        -Severity (Get-SeverityBelow $index $warnBelow $failBelow) -Value $index `
        -Hint (Get-Text 'Hint.Stability.OpenPerfmonRelForThe')
}

function New-UnreadableLogFinding {
    <#
    .SYNOPSIS
        One Finding per log this Run's rights could not open.
    #>

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

    foreach ($log in (Get-DataCollection $Data 'UnreadableLogs')) {
        New-Finding -Category Access -Check ((Get-Text 'Check.Stability.EventLog') -f $log) -Severity INFO `
            -Value (Get-Text 'Value.Stability.NotReadableWithRights') `
            -Hint (Get-Text 'Hint.Stability.NeedsAdminRightsThisLog')
    }
}

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

    # The column names are what the Report shows, so they are German and live with the
    # other text. An exception code stands with what it means behind it, here and in the
    # incidents below: a code is never shown bare.
    $crashes = (Get-DataCollection $Data 'Crashes')
    if ($crashes.Count) {
        New-Section -Title (Get-Text 'Title.Stability.ApplicationCrashesByApp') -Help (Get-Text 'Help.Stability.ApplicationCrashesByApp') -Row @(
            $crashes | Group-Object App | Sort-Object Count -Descending | ForEach-Object {
                $row = [ordered]@{}
                $row[(Get-Text 'Column.Stability.Crashes.Count')] = $_.Count
                $row[(Get-Text 'Column.Stability.Crashes.App')]   = $_.Name
                $row[(Get-Text 'Column.Stability.Crashes.Last')]  = (@($_.Group | Sort-Object Time -Descending)[0]).Time
                $row[(Get-Text 'Column.Stability.Crashes.TopModules')] = (
                    $_.Group | Group-Object { '{0} / {1}' -f $_.Module, $_.Exception } |
                        Sort-Object Count -Descending | Select-Object -First 3 |
                        ForEach-Object { '{0} / {1} x{2}' -f $_.Group[0].Module, (Get-ExceptionCodeText "$($_.Group[0].Exception)"), $_.Count }
                ) -join '; '
                [pscustomobject]$row
            }
        )
    }

    # Every incident, for whoever has to hand them to a vendor.
    $incidents = Get-StabilityIncident -Data $Data
    if ($incidents.Count) {
        $setting    = Get-SectionCorrelationParameter -Data $Data
        $disruption = Get-IncidentDisruptionColumn -Incident $incidents -Data $Data -Parameters $setting
        $windows    = Get-CorrelationSetting -Parameters $setting
        $heading    = Get-DisruptionColumnHeading -Seconds $windows.WindowSeconds -BeforeSeconds $windows.BeforeSeconds

        $index = -1
        New-Section -Title (Get-Text 'Title.Stability.CrashIncidents') -Help (Get-Text 'Help.Stability.CrashIncidents') -Row @(
            foreach ($incident in $incidents) {
                $index++
                $code = "$(Get-DataProperty $incident 'Exception')"
                if ($code) { $code = Get-ExceptionCodeText $code }
                elseif (-not (Select-CrashIncident -Incident $incident).Count) { $code = Get-Text 'Value.Stability.IncidentHang' }

                $time = ConvertTo-DataTime (Get-DataProperty $incident 'Time')

                $row = [ordered]@{}
                $row[(Get-Text 'Column.Stability.Incident.Time')]          = $time
                $row[(Get-Text 'Column.Stability.Incident.App')]           = Get-DataProperty $incident 'App'
                $row[(Get-Text 'Column.Stability.Incident.Version')]       = Get-DataProperty $incident 'Version'
                $row[(Get-Text 'Column.Stability.Incident.Exception')]     = $code
                $row[(Get-Text 'Column.Stability.Incident.Module')]        = Get-DataProperty $incident 'Module'
                $row[(Get-Text 'Column.Stability.Incident.ExceptionType')] = (Get-DataCollection $incident 'ExceptionTypes') -join ', '
                $row[(Get-Text 'Column.Stability.Incident.Method')]        = Get-DataProperty $incident 'Method'
                $row[(Get-Text 'Column.Stability.Incident.Path')]          = Get-DataProperty $incident 'AppPath'
                $row[$heading]                                             = $disruption[$index]
                [pscustomobject]$row
            }
        )
    }

    # What Windows said when memory ran low: it names the programs that held the most,
    # which is what the Finding sends a Technician to read.
    $lowMemory = @((Get-DataCollection $Data 'ResourceExhaustion') | Sort-Object { ConvertTo-DataTime (Get-DataProperty $_ 'Time') } -Descending | Select-Object -First 50)
    if ($lowMemory.Count) {
        New-Section -Title (Get-Text 'Title.Stability.ResourceExhaustion') -Help (Get-Text 'Help.Stability.ResourceExhaustion') -Row @(
            $lowMemory | ForEach-Object {
                $row = [ordered]@{}
                $row[(Get-Text 'Column.Stability.Incident.Time')]              = Get-DataProperty $_ 'Time'
                $row[(Get-Text 'Column.Stability.ResourceExhaustion.Message')] = Get-DataProperty $_ 'Message'
                [pscustomobject]$row
            }
        )
    }

    $hangs = (Get-DataCollection $Data 'Hangs')
    if ($hangs.Count) {
        New-Section -Title (Get-Text 'Title.Stability.ApplicationHangsByApp') -Help (Get-Text 'Help.Stability.ApplicationHangsByApp') -Row @(
            $hangs | Group-Object App | Sort-Object Count -Descending | ForEach-Object {
                $row = [ordered]@{}
                $row[(Get-Text 'Column.Stability.Crashes.Count')] = $_.Count
                $row[(Get-Text 'Column.Stability.Crashes.App')]   = $_.Name
                [pscustomobject]$row
            }
        )
    }

    # Every disk, controller and file system error, with the drive it was about.
    New-DiskErrorSection -Data $Data
}

function ConvertTo-StabilityEvent {
    <#
    .SYNOPSIS
        The events this Check gathered, for the Report's events.csv.
    .DESCRIPTION
        The optional fourth half of the Kind interface. A Kind that reads events returns
        them here and the Run collects them; nothing is written to shared state and no
        later Check reaches back into this one.
    #>

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

    (Get-DataCollection $Data 'Events')
}