Private/Kinds/OutlookAuth.ps1

# The OutlookAuth Kind: whether Outlook can sign in, to Microsoft 365 or to Exchange on premises.
#
# "Outlook keeps asking for my password" is, on a Microsoft 365 mailbox, almost always
# one of a handful of things on the machine rather than in the tenant: modern
# authentication (ADAL) or the Windows sign-in broker (WAM) switched off by a registry
# value somebody set years ago, MAPI over HTTP switched off so Outlook falls back to a
# transport and a prompt Exchange Online no longer accepts, the broker's AppX package
# missing or broken, or TLS 1.2 switched off for clients. Each of those is sourced in
# docs/research/outlook-connectivity-checks.md, checks 4 and 8.
#
# What this Gatherer reads, all of it without admin rights:
#
# - HKCU\Software\Microsoft\Office\16.0\Common\Identity: EnableADAL,
# DisableADALatopWAMOverride, DisableAADWAM, DisableMSAWAM
# - HKCU\Software\Microsoft\Exchange: MapiHttpDisabled (KB 2937684) and
# AlwaysUseMSOAuthForAutoDiscover (KB 3126599, reported and never graded)
# - each of those under HKCU\Software\Policies as well, where Group Policy writes; the
# policy value wins and the row says which one it was. The research sources the
# preference paths only; the policy paths are the usual Office mirror of them.
# - HKLM\...\SCHANNEL\Protocols\TLS 1.2\Client: Enabled, DisabledByDefault
# - Get-AppxPackage for the current user: Microsoft.AAD.BrokerPlugin and
# Microsoft.Windows.CloudExperienceHost, with version and status
# - over Days: Microsoft-Windows-AAD/Operational 1098, 1081, 1084, 1088, and the
# Application log's Microsoft-Windows-AppModel-State errors and repairs naming the
# broker's package family
# - whether the process is elevated, and who it runs as against who is logged on
#
# Event payloads are read by position, never by message text, for the reason
# Private/Kinds/Stability.ps1 gives. The templates, read off this machine's manifests with
# Get-WinEvent -ListProvider (German Windows 11 26200, 2026-09-25):
#
# Microsoft-Windows-AAD 1097/1098 Error (UInt32, shown hex), ErrorMessage,
# AdditionalInformation
# 1081/1088 Error (string, e.g. invalid_grant), ErrorDescription
# (carries the AADSTS number), [1081: CorrelationID]
# 1084 Result (HRESULT), Target
# Microsoft-Windows-AppModel-State (Application log)
# 10-12 ErrorString (package), Error
# 13 FolderString, PackageString, Error
# 20/21/23/24 Operation, PackageFamily, OperationError, ...
# 22 FolderPath, Error
#
# Only the error code is kept - and from 1081/1088 the AADSTS number out of the
# description - never the message: the messages are German here, and some of them name
# the account.
#
# All of this is the user's own state: HKCU, the per-user AppX registration, the broker
# that runs in the user's session. An elevated Run, or one running as somebody other than
# the logged-on user, reads another profile and would report it confidently, so the Judge
# says it could not look instead (research, check 4: "must run in the user's session").
#
# Where the mailbox is decides what is graded. The Gatherer declares -Observed and copies
# the Autodiscover Check's per-domain answer across (Private/Kinds/Autodiscover.ps1
# documents the shape), with that Check's ExchangeOnline pin; the Judge classifies it with
# ConvertTo-AutodiscoverMailboxLocation, the one classifier, honouring that pin, unless
# MailboxLocation here pins it over everything.
#
# On-prem sign-in prerequisites (research, check 9). These explain the password prompts
# that start after an Exchange update, and are graded only when the mailbox is not in
# Exchange Online:
#
# - HKLM\SYSTEM\CurrentControlSet\Control\Lsa: LmCompatibilityLevel, as the Settings row
# Lsa\LmCompatibilityLevel. Machine-wide and readable unelevated. Exchange's Extended
# Protection, on by default since Exchange 2019 CU14, refuses NTLMv1, and Microsoft says
# the level "must be set at least to a value of 3"; absent, Windows treats it as 3. The
# row keeps absent (Value $null) apart from any number set, and both apart from a key
# that could not be read (Error set), which is nobody's default.
# - the user's Windows credential store, through cmdkey /list: the target names of the
# MicrosoftOffice1x_Data:SSPI entries Outlook cached for RPC over HTTP and keeps
# offering after a switch to MAPI over HTTP (KB 4051374). Only those names are kept -
# every other entry is someone's unrelated account - and cmdkey never shows a secret.
# Its output is localised ("Ziel:", "Typ:" here), so ConvertFrom-OutlookAuthCmdkeyList
# reads it by position: the first line of each indented block is the target. Observed
# on this machine (German Windows 11 26200, 2026-09-25): blocks of three or four lines,
# separated by empty lines or by lines of four spaces, the persistence line sometimes
# missing.
#
# The Entra device and its sign-in token (research, check 7), graded whatever the mailbox
# location, because the device's sign-in serves every Microsoft 365 program, not Outlook
# alone:
#
# - dsregcmd /status, run as whoever runs the Gatherer. Its device part (join state,
# DeviceAuthStatus) is machine-wide; its User State and SSO State are the user's, so
# read from an elevated or foreign session they are somebody else's, and the Judge
# says so instead of grading them (Microsoft: "The command must run in a user context").
# - ConvertFrom-OutlookAuthDsregStatus reads the output by structure: a section is the
# "| Title |" line between two "+----+" banners, a value is an indented "Key : Value"
# line split at its first colon. Observed on this machine (German Windows 11 26200,
# 2026-09-25): keys, section titles and values all came out in English, as in
# Microsoft's documented samples, so the Judge reads the keys by name - they are
# dsregcmd's tokens, not translated labels - and the section titles only label rows.
# - Select-OutlookAuthDsregValue keeps only the keys the Judge and the Technician need:
# no device or tenant IDs, no user name, and of the server's error message only its
# AADSTS number.
# - The times are UTC in dsregcmd's own "yyyy-MM-dd HH:mm:ss.fff UTC"; the Gatherer
# records when it read them (ReadAt, UTC), so the Judge can age the token without a
# clock of its own.

$script:OutlookAuthIdentityKey       = 'Software\Microsoft\Office\16.0\Common\Identity'
$script:OutlookAuthIdentityValues    = @('EnableADAL', 'DisableADALatopWAMOverride', 'DisableAADWAM', 'DisableMSAWAM')
$script:OutlookAuthExchangeKey       = 'Software\Microsoft\Exchange'
$script:OutlookAuthExchangeValues    = @('MapiHttpDisabled', 'AlwaysUseMSOAuthForAutoDiscover')
$script:OutlookAuthSchannelKey       = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client'
$script:OutlookAuthLsaKey            = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'

# The saved credentials kept, by target name: KB 4051374 names MicrosoftOffice15_Data:SSPI
# and MicrosoftOffice16_Data:SSPI.
$script:OutlookAuthSspiPattern       = '^MicrosoftOffice1\d_Data:SSPI'

# What Windows does when LmCompatibilityLevel is absent (research, check 9).
$script:OutlookAuthLmDefault         = 3

$script:OutlookAuthBrokerPackages    = @('Microsoft.AAD.BrokerPlugin', 'Microsoft.Windows.CloudExperienceHost')

$script:OutlookAuthAadLog            = 'Microsoft-Windows-AAD/Operational'
$script:OutlookAuthAadIds            = @(1098, 1081, 1084, 1088)

# The AppModel-State events in the Application log, by id: the payload position of the
# package (or folder) and of the error code. See the file header.
$script:OutlookAuthAppModelProvider  = 'Microsoft-Windows-AppModel-State'
$script:OutlookAuthAppModelFields    = @{
    10 = @(0, 1); 11 = @(0, 1); 12 = @(0, 1); 13 = @(1, 2); 22 = @(0, 1)
    20 = @(1, 2); 21 = @(1, 2); 23 = @(1, 2); 24 = @(1, 2)
}

# Which AAD events a Judge counts as sign-in errors. 1097/1098 are the broker reporting
# that a silent token request needs the user - routine whenever MFA wants an interaction,
# hundreds a week on a healthy machine - so they are evidence, not a Severity (research,
# check 4, Inference). 1081 (the service refused), 1084 (network) and 1088 (WS-Trust) are.
$script:OutlookAuthAadGradedIds      = @(1081, 1084, 1088)

# The dsregcmd /status keys kept, from the research's list (check 7) and Microsoft's
# dsregcmd page: the join state, the device's health in Entra ID, the user's token and
# the codes of its last failed attempt, and the context dsregcmd itself says it ran in.
$script:OutlookAuthDsregKeys         = @(
    'AzureAdJoined', 'EnterpriseJoined', 'DomainJoined', 'DeviceAuthStatus'
    'WorkplaceJoined', 'WamDefaultSet'
    'AzureAdPrt', 'AzureAdPrtUpdateTime', 'AzureAdPrtExpiryTime'
    'Attempt Status', 'Server Error Code', 'Server Error Description'
    'User Context'
)

# The failed-attempt diagnostics dsregcmd prints after the PRT they belong to: after
# AzureAdPrt for Entra ID's, after EnterprisePrt for AD FS's (Microsoft's dsregcmd page).
$script:OutlookAuthDsregPrtDiagnostics = @('Attempt Status', 'Server Error Code', 'Server Error Description')

