Private/Kinds/NetworkProtocol.ps1

# The NetworkProtocol Kind: what Windows logged of a protocol failing, beyond file shares.
#
# The NetworkDisruption Kind reads what the SMB client and the adapter logged. A machine
# talks more than SMB: it signs in to a domain, asks DNS, takes its time and its address
# from a server, proves itself to a switch, and is told by Windows whether there is
# internet. Each of those writes down when it fails, in a log of its own, and a complaint
# nobody can reproduce on the day of the Run is as a rule in one of them.
#
# Which events count is data, as it is for the NetworkDisruption Kind, and read the same
# way. A Check Definition's Scans parameter names them, and the Local Definitions carry
# the entries verified against the event manifests (docs/research/network-protocol-events.md):
#
# { "Log": "System", "Provider": [ "NETLOGON" ], "Id": [ 5719 ],
# "Topic": "domain", which Finding the event belongs to
# "Kind": "domain-no-controller", what the Judge calls it
# "SubjectIndex": 0, optional; payload position of what was affected
# "SubjectType": "host", optional; how to read it: host, spn, peer, file, text
# "OwnDomainOnly": true, optional; a name outside the machine's own
# domain is counted and not kept. The Local
# Definitions do not ask for it: every name is
# named (ADR-0006, amended 2026-10-12)
# "CodeIndex": 1, "CodeType": "win32", optional; a code and whose it is: win32, ras,
# tls-alert, ncsi, text
# "CodePattern": "Reason: (\\w+)", optional; the code out of the message instead
# "Match": { "5": [ 2 ] }, optional; a payload field must hold one of these
# "Except": { "3": [ 631 ] }, optional; a payload field must hold none of these
# "KeepMessage": false } optional; Windows' message is not kept. The
# Local Definitions keep every message
#
# A topic is one of dns, domain, time, dhcp, dot1x, tls, internet and vpn: the wording of
# its Finding is written for it in Private/Text.ps1. How many events make a warning is the
# Topics parameter, per topic:
#
# "Topics": { "domain": { "WarnAbove": 10,
# "WarnKinds": [ "domain-trust-broken" ], warn from the first
# "AwayKinds": [ "domain-no-controller" ] } } expected on a notebook
# away from its network
#
# Two things are not in a log and are read beside it, because they are what the logged
# events are about: how far this clock is from its time source, measured, and what
# Windows says of the internet at the moment of the Run.
#
# What the Gatherer returns:
# Days, Since, UnreadableLogs, ScannedLogs, LogCoverage as the NetworkDisruption Kind
# Events { Time, Provider, Id, Topic, Kind, Subject, Withheld, Cause, Message }, newest first
# Clock { Type, Source, FromDomain, Answered, OffsetSeconds }
# Internet { Networks = { Alias, Connectivity }, ActiveProbeOff }

$script:NetworkProtocolDefaultDays = 30

# The tag these events carry in events.csv.
$script:NetworkProtocolEventTag = 'Network protocol'

# How many of the newest events the Section lists. events.csv holds all of them.
$script:NetworkProtocolSectionRows = 200

# Above how many events of a topic its Finding is a warning, where a Check Definition
# does not say.
$script:NetworkProtocolWarnAbove = 20

# The topics of which nothing is said while their log is quiet: most machines have no
# VPN of Windows' own and no 802.1X at the cable, and "none" would be a line about
# something that is not there.
$script:NetworkProtocolSilentTopics = @('vpn', 'dot1x')

function ConvertTo-NetworkProtocolFieldMap {
    <#
    .SYNOPSIS
        A scan's Match or Except as payload position to the values it names. Pure.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param([AllowNull()]$Map)

    $result = @{}
    if ($null -eq $Map) { return $result }
    $pairs = @()
    if ($Map -is [hashtable]) { $pairs = @($Map.Keys | ForEach-Object { [pscustomobject]@{ Name = "$_"; Value = $Map[$_] } }) }
    else { $pairs = @($Map.PSObject.Properties | ForEach-Object { [pscustomobject]@{ Name = "$($_.Name)"; Value = $_.Value } }) }
    foreach ($pair in $pairs) {
        $index = ConvertTo-Number $pair.Name
        if ($null -eq $index) { continue }
        $result["$([int]$index)"] = [string[]]@(@($pair.Value) | ForEach-Object { "$_".Trim() })
    }
    $result
}

function ConvertTo-NetworkProtocolScan {
    <#
    .SYNOPSIS
        Reads one entry of the Scans parameter. Pure, and reaches nothing.
    .DESCRIPTION
        Log, provider, ids and token are read as the NetworkDisruption Kind reads them. A
        broken entry comes back with a Problem, so the Report can name a mistyped
        Definition instead of reporting a quiet network.
    #>

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

    $base  = ConvertTo-NetworkDisruptionScan -Entry $Entry
    $topic = "$(Get-DataProperty $Entry 'Topic')".Trim().ToLowerInvariant()

    $subjectIndex = ConvertTo-Number (Get-DataProperty $Entry 'SubjectIndex')
    if ($null -ne $subjectIndex) { $subjectIndex = [int]$subjectIndex }
    $codeIndex = ConvertTo-Number (Get-DataProperty $Entry 'CodeIndex')
    if ($null -ne $codeIndex) { $codeIndex = [int]$codeIndex }

    $subjectType = "$(Get-DataProperty $Entry 'SubjectType')".Trim().ToLowerInvariant()
    if (-not $subjectType) { $subjectType = 'host' }

    # Kept unless the Definition says otherwise: "false" is the answer that matters.
    $keep = Get-DataProperty $Entry 'KeepMessage'
    $keepMessage = -not ($null -ne $keep -and "$keep" -match '^(?i)(false|0|no)$')

    $problem = $base.Problem
    if (-not $problem -and -not $topic) { $problem = 'NoTopic' }

    [pscustomobject]@{
        Log           = $base.Log
        Provider      = $base.Provider
        Id            = $base.Id
        Kind          = $base.Kind
        Topic         = $topic
        SubjectIndex  = $subjectIndex
        SubjectType   = $subjectType
        OwnDomainOnly = ("$(Get-DataProperty $Entry 'OwnDomainOnly')" -match '^(?i)(true|1|yes)$')
        CodeIndex     = $codeIndex
        CodeType      = "$(Get-DataProperty $Entry 'CodeType')".Trim().ToLowerInvariant()
        CodePattern   = "$(Get-DataProperty $Entry 'CodePattern')"
        Match         = ConvertTo-NetworkProtocolFieldMap -Map (Get-DataProperty $Entry 'Match')
        Except        = ConvertTo-NetworkProtocolFieldMap -Map (Get-DataProperty $Entry 'Except')
        KeepMessage   = $keepMessage
        Problem       = $problem
    }
}

