Private/NetworkMap.ps1

# The Network Map.
#
# Every Server, Subnet, Domain, Namespace, Service, share and mapped drive a Run observed,
# with the Links between them and the Target Machine at their start. Assembled once, after
# both Parts' Checks are merged and before the Leads are drawn, from the Links, the
# Measurements and the Timed Events the Judges returned and from which Checks ran: the one
# place that knows which Server is which and how things are connected. Written to the
# Report folder as network.json (Write-NetworkJson). See ADR-0012.
#
# Pure: the same records give the same Map, and nothing is asked of the machine.
#
# Observed, never assumed. A Subnet is one an adapter of the Target Machine is in; a range
# a view assumes around an address it cannot place is not in the Map. Two Links that
# disagree are both kept, and a Server is in every Subnet one of its addresses is in. What
# no Check that ran could have observed is unknown, not absent (Coverage.Unknown).
#
# A thing exists in the Map by being named - by a Link, a Measurement or a Timed Event.
# There is no record per thing.

# The Role a Link gives the Server at one of its ends, beside the Roles it was measured in:
# a DNS server is one by being used as one. By kind, the end, and for LeadsTo and OnServer
# only from the thing named.
$script:NetworkMapLinkRoles = @(
    @{ Kind = 'UsesGateway';  End = 'To';   From = '';          Role = 'Gateway' }
    @{ Kind = 'UsesDns';      End = 'To';   From = '';          Role = 'DnsServer' }
    @{ Kind = 'UsesDhcp';     End = 'To';   From = '';          Role = 'DhcpServer' }
    @{ Kind = 'ControllerOf'; End = 'From'; From = '';          Role = 'DomainController' }
    @{ Kind = 'LeadsTo';      End = 'To';   From = 'Namespace'; Role = 'DfsTarget' }
    @{ Kind = 'OnServer';     End = 'To';   From = 'Share';     Role = 'FileServer' }
)

# The port of the Service a Link of this kind uses its Server through: a DNS server answers
# on 53, a DHCP server on 67. A gateway routes, and is used as a Server.
$script:NetworkMapUsedPorts = @{ UsesDns = 53; UsesDhcp = 67 }

# What a Check became in this Run, as the coverage says it. Only Ran observed anything.
$script:NetworkMapCheckStatuses = @('Ran', 'Skipped', 'LeftOutByScope', 'LackedRights')

function New-NetworkMapCoverage {
    <#
    .SYNOPSIS
        Which Checks of a Run ran, which the Technician skipped, which the Scope left out
        and which lacked their rights: what the Network Map reads "not looked at" from. Pure.
    .DESCRIPTION
        A missing Link is unknown where the Check that would have observed it did not run,
        as a Lead says what was not looked at (ADR-0005). A Check skipped by -Skip is
        Skipped, whatever else holds; one that lacked its rights is LackedRights; a Check
        the Scope left out, of the machine or of an application, is LeftOutByScope.
    .PARAMETER Check
        The Check Definitions the Run performs, of both Parts.
    .PARAMETER Skip
        What the Technician skipped, by Check Name or Kind.
    .PARAMETER LeftOut
        The Check Definitions the Scope left out.
    .PARAMETER LackedRights
        The Check Definitions that needed admin rights this Run did not have: the Elevated
        Part was refused, broke off, or was not asked for.
    .PARAMETER Scope
        The title of the Scope the Run was narrowed by; empty for a Run of everything.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Check,
        [AllowNull()][AllowEmptyCollection()][string[]]$Skip = @(),
        [AllowNull()][AllowEmptyCollection()]$LeftOut,
        [AllowNull()][AllowEmptyCollection()]$LackedRights,
        [AllowNull()][AllowEmptyString()][string]$Scope = ''
    )

    $nameOf = { param($Definition) $n = "$(Get-DataProperty $Definition 'Name')"; if (-not $n) { $n = "$(Get-DataProperty $Definition 'Kind')" }; $n }
    $lacking = @(@($LackedRights | Where-Object { $_ }) | ForEach-Object { & $nameOf $_ })
    $entry = {
        param($Definition, [string]$Status)
        $parameters = ConvertTo-ParameterHashtable (Get-DataProperty $Definition 'Parameters')
        [pscustomobject]@{
            Check  = & $nameOf $Definition
            Kind   = "$(Get-DataProperty $Definition 'Kind')"
            App    = "$(Get-Parameter $parameters 'App' '')"
            Status = $Status
        }
    }

    $checks = @(
        foreach ($definition in @($Check | Where-Object { $_ })) {
            $status = if (Test-CheckSkipped -Definition $definition -Skip $Skip) { 'Skipped' }
                      elseif ($lacking -contains (& $nameOf $definition)) { 'LackedRights' }
                      else { 'Ran' }
            & $entry $definition $status
        }
        foreach ($definition in @($LeftOut | Where-Object { $_ })) { & $entry $definition 'LeftOutByScope' }
    )
    [pscustomobject]@{
        PSTypeName = 'Gutcheck.NetworkMapCoverage'
        Scope      = "$Scope"
        Checks     = $checks
    }
}

