IntuneOperator.psm1

[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
    'PSAvoidAssignmentToAutomaticVariable', 'IsWindows',
    Justification = 'IsWindows doesnt exist in PS5.1'
)]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
    'PSUseDeclaredVarsMoreThanAssignments', 'IsWindows',
    Justification = 'IsWindows doesnt exist in PS5.1'
)]
[CmdletBinding()]
param()
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath)
$script:PSModuleInfo = Import-PowerShellDataFile -Path "$PSScriptRoot\$baseName.psd1"
$script:PSModuleInfo | Format-List | Out-String -Stream | ForEach-Object { Write-Debug $_ }
$scriptName = $script:PSModuleInfo.Name
Write-Debug "[$scriptName] - Importing module"

if ($PSEdition -eq 'Desktop') {
    $IsWindows = $true
}

#region [functions] - [private]
Write-Debug "[$scriptName] - [functions] - [private] - Processing folder"
#region [functions] - [private] - [Get-FirstPropertyValue]
Write-Debug "[$scriptName] - [functions] - [private] - [Get-FirstPropertyValue] - Importing"
function Get-FirstPropertyValue {
    <#
    .SYNOPSIS
    Returns the first non-empty property value from an object or dictionary.

    .DESCRIPTION
    Checks each property name in order against the input object, dictionary keys,
    and an AdditionalProperties dictionary when present. Returns the first value
    that is not null and not empty/whitespace when converted to string.

    .PARAMETER InputObject
    The object or dictionary to inspect.

    .PARAMETER PropertyNames
    The ordered list of property names to evaluate.

    .PARAMETER DefaultValue
    The value to return when none of the requested properties contain a value.

    .EXAMPLE
    Get-FirstPropertyValue -InputObject $Object -PropertyNames @('displayName', 'name')

    Returns the first populated value found in `displayName` or `name`.

    .INPUTS
    System.Object

    .OUTPUTS
    System.Object
    #>


    [OutputType([object])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowNull()]
        [object]$InputObject,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string[]]$PropertyNames,

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [object]$DefaultValue = $null
    )

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

        $additionalProperties = $InputObject.PSObject.Properties['AdditionalProperties']
        $additionalPropertiesValue = $null
        if ($null -ne $additionalProperties -and $additionalProperties.Value -is [System.Collections.IDictionary]) {
            $additionalPropertiesValue = $additionalProperties.Value
        }

        foreach ($propertyName in $PropertyNames) {
            if ($InputObject -is [System.Collections.IDictionary] -and $InputObject.Contains($propertyName)) {
                $dictionaryValue = $InputObject[$propertyName]
                if ($null -ne $dictionaryValue -and -not [string]::IsNullOrWhiteSpace([string]$dictionaryValue)) {
                    return $dictionaryValue
                }
            }

            $property = $InputObject.PSObject.Properties[$propertyName]
            if ($null -ne $property) {
                $value = $property.Value
                if ($null -ne $value -and -not [string]::IsNullOrWhiteSpace([string]$value)) {
                    return $value
                }
            }

            if ($null -ne $additionalPropertiesValue -and $additionalPropertiesValue.Contains($propertyName)) {
                $apValue = $additionalPropertiesValue[$propertyName]
                if ($null -ne $apValue -and -not [string]::IsNullOrWhiteSpace([string]$apValue)) {
                    return $apValue
                }
            }
        }

        return $DefaultValue
    }
}
Write-Debug "[$scriptName] - [functions] - [private] - [Get-FirstPropertyValue] - Done"
#endregion [functions] - [private] - [Get-FirstPropertyValue]
#region [functions] - [private] - [Invoke-GraphGet]
Write-Debug "[$scriptName] - [functions] - [private] - [Invoke-GraphGet] - Importing"
function Invoke-GraphGet {
    <#
    .SYNOPSIS
    Invokes a GET request against the Microsoft Graph API with error handling and automatic pagination.

    .DESCRIPTION
    Wrapper function for making authenticated GET requests to the Microsoft Graph API.
    Provides consistent error handling, verbose output, and automatic pagination handling for all Graph API calls.
    When the response contains a 'value' collection and '@odata.nextLink', automatically follows pagination
    to retrieve all results.

    Requires an established Microsoft Graph connection via Connect-MgGraph.

    .PARAMETER Uri
    The full URI of the Graph API endpoint to query.

    .EXAMPLE
    Invoke-GraphGet -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices"

    Retrieves all managed devices from the Graph API, automatically following pagination links.

    .EXAMPLE
    Invoke-GraphGet -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices/12345"

    Retrieves a single managed device by ID (no pagination applies).

    .INPUTS
    System.String

    .OUTPUTS
    PSObject

    .NOTES
    Part of the Intune Device Login helper functions.
    Requires Microsoft.Graph PowerShell module with active connection.
    Automatically handles pagination for collection responses.
    #>


    [OutputType([PSObject])]
    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = "The full URI of the Graph API endpoint"
        )]
        [ValidateNotNullOrEmpty()]
        [string]$Uri
    )

    process {
        Write-Verbose -Message "GET $Uri"
        try {
            $splat = @{
                Method      = 'GET'
                Uri         = $Uri
                ErrorAction = 'Stop'
            }
            $response = Invoke-MgGraphRequest @splat

            # Check if response has pagination (value collection with nextLink)
            if ($null -ne $response.value -and $null -ne $response.'@odata.nextLink') {

                Write-Verbose -Message "Response contains pagination, retrieving all pages"
                $allValues = [System.Collections.Generic.List[object]]::new()
                $allValues.AddRange($response.value)

                $nextLink = $response.'@odata.nextLink'
                $pageCount = 1

                while ($null -ne $nextLink) {
                    $pageCount++
                    Write-Verbose -Message "Following pagination link (page $pageCount, current items: $($allValues.Count))"

                    $splat.Uri = $nextLink
                    $nextResponse = Invoke-MgGraphRequest @splat

                    if ($null -ne $nextResponse.value) {
                        $allValues.AddRange($nextResponse.value)
                    }

                    $nextLink = $nextResponse.'@odata.nextLink'
                }

                Write-Verbose -Message "Pagination complete: retrieved $($allValues.Count) total items across $pageCount pages"

                # Return modified response with all values
                $response.value = $allValues.ToArray()
                $response.PSObject.Properties.Remove('@odata.nextLink')
            }

            return $response

        } catch {
            $Exception = [Exception]::new("Graph request failed for '$Uri': $($_.Exception.Message)", $_.Exception)
            $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                $Exception,
                'GraphRequestFailed',
                [System.Management.Automation.ErrorCategory]::NotSpecified,
                $Uri
            )
            $PSCmdlet.ThrowTerminatingError($ErrorRecord)
        }
    } # Process
} # Cmdlet
Write-Debug "[$scriptName] - [functions] - [private] - [Invoke-GraphGet] - Done"
#endregion [functions] - [private] - [Invoke-GraphGet]
#region [functions] - [private] - [Resolve-EntraUserById]
Write-Debug "[$scriptName] - [functions] - [private] - [Resolve-EntraUserById] - Importing"
function Resolve-EntraUserById {
    <#
    .SYNOPSIS
    Resolves an Entra ID user by user ID to retrieve user principal name and other details.

    .DESCRIPTION
    Queries Microsoft Graph for a specific user by their user ID (object ID).
    Returns user object including userPrincipalName for reporting and audit purposes.
    Handles cases where user may no longer exist in Entra ID.

    .PARAMETER UserId
    The Entra ID user object identifier (GUID).

    .EXAMPLE
    Resolve-EntraUserById -UserId "d1e1a1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a"

    Returns the user object with UPN for the specified user ID.

    .INPUTS
    System.String

    .OUTPUTS
    PSObject

    .NOTES
    Part of the Intune Device Login helper functions.
    Uses Microsoft Graph /v1.0 endpoint.
    Requires User.Read.All scope.
    Returns a minimal user object if user cannot be found.
    #>


    [OutputType([PSObject])]
    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = "The Entra ID user object ID"
        )]
        [ValidateNotNullOrEmpty()]
        [string]$UserId
    )

    begin {
        $baseUri = 'https://graph.microsoft.com/v1.0/users'
    }

    process {
        $uri = "$baseUri/$UserId"
        try {
            Invoke-GraphGet -Uri $uri
        } catch {
            Write-Verbose -Message "Could not resolve user ID '$UserId': $($_.Exception.Message)"
            # Return a minimal object with the ID and a placeholder UPN
            [PSCustomObject]@{
                id                = $UserId
                userPrincipalName = "Unknown (ID: $UserId)"
            }
        }
    }
}
Write-Debug "[$scriptName] - [functions] - [private] - [Resolve-EntraUserById] - Done"
#endregion [functions] - [private] - [Resolve-EntraUserById]
#region [functions] - [private] - [Resolve-IntuneDeviceByName]
Write-Debug "[$scriptName] - [functions] - [private] - [Resolve-IntuneDeviceByName] - Importing"
function Resolve-IntuneDeviceByName {
    <#
    .SYNOPSIS
    Resolves one or more Intune managed devices by device name.

    .DESCRIPTION
    Queries Intune managed devices using the device name filter.
    Performs case-insensitive exact match searching via OData filter.
    Returns all devices matching the specified name.

    .PARAMETER Name
    The device name to search for in Intune managed devices.

    .EXAMPLE
    Resolve-IntuneDeviceByName -Name "PC-001"

    Returns the managed device object matching the name "PC-001", if found.

    .INPUTS
    System.String

    .OUTPUTS
    PSObject[]

    .NOTES
    Part of the Intune Device Login helper functions.
    Uses Microsoft Graph /beta endpoint.
    Requires DeviceManagementManagedDevices.Read.All scope.
    #>


    [OutputType([PSCustomObject[]])]
    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = "The device name to resolve"
        )]
        [ValidateNotNullOrEmpty()]
        [string]$Name
    )

    begin {
        $baseUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices'
    }

    process {
        # deviceName is case-insensitive in OData. Exact match.
        $encoded = [uri]::EscapeDataString("deviceName eq '$Name'")
        $uri = "$baseUri`?`$filter=$encoded&`$select=id,deviceName"

        $resp = Invoke-GraphGet -Uri $uri

        if ($null -eq $resp.value -or $resp.value.Count -eq 0) {
            Write-Verbose -Message "No managed devices found with deviceName '$Name'."
            return [PSCustomObject[]]@()
        }

        # Return PSCustomObject with Id and DeviceName
        $resp.value | ForEach-Object -Process {
            [PSCustomObject]@{
                Id         = $_.id
                DeviceName = $_.deviceName
            }
        }
    }
}
Write-Debug "[$scriptName] - [functions] - [private] - [Resolve-IntuneDeviceByName] - Done"
#endregion [functions] - [private] - [Resolve-IntuneDeviceByName]
Write-Debug "[$scriptName] - [functions] - [private] - Done"
#endregion [functions] - [private]
#region [functions] - [public]
Write-Debug "[$scriptName] - [functions] - [public] - Processing folder"
#region [functions] - [public] - [completers]
Write-Debug "[$scriptName] - [functions] - [public] - [completers] - Importing"
Register-ArgumentCompleter -CommandName New-PSModuleTest -ParameterName Name -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
    $null = $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters

    'Alice', 'Bob', 'Charlie' | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
        [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
    }
}
Write-Debug "[$scriptName] - [functions] - [public] - [completers] - Done"
#endregion [functions] - [public] - [completers]
#region [functions] - [public] - [Device]
Write-Debug "[$scriptName] - [functions] - [public] - [Device] - Processing folder"
#region [functions] - [public] - [Device] - [Get-IntuneDeviceLogin]
Write-Debug "[$scriptName] - [functions] - [public] - [Device] - [Get-IntuneDeviceLogin] - Importing"
#Requires -Modules @{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.28.0' }

