EntraReporter.psm1

$script:ModuleRoot = $PSScriptRoot

<#
    .SYNOPSIS
    Returns a list of Azure AD administrative units (plus the root directory scope) formatted for EntraReporter.
 
    .DESCRIPTION
    Retrieves all administrative units from Microsoft Graph using Invoke-MgGraphRequest and converts each item into a PSCustomObject with directoryScopeId and displayName.
 
    .PARAMETER None
    This cmdlet does not take any parameters.
 
    .OUTPUTS
    System.Management.Automation.PSCustomObject
    Contains properties:
    - directoryScopeId: String, e.g. '/administrativeUnits/{id}' or '/'
    - displayName: String, administrative unit display name or 'Directory'
 
    .EXAMPLE
    Get-AdministrativeUnit
 
    Returns all administrative units and the root directory scope.
 
    .NOTES
    Part of EntraReporter internal functions.
#>

function Get-AdministrativeUnit {
    [CmdletBinding()]

    $results = @()

    # Get all administrative units from directory to avoid making multiple calls
    $allAdministrativeUnits = (Invoke-MgGraphRequest -Method GET -Uri 'v1.0/directory/administrativeUnits' -Verbose:$false)['value']
    foreach ($adminUnit in $allAdministrativeUnits) {
        $results += [pscustomobject] @{
            directoryScopeId = "/administrativeUnits/$($adminUnit.id)"
            displayName      = $adminUnit.displayName
        }
    }
    $results += [pscustomobject] @{
        directoryScopeId = '/'
        displayName      = 'Directory'
    }

    return $results
}

<#
    .SYNOPSIS
    Retrieves membership role assignment or eligibility schedules for multiple Azure AD groups using batched Graph API requests.
 
    .DESCRIPTION
    Fetches PIM group membership schedules for a collection of group IDs. Supports both active assignments and eligible
    memberships. The function splits the input group IDs into chunks of 20 to optimize Graph API batch requests and handles
    error recovery per chunk to avoid total failure if some group IDs fail.
 
    .PARAMETER GroupId
    One or more Azure AD group IDs for which to retrieve schedules. The function will batch these requests for efficiency.
 
    .PARAMETER State
    The type of schedule to retrieve: 'Assigned' for active membership schedules, or 'Eligible' for eligible membership schedules.
 
    .OUTPUTS
    PSCustomObject array containing schedule records with principal expansion. Each record includes properties like principalId,
    groupId, scheduleInfo, and principal (expanded user/group details).
 
    .EXAMPLE
    Get-EntraIdGroupScheduleBatch -GroupId @('group-id-1', 'group-id-2') -State 'Assigned'
     
    Retrieves active membership assignment schedules for the specified groups.
 
    .NOTES
    Requires an active Microsoft Graph connection. Uses batched requests (20 IDs per batch) to optimize API throughput.
    Errors on individual chunks are logged as warnings but do not halt the overall operation.
 
#>

function Get-EntraIdGroupScheduleBatch {
    [CmdletBinding()]
    param(
        # Array of Azure AD group IDs to fetch schedules for
        [Parameter(Mandatory = $true)]
        [string[]]
        $GroupId,

        # Type of schedule: 'Assigned' for active, 'Eligible' for eligible memberships
        [Parameter(Mandatory = $true)]
        [ValidateSet('Assigned', 'Eligible')]
        [string]
        $State
    )

    # Select the appropriate Graph API endpoint based on the requested state
    switch ($State) {
        'Assigned' {
            # Endpoint for active group membership assignments
            $urlTemplate = "/identityGovernance/privilegedAccess/group/assignmentSchedules?`$filter=groupId eq '{Id}'&`$expand=principal"
        }
        'Eligible' {
            # Endpoint for eligible group membership schedules
            $urlTemplate = "/identityGovernance/privilegedAccess/group/eligibilitySchedules?`$filter=groupId eq '{Id}'&`$expand=principal"
        }
    }

    # Split the GroupId array into chunks of 20 for batch processing (Graph API batch limit optimization)
    $chunks = Split-ArrayIntoChunk -InputObject $GroupId -ChunkSize 20

    # Process each chunk through the Graph batch API and collect all responses
    $allResponses = foreach ($chunk in $chunks) {
        try {
            # Build batch request objects for each group ID in the current chunk
            $requests = foreach ($id in $chunk) {
                @{
                    id     = $id
                    method = 'GET'
                    url    = $urlTemplate.Replace('{Id}', $id)
                }
            }
            # Submit batch request for this chunk and retrieve responses
            Invoke-GraphBatch -Requests $requests -ThrowOnAnyError
        }
        catch {
            # Log warning if chunk processing fails, but continue with remaining chunks
            Write-Warning ("Failed to process chunk of group IDs '{0}': {1}" -f ($chunk -join ', '), $_.Exception.Message)
        }
    }

    # Extract the actual schedule data from batch responses and flatten the results
    $allResponses.responses | ForEach-Object { $_.body.value }
}



