Private/Kinds/NetworkDisruption.ps1

# The NetworkDisruption Kind: what Windows itself logged about the network going away.
#
# A program that runs from a share dies when the share does, and the crash it leaves in the
# Application log says nothing about why. Windows did write down why, elsewhere: the SMB
# client records every connection it lost and every Server it could not reach, the adapter
# records its link going down, and the power manager records the standby during which a
# dock or the Wi-Fi was switched off. This Kind reads those records for the period and lays
# them out as one list, so a crash can be held against it.
#
# Which events count is not written here. A Check Definition's Scans parameter names them,
# one entry per class of event, and the Local Definitions carry the entries that were
# verified against the event manifests (docs/research/network-disruption-events.md):
#
# { "Log": "Microsoft-Windows-SmbClient/Connectivity", the log to read
# "Provider": [ "Microsoft-Windows-SMBClient" ], optional; any provider when absent
# "Id": [ 30805, 30807 ], the event ids
# "Kind": "smb-disconnect", what a later Judge calls them
# "ServerIndex": 4, optional; payload position of the Server
# "ServerIndexByVersion": { "0": 3 }, optional; where an older version of
# the event keeps it instead
# "AddressIndex": 6 } optional; payload position of the
# address the Server was reached at
#
# A scan's providers and its ids match as a cross product: an event belongs to the scan when
# its provider is any of the scan's and its id is any of the scan's. Two providers with two
# ids are therefore four events, not two. A scan that names several ids names one provider,
# and an id that means the same thing from two providers is two scans - Tcpip 4202 and
# e1dexpress 27 are both a link going down, and Tcpip 27 is something else. A test over the
# Local Definitions holds them to that.
#
# The payload is read by position for the reason Private/Kinds/Stability.ps1 gives: the
# field names are localised and the positions are not. A position is per event version,
# because Microsoft has moved the Server's name within an event between versions.
#
# The events of a resume or of a link coming back are not disruptions, and are gathered all
# the same: the list is a timeline, and a disconnect reads differently beside the standby
# that caused it. The ContextKinds parameter names the Kind tokens that are shown but not
# counted.
#
# What the Gatherer returns:
# Days how many days back it read, from the Check Definition
# Since the start of that period: the time the events were filtered from
# Disruptions { Time, Provider, Id, Server, Address, Kind, Message, ServerExpected },
# newest first. Kind is the scan's token; Server is the host only, or
# $null when the event names none; ServerExpected says whether it should
# have. Address is where that name led when the event was logged, or '':
# a name several Servers answer to - a domain's - says not which did
# UnreadableLogs the logs this Run's rights were refused, by name
# ScannedLogs every log that was asked, by name, the refused ones included. A log this
# machine does not have was not asked and is not in it
# LogCoverage { Log, Oldest }, one per log that was asked and could be read: Oldest is
# the time of the oldest record the log still holds, or $null when the log
# is empty or would not say. A log overwrites its oldest records, so one
# that reaches back a week says nothing about the three weeks before
#
# Data from before Since, ScannedLogs and LogCoverage were gathered has none of the three,
# and is judged all the same.

# The SMBClient channels refuse a Run without admin rights.
$script:NetworkDisruptionNeedsAdmin = $true

$script:NetworkDisruptionDefaultDays = 30

# How far apart two events of one Server may be and still report the same lost connection.
# The events of one loss carry the same second; fifteen leaves room for a slow log and is
# well short of the forty seconds a drop and the next one were apart on a real machine.
$script:NetworkDisruptionEpisodeSeconds = 15

# The tag these events carry in events.csv.
$script:NetworkDisruptionEventTag = 'Network disruption'

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

# What a warning or an error of an adapter's own driver is called in the timeline, and
# how many of the newest are taken per adapter: a driver that warns every minute is not
# to push everything else out of it.
$script:NetworkDisruptionAdapterKind   = 'adapter-driver'
$script:NetworkDisruptionAdapterEvents = 200

# How many event ids one query may name. Get-WinEvent -FilterHashtable answers "no events
# matched" to a query naming 23 ids or more, whatever the log holds: verified on Windows 11
# with a query that found its events at 22 ids and none at 23. No error says so, which is
# the worst way for a list in a Check Definition to be too long.
$script:NetworkDisruptionMaxIdsPerQuery = 20

function Format-NetworkDisruptionServerList {
    <#
    .SYNOPSIS
        The Servers of a connection Finding with how often each, as the items of a list:
        the first five, and how many more there are. Pure.
    .DESCRIPTION
        Separated the way the Report takes for a list. In one line with commas it was a
        line nobody read to the end, and it stopped after five without saying so.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()]$Server, [AllowNull()][AllowEmptyCollection()]$Lookup)

    $all   = @($Server | Where-Object { $_ })
    $items = @($all | Select-Object -First 5 | ForEach-Object {
        '{0} {1}x' -f (Format-NetworkDisruptionServer -Server $_.Server -Lookup $Lookup), $_.Episodes })
    if ($all.Count -gt 5) { $items += (Get-Text 'Value.NetworkDisruption.MoreServers') -f ($all.Count - 5) }
    $items -join '; '
}

function ConvertTo-NetworkDisruptionScan {
    <#
    .SYNOPSIS
        Reads one entry of the Scans parameter into which events to read and what they are.
        Pure, and reaches nothing.
    .DESCRIPTION
        An entry arrives as ConvertFrom-Json made it or as a hashtable, and a hand-edited
        Definition writes a single id as a number rather than a list. Both halves of the
        Kind read entries through here.
 
        A broken entry is not dropped: it 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)

    $log   = "$(Get-DataProperty $Entry 'Log')".Trim()
    $token = "$(Get-DataProperty $Entry 'Kind')".Trim()

    $providers = @(@(Get-DataProperty $Entry 'Provider') | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
    $ids = @(@(Get-DataProperty $Entry 'Id') | ForEach-Object { ConvertTo-Number $_ } |
        Where-Object { $null -ne $_ } | ForEach-Object { [int]$_ })

    $serverIndex = ConvertTo-Number (Get-DataProperty $Entry 'ServerIndex')
    if ($null -ne $serverIndex) { $serverIndex = [int]$serverIndex }

    # Keyed by the version as text: JSON has no other kind of key, and an event's Version
    # is a byte that would not find an [int] key in a hashtable.
    $byVersion = @{}
    $versions  = Get-DataProperty $Entry 'ServerIndexByVersion'
    if ($versions -is [hashtable]) {
        foreach ($key in @($versions.Keys)) {
            $index = ConvertTo-Number $versions[$key]
            if ($null -ne $index) { $byVersion["$key"] = [int]$index }
        }
    }
    elseif ($null -ne $versions) {
        foreach ($property in $versions.PSObject.Properties) {
            $index = ConvertTo-Number $property.Value
            if ($null -ne $index) { $byVersion["$($property.Name)"] = [int]$index }
        }
    }

    $addressIndex = ConvertTo-Number (Get-DataProperty $Entry 'AddressIndex')
    if ($null -ne $addressIndex) { $addressIndex = [int]$addressIndex }

    $problem = $null
    if     (-not $log)       { $problem = 'NoLog' }
    elseif (-not $ids.Count) { $problem = 'NoId' }
    elseif (-not $token)     { $problem = 'NoKind' }

    [pscustomobject]@{
        Log                  = $log
        Provider             = $providers
        Id                   = $ids
        Kind                 = $token
        ServerIndex          = $serverIndex
        ServerIndexByVersion = $byVersion
        AddressIndex         = $addressIndex
        # A payload field that must, or must not, hold one of the values named: one id
        # means several things by its status. "Session lost" with the status "share name
        # not found" is the server not having the share, and no connection that dropped.
        Match                = ConvertTo-NetworkProtocolFieldMap -Map (Get-DataProperty $Entry 'Match')
        Except               = ConvertTo-NetworkProtocolFieldMap -Map (Get-DataProperty $Entry 'Except')
        Problem              = $problem
    }
}

function Get-NetworkDisruptionScan {
    <#
    .SYNOPSIS
        Every entry of a Check Definition's Scans parameter, read. Pure.
    .DESCRIPTION
        No entries when the Definition names none. The events that count are data
        (ADR-0001) and their verified set is in the Local Definitions; a second copy here
        would be the one nobody remembers to update when a Windows build moves a field.
    #>

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

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

function Get-NetworkDisruptionQuery {
    <#
    .SYNOPSIS
        The event log queries a set of scans amounts to: one per log, split where the ids
        outnumber what one query may name. Pure.
    .DESCRIPTION
        One query per log rather than one per scan. Twenty scans are a handful of logs, and
        every query against the System log reads an index a Technician waits for.
 
        A query therefore names the providers and ids of several scans, and can return an
        event that belongs to none of them - one scan's provider with another's id. Each
        event is matched back to its scan by Select-NetworkDisruptionScan, which drops those.
    #>

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

    $logs = [ordered]@{}
    foreach ($entry in @($Scan | Where-Object { $_ -and -not $_.Problem })) {
        $key = $entry.Log.ToLowerInvariant()
        if (-not $logs.Contains($key)) { $logs[$key] = New-Object System.Collections.Generic.List[psobject] }
        $logs[$key].Add($entry)
    }

    foreach ($key in $logs.Keys) {
        $entries = @($logs[$key])

        # A scan that names no provider wants the id from whoever logged it, so the query
        # for its log cannot filter by provider at all.
        $providers = @()
        if (-not @($entries | Where-Object { -not @($_.Provider).Count }).Count) {
            $providers = @($entries | ForEach-Object { $_.Provider } | Sort-Object -Unique)
        }
        $ids = @($entries | ForEach-Object { $_.Id } | Sort-Object -Unique)

        for ($start = 0; $start -lt $ids.Count; $start += $script:NetworkDisruptionMaxIdsPerQuery) {
            $end = [math]::Min($start + $script:NetworkDisruptionMaxIdsPerQuery, $ids.Count) - 1
            [pscustomobject]@{
                Log      = $entries[0].Log
                Provider = $providers
                Id       = @($ids[$start..$end])
            }
        }
    }
}

function Get-NetworkDisruptionRegistration {
    <#
    .SYNOPSIS
        The event logs and providers this machine has, by name. $null when it will not say.
    .DESCRIPTION
        Asked once, before any query, because a query is no way to find out. Asked for a
        provider the machine never registered - an Intel adapter's driver on a Realtek
        board - Get-WinEvent passes it over when it is one of several and throws "the
        parameter is incorrect" when it is the only one, whatever it is told about errors.
        That line in a transcript reads like a broken tool, for a machine that merely has
        a different network card.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param()

    try {
        $session   = [System.Diagnostics.Eventing.Reader.EventLogSession]::GlobalSession
        $providers = New-Object 'System.Collections.Generic.HashSet[string]' (, [StringComparer]::OrdinalIgnoreCase)
        $logs      = New-Object 'System.Collections.Generic.HashSet[string]' (, [StringComparer]::OrdinalIgnoreCase)
        foreach ($name in $session.GetProviderNames()) { $null = $providers.Add($name) }
        foreach ($name in $session.GetLogNames())      { $null = $logs.Add($name) }
        @{ Provider = $providers; Log = $logs }
    }
    catch { $null }
}

function Select-NetworkDisruptionQuery {
    <#
    .SYNOPSIS
        The queries this machine can answer, naming only providers it has. Pure.
    .DESCRIPTION
        A log or a provider the machine does not have is an expected miss, not a gap: there
        is nothing in it to have been read. A log the Run may not read is still asked, since
        a refusal is found by asking. Without a registration every query passes unchanged.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Query,
        [AllowNull()][hashtable]$Registration
    )

    foreach ($entry in @($Query | Where-Object { $_ })) {
        if (-not $Registration) { $entry; continue }
        if (-not $Registration.Log.Contains($entry.Log)) { continue }
        if (-not @($entry.Provider).Count) { $entry; continue }

        $registered = @($entry.Provider | Where-Object { $Registration.Provider.Contains($_) })
        if (-not $registered.Count) { continue }
        [pscustomobject]@{ Log = $entry.Log; Provider = $registered; Id = $entry.Id }
    }
}

