Private/Facts.ps1

#Requires -Version 5.1
# On-device fact builders for the inventory (docs/08 "Collection strategy"). Each builder calls wrappers from
# Platform.ps1 inside Invoke-DDStep, so a failing source becomes a notCollected record, never a crash and never
# a silent gap. Pure mapping helpers are separate functions with their own tests.

Set-StrictMode -Version Latest

# Delivery Optimization's own default keyword list, which it matches in the adapter's Description and FriendlyName (Learn, DO
# reference, "VPN keywords": "VPN", "Secure", and "Virtual Private Network"), then DoctorDO's list of VPN client names.
$script:DDDoDefaultVpnKeywords = @('vpn', 'secure', 'virtual private network')
$script:DDVpnKeywords = @($script:DDDoDefaultVpnKeywords) + @('tunnel', 'anyconnect', 'globalprotect', 'pangp', 'forti', 'wireguard', 'openvpn', 'tap-windows', 'juniper', 'pulse secure',
    'zscaler', 'nordlynx', 'sonicwall', 'checkpoint', 'wan miniport (ikev2)', 'wan miniport (l2tp)', 'wan miniport (pptp)', 'wan miniport (sstp)', 'cisco')
$script:DDNonVpnTunnel = 'teredo|6to4|isatap|hyper-v|virtualbox|vmware|bluetooth|loopback|ip-https'
$script:DDPlaceholderSerial = '(?i)^(none|n/a|default string|to be filled by o\.e\.m\.|system serial number|0|unknown|not specified)$'

function ConvertTo-DDVpnKeywordList {
    # The keywords DO is configured with (Get-DOConfig VpnKeywords, the DOVpnKeywords policy: "separate each by a comma"), as a
    # list of whole phrases: split on commas (and semicolons), never on spaces, so "Virtual Private Network" stays one keyword.
    [CmdletBinding()]
    param([AllowNull()] $Value)
    @($Value) | Where-Object { $null -ne $_ } | ForEach-Object { ([string]$_) -split '[,;]' } | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Select-Object -Unique
}

function Get-DDVpnKeywordMatch {
    # The keyword that marks the adapter as a VPN, or $null: DoctorDO's own heuristic (DO's defaults, the built-in client names,
    # and the keywords DO is configured with), which can still differ from DO's decision. Known tunnels that are not VPNs never match.
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Adapter, [AllowNull()][string[]] $ExtraKeywords = @())
    $text = ('{0} {1}' -f $Adapter.Name, $Adapter.Description).ToLowerInvariant()
    if ($text -match $script:DDNonVpnTunnel) { return $null }
    foreach ($k in (@($script:DDVpnKeywords) + @($ExtraKeywords | Where-Object { $_ }))) { $kw = ([string]$k).ToLowerInvariant(); if ($text.Contains($kw)) { return $kw } }
    $null
}

function Test-DDVpnAdapter {
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Adapter, [AllowNull()][string[]] $ExtraKeywords = @())
    $null -ne (Get-DDVpnKeywordMatch -Adapter $Adapter -ExtraKeywords $ExtraKeywords)
}

function ConvertTo-DDAdapterType {
    [CmdletBinding()]
    param($Adapter, [AllowNull()][string[]] $ExtraKeywords = @())
    if ($null -eq $Adapter) { return 'Unknown' }
    if (Test-DDVpnAdapter -Adapter $Adapter -ExtraKeywords $ExtraKeywords) { return 'VPN' }
    $t = ('{0} {1} {2}' -f $Adapter.PhysicalMediaType, $Adapter.MediaType, $Adapter.Description).ToLowerInvariant()
    if ($t -match '802\.11|wi-?fi|wireless lan|wlan') { return 'Wi-Fi' }
    if ($t -match 'wireless wan|mobile broadband|cellular|wwan') { return 'Cellular' }
    if ($t -match '802\.3|ethernet') { return 'Ethernet' }
    'Other'
}