<#
    .SYNOPSIS
    Executes a Microsoft Graph batch request and optionally fails on any non-success status code.
 
    .DESCRIPTION
    Sends a batch of individual Graph API requests in a single HTTP POST to reduce round-trips. Returns the full Graph batch response.
 
    .PARAMETER Requests
    An array of hashtables representing Graph batch requests. Each hashtable must include id, method, and url.
 
    .PARAMETER GraphVersion
    Graph API version to call (default: 'v1.0').
 
    .PARAMETER ThrowOnAnyError
    If specified, throws an exception when any response entry has HTTP status 400 or greater.
 
    .OUTPUTS
    System.Object (Graph batch response)
 
    .EXAMPLE
    $batch = @(
        @{ id = '1'; method = 'GET'; url = '/users' }
        @{ id = '2'; method = 'GET'; url = '/groups' }
    )
    Invoke-GraphBatch -Requests $batch -GraphVersion 'v1.0' -ThrowOnAnyError
 
    Performs the specified batch request against Microsoft Graph and throws if any individual request fails.
 
    .NOTES
    Used by EntraReporter internal routines to perform Graph batch calls.
#>

function Invoke-GraphBatch {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable[]]$Requests,

        [Parameter()]
        [string]$GraphVersion = 'v1.0',

        [Parameter()]
        [switch]$ThrowOnAnyError
    )

    $uri = "$GraphVersion/`$batch"
    $body = @{ requests = $Requests }

    $resp = Invoke-MgGraphRequest -Method POST -Uri $uri -Body $body -Verbose:$false

    if ($ThrowOnAnyError) {
        $errors = $resp.responses | Where-Object { $_.status -ge 400 }
        if ($errors) {
            $codes = ($errors | ForEach-Object { "$($_.id):$($_.status)" }) -join ', '
            throw "One or more batch requests failed: $codes"
        }
    }
    return $resp
}



<#
    .SYNOPSIS
    Retrieves all items from a paged Microsoft Graph endpoint by following @odata.nextLink.
 
    .DESCRIPTION
    Performs repeated GET calls to the provided Graph URI and collects results from each page. Appends all `value` arrays into a single output array. This supports Graph endpoints that return paged data.
 
    .PARAMETER Uri
    The initial Microsoft Graph URI to request (e.g. 'https://graph.microsoft.com/v1.0/users').
 
    .OUTPUTS
    System.Object[]
    An array of objects returned from Graph pages.
 
    .EXAMPLE
    Invoke-GraphPaged -Uri 'https://graph.microsoft.com/v1.0/users'
 
    Returns all users from the tenant by following pagination links.
 
    .NOTES
    Uses Invoke-MgGraphRequest to perform each request and expects standard Graph pagination (`@odata.nextLink`).
#>

function Invoke-GraphPaged {
    [CmdletBinding()]
    [OutputType([System.Object[]])]
    param(
        [Parameter(Mandatory)]
        [string]$Uri
    )
    $items = @()
    $next = $Uri
    while ($next) {
        $resp = Invoke-MgGraphRequest -Method GET -Uri $next -ErrorAction Stop
        if ($resp.value) { $items += $resp.value }
        $next = $resp.'@odata.nextLink'
    }
    return , $items
}



<#
    .SYNOPSIS
    Splits an array into multiple chunks of a specified size.
 
    .DESCRIPTION
    Takes an input array and divides it into smaller arrays (chunks). Each returned chunk is a fixed-size object[] (except the last chunk) and the function returns a collection of chunk arrays. Useful for batching operations.
 
    .PARAMETER InputObject
    The array of values to split into chunks. Cannot be null or empty.
 
    .PARAMETER ChunkSize
    The maximum number of items per chunk. Must be an integer greater than or equal to 1.
 
    .OUTPUTS
    System.Collections.Generic.List[object[]]
    A list of object arrays, where each array is a chunk from the original input.
 
    .EXAMPLE
    Split-ArrayIntoChunk -InputObject @(1,2,3,4,5) -ChunkSize 2
     
    Returns chunks: @(1,2), @(3,4), @(5)
 
    .NOTES
    Used by EntraReporter helpers for batching Graph requests.