function Get-OutlookAuthData {
    <#
    .PARAMETER Observed
        What earlier Checks gathered. Only the Autodiscover Check's domains and its pin are read.
    #>

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

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

    $settings = @(
        Get-OutlookAuthRegistryValue -Group 'Identity' -Name $script:OutlookAuthIdentityValues -Location @(
            @{ Source = 'Policy';     Path = 'HKCU:\Software\Policies\' + $script:OutlookAuthIdentityKey.Substring('Software\'.Length) }
            @{ Source = 'Preference'; Path = 'HKCU:\' + $script:OutlookAuthIdentityKey })
        Get-OutlookAuthRegistryValue -Group 'Exchange' -Name $script:OutlookAuthExchangeValues -Location @(
            @{ Source = 'Policy';     Path = 'HKCU:\Software\Policies\' + $script:OutlookAuthExchangeKey.Substring('Software\'.Length) }
            @{ Source = 'Preference'; Path = 'HKCU:\' + $script:OutlookAuthExchangeKey })
        Get-OutlookAuthRegistryValue -Group 'Schannel' -Name @('Enabled', 'DisabledByDefault') -Location @(
            @{ Source = 'Machine'; Path = $script:OutlookAuthSchannelKey })
        # Where the Group Policy "LAN Manager authentication level" writes too, so one row.
        Get-OutlookAuthRegistryValue -Group 'Lsa' -Name @('LmCompatibilityLevel') -Location @(
            @{ Source = 'Machine'; Path = $script:OutlookAuthLsaKey })
    )

    # The user's own store: cmdkey lists the credentials of whoever runs it. Only the
    # Office SSPI target names leave this block (see the file header).
    $credentials      = @()
    $credentialTotal  = $null
    $credentialsError = $null
    try {
        $listed = @(& cmdkey.exe /list 2>$null)
        if ($LASTEXITCODE -ne 0) { $credentialsError = 'cmdkey /list: exit code {0}' -f $LASTEXITCODE }
        else {
            $targets         = @(ConvertFrom-OutlookAuthCmdkeyList -Line $listed)
            $credentialTotal = $targets.Count
            $credentials     = @($targets | Where-Object { $_ -match $script:OutlookAuthSspiPattern })
        }
    }
    catch { $credentialsError = $_.Exception.Message }

    # The current user's registrations: the broker is installed per user profile, and a
    # package another profile has is no help to this one.
    $packages      = @()
    $packagesError = $null
    try {
        $packages = @(foreach ($name in $script:OutlookAuthBrokerPackages) {
            $found = @(Get-AppxPackage -Name $name -ErrorAction Stop)
            if (-not $found.Count) {
                [pscustomobject]@{ Name = $name; Present = $false; Version = $null; Status = $null }
                continue
            }
            foreach ($p in $found) {
                [pscustomobject]@{ Name = $name; Present = $true; Version = "$($p.Version)"; Status = "$($p.Status)" }
            }
        })
    }
    catch { $packagesError = $_.Exception.Message }

    $unreadable = @{}

    $aadEvents = @(Get-StabilityEvent -Log $script:OutlookAuthAadLog -Id $script:OutlookAuthAadIds -Since $since -Unreadable $unreadable |
        ForEach-Object {
            [pscustomobject]@{
                Id   = $_.Id
                Time = $_.TimeCreated
                Code = ConvertTo-OutlookAuthErrorCode -Value (Get-EventPayload -LogEntry $_ -Index 0) `
                    -Description $(if ($_.Id -eq 1081 -or $_.Id -eq 1088) { Get-EventPayload -LogEntry $_ -Index 1 })
                # Which application asked: its id and nothing else of the text, which
                # names the account.
                ClientId = ConvertTo-OutlookAuthClientId -Text ('{0} {1}' -f (Get-EventPayload -LogEntry $_ -Index 1), (Get-EventPayload -LogEntry $_ -Index 2))
            }
        })

    $brokerEvents = @(Get-StabilityEvent -Log 'Application' -Provider @($script:OutlookAuthAppModelProvider) `
            -Id @($script:OutlookAuthAppModelFields.Keys) -Since $since -Unreadable $unreadable |
        ForEach-Object {
            $fields  = $script:OutlookAuthAppModelFields[[int]$_.Id]
            $package = Get-EventPayload -LogEntry $_ -Index $fields[0]
            # Only the broker's own entries: the provider logs for every packaged app.
            if ("$package" -notmatch [regex]::Escape($script:OutlookAuthBrokerPackages[0])) { return }
            [pscustomobject]@{
                Id   = $_.Id
                Time = $_.TimeCreated
                Code = ConvertTo-OutlookAuthErrorCode -Value (Get-EventPayload -LogEntry $_ -Index $fields[1])
            }
        })

    # Run as the user, like everything above: its user half is whoever runs it.
    $deviceValues = @()
    $deviceError  = $null
    try {
        $status = @(& dsregcmd.exe /status 2>$null)
        if ($LASTEXITCODE -ne 0) { $deviceError = 'dsregcmd /status: exit code {0}' -f $LASTEXITCODE }
        else { $deviceValues = @(Select-OutlookAuthDsregValue -Row @(ConvertFrom-OutlookAuthDsregStatus -Line $status)) }
    }
    catch { $deviceError = $_.Exception.Message }
    $deviceReadAt = (Get-Date).ToUniversalTime().ToString('o', [Globalization.CultureInfo]::InvariantCulture)

    $autodiscover = $null
    if ($Observed -and $Observed.ContainsKey('Autodiscover')) { $autodiscover = $Observed['Autodiscover'] }

    [pscustomobject]@{
        PSTypeName     = 'Gutcheck.Data.OutlookAuth'
        Days           = $days
        IsElevated     = (Get-CurrentPrivilege) -eq 'admin'
        RunningAs      = [Security.Principal.WindowsIdentity]::GetCurrent().Name
        LoggedOnUser   = Get-InteractiveUser
        Settings       = $settings
        Packages       = $packages
        PackagesError  = $packagesError
        AadEvents      = @(Group-OutlookAuthEvent -Event $aadEvents)
        BrokerEvents   = @(Group-OutlookAuthEvent -Event $brokerEvents)
        UnreadableLogs = @($unreadable.Keys | Sort-Object)
        # Target names only. SavedCredentialTotal is how many entries the store listed in
        # all, $null when it was not read: a store with nothing of Office's in it is a
        # different fact from one whose listing came back empty.
        SavedCredentials      = $credentials
        SavedCredentialTotal  = $credentialTotal
        SavedCredentialsError = $credentialsError
        # dsregcmd's keys and values as it printed them, with its section; ReadAt is when,
        # in UTC, so the Judge can age the token dsregcmd dates in UTC.
        DeviceState    = [pscustomobject]@{ ReadAt = $deviceReadAt; Values = $deviceValues; Error = $deviceError }
        # Absent when the Autodiscover Check did not run in this Run, which is a different
        # fact from a mailbox whose location Microsoft would not say.
        Autodiscover   = $(if ($autodiscover) { ConvertTo-OutlookAuthAutodiscoverCopy -Data $autodiscover })
    }
}

function Get-OutlookAuthRegistryValue {
    <#
    .SYNOPSIS
        One row per value name: the value from the first location that sets it, and which.
    .DESCRIPTION
        A location whose key could not be read ends the search: the row carries its Source,
        Path and Error, and no Value. Below an unread policy the preference is not the value
        Office obeys, and taking the absent value for Windows' default would pass NTLMv1 or
        a switched-off TLS as fine (Read-OutlookRegistryValue, in OutlookData.ps1).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Group,
        [Parameter(Mandatory)][string[]]$Name,
        [Parameter(Mandatory)][hashtable[]]$Location
    )

    foreach ($n in $Name) {
        $row = [pscustomobject]@{ Group = $Group; Name = $n; Value = $null; Source = $null; Path = $null; Error = $null }
        foreach ($l in $Location) {
            $read = Read-OutlookRegistryValue -Path $l.Path -Name $n
            if ($read.Error) { $row.Source = $l.Source; $row.Path = $l.Path; $row.Error = $read.Error; break }
            if ($null -ne $read.Value) { $row.Value = $read.Value; $row.Source = $l.Source; $row.Path = $l.Path; break }
        }
        $row
    }
}

function Group-OutlookAuthEvent {
    <#
    .SYNOPSIS
        Events counted per id and code, newest time kept. Hundreds of 1098s a week are
        normal, and the Report needs how many and which, not each one.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyCollection()][object[]]$Event = @())

    # And per application: the log is every program's that signs in to Microsoft 365.
    @($Event | Where-Object { $_ }) | Group-Object Id, Code, { "$(Get-DataProperty $_ 'ClientId')" } | ForEach-Object {
        $first = $_.Group[0]
        [pscustomobject]@{
            Id       = [int]$first.Id
            Code     = $first.Code
            ClientId = "$(Get-DataProperty $first 'ClientId')"
            Count    = $_.Count
            Last     = ($_.Group | Measure-Object Time -Maximum).Maximum
        }
    }
}

# The applications Outlook signs in as: Office, which Outlook is a part of, and the new
# Outlook. An error of one of these, or one that names no application, is held against
# Outlook; an error of any other program is not.
$script:OutlookAuthOwnClientIds = @('d3590ed6-52b3-4102-aeff-aad2292ab01c', '5d661950-3475-41cd-a2c3-d671a3162bc1')

function ConvertTo-OutlookAuthClientId {
    <#
    .SYNOPSIS
        The id of the application a sign-in event is of, out of what the event says, or
        nothing where it names none. Pure.
    .DESCRIPTION
        Read off the events of this machine: the request is written out as "client:
        <id>", and a refusal by Microsoft 365 names the "first party application '<id>'".
        Only the id is kept. The rest of the text names the account and is dropped.
    #>

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

    $guid = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'
    $found = [regex]::Match("$Text", ('(?i)\bclient:\s*({0})' -f $guid))
    if (-not $found.Success) { $found = [regex]::Match("$Text", ("(?i)\bapplication\s+'({0})'" -f $guid)) }
    if (-not $found.Success) { return $null }
    $found.Groups[1].Value.ToLowerInvariant()
}

function Test-OutlookAuthOwnEvent {
    <#
    .SYNOPSIS
        Whether a sign-in event is held against Outlook: one of Office or of the new
        Outlook, or one that names no application. Pure.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()]$Event)

    $id = "$(Get-DataProperty $Event 'ClientId')".Trim().ToLowerInvariant()
    (-not $id) -or ($script:OutlookAuthOwnClientIds -contains $id)
}

function ConvertFrom-OutlookAuthCmdkeyList {
    <#
    .SYNOPSIS
        The target names out of cmdkey /list output, in the order listed. Pure.
    .DESCRIPTION
        Read by position, never by label, because the labels are localised: an entry is a
        block of indented lines, and its first line is the target, "<label>: <target>"
        (German "Ziel:", French "Cible :"). The label holds no colon, the target may, so
        the line is split at the first one. The "LegacyGeneric:target=" style prefix
        cmdkey puts in front says how the entry was stored and is dropped, leaving the
        name Credential Manager shows. The unindented heading and any line without a
        colon (an empty store's message) are not entries.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][AllowEmptyString()][string[]]$Line = @())

    $inBlock = $false
    foreach ($l in @($Line)) {
        if (-not "$l".Trim()) { $inBlock = $false; continue }
        if ($inBlock) { continue }
        $inBlock = $true
        if ("$l" -notmatch '^\s+[^:]+:\s*(\S.*)$') { continue }
        $target = $Matches[1].Trim() -replace '^[A-Za-z]+:target=', ''
        if ($target) { $target }
    }
}

function ConvertFrom-OutlookAuthDsregStatus {
    <#
    .SYNOPSIS
        dsregcmd /status output as rows of Section, Key and Value, in the order printed. Pure.
    .DESCRIPTION
        Read by structure (see the file header): "| Title |" names the section the lines
        after it belong to, and an indented line with a colon is "Key : Value", split at
        its first colon because values hold colons of their own (times, URLs). Microsoft's
        samples print one key without a space before the colon ("AadRecoveryEnabled: NO"),
        so the spacing is not relied on. Unindented lines - the banners, the closing "For
        more information, please visit https://..." - are never values, nor is anything
        before the first section.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()][AllowEmptyString()][string[]]$Line = @())

    $section = $null
    foreach ($l in @($Line)) {
        $text = "$l"
        if ($text -match '^\s*\|\s*(.*?)\s*\|\s*$') { $section = $Matches[1]; continue }
        if (-not $section) { continue }
        if ($text -notmatch '^\s+([^:\s][^:]*?)\s*:\s?(.*)$') { continue }
        [pscustomobject]@{ Section = $section; Key = $Matches[1]; Value = $Matches[2].Trim() }
    }
}

function Select-OutlookAuthDsregValue {
    <#
    .SYNOPSIS
        The dsregcmd rows the Report may carry, in the order printed. Pure.
    .DESCRIPTION
        Only $script:OutlookAuthDsregKeys. The failed-attempt diagnostics are kept only
        where they follow AzureAdPrt, never EnterprisePrt, whose AD FS token is no
        Microsoft 365 sign-in. The server's error description may carry the account's
        name, so only its AADSTS number stays, which is what Microsoft's documentation is
        searched by.
    #>

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

    $lastPrt = $null
    foreach ($r in @($Row | Where-Object { $_ })) {
        $key = "$($r.Key)"
        if ($key -eq 'AzureAdPrt' -or $key -eq 'EnterprisePrt') { $lastPrt = $key }
        if ($script:OutlookAuthDsregKeys -notcontains $key) { continue }
        if ($script:OutlookAuthDsregPrtDiagnostics -contains $key -and $lastPrt -ne 'AzureAdPrt') { continue }

        $value = "$($r.Value)"
        if ($key -eq 'Server Error Description') {
            $sts = [regex]::Match($value, 'AADSTS\d+')
            if (-not $sts.Success) { continue }
            $value = $sts.Value
        }
        [pscustomobject]@{ Section = "$($r.Section)"; Key = $key; Value = $value }
    }
}

function ConvertFrom-OutlookAuthDsregTime {
    <#
    .SYNOPSIS
        A dsregcmd time ("2026-09-25 05:21:32.000 UTC"), or the Gatherer's ISO 8601 UTC
        time, as a UTC DateTime; $null for anything else. Pure.
    .DESCRIPTION
        Taken apart by pattern, not by DateTime.Parse, so the culture the Run happens in
        (German: dd.MM.yyyy) cannot change the reading. A time without its UTC mark is not
        read: it would be a guess at the zone.
    #>

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

    if ("$Text" -notmatch '^\s*(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,7}))?\s*(?:UTC|Z)\s*$') { return $null }
    try {
        $time = [datetime]::new([int]$Matches[1], [int]$Matches[2], [int]$Matches[3],
            [int]$Matches[4], [int]$Matches[5], [int]$Matches[6], [DateTimeKind]::Utc)
    }
    catch { return $null }
    if ($Matches[7]) { $time = $time.AddTicks([long]$Matches[7].PadRight(7, '0')) }
    $time
}

function ConvertTo-OutlookAuthAutodiscoverCopy {
    <#
    .SYNOPSIS
        The part of the Autodiscover Check's data the mailbox location is read from.
    .DESCRIPTION
        A copy of what ConvertTo-AutodiscoverMailboxLocation reads, not the whole thing:
        the certificates and DNS answers belong to that Check's own Report entry and would
        only be carried twice.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Data)

    [pscustomobject]@{
        CheckName         = Get-DataProperty $Data 'CheckName'
        # That Check's ExchangeOnline parameter (auto, yes, no), so a Customer pinned there
        # is classified here the same way.
        ExchangeOnlinePin = Get-DataProperty $Data 'ExchangeOnlinePin'
        CachedAnswerMaxAgeDays = Get-DataProperty $Data 'CachedAnswerMaxAgeDays'
        Domains           = @(foreach ($d in (Get-DataCollection $Data 'Domains')) {
            $probe = Get-DataProperty $d 'ExchangeOnline'
            $failure = Get-DataProperty $probe 'Error'
            [pscustomobject]@{
                Domain         = Get-DataProperty $d 'Domain'
                AddressIsUser  = [bool](Get-DataProperty $d 'AddressIsUser')
                ExchangeOnline = $(if ($probe) {
                    [pscustomobject]@{
                        StatusCode = Get-DataProperty $probe 'StatusCode'
                        Location   = Get-DataProperty $probe 'Location'
                        Error      = $(if ($failure) { [pscustomobject]@{ Message = "$(Get-DataProperty $failure 'Message')" } })
                    }
                })
                V2Url          = Get-DataProperty $d 'V2Url'
                # What Outlook itself was last told; it outranks Microsoft's answer.
                Cached         = Get-DataProperty $d 'Cached'
            }
        })
    }
}

function ConvertTo-OutlookAuthErrorCode {
    <#
    .SYNOPSIS
        An event's error code as a Technician searches for it. Pure.
    .DESCRIPTION
        A number becomes eight hex digits (0xCAA2000C), which is how Microsoft's articles
        and the event viewer write these codes - whether the payload held it signed, as an
        HRESULT, or unsigned. Text stays text (invalid_grant). An AADSTS number in the
        description is appended, because it is the one thing Microsoft's sign-in
        documentation is searchable by; the rest of the description is dropped.
    #>

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

    $code = "$Value".Trim()
    $number = 0L
    if ($code -match '^-?\d+$' -and [long]::TryParse($code, [ref]$number)) {
        if ($number -lt 0) { $number += 4294967296L }
        if ($number -ge 0 -and $number -le 4294967295L) { $code = '0x{0:X8}' -f $number }
    }

    $sts = [regex]::Match("$Description", 'AADSTS\d+')
    if ($sts.Success) {
        if ($code) { $code = '{0} {1}' -f $code, $sts.Value } else { $code = $sts.Value }
    }
    if (-not $code) { return $null }
    $code
}

function Select-OutlookAuthMailboxLocation {
    <#
    .SYNOPSIS
        Where the mailbox is, and what that answer rests on. Pure.
    .DESCRIPTION
        MailboxLocation (auto, ExchangeOnline, OnPremises) pins it. Then the Autodiscover
        Check's own ExchangeOnline pin (yes, no), carried in its data. Otherwise the domain
        whose address is the user's own is asked first, because a hybrid answers per
        mailbox and a made-up address can land on the wrong side (see Autodiscover.ps1);
        then any domain Microsoft answered for; else Unknown. Anything but the two pins is
        auto; the Judge names a pin it does not know.
 
        Basis is Parameter, AutodiscoverPin (the Autodiscover Check's pin), Autodiscover
        (Domain says which), NoAutodiscover (that Check did not run in this Run) or
        NoAnswer.
 
        A profile may hold a mailbox on the own Exchange and one in Exchange Online. Then
        there is no one place: OnlineDomains and OnPremisesDomains name the mail domains
        of the User's own addresses at each, and the Judge looks at both sides. Location
        stays the first that answered, for what is worded by one place. A pin is one place
        by decision, and both lists are then empty.
    #>

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

    $pin = "$(Get-Parameter $Parameters 'MailboxLocation' 'auto')".Trim()
    foreach ($known in 'ExchangeOnline', 'OnPremises') {
        if ($pin -eq $known) { return [pscustomobject]@{ Location = $known; Basis = 'Parameter'; Domain = $null; OnlineDomains = @(); OnPremisesDomains = @() } }
    }

    $autodiscover = Get-DataProperty $Data 'Autodiscover'
    if (-not $autodiscover) { return [pscustomobject]@{ Location = 'Unknown'; Basis = 'NoAutodiscover'; Domain = $null; OnlineDomains = @(); OnPremisesDomains = @() } }

    # Classified as the Autodiscover Check classifies it, never a second way, and with that
    # Check's pin. The pin holds for the Customer, so it holds with no domain found too:
    # asked about no domain, the classifier answers the pin or Unknown.
    $classify = @{}
    $adPin    = Get-DataProperty $autodiscover 'ExchangeOnlinePin'
    if ($null -ne $adPin) { $classify = @{ ExchangeOnline = $adPin } }
    $adMaxAge = Get-DataProperty $autodiscover 'CachedAnswerMaxAgeDays'
    if ($null -ne $adMaxAge) { $classify['CachedAnswerMaxAgeDays'] = $adMaxAge }
    $pinned = ConvertTo-AutodiscoverMailboxLocation -Domain $null -Parameters $classify
    if ($pinned -ne 'Unknown') { return [pscustomobject]@{ Location = $pinned; Basis = 'AutodiscoverPin'; Domain = $null; OnlineDomains = @(); OnPremisesDomains = @() } }

    $domains = @((Get-DataCollection $autodiscover 'Domains') | ForEach-Object { $_ })

    # Every place a mailbox of the User is at. Only the User's own addresses: one made up
    # to ask with says where the domain's mail goes and not that this User has a mailbox.
    $online = @(); $onPremises = @()
    foreach ($d in @($domains | Where-Object { Get-DataProperty $_ 'AddressIsUser' })) {
        switch (ConvertTo-AutodiscoverMailboxLocation -Domain $d -Parameters $classify) {
            'ExchangeOnline' { $online     += "$(Get-DataProperty $d 'Domain')" }
            'OnPremises'     { $onPremises += "$(Get-DataProperty $d 'Domain')" }
        }
    }

    $ordered = @(@($domains | Where-Object { Get-DataProperty $_ 'AddressIsUser' }) +
                 @($domains | Where-Object { -not (Get-DataProperty $_ 'AddressIsUser') }))
    foreach ($d in $ordered) {
        $location = ConvertTo-AutodiscoverMailboxLocation -Domain $d -Parameters $classify
        if ($location -ne 'Unknown') {
            return [pscustomobject]@{ Location = $location; Basis = 'Autodiscover'; Domain = "$(Get-DataProperty $d 'Domain')"
                                      OnlineDomains = $online; OnPremisesDomains = $onPremises }
        }
    }
    [pscustomobject]@{ Location = 'Unknown'; Basis = 'NoAnswer'; Domain = $null; OnlineDomains = $online; OnPremisesDomains = $onPremises }
}

function Get-OutlookAuthSetting {
    <#
    .SYNOPSIS
        One gathered registry row, or $null. Pure.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)][string]$Group, [Parameter(Mandatory)][string]$Name)

    (Get-DataCollection $Data 'Settings') |
        Where-Object { "$(Get-DataProperty $_ 'Group')" -eq $Group -and "$(Get-DataProperty $_ 'Name')" -eq $Name } |
        Select-Object -First 1
}