function New-NetworkMap {
    <#
    .SYNOPSIS
        The Network Map of a Run, from the Links, Measurements and Timed Events its Judges
        returned and the Checks that ran. Pure.
    .DESCRIPTION
        Which Server is which is decided here, once, and only by the identity module
        (Private/ServerIdentity.ps1): every record that names a Server - a Measurement by its
        Subject and Address, a Timed Event of a Server's lane, an end of a Link, the Server
        a share or a Service is on - is one sighting, in that order, by its name and address
        as ConvertTo-NetworkMapSighting reads them: of a web address its host. Which Server a
        sighting is, Select-NetworkMapServer says, and New-NetworkMapServer joins them: by
        its name where it has one, by its address only where it has none - two different
        FQDNs are never one, whatever address they share (#145).
 
        A Server's Key is its first FQDN, else its first name, else its first address, as
        ConvertTo-ServerName writes them, else its Role in brackets, "(Gateway)"; where two
        Servers would have one Key - two machines that share a short name - each is keyed by
        its address. A share and a Service are keyed by the Key of the Server they are on, so
        that a share named on \\FS01 and a Server measured as fs01.contoso.example meet.
 
        The Map holds:
        - Machine: the Target Machine (Key "machine"), with its own Measurements.
        - Servers: Key, Names, Addresses, Roles (as measured and as its Links give them, in
          the order of the Measurement's Roles), Subnets (the Keys of the observed Subnets
          one of its addresses is in), Measurement and TimedEvent (the records naming it).
        - Subnets: Key, Address, PrefixLength. Domains: Key. Sites: Key, Name, Domain.
          Namespaces: Key, Server, Name. Shares: Key, Server, Name, Service (the Key of the
          SMB Service of its Server, which it is offered through). Services: Key, Server,
          Port, Url, Name (what the Links that name it call it, as their Check Definitions
          name its port: each name once, ignoring case, in the order of the Links, joined by
          " / "; empty where none does), Apps (the App of each Uses Link to it), and the
          Measurement and TimedEvent of it: each record of its Server that names its port,
          or its URL (Get-NetworkMapServiceKey) - a Service a record names is made by it.
          Adapters: Key, Name, Measurement. Drives: Key. Apps: Key, the name of an
          App whose Check reached a Service (Uses). WindowsServices: Key, Name, and the
          TimedEvent of it: the crashes the logs held, by its service name.
        - Links: Kind, From and To (each Thing and Key, as the Map keys it), CheckName, App;
          every one as it was observed, two that disagree both.
        - Coverage: Scope, Checks (Check, Kind, App, Status, and Links: the kinds of Link
          its Kind observes, by the thing each starts from, as Kind:From), Unknown: each
          Kind:From no Check that ran could have observed, and Unobservable: those of them
          no Kind of this module observes at all, whatever ran.
    .PARAMETER Coverage
        The Checks of the Run, as New-NetworkMapCoverage gives them. Without it nothing is
        known to have run, and every kind of Link is unknown.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Link,
        [AllowNull()][AllowEmptyCollection()]$Measurement,
        [AllowNull()][AllowEmptyCollection()]$TimedEvent,
        [AllowNull()]$Coverage
    )

    $links    = @($Link | Where-Object { $_ })
    $measured = @($Measurement | Where-Object { $_ })
    # Read by their properties and not through Get-DataProperty: a Run hands thousands of
    # events over, and a call for each field of each cost seconds. A record lacks none of
    # these; one that did would read as empty all the same.
    $events   = @(foreach ($e in @($TimedEvent)) { if ($e -and "$($e.Lane)" -eq 'Server') { $e } })

    # Every record that names a Server, as one sighting each, in the order the Map reads
    # them. The Servers are made of all of them at once (New-NetworkMapServer), and each
    # sighting is known by its number among them.
    $sightings = New-Object System.Collections.Generic.List[psobject]
    $addSighting = {
        param([string]$Name, [string]$Address, [string]$Role, $Measurement, $TimedEvent)
        $read = ConvertTo-NetworkMapSighting -Name $Name -Address $Address
        $sightings.Add([pscustomobject]@{ Name = $read.Name; Address = $read.Address; Role = $Role; Measurement = $Measurement; TimedEvent = $TimedEvent })
        $sightings.Count - 1
    }

    # The things other than Servers, by Key, in the order they were first named.
    $things = @{}
    foreach ($thing in $script:LinkThings) { $things[$thing] = [ordered]@{} }
    $machineMeasured = New-Object System.Collections.Generic.List[psobject]
    $adapterMeasured = @{}
    $remember = {
        param($End)
        $table = $things[$End.Thing]
        if (-not $table.Contains($End.Key)) { $table[$End.Key] = $End }
        if ($End.Thing -eq 'Site') {
            $domain = New-LinkEnd -Thing Domain -Name $End.Domain
            if (-not $things['Domain'].Contains($domain.Key)) { $things['Domain'][$domain.Key] = $domain }
        }
    }

    foreach ($m in $measured) {
        $role = "$(Get-DataProperty $m 'Role')"
        $subject = "$(Get-DataProperty $m 'Subject')".Trim()
        if ($role -eq 'Machine' -or ($role -eq 'Adapter' -and -not $subject)) { $machineMeasured.Add($m); continue }
        if ($role -eq 'Adapter') {
            $end = New-LinkEnd -Thing Adapter -Name $subject
            & $remember $end
            if (-not $adapterMeasured.ContainsKey($end.Key)) { $adapterMeasured[$end.Key] = New-Object System.Collections.Generic.List[psobject] }
            $adapterMeasured[$end.Key].Add($m)
            continue
        }
        $null = & $addSighting $subject "$(Get-DataProperty $m 'Address')" $role $m $null
    }
    foreach ($e in $events) {
        $read = ConvertTo-NetworkMapSighting -Name "$($e.Subject)" -Address "$($e.Address)"
        $sightings.Add([pscustomobject]@{ Name = $read.Name; Address = $read.Address; Role = ''; Measurement = $null; TimedEvent = $e })
    }

    # Each Link's ends: a Server sighted, the Server a share or a Service is on sighted
    # too, everything else remembered by its Key. Sighting is the number of the end's
    # sighting, -1 for an end that names no Server.
    $ends = @(foreach ($l in $links) {
        $kind = "$(Get-DataProperty $l 'Kind')"
        $from = ConvertTo-LinkEnd -End (Get-DataProperty $l 'From')
        $to   = ConvertTo-LinkEnd -End (Get-DataProperty $l 'To')
        $pair = foreach ($side in @(@('From', $from), @('To', $to))) {
            $end = $side[1]
            $role = @($script:NetworkMapLinkRoles | Where-Object {
                $_.Kind -eq $kind -and $_.End -eq $side[0] -and (-not $_.From -or $_.From -eq $from.Thing) }) | Select-Object -First 1
            $sighting = -1
            if ($end.Thing -eq 'Server') { $sighting = & $addSighting $end.Name $end.Address $(if ($role) { $role.Role }) $null $null }
            else {
                & $remember $end
                if ($end.Thing -in 'Share', 'Service' -and $end.Server) { $sighting = & $addSighting $end.Server $end.Address '' $null $null }
            }
            [pscustomobject]@{ End = $end; Sighting = $sighting }
        }
        , @($pair)
    })

    # The domain's names: a Domain the Links name, else the DNS suffix of a Server's FQDN.
    $domainNames = Get-NetworkMapDomainName -Domain @($things['Domain'].Keys) -Name @($sightings | ForEach-Object Name)
    $placed = New-NetworkMapServer -Sighting $sightings.ToArray() -DomainName $domainNames

    $subnets = @($things['Subnet'].Values | ForEach-Object {
        [pscustomobject]@{ Key = $_.Key; Address = $_.Address; PrefixLength = $_.PrefixLength }
    })
    $roleOrder = { param($Role) $at = [array]::IndexOf($script:MeasurementRoleValues, $Role); if ($at -lt 0) { 99 } else { $at } }
    $servers = @(foreach ($server in $placed.Server) {
        $in = @($subnets | Where-Object {
            $subnet = $_
            @($server.Addresses | Where-Object {
                $parsed = ConvertTo-NetworkAddress -Address $_
                $parsed -and (Test-AddressInPrefix -Address $parsed -Network $subnet.Address -PrefixLength $subnet.PrefixLength)
            }).Count
        } | ForEach-Object Key)
        [pscustomobject]@{
            Key         = $server.Key
            Names       = [string[]]@($server.Names)
            Addresses   = [string[]]@($server.Addresses)
            Roles       = [string[]]@($server.Roles | Sort-Object { & $roleOrder $_ })
            Subnets     = [string[]]$in
            Measurement = @($server.Measurement)
            TimedEvent  = @($server.TimedEvent)
        }
    })

    # A share and a Service as the Map keys them: by the Key of the Server they are on.
    $serverKeyOf = { param([int]$Sighting, [string]$Fallback) if ($Sighting -ge 0 -and $placed.Key[$Sighting]) { $placed.Key[$Sighting] } else { $Fallback } }
    $mapEnd = {
        param($Pair)
        $end = $Pair.End
        $key = $end.Key
        $server = $end.Server
        switch ($end.Thing) {
            'Server'  { $key = & $serverKeyOf $Pair.Sighting $end.Key }
            'Share'   { $server = & $serverKeyOf $Pair.Sighting $end.Server; $key = '\\{0}\{1}' -f $server, $end.Name }
            'Service' { $server = & $serverKeyOf $Pair.Sighting $end.Server; if (-not $end.Url) { $key = '{0}:{1}' -f $server, $end.Port } }
        }
        [pscustomobject]@{ Thing = $end.Thing; Key = $key; Server = $server }
    }
    $mappedLinks = New-Object System.Collections.Generic.List[psobject]
    $shares = [ordered]@{}
    $services = [ordered]@{}
    # What the Links call each Service, by its Key: every name once, in the order the Links
    # came. Two Definitions that call one port differently are both kept, as two Links that
    # disagree are (ADR-0012).
    $serviceNames = @{}
    for ($i = 0; $i -lt $links.Count; $i++) {
        $pair = $ends[$i]
        $from = & $mapEnd $pair[0]
        $to   = & $mapEnd $pair[1]
        foreach ($one in @(@($from, $pair[0].End), @($to, $pair[1].End))) {
            $mapped = $one[0]; $end = $one[1]
            if ($mapped.Thing -eq 'Share' -and -not $shares.Contains($mapped.Key)) {
                $shares[$mapped.Key] = [pscustomobject]@{ Key = $mapped.Key; Server = $mapped.Server; Name = $end.Name }
            }
            if ($mapped.Thing -eq 'Service' -and -not $services.Contains($mapped.Key)) {
                $services[$mapped.Key] = [pscustomobject]@{ Key = $mapped.Key; Server = $mapped.Server; Port = $end.Port; Url = $end.Url }
            }
            if ($mapped.Thing -eq 'Service' -and "$($end.Name)") {
                if (-not $serviceNames.ContainsKey($mapped.Key)) { $serviceNames[$mapped.Key] = New-Object System.Collections.Generic.List[string] }
                $named = $serviceNames[$mapped.Key]
                if (-not @($named | Where-Object { $_ -eq "$($end.Name)" }).Count) { $named.Add("$($end.Name)") }
            }
        }
        $mappedLinks.Add([pscustomobject]@{
            Kind      = "$(Get-DataProperty $links[$i] 'Kind')"
            From      = [pscustomobject]@{ Thing = $from.Thing; Key = $from.Key }
            To        = [pscustomobject]@{ Thing = $to.Thing; Key = $to.Key }
            CheckName = "$(Get-DataProperty $links[$i] 'CheckName')"
            App       = "$(Get-DataProperty $links[$i] 'App')"
        })
    }

    # Each record of a Server that names a port or a URL is that Service's as well: a TCP
    # connect to 445, the SMB drops the logs held of it, a URL's response. A ping names none,
    # and stays the Server's alone.
    foreach ($server in $servers) {
        foreach ($pair in @(@('Measurement', $server.Measurement), @('TimedEvent', $server.TimedEvent))) {
            foreach ($record in @($pair[1])) {
                $url = $(if ($pair[0] -eq 'Measurement') { "$($record.Group)" } else { "$($record.Url)" })
                $key = Get-NetworkMapServiceKey -ServerKey $server.Key -Port $record.Port -Url $url
                if (-not $key) { continue }
                if (-not $services.Contains($key)) {
                    $isUrl = $key -eq $url
                    $services[$key] = [pscustomobject]@{ Key = $key; Server = $server.Key; Port = $(if ($isUrl) { $record.Port } else { [int]$record.Port }); Url = $(if ($isUrl) { $url } else { '' }) }
                }
                $service = $services[$key]
                if (-not $service.PSObject.Properties['Measurement']) {
                    $service | Add-Member -NotePropertyName Measurement -NotePropertyValue (New-Object System.Collections.Generic.List[psobject])
                    $service | Add-Member -NotePropertyName TimedEvent -NotePropertyValue (New-Object System.Collections.Generic.List[psobject])
                }
                $service.($pair[0]).Add($record)
            }
        }
    }
    # A share is reached over SMB: its Server offers the SMB Service, which the share is
    # offered through - made here where no record or Link named it, as a Service a record
    # names is.
    foreach ($share in @($shares.Values)) {
        $key = Get-NetworkMapServiceKey -ServerKey "$($share.Server)" -Port 445
        if (-not $key) { continue }
        if (-not $services.Contains($key)) { $services[$key] = [pscustomobject]@{ Key = $key; Server = "$($share.Server)"; Port = 445; Url = '' } }
        $share | Add-Member -NotePropertyName Service -NotePropertyValue $key -Force
    }
    # A DNS server and a DHCP server are used as such through their Services, on 53 and 67:
    # the machine asks the service, not the Server. Made here where no record named it, as
    # the SMB Service of a share is.
    foreach ($link in $mappedLinks) {
        if (-not $script:NetworkMapUsedPorts.ContainsKey($link.Kind) -or $link.To.Thing -ne 'Server') { continue }
        $port = $script:NetworkMapUsedPorts[$link.Kind]
        $key = Get-NetworkMapServiceKey -ServerKey "$($link.To.Key)" -Port $port
        if (-not $key) { continue }
        if (-not $services.Contains($key)) { $services[$key] = [pscustomobject]@{ Key = $key; Server = "$($link.To.Key)"; Port = $port; Url = '' } }
    }
    foreach ($service in @($services.Values)) {
        if (-not $service.PSObject.Properties['Measurement']) {
            $service | Add-Member -NotePropertyName Measurement -NotePropertyValue (New-Object System.Collections.Generic.List[psobject])
            $service | Add-Member -NotePropertyName TimedEvent -NotePropertyValue (New-Object System.Collections.Generic.List[psobject])
        }
        # What it is called and who uses it, for the Report to say which Service a Finding is
        # about. A Service only a record, a share or the DNS made is called nothing.
        $named = $serviceNames[$service.Key]
        $service | Add-Member -NotePropertyName Name -NotePropertyValue $(if ($named) { $named -join ' / ' } else { '' })
        $service | Add-Member -NotePropertyName Apps -NotePropertyValue ([string[]]@($mappedLinks | Where-Object {
            $_.Kind -eq 'Uses' -and $_.To.Thing -eq 'Service' -and $_.To.Key -eq $service.Key } | ForEach-Object { $_.From.Key } | Select-Object -Unique))
    }

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.NetworkMap'
        Machine    = [pscustomobject]@{ Key = 'machine'; Measurement = @($machineMeasured) }
        Servers    = $servers
        Subnets    = $subnets
        Domains    = @($things['Domain'].Values | ForEach-Object { [pscustomobject]@{ Key = $_.Key } })
        Sites      = @($things['Site'].Values | ForEach-Object { [pscustomobject]@{ Key = $_.Key; Name = $_.Name; Domain = $_.Domain } })
        Namespaces = @($things['Namespace'].Values | ForEach-Object { [pscustomobject]@{ Key = $_.Key; Server = $_.Server; Name = $_.Name } })
        Services   = @($services.Values | ForEach-Object { $_ })
        Shares     = @($shares.Values)
        Adapters   = @($things['Adapter'].GetEnumerator() | ForEach-Object {
            $measuredHere = $adapterMeasured[$_.Key]
            [pscustomobject]@{ Key = $_.Value.Key; Name = $_.Value.Name; Measurement = @($(if ($measuredHere) { $measuredHere })) }
        })
        Drives     = @($things['Drive'].Values | ForEach-Object { [pscustomobject]@{ Key = $_.Key } })
        Apps       = @($things['App'].Values | ForEach-Object { [pscustomobject]@{ Key = $_.Key } })
        WindowsServices = @($things['WindowsService'].Values | ForEach-Object {
            $key = $_.Key
            [pscustomobject]@{ Key = $key; Name = $_.Name; TimedEvent = @(foreach ($e in @($TimedEvent)) { if ($e -and "$($e.Lane)" -eq 'Marker' -and "$($e.Subject)".Trim().ToLowerInvariant() -eq $key) { $e } }) }
        })
        Links      = @($mappedLinks)
        Coverage   = Get-NetworkMapCoverageView -Coverage $Coverage
    }
}

