Private/Kinds/Autodiscover.ps1

# The Autodiscover Kind: whether Outlook can find its mailbox.
#
# Every other Outlook service - MAPI, free/busy, the offline address book, shared
# mailboxes - is found through Autodiscover, so a domain whose Autodiscover answers wrongly
# is an Outlook that connects to the wrong place, or nowhere. This Kind asks the questions
# Outlook asks (Microsoft, "Outlook 2016 implementation of Autodiscover"), per mail domain,
# without anyone's credentials:
#
# - DNS: autodiscover.<domain> (CNAME chain and A), _autodiscover._tcp.<domain> (SRV) and
# <domain> itself
# - https://<domain>/autodiscover/autodiscover.xml and
# https://autodiscover.<domain>/autodiscover/autodiscover.xml, each a POST with
# Content-Length: 0 and an empty Bearer header, probed by the HttpsEndpoint Kind's
# Invoke-HttpsEndpointProbe (status, challenges, certificate, timings; no redirect
# followed, nothing sent that identifies anyone)
# - http://autodiscover.<domain>/autodiscover/autodiscover.xml, redirect not followed
# - Exchange Online's Autodiscover V2 answer for an address in the domain, which says
# whether Exchange Online hosts it
#
# Observed on 2026-09-25 from this development machine (domain-joined, Microsoft 365 with
# an Exchange hybrid), and different in three ways from what the research recorded the
# day before:
#
# - Autodiscover V2 answers 200 {"Protocol":"Autodiscoverv1","Url":"https://outlook.
# office365.com/autodiscover/autodiscover.xml"} for a mailbox in Exchange Online, and
# 302 to https://autodiscover.<domain>/autodiscover/autodiscover.json/...&RedirectCount=1
# for a domain it does not host. But it also answers 302 for a hosted domain when the
# address is not a mailbox: to outlook.office365.com again, with the address rewritten
# into the tenant's onmicrosoft.com domain (or, for microsoft.com, a subdomain). So a
# redirect is judged by where it points, not by being a redirect.
# - In a hybrid, the answer is per mailbox, not per domain: the user's real address
# answered 200 while a made-up address in the same domain answered 302 to the on-prem
# server. So the real address is used whenever the machine names one in the domain,
# and a made-up one ("probe@<domain>") only for a domain named in the Definition that
# no address on the machine belongs to. The address is sent only to
# outlook.office365.com, which is where Outlook sends it too.
# - autodiscover.outlook.com, the CNAME target Microsoft requires for Exchange Online,
# does not complete a TLS handshake at all (curl and SslStream alike); Exchange Online
# domains are served by the plain-HTTP redirect to autodiscover-s.outlook.com and by
# Outlook's direct Office 365 step. So a failed HTTPS probe of an autodiscover.<domain>
# that CNAMEs into Microsoft is normal and not judged.
#
# Certificates. The ones that matter here are the Customer's: the root domain's and an
# on-prem autodiscover.<domain>'s, which are graded at HealthChecker's 60/30 days. A
# certificate on a name that CNAMEs into Microsoft is Microsoft's, short-lived by design
# (100-199 days, renewed well ahead), and is never graded for expiry - nobody at the
# Customer could renew it.
#
# Where the domains come from: the Domains parameter if set; else the user's UPN
# (whoami /upn, which works unelevated whether the machine is domain-joined or
# Entra-joined), the directory's mail attribute when domain-joined, and the Exchange
# accounts of the user's Outlook profiles. A UPN is only a stand-in for a mail address, so
# its domain is used only when neither of the others names one - a UPN suffix that is not
# a mail domain would otherwise be reported as a broken mail domain.
#
# For later Kinds (-Observed): $Observed['Autodiscover'] is this Gatherer's data.
# .Domains[] one entry per domain probed
# .Domain the mail domain, lower case
# .Sources where it came from: Parameter, Upn, DirectoryMail,
# OutlookProfile
# .AddressIsUser true when .Address is the user's own, false for probe@<domain>
# .ExchangeOnline the V2 probe record (StatusCode, Location, Body, Error, ...)
# .V2Url the Url out of the V2 answer's body, or $null
# .ExchangeOnlinePin this Check's ExchangeOnline parameter as given (auto, yes,
# no; 'auto' when unset), so a later Kind honours a Customer
# pinned here
# Read the mailbox location through ConvertTo-AutodiscoverMailboxLocation -Domain <entry>
# -Parameters @{ ExchangeOnline = .ExchangeOnlinePin }: it returns ExchangeOnline,
# OnPremises or Unknown and applies the pin, so no Kind classifies the answer a second way.
# .Registry[] both AutoDiscover keys (research check 2): .Source Policy or
# Preference, .Path, .Exists, .Values[] { Name, Value, Type },
# .Error. Read the values Outlook obeys through
# Get-AutodiscoverOverride: the policy value wins.
# .Scp the Autodiscover SCP search, $null when the machine is known
# not to be domain-joined: .ConfigurationNamingContext,
# .Entries[] { Name, DistinguishedName, Keywords[],
# ServiceBindingInformation[], WhenChanged }, .Error
# .CacheFiles[] Outlook's cached Autodiscover answers: { Folder, Name,
# LastWriteTime, Length }
#
# What bends the lookup (research check 2). Microsoft names three leftovers that send
# Outlook to the wrong place after a migration: registry values that skip or replace a step
# of the search (both keys, "Policy" and "Non-Policy"; a Group Policy "AutoDiscover" setting
# ticks all five Exclude boxes once enabled, KB 2612922), an SCP in Active Directory that
# still advertises on-prem Exchange (KB 3012603, and in a hybrid KB 3137323), and a cached
# answer. The overrides are judged against the mailbox location above: a value is only in
# the way when it removes the step that mailbox needs. The SCP is read from the
# Configuration partition, which every authenticated domain user can read (standard AD
# behaviour, not re-sourced). No Microsoft threshold exists for a cached answer's age, so
# the files are shown, not judged.
#
# Observed on 2026-09-25 on this hybrid machine: one SCP, keywords "Site=<site>" and
# 77378F46-..., its serviceBindingInformation the on-prem https URL - so the Autodiscover
# URL SCP carries the 77378F46 keyword, and a 67661d7F one is the pointer to another forest
# (an LDAP URL). Only http(s) URLs are compared; pointers are shown. The preference key
# existed without values; the policy key did not exist; no cached answer file existed.

# Host names that are Microsoft's own Exchange Online front doors. A name ending in one of
# these is Microsoft's, not the Customer's.
$script:AutodiscoverMicrosoftSuffixes = @('outlook.com', 'office365.com', 'office.com', 'cloud.microsoft')

# How much of the V2 answer's body is kept: it is under 100 bytes when it is JSON at all.
$script:AutodiscoverBodyBytes = 4096

# The two keys Outlook reads its Autodiscover overrides from (Microsoft, "Outlook 2016
# implementation of Autodiscover"). Policy first: where both carry a value, the policy's is
# the one Outlook obeys.
$script:AutodiscoverRegistryKeys = @(
    [pscustomobject]@{ Source = 'Policy';     Path = 'HKCU\Software\Policies\Microsoft\Office\16.0\Outlook\AutoDiscover' }
    [pscustomobject]@{ Source = 'Preference'; Path = 'HKCU\Software\Microsoft\Office\16.0\Outlook\AutoDiscover' }
)

# Which Exclude value skips which step of the search, by the step's evidence in the data.
# Microsoft's archived KB "Unexpected Autodiscover behavior" names the values.
$script:AutodiscoverExcludeStep = [ordered]@{
    ExcludeScpLookup               = 'Scp'
    ExcludeHttpsRootDomain         = 'Root'
    ExcludeHttpsAutoDiscoverDomain = 'Autodiscover'
    ExcludeHttpRedirect            = 'HttpRedirect'
    ExcludeSrvRecord               = 'Srv'
}

# The Autodiscover SCP keywords (Microsoft, "Autodiscover service" and "Find Autodiscover
# endpoints by using SCP lookup"): 77378F46 marks an SCP carrying an Autodiscover URL,
# 67661d7F a pointer to another forest's SCPs.
$script:AutodiscoverScpFilter = '(&(objectClass=serviceConnectionPoint)(|(keywords=67661d7F-8FC4-4fa7-BFAC-E1D7794C1F68)(keywords=77378F46-2C66-4aa9-A6A6-3E7A48B19596)))'

function ConvertTo-AutodiscoverDomainName {
    <#
    .SYNOPSIS
        A mail domain as a lower-case DNS name, or $null when it is not one. Pure.
    .DESCRIPTION
        The name goes into URLs and DNS queries, so anything but letters, digits, hyphens
        and dots is refused rather than escaped: a Definition must not be able to write a
        path or a query into a request.
    #>

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

    $name = "$Value".Trim().TrimEnd('.').ToLowerInvariant()
    if ($name -match '@') { $name = $name.Substring($name.LastIndexOf('@') + 1) }
    if ($name.Length -gt 253) { return $null }
    if ($name -notmatch '^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$') { return $null }
    $name
}

function Get-AutodiscoverDomainEntry {
    <#
    .SYNOPSIS
        The entries of the Domains parameter, as a Definition may write them. Pure.
    .DESCRIPTION
        A list, or one string with commas or semicolons, because a hand-edited Definition
        writes both.
    #>

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

    @($Value) | ForEach-Object { "$_" -split '[,;\s]+' } | Where-Object { $_ }
}

