Public/NC.Licenses.ps1

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

# Nebula.Core: Licenses =============================================================================================================================

function Export-MsolAccountSku {
    <#
    .SYNOPSIS
        Exports assigned Microsoft 365 licenses to CSV.
    .DESCRIPTION
        Connects to Microsoft Graph, downloads the license catalog, iterates all licensed users,
        maps SKU part numbers to friendly names, and writes/resumes a CSV report.
    .PARAMETER CSVFolder
        Output folder (defaults to the current directory if omitted).
    .PARAMETER ForceLicenseCatalogRefresh
        Force a fresh download of the cached license catalog before processing.
    .EXAMPLE
        Export-MsolAccountSku
    .EXAMPLE
        Export-MsolAccountSku -CsvFolder "C:\Temp"
    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline = $True, HelpMessage = "Folder where export CSV file (e.g. C:\Temp)")]
        [string]$CSVFolder,
        [switch]$ForceLicenseCatalogRefresh
    )

    Set-ProgressAndInfoPreferences
    try {
        $GraphConnection = Test-MgGraphConnection
        if (-not $GraphConnection) {
            Write-NCMessage "`nCan't connect or use Microsoft Graph modules. `nPlease check logs." -Level ERROR
            return
        }

        $folder = Test-Folder($CSVFolder)
        try {
            $licenseCatalog = Get-LicenseCatalog -IncludeMetadata -ForceRefresh:$ForceLicenseCatalogRefresh.IsPresent
        }
        catch {
            Write-NCMessage $_ -Level ERROR
            return
        }

        $licenseLookup = $licenseCatalog.Lookup
        $customLookup = $licenseCatalog.CustomLookup
        $arr_MsolAccountSku = @()
        $ProcessedCount = 0
        $maxAttempts = 3
        $resolvedViaCustom = @{}
        $unknownSkuTracker = @{}

        $CSV = New-File("$($folder)\$((Get-Date -Format $($NCVars.DateTimeString_CSV)).ToString())_M365-User-License-Report.csv")
        if (Test-Path $CSV) {
            $ProcessedUsers = Import-CSV $CSV | Select-Object -ExpandProperty UserPrincipalName
        }
        else {
            $ProcessedUsers = @()
        }

        try {
            $Users = Get-MgUser -Filter 'assignedLicenses/$count ne 0' -ConsistencyLevel eventual -CountVariable totalUsers -All -ErrorAction Stop
        }
        catch {
            Write-NCMessage "Failed to retrieve users with assigned licenses: $_" -Level ERROR
            return
        }

        foreach ($User in $Users) {
            $ProcessedCount++
            $PercentComplete = (($ProcessedCount / $totalUsers) * 100)
            Write-Progress -Activity "Processing $($User.DisplayName)" -Status "$ProcessedCount out of $totalUsers ($($PercentComplete.ToString('0.00'))%)" -PercentComplete $PercentComplete

            if ($ProcessedUsers -contains $User.UserPrincipalName) {
                Write-NCMessage "Skipping $($User.UserPrincipalName), already processed." -Level WARNING
                continue
            }

            try {
                $GraphLicense = Invoke-NCRetry -Action {
                    Get-MgUserLicenseDetail -UserId $User.Id -ErrorAction Stop
                } -MaxAttempts $maxAttempts -DelaySeconds 5 -OperationDescription "retrieve licenses for $($User.UserPrincipalName)" -OnError {
                    param($attempt, $max, $err)
                    Write-NCMessage "Failed to retrieve licenses for $($User.UserPrincipalName), attempt $attempt of $max" -Level ERROR
                }
            }
            catch {
                Write-NCMessage "Failed to retrieve licenses for $($User.UserPrincipalName) after $maxAttempts attempts. Skipping." -Level ERROR
                continue
            }

            if ($null -ne $GraphLicense) {
                foreach ($licenseSku in $GraphLicense.SkuPartNumber) {
                    $matchSource = $null
                    $productName = Get-LicenseDisplayName -Lookup $licenseLookup `
                        -SkuPartNumber $licenseSku `
                        -FallbackLookup $customLookup `
                        -MatchSource ([ref]$matchSource)

                    if (-not $productName) {
                        Write-Verbose "Unknown license: $licenseSku for $($User.UserPrincipalName)"
                        if ($unknownSkuTracker.ContainsKey($licenseSku)) {
                            $unknownSkuTracker[$licenseSku]++
                        }
                        else {
                            $unknownSkuTracker[$licenseSku] = 1
                        }
                        $productName = $licenseSku
                    }
                    elseif ($matchSource -and $matchSource -ne 'Primary') {
                        if ($resolvedViaCustom.ContainsKey($licenseSku)) {
                            $resolvedViaCustom[$licenseSku]++
                        }
                        else {
                            $resolvedViaCustom[$licenseSku] = 1
                        }
                    }

                    $arr_MsolAccountSku += [pscustomobject]@{
                        DisplayName        = $User.DisplayName
                        UserPrincipalName  = $User.UserPrincipalName
                        PrimarySmtpAddress = $User.Mail
                        Licenses           = $productName
                    }
                }
            }

            if ($ProcessedCount % 50 -eq 0) {
                Write-NCMessage "Processed $ProcessedCount out of $totalUsers, saving partial results ..." -Level VERBOSE
                $arr_MsolAccountSku | Export-CSV $CSV -NoTypeInformation -Delimiter $($NCVars.CSV_DefaultLimiter) -Encoding $($NCVars.CSV_Encoding) -Append
            }
        }

        $arr_MsolAccountSku | Export-CSV $CSV -NoTypeInformation -Delimiter $($NCVars.CSV_DefaultLimiter) -Encoding $($NCVars.CSV_Encoding)

        if ($resolvedViaCustom.Count -gt 0) {
            Write-NCMessage "Licenses not found, but resolved via custom catalog:" -Level WARNING
            foreach ($sku in ($resolvedViaCustom.Keys | Sort-Object)) {
                $count = $resolvedViaCustom[$sku]
                Write-NCMessage (" - {0} ({1} occurrence{2})" -f $sku, $count, $(if ($count -ne 1) { 's' } else { '' })) -Level WARNING
            }
        }

        if ($unknownSkuTracker.Count -gt 0) {
            Write-NCMessage "Licenses still without mappings:" -Level WARNING
            foreach ($sku in ($unknownSkuTracker.Keys | Sort-Object)) {
                $count = $unknownSkuTracker[$sku]
                Write-NCMessage (" - {0} ({1} occurrence{2})" -f $sku, $count, $(if ($count -ne 1) { 's' } else { '' })) -Level WARNING
            }
        }

        Write-Progress -Activity "Export complete" -Completed
    }
    finally {
        Restore-ProgressAndInfoPreferences
    }
}

function Get-UserMsolAccountSku {
    <#
    .SYNOPSIS
        Shows licenses assigned to a specific user.
    .DESCRIPTION
        Downloads the license catalog, fetches the target user via Microsoft Graph, and prints each
        assigned SKU with the mapped product name (when available).
    .PARAMETER UserPrincipalName
        Target user UPN or object ID.
    .PARAMETER ForceLicenseCatalogRefresh
        Force a fresh download of the cached license catalog before processing.
    .EXAMPLE
        Get-UserMsolAccountSku -UserPrincipalName "user@contoso.com"
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, HelpMessage = "User Principal Name (e.g. user@contoso.com)")]
        [string] $UserPrincipalName,
        [switch] $ForceLicenseCatalogRefresh
    )

    Set-ProgressAndInfoPreferences
    try {
        $GraphConnection = Test-MgGraphConnection
        if (-not $GraphConnection) {
            Write-NCMessage "Can't connect or use Microsoft Graph modules. Please check logs." -Level ERROR
            return
        }

        try {
            $licenseCatalog = Get-LicenseCatalog -IncludeMetadata -ForceRefresh:$ForceLicenseCatalogRefresh.IsPresent
        }
        catch {
            Write-NCMessage $_ -Level ERROR
            return
        }

        $licenseLookup = $licenseCatalog.Lookup
        $customLookup = $licenseCatalog.CustomLookup
        $maxAttempts = 3

        $resolvedRecipient = Find-UserRecipient -UserPrincipalName $UserPrincipalName
        if (-not $resolvedRecipient) {
            Write-NCMessage "Unable to resolve user recipient for $UserPrincipalName" -Level ERROR
            return
        } else {
            $UserPrincipalName = $resolvedRecipient
        }

        try {
            $User = Get-MgUser -UserId $UserPrincipalName -ErrorAction Stop
        }
        catch {
            Write-NCMessage "User $UserPrincipalName not found or query failed: $_" -Level ERROR
            return
        }

        $catalogSource = $licenseCatalog.Source
        $catalogUpdated = if ($licenseCatalog.LastCommitUtc) {
            $licenseCatalog.LastCommitUtc.ToLocalTime().ToString($NCVars.DateTimeString_Full)
        } else { $null }
        $catalogInfo = if ($catalogSource -or $catalogUpdated) {
            $parts = @()
            if ($catalogSource) { $parts += $catalogSource }
            if ($catalogUpdated) { $parts += "last updated: $catalogUpdated" }
            " (source: {0})" -f ($parts -join ', ')
        }
        else { '' }

        Write-NCMessage ("`nProcessing user: {0} <{1}>{2}`n" -f $User.DisplayName, $User.UserPrincipalName, $catalogInfo) -Level SUCCESS

        try {
            $GraphLicense = Invoke-NCRetry -Action {
                Get-MgUserLicenseDetail -UserId $User.Id -ErrorAction Stop
            } -MaxAttempts $maxAttempts -DelaySeconds 5 -OperationDescription "retrieve licenses for $($User.UserPrincipalName)" -OnError {
                param($attempt, $max, $err)
                Write-NCMessage "Failed to retrieve licenses for $($User.UserPrincipalName), attempt $attempt of $max" -Level ERROR
            }
        }
        catch {
            Write-NCMessage "Failed to retrieve licenses for $($User.UserPrincipalName) after $maxAttempts attempts." -Level ERROR
            return
        }

        if ($GraphLicense -and $GraphLicense.Count -gt 0) {
            foreach ($lic in $GraphLicense) {
                $skuPart = $lic.SkuPartNumber
                $skuId = $lic.SkuId
                $matchSource = $null
                $display = Get-LicenseDisplayName -Lookup $licenseLookup -SkuPartNumber $skuPart -FallbackLookup $customLookup -MatchSource ([ref]$matchSource)
                if ($display) {
                    $suffix = if ($matchSource -and $matchSource -ne 'Primary') { ' (custom)' } else { '' }
                    Write-NCMessage (" - {0}{2} ({1})" -f $display, $skuId, $suffix) -Level INFO
                }
                else {
                    Write-Verbose (" - Unknown license: {0} ({1})" -f $skuPart, $skuId)
                    Write-NCMessage (" - {0} ({1})" -f $skuPart, $skuId) -Level WARNING
                }
            }
        }
        else {
            Write-NCMessage "No licenses assigned to this user." -Level VERBOSE
        }
    }
    finally {
        Add-EmptyLine
        Restore-ProgressAndInfoPreferences
    }
}

function Get-TenantMsolAccountSku {
    <#
    .SYNOPSIS
        Lists available tenant licenses with resolved names and usage counts.
    .DESCRIPTION
        Connects to Microsoft Graph, loads the license catalog, retrieves all tenant SKUs, resolves
        part numbers to friendly names (using the same lookup logic as other license functions), and
        returns counts for total, consumed, available, suspended, and warning seats.
    .PARAMETER ForceLicenseCatalogRefresh
        Force a fresh download of the cached license catalog before processing.
    .PARAMETER AsTable
        Display the result as a formatted table instead of returning objects.
    .PARAMETER GridView
        Show the result in Out-GridView instead of returning objects.
    .EXAMPLE
        Get-TenantMsolAccountSku
    .EXAMPLE
        Get-TenantMsolAccountSku -AsTable
    #>

    [CmdletBinding()]
    param(
        [switch]$ForceLicenseCatalogRefresh,
        [switch]$AsTable,
        [switch]$GridView
    )

    Set-ProgressAndInfoPreferences
    try {
        $GraphConnection = Test-MgGraphConnection
        if (-not $GraphConnection) {
            Write-NCMessage "`nCan't connect or use Microsoft Graph modules. `nPlease check logs." -Level ERROR
            return
        }

        try {
            $licenseCatalog = Get-LicenseCatalog -IncludeMetadata -ForceRefresh:$ForceLicenseCatalogRefresh.IsPresent
        }
        catch {
            Write-NCMessage $_ -Level ERROR
            return
        }

        $licenseLookup = $licenseCatalog.Lookup
        $customLookup = $licenseCatalog.CustomLookup
        $maxAttempts = 3

        try {
            $skus = Invoke-NCRetry -Action {
                Get-MgSubscribedSku -All -ErrorAction Stop
            } -MaxAttempts $maxAttempts -DelaySeconds 5 -OperationDescription "retrieve tenant licenses" -OnError {
                param($attempt, $max, $err)
                Write-NCMessage "Failed to retrieve tenant licenses, attempt $attempt of $max." -Level ERROR
            }
        }
        catch {
            Write-NCMessage "Failed to retrieve tenant licenses after $maxAttempts attempts." -Level ERROR
            return
        }

        if (-not $skus -or $skus.Count -eq 0) {
            Write-NCMessage "No licenses found for this tenant." -Level WARNING
            return
        }

        $results = foreach ($sku in $skus) {
            $matchSource = $null
            $display = Get-LicenseDisplayName -Lookup $licenseLookup `
                -SkuPartNumber $sku.SkuPartNumber `
                -FallbackLookup $customLookup `
                -MatchSource ([ref]$matchSource)

            $prepaid = $sku.PrepaidUnits
            $enabled = if ($prepaid) { [int]$prepaid.Enabled } else { 0 }
            $suspended = if ($prepaid) { [int]$prepaid.Suspended } else { 0 }
            $warning = if ($prepaid) { [int]$prepaid.Warning } else { 0 }
            $total = $enabled + $suspended + $warning
            $consumed = if ($sku.ConsumedUnits -is [int]) { [int]$sku.ConsumedUnits } else { [int]0 }
            $available = if ($total -gt 0) { [Math]::Max($total - $consumed, 0) } else { $null }
            $nameSource = if ($matchSource) { $matchSource } elseif ($display) { 'Primary' } else { 'Unknown' }

            [pscustomobject][ordered]@{
                Name          = if ($display) { $display } else { $sku.SkuPartNumber }
                SkuPartNumber = $sku.SkuPartNumber
                SkuId         = $sku.SkuId
                Total         = $total
                Consumed      = $consumed
                Available     = $available
                Enabled       = $enabled
                Suspended     = $suspended
                Warning       = $warning
                Source        = $nameSource
            }
        }

        $sorted = $results | Sort-Object Name

        if ($GridView.IsPresent) {
            $sorted | Out-GridView -Title "M365 Tenant Licenses"
        }
        elseif ($AsTable.IsPresent) {
            $limited = $sorted | Select-Object @{
                    Name       = 'Name'
                    Expression = { Format-OutputString -Value $_.Name -MaxLength $NCVars.MaxFieldLength }
                }, SkuPartNumber, Total, Consumed, Available
            Show-Table -Rows $limited -AsTable
        }
        else {
            $sorted
        }
    }
    finally {
        Restore-ProgressAndInfoPreferences
    }
}

function Move-MsolAccountSku {
    <#
    .SYNOPSIS
        Moves all licenses from one user to another.
    .DESCRIPTION
        Reads source user licenses (including disabled service plans), assigns them to the destination user,
        and then removes them from the source. Uses Microsoft Graph and the cached license catalog for friendly names.
    .PARAMETER SourceUserPrincipalName
        UserPrincipalName or object ID of the source user.
    .PARAMETER DestinationUserPrincipalName
        UserPrincipalName or object ID of the destination user.
    .EXAMPLE
        Move-MsolAccountSku -SourceUserPrincipalName user1@contoso.com -DestinationUserPrincipalName user2@contoso.com
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory = $true)]
        [Alias('Source', 'From')]
        [string]$SourceUserPrincipalName,
        [Parameter(Mandatory = $true)]
        [Alias('Destination', 'To')]
        [string]$DestinationUserPrincipalName
    )

    Set-ProgressAndInfoPreferences
    try {
        $GraphConnection = Test-MgGraphConnection
        if (-not $GraphConnection) {
            Write-NCMessage "`nCan't connect or use Microsoft Graph modules. `nPlease check logs." -Level ERROR
            return
        }

        $resolvedSource = Find-UserRecipient -UserPrincipalName $SourceUserPrincipalName
        $resolvedDestination = Find-UserRecipient -UserPrincipalName $DestinationUserPrincipalName

        if (-not $resolvedSource) {
            Write-NCMessage "Unable to resolve source user recipient for $SourceUserPrincipalName" -Level ERROR
            return
        }
        if (-not $resolvedDestination) {
            Write-NCMessage "Unable to resolve destination user recipient for $DestinationUserPrincipalName" -Level ERROR
            return
        }
        if ($resolvedSource -eq $resolvedDestination) {
            Write-NCMessage "Source and destination users are the same. Aborting." -Level ERROR
            return
        }

        try {
            $sourceUser = Get-MgUser -UserId $resolvedSource -ErrorAction Stop
            $destinationUser = Get-MgUser -UserId $resolvedDestination -ErrorAction Stop
        }
        catch {
            Write-NCMessage "Unable to retrieve users: $($_.Exception.Message)" -Level ERROR
            return
        }

        try {
            $licenseCatalog = Get-LicenseCatalog
        }
        catch {
            Write-NCMessage "License catalog unavailable: $($_.Exception.Message)" -Level WARNING
            $licenseCatalog = $null
        }

        $licenseLookup = $null
        $customLookup = $null
        if ($licenseCatalog) {
            if ($licenseCatalog.PSObject.Properties['Lookup']) {
                $licenseLookup = $licenseCatalog.Lookup
            }
            if ($licenseCatalog.PSObject.Properties['CustomLookup']) {
                $customLookup = $licenseCatalog.CustomLookup
            }
        }
        $maxAttempts = 3

        try {
            $sourceLicenses = Invoke-NCRetry -Action {
                Get-MgUserLicenseDetail -UserId $sourceUser.Id -ErrorAction Stop
            } -MaxAttempts $maxAttempts -DelaySeconds 5 -OperationDescription "retrieve licenses for $($sourceUser.UserPrincipalName)" -OnError {
                param($attempt, $max, $err)
                Write-NCMessage "Failed to retrieve licenses for $($sourceUser.UserPrincipalName), attempt $attempt of $max." -Level ERROR
            }
        }
        catch {
            Write-NCMessage "Failed to retrieve licenses for $($sourceUser.UserPrincipalName) after $maxAttempts attempts." -Level ERROR
            return
        }

        if (-not $sourceLicenses -or $sourceLicenses.Count -eq 0) {
            Write-NCMessage "Source user $($sourceUser.UserPrincipalName) has no licenses to move." -Level WARNING
            return
        }

        try {
            $destinationLicenses = Get-MgUserLicenseDetail -UserId $destinationUser.Id -ErrorAction Stop
        }
        catch {
            Write-NCMessage "Unable to read destination licenses for $($destinationUser.UserPrincipalName): $($_.Exception.Message)" -Level ERROR
            return
        }

        $destinationSkuIds = if ($destinationLicenses) { $destinationLicenses.SkuId } else { @() }
        $addLicenses = @()
        $removeSkuIds = @()

        foreach ($lic in $sourceLicenses) {
            $removeSkuIds += $lic.SkuId
            if ($destinationSkuIds -contains $lic.SkuId) {
                Write-NCMessage "Destination already has $($lic.SkuPartNumber); skipping add." -Level VERBOSE
                continue
            }

            $addLicenses += @{
                SkuId         = $lic.SkuId
                DisabledPlans = if ($lic.DisabledPlans) { $lic.DisabledPlans } else { @() }
            }
        }

        if ($addLicenses.Count -eq 0 -and $removeSkuIds.Count -eq 0) {
            Write-NCMessage "Nothing to move between $($sourceUser.UserPrincipalName) and $($destinationUser.UserPrincipalName)." -Level WARNING
            return
        }

        $licenseNames = $sourceLicenses | ForEach-Object {
            $matchSource = $null
            $name = if ($licenseLookup) {
                Get-LicenseDisplayName -Lookup $licenseLookup -SkuPartNumber $_.SkuPartNumber -FallbackLookup $customLookup -MatchSource ([ref]$matchSource)
            }
            if ($name) { $name } else { $_.SkuPartNumber }
        }
        $actionSummary = "Move licenses ($($licenseNames -join ', ')) from $($sourceUser.UserPrincipalName) to $($destinationUser.UserPrincipalName)"

        if (-not $PSCmdlet.ShouldProcess($destinationUser.UserPrincipalName, $actionSummary)) {
            return
        }

        if ($addLicenses.Count -gt 0) {
            try {
                Invoke-NCRetry -Action {
                    Set-MgUserLicense -UserId $destinationUser.Id -AddLicenses $addLicenses -RemoveLicenses @() -ErrorAction Stop
                } -MaxAttempts $maxAttempts -DelaySeconds 5 -OperationDescription "assign licenses to $($destinationUser.UserPrincipalName)" -OnError {
                    param($attempt, $max, $err)
                    Write-NCMessage "Failed to assign licenses to $($destinationUser.UserPrincipalName), attempt $attempt of $max." -Level ERROR
                } | Out-Null
                Write-NCMessage "Assigned licenses to $($destinationUser.UserPrincipalName)." -Level SUCCESS
            }
            catch {
                Write-NCMessage "License assignment to $($destinationUser.UserPrincipalName) failed. Aborting removal from source. $($_.Exception.Message)" -Level ERROR
                return
            }
        }

        if ($removeSkuIds.Count -gt 0) {
            try {
                Invoke-NCRetry -Action {
                    Set-MgUserLicense -UserId $sourceUser.Id -AddLicenses @() -RemoveLicenses $removeSkuIds -ErrorAction Stop
                } -MaxAttempts $maxAttempts -DelaySeconds 5 -OperationDescription "remove licenses from $($sourceUser.UserPrincipalName)" -OnError {
                    param($attempt, $max, $err)
                    Write-NCMessage "Failed to remove licenses from $($sourceUser.UserPrincipalName), attempt $attempt of $max." -Level ERROR
                } | Out-Null
                Write-NCMessage "Removed licenses from $($sourceUser.UserPrincipalName)." -Level SUCCESS
            }
            catch {
                Write-NCMessage "Failed to remove licenses from $($sourceUser.UserPrincipalName): $($_.Exception.Message)" -Level ERROR
            }
        }
    }
    finally {
        Restore-ProgressAndInfoPreferences
    }
}

function Add-MsolAccountSku {
    <#
    .SYNOPSIS
        Assigns licenses to a user by friendly name or SKU identifier.
    .DESCRIPTION
        Resolves the provided license names using the cached license catalog and the tenant's subscribed SKUs,
        then assigns them to the target user (preserving existing licenses). Accepts friendly product names,
        SKU part numbers, or SKU IDs.
    .PARAMETER UserPrincipalName
        Target user UPN or object ID.
    .PARAMETER License
        One or more license identifiers: friendly name (as resolved by the catalog), SKU part number, or SKU ID (GUID).
    .PARAMETER ForceLicenseCatalogRefresh
        Force a refresh of the cached license catalog before resolving friendly names.
    .EXAMPLE
        Add-MsolAccountSku -UserPrincipalName user@contoso.com -License "Microsoft 365 E3"
    .EXAMPLE
        Add-MsolAccountSku -UserPrincipalName user@contoso.com -License "ENTERPRISEPACK","VISIOCLIENT"
    .EXAMPLE
        Add-MsolAccountSku -UserPrincipalName user@contoso.com -License "18181a46-0d4e-45cd-891e-60aabd171b4e"
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory = $true)]
        [Alias('User', 'UPN')]
        [string]$UserPrincipalName,
        [Parameter(Mandatory = $true)]
        [string[]]$License,
        [switch]$ForceLicenseCatalogRefresh
    )

    Set-ProgressAndInfoPreferences
    try {
        $GraphConnection = Test-MgGraphConnection
        if (-not $GraphConnection) {
            Write-NCMessage "`nCan't connect or use Microsoft Graph modules. `nPlease check logs." -Level ERROR
            return
        }

        $resolvedPrincipal = Find-UserRecipient -UserPrincipalName $UserPrincipalName
        if (-not $resolvedPrincipal) {
            Write-NCMessage "Unable to resolve user recipient for $UserPrincipalName" -Level ERROR
            return
        }

        try {
            $user = Get-MgUser -UserId $resolvedPrincipal -ErrorAction Stop
        }
        catch {
            Write-NCMessage "User $UserPrincipalName not found or query failed: $($_.Exception.Message)" -Level ERROR
            return
        }

        $defaultUsageLocation = if (($NCVars -is [System.Collections.IDictionary]) -and $NCVars.Contains('UsageLocation') -and $NCVars.UsageLocation) {
            [string]$NCVars.UsageLocation
        }
        else { 'US' }

        if ([string]::IsNullOrWhiteSpace($user.UsageLocation)) {
            $targetUsage = $defaultUsageLocation
            try {
                Update-MgUser -UserId $user.Id -UsageLocation $targetUsage -ErrorAction Stop | Out-Null
                $user.UsageLocation = $targetUsage
                Write-NCMessage "Usage location set to $targetUsage for $($user.UserPrincipalName)." -Level VERBOSE
            }
            catch {
                Write-NCMessage "Unable to set usage location ($targetUsage) for $($user.UserPrincipalName): $($_.Exception.Message)" -Level ERROR
                return
            }
        }

        try {
            $licenseCatalog = Get-LicenseCatalog -IncludeMetadata -ForceRefresh:$ForceLicenseCatalogRefresh.IsPresent
        }
        catch {
            Write-NCMessage $_ -Level WARNING
            $licenseCatalog = $null
        }

        $licenseLookup = $null
        $customLookup = $null
        if ($licenseCatalog) {
            if ($licenseCatalog.PSObject.Properties['Lookup']) { $licenseLookup = $licenseCatalog.Lookup }
            if ($licenseCatalog.PSObject.Properties['CustomLookup']) { $customLookup = $licenseCatalog.CustomLookup }
        }

        $maxAttempts = 3
        try {
            $tenantSkus = Invoke-NCRetry -Action {
                Get-MgSubscribedSku -All -ErrorAction Stop
            } -MaxAttempts $maxAttempts -DelaySeconds 5 -OperationDescription "retrieve tenant licenses" -OnError {
                param($attempt, $max, $err)
                Write-NCMessage "Failed to retrieve tenant licenses, attempt $attempt of $max." -Level ERROR
            }
        }
        catch {
            Write-NCMessage "Unable to retrieve tenant licenses after $maxAttempts attempts." -Level ERROR
            return
        }

        if (-not $tenantSkus -or $tenantSkus.Count -eq 0) {
            Write-NCMessage "No tenant licenses available to assign." -Level WARNING
            return
        }

        $normalizeString = {
            param($value)
            if ([string]::IsNullOrWhiteSpace($value)) { return $null }
            return ($value.Trim().ToUpperInvariant())
        }

        $resolved = @()
        $unmatched = @()
        $inputLicenses = $License | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() } | Select-Object -Unique

        foreach ($entry in $inputLicenses) {
            $target = & $normalizeString $entry
            $match = $null

            foreach ($sku in $tenantSkus) {
                $skuIdString = [string]$sku.SkuId
                $skuPart = & $normalizeString $sku.SkuPartNumber

                $display = $null
                $matchSource = $null
                if ($licenseLookup) {
                    $display = Get-LicenseDisplayName -Lookup $licenseLookup -SkuPartNumber $sku.SkuPartNumber -FallbackLookup $customLookup -MatchSource ([ref]$matchSource)
                }
                $displayNormalized = if ($display) { & $normalizeString $display } else { $null }

                if ($target -eq $skuPart -or $target -eq ($skuIdString.ToUpperInvariant()) -or ($displayNormalized -and $target -eq $displayNormalized)) {
                    $match = @{
                        SkuId         = $sku.SkuId
                        SkuPartNumber = $sku.SkuPartNumber
                        Name          = if ($display) { $display } else { $sku.SkuPartNumber }
                        Available     = ($sku.PrepaidUnits.Enabled + $sku.PrepaidUnits.Warning + $sku.PrepaidUnits.Suspended) - $sku.ConsumedUnits
                    }
                    break
                }
            }

            if ($match) {
                $resolved += $match
            }
            else {
                $unmatched += $entry
            }
        }

        if ($unmatched.Count -gt 0) {
            Write-NCMessage ("Unable to resolve license(s): {0}" -f ($unmatched -join ', ')) -Level ERROR
            return
        }

        $uniqueAdds = $resolved | Group-Object SkuId | ForEach-Object {
            $_.Group | Select-Object -First 1
        }

        $addLicenses = @()
        $namesNoAvailability = @()
        foreach ($item in $uniqueAdds) {
            $available = $item.Available
            if ($available -le 0) {
                Write-NCMessage ("No available units for license {0} ({1}) (available: {2})" -f $item.Name, $item.SkuPartNumber, $available) -Level WARNING
                $namesNoAvailability += $item.Name
                continue
            }
            $addLicenses += @{
                SkuId         = $item.SkuId
                DisabledPlans = @()
            }
        }

        if ($addLicenses.Count -eq 0) {
            $requestedList = ($resolved | ForEach-Object { $_.Name } | Select-Object -Unique) -join ', '
            Write-NCMessage ("No licenses to assign: none available. Requested: {0}" -f $requestedList) -Level ERROR
            return
        }

        $summary = "Assign license(s): {0} to {1}" -f (($resolved | ForEach-Object { $_.Name } | Select-Object -Unique) -join ', '), $user.UserPrincipalName
        if (-not $PSCmdlet.ShouldProcess($user.UserPrincipalName, $summary)) {
            return
        }

        try {
            Invoke-NCRetry -Action {
                Set-MgUserLicense -UserId $user.Id -AddLicenses $addLicenses -RemoveLicenses @() -ErrorAction Stop
            } -MaxAttempts $maxAttempts -DelaySeconds 5 -OperationDescription "assign licenses to $($user.UserPrincipalName)" -OnError {
                param($attempt, $max, $err)
                Write-NCMessage ("Failed to assign licenses to {0}, attempt {1} of {2}. {3}" -f $user.UserPrincipalName, $attempt, $max, $err.Exception.Message) -Level ERROR
            } | Out-Null
            Write-NCMessage ("Assigned license(s) to {0}: {1}" -f $user.UserPrincipalName, (($resolved | ForEach-Object { $_.Name } | Select-Object -Unique) -join ', ')) -Level SUCCESS
        }
        catch {
            Write-NCMessage "License assignment failed for $($user.UserPrincipalName): $($_.Exception.Message)" -Level ERROR
        }
    }
    finally {
        Restore-ProgressAndInfoPreferences
    }
}