function Get-OutlookAuthLmLevelText {
    <#
    .SYNOPSIS
        What a LAN Manager authentication level does, in the words of its Group Policy.
        Nothing for what is no level. Pure.
    .DESCRIPTION
        The six levels of "Network security: LAN Manager authentication level", as
        Microsoft documents them for LmCompatibilityLevel: what the PC sends, and from 4
        on what a domain controller refuses.
    #>

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

    $number = ConvertTo-Number $Level
    if ($null -eq $number) { return '' }
    switch ([double]$number) {
        0 { return Get-Text 'Value.OutlookAuth.LmLevel.0' }
        1 { return Get-Text 'Value.OutlookAuth.LmLevel.1' }
        2 { return Get-Text 'Value.OutlookAuth.LmLevel.2' }
        3 { return Get-Text 'Value.OutlookAuth.LmLevel.3' }
        4 { return Get-Text 'Value.OutlookAuth.LmLevel.4' }
        5 { return Get-Text 'Value.OutlookAuth.LmLevel.5' }
    }
    ''
}

function Get-OutlookAuthSettingText {
    <#
    .SYNOPSIS
        What a gathered registry value does, set to the number it is set to. Nothing for
        a number that value is not documented with. Pure.
    .DESCRIPTION
        From the sources this Kind grades by (the file header): Microsoft's articles on
        modern authentication and WAM in Office, KB 2937684 and KB 3126599 for the two
        Exchange values, and the TLS registry settings of SChannel.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][string]$Group, [AllowNull()][string]$Name, [AllowNull()]$Value)

    $number = ConvertTo-Number $Value
    if ($null -eq $number) { return '' }
    if ("$Group" -eq 'Lsa' -and "$Name" -eq 'LmCompatibilityLevel') { return Get-OutlookAuthLmLevelText -Level $number }

    switch ('{0}\{1}={2}' -f $Group, $Name, $number) {
        'Identity\EnableADAL=0'                      { return Get-Text 'Value.OutlookAuth.Does.ModernAuthOff' }
        'Identity\EnableADAL=1'                      { return Get-Text 'Value.OutlookAuth.Does.ModernAuthOn' }
        'Identity\DisableADALatopWAMOverride=0'      { return Get-Text 'Value.OutlookAuth.Does.WamOn' }
        'Identity\DisableADALatopWAMOverride=1'      { return Get-Text 'Value.OutlookAuth.Does.WamOff' }
        'Identity\DisableAADWAM=0'                   { return Get-Text 'Value.OutlookAuth.Does.WamWorkOn' }
        'Identity\DisableAADWAM=1'                   { return Get-Text 'Value.OutlookAuth.Does.WamWorkOff' }
        'Identity\DisableMSAWAM=0'                   { return Get-Text 'Value.OutlookAuth.Does.WamPersonalOn' }
        'Identity\DisableMSAWAM=1'                   { return Get-Text 'Value.OutlookAuth.Does.WamPersonalOff' }
        'Exchange\MapiHttpDisabled=0'                { return Get-Text 'Value.OutlookAuth.Does.MapiOn' }
        'Exchange\MapiHttpDisabled=1'                { return Get-Text 'Value.OutlookAuth.Does.MapiOff' }
        'Exchange\AlwaysUseMSOAuthForAutoDiscover=0' { return Get-Text 'Value.OutlookAuth.Does.AutodiscoverModernOff' }
        'Exchange\AlwaysUseMSOAuthForAutoDiscover=1' { return Get-Text 'Value.OutlookAuth.Does.AutodiscoverModernOn' }
        'Schannel\Enabled=0'                         { return Get-Text 'Value.OutlookAuth.Does.TlsOff' }
        'Schannel\Enabled=1'                         { return Get-Text 'Value.OutlookAuth.Does.TlsOn' }
        'Schannel\DisabledByDefault=0'               { return Get-Text 'Value.OutlookAuth.Does.TlsByDefault' }
        'Schannel\DisabledByDefault=1'               { return Get-Text 'Value.OutlookAuth.Does.TlsNotByDefault' }
    }
    ''
}

