Private/NC-Hlp.Intune.ps1

#Requires -Version 5.0
using namespace System.Management.Automation

# Nebula.Core: (Private) Intune helpers =============================================================================================================

function Convert-NCGraphObjectToPlainObject {
    param($Object)

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

    try {
        return (($Object | ConvertTo-Json -Depth 20 -Compress) | ConvertFrom-Json -Depth 20)
    }
    catch {
        return $Object
    }
}

function Get-NCCoreProperty {
    param($Object, [string[]]$Names)

    if ($null -eq $Object) { return $null }
    if ($Object -is [System.Collections.IDictionary]) {
        foreach ($name in $Names) {
            if ($Object.Contains($name) -and -not [string]::IsNullOrWhiteSpace([string]$Object[$name])) {
                return $Object[$name]
            }
        }
    }

    $additionalProperties = $Object.PSObject.Properties['AdditionalProperties']
    if ($additionalProperties -and $additionalProperties.Value -is [System.Collections.IDictionary]) {
        foreach ($name in $Names) {
            if ($additionalProperties.Value.Contains($name) -and -not [string]::IsNullOrWhiteSpace([string]$additionalProperties.Value[$name])) {
                return $additionalProperties.Value[$name]
            }
        }
    }

    foreach ($name in $Names) {
        $property = $Object.PSObject.Properties[$name]
        if ($property -and -not [string]::IsNullOrWhiteSpace([string]$property.Value)) {
            return $property.Value
        }
    }
    return $null
}