function ConvertFrom-DDProxyState {
    <#
    .SYNOPSIS
        Derive the proxy facts and scope from the WinINET registry state and `netsh winhttp show proxy`.
        Precedence rules: docs/17 section 4.3 (https://learn.microsoft.com/windows/deployment/do/delivery-optimization-proxy).
    #>

    [CmdletBinding()]
    param($Internet, [AllowNull()][string[]] $Netsh = @(), [switch] $IsSystem)

    $val = { param($set, $n) if ($null -eq $set) { return $null }; foreach ($v in @($set.Values)) { if ($v['name'] -ieq $n) { return $v['data'] } }; $null }
    $uEnable = & $val $Internet.User 'ProxyEnable'
    $uPac = & $val $Internet.User 'AutoConfigURL'
    $mEnable = & $val $Internet.Machine 'ProxyEnable'
    $mServer = & $val $Internet.Machine 'ProxyServer'
    $mPac = & $val $Internet.Machine 'AutoConfigURL'
    $override = & $val $Internet.User 'ProxyOverride'
    if (-not $override) { $override = & $val $Internet.Machine 'ProxyOverride' }
    $perUserPolicy = Get-DDProp $Internet 'ProxySettingsPerUser'
    $deviceWide = ($null -ne $perUserPolicy -and [int]$perUserPolicy -eq 0) -and ($mEnable -eq '1' -or $mServer -or $mPac)
    $perUser = (-not $IsSystem) -and ($uEnable -eq '1' -or $uPac)
    $scope = 'none'
    if ($deviceWide) { $scope = 'device-wide' } elseif ($perUser) { $scope = 'per-user' }
    $netshText = (@($Netsh) -join "`n")
    $netshConfigured = ($netshText -match '(?i)Proxy Server\(s\)\s*:') -and ($netshText -notmatch '(?i)Direct access')
    $pac = $(if ($deviceWide -and $mPac) { $mPac } else { $uPac })
    $auto = $false
    $conn = Get-DDProp $Internet 'DefaultConnectionSettings'
    if ($conn -is [string] -and $conn.Length -ge 18) { $flags = [Convert]::ToInt32($conn.Substring(16, 2), 16); $auto = (($flags -band 0x08) -ne 0) }
    [ordered]@{
        configured      = ($scope -ne 'none') -or $netshConfigured
        wininetScope    = $scope
        netshConfigured = [bool]$netshConfigured
        autoDetect      = [bool]$auto
        pacUrlHost      = $(if ($pac) { Get-DDUrlHost -Url $pac } else { $null })
        bypassLocal     = $(if ($override) { ([string]$override -match '<local>') } else { $false })
    }
}

function Get-DDNetworkFacts {
    <#
    .SYNOPSIS
        Network facts (packages/schema NetworkFacts). From October 2026 `localSubnet` is the subnet of the adapter DO uses
        (-DoAdapter, Select-DDDoAdapter), with the default route's subnet kept as `defaultRouteSubnet` when it differs; DHCP
        options 234 and 235 are read on that adapter when DO itself would read them; and the public egress, when egress
        collection is on (-CollectPublicEgress or -PublicIpProbeUri), comes from the DO geo answer first.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Context, [string] $PublicIpProbeUri, [switch] $SkipPublicIp, $VpnKeywords,
        $Raw = $null, [switch] $RawCollected, $DoAdapter = $null, $Geo = $null, $Effective = $null, [switch] $CollectPublicEgress
    )

    $raw = $Raw
    if (-not $RawCollected) { $raw = Invoke-DDStep -Context $Context -Field 'network' -Script { Get-DDNetworkRaw } }
    $adapters = @(); $active = $null; $ipv4 = $null; $prefix = 24; $gateway = $null
    if ($raw) {
        $adapters = @($raw.Adapters)
        $active = $adapters | Where-Object { $_.IfIndex -eq $raw.DefaultInterfaceIndex } | Select-Object -First 1
        $ipv4 = $raw.IPv4; $gateway = $raw.Gateway
        if ($null -ne $raw.PrefixLength) { $prefix = [int]$raw.PrefixLength }
    }
    $extraKw = @(ConvertTo-DDVpnKeywordList -Value $VpnKeywords)
    $upVpn = @($adapters | Where-Object { $_.Status -eq 'Up' -and (Test-DDVpnAdapter -Adapter $_ -ExtraKeywords $extraKw) })
    # Which keyword marked each adapter, so the console can say why DoctorDO calls it a VPN; and whether DO's own default list
    # alone would (the documented keywords, without DoctorDO's client names or the configured ones).
    $matchedKw = @($upVpn | ForEach-Object { Get-DDVpnKeywordMatch -Adapter $_ -ExtraKeywords $extraKw } | Where-Object { $_ } | Select-Object -Unique -First 16 | ForEach-Object { $_.Substring(0, [math]::Min(64, $_.Length)) })
    $doDefault = @($upVpn | Where-Object { $t = ('{0} {1}' -f $_.Name, $_.Description).ToLowerInvariant(); @($script:DDDoDefaultVpnKeywords | Where-Object { $t.Contains($_) }).Count -gt 0 })
    $routeSubnet = $null
    if ($ipv4) { $routeSubnet = Get-DDSubnetString -Address $ipv4 -PrefixLength $prefix }
    $subnet = $routeSubnet
    $doAdapterOut = $null
    if ($null -ne $DoAdapter) {
        $doSubnet = Get-DDSubnetString -Address $DoAdapter.IPv4 -PrefixLength $DoAdapter.PrefixLength
        $doAdapterOut = [ordered]@{
            name = $(if ($Context.Anonymise) { $null } else { [string]$DoAdapter.Name })
            kind = [string]$DoAdapter.Kind; type = [string]$DoAdapter.Type; ifType = [int]$DoAdapter.IfType; subnet = $doSubnet
            defaultRoute = $DoAdapter.DefaultRoute
        }
        if ($doSubnet) { $subnet = $doSubnet; $ipv4 = $DoAdapter.IPv4; $prefix = [int]$DoAdapter.PrefixLength }
    }
    if ($gateway -and $Context.Anonymise) { $gateway = Protect-DDTopologyValue -Value $gateway -Salt $Context.Salt -Tag 'ip' }

    $cost = Invoke-DDStep -Context $Context -Field 'network.isMetered' -Script { Get-DDConnectionCost }
    if ($null -eq $cost) { Add-DDNotCollected -Context $Context -Field 'network.isMetered' -Reason 'unsupported-os' -Detail 'NetworkCostType needs the Windows Runtime (Windows PowerShell 5.1); reported as false' }
    $metered = ($cost -and $cost -ne 'Unrestricted' -and $cost -ne 'Unknown')

    $adSite = Invoke-DDStep -Context $Context -Field 'network.adSite' -Script { Get-DDAdSiteName }
    $internet = Invoke-DDStep -Context $Context -Field 'network.proxy' -Script { Get-DDInternetSettings }
    $netsh = Invoke-DDStep -Context $Context -Field 'network.proxy.netsh' -Script { Get-DDNetshWinHttpProxy } -Default @()
    $isSystem = Test-DDSystemContext
    if ($isSystem) { Add-DDNotCollected -Context $Context -Field 'network.proxy.wininetScope' -Reason 'insufficient-rights' -Detail 'per-user WinINET proxy is not observable from the SYSTEM account; device-wide and netsh are reported' }
    $proxy = [ordered]@{ configured = $false; wininetScope = 'none'; netshConfigured = $false; autoDetect = $false; pacUrlHost = $null; bypassLocal = $false }
    if ($internet) { $proxy = ConvertFrom-DDProxyState -Internet $internet -Netsh $netsh -IsSystem:$isSystem }
    if ($proxy['pacUrlHost'] -and $Context.Anonymise) { $proxy['pacUrlHost'] = Protect-DDTopologyValue -Value $proxy['pacUrlHost'] -Salt $Context.Salt }

    # DHCP options 234 and 235, on the adapter DO uses, only when DO itself would read them (Health.ps1).
    $dhcp = Get-DDDhcpFacts -Context $Context -Effective $Effective -Adapter $DoAdapter -Geo $Geo
    $v234 = $dhcp.Values['234']; $v235 = $dhcp.Values['235']
    foreach ($id in @($dhcp.Facts['wants'])) {
        if ($dhcp.Facts['state'] -ne 'answered') { Add-DDNotCollected -Context $Context -Field "network.dhcpOption$id" -Reason $(if ($dhcp.Facts['state'] -eq 'timeout') { 'timeout' } else { 'error' }) -Detail "DHCP option $id could not be read ($($dhcp.Facts['state']))" }
    }
    if ($Context.Anonymise) {
        # Hashed with the tenant's salt, like DOGroupId, so devices given the same group compare equal (linkable within the tenant).
        if ($v234) { $v234 = Protect-DDTopologyValue -Value $v234 -Salt $Context.TenantSalt -Tag 'redacted' }
        # DHCP option 235 names the Connected Cache server: infrastructure, kept in clear (ADR-0031).
    }

    $merge = Invoke-DDStep -Context $Context -Field 'network.localPolicyMergeEnabled' -Script { Get-DDFirewallProfilesRaw }
    $lpm = $null
    if ($merge) { $lpm = -not [bool](@($merge) | Where-Object { $_.AllowLocalFirewallRules -eq 'False' }) }

    # Public egress: only when egress collection is on. The DO geo answer (Microsoft's own service, which DO already
    # calls) is the source; -PublicIpProbeUri is the fallback when it gave none. Hashed at once with the tenant salt, as
    # before (a hash of an address can be reversed by enumeration by anyone who knows the tenant ID; unchanged here).
    $egress = $null
    $egressOn = ([bool]$PublicIpProbeUri -or $CollectPublicEgress) -and -not $SkipPublicIp
    if ($egressOn) {
        $ip = $(if ($null -ne $Geo -and $Geo.ExternalIp) { [string]$Geo.ExternalIp } else { $null })
        if (-not $ip -and $PublicIpProbeUri) { $ip = Invoke-DDStep -Context $Context -Field 'network.publicEgressHash' -Script { Invoke-DDPublicIpProbe -Uri $PublicIpProbeUri } }
        if ($ip) { $egress = Get-DDSaltedHash -Value $ip -Salt $Context.TenantSalt -Domain 'egress' }
        else { Add-DDNotCollected -Context $Context -Field 'network.publicEgressHash' -Reason 'error' -Detail 'egress collection is on, but neither the DO geo answer nor the probe URI gave a public address' }
        $ip = $null
    } else {
        Add-DDNotCollected -Context $Context -Field 'network.publicEgressHash' -Reason 'skipped-by-flag' -Detail 'egress collection is off: -CollectPublicEgress reads it from the DO geo answer; DoctorDO calls no third-party IP service by default'
    }

    $net = [ordered]@{
        adapterType               = (ConvertTo-DDAdapterType -Adapter $active -ExtraKeywords $extraKw)
        isVpnActive               = ($upVpn.Count -gt 0)
        vpnAdapterNames           = @($upVpn | Select-Object -First 16 | ForEach-Object { [string]$_.Name })
        matchesDefaultVpnKeywords = ($doDefault.Count -gt 0)
        vpnMatchedKeywords        = [string[]]$matchedKw
        isMetered                 = [bool]$metered
        localSubnet               = $subnet
        defaultGateway            = $gateway
        publicEgressHash          = $egress
        adSite                    = $adSite
        dhcpOption234             = $v234
        dhcpOption235             = $v235
        localPolicyMergeEnabled   = $lpm
        proxy                     = $proxy
    }
    if ($null -ne $doAdapterOut) { $net['doAdapter'] = $doAdapterOut }
    if ($null -ne $doAdapterOut -and $routeSubnet -and $routeSubnet -ne $subnet) { $net['defaultRouteSubnet'] = $routeSubnet }
    $net['dhcp'] = $dhcp.Facts
    # LocalNetworks: the device's IPv6 prefixes (in memory only), for Test-DDSameSubnet -LocalNetworks.
    $v6 = @(Get-DDProp $raw 'IPv6Networks' | Where-Object { $_ } | ForEach-Object { [string]$_ })
    [pscustomobject]@{ Network = $net; IPv4 = $ipv4; PrefixLength = $prefix; LocalNetworks = [string[]]$v6; FirewallProfiles = @($merge); Dhcp = $dhcp.Facts; Dhcp235 = $dhcp.Values['235'] }
}