function Format-OutlookAuthSettingValue {
    <#
    .SYNOPSIS
        "0: moderne Anmeldung abgeschaltet": a gathered value with what it does behind
        it, the value alone where that is not known. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Setting)

    $value = Get-DataProperty $Setting 'Value'
    $does  = Get-OutlookAuthSettingText -Group (Get-DataProperty $Setting 'Group') -Name (Get-DataProperty $Setting 'Name') -Value $value
    if (-not $does) { return "$value" }
    (Get-Text 'Value.OutlookAuth.ValueDoes') -f $value, $does
}

function Format-OutlookAuthSetting {
    <#
    .SYNOPSIS
        "EnableADAL = 0: moderne Anmeldung abgeschaltet (Richtlinie)": a value as the
        Technician will look for it, with what it does. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Setting)

    $source = switch ("$(Get-DataProperty $Setting 'Source')") {
        'Policy'     { Get-Text 'Value.OutlookAuth.Source.Policy' }
        'Preference' { Get-Text 'Value.OutlookAuth.Source.Preference' }
        default      { Get-Text 'Value.OutlookAuth.Source.Machine' }
    }
    (Get-Text 'Value.OutlookAuth.Setting') -f (Get-DataProperty $Setting 'Name'), (Format-OutlookAuthSettingValue -Setting $Setting), $source
}

function Format-OutlookAuthCode {
    <#
    .SYNOPSIS
        "0xCAA2000C (...), AADSTS50076 (...)": the codes of one event or one failed
        attempt, each with what it means behind it. Pure.
    .DESCRIPTION
        A code as the Gatherer keeps it is one or several words: an HRESULT or NTSTATUS
        in hex, an OAuth error word (invalid_grant), an AADSTS number - which dsregcmd
        also prints bare, as "Server Error Code". Each is decoded by what it looks like,
        and one nothing is known of stays as it is.
    #>

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

    $parts = @(foreach ($word in @("$Code" -split '\s+' | Where-Object { $_ })) {
        $meaning = ''
        if ($word -match '^(?i)0x[0-9a-f]+$')         { $meaning = Get-HResultText -Code $word }
        elseif ($word -match '^(?i)(AADSTS)?\d+$')    { $meaning = Get-AadStsText -Code $word }
        else                                          { $meaning = Get-OAuthErrorText -Code $word }
        Format-CodeWithMeaning -Code $word -Meaning $meaning
    })
    $parts -join ', '
}

function Get-OutlookAuthEventText {
    <#
    .SYNOPSIS
        What an event of the AAD log is, by its id. Nothing for one not gathered. Pure.
    .DESCRIPTION
        As the file header and the research (check 4) read them: 1098 is the broker
        saying a token could not be had silently, 1081 the service refusing, 1084 the
        network, 1088 the WS-Trust sign-in at a federation server.
    #>

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

    switch ([int](ConvertTo-Number $Id)) {
        1098 { return Get-Text 'Value.OutlookAuth.Event.1098' }
        1081 { return Get-Text 'Value.OutlookAuth.Event.1081' }
        1084 { return Get-Text 'Value.OutlookAuth.Event.1084' }
        1088 { return Get-Text 'Value.OutlookAuth.Event.1088' }
    }
    ''
}