function Select-AutodiscoverDomain {
    <#
    .SYNOPSIS
        Which domains to probe, with where each came from and which address to ask
        Exchange Online about. Pure.
    .DESCRIPTION
        Candidates are the addresses the machine named ({ Address, Source }). Pinned are
        the domains the Definition named; when there are any, they are the whole list.
        The address for a domain is the user's own when one exists (hybrid answers per
        mailbox, see the file header), preferring the directory over the profile over the
        UPN, else probe@<domain>.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Candidate = @(),
        [AllowNull()][AllowEmptyCollection()][string[]]$Pinned = @()
    )

    $rank = @{ DirectoryMail = 0; OutlookProfile = 1; Upn = 2 }
    $known = @(foreach ($c in @($Candidate | Where-Object { $_ })) {
        $address = "$(Get-DataProperty $c 'Address')".Trim()
        $domain  = ConvertTo-AutodiscoverDomainName $address
        if (-not $domain -or $address -notmatch '^[^@\s/?#%]+@[^@]+$') { continue }
        $source = "$(Get-DataProperty $c 'Source')"
        $order  = 9
        if ($rank.ContainsKey($source)) { $order = $rank[$source] }
        [pscustomobject]@{ Address = $address; Domain = $domain; Source = $source; Rank = $order }
    })

    $domains = New-Object System.Collections.Generic.List[string]
    $pinnedNames = @(@($Pinned) | ForEach-Object { ConvertTo-AutodiscoverDomainName $_ } | Where-Object { $_ })
    if ($pinnedNames.Count) {
        foreach ($d in $pinnedNames) { if (-not $domains.Contains($d)) { $domains.Add($d) } }
    }
    else {
        $mail = @($known | Where-Object { $_.Source -ne 'Upn' })
        if (-not $mail.Count) { $mail = $known }
        foreach ($k in ($mail | Sort-Object Rank)) { if (-not $domains.Contains($k.Domain)) { $domains.Add($k.Domain) } }
    }

    foreach ($d in $domains) {
        $mine = @($known | Where-Object { $_.Domain -eq $d } | Sort-Object Rank)
        $sources = @()
        if ($pinnedNames.Count) { $sources += 'Parameter' }
        $sources += @($mine | ForEach-Object { $_.Source })
        $address = 'probe@' + $d
        if ($mine.Count) { $address = $mine[0].Address }
        [pscustomobject]@{
            Domain        = $d
            Sources       = @($sources | Select-Object -Unique)
            Address       = $address
            AddressIsUser = [bool]$mine.Count
        }
    }
}

function ConvertFrom-AutodiscoverJson {
    <#
    .SYNOPSIS
        Protocol and Url out of an Autodiscover V2 answer's body, or $null. Pure.
    .DESCRIPTION
        The body is kept as it arrived, so a chunked answer still carries its chunk sizes;
        the JSON object is taken from the first brace to the last.
    #>

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

    if (-not $Text) { return $null }
    $start = $Text.IndexOf('{')
    $end   = $Text.LastIndexOf('}')
    if ($start -lt 0 -or $end -le $start) { return $null }
    try { $json = $Text.Substring($start, $end - $start + 1) | ConvertFrom-Json -ErrorAction Stop }
    catch { return $null }
    $url = "$(Get-DataProperty $json 'Url')"
    if (-not $url) { return $null }
    [pscustomobject]@{ Protocol = "$(Get-DataProperty $json 'Protocol')"; Url = $url }
}