function Get-NetworkMapServiceKey {
    <#
    .SYNOPSIS
        The Key of the Service a record of a Server is about: its URL where it names one,
        else the Server's Key and the port it names; nothing for a record of neither - a
        ping. Pure.
    .DESCRIPTION
        The one rule a record is put on its Service by, here on the Map and in the views,
        as New-LinkEnd keys a Service: "fs01.contoso.example:445", or the URL.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$ServerKey, [AllowNull()]$Port, [AllowNull()][AllowEmptyString()][string]$Url = '')

    $address = "$Url".Trim()
    # A URL as Split-ServiceSubject reads one: a share path in Group is no URL.
    if (Test-ServiceUrl -Text $address) { return $address }
    $number = ConvertTo-Number $Port
    if (-not "$ServerKey" -or $null -eq $number) { return '' }
    '{0}:{1}' -f $ServerKey, [int]$number
}

function New-NetworkMapServer {
    <#
    .SYNOPSIS
        The Servers of a Network Map, made of every sighting of one, and the Key of the
        Server each sighting is. Pure.
    .DESCRIPTION
        A sighting is the name, the address and the Role a record names a Server by, as
        ConvertTo-NetworkMapSighting reads them, with the Measurement or the Timed Event it
        is of. It is the Servers Select-NetworkMapServer says it is, joined into one, else a
        Server of its own. A sighting of an address alone is placed last, once every name
        is: it is the one Server that has that address, and which Servers have it is known
        only then; where none or several do, it stays a Server of its own, keyed by the
        address. The domain's name at an address counts as that address alone: it is the one
        Server there, never two, and no Server of its own where its address places it (#145).
        A sighting of nothing - no name, no address, no Role - is no Server.
 
        A Server's Key is its first FQDN, else its first name, else its first address, else
        its Role in brackets, "(Gateway)", which no name or address is; where two Servers
        would have one Key - two machines that share a short name - each is keyed by its
        address.
 
        Returns Server - each with Key, Names, Addresses, Roles, Measurement and TimedEvent,
        in the order they were first sighted - and Key: the Key of the Server of each
        sighting, in their order, empty for a sighting of nothing.
    .PARAMETER Sighting
        Each with Name, Address, Role, and the Measurement or the TimedEvent it is of.
    .PARAMETER DomainName
        The names that are a Domain's (Get-NetworkMapDomainName). A sighting of one at an
        address is the one Server at that address, as an address alone is.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Sighting, [AllowNull()][AllowEmptyCollection()][string[]]$DomainName = @())

    $servers = New-Object System.Collections.Generic.List[psobject]
    # How often the Servers have changed - one added, two joined, a name, an address or a
    # Role added to one. A sighting the same as one placed while they stood as they stand
    # now is the same Server, and is not asked again: a Run logs thousands of events of a
    # few Servers, and asking for each cost the Report half a minute.
    $changes = @{ Count = 0 }
    $placedAs = @{}
    # A Server joined into another points to the one it became part of (MergedInto), so that
    # a sighting placed before the joining still finds its Server.
    $survivorOf = { param($Server) while ($Server.MergedInto) { $Server = $Server.MergedInto }; $Server }
    $merge = {
        param($Server, $Other)
        if ([object]::ReferenceEquals($Server, $Other)) { return }
        $changes.Count++
        foreach ($x in $Other.Names)       { if (-not $Server.Names.Contains($x)) { $Server.Names.Add($x) } }
        foreach ($x in $Other.Addresses)   { if (-not $Server.Addresses.Contains($x)) { $Server.Addresses.Add($x) } }
        foreach ($x in $Other.Roles)       { if (-not $Server.Roles.Contains($x)) { $Server.Roles.Add($x) } }
        foreach ($x in $Other.Measurement) { $Server.Measurement.Add($x) }
        foreach ($x in $Other.TimedEvent)  { $Server.TimedEvent.Add($x) }
        $Other.MergedInto = $Server
        $null = $servers.Remove($Other)
    }

    # A sighting placed among the Servers of a pool: the ones it is, joined, else a new one.
    $place = {
        param($One, [string]$Name, [string]$Address, [string]$Role, $Pool)
        $found = @(Select-NetworkMapServer -Server $Pool -Name $Name -Address $Address -Role $Role)
        if ($found.Count) {
            $server = $found[0]
            foreach ($other in @($found | Select-Object -Skip 1)) { & $merge $server $other }
        }
        else {
            $server = [pscustomobject]@{
                Key = ''; Names = New-Object System.Collections.Generic.List[string]; Addresses = New-Object System.Collections.Generic.List[string]
                Roles = New-Object System.Collections.Generic.List[string]; Measurement = New-Object System.Collections.Generic.List[psobject]
                TimedEvent = New-Object System.Collections.Generic.List[psobject]; MergedInto = $null
            }
            $servers.Add($server)
            $changes.Count++
        }
        if ($Name -and -not $server.Names.Contains($Name)) { $server.Names.Add($Name); $changes.Count++ }
        if ($Address -and -not $server.Addresses.Contains($Address)) { $server.Addresses.Add($Address); $changes.Count++ }
        if ($Role -and -not $server.Roles.Contains($Role)) { $server.Roles.Add($Role); $changes.Count++ }
        $measured = Get-DataProperty $One 'Measurement'
        $logged = Get-DataProperty $One 'TimedEvent'
        if ($null -ne $measured) { $server.Measurement.Add($measured) }
        if ($null -ne $logged) { $server.TimedEvent.Add($logged) }
        $server
    }

    $all = @($Sighting)
    $serverOfSighting = New-Object System.Collections.Generic.List[psobject]
    $domainAtAddress = New-Object System.Collections.Generic.List[int]
    for ($i = 0; $i -lt $all.Count; $i++) {
        # By its properties, as New-NetworkMap made it: thousands of sightings a Run.
        $one = $all[$i]
        $name = "$($one.Name)"
        $address = "$($one.Address)"
        $role = "$($one.Role)"
        $serverOfSighting.Add($null)
        if (-not $name -and -not $address -and -not $role) { continue }
        # The domain's name at an address waits for every other Server (below).
        if ($name -and $address -and @($DomainName) -contains $name) { $domainAtAddress.Add($i); continue }
        # An address alone is placed among the Servers of no name for now, and joins a named
        # one only once every name is placed (below).
        $among = $(if ($name -or -not $address) { 'all' } else { 'unnamed' })
        $same = '{0}|{1}|{2}|{3}' -f $name, $address, $role, $among
        $before = $placedAs[$same]
        if ($before -and $before.Stable -and $before.Changes -eq $changes.Count) {
            # Placed so twice, the second time changing nothing, and nothing changed since: the
            # same question of the same Servers, so the same Server, and its record on it.
            $measured = $one.Measurement
            $logged = $one.TimedEvent
            if ($null -ne $measured) { $before.Server.Measurement.Add($measured) }
            if ($null -ne $logged) { $before.Server.TimedEvent.Add($logged) }
            $serverOfSighting[$i] = $before.Server
            continue
        }
        $pool = $(if ($among -eq 'all') { $servers.ToArray() } else { @($servers | Where-Object { -not $_.Names.Count }) })
        $unchanged = $changes.Count
        $serverOfSighting[$i] = & $place $all[$i] $name $address $role $pool
        $stable = $null -ne $before -and $before.Changes -eq $unchanged -and $changes.Count -eq $unchanged -and
                  [object]::ReferenceEquals($before.Server, $serverOfSighting[$i])
        $placedAs[$same] = @{ Changes = $changes.Count; Server = $serverOfSighting[$i]; Stable = $stable }
    }

    # Each address alone, now that every name is placed: the one named Server that has it.
    foreach ($bare in @($servers | Where-Object { -not $_.Names.Count -and $_.Addresses.Count })) {
        $holder = @(Select-NetworkMapServer -Server @($servers | Where-Object { $_.Names.Count }) -Address $bare.Addresses[0])
        if ($holder.Count -eq 1) { & $merge $holder[0] $bare }
    }

    # The domain's name at an address - a time source, a share of the domain's own, logged
    # at the controller that answered - is the one Server at that address, as an address
    # alone is, and does not become a name of it: it is no Server of its own beside the
    # controllers (#145). Where its address places it on none or on several, it is a Server
    # by its name, as any name is.
    foreach ($i in $domainAtAddress) {
        $name = "$(Get-DataProperty $all[$i] 'Name')"
        $address = "$(Get-DataProperty $all[$i] 'Address')"
        $role = "$(Get-DataProperty $all[$i] 'Role')"
        $holder = @(Select-NetworkMapServer -Server $servers.ToArray() -Address $address)
        $serverOfSighting[$i] = $(if ($holder.Count -eq 1) { & $place $all[$i] '' $address $role $holder }
                                  else { & $place $all[$i] $name $address $role $servers.ToArray() })
    }
    # Each Server's Key, its own where no other Server would have it.
    $keyCount = @{}
    foreach ($server in $servers) {
        $server.Key = "$(@(@($server.Names | Where-Object { $_.Contains('.') }) + @($server.Names) + @($server.Addresses))[0])"
        if (-not $server.Key) { $server.Key = '({0})' -f $server.Roles[0] }
        $keyCount[$server.Key] = 1 + [int]$keyCount[$server.Key]
    }
    foreach ($server in $servers) {
        if ($keyCount[$server.Key] -gt 1 -and $server.Addresses.Count) { $server.Key = $server.Addresses[0] }
    }

    $keys = New-Object System.Collections.Generic.List[string]
    foreach ($server in $serverOfSighting) { $keys.Add($(if ($null -ne $server) { (& $survivorOf $server).Key } else { '' })) }
    [pscustomobject]@{ Server = $servers.ToArray(); Key = $keys.ToArray() }
}