function Get-DDEditionSupported {
    [CmdletBinding()]
    param([string] $EditionId)
    # DO policies apply to Pro, Enterprise, Education and IoT Enterprise (DMClient/DO policy CSP applicability tables).
    [bool]($EditionId -match '(?i)Professional|Enterprise|Education|IoTEnterprise|Server')
}

function ConvertTo-DDChassis {
    [CmdletBinding()]
    param([int[]] $ChassisTypes = @(), [bool] $IsVirtual)
    if ($IsVirtual) { return 'VM' }
    foreach ($t in $ChassisTypes) {
        if ($t -in 8, 9, 10, 14, 31, 32) { return 'Laptop' }
        if ($t -in 30) { return 'Tablet' }
        if ($t -in 17, 23, 28) { return 'Server' }
        if ($t -in 3, 4, 5, 6, 7, 13, 15, 16, 24, 34, 35, 36) { return 'Desktop' }
    }
    'Unknown'
}

function Get-DDOsFacts {
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Context)

    $cv = 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
    $r = Invoke-DDStep -Context $Context -Field 'os' -Script {
        [pscustomobject]@{
            Major   = Get-DDRegistryValue -Path $cv -Name 'CurrentMajorVersionNumber'
            Minor   = Get-DDRegistryValue -Path $cv -Name 'CurrentMinorVersionNumber'
            Build   = Get-DDRegistryValue -Path $cv -Name 'CurrentBuild'
            Ubr     = Get-DDRegistryValue -Path $cv -Name 'UBR'
            Edition = Get-DDRegistryValue -Path $cv -Name 'EditionID'
            Product = Get-DDRegistryValue -Path $cv -Name 'ProductName'
            Display = $(if (Get-DDRegistryValue -Path $cv -Name 'DisplayVersion') { Get-DDRegistryValue -Path $cv -Name 'DisplayVersion' } else { Get-DDRegistryValue -Path $cv -Name 'ReleaseId' })
            Install = Get-DDRegistryValue -Path $cv -Name 'InstallDate'
        }
    }
    if ($null -eq $r -or -not $r.Build) { return $null }
    $major = $(if ($null -ne $r.Major) { [int]$r.Major } else { 10 })
    $minor = $(if ($null -ne $r.Minor) { [int]$r.Minor } else { 0 })
    $build = '{0}.{1}.{2}' -f $major, $minor, $r.Build
    $product = [string]$r.Product
    # Windows 11 still reports "Windows 10 ..." in ProductName; build 22000+ is Windows 11 (observed, widely documented behaviour).
    if ([int]$r.Build -ge 22000 -and $product -match '^Windows 10') { $product = $product -replace '^Windows 10', 'Windows 11' }
    $ubr = $(if ($null -ne $r.Ubr) { [int]$r.Ubr } else { $null })
    $install = $null
    # InstallDate counts seconds since 1970-01-01 UTC; the date is the UTC one. (Adding the seconds to a local 1970 time, as
    # before, shifted a date near midnight by the daylight-saving difference.)
    if ($null -ne $r.Install) { $install = [System.DateTimeOffset]::FromUnixTimeSeconds([int64][uint32]$r.Install).UtcDateTime.ToString('yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture) }
    [pscustomobject]@{
        Os = [ordered]@{ build = $build; ubr = $ubr; edition = $(if ($product) { $product } else { [string]$r.Edition }); displayVersion = $(if ($r.Display) { [string]$r.Display } else { $null }); installDate = $install }
        FullBuild = $(if ($null -ne $ubr) { "$build.$ubr" } else { $build })
        EditionId = [string]$r.Edition
    }
}

function Get-DDHardwareFacts {
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Context)

    $bios = 'HKLM\HARDWARE\DESCRIPTION\System\BIOS'
    $dev = Invoke-DDStep -Context $Context -Field 'hardware.devDetail' -Script { Get-DDDevDetail }
    $manu = Get-DDProp $dev 'OEM'
    $model = Get-DDProp $dev 'DevTyp'
    if (-not $manu) { $manu = Invoke-DDStep -Context $Context -Field 'hardware.manufacturer' -Script { Get-DDRegistryValue -Path $bios -Name 'SystemManufacturer' } }
    if (-not $model) { $model = Invoke-DDStep -Context $Context -Field 'hardware.model' -Script { Get-DDRegistryValue -Path $bios -Name 'SystemProductName' } }
    $biosVersion = Invoke-DDStep -Context $Context -Field 'hardware.biosVersion' -Script { Get-DDRegistryValue -Path $bios -Name 'BIOSVersion' }
    if ($biosVersion -is [array]) { $biosVersion = $biosVersion -join ' ' }
    $board = @(Invoke-DDStep -Context $Context -Field 'hardware.serialHash' -Script { Get-DDCim -ClassName 'Win32_BaseBoard' } -Default @())
    $serialHash = $null
    if ($board.Count -gt 0) {
        $sn = [string](Get-DDProp $board[0] 'SerialNumber')
        if ($sn -and $sn.Trim() -notmatch $script:DDPlaceholderSerial) { $serialHash = Get-DDSaltedHash -Value $sn.Trim() -Salt $Context.Salt -Domain 'serial' }
        else { Add-DDNotCollected -Context $Context -Field 'hardware.serialHash' -Reason 'error' -Detail 'baseboard serial number is empty or a placeholder' }
    }
    $cpus = @(Invoke-DDStep -Context $Context -Field 'hardware.cpu' -Script { Get-DDCim -ClassName 'Win32_Processor' } -Default @())
    $cpuName = $null; $cores = $null
    if ($cpus.Count -gt 0) {
        $cpuName = ([string](Get-DDProp $cpus[0] 'Name')).Trim()
        $sum = 0; foreach ($c in $cpus) { $n = Get-DDProp $c 'NumberOfLogicalProcessors'; if ($n) { $sum += [int]$n } }
        if ($sum -gt 0) { $cores = $sum }
    }
    $tpm = $null
    $tpmRaw = Invoke-DDStep -Context $Context -Field 'hardware.tpm' -Script { Get-DDTpmRaw }
    if ($tpmRaw) { $ver = ([string]$tpmRaw.SpecVersion).Split(',')[0].Trim(); $tpm = [ordered]@{ present = $true; version = $(if ($ver) { $ver } else { $null }) } }
    elseif ($null -ne $tpmRaw) { $tpm = $null }
    $disks = New-Object 'System.Collections.Generic.List[object]'
    foreach ($d in @(Invoke-DDStep -Context $Context -Field 'hardware.disks' -Script { Get-DDPhysicalDisks } -Default @())) {
        if ($disks.Count -ge 8 -or $d.Size -le 0) { continue }
        $kind = 'Unknown'
        if ($d.BusType -eq 17) { $kind = 'NVMe' } elseif ($d.MediaType -eq 4) { $kind = 'SSD' } elseif ($d.MediaType -eq 3) { $kind = 'HDD' }
        $disks.Add([ordered]@{ kind = $kind; sizeGB = [math]::Round($d.Size / 1GB, 1) })
    }
    [ordered]@{
        manufacturer = $(if ($manu) { ([string]$manu).Trim() } else { $null })
        model        = $(if ($model) { ([string]$model).Trim() } else { $null })
        serialHash   = $serialHash
        biosVersion  = $(if ($biosVersion) { ([string]$biosVersion).Trim() } else { $null })
        cpu          = [ordered]@{ name = $cpuName; logicalCores = $cores }
        tpm          = $tpm
        disks        = $disks.ToArray()
    }
}