#>

function Split-ArrayIntoChunk {
    [CmdletBinding()]
    [OutputType([System.Array])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [object[]]$InputObject,

        [ValidateRange(1, [int]::MaxValue)]
        [int]$ChunkSize = 20
    )

    $items = @($InputObject)
    # Always return a collection object
    $result = New-Object System.Collections.Generic.List[object[]]

    if ($items.Count -gt 0) {
        for ($i = 0; $i -lt $items.Count; $i += $ChunkSize) {
            $end = [Math]::Min($i + $ChunkSize - 1, $items.Count - 1)
            [void]$result.Add(@($items[$i..$end]))   # ensure each chunk is an object[]
        }
    }

    # Emit the *collection* as a single object so callers get one wrapper
    , $result
}


<#
    .SYNOPSIS
    Test Microsoft Graph connection
    .DESCRIPTION
    Verifies that the Microsoft.Graph.Authentication module is installed and that the session is connected to Microsoft Graph.
    .EXAMPLE
    Test-GraphConnection
 
    Returns true if connected to Graph, false otherwise. Used by EntraReporter internal routines to check Graph connectivity before making API calls.
#>

function Test-GraphConnection {
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param ()

    # Check if Graph module is installed
    if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication -Verbose:$false)) {
        Write-Verbose 'Microsoft.Graph.Authentication module is not installed.'
        return $false
    }

    # Check if connected
    $mgContext = Get-MgContext -ErrorAction SilentlyContinue
    if (-not $mgContext) {
        Write-Verbose 'Not connected to Microsoft Graph.'
        return $false
    }

    Write-Verbose 'Connected to Microsoft Graph.'
    return $true
}

<#
    .SYNOPSIS
    Determines the Entra ID license level (Free, P1, or P2) for the connected tenant.
 
    .DESCRIPTION
    Queries the tenant's Microsoft Graph SKU information to determine which premium Entra ID licenses are enabled.
    Returns the highest available license level (P2 > P1 > Free). Optionally includes detailed information about
    provisioned and consumed license units.
 
    .PARAMETER IncludeDetails
    When specified, includes detailed license counts (enabled and assigned units) and SKU part numbers for P1 and P2 plans.
 
    .OUTPUTS
    PSCustomObject with properties:
        - Level: 'Free', 'P1', or 'P2' (highest available)
        - IsP1Available: $true if P1 is licensed and active
        - IsP2Available: $true if P2 is licensed and active
        - P1EnabledCount: (with -IncludeDetails) Total P1 licenses enabled
        - P2EnabledCount: (with -IncludeDetails) Total P2 licenses enabled
        - P1AssignedCount: (with -IncludeDetails) Currently consumed P1 units
        - P2AssignedCount: (with -IncludeDetails) Currently consumed P2 units
        - P1SkuPartNumbers: (with -IncludeDetails) Unique P1 SKU part numbers
        - P2SkuPartNumbers: (with -IncludeDetails) Unique P2 SKU part numbers
 
    .EXAMPLE
    Get-EntraIdLevel
 
    Returns the license level for the tenant.
 
    .EXAMPLE
    Get-EntraIdLevel -IncludeDetails
     
    Returns the license level along with detailed unit and SKU information.
 
    .NOTES
    Requires an active Microsoft Graph connection. Uses v1.0 subscription API endpoint.
 
#>