function Select-NetworkMapServer {
    <#
    .SYNOPSIS
        The Servers a name and an address are, by the Network Map's one rule: what the Map
        joins a sighting to, and where a view finds a record or a Subject. Pure.
    .DESCRIPTION
        By the name where there is one, by the address only where there is none - no record
        is matched by an address it shares with another (#145):
 
        - A name is each Server one of whose names is the same Server (Test-NetworkMapServerName):
          two different FQDNs are never one. A FQDN is one machine at every address it was
          logged at. A short name with an address keeps apart from a Server of that name that
          has another address of the same family - two machines can share a short name - while
          an IPv4 and an IPv6 address say nothing of each other.
        - A name without an address that several Servers with an address are - a short name
          logged at two machines - stands for none of them: only for one without an address,
          or the one whose FQDN it is.
        - A short name that is two Servers of two FQDNs - FS01 beside fs01.contoso.example
          and fs01.fabrikam.example - is the one its address puts it with, else one of no FQDN.
        - An address alone is the one Server that has it; where several do, the one of them
          with no name - the address on its own - else none.
        - Neither - a gateway that is not there - is the Server of its Role that has neither.
 
        Returns each Server it is, in the order handed in; nothing where it is none.
    .PARAMETER Server
        The Servers to choose from, each with Names, Addresses and Roles as the Map keeps them.
    .PARAMETER Name
        The name, as ConvertTo-NetworkMapSighting reads it.
    .PARAMETER Address
        The address, likewise.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Server,
        [AllowNull()][AllowEmptyString()][string]$Name = '',
        [AllowNull()][AllowEmptyString()][string]$Address = '',
        [AllowNull()][AllowEmptyString()][string]$Role = ''
    )

    $servers = @($Server | Where-Object { $_ })
    $fqdnsOf = { param($One) @(@($One.Names) | Where-Object { $_.Contains('.') }) }
    $isFqdn = $Name.Contains('.')
    if ($Name) {
        $family = $(if ($Address) { (ConvertTo-NetworkAddress -Address $Address).AddressFamily })
        $named = @($servers | Where-Object {
            $addresses = @($_.Addresses)
            if ($isFqdn -and @($_.Names) -contains $Name) { return $true }
            if (-not (Test-NetworkMapServerName -Server $_ -Name $Name)) { return $false }
            -not $Address -or $addresses -contains $Address -or
                -not @($addresses | Where-Object { (ConvertTo-NetworkAddress -Address $_).AddressFamily -eq $family }).Count
        })
        if (-not $Address -and @($named | Where-Object { @($_.Addresses).Count }).Count -gt 1) {
            $named = @($named | Where-Object { -not @($_.Addresses).Count -or ($isFqdn -and @($_.Names) -contains $Name) })
        }
        if (@($named | ForEach-Object { & $fqdnsOf $_ } | Select-Object -Unique).Count -gt 1) {
            $atAddress = @($named | Where-Object { $Address -and @($_.Addresses) -contains $Address })
            $oneMachine = $atAddress.Count -and @($atAddress | ForEach-Object { & $fqdnsOf $_ } | Select-Object -Unique).Count -le 1
            $named = @(if ($oneMachine) { $atAddress } else { $named | Where-Object { -not (& $fqdnsOf $_).Count } })
        }
        return $named
    }
    if ($Address) {
        $holding = @($servers | Where-Object { @($_.Addresses) -contains $Address })
        if ($holding.Count -le 1) { return $holding }
        return @($holding | Where-Object { -not @($_.Names).Count })
    }
    if (-not $Role) { return }
    @($servers | Where-Object { -not @($_.Names).Count -and -not @($_.Addresses).Count -and @($_.Roles) -contains $Role })
}