function Get-DDManagementFacts {
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Context, $Dsreg, [bool] $IsConfigMgrClient, $ConfigMgr)

    $enrol = @(Invoke-DDStep -Context $Context -Field 'management.mdm' -Script { Get-DDEnrollmentRecords } -Default @())
    $intune = $enrol | Where-Object { $_.ProviderId -eq 'MS DM Server' -or ([string]$_.DiscoveryUrl -match 'manage\.microsoft\.com') } | Select-Object -First 1
    $other = $enrol | Where-Object { $_.ProviderId } | Select-Object -First 1
    $rec = $(if ($intune) { $intune } else { $other })
    $guid = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$'
    $intuneId = $null
    if ($intune -and [string]$intune.EntDmId -match $guid) { $intuneId = ([string]$intune.EntDmId).ToLowerInvariant() }
    if ($intune -and -not $intuneId) { $intuneId = Invoke-DDStep -Context $Context -Field 'management.mdm.intuneDeviceId' -Script { Get-DDIntuneDeviceIdFromCertificate } }
    $serverHost = $null
    if ($rec -and $rec.DiscoveryUrl) { $serverHost = Get-DDUrlHost -Url ([string]$rec.DiscoveryUrl) }
    $mdm = [ordered]@{
        enrolled       = [bool]$rec
        provider       = $(if ($intune) { 'intune' } elseif ($rec) { 'other' } else { 'none' })
        enrolmentType  = $(if ($rec -and $null -ne $rec.EnrollmentType) { [string]$rec.EnrollmentType } else { $null })
        intuneDeviceId = $intuneId
        serverHost     = $serverHost
    }
    $join = 'none'; $entraId = $null; $tenant = $null
    if ($Dsreg) { $join = $Dsreg.JoinType; $entraId = $Dsreg.DeviceId; $tenant = $Dsreg.TenantId }
    else { Add-DDNotCollected -Context $Context -Field 'management.joinType' -Reason 'error' -Detail 'dsregcmd /status unavailable or unparseable' }

    $wuPol = 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'
    $useWu = Invoke-DDStep -Context $Context -Field 'management.updateSource' -Script { Get-DDRegistryValue -Path "$wuPol\AU" -Name 'UseWUServer' }
    $source = 'wufb'
    if ($null -ne $useWu -and [int]$useWu -eq 1) { $source = $(if ($IsConfigMgrClient) { 'configmgr' } else { 'wsus' }) }
    elseif ($null -eq $useWu -and $Context.NotCollected.Exists([Predicate[object]] { param($e) $e['field'] -eq 'management.updateSource' })) { $source = 'unknown' }

    $cm = $null
    if ($ConfigMgr) {
        $flags = $(if ($ConfigMgr.PSObject.Properties['CoManagementFlags']) { $ConfigMgr.CoManagementFlags } else { $null })
        $cm = [ordered]@{ client = [bool]$ConfigMgr.Client; siteCode = $ConfigMgr.SiteCode; boundaryGroups = @(); coManagementFlags = $flags }
    }
    Add-DDNotCollected -Context $Context -Field 'management.autopilot' -Reason 'error' -Detail 'Autopilot group tag: Windows keeps no documented copy on the device (it belongs to the Autopilot registration in Intune); left empty'
    if ($ConfigMgr -and $ConfigMgr.Client) { Add-DDNotCollected -Context $Context -Field 'management.configMgr.boundaryGroups' -Reason 'error' -Detail 'boundary groups are not read (no verified on-device source); reported empty' }
    [ordered]@{
        joinType     = $join
        entraDeviceId = $entraId
        tenantId     = $tenant
        mdm          = $mdm
        configMgr    = $cm
        autopilot    = $null
        updateSource = $source
    }
}