function Get-NetworkProtocolScan {
    <#
    .SYNOPSIS
        Every entry of a Check Definition's Scans parameter, read. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][hashtable]$Parameters)

    foreach ($entry in @(Get-Parameter $Parameters 'Scans' @())) {
        if ($null -ne $entry) { ConvertTo-NetworkProtocolScan -Entry $entry }
    }
}

function Test-NetworkProtocolPayload {
    <#
    .SYNOPSIS
        Whether an event's payload is what a scan's Match and Except ask for. Pure.
    .DESCRIPTION
        One id means several things by what a field holds: Windows' judgement of the
        internet is one event whether it rose or fell, and a VPN that ended is one event
        whether the line dropped or the user hung up.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)]$LogEntry, [Parameter(Mandatory)]$Scan)

    foreach ($key in @($Scan.Match.Keys)) {
        $value = Get-EventPayload -LogEntry $LogEntry -Index ([int]$key)
        if ($null -eq $value -or @($Scan.Match[$key]) -notcontains "$value".Trim()) { return $false }
    }
    foreach ($key in @($Scan.Except.Keys)) {
        $value = Get-EventPayload -LogEntry $LogEntry -Index ([int]$key)
        if ($null -ne $value -and @($Scan.Except[$key]) -contains "$value".Trim()) { return $false }
    }
    $true
}

function Select-NetworkProtocolScan {
    <#
    .SYNOPSIS
        The scan an event belongs to, or nothing when no scan asked for it. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$LogEntry,
        [Parameter(Mandatory)][string]$Log,
        [AllowNull()][AllowEmptyCollection()]$Scan
    )

    foreach ($entry in @($Scan | Where-Object { $_ -and -not $_.Problem })) {
        if ($entry.Log -ne $Log) { continue }
        if (@($entry.Id) -notcontains [int]$LogEntry.Id) { continue }
        if (@($entry.Provider).Count -and @($entry.Provider) -notcontains "$($LogEntry.ProviderName)") { continue }
        if (-not (Test-NetworkProtocolPayload -LogEntry $LogEntry -Scan $entry)) { continue }
        return $entry
    }
}

function ConvertTo-NetworkProtocolSubject {
    <#
    .SYNOPSIS
        What an event is about, out of the payload field that names it. Pure.
    .DESCRIPTION
        host a Server, as the NetworkDisruption Kind reads one
        spn a service principal name, "cifs/fs01.contoso.example": the host in it
        peer a time source as the time service writes it, "ntp.contoso.example,0x9 (...)"
        file a program by its path: the file's name
        text as it is written, shortened
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Value, [string]$Type = 'host')

    $text = "$Value".Trim()
    if (-not $text) { return '' }
    switch ($Type) {
        'spn' {
            $parts = @($text -split '/')
            if ($parts.Count -gt 1) { $text = $parts[1] }
            $text = ($text -split '[:@]')[0]
            return "$(ConvertTo-NetworkDisruptionServer -Value $text)"
        }
        'peer' {
            $text = ($text -split '[\s,(]')[0]
            return "$(ConvertTo-NetworkDisruptionServer -Value $text)"
        }
        'file' {
            return "$(@($text -split '[\\/]')[-1])".Trim()
        }
        'text' {
            $text = ($text -replace '\s+', ' ')
            if ($text.Length -gt 80) { $text = $text.Substring(0, 80).TrimEnd() + ' [...]' }
            return $text
        }
    }
    "$(ConvertTo-NetworkDisruptionServer -Value $text)"
}

function Test-NetworkProtocolOwnName {
    <#
    .SYNOPSIS
        Whether a name belongs to the machine's own network: a name without a domain, or
        one under a DNS suffix the machine itself carries. Pure.
    .DESCRIPTION
        A name that DNS did not answer for is, more often than a Server, a website. A
        Check Definition that does not want those named asks for it (OwnDomainOnly); the
        names of the machine's own domain are the Servers a Technician is asked about.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyString()][string]$Name, [AllowNull()][AllowEmptyCollection()][string[]]$Suffix)

    $text = "$Name".Trim().TrimEnd('.').ToLowerInvariant()
    if (-not $text) { return $false }
    if ($text -notmatch '\.') { return $true }
    foreach ($entry in @($Suffix | Where-Object { $_ })) {
        $own = "$entry".Trim().Trim('.').ToLowerInvariant()
        if (-not $own) { continue }
        if ($text -eq $own -or $text.EndsWith('.' + $own)) { return $true }
    }
    $false
}

function Get-NetworkProtocolCause {
    <#
    .SYNOPSIS
        The code an event carries with what it means behind it, or the text it gives as
        its reason. Empty where the scan names none.
    .DESCRIPTION
        Never the code alone where anything is known of it: Windows' own sentence for an
        error number of Windows or of its dial-up networking, the standard's word for a
        TLS alert.
    #>

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

    $raw = $null
    if ($Scan.CodePattern) {
        $found = [regex]::Match("$($LogEntry.Message)", $Scan.CodePattern)
        if ($found.Success -and $found.Groups.Count -gt 1) { $raw = $found.Groups[1].Value }
    }
    elseif ($null -ne $Scan.CodeIndex) { $raw = Get-EventPayload -LogEntry $LogEntry -Index $Scan.CodeIndex }
    $raw = "$raw".Trim()
    if (-not $raw) { return '' }

    switch ($Scan.CodeType) {
        'win32' {
            # Netlogon writes the number as a reference to a message: "%%1311".
            $number = $raw -replace '^%+', ''
            $meaning = "$(Get-Win32ErrorText -Code $number)".Trim()
            # Of a long sentence the first: the rest is advice to the person signing in.
            if ($meaning.Length -gt 120) {
                $end = $meaning.IndexOf('. ')
                if ($end -gt 20) { $meaning = $meaning.Substring(0, $end) }
            }
            return (Format-CodeWithMeaning -Code $number -Meaning $meaning.TrimEnd('.'))
        }
        'ras'       { return (Format-CodeWithMeaning -Code $raw -Meaning (Get-RasErrorText -Code $raw)) }
        'tls-alert' { return (Format-CodeWithMeaning -Code $raw -Meaning (Get-TlsAlertText -Alert $raw)) }
        'ncsi'      { return (Format-CodeWithMeaning -Code $raw -Meaning (Get-NetworkProtocolInternetReasonText -Reason $raw)) }
        'text' {
            $text = ($raw -replace '\s+', ' ')
            if ($text.Length -gt 200) { $text = $text.Substring(0, 200).TrimEnd() + ' [...]' }
            return $text
        }
    }
    $raw
}