function Test-NetworkMapServerName {
    <#
    .SYNOPSIS
        Whether a Server of the Map is a name, by the identity module: one of its names is the
        same Server (Test-SameServer), and for a FQDN it has no other FQDN. Pure.
    .DESCRIPTION
        Two different FQDNs are never one, though a short name of the one begins the other:
        fs01.fabrikam.example is not the Server of fs01.contoso.example and FS01.
    #>

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

    $names = @(@($Server.Names) | Where-Object { $_ })
    if (-not $Name -or -not @($names | Where-Object { Test-SameServer -Name $Name -Other $_ }).Count) { return $false }
    -not ($Name.Contains('.') -and @($names | Where-Object { $_.Contains('.') -and $_ -ne $Name }).Count)
}

function Get-NetworkMapDomainName {
    <#
    .SYNOPSIS
        The names that are a Domain's name, for the identity of a Server: each Domain the Map
        holds, and each name that is the DNS suffix of another's - contoso.example of
        dc01.contoso.example. Pure.
    .DESCRIPTION
        A time source or a share of the domain's own named by the domain's name is the
        controller that answered, not a Server of its own beside the controllers (#145). A Run
        with the Domain Check knows the Domain from its Links (ControllerOf, NamespaceOf, a
        site's); one without it knows the domain's name only as the suffix of the FQDNs of
        the Servers in it.
    .PARAMETER Domain
        The Keys of the Domains on the Map.
    .PARAMETER Name
        Every name a Server is named by, as ConvertTo-NetworkMapSighting reads it.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Domain, [AllowNull()][AllowEmptyCollection()][string[]]$Name)

    $names = @(@($Name) | Where-Object { $_ } | Select-Object -Unique)
    $suffixes = @($names | Where-Object {
        $suffix = '.' + $_
        $_.Contains('.') -and @($names | Where-Object { $_.EndsWith($suffix, [StringComparison]::OrdinalIgnoreCase) }).Count
    })
    [string[]]@(@(@($Domain) | Where-Object { $_ }) + $suffixes | Select-Object -Unique)
}

function Get-NetworkMapCoverageView {
    # The coverage as the Map carries it: each Check with the kinds of Link its Kind
    # observes ($script:<Kind>Links), each Kind:From ($script:LinkObservations) no Check that
    # ran could have observed, and of those the ones no Kind this module ships observes. What
    # the Run adds itself ($script:LinkObservationsOfTheRun) is known where what it adds it
    # to is, and observable where that is.
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Coverage)

    $checks = @((Get-DataCollection $Coverage 'Checks') | Where-Object { $_ } | ForEach-Object {
        $kind = "$(Get-DataProperty $_ 'Kind')"
        $observes = @()
        if ($kind) { $observes = @(Get-KindDeclaration -Kind $kind -Name 'Links') | Where-Object { $_ } }
        [pscustomobject]@{
            Check  = "$(Get-DataProperty $_ 'Check')"
            Kind   = $kind
            App    = "$(Get-DataProperty $_ 'App')"
            Status = "$(Get-DataProperty $_ 'Status')"
            Links  = [string[]]@($observes)
        }
    })
    $known = @($checks | Where-Object { $_.Status -eq 'Ran' } | ForEach-Object { $_.Links })
    $observable = @(@($script:KindVocabulary.Keys) | ForEach-Object { Get-KindDeclaration -Kind $_ -Name 'Links' } | Where-Object { $_ })
    foreach ($added in $script:LinkObservationsOfTheRun.Keys) {
        if ($known -contains $script:LinkObservationsOfTheRun[$added]) { $known += $added }
        if ($observable -contains $script:LinkObservationsOfTheRun[$added]) { $observable += $added }
    }
    $unknown = @($script:LinkObservations | Where-Object { $known -notcontains $_ })
    [pscustomobject]@{
        Scope        = "$(Get-DataProperty $Coverage 'Scope')"
        Checks       = $checks
        Unknown      = [string[]]$unknown
        Unobservable = [string[]]@($unknown | Where-Object { $observable -notcontains $_ })
    }
}

