Private/Kinds/GroupPolicy.ps1

# The GroupPolicy Kind: how long Group Policy holds up a boot and a logon, and why.
#
# On a domain-joined machine the desktop does not appear until the user's policy has been
# applied, and the machine's policy runs before anyone can log on at all. A drive mapping
# that waits for a file server that is gone, a printer deployment that installs a driver at
# every logon, a domain controller reachable only through a VPN that is not up yet: each is
# a minute a Customer spends looking at "Willkommen", and each is recorded in the Group
# Policy log with its duration.
#
# Every selection is by event id, and every payload field is read positionally as the
# provider's manifest defines it, for the reason Private/Kinds/Stability.ps1 gives. The
# templates, verified against Microsoft-Windows-GroupPolicy's manifest:
#
# 8000-8007 PolicyElaspedTimeInSeconds, ErrorCode, PrincipalSamName, IsMachine,
# IsConnectivityFailure (8000 boot, 8001 logon)
# 5016/6016/7016 CSEElaspedTimeInMilliSeconds, ErrorCode, CSEExtensionName, CSEExtensionId
# (one extension finished: success / warning / error)
#
# Which run an extension belonged to is the event's ActivityId: every event of one
# processing run carries the same, and the 8000..8007 that ends the run says what kind it
# was. So an extension that took ten minutes is told apart: at a sign-in somebody waited
# for it, in a background refresh nobody did.
# 5314 BandwidthInkbps, IsSlowLink, ThresholdInkbps, ...
# 5308 DCName, DCIPAddress (the domain controller a processing run used)
# System log 1030-1129 SupportInfo1, SupportInfo2, ProcessingMode,
# ProcessingTimeInMilliseconds, ErrorCode, ErrorDescription, DCName, ...
# (1085: ... DCName, ExtensionName, ExtensionId)
#
# ("Elasped" is Microsoft's spelling, in the manifest itself.)
#
# The manifest is not what a machine writes for 1085. Read off a real one: position 5
# holds the domain controller, \\DC01.contoso.example, and position 6 is empty - the two
# the wrong way round, and no description. So of positions 5 and 6 the one that begins
# with two backslashes is the controller and the other the description, whichever way
# round they stand. The Report had shown "Fehler 183 (\\DC01...)": a server where the
# meaning of the number belongs.

# Beside the logs, the result itself: which Group Policy objects Windows applied to the
# computer and to the User, which it did not and why, and how each component ended. That
# is what gpresult writes, and it is asked for as XML - gpresult /scope computer /x - and
# read here, so that a Technician need not run it and read three hundred lines for the
# five that matter. For the User it is asked with /user: this Kind runs in the Elevated
# Part, which may be another account's, and that account's policies are nobody's question.
#
# Of the result only what says how policy was applied is kept: the objects, their state,
# their versions, the components. Not the settings in them, and not the groups an account
# is a member of. One thing more: of each item a preference extension has - a drive, a
# service, a registry value - its name and the object it comes from, and nothing of what
# it sets. A failed extension says in no log which of its items it was; with the items
# beside it, the one that has a single item has said it.
#
# gpresult takes no path of more than 127 characters for its file, and says so in an
# error nobody sees: the file goes into the temp folder under a short name.

# The Operational channel is readable only with admin rights.
$script:GroupPolicyNeedsAdmin = $true

$script:GroupPolicyLog       = 'Microsoft-Windows-GroupPolicy/Operational'
$script:GroupPolicyProvider  = 'Microsoft-Windows-GroupPolicy'

# The Operational log records every extension of every background refresh - every 90
# minutes, a dozen extensions each - so a month of it is thousands of events. The newest
# are the ones that describe the machine as it is.
$script:GroupPolicyMaxExtensionEvents = 2000

# The System-log failures, by what they mean for the Technician.
$script:GroupPolicyConnectivityIds = @(1054, 1129)
$script:GroupPolicyErrorIds        = @(1030, 1053, 1055, 1058, 1096)
$script:GroupPolicyExtensionFailId = 1085

# How long gpresult may take for one scope. Seen: fifteen seconds on a desktop.
$script:GroupPolicyResultTimeoutSeconds = 90
# How many objects a Finding names before it says how many more there are.
$script:GroupPolicyResultNamed = 12

function Get-GroupPolicyData {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

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

    $partOfDomain = $null
    try { $partOfDomain = [bool](Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).PartOfDomain } catch { }

    $unreadable = @{}

    $processing = @(Get-GroupPolicyEvent -Log $script:GroupPolicyLog -Id (8000..8007) -Since $since -Unreadable $unreadable |
        ForEach-Object {
            [pscustomobject]@{
                Time                  = $_.TimeCreated
                Id                    = $_.Id
                Activity              = "$($_.ActivityId)"
                Seconds               = Get-EventPayload -LogEntry $_ -Index 0
                ErrorCode             = Get-EventPayload -LogEntry $_ -Index 1
                Principal             = Get-EventPayload -LogEntry $_ -Index 2
                IsConnectivityFailure = Get-EventPayload -LogEntry $_ -Index 4
            }
        })

    $extensions = @(Get-GroupPolicyEvent -Log $script:GroupPolicyLog -Id @(5016, 6016, 7016) -Since $since `
            -Unreadable $unreadable -MaxEvents $script:GroupPolicyMaxExtensionEvents |
        ForEach-Object {
            [pscustomobject]@{
                Time         = $_.TimeCreated
                Id           = $_.Id
                Activity     = "$($_.ActivityId)"
                Milliseconds = Get-EventPayload -LogEntry $_ -Index 0
                ErrorCode    = Get-EventPayload -LogEntry $_ -Index 1
                Extension    = Get-EventPayload -LogEntry $_ -Index 2
            }
        })

    # Which domain controller each processing run used: the run is the event's activity.
    # The two fields are told apart by what they hold, and not by their place alone - a
    # failure event of this provider had its two the other way round from the manifest.
    $controllers = @(Get-GroupPolicyEvent -Log $script:GroupPolicyLog -Id @(5308) -Since $since -Unreadable $unreadable `
            -MaxEvents $script:GroupPolicyMaxExtensionEvents | ForEach-Object {
        $used = Split-GroupPolicyControllerText -Text @((Get-EventPayload -LogEntry $_ -Index 0), (Get-EventPayload -LogEntry $_ -Index 1))
        if ($used.Name -or $used.Address) {
            [pscustomobject]@{ Time = $_.TimeCreated; Activity = "$($_.ActivityId)"; Name = $used.Name; Address = $used.Address }
        }
    })

    $links = @(Get-GroupPolicyEvent -Log $script:GroupPolicyLog -Id @(5314) -Since $since -Unreadable $unreadable |
        ForEach-Object {
            [pscustomobject]@{
                Time           = $_.TimeCreated
                BandwidthKbps  = Get-EventPayload -LogEntry $_ -Index 0
                IsSlowLink     = Get-EventPayload -LogEntry $_ -Index 1
                ThresholdKbps  = Get-EventPayload -LogEntry $_ -Index 2
            }
        })

    $failureIds = @($script:GroupPolicyConnectivityIds) + @($script:GroupPolicyErrorIds) + @($script:GroupPolicyExtensionFailId)
    $failureEvents = @(Get-GroupPolicyEvent -Log 'System' -Provider $script:GroupPolicyProvider -Id $failureIds `
            -Since $since -Unreadable $unreadable)
    $failures = @($failureEvents | ForEach-Object {
        $said = Split-GroupPolicyFailureText -Text @((Get-EventPayload -LogEntry $_ -Index 5), (Get-EventPayload -LogEntry $_ -Index 6))
        [pscustomobject]@{
            Time             = $_.TimeCreated
            Id               = $_.Id
            ErrorCode        = Get-EventPayload -LogEntry $_ -Index 4
            ErrorDescription = $said.Description
            Controller       = $said.Controller
            Extension        = $(if ($_.Id -eq $script:GroupPolicyExtensionFailId) { Get-EventPayload -LogEntry $_ -Index 7 } else { $null })
        }
    })

    # How far back the log reaches, and since when the machine runs: where the log holds
    # no start and no sign-in, these say why. The log is of a fixed size and overwrites
    # its oldest; on a domain machine every background refresh adds to it.
    $logOldest = $null
    if (-not $unreadable.ContainsKey($script:GroupPolicyLog)) {
        try { $logOldest = (Get-WinEvent -LogName $script:GroupPolicyLog -MaxEvents 1 -Oldest -ErrorAction Stop).TimeCreated } catch { }
    }
    $lastBoot = $null
    try { $lastBoot = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime } catch { }

    # The result of policy, for the computer and for whoever is at the machine. Not asked
    # outside a domain: there is none.
    $result = $null
    if ($partOfDomain) {
        $user = "$(Get-InteractiveUser)".Trim()
        Write-CheckProgress -Status (Get-Text 'Console.Progress.PolicyResult') -Step 0 -Of 2
        $computerResult = Invoke-GroupPolicyResult -Scope computer
        Write-CheckProgress -Status (Get-Text 'Console.Progress.PolicyResult') -Step 1 -Of 2
        $userResult = $(if ($user) { Invoke-GroupPolicyResult -Scope user -User $user } else { [pscustomobject]@{ Xml = ''; Problem = 'NoUser' } })
        $result = [pscustomobject]@{
            UserName        = $user
            Computer        = ConvertFrom-GroupPolicyResult -Xml $computerResult.Xml
            ComputerProblem = "$($computerResult.Problem)"
            User            = ConvertFrom-GroupPolicyResult -Xml $userResult.Xml
            UserProblem     = "$($userResult.Problem)"
        }
    }

    [pscustomobject]@{
        PSTypeName     = 'Gutcheck.Data.GroupPolicy'
        GatheredAt     = Get-Date
        Result         = $result
        LogOldest      = $logOldest
        LastBoot       = $lastBoot
        Days           = $days
        PartOfDomain   = $partOfDomain
        Processing     = $processing
        Extensions     = $extensions
        Controllers    = $controllers
        Links          = $links
        Failures       = $failures
        UnreadableLogs = @($unreadable.Keys | Sort-Object)
        Events         = @($failureEvents | ForEach-Object { ConvertTo-EventRow -LogEntry $_ -Tag 'Group Policy' })
    }
}

function Invoke-GroupPolicyResult {
    <#
    .SYNOPSIS
        Asks Windows for the result of policy in one scope, as XML. Never throws.
    .DESCRIPTION
        Returns the XML and, where there is none, why: 'Timeout', 'Failed' with the exit
        code, or 'NotRun'. The file is removed.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][ValidateSet('computer', 'user')][string]$Scope, [string]$User,
          [int]$TimeoutSeconds = $script:GroupPolicyResultTimeoutSeconds)

    $name = 'gc-rsop-{0}-{1}.xml' -f $Scope, $PID
    $file = Join-Path ([IO.Path]::GetTempPath()) $name
    # See the head of this file.
    if ($file.Length -gt 120) { $file = Join-Path (Join-Path $env:SystemRoot 'Temp') $name }

    $arguments = @('/scope', $Scope, '/x', ('"{0}"' -f $file), '/f')
    if ($Scope -eq 'user' -and $User) { $arguments = @('/user', ('"{0}"' -f $User)) + $arguments }

    $xml = ''
    $problem = ''
    try {
        $process = Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\gpresult.exe') -ArgumentList $arguments `
            -WindowStyle Hidden -PassThru -ErrorAction Stop
        if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
            try { $process.Kill() } catch { }
            $problem = 'Timeout'
        }
        elseif (Test-Path -LiteralPath $file) { $xml = [IO.File]::ReadAllText($file) }
        else { $problem = 'Failed {0}' -f $process.ExitCode }
    }
    catch { $problem = 'NotRun' }
    finally { try { Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue } catch { } }

    [pscustomobject]@{ Xml = $xml; Problem = $problem }
}

