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,
# ntstatus, 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
# "QuietKinds": [ "domain-kerberos-fallback" ] } } known, and without
# consequence: shown, never
# a warning
#
# 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 }
# Dns { Domain, SearchList, Adapters = { Alias, Suffix, Servers },
# Probes = { Server, Name, Type, State, Milliseconds, Code } }: what the
# machine is set to ask, and what each of those servers answered when
# asked by itself
# Internet { Networks = { Alias, Connectivity }, ActiveProbeOff }
# MachinePassword { AgeDays, MaximumAgeDays, ChangeDisabled }: how old the password of
# the computer account is, as the domain has it; AgeDays $null where the
# domain was not asked or did not say

$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)) }
        'ntstatus'  {
            # A status of Windows is written in hexadecimal wherever it is looked up; the
            # event carries it as a number, 3221225566 for 0xC000005E.
            $number = ConvertTo-CodeNumber -Code $raw
            if ($null -eq $number) { return $raw }
            $code = '0x{0:X8}' -f ($number -band 0xFFFFFFFFL)
            return (Format-CodeWithMeaning -Code $code -Meaning (Get-NtStatusText -Code $code))
        }
        '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 ConvertTo-NetworkProtocolDnsState {
    <#
    .SYNOPSIS
        What a DNS server did with a question, out of the error number the question
        ended with. Pure.
    .DESCRIPTION
        Answered it answered with records
        NameUnknown it answered that there is no such name (9003): a server that does
                     not know the domain it was asked about
        NoAnswer it did not answer in time (1460), or could not be reached
        Refused it answered that it will not (9005)
        Failed anything else; the number is kept
        The numbers are Windows' own for DNS (winerror.h, DNS_ERROR_RCODE_*).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Code, [bool]$Answered)

    if ($Answered) { return 'Answered' }
    $number = ConvertTo-Number $Code
    if ($null -eq $number) { return 'NoAnswer' }
    switch ([int]$number) {
        9003  { return 'NameUnknown' }
        9501  { return 'NameUnknown' }
        9005  { return 'Refused' }
        1460  { return 'NoAnswer' }
        9002  { return 'Failed' }
    }
    'Failed'
}

function Get-NetworkProtocolDns {
    <#
    .SYNOPSIS
        Which DNS servers this machine is set to ask, with which suffixes, and what each
        of those servers answers when asked by itself. Decides nothing; never throws.
    .DESCRIPTION
        Windows asks the first server and goes on to the next when that one is silent:
        with two servers and one of them dead, every name still resolves - after a wait,
        some of the time. Asked now through Windows, DNS is in order. So each server is
        asked alone.
 
        What it is asked for is what it has to know: on a machine of a domain, the
        record the domain's controllers are found by (_ldap._tcp.dc._msdcs.<domain>) - a
        server that answers "no such name" to that is not one of the domain's, and the
        router or a public resolver entered beside the domain's own is the classic way
        to a logon that sometimes takes a minute. Elsewhere, the name a Check Definition
        gives.
 
        Only the adapters that are up, and no more than a handful of servers. The
        addresses Windows enters by itself where none is given (fec0:0:0:ffff::) are
        left out: nobody answers at them.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([string]$ProbeName = 'www.msftconnecttest.com', [int]$Maximum = 8)

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

    $search = @()
    try { $search = @((Get-DnsClientGlobalSetting -ErrorAction Stop).SuffixSearchList | Where-Object { $_ } | ForEach-Object { "$_" }) } catch { }

    $adapters = @()
    try {
        $up = @(Get-NetAdapter -ErrorAction Stop | Where-Object { "$($_.Status)" -eq 'Up' } | ForEach-Object { "$($_.Name)" })
        $suffix = @{}
        foreach ($client in @(Get-DnsClient -ErrorAction SilentlyContinue)) { $suffix["$($client.InterfaceAlias)"] = "$($client.ConnectionSpecificSuffix)" }
        $adapters = @(Get-DnsClientServerAddress -ErrorAction Stop | Where-Object { $up -contains "$($_.InterfaceAlias)" } |
            Group-Object InterfaceAlias | ForEach-Object {
                $servers = @($_.Group | ForEach-Object { $_.ServerAddresses } | Where-Object { $_ -and "$_" -notmatch '^(?i)fec0:0:0:ffff:' } |
                    ForEach-Object { "$_" } | Select-Object -Unique)
                if ($servers.Count) { [pscustomobject]@{ Alias = "$($_.Name)"; Suffix = "$($suffix["$($_.Name)"])"; Servers = $servers } }
            })
    }
    catch { $adapters = @() }

    $name = $ProbeName
    $type = 'A'
    if ($domain) { $name = '_ldap._tcp.dc._msdcs.{0}' -f $domain; $type = 'SRV' }

    $probes = @()
    if (Get-Command -Name Resolve-DnsName -ErrorAction SilentlyContinue) {
        $probes = @($adapters | ForEach-Object { $_.Servers } | Select-Object -Unique | Select-Object -First $Maximum | ForEach-Object {
            $server = "$_"
            $code = $null
            $answered = $false
            $watch = [System.Diagnostics.Stopwatch]::StartNew()
            try {
                $found = @(Resolve-DnsName -Name $name -Type $type -Server $server -DnsOnly -QuickTimeout -ErrorAction Stop)
                $answered = [bool]$found.Count
            }
            catch {
                $exception = $_.Exception
                while ($exception -and $null -eq $code) {
                    if ($exception -is [System.ComponentModel.Win32Exception]) { $code = $exception.NativeErrorCode }
                    $exception = $exception.InnerException
                }
            }
            $watch.Stop()
            [pscustomobject]@{
                Server       = $server
                Name         = $name
                Type         = $type
                State        = ConvertTo-NetworkProtocolDnsState -Code $code -Answered $answered
                Milliseconds = [int]$watch.ElapsedMilliseconds
                Code         = $code
            }
        })
    }

    [pscustomobject]@{ Domain = $domain; SearchList = $search; Adapters = $adapters; Probes = $probes }
}