function Find-NetworkMapServer {
    <#
    .SYNOPSIS
        The Server of the Network Map a name or an address is, or nothing. Pure.
    .DESCRIPTION
        By the Map's own rule (Select-NetworkMapServer), so that a record is found on the
        Server the Map put it on - and so is a copy of it, which says the same: by its name
        where it has one, by its address only where it has none, by its Role where it has
        neither; a web address by its host; the domain's name at an address as the one Server
        there (Get-NetworkMapDomainName). How a view finds the Server of a record, and
        of a Finding's Subject. Where that is several Servers, the first whose Key is among
        -Prefer, else the first.
 
        Where it is none, a name is the one Server that carries it as it is, at the address
        where one is given: a short name the Map put with its one FQDN before a Link named a
        second. Else it is of the Servers among -Prefer the first it is by name - FS01 of a
        Finding about fs01.fabrikam.example, beside fs01.contoso.example - and else nothing:
        FS01 alone does not say which of the two it is (ADR-0013).
    .PARAMETER Role
        The Role of a Measurement, which finds the one Server measured without a name or an
        address: a gateway that is not there.
    .PARAMETER Prefer
        The Keys of the Servers to take where a name is several: for a Finding's part, those
        of the Servers that hold a record of the Finding.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$Map,
        [AllowNull()][AllowEmptyString()][string]$Name = '',
        [AllowNull()][AllowEmptyString()][string]$Address = '',
        [AllowNull()][AllowEmptyString()][string]$Role = '',
        [AllowNull()][AllowEmptyCollection()][string[]]$Prefer = @()
    )

    $servers = @((Get-DataCollection $Map 'Servers') | Where-Object { $_ })
    $sighting = ConvertTo-NetworkMapSighting -Name $Name -Address $Address
    # The domain's name at an address is the one Server there, as the Map placed it.
    if ($sighting.Name -and $sighting.Address) {
        $domainNames = Get-NetworkMapDomainName -Domain @((Get-DataCollection $Map 'Domains') | ForEach-Object { "$($_.Key)" }) -Name @(@($servers | ForEach-Object { $_.Names }) + $sighting.Name)
        if (@($domainNames) -contains $sighting.Name) {
            $holder = @(Select-NetworkMapServer -Server $servers -Address $sighting.Address)
            if ($holder.Count -eq 1) { return $holder[0] }
        }
    }
    $found = @(Select-NetworkMapServer -Server $servers -Name $sighting.Name -Address $sighting.Address -Role $Role)
    if (-not $found.Count -and $sighting.Name) {
        $carrying = @($servers | Where-Object { @($_.Names) -contains $sighting.Name -and (-not $sighting.Address -or @($_.Addresses) -contains $sighting.Address) })
        $found = @(if ($carrying.Count -eq 1) { $carrying }
                   else { $servers | Where-Object { @($Prefer) -ccontains $_.Key -and (Test-NetworkMapServerName -Server $_ -Name $sighting.Name) } })
    }
    $preferred = @($found | Where-Object { @($Prefer) -ccontains $_.Key }) | Select-Object -First 1
    if ($preferred) { return $preferred }
    if ($found.Count) { return $found[0] }
    $null
}