function Get-DDDoStoreDumps {
    # Every value in each Delivery Optimization registry store, unredacted (in memory only). A store that could not be read
    # is absent and named in notCollected.
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Context)

    $defs = [ordered]@{
        policy                 = @('HKLM\SOFTWARE\Policies\Microsoft\Windows\DeliveryOptimization', 'documented')
        # Layout observed on Windows 11 26H2 (an Intune-managed device): <Name>, <Name>_ProviderSet, <Name>_WinningProvider. Microsoft
        # documents the Policy CSP, not this registry store, so it is tagged to-verify (the contract has no "observed" value).
        mdm                    = @('HKLM\SOFTWARE\Microsoft\PolicyManager\current\device\DeliveryOptimization', 'to-verify')
        serviceConfig          = @('HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\DeliveryOptimization\Config', 'to-verify')
        userSettings           = @('HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\DeliveryOptimization\Settings', 'to-verify')
        # The Settings app's choices live in the NetworkService profile DoSvc runs as (readable only elevated).
        networkServiceSettings = @('HKU\S-1-5-20\Software\Microsoft\Windows\CurrentVersion\DeliveryOptimization\Settings', 'to-verify')
        serviceState           = @('HKLM\SYSTEM\CurrentControlSet\Services\DoSvc', 'documented')
    }
    $dumps = [ordered]@{}
    foreach ($id in $defs.Keys) {
        $key = $defs[$id][0]
        $dump = Invoke-DDStep -Context $Context -Field "doConfig.stores.$id" -Script { Get-DDRegistryValues -Path $key }
        if ($null -eq $dump) { continue }
        $dumps[$id] = @{ Key = $key; Present = [bool]$dump.Present; Values = @($dump.Values | Select-Object -First 256); Confidence = $defs[$id][1] }
    }
    $dumps
}

function Get-DDDoStores {
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Context, $Dumps = $null)

    if ($null -eq $Dumps) { $Dumps = Get-DDDoStoreDumps -Context $Context }
    $stores = [ordered]@{}
    foreach ($id in $Dumps.Keys) {
        $d = $Dumps[$id]
        $values = @($d.Values)
        if ($Context.Anonymise) { $values = Protect-DDRegistryValues -Values $values -Salt $Context.TenantSalt }
        $stores[$id] = [ordered]@{ key = $d.Key; present = [bool]$d.Present; values = @($values); confidence = $d.Confidence }
    }
    $stores
}