function Get-NetworkProtocolDnsStateText {
    <#
    .SYNOPSIS
        What a DNS server did with the question, in the Report's words, with the number
        and its meaning where it failed. Pure.
    #>

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

    switch ("$(Get-DataProperty $Probe 'State')") {
        'Answered'    { return ((Get-Text 'Value.NetworkProtocol.Dns.Answered') -f (Get-DataProperty $Probe 'Milliseconds')) }
        'NameUnknown' { return (Get-Text 'Value.NetworkProtocol.Dns.NameUnknown') }
        'NoAnswer'    { return (Get-Text 'Value.NetworkProtocol.Dns.NoAnswer') }
        'Refused'     { return (Get-Text 'Value.NetworkProtocol.Dns.Refused') }
        'Failed' {
            $code = "$(Get-DataProperty $Probe 'Code')"
            return ((Get-Text 'Value.NetworkProtocol.Dns.Failed') -f (Format-CodeWithMeaning -Code $code -Meaning (Get-Win32ErrorText -Code $code)))
        }
    }
    Get-Text 'Value.NetworkProtocol.Dns.NotAsked'
}

function New-NetworkProtocolDnsFinding {
    <#
    .SYNOPSIS
        What the DNS servers this machine is set to ask answer, each by itself. Nothing
        where that was not gathered or none is entered.
    .DESCRIPTION
        A server that is silent is a warning, and a failure where all are. A server that
        does not know the domain is a warning where it stands beside one that does on
        the same adapter - that is the mistake - and a note where it is all an adapter
        has: the server of a VPN or of a second network knows its own names and not
        these.
    #>

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

    $dns    = Get-DataProperty $Data 'Dns'
    $probes = @((Get-DataCollection $dns 'Probes') | ForEach-Object { $_ })
    if (-not $probes.Count) { return }

    $state = @{}
    foreach ($probe in $probes) { $state["$(Get-DataProperty $probe 'Server')"] = "$(Get-DataProperty $probe 'State')" }

    $silent  = @($probes | Where-Object { "$(Get-DataProperty $_ 'State')" -in 'NoAnswer', 'Refused', 'Failed' })
    $unknown = @($probes | Where-Object { "$(Get-DataProperty $_ 'State')" -eq 'NameUnknown' })
    $good    = @($probes | Where-Object { "$(Get-DataProperty $_ 'State')" -eq 'Answered' })

    # Beside a server that knows the domain, on one adapter: the mistake, not a second network.
    $mixed = @(foreach ($adapter in (Get-DataCollection $dns 'Adapters')) {
        $servers = @((Get-DataCollection $adapter 'Servers') | ForEach-Object { "$_" })
        if (@($servers | Where-Object { $state[$_] -eq 'Answered' }).Count) { $servers | Where-Object { $state[$_] -eq 'NameUnknown' } }
    }) | Select-Object -Unique

    $severity = 'OK'
    if ($unknown.Count) { $severity = 'INFO' }
    if ($silent.Count -or @($mixed).Count) { $severity = 'WARN' }
    if (-not $good.Count) { $severity = 'FAIL' }
    # No server knows the name and none is silent: nothing wrong with DNS, the name is
    # one this network does not have.
    if (-not $good.Count -and -not $silent.Count) { $severity = 'INFO' }

    $value = @((Get-Text 'Value.NetworkProtocol.Dns.Summary') -f $good.Count, $probes.Count, "$(Get-DataProperty $probes[0] 'Name')")
    $value += (Get-Text 'Value.NetworkProtocol.Dns.Servers') -f (@($probes | ForEach-Object {
        '{0} {1}' -f "$(Get-DataProperty $_ 'Server')", (Get-NetworkProtocolDnsStateText -Probe $_) }) -join '; ')

    $check = Get-Text 'Check.NetworkProtocol.DnsServers'
    if ($severity -eq 'OK') { return New-Finding -Category Network -Check $check -Severity OK -Value ($value -join ' | ') }

    $meaning = @()
    $hint    = @()
    if ($silent.Count) {
        $meaning += Get-Text 'Meaning.NetworkProtocol.Dns.Silent'
        $hint    += Get-Text 'Hint.NetworkProtocol.Dns.Silent'
    }
    if ($unknown.Count) {
        if (@($mixed).Count) { $meaning += Get-Text 'Meaning.NetworkProtocol.Dns.Foreign'; $hint += Get-Text 'Hint.NetworkProtocol.Dns.Foreign' }
        else { $meaning += Get-Text 'Meaning.NetworkProtocol.Dns.OtherNetwork'; $hint += Get-Text 'Hint.NetworkProtocol.Dns.OtherNetwork' }
    }
    New-Finding -Category Network -Check $check -Severity $severity -Value ($value -join ' | ') `
        -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ') `
        -Reference @(New-Reference -Section 'Title.NetworkProtocol.DnsServers')
}

