Skip to content
  • There are no suggestions because the search field is empty.

Check devices seen from users in EntraID

A quick script to pull all the devices seen in use, from users, in the last XX days across your MS365 estate.

As always, please test these scripts in your own environment. Cyber Tec Security do not warrant these scripts in any way; the use of these is solely your responsibility.

Copy the PowerShell script below, save it somewhere you know where to find it, and use the command below to execute it. This will create multiple CSV files covering:

  • CollectionIssues (any trouble the script had whilst running)
  • MAMAppRegistrations
  • SinInOnlyObservations
  • UnifiedDeviceInventory (CSV and XLS)
powershell -executionpolicy Bypass -File .\Export-EntraDeviceDiscovery.ps1 -TenantID "your tenant ID.onmicrosoft.com" -SignInLookbackDays 30 -OutputDirectory "c:\temp\DeviceInventory"
<#
.SYNOPSIS
    Creates a unified Microsoft Entra, Intune MDM, Intune MAM and sign-in device inventory.
Use this script at your own risk. Cyber Tec Security accepts no liability and provides no warranty.

.DESCRIPTION
    Installs required PowerShell modules, authenticates interactively to Microsoft Graph,
    and builds a consolidated inventory from:

      - Microsoft Entra registered device objects
      - Registered owners for each Entra device
      - Intune managed devices (MDM), when available
      - Intune managed app registrations (MAM), when available
      - Microsoft Entra sign-in logs, when available

    The script is deliberately resilient. Optional Graph properties are read safely and
    failure of one data source does not stop the remaining report.

    Outputs:
      - UnifiedDeviceInventory.csv
      - MamAppRegistrations.csv
      - SignInOnlyObservations.csv
      - CollectionIssues.csv
      - UnifiedDeviceInventory.xlsx (when ImportExcel is available)

.NOTES
    Recommended PowerShell: PowerShell 7+

    Delegated Microsoft Graph permissions requested:
      Device.Read.All
      Directory.Read.All
      User.Read.All
      DeviceManagementManagedDevices.Read.All
      DeviceManagementApps.Read.All
      AuditLog.Read.All

    Microsoft Entra roles and Intune RBAC still apply. An administrator may need to
    grant consent. Sign-in log access and retention depend on tenant licensing.

.EXAMPLE
    .\Export-EntraDeviceDiscovery.ps1 `
        -TenantId "cybertecsecurity.onmicrosoft.com" `
        -SignInLookbackDays 14

.EXAMPLE
    .\Export-EntraDeviceDiscovery.ps1 `
        -TenantId "00000000-0000-0000-0000-000000000000" `
        -OutputDirectory "C:\CTS-Reports\DeviceInventory" `
        -SignInLookbackDays 30
#>

[CmdletBinding()]
param(
    [Parameter()]
    [string]$TenantId,

    [Parameter()]
    [ValidateRange(1, 30)]
    [int]$SignInLookbackDays = 14,

    [Parameter()]
    [string]$OutputDirectory = (
        Join-Path -Path $PWD -ChildPath (
            "UnifiedDeviceInventory_{0}" -f (Get-Date -Format "yyyyMMdd_HHmmss")
        )
    ),

    [Parameter()]
    [switch]$SkipModuleInstallation,

    [Parameter()]
    [switch]$SkipExcel,

    [Parameter()]
    [switch]$IncludeFailedSignIns
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgressPreference = "Continue"

$script:CollectionIssues = [System.Collections.Generic.List[object]]::new()

function Write-Section {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Message
    )

    Write-Host ""
    Write-Host ("=== {0} ===" -f $Message) -ForegroundColor Cyan
}

function Add-CollectionIssue {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Source,

        [Parameter(Mandatory)]
        [string]$Message,

        [Parameter()]
        [string]$ObjectId,

        [Parameter()]
        [ValidateSet("Information", "Warning", "Error")]
        [string]$Severity = "Warning"
    )

    $script:CollectionIssues.Add(
        [pscustomobject][ordered]@{
            Timestamp = Get-Date
            Severity  = $Severity
            Source    = $Source
            ObjectId  = $ObjectId
            Message   = $Message
        }
    )
}

function Get-SafeProperty {
    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowNull()]
        [object]$InputObject,

        [Parameter(Mandatory)]
        [string]$Name,

        [Parameter()]
        [AllowNull()]
        $DefaultValue = $null
    )

    if ($null -eq $InputObject) {
        return $DefaultValue
    }

    if ($InputObject -is [System.Collections.IDictionary]) {
        if ($InputObject.Contains($Name)) {
            return $InputObject[$Name]
        }

        return $DefaultValue
    }

    $property = $InputObject.PSObject.Properties[$Name]

    if ($null -eq $property) {
        return $DefaultValue
    }

    return $property.Value
}

function Get-NestedProperty {
    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowNull()]
        [object]$InputObject,

        [Parameter(Mandatory)]
        [string[]]$Path,

        [Parameter()]
        [AllowNull()]
        $DefaultValue = $null
    )

    $current = $InputObject

    foreach ($segment in $Path) {
        $current = Get-SafeProperty -InputObject $current -Name $segment -DefaultValue $null

        if ($null -eq $current) {
            return $DefaultValue
        }
    }

    return $current
}

function Convert-ToLocalDateTime {
    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowNull()]
        $Value
    )

    if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) {
        return $null
    }

    try {
        return ([datetimeoffset]$Value).ToLocalTime().DateTime
    }
    catch {
        return $null
    }
}

function Get-LatestDateTime {
    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowEmptyCollection()]
        [object[]]$Values
    )

    $validValues = @(
        foreach ($value in $Values) {
            $converted = Convert-ToLocalDateTime -Value $value
            if ($null -ne $converted) {
                $converted
            }
        }
    )

    if ($validValues.Count -eq 0) {
        return $null
    }

    return $validValues | Sort-Object -Descending | Select-Object -First 1
}

function Convert-ToBooleanOrNull {
    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowNull()]
        $Value
    )

    if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) {
        return $null
    }

    if ($Value -is [bool]) {
        return $Value
    }

    $parsed = $false
    if ([bool]::TryParse([string]$Value, [ref]$parsed)) {
        return $parsed
    }

    return $null
}

function Convert-ToText {
    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowNull()]
        $Value,

        [Parameter()]
        [string]$Separator = "; "
    )

    if ($null -eq $Value) {
        return $null
    }

    if ($Value -is [string]) {
        return $Value
    }

    if ($Value -is [System.Collections.IEnumerable]) {
        return (@($Value) | ForEach-Object { [string]$_ }) -join $Separator
    }

    return [string]$Value
}

function Get-ObjectFingerprint {
    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowNull()]
        [object]$InputObject
    )

    if ($null -eq $InputObject) {
        return $null
    }

    try {
        return $InputObject | ConvertTo-Json -Depth 10 -Compress
    }
    catch {
        return [string]$InputObject
    }
}

function Ensure-PackageProvider {
    [CmdletBinding()]
    param()

    if ($PSVersionTable.PSVersion.Major -ge 7) {
        return
    }

    [Net.ServicePointManager]::SecurityProtocol = `
        [Net.ServicePointManager]::SecurityProtocol -bor `
        [Net.SecurityProtocolType]::Tls12

    if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) {
        Install-PackageProvider `
            -Name NuGet `
            -MinimumVersion "2.8.5.201" `
            -Scope CurrentUser `
            -Force | Out-Null
    }
}

function Ensure-Module {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Name,

        [Parameter()]
        [switch]$Optional
    )

    $available = Get-Module -ListAvailable -Name $Name |
        Sort-Object Version -Descending |
        Select-Object -First 1

    if ($null -eq $available) {
        if ($SkipModuleInstallation) {
            if ($Optional) {
                Add-CollectionIssue `
                    -Source "Module" `
                    -Severity "Information" `
                    -Message ("Optional module '{0}' is not installed." -f $Name)
                return $false
            }

            throw "Required module '$Name' is not installed and -SkipModuleInstallation was supplied."
        }

        try {
            Ensure-PackageProvider
            Write-Host ("Installing PowerShell module {0}..." -f $Name)
            Install-Module `
                -Name $Name `
                -Repository PSGallery `
                -Scope CurrentUser `
                -Force `
                -AllowClobber
        }
        catch {
            if ($Optional) {
                Add-CollectionIssue `
                    -Source "Module" `
                    -Message ("Could not install optional module '{0}': {1}" -f $Name, $_.Exception.Message)
                return $false
            }

            throw
        }
    }

    try {
        Import-Module -Name $Name -Force
        return $true
    }
    catch {
        if ($Optional) {
            Add-CollectionIssue `
                -Source "Module" `
                -Message ("Could not import optional module '{0}': {1}" -f $Name, $_.Exception.Message)
            return $false
        }

        throw
    }
}

function Get-AllGraphResults {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Uri,

        [Parameter()]
        [string]$Source = "Microsoft Graph"
    )

    $results = [System.Collections.Generic.List[object]]::new()
    $nextLink = $Uri
    $pageNumber = 0

    while (-not [string]::IsNullOrWhiteSpace($nextLink)) {
        $pageNumber++

        try {
            $response = Invoke-MgGraphRequest `
                -Method GET `
                -Uri $nextLink `
                -OutputType PSObject
        }
        catch {
            throw ("{0} failed on page {1}: {2}" -f $Source, $pageNumber, $_.Exception.Message)
        }

        $value = Get-SafeProperty -InputObject $response -Name "value" -DefaultValue $null

        if ($null -ne $value) {
            foreach ($item in @($value)) {
                $results.Add($item)
            }
        }
        elseif ($null -ne $response) {
            $results.Add($response)
        }

        $nextLink = [string](
            Get-SafeProperty -InputObject $response -Name "@odata.nextLink" -DefaultValue $null
        )
    }

    return @($results)
}