function Resolve-NCIntuneManagedDeviceEntraMember {
    param(
        [Parameter(Mandatory = $true)]
        [object[]]$ManagedDevices,
        [Parameter(Mandatory = $true)]
        [string]$DeviceId
    )

    $intuneDevice = $ManagedDevices | Where-Object { [string]$_.id -eq [string]$DeviceId } | Select-Object -First 1
    $intuneDevicePlain = Convert-NCGraphObjectToPlainObject -Object $intuneDevice

    $deviceLabel = Get-NCCoreProperty -Object $intuneDevicePlain -Names @('deviceName', 'DeviceName', 'displayName', 'DisplayName', 'id', 'Id')
    if ([string]::IsNullOrWhiteSpace($deviceLabel)) {
        $deviceLabel = [string]$DeviceId
    }

    $azureAdDeviceId = Get-NCCoreProperty -Object $intuneDevicePlain -Names @('azureADDeviceId', 'azureActiveDirectoryDeviceId', 'azureAdDeviceId')
    if ($intuneDevicePlain -and -not $azureAdDeviceId) {
        try {
            $deviceDetailUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$DeviceId?`$select=id,deviceName,operatingSystem,userPrincipalName,azureADDeviceId,azureActiveDirectoryDeviceId"
            $deviceDetails = Invoke-MgGraphRequest -Uri $deviceDetailUri -Method GET
            $deviceDetailsPlain = Convert-NCGraphObjectToPlainObject -Object $deviceDetails
            $azureAdDeviceId = Get-NCCoreProperty -Object $deviceDetailsPlain -Names @('azureADDeviceId', 'azureActiveDirectoryDeviceId', 'azureAdDeviceId')
        }
        catch {
            Write-NCMessage "Unable to refresh Intune device details for ${deviceLabel}: $($_.Exception.Message)" -Level WARNING
        }
    }

    if (-not $intuneDevicePlain) {
        Write-NCMessage "No Azure AD Device ID for: $deviceLabel" -Level WARNING
        return $null
    }

    if (-not $azureAdDeviceId) {
        Write-NCMessage "No Azure AD Device ID for: $deviceLabel" -Level WARNING
        return $null
    }

    $filter = "deviceId eq '$azureAdDeviceId'"
    $entraDeviceUri = "https://graph.microsoft.com/v1.0/devices?`$filter=$filter"

    try {
        $entraDeviceResponse = Invoke-MgGraphRequest -Uri $entraDeviceUri -Method GET
    }
    catch {
        Write-NCMessage "Error looking up Entra ID device for ${deviceLabel}: $($_.Exception.Message)" -Level ERROR
        return $null
    }

    if ($entraDeviceResponse.value -and $entraDeviceResponse.value.Count -gt 0) {
        $entraDevice = $entraDeviceResponse.value[0]
        Write-Verbose "Found Entra ID device: $deviceLabel -> $($entraDevice.id)"
        return [pscustomobject]@{
            IntuneDeviceId  = $DeviceId
            EntraDeviceId   = $entraDevice.id
            DeviceName      = $deviceLabel
            AzureAdDeviceId = $azureAdDeviceId
        }
    }

    Write-NCMessage "Device not found in Entra ID: $deviceLabel (Azure AD Device ID: $azureAdDeviceId)" -Level WARNING
    return $null
}

function Invoke-NCIntuneGroupUsageCore {
    <#
    .SYNOPSIS
        Core Intune group usage lookup logic.
    .DESCRIPTION
        Resolves the requested Entra group, scans supported Intune surfaces, and returns matching assignment
        records. This is the implementation behind the public group usage command.
    .PARAMETER ParameterSetName
        Active parameter set name from the public wrapper.
    .PARAMETER GroupName
        Target group display name.
    .PARAMETER GroupId
        Target group object ID.
    .PARAMETER ProfileName
        Optional filter for the profile or app display name.
    .PARAMETER ProfileId
        Optional filter for a specific Intune object ID.
    .PARAMETER IncludeNestedGroups
        Also include parent groups that contain the requested group.
    .PARAMETER GridView
        Show results in Out-GridView when requested.
    .PARAMETER Diagnostic
        Include diagnostic columns in the returned objects.
    #>

    [CmdletBinding()]
    param(
        [string]$ParameterSetName,
        [string]$GroupName,
        [string]$GroupId,
        [string]$ProfileName,
        [string]$ProfileId,
        [switch]$IncludeNestedGroups,
        [switch]$GridView,
        [switch]$Diagnostic
    )

    function Invoke-NCGraphPagedRequestCore {
        param([Parameter(Mandatory = $true)][string]$Uri)

        $items = [System.Collections.Generic.List[object]]::new()
        $nextUri = $Uri
        while (-not [string]::IsNullOrWhiteSpace($nextUri)) {
            $response = Invoke-MgGraphRequest -Uri $nextUri -Method Get -ErrorAction Stop
            $pageItems = @()

            if ($response -is [System.Collections.IDictionary]) {
                if ($response.Contains('value')) { $pageItems = @($response['value']) }
                elseif ($response.Contains('id')) { $pageItems = @($response) }
                if ($response.Contains('@odata.nextLink')) { $nextUri = [string]$response['@odata.nextLink'] } else { $nextUri = $null }
            }
            else {
                if ($response.PSObject.Properties['value']) { $pageItems = @($response.value) }
                elseif ($response.PSObject.Properties['id']) { $pageItems = @($response) }
                if ($response.PSObject.Properties['@odata.nextLink']) { $nextUri = [string]$response.'@odata.nextLink' } else { $nextUri = $null }
            }

            foreach ($item in $pageItems) {
                if ($null -ne $item) { $items.Add($item) | Out-Null }
            }
        }

        return @($items)
    }

    function Test-NCCoreNameMatch {
        param([string]$CandidateName, [string]$FilterName)
        if ([string]::IsNullOrWhiteSpace($FilterName)) { return $true }
        if ([string]::IsNullOrWhiteSpace($CandidateName)) { return $false }
        return $CandidateName -like "*$FilterName*"
    }

    function Resolve-NCCoreGroupName {
        param([string]$Id)
        if ([string]::IsNullOrWhiteSpace($Id)) { return $null }
        if ($script:NCIntuneGroupNameCache -and $script:NCIntuneGroupNameCache.ContainsKey($Id)) {
            return $script:NCIntuneGroupNameCache[$Id]
        }
        if (-not $script:NCIntuneGroupNameCache) {
            $script:NCIntuneGroupNameCache = @{}
        }
        $name = $null
        try {
            $groupInfo = Get-MgGroup -GroupId $Id -ErrorAction Stop
            $name = $groupInfo.DisplayName
        }
        catch {
            $name = $null
        }
        $script:NCIntuneGroupNameCache[$Id] = $name
        return $name
    }

    function Get-NCCoreEffectiveGroupIds {
        param([string]$RootGroupId)

        $ids = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        [void]$ids.Add($RootGroupId)

        if (-not $IncludeNestedGroups.IsPresent) {
            return $ids
        }

        try {
            $parents = @(Get-MgGroupTransitiveMemberOf -GroupId $RootGroupId -All -ErrorAction Stop)
        }
        catch {
            try {
                $parents = @(Get-MgGroupMemberOf -GroupId $RootGroupId -All -ErrorAction Stop)
            }
            catch {
                Write-NCMessage "Unable to resolve parent groups for '$RootGroupId': $($_.Exception.Message)" -Level WARNING
                return $ids
            }
        }

        foreach ($parent in $parents) {
            $odataType = $null
            if ($parent.AdditionalProperties -and $parent.AdditionalProperties.ContainsKey('@odata.type')) {
                $odataType = [string]$parent.AdditionalProperties['@odata.type']
            }
            if ($odataType -and $odataType -notmatch 'group') { continue }
            $parentId = [string](Get-NCCoreProperty -Object $parent -Names @('id', 'Id'))
            if (-not [string]::IsNullOrWhiteSpace($parentId)) {
                [void]$ids.Add($parentId)
            }
        }

        return $ids
    }

    function Get-NCCoreAssignmentRecords {
        param(
            [string]$EntityType,
            [string]$EntityId,
            [System.Collections.Generic.HashSet[string]]$EffectiveGroupIds,
            [string]$RequestedGroupId
        )

        $uri = switch ($EntityType) {
            'deviceConfigurations' { "beta/deviceManagement/deviceConfigurations('$EntityId')/assignments" }
            'configurationPolicies' { "beta/deviceManagement/configurationPolicies('$EntityId')/assignments" }
            'mobileApps' { "beta/deviceAppManagement/mobileApps('$EntityId')/assignments" }
            default { $null }
        }
        if (-not $uri) { return @() }

        try {
            $assignments = @(Invoke-NCGraphPagedRequestCore -Uri $uri)
        }
        catch {
            Write-NCMessage "Unable to read assignments for '$EntityType' object '$EntityId': $($_.Exception.Message)" -Level WARNING
            return @()
        }

        $records = [System.Collections.Generic.List[object]]::new()
        foreach ($assignment in $assignments) {
            $target = Get-NCCoreProperty -Object $assignment -Names @('target', 'Target')
            if (-not $target) { continue }

            $odataType = [string](Get-NCCoreProperty -Object $target -Names @('@odata.type'))
            $targetGroupId = [string](Get-NCCoreProperty -Object $target -Names @('groupId', 'GroupId'))
            $intent = [string](Get-NCCoreProperty -Object $assignment -Names @('intent', 'Intent'))
            if (-not [string]::IsNullOrWhiteSpace($intent)) { $intent = $intent.ToLowerInvariant() }

            $reason = $null
            switch ($odataType) {
                '#microsoft.graph.groupAssignmentTarget' {
                    if ($EffectiveGroupIds.Contains($targetGroupId)) {
                        $reason = if ($targetGroupId -eq $RequestedGroupId) { 'Direct Assignment' } else { 'Group Assignment' }
                    }
                }
                '#microsoft.graph.exclusionGroupAssignmentTarget' {
                    if ($EffectiveGroupIds.Contains($targetGroupId)) {
                        $reason = if ($targetGroupId -eq $RequestedGroupId) { 'Direct Exclusion' } else { 'Group Exclusion' }
                    }
                }
            }

            if (-not $reason -and -not $Diagnostic.IsPresent) { continue }

            $records.Add([pscustomobject]@{
                    Reason          = $reason
                    GroupId         = $targetGroupId
                    GroupName       = Resolve-NCCoreGroupName -Id $targetGroupId
                    AssignmentId    = [string](Get-NCCoreProperty -Object $assignment -Names @('id', 'Id'))
                    TargetODataType = $odataType
                    Intent          = $intent
                }) | Out-Null
        }

        return @($records)
    }

    function Resolve-NCCoreAssignmentValue {
        param([object[]]$Assignments)

        $hasInclude = @($Assignments | Where-Object { $_.Reason -in @('Direct Assignment', 'Group Assignment') }).Count -gt 0
        $hasExclude = @($Assignments | Where-Object { $_.Reason -in @('Direct Exclusion', 'Group Exclusion') }).Count -gt 0

        if ($hasInclude -and $hasExclude) { return 'Include; Exclude' }
        if ($hasExclude) { return 'Exclude' }
        if ($hasInclude) { return 'Include' }
        return $null
    }

    function Add-NCCoreResult {
        param(
            [System.Collections.Generic.List[object]]$Results,
            [string]$Category,
            $Item,
            [string]$Source,
            [object[]]$Assignments,
            $ResolvedGroup,
            [string]$AppIntent
        )

        $itemId = [string](Get-NCCoreProperty -Object $Item -Names @('id', 'Id'))
        $itemName = [string](Get-NCCoreProperty -Object $Item -Names @('displayName', 'DisplayName', 'name', 'Name'))
        $itemType = [string](Get-NCCoreProperty -Object $Item -Names @('@odata.type'))

        if ($ProfileId -and $itemId -ne $ProfileId) { return }
        if (-not (Test-NCCoreNameMatch -CandidateName $itemName -FilterName $ProfileName)) { return }

        $assignmentValue = Resolve-NCCoreAssignmentValue -Assignments $Assignments
        if (-not $assignmentValue -and -not $Diagnostic.IsPresent) { return }

        $row = [ordered]@{
            'Category'     = $Category
            'Profile Name' = $itemName
            'Profile Type' = $itemType
            'Assignment'   = $assignmentValue
        }

        if ($GridView.IsPresent -or $Diagnostic.IsPresent) {
            $row['Profile Id'] = $itemId
            $row['Source'] = $Source
            $row['Group Name'] = $ResolvedGroup.DisplayName
            $row['Group Id'] = $ResolvedGroup.Id
            $row['Assignment Id'] = (($Assignments | ForEach-Object { $_.AssignmentId } | Where-Object { $_ } | Select-Object -Unique) -join '; ')
            $row['Target OData Type'] = (($Assignments | ForEach-Object { $_.TargetODataType } | Where-Object { $_ } | Select-Object -Unique) -join '; ')
            $row['Target Group Id'] = (($Assignments | ForEach-Object { $_.GroupId } | Where-Object { $_ } | Select-Object -Unique) -join '; ')
            $row['Target Group Name'] = (($Assignments | ForEach-Object { $_.GroupName } | Where-Object { $_ } | Select-Object -Unique) -join '; ')
            $row['Matched Requested Group'] = (@($Assignments | Where-Object { $_.Reason }).Count -gt 0)
            if ($AppIntent) { $row['App Intent'] = $AppIntent }
        }

        $resultObject = [pscustomobject]$row
        $resultObject.PSObject.TypeNames.Insert(0, 'Nebula.Core.IntuneGroupUsage')
        $Results.Add($resultObject) | Out-Null
    }

    if (-not (Test-MgGraphConnection -Scopes @('DeviceManagementConfiguration.Read.All', 'DeviceManagementApps.Read.All', 'Group.Read.All', 'Directory.Read.All') -EnsureExchangeOnline:$false)) {
        Add-EmptyLine
        Write-NCMessage "Can't connect or use Microsoft Graph modules. Please check logs." -Level ERROR
        return
    }

    if (-not (Get-Command -Name Invoke-MgGraphRequest -ErrorAction SilentlyContinue)) {
        Write-NCMessage "Invoke-MgGraphRequest is not available in the current Microsoft Graph session." -Level ERROR
        return
    }

    try {
        if ($ParameterSetName -eq 'ById') {
            $resolvedGroup = Get-MgGroup -GroupId $GroupId -ErrorAction Stop
        }
        else {
            $groupCandidates = @(Get-MgGroup -Filter "displayName eq '$GroupName'" -ConsistencyLevel eventual -CountVariable ignored -All -ErrorAction Stop)
            if ($groupCandidates.Count -eq 0) {
                Write-NCMessage "No Entra group found with display name '$GroupName'." -Level WARNING
                return
            }
            if ($groupCandidates.Count -gt 1) {
                Write-NCMessage "Multiple Entra groups found with display name '$GroupName'. Use -GroupId instead." -Level ERROR
                return
            }
            $resolvedGroup = $groupCandidates[0]
        }
    }
    catch {
        Write-NCMessage "Unable to resolve target group: $($_.Exception.Message)" -Level ERROR
        return
    }

    $effectiveGroupIds = Get-NCCoreEffectiveGroupIds -RootGroupId $resolvedGroup.Id
    $results = [System.Collections.Generic.List[object]]::new()

    $deviceConfigurations = @()
    $configurationPolicies = @()
    $mobileApps = @()

    try { $deviceConfigurations = @(Invoke-NCGraphPagedRequestCore -Uri 'beta/deviceManagement/deviceConfigurations') }
    catch { Write-NCMessage "Unable to retrieve Intune device configurations: $($_.Exception.Message)" -Level WARNING }

    try { $configurationPolicies = @(Invoke-NCGraphPagedRequestCore -Uri 'beta/deviceManagement/configurationPolicies') }
    catch { Write-NCMessage "Unable to retrieve Intune configuration policies: $($_.Exception.Message)" -Level WARNING }

    try { $mobileApps = @(Invoke-NCGraphPagedRequestCore -Uri "beta/deviceAppManagement/mobileApps?`$filter=isAssigned eq true") }
    catch { Write-NCMessage "Unable to retrieve Intune mobile apps: $($_.Exception.Message)" -Level WARNING }

    $scannedIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($collection in @($deviceConfigurations, $configurationPolicies, $mobileApps)) {
        foreach ($entry in @($collection)) {
            $entryId = [string](Get-NCCoreProperty -Object $entry -Names @('id', 'Id'))
            if (-not [string]::IsNullOrWhiteSpace($entryId)) { [void]$scannedIds.Add($entryId) }
        }
    }

    Add-EmptyLine
    Write-Verbose "Scanning $($scannedIds.Count) Intune profile(s) for group '$($resolvedGroup.DisplayName)' ..."

    foreach ($entity in $deviceConfigurations) {
        $entityId = [string](Get-NCCoreProperty -Object $entity -Names @('id', 'Id'))
        if ([string]::IsNullOrWhiteSpace($entityId)) { continue }
        $assignments = @(Get-NCCoreAssignmentRecords -EntityType 'deviceConfigurations' -EntityId $entityId -EffectiveGroupIds $effectiveGroupIds -RequestedGroupId $resolvedGroup.Id)
        Add-NCCoreResult -Results $results -Category 'Device Configuration' -Item $entity -Source 'deviceConfigurations' -Assignments $assignments -ResolvedGroup $resolvedGroup
    }

    foreach ($entity in $configurationPolicies) {
        $entityId = [string](Get-NCCoreProperty -Object $entity -Names @('id', 'Id'))
        if ([string]::IsNullOrWhiteSpace($entityId)) { continue }
        $assignments = @(Get-NCCoreAssignmentRecords -EntityType 'configurationPolicies' -EntityId $entityId -EffectiveGroupIds $effectiveGroupIds -RequestedGroupId $resolvedGroup.Id)
        Add-NCCoreResult -Results $results -Category 'Settings Catalog Policy' -Item $entity -Source 'configurationPolicies' -Assignments $assignments -ResolvedGroup $resolvedGroup
    }

    foreach ($entity in $mobileApps) {
        if ($entity.isFeatured -or $entity.isBuiltIn) { continue }
        $entityId = [string](Get-NCCoreProperty -Object $entity -Names @('id', 'Id'))
        if ([string]::IsNullOrWhiteSpace($entityId)) { continue }

        $assignments = @(Get-NCCoreAssignmentRecords -EntityType 'mobileApps' -EntityId $entityId -EffectiveGroupIds $effectiveGroupIds -RequestedGroupId $resolvedGroup.Id)
        if ($assignments.Count -eq 0 -and -not $Diagnostic.IsPresent) { continue }

        $intentGroups = @{}
        foreach ($assignment in $assignments) {
            if ([string]::IsNullOrWhiteSpace($assignment.Intent)) { continue }
            if (-not $intentGroups.ContainsKey($assignment.Intent)) {
                $intentGroups[$assignment.Intent] = [System.Collections.Generic.List[object]]::new()
            }
            $intentGroups[$assignment.Intent].Add($assignment) | Out-Null
        }

        foreach ($intent in $intentGroups.Keys) {
            $category = switch ($intent) {
                'required' { 'Required App' }
                'available' { 'Available App' }
                'uninstall' { 'Uninstall App' }
                default { 'Assigned App' }
            }
            Add-NCCoreResult -Results $results -Category $category -Item $entity -Source 'mobileApps' -Assignments @($intentGroups[$intent]) -ResolvedGroup $resolvedGroup -AppIntent $intent
        }
    }

    $sorted = $results | Sort-Object 'Category', 'Profile Name', 'Assignment' -Unique

    Add-EmptyLine
    Write-Verbose "Intune profiles found for '$($resolvedGroup.DisplayName)': $($sorted.Count)"

    if ($sorted.Count -eq 0) {
        Write-NCMessage "No Intune profiles found for group '$($resolvedGroup.DisplayName)'." -Level WARNING
        return
    }

    if ($GridView.IsPresent) {
        $sorted | Out-GridView -Title "Intune Profiles - $($resolvedGroup.DisplayName)"
    }
    else {
        $sorted
    }
}

function Invoke-NCGraphCollectionRequest {
    <#
    .SYNOPSIS
        Retrieves a Graph collection with pagination.
    .DESCRIPTION
        Repeatedly calls Microsoft Graph until no next link remains, with light throttling and basic 429 retry
        handling.
    .PARAMETER Uri
        Initial Graph request URI.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri
    )

    $items = [System.Collections.Generic.List[object]]::new()
    $nextUri = $Uri
    $requestCount = 0

    while (-not [string]::IsNullOrWhiteSpace($nextUri)) {
        try {
            if ($requestCount -gt 0) {
                Start-Sleep -Milliseconds 100
            }

            $response = Invoke-MgGraphRequest -Uri $nextUri -Method GET -ErrorAction Stop
            $requestCount++

            if ($response.PSObject.Properties['value']) {
                $items.AddRange(@($response.value))
            }
            else {
                $items.AddRange(@($response))
            }

            if ($requestCount % 10 -eq 0) {
                Write-Verbose "."
            }

            $nextLink = $response.'@odata.nextLink'
            $nextUri = if ($nextLink) { [string]$nextLink } else { $null }
        }
        catch {
            if ($_.Exception.Message -like '*429*') {
                Write-Verbose "`nRate limit hit, waiting 60 seconds ..."
                Start-Sleep -Seconds 60
                continue
            }

            Write-NCMessage "Error fetching data: $($_.Exception.Message)" -Level WARNING
            break
        }
    }

    return @($items)
}

function Invoke-NCGraphAllPagesCore {
    <#
    .SYNOPSIS
        Retrieves all pages from a Graph endpoint.
    .DESCRIPTION
        Follows @odata.nextLink until the collection is exhausted and returns all items as an array.
    .PARAMETER Uri
        Initial Graph request URI.
    .PARAMETER DelayMs
        Delay in milliseconds between follow-up page requests.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri,
        [int]$DelayMs = 100
    )

    $allResults = @()
    $nextLink = $Uri
    $requestCount = 0

    do {
        try {
            if ($requestCount -gt 0) {
                Start-Sleep -Milliseconds $DelayMs
            }

            $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET
            $requestCount++

            if ($response.value) {
                $allResults += $response.value
            }
            else {
                $allResults += $response
            }

            $nextLink = $response.'@odata.nextLink'
            if ($requestCount % 10 -eq 0) {
                Write-Verbose "."
            }
        }
        catch {
            if ($_.Exception.Message -like "*429*") {
                Write-Verbose "`nRate limit hit, waiting 60 seconds ..."
                Start-Sleep -Seconds 60
                continue
            }

            Write-NCMessage "Error fetching data: $($_.Exception.Message)" -Level WARNING
            break
        }
    }
    while ($nextLink)

    return $allResults
}