function ConvertFrom-GroupPolicyResult {
    <#
    .SYNOPSIS
        What gpresult's XML says of one scope: the objects with their state as Windows
        gives it, and the components with how they ended. $null where it is no such
        XML. Decides nothing, and reaches nothing.
    .DESCRIPTION
        Elements are found by their local name: the document has a namespace per kind
        of setting, and none of them matters here.
    #>

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

    if (-not "$Xml".Trim()) { return $null }
    $document = [xml]::new()
    try { $document.LoadXml($Xml) } catch { return $null }
    $scope = $document.DocumentElement.SelectSingleNode("*[local-name()='ComputerResults' or local-name()='UserResults']")
    if (-not $scope) { return $null }

    $text = { param($Node, [string]$Name)
        $child = $(if ($Node) { $Node.SelectSingleNode(("*[local-name()='{0}']" -f $Name)) })
        if ($child) { "$($child.InnerText)".Trim() } else { '' } }

    [pscustomobject]@{
        Domain   = & $text $scope 'Domain'
        Site     = & $text $scope 'Site'
        SlowLink = & $text $scope 'SlowLink'
        Objects  = @($scope.SelectNodes("*[local-name()='GPO']") | ForEach-Object {
            $link = $_.SelectSingleNode("*[local-name()='Link']")
            [pscustomobject]@{
                Name             = & $text $_ 'Name'
                Id               = & $text $_.SelectSingleNode("*[local-name()='Path']") 'Identifier'
                Link             = & $text $link 'SOMPath'
                LinkEnabled      = & $text $link 'Enabled'
                AppliedOrder     = & $text $link 'AppliedOrder'
                Enabled          = & $text $_ 'Enabled'
                IsValid          = & $text $_ 'IsValid'
                FilterAllowed    = & $text $_ 'FilterAllowed'
                AccessDenied     = & $text $_ 'AccessDenied'
                VersionDirectory = & $text $_ 'VersionDirectory'
                VersionSysvol    = & $text $_ 'VersionSysvol'
                Extensions       = @($_.SelectNodes("*[local-name()='ExtensionName']") | ForEach-Object { "$($_.InnerText)".Trim() } | Where-Object { $_ })
            }
        })
        # The items of the preference extensions: each an element that carries what the
        # extension recorded of it, by the names the extension gives its fields.
        Items = @($scope.SelectNodes("*[local-name()='ExtensionData']") | ForEach-Object {
            $extension = & $text $_ 'Name'
            foreach ($item in @($_.SelectNodes(".//*[*[local-name()='BaseInstanceXml']]"))) {
                $base  = $item.SelectSingleNode("*[local-name()='BaseInstanceXml']")
                $field = { param([string]$Name)
                    $found = $base.SelectSingleNode(("*[local-name()='PROPERTY'][@NAME='{0}']/*[local-name()='VALUE']" -f $Name))
                    if ($found) { "$($found.InnerText)".Trim() } else { '' } }
                $name = & $field 'name'
                if ($name) { [pscustomobject]@{ Extension = $extension; Name = $name; Policy = (& $field 'polmkrBaseGpoDisplayName') } }
            }
        })
        Components = @($scope.SelectNodes("*[local-name()='ExtensionStatus']") | ForEach-Object {
            [pscustomobject]@{
                Name   = & $text $_ 'Name'
                Begin  = & $text $_ 'BeginTime'
                End    = & $text $_ 'EndTime'
                Status = & $text $_ 'LoggingStatus'
                Error  = & $text $_ 'Error'
            }
        })
    }
}