function New-NetworkProtocolDnsSection {
    <#
    .SYNOPSIS
        DNS in one place: the servers the machine asks and what each answered, how names
        are completed, and every event of the period that is about DNS - also where a
        table further down shows the same. Pure.
    #>

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

    $dns    = Get-DataProperty $Data 'Dns'
    $probes = @((Get-DataCollection $dns 'Probes') | ForEach-Object { $_ })

    $adapterColumn = Get-Text 'Column.NetworkProtocol.Dns.Adapter'
    $serverColumn  = Get-Text 'Column.NetworkProtocol.Dns.Server'
    $answerColumn  = Get-Text 'Column.NetworkProtocol.Dns.Answer'
    $askedColumn   = Get-Text 'Column.NetworkProtocol.Dns.Asked'
    New-Section -Title (Get-Text 'Title.NetworkProtocol.DnsServers') -Help (Get-Text 'Help.NetworkProtocol.DnsServers') -Row @(
        foreach ($adapter in (Get-DataCollection $dns 'Adapters')) {
            foreach ($server in @((Get-DataCollection $adapter 'Servers') | ForEach-Object { "$_" })) {
                $probe = @($probes | Where-Object { "$(Get-DataProperty $_ 'Server')" -eq $server })[0]
                $row = [ordered]@{}
                $row[$adapterColumn] = "$(Get-DataProperty $adapter 'Alias')"
                $row[$serverColumn]  = $server
                $row[$answerColumn]  = Get-NetworkProtocolDnsStateText -Probe $probe
                $row[$askedColumn]   = "$(Get-DataProperty $probe 'Name')"
                [pscustomobject]$row
            }
        }
    )

    $name  = Get-Text 'Column.NetworkProtocol.Dns.Setting'
    $value = Get-Text 'Column.NetworkProtocol.Dns.Value'
    $none  = Get-Text 'Value.NetworkProtocol.Dns.None'
    $setting = {
        param([string]$Label, [string]$Text)
        $row = [ordered]@{}
        $row[$name]  = $Label
        $row[$value] = $(if ($Text) { $Text } else { $none })
        [pscustomobject]$row
    }
    if ($null -ne $dns) {
        New-Section -Title (Get-Text 'Title.NetworkProtocol.DnsSettings') -Help (Get-Text 'Help.NetworkProtocol.DnsSettings') -Row @(
            & $setting (Get-Text 'Value.NetworkProtocol.Dns.Setting.Domain') "$(Get-DataProperty $dns 'Domain')"
            & $setting (Get-Text 'Value.NetworkProtocol.Dns.Setting.SearchList') (@((Get-DataCollection $dns 'SearchList') | ForEach-Object { "$_" }) -join ', ')
            foreach ($adapter in (Get-DataCollection $dns 'Adapters')) {
                & $setting ((Get-Text 'Value.NetworkProtocol.Dns.Setting.Suffix') -f "$(Get-DataProperty $adapter 'Alias')") "$(Get-DataProperty $adapter 'Suffix')"
            }
        )
    }

    # What Windows logged that is about DNS, whichever topic it stands under: a name
    # nobody answered for, a time source DNS did not find, Windows' own DNS probe.
    $events = @((Get-DataCollection $Data 'Events') | Where-Object {
        "$(Get-DataProperty $_ 'Topic')" -eq 'dns' -or "$(Get-DataProperty $_ 'Cause')" -match 'DnsProbe' -or
        ("$(Get-DataProperty $_ 'Kind')" -eq 'time-no-source' -and "$(Get-DataProperty $_ 'Subject')")
    } | Sort-Object { Get-DataProperty $_ 'Time' } -Descending)
    New-Section -Title (Get-Text 'Title.NetworkProtocol.DnsEvents') -Help (Get-Text 'Help.NetworkProtocol.DnsEvents') -Row @(
        $events | Select-Object -First $script:NetworkProtocolSectionRows | ForEach-Object {
            $row = [ordered]@{}
            $row[(Get-Text 'Column.NetworkDisruption.Time')]     = Get-DataProperty $_ 'Time'
            $row[(Get-Text 'Column.NetworkDisruption.Kind')]     = Get-NetworkProtocolKindText -Kind "$(Get-DataProperty $_ 'Kind')"
            $row[(Get-Text 'Column.NetworkProtocol.Subject')]    = "$(Get-DataProperty $_ 'Subject')"
            $row[(Get-Text 'Column.NetworkProtocol.Cause')]      = "$(Get-DataProperty $_ 'Cause')"
            $row[(Get-Text 'Column.NetworkDisruption.Message')]  = Get-DataProperty $_ 'Message'
            [pscustomobject]$row
        }
    )
}

