Private/Kinds/Change.ps1

# The Change Kind: what Windows logged as altered on this machine, as a timeline.
#
# "What changed?" is the first thing a second-level Technician asks, and until now it was
# asked by telephone. This Check answers it from what Windows wrote down itself: programs
# installed, updated or removed, Windows updates, drivers, services added, and the starts
# of Windows with the shutdowns that were not planned.
#
# A Change is a fact Windows logged, with its time and, where Windows logged one, the
# account it happened under. It is no judgement about anybody (ADR-0006), and nothing here
# reads what a User did: no document, no program that was merely used.
#
# Get-ChangeData reads the machine and decides nothing. Every event is reduced to a Change
# by a pure function of plain rows, and ConvertTo-ChangeFinding and ConvertTo-ChangeSection
# are pure.
#
# The Changes in the data are what a later Check is handed through -Observed. Each is:
#
# Time when Windows logged it. For a Change known by its date only, midnight.
# Kind one of $script:ChangeKinds, a stable word that is never reworded
# What the name: the program, the update, the device, the service, 'Windows'
# Version where Windows logged one, otherwise ''
# Account the account as a name, otherwise ''
# AccountState 'named', 'not-logged' (Windows logged none) or 'unresolved' (it logged
# one that no longer translates to a name)
# DateOnly whether only the day is known
# Before for an update, the version that was there before, otherwise ''
# Detail what else the event names: manufacturer, driver package, process, and
# for a service the name of the file it runs - never its folder or its
# arguments, see Get-ChangeFileName

# How far back the timeline goes, in days. The spec's (#59): "the last 30 days".
$script:ChangeDefaultWindowDays = 30

# How many rows the table shows. No limit of Windows: a table of thousands of rows is not
# read, and 200 is more than the busiest month seen while this was written (about 60).
$script:ChangeDefaultMaxRows = 200

# How many seconds a removal and an installation of the same program may lie apart and
# still be one update. Windows Installer removes the old version inside the installation
# of the new one: six seconds apart on the machine this was written on. Five minutes
# leaves room for a slow machine and a large program.
$script:ChangeDefaultUpdatePairSeconds = 300

# The kinds of Change, in the order the Finding counts them.
$script:ChangeKinds = @(
    'program-installed', 'program-updated', 'program-removed', 'program-listed',
    'windows-update', 'driver-installed', 'service-added', 'restart', 'unexpected-shutdown'
)

# The kinds of Change that recur by themselves, and stand in the table once with how often:
# the virus definitions Windows Update installs every day, a service a program adds again
# at every start. An installation of a program or a start of Windows is never one of many.
$script:ChangeCollapsedKinds = @('windows-update', 'driver-installed', 'service-added')

# What is read, by provider name and numeric event id and never by message text: a German
# Windows returns German messages and invariant ids. Where each id's meaning comes from:
#
# MsiInstaller 1033, 1034, 1036 (Application). A classic provider without a manifest,
# so (Get-WinEvent -ListProvider MsiInstaller).Events is empty. Read off real events
# on Windows 11: 1033 "product installed", 1034 "product removed", 1036 "update
# installed". Fields: name, version, language, then the status (1036: the name of
# the update, then the status) and the manufacturer. Status 0 is success.
# Not read: 11707 and 11724 say the same as 1033 and 1034 a second time, without the
# version and as one sentence; 1035 "product reconfigured" is written for every
# product whenever something lists the installed products, 2,400 times in a month
# on that machine, and nothing changed.
# Microsoft-Windows-WindowsUpdateClient 19 (System). The provider's manifest:
# "Installation successful", fields updateTitle, updateGuid, updateRevisionNumber.
# The Updates Kind reads the update history for its Findings about the last and the
# failed updates; this is the list of what was installed, which it does not show.
# Microsoft-Windows-Kernel-PnP 400 (Microsoft-Windows-Kernel-PnP/Configuration). The
# provider's manifest: "device configured", fields DeviceInstanceId, DriverName,
# ClassGuid, DriverDate, DriverVersion, DriverProvider, DriverInbox, DriverSection,
# DriverRank, MatchingDeviceId, OutrankedDrivers, DeviceUpdated, Status. Readable
# without admin rights on Windows 11. Only DeviceUpdated = true is a Change: the
# event is also written each time a device is plugged in again. Not read: UserPnp
# 20001 ("driver installed", in the manifest) was not written on that machine in a
# month in which four drivers were replaced.
# Service Control Manager 7045 (System). The provider's manifest: "a service was
# installed in the system", fields ServiceName, ImagePath, ServiceType, StartType,
# AccountName. The event's user is who added it.
# Microsoft-Windows-Kernel-General 12 (System). The provider's manifest: "the operating
# system started", fields MajorVersion, MinorVersion, BuildVersion, QfeVersion.
# One per start of Windows. Not read: Kernel-General 13 and EventLog 6005, 6006 and
# 6009 say the same start and the shutdown before it a second time.
# User32 1074 (System). The provider's manifest: a process asked for a shutdown or a
# restart on behalf of a user. Fields: the process with the computer's name behind
# it, the computer, the reason, a code, the kind of shutdown as a localised word,
# a comment, the user. Read only for the account and the process behind a start.
# Microsoft-Windows-Kernel-Power 41 (System). The provider's manifest: the system
# restarted without shutting down cleanly first. The one the Stability Kind counts,
# so that both say the same number. Not read: EventLog 6008 says it a second time.
$script:ChangeScans = @(
    @{ Source = 'Program'; Log = 'Application'
       Provider = @('MsiInstaller'); Id = @(1033, 1034, 1036) }
    @{ Source = 'Update';  Log = 'System'
       Provider = @('Microsoft-Windows-WindowsUpdateClient'); Id = @(19) }
    @{ Source = 'Driver';  Log = 'Microsoft-Windows-Kernel-PnP/Configuration'
       Provider = @('Microsoft-Windows-Kernel-PnP'); Id = @(400) }
    @{ Source = 'Service'; Log = 'System'
       Provider = @('Service Control Manager'); Id = @(7045) }
    @{ Source = 'Restart'; Log = 'System'
       Provider = @('Microsoft-Windows-Kernel-General', 'Microsoft-Windows-Kernel-Power', 'User32'); Id = @(12, 41, 1074) }
)

