Src/Private/Assignment-Helpers.ps1

#region --- Assignment Resolution Helpers ---
# Used across all section functions to resolve:
# - Group IDs -> display names (cached)
# - Included vs excluded group assignments
# - Scope tag IDs -> names (cached)
# - Empty group detection (member count = 0)
# All lookups are initialised once at report startup and stored in script-scoped hashtables.

#region --- Initialise Lookup Tables ---

function Initialize-IntuneGroupLookup {
    <#
    .SYNOPSIS
    Pre-fetches all Azure AD groups referenced in Intune assignments and builds
    an ID -> DisplayName hashtable. Called once at report startup.
    Stores results in $script:GroupNameCache.
    #>

    [CmdletBinding()]
    param()

    $script:GroupNameCache     = @{}
    $script:GroupMemberCache   = @{}   # groupId -> member count (-1 = not fetched)
    Write-AbrDebugLog 'Group lookup cache initialised (lazy-populated on first use)' 'INFO' 'GROUPS'
}

function Initialize-IntuneScopeTagLookup {
    <#
    .SYNOPSIS
    Fetches all Intune Role Scope Tags and builds an ID -> name hashtable.
    Stored in $script:ScopeTagCache. Called once at report startup.
    #>

    [CmdletBinding()]
    param()

    $script:ScopeTagCache = @{ '0' = 'Default' }
    try {
        $resp = Invoke-MgGraphRequest -Method GET `
            -Uri "$($script:GraphEndpoint)/beta/deviceManagement/roleScopeTags?`$select=id,displayName" `
            -ErrorAction SilentlyContinue
        if ($resp.value) {
            foreach ($tag in $resp.value) {
                $script:ScopeTagCache["$($tag.id)"] = $tag.displayName
            }
        }
        Write-Host " - Scope tag lookup: $($script:ScopeTagCache.Count) tag(s) loaded" -ForegroundColor Cyan
        Write-AbrDebugLog "Scope tag lookup: $($script:ScopeTagCache.Count) tags" 'INFO' 'SCOPETAGS'
    }
    catch {
        Write-AbrDebugLog "Scope tag lookup failed: $($_.Exception.Message)" 'WARN' 'SCOPETAGS'
    }
}

#endregion

#region --- Group Name Resolution ---

function Get-IntuneGroupName {
    <#
    .SYNOPSIS
    Returns the display name for an Azure AD group ID.
    Uses $script:GroupNameCache to avoid repeated Graph calls.
    Falls back to a truncated ID if the group cannot be resolved.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$GroupId
    )

    if (-not $GroupId) { return '--' }

    # Return from cache if available
    if ($script:GroupNameCache.ContainsKey($GroupId)) {
        return $script:GroupNameCache[$GroupId]
    }

    # Fetch from Graph
    try {
        $resp = Invoke-MgGraphRequest -Method GET `
            -Uri "$($script:GraphEndpoint)/v1.0/groups/${GroupId}?`$select=id,displayName" `
            -ErrorAction Stop
        $name = if ($resp.displayName) { $resp.displayName } else { "Unknown ($GroupId)" }
        $script:GroupNameCache[$GroupId] = $name
        return $name
    }
    catch {
        $short = if ($GroupId.Length -gt 8) { "$($GroupId.Substring(0,8))..." } else { $GroupId }
        $script:GroupNameCache[$GroupId] = "Unknown ($short)"
        return $script:GroupNameCache[$GroupId]
    }
}