function Select-NetworkDisruptionScan {
    <#
    .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-NetworkDisruptionServer {
    <#
    .SYNOPSIS
        The host a payload field names, or $null when it names none. Pure.
    .DESCRIPTION
        The SMB client writes a Server as its name, a share as \Server\Share and Offline
        Files a path as \\Server\Share\Folder, all in a field the manifest calls ServerName,
        ShareName or Path. What a Technician and a later Check want from each is the host.
 
        Anything that does not look like a host name or an address is no Server: a wrong
        position in a Definition reads a length, a status code or a byte array, and a
        Report naming "System.Byte[]" as a Server would be believed.
 
        A link-local IPv6 address is written with the interface it was reached through,
        fe80::1%4. The Server is the address without it: the number is this machine's name
        for one of its own adapters, and the same Server reached through the dock and
        through Wi-Fi would otherwise be counted as two.
    #>

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

    $text = "$Value".Trim().TrimStart('\')
    $cut  = $text.IndexOf('\')
    if ($cut -ge 0) { $text = $text.Substring(0, $cut) }

    # Only behind an address: a percent sign after anything else is no scope.
    if ($text -match '^([0-9A-Fa-f.]*:[0-9A-Fa-f:.]*)%[A-Za-z0-9._\-]+$') { $text = $Matches[1] }

    # A bare number is a length or a status, not a host. A name has a letter in it, and an
    # address a dot or a colon.
    if ($text -notmatch '^[A-Za-z0-9][A-Za-z0-9._:\-]*$') { return $null }
    if ($text -match '^\d+$') { return $null }

    # A drive letter is no Server. The SMB client does log "the server name C: cannot be
    # resolved" when something hands it a local path, and a Report then counted C: among
    # the file servers that dropped.
    if ($text -match '^[A-Za-z]:$') { return $null }
    $text
}

function ConvertTo-NetworkDisruptionShare {
    <#
    .SYNOPSIS
        The share a payload field names beside its Server, or '' where it names a Server
        only. Pure.
    .DESCRIPTION
        \FS01\Daten and \\FS01\Daten\Ordner are "Daten"; \FS01 is none. Windows names the
        share in the events about a share and not in those about a session - and "share
        name not found" is, as a rule, one about a session.
    #>

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

    $parts = @("$Value".Trim().TrimStart('\') -split '\\' | Where-Object { $_ })
    if ($parts.Count -lt 2) { return '' }
    if (-not (ConvertTo-NetworkDisruptionServer -Value $parts[0])) { return '' }
    "$($parts[1])".Trim()
}

function ConvertTo-NetworkDisruptionAddress {
    <#
    .SYNOPSIS
        The address in a payload field the SMB client writes a socket address into, or ''
        when the field holds none. Pure.
    .DESCRIPTION
        The field is binary: the address family in two bytes, the port in two, then the
        address - four bytes for IPv4, sixteen from the ninth byte on for IPv6. Anything
        else is a wrong position in a Definition, and no address.
    #>

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

    $bytes = $Value -as [byte[]]
    if ($Value -is [string] -or $null -eq $bytes -or $bytes.Length -lt 8) { return '' }
    try {
        $family = [BitConverter]::ToUInt16($bytes, 0)
        if ($family -eq 2) { return ([System.Net.IPAddress]::new([byte[]]$bytes[4..7])).ToString() }
        if ($family -eq 23 -and $bytes.Length -ge 24) { return ([System.Net.IPAddress]::new([byte[]]$bytes[8..23])).ToString() }
    }
    catch { }
    ''
}

function ConvertTo-NetworkDisruptionRow {
    <#
    .SYNOPSIS
        One event as the row the Judge, the Section and a later Check read. Pure.
    #>

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

    $index   = $Scan.ServerIndex
    $version = "$($LogEntry.Version)"
    if ($version -and $Scan.ServerIndexByVersion.ContainsKey($version)) {
        $index = $Scan.ServerIndexByVersion[$version]
    }

    $server = $null
    $share  = ''
    if ($null -ne $index) {
        $named  = Get-EventPayload -LogEntry $LogEntry -Index $index
        $server = ConvertTo-NetworkDisruptionServer -Value $named
        $share  = ConvertTo-NetworkDisruptionShare -Value $named
    }

    # Read as it is, not through Get-EventPayload: that hands back text, and the address
    # is bytes.
    $address    = ''
    $properties = @($LogEntry.Properties)
    if ($null -ne $Scan.AddressIndex -and $Scan.AddressIndex -ge 0 -and $Scan.AddressIndex -lt $properties.Count) {
        $address = ConvertTo-NetworkDisruptionAddress -Value $properties[$Scan.AddressIndex].Value
    }

    $row = ConvertTo-EventRow -LogEntry $LogEntry
    [pscustomobject]@{
        Time     = $row.Time
        Provider = $row.Provider
        Id       = $row.Id
        Server   = $server
        # The share, where the event names one beside the Server.
        Share    = $share
        Address  = $address
        Kind     = $Scan.Kind
        Message  = $row.Message
        # Whether this event should have named a Server. One that should have and named
        # nothing usable is neither a Server that dropped nor the machine losing its
        # network, and the Judge counts it as neither.
        ServerExpected = ($null -ne $index)
    }
}

function Group-NetworkDisruptionEpisode {
    <#
    .SYNOPSIS
        The times a connection to a Server was lost, from the events that reported it. Pure.
    .DESCRIPTION
        One lost connection is several events: the SMB client logs the connection, the
        session on it, and each share that was open, all within the same second. Seen on a
        real notebook: 167 events for 31 lost connections. Counting events told a
        Technician a Server had dropped 86 times when it had dropped sixteen.
 
        Events of one Server no further apart than the gap are one episode. A row whose
        time cannot be read is an episode of its own: it happened, and nothing says when.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Disruption,
        [double]$GapSeconds = $script:NetworkDisruptionEpisodeSeconds
    )

    $byServer = @($Disruption | Where-Object { $_ }) | Group-Object { "$(Get-DataProperty $_ 'Server')".ToUpperInvariant() }
    foreach ($group in @($byServer)) {
        $timed   = [System.Collections.Generic.List[psobject]]::new()
        $untimed = 0
        foreach ($row in $group.Group) {
            $time = ConvertTo-DataTime (Get-DataProperty $row 'Time')
            if ($null -eq $time) { $untimed++ } else { $timed.Add([pscustomobject]@{ Time = $time }) }
        }

        $episodes = $untimed
        $last     = $null
        foreach ($entry in @($timed | Sort-Object Time)) {
            if ($null -eq $last -or ($entry.Time - $last).TotalSeconds -gt $GapSeconds) { $episodes++ }
            $last = $entry.Time
        }

        [pscustomobject]@{ Server = $group.Name; Episodes = $episodes; Events = @($group.Group).Count }
    }
}

function Get-NetworkDisruptionLogOldest {
    <#
    .SYNOPSIS
        The time of the oldest record a log still holds, or $null when it is empty or will
        not say. Decides nothing.
    .DESCRIPTION
        Asked not to stop, and the error collected instead of caught, for the reason
        Get-StabilityEvent gives: an empty log answers "no events" as an error, and that
        must not leave a TerminatingError line in a transcript.
    #>

    [CmdletBinding()]
    [OutputType([datetime])]
    param([Parameter(Mandatory)][string]$Log)

    $problems = @()
    $oldest = @(Get-WinEvent -LogName $Log -Oldest -MaxEvents 1 -ErrorAction SilentlyContinue -ErrorVariable problems)
    if (-not $oldest.Count -or $null -eq $oldest[0].TimeCreated) { return $null }
    [datetime]$oldest[0].TimeCreated
}

function Get-NetworkDisruptionAdapterDriver {
    <#
    .SYNOPSIS
        The physical network adapters of this machine, each with the service of its
        driver. Never throws.
    .DESCRIPTION
        A driver logs under the name of its service: e1dexpress for an Intel adapter. The
        Scans of a Check Definition name the link events of the drivers somebody looked
        at, and every other maker's adapter then dropped its link without a line in the
        Report. Read from the machine, the driver is whichever it has.
    #>

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

    try {
        Get-CimInstance Win32_NetworkAdapter -Filter 'PhysicalAdapter = TRUE' -ErrorAction Stop |
            Where-Object { "$($_.ServiceName)".Trim() } | Group-Object ServiceName | ForEach-Object {
                [pscustomobject]@{ Service = "$($_.Name)".Trim(); Name = "$($_.Group[0].Name)".Trim() }
            }
    }
    catch { }
}

function ConvertTo-NetworkDisruptionAdapterRow {
    <#
    .SYNOPSIS
        A warning or an error of an adapter's driver as a row of the timeline. Pure.
    .DESCRIPTION
        What it says is the driver's to say, in its own words: no table here knows the
        ids of every maker. So it is shown and not counted - the Kind token is context
        whatever a Check Definition lists - and what is counted stays what Windows itself
        logged of the network going away. The adapter is named in front of a message that
        does not name it.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)]$LogEntry, [AllowNull()][AllowEmptyString()][string]$Adapter)

    $row     = ConvertTo-EventRow -LogEntry $LogEntry
    $message = "$($row.Message)"
    $name    = "$Adapter".Trim()
    if ($name -and $message.IndexOf($name, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
        $message = @(@($name, $message) | Where-Object { $_ }) -join ': '
    }
    [pscustomobject]@{
        Time     = $row.Time
        Provider = $row.Provider
        Id       = $row.Id
        Server   = $null
        Share    = ''
        Address  = ''
        Kind     = $script:NetworkDisruptionAdapterKind
        Message  = $message
        ServerExpected = $false
    }
}

function Get-NetworkDisruptionAtStart {
    <#
    .SYNOPSIS
        The rows about an adapter's link that were logged while the machine started, woke
        up or went to sleep: what a driver says of itself then, and no link that dropped.
        Pure.
    .DESCRIPTION
        A driver that starts has no link yet, and says so: "network link is disconnected",
        as a warning, every morning. Seen on a desktop: 27 of them in a month, 21 the
        first thing of a working day, the rest at restarts - and a Technician reading the
        timeline took them for 27 drops.
 
        A row counts as one of these within the seconds given after a start or a resume,
        and within half a minute around a standby, where the adapter is switched off.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Disruption,
        [string[]]$Kind = @('link-down', $script:NetworkDisruptionAdapterKind),
        [double]$Seconds = 180
    )

    $marks = @($Disruption | Where-Object { $_ } | ForEach-Object {
        $token = "$(Get-DataProperty $_ 'Kind')"
        $at    = ConvertTo-DataTime (Get-DataProperty $_ 'Time')
        if ($null -eq $at) { return }
        if ($token -in 'system-start', 'resume') { [pscustomobject]@{ From = $at; Until = $at.AddSeconds($Seconds) } }
        elseif ($token -eq 'standby')            { [pscustomobject]@{ From = $at.AddSeconds(-30); Until = $at.AddSeconds(30) } }
    })
    if (-not $marks.Count) { return }

    foreach ($row in @($Disruption | Where-Object { $_ })) {
        if ($Kind -notcontains "$(Get-DataProperty $row 'Kind')") { continue }
        $at = ConvertTo-DataTime (Get-DataProperty $row 'Time')
        if ($null -eq $at) { continue }
        if (@($marks | Where-Object { $at -ge $_.From -and $at -le $_.Until }).Count) { $row }
    }
}

function Get-NetworkDisruptionRunningLinkDown {
    <#
    .SYNOPSIS
        The times an adapter's link went down while the machine was running: every
        "link down" that is not the driver starting. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Disruption, [double]$Seconds = 180)

    $atStart = @(Get-NetworkDisruptionAtStart -Disruption $Disruption -Kind @('link-down') -Seconds $Seconds)
    @($Disruption | Where-Object { $_ -and "$(Get-DataProperty $_ 'Kind')" -eq 'link-down' -and $atStart -notcontains $_ })
}