function Get-NetworkProtocolMachinePassword {
    <#
    .SYNOPSIS
        How old the password of this computer's account in the domain is, and how the
        machine is set to renew it. Reads the directory, and changes nothing. Never throws.
    .DESCRIPTION
        A machine renews the password of its account every thirty days by itself. Where
        that stops, nothing fails at once - and then, one day, the domain no longer
        knows the machine. Reported for Windows 11 24H2 beside the secure channel that
        comes about at the second attempt; the age of the password is what tells a
        machine that only logs the attempt from one that has the fault.
 
        Asked only where a DNS server just answered for the domain: without a domain
        controller in reach the question waits for one, for longer than a Run should.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([bool]$DomainReachable)

    $maximum  = 30
    $disabled = $false
    try {
        $settings = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters' -ErrorAction Stop
        if ($null -ne $settings.MaximumPasswordAge) { $maximum = [int]$settings.MaximumPasswordAge }
        $disabled = ("$($settings.DisablePasswordChange)" -eq '1')
    }
    catch { }

    $age = $null
    if ($DomainReachable) {
        try {
            $searcher = New-Object System.DirectoryServices.DirectorySearcher
            $searcher.Filter = '(&(objectCategory=computer)(sAMAccountName={0}$))' -f $env:COMPUTERNAME
            $searcher.ClientTimeout = [timespan]::FromSeconds(8)
            $null = $searcher.PropertiesToLoad.Add('pwdLastSet')
            $found = $searcher.FindOne()
            if ($found -and $found.Properties['pwdlastset'].Count) {
                $set = [datetime]::FromFileTime([long]"$($found.Properties['pwdlastset'][0])")
                $age = [int][math]::Floor(((Get-Date) - $set).TotalDays)
            }
        }
        catch { $age = $null }
    }
    [pscustomobject]@{ AgeDays = $age; MaximumAgeDays = $maximum; ChangeDisabled = $disabled }
}