function Get-IntuneGroupMemberCount {
    <#
    .SYNOPSIS
    Returns the member count for an Azure AD group.
    Returns -1 if unavailable, 0 if empty (triggers HealthCheck Warning).
    Uses $script:GroupMemberCache to avoid repeated calls.
    #>

    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Mandatory)]
        [string]$GroupId
    )

    if (-not $GroupId) { return -1 }
    if ($script:GroupMemberCache.ContainsKey($GroupId)) {
        return $script:GroupMemberCache[$GroupId]
    }

    try {
        $resp = Invoke-MgGraphRequest -Method GET `
            -Uri "$($script:GraphEndpoint)/v1.0/groups/$GroupId/members/`$count" `
            -Headers @{ 'ConsistencyLevel' = 'eventual' } `
            -ErrorAction Stop
        $count = [int]$resp
        $script:GroupMemberCache[$GroupId] = $count
        return $count
    }
    catch {
        $script:GroupMemberCache[$GroupId] = -1
        return -1
    }
}

function Resolve-IntuneAssignments {
    <#
    .SYNOPSIS
    Resolves an array of Intune assignment objects into human-readable strings.
    Returns a [pscustomobject] with:
      - IncludedGroups : comma-separated group display names (included targets)
      - ExcludedGroups : comma-separated group display names (excluded targets)
      - AllUsers : $true if All Users virtual group is targeted
      - AllDevices : $true if All Devices virtual group is targeted
      - HasEmptyGroup : $true if any included group has 0 members
      - AssignmentSummary : short single string for summary tables

    .PARAMETER Assignments
        Array of assignment objects from a Graph API response (with .target property).
    .PARAMETER CheckMemberCount
        If $true, fetches member counts for each group (slower but detects empty groups).
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [object[]]$Assignments,

        [switch]$CheckMemberCount
    )

    $includedNames  = [System.Collections.ArrayList]::new()
    $excludedNames  = [System.Collections.ArrayList]::new()
    $allUsers       = $false
    $allDevices     = $false
    $hasEmptyGroup  = $false

    if (-not $Assignments -or $Assignments.Count -eq 0) {
        return [pscustomobject]@{
            IncludedGroups   = '--'
            ExcludedGroups   = '--'
            AllUsers         = $false
            AllDevices       = $false
            HasEmptyGroup    = $false
            AssignmentSummary = 'Not assigned'
        }
    }

    foreach ($a in $Assignments) {
        $target    = $a.target
        $odataType = $target.'@odata.type'

        switch ($odataType) {
            '#microsoft.graph.allLicensedUsersAssignmentTarget' {
                $allUsers = $true
            }
            '#microsoft.graph.allDevicesAssignmentTarget' {
                $allDevices = $true
            }
            '#microsoft.graph.groupAssignmentTarget' {
                $groupId   = $target.groupId
                $groupName = Get-IntuneGroupName -GroupId $groupId
                $null      = $includedNames.Add($groupName)
                if ($CheckMemberCount) {
                    $count = Get-IntuneGroupMemberCount -GroupId $groupId
                    if ($count -eq 0) { $hasEmptyGroup = $true }
                }
            }
            '#microsoft.graph.exclusionGroupAssignmentTarget' {
                $groupId   = $target.groupId
                $groupName = Get-IntuneGroupName -GroupId $groupId
                $null      = $excludedNames.Add("[EXCLUDED] $groupName")
            }
        }
    }

    # Build summary string
    $parts = [System.Collections.ArrayList]::new()
    if ($allUsers)                              { $null = $parts.Add('All Users') }
    if ($allDevices)                            { $null = $parts.Add('All Devices') }
    if ($includedNames.Count -gt 0)             { $null = $parts.Add($includedNames -join ', ') }
    $summary = if ($parts.Count -gt 0) { $parts -join ' | ' } else { 'Not assigned' }

    $includedStr = if ($allUsers)    { 'All Users' }
                   elseif ($allDevices) { 'All Devices' }
                   elseif ($includedNames.Count -gt 0) { $includedNames -join ', ' }
                   else { '--' }

    if ($allUsers -and $includedNames.Count -gt 0) {
        $includedStr = "All Users, $($includedNames -join ', ')"
    }
    elseif ($allDevices -and $includedNames.Count -gt 0) {
        $includedStr = "All Devices, $($includedNames -join ', ')"
    }

    $excludedStr = if ($excludedNames.Count -gt 0) { $excludedNames -join ', ' } else { '--' }

    return [pscustomobject]@{
        IncludedGroups    = $includedStr
        ExcludedGroups    = $excludedStr
        AllUsers          = $allUsers
        AllDevices        = $allDevices
        HasEmptyGroup     = $hasEmptyGroup
        AssignmentSummary = $summary
    }
}

#endregion

#region --- Scope Tag Resolution ---

function Get-IntuneScopeTagNames {
    <#
    .SYNOPSIS
    Resolves an array of scope tag IDs to display names using the cached lookup.
    Returns a comma-separated string, e.g. "Default, APAC-Region, IT-Helpdesk"
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter()]
        [object[]]$ScopeTagIds
    )

    if (-not $ScopeTagIds -or $ScopeTagIds.Count -eq 0) { return 'Default' }

    $names = $ScopeTagIds | ForEach-Object {
        $id = "$_"
        if ($script:ScopeTagCache -and $script:ScopeTagCache.ContainsKey($id)) {
            $script:ScopeTagCache[$id]
        } else {
            "Tag-$id"
        }
    }
    return $names -join ', '
}

#endregion

#endregion