function Test-AutodiscoverMicrosoftHost {
    <#
    .SYNOPSIS
        Whether a host name (or a URL's host) is one of Exchange Online's own. Pure.
    #>

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

    $hostName = "$Name".Trim().TrimEnd('.').ToLowerInvariant()
    $uri = $null
    if ([uri]::TryCreate($hostName, [UriKind]::Absolute, [ref]$uri) -and $uri.Host) { $hostName = $uri.Host }
    if (-not $hostName) { return $false }
    foreach ($suffix in $script:AutodiscoverMicrosoftSuffixes) {
        if ($hostName -eq $suffix -or $hostName.EndsWith('.' + $suffix)) { return $true }
    }
    $false
}

function Get-AutodiscoverExchangeOnlineAnswer {
    <#
    .SYNOPSIS
        What Exchange Online's Autodiscover V2 said about a domain's address: Yes, No or
        Unknown. Pure.
    .DESCRIPTION
        200 with a Url on Exchange Online, or a redirect that stays on Exchange Online
        (the address rewritten within the tenant), is Yes. A redirect anywhere else - to
        autodiscover.<domain> - or a 200 whose Url points on-prem (a hybrid mailbox) is No.
        Everything else, a failed request above all, is Unknown. Observed 2026-09-25; see
        the file header.
    #>

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

    $probe  = Get-DataProperty $Domain 'ExchangeOnline'
    $status = ConvertTo-Number (Get-DataProperty $probe 'StatusCode')
    if (-not $probe -or (Get-DataProperty $probe 'Error') -or $null -eq $status) { return 'Unknown' }

    if ($status -eq 200) {
        $url = "$(Get-DataProperty $Domain 'V2Url')"
        if (-not $url) { return 'Unknown' }
        if (Test-AutodiscoverMicrosoftHost $url) { return 'Yes' }
        return 'No'
    }
    if ($status -ge 300 -and $status -lt 400) {
        $location = "$(Get-DataProperty $probe 'Location')"
        if (-not $location) { return 'Unknown' }
        if (Test-AutodiscoverMicrosoftHost $location) { return 'Yes' }
        return 'No'
    }
    'Unknown'
}

function ConvertTo-AutodiscoverMailboxLocation {
    <#
    .SYNOPSIS
        Where a domain's mailbox is, as the Judge and later Kinds take it: ExchangeOnline,
        OnPremises or Unknown. Pure.
    .DESCRIPTION
        The ExchangeOnline parameter (auto, yes, no) pins it for a Customer whose answer
        should not be taken from Microsoft - an on-prem or hybrid Customer who knows better.
        Anything but yes or no is auto.
    #>

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

    $pin = "$(Get-Parameter $Parameters 'ExchangeOnline' 'auto')".Trim().ToLowerInvariant()
    if ($pin -eq 'yes') { return 'ExchangeOnline' }
    if ($pin -eq 'no')  { return 'OnPremises' }
    switch (Get-AutodiscoverExchangeOnlineAnswer -Domain $Domain) {
        'Yes'   { return 'ExchangeOnline' }
        'No'    { return 'OnPremises' }
        default { return 'Unknown' }
    }
}

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

    $pinned = @(Get-AutodiscoverDomainEntry (Get-Parameter $Parameters 'Domains' @()))

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

    $candidates = New-Object System.Collections.Generic.List[psobject]
    $sourceErrors = New-Object System.Collections.Generic.List[psobject]

    try {
        $upn = @(& whoami.exe /upn 2>$null)
        if ($LASTEXITCODE -eq 0 -and "$($upn[0])" -match '@') {
            $candidates.Add([pscustomobject]@{ Address = "$($upn[0])".Trim(); Source = 'Upn' })
        }
    }
    catch { $sourceErrors.Add([pscustomobject]@{ Source = 'Upn'; Error = $_.Exception.Message }) }

    if ($isDomainJoined) {
        try {
            # By SID rather than by name: the SID is the one identity that cannot be
            # ambiguous across a forest.
            $searcher = New-Object System.DirectoryServices.DirectorySearcher
            $searcher.Filter = '(objectSid={0})' -f [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
            $searcher.ClientTimeout   = [timespan]::FromSeconds(10)
            $searcher.ServerTimeLimit = [timespan]::FromSeconds(10)
            $null = $searcher.PropertiesToLoad.Add('mail')
            $found = $searcher.FindOne()
            if ($found) {
                foreach ($mail in @($found.Properties['mail'])) {
                    if ($mail) { $candidates.Add([pscustomobject]@{ Address = "$mail"; Source = 'DirectoryMail' }) }
                }
            }
        }
        catch { $sourceErrors.Add([pscustomobject]@{ Source = 'DirectoryMail'; Error = $_.Exception.GetBaseException().Message }) }
    }

    try {
        foreach ($address in (Get-AutodiscoverProfileAddress)) {
            $candidates.Add([pscustomobject]@{ Address = $address; Source = 'OutlookProfile' })
        }
    }
    catch { $sourceErrors.Add([pscustomobject]@{ Source = 'OutlookProfile'; Error = $_.Exception.Message }) }

    $selected = @(Select-AutodiscoverDomain -Candidate $candidates -Pinned $pinned)

    $domains = @(foreach ($entry in $selected) {
        $d = $entry.Domain
        $v2 = Invoke-HttpsEndpointProbe -Target ([pscustomobject]@{
            Key = 'V2'; Method = 'GET'; BearerProbe = $false; ReadBodyBytes = $script:AutodiscoverBodyBytes
            Url = 'https://outlook.office365.com/autodiscover/autodiscover.json/v1.0/{0}?Protocol=Autodiscoverv1' -f
                  [uri]::EscapeDataString($entry.Address).Replace('%40', '@')
        })
        $parsed = ConvertFrom-AutodiscoverJson -Text $v2.Body

        [pscustomobject]@{
            Domain         = $d
            Sources        = $entry.Sources
            Address        = $entry.Address
            AddressIsUser  = $entry.AddressIsUser
            Dns            = [pscustomobject]@{
                Autodiscover = Resolve-AutodiscoverName -Name ('autodiscover.' + $d) -Type 'A'
                Srv          = Resolve-AutodiscoverName -Name ('_autodiscover._tcp.' + $d) -Type 'SRV'
                Root         = Resolve-AutodiscoverName -Name $d -Type 'A'
            }
            Root           = Invoke-HttpsEndpointProbe -Target ([pscustomobject]@{
                Key = 'Root'; Method = 'POST'; BearerProbe = $true
                Url = 'https://{0}/autodiscover/autodiscover.xml' -f $d })
            Autodiscover   = Invoke-HttpsEndpointProbe -Target ([pscustomobject]@{
                Key = 'Autodiscover'; Method = 'POST'; BearerProbe = $true
                Url = 'https://autodiscover.{0}/autodiscover/autodiscover.xml' -f $d })
            HttpRedirect   = Invoke-HttpsEndpointProbe -Target ([pscustomobject]@{
                Key = 'HttpRedirect'; Method = 'GET'; BearerProbe = $false
                Url = 'http://autodiscover.{0}/autodiscover/autodiscover.xml' -f $d })
            ExchangeOnline = $v2
            V2Url          = $(if ($parsed) { $parsed.Url } else { $null })
        }
    })

    [pscustomobject]@{
        PSTypeName     = 'Gutcheck.Data.Autodiscover'
        # What certificate lifetimes are counted from, so the Judge never asks the clock.
        GatheredAt     = Get-Date
        IsDomainJoined = $isDomainJoined
        Candidates     = @($candidates)
        SourceErrors   = @($sourceErrors)
        Domains        = $domains
        Registry       = @(Get-AutodiscoverRegistry)
        # Searched unless the machine is known not to be joined: when CIM would not say,
        # the search itself finds out, and its error is the evidence.
        Scp            = $(if ($isDomainJoined -ne $false) { Get-AutodiscoverScp } else { $null })
        CacheFiles     = @(Get-AutodiscoverCacheFile)
        # The pin as given, for later Kinds; ConvertTo-AutodiscoverMailboxLocation reads it.
        ExchangeOnlinePin = "$(Get-Parameter $Parameters 'ExchangeOnline' 'auto')"
    }
}

function Get-AutodiscoverRegistry {
    <#
    .SYNOPSIS
        Both AutoDiscover keys, every value as it is stored. Never throws.
    .DESCRIPTION
        Every value is kept, known or not: a domain-named string value is the local XML
        file PreferLocalXML points at, and a value this Kind does not know yet is still
        evidence for second level.
    #>

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

    foreach ($key in $script:AutodiscoverRegistryKeys) {
        $record = [ordered]@{ Source = $key.Source; Path = $key.Path; Exists = $false; Values = @(); Error = $null }
        try {
            $item = Get-Item -LiteralPath ('Registry::HKEY_CURRENT_USER' + $key.Path.Substring(4)) -ErrorAction Stop
            $record.Exists = $true
            $record.Values = @(foreach ($name in $item.GetValueNames()) {
                if (-not $name) { continue }
                $value = $item.GetValue($name, $null, 'DoNotExpandEnvironmentNames')
                if ($value -is [byte[]])   { $value = [BitConverter]::ToString($value) }
                if ($value -is [string[]]) { $value = $value -join '; ' }
                [pscustomobject]@{ Name = $name; Value = $value; Type = "$($item.GetValueKind($name))" }
            })
        }
        catch [System.Management.Automation.ItemNotFoundException] { }
        catch { $record.Error = $_.Exception.Message }
        [pscustomobject]$record
    }
}

function Get-AutodiscoverScp {
    <#
    .SYNOPSIS
        The Autodiscover SCPs in the forest's Configuration partition. Never throws.
    .DESCRIPTION
        The same search Outlook makes (research check 2): RootDSE names the partition, and
        the SCPs are found by keyword. Runs as the user; no elevation is needed to read it.
    #>

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

    $record = [ordered]@{ ConfigurationNamingContext = $null; Entries = @(); Error = $null }
    try {
        $rootDse = New-Object System.DirectoryServices.DirectoryEntry('LDAP://RootDSE')
        $configuration = "$($rootDse.Properties['configurationNamingContext'].Value)"
        if (-not $configuration) { throw 'RootDSE names no configurationNamingContext' }
        $record.ConfigurationNamingContext = $configuration

        $searcher = New-Object System.DirectoryServices.DirectorySearcher
        $searcher.SearchRoot      = New-Object System.DirectoryServices.DirectoryEntry('LDAP://' + $configuration.Replace('/', '\/'))
        $searcher.Filter          = $script:AutodiscoverScpFilter
        $searcher.PageSize        = 200
        $searcher.ClientTimeout   = [timespan]::FromSeconds(10)
        $searcher.ServerTimeLimit = [timespan]::FromSeconds(10)
        foreach ($p in 'cn', 'distinguishedName', 'keywords', 'serviceBindingInformation', 'whenChanged') {
            $null = $searcher.PropertiesToLoad.Add($p)
        }
        $found = $searcher.FindAll()
        try {
            $record.Entries = @(foreach ($r in $found) {
                # whenChanged is not replicated: each domain controller keeps its own, so
                # two Runs can show different dates for the same SCP (observed: 2022 and
                # 2024 a minute apart). It dates the SCP roughly, no more.
                $when = @($r.Properties['whenchanged'])
                [pscustomobject]@{
                    Name                      = "$(@($r.Properties['cn'])[0])"
                    DistinguishedName         = "$(@($r.Properties['distinguishedname'])[0])"
                    Keywords                  = @($r.Properties['keywords'] | ForEach-Object { "$_" })
                    ServiceBindingInformation = @($r.Properties['servicebindinginformation'] | ForEach-Object { "$_" })
                    WhenChanged               = $(if ($when.Count -and $when[0] -is [datetime]) { $when[0] } else { $null })
                }
            })
        }
        finally { $found.Dispose() }
    }
    catch { $record.Error = $_.Exception.GetBaseException().Message }
    [pscustomobject]$record
}

function Get-AutodiscoverCacheFile {
    <#
    .SYNOPSIS
        Outlook's cached Autodiscover answers: name, date and size, never the content.
    .DESCRIPTION
        The files Microsoft says to delete when resetting Autodiscover: *Autodiscover.xml
        in the Outlook folder, and the XML and JSON files in its 16 subfolder. Their names
        carry mail addresses, which is what makes them worth showing.
    #>

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

    if (-not $env:LOCALAPPDATA) { return }
    $outlook = Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook'
    foreach ($place in @(
            @{ Folder = 'Outlook';    Path = $outlook;                     Filter = '*Autodiscover.xml' }
            @{ Folder = 'Outlook\16'; Path = (Join-Path $outlook '16');    Filter = '*.xml' }
            @{ Folder = 'Outlook\16'; Path = (Join-Path $outlook '16');    Filter = '*.json' })) {
        if (-not (Test-Path -LiteralPath $place.Path)) { continue }
        foreach ($file in (Get-ChildItem -LiteralPath $place.Path -Filter $place.Filter -File -ErrorAction SilentlyContinue)) {
            [pscustomobject]@{ Folder = $place.Folder; Name = $file.Name; LastWriteTime = $file.LastWriteTime; Length = $file.Length }
        }
    }
}

function Get-AutodiscoverProfileAddress {
    <#
    .SYNOPSIS
        The addresses of the Exchange accounts in the user's Outlook profiles.
    .DESCRIPTION
        Exchange accounts only (Service Name MSEMS): an IMAP or POP account's domain has
        no Autodiscover to find, and probing it would report a working mailbox as broken.
        Values are strings on current builds and UTF-16 bytes on older ones.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

    $office = 'HKCU:\Software\Microsoft\Office'
    if (-not (Test-Path $office)) { return }
    foreach ($version in (Get-ChildItem $office -ErrorAction SilentlyContinue)) {
        $profiles = Join-Path $version.PSPath 'Outlook\Profiles'
        if (-not (Test-Path $profiles)) { continue }
        foreach ($outlookProfile in (Get-ChildItem $profiles -ErrorAction SilentlyContinue)) {
            $accounts = Join-Path $outlookProfile.PSPath '9375CFF0413111d3B88A00104B2A6676'
            if (-not (Test-Path $accounts)) { continue }
            foreach ($account in (Get-ChildItem $accounts -ErrorAction SilentlyContinue)) {
                $read = {
                    param($Name)
                    $value = $account.GetValue($Name)
                    if ($value -is [byte[]]) { return [Text.Encoding]::Unicode.GetString($value).TrimEnd([char]0) }
                    "$value"
                }
                if ((& $read 'Service Name') -ne 'MSEMS') { continue }
                $name = & $read 'Account Name'
                if ($name -match '@') { $name.Trim() }
            }
        }
    }
}

function Resolve-AutodiscoverName {
    <#
    .SYNOPSIS
        One DNS question and its raw answer. Never throws.
    .DESCRIPTION
        Resolve-DnsName where it exists, because only it gives the CNAME chain and SRV.
        The error is kept by its id (DNS_ERROR_RCODE_NAME_ERROR, ...) as well as its
        message, because the message is in the machine's language. Without
        Resolve-DnsName, an A question falls back to .NET, which gives addresses only.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Type)

    $record = [ordered]@{ Name = $Name; Type = $Type; Records = @(); ErrorId = $null; Error = $null }

    if (Get-Command -Name Resolve-DnsName -ErrorAction SilentlyContinue) {
        try {
            $answers = @(Resolve-DnsName -Name $Name -Type $Type -DnsOnly -QuickTimeout -ErrorAction Stop)
            $record.Records = @(foreach ($a in $answers) {
                if ("$($a.Section)" -and "$($a.Section)" -ne 'Answer') { continue }
                $data = switch ("$($a.Type)") {
                    'CNAME' { $a.NameHost }
                    'A'     { $a.IPAddress }
                    'AAAA'  { $a.IPAddress }
                    'SRV'   { '{0}:{1} priority {2} weight {3}' -f $a.NameTarget, $a.Port, $a.Priority, $a.Weight }
                    default { $null }
                }
                if ($null -eq $data) { continue }
                [pscustomobject]@{ Name = $a.Name; Type = "$($a.Type)"; Data = "$data" }
            })
        }
        catch {
            $record.ErrorId = ("$($_.FullyQualifiedErrorId)" -split ',')[0]
            $record.Error   = $_.Exception.Message
        }
    }
    elseif ($Type -eq 'A') {
        try {
            $record.Records = @([System.Net.Dns]::GetHostAddresses($Name) |
                Where-Object { $_.AddressFamily -eq 'InterNetwork' } |
                ForEach-Object { [pscustomobject]@{ Name = $Name; Type = 'A'; Data = "$_" } })
        }
        catch {
            $record.ErrorId = 'DotNetLookupFailed'
            $record.Error   = $_.Exception.GetBaseException().Message
        }
    }
    else {
        $record.ErrorId = 'Unsupported'
        $record.Error   = 'Resolve-DnsName is not available on this machine'
    }

    [pscustomobject]$record
}