function Find-NetworkMapService {
    <#
    .SYNOPSIS
        The Service of the Network Map of a key, or nothing. Pure.
    .DESCRIPTION
        By its Key as it is written, as the Map keys a Service (Get-NetworkMapServiceKey):
        the Key of its Server and the port, or the URL. A key a view or a Lead made for a
        Service of a part is found here; how a part is read into one is
        Get-ReportNetworkPartKey's.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Map, [AllowNull()][AllowEmptyString()][string]$Key)

    if (-not $Key) { return $null }
    $found = @((Get-DataCollection $Map 'Services') | Where-Object { $_ -and "$($_.Key)" -ceq $Key } | Select-Object -First 1)
    if ($found.Count) { return $found[0] }
    $null
}

function Get-NetworkMapServerKey {
    <#
    .SYNOPSIS
        The key of the Server a Subject names, as the Report draws it: the Key of the Network
        Map's Server it is, else the identity module's key among the Run's Subjects. Pure.
    .DESCRIPTION
        What a Lead groups its Findings by and a Reference that keeps to one Server compares
        (ADR-0013), so that neither names a Server "Big Picture" draws as two: FS01 and
        an address the Map holds fs01.contoso.example at are one; fs01.a.example and
        fs01.b.example two, at one address too. Find-NetworkMapServer finds it. Where there
        is no Map - a Report made without a Run - or the Map has no such Server - a program,
        a Server no Check observed - Get-ServerKey among -Among.
    .PARAMETER Among
        Every Subject of the Run, for Get-ServerKey: what tells which FQDN a short name is.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()][AllowEmptyString()][string]$Name,
        [AllowNull()][AllowEmptyCollection()][string[]]$Among,
        [AllowNull()]$Map
    )

    if ($Name -and $null -ne $Map) {
        $server = Find-NetworkMapServer -Map $Map -Name $Name
        if ($server) { return "$($server.Key)" }
    }
    Get-ServerKey -Name $Name -Among $Among
}