function Get-DDDoConfiguration {
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Context, [AllowEmptyCollection()] $Enrollments = @())

    $effective = [ordered]@{}
    $kinds = @{}
    $workingDirectory = $null
    $vpnKeywords = $null
    if (Test-DDDeliveryOptimizationModule) {
        $cfg = Invoke-DDStep -Context $Context -Field 'doConfig.effective' -Script { Get-DDDOConfigRaw }
        if ($cfg) {
            $parsed = ConvertFrom-DDDoConfig -Config $cfg.Config -VerboseLines $cfg.Verbose
            $effective = $parsed.Effective
            $kinds = $parsed.Kinds
            $workingDirectory = $parsed.WorkingDirectory
            $vpnKeywords = $parsed.VpnKeywords
            if ($effective.Count -eq 0) { Add-DDNotCollected -Context $Context -Field 'doConfig.effective' -Reason 'error' -Detail 'Get-DOConfig returned no property the collector recognises (this Windows build may name them differently)' }
        }
    } else {
        Add-DDNotCollected -Context $Context -Field 'doConfig.effective' -Reason 'cmdlet-missing' -Detail 'DeliveryOptimization module not available (Windows 10 1703+ / Windows 11)'
    }
    $dumps = Get-DDDoStoreDumps -Context $Context
    # MDMWinsOverGP: its meaning is documented (Policy CSP ControlPolicyConflict); the registry path it is read from is observed.
    $wins = Invoke-DDStep -Context $Context -Field 'doConfig.mdmWinsOverGp' -Script { Get-DDRegistryValue -Path 'HKLM\SOFTWARE\Microsoft\PolicyManager\current\device\ControlPolicyConflict' -Name 'MDMWinsOverGP' }
    $mdmWins = $(if ($null -eq $wins) { $null } else { [int]$wins -eq 1 })
    $pick = { param($id) if ($dumps.Contains($id)) { $dumps[$id] } else { $null } }
    $effective = Resolve-DDSettingSources -Effective $effective -Kinds $kinds -MdmStore (& $pick 'mdm') -PolicyStore (& $pick 'policy') -LocalStore (& $pick 'networkServiceSettings') -Enrollments $Enrollments -MdmWinsOverGp $mdmWins
    if (@($effective.Values | Where-Object { $_['source']['kind'] -eq 'unknown' }).Count -gt 0) {
        Add-DDNotCollected -Context $Context -Field 'doConfig.effective.provider' -Reason 'error' -Detail 'some settings have no provider in Get-DOConfig and no single policy store; reported as unknown'
    }

    $out = [ordered]@{ effective = $effective; stores = $null }
    $out['stores'] = Get-DDDoStores -Context $Context -Dumps $dumps
    if ($workingDirectory) { $out['workingDirectory'] = (Protect-DDLogText -Text ([string]$workingDirectory)) }
    $out['mdmWinsOverGp'] = $mdmWins
    $out['cacheFolder'] = Get-DDCacheFolderFacts -Context $Context -WorkingDirectory $(if ($workingDirectory) { [string]$workingDirectory } else { $null })
    $out['_vpnKeywords'] = $vpnKeywords
    $out['_workingDirectory'] = $workingDirectory
    $out['_rawEffective'] = $effective
    if ($Context.Anonymise) {
        # The group ID is organisation topology, hashed with the tenant's salt so that devices in one group compare equal across the
        # tenant (as peers do), and differ between tenants. The Connected Cache server is infrastructure and stays in clear (ADR-0031).
        $hide = { param($k, $v) if ($k -match '(?i)^DOGroupId$' -and $v) { Protect-DDTopologyValue -Value ([string]$v) -Salt $Context.TenantSalt -Tag 'redacted' } else { $v } }
        $anon = [ordered]@{}
        foreach ($k in $effective.Keys) {
            $e = $effective[$k]
            $src = $e['source']
            $alt = @(foreach ($a in @($src['alt'])) { [ordered]@{ store = $a['store']; value = (& $hide $k $a['value']) } })
            $anon[$k] = [ordered]@{
                value    = (& $hide $k $e['value'])
                provider = $e['provider']
                source   = [ordered]@{ kind = $src['kind']; mdm = $src['mdm']; basis = $src['basis']; setIn = $src['setIn']; alt = $alt }
            }
        }
        $out['effective'] = $anon
    }
    $out
}