function Get-NetworkProtocolInternetReasonText {
    <#
    .SYNOPSIS
        What Windows' reason for changing its judgement of the internet means. Nothing for
        a reason not known here. Pure.
    .DESCRIPTION
        The names are Windows' own, as the event renders them in every language. Seen on
        Windows 11; not a table Microsoft documents.
    #>

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

    switch ("$Reason".Trim()) {
        'SuspectDnsProbeFailed'  { return (Get-Text 'Value.NetworkProtocol.Reason.DnsProbe') }
        'SuspectArpProbeFailed'  { return (Get-Text 'Value.NetworkProtocol.Reason.ArpProbe') }
        'NoRoute'                { return (Get-Text 'Value.NetworkProtocol.Reason.NoRoute') }
        'ActiveHttpProbeFailed'  { return (Get-Text 'Value.NetworkProtocol.Reason.HttpProbe') }
        'ActiveDnsProbeFailed'   { return (Get-Text 'Value.NetworkProtocol.Reason.DnsProbe') }
        'CapabilityReset'        { return (Get-Text 'Value.NetworkProtocol.Reason.Reset') }
    }
    ''
}

function ConvertTo-NetworkProtocolRow {
    <#
    .SYNOPSIS
        One event as the row the Judge and the Sections read.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$LogEntry,
        [Parameter(Mandatory)]$Scan,
        # The DNS suffixes the machine carries: what "its own domain" is.
        [AllowNull()][AllowEmptyCollection()][string[]]$Suffix
    )

    $subject  = ''
    $withheld = $false
    if ($null -ne $Scan.SubjectIndex) {
        $subject = ConvertTo-NetworkProtocolSubject -Value (Get-EventPayload -LogEntry $LogEntry -Index $Scan.SubjectIndex) -Type $Scan.SubjectType
        if ($subject -and $Scan.OwnDomainOnly -and -not (Test-NetworkProtocolOwnName -Name $subject -Suffix $Suffix)) {
            $subject  = ''
            $withheld = $true
        }
    }

    $row = ConvertTo-EventRow -LogEntry $LogEntry
    $message = $row.Message
    # Windows' sentence names what is withheld, or an account: then it is not kept, and
    # the row says what happened through its kind, its subject and its cause.
    if ($withheld -or -not $Scan.KeepMessage) { $message = '' }

    [pscustomobject]@{
        Time     = $row.Time
        Provider = $row.Provider
        Id       = $row.Id
        Topic    = $Scan.Topic
        Kind     = $Scan.Kind
        Subject  = $subject
        Withheld = $withheld
        Cause    = Get-NetworkProtocolCause -LogEntry $LogEntry -Scan $Scan
        Message  = $message
    }
}

function Get-NetworkProtocolSuffix {
    <#
    .SYNOPSIS
        The DNS suffixes this machine carries: its domain, its search list, and what its
        connections were given. Never throws.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

    $found = New-Object 'System.Collections.Generic.List[string]'
    try {
        $system = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
        if ($system.PartOfDomain -and $system.Domain) { $found.Add("$($system.Domain)") }
    }
    catch { }
    try {
        if (Get-Command -Name Get-DnsClientGlobalSetting -ErrorAction SilentlyContinue) {
            foreach ($entry in @((Get-DnsClientGlobalSetting -ErrorAction Stop).SuffixSearchList)) { if ($entry) { $found.Add("$entry") } }
        }
    }
    catch { }
    try {
        if (Get-Command -Name Get-DnsClient -ErrorAction SilentlyContinue) {
            foreach ($entry in @(Get-DnsClient -ErrorAction Stop | ForEach-Object { $_.ConnectionSpecificSuffix })) { if ($entry) { $found.Add("$entry") } }
        }
    }
    catch { }
    @($found | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ } | Sort-Object -Unique)
}

function ConvertFrom-SntpResponse {
    <#
    .SYNOPSIS
        How far this clock is from the server's, in seconds, out of the server's answer
        and the two moments it was asked and answered. $null for what is no answer. Pure.
    .DESCRIPTION
        RFC 4330: the offset is ((T2 - T1) + (T3 - T4)) / 2, with T1 and T4 this clock's
        times of asking and of the answer, T2 and T3 the server's of receiving and of
        sending. Positive: this clock is behind. A server that says it has no time of its
        own - stratum 0 - gave no answer.
    #>

    [CmdletBinding()]
    [OutputType([double])]
    param([AllowNull()][byte[]]$Response, [datetime]$Sent, [datetime]$Received)

    if ($null -eq $Response -or $Response.Length -lt 48) { return $null }
    if (($Response[0] -band 0x07) -ne 4) { return $null }
    if ($Response[1] -eq 0) { return $null }

    $epoch = [datetime]::new(1900, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)
    $read  = {
        param([int]$At)
        $seconds  = ([double]$Response[$At] * 16777216) + ([double]$Response[$At + 1] * 65536) + ([double]$Response[$At + 2] * 256) + [double]$Response[$At + 3]
        $fraction = ([double]$Response[$At + 4] * 16777216) + ([double]$Response[$At + 5] * 65536) + ([double]$Response[$At + 6] * 256) + [double]$Response[$At + 7]
        if ($seconds -eq 0) { return $null }
        $epoch.AddSeconds($seconds + ($fraction / 4294967296))
    }
    $t2 = & $read 32
    $t3 = & $read 40
    if ($null -eq $t2 -or $null -eq $t3) { return $null }

    $t1 = $Sent.ToUniversalTime()
    $t4 = $Received.ToUniversalTime()
    [double]((($t2 - $t1).TotalSeconds + ($t3 - $t4).TotalSeconds) / 2)
}

