Public/Get-NSPEntraUserPairCandidate.ps1

function Get-NSPEntraUserPairCandidate {
    <#
    .SYNOPSIS
        Suggests AD users for each cloud user, with reasons. Offline, and decides nothing.
    .DESCRIPTION
        Returns one record per cloud user. Decision is always Undecided: a person accepts a pair,
        however strong the evidence. Names and account-name look-alikes are hints, so a candidate
        with only those is Low confidence.
 
        Status:
          - Suggested: one best candidate.
          - Ambiguous: several candidates tied for best.
          - OneToMany: this user's best candidate is also another cloud user's.
          - LinkedElsewhere: this user's best candidate is already linked to another cloud user.
          - NotFound: no candidate.
          - Linked: an immutable ID already equals this AD user's anchor. Diagnostic.
          - AnchorConflict: an immutable ID is set and equals no AD user's anchor. Diagnostic.
          - SyncedUnmatched: synced, and no AD user here has that anchor. Diagnostic.
          - DuplicateAnchor: two cloud users hold one immutable ID, or two AD users have the anchor
            a cloud user holds. Diagnostic.
          - Excluded: a guest; shown only with -IncludeGuests.
 
        Without a Verified -SourceAnchor, immutable IDs are compared against both objectGUID and
        mS-DS-ConsistencyGuid, and AnchorBasis says Unverified. That helps diagnosis and proves
        nothing about the strategy.
    .EXAMPLE
        Get-NSPEntraUserPairCandidate -Inventory $inventory -SourceAnchor $anchor | Group-Object Status
    #>

    [CmdletBinding(DefaultParameterSetName = 'Inventory')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Inventory')]$Inventory,
        [Parameter(Mandatory, ParameterSetName = 'Supplied')][AllowEmptyCollection()][object[]]$CloudUser,
        [Parameter(Mandatory, ParameterSetName = 'Supplied')][AllowEmptyCollection()][object[]]$ADUser,
        $SourceAnchor,
        [switch]$IncludeGuests
    )

    if ($PSCmdlet.ParameterSetName -eq 'Inventory') {
        $CloudUser = @($Inventory.CloudUsers)
        $ADUser = @($Inventory.ADUsers)
    }
    $verified = $SourceAnchor -and $SourceAnchor.Provenance -eq 'Verified' -and $SourceAnchor.Strategy -ne 'Unknown'
    $anchorBasis = if ($verified) { 'Verified' } else { 'Unverified' }

    function Get-NormalizedName { param([string]$Value) (($Value -replace '\s+', ' ').Trim()).ToLowerInvariant() }
    function Get-SmtpAddress {
        # proxyAddresses entries with their type removed; primary is the uppercase SMTP: entry.
        param([object[]]$Address, [switch]$PrimaryOnly)
        foreach ($entry in @($Address | Where-Object { $_ })) {
            $text = [string]$entry
            if ($PrimaryOnly) { if ($text.StartsWith('SMTP:', [StringComparison]::Ordinal)) { $text.Substring(5) } }
            elseif ($text -match '^smtp:(?<address>.+)$') { $Matches.address }
        }
    }

    # Lookups by value, so a tenant of thousands is not compared pair by pair. @{} ignores case.
    $byUpn = @{}; $bySmtp = @{}; $byPrimarySmtp = @{}; $bySam = @{}; $byDisplay = @{}; $byFullName = @{}; $byAnchor = @{}
    function Add-Lookup {
        param([hashtable]$Map, [string]$Key, [int]$Index)
        if ([string]::IsNullOrWhiteSpace($Key)) { return }
        if (-not $Map.ContainsKey($Key)) { $Map[$Key] = [System.Collections.Generic.List[int]]::new() }
        if (-not $Map[$Key].Contains($Index)) { $Map[$Key].Add($Index) }
    }
    for ($i = 0; $i -lt $ADUser.Count; $i++) {
        $user = $ADUser[$i]
        Add-Lookup $byUpn ([string]$user.UserPrincipalName) $i
        Add-Lookup $bySam ([string]$user.SamAccountName) $i
        Add-Lookup $bySmtp ([string]$user.Mail) $i
        Add-Lookup $byPrimarySmtp ([string]$user.Mail) $i
        foreach ($address in Get-SmtpAddress -Address $user.ProxyAddresses) { Add-Lookup $bySmtp $address $i }
        foreach ($address in Get-SmtpAddress -Address $user.ProxyAddresses -PrimaryOnly) { Add-Lookup $byPrimarySmtp $address $i }
        Add-Lookup $byDisplay (Get-NormalizedName $user.DisplayName) $i
        if ($user.GivenName -and $user.Surname) { Add-Lookup $byFullName (Get-NormalizedName "$($user.GivenName) $($user.Surname)") $i }
        $strategies = if ($verified) { @($SourceAnchor.Strategy) } else { @('ObjectGuid', 'ConsistencyGuid') }
        foreach ($strategy in $strategies) {
            Add-Lookup $byAnchor (ConvertTo-NSPEntraImmutableId -ADUser $user -Strategy $strategy) $i
        }
    }

    $weights = @{ 'exact-upn' = 50; 'primary-smtp' = 40; 'proxy-address' = 30; 'upn-local-sam' = 15; 'display-name' = 5; 'given-surname' = 5 }
    $records = foreach ($cloud in $CloudUser) {
        $isGuest = [string]$cloud['userType'] -eq 'Guest'
        if ($isGuest -and -not $IncludeGuests) { continue }
        $immutableId = [string]$cloud['onPremisesImmutableId']
        $synced = $cloud['onPremisesSyncEnabled'] -eq $true
        $upn = [string]$cloud['userPrincipalName']

        $evidence = @{}
        $addEvidence = {
            param([hashtable]$Map, [string]$Key, [string]$Reason)
            if ([string]::IsNullOrWhiteSpace($Key) -or -not $Map.ContainsKey($Key)) { return }
            foreach ($index in $Map[$Key]) {
                if (-not $evidence.ContainsKey($index)) { $evidence[$index] = [System.Collections.Generic.List[string]]::new() }
                if (-not $evidence[$index].Contains($Reason)) { $evidence[$index].Add($Reason) }
            }
        }
        & $addEvidence $byUpn $upn 'exact-upn'
        & $addEvidence $byPrimarySmtp ([string]$cloud['mail']) 'primary-smtp'
        foreach ($address in Get-SmtpAddress -Address $cloud['proxyAddresses'] -PrimaryOnly) { & $addEvidence $byPrimarySmtp $address 'primary-smtp' }
        foreach ($address in Get-SmtpAddress -Address $cloud['proxyAddresses']) { & $addEvidence $bySmtp $address 'proxy-address' }
        & $addEvidence $bySam ($upn -split '@')[0] 'upn-local-sam'
        & $addEvidence $byDisplay (Get-NormalizedName $cloud['displayName']) 'display-name'
        if ($cloud['givenName'] -and $cloud['surname']) { & $addEvidence $byFullName (Get-NormalizedName "$($cloud['givenName']) $($cloud['surname'])") 'given-surname' }
        $linkedIndexes = @(if ($immutableId -and $byAnchor.ContainsKey($immutableId)) { $byAnchor[$immutableId] })
        & $addEvidence $byAnchor $immutableId 'existing-anchor'

        $candidates = foreach ($index in $evidence.Keys) {
            $reasons = @($evidence[$index])
            $strong = @($reasons | Where-Object { $_ -in @('exact-upn', 'primary-smtp', 'proxy-address') })
            $confidence = if ($reasons -contains 'existing-anchor') { 'Linked' }
                elseif ($reasons -contains 'exact-upn' -and $strong.Count -ge 2) { 'High' }
                elseif ($strong.Count) { 'Medium' }
                else { 'Low' }
            $score = 0
            foreach ($reason in $reasons) { $score += if ($reason -eq 'existing-anchor') { 100 } else { $weights[$reason] } }
            $user = $ADUser[$index]
            [pscustomobject]@{
                ADIndex = $index
                ObjectGUID = $user.ObjectGUID
                ObjectSID = $user.ObjectSID
                SamAccountName = $user.SamAccountName
                UserPrincipalName = $user.UserPrincipalName
                DisplayName = $user.DisplayName
                DistinguishedName = $user.DistinguishedName
                DomainDNSRoot = $user.DomainDNSRoot
                Enabled = $user.Enabled
                PasswordLastSetUtc = if ($user.PasswordLastSetUtc) { $user.PasswordLastSetUtc }
                    elseif ($user.PasswordLastSetState -eq 'Zero') {
                        if ($user.PasswordMustChangeAtNextLogon -eq $true) { 'pwdLastSet=0; must change at next logon' }
                        elseif ($user.PasswordMustChangeAtNextLogon -eq $false) { 'pwdLastSet=0; next-logon change not required' }
                        else { 'pwdLastSet=0; next-logon requirement unknown' }
                    } else { $null }
                DesiredImmutableId = if ($verified) { ConvertTo-NSPEntraImmutableId -ADUser $user -Strategy $SourceAnchor.Strategy } else { $null }
                Score = $score
                Confidence = $confidence
                Evidence = $reasons
            }
        }
        $candidates = @($candidates | Sort-Object @{ Expression = 'Score'; Descending = $true }, SamAccountName)
        $best = @(if ($candidates.Count) { $candidates | Where-Object Score -eq $candidates[0].Score })

        $cloudState = if ($isGuest) { 'Guest' } elseif ($synced) { 'Synced' } elseif ($immutableId) { 'AnchorSet' } else { 'CloudOnly' }
        $status = if ($isGuest) { 'Excluded' }
            elseif ($linkedIndexes.Count -gt 1) { 'DuplicateAnchor' }
            elseif ($linkedIndexes.Count) { 'Linked' }
            elseif ($synced) { 'SyncedUnmatched' }
            elseif ($immutableId) { 'AnchorConflict' }
            elseif (-not $candidates.Count) { 'NotFound' }
            elseif ($best.Count -gt 1) { 'Ambiguous' }
            else { 'Suggested' }

        [pscustomobject]@{
            PSTypeName = 'NSP.M365.UserPairing.Candidate'
            CloudLastPasswordChangeUtc = $cloud['lastPasswordChangeDateTime']
            RoleReview = if ($PSCmdlet.ParameterSetName -eq 'Inventory' -and $Inventory.RoleState) {
                if ($Inventory.RoleState.RoleHolders.ContainsKey([string]$cloud['id'])) { 'ASSIGNED DIRECTORY ROLE - privileged account' }
                elseif ($Inventory.RoleState.EligibleHolders.ContainsKey([string]$cloud['id'])) { 'ELIGIBLE DIRECTORY ROLE - privileged account' }
                elseif (@($Inventory.RoleState.Blocks).Count) { 'UNKNOWN role eligibility - preflight blocked' }
                elseif (@($Inventory.RoleState.Warnings | Where-Object { $_ -like 'Role eligibility was not read*' })) { 'Eligibility not read - operator assumption; assigned roles checked' }
                else { 'No assigned or eligible directory role observed' }
            } else { 'UNKNOWN directory roles - not read; preflight required' }
            CloudObjectId = [string]$cloud['id']
            CloudUserPrincipalName = $upn
            CloudDisplayName = [string]$cloud['displayName']
            CloudAccountEnabled = $cloud['accountEnabled']
            CloudState = $cloudState
            CloudImmutableId = $immutableId
            Status = $status
            AnchorBasis = $anchorBasis
            Candidates = $candidates
            Decision = 'Undecided'
        }
    }
    $records = @($records)

    # Conflicts only show across records.
    $holders = @{}
    foreach ($record in $records) {
        if (-not $record.CloudImmutableId) { continue }
        if (-not $holders.ContainsKey($record.CloudImmutableId)) { $holders[$record.CloudImmutableId] = 0 }
        $holders[$record.CloudImmutableId]++
    }
    $linkedAD = @{}
    $claims = @{}
    foreach ($record in $records) {
        if ($record.CloudImmutableId -and $holders[$record.CloudImmutableId] -gt 1) { $record.Status = 'DuplicateAnchor'; continue }
        if ($record.Status -eq 'Linked') { foreach ($c in $record.Candidates) { if ($c.Confidence -eq 'Linked') { $linkedAD[$c.ADIndex] = $true } } }
        if ($record.Status -eq 'Suggested') {
            $top = $record.Candidates[0].ADIndex
            if (-not $claims.ContainsKey($top)) { $claims[$top] = 0 }
            $claims[$top]++
        }
    }
    foreach ($record in $records) {
        if ($record.Status -ne 'Suggested') { continue }
        $top = $record.Candidates[0].ADIndex
        if ($linkedAD.ContainsKey($top)) { $record.Status = 'LinkedElsewhere' }
        elseif ($claims[$top] -gt 1) { $record.Status = 'OneToMany' }
    }
    $records
}