function Get-ChangeData {
    <#
    .SYNOPSIS
        What Windows logged as changed in the window, as Changes. Decides nothing.
    .DESCRIPTION
        Reads the events of $script:ChangeScans and the list of installed programs, and
        hands them as plain rows to the pure functions that make Changes of them. A log
        that could not be read, or that this machine does not have, is recorded by name:
        a month nobody could read is not a quiet month.
    #>

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

    $days = [int](Get-Parameter $Parameters 'WindowDays' 30)
    $max  = [int](Get-Parameter $Parameters 'MaxRows' 200)
    $pair = [int](Get-Parameter $Parameters 'UpdatePairSeconds' 300)
    if ($days -lt 1) { $days = $script:ChangeDefaultWindowDays }
    if ($max  -lt 1) { $max  = $script:ChangeDefaultMaxRows }
    if ($pair -lt 0) { $pair = $script:ChangeDefaultUpdatePairSeconds }

    $now   = Get-Date
    $since = $now.AddDays(-$days)

    $refused  = @{}
    $bySource = @{}
    $logs     = @()

    foreach ($log in @($script:ChangeScans | ForEach-Object { $_.Log } | Select-Object -Unique)) {
        $scans = @($script:ChangeScans | Where-Object { $_.Log -eq $log })

        # Asked for by name first: a filtered query for a log that does not exist, or is
        # switched off, answers "no events" like an empty one.
        $found = $false
        try {
            $entry = Get-WinEvent -ListLog $log -ErrorAction SilentlyContinue
            $found = [bool]($entry -and $entry.IsEnabled)
        }
        catch { }

        $readable = $false
        $oldest   = $null
        if ($found) {
            $readable = Test-EventLogReadable -Log $log -Unreadable $refused
            if ($readable) {
                # How far back the log reaches: one that was overwritten after ten days
                # says nothing about the twenty before.
                try {
                    $first = Get-WinEvent -LogName $log -Oldest -MaxEvents 1 -ErrorAction SilentlyContinue
                    if ($first) { $oldest = $first.TimeCreated }
                }
                catch { }

                foreach ($scan in $scans) {
                    $bySource[$scan.Source] = @(Get-StabilityEvent -Log $log -Provider $scan.Provider -Id $scan.Id `
                            -Since $since -Unreadable $refused |
                        ForEach-Object { ConvertTo-ChangeLogRow -LogEntry $_ })
                }
                # A query may be refused where reading the log by name was not.
                if ($refused.ContainsKey($log)) { $readable = $false }
            }
        }

        $logs += [pscustomobject]@{
            Log      = $log
            Sources  = @($scans | ForEach-Object { $_.Source })
            Found    = $found
            Readable = $readable
            Oldest   = $oldest
        }
    }

    # The name Windows shows for a device. The event names the device by its instance id.
    $devices = @{}
    if (@($bySource['Driver']).Count) {
        try {
            Get-CimInstance Win32_PnPEntity -Property PNPDeviceID, Name -ErrorAction SilentlyContinue | ForEach-Object {
                if ($_.PNPDeviceID -and $_.Name) { $devices["$($_.PNPDeviceID)"] = "$($_.Name)" }
            }
        }
        catch { }
        foreach ($row in @($bySource['Driver'])) {
            $id = "$(@($row.Payload)[0])"
            if ($id -and $devices.ContainsKey($id)) { $row.Device = $devices[$id] }
        }
    }

    # The list of installed programs, for what Windows Installer did not log: a program
    # with an installer of its own leaves its date there and no event.
    $listed = @(Get-InstalledProgram | ForEach-Object {
        [pscustomobject]@{
            Name            = "$($_.DisplayName)"
            Version         = "$($_.DisplayVersion)"
            Publisher       = "$($_.Publisher)"
            InstallDate     = "$($_.InstallDate)"
            SystemComponent = "$($_.SystemComponent)"
        }
    })

    $changes = @(
        ConvertTo-ChangeOfProgram -LogRow @($bySource['Program']) -Listed $listed -Since $since -Until $now -PairSeconds $pair
        ConvertTo-ChangeOfUpdate  -LogRow @($bySource['Update'])
        ConvertTo-ChangeOfDriver  -LogRow @($bySource['Driver'])
        ConvertTo-ChangeOfService -LogRow @($bySource['Service'])
        ConvertTo-ChangeOfRestart -LogRow @($bySource['Restart'])
    )

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.Data.Change'
        GatheredAt = $now
        WindowDays = $days
        Since      = $since
        # Carried for the Section, which is built from data alone.
        MaxRows    = $max
        Logs       = $logs
        Changes    = @(Select-ChangeInWindow -Change $changes -Since $since -Until $now)
    }
}

function ConvertTo-ChangeLogRow {
    <#
    .SYNOPSIS
        One event as a plain row: when, from whom, under which account, and its fields.
    .DESCRIPTION
        The account is translated here, where the machine can be asked: a row carries the
        name, and an empty name beside a Sid says that Windows could not translate it.
        Asked of Windows once for each account in a Run, by the one function that asks:
        see Resolve-AccountName. So this reads the machine, and is no part of the pure
        half: New-ChangeRow says from the row alone whether an account was named.
        Fields are kept by position, because their names are localised and their
        positions are not. A field that is binary is left empty.
    #>

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

    $sid = "$($LogEntry.UserId)"
    $account = ''
    if ($sid) { $account = (Resolve-AccountName -Sid $sid).Name }

    [pscustomobject]@{
        Time     = $LogEntry.TimeCreated
        Provider = "$($LogEntry.ProviderName)"
        Id       = [int]$LogEntry.Id
        Sid      = $sid
        Account  = $account
        Payload  = @(@($LogEntry.Properties) | ForEach-Object { if ($_.Value -is [byte[]]) { '' } else { "$($_.Value)" } })
        # Filled in for a driver event, where the device is still there to be asked.
        Device   = ''
    }
}

function Get-ChangeLogField {
    <#
    .SYNOPSIS
        One field of a row by its position, or '' when the event did not carry it. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$LogRow, [Parameter(Mandatory)][int]$Index)

    # Not through Get-DataCollection: it takes the empty fields out, and with them the
    # positions of the fields behind them.
    $payload = @(Get-DataProperty $LogRow 'Payload')
    if ($Index -lt 0 -or $Index -ge $payload.Count) { return '' }
    $value = "$($payload[$Index])".Trim()
    # What Windows Installer writes where it has nothing to say.
    if ($value -eq '(NULL)') { return '' }
    $value
}

function Select-ChangeLogRow {
    <#
    .SYNOPSIS
        The rows of one provider and one event id that have a time. Pure.
    .DESCRIPTION
        A time is read the one way a Judge reads one, and never by a free parse: see
        ConvertTo-DataTime. A row whose time is none is no row here, so that whatever
        comes after may take the time of a row for granted.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyCollection()]$LogRow, [Parameter(Mandatory)][string]$Provider, [Parameter(Mandatory)][int]$Id)

    @($LogRow) | Where-Object {
        $null -ne $_ -and "$(Get-DataProperty $_ 'Provider')" -eq $Provider -and
        (ConvertTo-Number (Get-DataProperty $_ 'Id')) -eq $Id -and $null -ne (ConvertTo-DataTime (Get-DataProperty $_ 'Time'))
    }
}

function New-ChangeRow {
    <#
    .SYNOPSIS
        One Change. Pure.
    .DESCRIPTION
        The account is the name Windows gave for the event's user. Where it logged none,
        or one that no longer translates to a name, the state says which: an empty
        account must not read as nobody.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][datetime]$Time,
        [Parameter(Mandatory)][string]$Kind,
        [AllowEmptyString()][string]$What = '',
        [AllowEmptyString()][string]$Version = '',
        # The row whose account this Change happened under. None: Windows logged no account.
        [AllowNull()]$AccountOf,
        [AllowEmptyString()][string]$Before = '',
        [AllowEmptyString()][string]$Detail = '',
        [switch]$DateOnly
    )

    $sid     = "$(Get-DataProperty $AccountOf 'Sid')".Trim()
    $account = "$(Get-DataProperty $AccountOf 'Account')".Trim()
    $state   = 'not-logged'
    if ($account)  { $state = 'named' }
    elseif ($sid)  { $state = 'unresolved' }

    [pscustomobject]@{
        Time         = $Time
        Kind         = $Kind
        What         = $What.Trim()
        Version      = $Version.Trim()
        Account      = $account
        AccountState = $state
        DateOnly     = [bool]$DateOnly
        Before       = $Before.Trim()
        Detail       = $Detail.Trim()
    }
}

function Get-ChangeProgramKey {
    <#
    .SYNOPSIS
        A program's name without its numbers, to know two versions of it as one. Pure.
    .DESCRIPTION
        Many programs carry their version in their name: "Contoso Office 4.2" is removed
        and "Contoso Office 4.3" installed, and that is an update of Contoso Office.
    #>

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

    (("$Name" -replace '\d+', '') -replace '[\s.]+', ' ').Trim().ToLowerInvariant()
}

function ConvertTo-ChangeOfProgram {
    <#
    .SYNOPSIS
        Programs installed, updated and removed, from what Windows Installer logged and
        from the list of installed programs. Pure.
    .DESCRIPTION
        An installation that failed changed nothing and is no Change: only status 0.
 
        A removal and an installation of the same program by the same manufacturer, no
        more than PairSeconds apart and of two versions, are one update: that is how
        Windows Installer replaces a version.
 
        A program in the list of installed programs with a date in the window, which
        Windows Installer logged nothing about on that day or the days beside it, is a
        Change known by its date only. The list says neither the time nor the account,
        and not whether the program was installed or updated on that day.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$LogRow,
        [AllowNull()][AllowEmptyCollection()]$Listed,
        [AllowNull()]$Since,
        [AllowNull()]$Until,
        [int]$PairSeconds = $script:ChangeDefaultUpdatePairSeconds
    )

    $read = {
        param($Row, [int]$StatusIndex, [int]$ManufacturerIndex)
        $status = Get-ChangeLogField $Row $StatusIndex
        if ($status -and $status -ne '0') { return }
        $name = Get-ChangeLogField $Row 0
        if (-not $name) { return }
        [pscustomobject]@{
            Row = $Row; Time = (ConvertTo-DataTime (Get-DataProperty $Row 'Time')); Name = $name
            Version = Get-ChangeLogField $Row 1; Manufacturer = Get-ChangeLogField $Row $ManufacturerIndex; Used = $false
        }
    }

    $installed = @(Select-ChangeLogRow -LogRow $LogRow -Provider 'MsiInstaller' -Id 1033 | ForEach-Object { & $read $_ 3 4 })
    $removed   = @(Select-ChangeLogRow -LogRow $LogRow -Provider 'MsiInstaller' -Id 1034 | ForEach-Object { & $read $_ 3 4 })
    $patched   = @(Select-ChangeLogRow -LogRow $LogRow -Provider 'MsiInstaller' -Id 1036 | ForEach-Object { & $read $_ 4 5 })

    $changes = @(
        foreach ($new in $installed) {
            $old = $null
            $gap = [double]::MaxValue
            foreach ($candidate in $removed) {
                if ($candidate.Used) { continue }
                if ($candidate.Manufacturer.ToLowerInvariant() -ne $new.Manufacturer.ToLowerInvariant()) { continue }
                if ((Get-ChangeProgramKey $candidate.Name) -ne (Get-ChangeProgramKey $new.Name)) { continue }
                if ($candidate.Version -eq $new.Version) { continue }
                $apart = [math]::Abs(($new.Time - $candidate.Time).TotalSeconds)
                if ($apart -le $PairSeconds -and $apart -lt $gap) { $old = $candidate; $gap = $apart }
            }
            if ($old) {
                $old.Used = $true
                New-ChangeRow -Time $new.Time -Kind 'program-updated' -What $new.Name -Version $new.Version `
                    -AccountOf $new.Row -Before $old.Version -Detail $new.Manufacturer
            }
            else {
                New-ChangeRow -Time $new.Time -Kind 'program-installed' -What $new.Name -Version $new.Version `
                    -AccountOf $new.Row -Detail $new.Manufacturer
            }
        }
        foreach ($old in @($removed | Where-Object { -not $_.Used })) {
            New-ChangeRow -Time $old.Time -Kind 'program-removed' -What $old.Name -Version $old.Version `
                -AccountOf $old.Row -Detail $old.Manufacturer
        }
        foreach ($patch in $patched) {
            New-ChangeRow -Time $patch.Time -Kind 'program-updated' -What $patch.Name -Version $patch.Version `
                -AccountOf $patch.Row -Detail $patch.Manufacturer
        }
    )
    $changes

    $seen = @{}
    foreach ($program in @($Listed | Where-Object { $null -ne $_ })) {
        $name = "$(Get-DataProperty $program 'Name')".Trim()
        if (-not $name) { continue }
        # What Windows does not show among the installed programs either: the parts a
        # program brings along.
        if ("$(Get-DataProperty $program 'SystemComponent')".Trim() -eq '1') { continue }

        # The date as installers write it: eight digits, year first. Anything else is not
        # guessed at.
        $date = [datetime]::MinValue
        if (-not [datetime]::TryParseExact("$(Get-DataProperty $program 'InstallDate')".Trim(), 'yyyyMMdd',
                [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$date)) { continue }
        if ($Since -and $date -lt (ConvertTo-DataTime $Since).Date) { continue }
        if ($Until -and $date -gt (ConvertTo-DataTime $Until)) { continue }

        $version = "$(Get-DataProperty $program 'Version')".Trim()
        $key = ('{0}|{1}|{2:yyyyMMdd}' -f $name, $version, $date).ToLowerInvariant()
        if ($seen.ContainsKey($key)) { continue }
        $seen[$key] = $true

        $logged = @($changes | Where-Object {
            $_.What.ToLowerInvariant() -eq $name.ToLowerInvariant() -and [math]::Abs(($_.Time.Date - $date).TotalDays) -le 1
        })
        if ($logged.Count) { continue }

        New-ChangeRow -Time $date -Kind 'program-listed' -What $name -Version $version `
            -Detail "$(Get-DataProperty $program 'Publisher')" -DateOnly
    }
}

function ConvertTo-ChangeOfUpdate {
    <#
    .SYNOPSIS
        The updates Windows Update installed. Pure.
    .DESCRIPTION
        The title is the update's name as Windows Update gives it, with the number of its
        article in it where it has one.
    #>

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

    Select-ChangeLogRow -LogRow $LogRow -Provider 'Microsoft-Windows-WindowsUpdateClient' -Id 19 | ForEach-Object {
        $title = Get-ChangeLogField $_ 0
        if (-not $title) { return }
        New-ChangeRow -Time ((ConvertTo-DataTime (Get-DataProperty $_ 'Time'))) -Kind 'windows-update' -What $title -AccountOf $_
    }
}

function ConvertTo-ChangeOfDriver {
    <#
    .SYNOPSIS
        The devices that were given another driver. Pure.
    .DESCRIPTION
        Windows writes "device configured" each time a device appears, a headset plugged
        in again as much as a new graphics driver. Only an event that says the device was
        updated is a Change, and only one that succeeded.
 
        The device is named as Windows shows it where it is still there. Where it is not
        - unplugged since, or removed - Windows has no name for it any more, and the
        event carries its device instance id only. That id is no name and does not stand
        bare: the Change says in words that the device has no name any more, with the id
        beside them, which is what the device is found by in the event log.
    #>

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

    Select-ChangeLogRow -LogRow $LogRow -Provider 'Microsoft-Windows-Kernel-PnP' -Id 400 | ForEach-Object {
        if ((Get-ChangeLogField $_ 11) -ne 'True') { return }
        $status = Get-ChangeLogField $_ 12
        if ($status -and $status -ne '0') { return }

        $device = "$(Get-DataProperty $_ 'Device')".Trim()
        if (-not $device) {
            $instance = Get-ChangeLogField $_ 0
            if (-not $instance) { return }
            $device = (Get-Text 'Value.Change.Device.Unnamed') -f $instance
        }

        # The driver package: who it is from and the file Windows keeps it under.
        $package = @((Get-ChangeLogField $_ 5), (Get-ChangeLogField $_ 1) | Where-Object { $_ }) -join ', '
        New-ChangeRow -Time ((ConvertTo-DataTime (Get-DataProperty $_ 'Time'))) -Kind 'driver-installed' -What $device `
            -Version (Get-ChangeLogField $_ 4) -AccountOf $_ -Detail $package
    }
}

function Get-ChangeFileName {
    <#
    .SYNOPSIS
        The name of the file a command line starts, without its folder and without its
        arguments. Empty where there is none. Pure.
    .DESCRIPTION
        What Windows logs for a service that was added is its whole command line, and
        that is more than a Change: the folder may lie in a User Profile and name the
        User, and the arguments may hold a password. The name of the file says which
        program the service belongs to, and is all that is kept or shown (ADR-0006).
 
        In quotation marks where the path has a blank in it; what follows is arguments.
        Without them, up to the first .exe, .sys or .dll. Where neither is there, up to
        the first blank: rather half a name than an argument taken for one.
    #>

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

    $text = "$Path".Trim()
    if (-not $text) { return '' }
    if ($text -match '^"([^"]+)"') { $text = $Matches[1] }
    elseif ($text -match '^(.*?\.(?:exe|sys|dll))(?:\s|$)') { $text = $Matches[1] }
    else { $text = @($text -split '\s+')[0] }
    "$(@($text -split '[\\/]')[-1])".Trim()
}

function Get-ChangeServiceFile {
    <#
    .SYNOPSIS
        The name of the file a service that was added runs, in lower case, to compare
        by. Empty where the Change names none. Pure.
    .DESCRIPTION
        Read off what the Change keeps of it, which is the name already; a Change from
        before that was so, with the whole command line, comes out the same.
    #>

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

    (Get-ChangeFileName -Path "$(Get-DataProperty $Change 'Detail')").ToLowerInvariant()
}

function ConvertTo-ChangeOfService {
    <#
    .SYNOPSIS
        The services added to Windows, each with the name of the file it runs. Pure.
    .DESCRIPTION
        The name of the file and nothing else of the command line Windows logged: see
        Get-ChangeFileName.
    #>

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

    Select-ChangeLogRow -LogRow $LogRow -Provider 'Service Control Manager' -Id 7045 | ForEach-Object {
        $name = Get-ChangeLogField $_ 0
        if (-not $name) { return }
        New-ChangeRow -Time ((ConvertTo-DataTime (Get-DataProperty $_ 'Time'))) -Kind 'service-added' -What $name `
            -AccountOf $_ -Detail (Get-ChangeFileName -Path (Get-ChangeLogField $_ 1))
    }
}

function ConvertTo-ChangeOfRestart {
    <#
    .SYNOPSIS
        The starts of Windows, and the shutdowns that were not planned. Pure.
    .DESCRIPTION
        A start is logged by the kernel under no account that says anything. Where a
        shutdown or a restart was asked for since the start before it, the account and the
        process that asked are that start's: the last one asked for is the one that
        happened. A start nothing asked for has no account, and the cell says so.
 
        The version is that of Windows as it started: a start after a feature update
        shows it.
 
        An unexpected shutdown is logged at the start after it, and bears that time.
    #>

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

    $starts   = @(Select-ChangeLogRow -LogRow $LogRow -Provider 'Microsoft-Windows-Kernel-General' -Id 12 |
        Sort-Object @{ Expression = { (ConvertTo-DataTime (Get-DataProperty $_ 'Time')) } })
    $requests = @(Select-ChangeLogRow -LogRow $LogRow -Provider 'User32' -Id 1074 |
        Sort-Object @{ Expression = { (ConvertTo-DataTime (Get-DataProperty $_ 'Time')) } })

    $previous = [datetime]::MinValue
    foreach ($start in $starts) {
        $time = (ConvertTo-DataTime (Get-DataProperty $start 'Time'))

        $request = $null
        foreach ($candidate in $requests) {
            $asked = (ConvertTo-DataTime (Get-DataProperty $candidate 'Time'))
            if ($asked -gt $previous -and $asked -lt $time) { $request = $candidate }
        }
        $previous = $time

        $parts = @(0..3 | ForEach-Object { Get-ChangeLogField $start $_ })
        $version = ''
        if (@($parts | Where-Object { $_ -match '^\d+$' }).Count -eq 4) { $version = $parts -join '.' }

        # The process as the event names it: its path, and the computer's name behind it
        # in brackets. The file's name is what tells an update from a person.
        $process = ''
        if ($request) {
            $process = (Get-ChangeLogField $request 0) -replace '\s*\([^()]*\)\s*$', ''
            $process = @($process -split '[\\/]')[-1]
        }

        New-ChangeRow -Time $time -Kind 'restart' -What 'Windows' -Version $version -AccountOf $request -Detail $process
    }

    Select-ChangeLogRow -LogRow $LogRow -Provider 'Microsoft-Windows-Kernel-Power' -Id 41 | ForEach-Object {
        New-ChangeRow -Time ((ConvertTo-DataTime (Get-DataProperty $_ 'Time'))) -Kind 'unexpected-shutdown' -What 'Windows'
    }
}

function Select-ChangeInWindow {
    <#
    .SYNOPSIS
        The Changes of the window, newest first. Pure.
    .DESCRIPTION
        Sorted on every column that tells two Changes apart: a sort does not keep the order
        of equals on Windows PowerShell 5.1, and four drivers are installed in one second.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Change, [AllowNull()]$Since, [AllowNull()]$Until)

    @($Change | Where-Object {
        $null -ne $_ -and $null -ne (ConvertTo-DataTime (Get-DataProperty $_ 'Time')) -and
        # A Change known by its day only belongs to the window if its day does.
        (-not $Since -or (ConvertTo-DataTime (Get-DataProperty $_ 'Time')) -ge $(if (Get-DataProperty $_ 'DateOnly') { (ConvertTo-DataTime $Since).Date } else { (ConvertTo-DataTime $Since) })) -and
        (-not $Until -or (ConvertTo-DataTime (Get-DataProperty $_ 'Time')) -le (ConvertTo-DataTime $Until))
    } | Sort-Object @{ Expression = { (ConvertTo-DataTime (Get-DataProperty $_ 'Time')) }; Descending = $true },
        @{ Expression = { "$(Get-DataProperty $_ 'Kind')" }; Descending = $false },
        @{ Expression = { "$(Get-DataProperty $_ 'What')" }; Descending = $false },
        @{ Expression = { "$(Get-DataProperty $_ 'Version')" }; Descending = $false },
        @{ Expression = { "$(Get-DataProperty $_ 'Account')" }; Descending = $false })
}

function Group-ChangeRow {
    <#
    .SYNOPSIS
        The Changes as the table shows and the Finding counts them: what recurs by itself
        once, with how often and since when. Pure.
    .DESCRIPTION
        Only the kinds of $script:ChangeCollapsedKinds, and only what is the same thing
        under the same account: an update with the same article number - the virus
        definitions, installed every day under one - or of the same title, the same
        service with the same file, the same device with the same driver version.
 
        The row is the newest of them. Count is how many there were and First the oldest.
    #>

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

    $order  = @()
    $groups = @{}
    $number = 0
    foreach ($change in (Select-ChangeInWindow -Change @((Get-DataCollection $Data 'Changes') | ForEach-Object { $_ }))) {
        $kind = "$(Get-DataProperty $change 'Kind')"
        $what = "$(Get-DataProperty $change 'What')"

        $number++
        $key = "single|$number"
        if ($script:ChangeCollapsedKinds -contains $kind) {
            $name = '{0}|{1}|{2}' -f $what, (Get-DataProperty $change 'Version'), (Get-DataProperty $change 'Detail')
            if ($kind -eq 'windows-update' -and $what -match 'KB\d{6,}') { $name = $Matches[0] }
            $key = ('{0}|{1}|{2}|{3}' -f $kind, $name, (Get-DataProperty $change 'Account'), (Get-DataProperty $change 'AccountState')).ToLowerInvariant()
        }

        if (-not $groups.ContainsKey($key)) {
            $order += $key
            $groups[$key] = [pscustomobject]@{
                Time         = (ConvertTo-DataTime (Get-DataProperty $change 'Time'))
                Kind         = $kind
                What         = $what
                Version      = "$(Get-DataProperty $change 'Version')"
                Account      = "$(Get-DataProperty $change 'Account')"
                AccountState = "$(Get-DataProperty $change 'AccountState')"
                DateOnly     = [bool](Get-DataProperty $change 'DateOnly')
                Before       = "$(Get-DataProperty $change 'Before')"
                Detail       = "$(Get-DataProperty $change 'Detail')"
                Count        = 1
                First        = (ConvertTo-DataTime (Get-DataProperty $change 'Time'))
            }
            continue
        }
        # Newest first: what comes later in the list is older.
        $groups[$key].Count++
        $groups[$key].First = (ConvertTo-DataTime (Get-DataProperty $change 'Time'))
    }

    foreach ($key in $order) { $groups[$key] }
}

function Get-ChangeKindText {
    <#
    .SYNOPSIS
        A kind of Change in a Technician's words; one this module does not know, as it is. Pure.
    #>

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

    switch ("$Kind") {
        'program-installed'   { return (Get-Text 'Value.Change.Kind.ProgramInstalled') }
        'program-updated'     { return (Get-Text 'Value.Change.Kind.ProgramUpdated') }
        'program-removed'     { return (Get-Text 'Value.Change.Kind.ProgramRemoved') }
        'program-listed'      { return (Get-Text 'Value.Change.Kind.ProgramListed') }
        'windows-update'      { return (Get-Text 'Value.Change.Kind.WindowsUpdate') }
        'driver-installed'    { return (Get-Text 'Value.Change.Kind.DriverInstalled') }
        'service-added'       { return (Get-Text 'Value.Change.Kind.ServiceAdded') }
        'restart'             { return (Get-Text 'Value.Change.Kind.Restart') }
        'unexpected-shutdown' { return (Get-Text 'Value.Change.Kind.UnexpectedShutdown') }
    }
    "$Kind"
}

function Get-ChangeSourceText {
    <#
    .SYNOPSIS
        What one of the sources of $script:ChangeScans tells, in a Technician's words. Pure.
    #>

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

    switch ("$Source") {
        'Program' { return (Get-Text 'Value.Change.Source.Program') }
        'Update'  { return (Get-Text 'Value.Change.Source.Update') }
        'Driver'  { return (Get-Text 'Value.Change.Source.Driver') }
        'Service' { return (Get-Text 'Value.Change.Source.Service') }
        'Restart' { return (Get-Text 'Value.Change.Source.Restart') }
    }
    "$Source"
}

function Get-ChangeGap {
    <#
    .SYNOPSIS
        What of the window was not read, as sentences: a log that was refused, one this
        machine does not have, one that does not reach back as far as the window. Pure.
    #>

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

    # Not read in this Run, whatever the reason: the one wording for that (see Get-UnreadText).
    $notRead = Get-Text 'Value.Cell.NotRead'
    $since = Get-DataProperty $Data 'Since'
    foreach ($log in (Get-DataCollection $Data 'Logs')) {
        $name    = "$(Get-DataProperty $log 'Log')"
        $sources = @((Get-DataCollection $log 'Sources') | ForEach-Object { Get-ChangeSourceText -Source "$_" }) -join ', '
        $oldest  = Get-DataProperty $log 'Oldest'

        if (-not [bool](Get-DataProperty $log 'Found'))        { (Get-Text 'Value.Change.LogMissing') -f $notRead, $sources, $name }
        elseif (-not [bool](Get-DataProperty $log 'Readable')) { (Get-Text 'Value.Change.LogUnread') -f $notRead, $sources, $name }
        elseif ($since -and $oldest -and (ConvertTo-DataTime $oldest) -gt (ConvertTo-DataTime $since)) {
            (Get-Text 'Value.Change.LogShort') -f $sources, $name, (ConvertTo-DataTime $oldest)
        }
    }
}

function ConvertTo-ChangeFinding {
    <#
    .SYNOPSIS
        One Finding: how many Changes of each kind Windows logged in the window. Pure.
    .DESCRIPTION
        Information, and never more: a Change is a fact and no fault, and whether it has
        to do with the trouble a Technician was called for is theirs to conclude. A month
        without a Change is OK - but only one that was read. What could not be read is
        said in the same Finding, which is then information whatever was counted.
 
        Counted as the table shows them: what recurs by itself counts once.
    #>

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

    $check = Get-Text 'Check.Change.Summary'
    if ($null -eq $Data -or -not (Get-DataCollection $Data 'Logs').Count) {
        return New-UnavailableFinding -Category Stability -Check $check -Hint (Get-Text 'Hint.Change.NothingRead')
    }

    $days = ConvertTo-Number (Get-DataProperty $Data 'WindowDays')
    if ($null -eq $days) { $days = $script:ChangeDefaultWindowDays }
    $max = ConvertTo-Number (Get-DataProperty $Data 'MaxRows')
    if ($null -eq $max -or $max -lt 1) { $max = $script:ChangeDefaultMaxRows }

    $rows = @(Group-ChangeRow -Data $Data)
    $gaps = @(Get-ChangeGap -Data $Data)

    $value = @()
    if ($rows.Count) {
        $value += (Get-Text 'Value.Change.Total') -f $rows.Count, $days
        foreach ($kind in $script:ChangeKinds) {
            $count = @($rows | Where-Object { $_.Kind -eq $kind }).Count
            if ($count) { $value += (Get-Text 'Value.Change.Count') -f (Get-ChangeKindText -Kind $kind), $count }
        }
        if ($rows.Count -gt $max) { $value += (Get-Text 'Value.Change.Cut') -f $max, $rows.Count }
    }
    else { $value += (Get-Text 'Value.Change.None') -f $days }
    $value += $gaps

    if (-not $rows.Count -and -not $gaps.Count) {
        return New-Finding -Category Stability -Check $check -Severity OK -Value ($value -join ' | ')
    }

    $meaning = @()
    $hint    = @()
    if ($rows.Count) {
        $meaning += Get-Text 'Meaning.Change.Summary'
        $hint    += Get-Text 'Hint.Change.Summary'
    }
    if ($gaps.Count) {
        $meaning += Get-Text 'Meaning.Change.Gap'
        $hint    += Get-Text 'Hint.Change.Gap'
    }

    New-Finding -Category Stability -Check $check -Severity INFO -Value ($value -join ' | ') `
        -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ') `
        -Reference @(New-Reference -Section 'Title.Change.Timeline' -Argument $days)
}

function ConvertTo-ChangeSection {
    <#
    .SYNOPSIS
        The Changes of the window as one table, newest first. Pure.
    .DESCRIPTION
        Every cell in words. The account is the name Windows logged; where it logged
        none, or one that no longer has a name, the cell says which, and never shows a
        Sid. The newest MaxRows only: the Finding says when the table was cut.
    #>

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

    $days = ConvertTo-Number (Get-DataProperty $Data 'WindowDays')
    if ($null -eq $days) { $days = $script:ChangeDefaultWindowDays }
    $max = ConvertTo-Number (Get-DataProperty $Data 'MaxRows')
    if ($null -eq $max -or $max -lt 1) { $max = $script:ChangeDefaultMaxRows }

    New-Section -Title ((Get-Text 'Title.Change.Timeline') -f $days) -Row @(
        Group-ChangeRow -Data $Data | Select-Object -First ([int]$max) | ForEach-Object {
            $account = $_.Account
            if ($_.AccountState -eq 'unresolved') { $account = Get-Text 'Value.Change.Account.Unresolved' }
            elseif (-not $account)                { $account = Get-Text 'Value.Change.Account.NotLogged' }

            $detail = @()
            if ($_.Before) { $detail += (Get-Text 'Value.Change.Detail.Before') -f $_.Before }
            if ($_.Detail) {
                # Named: inside a switch, $_ is what is switched on.
                $named = $_.Detail
                switch ($_.Kind) {
                    'driver-installed' { $detail += (Get-Text 'Value.Change.Detail.Driver') -f $named }
                    'service-added'    { $detail += (Get-Text 'Value.Change.Detail.Service') -f (Get-ChangeFileName -Path $named) }
                    'restart'          { $detail += (Get-Text 'Value.Change.Detail.Restart') -f $named }
                    default            { $detail += (Get-Text 'Value.Change.Detail.Manufacturer') -f $named }
                }
            }
            if ($_.Kind -eq 'unexpected-shutdown') { $detail += Get-Text 'Value.Change.Detail.UnexpectedShutdown' }
            if ($_.DateOnly)     { $detail += Get-Text 'Value.Change.Detail.DateOnly' }
            if ($_.Count -gt 1)  { $detail += (Get-Text 'Value.Change.Detail.Repeated') -f $_.Count, $_.First }

            $row = [ordered]@{}
            $row[(Get-Text 'Column.Change.Time')]    = $(if ($_.DateOnly) { (Get-Text 'Value.Change.Date') -f $_.Time } else { (Get-Text 'Value.Change.Time') -f $_.Time })
            $row[(Get-Text 'Column.Change.Kind')]    = Get-ChangeKindText -Kind $_.Kind
            $row[(Get-Text 'Column.Change.What')]    = $_.What
            $row[(Get-Text 'Column.Change.Version')] = $_.Version
            $row[(Get-Text 'Column.Change.Account')] = $account
            $row[(Get-Text 'Column.Change.Detail')]  = $detail -join '; '
            [pscustomobject]$row
        }
    )
}