function Get-SntpOffset {
    <#
    .SYNOPSIS
        Asks a time server once and returns how far this clock is from it, in seconds.
        $null when it does not answer. Changes nothing: the clock is read, not set.
    #>

    [CmdletBinding()]
    [OutputType([double])]
    param([Parameter(Mandatory)][string]$Server, [int]$TimeoutMs = 2000)

    $client = $null
    try {
        $client = New-Object System.Net.Sockets.UdpClient
        $client.Client.ReceiveTimeout = $TimeoutMs
        $client.Client.SendTimeout    = $TimeoutMs
        $client.Connect($Server, 123)

        # Version 4, mode 3: a client asking.
        $request = New-Object byte[] 48
        $request[0] = 0x23
        $sent = [datetime]::UtcNow
        $null = $client.Send($request, $request.Length)
        $from = New-Object System.Net.IPEndPoint ([System.Net.IPAddress]::Any, 0)
        $response = $client.Receive([ref]$from)
        $received = [datetime]::UtcNow
        ConvertFrom-SntpResponse -Response $response -Sent $sent -Received $received
    }
    catch { $null }
    finally { if ($client) { try { $client.Close() } catch { } } }
}

function Select-NetworkProtocolTimeSource {
    <#
    .SYNOPSIS
        Where this machine takes its time from, out of how the time service is set. Pure.
    .DESCRIPTION
        Type NT5DS is the domain; NTP is the servers named; AllSync is both, and the
        named servers are the ones that can be asked. A server is written with flags
        behind a comma, "ntp.contoso.example,0x9", and several with spaces between.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyString()][string]$Type, [AllowNull()][AllowEmptyString()][string]$NtpServer, [AllowNull()][AllowEmptyString()][string]$Domain)

    $kind   = "$Type".Trim()
    $manual = "$(@("$NtpServer".Trim() -split '\s+' | Where-Object { $_ })[0])"
    $manual = ($manual -split ',')[0].Trim()

    $source = ''
    $fromDomain = $false
    if ($kind -eq 'NoSync') { $source = '' }
    elseif ($kind -eq 'NT5DS' -or ($kind -eq 'AllSync' -and -not $manual)) { $source = "$Domain".Trim(); $fromDomain = [bool]$source }
    else { $source = $manual }

    [pscustomobject]@{ Type = $kind; Source = $source; FromDomain = $fromDomain }
}

function Get-NetworkProtocolClock {
    <#
    .SYNOPSIS
        How the time service is set, and how far this clock is from the source it names.
        Decides nothing, and never throws.
    #>

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

    $type = ''
    $server = ''
    try {
        $settings = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -ErrorAction Stop
        $type   = "$($settings.Type)"
        $server = "$($settings.NtpServer)"
    }
    catch { }

    $domain = ''
    try {
        $system = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
        if ($system.PartOfDomain) { $domain = "$($system.Domain)" }
    }
    catch { }

    $source = Select-NetworkProtocolTimeSource -Type $type -NtpServer $server -Domain $domain
    $offset = $null
    if ($source.Source) { $offset = Get-SntpOffset -Server $source.Source }

    [pscustomobject]@{
        Type          = $source.Type
        Source        = $source.Source
        FromDomain    = $source.FromDomain
        Answered      = ($null -ne $offset)
        OffsetSeconds = $offset
    }
}

function Get-NetworkProtocolInternetState {
    <#
    .SYNOPSIS
        What Windows says of the internet on each connected network, and whether its own
        probe is switched off. Decides nothing, and never throws.
    #>

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

    $networks = @()
    try {
        if (Get-Command -Name Get-NetConnectionProfile -ErrorAction SilentlyContinue) {
            $networks = @(Get-NetConnectionProfile -ErrorAction Stop | ForEach-Object {
                # The better of the two: a network with internet over IPv6 only has it.
                $both = @("$($_.IPv4Connectivity)", "$($_.IPv6Connectivity)")
                $best = @('Internet', 'LocalNetwork', 'Subnet', 'NoTraffic', 'Disconnected' | Where-Object { $both -contains $_ })[0]
                [pscustomobject]@{ Alias = "$($_.InterfaceAlias)"; Connectivity = "$best" }
            })
        }
    }
    catch { $networks = @() }

    $off = $false
    try {
        $policy = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\NetworkConnectivityStatusIndicator' -ErrorAction Stop
        if ("$($policy.NoActiveProbe)" -eq '1') { $off = $true }
    }
    catch { }
    try {
        $setting = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\NlaSvc\Parameters\Internet' -ErrorAction Stop
        if ("$($setting.EnableActiveProbing)" -eq '0') { $off = $true }
    }
    catch { }

    [pscustomobject]@{ Networks = $networks; ActiveProbeOff = $off }
}

function Get-NetworkProtocolData {
    <#
    .SYNOPSIS
        Reads the events a Check Definition names, the clock against its source, and
        what Windows says of the internet. Judges none of it.
    #>

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

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

    $scans      = @(Get-NetworkProtocolScan -Parameters $Parameters)
    $suffixes   = @(Get-NetworkProtocolSuffix)
    $unreadable = @{}
    # Not List[object]: @($x) throws on one of those. See Public/Invoke-Gutcheck.ps1.
    $rows       = New-Object System.Collections.Generic.List[psobject]
    $scanned    = New-Object System.Collections.Generic.List[psobject]

    $queries = @(Select-NetworkDisruptionQuery -Query @(Get-NetworkDisruptionQuery -Scan $scans) `
                                               -Registration (Get-NetworkDisruptionRegistration))
    foreach ($query in $queries) {
        if (@($scanned | Where-Object { $_ -eq $query.Log }).Count -eq 0) { $scanned.Add($query.Log) }

        $found = @(Get-StabilityEvent -Log $query.Log -Provider $query.Provider -Id $query.Id `
                                      -Since $since -Unreadable $unreadable)
        foreach ($entry in $found) {
            $scan = Select-NetworkProtocolScan -LogEntry $entry -Log $query.Log -Scan $scans
            if ($scan) { $rows.Add((ConvertTo-NetworkProtocolRow -LogEntry $entry -Scan $scan -Suffix $suffixes)) }
        }
    }

    # Materialised before it is sorted, for the reason Get-StabilityData gives.
    $unreadableLogs = @($unreadable.Keys)
    $coverage = @(foreach ($log in $scanned) {
        if ($unreadable.ContainsKey($log)) { continue }
        [pscustomobject]@{ Log = $log; Oldest = Get-NetworkDisruptionLogOldest -Log $log }
    })

    [pscustomobject]@{
        PSTypeName     = 'Gutcheck.Data.NetworkProtocol'
        Days           = $days
        Since          = $since
        Events         = @($rows | Sort-Object Time -Descending)
        UnreadableLogs = @($unreadableLogs | Sort-Object)
        ScannedLogs    = [string[]]@($scanned)
        LogCoverage    = $coverage
        Clock          = Get-NetworkProtocolClock
        Internet       = Get-NetworkProtocolInternetState
    }
}