function Get-NCIntuneItemName {
    <#
    .SYNOPSIS
        Returns the best display name for an Intune object.
    .DESCRIPTION
        Reads common display-name properties and returns the first non-empty value.
    .PARAMETER Item
        Intune object to inspect.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        $Item
    )

    if (-not $Item) { return $null }

    foreach ($propertyName in @('displayName', 'DisplayName', 'name', 'Name')) {
        $property = $Item.PSObject.Properties[$propertyName]
        if ($property -and -not [string]::IsNullOrWhiteSpace([string]$property.Value)) {
            return [string]$property.Value
        }
    }

    return $null
}

function Get-NCIntuneItemId {
    <#
    .SYNOPSIS
        Returns the best identifier for an Intune object.
    .DESCRIPTION
        Reads common id properties and returns the first non-empty value.
    .PARAMETER Item
        Intune object to inspect.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        $Item
    )

    if (-not $Item) { return $null }

    foreach ($propertyName in @('id', 'Id')) {
        $property = $Item.PSObject.Properties[$propertyName]
        if ($property -and -not [string]::IsNullOrWhiteSpace([string]$property.Value)) {
            return [string]$property.Value
        }
    }

    return $null
}

function Get-NCIntuneItemODataType {
    <#
    .SYNOPSIS
        Returns the OData type for an Intune object.
    .DESCRIPTION
        Reads the standard @odata.type property or falls back to AdditionalProperties when available.
    .PARAMETER Item
        Intune object to inspect.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        $Item
    )

    if (-not $Item) { return $null }

    $odataProperty = $Item.PSObject.Properties['@odata.type']
    if ($odataProperty -and -not [string]::IsNullOrWhiteSpace([string]$odataProperty.Value)) {
        return [string]$odataProperty.Value
    }

    $additionalProperties = $Item.PSObject.Properties['AdditionalProperties']
    if ($additionalProperties -and $additionalProperties.Value -and $additionalProperties.Value.ContainsKey('@odata.type')) {
        return [string]$additionalProperties.Value['@odata.type']
    }

    return $null
}