function New-NetworkDisruptionLinkFinding {
    <#
    .SYNOPSIS
        How often an adapter lost its link while the machine was running, apart from
        what its driver logs at every start. Nothing where no link went down, or where
        the Check Definition does not read the starts.
    .DESCRIPTION
        Without the starts nothing tells a drop from a driver starting, and to count
        them all would warn of every machine that is switched on in the morning.
 
        A desktop's cable does not come out: a few are a warning there. A notebook
        leaves its dock, and is held to what the network going away is held to.
    #>

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

    if (@($Scan | Where-Object { $_.Kind -eq 'system-start' }).Count -eq 0) { return }
    $rows = @((Get-DataCollection $Data 'Disruptions') | ForEach-Object { $_ })
    $all  = @($rows | Where-Object { $_ -and "$(Get-DataProperty $_ 'Kind')" -eq 'link-down' })
    if (-not $all.Count) { return }

    $seconds = ConvertTo-Number (Get-Parameter $Parameters 'StartSeconds' 180)
    if ($null -eq $seconds -or $seconds -le 0) { $seconds = 180 }
    $running = @(Get-NetworkDisruptionRunningLinkDown -Disruption $rows -Seconds $seconds)
    $atStart = $all.Count - $running.Count
    $days    = Get-NetworkDisruptionDays -Data $Data
    $check   = Get-Text 'Check.NetworkDisruption.Link'
    $table   = @(New-Reference -Section 'Title.NetworkDisruption.Events')

    if (-not $running.Count) {
        return New-Finding -Category Network -Check $check -Severity OK `
            -Value ((Get-Text 'Value.NetworkDisruption.Link.OnlyAtStart') -f $all.Count, $days) -Reference $table
    }

    $warn = Select-SituationText -Situation $Situation -Fact Form -Text @{
        Notebook = Get-Parameter $Parameters 'LocalDisruptionWarnAbove' 30
        Unknown  = Get-Parameter $Parameters 'LinkDownWarnAbove' 2
    }
    $severity = Get-Severity $running.Count ([double]$warn) ([double]::MaxValue)
    if ($severity -eq 'OK') { $severity = 'INFO' }
    $last = @($running | ForEach-Object { ConvertTo-DataTime (Get-DataProperty $_ 'Time') } | Where-Object { $_ } | Sort-Object)[-1]

    $value = @((Get-Text 'Value.NetworkDisruption.Link.Running') -f $running.Count, $days, $last)
    if ($atStart) { $value += (Get-Text 'Value.NetworkDisruption.Link.AtStart') -f $atStart }
    $signal = @()
    if ($severity -ne 'INFO') { $signal = @('link-dropped') }
    New-Finding -Category Network -Check $check -Severity $severity -Value ($value -join ' | ') `
        -Meaning (Get-Text 'Meaning.NetworkDisruption.Link') -Hint (Get-Text 'Hint.NetworkDisruption.Link') `
        -Signal $signal -Reference $table
}

function Get-NetworkDisruptionSwitching {
    <#
    .SYNOPSIS
        How often the machine went from its cable to Wi-Fi and back: a network connection
        that went away, and a Wi-Fi disconnect shortly after it. Pure.
    .DESCRIPTION
        Windows drops the Wi-Fi when the cable is back. So a network that goes away and a
        Wi-Fi that is disconnected within the next minute and a half is a cable connection
        that failed for that long, with the Wi-Fi taking over in between - and every
        connection to a Server cut twice, once each way. Seen on a notebook at a USB
        network adapter: seventeen times in a month, and a program on a share that
        crashed after each.
 
        A notebook taken from its desk loses its cable once and gets it back hours later:
        that is no pair. Each Wi-Fi disconnect is counted for one loss.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Disruption, [double]$Seconds = 90)

    $timed = @($Disruption | Where-Object { $_ } | ForEach-Object {
        $at = ConvertTo-DataTime (Get-DataProperty $_ 'Time')
        if ($null -ne $at) { [pscustomobject]@{ Time = $at; Kind = "$(Get-DataProperty $_ 'Kind')" } }
    } | Sort-Object Time)
    $gone = @($timed | Where-Object { $_.Kind -in 'network-disconnected', 'link-down' })
    $wifi = [System.Collections.Generic.List[psobject]]::new()
    foreach ($row in @($timed | Where-Object { $_.Kind -eq 'wlan-disconnect' })) { $wifi.Add($row) }

    $count = 0
    $last  = $null
    foreach ($row in $gone) {
        $after = @($wifi | Where-Object { $_.Time -gt $row.Time -and ($_.Time - $row.Time).TotalSeconds -le $Seconds })[0]
        if (-not $after) { continue }
        $null = $wifi.Remove($after)
        $count++
        $last = $row.Time
    }
    [pscustomobject]@{ Count = $count; Last = $last }
}

function New-NetworkDisruptionSwitchingFinding {
    <#
    .SYNOPSIS
        The Finding about a cable connection that keeps failing over to Wi-Fi and back.
        Nothing below the number a Check Definition takes for a pattern.
    #>

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

    $seconds = ConvertTo-Number (Get-Parameter $Parameters 'SwitchingSeconds' 90)
    if ($null -eq $seconds -or $seconds -le 0) { $seconds = 90 }
    $from = Get-Parameter $Parameters 'SwitchingNoteFrom' 3
    $warn = Get-Parameter $Parameters 'SwitchingWarnAbove' 8

    $found = Get-NetworkDisruptionSwitching -Disruption (Get-DataCollection $Data 'Disruptions') -Seconds $seconds
    if ($found.Count -lt $from) { return }

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

    New-Finding -Category Network -Check (Get-Text 'Check.NetworkDisruption.Switching') -Severity $severity `
        -Value ((Get-Text 'Value.NetworkDisruption.Switching') -f $found.Count, (Get-NetworkDisruptionDays -Data $Data), [int]$seconds, $found.Last) `
        -Meaning (Get-Text 'Meaning.NetworkDisruption.Switching') -Hint (Get-Text 'Hint.NetworkDisruption.Switching') `
        -Signal 'cable-wifi-switching' -Reference @(New-Reference -Section 'Title.NetworkDisruption.Events')
}

function Get-NetworkDisruptionData {
    <#
    .SYNOPSIS
        Reads the events a Check Definition names and returns them. Judges none of them.
    .DESCRIPTION
        A log this Run's rights could not open is recorded by name, because the SMBClient
        channels are closed to a Run without admin rights and a list that is empty for that
        reason is not a quiet network.
    #>

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

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

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

    $registration = Get-NetworkDisruptionRegistration
    $queries = @(Select-NetworkDisruptionQuery -Query @(Get-NetworkDisruptionQuery -Scan $scans) -Registration $registration)
    # Not List[object], as above.
    $scanned = New-Object System.Collections.Generic.List[psobject]
    foreach ($query in $queries) {
        if (@($scanned | Where-Object { $_ -eq $query.Log }).Count -eq 0) { $scanned.Add($query.Log) }

        # Get-StabilityEvent, because it already knows the three ways an event log says
        # nothing: no events, no such provider, and no permission. Only the last is a gap.
        $found = @(Get-StabilityEvent -Log $query.Log -Provider $query.Provider -Id $query.Id `
                                      -Since $since -Unreadable $unreadable)
        foreach ($entry in $found) {
            $scan = Select-NetworkDisruptionScan -LogEntry $entry -Log $query.Log -Scan $scans
            if ($scan) { $rows.Add((ConvertTo-NetworkDisruptionRow -LogEntry $entry -Scan $scan)) }
        }
    }

    # What the drivers of this machine's own adapters logged. Not asked of a machine that
    # will not say which providers it has: a driver that logs nothing is not registered,
    # and asking for it alone throws. An event a scan already took is not taken twice.
    if ($registration -and $registration.Log.Contains('System')) {
        foreach ($adapter in @(Get-NetworkDisruptionAdapterDriver)) {
            if (-not $registration.Provider.Contains($adapter.Service)) { continue }
            $found = @(Get-StabilityEvent -Log 'System' -Provider @($adapter.Service) -Since $since -Unreadable $unreadable |
                Where-Object { [int]$_.Level -in 1, 2, 3 } | Select-Object -First $script:NetworkDisruptionAdapterEvents)
            foreach ($entry in $found) {
                if (Select-NetworkDisruptionScan -LogEntry $entry -Log 'System' -Scan $scans) { continue }
                $rows.Add((ConvertTo-NetworkDisruptionAdapterRow -LogEntry $entry -Adapter $adapter.Name))
            }
        }
    }

    # Materialised before it is sorted, for the reason Get-StabilityData gives.
    $unreadableLogs = @($unreadable.Keys)

    # How far back each log reaches. Not asked of a log that was refused: the answer is
    # the same refusal.
    $coverage = @(foreach ($log in $scanned) {
        if ($unreadable.ContainsKey($log)) { continue }
        [pscustomobject]@{
            Log    = $log
            Oldest = Get-NetworkDisruptionLogOldest -Log $log
        }
    })

    [pscustomobject]@{
        PSTypeName     = 'Gutcheck.Data.NetworkDisruption'
        Days           = $days
        Since          = $since
        Disruptions    = @($rows | Sort-Object Time -Descending)
        UnreadableLogs = @($unreadableLogs | Sort-Object)
        ScannedLogs    = [string[]]@($scanned)
        LogCoverage    = $coverage
        # The Kind tokens the Check Definition reads: a later Check that finds no start
        # of the machine among the rows has to know whether one was looked for.
        ScannedKinds   = [string[]]@($scans | Where-Object { -not $_.Problem } | ForEach-Object { $_.Kind } | Sort-Object -Unique)
        # Not an event, and gathered here all the same: this is the Check about the
        # network that runs with admin rights, and the adapters' power management is
        # closed without them. The Network Check, which judges it, takes it from here.
        AdapterPowerManagement = @((Get-NetworkAdapterPowerManagement).Rows)
        # The other half of every Server an event names: the address of a name, the name
        # of an address. As DNS answers now, which is not when the event was logged.
        ServerNames    = @(Resolve-NetworkDisruptionServer -Server @($rows | ForEach-Object { $_.Server }))
    }
}

function Resolve-NetworkDisruptionServer {
    <#
    .SYNOPSIS
        For every Server the events name: its address when they name it by name, its
        name when they name it by address. Never throws.
    .DESCRIPTION
        An event says "OLDDC" or it says "192.0.2.25", and a Technician needs the
        other one to know which machine that is. All are asked at once and given a few
        seconds together: a name that no longer exists takes DNS and NetBIOS seconds to
        give up on, and thirty of them one after the other would be minutes.
 
        No answer is an answer here: a name without an address is a Server that is gone.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Server, [int]$TimeoutMs = 4000, [int]$Maximum = 40)

    $names = New-Object 'System.Collections.Generic.List[string]'
    foreach ($entry in @($Server)) {
        $name = "$entry".Trim()
        if ($name -and -not @($names | Where-Object { $_ -eq $name }).Count) { $names.Add($name) }
        if ($names.Count -ge $Maximum) { break }
    }
    if (-not $names.Count) { return }

    $asked = @(foreach ($name in $names) {
        $isAddress = Test-NetworkDisruptionAddress -Text $name
        $task = $null
        try {
            if ($isAddress) { $task = [System.Net.Dns]::GetHostEntryAsync($name) }
            else            { $task = [System.Net.Dns]::GetHostAddressesAsync($name) }
        }
        catch { $task = $null }
        [pscustomobject]@{ Targeted = $name; IsAddress = $isAddress; Task = $task }
    })

    $until = [DateTime]::UtcNow.AddMilliseconds($TimeoutMs)
    foreach ($entry in $asked) {
        $other = $null
        $every = @()
        if ($entry.Task) {
            $left = [int][math]::Max(0, ($until - [DateTime]::UtcNow).TotalMilliseconds)
            try {
                if ($entry.Task.Wait($left) -and $entry.Task.Status -eq 'RanToCompletion') {
                    if ($entry.IsAddress) {
                        $found = "$($entry.Task.Result.HostName)".Trim()
                        if ($found -and $found -ne $entry.Targeted) { $other = $found }
                    }
                    else {
                        $addresses = @($entry.Task.Result)
                        $v4 = @($addresses | Where-Object { $_.AddressFamily -eq 'InterNetwork' })
                        if ($v4.Count) { $other = "$($v4[0])" } elseif ($addresses.Count) { $other = "$($addresses[0])" }
                        # Every one of them: a name that several Servers answer to - a
                        # domain's - leads to each, and to show the first is to name one
                        # Server for what any of them did.
                        $every = @($(if ($v4.Count) { $v4 } else { $addresses }) | ForEach-Object { "$_" } | Sort-Object -Unique)
                    }
                }
            }
            catch { $other = $null }
        }
        [pscustomobject]@{
            Targeted  = $entry.Targeted
            IsAddress = [bool]$entry.IsAddress
            Name      = $(if ($entry.IsAddress) { $other } else { $entry.Targeted })
            Address   = $(if ($entry.IsAddress) { $entry.Targeted } else { $other })
            Addresses = $(if ($entry.IsAddress) { @($entry.Targeted) } else { $every })
        }
    }
}

function Test-NetworkDisruptionAddress {
    <#
    .SYNOPSIS
        Whether what an event names is an address and not a name. Pure.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyString()][string]$Text)

    $value = "$Text".Trim()
    if ($value -match '^\d{1,3}(\.\d{1,3}){3}$') { return $true }
    # An IPv6 address: nothing but hexadecimal digits and colons, and at least two colons.
    [bool]($value -match '^[0-9A-Fa-f:]+(%\w+)?$' -and ($value.Split(':').Count -ge 3))
}

function Test-NetworkDisruptionPublicAddress {
    <#
    .SYNOPSIS
        Whether an address is one of the public internet: none of the ranges kept for
        private networks, for one link, for the machine itself or for examples. Pure.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyString()][string]$Text)

    $ip = $null
    if (-not [System.Net.IPAddress]::TryParse("$Text".Trim(), [ref]$ip)) { return $false }
    if ($ip.AddressFamily -eq 'InterNetworkV6') {
        if ($ip.IsIPv4MappedToIPv6) { $ip = $ip.MapToIPv4() }
        else {
            # Global unicast is 2000::/3, and 2001:db8::/32 of it is kept for examples.
            $b = $ip.GetAddressBytes()
            if (($b[0] -band 0xE0) -ne 0x20) { return $false }
            return -not ($b[0] -eq 0x20 -and $b[1] -eq 0x01 -and $b[2] -eq 0x0D -and $b[3] -eq 0xB8)
        }
    }
    $b = $ip.GetAddressBytes()
    if ($b[0] -eq 0 -or $b[0] -eq 10 -or $b[0] -eq 127 -or $b[0] -ge 224) { return $false }
    if ($b[0] -eq 100 -and $b[1] -ge 64 -and $b[1] -le 127) { return $false }
    if ($b[0] -eq 169 -and $b[1] -eq 254) { return $false }
    if ($b[0] -eq 172 -and $b[1] -ge 16 -and $b[1] -le 31) { return $false }
    if ($b[0] -eq 192 -and $b[1] -eq 168) { return $false }
    if ($b[0] -eq 192 -and $b[1] -eq 0 -and ($b[2] -eq 0 -or $b[2] -eq 2)) { return $false }
    if ($b[0] -eq 198 -and ($b[1] -eq 18 -or $b[1] -eq 19)) { return $false }
    if ($b[0] -eq 198 -and $b[1] -eq 51 -and $b[2] -eq 100) { return $false }
    if ($b[0] -eq 203 -and $b[1] -eq 0 -and $b[2] -eq 113) { return $false }
    $true
}

function Test-NetworkDisruptionInternetServer {
    <#
    .SYNOPSIS
        Whether a Server the events name is a host on the internet: somebody asked, and
        every address it has is a public one. Pure.
    .DESCRIPTION
        A name that could not be resolved is not one: nothing says where it would have
        led, and it stays with the Servers that may be gone.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyString()][string]$Server, [AllowNull()][AllowEmptyCollection()]$Lookup)

    $known = Get-NetworkDisruptionServerName -Server $Server -Lookup $Lookup
    if (-not $known.Asked) { return $false }
    $addresses = @("$($known.Address)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
    if (-not $addresses.Count) { return $false }
    -not @($addresses | Where-Object { -not (Test-NetworkDisruptionPublicAddress -Text $_) }).Count
}

function Get-NetworkDisruptionServerName {
    <#
    .SYNOPSIS
        A Server as its name and its address, and which of the two the machine asked
        for. Pure.
    .DESCRIPTION
        Read from what Resolve-NetworkDisruptionServer gathered. Without that - data from
        before it was gathered - the half the event named is all there is, and which half
        it is can still be told from how it is written.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyString()][string]$Server, [AllowNull()][AllowEmptyCollection()]$Lookup)

    $targeted = "$Server".Trim()
    $found = @(@($Lookup | Where-Object { $_ }) | Where-Object { "$(Get-DataProperty $_ 'Targeted')" -eq $targeted })[0]
    $isAddress = Test-NetworkDisruptionAddress -Text $targeted
    $name = $null
    $address = $null
    if ($isAddress) { $address = $targeted; $name = "$(Get-DataProperty $found 'Name')".Trim() }
    else            { $name = $targeted;    $address = "$(Get-DataProperty $found 'Address')".Trim() }
    # A name with several addresses is shown with all of them. Data from before they
    # were gathered has the one.
    $several = @((Get-DataCollection $found 'Addresses') | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
    if (-not $isAddress -and $several.Count -gt 1) { $address = $several -join ', ' }
    [pscustomobject]@{
        Targeted  = $targeted
        IsAddress = $isAddress
        Name      = $name
        Address   = $address
        Several   = [bool](-not $isAddress -and $several.Count -gt 1)
        # Whether anybody asked: "no address" is only a fact where the name was looked up.
        Asked     = [bool]$found
    }
}

function Format-NetworkDisruptionServer {
    <#
    .SYNOPSIS
        A Server in a Finding: what the machine asked for first, the other half in
        brackets - "OLDDC (nicht aufloesbar)", "192.0.2.25 (fs01.contoso.example)". Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Server, [AllowNull()][AllowEmptyCollection()]$Lookup)

    $known = Get-NetworkDisruptionServerName -Server $Server -Lookup $Lookup
    if (-not $known.Asked) { return $known.Targeted }
    $other = $known.Address
    $none  = Get-Text 'Value.NetworkDisruption.NoAddress'
    if ($known.IsAddress) { $other = $known.Name; $none = Get-Text 'Value.NetworkDisruption.NoName' }
    if (-not $other) { $other = $none }
    # Not "the Server at this address": the name leads to each of them, and which one it
    # was at the time is for the events to say.
    elseif ($known.Several) { $other = (Get-Text 'Value.NetworkDisruption.LeadsTo') -f $other }
    '{0} ({1})' -f $known.Targeted, $other
}

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

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

    if ($broken.Count -eq $scans.Count) {
        # Nothing was asked for, so nothing was read. Saying "none" here would be the
        # Report calling a network quiet because a Definition was empty or mistyped.
        return New-Finding -Category Gutcheck -Check (Get-Text 'Check.NetworkDisruption.Definition') -Severity INFO `
            -Value (Get-Text 'Value.NetworkDisruption.NoScans') `
            -Hint (Get-Text 'Hint.NetworkDisruption.FixDefinition')
    }
    if ($broken.Count) {
        New-Finding -Category Gutcheck -Check (Get-Text 'Check.NetworkDisruption.Definition') -Severity INFO `
            -Value ((Get-Text 'Value.NetworkDisruption.BrokenScans') -f $broken.Count, $scans.Count) `
            -Hint (Get-Text 'Hint.NetworkDisruption.FixDefinition')
    }

    # The Kind tokens that are part of the timeline without being a disruption: a resume,
    # a link that came back, a session that was re-established.
    $context = @(@(Get-Parameter $Parameters 'ContextKinds' @()) | ForEach-Object { "$_" })
    # What an adapter's driver logged is context whatever a Definition lists: nobody
    # knows of every driver which of its warnings is a link that went down.
    $context += $script:NetworkDisruptionAdapterKind
    $counted = @((Get-DataCollection $Data 'Disruptions') | Where-Object { $context -notcontains "$(Get-DataProperty $_ 'Kind')" })

    # Which refused log leaves which Finding without its evidence. The SMBClient channels
    # are where a Server is named, so a Run that was refused them can still say what the
    # adapter did, and must not say that no Server dropped.
    $unreadable = Get-DataCollection $Data 'UnreadableLogs'
    $counting   = @($scans | Where-Object { -not $_.Problem -and $context -notcontains $_.Kind })
    $naming     = @($counting | Where-Object { $null -ne $_.ServerIndex -or $_.ServerIndexByVersion.Count })
    $serverLogs = @($naming | ForEach-Object { $_.Log })
    $localLogs  = @($counting | Where-Object { $naming -notcontains $_ } | ForEach-Object { $_.Log })

    # A connection that was lost and one that never came about are two Findings: the
    # first is a Server, a cable or a switch failing under a program that was using it,
    # the second is something asking for a Server that is not there - as a rule one that
    # was retired, still named by a drive, a printer, a script or a policy.
    $failedKinds = @(@(Get-Parameter $Parameters 'FailedConnectionKinds' @('smb-connect-failed')) | ForEach-Object { "$_" })
    # And a third that is neither: the Server answered that it has no such share. Seen
    # on a machine whose drives go by the domain's name: 114 "lost connections" in a
    # month, a FAIL, and the advice to check the cable - for a namespace that one of
    # two Servers did not have.
    $named    = @($counted | Where-Object { Get-DataProperty $_ 'Server' })
    $notFound = @($named | Where-Object { "$(Get-DataProperty $_ 'Kind')" -eq 'smb-share-not-found' })
    $named    = @($named | Where-Object { "$(Get-DataProperty $_ 'Kind')" -ne 'smb-share-not-found' })
    $failed = @($named | Where-Object { $failedKinds -contains "$(Get-DataProperty $_ 'Kind')" })
    $lost   = @($named | Where-Object { $failedKinds -notcontains "$(Get-DataProperty $_ 'Kind')" })

    New-NetworkDisruptionServerFinding -Data $Data -Parameters $Parameters -Situation $Situation `
        -Disruption $lost `
        -Unread @($unreadable | Where-Object { $serverLogs -contains $_ }) -Log $serverLogs
    New-NetworkDisruptionFailedFinding -Data $Data -Parameters $Parameters -Disruption $failed
    New-NetworkDisruptionShareFinding -Data $Data -Parameters $Parameters -Disruption $notFound
    # Not among them: an event that should have named a Server and named nothing usable.
    # Rows gathered before that was recorded carry no such mark and count as they did.
    New-NetworkDisruptionLocalFinding -Data $Data -Parameters $Parameters -Situation $Situation `
        -Disruption @($counted | Where-Object { -not (Get-DataProperty $_ 'Server') -and -not (Get-DataProperty $_ 'ServerExpected') }) `
        -Unread @($unreadable | Where-Object { $localLogs -contains $_ }) -Log $localLogs

    New-NetworkDisruptionSwitchingFinding -Data $Data -Parameters $Parameters
    New-NetworkDisruptionLinkFinding -Data $Data -Parameters $Parameters -Situation $Situation -Scan @($scans | Where-Object { -not $_.Problem })

    New-UnreadableLogFinding -Data $Data -Parameters $Parameters
}

function New-NetworkDisruptionQuietFinding {
    <#
    .SYNOPSIS
        The Finding for a list with nothing in it: quiet, or not read.
    .DESCRIPTION
        An empty list is only good news when every log behind it was read. With one
        refused, "none" is what a Run without admin rights reports about every network,
        however bad.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$Data,
        [Parameter(Mandatory)][string]$Check,
        [AllowNull()][AllowEmptyCollection()]$Unread,
        # The logs this Finding is read from: how far back each reaches decides how
        # much "none" is worth.
        [AllowNull()][AllowEmptyCollection()][string[]]$Log
    )

    $unreadable = @($Unread | Where-Object { $_ })
    if ($unreadable.Count) {
        return New-Finding -Category Network -Check $Check -Severity INFO `
            -Value ((Get-Text 'Value.NetworkDisruption.NotAssessable') -f ($unreadable -join ', ')) `
            -Hint (Get-Text 'Hint.NetworkDisruption.RerunWithAdmin')
    }

    # "None in 30 days" from a log that holds three is none in three. The log that
    # starts latest is the one that limits it. A day into the period is no gap worth a
    # sentence, and an empty log says nothing of how far it reaches.
    $since = ConvertTo-DataTime (Get-DataProperty $Data 'Since')
    if ($null -ne $since) {
        $short = @((Get-DataCollection $Data 'LogCoverage') | Where-Object { @($Log) -contains "$(Get-DataProperty $_ 'Log')" } |
            ForEach-Object { [pscustomobject]@{ Log = "$(Get-DataProperty $_ 'Log')"; Oldest = ConvertTo-DataTime (Get-DataProperty $_ 'Oldest') } } |
            Where-Object { $null -ne $_.Oldest -and $_.Oldest -gt $since.AddDays(1) } | Sort-Object Oldest -Descending)
        if ($short.Count) {
            return New-Finding -Category Network -Check $Check -Severity INFO `
                -Value ((Get-Text 'Value.NetworkDisruption.NoneSince') -f $short[0].Oldest, (Get-NetworkDisruptionDays -Data $Data), $short[0].Log) `
                -Meaning (Get-Text 'Meaning.NetworkDisruption.ShortLog') -Hint (Get-Text 'Hint.NetworkDisruption.ShortLog')
        }
    }

    New-Finding -Category Network -Check $Check -Severity OK `
        -Value ((Get-Text 'Value.NetworkDisruption.NoneInPeriod') -f (Get-NetworkDisruptionDays -Data $Data))
}

function Get-NetworkDisruptionDays {
    [CmdletBinding()]
    param([AllowNull()]$Data)

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

function New-NetworkDisruptionServerFinding {
    <#
    .SYNOPSIS
        How often the connection to a Server was lost or refused, per Server.
    .DESCRIPTION
        Judged on the Server with the most, not on the total: ten Servers that each dropped
        once are a laptop that was carried home, and one Server that dropped ten times is a
        Server, a switch port or a cable.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$Data, [hashtable]$Parameters, [AllowNull()]$Situation,
        [AllowNull()][AllowEmptyCollection()]$Disruption,
        [AllowNull()][AllowEmptyCollection()]$Unread,
        [AllowNull()][AllowEmptyCollection()][string[]]$Log
    )

    $warn = Get-Parameter $Parameters 'ServerDisruptionWarnAbove' 5
    $fail = Get-Parameter $Parameters 'ServerDisruptionFailAbove' 20

    $check = Get-Text 'Check.NetworkDisruption.Servers'
    $rows  = @($Disruption | Where-Object { $_ })
    if (-not $rows.Count) { return New-NetworkDisruptionQuietFinding -Data $Data -Check $check -Unread $Unread -Log $Log }

    # Counted as the times a connection was lost, not as the events that reported it: one
    # lost connection is logged as five or more. See Group-NetworkDisruptionEpisode.
    $gap     = ConvertTo-Number (Get-Parameter $Parameters 'EpisodeSeconds' $script:NetworkDisruptionEpisodeSeconds)
    if ($null -eq $gap -or $gap -lt 0) { $gap = $script:NetworkDisruptionEpisodeSeconds }
    $servers = @(Group-NetworkDisruptionEpisode -Disruption $rows -GapSeconds $gap |
        Sort-Object @{ Expression = 'Episodes'; Descending = $true }, Server)
    $lookup = Get-DataCollection $Data 'ServerNames'
    $named = Format-NetworkDisruptionServerList -Server $servers -Lookup $lookup
    $total = ($servers | Measure-Object -Property Episodes -Sum).Sum

    # Even one is worth a line: a program on a share does not survive it.
    $severity = Get-Severity $servers[0].Episodes $warn $fail
    if ($severity -eq 'OK') { $severity = 'INFO' }

    # Several Servers is this machine's connection. One Server is that Server, and the
    # Finding is then about it.
    # Several Servers lost once each is a note, and no sign of this machine's
    # connection: beside an adapter with power saving on - which most have - it made a
    # Lead of two things neither of which was a warning. It counts where it warns.
    $signal  = @()
    if ($severity -ne 'INFO') { $signal = @('servers-lost') }
    $subject = ''
    if ($servers.Count -eq 1) { $signal = @('server-lost'); $subject = "$($servers[0].Server)" }

    New-Finding -Category Network -Check $check -Severity $severity `
        -Value ((Get-Text 'Value.NetworkDisruption.Episodes') -f $total, (Get-NetworkDisruptionDays -Data $Data), $rows.Count, $named) `
        -Meaning (Get-Text 'Meaning.NetworkDisruption.Servers') `
        -Hint ((Get-Text 'Hint.NetworkDisruption.Servers') -f (Get-SituationLinkText -Situation $Situation)) `
        -Signal $signal -Subject $subject
}

function Get-NetworkDisruptionKindMeaning {
    <#
    .SYNOPSIS
        What a kind of disruption means, in a sentence. Nothing for a token a Check
        Definition invented. Pure.
    #>

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

    switch ($Kind) {
        'smb-connect-failed'      { return (Get-Text 'Meaning.NetworkDisruption.Kind.SmbConnectFailed') }
        'smb-disconnect'          { return (Get-Text 'Meaning.NetworkDisruption.Kind.SmbDisconnect') }
        'smb-timeout'             { return (Get-Text 'Meaning.NetworkDisruption.Kind.SmbTimeout') }
        'smb-handle-lost'         { return (Get-Text 'Meaning.NetworkDisruption.Kind.SmbHandleLost') }
        'smb-auth-failed'         { return (Get-Text 'Meaning.NetworkDisruption.Kind.SmbAuthFailed') }
        'smb-share-refused'       { return (Get-Text 'Meaning.NetworkDisruption.Kind.SmbShareRefused') }
        'smb-reconnect'           { return (Get-Text 'Meaning.NetworkDisruption.Kind.SmbReconnect') }
        'network-disconnected'    { return (Get-Text 'Meaning.NetworkDisruption.Kind.NetworkDisconnected') }
        'adapter-reset'           { return (Get-Text 'Meaning.NetworkDisruption.Kind.AdapterReset') }
        'link-down'               { return (Get-Text 'Meaning.NetworkDisruption.Kind.LinkDown') }
        'link-up'                 { return (Get-Text 'Meaning.NetworkDisruption.Kind.LinkUp') }
        'wlan-disconnect'         { return (Get-Text 'Meaning.NetworkDisruption.Kind.WlanDisconnect') }
        'wlan-connect-failed'     { return (Get-Text 'Meaning.NetworkDisruption.Kind.WlanConnectFailed') }
        'standby'                 { return (Get-Text 'Meaning.NetworkDisruption.Kind.Standby') }
        'resume'                  { return (Get-Text 'Meaning.NetworkDisruption.Kind.Resume') }
        'offline-files'           { return (Get-Text 'Meaning.NetworkDisruption.Kind.OfflineFiles') }
        'offline-files-online'    { return (Get-Text 'Meaning.NetworkDisruption.Kind.OfflineFilesOnline') }
        'offline-files-slow-link' { return (Get-Text 'Meaning.NetworkDisruption.Kind.OfflineFilesSlowLink') }
        'adapter-driver'          { return (Get-Text 'Meaning.NetworkDisruption.Kind.AdapterDriver') }
        'system-start'            { return (Get-Text 'Meaning.NetworkDisruption.Kind.SystemStart') }
        'smb-share-not-found'     { return (Get-Text 'Meaning.NetworkDisruption.Kind.SmbShareNotFound') }
    }
    ''
}

function Get-NetworkDisruptionSweep {
    <#
    .SYNOPSIS
        Whether failed connections are a program going through the network address by
        address, and how. Nothing when they are not. Pure.
    .DESCRIPTION
        Seen on an administrator's PC: thirty-three addresses of one subnet, each asked
        once over SMB within ten minutes, every evening for a month - printers, phones,
        switches that have no file service to answer with. A thousand failed connections
        that are no fault of any network.
 
        A pass is the attempts that follow each other without a pause longer than
        GapSeconds; it is a sweep when it reaches at least MinimumTargets different
        Servers. One retired Server asked for at every start is many attempts at one
        target; four Servers that failed together once are few targets and one pass. It
        is called a pattern from two sweeps that between them hold most of the attempts.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Disruption,
        [double]$GapSeconds = 300,
        [int]$MinimumTargets = 10
    )

    $rows = @($Disruption | Where-Object { $_ -and (Get-DataProperty $_ 'Time') -as [datetime] } |
        Sort-Object { [datetime](Get-DataProperty $_ 'Time') })
    if ($rows.Count -lt $MinimumTargets * 2) { return }

    $sweeps = New-Object 'System.Collections.Generic.List[object]'
    $pass   = New-Object 'System.Collections.Generic.List[object]'
    $close  = {
        if (-not $pass.Count) { return }
        $targets = @($pass | ForEach-Object { "$(Get-DataProperty $_ 'Server')".ToUpperInvariant() } | Where-Object { $_ } | Sort-Object -Unique)
        if ($targets.Count -ge $MinimumTargets) {
            $sweeps.Add([pscustomobject]@{
                Start = [datetime](Get-DataProperty $pass[0] 'Time'); End = [datetime](Get-DataProperty $pass[$pass.Count - 1] 'Time')
                Targets = $targets.Count; Attempts = $pass.Count })
        }
        $pass.Clear()
    }
    $previous = $null
    foreach ($row in $rows) {
        $at = [datetime](Get-DataProperty $row 'Time')
        if ($null -ne $previous -and ($at - $previous).TotalSeconds -gt $GapSeconds) { & $close }
        $pass.Add($row)
        $previous = $at
    }
    & $close

    $inSweeps = ($sweeps | Measure-Object -Property Attempts -Sum).Sum
    if ($sweeps.Count -lt 2 -or ($inSweeps * 100) -lt (80 * $rows.Count)) { return }

    $sizes = @($sweeps | ForEach-Object { $_.Targets } | Sort-Object)
    [pscustomobject]@{
        Sweeps  = $sweeps.Count
        Targets = $sizes[[int][math]::Floor(($sizes.Count - 1) / 2)]
        Start   = $sweeps[$sweeps.Count - 1].Start
        End     = $sweeps[$sweeps.Count - 1].End
    }
}

function New-NetworkDisruptionFailedFinding {
    <#
    .SYNOPSIS
        How often a connection to a Server did not come about, per Server. Nothing when
        there was none.
    .DESCRIPTION
        Seen on four machines of one domain: a domain controller retired years ago, 13
        to 19 times a month on each, listed among the Servers whose connection "dropped"
        - and on one machine that made a FAIL. Nothing dropped. Something asks for a name
        at every start, and the name is gone.
 
        Never a FAIL, and a WARN only above ServerConnectFailedWarnAbove for one Server:
        no program lost its connection. Says nothing when there was none - that the logs
        could not be read is said by the Finding about lost connections.
    #>

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

    $warn = Get-Parameter $Parameters 'ServerConnectFailedWarnAbove' 10
    $fail = Get-Parameter $Parameters 'ServerConnectFailedFailAbove' ([double]::MaxValue)

    $rows = @($Disruption | Where-Object { $_ })
    if (-not $rows.Count) { return }

    $gap = ConvertTo-Number (Get-Parameter $Parameters 'EpisodeSeconds' $script:NetworkDisruptionEpisodeSeconds)
    if ($null -eq $gap -or $gap -lt 0) { $gap = $script:NetworkDisruptionEpisodeSeconds }
    $servers = @(Group-NetworkDisruptionEpisode -Disruption $rows -GapSeconds $gap |
        Sort-Object @{ Expression = 'Episodes'; Descending = $true }, Server)
    $lookup = Get-DataCollection $Data 'ServerNames'
    $named = Format-NetworkDisruptionServerList -Server $servers -Lookup $lookup
    $total = ($servers | Measure-Object -Property Episodes -Sum).Sum

    # A host on the internet is no Server of this network: seen was the licence counter
    # of a font foundry, asked for as a file share because a file held its address
    # without a protocol. It is named apart, and neither warns nor is looked for as a
    # Server that is gone or was switched off.
    $internet = @($servers | Where-Object { Test-NetworkDisruptionInternetServer -Server $_.Server -Lookup $lookup })
    $servers  = @($servers | Where-Object { $internet -notcontains $_ })

    $severity = 'INFO'
    if ($servers.Count) { $severity = Get-Severity $servers[0].Episodes $warn $fail }
    if ($severity -eq 'OK') { $severity = 'INFO' }

    $value   = (Get-Text 'Value.NetworkDisruption.FailedEpisodes') -f $total, (Get-NetworkDisruptionDays -Data $Data), $rows.Count, $named
    $meaning = Get-Text 'Meaning.NetworkDisruption.Failed'
    $hint    = Get-Text 'Hint.NetworkDisruption.Failed'
    $reference = @(New-Reference -Section 'Title.NetworkDisruption.Summary')

    # A Server whose name has an address is not a Server that is gone: it was switched
    # off, or behind a VPN that was down. "Remove what still points at it" sent a
    # Technician after a reference to a Server that was in daily use. Told apart by what
    # DNS answered at the Run; where nobody asked - older data - as it was.
    $existing = @($servers | Where-Object {
        $known = Get-NetworkDisruptionServerName -Server $_.Server -Lookup $lookup
        $known.Asked -and $known.Name -and $known.Address } | ForEach-Object { $_.Server })
    if ($existing.Count -eq $servers.Count) {
        $meaning = Get-Text 'Meaning.NetworkDisruption.FailedExisting'
        $hint    = Get-Text 'Hint.NetworkDisruption.FailedExisting'
    }
    elseif ($existing.Count) {
        $meaning = '{0} | {1}' -f $meaning, ((Get-Text 'Meaning.NetworkDisruption.FailedSomeExisting') -f ($existing -join ', '))
        $hint    = Get-Text 'Hint.NetworkDisruption.FailedMixed'
    }

    if ($internet.Count) {
        $outside = @($internet | ForEach-Object { $_.Server }) -join ', '
        $value   = '{0} | {1}' -f $value, ((Get-Text 'Value.NetworkDisruption.FailedInternet') -f $outside)
        $about   = (Get-Text 'Meaning.NetworkDisruption.FailedInternet') -f $outside
        if ($servers.Count) {
            $meaning = '{0} | {1}' -f $meaning, $about
            $hint    = '{0} | {1}' -f $hint, (Get-Text 'Hint.NetworkDisruption.FailedInternet')
        }
        else {
            $meaning = $about
            $hint    = Get-Text 'Hint.NetworkDisruption.FailedInternet'
        }
    }

    # Many addresses, each once, pass after pass: that is a program looking through the
    # network, and "something still points at a Server that is gone" would send the
    # Technician after thirty-three Servers that never were.
    $sweepGap     = ConvertTo-Number (Get-Parameter $Parameters 'SweepGapSeconds' 300)
    $sweepTargets = ConvertTo-Number (Get-Parameter $Parameters 'SweepMinimumTargets' 10)
    $sweep = Get-NetworkDisruptionSweep -Disruption $rows -GapSeconds $sweepGap -MinimumTargets $sweepTargets
    if ($sweep) {
        $value     = '{0} | {1}' -f $value, ((Get-Text 'Value.NetworkDisruption.Sweep') -f $sweep.Sweeps, $sweep.Targets, $sweep.Start, $sweep.End)
        $meaning   = Get-Text 'Meaning.NetworkDisruption.Sweep'
        $hint      = Get-Text 'Hint.NetworkDisruption.Sweep'
        $reference += New-Reference -Section 'Title.Startup.ScheduledTasks'
    }

    New-Finding -Category Network -Check (Get-Text 'Check.NetworkDisruption.Failed') -Severity $severity `
        -Value $value -Meaning $meaning -Hint $hint -Reference $reference
}

function New-NetworkDisruptionShareFinding {
    <#
    .SYNOPSIS
        How often a Server answered that it does not have the share it was asked for,
        per Server. Nothing when it never did.
    .DESCRIPTION
        Not a disruption of the network: the Server was reached and answered. It is what
        a drive mapped by the domain's name logs when the name leads to several Servers
        and not every one of them has the share - and then a sign-in waits a minute and
        a half for a drive, on the mornings the wrong Server answers first.
    #>

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

    $rows = @($Disruption | Where-Object { $_ })
    if (-not $rows.Count) { return }

    $warn = Get-Parameter $Parameters 'ShareNotFoundWarnAbove' 10
    $gap  = ConvertTo-Number (Get-Parameter $Parameters 'EpisodeSeconds' $script:NetworkDisruptionEpisodeSeconds)
    if ($null -eq $gap -or $gap -lt 0) { $gap = $script:NetworkDisruptionEpisodeSeconds }
    $servers = @(Group-NetworkDisruptionEpisode -Disruption $rows -GapSeconds $gap |
        Sort-Object @{ Expression = 'Episodes'; Descending = $true }, Server)
    $total = ($servers | Measure-Object -Property Episodes -Sum).Sum

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

    $value = @((Get-Text 'Value.NetworkDisruption.ShareNotFound') -f $total, (Get-NetworkDisruptionDays -Data $Data), $rows.Count,
        (Format-NetworkDisruptionServerList -Server $servers -Lookup (Get-DataCollection $Data 'ServerNames')))
    # When last: a fault that stopped a week ago and one of this morning have the same
    # count for the month, and only this tells them apart.
    $lastSeen = @($rows | ForEach-Object { ConvertTo-DataTime (Get-DataProperty $_ 'Time') } | Where-Object { $null -ne $_ } | Sort-Object)[-1]
    if ($null -ne $lastSeen) { $value += (Get-Text 'Value.NetworkDisruption.ShareNotFound.Last') -f $lastSeen }

    # Which share: said where the events name one. Most do not - Windows logs this as a
    # session that was lost, and a session names its Server only - and then that is
    # said, so that nobody looks for a name in the list of events that is not there.
    $shares = @($rows | ForEach-Object { "$(Get-DataProperty $_ 'Share')" } | Where-Object { $_ } | Group-Object |
        Sort-Object @{ Expression = 'Count'; Descending = $true }, Name | Select-Object -First 8 | ForEach-Object { '{0} {1}x' -f $_.Name, $_.Count })
    if ($shares.Count) { $value += (Get-Text 'Value.NetworkDisruption.ShareNotFound.Shares') -f ($shares -join '; ') }
    else { $value += Get-Text 'Value.NetworkDisruption.ShareNotFound.NotNamed' }

    # Which machine it was that answered: a name several Servers answer to does not say,
    # and the one that does not have the share is the one to look at.
    $answered = @($rows | ForEach-Object { "$(Get-DataProperty $_ 'Address')" } | Where-Object { $_ } | Group-Object |
        Sort-Object @{ Expression = 'Count'; Descending = $true }, Name | Select-Object -First 5 | ForEach-Object { '{0} {1}x' -f $_.Name, $_.Count })
    if ($answered.Count) { $value += (Get-Text 'Value.NetworkDisruption.ShareNotFound.Answered') -f ($answered -join '; ') }
    else {
        # The event itself may name no address. The session that is re-established a
        # moment later does: not who refused, but where the name led then.
        $marks = @($rows | ForEach-Object {
            $at = ConvertTo-DataTime (Get-DataProperty $_ 'Time')
            if ($null -ne $at) { [pscustomobject]@{ Server = "$(Get-DataProperty $_ 'Server')"; Time = $at } } })
        $back = @((Get-DataCollection $Data 'Disruptions') | Where-Object { "$(Get-DataProperty $_ 'Kind')" -eq 'smb-reconnect' -and "$(Get-DataProperty $_ 'Address')" } | Where-Object {
            $server = "$(Get-DataProperty $_ 'Server')"
            $at     = ConvertTo-DataTime (Get-DataProperty $_ 'Time')
            $null -ne $at -and @($marks | Where-Object { $_.Server -eq $server -and $at -ge $_.Time -and ($at - $_.Time).TotalSeconds -le 10 }).Count
        } | ForEach-Object { "$(Get-DataProperty $_ 'Address')" } | Group-Object |
            Sort-Object @{ Expression = 'Count'; Descending = $true }, Name | Select-Object -First 5 | ForEach-Object { '{0} {1}x' -f $_.Name, $_.Count })
        if ($back.Count) { $value += (Get-Text 'Value.NetworkDisruption.ShareNotFound.Reconnected') -f ($back -join '; ') }
    }

    New-Finding -Category Network -Check (Get-Text 'Check.NetworkDisruption.ShareNotFound') -Severity $severity `
        -Value ($value -join ' | ') `
        -Meaning (Get-Text 'Meaning.NetworkDisruption.ShareNotFound') -Hint (Get-Text 'Hint.NetworkDisruption.ShareNotFound') `
        -Signal 'share-not-found' -LastSeen $lastSeen -Reference @(New-Reference -Section 'Title.NetworkDisruption.Events')
}

function New-NetworkDisruptionLocalFinding {
    <#
    .SYNOPSIS
        How often the network went away at the machine itself: the events that name no Server.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$Data, [hashtable]$Parameters, [AllowNull()]$Situation,
        [AllowNull()][AllowEmptyCollection()]$Disruption,
        [AllowNull()][AllowEmptyCollection()]$Unread,
        [AllowNull()][AllowEmptyCollection()][string[]]$Log
    )

    # A notebook loses its network every time it leaves its dock, so only a count no
    # working day explains is a warning, and none is a failure unless a Definition says so.
    $warn = Get-Parameter $Parameters 'LocalDisruptionWarnAbove' 30
    $fail = Get-Parameter $Parameters 'LocalDisruptionFailAbove' ([double]::MaxValue)

    $check = Get-Text 'Check.NetworkDisruption.Local'
    $rows  = @($Disruption | Where-Object { $_ })
    if (-not $rows.Count) { return New-NetworkDisruptionQuietFinding -Data $Data -Check $check -Unread $Unread -Log $Log }

    $byKind = ($rows | Group-Object { "$(Get-DataProperty $_ 'Kind')" } |
        Sort-Object @{ Expression = 'Count'; Descending = $true }, Name |
        ForEach-Object { '{0} {1}x' -f (Get-NetworkDisruptionKindText -Kind $_.Name), $_.Count }) -join ', '

    $severity = Get-Severity $rows.Count $warn $fail
    if ($severity -eq 'OK') { $severity = 'INFO' }

    # Whether that is normal depends on what the machine is. Nothing to add for a
    # virtual machine: its network does not go away with a dock or a change of place.
    $normal = Select-NetworkDisruptionLocalText -Situation $Situation -Text @{
        Desktop  = Get-Text 'Meaning.NetworkDisruption.Local.Desktop'
        Virtual  = ''
        Docked   = Get-Text 'Meaning.NetworkDisruption.Local.Docked'
        Notebook = Get-Text 'Meaning.NetworkDisruption.Local.Notebook'
        Unknown  = Get-Text 'Meaning.NetworkDisruption.Local.Unknown'
    }

    New-Finding -Category Network -Check $check -Severity $severity `
        -Value ((Get-Text 'Value.NetworkDisruption.Count') -f $rows.Count, (Get-NetworkDisruptionDays -Data $Data), $byKind) `
        -Signal 'link-dropped' `
        -Meaning (@(@((Get-Text 'Meaning.NetworkDisruption.Local'), $normal) | Where-Object { $_ }) -join ' | ') `
        -Hint (Select-NetworkDisruptionLocalText -Situation $Situation -Text @{
            Desktop  = Get-Text 'Hint.NetworkDisruption.Local.Desktop'
            Virtual  = Get-Text 'Hint.NetworkDisruption.Local.Virtual'
            Docked   = Get-Text 'Hint.NetworkDisruption.Local.Docked'
            Notebook = Get-Text 'Hint.NetworkDisruption.Local.Notebook'
            Unknown  = Get-Text 'Hint.NetworkDisruption.Local'
        })
}

function Select-NetworkDisruptionLocalText {
    <#
    .SYNOPSIS
        The wording about a network that went away at the machine, by what the machine
        is. Pure.
    .PARAMETER Text
        A wording for each of Desktop, Virtual, Docked and Notebook, and one for Unknown.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Situation, [Parameter(Mandatory)][hashtable]$Text)

    Select-SituationText -Situation $Situation -Fact Form -Text @{
        Desktop  = $Text['Desktop']
        Virtual  = $Text['Virtual']
        # A dropped link is what undocking looks like; on a notebook that is not
        # docked, or of which that is not known, it is what moving about looks like.
        Notebook = Select-SituationText -Situation $Situation -Fact Docked -Text @{
            Yes     = $Text['Docked']
            Unknown = $Text['Notebook']
        }
        Unknown  = $Text['Unknown']
    }
}