function Get-NetworkProtocolTopicText {
    <#
    .SYNOPSIS
        The wording of a topic: what its Finding is called, what it means, what to do,
        and its name in a table. Pure.
    .DESCRIPTION
        A literal Get-Text per topic and no computed key, so that the tests can hold
        every key to being defined and to being used. A topic a Check Definition
        invented is reported under its own name, with what fits every topic.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Topic, [Parameter(Mandatory)][ValidateSet('Check', 'Meaning', 'Hint', 'Name')][string]$Part)

    switch ("$Topic|$Part") {
        'dns|Check'        { return (Get-Text 'Check.NetworkProtocol.Dns') }
        'dns|Meaning'      { return (Get-Text 'Meaning.NetworkProtocol.Dns') }
        'dns|Hint'         { return (Get-Text 'Hint.NetworkProtocol.Dns') }
        'dns|Name'         { return (Get-Text 'Value.NetworkProtocol.Topic.Dns') }
        'domain|Check'     { return (Get-Text 'Check.NetworkProtocol.Domain') }
        'domain|Meaning'   { return (Get-Text 'Meaning.NetworkProtocol.Domain') }
        'domain|Hint'      { return (Get-Text 'Hint.NetworkProtocol.Domain') }
        'domain|Name'      { return (Get-Text 'Value.NetworkProtocol.Topic.Domain') }
        'time|Check'       { return (Get-Text 'Check.NetworkProtocol.Time') }
        'time|Meaning'     { return (Get-Text 'Meaning.NetworkProtocol.Time') }
        'time|Hint'        { return (Get-Text 'Hint.NetworkProtocol.Time') }
        'time|Name'        { return (Get-Text 'Value.NetworkProtocol.Topic.Time') }
        'dhcp|Check'       { return (Get-Text 'Check.NetworkProtocol.Dhcp') }
        'dhcp|Meaning'     { return (Get-Text 'Meaning.NetworkProtocol.Dhcp') }
        'dhcp|Hint'        { return (Get-Text 'Hint.NetworkProtocol.Dhcp') }
        'dhcp|Name'        { return (Get-Text 'Value.NetworkProtocol.Topic.Dhcp') }
        'dot1x|Check'      { return (Get-Text 'Check.NetworkProtocol.Dot1x') }
        'dot1x|Meaning'    { return (Get-Text 'Meaning.NetworkProtocol.Dot1x') }
        'dot1x|Hint'       { return (Get-Text 'Hint.NetworkProtocol.Dot1x') }
        'dot1x|Name'       { return (Get-Text 'Value.NetworkProtocol.Topic.Dot1x') }
        'tls|Check'        { return (Get-Text 'Check.NetworkProtocol.Tls') }
        'tls|Meaning'      { return (Get-Text 'Meaning.NetworkProtocol.Tls') }
        'tls|Hint'         { return (Get-Text 'Hint.NetworkProtocol.Tls') }
        'tls|Name'         { return (Get-Text 'Value.NetworkProtocol.Topic.Tls') }
        'internet|Check'   { return (Get-Text 'Check.NetworkProtocol.Internet') }
        'internet|Meaning' { return (Get-Text 'Meaning.NetworkProtocol.Internet') }
        'internet|Hint'    { return (Get-Text 'Hint.NetworkProtocol.Internet') }
        'internet|Name'    { return (Get-Text 'Value.NetworkProtocol.Topic.Internet') }
        'vpn|Check'        { return (Get-Text 'Check.NetworkProtocol.Vpn') }
        'vpn|Meaning'      { return (Get-Text 'Meaning.NetworkProtocol.Vpn') }
        'vpn|Hint'         { return (Get-Text 'Hint.NetworkProtocol.Vpn') }
        'vpn|Name'         { return (Get-Text 'Value.NetworkProtocol.Topic.Vpn') }
    }
    switch ($Part) {
        'Check'   { return ((Get-Text 'Check.NetworkProtocol.Other') -f "$Topic") }
        'Meaning' { return (Get-Text 'Meaning.NetworkProtocol.Other') }
        'Hint'    { return (Get-Text 'Hint.NetworkProtocol.Other') }
    }
    "$Topic"
}

function Get-NetworkProtocolKindText {
    <#
    .SYNOPSIS
        What the Report calls a Kind token. Pure.
    .DESCRIPTION
        The token is data, as in the NetworkDisruption Kind; a Technician reads German.
        A token a Check Definition invented is shown as it came rather than hidden.
    #>

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

    switch ($Kind) {
        'dns-timeout'              { return (Get-Text 'Value.NetworkProtocol.Kind.DnsTimeout') }
        'domain-no-controller'     { return (Get-Text 'Value.NetworkProtocol.Kind.DomainNoController') }
        'domain-trust-broken'      { return (Get-Text 'Value.NetworkProtocol.Kind.DomainTrustBroken') }
        'domain-controller-silent' { return (Get-Text 'Value.NetworkProtocol.Kind.DomainControllerSilent') }
        'kerberos-ticket-refused'  { return (Get-Text 'Value.NetworkProtocol.Kind.KerberosTicketRefused') }
        'kerberos-clock'           { return (Get-Text 'Value.NetworkProtocol.Kind.KerberosClock') }
        'auth-failed'              { return (Get-Text 'Value.NetworkProtocol.Kind.AuthFailed') }
        'auth-no-protocol'         { return (Get-Text 'Value.NetworkProtocol.Kind.AuthNoProtocol') }
        'time-source-silent'       { return (Get-Text 'Value.NetworkProtocol.Kind.TimeSourceSilent') }
        'time-no-source'           { return (Get-Text 'Value.NetworkProtocol.Kind.TimeNoSource') }
        'time-unsynced'            { return (Get-Text 'Value.NetworkProtocol.Kind.TimeUnsynced') }
        'time-jump'                { return (Get-Text 'Value.NetworkProtocol.Kind.TimeJump') }
        'time-too-far'             { return (Get-Text 'Value.NetworkProtocol.Kind.TimeTooFar') }
        'dhcp-lease-lost'          { return (Get-Text 'Value.NetworkProtocol.Kind.DhcpLeaseLost') }
        'dhcp-no-address'          { return (Get-Text 'Value.NetworkProtocol.Kind.DhcpNoAddress') }
        'dhcp-refused'             { return (Get-Text 'Value.NetworkProtocol.Kind.DhcpRefused') }
        'dhcp-renew-failed'        { return (Get-Text 'Value.NetworkProtocol.Kind.DhcpRenewFailed') }
        'dhcp-conflict'            { return (Get-Text 'Value.NetworkProtocol.Kind.DhcpConflict') }
        'dhcp-config-failed'       { return (Get-Text 'Value.NetworkProtocol.Kind.DhcpConfigFailed') }
        'dhcp-autoconfig'          { return (Get-Text 'Value.NetworkProtocol.Kind.DhcpAutoconfig') }
        'dot1x-auth-failed'        { return (Get-Text 'Value.NetworkProtocol.Kind.Dot1xAuthFailed') }
        'tls-alert-received'       { return (Get-Text 'Value.NetworkProtocol.Kind.TlsAlertReceived') }
        'tls-alert-sent'           { return (Get-Text 'Value.NetworkProtocol.Kind.TlsAlertSent') }
        'tls-no-credentials'       { return (Get-Text 'Value.NetworkProtocol.Kind.TlsNoCredentials') }
        'tls-cert-expired'         { return (Get-Text 'Value.NetworkProtocol.Kind.TlsCertExpired') }
        'tls-cert-untrusted'       { return (Get-Text 'Value.NetworkProtocol.Kind.TlsCertUntrusted') }
        'tls-cert-name'            { return (Get-Text 'Value.NetworkProtocol.Kind.TlsCertName') }
        'internet-lost'            { return (Get-Text 'Value.NetworkProtocol.Kind.InternetLost') }
        'vpn-connect-failed'       { return (Get-Text 'Value.NetworkProtocol.Kind.VpnConnectFailed') }
        'vpn-dropped'              { return (Get-Text 'Value.NetworkProtocol.Kind.VpnDropped') }
    }
    "$Kind"
}