function Get-EntraIdLevel {
    [CmdletBinding()]
    param(
        # When set, includes enabled/assigned counts and SKU part numbers in output
        [switch] $IncludeDetails
    )

    # Fetch all subscribed SKUs from Graph API using v1.0 endpoint for stability
    $skus = Invoke-GraphPaged -Uri 'https://graph.microsoft.com/v1.0/subscribedSkus' -Verbose:$false

    # Handle edge case: if tenant has no SKUs (rare), default to Free tier
    if (-not $skus -or $skus.Count -eq 0) {
        # No SKUs found; initialize result with Free tier
        $result = [ordered]@{
            Level         = 'Free'
            IsP1Available = $false
            IsP2Available = $false
        }
        if ($IncludeDetails) {
            # Add detailed counts and SKU info (all zeros/empty for Free tier)
            $result.P1EnabledCount = 0
            $result.P2EnabledCount = 0
            $result.P1AssignedCount = 0
            $result.P2AssignedCount = 0
            $result.P1SkuPartNumbers = @()
            $result.P2SkuPartNumbers = @()
        }
        return [PSCustomObject]$result
    }

    # Identify P1 and P2 licensed SKUs (filter by service plan and provisioning status)
    $p1Skus = $skus | Where-Object {
        $_.servicePlans | Where-Object {
            $_.servicePlanName -eq 'AAD_PREMIUM' -and $_.provisioningStatus -eq 'Success'
        }
    }
    $p2Skus = $skus | Where-Object {
        $_.servicePlans | Where-Object {
            $_.servicePlanName -eq 'AAD_PREMIUM_P2' -and $_.provisioningStatus -eq 'Success'
        }
    }

    # Sum total enabled licenses (capacity purchased for each tier)
    $p1Enabled = ($p1Skus | ForEach-Object { $_.prepaidUnits.enabled } | Measure-Object -Sum).Sum
    $p2Enabled = ($p2Skus | ForEach-Object { $_.prepaidUnits.enabled } | Measure-Object -Sum).Sum

    # Sum consumed/assigned units (how many are currently in use)
    $p1Assigned = ($p1Skus | ForEach-Object { $_.consumedUnits } | Measure-Object -Sum).Sum
    $p2Assigned = ($p2Skus | ForEach-Object { $_.consumedUnits } | Measure-Object -Sum).Sum

    # Determine the effective license level: P2 (highest) > P1 > Free
    $level = if ($p2Enabled -gt 0) { 'P2' }
    elseif ($p1Enabled -gt 0) { 'P1' }
    else { 'Free' }

    # Build the result object with availability flags
    $result = [ordered]@{
        Level         = $level
        IsP1Available = ($p1Enabled -gt 0)
        IsP2Available = ($p2Enabled -gt 0)
    }

    if ($IncludeDetails) {
        # Convert sums to int and populate unit counts
        $result.P1EnabledCount = [int]($p1Enabled | ForEach-Object { $_ } )
        $result.P2EnabledCount = [int]($p2Enabled | ForEach-Object { $_ } )
        $result.P1AssignedCount = [int]($p1Assigned | ForEach-Object { $_ } )
        $result.P2AssignedCount = [int]($p2Assigned | ForEach-Object { $_ } )
        # Extract and deduplicate SKU part numbers for reporting
        $result.P1SkuPartNumbers = $p1Skus.skuPartNumber | Sort-Object -Unique
        $result.P2SkuPartNumbers = $p2Skus.skuPartNumber | Sort-Object -Unique
    }

    # Return the result as a PowerShell custom object with all properties
    [PSCustomObject]$result
}



<#
    .SYNOPSIS
    Retrieves Entra ID role assignments and eligibility information for users, groups, and service principals.
 
    .DESCRIPTION
    The command queries Microsoft Graph Privileged Identity Management (PIM) role assignment and role eligibility schedules, resolves group member assignments, and returns a consolidated set of role assignment records.
 
    .PARAMETER RoleName
    Optional filter for one or more role names. If provided, only role assignments and eligibilities matching the specified role names are returned.
 
    .EXAMPLE
    Get-EntraIdRoleAssignment
     
    Retrieves all role assignments and eligibilities in the connected tenant (requires Entra P2).
 
    .EXAMPLE
    Get-EntraIdRoleAssignment -RoleName 'Global Administrator'
 
    Retrieves assignments and eligibilities for the Global Administrator role only.
 
    .NOTES
    Requires an active connection to Microsoft Graph (`Connect-MgGraph`) and Entra P2 license level.
    This module does not yet support nested group role assignments completely; a warning is emitted when nested groups are present.
 
    .LINK
    https://learn.microsoft.com/graph/api/rolemanagement-root
#>

