internal/functions/EPO_Invoke-ResourceAssignments.ps1

function New-EasyPIMAssignments {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object]$Config,
        [Parameter(Mandatory)] [string]$TenantId,
        [Parameter()] [string]$SubscriptionId
    )

    # Store original verbose preference to restore later
    $script:originalVerbosePreference = $VerbosePreference

    $summary = [pscustomobject]@{
        Created        = 0
        Skipped        = 0
        Failed         = 0
        PlannedCreated = 0
    }

    if (-not $Config -or -not $Config.PSObject.Properties.Name -contains 'Assignments' -or -not $Config.Assignments) {
        Write-Verbose "[Assignments] No Assignments block found; nothing to do"
        return $summary
    }

    $assign = $Config.Assignments
    $whatIf = $WhatIfPreference

    function Invoke-Safely {
        param(
            [Parameter(Mandatory)] [scriptblock]$Script,
            [string]$Context
        )
        try {
            & $Script
            Write-Host " ✅ Assignment created: $Context" -ForegroundColor Green
            $true
        } catch {
            $emsg = $_.Exception.Message

            # Handle different types of errors with appropriate messages
            if ($emsg -match 'RoleAssignmentExists|The Role assignment already exists') {
                Write-Host " ⏭️ Skipped existing: $Context" -ForegroundColor Yellow
                return $true
            }
            elseif ($emsg -match 'POLICY VALIDATION FAILED') {
                # Extract just the clear policy message, suppress ARM 400 errors
                $policyMsg = $emsg -replace '.*inner=', '' -replace 'Error, script did not terminate gracefuly \| inner=', ''
                Write-Host " 🚫 Policy conflict: $Context" -ForegroundColor Magenta
                Write-Host " $policyMsg" -ForegroundColor Yellow
                return $false
            }
            elseif ($emsg -match 'ARM API call failed.*400.*Bad Request' -and $emsg -match 'principalID') {
                # Suppress verbose ARM 400 errors that we know are policy-related
                Write-Host " 🚫 Assignment failed: $Context - Policy validation or parameter issue" -ForegroundColor Magenta
                return $false
            }
            else {
                # Other genuine errors
                Write-Host " ❌ Assignment failed: $Context" -ForegroundColor Red
                Write-Host " Error: $emsg" -ForegroundColor Yellow
                return $false
            }
        }
    }

    # Entra Roles
    if ($assign.PSObject.Properties.Name -contains 'EntraRoles' -and $assign.EntraRoles) {
        foreach ($roleBlock in $assign.EntraRoles) {
            $roleName = $roleBlock.roleName
            foreach ($a in ($roleBlock.assignments | Where-Object { $_ })) {
                $ctx = "Entra/$roleName/$($a.principalId) [$($a.assignmentType)]"
                if ($whatIf) { $summary.PlannedCreated++ ; continue }
                # Idempotency: skip if already assigned (active or eligible) for directory scope '/'
                try {
                    $role = Get-PIMEntraRolePolicy -tenantID $TenantId -rolename $roleName -ErrorAction Stop
                    $VerbosePreference = 'SilentlyContinue'  # Suppress confusing "0 assignments found" messages
                    $existsActive = Get-PIMEntraRoleActiveAssignment -tenantID $TenantId -principalID $a.principalId -rolename $roleName -ErrorAction SilentlyContinue
                    $existsElig = Get-PIMEntraRoleEligibleAssignment -tenantID $TenantId -principalID $a.principalId -rolename $roleName -ErrorAction SilentlyContinue
                    $VerbosePreference = $script:originalVerbosePreference
                    if ($existsActive -or $existsElig) {
                        $existingType = if ($existsActive) { "Active" } else { "Eligible" }
                        Write-Host " ⏭️ Skipped existing: $ctx [Found: $existingType]" -ForegroundColor Yellow
                        $summary.Skipped++
                        continue
                    }
                } catch {
                    $VerbosePreference = $script:originalVerbosePreference
                    # Pre-check failed, but assignment will still be attempted
                    Write-Verbose ("[Assignments] Entra pre-check skipped for ${ctx} (will attempt assignment anyway): {0}" -f $_.Exception.Message)
                }
                $sb = {
                    if ($a.assignmentType -match 'Active') {
                        $params = @{ tenantID = $TenantId; rolename = $roleName; principalID = $a.principalId }
                        if ($a.duration)   { $params.duration = $a.duration }
                        if ($a.permanent)  { $params.permanent = $true }
                        if ($a.justification) { $params.justification = $a.justification }
                        New-PIMEntraRoleActiveAssignment @params | Out-Null
                    } else {
                        $params = @{ tenantID = $TenantId; rolename = $roleName; principalID = $a.principalId }
                        if ($a.duration)   { $params.duration = $a.duration }
                        if ($a.permanent)  { $params.permanent = $true }
                        if ($a.justification) { $params.justification = $a.justification }
                        New-PIMEntraRoleEligibleAssignment @params | Out-Null
                    }
                }
                if (Invoke-Safely -Script $sb -Context $ctx) { $summary.Created++ } else { $summary.Failed++ }
            }
        }
    }

    # Azure Resource Roles
    if ($assign.PSObject.Properties.Name -contains 'AzureRoles' -and $assign.AzureRoles) {
        foreach ($roleBlock in $assign.AzureRoles) {
            $roleName = $roleBlock.RoleName; if (-not $roleName) { $roleName = $roleBlock.roleName }
            $scope = $roleBlock.Scope; if (-not $scope) { $scope = $roleBlock.scope }
            foreach ($a in ($roleBlock.assignments | Where-Object { $_ })) {
                $ctx = "Azure/$roleName@$scope/$($a.principalId) [$($a.assignmentType)]"
                if ($whatIf) { $summary.PlannedCreated++ ; continue }
                # Idempotency: naive check via active/eligible getters if available; otherwise proceed
                try {
                    $VerbosePreference = 'SilentlyContinue'  # Suppress confusing verbose output
                    $existsActive = Get-PIMAzureResourceActiveAssignment -tenantID $TenantId -subscriptionID $SubscriptionId -scope $scope -principalId $a.principalId -ErrorAction SilentlyContinue
                    $existsElig = Get-PIMAzureResourceEligibleAssignment -tenantID $TenantId -subscriptionID $SubscriptionId -scope $scope -principalId $a.principalId -ErrorAction SilentlyContinue
                    $VerbosePreference = $script:originalVerbosePreference
                    if ($existsActive -or $existsElig) { Write-Host " ⏭️ Skipped existing: $ctx" -ForegroundColor Yellow; $summary.Skipped++; continue }
                } catch {
                    $VerbosePreference = $script:originalVerbosePreference
                    # Pre-check failed, but assignment will still be attempted
                    Write-Verbose ("[Assignments] Azure pre-check skipped for ${ctx} (will attempt assignment anyway): {0}" -f $_.Exception.Message)
                }
                $sb = {
                    if ($a.assignmentType -match 'Active') {
                        $params = @{ tenantID = $TenantId; subscriptionID = $SubscriptionId; scope = $scope; rolename = $roleName; principalID = $a.principalId }
                        if ($a.duration)   { $params.duration = $a.duration }
                        if ($a.permanent)  { $params.permanent = $true }
                        if ($a.justification) { $params.justification = $a.justification }
                        New-PIMAzureResourceActiveAssignment @params | Out-Null
                    } else {
                        $params = @{ tenantID = $TenantId; subscriptionID = $SubscriptionId; scope = $scope; rolename = $roleName; principalID = $a.principalId }
                        if ($a.duration)   { $params.duration = $a.duration }
                        if ($a.permanent)  { $params.permanent = $true }
                        if ($a.justification) { $params.justification = $a.justification }
                        New-PIMAzureResourceEligibleAssignment @params | Out-Null
                    }
                }
                if (Invoke-Safely -Script $sb -Context $ctx) { $summary.Created++ } else { $summary.Failed++ }
            }
        }
    }

    # Group Roles
    if ($assign.PSObject.Properties.Name -contains 'Groups' -and $assign.Groups) {
        foreach ($grp in $assign.Groups) {
            $groupId = $grp.groupId
            $roleName = $grp.roleName
            # normalize to API expected values for group membership type (owner|member)
            $groupType = $roleName
            try { if ($roleName) { $ln = $roleName.ToLower(); if ($ln -in @('owner','member')) { $groupType = $ln } } } catch { Write-Verbose "[Assignments] Could not normalize group type '$roleName': $($_.Exception.Message)" }
            foreach ($a in ($grp.assignments | Where-Object { $_ })) {
                $ctx = "Group/$groupId/$roleName/$($a.principalId) [$($a.assignmentType)]"
                if ($whatIf) { $summary.PlannedCreated++ ; continue }
                # Idempotency: check existing elig/active for group PIM
                try {
                    $VerbosePreference = 'SilentlyContinue'  # Suppress confusing verbose output
                    $existsActive = Get-PIMGroupActiveAssignment -tenantID $TenantId -groupID $groupId -principalID $a.principalId -type $groupType -ErrorAction SilentlyContinue
                    $existsElig = Get-PIMGroupEligibleAssignment -tenantID $TenantId -groupID $groupId -principalID $a.principalId -type $groupType -ErrorAction SilentlyContinue
                    $VerbosePreference = $script:originalVerbosePreference
                    if ($existsActive -or $existsElig) { Write-Host " ⏭️ Skipped existing: $ctx" -ForegroundColor Yellow; $summary.Skipped++; continue }
                } catch {
                    $VerbosePreference = $script:originalVerbosePreference
                    # Pre-check failed, but assignment will still be attempted
                    Write-Verbose ("[Assignments] Group pre-check skipped for ${ctx} (will attempt assignment anyway): {0}" -f $_.Exception.Message)
                }
                $sb = {
                    if ($a.assignmentType -match 'Active') {
                        $params = @{ tenantID = $TenantId; groupID = $groupId; type = $groupType; principalID = $a.principalId }
                        if ($a.duration)   { $params.duration = $a.duration }
                        if ($a.permanent)  { $params.permanent = $true }
                        if ($a.justification) { $params.justification = $a.justification }
                        New-PIMGroupActiveAssignment @params | Out-Null
                    } else {
                        $params = @{ tenantID = $TenantId; groupID = $groupId; type = $groupType; principalID = $a.principalId }
                        if ($a.duration)   { $params.duration = $a.duration }
                        if ($a.permanent)  { $params.permanent = $true }
                        if ($a.justification) { $params.justification = $a.justification }
                        New-PIMGroupEligibleAssignment @params | Out-Null
                    }
                }
                if (Invoke-Safely -Script $sb -Context $ctx) { $summary.Created++ } else { $summary.Failed++ }
            }
        }
    }

    return $summary
}