function Get-EntraDevices {
    [CmdletBinding()]
    param()

    Write-Section "Retrieving Entra registered devices"

    $select = @(
        "id"
        "deviceId"
        "displayName"
        "operatingSystem"
        "operatingSystemVersion"
        "trustType"
        "profileType"
        "accountEnabled"
        "isManaged"
        "isCompliant"
        "deviceOwnership"
        "registrationDateTime"
        "approximateLastSignInDateTime"
        "manufacturer"
        "model"
        "mdmAppId"
    ) -join ","

    $uri = "https://graph.microsoft.com/v1.0/devices?`$select=$select&`$top=999"
    $devices = @(Get-AllGraphResults -Uri $uri -Source "Entra devices")

    Write-Host ("Retrieved {0} Entra device object(s)." -f $devices.Count) -ForegroundColor Green
    return $devices
}

function Get-EntraDeviceOwnerLookup {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object[]]$Devices
    )

    Write-Section "Retrieving registered owner for each Entra device"

    $lookup = @{}
    $total = [Math]::Max($Devices.Count, 1)
    $position = 0

    foreach ($device in $Devices) {
        $position++
        $directoryObjectId = [string](Get-SafeProperty -InputObject $device -Name "id")
        $displayName = [string](Get-SafeProperty -InputObject $device -Name "displayName")

        if ([string]::IsNullOrWhiteSpace($directoryObjectId)) {
            continue
        }

        Write-Progress `
            -Activity "Reading Entra registered owners" `
            -Status ("{0} of {1}: {2}" -f $position, $Devices.Count, $displayName) `
            -PercentComplete (($position / $total) * 100)

        $uri = (
            "https://graph.microsoft.com/v1.0/devices/{0}/registeredOwners/" +
            "microsoft.graph.user?`$select=id,displayName,userPrincipalName,accountEnabled,userType"
        ) -f $directoryObjectId

        try {
            $owners = @(Get-AllGraphResults -Uri $uri -Source "Entra registered owners")
            $lookup[$directoryObjectId] = $owners
        }
        catch {
            $statusText = $_.Exception.Message

            # A missing owner or inaccessible relationship should not stop the report.
            $lookup[$directoryObjectId] = @()
            Add-CollectionIssue `
                -Source "Entra registered owner" `
                -ObjectId $directoryObjectId `
                -Message ("Could not retrieve owner for device '{0}': {1}" -f $displayName, $statusText)
        }
    }

    Write-Progress -Activity "Reading Entra registered owners" -Completed
    return $lookup
}

function Get-IntuneManagedDevices {
    [CmdletBinding()]
    param()

    Write-Section "Retrieving Intune MDM devices"

    $select = @(
        "id"
        "azureADDeviceId"
        "deviceName"
        "userId"
        "userDisplayName"
        "userPrincipalName"
        "emailAddress"
        "operatingSystem"
        "osVersion"
        "manufacturer"
        "model"
        "serialNumber"
        "managedDeviceOwnerType"
        "managementAgent"
        "complianceState"
        "enrolledDateTime"
        "lastSyncDateTime"
        "deviceEnrollmentType"
        "jailBroken"
        "partnerReportedThreatState"
        "wiFiMacAddress"
        "ethernetMacAddress"
    ) -join ","

    $uri = (
        "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices" +
        "?`$select=$select&`$top=999"
    )

    try {
        $devices = @(Get-AllGraphResults -Uri $uri -Source "Intune managed devices")
        Write-Host ("Retrieved {0} Intune MDM device record(s)." -f $devices.Count) -ForegroundColor Green
        return $devices
    }
    catch {
        Add-CollectionIssue `
            -Source "Intune MDM" `
            -Message $_.Exception.Message
        Write-Warning "Intune MDM data is unavailable. The remaining report will continue."
        return @()
    }
}

function Get-IntuneMamRegistrations {
    [CmdletBinding()]
    param()

    Write-Section "Retrieving Intune MAM app registrations"

    $uri = (
        "https://graph.microsoft.com/v1.0/deviceAppManagement/managedAppRegistrations" +
        "?`$top=999"
    )

    try {
        $registrations = @(Get-AllGraphResults -Uri $uri -Source "Intune MAM registrations")
        Write-Host ("Retrieved {0} MAM registration record(s)." -f $registrations.Count) -ForegroundColor Green
        return $registrations
    }
    catch {
        Add-CollectionIssue `
            -Source "Intune MAM" `
            -Message $_.Exception.Message
        Write-Warning "Intune MAM data is unavailable. The remaining report will continue."
        return @()
    }
}

function Get-EntraUsers {
    [CmdletBinding()]
    param()

    Write-Section "Retrieving Entra users for identity enrichment"

    $uri = (
        "https://graph.microsoft.com/v1.0/users" +
        "?`$select=id,displayName,userPrincipalName,accountEnabled,userType&`$top=999"
    )

    try {
        $users = @(Get-AllGraphResults -Uri $uri -Source "Entra users")
        Write-Host ("Retrieved {0} Entra user object(s)." -f $users.Count) -ForegroundColor Green
        return $users
    }
    catch {
        Add-CollectionIssue -Source "Entra users" -Message $_.Exception.Message
        Write-Warning "User enrichment is unavailable. IDs will be retained where possible."
        return @()
    }
}