function Test-AutodiscoverExchangeAnswer {
    <#
    .SYNOPSIS
        Whether a probe was answered the way Exchange answers an anonymous request: 401
        with a challenge Exchange offers. Pure.
    .DESCRIPTION
        Negotiate and NTLM on-prem, Bearer with hybrid modern authentication or in
        Exchange Online, Basic where it is still on. Microsoft: an answer without
        WWW-Authenticate means a device in front of Exchange responded.
    #>

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

    if (-not $Probe -or (Get-DataProperty $Probe 'Error')) { return $false }
    if ((ConvertTo-Number (Get-DataProperty $Probe 'StatusCode')) -ne 401) { return $false }
    $schemes = @((Get-DataCollection $Probe 'WwwAuthenticate') | ForEach-Object { ConvertFrom-WwwAuthenticate -Value $_ } |
        ForEach-Object { $_.Scheme })
    @($schemes | Where-Object { @('Negotiate', 'NTLM', 'Bearer', 'Basic') -contains $_ }).Count -gt 0
}

function Get-AutodiscoverChallengeText {
    <#
    .SYNOPSIS
        The challenge schemes a probe was offered, comma-separated. Pure.
    #>

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

    (@((Get-DataCollection $Probe 'WwwAuthenticate') | ForEach-Object { ConvertFrom-WwwAuthenticate -Value $_ } |
        ForEach-Object { $_.Scheme } | Select-Object -Unique) -join ', ')
}

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

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

    foreach ($entry in (Get-AutodiscoverDomainEntry (Get-Parameter $Parameters 'Domains' @()))) {
        if (-not (ConvertTo-AutodiscoverDomainName $entry)) {
            New-Finding -Category Apps -Check (Get-Text 'Check.Autodiscover.Autodiscover') -Severity INFO `
                -Value ((Get-Text 'Value.Autodiscover.InvalidDomain') -f $entry) `
                -Hint (Get-Text 'Hint.Autodiscover.InvalidDomain')
        }
    }

    # Machine-wide, so judged whether or not a domain was found.
    New-AutodiscoverOverrideFinding -Data $Data -Parameters $Parameters
    New-AutodiscoverScpFinding -Data $Data -Parameters $Parameters

    $domains = Get-DataCollection $Data 'Domains'
    if (-not $domains.Count) {
        return New-Finding -Category Apps -Check (Get-Text 'Check.Autodiscover.Autodiscover') -Severity INFO `
            -Value (Get-Text 'Value.Autodiscover.NoDomain') `
            -Hint (Get-Text 'Hint.Autodiscover.NoDomain')
    }

    $gatheredAt = Get-DataProperty $Data 'GatheredAt'
    foreach ($domain in $domains) {
        New-AutodiscoverLocationFinding -Domain $domain -Parameters $Parameters
        New-AutodiscoverVerdict -Domain $domain -GatheredAt $gatheredAt -Parameters $Parameters -Data $Data
        New-AutodiscoverRootFinding -Domain $domain
    }
}

function New-AutodiscoverLocationFinding {
    <#
    .SYNOPSIS
        The INFO Finding saying whether the domain's mailbox is in Exchange Online. Pure.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)]$Domain, [hashtable]$Parameters = @{})

    $name     = "$(Get-DataProperty $Domain 'Domain')"
    $answer   = Get-AutodiscoverExchangeOnlineAnswer -Domain $Domain
    $location = ConvertTo-AutodiscoverMailboxLocation -Domain $Domain -Parameters $Parameters
    $probe    = Get-DataProperty $Domain 'ExchangeOnline'

    $said = switch ($answer) {
        'Yes'   { Get-Text 'Value.Autodiscover.Yes' }
        'No'    { Get-Text 'Value.Autodiscover.No' }
        default { Get-Text 'Value.Autodiscover.Unknown' }
    }
    $failure = Get-DataProperty $probe 'Error'
    $status  = Get-DataProperty $probe 'StatusCode'
    $evidence = if ($failure) {
        (Get-Text 'Value.Autodiscover.V2Failed') -f (Get-DataProperty $failure 'Message')
    }
    elseif (Get-DataProperty $probe 'Location') {
        (Get-Text 'Value.Autodiscover.V2Redirect') -f $status, ([uri](Get-DataProperty $probe 'Location')).Host
    }
    else {
        (Get-Text 'Value.Autodiscover.V2Status') -f $status
    }
    $value = (Get-Text 'Value.Autodiscover.Location') -f $said, $evidence

    $pin = "$(Get-Parameter $Parameters 'ExchangeOnline' 'auto')".Trim().ToLowerInvariant()
    if ($pin -eq 'yes' -or $pin -eq 'no') {
        $pinned = Get-Text 'Value.Autodiscover.No'
        if ($location -eq 'ExchangeOnline') { $pinned = Get-Text 'Value.Autodiscover.Yes' }
        $value = (Get-Text 'Value.Autodiscover.Pinned') -f $pinned, $value
    }
    # A made-up address answers for the domain, and in a hybrid the domain is split.
    if ($answer -eq 'No' -and -not (Get-DataProperty $Domain 'AddressIsUser')) {
        $value += Get-Text 'Value.Autodiscover.ProbeAddress'
    }

    $hint = switch ($location) {
        'ExchangeOnline' { Get-Text 'Hint.Autodiscover.InExchangeOnline' }
        'OnPremises'     { Get-Text 'Hint.Autodiscover.NotInExchangeOnline' }
        default          { Get-Text 'Hint.Autodiscover.LocationUnknown' }
    }

    New-Finding -Category Apps -Check ((Get-Text 'Check.Autodiscover.Mailbox') -f $name) -Severity INFO -Value $value -Hint $hint
}