function ConvertFrom-DDFirewallRaw {
    # A rule's display name and program pass through the log-line scrub: a per-user rule names a profile path such as
    # C:\Users\<name>\AppData\Local\...\app.exe. With an anonymised -Context the machine name and addresses go too.
    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyCollection()] $Rules, $Context = $null)

    $scrub = {
        param([string] $s)
        if ($null -eq $Context) { return (Protect-DDLogText -Text $s) }
        Protect-DDLogText -Text $s -Anonymise:([bool]$Context.Anonymise) -Salt ([string]$Context.Salt) -ComputerName ([string]$Context.ComputerName)
    }
    $out = New-Object 'System.Collections.Generic.List[object]'
    foreach ($r in @($Rules)) {
        if ($out.Count -ge 64) { break }
        $profiles = New-Object 'System.Collections.Generic.List[string]'
        $p = [string]$r.Profile
        if ($p -match '(?i)\bAny\b|\bAll\b') { $profiles.AddRange([string[]]@('Domain', 'Private', 'Public')) }
        else { foreach ($n in 'Domain', 'Private', 'Public') { if ($p -match $n) { $profiles.Add($n) } } }
        $dir = $(if ([string]$r.Direction -match '(?i)out') { 'Outbound' } else { 'Inbound' })
        $act = $(if ([string]$r.Action -match '(?i)block') { 'Block' } else { 'Allow' })
        $row = [ordered]@{
            displayName = (& $scrub ([string]$r.DisplayName))
            direction   = $dir
            action      = $act
            enabled     = ([string]$r.Enabled -match '(?i)^(true|1)$')
            protocol    = [string]$r.Protocol
            localPort   = [string]$r.LocalPort
            program     = $(if ($r.Program) { & $scrub ([string]$r.Program) } else { $null })
            service     = $(if ($r.Service) { [string]$r.Service } else { $null })
            profiles    = $profiles.ToArray()
        }
        # Collectors from October 2026: the remote port, whether the rule is in force, and the remote address condition as
        # a category and a count. The addresses themselves are dropped here.
        $rp = Get-DDProp $r 'RemotePort'
        if ($null -ne $rp) { $s = [string]$rp; $row['remotePort'] = $s.Substring(0, [math]::Min(64, $s.Length)) }
        $ps = [string](Get-DDProp $r 'PrimaryStatus')
        if ($ps) { $row['primaryStatus'] = $(if ($ps -in 'OK', 'Inactive', 'Error') { $ps } else { 'Unknown' }) }
        $es = @(@(Get-DDProp $r 'EnforcementStatus') | Where-Object { [string]$_ -match '^[A-Za-z]{1,40}$' -and [string]$_ -ne 'NotApplicable' } | ForEach-Object { [string]$_ } | Select-Object -First 6)
        if ($es.Count -gt 0) { $row['enforcementStatus'] = [string[]]$es }
        if ($null -ne $r.PSObject.Properties['RemoteAddress']) {
            $scope = Get-DDRemoteScope -Addresses ([string[]]@($r.RemoteAddress))
            $row['remoteScope'] = $scope.Scope
            $row['remoteAddresses'] = [int]$scope.Count
        }
        $out.Add($row)
    }
    , $out.ToArray()
}

function Get-DDFirewallFacts {
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Context)
    $raw = Invoke-DDStep -Context $Context -Field 'firewallRules' -Script { Get-DDFirewallRulesRaw }
    if ($null -eq $raw) { return , @() }
    ConvertFrom-DDFirewallRaw -Rules $raw -Context $Context
}

function Get-DDCacheDriveName {
    # The drive Delivery Optimization caches on, as "D:": the drive of the working directory the service reports (Get-DOConfig
    # WorkingDirectory, documented), else the system drive. The disk-size peering check and the free-space reading follow it
    # (Learn: "If the Modify Cache Drive policy is configured, the disk size check applies to the new working directory"
    # https://learn.microsoft.com/windows/deployment/do/waas-delivery-optimization-reference#minimum-disk-size-allowed-to-use-peer-caching).
    [CmdletBinding()]
    param([string] $WorkingDirectory)
    $drive = $(if ($env:SystemDrive) { $env:SystemDrive } else { 'C:' })
    if ($WorkingDirectory -match '^[A-Za-z]:') { $drive = $WorkingDirectory.Substring(0, 2).ToUpperInvariant() }
    $drive
}

function Get-DDStaticDeviceFacts {
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Context, $Os, $Dsreg, [bool] $IsConfigMgrClient, [string] $WorkingDirectory)

    $cs = @(Invoke-DDStep -Context $Context -Field 'device.computerSystem' -Script { Get-DDCim -ClassName 'Win32_ComputerSystem' } -Default @())
    $enc = @(Invoke-DDStep -Context $Context -Field 'device.chassis' -Script { Get-DDCim -ClassName 'Win32_SystemEnclosure' } -Default @())
    $model = [string](Get-DDProp ($cs | Select-Object -First 1) 'Model')
    $manu = [string](Get-DDProp ($cs | Select-Object -First 1) 'Manufacturer')
    $isVirtual = ($model -match '(?i)virtual|vmware|kvm|bochs|qemu|hyper-v') -or ($manu -match '(?i)vmware|qemu|xen|microsoft corporation' -and $model -match '(?i)virtual')
    $types = @()
    if ($enc.Count -gt 0) { $types = @($enc[0].ChassisTypes | ForEach-Object { [int]$_ }) }
    $chassis = ConvertTo-DDChassis -ChassisTypes $types -IsVirtual $isVirtual

    $memBytes = $null
    if ($cs.Count -gt 0 -and (Get-DDProp $cs[0] 'TotalPhysicalMemory')) { $memBytes = [double](Get-DDProp $cs[0] 'TotalPhysicalMemory') }
    if (-not $memBytes) { $memBytes = Invoke-DDStep -Context $Context -Field 'device.ramGB' -Script { Get-DDSystemMemoryBytes } }
    $sysDrive = $(if ($env:SystemDrive) { $env:SystemDrive } else { 'C:' })
    $disks = @(Invoke-DDStep -Context $Context -Field 'device.disks' -Script { Get-DDLocalDisks } -Default @())
    $sys = $disks | Where-Object { $_.DeviceId -eq $sysDrive } | Select-Object -First 1
    $sysSize = $null
    if ($sys) { $sysSize = [double]$sys.Size }
    else { $sysSize = Invoke-DDStep -Context $Context -Field 'device.systemDiskGB' -Script { (Get-DDDriveInfo -Name $sysDrive).TotalSize } }

    # Cache drive: the drive of the DO working directory as the service reports it (Get-DOConfig WorkingDirectory,
    # documented), else the system drive. The disk-size peering check follows it.
    $cacheDrive = Get-DDCacheDriveName -WorkingDirectory $WorkingDirectory
    $cacheDisk = $disks | Where-Object { $_.DeviceId -eq $cacheDrive } | Select-Object -First 1
    $cacheGB = $null
    if ($cacheDisk) { $cacheGB = [math]::Round($cacheDisk.Size / 1GB, 1) }

    $edition = $(if ($Os) { $Os.Os['edition'] } else { $null })
    $deviceId = $null
    $deviceId = Get-DDSaltedHash -Value ('{0}|{1}' -f (Get-DDHostName), (Get-DDMachineGuid)) -Salt $Context.Salt -Domain 'device'
    $d = [ordered]@{
        deviceId           = $deviceId
        hostname           = $(if ($Context.Anonymise) { $null } else { Get-DDHostName })
        osBuild            = $(if ($Os) { $Os.FullBuild } else { $null })
        osEdition          = $edition
        isSupportedEdition = $(if ($Os) { Get-DDEditionSupported -EditionId $Os.EditionId } else { $false })
        isVirtual          = [bool]$isVirtual
        ramGB              = $(if ($memBytes) { [math]::Round($memBytes / 1GB, 1) } else { $null })
        systemDiskGB       = $(if ($sysSize) { [math]::Round($sysSize / 1GB, 1) } else { $null })
        cacheDriveGB       = $cacheGB
        chassis            = $chassis
        isConfigMgrClient  = $IsConfigMgrClient
        domainJoined       = $(if ($Dsreg) { [bool]$Dsreg.DomainJoined } else { $false })
        entraJoined        = $(if ($Dsreg) { [bool]$Dsreg.AzureAdJoined } else { $false })
    }
    # Collectors from October 2026: the active power plan beside the battery state, and the Unified Update Stack version
    # (a file Microsoft's DeliveryOptimizationTroubleshooter reads; not a documented interface, so absent is not an error).
    $plan = Invoke-DDStep -Context $Context -Field 'device.powerPlan' -Script { Get-DDActivePowerPlan }
    $d['powerPlan'] = $(if ($plan) { $s = [string]$plan; $s.Substring(0, [math]::Min(128, $s.Length)) } else { $null })
    $uus = Invoke-DDStep -Context $Context -Field 'device.uusVersion' -Script { Get-DDUusVersionRaw }
    $d['uusVersion'] = $(if ($uus -and ([string]$uus).Trim() -match '^[0-9A-Za-z.\-]{1,64}$') { ([string]$uus).Trim() } else { $null })
    foreach ($req in 'osBuild', 'osEdition', 'ramGB', 'systemDiskGB') {
        if ($null -eq $d[$req]) { Add-DDNotCollected -Context $Context -Field "device.$req" -Reason 'error' -Detail 'required inventory field could not be collected from any source' }
    }
    $d
}