function Get-GroupPolicyObjectState {
    <#
    .SYNOPSIS
        Whether a Group Policy object was applied, and where not, why: 'Applied',
        'AccessDenied', 'NotReadable', 'Disabled', 'LinkDisabled', 'WmiFilter', 'Empty' or
        'NotApplied'. Pure.
    .DESCRIPTION
        An object the account may not read comes back under its identifier instead of
        its name: that is the security filter too, seen from the other side.
    #>

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

    $order = ConvertTo-Number (Get-DataProperty $Object 'AppliedOrder')
    if ($null -ne $order -and $order -gt 0) { return 'Applied' }
    if ("$(Get-DataProperty $Object 'AccessDenied')" -eq 'true') { return 'AccessDenied' }
    if ("$(Get-DataProperty $Object 'Name')" -match '^\{[0-9A-Fa-f-]{36}\}$') { return 'NotReadable' }
    if ("$(Get-DataProperty $Object 'Enabled')" -eq 'false') { return 'Disabled' }
    if ("$(Get-DataProperty $Object 'LinkEnabled')" -eq 'false') { return 'LinkDisabled' }
    if ("$(Get-DataProperty $Object 'FilterAllowed')" -eq 'false') { return 'WmiFilter' }
    $directory = ConvertTo-Number (Get-DataProperty $Object 'VersionDirectory')
    $sysvol    = ConvertTo-Number (Get-DataProperty $Object 'VersionSysvol')
    if ($directory -eq 0 -and $sysvol -eq 0) { return 'Empty' }
    'NotApplied'
}

function Get-GroupPolicyObjectStateText {
    <#
    .SYNOPSIS
        What the Report calls the state of a Group Policy object. Pure.
    #>

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

    switch ($State) {
        'Applied'      { return (Get-Text 'Value.GroupPolicy.Object.Applied') }
        'AccessDenied' { return (Get-Text 'Value.GroupPolicy.Object.AccessDenied') }
        'NotReadable'  { return (Get-Text 'Value.GroupPolicy.Object.NotReadable') }
        'Disabled'     { return (Get-Text 'Value.GroupPolicy.Object.Disabled') }
        'LinkDisabled' { return (Get-Text 'Value.GroupPolicy.Object.LinkDisabled') }
        'WmiFilter'    { return (Get-Text 'Value.GroupPolicy.Object.WmiFilter') }
        'Empty'        { return (Get-Text 'Value.GroupPolicy.Object.Empty') }
    }
    Get-Text 'Value.GroupPolicy.Object.NotApplied'
}

function New-GroupPolicyResultFinding {
    <#
    .SYNOPSIS
        The Finding about the policy objects of one scope: how many were applied, which
        were not and why, whether a component ended in an error, and whether an object
        stands at one version in the directory and at another in SYSVOL.
    .DESCRIPTION
        An object that is not applied is no fault: a security filter, a WMI filter and
        an empty object are how policy is meant to be aimed. A warning is a component
        that ended in an error, and an applied object whose two versions differ - then
        the domain controllers do not hold the same, and which policy a machine gets
        depends on which of them answers.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)][ValidateSet('Computer', 'User')][string]$Scope)

    $result = Get-DataProperty $Data 'Result'
    if ($null -eq $result) { return }

    $check = Get-Text 'Check.GroupPolicy.Objects.Computer'
    if ($Scope -eq 'User') { $check = Get-Text 'Check.GroupPolicy.Objects.User' }

    $read = Get-DataProperty $result $Scope
    if ($null -eq $read) {
        $problem = "$(Get-DataProperty $result ('{0}Problem' -f $Scope))"
        $why = Get-Text 'Value.GroupPolicy.Result.Failed'
        if ($problem -eq 'Timeout')    { $why = Get-Text 'Value.GroupPolicy.Result.Timeout' }
        elseif ($problem -eq 'NoUser') { $why = Get-Text 'Value.GroupPolicy.Result.NoUser' }
        return New-Finding -Category Startup -Check $check -Severity INFO -Value $why `
            -Meaning (Get-Text 'Meaning.GroupPolicy.Result.NotRead') -Hint (Get-Text 'Hint.GroupPolicy.Result.NotRead')
    }

    $objects = @((Get-DataCollection $read 'Objects') | ForEach-Object {
        [pscustomobject]@{ Object = $_; State = Get-GroupPolicyObjectState -Object $_; Order = ConvertTo-Number (Get-DataProperty $_ 'AppliedOrder') } })
    $applied = @($objects | Where-Object { $_.State -eq 'Applied' } | Sort-Object Order)
    $others  = @($objects | Where-Object { $_.State -ne 'Applied' })

    $named = { param($List, $Format)
        $items = @($List | Select-Object -First $script:GroupPolicyResultNamed | ForEach-Object { & $Format $_ })
        if (@($List).Count -gt $script:GroupPolicyResultNamed) { $items += (Get-Text 'Value.NetworkProtocol.More') -f (@($List).Count - $script:GroupPolicyResultNamed) }
        $items -join '; ' }

    $value = @((Get-Text 'Value.GroupPolicy.Objects.Count') -f $applied.Count, $others.Count)
    if ($Scope -eq 'User' -and "$(Get-DataProperty $result 'UserName')") { $value[0] = '{0} ({1})' -f $value[0], "$(Get-DataProperty $result 'UserName')" }
    if ($applied.Count) { $value += (Get-Text 'Value.GroupPolicy.Objects.Applied') -f (& $named $applied { param($o) "$(Get-DataProperty $o.Object 'Name')" }) }
    # One the account may not read has no name to show, only an identifier: counted.
    $nameless = @($others | Where-Object { $_.State -eq 'NotReadable' })
    $namedOthers = @($others | Where-Object { $_.State -ne 'NotReadable' })
    if ($namedOthers.Count) {
        $value += (Get-Text 'Value.GroupPolicy.Objects.NotApplied') -f (& $named $namedOthers { param($o)
            '{0} - {1}' -f "$(Get-DataProperty $o.Object 'Name')", (Get-GroupPolicyObjectStateText -State $o.State) })
    }
    if ($nameless.Count) { $value += (Get-Text 'Value.GroupPolicy.Objects.NotReadable') -f $nameless.Count }

    $meaning = @()
    $hint    = @()
    $severity = 'OK'

    # Applied, and not the same in the directory as in SYSVOL. 65535 is "not read".
    $apart = @($applied | Where-Object {
        $directory = ConvertTo-Number (Get-DataProperty $_.Object 'VersionDirectory')
        $sysvol    = ConvertTo-Number (Get-DataProperty $_.Object 'VersionSysvol')
        $null -ne $directory -and $null -ne $sysvol -and $sysvol -ne 65535 -and $directory -ne $sysvol })
    if ($apart.Count) {
        $severity = 'WARN'
        $value += (Get-Text 'Value.GroupPolicy.Objects.Apart') -f (& $named $apart { param($o)
            (Get-Text 'Value.GroupPolicy.Objects.ApartItem') -f "$(Get-DataProperty $o.Object 'Name')", (Get-DataProperty $o.Object 'VersionDirectory'), (Get-DataProperty $o.Object 'VersionSysvol') })
        $meaning += Get-Text 'Meaning.GroupPolicy.Objects.Apart'
        $hint    += Get-Text 'Hint.GroupPolicy.Objects.Apart'
    }

    $components = @((Get-DataCollection $read 'Components') | ForEach-Object { $_ })
    $failed = @($components | Where-Object { $code = ConvertTo-Number (Get-DataProperty $_ 'Error'); $null -ne $code -and $code -ne 0 })
    if ($failed.Count) {
        $severity = 'WARN'
        $value += (Get-Text 'Value.GroupPolicy.Components.Failed') -f (@($failed | ForEach-Object {
            '{0} - {1}' -f "$(Get-DataProperty $_ 'Name')", (Format-GroupPolicyErrorCode -Code (Get-DataProperty $_ 'Error')) }) -join '; ')
        $meaning += Get-Text 'Meaning.GroupPolicy.Components.Failed'
        $hint    += Get-Text 'Hint.GroupPolicy.Components.Failed'
    }
    elseif ($components.Count) { $value += (Get-Text 'Value.GroupPolicy.Components.Fine') -f $components.Count }

    if ("$(Get-DataProperty $read 'SlowLink')" -eq 'true') {
        if ($severity -eq 'OK') { $severity = 'INFO' }
        $value   += Get-Text 'Value.GroupPolicy.Result.SlowLink'
        $meaning += Get-Text 'Meaning.GroupPolicy.Result.SlowLink'
        $hint    += Get-Text 'Hint.GroupPolicy.Result.SlowLink'
    }

    New-Finding -Category Startup -Check $check -Severity $severity -Value ($value -join ' | ') `
        -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ') `
        -Reference @((New-Reference -Section 'Title.GroupPolicy.Objects'), (New-Reference -Section 'Title.GroupPolicy.Components'))
}