function New-AutodiscoverVerdict {
    <#
    .SYNOPSIS
        The one endpoint-health Finding for one domain: whether Outlook's Autodiscover
        lookup for it ends at a working Exchange. Pure.
    .DESCRIPTION
        Judged against where the mailbox is. For Exchange Online, Microsoft requires
        autodiscover.<domain> to CNAME to autodiscover.outlook.com; missing or pointing
        elsewhere, Outlook still gets there through its direct Office 365 step, but other
        clients and Outlook's other steps do not (WARN); pointing at something that answers
        but is not Exchange is the old-server-after-migration case (FAIL). An
        autodiscover.<domain> that answers as Exchange with a valid certificate is fine in
        either case, which is how a hybrid is set up.
 
        On-prem (and where the mailbox is unknown), the domain is judged by the first step
        of Outlook's lookup that works, in Outlook's order: the SCP, the root domain,
        autodiscover.<domain>, the HTTP redirect, SRV (Microsoft, "Outlook 2016
        implementation of Autodiscover"; research check 1). Outlook stops at the first step
        that answers (Inference, research check 1), so a later step that is broken - a
        wildcard DNS entry sending autodiscover.<domain> to the website - is INFO when an
        earlier one works, and FAIL only when none does. The certificate graded is the one
        of the step Outlook uses: autodiscover.<domain>'s must be valid, because Outlook
        does not silence its errors (step 7), and is graded for lifetime like any Customer
        certificate; the root domain's is not, because Outlook silences its errors (step
        6). The SCP's URL gets no probe of its own: a probe of the same URL for any domain
        is its answer, and an SCP nobody asked counts as working, untested, and says so.
        A step the registry tells Outlook to skip does not count, except
        autodiscover.<domain> itself, whose exclusion the overrides Finding reports.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)]$Domain,
        [AllowNull()]$GatheredAt,
        [hashtable]$Parameters = @{},
        # The whole data, for what is machine-wide: the SCP and the registry overrides.
        [AllowNull()]$Data
    )

    # Microsoft's HealthChecker grades an Exchange certificate green at 60 days or more,
    # yellow at 30-59 and red under 30 (CSS-Exchange CertificateCheck).
    $warnDays    = Get-Parameter $Parameters 'CertificateWarnDays' 60
    $failDays    = Get-Parameter $Parameters 'CertificateFailDays' 30
    # Unsourced: no Microsoft threshold exists; a first guess to tune. Judged on the
    # connection setup, not the whole request, for the reason the HttpsEndpoint Kind
    # gives (Exchange takes its own time to answer), and so borrowed from its LatencyWarnMs.
    $responseWarn = Get-Parameter $Parameters 'ResponseWarnMs' 150

    $name     = "$(Get-DataProperty $Domain 'Domain')"
    $check    = (Get-Text 'Check.Autodiscover.Domain') -f $name
    $location = ConvertTo-AutodiscoverMailboxLocation -Domain $Domain -Parameters $Parameters
    $online   = $location -eq 'ExchangeOnline'
    $adName   = 'autodiscover.' + $name

    $dns      = Get-DataProperty (Get-DataProperty $Domain 'Dns') 'Autodiscover'
    $records  = Get-DataCollection $dns 'Records'
    $cnames   = @($records | Where-Object { $_.Type -eq 'CNAME' } | ForEach-Object { "$($_.Data)" })
    $resolved = @($records | Where-Object { $_.Type -eq 'A' -or $_.Type -eq 'AAAA' }).Count -gt 0
    $toMicrosoft = @($cnames | Where-Object { Test-AutodiscoverMicrosoftHost $_ }).Count -gt 0

    if ($null -eq $dns) {
        return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.Autodiscover.NotRecorded')
    }

    if ($toMicrosoft -and $location -ne 'OnPremises') {
        return New-Finding -Category Apps -Check $check -Severity OK `
            -Value ((Get-Text 'Value.Autodiscover.PointsToMicrosoft') -f $adName, $cnames[0])
    }

    # What autodiscover.<domain> itself says: $null when the name does not resolve, else
    # the Finding it earns on its own.
    $why = $null
    $adProbe = Get-DataProperty $Domain 'Autodiscover'
    $adFinding = $null
    if ($toMicrosoft) {
        $adFinding = New-Finding -Category Apps -Check $check -Severity FAIL `
            -Value ((Get-Text 'Value.Autodiscover.PointsToMicrosoft') -f $adName, $cnames[0]) `
            -Hint (Get-Text 'Hint.Autodiscover.PointsToMicrosoftButOnPremises')
    }
    elseif ($resolved) {
        $adFinding = New-AutodiscoverEndpointFinding -Probe $adProbe -Check $check -Label $adName -Online $online `
            -GatheredAt $GatheredAt -WarnDays $warnDays -FailDays $failDays -ResponseWarnMs $responseWarn
    }
    else {
        # "Does not exist" and "has no address" are the same news; a lookup that failed
        # is not, and its message says why.
        $why = (Get-Text 'Value.Autodiscover.NotResolving') -f $adName
        $errorId = "$(Get-DataProperty $dns 'ErrorId')"
        if ($errorId -and $errorId -ne 'DNS_ERROR_RCODE_NAME_ERROR' -and $errorId -ne 'DNS_INFO_NO_RECORDS') {
            $why = (Get-Text 'Value.Autodiscover.LookupFailed') -f $adName, (Get-DataProperty $dns 'Error')
        }
    }

    if ($online) {
        if ($adFinding) { return $adFinding }
        return New-Finding -Category Apps -Check $check -Severity WARN -Value $why `
            -Hint (Get-Text 'Hint.Autodiscover.MissingCname')
    }

    # Which step of Outlook's lookup works, as far as the data shows.
    $override  = @(Get-AutodiscoverOverride -Data $Data)
    $rootProbe = Get-DataProperty $Domain 'Root'
    $adWorks   = $resolved -and -not $toMicrosoft -and (Test-AutodiscoverExchangeAnswer $adProbe)
    $rootWorks = -not (Test-AutodiscoverOverrideOn $override 'ExcludeHttpsRootDomain') -and (Test-AutodiscoverExchangeAnswer $rootProbe)

    $redirect = Get-DataProperty $Domain 'HttpRedirect'
    $redirectStatus = ConvertTo-Number (Get-DataProperty $redirect 'StatusCode')
    $redirectTo = "$(Get-DataProperty $redirect 'Location')"
    $redirectWorks = -not (Test-AutodiscoverOverrideOn $override 'ExcludeHttpRedirect') -and
        $null -ne $redirectStatus -and $redirectStatus -ge 300 -and $redirectStatus -lt 400 -and
        $redirectTo -match '^https://' -and -not (Test-AutodiscoverMicrosoftHost $redirectTo)
    $srv = @((Get-DataCollection (Get-DataProperty (Get-DataProperty $Domain 'Dns') 'Srv') 'Records') | Where-Object { $_.Type -eq 'SRV' })
    $srvWorks = -not (Test-AutodiscoverOverrideOn $override 'ExcludeSrvRecord') -and $srv.Count -gt 0

    # The SCP. Its URL is not probed itself; but when it names, on 443, a host a probe of
    # any domain asked at the same path, that probe is its answer - an SCP naming
    # autodiscover.<domain> works exactly when that step does (as on this machine). An SCP
    # whose answer is known not to be Exchange is a step that fails, and Outlook moves on.
    # Any other on-prem URL is taken as the step Outlook tries first, untested.
    $asked = @{}
    $all = New-Object System.Collections.Generic.List[object]
    $all.Add($Domain)
    foreach ($d in (Get-DataCollection $Data 'Domains')) { $all.Add($d) }
    foreach ($d in $all) {
        $dn = "$(Get-DataProperty $d 'Domain')"
        if (-not $dn) { continue }
        if (-not $asked.ContainsKey('autodiscover.' + $dn)) { $asked['autodiscover.' + $dn] = Get-DataProperty $d 'Autodiscover' }
        if (-not $asked.ContainsKey($dn)) { $asked[$dn] = Get-DataProperty $d 'Root' }
    }
    $scpSkipped  = $false
    $scpAsked    = $null
    $scpUntested = $null
    $scpUrls = @(Get-AutodiscoverScpUrl -Data $Data | Where-Object { $_.OnPremises })
    if ($scpUrls.Count -and (Test-AutodiscoverOverrideOn $override 'ExcludeScpLookup')) { $scpSkipped = $true }
    elseif ($scpUrls.Count) {
        foreach ($u in $scpUrls) {
            $uri = $null
            if (-not [uri]::TryCreate($u.Url, [UriKind]::Absolute, [ref]$uri)) { continue }
            $scpHost = $uri.Host.ToLowerInvariant()
            $probe = $null
            if ($uri.Scheme -eq 'https' -and $uri.Port -eq 443 -and $uri.AbsolutePath -ieq '/autodiscover/autodiscover.xml' -and
                $asked.ContainsKey($scpHost)) { $probe = $asked[$scpHost] }
            if ($probe) {
                if (-not $scpAsked -and (Test-AutodiscoverExchangeAnswer $probe)) {
                    $scpAsked = [pscustomobject]@{ Url = $u.Url; Probe = $probe; IsAutodiscover = $scpHost -eq $adName }
                }
            }
            elseif (-not $scpUntested) { $scpUntested = $u.Url }
        }
    }
    # An SCP a probe answered as Exchange outranks one nobody asked: it is known to work.
    $scpFinding = $null
    if ($scpAsked) {
        # The same server Outlook would ask next: autodiscover.<domain>'s own grading says it.
        if ($scpAsked.IsAutodiscover) { return $adFinding }
        $scpFinding = New-AutodiscoverEndpointFinding -Probe $scpAsked.Probe -Check $check `
            -Label ((Get-Text 'Value.Autodiscover.ScpLabel') -f $scpAsked.Url) -Online $false `
            -GatheredAt $GatheredAt -WarnDays $warnDays -FailDays $failDays -ResponseWarnMs $responseWarn
        # A certificate or speed problem on the server Outlook uses is this domain's problem.
        if ($scpFinding.Severity -ne 'OK') { return $scpFinding }
    }

    # The verdict without an untested SCP.
    $broken = $why
    if ($adFinding) { $broken = $adFinding.Value }
    $rest = if ($rootWorks) {
        $step = (Get-Text 'Value.Autodiscover.StepRoot') -f $name
        if (-not $adFinding) {
            New-Finding -Category Apps -Check $check -Severity OK -Value ((Get-Text 'Value.Autodiscover.ViaRoot') -f $why, $name)
        }
        elseif ($adFinding.Severity -eq 'OK') {
            New-Finding -Category Apps -Check $check -Severity OK -Value ((Get-Text 'Value.Autodiscover.UsesEarlier') -f $adFinding.Value, $step)
        }
        else {
            New-Finding -Category Apps -Check $check -Severity INFO `
                -Value ((Get-Text 'Value.Autodiscover.UsesEarlier') -f $adFinding.Value, $step) -Hint (Get-Text 'Hint.Autodiscover.LaterStepBroken')
        }
    }
    elseif ($adWorks) { $adFinding }
    elseif ($redirectWorks) {
        New-Finding -Category Apps -Check $check -Severity INFO `
            -Value ((Get-Text 'Value.Autodiscover.UsesLater') -f $broken, ((Get-Text 'Value.Autodiscover.StepHttpRedirect') -f $redirectTo)) `
            -Hint (Get-Text 'Hint.Autodiscover.HttpRedirectOnly')
    }
    elseif ($srvWorks) {
        New-Finding -Category Apps -Check $check -Severity INFO `
            -Value ((Get-Text 'Value.Autodiscover.SrvOnly') -f $broken, $srv[0].Data) `
            -Hint (Get-Text 'Hint.Autodiscover.SrvOnly')
    }
    elseif ($adFinding) { $adFinding }
    else {
        New-Finding -Category Apps -Check $check -Severity FAIL `
            -Value ((Get-Text 'Value.Autodiscover.NothingAnswers') -f $why) `
            -Hint (Get-Text 'Hint.Autodiscover.NothingAnswers')
    }

    if ($scpFinding -or $scpUntested) {
        $step = if ($scpFinding) { $scpFinding.Value } else { (Get-Text 'Value.Autodiscover.StepScp') -f $scpUntested }
        $value = (Get-Text 'Value.Autodiscover.UsesEarlier') -f $rest.Value, $step
        if ($rest.Severity -eq 'OK') { return New-Finding -Category Apps -Check $check -Severity OK -Value $value }
        $hint = Get-Text 'Hint.Autodiscover.LaterStepBroken'
        if (-not ($rootWorks -or $adWorks -or $redirectWorks -or $srvWorks)) { $hint = Get-Text 'Hint.Autodiscover.ScpOnly' }
        elseif ($rest.Severity -eq 'INFO') { $hint = $rest.Hint }
        return New-Finding -Category Apps -Check $check -Severity INFO -Value $value -Hint $hint
    }
    if ($scpSkipped -and $rest.Severity -eq 'FAIL') {
        return New-Finding -Category Apps -Check $check -Severity FAIL `
            -Value ($rest.Value + (Get-Text 'Value.Autodiscover.ScpSkipped')) -Hint $rest.Hint
    }
    $rest
}

function New-AutodiscoverEndpointFinding {
    <#
    .SYNOPSIS
        What https://autodiscover.<domain>/ earns on its own: whether Exchange answers,
        its certificate and how fast it connects. Pure.
    .DESCRIPTION
        The name resolves to something that is not Microsoft's. The thresholds are read by
        New-AutodiscoverVerdict and handed in.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Probe,
        [Parameter(Mandatory)][string]$Check,
        [Parameter(Mandatory)][string]$Label,
        [bool]$Online,
        [AllowNull()]$GatheredAt,
        [double]$WarnDays,
        [double]$FailDays,
        [double]$ResponseWarnMs
    )

    $failure = Get-DataProperty $Probe 'Error'
    if (-not $Probe) {
        return New-UnavailableFinding -Category Apps -Check $Check -Hint (Get-Text 'Hint.Autodiscover.NotRecorded')
    }
    if ($failure) {
        $stage = switch ("$(Get-DataProperty $Probe 'Stage')") {
            'Dns'     { Get-Text 'Value.HttpsEndpoint.Stage.Dns' }
            'Connect' { Get-Text 'Value.HttpsEndpoint.Stage.Connect' }
            'Proxy'   { Get-Text 'Value.HttpsEndpoint.Stage.Proxy' }
            'Tls'     { Get-Text 'Value.HttpsEndpoint.Stage.Tls' }
            default   { Get-Text 'Value.HttpsEndpoint.Stage.Http' }
        }
        $value = (Get-Text 'Value.Autodiscover.NoAnswer') -f $Label, $stage, (Get-DataProperty $failure 'Message')
        if ($Online) {
            return New-Finding -Category Apps -Check $Check -Severity WARN -Value $value `
                -Hint (Get-Text 'Hint.Autodiscover.ExchangeOnlineElsewhere')
        }
        return New-Finding -Category Apps -Check $Check -Severity FAIL -Value $value `
            -Hint (Get-Text 'Hint.Autodiscover.Unreachable')
    }

    if (-not (Test-AutodiscoverExchangeAnswer $Probe)) {
        $shown = "$(Get-DataProperty $Probe 'StatusCode')"
        $challenge = Get-AutodiscoverChallengeText $Probe
        if ($challenge) { $shown += ' ' + $challenge }
        $value = (Get-Text 'Value.Autodiscover.NotExchange') -f $Label, $shown
        $hint = Get-Text 'Hint.Autodiscover.NotExchange'
        if ($Online) { $hint = Get-Text 'Hint.Autodiscover.ExchangeOnlineOldServer' }
        return New-Finding -Category Apps -Check $Check -Severity FAIL -Value $value -Hint $hint
    }

    # Exchange answered on the Customer's own name: now its certificate is the question.
    $certificate = Get-DataProperty $Probe 'Certificate'
    if (-not $certificate) {
        return New-Finding -Category Apps -Check $Check -Severity INFO `
            -Value (Get-Text 'Value.HttpsEndpoint.NoCertificate') -Hint (Get-Text 'Hint.Autodiscover.NotRecorded')
    }
    $policyErrors = "$(Get-DataProperty $certificate 'PolicyErrors')"
    if ($policyErrors -and $policyErrors -ne 'None') {
        $reasons = @()
        if ($policyErrors -match 'RemoteCertificateNameMismatch') { $reasons += Get-Text 'Value.HttpsEndpoint.NameMismatch' }
        if ($policyErrors -match 'RemoteCertificateChainErrors') {
            $reasons += (Get-Text 'Value.HttpsEndpoint.ChainErrors') -f ((Get-DataCollection $certificate 'ChainStatus') -join ', ')
        }
        if (-not $reasons.Count) { $reasons += $policyErrors }
        return New-Finding -Category Apps -Check $Check -Severity FAIL `
            -Value ((Get-Text 'Value.Autodiscover.Certificate') -f $Label, ($reasons -join '; ')) `
            -Hint (Get-Text 'Hint.Autodiscover.CertificateUntrusted')
    }

    $notAfter = Get-DataProperty $certificate 'NotAfter'
    $days = $null
    if ($notAfter -and $GatheredAt) {
        $days = ([datetime]$notAfter - [datetime]$GatheredAt).TotalDays
        $lifetime = Get-SeverityBelow $days $WarnDays $FailDays
        if ($lifetime -ne 'OK') {
            $value = (Get-Text 'Value.HttpsEndpoint.CertificateExpires') -f $days, [datetime]$notAfter
            if ($days -lt 0) { $value = (Get-Text 'Value.HttpsEndpoint.CertificateExpired') -f [datetime]$notAfter }
            return New-Finding -Category Apps -Check $Check -Severity $lifetime `
                -Value ((Get-Text 'Value.Autodiscover.Certificate') -f $Label, $value) `
                -Hint (Get-Text 'Hint.Autodiscover.CertificateExpiring')
        }
    }

    $connectMs = ConvertTo-Number (Get-DataProperty $Probe 'ConnectMs')
    if ($null -ne $connectMs -and (Get-Severity $connectMs $ResponseWarnMs ([double]::MaxValue)) -ne 'OK') {
        return New-Finding -Category Apps -Check $Check -Severity WARN `
            -Value ((Get-Text 'Value.Autodiscover.Slow') -f $Label, $connectMs) `
            -Hint (Get-Text 'Hint.Autodiscover.Slow')
    }

    $shownDays = '-'
    if ($null -ne $days) { $shownDays = '{0:N0}' -f $days }
    New-Finding -Category Apps -Check $Check -Severity OK `
        -Value ((Get-Text 'Value.Autodiscover.Ok') -f $Label, (Get-AutodiscoverChallengeText $Probe), $shownDays, $connectMs)
}

function New-AutodiscoverRootFinding {
    <#
    .SYNOPSIS
        A Finding about https://<domain>/autodiscover/ when it answers in a way that
        matters, and nothing otherwise. Pure.
    .DESCRIPTION
        Outlook asks the root domain before autodiscover.<domain>. A web host answering
        there with anything but Exchange can make Outlook set the account up as IMAP
        (Microsoft KB 3049615): WARN. A root domain that only presents the wrong
        certificate is INFO: Outlook 2016 and later silence certificate errors at this step
        (Microsoft, Outlook 2016 Autodiscover order, step 6), but older Outlook and other
        mail clients show the warning KB 2783881 describes. A root that does not answer at
        all is how most domains look, and the Section shows it.
    #>

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

    $name  = "$(Get-DataProperty $Domain 'Domain')"
    $probe = Get-DataProperty $Domain 'Root'
    if (-not $probe -or (Get-DataProperty $probe 'Error')) { return }
    $status = ConvertTo-Number (Get-DataProperty $probe 'StatusCode')
    if ($null -eq $status -or (Test-AutodiscoverExchangeAnswer $probe)) { return }

    $check = (Get-Text 'Check.Autodiscover.Root') -f $name
    $url   = 'https://{0}/autodiscover/' -f $name

    if ($status -ge 200 -and $status -lt 300) {
        return New-Finding -Category Apps -Check $check -Severity WARN `
            -Value ((Get-Text 'Value.Autodiscover.RootAnswers') -f $url, $status) `
            -Hint (Get-Text 'Hint.Autodiscover.RootAnswers')
    }

    $certificate  = Get-DataProperty $probe 'Certificate'
    $policyErrors = "$(Get-DataProperty $certificate 'PolicyErrors')"
    if ($certificate -and $policyErrors -and $policyErrors -ne 'None') {
        $reason = $policyErrors
        if ($policyErrors -match 'RemoteCertificateNameMismatch') { $reason = Get-Text 'Value.HttpsEndpoint.NameMismatch' }
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value ((Get-Text 'Value.Autodiscover.RootCertificate') -f $url, $status, $reason) `
            -Hint (Get-Text 'Hint.Autodiscover.RootCertificate')
    }
}