function Get-EntraIdRoleAssignment {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter()]
        [string[]]
        $RoleName
    )

    # TODO: Add switch to include expired assigments. These are not included by default and may require additional API calls to fetch historical assignment data, so we will implement this as an optional switch to avoid unnecessary API calls for users who do not need this information.

    # TODO: Consider options for adding a property showing whether accounts have sign-in disabled, stop showing them default, and add switch to include them. This will require fetching additional information about the principal (user/service principal) for each assignment to check the sign-in status, which may lead to a significant increase in API calls.

    #region Internal functions

    # Resolve-AssignmentWindow: compute the effective assignment window from principal and user time windows
    function Resolve-AssignmentWindow {
        [CmdletBinding()]
        param(
            [Nullable[datetime]] $PrincipalStart, # Start datetime from principal assignment schedule
            [Nullable[datetime]] $PrincipalEnd,   # End datetime from principal assignment schedule
            [Nullable[datetime]] $UserStart,      # Start datetime from user assignment schedule
            [Nullable[datetime]] $UserEnd         # End datetime from user assignment schedule
        )

        if ($null -eq $UserEnd -and $null -eq $PrincipalEnd) {
            # Both are permanent (no end date); assignment is permanent
            return [pscustomobject]@{
                Start       = $null
                End         = $null
                IsPermanent = $true
            }
        }

        if ($null -eq $PrincipalEnd) {
            # Principal is permanent; use user window
            return [pscustomobject]@{
                Start       = $UserStart
                End         = $UserEnd
                IsPermanent = $false
            }
        }

        if ($null -eq $UserEnd) {
            # User is permanent; use principal window
            return [pscustomobject]@{
                Start       = $PrincipalStart
                End         = $PrincipalEnd
                IsPermanent = $false
            }
        }

        # Both have end dates; return intersection (most restrictive window)
        return [pscustomobject]@{
            Start       = if ($PrincipalStart -gt $UserStart) { $PrincipalStart } else { $UserStart }
            End         = if ($PrincipalEnd -lt $UserEnd) { $PrincipalEnd } else { $UserEnd }
            IsPermanent = $false
        }
    }

    # New-RoleAssignmentEntry: build and normalize a role assignment output record
    function New-RoleAssignmentEntry {
        [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Doesnt really change state, just normalizes output')]
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)] [string]$RoleId,              # Role definition ID
            [Parameter(Mandatory)] [string]$RoleName,            # Role display name
            [Parameter(Mandatory)] [string]$PrincipalId,         # Assigned principal ID (user/group/servicePrincipal)
            [Parameter(Mandatory)] [string]$PrincipalDisplayName,# Assigned principal display name
            [Parameter(Mandatory)] [string]$AssignmentType,      # Assignment type (User/Group/Service principal)
            [Parameter(Mandatory)] [string]$Scope,               # Scope/administrative unit for assignment
            [Parameter(Mandatory)] [string]$PrincipalState,      # Principal state (Assigned/Eligible)
            [Nullable[datetime]] $PrincipalStartTime,            # Principal assignment start
            [Nullable[datetime]] $PrincipalEndTime,              # Principal assignment end
            [Parameter(Mandatory)] [string]$UserId,              # User ID (expanded member if group schedule)
            [Parameter(Mandatory)] [string]$UserDisplayName,     # User display name
            [Parameter(Mandatory)] [string]$UserPrincipalName,   # User principal name/appId
            [Parameter(Mandatory)] [string]$UserState,           # User state (Assigned/Eligible)
            [Nullable[datetime]] $UserStartTime,                 # User assignment start
            [Nullable[datetime]] $UserEndTime                    # User assignment end
        )

        # Resolve effective assignment window by calculating the intersection of the principal and user assignment windows. If either of the windows is permanent (i.e. has no end time), the effective window will be determined by the other window. If both windows are permanent, the effective window will also be permanent.
        $window = Resolve-AssignmentWindow -PrincipalStart $PrincipalStartTime -PrincipalEnd $PrincipalEndTime -UserStart $UserStartTime -UserEnd $UserEndTime

        # Determine effective state. If both principal and user are assigned, the effective state is assigned. In all other cases (e.g. one of them is eligible or both are eligible), the effective state is eligible.
        $effectiveState = if ($PrincipalState -eq 'Assigned' -and $UserState -eq 'Assigned') {
            'Assigned'
        }
        else {
            'Eligible'
        }

        # Emit a single entry for the combination of principal and user with the resolved effective assignment window and state.
        [PSCustomObject]@{
            PSTypeName           = 'EntraReporter.RoleAssignment'
            RoleId               = $RoleId
            RoleName             = $RoleName
            PrincipalId          = $PrincipalId
            PrincipalDisplayName = $PrincipalDisplayName
            AssignmentType       = $AssignmentType
            Scope                = $Scope
            PrincipalState       = $PrincipalState
            PrincipalStartTime   = $PrincipalStartTime
            PrincipalEndTime     = $PrincipalEndTime
            UserId               = $UserId
            UserDisplayName      = $UserDisplayName
            UserPrincipalName    = $UserPrincipalName
            UserState            = $UserState
            UserStartTime        = $UserStartTime
            UserEndTime          = $UserEndTime
            EffectiveState       = $effectiveState
            EffectiveStartTime   = $window.Start
            EffectiveEndTime     = $window.End
            IsPermanent          = $window.IsPermanent
        }
    }

    # Resolve-RoleAssignedGroup: expand group principal schedules into per-member entries
    # This function reads the preloaded group schedule entries, maps each member to user-level
    # role assignment/eligibility and then calls New-RoleAssignmentEntry to normalize output.
    function Resolve-RoleAssignedGroup {
        [CmdletBinding()]
        param(
            # Graph schedule object for group role assignment/eligibility
            [Parameter(Mandatory = $true)]
            [psobject]
            $Schedule,

            # Group state context being processed
            [Parameter(Mandatory = $true)]
            [ValidateSet('Assigned', 'Eligible')]
            [string]
            $GroupState,

            # Resulting user-level state for group members
            [Parameter(Mandatory = $true)]
            [ValidateSet('Assigned', 'Eligible')]
            [string]
            $UserState
        )

        $principal = $Schedule.principal

        # Choose group schedule entries based on whether we are resolving assigned or eligible members
        if ($UserState -eq 'Assigned') {
            $scheduleEntries = $script:groupAssignmentSchedules | Where-Object { $_.groupId -eq $principal.id }
        }
        else {
            $scheduleEntries = $script:groupEligibilitySchedules | Where-Object { $_.groupId -eq $principal.id }
        }

        foreach ($scheduleEntry in $scheduleEntries ) {
            # Ignore eligble entries that has been activated to avoid emitting duplicate entries for the same user that would show up as both eligible and assigned.
            if ($scheduleEntry.assignmentType -eq 'activated') {
                continue
            }
            if ($scheduleEntry.principal.'@odata.type' -eq '#microsoft.graph.user') {
                # User member found; create normalized assignment entry
                $roleEntrySplat = @{
                    RoleId               = $Schedule.roleDefinitionId
                    RoleName             = $Schedule.roleDefinition.displayName
                    PrincipalId          = $Schedule.principal.id
                    PrincipalDisplayName = $Schedule.principal.displayName
                    AssignmentType       = 'Group'
                    Scope                = ($administrativeUnits | Where-Object { $_.directoryScopeId -eq $Schedule.directoryScopeId }).displayName
                    PrincipalState       = $GroupState
                    PrincipalStartTime   = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.startDateTime } else { $null }
                    PrincipalEndTime     = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.expiration.endDateTime } else { $null }
                    UserId               = $scheduleEntry.principalId
                    UserDisplayName      = $scheduleEntry.principal.displayName
                    UserPrincipalName    = $scheduleEntry.principal.userPrincipalName
                    UserState            = $UserState
                    UserStartTime        = if ($scheduleEntry.scheduleInfo.expiration.endDateTime) { $scheduleEntry.scheduleInfo.startDateTime } else { $null }
                    UserEndTime          = if ($scheduleEntry.scheduleInfo.expiration.endDateTime) { $scheduleEntry.scheduleInfo.expiration.endDateTime } else { $null }
                }
                New-RoleAssignmentEntry @roleEntrySplat
            }
            elseif ($scheduleEntry.principal.'@odata.type' -eq '#microsoft.graph.group') {
                # Nested group found; recursion not yet supported
                Write-Verbose "Skipping nested group with ID '$($scheduleEntry.principal.id)' since nested groups are currently not supported by the command."
            }
            else {
                Write-Warning "Principal with ID '$($scheduleEntry.principal.id)' is of type $($scheduleEntry.principal.'@odata.type'), which is currently not supported by the command. Skipping entry."
                $scheduleEntry | ConvertTo-Json -Depth 5 | Write-Verbose
            }
        }
    }

    # Resolve-EntraIDRoleSchedule: normalize a schedule entry into 1+ role assignment rows
    # Handles user/servicePrincipal directly and delegates group principals to Resolve-RoleAssignedGroup.
    function Resolve-EntraIDRoleSchedule {
        param(
            # The Graph role schedule to resolve into output entries
            [Parameter(Mandatory = $true)]
            [psobject]
            $Schedule,

            # The schedule state to apply for this resolution pass
            [Parameter(Mandatory = $true)]
            [ValidateSet('Assigned', 'Eligible')]
            [string]
            $State
        )

        # Resolve principal to allow handling of different principal types
        # (user, servicePrincipal, group) and convert each to normalized rows.
        try {
            $principal = $Schedule.principal

            if ($principal.'@odata.type' -eq '#microsoft.graph.user') {
                # User principal; create entry with user as both principal and assignee
                $roleEntrySplat = @{
                    RoleId               = $Schedule.roleDefinitionId
                    RoleName             = $Schedule.roleDefinition.displayName
                    PrincipalId          = $Schedule.principal.id
                    PrincipalDisplayName = $Schedule.principal.displayName
                    AssignmentType       = 'User'
                    Scope                = ($administrativeUnits | Where-Object { $_.directoryScopeId -eq $Schedule.directoryScopeId }).displayName
                    PrincipalState       = $State
                    PrincipalStartTime   = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.startDateTime } else { $null }
                    PrincipalEndTime     = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.expiration.endDateTime } else { $null }
                    UserId               = $Schedule.principalId
                    UserDisplayName      = $Schedule.principal.displayName
                    UserPrincipalName    = $Schedule.principal.userPrincipalName
                    UserState            = $State
                    UserStartTime        = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.startDateTime } else { $null }
                    UserEndTime          = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.expiration.endDateTime } else { $null }
                }
                New-RoleAssignmentEntry @roleEntrySplat
            }
            elseif ($principal.'@odata.type' -eq '#microsoft.graph.servicePrincipal') {
                # Service principal; create entry with appId as principal name
                $roleEntrySplat = @{
                    RoleId               = $Schedule.roleDefinitionId
                    RoleName             = $Schedule.roleDefinition.displayName
                    PrincipalId          = $Schedule.principal.id
                    PrincipalDisplayName = $Schedule.principal.displayName
                    AssignmentType       = 'Service principal'
                    Scope                = ($administrativeUnits | Where-Object { $_.directoryScopeId -eq $Schedule.directoryScopeId }).displayName
                    PrincipalState       = $State
                    PrincipalStartTime   = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.startDateTime } else { $null }
                    PrincipalEndTime     = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.expiration.endDateTime } else { $null }
                    UserId               = $Schedule.principalId
                    UserDisplayName      = $Schedule.principal.displayName
                    UserPrincipalName    = $Schedule.principal.appId
                    UserState            = $State
                    UserStartTime        = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.startDateTime } else { $null }
                    UserEndTime          = if ($Schedule.scheduleInfo.expiration.endDateTime) { $Schedule.scheduleInfo.expiration.endDateTime } else { $null }
                }
                New-RoleAssignmentEntry @roleEntrySplat
            }

            elseif ($principal.'@odata.type' -eq '#microsoft.graph.group') {
                # Group principal; expand into per-member entries, handling both assigned and eligible
                Resolve-RoleAssignedGroup -Schedule $Schedule -GroupState $State -UserState 'Assigned'
                Resolve-RoleAssignedGroup -Schedule $Schedule -GroupState $State -UserState 'Eligible'
            }
            else {
                Write-Warning "Principal with ID '$($principal.id)' is of type $($principal.'@odata.type'), which is currently not supported by the command. Skipping entry."
                $Schedule | ConvertTo-Json -Depth 5 -Compress | Write-Verbose
            }
        }
        catch {
            Write-Warning "An error occurred while processing principal with ID '$($principal.id)' for role assignment with role ID '$($Schedule.roleDefinitionId)'. Skipping entry. Error details: $_"
            $Schedule | ConvertTo-Json -Depth 5 -Compress | Write-Verbose
        }
    }

    #endregion Internal Functions

    #region MAIN

    # Always stop on errors to avoid emitting incomplete data. Errors should be handled at the command level to allow for more granular error handling (e.g. skipping individual entries that fail to resolve rather than failing the entire command).
    $ErrorActionPreference = 'Stop'

    if (!(Test-GraphConnection)) {
        throw 'No active connection to Microsoft Graph. Run Connect-MgGraph to sign in and then retry.'
    }

    $EntraIdLevel = Get-EntraIdLevel
    if ($EntraIdLevel.Level -ne 'P2') {
        throw 'This command requires at P2 level to run since it relies on APIs that are not available for Entra P1 or Free tenants.'
        # TODO: Add support for P1 tenants by falling back to fetching role assignments via the standard directory role assignments API for tenants that do not have PIM / Privileged Access enabled. This will likely require significant changes to the command logic since the standard directory role assignments API does not return future-dated assignments or eligible assignments, so it will require fetching all role assignments and then checking each assignment against the role eligibility schedules to determine eligibility and future-dated status. In the meantime, we will throw an error for non-P2 tenants to avoid emitting incomplete data.
    }

    $timer = [Diagnostics.Stopwatch]::StartNew()
    $activityName = 'Fetching Entra ID role assignments'

    # Fetch all role schedules, assigned and eligible.
    Write-Progress -Activity $activityName -Status 'Fetching assigned role schedules' -PercentComplete 20
    $roleAssignmentSchedules = (Invoke-MgGraphRequest -Method GET -Uri "v1.0/roleManagement/directory/roleAssignmentSchedules?`$filter=assignmentType eq 'Assigned'&`$expand=principal,roleDefinition" -Verbose:$false)['value']
    Write-Progress -Activity $activityName -Status 'Fetching eligible role schedules' -PercentComplete 40
    $roleEligibilitySchedules = (Invoke-MgGraphRequest -Method GET -Uri "v1.0/roleManagement/directory/roleEligibilitySchedules?`$expand=principal,roleDefinition" -Verbose:$false)['value']

    # If Rolename has been specified, filter out any assigments not in the specified role(s).
    if ($RoleName) {
        $roleAssignmentSchedules = $roleAssignmentSchedules | Where-Object { $_.roleDefinition.displayName -in $RoleName }
        $roleEligibilitySchedules = $roleEligibilitySchedules | Where-Object { $_.roleDefinition.displayName -in $RoleName }
    }

    # If any scopes are used in the role schedules (i.e. scope is not just the entire directory), fetch information about the scopes to allow for better reporting (e.g. resolving administrative unit names).
    Write-Progress -Activity $activityName -Status 'Fetching scope information' -PercentComplete 60
    $script:administrativeUnits = Get-AdministrativeUnit

    # Extract unique group IDs from all role schedules for prefetching group schedule information in bulk to reduce number of API calls later on.
    $groupIds = @()
    $groupIds += ($roleAssignmentSchedules.principal | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.group' }).Id
    $groupIds += ($roleEligibilitySchedules.principal | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.group' }).Id
    $groupIds = $groupIds | Select-Object -Unique

    # Prefetch group schedules for all groups used groups
    if ($groupIds.Count -gt 0) {
        Write-Progress -Activity $activityName -Status 'Fetching assigned group schedules' -PercentComplete 65
        $script:groupAssignmentSchedules = Get-EntraIdGroupScheduleBatch -GroupId $groupIds -State 'Assigned'
        Write-Progress -Activity $activityName -Status 'Fetching eligible group schedules' -PercentComplete 80
        $script:groupEligibilitySchedules = Get-EntraIdGroupScheduleBatch -GroupId $groupIds -State 'Eligible'
    }
    else {
        $script:groupAssignmentSchedules = @()
        $script:groupEligibilitySchedules = @()
    }

    if (($groupAssignmentSchedules | Where-Object { $_.principal.'@odata.type' -eq '#microsoft.graph.group' }) -or ($groupEligibilitySchedules | Where-Object { $_.principal.'@odata.type' -eq '#microsoft.graph.group' })) {
        Write-Warning 'One or more role assignment uses nested groups, which is currently not supported by the command. This may lead to incomplete reporting.'
        # TODO: Add support for nested groups by recursively resolving group memberships until only user principals are left. This will likely require a significant increase in the number of API calls, so it should be implemented with caution and ideally with some form of caching to avoid hitting API limits. In the meantime, we will emit a warning to alert users of potential incompleteness in the reporting.
    }

    Write-Progress -Activity $activityName -Status 'Consolidating results' -PercentComplete 95
    $resolvedGroupSchedules = @()
    $resolvedGroupSchedules += foreach ($schedule in $roleAssignmentSchedules) {
        Resolve-EntraIDRoleSchedule -Schedule $schedule -State 'Assigned'
    }
    $resolvedGroupSchedules += foreach ($schedule in $roleEligibilitySchedules) {
        Resolve-EntraIDRoleSchedule -Schedule $schedule -State 'Eligible'
    }

    Write-Progress -Activity $activityName -Completed

    $resolvedGroupSchedules | Sort-Object -Property RoleName, UserDisplayName

    Write-Verbose "Total execution time: $($timer.Elapsed.TotalSeconds) seconds"
}
#endregion MAIN

# Commands run on module import go here
# E.g. Argument Completers could be placed here

# Module-wide variables go here
# For example if you want to cache some data, have some module-wide config settings, etc. ... those could go here
# Example:
# $script:config = @{ }

Export-ModuleMember -Function 'Get-EntraIdLevel','Get-EntraIdRoleAssignment'