function Get-DDSystemMemoryBytes {
    [CmdletBinding()]
    param()
    Add-Type -Verbose:$false -AssemblyName Microsoft.VisualBasic
    [double](New-Object Microsoft.VisualBasic.Devices.ComputerInfo).TotalPhysicalMemory
}

function Get-DDDriveInfo {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string] $Name)
    New-Object System.IO.DriveInfo($Name)
}

function Get-DDDeviceVitals {
    <#
    .SYNOPSIS
        The volatile device facts that ride with every telemetry report (battery, free space, cache use).
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)] $Context, [string] $WorkingDirectory)

    $sysDrive = $(if ($env:SystemDrive) { $env:SystemDrive } else { 'C:' })
    $disks = @(Invoke-DDStep -Context $Context -Field 'vitals.systemDiskFreeGB' -Script { Get-DDLocalDisks } -Default @())
    $sys = $disks | Where-Object { $_.DeviceId -eq $sysDrive } | Select-Object -First 1
    # $null, never 0, when the system drive is not among the local disks: 0 reads as "full".
    $free = $(if ($sys) { [math]::Round($sys.FreeSpace / 1GB, 1) } else { $null })
    if ($null -eq $free -and @($disks).Count -gt 0) { Add-DDNotCollected -Context $Context -Field 'vitals.systemDiskFreeGB' -Reason 'error' -Detail "the system drive $sysDrive is not among the local disks" }
    # Free space on the cache drive (docs/27 3.6): $null, never 0, when that drive is not among the local disks, because 0 reads as "full".
    $cacheDisk = $disks | Where-Object { $_.DeviceId -eq (Get-DDCacheDriveName -WorkingDirectory $WorkingDirectory) } | Select-Object -First 1
    $cacheFree = $(if ($cacheDisk) { [math]::Round($cacheDisk.FreeSpace / 1GB, 1) } else { $null })
    $bat = @(Invoke-DDStep -Context $Context -Field 'vitals.battery' -Script { Get-DDBatteryRaw } -Default @())
    $onBattery = $false; $pct = $null; $low = $null
    if ($bat.Count -gt 0) {
        $b = $bat[0]
        # Win32_Battery BatteryStatus: 1 Other ("The battery is discharging"), 2 Unknown ("The system has access to AC"), 3 Fully
        # Charged, 4 Low, 5 Critical, 6 to 9 Charging (and High, Low, Critical), 10 Undefined, 11 Partially Charged. Low and
        # Critical are read as on battery and low (Learn does not say whether AC is connected then; DoctorDO's reading): that is
        # when DO stops uploading, so they must not read as AC.
        $status = [int](Get-DDProp $b 'BatteryStatus')
        $onBattery = ($status -in 1, 4, 5)
        $low = ($status -in 4, 5)
        $est = Get-DDProp $b 'EstimatedChargeRemaining'
        if ($null -ne $est) { $pct = [math]::Min(100, [math]::Max(0, [double]$est)) }
    }
    $cacheUsed = $null
    $cachePath = $WorkingDirectory
    if (-not $cachePath -and $env:SystemRoot) { $cachePath = Join-Path $env:SystemRoot 'ServiceProfiles\NetworkService\AppData\Local\Microsoft\Windows\DeliveryOptimization\Cache' }
    if ($cachePath) {
        $sz = Invoke-DDStep -Context $Context -Field 'vitals.cacheUsedBytes' -Script { Get-DDDirectorySize -Path $cachePath }
        if ($null -ne $sz) { $cacheUsed = ([long]$sz).ToString([System.Globalization.CultureInfo]::InvariantCulture) }
    }
    # batteryLow: $null without a battery reading (no battery, or the read failed: then vitals.battery is in notCollected).
    [ordered]@{ onBattery = $onBattery; batteryPercent = $pct; batteryLow = $low; systemDiskFreeGB = $free; cacheDriveFreeGB = $cacheFree; cacheUsedBytes = $cacheUsed }
}