function Get-EntraSignIns {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [int]$LookbackDays,

        [Parameter()]
        [switch]$IncludeFailures
    )

    Write-Section "Retrieving recent Entra sign-in observations"

    $startUtc = (Get-Date).ToUniversalTime().AddDays(-$LookbackDays)
    $startValue = $startUtc.ToString("yyyy-MM-ddTHH:mm:ssZ")

    $filterText = "createdDateTime ge $startValue"

    if (-not $IncludeFailures) {
        $filterText = "$filterText and status/errorCode eq 0"
    }

    $filter = [uri]::EscapeDataString($filterText)
    $select = @(
        "id"
        "createdDateTime"
        "userId"
        "userDisplayName"
        "userPrincipalName"
        "appDisplayName"
        "clientAppUsed"
        "ipAddress"
        "deviceDetail"
        "status"
        "conditionalAccessStatus"
        "isInteractive"
        "resourceDisplayName"
    ) -join ","

    $uri = (
        "https://graph.microsoft.com/v1.0/auditLogs/signIns" +
        "?`$filter=$filter&`$select=$select&`$top=1000"
    )

    try {
        $signIns = @(Get-AllGraphResults -Uri $uri -Source "Entra sign-ins")
        Write-Host (
            "Retrieved {0} sign-in record(s) from the last {1} day(s)." -f
            $signIns.Count,
            $LookbackDays
        ) -ForegroundColor Green
        return $signIns
    }
    catch {
        Add-CollectionIssue -Source "Entra sign-ins" -Message $_.Exception.Message
        Write-Warning "Sign-in observations are unavailable. The remaining report will continue."
        return @()
    }
}

function Convert-MamRegistration {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Registration,

        [Parameter(Mandatory)]
        [hashtable]$UserLookup
    )

    $userId = [string](Get-SafeProperty -InputObject $Registration -Name "userId")
    $user = $null

    if (-not [string]::IsNullOrWhiteSpace($userId) -and $UserLookup.ContainsKey($userId)) {
        $user = $UserLookup[$userId]
    }

    $appIdentifier = Get-SafeProperty -InputObject $Registration -Name "appIdentifier"
    $appIdentity = $null

    foreach ($candidateName in @("packageId", "bundleId", "applicationId", "appId")) {
        $candidate = Get-SafeProperty -InputObject $appIdentifier -Name $candidateName
        if (-not [string]::IsNullOrWhiteSpace([string]$candidate)) {
            $appIdentity = [string]$candidate
            break
        }
    }

    if ([string]::IsNullOrWhiteSpace($appIdentity)) {
        if ($appIdentifier -is [string]) {
            $appIdentity = [string]$appIdentifier
        }
        elseif ($null -ne $appIdentifier) {
            $appIdentity = Get-ObjectFingerprint -InputObject $appIdentifier
        }
    }

    $odataType = [string](Get-SafeProperty -InputObject $Registration -Name "@odata.type")
    $deviceType = [string](Get-SafeProperty -InputObject $Registration -Name "deviceType")

    if ([string]::IsNullOrWhiteSpace($deviceType) -and -not [string]::IsNullOrWhiteSpace($odataType)) {
        $deviceType = $odataType -replace '^#microsoft\.graph\.', '' -replace 'ManagedAppRegistration$', ''
    }

    return [pscustomobject][ordered]@{
        UserId                = $userId
        UserDisplayName       = if ($null -ne $user) { Get-SafeProperty $user "displayName" } else { $null }
        UserPrincipalName     = if ($null -ne $user) { Get-SafeProperty $user "userPrincipalName" } else { $null }
        UserEnabled           = if ($null -ne $user) { Get-SafeProperty $user "accountEnabled" } else { $null }
        UserType              = if ($null -ne $user) { Get-SafeProperty $user "userType" } else { $null }
        DeviceName            = Get-SafeProperty $Registration "deviceName"
        DeviceType            = $deviceType
        PlatformVersion       = Get-SafeProperty $Registration "platformVersion"
        ApplicationIdentifier = $appIdentity
        ApplicationVersion    = Get-SafeProperty $Registration "applicationVersion"
        ManagementSdkVersion  = Get-SafeProperty $Registration "managementSdkVersion"
        FirstRegistered       = Convert-ToLocalDateTime (Get-SafeProperty $Registration "createdDateTime")
        LastMamSync           = Convert-ToLocalDateTime (Get-SafeProperty $Registration "lastSyncDateTime")
        FlaggedReasons        = Convert-ToText (Get-SafeProperty $Registration "flaggedReasons")
        DeviceTag             = Get-SafeProperty $Registration "deviceTag"
        RegistrationType      = $odataType
        MamRegistrationId     = Get-SafeProperty $Registration "id"
        RawAppIdentifier      = Get-ObjectFingerprint $appIdentifier
    }
}

function Get-SourceLabel {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [bool]$HasEntra,

        [Parameter(Mandatory)]
        [bool]$HasMdm,

        [Parameter(Mandatory)]
        [bool]$HasMam,

        [Parameter(Mandatory)]
        [bool]$HasSignIn
    )

    $parts = [System.Collections.Generic.List[string]]::new()

    if ($HasEntra)  { $parts.Add("Entra") }
    if ($HasMdm)    { $parts.Add("Intune MDM") }
    if ($HasMam)    { $parts.Add("Intune MAM") }
    if ($HasSignIn) { $parts.Add("Sign-in") }

    if ($parts.Count -eq 0) {
        return "Unknown"
    }

    return $parts -join " + "
}

function Get-InventoryClassification {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [bool]$HasEntra,

        [Parameter(Mandatory)]
        [bool]$HasMdm,

        [Parameter(Mandatory)]
        [bool]$HasMam,

        [Parameter(Mandatory)]
        [bool]$HasSignIn
    )

    if ($HasMdm) {
        return "Intune managed"
    }

    if ($HasEntra -and $HasMam) {
        return "Entra registered + MAM protected"
    }

    if ($HasMam) {
        return "MAM only"
    }

    if ($HasEntra) {
        return "Entra registered - unmanaged"
    }

    if ($HasSignIn) {
        return "Sign-in only"
    }

    return "Unknown"
}