function Get-IntuneDeviceLogin {
    <#
    .SYNOPSIS
    Retrieves logged-on user info for an Intune-managed device by DeviceId, DeviceName, UserPrincipalName, or UserId.

    .DESCRIPTION
    Uses Microsoft Graph (beta) to read managed device metadata and the `usersLoggedOn` collection.
    When given a DeviceId, queries that specific device. When given a DeviceName, resolves one or more
    matching managed devices (`deviceName eq '<name>'`) and returns logon info for each match.
    When given a UserPrincipalName or UserId, searches all managed devices and returns only those where
    the specified user has logged in.

    Requires an authenticated Graph session with appropriate scopes.

    Scopes (minimum):
        - DeviceManagementManagedDevices.Read.All
        - User.Read.All

    .PARAMETER DeviceId
    The Intune managed device identifier (GUID). Parameter set: ById.

    .PARAMETER DeviceName
    The device name to resolve in Intune managed devices. Parameter set: ByName.
    If multiple devices share the same name, all matches are processed.

    .PARAMETER UserPrincipalName
    The user principal name (UPN) to search for across all managed devices. Parameter set: ByUserPrincipalName.
    Returns all devices where this user has logged in.

    .PARAMETER UserId
    The Entra ID user object identifier (GUID). Parameter set: ByUserId.
    Returns all devices where this user has logged in.

    .EXAMPLE
    Connect-MgGraph -Scopes "DeviceManagementManagedDevices.Read.All","User.Read.All"
    Get-IntuneDeviceLogin -DeviceId "c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a"

    Gets logged-on user info for the specified device.

    .EXAMPLE
    Get-IntuneDeviceLogin -DeviceName PC-001

    Resolves the device name and returns logged-on user info for the match.

    .EXAMPLE
    Get-IntuneDeviceLogin -UserPrincipalName "<UserPrincipalName>"

    Returns all devices where the specified user principal name has logged in.

    .EXAMPLE
    Get-IntuneDeviceLogin -UserId "c1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a"

    Returns all devices where the specified user (by ID) has logged in.

    .INPUTS
    System.String (DeviceId, DeviceName, UserPrincipalName, or UserId via pipeline/property name)

    .OUTPUTS
    PSCustomObject with the following properties
    - DeviceId (string)
    - DeviceName (string)
    - OperatingSystem (string)
    - UserId (string)
    - UserPrincipalName (string)
    - LastLogonDateTime (datetime)

    .NOTES
    Author: FHN & ChatGPT & GitHub Copilot
    - Uses /beta Graph endpoints because usersLoggedOn is exposed there.
    #>


    [OutputType([PSCustomObject])]
    [CmdletBinding(DefaultParameterSetName = 'ById', SupportsShouldProcess = $false)]
    param(
        # ById: DeviceId (GUID)
        [Parameter(
            ParameterSetName = 'ById',
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidatePattern('^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$')]
        [Alias('Id', 'ManagedDeviceId')]
        [string]$DeviceId,

        # ByName: DeviceName (string)
        [Parameter(
            ParameterSetName = 'ByName',
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateNotNullOrEmpty()]
        [Alias('Name', 'ComputerName')]
        [string]$DeviceName,

        # ByUserPrincipalName: UserPrincipalName (string)
        [Parameter(
            ParameterSetName = 'ByUserPrincipalName',
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateNotNullOrEmpty()]
        [Alias('UPN')]
        [string]$UserPrincipalName,

        # ByUserId: UserId (GUID)
        [Parameter(
            ParameterSetName = 'ByUserId',
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidatePattern('^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$')]
        [string]$UserId
    )

    begin {
        $baseUri = 'https://graph.microsoft.com/beta/deviceManagement/managedDevices'
    }

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'ById' {
                Write-Verbose -Message "Resolving usersLoggedOn for device id: $DeviceId"
                $uri = "$baseUri/$DeviceId"
                try {
                    $device = Invoke-GraphGet -Uri $uri
                } catch {
                    $errorMessage = $_.Exception.Message
                    if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') {
                        $Exception = [Exception]::new("Managed device not found for id '$DeviceId': $errorMessage", $_.Exception)
                        $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                            $Exception,
                            'DeviceNotFound',
                            [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                            $DeviceId
                        )
                        $PSCmdlet.WriteError($ErrorRecord)
                        return
                    }

                    $Exception = [Exception]::new("Failed to resolve device id '$DeviceId': $errorMessage", $_.Exception)
                    $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                        $Exception,
                        'DeviceLookupFailed',
                        [System.Management.Automation.ErrorCategory]::NotSpecified,
                        $DeviceId
                    )
                    $PSCmdlet.ThrowTerminatingError($ErrorRecord)
                }

                if (-not $device) {
                    $Exception = [Exception]::new("Managed device not found for id '$DeviceId'.")
                    $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                        $Exception,
                        'DeviceNotFound',
                        [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                        $DeviceId
                    )
                    $PSCmdlet.WriteError($ErrorRecord)
                    return
                }

                if (-not $device.usersLoggedOn -or $device.usersLoggedOn.Count -eq 0) {
                    Write-Verbose -Message "No logged-on users found for device '$($device.deviceName)' ($DeviceId)."
                    return
                }

                foreach ($entry in $device.usersLoggedOn) {
                    $user = Resolve-EntraUserById -UserId $entry.userId
                    [PSCustomObject]@{
                        DeviceName        = $device.deviceName
                        OperatingSystem   = $device.operatingSystem
                        UserPrincipalName = $user.userPrincipalName
                        DeviceId          = $device.id
                        UserId            = $entry.userId
                        LastLogonDateTime = [datetime]$entry.lastLogOnDateTime
                    }
                }
            }

            'ByName' {
                Write-Verbose -Message "Resolving device(s) by name: $DeviceName"
                try {
                    $deviceSummaries = Resolve-IntuneDeviceByName -Name $DeviceName
                } catch {
                    $errorMessage = $_.Exception.Message
                    if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') {
                        $Exception = [Exception]::new("Managed device not found for name '$DeviceName': $errorMessage", $_.Exception)
                        $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                            $Exception,
                            'DeviceNameNotFound',
                            [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                            $DeviceName
                        )
                        $PSCmdlet.WriteError($ErrorRecord)
                        return
                    }

                    $Exception = [Exception]::new("Failed to resolve device name '$DeviceName': $errorMessage", $_.Exception)
                    $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                        $Exception,
                        'DeviceNameLookupFailed',
                        [System.Management.Automation.ErrorCategory]::NotSpecified,
                        $DeviceName
                    )
                    $PSCmdlet.ThrowTerminatingError($ErrorRecord)
                }

                if ($null -eq $deviceSummaries -or $deviceSummaries.Count -eq 0) {
                    $Exception = [Exception]::new("Managed device not found for name '$DeviceName'.")
                    $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                        $Exception,
                        'DeviceNameNotFound',
                        [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                        $DeviceName
                    )
                    $PSCmdlet.WriteError($ErrorRecord)
                    return
                }

                if ($deviceSummaries.Count -gt 1) {
                    Write-Verbose -Message "Multiple devices matched name '$DeviceName' ($($deviceSummaries.Count) matches). Returning results for all."
                }

                foreach ($summary in $deviceSummaries) {
                    $uri = "$baseUri/$($summary.Id)"
                    try {
                        $device = Invoke-GraphGet -Uri $uri
                    } catch {
                        $errorMessage = $_.Exception.Message
                        if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') {
                            $Exception = [Exception]::new("Managed device not found for id '$($summary.Id)' while resolving name '$DeviceName': $errorMessage", $_.Exception)
                            $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                                $Exception,
                                'DeviceNotFound',
                                [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                                $summary.Id
                            )
                            $PSCmdlet.WriteError($ErrorRecord)
                            continue
                        }

                        $Exception = [Exception]::new("Failed to retrieve device id '$($summary.Id)' while resolving name '$DeviceName': $errorMessage", $_.Exception)
                        $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                            $Exception,
                            'DeviceLookupFailed',
                            [System.Management.Automation.ErrorCategory]::NotSpecified,
                            $summary.Id
                        )
                        $PSCmdlet.ThrowTerminatingError($ErrorRecord)
                    }

                    if (-not $device) {
                        $Exception = [Exception]::new("Managed device not found for id '$($summary.Id)' while resolving name '$DeviceName'.")
                        $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                            $Exception,
                            'DeviceNotFound',
                            [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                            $summary.Id
                        )
                        $PSCmdlet.WriteError($ErrorRecord)
                        continue
                    }

                    if (-not $device.usersLoggedOn -or $device.usersLoggedOn.Count -eq 0) {
                        Write-Verbose -Message "No logged-on users found for device '$($summary.DeviceName)' ($($summary.Id))."
                        continue
                    }

                    foreach ($entry in $device.usersLoggedOn) {
                        $user = Resolve-EntraUserById -UserId $entry.userId
                        [PSCustomObject]@{
                            DeviceName        = $device.deviceName
                            OperatingSystem   = $device.operatingSystem
                            UserPrincipalName = $user.userPrincipalName
                            DeviceId          = $device.id
                            UserId            = $entry.userId
                            LastLogonDateTime = [datetime]$entry.lastLogOnDateTime
                        }
                    }
                }
            }

            { $_ -in 'ByUserPrincipalName', 'ByUserId' } {
                # Resolve UPN to UserId if needed
                if ($PSCmdlet.ParameterSetName -eq 'ByUserPrincipalName') {
                    Write-Verbose -Message "Resolving UserPrincipalName '$UserPrincipalName' to UserId"
                    $userUri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName"
                    try {
                        $userObj = Invoke-GraphGet -Uri $userUri
                        $targetUserId = $userObj.id
                        Write-Verbose -Message "Resolved to UserId: $targetUserId"
                    } catch {
                        $errorMessage = $_.Exception.Message
                        if ($errorMessage -match 'Request_ResourceNotFound|NotFound|404') {
                            $Exception = [Exception]::new("Could not resolve UserPrincipalName '$UserPrincipalName': $errorMessage", $_.Exception)
                            $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                                $Exception,
                                'UserResolutionFailed',
                                [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                                $UserPrincipalName
                            )
                            $PSCmdlet.WriteError($ErrorRecord)
                            return
                        }

                        $Exception = [Exception]::new("Failed to resolve UserPrincipalName '$UserPrincipalName': $errorMessage", $_.Exception)
                        $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                            $Exception,
                            'UserResolutionGraphRequestFailed',
                            [System.Management.Automation.ErrorCategory]::NotSpecified,
                            $UserPrincipalName
                        )
                        $PSCmdlet.ThrowTerminatingError($ErrorRecord)
                    }
                } else {
                    $targetUserId = $UserId
                }

                Write-Verbose -Message "Searching for devices where user '$targetUserId' has logged in"

                # Get all managed devices with usersLoggedOn property.
                # Invoke-GraphGet already handles pagination, so we query once and filter client-side.
                $uri = "$baseUri`?`$select=id,deviceName,operatingSystem,usersLoggedOn"
                $resp = Invoke-GraphGet -Uri $uri

                $allDevices = @()
                if ($null -ne $resp) {
                    if ($null -ne $resp.value) {
                        $allDevices = @($resp.value)
                    } else {
                        $allDevices = @($resp)
                    }
                }

                if ($allDevices.Count -eq 0) {
                    Write-Verbose -Message "No managed devices found."
                    return
                }

                Write-Verbose -Message "Checking $($allDevices.Count) managed devices for user logons"

                $matchCount = 0
                $seenResults = [System.Collections.Generic.HashSet[string]]::new()
                foreach ($device in $allDevices) {
                    # Check if target user is in the usersLoggedOn collection
                    $userLogon = $device.usersLoggedOn | Where-Object -FilterScript { $_.userId -eq $targetUserId }
                    if ($userLogon) {
                        $latestUserLogon = $userLogon |
                            Sort-Object -Property lastLogOnDateTime -Descending |
                            Select-Object -First 1
                        $logonTimestamp = [datetime]$latestUserLogon.lastLogOnDateTime
                        $resultKey = "{0}|{1}|{2}" -f $device.id, $targetUserId, $logonTimestamp.ToString('o')
                        if (-not $seenResults.Add($resultKey)) {
                            continue
                        }

                        $matchCount++
                        $user = Resolve-EntraUserById -UserId $targetUserId
                        [PSCustomObject]@{
                            DeviceName        = $device.deviceName
                            OperatingSystem   = $device.operatingSystem
                            UserPrincipalName = $user.userPrincipalName
                            DeviceId          = $device.id
                            UserId            = $targetUserId
                            LastLogonDateTime = $logonTimestamp
                        }
                    }
                }

                if ($matchCount -eq 0) {
                    Write-Verbose -Message "No devices found where user '$targetUserId' has logged in."
                }
            }
        }
    } # Process
} # Cmdlet
Write-Debug "[$scriptName] - [functions] - [public] - [Device] - [Get-IntuneDeviceLogin] - Done"
#endregion [functions] - [public] - [Device] - [Get-IntuneDeviceLogin]
Write-Debug "[$scriptName] - [functions] - [public] - [Device] - Done"
#endregion [functions] - [public] - [Device]
#region [functions] - [public] - [Remediation]
Write-Debug "[$scriptName] - [functions] - [public] - [Remediation] - Processing folder"
#region [functions] - [public] - [Remediation] - [Get-IntuneRemediationDeviceStatus]
Write-Debug "[$scriptName] - [functions] - [public] - [Remediation] - [Get-IntuneRemediationDeviceStatus] - Importing"
function Get-IntuneRemediationDeviceStatus {
    <#
    .SYNOPSIS
    Retrieves per-device run state and pre/post remediation output for a specific Intune proactive remediation.

    .DESCRIPTION
    Queries Microsoft Graph (beta) for the device run states of a proactive remediation script
    (device health script). Returns one row per device, including the detection and remediation
    states and the captured pre/post-remediation detection script output.

    Requires an authenticated Graph session with appropriate scopes.

    Scopes (minimum):
        - DeviceManagementConfiguration.Read.All

    .PARAMETER Name
    The display name of the remediation script. Supports wildcards (*).
    When multiple scripts match, all are processed.
    Parameter set: ByName.

    .PARAMETER Id
    The ID (GUID) of the device health script (remediation) to query.
    Parameter set: ById.

    .EXAMPLE
    Connect-MgGraph -Scopes "DeviceManagementConfiguration.Read.All"
    Get-IntuneRemediationDeviceStatus -Name "BitLocker*"

    Returns per-device run states for all remediations whose name starts with "BitLocker".

    .EXAMPLE
    Get-IntuneRemediationDeviceStatus -Id "f1f5d1d7-2d2b-4d8c-9f0a-0d2a3d1e2f3a"

    Returns per-device run states for the remediation with the specified ID.

    .EXAMPLE
    Get-IntuneRemediationSummary | Where-Object WithIssues -gt 0 | Get-IntuneRemediationDeviceStatus

    Pipes remediations that have devices with issues into this cmdlet to get device-level detail.

    .INPUTS
    System.String (Name or Id via pipeline by property name)

    .OUTPUTS
    PSCustomObject with the following properties
    - RemediationName (string) : Display name of the remediation script
    - RemediationId (string) : GUID of the device health script
    - DeviceId (string) : Managed device ID
    - DeviceName (string) : Managed device display name
    - UserPrincipalName (string) : Primary user UPN of the device
    - LastStateUpdate (datetime / null): When the run state was last updated
    - DetectionState (string) : Outcome of the last detection script run
    - RemediationState (string) : Outcome of the last remediation script run
    - PreRemediationOutput (string) : stdout captured before remediation ran
    - PostRemediationOutput (string) : stdout captured after remediation ran
    - DetectionOutput (string) : Detection-only script stdout
    - PreRemediationError (string) : stderr captured before remediation ran
    - RemediationError (string) : stderr from the remediation script
    - DetectionError (string) : stderr from the detection script

    .NOTES
    Author: FHN & GitHub Copilot
    - Uses /beta Graph endpoints.
    - Expands managedDevice to include DeviceName and UserPrincipalName inline.
    #>


    [OutputType([PSCustomObject])]
    [CmdletBinding(DefaultParameterSetName = 'ByName', SupportsShouldProcess = $false)]
    param(
        [Parameter(
            ParameterSetName = 'ByName',
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = 'Display name (supports wildcards) of the remediation script'
        )]
        [ValidateNotNullOrEmpty()]
        [Alias('RemediationName')]
        [string]$Name,

        [Parameter(
            ParameterSetName = 'ById',
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = 'GUID of the device health / remediation script'
        )]
        [ValidatePattern('^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$')]
        [Alias('RemediationId')]
        [string]$Id
    )

    begin {
        # Expand managedDevice so device identity fields are available in each run-state row.
        $expandQuery = '$expand=managedDevice($select=id,deviceName,userPrincipalName)'
    }

    process {
        # ------------------------------------------------------------------ #
        # 1. Resolve the target script(s) when called by name #
        # ------------------------------------------------------------------ #
        $targetScripts = @()

        if ($PSCmdlet.ParameterSetName -eq 'ById') {
            $targetScripts += [PSCustomObject]@{
                id          = $Id
                displayName = $Id   # will be overwritten once we fetch the real name
            }

            # Resolve display name for friendlier output while still accepting direct ID input.
            try {
                $scriptDetailUri = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$($Id)?`$select=id,displayName"
                $scriptDetail = Invoke-GraphGet -Uri $scriptDetailUri
                if ($null -ne $scriptDetail -and -not [string]::IsNullOrWhiteSpace($scriptDetail.displayName)) {
                    $targetScripts[0] = [PSCustomObject]@{
                        id          = $Id
                        displayName = [string]$scriptDetail.displayName
                    }
                }
            } catch {
                Write-Warning -Message "Could not fetch display name for remediation '$Id': $($_.Exception.Message)"
            }
        } else {
            # ByName – list all scripts then filter
            $listUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts?$select=id,displayName'
            try {
                $listResponse = Invoke-GraphGet -Uri $listUri
            } catch {
                if ($_.FullyQualifiedErrorId -match 'GraphRequestFailed' -and $_.Exception.Message -match 'Forbidden|403') {
                    $Exception = [Exception]::new("Failed to list proactive remediations: access denied. Ensure your account has an Intune role with permission to read Device configurations, then reconnect and try again. Original error: $($_.Exception.Message)", $_.Exception)
                    $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                        $Exception,
                        'RemediationListAccessDenied',
                        [System.Management.Automation.ErrorCategory]::PermissionDenied,
                        $listUri
                    )
                    $PSCmdlet.ThrowTerminatingError($ErrorRecord)
                }

                $Exception = [Exception]::new("Failed to list proactive remediations: $($_.Exception.Message)", $_.Exception)
                $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                    $Exception,
                    'RemediationListFailed',
                    [System.Management.Automation.ErrorCategory]::NotSpecified,
                    $listUri
                )
                $PSCmdlet.ThrowTerminatingError($ErrorRecord)
            }

            # Normalize payload shape before wildcard name matching.
            $allScripts = @()
            if ($null -ne $listResponse) {
                if ($null -ne $listResponse.value) {
                    $allScripts = @($listResponse.value)
                } else {
                    $allScripts = @($listResponse)
                }
            }

            $targetScripts = @($allScripts | Where-Object { $_.displayName -like $Name })

            if ($targetScripts.Count -eq 0) {
                $Exception = [Exception]::new("No remediation script found matching name '$Name'.")
                $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                    $Exception,
                    'RemediationNotFound',
                    [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                    $Name
                )
                $PSCmdlet.WriteError($ErrorRecord)
                return
            }
        }

        # ------------------------------------------------------------------ #
        # 2. For each matched script, retrieve per-device run states #
        # ------------------------------------------------------------------ #
        foreach ($script in $targetScripts) {
            $remediationName = [string]$script.displayName
            $remediationId = [string]$script.id

            Write-Verbose -Message "Retrieving device run states for remediation '$remediationName' ($remediationId)"

            $runStatesUri = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$remediationId/deviceRunStates?$expandQuery"

            try {
                $runStatesResponse = Invoke-GraphGet -Uri $runStatesUri
            } catch {
                Write-Warning -Message "Failed to retrieve device run states for '$remediationName' ($remediationId): $($_.Exception.Message)"
                continue
            }

            # Normalize run-state list: handle both .value and direct response shapes.
            $runStates = @()
            if ($null -ne $runStatesResponse) {
                if ($null -ne $runStatesResponse.value) {
                    $runStates = @($runStatesResponse.value)
                } else {
                    $runStates = @($runStatesResponse)
                }
            }

            if ($runStates.Count -eq 0) {
                Write-Verbose -Message "No device run states found for '$remediationName'."
                continue
            }

            foreach ($state in $runStates) {
                # Resolve device fields from the expanded managedDevice object
                $device = $null
                $deviceId = $null
                $deviceName = $null
                $upn = $null

                if ($null -ne $state.managedDevice) {
                    $device = $state.managedDevice
                    $deviceId = [string]$device.id
                    $deviceName = [string]$device.deviceName
                    $upn = [string]$device.userPrincipalName
                }

                # Resolve last-state timestamp
                $lastUpdate = $null
                $rawDate = $state.lastStateUpdateDateTime
                if ($null -ne $rawDate -and -not [string]::IsNullOrWhiteSpace([string]$rawDate)) {
                    # Guard output typing: invalid timestamps should not break object emission.
                    try {
                        $lastUpdate = [datetime]$rawDate
                    } catch {
                        $lastUpdate = $null
                    }
                }

                # Cast Graph fields to predictable output types for downstream filtering/export.
                [PSCustomObject]@{
                    RemediationName       = $remediationName
                    RemediationId         = $remediationId
                    DeviceId              = $deviceId
                    DeviceName            = $deviceName
                    UserPrincipalName     = $upn
                    LastStateUpdate       = $lastUpdate
                    DetectionState        = [string]$state.detectionState
                    RemediationState      = [string]$state.remediationState
                    PreRemediationOutput  = [string]$state.preRemediationDetectionScriptOutput
                    PostRemediationOutput = [string]$state.postRemediationDetectionScriptOutput
                    DetectionOutput       = [string]$state.detectionScriptOutput
                    PreRemediationError   = [string]$state.preRemediationDetectionScriptError
                    RemediationError      = [string]$state.remediationScriptError
                    DetectionError        = [string]$state.detectionScriptError
                }
            }
        }
    } # Process
} # Cmdlet
Write-Debug "[$scriptName] - [functions] - [public] - [Remediation] - [Get-IntuneRemediationDeviceStatus] - Done"
#endregion [functions] - [public] - [Remediation] - [Get-IntuneRemediationDeviceStatus]
#region [functions] - [public] - [Remediation] - [Get-IntuneRemediationSummary]
Write-Debug "[$scriptName] - [functions] - [public] - [Remediation] - [Get-IntuneRemediationSummary] - Importing"
function Get-IntuneRemediationSummary {
    <#
    .SYNOPSIS
    Retrieves remediation summary statistics for all Intune proactive remediations.

    .DESCRIPTION
    Queries Microsoft Graph (beta) for all device health scripts (proactive remediations)
    and returns one summary row per remediation, including status and key issue counters.

    Requires an authenticated Graph session with appropriate scopes.

    Scopes (minimum):
        - DeviceManagementConfiguration.Read.All

    .EXAMPLE
    Connect-MgGraph -Scopes "DeviceManagementConfiguration.Read.All"
    Get-IntuneRemediationSummary

    Returns one row per remediation with status and issue/remediation counters.

    .OUTPUTS
    PSCustomObject with the following properties
    - Name (string)
    - Status (string)
    - WithoutIssues (int)
    - WithIssues (int)
    - IssueFixed (int)
    - IssueRecurred (int)
    - TotalRemediated (int)

    .NOTES
    Author: FHN & GitHub Copilot
    - Uses /beta Graph endpoints.
    #>


    [OutputType([PSCustomObject])]
    [CmdletBinding(SupportsShouldProcess = $false)]
    param()

    begin {
        # Query all proactive remediations once and process summaries per script.
        $listUri = 'https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts?$select=id,displayName'
    }

    process {
        Write-Verbose -Message 'Retrieving proactive remediations from Microsoft Graph'

        try {
            $scriptsResponse = Invoke-GraphGet -Uri $listUri
        } catch {
            if ($_.FullyQualifiedErrorId -match 'GraphRequestFailed' -and $_.Exception.Message -match 'Forbidden|403') {
                $Exception = [Exception]::new("Failed to retrieve proactive remediations: access denied by Intune RBAC or tenant policy. Ensure your account has an Intune role with permission to read Device configurations (Endpoint Analytics/Remediations), then reconnect and try again. Original error: $($_.Exception.Message)", $_.Exception)
                $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                    $Exception,
                    'RemediationListAccessDenied',
                    [System.Management.Automation.ErrorCategory]::PermissionDenied,
                    $listUri
                )
                $PSCmdlet.ThrowTerminatingError($ErrorRecord)
            }

            $Exception = [Exception]::new("Failed to retrieve proactive remediations: $($_.Exception.Message)", $_.Exception)
            $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                $Exception,
                'RemediationListFailed',
                [System.Management.Automation.ErrorCategory]::NotSpecified,
                $listUri
            )
            $PSCmdlet.ThrowTerminatingError($ErrorRecord)
        }

        # Normalize payload shape: Graph may return either an object with .value or a direct object/array.
        $scripts = @()
        if ($null -ne $scriptsResponse) {
            if ($null -ne $scriptsResponse.value) {
                $scripts = @($scriptsResponse.value)
            } else {
                $scripts = @($scriptsResponse)
            }
        }

        foreach ($script in $scripts) {
            if ($null -eq $script.id) {
                continue
            }

            $scriptName = [string]$script.displayName
            if ([string]::IsNullOrWhiteSpace($scriptName)) {
                $scriptName = [string](Get-FirstPropertyValue -InputObject $script -PropertyNames @('displayName', 'name') -DefaultValue $script.id)
            }

            Write-Verbose -Message "Processing remediation summary for '$scriptName'"

            $summaryUri = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$($script.id)/runSummary"
            try {
                $summaryResponse = Invoke-GraphGet -Uri $summaryUri
            } catch {
                Write-Warning -Message "Failed to retrieve run summary for '$scriptName' ($($script.id)): $($_.Exception.Message)"
                continue
            }

            # Normalize run summary root before probing nested alternatives.
            $summary = $summaryResponse
            if ($null -ne $summaryResponse.value) {
                if ($summaryResponse.value -is [array]) {
                    $summary = $summaryResponse.value | Select-Object -First 1
                } else {
                    $summary = $summaryResponse.value
                }
            }

            if ($null -eq $summary) {
                $summary = $summaryResponse
            }

            # Some tenants return nested shapes for run summary payloads.
            # Resolve to the first object that actually contains known summary counters.
            $summaryCandidates = @()
            if ($null -ne $summary) {
                $summaryCandidates += $summary
                if ($null -ne $summary.runSummary) {
                    $summaryCandidates += $summary.runSummary
                }
            }
            if ($null -ne $summaryResponse) {
                $summaryCandidates += $summaryResponse
                if ($null -ne $summaryResponse.runSummary) {
                    $summaryCandidates += $summaryResponse.runSummary
                }
                if ($null -ne $summaryResponse.value -and $null -ne $summaryResponse.value.runSummary) {
                    $summaryCandidates += $summaryResponse.value.runSummary
                }
            }
            if ($null -ne $summary.value) {
                $summaryCandidates += $summary.value
                if ($null -ne $summary.value.runSummary) {
                    $summaryCandidates += $summary.value.runSummary
                }
            }

            $resolvedSummary = $null
            foreach ($candidate in $summaryCandidates) {
                if ($null -eq $candidate) {
                    continue
                }

                $candidateNoIssue = Get-FirstPropertyValue -InputObject $candidate -PropertyNames @(
                    'noIssueDetectedDeviceCount', 'withoutIssues', 'noIssueCount', 'noIssueDeviceCount', 'devicesWithoutIssues'
                ) -DefaultValue $null
                $candidateWithIssue = Get-FirstPropertyValue -InputObject $candidate -PropertyNames @(
                    'issueDetectedDeviceCount', 'withIssues', 'issueCount', 'issueDeviceCount', 'devicesWithIssues'
                ) -DefaultValue $null
                $candidateFixed = Get-FirstPropertyValue -InputObject $candidate -PropertyNames @(
                    'issueRemediatedDeviceCount', 'issueFixed', 'issueFixedCount', 'issuesFixed', 'issueRemediatedCount'
                ) -DefaultValue $null
                $candidateRecurred = Get-FirstPropertyValue -InputObject $candidate -PropertyNames @(
                    'issueReoccurredDeviceCount', 'issueRecurred', 'issueRecurredCount', 'recurredIssueCount', 'issueRecurredDevicesCount'
                ) -DefaultValue $null
                $candidateTotalRemediated = Get-FirstPropertyValue -InputObject $candidate -PropertyNames @(
                    'issueRemediatedCumulativeDeviceCount', 'totalRemediated', 'remediatedCount', 'remediatedDeviceCount', 'devicesRemediated'
                ) -DefaultValue $null

                if ($null -ne $candidateNoIssue -or $null -ne $candidateWithIssue -or $null -ne $candidateFixed -or $null -ne $candidateRecurred -or $null -ne $candidateTotalRemediated) {
                    $resolvedSummary = $candidate
                    break
                }
            }

            if ($null -ne $resolvedSummary) {
                $summary = $resolvedSummary
            }

            # Prefer status from script metadata; fall back to summary payload when missing.
            $status = Get-FirstPropertyValue -InputObject $script -PropertyNames @(
                'status'
            ) -DefaultValue $null

            if ([string]::IsNullOrWhiteSpace([string]$status)) {
                $status = Get-FirstPropertyValue -InputObject $summary -PropertyNames @(
                    'status',
                    'remediationStatus',
                    'scriptExecutionStatus'
                ) -DefaultValue $null
            }

            # Counter aliases vary by tenant/API shape, so probe multiple known names.
            $withoutIssues = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @(
                    'noIssueDetectedDeviceCount', 'withoutIssues', 'noIssueCount', 'noIssueDeviceCount', 'devicesWithoutIssues'
                ) -DefaultValue 0)
            $withIssues = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @(
                    'issueDetectedDeviceCount', 'withIssues', 'issueCount', 'issueDeviceCount', 'devicesWithIssues'
                ) -DefaultValue 0)
            $issueFixed = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @(
                    'issueRemediatedDeviceCount', 'issueFixed', 'issueFixedCount', 'issuesFixed', 'issueRemediatedCount'
                ) -DefaultValue 0)
            $issueRecurred = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @(
                    'issueReoccurredDeviceCount', 'issueRecurred', 'issueRecurredCount', 'recurredIssueCount', 'issueRecurredDevicesCount'
                ) -DefaultValue 0)
            $totalRemediated = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @(
                    'issueRemediatedCumulativeDeviceCount', 'totalRemediated', 'remediatedCount', 'remediatedDeviceCount', 'devicesRemediated'
                ) -DefaultValue 0)

            # If summary counters are empty, derive best-effort values from remediation history.
            if ($withoutIssues -eq 0 -and $withIssues -eq 0 -and $issueFixed -eq 0 -and $issueRecurred -eq 0 -and $totalRemediated -eq 0) {
                $historyUri = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$($script.id)/getRemediationHistory"
                try {
                    $historyResponse = Invoke-GraphGet -Uri $historyUri

                    $historyRoot = $historyResponse
                    if ($null -ne $historyResponse.value) {
                        $historyRoot = $historyResponse.value
                    }

                    $historyData = @()
                    if ($null -ne $historyRoot.historyData) {
                        $historyData = @($historyRoot.historyData)
                    } elseif ($historyRoot -is [array]) {
                        $historyData = @($historyRoot)
                    } elseif ($null -ne $historyRoot.date) {
                        $historyData = @($historyRoot)
                    }

                    $latestHistory = $null
                    if ($historyData.Count -gt 0) {
                        $latestHistory = $historyData |
                            Sort-Object -Property date -Descending |
                            Select-Object -First 1
                    }

                    if ($null -ne $latestHistory) {
                        $historyNoIssue = [int](Get-FirstPropertyValue -InputObject $latestHistory -PropertyNames @('noIssueDeviceCount', 'noIssueCount', 'withoutIssues') -DefaultValue 0)
                        $historyDetectFailed = [int](Get-FirstPropertyValue -InputObject $latestHistory -PropertyNames @('detectFailedDeviceCount', 'issueDetectedDeviceCount', 'issueCount', 'withIssues') -DefaultValue 0)
                        $historyRemediated = [int](Get-FirstPropertyValue -InputObject $latestHistory -PropertyNames @('remediatedDeviceCount', 'issueRemediatedDeviceCount', 'issueFixedCount', 'issueFixed') -DefaultValue 0)

                        if ($historyNoIssue -eq 0 -and $historyDetectFailed -eq 0 -and $historyRemediated -eq 0 -and $historyData.Count -gt 0) {
                            $historyNoIssue = ($historyData | ForEach-Object {
                                    [int](Get-FirstPropertyValue -InputObject $_ -PropertyNames @('noIssueDeviceCount', 'noIssueCount', 'withoutIssues') -DefaultValue 0)
                                } | Measure-Object -Maximum).Maximum

                            $historyDetectFailed = ($historyData | ForEach-Object {
                                    [int](Get-FirstPropertyValue -InputObject $_ -PropertyNames @('detectFailedDeviceCount', 'issueDetectedDeviceCount', 'issueCount', 'withIssues') -DefaultValue 0)
                                } | Measure-Object -Maximum).Maximum

                            $historyRemediated = ($historyData | ForEach-Object {
                                    [int](Get-FirstPropertyValue -InputObject $_ -PropertyNames @('remediatedDeviceCount', 'issueRemediatedDeviceCount', 'issueFixedCount', 'issueFixed') -DefaultValue 0)
                                } | Measure-Object -Sum).Sum
                        }

                        $withoutIssues = $historyNoIssue
                        $issueFixed = $historyRemediated
                        if ($totalRemediated -eq 0) {
                            $totalRemediated = $historyRemediated
                        }
                        if ($withIssues -eq 0) {
                            $withIssues = $historyRemediated + $historyDetectFailed
                        }
                    }

                    if ($null -ne $historyRoot.lastModifiedDateTime -and [string]::IsNullOrWhiteSpace([string]$status)) {
                        $status = 'Completed'
                    }
                } catch {
                    Write-Verbose -Message "No remediation history fallback available for '$scriptName' ($($script.id)): $($_.Exception.Message)"
                }
            }

            # Resolve a display-friendly status when no explicit status was returned.
            if ([string]::IsNullOrWhiteSpace([string]$status)) {
                $pendingCount = [int](Get-FirstPropertyValue -InputObject $summary -PropertyNames @(
                        'detectionScriptPendingDeviceCount'
                    ) -DefaultValue 0)
                $lastRun = Get-FirstPropertyValue -InputObject $summary -PropertyNames @(
                    'lastScriptRunDateTime'
                ) -DefaultValue $null

                if ($pendingCount -gt 0) {
                    $status = 'Pending'
                } elseif ($null -ne $lastRun -and -not [string]::IsNullOrWhiteSpace([string]$lastRun)) {
                    $status = 'Completed'
                } else {
                    $status = 'Unknown'
                }
            }

            [PSCustomObject]@{
                Name            = [string]$scriptName
                Status          = [string]$status
                WithoutIssues   = $withoutIssues
                WithIssues      = $withIssues
                IssueFixed      = $issueFixed
                IssueRecurred   = $issueRecurred
                TotalRemediated = $totalRemediated
            }
        }
    } # Process
} # Cmdlet
Write-Debug "[$scriptName] - [functions] - [public] - [Remediation] - [Get-IntuneRemediationSummary] - Done"
#endregion [functions] - [public] - [Remediation] - [Get-IntuneRemediationSummary]
Write-Debug "[$scriptName] - [functions] - [public] - [Remediation] - Done"
#endregion [functions] - [public] - [Remediation]
Write-Debug "[$scriptName] - [functions] - [public] - Done"
#endregion [functions] - [public]

#region Member exporter
$exports = @{
    Alias    = '*'
    Cmdlet   = ''
    Function = @(
        'Get-IntuneDeviceLogin'
        'Get-IntuneRemediationDeviceStatus'
        'Get-IntuneRemediationSummary'
    )
    Variable = ''
}
Export-ModuleMember @exports
#endregion Member exporter