function Get-AutodiscoverOverride {
    <#
    .SYNOPSIS
        The AutoDiscover values Outlook obeys, one per name: the policy's where both keys
        carry it, with the preference value it overrules. Pure.
    #>

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

    $effective = [ordered]@{}
    foreach ($source in 'Policy', 'Preference') {
        foreach ($key in @((Get-DataCollection $Data 'Registry') | Where-Object { "$(Get-DataProperty $_ 'Source')" -eq $source })) {
            foreach ($v in (Get-DataCollection $key 'Values')) {
                $name = "$(Get-DataProperty $v 'Name')"
                if (-not $name) { continue }
                $id = $name.ToLowerInvariant()
                if ($effective.Contains($id)) {
                    $effective[$id].Overrules = $true
                    $effective[$id].OverruledValue = Get-DataProperty $v 'Value'
                    continue
                }
                $effective[$id] = [pscustomobject]@{
                    Name = $name; Value = Get-DataProperty $v 'Value'; Source = $source; Overrules = $false; OverruledValue = $null
                }
            }
        }
    }
    foreach ($o in $effective.Values) { $o }
}

function Test-AutodiscoverOverrideOn {
    <#
    .SYNOPSIS
        Whether an override is switched on (a number other than 0). Pure.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyCollection()]$Override, [Parameter(Mandatory)][string]$Name)

    $found = @(@($Override) | Where-Object { $_ -and $_.Name -eq $Name })
    if (-not $found.Count) { return $false }
    $number = ConvertTo-Number $found[0].Value
    $null -ne $number -and $number -ne 0
}