function Format-NetworkProtocolCount {
    <#
    .SYNOPSIS
        Names with how often each, the most frequent first, as the items of a list: the
        first few, and how many more there are. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Name, [int]$First = 5)

    $groups = @(@($Name | Where-Object { $_ }) | Group-Object | Sort-Object @{ Expression = 'Count'; Descending = $true }, Name)
    if (-not $groups.Count) { return '' }
    $items = @($groups | Select-Object -First $First | ForEach-Object { '{0} {1}x' -f $_.Name, $_.Count })
    if ($groups.Count -gt $First) { $items += (Get-Text 'Value.NetworkProtocol.More') -f ($groups.Count - $First) }
    $items -join '; '
}

function Get-NetworkProtocolTopicSetting {
    <#
    .SYNOPSIS
        What a Check Definition says of one topic: from how many events it warns, which
        kinds warn at once, and which are expected away from the network. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][hashtable]$Parameters, [AllowNull()][AllowEmptyString()][string]$Topic)

    $entry = Get-DataProperty (Get-Parameter $Parameters 'Topics' @{}) "$Topic"
    $warn  = ConvertTo-Number (Get-DataProperty $entry 'WarnAbove')
    if ($null -eq $warn) { $warn = $script:NetworkProtocolWarnAbove }
    [pscustomobject]@{
        WarnAbove = [double]$warn
        WarnKinds = [string[]]@(@(Get-DataProperty $entry 'WarnKinds') | ForEach-Object { "$_" } | Where-Object { $_ })
        AwayKinds = [string[]]@(@(Get-DataProperty $entry 'AwayKinds') | ForEach-Object { "$_" } | Where-Object { $_ })
    }
}

function New-NetworkProtocolTopicFinding {
    <#
    .SYNOPSIS
        The Finding of one topic: what Windows logged of it in the period, or that it
        logged nothing.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$Data, [hashtable]$Parameters, [AllowNull()]$Situation,
        [Parameter(Mandatory)][string]$Topic,
        [AllowNull()][AllowEmptyCollection()]$Scan
    )

    $check = Get-NetworkProtocolTopicText -Topic $Topic -Part Check
    $logs  = [string[]]@($Scan | ForEach-Object { $_.Log } | Sort-Object -Unique)
    $rows  = @((Get-DataCollection $Data 'Events') | Where-Object { "$(Get-DataProperty $_ 'Topic')" -eq $Topic })

    if (-not $rows.Count) {
        if ($script:NetworkProtocolSilentTopics -contains $Topic) { return }
        # Nothing to say of a domain on a machine that is in none.
        if ($Topic -eq 'domain') {
            $joined = Select-SituationText -Situation $Situation -Fact Domain -Text @{ Joined = 'yes'; Unknown = '' }
            if (-not $joined) { return }
        }
        $unread = @((Get-DataCollection $Data 'UnreadableLogs') | Where-Object { $logs -contains $_ })
        return New-NetworkDisruptionQuietFinding -Data $Data -Check $check -Unread $unread -Log $logs
    }

    $setting = Get-NetworkProtocolTopicSetting -Parameters $Parameters -Topic $Topic

    # A notebook away from its network cannot reach its domain controller or its time
    # source, and logs that every day: expected, and no count of it is a warning.
    $away = [bool](Select-SituationText -Situation $Situation -Fact Form -Text @{ Notebook = 'yes'; Unknown = '' })
    $awayRows = @($rows | Where-Object { $setting.AwayKinds -contains "$(Get-DataProperty $_ 'Kind')" })
    $counted  = $rows.Count
    if ($away) { $counted = $rows.Count - $awayRows.Count }

    $severity = 'INFO'
    if ($counted -gt $setting.WarnAbove) { $severity = 'WARN' }
    # A kind that warns from the first event does so on a notebook too, unless it is one
    # of those expected away: a clock that could not be set for a day at home is no fault.
    if (@($rows | Where-Object {
            $token = "$(Get-DataProperty $_ 'Kind')"
            $setting.WarnKinds -contains $token -and -not ($away -and $setting.AwayKinds -contains $token) }).Count) { $severity = 'WARN' }

    $times = @($rows | ForEach-Object { ConvertTo-DataTime (Get-DataProperty $_ 'Time') } | Where-Object { $null -ne $_ } | Sort-Object)
    $value = @(Get-EventCountText -Count $rows.Count -Data $Data)
    if ($times.Count) {
        $onDays = @($times | ForEach-Object { $_.Date } | Sort-Object -Unique).Count
        $value  = @((Get-Text 'Value.NetworkProtocol.Spread') -f $value[0], $onDays, $times[-1])
    }
    $value += (Get-Text 'Value.NetworkProtocol.Kinds') -f (Format-NetworkProtocolCount -First 6 -Name @(
        $rows | ForEach-Object { Get-NetworkProtocolKindText -Kind "$(Get-DataProperty $_ 'Kind')" }))

    $causes = Format-NetworkProtocolCount -First 3 -Name @($rows | ForEach-Object { "$(Get-DataProperty $_ 'Cause')" })
    if ($causes) { $value += (Get-Text 'Value.NetworkProtocol.Causes') -f $causes }

    $subjects = Format-NetworkProtocolCount -First 5 -Name @($rows | ForEach-Object { "$(Get-DataProperty $_ 'Subject')" })
    $withheld = @($rows | Where-Object { Get-DataProperty $_ 'Withheld' }).Count
    if ($withheld) {
        $hidden = (Get-Text 'Value.NetworkProtocol.Withheld') -f $withheld
        if ($subjects) { $subjects = '{0}; {1}' -f $subjects, $hidden } else { $subjects = $hidden }
    }
    if ($subjects) { $value += (Get-Text 'Value.NetworkProtocol.Subjects') -f $subjects }

    $meaning = @(Get-NetworkProtocolTopicText -Topic $Topic -Part Meaning)
    if ($away -and $awayRows.Count) { $meaning += Get-Text 'Meaning.NetworkProtocol.Away' }

    New-Finding -Category Network -Check $check -Severity $severity `
        -Value ($value -join ' | ') `
        -Meaning (@($meaning | Where-Object { $_ }) -join ' | ') `
        -Hint (Get-NetworkProtocolTopicText -Topic $Topic -Part Hint) `
        -Reference @(New-Reference -Section 'Title.NetworkProtocol.Events')
}

