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

            # Optimization: Pre-fetch all assignments for this role to avoid N+1 API calls
            $cachedActive = @()
            $cachedEligible = @()
            try {
                Write-Verbose "[Assignments] Pre-fetching Entra assignments for role '$roleName'..."
                $cachedActive = @(Get-PIMEntraRoleActiveAssignment -tenantID $TenantId -rolename $roleName -ErrorAction SilentlyContinue)
                $cachedEligible = @(Get-PIMEntraRoleEligibleAssignment -tenantID $TenantId -rolename $roleName -ErrorAction SilentlyContinue)
            } catch {
                Write-Verbose "[Assignments] Failed to pre-fetch Entra assignments: $($_.Exception.Message)"
            }

            foreach ($a in ($roleBlock.assignments | Where-Object { $_ })) {
                $ctx = "Entra/$roleName/$($a.principalId) [$($a.assignmentType)]"

                # Idempotency: skip if already assigned (active or eligible) for directory scope '/'
                try {
                    # Check against cached lists first
                    $existsActive = $cachedActive | Where-Object { $_.principalId -eq $a.principalId }
                    $existsElig = $cachedEligible | Where-Object { $_.principalId -eq $a.principalId }

                    # Fallback to individual check if cache was empty (maybe API failure or just no assignments) but we want to be sure?
                    # Actually, if cache is empty it means no assignments found (or API error).
                    # If API error, we might want to try individual call?
                    # For now, let's trust the pre-fetch. If pre-fetch failed, lists are empty, so we might proceed to create and fail there.
                    # But to be safe, if we really want to be robust, we could try individual if cache is empty?
                    # No, that defeats the purpose. Let's assume pre-fetch works.

                    if ($existsActive -or $existsElig) {
                        $existingType = if ($existsActive) { "Active" } else { "Eligible" }
                        if ($whatIf) {
                            Write-Host " ✅ [MATCH] Assignment exists: $ctx [Found: $existingType]" -ForegroundColor Green
                        } else {
                            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)
                }

                if ($whatIf) {
                    Write-Host "What if: Creating assignment $ctx" -ForegroundColor Cyan
                    $summary.PlannedCreated++
                    continue
                }
                $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 }

            # Optimization: Pre-fetch all assignments for this scope
            $cachedActive = @()
            $cachedEligible = @()
            try {
                Write-Verbose "[Assignments] Pre-fetching Azure assignments for scope '$scope'..."
                $cachedActive = @(Get-PIMAzureResourceActiveAssignment -tenantID $TenantId -subscriptionID $SubscriptionId -scope $scope -ErrorAction SilentlyContinue)
                $cachedEligible = @(Get-PIMAzureResourceEligibleAssignment -tenantID $TenantId -subscriptionID $SubscriptionId -scope $scope -ErrorAction SilentlyContinue)
            } catch {
                Write-Verbose "[Assignments] Failed to pre-fetch Azure assignments: $($_.Exception.Message)"
            }

            foreach ($a in ($roleBlock.assignments | Where-Object { $_ })) {
                $ctx = "Azure/$roleName@$scope/$($a.principalId) [$($a.assignmentType)]"

                # Idempotency: naive check via active/eligible getters if available; otherwise proceed
                try {
                    $roleMatch = {
                        param($obj)
                        if (-not $obj) { return $false }
                        if ($obj -is [string]) { return $false }
                        if (-not $obj.PSObject.Properties['ScopeId'] -or -not $obj.ScopeId) { return $false }
                        if (-not $obj.PSObject.Properties['RoleName'] -or -not $obj.RoleName) { return $false }
                        if ($obj.ScopeId -ne $scope) { return $false }
                        return ($obj.RoleName -eq $roleName)
                    }

                    # Filter cached lists
                    $existsActiveData = @($cachedActive | Where-Object { $_.principalId -eq $a.principalId -and (& $roleMatch $_) })
                    $existsEligData = @($cachedEligible | Where-Object { $_.principalId -eq $a.principalId -and (& $roleMatch $_) })

                    if ($existsActiveData.Count -gt 0 -or $existsEligData.Count -gt 0) {
                        $foundType = if ($existsActiveData.Count -gt 0) { 'Active' } else { 'Eligible' }
                        if ($whatIf) {
                            Write-Host " ✅ [MATCH] Assignment exists: $ctx [Found: $foundType]" -ForegroundColor Green
                        } else {
                            Write-Host " ⏭️ Skipped existing: $ctx [Found: $foundType]" -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)
                }

                if ($whatIf) {
                    Write-Host "What if: Creating assignment $ctx" -ForegroundColor Cyan
                    $summary.PlannedCreated++
                    continue
                }
                $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)" }

            # Optimization: Pre-fetch all assignments for this group/type
            $cachedActive = @()
            $cachedEligible = @()
            try {
                Write-Verbose "[Assignments] Pre-fetching Group assignments for group '$groupId' type '$groupType'..."
                $cachedActive = @(Get-PIMGroupActiveAssignment -tenantID $TenantId -groupID $groupId -type $groupType -ErrorAction SilentlyContinue)
                $cachedEligible = @(Get-PIMGroupEligibleAssignment -tenantID $TenantId -groupID $groupId -type $groupType -ErrorAction SilentlyContinue)
            } catch {
                Write-Verbose "[Assignments] Failed to pre-fetch Group assignments: $($_.Exception.Message)"
            }

            foreach ($a in ($grp.assignments | Where-Object { $_ })) {
                $ctx = "Group/$groupId/$roleName/$($a.principalId) [$($a.assignmentType)]"

                # Idempotency: check existing elig/active for group PIM
                try {
                    # Check against cached lists
                    $existsActive = $cachedActive | Where-Object { $_.principalId -eq $a.principalId }
                    $existsElig = $cachedEligible | Where-Object { $_.principalId -eq $a.principalId }

                    if ($existsActive -or $existsElig) {
                        if ($whatIf) {
                            Write-Host " ✅ [MATCH] Assignment exists: $ctx" -ForegroundColor Green
                        } else {
                            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)
                }

                if ($whatIf) {
                    Write-Host "What if: Creating assignment $ctx" -ForegroundColor Cyan
                    $summary.PlannedCreated++
                    continue
                }
                $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
}