src/Private/Get-AdAccountSignals.ps1

function Get-AdAccountSignals {
    <#
    .SYNOPSIS
        Collects read-only lifecycle signals for user accounts from on-premises Active Directory.
    .DESCRIPTION
        Uses .NET System.DirectoryServices.DirectorySearcher directly - NO RSAT ActiveDirectory
        module required - so it runs on a stock domain-joined machine or DC. Read-only: it only
        issues an LDAP search and never writes.

        Emits one object per enabled-or-disabled user account with the attributes needed to decide
        whether the account is "dead" (disabled or stale). Service/computer accounts are excluded
        by the (objectCategory=person)(objectClass=user) filter.

        Note: lastLogonTimestamp is replicated with up to ~14 days of latency by design, which is
        immaterial for a staleness window measured in months but is documented in docs/permissions.md.
    .OUTPUTS
        PSCustomObject: SamAccountName, UserPrincipalName, DisplayName, ObjectGuid, ObjectSid,
                        DistinguishedName, Domain (DNS of the searched domain), Enabled,
                        UserAccountControl, LastLogonTimestamp (DateTime?), WhenChanged (DateTime?),
                        WhenCreated (DateTime?)
    #>

    [CmdletBinding()]
    param(
        # LDAP search base, e.g. 'OU=Users,DC=contoso,DC=com'. Defaults to the current domain root.
        [string] $SearchBase,

        # Optional explicit server / domain to bind to. Defaults to the machine's domain (RootDSE).
        [string] $Server,

        # Page size for the directory search.
        [int] $PageSize = 1000
    )

    # Must bind RootDSE (not the bare server) to read defaultNamingContext: 'LDAP://<server>' alone
    # binds the domain object, which doesn't carry that constructed attribute. Get-AdRootDsePath adds
    # the required '/RootDSE' suffix - critical for forest mode, where every call names a -Server.
    $rootPath = Get-AdRootDsePath -Server $Server
    if (-not $SearchBase) {
        $rootDse = New-Object System.DirectoryServices.DirectoryEntry($rootPath)
        $SearchBase = [string]$rootDse.Properties['defaultNamingContext'].Value
    }

    # DNS name of the domain actually being searched (from the DC= components of the search base).
    # Stamped on every emitted account so forest scans can show which domain each reclaim candidate came from.
    $domainDns = ConvertTo-DomainDns -DistinguishedName $SearchBase

    $entryPath = if ($Server) { "LDAP://$Server/$SearchBase" } else { "LDAP://$SearchBase" }
    $root = New-Object System.DirectoryServices.DirectoryEntry($entryPath)

    $searcher = New-Object System.DirectoryServices.DirectorySearcher($root)
    $searcher.Filter = '(&(objectCategory=person)(objectClass=user))'
    $searcher.PageSize = $PageSize
    $searcher.SearchScope = 'Subtree'
    foreach ($attr in 'sAMAccountName','userPrincipalName','displayName','objectGUID','objectSid',
                      'distinguishedName','userAccountControl','lastLogonTimestamp','whenChanged','whenCreated') {
        [void] $searcher.PropertiesToLoad.Add($attr)
    }

    $UF_ACCOUNTDISABLE = 0x2

    # Declared before the try so the finally block can reference it even if FindAll() throws
    # (under Set-StrictMode -Version Latest, touching an unassigned variable would otherwise throw
    # and mask the original error).
    $results = $null
    try {
        # Safe single-value read: returns $null when the attribute is absent/empty rather than
        # indexing [0] on an empty collection (which can throw under Set-StrictMode).
        $first = { param($coll) if ($coll -and $coll.Count -gt 0) { $coll[0] } else { $null } }

        $results = $searcher.FindAll()
        foreach ($r in $results) {
            $p = $r.Properties

            $uac = if ($p['useraccountcontrol'].Count) { [int]$p['useraccountcontrol'][0] } else { 0 }

            $lastLogon = $null
            if ($p['lastlogontimestamp'].Count -and [long]$p['lastlogontimestamp'][0] -gt 0) {
                $lastLogon = [DateTime]::FromFileTimeUtc([long]$p['lastlogontimestamp'][0])
            }

            $whenChanged = if ($p['whenchanged'].Count) { [DateTime]$p['whenchanged'][0] } else { $null }

            $whenCreated = if ($p['whencreated'].Count) { [DateTime]$p['whencreated'][0] } else { $null }

            $guid = if ($p['objectguid'].Count) { (New-Object Guid (,[byte[]]$p['objectguid'][0])).ToString() } else { $null }

            # objectSid is the synced anchor used by Entra (onPremisesSecurityIdentifier). It is the
            # most reliable correlation key: independent of which sourceAnchor the tenant configured.
            $sid = if ($p['objectsid'].Count) {
                (New-Object System.Security.Principal.SecurityIdentifier([byte[]]$p['objectsid'][0], 0)).Value
            } else { $null }

            [pscustomobject]@{
                SamAccountName     = [string](& $first $p['samaccountname'])
                UserPrincipalName  = [string](& $first $p['userprincipalname'])
                DisplayName        = [string](& $first $p['displayname'])
                ObjectGuid         = $guid
                ObjectSid          = $sid
                DistinguishedName  = [string](& $first $p['distinguishedname'])
                Domain             = $domainDns
                Enabled            = -not ($uac -band $UF_ACCOUNTDISABLE)
                UserAccountControl = $uac
                LastLogonTimestamp = $lastLogon
                WhenChanged        = $whenChanged
                WhenCreated        = $whenCreated
            }
        }
    }
    finally {
        if ($results) { $results.Dispose() }
        $searcher.Dispose()
    }
}