function Get-OutlookAuthPackageStatusText {
    <#
    .SYNOPSIS
        "muss repariert werden (NeedsRemediation)": what Windows says of an AppX package,
        each word of it in German with the word behind it. Pure.
    .DESCRIPTION
        Get-AppxPackage prints the flags of PackageStatus by name, several joined by
        commas. Their meanings are Microsoft's, from the PackageStatus class of
        Windows.ApplicationModel. A word not known here stays as it is.
    #>

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

    $parts = @(foreach ($flag in @("$Status" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) {
        $meaning = switch ($flag) {
            'Ok'                   { Get-Text 'Value.OutlookAuth.Package.Ok' }
            'LicenseIssue'         { Get-Text 'Value.OutlookAuth.Package.LicenseIssue' }
            'Modified'             { Get-Text 'Value.OutlookAuth.Package.Modified' }
            'Tampered'             { Get-Text 'Value.OutlookAuth.Package.Tampered' }
            'Disabled'             { Get-Text 'Value.OutlookAuth.Package.Disabled' }
            'NeedsRemediation'     { Get-Text 'Value.OutlookAuth.Package.NeedsRemediation' }
            'DependencyIssue'      { Get-Text 'Value.OutlookAuth.Package.DependencyIssue' }
            'DeploymentInProgress' { Get-Text 'Value.OutlookAuth.Package.DeploymentInProgress' }
            'PackageOffline'       { Get-Text 'Value.OutlookAuth.Package.PackageOffline' }
            'DataOffline'          { Get-Text 'Value.OutlookAuth.Package.DataOffline' }
            'NotAvailable'         { Get-Text 'Value.OutlookAuth.Package.NotAvailable' }
            'Servicing'            { Get-Text 'Value.OutlookAuth.Package.Servicing' }
            default                { '' }
        }
        if ($meaning) { (Get-Text 'Value.OutlookAuth.Package.Means') -f $meaning, $flag } else { $flag }
    })
    $parts -join ', '
}

function Format-OutlookAuthUnread {
    <#
    .SYNOPSIS
        "EnableADAL nicht lesbar (HKCU:\...): <error>" for each row that could not be read,
        or nothing. Pure.
    .DESCRIPTION
        A row the Gatherer could not read has no Value, exactly like one that is not set,
        so every Judge that would take an unset value for Windows' or Office's default asks
        here first: unread is INFO, never the default and never OK.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][object[]]$Setting = @())

    foreach ($s in @($Setting | Where-Object { $_ })) {
        $failure = Get-DataProperty $s 'Error'
        if (-not $failure) { continue }
        (Get-Text 'Value.OutlookAuth.SettingUnreadable') -f (Get-DataProperty $s 'Name'), (Get-DataProperty $s 'Path'), $failure
    }
}