function New-UnifiedInventory {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object[]]$EntraDevices,

        [Parameter(Mandatory)]
        [hashtable]$OwnerLookup,

        [Parameter(Mandatory)]
        [object[]]$MdmDevices,

        [Parameter(Mandatory)]
        [object[]]$MamRows,

        [Parameter(Mandatory)]
        [object[]]$SignIns,

        [Parameter(Mandatory)]
        [hashtable]$UserLookup
    )

    Write-Section "Correlating device sources"

    $inventory = [System.Collections.Generic.List[object]]::new()
    $matchedMamIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $matchedSignInIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    $mdmByEntraDeviceId = @{}
    foreach ($mdm in $MdmDevices) {
        $key = [string](Get-SafeProperty $mdm "azureADDeviceId")
        if (-not [string]::IsNullOrWhiteSpace($key)) {
            $mdmByEntraDeviceId[$key.ToLowerInvariant()] = $mdm
        }
    }

    $mamByUserId = @{}
    foreach ($mam in $MamRows) {
        $key = [string](Get-SafeProperty $mam "UserId")
        if ([string]::IsNullOrWhiteSpace($key)) {
            continue
        }

        if (-not $mamByUserId.ContainsKey($key)) {
            $mamByUserId[$key] = [System.Collections.Generic.List[object]]::new()
        }

        $mamByUserId[$key].Add($mam)
    }

    $signInsByDeviceId = @{}
    foreach ($signIn in $SignIns) {
        $deviceId = [string](Get-NestedProperty $signIn @("deviceDetail", "deviceId"))
        if ([string]::IsNullOrWhiteSpace($deviceId)) {
            continue
        }

        $key = $deviceId.ToLowerInvariant()
        if (-not $signInsByDeviceId.ContainsKey($key)) {
            $signInsByDeviceId[$key] = [System.Collections.Generic.List[object]]::new()
        }

        $signInsByDeviceId[$key].Add($signIn)
    }

    foreach ($entra in $EntraDevices) {
        $entraDirectoryId = [string](Get-SafeProperty $entra "id")
        $entraDeviceId = [string](Get-SafeProperty $entra "deviceId")
        $entraDeviceName = [string](Get-SafeProperty $entra "displayName")

        $mdm = $null
        if (-not [string]::IsNullOrWhiteSpace($entraDeviceId)) {
            $mdmKey = $entraDeviceId.ToLowerInvariant()
            if ($mdmByEntraDeviceId.ContainsKey($mdmKey)) {
                $mdm = $mdmByEntraDeviceId[$mdmKey]
            }
        }

        $deviceSignIns = @()
        if (-not [string]::IsNullOrWhiteSpace($entraDeviceId)) {
            $signInKey = $entraDeviceId.ToLowerInvariant()
            if ($signInsByDeviceId.ContainsKey($signInKey)) {
                $deviceSignIns = @($signInsByDeviceId[$signInKey])
            }
        }

        $latestSignIn = $deviceSignIns |
            Sort-Object { Convert-ToLocalDateTime (Get-SafeProperty $_ "createdDateTime") } -Descending |
            Select-Object -First 1

        foreach ($signIn in $deviceSignIns) {
            $signInId = [string](Get-SafeProperty $signIn "id")
            if (-not [string]::IsNullOrWhiteSpace($signInId)) {
                [void]$matchedSignInIds.Add($signInId)
            }
        }

        $owners = @()
        if ($OwnerLookup.ContainsKey($entraDirectoryId)) {
            $owners = @($OwnerLookup[$entraDirectoryId])
        }

        if ($owners.Count -eq 0 -and $null -ne $mdm) {
            $mdmUserId = [string](Get-SafeProperty $mdm "userId")
            if (-not [string]::IsNullOrWhiteSpace($mdmUserId)) {
                $mdmUser = if ($UserLookup.ContainsKey($mdmUserId)) { $UserLookup[$mdmUserId] } else { $null }
                $owners = @(
                    [pscustomobject]@{
                        id                = $mdmUserId
                        displayName       = if ($null -ne $mdmUser) { Get-SafeProperty $mdmUser "displayName" } else { Get-SafeProperty $mdm "userDisplayName" }
                        userPrincipalName = if ($null -ne $mdmUser) { Get-SafeProperty $mdmUser "userPrincipalName" } else { Get-SafeProperty $mdm "userPrincipalName" }
                        accountEnabled    = if ($null -ne $mdmUser) { Get-SafeProperty $mdmUser "accountEnabled" } else { $null }
                        userType          = if ($null -ne $mdmUser) { Get-SafeProperty $mdmUser "userType" } else { $null }
                    }
                )
            }
        }

        if ($owners.Count -eq 0 -and $null -ne $latestSignIn) {
            $signInUserId = [string](Get-SafeProperty $latestSignIn "userId")
            $owners = @(
                [pscustomobject]@{
                    id                = $signInUserId
                    displayName       = Get-SafeProperty $latestSignIn "userDisplayName"
                    userPrincipalName = Get-SafeProperty $latestSignIn "userPrincipalName"
                    accountEnabled    = if ($UserLookup.ContainsKey($signInUserId)) { Get-SafeProperty $UserLookup[$signInUserId] "accountEnabled" } else { $null }
                    userType          = if ($UserLookup.ContainsKey($signInUserId)) { Get-SafeProperty $UserLookup[$signInUserId] "userType" } else { $null }
                }
            )
        }

        if ($owners.Count -eq 0) {
            $owners = @(
                [pscustomobject]@{
                    id                = $null
                    displayName       = "(No registered owner)"
                    userPrincipalName = "(Unassigned)"
                    accountEnabled    = $null
                    userType          = $null
                }
            )
        }

        foreach ($owner in $owners) {
            $ownerId = [string](Get-SafeProperty $owner "id")
            $ownerMamRows = @()

            if (-not [string]::IsNullOrWhiteSpace($ownerId) -and $mamByUserId.ContainsKey($ownerId)) {
                $candidateMamRows = @($mamByUserId[$ownerId])

                # MAM device names are not guaranteed to match Entra names. Prefer a name
                # match; otherwise attach MAM only when the user has exactly one candidate.
                $nameMatches = @(
                    $candidateMamRows | Where-Object {
                        $mamDeviceName = [string](Get-SafeProperty $_ "DeviceName")
                        -not [string]::IsNullOrWhiteSpace($mamDeviceName) -and
                        -not [string]::IsNullOrWhiteSpace($entraDeviceName) -and
                        $mamDeviceName.Equals($entraDeviceName, [System.StringComparison]::OrdinalIgnoreCase)
                    }
                )

                if ($nameMatches.Count -gt 0) {
                    $ownerMamRows = $nameMatches
                }
                elseif ($candidateMamRows.Count -eq 1) {
                    $ownerMamRows = $candidateMamRows
                }
            }

            foreach ($mam in $ownerMamRows) {
                $mamId = [string](Get-SafeProperty $mam "MamRegistrationId")
                if (-not [string]::IsNullOrWhiteSpace($mamId)) {
                    [void]$matchedMamIds.Add($mamId)
                }
            }

            $latestMam = $ownerMamRows |
                Sort-Object { Get-SafeProperty $_ "LastMamSync" } -Descending |
                Select-Object -First 1

            $hasMdm = $null -ne $mdm
            $hasMam = $ownerMamRows.Count -gt 0
            $hasSignIn = $null -ne $latestSignIn

            $entraLastSeen = Convert-ToLocalDateTime (Get-SafeProperty $entra "approximateLastSignInDateTime")
            $mdmLastSeen = if ($hasMdm) { Convert-ToLocalDateTime (Get-SafeProperty $mdm "lastSyncDateTime") } else { $null }
            $mamLastSeen = if ($hasMam) { Get-SafeProperty $latestMam "LastMamSync" } else { $null }
            $signInLastSeen = if ($hasSignIn) { Convert-ToLocalDateTime (Get-SafeProperty $latestSignIn "createdDateTime") } else { $null }

            $operatingSystem = if ($hasMdm -and -not [string]::IsNullOrWhiteSpace([string](Get-SafeProperty $mdm "operatingSystem"))) {
                Get-SafeProperty $mdm "operatingSystem"
            }
            elseif (-not [string]::IsNullOrWhiteSpace([string](Get-SafeProperty $entra "operatingSystem"))) {
                Get-SafeProperty $entra "operatingSystem"
            }
            elseif ($hasSignIn) {
                Get-NestedProperty $latestSignIn @("deviceDetail", "operatingSystem")
            }
            elseif ($hasMam) {
                Get-SafeProperty $latestMam "DeviceType"
            }
            else {
                $null
            }

            $osVersion = if ($hasMdm -and -not [string]::IsNullOrWhiteSpace([string](Get-SafeProperty $mdm "osVersion"))) {
                Get-SafeProperty $mdm "osVersion"
            }
            elseif (-not [string]::IsNullOrWhiteSpace([string](Get-SafeProperty $entra "operatingSystemVersion"))) {
                Get-SafeProperty $entra "operatingSystemVersion"
            }
            elseif ($hasMam) {
                Get-SafeProperty $latestMam "PlatformVersion"
            }
            else {
                $null
            }

            $inventory.Add(
                [pscustomobject][ordered]@{
                    UserDisplayName            = Get-SafeProperty $owner "displayName"
                    UserPrincipalName          = Get-SafeProperty $owner "userPrincipalName"
                    UserId                     = $ownerId
                    UserEnabled                = Get-SafeProperty $owner "accountEnabled"
                    UserType                   = Get-SafeProperty $owner "userType"
                    DeviceName                 = if ($hasMdm -and -not [string]::IsNullOrWhiteSpace([string](Get-SafeProperty $mdm "deviceName"))) { Get-SafeProperty $mdm "deviceName" } else { $entraDeviceName }
                    Classification             = Get-InventoryClassification -HasEntra $true -HasMdm $hasMdm -HasMam $hasMam -HasSignIn $hasSignIn
                    DataSources                = Get-SourceLabel -HasEntra $true -HasMdm $hasMdm -HasMam $hasMam -HasSignIn $hasSignIn
                    OperatingSystem            = $operatingSystem
                    OSVersion                  = $osVersion
                    Manufacturer               = if ($hasMdm) { Get-SafeProperty $mdm "manufacturer" } else { Get-SafeProperty $entra "manufacturer" }
                    Model                      = if ($hasMdm) { Get-SafeProperty $mdm "model" } else { Get-SafeProperty $entra "model" }
                    SerialNumber               = if ($hasMdm) { Get-SafeProperty $mdm "serialNumber" } else { $null }
                    Ownership                  = if ($hasMdm) { Get-SafeProperty $mdm "managedDeviceOwnerType" } else { Get-SafeProperty $entra "deviceOwnership" }
                    JoinTrustType              = Get-SafeProperty $entra "trustType"
                    EntraProfileType           = Get-SafeProperty $entra "profileType"
                    EntraAccountEnabled        = Get-SafeProperty $entra "accountEnabled"
                    EntraIsManaged             = Convert-ToBooleanOrNull (Get-SafeProperty $entra "isManaged")
                    EntraIsCompliant           = Convert-ToBooleanOrNull (Get-SafeProperty $entra "isCompliant")
                    IntuneManaged              = $hasMdm
                    IntuneComplianceState      = if ($hasMdm) { Get-SafeProperty $mdm "complianceState" } else { $null }
                    IntuneManagementAgent      = if ($hasMdm) { Get-SafeProperty $mdm "managementAgent" } else { $null }
                    IntuneEnrollmentType       = if ($hasMdm) { Get-SafeProperty $mdm "deviceEnrollmentType" } else { $null }
                    MamProtected               = $hasMam
                    MamApplicationCount        = @($ownerMamRows | Select-Object -ExpandProperty ApplicationIdentifier -Unique).Count
                    MamApplications            = (@($ownerMamRows | Select-Object -ExpandProperty ApplicationIdentifier -Unique) | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) -join "; "
                    MamFlaggedReasons          = (@($ownerMamRows | Select-Object -ExpandProperty FlaggedReasons -Unique) | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) -join "; "
                    RegistrationDate           = Convert-ToLocalDateTime (Get-SafeProperty $entra "registrationDateTime")
                    EntraApproximateLastSignIn  = $entraLastSeen
                    IntuneLastCheckIn           = $mdmLastSeen
                    MamLastSync                 = $mamLastSeen
                    LatestObservedSignIn        = $signInLastSeen
                    BestAvailableLastSeen       = Get-LatestDateTime @($entraLastSeen, $mdmLastSeen, $mamLastSeen, $signInLastSeen)
                    LatestSignInApplication     = if ($hasSignIn) { Get-SafeProperty $latestSignIn "appDisplayName" } else { $null }
                    LatestSignInResource        = if ($hasSignIn) { Get-SafeProperty $latestSignIn "resourceDisplayName" } else { $null }
                    LatestSignInClient          = if ($hasSignIn) { Get-SafeProperty $latestSignIn "clientAppUsed" } else { $null }
                    LatestSignInBrowser         = if ($hasSignIn) { Get-NestedProperty $latestSignIn @("deviceDetail", "browser") } else { $null }
                    LatestSignInIPAddress       = if ($hasSignIn) { Get-SafeProperty $latestSignIn "ipAddress" } else { $null }
                    SignInManagedFlag           = if ($hasSignIn) { Convert-ToBooleanOrNull (Get-NestedProperty $latestSignIn @("deviceDetail", "isManaged")) } else { $null }
                    SignInCompliantFlag         = if ($hasSignIn) { Convert-ToBooleanOrNull (Get-NestedProperty $latestSignIn @("deviceDetail", "isCompliant")) } else { $null }
                    SignInTrustType             = if ($hasSignIn) { Get-NestedProperty $latestSignIn @("deviceDetail", "trustType") } else { $null }
                    JailBroken                  = if ($hasMdm) { Get-SafeProperty $mdm "jailBroken" } else { $null }
                    ThreatState                 = if ($hasMdm) { Get-SafeProperty $mdm "partnerReportedThreatState" } else { $null }
                    WiFiMacAddress              = if ($hasMdm) { Get-SafeProperty $mdm "wiFiMacAddress" } else { $null }
                    EthernetMacAddress          = if ($hasMdm) { Get-SafeProperty $mdm "ethernetMacAddress" } else { $null }
                    EntraDeviceId               = $entraDeviceId
                    EntraDirectoryObjectId      = $entraDirectoryId
                    IntuneManagedDeviceId       = if ($hasMdm) { Get-SafeProperty $mdm "id" } else { $null }
                    CorrelationConfidence       = if ($hasMdm -or $hasSignIn) { "High" } elseif ($hasMam) { "Medium" } else { "High" }
                    CorrelationNotes            = if ($hasMam -and $ownerMamRows.Count -eq 1 -and -not ([string](Get-SafeProperty $latestMam "DeviceName")).Equals($entraDeviceName, [System.StringComparison]::OrdinalIgnoreCase)) { "MAM associated by sole registration for this user; physical-device match is not guaranteed." } else { $null }
                }
            )
        }
    }

    # Add MDM records that did not correlate to an Entra device object.
    foreach ($mdm in $MdmDevices) {
        $entraId = [string](Get-SafeProperty $mdm "azureADDeviceId")
        if (-not [string]::IsNullOrWhiteSpace($entraId) -and $mdmByEntraDeviceId.ContainsKey($entraId.ToLowerInvariant())) {
            $alreadyPresent = $inventory | Where-Object { $_.IntuneManagedDeviceId -eq (Get-SafeProperty $mdm "id") } | Select-Object -First 1
            if ($null -ne $alreadyPresent) {
                continue
            }
        }

        $userId = [string](Get-SafeProperty $mdm "userId")
        $user = if (-not [string]::IsNullOrWhiteSpace($userId) -and $UserLookup.ContainsKey($userId)) { $UserLookup[$userId] } else { $null }

        $inventory.Add(
            [pscustomobject][ordered]@{
                UserDisplayName            = if ($null -ne $user) { Get-SafeProperty $user "displayName" } else { Get-SafeProperty $mdm "userDisplayName" }
                UserPrincipalName          = if ($null -ne $user) { Get-SafeProperty $user "userPrincipalName" } else { Get-SafeProperty $mdm "userPrincipalName" }
                UserId                     = $userId
                UserEnabled                = if ($null -ne $user) { Get-SafeProperty $user "accountEnabled" } else { $null }
                UserType                   = if ($null -ne $user) { Get-SafeProperty $user "userType" } else { $null }
                DeviceName                 = Get-SafeProperty $mdm "deviceName"
                Classification             = "Intune managed - no Entra match"
                DataSources                = "Intune MDM"
                OperatingSystem            = Get-SafeProperty $mdm "operatingSystem"
                OSVersion                  = Get-SafeProperty $mdm "osVersion"
                Manufacturer               = Get-SafeProperty $mdm "manufacturer"
                Model                      = Get-SafeProperty $mdm "model"
                SerialNumber               = Get-SafeProperty $mdm "serialNumber"
                Ownership                  = Get-SafeProperty $mdm "managedDeviceOwnerType"
                JoinTrustType              = $null
                EntraProfileType           = $null
                EntraAccountEnabled        = $null
                EntraIsManaged             = $null
                EntraIsCompliant           = $null
                IntuneManaged              = $true
                IntuneComplianceState      = Get-SafeProperty $mdm "complianceState"
                IntuneManagementAgent      = Get-SafeProperty $mdm "managementAgent"
                IntuneEnrollmentType       = Get-SafeProperty $mdm "deviceEnrollmentType"
                MamProtected               = $false
                MamApplicationCount        = 0
                MamApplications            = $null
                MamFlaggedReasons          = $null
                RegistrationDate           = $null
                EntraApproximateLastSignIn  = $null
                IntuneLastCheckIn           = Convert-ToLocalDateTime (Get-SafeProperty $mdm "lastSyncDateTime")
                MamLastSync                 = $null
                LatestObservedSignIn        = $null
                BestAvailableLastSeen       = Convert-ToLocalDateTime (Get-SafeProperty $mdm "lastSyncDateTime")
                LatestSignInApplication     = $null
                LatestSignInResource        = $null
                LatestSignInClient          = $null
                LatestSignInBrowser         = $null
                LatestSignInIPAddress       = $null
                SignInManagedFlag           = $null
                SignInCompliantFlag         = $null
                SignInTrustType             = $null
                JailBroken                  = Get-SafeProperty $mdm "jailBroken"
                ThreatState                 = Get-SafeProperty $mdm "partnerReportedThreatState"
                WiFiMacAddress              = Get-SafeProperty $mdm "wiFiMacAddress"
                EthernetMacAddress          = Get-SafeProperty $mdm "ethernetMacAddress"
                EntraDeviceId               = $entraId
                EntraDirectoryObjectId      = $null
                IntuneManagedDeviceId       = Get-SafeProperty $mdm "id"
                CorrelationConfidence       = "High"
                CorrelationNotes            = "Intune record did not match an Entra directory device object."
            }
        )
    }

    # Add unmatched MAM records, grouped into a best-effort MAM-only device view.
    $unmatchedMam = @(
        $MamRows | Where-Object {
            $id = [string](Get-SafeProperty $_ "MamRegistrationId")
            [string]::IsNullOrWhiteSpace($id) -or -not $matchedMamIds.Contains($id)
        }
    )

    $mamGroups = $unmatchedMam | Group-Object {
        "{0}|{1}|{2}|{3}" -f
            (Get-SafeProperty $_ "UserId"),
            (Get-SafeProperty $_ "DeviceName"),
            (Get-SafeProperty $_ "DeviceType"),
            (Get-SafeProperty $_ "PlatformVersion")
    }

    foreach ($group in $mamGroups) {
        $rows = @($group.Group)
        $latest = $rows | Sort-Object LastMamSync -Descending | Select-Object -First 1

        $inventory.Add(
            [pscustomobject][ordered]@{
                UserDisplayName            = Get-SafeProperty $latest "UserDisplayName"
                UserPrincipalName          = Get-SafeProperty $latest "UserPrincipalName"
                UserId                     = Get-SafeProperty $latest "UserId"
                UserEnabled                = Get-SafeProperty $latest "UserEnabled"
                UserType                   = Get-SafeProperty $latest "UserType"
                DeviceName                 = Get-SafeProperty $latest "DeviceName"
                Classification             = "MAM only"
                DataSources                = "Intune MAM"
                OperatingSystem            = Get-SafeProperty $latest "DeviceType"
                OSVersion                  = Get-SafeProperty $latest "PlatformVersion"
                Manufacturer               = $null
                Model                      = $null
                SerialNumber               = $null
                Ownership                  = "Personal or unknown"
                JoinTrustType              = $null
                EntraProfileType           = $null
                EntraAccountEnabled        = $null
                EntraIsManaged             = $false
                EntraIsCompliant           = $null
                IntuneManaged              = $false
                IntuneComplianceState      = $null
                IntuneManagementAgent      = "MAM"
                IntuneEnrollmentType       = "MAM without enrolment or unmatched"
                MamProtected               = $true
                MamApplicationCount        = @($rows | Select-Object -ExpandProperty ApplicationIdentifier -Unique).Count
                MamApplications            = (@($rows | Select-Object -ExpandProperty ApplicationIdentifier -Unique) | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) -join "; "
                MamFlaggedReasons          = (@($rows | Select-Object -ExpandProperty FlaggedReasons -Unique) | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) -join "; "
                RegistrationDate           = ($rows | Sort-Object FirstRegistered | Select-Object -First 1).FirstRegistered
                EntraApproximateLastSignIn  = $null
                IntuneLastCheckIn           = $null
                MamLastSync                 = Get-SafeProperty $latest "LastMamSync"
                LatestObservedSignIn        = $null
                BestAvailableLastSeen       = Get-SafeProperty $latest "LastMamSync"
                LatestSignInApplication     = $null
                LatestSignInResource        = $null
                LatestSignInClient          = $null
                LatestSignInBrowser         = $null
                LatestSignInIPAddress       = $null
                SignInManagedFlag           = $null
                SignInCompliantFlag         = $null
                SignInTrustType             = $null
                JailBroken                  = $null
                ThreatState                 = $null
                WiFiMacAddress              = $null
                EthernetMacAddress          = $null
                EntraDeviceId               = $null
                EntraDirectoryObjectId      = $null
                IntuneManagedDeviceId       = $null
                CorrelationConfidence       = "Medium"
                CorrelationNotes            = "MAM registrations describe protected app instances and may not uniquely identify a physical device."
            }
        )
    }

    # Add sign-in observations that have no matched Entra device ID.
    $unmatchedSignIns = @(
        $SignIns | Where-Object {
            $id = [string](Get-SafeProperty $_ "id")
            [string]::IsNullOrWhiteSpace($id) -or -not $matchedSignInIds.Contains($id)
        }
    )

    $signInGroups = $unmatchedSignIns | Group-Object {
        "{0}|{1}|{2}|{3}|{4}" -f
            (Get-SafeProperty $_ "userId"),
            (Get-NestedProperty $_ @("deviceDetail", "deviceId")),
            (Get-NestedProperty $_ @("deviceDetail", "operatingSystem")),
            (Get-NestedProperty $_ @("deviceDetail", "browser")),
            (Get-SafeProperty $_ "clientAppUsed")
    }

    foreach ($group in $signInGroups) {
        $rows = @($group.Group)
        $latest = $rows |
            Sort-Object { Convert-ToLocalDateTime (Get-SafeProperty $_ "createdDateTime") } -Descending |
            Select-Object -First 1

        $deviceId = [string](Get-NestedProperty $latest @("deviceDetail", "deviceId"))
        $userId = [string](Get-SafeProperty $latest "userId")
        $user = if (-not [string]::IsNullOrWhiteSpace($userId) -and $UserLookup.ContainsKey($userId)) { $UserLookup[$userId] } else { $null }

        $inventory.Add(
            [pscustomobject][ordered]@{
                UserDisplayName            = if ($null -ne $user) { Get-SafeProperty $user "displayName" } else { Get-SafeProperty $latest "userDisplayName" }
                UserPrincipalName          = if ($null -ne $user) { Get-SafeProperty $user "userPrincipalName" } else { Get-SafeProperty $latest "userPrincipalName" }
                UserId                     = $userId
                UserEnabled                = if ($null -ne $user) { Get-SafeProperty $user "accountEnabled" } else { $null }
                UserType                   = if ($null -ne $user) { Get-SafeProperty $user "userType" } else { $null }
                DeviceName                 = if ([string]::IsNullOrWhiteSpace($deviceId)) { "Unknown device" } else { $deviceId }
                Classification             = "Sign-in only"
                DataSources                = "Sign-in"
                OperatingSystem            = Get-NestedProperty $latest @("deviceDetail", "operatingSystem")
                OSVersion                  = $null
                Manufacturer               = $null
                Model                      = $null
                SerialNumber               = $null
                Ownership                  = "Unknown"
                JoinTrustType              = Get-NestedProperty $latest @("deviceDetail", "trustType")
                EntraProfileType           = $null
                EntraAccountEnabled        = $null
                EntraIsManaged             = Convert-ToBooleanOrNull (Get-NestedProperty $latest @("deviceDetail", "isManaged"))
                EntraIsCompliant           = Convert-ToBooleanOrNull (Get-NestedProperty $latest @("deviceDetail", "isCompliant"))
                IntuneManaged              = $false
                IntuneComplianceState      = $null
                IntuneManagementAgent      = $null
                IntuneEnrollmentType       = $null
                MamProtected               = $false
                MamApplicationCount        = 0
                MamApplications            = $null
                MamFlaggedReasons          = $null
                RegistrationDate           = $null
                EntraApproximateLastSignIn  = $null
                IntuneLastCheckIn           = $null
                MamLastSync                 = $null
                LatestObservedSignIn        = Convert-ToLocalDateTime (Get-SafeProperty $latest "createdDateTime")
                BestAvailableLastSeen       = Convert-ToLocalDateTime (Get-SafeProperty $latest "createdDateTime")
                LatestSignInApplication     = Get-SafeProperty $latest "appDisplayName"
                LatestSignInResource        = Get-SafeProperty $latest "resourceDisplayName"
                LatestSignInClient          = Get-SafeProperty $latest "clientAppUsed"
                LatestSignInBrowser         = Get-NestedProperty $latest @("deviceDetail", "browser")
                LatestSignInIPAddress       = Get-SafeProperty $latest "ipAddress"
                SignInManagedFlag           = Convert-ToBooleanOrNull (Get-NestedProperty $latest @("deviceDetail", "isManaged"))
                SignInCompliantFlag         = Convert-ToBooleanOrNull (Get-NestedProperty $latest @("deviceDetail", "isCompliant"))
                SignInTrustType             = Get-NestedProperty $latest @("deviceDetail", "trustType")
                JailBroken                  = $null
                ThreatState                 = $null
                WiFiMacAddress              = $null
                EthernetMacAddress          = $null
                EntraDeviceId               = $deviceId
                EntraDirectoryObjectId      = $null
                IntuneManagedDeviceId       = $null
                CorrelationConfidence       = if ([string]::IsNullOrWhiteSpace($deviceId)) { "Low" } else { "Medium" }
                CorrelationNotes            = if ([string]::IsNullOrWhiteSpace($deviceId)) { "No stable device ID was supplied in the sign-in record; rows are grouped by user, OS, browser and client." } else { "Sign-in supplied a device ID that did not match the retrieved Entra device inventory." }
            }
        )
    }

    return @(
        $inventory |
            Sort-Object `
                UserPrincipalName,
                @{ Expression = "BestAvailableLastSeen"; Descending = $true },
                DeviceName
    )
}

function New-SummaryRows {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object[]]$Inventory,

        [Parameter(Mandatory)]
        [int]$EntraDeviceCount,

        [Parameter(Mandatory)]
        [int]$MdmCount,

        [Parameter(Mandatory)]
        [int]$MamCount,

        [Parameter(Mandatory)]
        [int]$SignInCount,

        [Parameter(Mandatory)]
        [int]$LookbackDays
    )

    $now = Get-Date
    $stale30 = @($Inventory | Where-Object { $null -eq $_.BestAvailableLastSeen -or $_.BestAvailableLastSeen -lt $now.AddDays(-30) }).Count
    $stale90 = @($Inventory | Where-Object { $null -eq $_.BestAvailableLastSeen -or $_.BestAvailableLastSeen -lt $now.AddDays(-90) }).Count

    return @(
        [pscustomobject]@{ Metric = "Report generated"; Value = $now }
        [pscustomobject]@{ Metric = "Sign-in lookback days"; Value = $LookbackDays }
        [pscustomobject]@{ Metric = "Unified inventory rows"; Value = $Inventory.Count }
        [pscustomobject]@{ Metric = "Unique users in inventory"; Value = @($Inventory | Where-Object { $_.UserPrincipalName -and $_.UserPrincipalName -ne "(Unassigned)" } | Select-Object -ExpandProperty UserPrincipalName -Unique).Count }
        [pscustomobject]@{ Metric = "Entra registered device objects"; Value = $EntraDeviceCount }
        [pscustomobject]@{ Metric = "Intune MDM records"; Value = $MdmCount }
        [pscustomobject]@{ Metric = "Intune MAM app registrations"; Value = $MamCount }
        [pscustomobject]@{ Metric = "Sign-in records analysed"; Value = $SignInCount }
        [pscustomobject]@{ Metric = "Intune managed inventory rows"; Value = @($Inventory | Where-Object IntuneManaged -eq $true).Count }
        [pscustomobject]@{ Metric = "Entra registered, unmanaged rows"; Value = @($Inventory | Where-Object Classification -eq "Entra registered - unmanaged").Count }
        [pscustomobject]@{ Metric = "MAM-only rows"; Value = @($Inventory | Where-Object Classification -eq "MAM only").Count }
        [pscustomobject]@{ Metric = "Sign-in-only rows"; Value = @($Inventory | Where-Object Classification -eq "Sign-in only").Count }
        [pscustomobject]@{ Metric = "Rows not seen in 30 days or never"; Value = $stale30 }
        [pscustomobject]@{ Metric = "Rows not seen in 90 days or never"; Value = $stale90 }
        [pscustomobject]@{ Metric = "Collection issues"; Value = $script:CollectionIssues.Count }
    )
}

function Export-ExcelReport {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter(Mandatory)]
        [object[]]$Inventory,

        [Parameter(Mandatory)]
        [object[]]$MamRows,

        [Parameter(Mandatory)]
        [object[]]$SignInOnlyRows,

        [Parameter(Mandatory)]
        [object[]]$SummaryRows,

        [Parameter()]
        [AllowEmptyCollection()]
        [object[]]$Issues = @()
    )

    if (Test-Path -LiteralPath $Path) {
        Remove-Item -LiteralPath $Path -Force
    }

    $common = @{
        Path          = $Path
        AutoSize      = $true
        AutoFilter    = $true
        FreezeTopRow  = $true
        BoldTopRow    = $true
        TableStyle    = "Medium2"
        ClearSheet    = $true
    }

    $Inventory | Export-Excel @common -WorksheetName "Inventory" -TableName "UnifiedInventory"
    $MamRows | Export-Excel @common -WorksheetName "MAM Registrations" -TableName "MamRegistrations"
    $SignInOnlyRows | Export-Excel @common -WorksheetName "Sign-In Only" -TableName "SignInOnly"
    $SummaryRows | Export-Excel @common -WorksheetName "Summary" -TableName "SummaryMetrics"
    $issuesForExcel = @($Issues)
    if ($issuesForExcel.Count -eq 0) {
        $issuesForExcel = @(
            [pscustomobject][ordered]@{
                Timestamp = Get-Date
                Severity  = "Information"
                Source    = "Report"
                ObjectId  = $null
                Message   = "No collection issues were recorded."
            }
        )
    }

    $issuesForExcel | Export-Excel @common -WorksheetName "Collection Issues" -TableName "CollectionIssues"

    $package = Open-ExcelPackage -Path $Path
    try {
        $inventorySheet = $package.Workbook.Worksheets["Inventory"]
        if ($null -ne $inventorySheet -and $inventorySheet.Dimension) {
            $inventorySheet.View.FreezePanes(2, 1)
            $inventorySheet.Column(1).Width = 24
            $inventorySheet.Column(2).Width = 34
            $inventorySheet.Column(6).Width = 34
            $inventorySheet.Column(7).Width = 30
            $inventorySheet.Column(8).Width = 24
            $inventorySheet.Column(31).Width = 24
            $inventorySheet.Column(32).Width = 24
            $inventorySheet.Column(33).Width = 24
            $inventorySheet.Column(34).Width = 24
            $inventorySheet.Column(35).Width = 24
            $inventorySheet.Column(55).Width = 65

            # Classification colours: managed, Entra-only, MAM-only and sign-in-only.
            Add-ConditionalFormatting `
                -WorkSheet $inventorySheet `
                -Address ("F2:F{0}" -f $inventorySheet.Dimension.End.Row) `
                -RuleType ContainsText `
                -ConditionValue "Intune managed" `
                -BackgroundColor "C6EFCE" `
                -ForegroundColor "006100"

            Add-ConditionalFormatting `
                -WorkSheet $inventorySheet `
                -Address ("F2:F{0}" -f $inventorySheet.Dimension.End.Row) `
                -RuleType ContainsText `
                -ConditionValue "Entra registered - unmanaged" `
                -BackgroundColor "FFEB9C" `
                -ForegroundColor "9C6500"

            Add-ConditionalFormatting `
                -WorkSheet $inventorySheet `
                -Address ("F2:F{0}" -f $inventorySheet.Dimension.End.Row) `
                -RuleType ContainsText `
                -ConditionValue "MAM only" `
                -BackgroundColor "DDEBF7" `
                -ForegroundColor "1F4E78"

            Add-ConditionalFormatting `
                -WorkSheet $inventorySheet `
                -Address ("F2:F{0}" -f $inventorySheet.Dimension.End.Row) `
                -RuleType ContainsText `
                -ConditionValue "Sign-in only" `
                -BackgroundColor "F4CCCC" `
                -ForegroundColor "9C0006"
        }

        Close-ExcelPackage -ExcelPackage $package
    }
    catch {
        if ($null -ne $package) {
            Close-ExcelPackage -ExcelPackage $package -NoSave
        }
        throw
    }
}

try {
    Write-Section "Preparing dependencies"
    [void](Ensure-Module -Name "Microsoft.Graph.Authentication")

    $excelAvailable = $false
    if (-not $SkipExcel) {
        $excelAvailable = Ensure-Module -Name "ImportExcel" -Optional
    }

    New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null

    Write-Section "Connecting to Microsoft Graph"

    $scopes = @(
        "Device.Read.All"
        "Directory.Read.All"
        "User.Read.All"
        "DeviceManagementManagedDevices.Read.All"
        "DeviceManagementApps.Read.All"
        "AuditLog.Read.All"
    )

    $connectParameters = @{
        Scopes       = $scopes
        ContextScope = "Process"
        NoWelcome    = $true
    }

    if (-not [string]::IsNullOrWhiteSpace($TenantId)) {
        $connectParameters["TenantId"] = $TenantId
    }

    Connect-MgGraph @connectParameters

    $context = Get-MgContext
    if ($null -eq $context) {
        throw "Microsoft Graph sign-in did not return an authentication context."
    }

    Write-Host ("Signed in as: {0}" -f $context.Account) -ForegroundColor Green
    Write-Host ("Tenant ID:    {0}" -f $context.TenantId)

    $users = @(Get-EntraUsers)
    $userLookup = @{}
    foreach ($user in $users) {
        $id = [string](Get-SafeProperty $user "id")
        if (-not [string]::IsNullOrWhiteSpace($id)) {
            $userLookup[$id] = $user
        }
    }

    $entraDevices = @(Get-EntraDevices)
    $ownerLookup = Get-EntraDeviceOwnerLookup -Devices $entraDevices
    $mdmDevices = @(Get-IntuneManagedDevices)
    $mamRegistrations = @(Get-IntuneMamRegistrations)
    $signIns = @(Get-EntraSignIns -LookbackDays $SignInLookbackDays -IncludeFailures:$IncludeFailedSignIns)

    Write-Section "Normalising MAM registrations"
    $mamRows = @(
        foreach ($registration in $mamRegistrations) {
            try {
                Convert-MamRegistration -Registration $registration -UserLookup $userLookup
            }
            catch {
                $registrationId = [string](Get-SafeProperty $registration "id")
                Add-CollectionIssue `
                    -Source "MAM normalisation" `
                    -ObjectId $registrationId `
                    -Message $_.Exception.Message
            }
        }
    )

    $inventory = @(
        New-UnifiedInventory `
            -EntraDevices $entraDevices `
            -OwnerLookup $ownerLookup `
            -MdmDevices $mdmDevices `
            -MamRows $mamRows `
            -SignIns $signIns `
            -UserLookup $userLookup
    )

    $signInOnlyRows = @($inventory | Where-Object Classification -eq "Sign-in only")
    $summaryRows = @(
        New-SummaryRows `
            -Inventory $inventory `
            -EntraDeviceCount $entraDevices.Count `
            -MdmCount $mdmDevices.Count `
            -MamCount $mamRegistrations.Count `
            -SignInCount $signIns.Count `
            -LookbackDays $SignInLookbackDays
    )

    Write-Section "Exporting reports"

    $inventoryCsv = Join-Path $OutputDirectory "UnifiedDeviceInventory.csv"
    $mamCsv = Join-Path $OutputDirectory "MamAppRegistrations.csv"
    $signInCsv = Join-Path $OutputDirectory "SignInOnlyObservations.csv"
    $issuesCsv = Join-Path $OutputDirectory "CollectionIssues.csv"
    $excelPath = Join-Path $OutputDirectory "UnifiedDeviceInventory.xlsx"

    $inventory | Export-Csv -Path $inventoryCsv -NoTypeInformation -Encoding UTF8
    $mamRows | Export-Csv -Path $mamCsv -NoTypeInformation -Encoding UTF8
    $signInOnlyRows | Export-Csv -Path $signInCsv -NoTypeInformation -Encoding UTF8
    $issuesForExport = @($script:CollectionIssues)
    if ($issuesForExport.Count -eq 0) {
        $issuesForExport = @(
            [pscustomobject][ordered]@{
                Timestamp = Get-Date
                Severity  = "Information"
                Source    = "Report"
                ObjectId  = $null
                Message   = "No collection issues were recorded."
            }
        )
    }
    $issuesForExport | Export-Csv -Path $issuesCsv -NoTypeInformation -Encoding UTF8

    if ($excelAvailable) {
        try {
            Export-ExcelReport `
                -Path $excelPath `
                -Inventory $inventory `
                -MamRows $mamRows `
                -SignInOnlyRows $signInOnlyRows `
                -SummaryRows $summaryRows `
                -Issues @($script:CollectionIssues)

            Write-Host ("Excel report: {0}" -f $excelPath) -ForegroundColor Green
        }
        catch {
            Add-CollectionIssue -Source "Excel export" -Message $_.Exception.Message
            Write-Warning ("Excel export failed, but CSV files were created: {0}" -f $_.Exception.Message)
            @($script:CollectionIssues) | Export-Csv -Path $issuesCsv -NoTypeInformation -Encoding UTF8

            $summaryRows = @(
                New-SummaryRows `
                    -Inventory $inventory `
                    -EntraDeviceCount $entraDevices.Count `
                    -MdmCount $mdmDevices.Count `
                    -MamCount $mamRegistrations.Count `
                    -SignInCount $signIns.Count `
                    -LookbackDays $SignInLookbackDays
            )
        }
    }

    Write-Section "Summary"
    $summaryRows | Format-Table -AutoSize

    Write-Host ""
    Write-Host ("Output directory: {0}" -f $OutputDirectory) -ForegroundColor Yellow
    Write-Host ("Inventory CSV:    {0}" -f $inventoryCsv)
    Write-Host ("MAM detail CSV:   {0}" -f $mamCsv)
    Write-Host ("Sign-in-only CSV: {0}" -f $signInCsv)
    Write-Host ("Issues CSV:       {0}" -f $issuesCsv)

    if ($script:CollectionIssues.Count -gt 0) {
        Write-Warning (
            "The report completed with {0} collection issue(s). Review CollectionIssues.csv." -f
            $script:CollectionIssues.Count
        )
    }
    else {
        Write-Host "Report completed without recorded collection issues." -ForegroundColor Green
    }
}
catch {
    Write-Host ""
    Write-Host "Report failed:" -ForegroundColor Red
    Write-Host $_.Exception.Message -ForegroundColor Red

    if (-not (Test-Path -LiteralPath $OutputDirectory)) {
        New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
    }

    Add-CollectionIssue -Source "Fatal" -Severity "Error" -Message $_.Exception.Message
    @($script:CollectionIssues) |
        Export-Csv `
            -Path (Join-Path $OutputDirectory "CollectionIssues.csv") `
            -NoTypeInformation `
            -Encoding UTF8

    exit 1
}
finally {
    if (Get-Command Get-MgContext -ErrorAction SilentlyContinue) {
        if (Get-MgContext -ErrorAction SilentlyContinue) {
            Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
        }
    }
}