function New-NetworkProtocolMachinePasswordFinding {
    <#
    .SYNOPSIS
        Whether the machine still renews the password of its account. Nothing where the
        domain was not asked.
    .DESCRIPTION
        In order up to the age the machine is set to renew at, and for as long again:
        a notebook that was away renews when it is back. A warning beyond that - the
        domain was reachable just now, and the password is still old. Renewal switched
        off is a note: somebody did that.
    #>

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

    $password = Get-DataProperty $Data 'MachinePassword'
    $age = ConvertTo-Number (Get-DataProperty $password 'AgeDays')
    if ($null -eq $age) { return }

    $maximum = ConvertTo-Number (Get-DataProperty $password 'MaximumAgeDays')
    if ($null -eq $maximum -or $maximum -le 0) { $maximum = 30 }
    $warn = ConvertTo-Number (Get-Parameter $Parameters 'MachinePasswordWarnDays' ($maximum * 2))
    if ($null -eq $warn) { $warn = $maximum * 2 }

    $check = Get-Text 'Check.NetworkProtocol.MachinePassword'
    $value = (Get-Text 'Value.NetworkProtocol.MachinePassword') -f [int]$age, [int]$maximum
    if ([bool](Get-DataProperty $password 'ChangeDisabled')) {
        return New-Finding -Category Domain -Check $check -Severity INFO `
            -Value ('{0} | {1}' -f $value, (Get-Text 'Value.NetworkProtocol.MachinePassword.Disabled')) `
            -Meaning (Get-Text 'Meaning.NetworkProtocol.MachinePassword.Disabled') `
            -Hint (Get-Text 'Hint.NetworkProtocol.MachinePassword.Disabled')
    }
    if ($age -le $warn) { return New-Finding -Category Domain -Check $check -Severity OK -Value $value }
    New-Finding -Category Domain -Check $check -Severity WARN -Value $value `
        -Meaning (Get-Text 'Meaning.NetworkProtocol.MachinePassword.Old') `
        -Hint (Get-Text 'Hint.NetworkProtocol.MachinePassword.Old')
}

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

    $dns = Get-NetworkProtocolDns -ProbeName "$(Get-Parameter $Parameters 'DnsProbeName' 'www.msftconnecttest.com')"
    # A DNS server that knows the domain answered a moment ago: then a domain controller
    # can be asked without waiting for one that is not there.
    $reachable = [bool]("$($dns.Domain)" -and @($dns.Probes | Where-Object { $_.State -eq 'Answered' }).Count)

    [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
        Dns            = $dns
        MachinePassword = Get-NetworkProtocolMachinePassword -DomainReachable $reachable
        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') }
        'domain-channel-retried'   { return (Get-Text 'Value.NetworkProtocol.Kind.DomainChannelRetried') }
        'domain-kerberos-fallback' { return (Get-Text 'Value.NetworkProtocol.Kind.DomainKerberosFallback') }
        'domain-channel-failed'    { return (Get-Text 'Value.NetworkProtocol.Kind.DomainChannelFailed') }
        '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 { $_ })
        QuietKinds = [string[]]@(@(Get-DataProperty $entry 'QuietKinds') | ForEach-Object { "$_" } | Where-Object { $_ })
    }
}

function Get-NetworkProtocolDomainText {
    <#
    .SYNOPSIS
        What the events of the domain's topic mean and what to do about them, by the
        kinds that are among them. Pure.
    .DESCRIPTION
        All of it at once was six statements about four things, of which a machine had
        one: a broken trust explained beside 366 harmless retries and one controller
        that did not answer. So: how much of it is the fallback of Kerberos, which is
        without consequence; then what each kind that is there means; the fallback last.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Row)

    $kinds    = @($Row | ForEach-Object { "$(Get-DataProperty $_ 'Kind')" })
    $fallback = @($kinds | Where-Object { $_ -in 'domain-channel-retried', 'domain-kerberos-fallback' })
    $other    = @($kinds | Where-Object { $_ -notin 'domain-channel-retried', 'domain-kerberos-fallback' })
    $meaning  = @()
    $hint     = @()

    if ($other.Count) {
        if ($fallback.Count) { $meaning += (Get-Text 'Meaning.NetworkProtocol.Domain.Mostly') -f $fallback.Count, $kinds.Count, $other.Count }
        $meaning += Get-Text 'Meaning.NetworkProtocol.Domain'
        if (@($other | Where-Object { $_ -in 'domain-no-controller', 'domain-controller-silent', 'domain-channel-failed' }).Count) {
            $meaning += Get-Text 'Meaning.NetworkProtocol.Domain.Reach'
            $hint    += Get-Text 'Hint.NetworkProtocol.Domain.Reach'
        }
        if ($other -contains 'domain-trust-broken') {
            $meaning += Get-Text 'Meaning.NetworkProtocol.Domain.Trust'
            $hint    += Get-Text 'Hint.NetworkProtocol.Domain.Trust'
        }
        if (@($other | Where-Object { $_ -in 'kerberos-ticket-refused', 'kerberos-clock' }).Count) {
            $meaning += Get-Text 'Meaning.NetworkProtocol.Domain.Kerberos'
            $hint    += Get-Text 'Hint.NetworkProtocol.Domain.Kerberos'
        }
        if (@($other | Where-Object { $_ -in 'auth-failed', 'auth-no-protocol' }).Count) {
            $meaning += Get-Text 'Meaning.NetworkProtocol.Domain.Auth'
            $hint    += Get-Text 'Hint.NetworkProtocol.Domain.Auth'
        }
        $hint += Get-Text 'Hint.NetworkProtocol.Domain'
    }
    if ($fallback.Count) {
        $meaning += Get-Text 'Meaning.NetworkProtocol.Domain.KerberosFallback'
        $hint    += Get-Text 'Hint.NetworkProtocol.Domain.KerberosFallback'
    }
    [pscustomobject]@{ Meaning = $meaning; Hint = $hint }
}

function Get-NetworkProtocolTopicCategory {
    <#
    .SYNOPSIS
        The area of the Report a topic's Finding stands in: the domain's among the
        Findings about the domain, every other under the network. Pure.
    #>

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

    if ("$Topic" -eq 'domain') { return 'Domain' }
    'Network'
}

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 $_ })
        $quiet  = New-NetworkDisruptionQuietFinding -Data $Data -Check $check -Unread $unread -Log $logs
        if ($quiet) { $quiet.Category = Get-NetworkProtocolTopicCategory -Topic $Topic }
        return $quiet
    }

    $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')" })
    # What is known and without consequence is shown and never counted: a first attempt
    # that fails by design, every few hours, is no reason to look at a domain controller.
    $quietRows = @($rows | Where-Object { $setting.QuietKinds -contains "$(Get-DataProperty $_ 'Kind')" })
    $counted   = $rows.Count - $quietRows.Count
    if ($away) { $counted -= @($awayRows | Where-Object { $setting.QuietKinds -notcontains "$(Get-DataProperty $_ 'Kind')" }).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)
    $hint    = @(Get-NetworkProtocolTopicText -Topic $Topic -Part Hint)
    if ($away -and $awayRows.Count) { $meaning += Get-Text 'Meaning.NetworkProtocol.Away' }

    # The domain's topic is many kinds of event with nothing in common but the domain:
    # it says what belongs to the kinds that were found, and not all of it every time.
    if ($Topic -eq 'domain') {
        $said    = Get-NetworkProtocolDomainText -Row $rows
        $beside  = @($meaning | Select-Object -Skip 1)
        $meaning = @($said.Meaning) + $beside
        $hint    = @($said.Hint)
    }

    New-Finding -Category (Get-NetworkProtocolTopicCategory -Topic $Topic) -Check $check -Severity $severity `
        -Value ($value -join ' | ') `
        -Meaning (@($meaning | Where-Object { $_ }) -join ' | ') `
        -Hint (@($hint | Where-Object { $_ }) -join ' | ') `
        -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-NetworkProtocolDnsFinding -Data $Data
    New-NetworkProtocolMachinePasswordFinding -Data $Data -Parameters $Parameters
    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)

    # DNS first, and in one place: it is what most of the rest depends on.
    New-NetworkProtocolDnsSection -Data $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
        }
    }
}