function New-OutlookAuthUnreadFinding {
    <#
    .SYNOPSIS
        The INFO Finding for settings that could not be read, or nothing when all were. Pure.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Check, [AllowNull()][AllowEmptyCollection()][object[]]$Setting = @(), [string]$Suffix = '')

    $unread = @(Format-OutlookAuthUnread -Setting $Setting)
    if (-not $unread.Count) { return }
    New-Finding -Category Apps -Check $Check -Severity INFO -Value (($unread -join '; ') + $Suffix) `
        -Hint (Get-Text 'Hint.OutlookAuth.SettingUnread')
}

function Test-OutlookAuthSettingIs {
    <#
    .SYNOPSIS
        Whether a gathered value is set to the given number. Pure.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()]$Setting, [Parameter(Mandatory)][double]$Number)

    $value = ConvertTo-Number (Get-DataProperty $Setting 'Value')
    $null -ne $value -and $value -eq $Number
}

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

    $pin = "$(Get-Parameter $Parameters 'MailboxLocation' 'auto')".Trim()
    if (@('auto', 'ExchangeOnline', 'OnPremises') -notcontains $pin) {
        New-Finding -Category Apps -Check (Get-Text 'Check.OutlookAuth.SignIn') -Severity INFO `
            -Value ((Get-Text 'Value.OutlookAuth.InvalidPin') -f $pin) `
            -Hint (Get-Text 'Hint.OutlookAuth.InvalidPin')
    }

    $mailbox   = Select-OutlookAuthMailboxLocation -Data $Data -Parameters $Parameters
    # A profile may hold a mailbox at each place, and then both sides are looked at. A
    # Run took the first mailbox it found, which was on the own Exchange, and said nothing
    # of the sign-in to Microsoft 365 the second mailbox depends on.
    $alsoOnline     = [bool]@($mailbox.OnlineDomains).Count
    $alsoOnPremises = [bool]@($mailbox.OnPremisesDomains).Count
    $microsoft      = $mailbox.Location -ne 'OnPremises' -or $alsoOnline
    $ownExchange    = $mailbox.Location -ne 'ExchangeOnline' -or $alsoOnPremises

    # Everything but TLS and the AAD log is the user's own state, and read from the wrong
    # session it is somebody else's - answered confidently, and most likely clean.
    $running  = "$(Get-DataProperty $Data 'RunningAs')"
    $loggedOn = "$(Get-DataProperty $Data 'LoggedOnUser')"
    $session  = $null
    if (Get-DataProperty $Data 'IsElevated') { $session = Get-Text 'Value.OutlookAuth.Elevated' }
    elseif ($running -and $loggedOn -and $running -ne $loggedOn) {
        $session = (Get-Text 'Value.OutlookAuth.OtherUser') -f $running, $loggedOn
    }

    if ($session) {
        New-Finding -Category Apps -Check (Get-Text 'Check.OutlookAuth.SignIn') -Severity INFO `
            -Value $session -Hint (Get-Text 'Hint.OutlookAuth.UserSession')
    }
    elseif ($microsoft) {
        New-OutlookAuthModernAuthFinding -Data $Data -Mailbox $mailbox
        New-OutlookAuthWamFinding        -Data $Data -Mailbox $mailbox
        New-OutlookAuthBrokerFinding     -Data $Data -Mailbox $mailbox
    }
    if ($alsoOnline -and $alsoOnPremises) {
        New-Finding -Category Apps -Check (Get-Text 'Check.OutlookAuth.Microsoft365') -Severity OK `
            -Value ((Get-Text 'Value.OutlookAuth.BothPlaces') -f (@($mailbox.OnlineDomains) -join ', '), (@($mailbox.OnPremisesDomains) -join ', '))
    }
    if (-not $microsoft) {
        $basis = Get-Text 'Value.OutlookAuth.Basis.Parameter'
        if ($mailbox.Basis -eq 'Autodiscover') { $basis = (Get-Text 'Value.OutlookAuth.Basis.Domain') -f $mailbox.Domain }
        if ($mailbox.Basis -eq 'AutodiscoverPin') {
            $basis = (Get-Text 'Value.OutlookAuth.Basis.AutodiscoverPin') -f (Get-DataProperty (Get-DataProperty $Data 'Autodiscover') 'CheckName')
        }
        New-Finding -Category Apps -Check (Get-Text 'Check.OutlookAuth.Microsoft365') -Severity INFO `
            -Value ((Get-Text 'Value.OutlookAuth.OnPremises') -f $basis) `
            -Hint (Get-Text 'Hint.OutlookAuth.OnPremises')
    }

    if (-not $session) { New-OutlookAuthMapiFinding -Data $Data -Mailbox $mailbox }
    if ($microsoft)    { New-OutlookAuthAadFinding -Data $Data -Mailbox $mailbox -Parameters $Parameters }
    New-OutlookAuthTlsFinding -Data $Data

    # On-prem prerequisites: Exchange Online signs in with tokens, never NTLM, and has no
    # use for SSPI credentials, so a mailbox known to be there gets neither. Unknown gets
    # both, marked as such. The credential store is the user's, like the settings above.
    if ($ownExchange) {
        New-OutlookAuthNtlmFinding -Data $Data -Mailbox $mailbox -Parameters $Parameters
        if (-not $session) { New-OutlookAuthSavedCredentialFinding -Data $Data -Mailbox $mailbox }
    }

    # The device's sign-in serves every Microsoft 365 program, so it is graded wherever the
    # mailbox is. Its user half is the wrong user's outside the user's session, and the
    # session Finding above already says so.
    New-OutlookAuthDeviceFinding -Data $Data -Mailbox $mailbox -Parameters $Parameters -UserUnread ([bool]$session)
}

function Get-OutlookAuthDeviceValue {
    <#
    .SYNOPSIS
        The first value dsregcmd printed for a key, or $null. Pure.
    #>

    [CmdletBinding()]
    param([AllowNull()]$DeviceState, [Parameter(Mandatory)][string]$Key)

    $row = (Get-DataCollection $DeviceState 'Values') | Where-Object { "$(Get-DataProperty $_ 'Key')" -eq $Key } | Select-Object -First 1
    if ($row) { "$(Get-DataProperty $row 'Value')" }
}

function New-OutlookAuthDeviceFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox, [hashtable]$Parameters, [bool]$UserUnread)

    # Microsoft's figure: "If the value of the AzureAdPrtUpdateTime field is more than four
    # hours, a problem is likely preventing the PRT from refreshing" (research, check 7).
    $staleHours = Get-Parameter $Parameters 'PrtStaleHours' 4

    $check = Get-Text 'Check.OutlookAuth.Device'
    $state = Get-DataProperty $Data 'DeviceState'
    $failure = Get-DataProperty $state 'Error'
    if ($failure) {
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value ((Get-Text 'Value.OutlookAuth.DsregUnreadable') -f $failure) -Hint (Get-Text 'Hint.OutlookAuth.DsregUnread')
    }
    if (-not (Get-DataCollection $state 'Values').Count) {
        return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.OutlookAuth.DsregUnread')
    }

    $yes = { param($Key) (Get-OutlookAuthDeviceValue $state $Key) -eq 'YES' }
    $entra = & $yes 'AzureAdJoined'

    # Microsoft's table on its dsregcmd page: AzureAdJoined and DomainJoined together are
    # hybrid; EnterpriseJoined is the on-prem device registration service.
    $value = Get-Text 'Value.OutlookAuth.Join.None'
    if ($entra -and (& $yes 'DomainJoined')) { $value = Get-Text 'Value.OutlookAuth.Join.Hybrid' }
    elseif ($entra)                          { $value = Get-Text 'Value.OutlookAuth.Join.Entra' }
    elseif (& $yes 'EnterpriseJoined')       { $value = Get-Text 'Value.OutlookAuth.Join.Enterprise' }
    elseif (& $yes 'DomainJoined')           { $value = Get-Text 'Value.OutlookAuth.Join.Domain' }
    # WorkplaceJoined is the user's registered work account, in dsregcmd's User State.
    if ($UserUnread)                    { $value += Get-Text 'Value.OutlookAuth.Join.UserUnread' }
    elseif (& $yes 'WorkplaceJoined')   { $value += Get-Text 'Value.OutlookAuth.Join.Registered' }

    $hint = Get-Text 'Hint.OutlookAuth.DeviceJoined'
    if (-not $entra) {
        $hint = Get-Text 'Hint.OutlookAuth.DeviceNotJoined'
        if ($Mailbox.Location -eq 'OnPremises') { $hint = Get-Text 'Hint.OutlookAuth.DeviceOnPremises' }
    }
    New-Finding -Category Apps -Check $check -Severity INFO -Value $value -Hint $hint

    # A device not joined to Entra ID has no Entra device to check and gets no PRT;
    # Microsoft: the SSO state "can be ignored" for registered devices.
    if (-not $entra) { return }

    $check  = Get-Text 'Check.OutlookAuth.DeviceAuth'
    $status = Get-OutlookAuthDeviceValue $state 'DeviceAuthStatus'
    # Microsoft's three: SUCCESS, "FAILED. Device is either disabled or deleted", and
    # "FAILED. ERROR" when the test could not run. Only the second is the device's fault.
    if ($status -eq 'SUCCESS') {
        New-Finding -Category Apps -Check $check -Severity OK -Value (Get-Text 'Value.OutlookAuth.DeviceAuthOk')
    }
    elseif ($status -match '^FAILED' -and $status -notmatch '^FAILED\.\s*ERROR') {
        New-Finding -Category Apps -Check $check -Severity FAIL `
            -Value ((Get-Text 'Value.OutlookAuth.DeviceAuthFailed') -f $status) `
            -Meaning (Get-Text 'Meaning.OutlookAuth.DeviceDisabled') -Hint (Get-Text 'Hint.OutlookAuth.DeviceDisabled')
    }
    else {
        # Absent before Windows 10 21H1, or a value Microsoft does not document.
        $shown = Get-Text 'Value.OutlookAuth.DeviceAuthAbsent'
        if ($status) { $shown = (Get-Text 'Value.OutlookAuth.DeviceAuthUnknown') -f $status }
        New-Finding -Category Apps -Check $check -Severity INFO -Value $shown -Hint (Get-Text 'Hint.OutlookAuth.DeviceAuthUnknown')
    }

    if ($UserUnread) { return }

    $check = Get-Text 'Check.OutlookAuth.Prt'
    if (-not (& $yes 'AzureAdPrt')) {
        # Research, check 7: no PRT on a joined device is WARN; the Hint's error code is
        # the failed attempt's, when dsregcmd printed one.
        $value = Get-Text 'Value.OutlookAuth.PrtMissing'
        $codes = @(foreach ($k in $script:OutlookAuthDsregPrtDiagnostics) { Get-OutlookAuthDeviceValue $state $k }) | Where-Object { $_ }
        # Attempt Status is an NTSTATUS, the two of the server an OAuth word and an AADSTS
        # number: each is shown with what it means.
        if ($codes) { $value += (Get-Text 'Value.OutlookAuth.PrtCode') -f (Format-OutlookAuthCode -Code ($codes -join ' ')) }
        return New-Finding -Category Apps -Check $check -Severity WARN -Value $value `
            -Meaning (Get-Text 'Meaning.OutlookAuth.PrtStale') -Hint (Get-Text 'Hint.OutlookAuth.PrtStale')
    }

    $printed = Get-OutlookAuthDeviceValue $state 'AzureAdPrtUpdateTime'
    $updated = ConvertFrom-OutlookAuthDsregTime $printed
    # PowerShell 7's ConvertFrom-Json turns an ISO time into a DateTime of its own accord,
    # so data read back from a file may carry ReadAt either way.
    $readAt  = Get-DataProperty $state 'ReadAt'
    if ($readAt -is [datetime]) { $readAt = $readAt.ToUniversalTime() }
    else { $readAt = ConvertFrom-OutlookAuthDsregTime "$readAt" }
    if (-not $updated -or -not $readAt) {
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value ((Get-Text 'Value.OutlookAuth.PrtTimeUnknown') -f $printed) -Hint (Get-Text 'Hint.OutlookAuth.PrtTimeUnknown')
    }

    $age = ($readAt - $updated).TotalHours
    $hours = [math]::Floor([math]::Max(0, $age))
    if ($age -gt $staleHours) {
        return New-Finding -Category Apps -Check $check -Severity WARN `
            -Value ((Get-Text 'Value.OutlookAuth.PrtStale') -f $hours, $updated.ToString('yyyy-MM-dd HH:mm', [Globalization.CultureInfo]::InvariantCulture)) `
            -Meaning (Get-Text 'Meaning.OutlookAuth.PrtStale') `
            -Hint (Get-Text 'Hint.OutlookAuth.PrtStale')
    }
    New-Finding -Category Apps -Check $check -Severity OK -Value ((Get-Text 'Value.OutlookAuth.PrtFresh') -f $hours)
}

function Get-OutlookAuthUnknownSuffix {
    <#
    .SYNOPSIS
        What a Microsoft 365 Value adds when nobody knows the mailbox is in Microsoft 365.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Mailbox)

    if ($Mailbox.Location -eq 'Unknown') { return (Get-Text 'Value.OutlookAuth.LocationUnknown') }
    ''
}

function New-OutlookAuthModernAuthFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox)

    $check  = Get-Text 'Check.OutlookAuth.ModernAuth'
    $suffix = Get-OutlookAuthUnknownSuffix $Mailbox
    $adal   = Get-OutlookAuthSetting -Data $Data -Group 'Identity' -Name 'EnableADAL'

    # EnableADAL=0 is basic authentication, which Exchange Online no longer accepts from
    # anyone (research, check 4).
    if (Test-OutlookAuthSettingIs $adal 0) {
        return New-Finding -Category Apps -Check $check -Severity FAIL `
            -Value ((Format-OutlookAuthSetting $adal) + $suffix) `
            -Meaning (Get-Text 'Meaning.OutlookAuth.ModernAuthOff') -Hint (Get-Text 'Hint.OutlookAuth.ModernAuthOff')
    }
    $unread = New-OutlookAuthUnreadFinding -Check $check -Setting @($adal) -Suffix $suffix
    if ($unread) { return $unread }
    New-Finding -Category Apps -Check $check -Severity OK -Value ((Get-Text 'Value.OutlookAuth.On') + $suffix)
}

function New-OutlookAuthWamFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox)

    $check  = Get-Text 'Check.OutlookAuth.Wam'
    $suffix = Get-OutlookAuthUnknownSuffix $Mailbox

    # Any of the three at 1 takes Office off WAM, which Microsoft calls unsupported - so one
    # read at 1 fails it whatever the others are, and only then does an unread one matter.
    $settings = @(foreach ($name in 'DisableADALatopWAMOverride', 'DisableAADWAM', 'DisableMSAWAM') {
        Get-OutlookAuthSetting -Data $Data -Group 'Identity' -Name $name
    })
    $off = @(foreach ($setting in $settings) {
        if (Test-OutlookAuthSettingIs $setting 1) { Format-OutlookAuthSetting $setting }
    })
    if ($off.Count) {
        return New-Finding -Category Apps -Check $check -Severity FAIL `
            -Value (($off -join ', ') + $suffix) `
            -Meaning (Get-Text 'Meaning.OutlookAuth.WamOff') -Hint (Get-Text 'Hint.OutlookAuth.WamOff')
    }
    $unread = New-OutlookAuthUnreadFinding -Check $check -Setting $settings -Suffix $suffix
    if ($unread) { return $unread }
    New-Finding -Category Apps -Check $check -Severity OK -Value ((Get-Text 'Value.OutlookAuth.On') + $suffix)
}

function New-OutlookAuthBrokerFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox)

    $check  = Get-Text 'Check.OutlookAuth.Broker'
    $suffix = Get-OutlookAuthUnknownSuffix $Mailbox

    $failure  = Get-DataProperty $Data 'PackagesError'
    $packages = Get-DataCollection $Data 'Packages'
    if ($failure -or -not $packages.Count) {
        $value = Get-Text 'Value.Shared.NotAvailable'
        if ($failure) { $value = (Get-Text 'Value.OutlookAuth.PackagesUnreadable') -f $failure }
        return New-Finding -Category Apps -Check $check -Severity INFO -Value $value `
            -Hint (Get-Text 'Hint.OutlookAuth.BrokerUnknown')
    }

    # A package with any status but Ok (Modified, Tampered, NeedsRemediation, ...) is one
    # Windows itself says is not whole.
    $wrong = @(foreach ($p in $packages) {
        $name = "$(Get-DataProperty $p 'Name')"
        if (-not (Get-DataProperty $p 'Present')) { (Get-Text 'Value.OutlookAuth.PackageMissing') -f $name; continue }
        $status = "$(Get-DataProperty $p 'Status')"
        if ($status -ne 'Ok') { (Get-Text 'Value.OutlookAuth.PackageStatus') -f $name, (Get-OutlookAuthPackageStatusText -Status $status) }
    })

    # A repair Windows had to start on the broker's package is the "broken package" of the
    # research, check 4: FAIL on its own.
    $events = Get-DataCollection $Data 'BrokerEvents'
    if ($events.Count) {
        $top = $events | Sort-Object { - (ConvertTo-Number (Get-DataProperty $_ 'Count')) } | Select-Object -First 1
        $wrong += (Get-Text 'Value.OutlookAuth.BrokerEvents') -f ($events | Measure-Object Count -Sum).Sum,
            (Get-DataProperty $Data 'Days'), (Format-OutlookAuthCode -Code (Get-DataProperty $top 'Code'))
    }

    if ($wrong.Count) {
        return New-Finding -Category Apps -Check $check -Severity FAIL `
            -Value (($wrong -join '; ') + $suffix) -Hint (Get-Text 'Hint.OutlookAuth.BrokerBroken')
    }

    $present = @($packages | ForEach-Object { '{0} {1}' -f $_.Name, $_.Version }) -join ', '
    if ((Get-DataCollection $Data 'UnreadableLogs') -contains 'Application') {
        # The packages are there, but whether Windows has been repairing them is unread -
        # which is not the same as it not having done so.
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value (((Get-Text 'Value.OutlookAuth.BrokerLogUnread') -f $present) + $suffix) `
            -Hint (Get-Text 'Hint.OutlookAuth.ApplicationLogUnread')
    }
    New-Finding -Category Apps -Check $check -Severity OK -Value (((Get-Text 'Value.OutlookAuth.BrokerOk') -f $present) + $suffix)
}