function Get-NCIntuneSearchFields {
    <#
    .SYNOPSIS
        Returns searchable fields for an Intune object.
    .DESCRIPTION
        Collects common identifying and descriptive properties into a normalized search field list.
    .PARAMETER Item
        Intune object to inspect.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        $Item
    )

    $fields = [System.Collections.Generic.List[object]]::new()
    if (-not $Item) { return $fields.ToArray() }

    foreach ($propertyName in @('id', 'Id', 'displayName', 'DisplayName', 'name', 'Name', 'description', 'Description', 'networkName', 'NetworkName', 'ssid', 'Ssid')) {
        $property = $Item.PSObject.Properties[$propertyName]
        if ($property -and -not [string]::IsNullOrWhiteSpace([string]$property.Value)) {
            $fields.Add([pscustomobject]@{
                    Property = $propertyName
                    Value    = [string]$property.Value
                }) | Out-Null
        }
    }

    return $fields.ToArray()
}

function Get-NCIntuneAppTypeFromODataType {
    <#
    .SYNOPSIS
        Maps an Intune app OData type to a friendly app type.
    .DESCRIPTION
        Translates common Microsoft Graph Intune app type names into a short label used by the module.
    .PARAMETER ODataType
        OData type string to translate.
    #>

    [CmdletBinding()]
    param([string]$ODataType)

    switch ($ODataType) {
        '#microsoft.graph.win32LobApp' { return 'Win32' }
        '#microsoft.graph.microsoftStoreForBusinessApp' { return 'Store' }
        '#microsoft.graph.webApp' { return 'Web' }
        '#microsoft.graph.officeSuiteApp' { return 'Office' }
        '#microsoft.graph.winGetApp' { return 'WinGet' }
        '#microsoft.graph.iosLobApp' { return 'iOS' }
        '#microsoft.graph.iosStoreApp' { return 'iOS' }
        '#microsoft.graph.androidManagedStoreApp' { return 'Android' }
        '#microsoft.graph.androidLobApp' { return 'Android' }
        '#microsoft.graph.macOSLobApp' { return 'macOS' }
        '#microsoft.graph.macOSOfficeSuiteApp' { return 'macOS' }
        default { return 'Other' }
    }
}