function Split-GroupPolicyControllerText {
    <#
    .SYNOPSIS
        Of the two fields that name the domain controller a processing run used, which is
        its name and which its address: the one that reads as an address is the address.
        Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Text)

    $name = ''
    $address = ''
    foreach ($one in @($Text | ForEach-Object { "$_".Trim().TrimStart('\') } | Where-Object { $_ })) {
        if ($one -match '^\d{1,3}(\.\d{1,3}){3}$' -or ($one -match '^[0-9A-Fa-f:]+$' -and $one.Contains(':'))) { if (-not $address) { $address = $one } }
        elseif (-not $name) { $name = $one }
    }
    [pscustomobject]@{ Name = $name; Address = $address }
}

function Split-GroupPolicyFailureText {
    <#
    .SYNOPSIS
        Of the two fields a failure keeps its description and its domain controller in,
        which is which: the one that begins with two backslashes is the controller. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Text)

    $controller  = ''
    $description = ''
    foreach ($one in @($Text | ForEach-Object { "$_".Trim() } | Where-Object { $_ })) {
        if ($one.StartsWith('\\')) { if (-not $controller) { $controller = $one.TrimStart('\') } }
        elseif (-not $description) { $description = $one }
    }
    [pscustomobject]@{ Description = $description; Controller = $controller }
}

function Get-GroupPolicyEvent {
    <#
    .SYNOPSIS
        Reads events by id out of one log, newest first. Records a log it cannot open.
    #>

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

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

    $filter = @{ LogName = $Log; Id = $Id; StartTime = $Since }
    if ($Provider) { $filter.ProviderName = $Provider }

    # Not asked to stop: "no events matched" is an error to Get-WinEvent, and stopping on
    # it writes a TerminatingError line into the transcript for a log that was merely quiet.
    $problems  = @()
    $arguments = @{ FilterHashtable = $filter; ErrorAction = 'SilentlyContinue'; ErrorVariable = 'problems' }
    if ($MaxEvents -gt 0) { $arguments.MaxEvents = $MaxEvents }

    try { Get-WinEvent @arguments }
    catch { $problems = @($_) }

    foreach ($problem in @($problems)) {
        # "No events matched" is raised as an error as well; only a refusal is a gap.
        if (Test-AccessDenied -ErrorRecord $problem) { $Unreadable[$Log] = $true }
    }
}

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

    if ((Get-DataProperty $Data 'PartOfDomain') -eq $false) {
        # A workgroup or Entra-only machine has no domain policy to wait for. Said once,
        # so the Report shows the Check was considered rather than forgotten.
        return New-Finding -Category Startup -Check (Get-Text 'Check.GroupPolicy.GroupPolicy') -Severity INFO `
            -Value (Get-Text 'Value.GroupPolicy.NotInDomain')
    }

    $unreadable = Get-DataCollection $Data 'UnreadableLogs'
    if ($unreadable -contains $script:GroupPolicyLog) {
        New-Finding -Category Startup -Check (Get-Text 'Check.GroupPolicy.GroupPolicy') -Severity INFO `
            -Value (Get-Text 'Value.Shared.NeedsAdmin') `
            -Hint (Get-Text 'Hint.GroupPolicy.RerunWithAdmin')
    }
    else {
        New-GroupPolicyDurationFinding -Data $Data -Id 8000 -Check (Get-Text 'Check.GroupPolicy.Boot') `
            -Warn (Get-Parameter $Parameters 'GpBootWarnAboveSeconds' 30) `
            -Fail (Get-Parameter $Parameters 'GpBootFailAboveSeconds' 90)
        New-GroupPolicyDurationFinding -Data $Data -Id 8001 -Check (Get-Text 'Check.GroupPolicy.Logon') `
            -Warn (Get-Parameter $Parameters 'GpLogonWarnAboveSeconds' 15) `
            -Fail (Get-Parameter $Parameters 'GpLogonFailAboveSeconds' 60)
        New-GroupPolicySlowExtensionFinding -Data $Data -Parameters $Parameters
        New-GroupPolicySlowLinkFinding      -Data $Data -Parameters $Parameters
    }

    New-GroupPolicyResultFinding -Data $Data -Scope Computer
    New-GroupPolicyResultFinding -Data $Data -Scope User

    if ($unreadable -contains 'System') { return }
    New-GroupPolicyConnectivityFinding     -Data $Data -Parameters $Parameters
    New-GroupPolicyErrorFinding            -Data $Data -Parameters $Parameters
    New-GroupPolicyExtensionFailureFinding -Data $Data -Parameters $Parameters
}

function New-GroupPolicyDurationFinding {
    <#
    .SYNOPSIS
        Judges how long boot or logon policy processing took across the window.
    .DESCRIPTION
        Judged on the median rather than the slowest: the first boot after a Windows update
        or a new GPO is slow on every machine, and a Customer's complaint is about the
        typical morning. The slowest is still in the Value, for the Technician to see.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$Data,
        [Parameter(Mandatory)][int]$Id,
        [Parameter(Mandatory)][string]$Check,
        [Parameter(Mandatory)][double]$Warn,
        [Parameter(Mandatory)][double]$Fail
    )

    $rows    = @((Get-DataCollection $Data 'Processing') | Where-Object { $_.Id -eq $Id })
    $seconds = @($rows | ForEach-Object { ConvertTo-Number $_.Seconds } | Where-Object { $null -ne $_ })

    if (-not $seconds.Count) { return New-GroupPolicyNoneFinding -Data $Data -Id $Id -Check $Check }

    $median  = Get-SamplePercentile -Sample $seconds -Percentile 50
    $maximum = ($seconds | Measure-Object -Maximum).Maximum

    New-Finding -Category Startup -Check $Check -Severity (Get-Severity $median $Warn $Fail) `
        -Value ((Get-Text 'Value.GroupPolicy.Duration') -f $median, $maximum, $seconds.Count) `
        -Hint (Get-Text 'Hint.GroupPolicy.SlowProcessing')
}

function New-GroupPolicyNoneFinding {
    <#
    .SYNOPSIS
        The Finding where the log holds no start, or no sign-in, to judge: why there is
        none, as far as that is known. Pure.
    .DESCRIPTION
        "Keine Eintraege in 30 Tagen" under both left a Technician guessing whether that
        was fine or a gap. There are three reasons and they are told apart: the log no
        longer reaches back to the last start (the usual one on a domain machine: the
        log is of a fixed size and every background refresh adds to it); the machine has
        run for longer than the period, so there was no start to measure; or the log
        reaches back over the whole period and holds none. Information in each case:
        nothing was measured, which is no fault of the machine.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)][int]$Id, [Parameter(Mandatory)][string]$Check)

    $days     = ConvertTo-Number (Get-DataProperty $Data 'Days')
    $oldest   = ConvertTo-DataTime (Get-DataProperty $Data 'LogOldest')
    $lastBoot = ConvertTo-DataTime (Get-DataProperty $Data 'LastBoot')
    $gathered = ConvertTo-DataTime (Get-DataProperty $Data 'GatheredAt')
    $since    = $null
    if ($null -ne $gathered -and $null -ne $days) { $since = $gathered.AddDays(-$days) }

    $none = (Get-Text 'Value.GroupPolicy.NoneInPeriod') -f $days

    # At startup: the machine has run for longer than the period.
    if ($Id -eq 8000 -and $null -ne $lastBoot -and $null -ne $since -and $lastBoot -lt $since) {
        return New-Finding -Category Startup -Check $Check -Severity INFO `
            -Value ((Get-Text 'Value.GroupPolicy.NoBootInPeriod') -f $days, $lastBoot) `
            -Meaning (Get-Text 'Meaning.GroupPolicy.NoBootInPeriod') -Hint (Get-Text 'Hint.GroupPolicy.MeasureByRestart')
    }

    if ($null -eq $oldest -or $null -eq $since) {
        return New-Finding -Category Startup -Check $Check -Severity INFO -Value $none `
            -Meaning (Get-Text 'Meaning.GroupPolicy.NoneReachUnknown') -Hint (Get-Text 'Hint.GroupPolicy.MeasureByRestart')
    }

    # The log is shorter than the period: what was looked for may have been in it once.
    if ($oldest -gt $since) {
        $value = (Get-Text 'Value.GroupPolicy.LogTooShort') -f $oldest
        if ($Id -eq 8000 -and $null -ne $lastBoot -and $lastBoot -lt $oldest) {
            $value = (Get-Text 'Value.GroupPolicy.LogTooShortBoot') -f $lastBoot, $oldest
        }
        return New-Finding -Category Startup -Check $Check -Severity INFO -Value $value `
            -Meaning (Get-Text 'Meaning.GroupPolicy.LogTooShort') -Hint (Get-Text 'Hint.GroupPolicy.LogTooShort')
    }

    New-Finding -Category Startup -Check $Check -Severity INFO -Value $none `
        -Meaning (Get-Text 'Meaning.GroupPolicy.NoneInPeriod') -Hint (Get-Text 'Hint.GroupPolicy.MeasureByRestart')
}

function Get-GroupPolicyRunPlace {
    <#
    .SYNOPSIS
        Where the runs of the extensions took place: for each, 'Foreground' at a start
        or a sign-in, 'Background' in any other processing run, 'Unknown' where the
        events do not say. Returns a block that answers for one run. Pure.
    #>

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

    $kind = @{}
    foreach ($run in (Get-DataCollection $Data 'Processing')) {
        $activity = "$(Get-DataProperty $run 'Activity')".Trim()
        if (-not $activity) { continue }
        $id = ConvertTo-Number (Get-DataProperty $run 'Id')
        if ($id -in 8000, 8001) { $kind[$activity] = 'Foreground' } elseif (-not $kind.ContainsKey($activity)) { $kind[$activity] = 'Background' }
    }
    $kind
}

function Get-GroupPolicyExtensionTally {
    <#
    .SYNOPSIS
        Per extension: how many runs, how many of them slow, where those took place,
        the longest and when the last slow one was. Pure.
    .DESCRIPTION
        A run whose processing run is not among those gathered took place in the
        background if it names one at all: the log keeps the newest events, and a
        sign-in is not what falls out of it between two refreshes. One that names none
        is of unknown place, and is judged as one somebody waited for.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [double]$SlowSeconds = 10)

    $kind = Get-GroupPolicyRunPlace -Data $Data
    (Get-DataCollection $Data 'Extensions') | Where-Object { $_.Extension } | Group-Object Extension | ForEach-Object {
        $runs = @($_.Group | ForEach-Object {
            $ms = ConvertTo-Number (Get-DataProperty $_ 'Milliseconds')
            if ($null -eq $ms) { return }
            $activity = "$(Get-DataProperty $_ 'Activity')".Trim()
            $place = 'Unknown'
            if ($activity) { $place = $(if ($kind[$activity] -eq 'Foreground') { 'Foreground' } else { 'Background' }) }
            [pscustomobject]@{ Seconds = [double]$ms / 1000; Place = $place; Time = ConvertTo-DataTime (Get-DataProperty $_ 'Time') }
        })
        if (-not $runs.Count) { return }
        $slow    = @($runs | Where-Object { $_.Seconds -gt $SlowSeconds })
        $longest = @($runs | Sort-Object Seconds -Descending)[0]
        [pscustomobject]@{
            Extension  = $_.Name
            Runs       = $runs.Count
            Slow       = $slow
            Waited     = @($slow | Where-Object { $_.Place -ne 'Background' })
            Background = @($slow | Where-Object { $_.Place -eq 'Background' })
            PlaceKnown = -not @($slow | Where-Object { $_.Place -eq 'Unknown' }).Count
            MaxSeconds = $longest.Seconds
            Longest    = $longest
            Last       = @($slow | ForEach-Object { $_.Time } | Where-Object { $null -ne $_ } | Sort-Object)[-1]
            # How many runs came after the last slow one, all of them quick: what tells
            # something that has stopped from something that is going on.
            After      = $(
                $newest = @($slow | ForEach-Object { $_.Time } | Where-Object { $null -ne $_ } | Sort-Object)[-1]
                if ($null -eq $newest) { 0 } else { @($runs | Where-Object { $null -ne $_.Time -and $_.Time -gt $newest }).Count })
        }
    }
}

function New-GroupPolicySlowExtensionFinding {
    <#
    .SYNOPSIS
        The extensions whose runs are slow: how many of how many, the longest, when
        last, and whether somebody waited.
    .DESCRIPTION
        Graded on what a User feels, not on the longest number in the log. A run at a
        start or a sign-in holds the desktop back: slow is a warning, very slow a
        failure. A run in the background holds nobody up: one is a note, and only one
        that keeps coming back is more. Seen on a desktop: a single background run of
        ten minutes was the failure, and the 88 seconds at every second sign-in stood
        beside it as a detail.
 
        And one that has stopped is a note again. Seen on a machine a week after its
        domain was repaired: four background runs of a minute, the last five days
        before, and every run since quick - reported as a failure of today.
    #>

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

    $warn   = [double](Get-Parameter $Parameters 'GpExtensionWarnAboveSeconds' 10)
    $fail   = [double](Get-Parameter $Parameters 'GpExtensionFailAboveSeconds' 60)
    $repeat = [double](Get-Parameter $Parameters 'GpExtensionRepeat' 3)
    $quiet  = [double](Get-Parameter $Parameters 'GpExtensionQuietRuns' 5)

    $check = Get-Text 'Check.GroupPolicy.SlowestExtensions'
    $tally = @(Get-GroupPolicyExtensionTally -Data $Data -SlowSeconds $warn | Sort-Object MaxSeconds -Descending)

    if (-not $tally.Count) {
        return New-Finding -Category Startup -Check $check -Severity INFO `
            -Value ((Get-Text 'Value.GroupPolicy.NoneInPeriod') -f (Get-DataProperty $Data 'Days'))
    }

    $table = @(New-Reference -Section 'Title.GroupPolicy.Extensions')
    $top   = ($tally | Select-Object -First 3 | ForEach-Object { '{0} {1:N0} s' -f $_.Extension, $_.MaxSeconds }) -join ', '
    $slow  = @($tally | Where-Object { $_.Slow.Count })
    if (-not $slow.Count) {
        return New-Finding -Category Startup -Check $check -Severity OK `
            -Value ('{0} | {1}' -f ((Get-Text 'Value.GroupPolicy.SlowExtensions.None') -f $warn), ((Get-Text 'Value.GroupPolicy.SlowestExtensions') -f $top)) -Reference $table
    }

    $order = @{ FAIL = 0; WARN = 1; INFO = 2 }
    $graded = @($slow | ForEach-Object {
        $waitedMax = @($_.Waited | ForEach-Object { $_.Seconds } | Sort-Object)[-1]
        $severity  = 'INFO'
        if ($_.Waited.Count -or $_.Background.Count -ge $repeat) { $severity = 'WARN' }
        if (($null -ne $waitedMax -and $waitedMax -gt $fail) -or @($_.Background | Where-Object { $_.Seconds -gt $fail }).Count -ge $repeat) { $severity = 'FAIL' }
        # What was only ever in the background and has not come back in the runs since
        # has stopped. Not said of a run somebody waited for: a sign-in is one run a day,
        # and quick runs in the background say nothing of the next sign-in.
        $after   = [int](ConvertTo-Number (Get-DataProperty $_ 'After'))
        $stopped = -not $_.Waited.Count -and $after -ge $quiet
        if ($stopped) { $severity = 'INFO' }

        $item = (Get-Text 'Value.GroupPolicy.SlowExtension.Item') -f $_.Extension, $_.Slow.Count, $_.Runs, $warn, $_.MaxSeconds
        if ($null -ne $_.Last) { $item = '{0}, {1}' -f $item, ((Get-Text 'Value.GroupPolicy.SlowExtension.Last') -f $_.Last) }
        if ($stopped) { $item = '{0}, {1}' -f $item, ((Get-Text 'Value.GroupPolicy.SlowExtension.Since') -f $after) }
        if ($_.PlaceKnown) {
            if ($_.Waited.Count) { $item = '{0}, {1}' -f $item, ((Get-Text 'Value.GroupPolicy.SlowExtension.Waited') -f $_.Waited.Count) }
            else                 { $item = '{0}, {1}' -f $item, (Get-Text 'Value.GroupPolicy.SlowExtension.Background') }
        }
        [pscustomobject]@{ Tally = $_; Severity = $severity; Item = $item; Stopped = $stopped }
    } | Sort-Object @{ Expression = { $order[$_.Severity] } }, @{ Expression = { $_.Tally.MaxSeconds }; Descending = $true })

    $severity = $graded[0].Severity
    $value = @((Get-Text 'Value.GroupPolicy.SlowExtensions.Lead') -f $graded.Count, $warn)
    $value += (Get-Text 'Value.GroupPolicy.SlowExtensions.List') -f (@($graded | Select-Object -First 5 | ForEach-Object { $_.Item }) -join '; ')

    $meaning = @()
    $hint    = @()
    $waited  = @($graded | Where-Object { $_.Tally.Waited.Count })
    $behind  = @($graded | Where-Object { $_.Tally.Background.Count })
    if ($waited.Count) {
        $meaning += Get-Text 'Meaning.GroupPolicy.SlowExtension.Waited'
        $hint    += Get-Text 'Hint.GroupPolicy.SlowExtension'
        if (@($waited | Where-Object { "$($_.Tally.Extension)" -match 'Drive Maps|Laufwerk' }).Count) { $hint += Get-Text 'Hint.GroupPolicy.SlowExtension.DriveMaps' }
    }
    if ($behind.Count) {
        $meaning += Get-Text 'Meaning.GroupPolicy.SlowExtension.Background'
        $hint    += Get-Text 'Hint.GroupPolicy.SlowExtension.Background'
    }
    if (@($graded | Where-Object { $_.Stopped }).Count) { $meaning += Get-Text 'Meaning.GroupPolicy.SlowExtension.Stopped' }
    # The drives among them: what a Lead about drives mapped by the domain's name reads.
    $signal = @()
    if (@($graded | Where-Object { "$($_.Tally.Extension)" -match 'Drive Maps|Laufwerk' }).Count) { $signal = @('drive-mapping-slow') }
    $newest = @($graded | ForEach-Object { $_.Tally.Last } | Where-Object { $null -ne $_ } | Sort-Object)[-1]
    New-Finding -Category Startup -Check $check -Severity $severity -Value ($value -join ' | ') `
        -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ') -Reference $table -Signal $signal -LastSeen $newest
}

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

    $warn = Get-Parameter $Parameters 'GpSlowLinkWarnAbove' 0
    $fail = Get-Parameter $Parameters 'GpSlowLinkFailAbove' ([double]::MaxValue)

    $links = Get-DataCollection $Data 'Links'
    if (-not $links.Count) { return }

    # Read as text: the payload of a boolean field arrives as "True" or "False", and as
    # "true" after the Elevated Part's round trip on some editions.
    $slow = @($links | Where-Object { "$($_.IsSlowLink)" -eq 'true' })

    $check = Get-Text 'Check.GroupPolicy.SlowLink'
    if (-not $slow.Count) {
        return New-Finding -Category Startup -Check $check -Severity OK `
            -Value ((Get-Text 'Value.GroupPolicy.SlowLinkNone') -f $links.Count)
    }

    New-Finding -Category Startup -Check $check -Severity (Get-Severity $slow.Count $warn $fail) `
        -Value ((Get-Text 'Value.GroupPolicy.SlowLink') -f $slow.Count, $links.Count) `
        -Hint (Get-Text 'Hint.GroupPolicy.SlowLink')
}

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

    $warn = Get-Parameter $Parameters 'GpConnectivityFailureWarnAbove' 5
    $fail = Get-Parameter $Parameters 'GpConnectivityFailureFailAbove' ([double]::MaxValue)

    # Both records of the same fact: the System log's "no domain controller" events, and
    # a processing run that ended flagged as a connectivity failure.
    $system     = @((Get-DataCollection $Data 'Failures') | Where-Object { $_.Id -in $script:GroupPolicyConnectivityIds })
    $processing = @((Get-DataCollection $Data 'Processing') | Where-Object { "$($_.IsConnectivityFailure)" -eq 'true' })
    $count = [math]::Max($system.Count, $processing.Count)

    $check = Get-Text 'Check.GroupPolicy.DomainController'
    $days  = Get-DataProperty $Data 'Days'
    if (-not $count) {
        return New-Finding -Category Startup -Check $check -Severity OK -Value ((Get-Text 'Value.GroupPolicy.AlwaysReached') -f $days)
    }

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

    New-Finding -Category Startup -Check $check -Severity $severity `
        -Value ((Get-Text 'Value.GroupPolicy.NotReached') -f $count, $days) `
        -Hint (Get-Text 'Hint.GroupPolicy.DomainControllerUnreachable')
}

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

    $warn = Get-Parameter $Parameters 'GpErrorWarnAbove' 0
    $fail = Get-Parameter $Parameters 'GpErrorFailAbove' 10

    $errors = @((Get-DataCollection $Data 'Failures') | Where-Object { $_.Id -in $script:GroupPolicyErrorIds })
    $check  = Get-Text 'Check.GroupPolicy.Errors'
    if (-not $errors.Count) {
        return New-Finding -Category Startup -Check $check -Severity OK -Value (Get-Text 'Value.Shared.None')
    }

    # Each id with what it means, and behind it the error Windows gave with it: 1058 says
    # that a policy file could not be read, and the error number says why.
    $byId = ($errors | Group-Object Id | Sort-Object Count -Descending | ForEach-Object {
        $id = Format-CodeWithMeaning -Code ('ID {0}' -f $_.Name) -Meaning (Get-GroupPolicyEventIdText -Id $_.Name)
        Format-GroupPolicyCount -Name $id -Row $_.Group
    }) -join ' | '

    # 1058 and 1096 are policy files that could not be read from SYSVOL; the rest are a
    # user or computer name that could not be resolved, or processing that failed outright.
    $hint = Get-Text 'Hint.GroupPolicy.Errors'
    if (@($errors | Where-Object { $_.Id -in 1058, 1096 }).Count -eq $errors.Count) {
        $hint = Get-Text 'Hint.GroupPolicy.SysvolUnreadable'
    }

    New-Finding -Category Startup -Check $check -Severity (Get-Severity $errors.Count $warn $fail) `
        -Value ((Get-Text 'Value.GroupPolicy.Errors') -f $errors.Count, (Get-DataProperty $Data 'Days'), $byId) `
        -Hint $hint
}

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

    $warn = Get-Parameter $Parameters 'GpExtensionFailureWarnAbove' 0
    $fail = Get-Parameter $Parameters 'GpExtensionFailureFailAbove' ([double]::MaxValue)

    # An extension that failed is recorded twice when both logs were readable: 1085 in the
    # System log and 7016 with an error code in the Operational log. Counted per source
    # and the larger taken, per extension, so one failure is not reported as two.
    $system = @((Get-DataCollection $Data 'Failures') |
        Where-Object { $_.Id -eq $script:GroupPolicyExtensionFailId -and $_.Extension })
    $operational = @((Get-DataCollection $Data 'Extensions') |
        Where-Object { $_.Id -eq 7016 -and $_.Extension })

    $names = @(@($system) + @($operational) | ForEach-Object { $_.Extension } | Sort-Object -Unique)
    $check = Get-Text 'Check.GroupPolicy.FailedExtensions'
    if (-not $names.Count) {
        return New-Finding -Category Startup -Check $check -Severity OK -Value (Get-Text 'Value.Shared.None')
    }

    $counted = @(foreach ($name in $names) {
        $count = [math]::Max(@($system | Where-Object { $_.Extension -eq $name }).Count,
                             @($operational | Where-Object { $_.Extension -eq $name }).Count)
        [pscustomobject]@{
            Extension = $name
            Count     = $count
            Rows      = @(@($system) + @($operational) | Where-Object { $_.Extension -eq $name })
        }
    })
    $total = ($counted | Measure-Object Count -Sum).Sum
    $value = @($counted | Sort-Object Count -Descending |
        ForEach-Object { Format-GroupPolicyCount -Name $_.Extension -Count $_.Count -Row $_.Rows })

    # When last, and through which domain controller: once through each of two is no
    # controller's fault, ten times through one is.
    $all  = @($counted | ForEach-Object { $_.Rows })
    $last = @($all | ForEach-Object { ConvertTo-DataTime (Get-DataProperty $_ 'Time') } | Where-Object { $null -ne $_ } | Sort-Object)[-1]
    if ($null -ne $last) { $value += (Get-Text 'Value.GroupPolicy.FailedExtensions.Last') -f $last }
    $via = @($all | ForEach-Object { (("$(Get-DataProperty $_ 'Controller')" -split '\.')[0]).ToUpperInvariant() } | Where-Object { $_ } | Group-Object |
        Sort-Object @{ Expression = 'Count'; Descending = $true }, Name | ForEach-Object { '{0} {1}x' -f $_.Name, $_.Count })
    if ($via.Count) { $value += (Get-Text 'Value.GroupPolicy.FailedExtensions.Via') -f ($via -join ', ') }

    # Which items each of them has, from the result of policy: no log says which item
    # failed, and where an extension has one, that is the one.
    $hint = @(Get-Text 'Hint.GroupPolicy.ExtensionFailed')
    $result = Get-DataProperty $Data 'Result'
    $items  = @(@((Get-DataCollection (Get-DataProperty $result 'Computer') 'Items') | ForEach-Object { $_ }) +
                @((Get-DataCollection (Get-DataProperty $result 'User') 'Items') | ForEach-Object { $_ }))
    foreach ($one in @($counted | Sort-Object Count -Descending)) {
        # The log calls it "Group Policy Registry", the result "Windows Registry" or "Registry".
        $short = "$($one.Extension)" -replace '^Group Policy ', ''
        $mine  = @($items | Where-Object { "$(Get-DataProperty $_ 'Extension')" -in "$($one.Extension)", $short, ('Windows {0}' -f $short) } | ForEach-Object {
            $policy = "$(Get-DataProperty $_ 'Policy')"
            if ($policy) { (Get-Text 'Value.GroupPolicy.FailedExtensions.Item') -f "$(Get-DataProperty $_ 'Name')", $policy } else { "$(Get-DataProperty $_ 'Name')" } } | Select-Object -Unique)
        if (-not $mine.Count) { continue }
        $shown = @($mine | Select-Object -First 8)
        if ($mine.Count -gt 8) { $shown += (Get-Text 'Value.NetworkProtocol.More') -f ($mine.Count - 8) }
        $value += (Get-Text 'Value.GroupPolicy.FailedExtensions.Items') -f "$($one.Extension)", ($shown -join '; ')
        $hint = @($(if ($mine.Count -eq 1) { Get-Text 'Hint.GroupPolicy.ExtensionFailed.OneItem' } else { Get-Text 'Hint.GroupPolicy.ExtensionFailed.Items' })) + $hint | Select-Object -Unique
    }

    New-Finding -Category Startup -Check $check -Severity (Get-Severity $total $warn $fail) `
        -Value ($value -join ' | ') `
        -Meaning (Get-Text 'Meaning.GroupPolicy.ExtensionFailed') `
        -Hint (@($hint) -join ' | ') -Reference @(New-Reference -Section 'Title.GroupPolicy.Extensions')
}

function Get-GroupPolicyEventIdText {
    <#
    .SYNOPSIS
        What a failure event of Group Policy in the System log means. Nothing for one not
        known here, which is then shown as the number it is. Pure.
    .DESCRIPTION
        As Microsoft's own messages for these events say it, shortened: 1006 could not
        bind to Active Directory, 1030 processing failed and another event says why, 1053
        and 1055 the user's or the computer's name could not be resolved, 1054 and 1129 no
        domain controller, 1058 the policy file (gpt.ini) on SYSVOL could not be read,
        1085 an extension failed, 1096 registry.pol could not be applied.
    #>

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

    switch ("$Id".Trim()) {
        '1006' { return (Get-Text 'Value.GroupPolicy.EventId.1006') }
        '1030' { return (Get-Text 'Value.GroupPolicy.EventId.1030') }
        '1053' { return (Get-Text 'Value.GroupPolicy.EventId.1053') }
        '1054' { return (Get-Text 'Value.GroupPolicy.EventId.1054') }
        '1055' { return (Get-Text 'Value.GroupPolicy.EventId.1055') }
        '1058' { return (Get-Text 'Value.GroupPolicy.EventId.1058') }
        '1085' { return (Get-Text 'Value.GroupPolicy.EventId.1085') }
        '1096' { return (Get-Text 'Value.GroupPolicy.EventId.1096') }
        '1129' { return (Get-Text 'Value.GroupPolicy.EventId.1129') }
    }
    ''
}

function Get-GroupPolicyProcessingIdText {
    <#
    .SYNOPSIS
        Which run of policy processing an event 8000-8007 is the end of. Nothing for
        another id. Pure.
    .DESCRIPTION
        As Microsoft documents the Operational log: the even ids are the computer's
        policy and the odd ones the user's; 8000 and 8001 end the run at startup and at
        logon, 8002 and 8003 the one after a network change, 8004 and 8005 a manual
        refresh (gpupdate), 8006 and 8007 the periodic one in the background.
    #>

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

    switch ("$Id".Trim()) {
        '8000' { return (Get-Text 'Value.GroupPolicy.Processing.8000') }
        '8001' { return (Get-Text 'Value.GroupPolicy.Processing.8001') }
        '8002' { return (Get-Text 'Value.GroupPolicy.Processing.8002') }
        '8003' { return (Get-Text 'Value.GroupPolicy.Processing.8003') }
        '8004' { return (Get-Text 'Value.GroupPolicy.Processing.8004') }
        '8005' { return (Get-Text 'Value.GroupPolicy.Processing.8005') }
        '8006' { return (Get-Text 'Value.GroupPolicy.Processing.8006') }
        '8007' { return (Get-Text 'Value.GroupPolicy.Processing.8007') }
    }
    ''
}

function Format-GroupPolicyErrorCode {
    <#
    .SYNOPSIS
        The error number of a Group Policy event with what it means behind it. Pure.
    .DESCRIPTION
        The number is a Win32 error. The System-log events carry Windows' own sentence
        for it (ErrorDescription), which is used where it is there; the Operational log
        carries the number alone, and Windows is asked. Zero is no error and is said so
        only where -ShowZero asks for it, for a table that has a cell to fill.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Code, [AllowNull()]$Description, [switch]$ShowZero)

    $text = "$Code".Trim()
    if (-not $text) { return '' }
    if ((ConvertTo-Number $text) -eq 0) {
        if ($ShowZero) { return (Format-CodeWithMeaning -Code $text -Meaning (Get-Text 'Value.GroupPolicy.NoError')) }
        return ''
    }

    $meaning = "$Description".Trim()
    if (-not $meaning) { $meaning = Get-Win32ErrorText -Code $text }
    # Some extensions report an HRESULT where a Win32 error belongs.
    if (-not $meaning) { $meaning = Get-HResultText -Code $text }
    # No number stands bare: one nobody has a sentence for is said to be that.
    if (-not $meaning) { $meaning = Get-Text 'Value.Code.NoText' }
    Format-CodeWithMeaning -Code $text -Meaning $meaning
}

function Format-GroupPolicyCount {
    <#
    .SYNOPSIS
        A name, how often it was logged, and the error numbers logged with it, the most
        frequent first and no more than three. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][string]$Name,
        [AllowNull()][AllowEmptyCollection()][object[]]$Row = @(),

        # Where the rows are two records of the same failures and their number is not the count.
        [int]$Count = -1
    )

    $rows = @($Row | Where-Object { $null -ne $_ })
    if ($Count -lt 0) { $Count = $rows.Count }

    $codes = @($rows | ForEach-Object {
        Format-GroupPolicyErrorCode -Code (Get-DataProperty $_ 'ErrorCode') -Description (Get-DataProperty $_ 'ErrorDescription')
    } | Where-Object { $_ } | Group-Object | Sort-Object Count -Descending | Select-Object -First 3 | ForEach-Object { $_.Name })

    if (-not $codes.Count) { return (Get-Text 'Value.GroupPolicy.Count') -f $Name, $Count }
    (Get-Text 'Value.GroupPolicy.CountWithCode') -f $Name, $Count, ($codes -join ', ')
}

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

    New-Section -Title (Get-Text 'Title.GroupPolicy.Processing') -Help (Get-Text 'Help.GroupPolicy.Processing') -Row @(
        (Get-DataCollection $Data 'Processing') | Sort-Object Time -Descending | Select-Object -First 30 | ForEach-Object {
            # The payload of the boolean field arrives as text; see New-GroupPolicySlowLinkFinding.
            $failed = $null
            if ("$($_.IsConnectivityFailure)" -eq 'true')      { $failed = $true }
            elseif ("$($_.IsConnectivityFailure)" -eq 'false') { $failed = $false }

            $row = [ordered]@{}
            $row[(Get-Text 'Column.GroupPolicy.Time')]                = $_.Time
            $row[(Get-Text 'Column.GroupPolicy.Event')]               = Format-CodeWithMeaning -Code "$($_.Id)" -Meaning (Get-GroupPolicyProcessingIdText -Id $_.Id)
            $row[(Get-Text 'Column.GroupPolicy.Seconds')]             = $_.Seconds
            $row[(Get-Text 'Column.GroupPolicy.ErrorCode')]           = Format-GroupPolicyErrorCode -Code $_.ErrorCode -ShowZero
            $row[(Get-Text 'Column.GroupPolicy.Principal')]           = $_.Principal
            $row[(Get-Text 'Column.GroupPolicy.ConnectivityFailure')] = Get-TriStateText $failed
            [pscustomobject]$row
        }
    )

    $extension = Get-Text 'Column.GroupPolicy.Extension'
    $runs      = Get-Text 'Column.GroupPolicy.Runs'
    $average   = Get-Text 'Column.GroupPolicy.AverageMs'
    $maximum   = Get-Text 'Column.GroupPolicy.MaximumMs'
    $errors    = Get-Text 'Column.GroupPolicy.Errors'
    $codes     = Get-Text 'Column.GroupPolicy.ErrorCodes'
    $longestAt = Get-Text 'Column.GroupPolicy.LongestAt'
    $longestIn = Get-Text 'Column.GroupPolicy.LongestIn'
    $tally     = @{}
    foreach ($one in @(Get-GroupPolicyExtensionTally -Data $Data)) { $tally["$($one.Extension)"] = $one }

    New-Section -Title (Get-Text 'Title.GroupPolicy.Extensions') -Help (Get-Text 'Help.GroupPolicy.Extensions') -Row @(
        (Get-DataCollection $Data 'Extensions') | Where-Object { $_.Extension } | Group-Object Extension | ForEach-Object {
            $ms = @($_.Group | ForEach-Object { ConvertTo-Number $_.Milliseconds } | Where-Object { $null -ne $_ })
            $row = [ordered]@{}
            $row[$extension] = $_.Name
            $row[$runs]      = $_.Count
            $row[$average]   = $(if ($ms.Count) { [math]::Round(($ms | Measure-Object -Average).Average) } else { $null })
            $row[$maximum]   = $(if ($ms.Count) { ($ms | Measure-Object -Maximum).Maximum } else { $null })
            # When the longest run was and whether somebody waited for it: a maximum
            # without those is a number nobody can place.
            $longest = $(if ($tally.ContainsKey("$($_.Name)")) { $tally["$($_.Name)"].Longest })
            $row[$longestAt] = $(if ($longest) { $longest.Time })
            $row[$longestIn] = $(if (-not $longest -or $longest.Place -eq 'Unknown') { '' }
                                 elseif ($longest.Place -eq 'Foreground') { Get-Text 'Value.GroupPolicy.Place.Foreground' }
                                 else { Get-Text 'Value.GroupPolicy.Place.Background' })
            $row[$errors]    = @($_.Group | Where-Object { $_.Id -eq 7016 }).Count
            # What the runs that did not end well ended with (6016 a warning, 7016 an error).
            $row[$codes]     = @($_.Group | ForEach-Object { Format-GroupPolicyErrorCode -Code $_.ErrorCode } |
                Where-Object { $_ } | Sort-Object -Unique) -join ', '
            [pscustomobject]$row
        } | Sort-Object $maximum -Descending
    )

    # What Windows applied, and what not: the result of policy for each scope.
    $result = Get-DataProperty $Data 'Result'
    if ($null -eq $result) { return }
    $scopes = @(
        [pscustomobject]@{ Name = (Get-Text 'Value.GroupPolicy.Scope.Computer'); Read = Get-DataProperty $result 'Computer' }
        [pscustomobject]@{ Name = (Get-Text 'Value.GroupPolicy.Scope.User');     Read = Get-DataProperty $result 'User' })
    $scopeColumn = Get-Text 'Column.GroupPolicy.Scope'

    New-Section -Title (Get-Text 'Title.GroupPolicy.Objects') -Help (Get-Text 'Help.GroupPolicy.Objects') -Row @(
        foreach ($scope in $scopes) {
            (Get-DataCollection $scope.Read 'Objects') | ForEach-Object {
                [pscustomobject]@{ Object = $_; State = Get-GroupPolicyObjectState -Object $_; Order = ConvertTo-Number (Get-DataProperty $_ 'AppliedOrder') }
            } | Sort-Object @{ Expression = { $_.State -ne 'Applied' } }, Order | ForEach-Object {
                $row = [ordered]@{}
                $row[$scopeColumn] = $scope.Name
                $row[(Get-Text 'Column.GroupPolicy.Object')]   = "$(Get-DataProperty $_.Object 'Name')"
                $row[(Get-Text 'Column.GroupPolicy.State')]    = Get-GroupPolicyObjectStateText -State $_.State
                $row[(Get-Text 'Column.GroupPolicy.Order')]    = $(if ($_.State -eq 'Applied') { [int]$_.Order })
                $row[(Get-Text 'Column.GroupPolicy.Link')]     = "$(Get-DataProperty $_.Object 'Link')"
                $row[(Get-Text 'Column.GroupPolicy.VersionDirectory')] = "$(Get-DataProperty $_.Object 'VersionDirectory')"
                $row[(Get-Text 'Column.GroupPolicy.VersionSysvol')]    = "$(Get-DataProperty $_.Object 'VersionSysvol')"
                $row[(Get-Text 'Column.GroupPolicy.Sets')]     = @((Get-DataCollection $_.Object 'Extensions') | ForEach-Object { "$_" }) -join ', '
                [pscustomobject]$row
            }
        }
    )

    New-Section -Title (Get-Text 'Title.GroupPolicy.Components') -Help (Get-Text 'Help.GroupPolicy.Components') -Row @(
        foreach ($scope in $scopes) {
            (Get-DataCollection $scope.Read 'Components') | ForEach-Object {
                $begin = ConvertTo-DataTime (Get-DataProperty $_ 'Begin')
                $end   = ConvertTo-DataTime (Get-DataProperty $_ 'End')
                $row = [ordered]@{}
                $row[$scopeColumn] = $scope.Name
                $row[$extension]   = "$(Get-DataProperty $_ 'Name')"
                $row[(Get-Text 'Column.GroupPolicy.LastRun')]  = $end
                $row[(Get-Text 'Column.GroupPolicy.Seconds')]  = $(if ($null -ne $begin -and $null -ne $end) { [int][math]::Round(($end - $begin).TotalSeconds) })
                $row[(Get-Text 'Column.GroupPolicy.ErrorCode')] = Format-GroupPolicyErrorCode -Code (Get-DataProperty $_ 'Error') -ShowZero
                [pscustomobject]$row
            }
        }
    )
}

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

    (Get-DataCollection $Data 'Events')
}