function New-OutlookAuthAadFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox, [hashtable]$Parameters)

    # Unsourced: the research grades any 1081/1084/1088 as WARN and calls the number a
    # guess to tune once Runs have shown what an ordinary week looks like.
    $warnAbove = Get-Parameter $Parameters 'AadErrorWarnAbove' 0

    $check  = Get-Text 'Check.OutlookAuth.AadErrors'
    $suffix = Get-OutlookAuthUnknownSuffix $Mailbox
    $days   = Get-DataProperty $Data 'Days'

    if ((Get-DataCollection $Data 'UnreadableLogs') -contains $script:OutlookAuthAadLog) {
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value (Get-Text 'Value.OutlookAuth.AadUnread') -Hint (Get-Text 'Hint.OutlookAuth.AadUnread')
    }

    # Outlook's own and those of no named application here; every other program's in a
    # Finding of their own, further down.
    $all     = @((Get-DataCollection $Data 'AadEvents') | ForEach-Object { $_ })
    $events  = @($all | Where-Object { Test-OutlookAuthOwnEvent -Event $_ })
    $others  = @($all | Where-Object { -not (Test-OutlookAuthOwnEvent -Event $_) })
    $graded  = @($events | Where-Object { $script:OutlookAuthAadGradedIds -contains [int](Get-DataProperty $_ 'Id') })
    $routine = @($events | Where-Object { $script:OutlookAuthAadGradedIds -notcontains [int](Get-DataProperty $_ 'Id') })

    # Each code with what it means behind it: the HRESULT, the OAuth word, the AADSTS number.
    $describe = {
        param($Rows)
        @($Rows | Sort-Object { - (ConvertTo-Number (Get-DataProperty $_ 'Count')) } | Select-Object -First 3 |
            ForEach-Object { (Get-Text 'Value.OutlookAuth.Code') -f (Format-OutlookAuthCode -Code $_.Code), $_.Id, $_.Count }) -join '; '
    }

    $count = 0
    if ($graded.Count) { $count = ($graded | Measure-Object Count -Sum).Sum }
    $value = (Get-Text 'Value.OutlookAuth.AadNone') -f $days
    if ($count) { $value = (Get-Text 'Value.OutlookAuth.AadErrors') -f $count, $days, (& $describe $graded) }
    if ($routine.Count) {
        $value += (Get-Text 'Value.OutlookAuth.AadRoutine') -f ($routine | Measure-Object Count -Sum).Sum, (& $describe $routine)
    }

    # Whose they are, where the events say: Office's, or no named application's.
    $named = @($events | Where-Object { "$(Get-DataProperty $_ 'ClientId')".Trim() })
    if ($named.Count) {
        $byApp = @($events | Group-Object { Get-MicrosoftAppText -ClientId "$(Get-DataProperty $_ 'ClientId')" } |
            Sort-Object @{ Expression = { ($_.Group | Measure-Object Count -Sum).Sum }; Descending = $true }, Name |
            ForEach-Object { (Get-Text 'Value.OutlookAuth.AppCount') -f $_.Name, ($_.Group | Measure-Object Count -Sum).Sum })
        $value += ' | ' + ((Get-Text 'Value.OutlookAuth.ByApp') -f ($byApp -join '; '))
    }
    if ($others.Count) { $value += ' | ' + ((Get-Text 'Value.OutlookAuth.OthersElsewhere') -f (Get-Text 'Check.OutlookAuth.AadOtherApps')) }

    New-Finding -Category Apps -Check $check -Severity (Get-Severity $count $warnAbove ([double]::MaxValue)) `
        -Value ($value + $suffix) `
        -Meaning (Get-Text 'Meaning.OutlookAuth.AadErrors') -Hint (Get-Text 'Hint.OutlookAuth.AadErrors')

    if (-not $others.Count) { return }

    # The other programs: each by name with how many, and its most frequent code with
    # what it means. Information whatever the number: they are not what this Check is
    # about, and a Technician asked about Outlook is not to chase the browser's errors.
    $apps = @($others | Group-Object { Get-MicrosoftAppText -ClientId "$(Get-DataProperty $_ 'ClientId')" } |
        Sort-Object @{ Expression = { ($_.Group | Measure-Object Count -Sum).Sum }; Descending = $true }, Name)
    $total = ($others | Measure-Object Count -Sum).Sum
    $lines = @((Get-Text 'Value.OutlookAuth.OtherAppsTotal') -f $total, $days, $apps.Count)
    foreach ($app in $apps) {
        $sum = ($app.Group | Measure-Object Count -Sum).Sum
        $top = @($app.Group | Sort-Object @{ Expression = { ConvertTo-Number (Get-DataProperty $_ 'Count') }; Descending = $true }, Id, Code | Select-Object -First 1)[0]
        $lines += (Get-Text 'Value.OutlookAuth.AppErrors') -f $app.Name, $sum, (Format-OutlookAuthCode -Code "$($top.Code)")
    }
    New-Finding -Category Apps -Check (Get-Text 'Check.OutlookAuth.AadOtherApps') -Severity INFO -Value ($lines -join ' | ') `
        -Meaning (Get-Text 'Meaning.OutlookAuth.AadOtherApps') -Hint (Get-Text 'Hint.OutlookAuth.AadOtherApps')
}

function New-OutlookAuthMapiFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox)

    $check   = Get-Text 'Check.OutlookAuth.MapiHttp'
    $setting = Get-OutlookAuthSetting -Data $Data -Group 'Exchange' -Name 'MapiHttpDisabled'

    if (Test-OutlookAuthSettingIs $setting 1) {
        # FAIL for Exchange Online, which has not spoken RPC over HTTP since 2017; WARN on
        # premises, where it still works but is a testing switch (research, check 4).
        # Unknown is graded as Exchange Online, like the rest of this Check.
        $severity = 'FAIL'
        if ($Mailbox.Location -eq 'OnPremises') { $severity = 'WARN' }
        return New-Finding -Category Apps -Check $check -Severity $severity `
            -Value ((Format-OutlookAuthSetting $setting) + (Get-OutlookAuthUnknownSuffix $Mailbox)) `
            -Meaning (Get-Text 'Meaning.OutlookAuth.MapiOff') `
            -Hint (Get-Text 'Hint.OutlookAuth.MapiOff')
    }
    $unread = New-OutlookAuthUnreadFinding -Check $check -Setting @($setting) -Suffix (Get-OutlookAuthUnknownSuffix $Mailbox)
    if ($unread) { return $unread }
    New-Finding -Category Apps -Check $check -Severity OK -Value (Get-Text 'Value.OutlookAuth.On')
}

function New-OutlookAuthTlsFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data)

    $check = Get-Text 'Check.OutlookAuth.Tls12'
    if (-not (Get-DataCollection $Data 'Settings').Count) {
        return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.OutlookAuth.NotRead')
    }

    $enabled  = Get-OutlookAuthSetting -Data $Data -Group 'Schannel' -Name 'Enabled'
    $disabled = Get-OutlookAuthSetting -Data $Data -Group 'Schannel' -Name 'DisabledByDefault'

    if (Test-OutlookAuthSettingIs $enabled 0) {
        return New-Finding -Category Apps -Check $check -Severity FAIL `
            -Value (Format-OutlookAuthSetting $enabled) `
            -Meaning (Get-Text 'Meaning.OutlookAuth.Tls12Off') -Hint (Get-Text 'Hint.OutlookAuth.Tls12Off')
    }
    # Inference, not graded by the research: with DisabledByDefault=1 a program gets TLS
    # 1.2 only when it asks for it by name, so whatever does not is left on older versions
    # Microsoft 365 refuses. Worth a WARN, not a FAIL.
    if (Test-OutlookAuthSettingIs $disabled 1) {
        return New-Finding -Category Apps -Check $check -Severity WARN `
            -Value (Format-OutlookAuthSetting $disabled) `
            -Meaning (Get-Text 'Meaning.OutlookAuth.Tls12NotDefault') -Hint (Get-Text 'Hint.OutlookAuth.Tls12NotDefault')
    }
    $unread = New-OutlookAuthUnreadFinding -Check $check -Setting @($enabled, $disabled)
    if ($unread) { return $unread }
    New-Finding -Category Apps -Check $check -Severity OK -Value (Get-Text 'Value.OutlookAuth.Tls12Default')
}

function New-OutlookAuthNtlmFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox, [hashtable]$Parameters)

    # Microsoft's figure: with Extended Protection on, LmCompatibilityLevel "must be set at
    # least to a value of 3" (Exchange Extended Protection, research check 9).
    $minimum = Get-Parameter $Parameters 'LmCompatibilityMinimum' 3

    $check   = Get-Text 'Check.OutlookAuth.Ntlm'
    $suffix  = Get-OutlookAuthUnknownSuffix $Mailbox
    $setting = Get-OutlookAuthSetting -Data $Data -Group 'Lsa' -Name 'LmCompatibilityLevel'

    # No row at all is a value never looked for; a row without a value is Windows' default.
    if (-not $setting) {
        return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.OutlookAuth.NotRead')
    }
    # A row whose key could not be read has no Value either, and is not Windows' default.
    $unread = New-OutlookAuthUnreadFinding -Check $check -Setting @($setting) -Suffix $suffix
    if ($unread) { return $unread }
    if ($null -eq (Get-DataProperty $setting 'Value')) {
        $level = $script:OutlookAuthLmDefault
        $shown = (Get-Text 'Value.OutlookAuth.LmAbsent') -f $script:OutlookAuthLmDefault, (Get-OutlookAuthLmLevelText -Level $script:OutlookAuthLmDefault)
    }
    else {
        $level = ConvertTo-Number (Get-DataProperty $setting 'Value')
        $shown = Format-OutlookAuthSetting $setting
    }
    if ($null -eq $level) {
        return New-Finding -Category Apps -Check $check -Severity INFO -Value ($shown + $suffix) `
            -Hint (Get-Text 'Hint.OutlookAuth.LmUnknown')
    }

    if ($level -lt $minimum) {
        # FAIL where the research grades it, on premises. Unknown is WARN: the setting only
        # hurts against an Exchange server, and nobody knows there is one (Inference).
        $severity = 'WARN'
        if ($Mailbox.Location -eq 'OnPremises') { $severity = 'FAIL' }
        return New-Finding -Category Apps -Check $check -Severity $severity `
            -Value (((Get-Text 'Value.OutlookAuth.LmTooLow') -f $shown, $minimum) + $suffix) `
            -Meaning (Get-Text 'Meaning.OutlookAuth.LmTooLow') `
            -Hint (Get-Text 'Hint.OutlookAuth.LmTooLow')
    }
    New-Finding -Category Apps -Check $check -Severity OK -Value ($shown + $suffix)
}

function New-OutlookAuthSavedCredentialFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)]$Mailbox)

    $check  = Get-Text 'Check.OutlookAuth.SavedCredentials'
    $suffix = Get-OutlookAuthUnknownSuffix $Mailbox

    $failure = Get-DataProperty $Data 'SavedCredentialsError'
    if ($failure) {
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value ((Get-Text 'Value.OutlookAuth.CredentialsUnreadable') -f $failure) `
            -Hint (Get-Text 'Hint.OutlookAuth.CredentialsUnread')
    }
    if ($null -eq (Get-DataProperty $Data 'SavedCredentialTotal')) {
        return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.OutlookAuth.CredentialsUnread')
    }

    # Presence is INFO, not WARN: the entries hurt only when Outlook keeps prompting, which
    # a Run cannot see (research, check 9, Inference). The Hint carries the condition.
    $names = Get-DataCollection $Data 'SavedCredentials'
    if ($names.Count) {
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value (((Get-Text 'Value.OutlookAuth.SspiFound') -f $names.Count, ($names -join ', ')) + $suffix) `
            -Hint (Get-Text 'Hint.OutlookAuth.SspiStale')
    }
    New-Finding -Category Apps -Check $check -Severity OK -Value ((Get-Text 'Value.OutlookAuth.SspiNone') + $suffix)
}

function Get-OutlookAuthSourceText {
    <#
    .SYNOPSIS
        Where a row of the settings Section is from, in German. A section of dsregcmd
        stays in dsregcmd's own words: that is where the Technician finds it again. Pure.
    #>

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

    switch ("$Source") {
        'Policy'      { return Get-Text 'Value.OutlookAuth.Source.Policy' }
        'Preference'  { return Get-Text 'Value.OutlookAuth.Source.Preference' }
        'Machine'     { return Get-Text 'Value.OutlookAuth.Source.Machine' }
        'CurrentUser' { return Get-Text 'Value.OutlookAuth.Source.CurrentUser' }
        'Error'       { return Get-Text 'Value.OutlookAuth.NotReadable' }
    }
    "$Source"
}

function New-OutlookAuthSettingRow {
    <#
    .SYNOPSIS
        One row of the settings Section, under the headings the Report shows. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Item, [AllowNull()]$Value, [AllowNull()][AllowEmptyString()][string]$Source)

    $row = [ordered]@{}
    $row[(Get-Text 'Column.OutlookAuth.Item')]   = $Item
    $row[(Get-Text 'Column.OutlookAuth.Value')]  = $Value
    $row[(Get-Text 'Column.OutlookAuth.Source')] = Get-OutlookAuthSourceText -Source $Source
    [pscustomobject]$row
}

function New-OutlookAuthEventRow {
    <#
    .SYNOPSIS
        One row of the events Section, under the headings the Report shows. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Log, [AllowNull()]$EventId, [AllowNull()]$Code, [AllowNull()]$Count, [AllowNull()]$Last, [AllowNull()]$App)

    $row = [ordered]@{}
    $row[(Get-Text 'Column.OutlookAuth.App')]   = $App
    $row[(Get-Text 'Column.OutlookAuth.Log')]   = $Log
    $row[(Get-Text 'Column.OutlookAuth.Event')] = $EventId
    $row[(Get-Text 'Column.OutlookAuth.Code')]  = $Code
    $row[(Get-Text 'Column.OutlookAuth.Count')] = $Count
    $row[(Get-Text 'Column.OutlookAuth.Last')]  = $Last
    [pscustomobject]$row
}

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

    # No value and no code is shown bare here: what a registry value does, what a package
    # status and an error code mean, stands beside it. The names of the registry values,
    # of the packages and of dsregcmd's keys stay as they are: they are what is searched for.
    $settings = Get-DataCollection $Data 'Settings'
    $packages = Get-DataCollection $Data 'Packages'
    $state    = Get-DataProperty $Data 'DeviceState'
    $device   = Get-DataCollection $state 'Values'
    if ($settings.Count -or $packages.Count -or $device.Count -or (Get-DataProperty $state 'Error')) {
        New-Section -Title (Get-Text 'Title.OutlookAuth.Settings') -Help (Get-Text 'Help.OutlookAuth.Settings') -Row @(
            foreach ($s in $settings) {
                $item    = '{0}\{1}' -f $s.Group, $s.Name
                $failure = Get-DataProperty $s 'Error'
                if ($failure) {
                    # Where it failed and why, as the other unread rows below say it.
                    New-OutlookAuthSettingRow -Item $item -Value ('{0}: {1}' -f (Get-DataProperty $s 'Path'), $failure) -Source 'Error'
                    continue
                }
                if ($null -eq (Get-DataProperty $s 'Value')) {
                    $absent = Get-Text 'Value.OutlookAuth.NotSet'
                    if ("$($s.Group)" -eq 'Lsa') {
                        $absent = (Get-Text 'Value.OutlookAuth.LmAbsent') -f $script:OutlookAuthLmDefault, (Get-OutlookAuthLmLevelText -Level $script:OutlookAuthLmDefault)
                    }
                    New-OutlookAuthSettingRow -Item $item -Value $absent -Source $s.Source
                    continue
                }
                New-OutlookAuthSettingRow -Item $item -Value (Format-OutlookAuthSettingValue -Setting $s) -Source $s.Source
            }
            foreach ($p in $packages) {
                $shown = Get-Text 'Value.OutlookAuth.Package.Missing'
                if ($p.Present) { $shown = (Get-Text 'Value.OutlookAuth.Package.Version') -f $p.Version, (Get-OutlookAuthPackageStatusText -Status $p.Status) }
                New-OutlookAuthSettingRow -Item ('AppX\' + $p.Name) -Value $shown -Source 'CurrentUser'
            }
            if (Get-DataProperty $Data 'PackagesError') {
                New-OutlookAuthSettingRow -Item 'AppX' -Value (Get-DataProperty $Data 'PackagesError') -Source 'Error'
            }
            foreach ($name in (Get-DataCollection $Data 'SavedCredentials')) {
                New-OutlookAuthSettingRow -Item (Get-Text 'Value.OutlookAuth.Item.Credentials') -Value $name -Source 'CurrentUser'
            }
            if (Get-DataProperty $Data 'SavedCredentialsError') {
                New-OutlookAuthSettingRow -Item (Get-Text 'Value.OutlookAuth.Item.Credentials') -Value (Get-DataProperty $Data 'SavedCredentialsError') -Source 'Error'
            }
            elseif ($null -ne (Get-DataProperty $Data 'SavedCredentialTotal')) {
                New-OutlookAuthSettingRow -Item (Get-Text 'Value.OutlookAuth.Item.CredentialTotal') -Value (Get-DataProperty $Data 'SavedCredentialTotal') -Source 'CurrentUser'
            }
            # dsregcmd's own words, with the section it printed them in as the source: the
            # user's half (User State, SSO State) is told apart from the device's. The
            # codes of the last failed attempt are the exception: they get their meaning.
            foreach ($d in $device) {
                $shown = $d.Value
                if ($script:OutlookAuthDsregPrtDiagnostics -contains "$($d.Key)") { $shown = Format-OutlookAuthCode -Code "$($d.Value)" }
                New-OutlookAuthSettingRow -Item ('dsregcmd\' + $d.Key) -Value $shown -Source $d.Section
            }
            if (Get-DataProperty $state 'Error') {
                New-OutlookAuthSettingRow -Item 'dsregcmd' -Value (Get-DataProperty $state 'Error') -Source 'Error'
            }
            # The folders the research says security software must not scan, so a
            # Technician following the broker Hint has them at hand.
            foreach ($folder in '%LOCALAPPDATA%\Packages\Microsoft.AAD.BrokerPlugin_cw5n1h2txyewy', '%LOCALAPPDATA%\Microsoft\TokenBroker',
                                '%LOCALAPPDATA%\Microsoft\OneAuth', '%LOCALAPPDATA%\Microsoft\IdentityCache') {
                New-OutlookAuthSettingRow -Item (Get-Text 'Value.OutlookAuth.Item.ExcludeFromScanning') -Value $folder -Source ''
            }
        )
    }

    # The id with what the event is, the code with what it means. The AppModel-State ids
    # were not told apart one by one: all of them are Windows reporting on the state of
    # the package, which is how the Judge counts them.
    $rows = @(
        foreach ($e in (Get-DataCollection $Data 'AadEvents')) {
            New-OutlookAuthEventRow -Log $script:OutlookAuthAadLog -App (Get-MicrosoftAppText -ClientId "$(Get-DataProperty $e 'ClientId')") `
                -EventId (Format-CodeWithMeaning -Code "$($e.Id)" -Meaning (Get-OutlookAuthEventText -Id $e.Id)) `
                -Code (Format-OutlookAuthCode -Code "$($e.Code)") -Count $e.Count -Last $e.Last
        }
        foreach ($e in (Get-DataCollection $Data 'BrokerEvents')) {
            New-OutlookAuthEventRow -Log ('Application (' + $script:OutlookAuthAppModelProvider + ')') -App (Get-Text 'Value.Code.App.Broker') `
                -EventId (Format-CodeWithMeaning -Code "$($e.Id)" -Meaning (Get-Text 'Value.OutlookAuth.Event.AppModel')) `
                -Code (Format-OutlookAuthCode -Code "$($e.Code)") -Count $e.Count -Last $e.Last
        }
        foreach ($log in (Get-DataCollection $Data 'UnreadableLogs')) {
            New-OutlookAuthEventRow -Log $log -EventId $null -Code (Get-Text 'Value.OutlookAuth.NotReadable') -Count $null -Last $null
        }
    )
    if ($rows.Count) { New-Section -Title (Get-Text 'Title.OutlookAuth.Events') -Help (Get-Text 'Help.OutlookAuth.Events') -Row $rows }
}