function Update-LicenseCatalog {
    <#
    .SYNOPSIS
        Forces an immediate refresh of the cached license catalog.
    .DESCRIPTION
        Downloads the latest catalog from GitHub, updates the local cache, and returns the resulting
        object so callers can inspect the data if needed.
    .EXAMPLE
        Update-LicenseCatalog
    #>

    [CmdletBinding()]
    param()

    try {
        $catalog = Get-LicenseCatalog -ForceRefresh -IncludeMetadata
        if ($catalog.LastCommitUtc) {
            $timestamp = $catalog.LastCommitUtc.ToLocalTime().ToString($NCVars.DateTimeString_Full)
            Write-NCMessage "Primary license catalog refreshed. Last commit: $timestamp" -Level SUCCESS
        }
        else {
            Write-NCMessage "Primary license catalog refreshed." -Level SUCCESS
        }

        if ($catalog.CustomLookup) {
            if ($catalog.CustomLastCommitUtc) {
                $customStamp = $catalog.CustomLastCommitUtc.ToLocalTime().ToString($NCVars.DateTimeString_Full)
                Write-NCMessage "Custom license catalog refreshed. Last commit: $customStamp" -Level INFO
            }
            else {
                Write-NCMessage "Custom license catalog refreshed." -Level INFO
            }
        }

        return $catalog
    }
    catch {
        Write-NCMessage "Unable to refresh license catalog. $($_.Exception.Message)" -Level ERROR
        throw
    }
}