function Get-NetworkDisruptionKindText {
    <#
    .SYNOPSIS
        What the Report calls a Kind token. Pure.
    .DESCRIPTION
        The token is data: it is what a Check Definition writes, what a later Check
        compares and what the gathered rows carry. A Technician reads German, so a Finding
        and a Section show the name written for the token in Private/Text.ps1.
 
        A literal Get-Text per token and no computed key, so that the tests can hold every
        key to being defined and to being used.
    #>

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

    switch ($Kind) {
        'smb-connect-failed'      { return (Get-Text 'Value.NetworkDisruption.Kind.SmbConnectFailed') }
        'smb-disconnect'          { return (Get-Text 'Value.NetworkDisruption.Kind.SmbDisconnect') }
        'smb-timeout'             { return (Get-Text 'Value.NetworkDisruption.Kind.SmbTimeout') }
        'smb-handle-lost'         { return (Get-Text 'Value.NetworkDisruption.Kind.SmbHandleLost') }
        'smb-auth-failed'         { return (Get-Text 'Value.NetworkDisruption.Kind.SmbAuthFailed') }
        'smb-share-refused'       { return (Get-Text 'Value.NetworkDisruption.Kind.SmbShareRefused') }
        'smb-reconnect'           { return (Get-Text 'Value.NetworkDisruption.Kind.SmbReconnect') }
        'network-disconnected'    { return (Get-Text 'Value.NetworkDisruption.Kind.NetworkDisconnected') }
        'adapter-reset'           { return (Get-Text 'Value.NetworkDisruption.Kind.AdapterReset') }
        'link-down'               { return (Get-Text 'Value.NetworkDisruption.Kind.LinkDown') }
        'link-up'                 { return (Get-Text 'Value.NetworkDisruption.Kind.LinkUp') }
        'wlan-disconnect'         { return (Get-Text 'Value.NetworkDisruption.Kind.WlanDisconnect') }
        'wlan-connect-failed'     { return (Get-Text 'Value.NetworkDisruption.Kind.WlanConnectFailed') }
        'standby'                 { return (Get-Text 'Value.NetworkDisruption.Kind.Standby') }
        'resume'                  { return (Get-Text 'Value.NetworkDisruption.Kind.Resume') }
        'offline-files'           { return (Get-Text 'Value.NetworkDisruption.Kind.OfflineFiles') }
        'offline-files-online'    { return (Get-Text 'Value.NetworkDisruption.Kind.OfflineFilesOnline') }
        'offline-files-slow-link' { return (Get-Text 'Value.NetworkDisruption.Kind.OfflineFilesSlowLink') }
        'adapter-driver'          { return (Get-Text 'Value.NetworkDisruption.Kind.AdapterDriver') }
        'system-start'            { return (Get-Text 'Value.NetworkDisruption.Kind.SystemStart') }
        'smb-share-not-found'     { return (Get-Text 'Value.NetworkDisruption.Kind.SmbShareNotFound') }
    }
    # A token a Check Definition invented is shown as it came rather than hidden.
    "$Kind"
}

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

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

    # The headings are German like the rest of the Report, so the rows are built with the
    # names the tables are to show.
    $count    = Get-Text 'Column.NetworkDisruption.Count'
    $name     = Get-Text 'Column.NetworkDisruption.Name'
    $address  = Get-Text 'Column.NetworkDisruption.Address'
    $lookup   = Get-DataCollection $Data 'ServerNames'
    $missing  = @{ Name = (Get-Text 'Value.NetworkDisruption.NoName'); Address = (Get-Text 'Value.NetworkDisruption.NoAddress') }
    # Name and address of a Server, and which column holds what the machine asked for:
    # the Report sets that cell apart. A row without a Server has neither.
    $columns = {
        param($Row, [string]$Server)
        if (-not "$Server".Trim()) { $Row[$name] = ''; $Row[$address] = ''; return }
        $known = Get-NetworkDisruptionServerName -Server $Server -Lookup $lookup
        $Row[$name]    = $known.Name
        $Row[$address] = $known.Address
        if ($known.Asked) {
            if (-not $known.Name)    { $Row[$name]    = $missing.Name }
            if (-not $known.Address) { $Row[$address] = $missing.Address }
        }
        $Row['_Highlight'] = $(if ($known.IsAddress) { $address } else { $name })
    }
    $kind     = Get-Text 'Column.NetworkDisruption.Kind'
    $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'

    # Sorted on the gathered values and named afterwards: the most frequent first.
    New-Section -Title (Get-Text 'Title.NetworkDisruption.Summary') -Help (Get-Text 'Help.NetworkDisruption.Summary') -Row @(
        $rows | Group-Object { '{0}|{1}' -f "$(Get-DataProperty $_ 'Server')".ToUpperInvariant(), (Get-DataProperty $_ 'Kind') } |
            ForEach-Object {
                [pscustomobject]@{
                    Count  = $_.Count
                    Server = "$(Get-DataProperty $_.Group[0] 'Server')".ToUpperInvariant()
                    Kind   = "$(Get-DataProperty $_.Group[0] 'Kind')"
                    First  = Get-DataProperty $_.Group[-1] 'Time'
                    Last   = Get-DataProperty $_.Group[0] 'Time'
                }
            } | Sort-Object @{ Expression = 'Count'; Descending = $true }, Server, Kind |
            ForEach-Object {
                $row = [ordered]@{}
                $row[$count]  = $_.Count
                & $columns $row $_.Server
                $row[$kind]   = Get-NetworkDisruptionKindText -Kind $_.Kind
                # What that kind is, where a Technician reads the table and not the Findings.
                $row[(Get-Text 'Column.NetworkDisruption.Meaning')] = Get-NetworkDisruptionKindMeaning -Kind $_.Kind
                $row[$first]  = $_.First
                $row[$last]   = $_.Last
                [pscustomobject]$row
            }
    )

    # What a driver logs while the machine starts is named as that: a warning in this
    # list is otherwise read as a link that dropped.
    $atStart = @(Get-NetworkDisruptionAtStart -Disruption $rows)
    New-Section -Title (Get-Text 'Title.NetworkDisruption.Events') -Help (Get-Text 'Help.NetworkDisruption.Events') -Row @(
        $rows | Select-Object -First $script:NetworkDisruptionSectionRows | ForEach-Object {
            $row = [ordered]@{}
            $row[$time]     = Get-DataProperty $_ 'Time'
            $row[$kind]     = Get-NetworkDisruptionKindText -Kind "$(Get-DataProperty $_ 'Kind')"
            if ($atStart -contains $_) { $row[$kind] = (Get-Text 'Value.NetworkDisruption.Kind.AtStart') -f $row[$kind] }
            & $columns $row "$(Get-DataProperty $_ 'Server')"
            # The address the event itself names is where the name led then. What DNS
            # answers now is one of several for a name several Servers answer to.
            $logged = "$(Get-DataProperty $_ 'Address')".Trim()
            if ($logged) { $row[$address] = $logged }
            $row[$provider] = Get-DataProperty $_ 'Provider'
            $row[$id]       = Get-DataProperty $_ 'Id'
            $row[$message]  = Get-DataProperty $_ 'Message'
            [pscustomobject]$row
        }
    )
}
function ConvertTo-NetworkDisruptionEvent {
    <#
    .SYNOPSIS
        The events this Check gathered, for the Report's events.csv.
    .DESCRIPTION
        Built from the rows rather than carried beside them: the same events twice in the
        gathered data would be twice the data for a later Check to be handed, and two lists
        that have to agree.
    #>

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

    foreach ($row in (Get-DataCollection $Data 'Disruptions')) {
        [pscustomobject]@{
            Time     = Get-DataProperty $row 'Time'
            Category = $script:NetworkDisruptionEventTag
            Provider = Get-DataProperty $row 'Provider'
            Id       = Get-DataProperty $row 'Id'
            Message  = Get-DataProperty $row 'Message'
        }
    }
}