function New-NetworkProtocolClockFinding {
    <#
    .SYNOPSIS
        How far this clock is from its time source. Nothing where that was not gathered.
    .DESCRIPTION
        Kerberos refuses a ticket from a clock more than five minutes off, and that is
        where it fails. A source that does not answer is not a clock that is wrong.
    #>

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

    $clock = Get-DataProperty $Data 'Clock'
    if ($null -eq $clock) { return }

    $check  = Get-Text 'Check.NetworkProtocol.Clock'
    $source = "$(Get-DataProperty $clock 'Source')".Trim()
    if (-not $source) {
        return New-Finding -Category Network -Check $check -Severity INFO `
            -Value (Get-Text 'Value.NetworkProtocol.Clock.NoSource') `
            -Meaning (Get-Text 'Meaning.NetworkProtocol.Clock.NoSource') `
            -Hint (Get-Text 'Hint.NetworkProtocol.Clock.NoSource')
    }

    $named = $source
    if (Get-DataProperty $clock 'FromDomain') { $named = (Get-Text 'Value.NetworkProtocol.Clock.Domain') -f $source }

    $offset = ConvertTo-Number (Get-DataProperty $clock 'OffsetSeconds')
    if ($null -eq $offset) {
        return New-Finding -Category Network -Check $check -Severity INFO `
            -Value ((Get-Text 'Value.NetworkProtocol.Clock.NoAnswer') -f $named) `
            -Meaning (Get-Text 'Meaning.NetworkProtocol.Clock.NoAnswer') `
            -Hint (Get-Text 'Hint.NetworkProtocol.Clock.NoAnswer')
    }

    $warn = [double](Get-Parameter $Parameters 'ClockWarnSeconds' 60)
    $fail = [double](Get-Parameter $Parameters 'ClockFailSeconds' 300)
    $severity = Get-Severity ([math]::Abs([double]$offset)) $warn $fail

    $direction = Get-Text 'Value.NetworkProtocol.Clock.Behind'
    if ([double]$offset -lt 0) { $direction = Get-Text 'Value.NetworkProtocol.Clock.Ahead' }
    $value = (Get-Text 'Value.NetworkProtocol.Clock.Offset') -f ([math]::Abs([double]$offset)), $direction, $named
    if ($severity -eq 'OK') { return New-Finding -Category Network -Check $check -Severity OK -Value $value }

    New-Finding -Category Network -Check $check -Severity $severity -Value $value `
        -Meaning (Get-Text 'Meaning.NetworkProtocol.Clock.Offset') `
        -Hint (Get-Text 'Hint.NetworkProtocol.Clock.Offset')
}

function Get-NetworkProtocolConnectivityText {
    <#
    .SYNOPSIS
        Windows' judgement of a network, in the Report's words. Pure.
    #>

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

    switch ("$Connectivity") {
        'Internet'     { return (Get-Text 'Value.NetworkProtocol.Connectivity.Internet') }
        'LocalNetwork' { return (Get-Text 'Value.NetworkProtocol.Connectivity.Local') }
        'Subnet'       { return (Get-Text 'Value.NetworkProtocol.Connectivity.Subnet') }
        'NoTraffic'    { return (Get-Text 'Value.NetworkProtocol.Connectivity.NoTraffic') }
    }
    "$Connectivity"
}