function ConvertTo-NetworkMapSighting {
    <#
    .SYNOPSIS
        The name and the address a record names a Server by, as the Map compares them. Pure.
    .DESCRIPTION
        The name as ConvertTo-ServerName writes it - without its port, UNC path, trailing dot
        or case - and of a web address its host: a website's page, measured through a proxy
        too, is the host it was loaded from. A name that is an address is the address. The
        address as ConvertTo-ServerAddress writes it. Either empty where there is none.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyString()][string]$Name, [AllowNull()][AllowEmptyString()][string]$Address)

    # Read once for each way it is written: a Run names a few Servers in thousands of events,
    # and the Map and the Report each read every one.
    $asked = '{0}|{1}' -f $Name, $Address
    $known = $null
    if ($script:NetworkMapSightingRead.TryGetValue($asked, [ref]$known)) { return $known }

    $text = ConvertTo-ServerName -Name $Name
    if (Test-ServiceUrl -Text $text) { $text = ConvertTo-ServerName -Name ([uri]$text).Host }
    $at = ConvertTo-ServerAddress -Address $Address
    if ($text -and (Test-NetworkAddress -Text $text)) {
        if (-not $at) { $at = ConvertTo-ServerAddress -Address $text }
        $text = ''
    }
    $read = [pscustomobject]@{ Name = $text; Address = $at }
    $script:NetworkMapSightingRead[$asked] = $read
    $read
}

# What ConvertTo-NetworkMapSighting read, by what it was given, exactly as given. It is pure,
# so what it read once it reads every time.
$script:NetworkMapSightingRead = New-Object 'System.Collections.Generic.Dictionary[string,psobject]' ([StringComparer]::Ordinal)

# The version of network.json's shape. Raised when a field changes meaning or goes; a field
# added is read past by whoever does not know it. 2: coverage.unknown names a kind where any
# Link of it may be missing, no longer only where all may be; an adapter's key is its alias in
# lower case; and two Servers that only share an address are two.
$script:NetworkJsonVersion = 4

function ConvertTo-NetworkJsonDocument {
    <#
    .SYNOPSIS
        The Network Map as network.json writes it: a version, the things by kind, the Links
        and the coverage. Pure.
    .DESCRIPTION
        The coverage says each Check's Links and what is unknown twice: as kinds (links,
        unknown) - a kind is unknown where any Link of it may be missing, so that a reader of
        the kinds alone errs to unknown - and by the thing each starts from (observes,
        unknownLinks: kind and from), since one kind can be two Checks': LeadsTo from a
        Namespace is the Domain Check's, from a drive the Network Check's. Version 1 named a
        kind unknown only where no Check that ran observed any of it; that meaning changed,
        and so did the version, to 2. Version 3 added a thing, apps, and a kind of Link,
        Uses, a reader of 2 did not know; version 4 another thing, windowsServices, and the
        kind of Link HasService ($script:NetworkJsonVersion).
 
        No Measurements, Timed Events or Findings: a reader joins them by a Server's key -
        the Subject of a row of measurements.csv or timeline.csv, and each Server of the
        Subjects of findings.csv, is that key (Get-CsvServerName) - and by a Finding's Id.
        A Map that is not there is written as the Map of a Run that observed nothing: its
        skeleton, with every kind of Link unknown.
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param([AllowNull()]$Map)

    if ($null -eq $Map) { $Map = New-NetworkMap }
    # As a list however many there are: none is [], not null, and one is [x].
    $list = { param($Name, [scriptblock]$Shape) , @((Get-DataCollection $Map $Name) | Where-Object { $_ } | ForEach-Object $Shape) }
    $end = { param($End) [ordered]@{ thing = "$($End.Thing)"; key = "$($End.Key)" } }
    $coverage = Get-DataProperty $Map 'Coverage'
    # Kind:From, as the kinds alone, each once, and as kind and from.
    $kindsOf = { param($Observed) @($Observed) | Where-Object { $_ } | ForEach-Object { ("$_" -split ':')[0] } | Select-Object -Unique }
    $pairsOf = { param($Observed) @($Observed) | Where-Object { $_ } | ForEach-Object { $one = "$_" -split ':'; [ordered]@{ kind = $one[0]; from = $one[1] } } }

    [ordered]@{
        version    = $script:NetworkJsonVersion
        servers    = & $list 'Servers' {
            [ordered]@{ key = $_.Key; names = @($_.Names); addresses = @($_.Addresses); roles = @($_.Roles); subnets = @($_.Subnets) } }
        subnets    = & $list 'Subnets'    { [ordered]@{ key = $_.Key; address = $_.Address; prefixLength = $_.PrefixLength } }
        domains    = & $list 'Domains'    { [ordered]@{ key = $_.Key } }
        sites      = & $list 'Sites'      { [ordered]@{ key = $_.Key; name = $_.Name; domain = $_.Domain } }
        namespaces = & $list 'Namespaces' { [ordered]@{ key = $_.Key; server = $_.Server; name = $_.Name } }
        services   = & $list 'Services'   { [ordered]@{ key = $_.Key; server = $_.Server; port = $_.Port; url = $_.Url; name = "$($_.Name)" } }
        shares     = & $list 'Shares'     { [ordered]@{ key = $_.Key; server = $_.Server; name = $_.Name; service = "$($_.Service)" } }
        adapters   = & $list 'Adapters'   { [ordered]@{ key = $_.Key } }
        drives     = & $list 'Drives'     { [ordered]@{ key = $_.Key } }
        apps       = & $list 'Apps'       { [ordered]@{ key = $_.Key } }
        windowsServices = & $list 'WindowsServices' { [ordered]@{ key = $_.Key; name = $_.Name } }
        links      = & $list 'Links' {
            [ordered]@{ kind = $_.Kind; from = (& $end $_.From); to = (& $end $_.To); check = $_.CheckName; app = $_.App } }
        coverage   = [ordered]@{
            scope   = "$(Get-DataProperty $coverage 'Scope')"
            checks  = @((Get-DataCollection $coverage 'Checks') | Where-Object { $_ } | ForEach-Object {
                [ordered]@{
                    check = $_.Check; kind = $_.Kind; app = $_.App; status = $_.Status
                    links = @(& $kindsOf $_.Links); observes = @(& $pairsOf $_.Links)
                } })
            unknown      = @(& $kindsOf (Get-DataCollection $coverage 'Unknown'))
            unknownLinks = @(& $pairsOf (Get-DataCollection $coverage 'Unknown'))
        }
    }
}

function Write-NetworkJson {
    <#
    .SYNOPSIS
        Writes a Run's Network Map to network.json, beside the CSVs, for Second Level.
    .DESCRIPTION
        UTF-8 without the byte order mark Windows PowerShell would put before it: the file
        is read by tools, not by German Excel. Written with its skeleton when the Run
        observed nothing, as the CSVs are written with their header: a missing file and a
        Run without the network Checks must not look the same on disk. ADR-0012.
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [AllowNull()]$Map,
        [Parameter(Mandatory)][string]$Path
    )

    $json = ConvertTo-NetworkJsonDocument -Map $Map | ConvertTo-Json -Depth 8
    [IO.File]::WriteAllText($Path, $json, (New-Object System.Text.UTF8Encoding $false))
}