function Get-AutodiscoverScpUrl {
    <#
    .SYNOPSIS
        The Autodiscover URLs the SCPs advertise, each with its date and whether it is
        the Customer's own server. Pure.
    .DESCRIPTION
        A pointer SCP carries an LDAP URL to another forest, which is not where Outlook
        connects, so only http(s) URLs are returned.
    #>

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

    foreach ($entry in (Get-DataCollection (Get-DataProperty $Data 'Scp') 'Entries')) {
        foreach ($url in (Get-DataCollection $entry 'ServiceBindingInformation')) {
            if ("$url" -notmatch '^https?://') { continue }
            [pscustomobject]@{
                Url         = "$url"
                WhenChanged = Get-DataProperty $entry 'WhenChanged'
                OnPremises  = -not (Test-AutodiscoverMicrosoftHost "$url")
            }
        }
    }
}

function Format-AutodiscoverDate {
    <#
    .SYNOPSIS
        A recorded date as yyyy-MM-dd, or '-' when there is none. Pure.
    #>

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

    if ($null -eq $Value -or "$Value" -eq '') { return '-' }
    try { '{0:yyyy-MM-dd}' -f [datetime]$Value } catch { '-' }
}

function Get-AutodiscoverBlockedStep {
    <#
    .SYNOPSIS
        The overrides that stand in the way of the mailbox, by name. Pure.
    .DESCRIPTION
        PreferLocalXML always: a local file replaces whatever the server would answer.
        For a mailbox in Exchange Online, ExcludeExplicitO365Endpoint: it removes
        Outlook's direct Office 365 step, its safety net once the mailbox has moved
        (research check 2; Inference, Microsoft says no more than that the step is
        skipped). For an on-prem mailbox, the Exclude values of the steps that lead to its
        Exchange, when every such step is excluded - one step left open is enough for
        Outlook to find it.
    #>

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

    $blocked = New-Object System.Collections.Generic.List[string]
    if (Test-AutodiscoverOverrideOn $Override 'PreferLocalXML') { $blocked.Add('PreferLocalXML') }

    $scpOnPremises = @(Get-AutodiscoverScpUrl -Data $Data | Where-Object { $_.OnPremises }).Count -gt 0
    foreach ($domain in (Get-DataCollection $Data 'Domains')) {
        switch (ConvertTo-AutodiscoverMailboxLocation -Domain $domain -Parameters $Parameters) {
            'ExchangeOnline' {
                if (Test-AutodiscoverOverrideOn $Override 'ExcludeExplicitO365Endpoint') { $blocked.Add('ExcludeExplicitO365Endpoint') }
            }
            'OnPremises' {
                $redirect = Get-DataProperty $domain 'HttpRedirect'
                $status   = ConvertTo-Number (Get-DataProperty $redirect 'StatusCode')
                $location = "$(Get-DataProperty $redirect 'Location')"
                $srv = @((Get-DataCollection (Get-DataProperty (Get-DataProperty $domain 'Dns') 'Srv') 'Records') | Where-Object { $_.Type -eq 'SRV' })
                $works = @{
                    Scp          = $scpOnPremises
                    Root         = Test-AutodiscoverExchangeAnswer (Get-DataProperty $domain 'Root')
                    Autodiscover = Test-AutodiscoverExchangeAnswer (Get-DataProperty $domain 'Autodiscover')
                    HttpRedirect = ($null -ne $status -and $status -ge 300 -and $status -lt 400 -and
                                    $location -match '^https://' -and -not (Test-AutodiscoverMicrosoftHost $location))
                    Srv          = $srv.Count -gt 0
                }
                $needed = @($script:AutodiscoverExcludeStep.Keys | Where-Object { $works[$script:AutodiscoverExcludeStep[$_]] })
                $open   = @($needed | Where-Object { -not (Test-AutodiscoverOverrideOn $Override $_) })
                if ($needed.Count -and -not $open.Count) { foreach ($n in $needed) { $blocked.Add($n) } }
            }
        }
    }
    $blocked | Select-Object -Unique
}

function Format-AutodiscoverOverride {
    <#
    .SYNOPSIS
        One override as the Value shows it: name, value and which key it came from. Pure.
    #>

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

    if ($Override.Overrules) {
        return (Get-Text 'Value.Autodiscover.OverrideOverrules') -f $Override.Name, $Override.Value, $Override.OverruledValue
    }
    $source = Get-Text 'Value.Autodiscover.Preference'
    if ($Override.Source -eq 'Policy') { $source = Get-Text 'Value.Autodiscover.Policy' }
    (Get-Text 'Value.Autodiscover.Override') -f $Override.Name, $Override.Value, $source
}