function Test-NCIntuneVersionAtLeast {
    <#
    .SYNOPSIS
        Compares two version strings.
    .DESCRIPTION
        Returns $true when the current version is greater than or equal to the minimum version. Falls back
        to string comparison if the inputs cannot be parsed as [version].
    .PARAMETER CurrentVersion
        Current version string.
    .PARAMETER MinimumVersion
        Minimum version string to compare against.
    #>

    [CmdletBinding()]
    param(
        [string]$CurrentVersion,
        [string]$MinimumVersion
    )

    if ([string]::IsNullOrWhiteSpace($MinimumVersion)) {
        return $true
    }

    if ([string]::IsNullOrWhiteSpace($CurrentVersion)) {
        return $false
    }

    $currentParsed = [version]'0.0'
    $minimumParsed = [version]'0.0'
    if ([version]::TryParse($CurrentVersion, [ref]$currentParsed) -and [version]::TryParse($MinimumVersion, [ref]$minimumParsed)) {
        return $currentParsed -ge $minimumParsed
    }

    return $CurrentVersion -ge $MinimumVersion
}

function Get-NCIntuneAppBasedGroupName {
    <#
    .SYNOPSIS
        Builds a sanitized Entra group name for app-based Intune groups.
    .DESCRIPTION
        Uses an explicit group name when provided, otherwise removes invalid characters, collapses
        separators, trims edges, and enforces the Entra group name length limit while preserving the
        requested prefix and suffix.
    .PARAMETER GroupName
        Explicit full group name to use as-is instead of generating one from prefix and suffix.
    .PARAMETER AppName
        Application display name used as the base for the group name.
    .PARAMETER GroupPrefix
        Prefix prepended to the sanitized application name.
    .PARAMETER GroupSuffix
        Suffix appended to the sanitized application name.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [string]$GroupName,
        [Parameter(Mandatory = $false)]
        [string]$AppName,
        [Parameter(Mandatory = $false)]
        [string]$GroupPrefix = 'Devices-With-',
        [Parameter(Mandatory = $false)]
        [string]$GroupSuffix = ''
    )

    if (-not [string]::IsNullOrWhiteSpace($GroupName)) {
        return $GroupName
    }

    $sanitized = $AppName -replace '[^\w\s-]', ''
    $sanitized = $sanitized -replace '\s+', '-'
    $sanitized = $sanitized -replace '-+', '-'
    $sanitized = $sanitized.Trim('-')

    $maxLength = 256 - $GroupPrefix.Length - $GroupSuffix.Length
    if ($sanitized.Length -gt $maxLength) {
        $sanitized = $sanitized.Substring(0, $maxLength)
    }

    return "${GroupPrefix}${sanitized}${GroupSuffix}"
}