function New-NetworkProtocolInternetFinding {
    <#
    .SYNOPSIS
        What Windows says of the internet now. Nothing where no network is connected or
        that was not gathered.
    #>

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

    $state    = Get-DataProperty $Data 'Internet'
    $networks = @((Get-DataCollection $state 'Networks') | Where-Object {
        "$(Get-DataProperty $_ 'Connectivity')" -and "$(Get-DataProperty $_ 'Connectivity')" -ne 'Disconnected' })
    if (-not $networks.Count) { return }

    $check = Get-Text 'Check.NetworkProtocol.InternetState'
    $named = @($networks | ForEach-Object {
        '{0}: {1}' -f "$(Get-DataProperty $_ 'Alias')", (Get-NetworkProtocolConnectivityText -Connectivity "$(Get-DataProperty $_ 'Connectivity')") }) -join '; '
    $value = @((Get-Text 'Value.NetworkProtocol.InternetState') -f $named)
    $off   = [bool](Get-DataProperty $state 'ActiveProbeOff')
    if ($off) { $value += Get-Text 'Value.NetworkProtocol.InternetState.ProbeOff' }

    $online = @($networks | Where-Object { "$(Get-DataProperty $_ 'Connectivity')" -eq 'Internet' }).Count
    if (-not $online) {
        return New-Finding -Category Network -Check $check -Severity WARN -Value ($value -join ' | ') `
            -Meaning (Get-Text 'Meaning.NetworkProtocol.InternetState.None') `
            -Hint (Get-Text 'Hint.NetworkProtocol.InternetState.None')
    }
    if ($off) {
        return New-Finding -Category Network -Check $check -Severity INFO -Value ($value -join ' | ') `
            -Meaning (Get-Text 'Meaning.NetworkProtocol.InternetState.ProbeOff') `
            -Hint (Get-Text 'Hint.NetworkProtocol.InternetState.ProbeOff')
    }
    New-Finding -Category Network -Check $check -Severity OK -Value ($value -join ' | ')
}

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

    $scans  = @(Get-NetworkProtocolScan -Parameters $Parameters)
    $broken = @($scans | Where-Object { $_.Problem })

    if ($broken.Count -eq $scans.Count) {
        # Nothing was asked for, so nothing was read: that is not a quiet network.
        New-Finding -Category Gutcheck -Check (Get-Text 'Check.NetworkProtocol.Definition') -Severity INFO `
            -Value (Get-Text 'Value.NetworkProtocol.NoScans') `
            -Hint (Get-Text 'Hint.NetworkProtocol.FixDefinition')
    }
    elseif ($broken.Count) {
        New-Finding -Category Gutcheck -Check (Get-Text 'Check.NetworkProtocol.Definition') -Severity INFO `
            -Value ((Get-Text 'Value.NetworkProtocol.BrokenScans') -f $broken.Count, $scans.Count) `
            -Hint (Get-Text 'Hint.NetworkProtocol.FixDefinition')
    }

    # One Finding per topic, in the order the Definition first names each.
    $good   = @($scans | Where-Object { -not $_.Problem })
    $topics = New-Object 'System.Collections.Generic.List[string]'
    foreach ($scan in $good) { if (-not $topics.Contains($scan.Topic)) { $topics.Add($scan.Topic) } }
    foreach ($topic in $topics) {
        New-NetworkProtocolTopicFinding -Data $Data -Parameters $Parameters -Situation $Situation `
            -Topic $topic -Scan @($good | Where-Object { $_.Topic -eq $topic })
    }

    New-NetworkProtocolClockFinding -Data $Data -Parameters $Parameters
    New-NetworkProtocolInternetFinding -Data $Data
    New-UnreadableLogFinding -Data $Data -Parameters $Parameters
}

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

    $rows = @((Get-DataCollection $Data 'Events') | Sort-Object { Get-DataProperty $_ 'Time' } -Descending)

    $count    = Get-Text 'Column.NetworkDisruption.Count'
    $topic    = Get-Text 'Column.NetworkProtocol.Topic'
    $kind     = Get-Text 'Column.NetworkDisruption.Kind'
    $subject  = Get-Text 'Column.NetworkProtocol.Subject'
    $cause    = Get-Text 'Column.NetworkProtocol.Cause'
    $first    = Get-Text 'Column.NetworkDisruption.First'
    $last     = Get-Text 'Column.NetworkDisruption.Last'
    $time     = Get-Text 'Column.NetworkDisruption.Time'
    $provider = Get-Text 'Column.NetworkDisruption.Provider'
    $id       = Get-Text 'Column.NetworkDisruption.Id'
    $message  = Get-Text 'Column.NetworkDisruption.Message'
    $hidden   = Get-Text 'Value.NetworkProtocol.SubjectWithheld'
    $named    = {
        param($Row)
        if (Get-DataProperty $Row 'Withheld') { return $hidden }
        "$(Get-DataProperty $Row 'Subject')"
    }

    New-Section -Title (Get-Text 'Title.NetworkProtocol.Summary') -Help (Get-Text 'Help.NetworkProtocol.Summary') -Row @(
        $rows | Group-Object { '{0}|{1}|{2}|{3}' -f (Get-DataProperty $_ 'Topic'), (Get-DataProperty $_ 'Kind'), (& $named $_), (Get-DataProperty $_ 'Cause') } |
            Sort-Object @{ Expression = 'Count'; Descending = $true }, Name |
            ForEach-Object {
                $row = [ordered]@{}
                $row[$count]   = $_.Count
                $row[$topic]   = Get-NetworkProtocolTopicText -Topic "$(Get-DataProperty $_.Group[0] 'Topic')" -Part Name
                $row[$kind]    = Get-NetworkProtocolKindText -Kind "$(Get-DataProperty $_.Group[0] 'Kind')"
                $row[$subject] = & $named $_.Group[0]
                $row[$cause]   = "$(Get-DataProperty $_.Group[0] 'Cause')"
                $row[$first]   = Get-DataProperty $_.Group[-1] 'Time'
                $row[$last]    = Get-DataProperty $_.Group[0] 'Time'
                [pscustomobject]$row
            }
    )

    New-Section -Title (Get-Text 'Title.NetworkProtocol.Events') -Help (Get-Text 'Help.NetworkProtocol.Events') -Row @(
        $rows | Select-Object -First $script:NetworkProtocolSectionRows | ForEach-Object {
            $row = [ordered]@{}
            $row[$time]     = Get-DataProperty $_ 'Time'
            $row[$topic]    = Get-NetworkProtocolTopicText -Topic "$(Get-DataProperty $_ 'Topic')" -Part Name
            $row[$kind]     = Get-NetworkProtocolKindText -Kind "$(Get-DataProperty $_ 'Kind')"
            $row[$subject]  = & $named $_
            $row[$cause]    = "$(Get-DataProperty $_ 'Cause')"
            $row[$provider] = Get-DataProperty $_ 'Provider'
            $row[$id]       = Get-DataProperty $_ 'Id'
            $row[$message]  = Get-DataProperty $_ 'Message'
            [pscustomobject]$row
        }
    )
}

function ConvertTo-NetworkProtocolEvent {
    <#
    .SYNOPSIS
        The events this Check gathered, for the Report's events.csv.
    .DESCRIPTION
        A row whose message was not kept says what it is through its kind, what it was
        about and its cause: a line without any text would be a line nobody can read.
    #>

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

    foreach ($row in (Get-DataCollection $Data 'Events')) {
        $message = "$(Get-DataProperty $row 'Message')"
        if (-not $message) {
            $message = @(@(
                (Get-NetworkProtocolKindText -Kind "$(Get-DataProperty $row 'Kind')")
                "$(Get-DataProperty $row 'Subject')"
                "$(Get-DataProperty $row 'Cause')") | Where-Object { $_ }) -join ' | '
        }
        [pscustomobject]@{
            Time     = Get-DataProperty $row 'Time'
            Category = $script:NetworkProtocolEventTag
            Provider = Get-DataProperty $row 'Provider'
            Id       = Get-DataProperty $row 'Id'
            Message  = $message
        }
    }
}