function New-AutodiscoverOverrideFinding {
    <#
    .SYNOPSIS
        The one Finding about the AutoDiscover registry values. Pure.
    .DESCRIPTION
        None set is OK. Set, but none in the mailbox's way, is INFO with the list: a
        Technician should still see a forgotten workaround. One in the way is WARN, naming
        it (Get-AutodiscoverBlockedStep says which are). Microsoft: "If you configure the
        Autodiscover registry/policy values incorrectly, you may prevent Outlook from
        obtaining Autodiscover information" (archived KB, unexpected Autodiscover behavior).
    #>

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

    $check = Get-Text 'Check.Autodiscover.Overrides'
    $keys  = Get-DataCollection $Data 'Registry'
    if (-not $keys.Count) {
        return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.Autodiscover.OverridesNotRead')
    }
    $failed = @($keys | Where-Object { Get-DataProperty $_ 'Error' })
    if ($failed.Count) {
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value ((Get-Text 'Value.Autodiscover.RegistryFailed') -f (Get-DataProperty $failed[0] 'Path'), (Get-DataProperty $failed[0] 'Error')) `
            -Hint (Get-Text 'Hint.Autodiscover.RegistryFailed')
    }

    $overrides = @(Get-AutodiscoverOverride -Data $Data)
    if (-not $overrides.Count) {
        return New-Finding -Category Apps -Check $check -Severity OK -Value (Get-Text 'Value.Autodiscover.NoOverrides')
    }

    $listed  = @(foreach ($o in $overrides) { Format-AutodiscoverOverride $o }) -join ', '
    $blocked = @(Get-AutodiscoverBlockedStep -Data $Data -Override $overrides -Parameters $Parameters)
    if ($blocked.Count) {
        $named = @(foreach ($b in $blocked) {
            $o = @($overrides | Where-Object { $_.Name -eq $b })[0]
            $source = Get-Text 'Value.Autodiscover.Preference'
            if ($o.Source -eq 'Policy') { $source = Get-Text 'Value.Autodiscover.Policy' }
            '{0} ({1})' -f $o.Name, $source
        }) -join ', '
        return New-Finding -Category Apps -Check $check -Severity WARN `
            -Value ((Get-Text 'Value.Autodiscover.OverridesBlocking') -f $listed, $named) `
            -Hint ((Get-Text 'Hint.Autodiscover.OverrideRemove') -f $named)
    }
    New-Finding -Category Apps -Check $check -Severity INFO -Value $listed -Hint (Get-Text 'Hint.Autodiscover.OverridesHarmless')
}

function New-AutodiscoverScpFinding {
    <#
    .SYNOPSIS
        The one Finding comparing the Autodiscover SCP with where the mailbox is. Pure.
    .DESCRIPTION
        An SCP still naming on-prem Exchange while a mailbox is in Exchange Online makes
        Outlook ask that server first (KB 3012603; in a hybrid, the delay of KB 3137323):
        WARN, unless ExcludeScpLookup tells Outlook to skip it. No Finding on a machine
        known not to be domain-joined: there is no SCP to ask.
    #>

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

    if ((Get-DataProperty $Data 'IsDomainJoined') -eq $false) { return }
    $check = Get-Text 'Check.Autodiscover.Scp'
    $scp   = Get-DataProperty $Data 'Scp'
    if (-not $scp) {
        return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.Autodiscover.ScpNotRead')
    }
    $failure = Get-DataProperty $scp 'Error'
    if ($failure) {
        return New-Finding -Category Apps -Check $check -Severity INFO `
            -Value ((Get-Text 'Value.Autodiscover.ScpFailed') -f $failure) -Hint (Get-Text 'Hint.Autodiscover.ScpFailed')
    }

    $urls   = @(Get-AutodiscoverScpUrl -Data $Data)
    $onPrem = @($urls | Where-Object { $_.OnPremises })
    if (-not $onPrem.Count) {
        if ($urls.Count) {
            return New-Finding -Category Apps -Check $check -Severity OK `
                -Value ((Get-Text 'Value.Autodiscover.ScpMicrosoft') -f (@($urls | ForEach-Object { $_.Url }) -join ', '))
        }
        return New-Finding -Category Apps -Check $check -Severity OK -Value (Get-Text 'Value.Autodiscover.ScpNone')
    }

    $shown = @($onPrem | ForEach-Object { (Get-Text 'Value.Autodiscover.ScpUrl') -f $_.Url, (Format-AutodiscoverDate $_.WhenChanged) }) -join ', '
    $online = New-Object System.Collections.Generic.List[string]
    $onPremises = $false
    foreach ($domain in (Get-DataCollection $Data 'Domains')) {
        switch (ConvertTo-AutodiscoverMailboxLocation -Domain $domain -Parameters $Parameters) {
            'ExchangeOnline' { $online.Add("$(Get-DataProperty $domain 'Domain')") }
            'OnPremises'     { $onPremises = $true }
        }
    }

    if ($online.Count) {
        $value = (Get-Text 'Value.Autodiscover.ScpStale') -f $shown, ($online -join ', ')
        if (Test-AutodiscoverOverrideOn @(Get-AutodiscoverOverride -Data $Data) 'ExcludeScpLookup') {
            return New-Finding -Category Apps -Check $check -Severity INFO `
                -Value ($value + (Get-Text 'Value.Autodiscover.ScpSkipped')) -Hint (Get-Text 'Hint.Autodiscover.ScpSkipped')
        }
        return New-Finding -Category Apps -Check $check -Severity WARN -Value $value -Hint (Get-Text 'Hint.Autodiscover.ScpStale')
    }
    if ($onPremises) {
        return New-Finding -Category Apps -Check $check -Severity OK -Value ((Get-Text 'Value.Autodiscover.ScpOnPremises') -f $shown)
    }
    New-Finding -Category Apps -Check $check -Severity INFO `
        -Value ((Get-Text 'Value.Autodiscover.ScpUnknownMailbox') -f $shown) -Hint (Get-Text 'Hint.Autodiscover.ScpUnknownMailbox')
}

function ConvertTo-AutodiscoverMachineSection {
    <#
    .SYNOPSIS
        The Sections of what bends the lookup: registry values, SCPs, cached answers. Pure.
    .DESCRIPTION
        A cached answer's age is shown, not judged: Microsoft gives no age at which one is
        stale.
    #>

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

    $inEffect = @(Get-AutodiscoverOverride -Data $Data)
    New-Section -Title (Get-Text 'Title.Autodiscover.Overrides') -Row @(
        foreach ($key in (Get-DataCollection $Data 'Registry')) {
            $source = "$(Get-DataProperty $key 'Source')"
            foreach ($v in (Get-DataCollection $key 'Values')) {
                $name = "$(Get-DataProperty $v 'Name')"
                [pscustomobject]@{
                    Source   = $source
                    Name     = $name
                    Value    = Get-DataProperty $v 'Value'
                    Type     = Get-DataProperty $v 'Type'
                    InEffect = [bool]@($inEffect | Where-Object { $_.Name -eq $name -and $_.Source -eq $source }).Count
                    Key      = Get-DataProperty $key 'Path'
                    Error    = $null
                }
            }
            if (Get-DataProperty $key 'Error') {
                [pscustomobject]@{ Source = $source; Name = $null; Value = $null; Type = $null; InEffect = $null
                    Key = Get-DataProperty $key 'Path'; Error = Get-DataProperty $key 'Error' }
            }
        }
    )

    $scp = Get-DataProperty $Data 'Scp'
    New-Section -Title (Get-Text 'Title.Autodiscover.Scp') -Row @(
        foreach ($entry in (Get-DataCollection $scp 'Entries')) {
            [pscustomobject]@{
                Name                      = Get-DataProperty $entry 'Name'
                ServiceBindingInformation = (Get-DataCollection $entry 'ServiceBindingInformation') -join '; '
                Keywords                  = (Get-DataCollection $entry 'Keywords') -join '; '
                WhenChanged               = Format-AutodiscoverDate (Get-DataProperty $entry 'WhenChanged')
                DistinguishedName         = Get-DataProperty $entry 'DistinguishedName'
            }
        }
    )

    $gatheredAt = Get-DataProperty $Data 'GatheredAt'
    New-Section -Title (Get-Text 'Title.Autodiscover.Cache') -Row @(
        foreach ($file in (Get-DataCollection $Data 'CacheFiles')) {
            $written = Get-DataProperty $file 'LastWriteTime'
            $age = $null
            try { if ($written -and $gatheredAt) { $age = [math]::Floor(([datetime]$gatheredAt - [datetime]$written).TotalDays) } } catch { }
            [pscustomobject]@{
                Folder        = Get-DataProperty $file 'Folder'
                Name          = Get-DataProperty $file 'Name'
                LastWriteTime = Format-AutodiscoverDate $written
                AgeDays       = $age
                Length        = Get-DataProperty $file 'Length'
            }
        }
    )
}

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

    $domains = Get-DataCollection $Data 'Domains'
    if (-not $domains.Count) {
        $candidates = Get-DataCollection $Data 'Candidates'
        $errors     = Get-DataCollection $Data 'SourceErrors'
        if ($candidates.Count -or $errors.Count) {
            New-Section -Title (Get-Text 'Title.Autodiscover.Domains') -Row @(
                foreach ($c in $candidates) { [pscustomobject]@{ Source = $c.Source; Address = $c.Address; Error = $null } }
                foreach ($e in $errors)     { [pscustomobject]@{ Source = $e.Source; Address = $null; Error = $e.Error } }
            )
        }
        return ConvertTo-AutodiscoverMachineSection -Data $Data
    }

    $describe = {
        param($Answer)
        if (-not $Answer) { return $null }
        $records = Get-DataCollection $Answer 'Records'
        if ($records.Count) { return (@($records | ForEach-Object { '{0} {1}' -f $_.Type, $_.Data }) -join '; ') }
        $id = Get-DataProperty $Answer 'ErrorId'
        if ($id) { return $id }
        'no answer'
    }

    New-Section -Title (Get-Text 'Title.Autodiscover.Domains') -Row @(
        foreach ($d in $domains) {
            $dns = Get-DataProperty $d 'Dns'
            [pscustomobject]@{
                Domain       = Get-DataProperty $d 'Domain'
                Sources      = ((Get-DataCollection $d 'Sources') -join ', ')
                Address      = $(if (Get-DataProperty $d 'AddressIsUser') { 'user' } else { 'probe@' })
                Autodiscover = & $describe (Get-DataProperty $dns 'Autodiscover')
                Srv          = & $describe (Get-DataProperty $dns 'Srv')
                Root         = & $describe (Get-DataProperty $dns 'Root')
            }
        }
        foreach ($e in (Get-DataCollection $Data 'SourceErrors')) {
            [pscustomobject]@{ Domain = $null; Sources = $e.Source; Address = $null; Autodiscover = $e.Error; Srv = $null; Root = $null }
        }
    )

    New-Section -Title (Get-Text 'Title.Autodiscover.Urls') -Row @(
        foreach ($d in $domains) {
            foreach ($purpose in 'Root', 'Autodiscover', 'HttpRedirect', 'ExchangeOnline') {
                $probe = Get-DataProperty $d $purpose
                if (-not $probe) { continue }
                $certificate = Get-DataProperty $probe 'Certificate'
                $failure     = Get-DataProperty $probe 'Error'
                $notAfter    = Get-DataProperty $certificate 'NotAfter'
                $proxy       = Get-DataProperty $probe 'Proxy'
                $answer      = Get-DataProperty $probe 'Location'
                if ($purpose -eq 'ExchangeOnline' -and (Get-DataProperty $d 'V2Url')) { $answer = 'Url ' + (Get-DataProperty $d 'V2Url') }
                [pscustomobject]@{
                    Domain       = Get-DataProperty $d 'Domain'
                    Purpose      = $purpose
                    Method       = Get-DataProperty $probe 'Method'
                    Url          = Get-DataProperty $probe 'Url'
                    Proxy        = $(if ($proxy) { $proxy } else { 'direct' })
                    Status       = $(if ($failure) { 'failed at {0}' -f (Get-DataProperty $probe 'Stage') } else { Get-DataProperty $probe 'StatusCode' })
                    Answer       = $answer
                    Challenge    = Get-AutodiscoverChallengeText $probe
                    Issuer       = Get-DataProperty $certificate 'Issuer'
                    NotAfter     = $(if ($notAfter) { '{0:yyyy-MM-dd}' -f [datetime]$notAfter })
                    PolicyErrors = Get-DataProperty $certificate 'PolicyErrors'
                    ConnectMs    = Get-DataProperty $probe 'ConnectMs'
                    Error        = Get-DataProperty $failure 'Message'
                }
            }
        }
    )

    ConvertTo-AutodiscoverMachineSection -Data $Data
}