Public/Compare-AzPolicyCompliance.ps1

function Compare-AzPolicyCompliance {
    <#
    .SYNOPSIS
        Compare Azure Policy assignments against baseline for compliance scoring.
     
    .DESCRIPTION
        Analyzes policy assignments to identify:
        - Missing policies (in baseline but not assigned)
        - Extra policies (assigned but not in baseline)
        - Version mismatches (different versions between baseline and assignments)
         
        Calculates compliance scores:
        - ALZ Score: Percentage of ALZ baseline policies deployed
        - MCSB Score: Percentage of MCSB baseline policies deployed
        - Global Score: Weighted average of ALZ and MCSB scores
         
        ⚡ OPTIMIZED VERSION - Uses HashSet for O(1) lookups in InCommon calculation.
     
    .PARAMETER Baseline
        Baseline object from Get-AzPolicyBaseline.
        Must contain .Policies array and .Index object.
     
    .PARAMETER Assignments
        Array of assignments from Get-AzPolicyAssignmentScan.
        Each assignment must have .ExpandedPolicies property.
     
    .PARAMETER MatchByNameOnly
        Match policies by normalized name only, ignoring IDs.
        Useful when baseline uses built-in IDs but assignments use custom definitions.
        Default: $false (match by ID first, fallback to name)
     
    .PARAMETER InitiativesFolder
        Folder containing per-initiative policy CSV files.
        Required for compliance score calculation.
        If not specified, scores will not include initiative-level analysis.
     
    .EXAMPLE
        $baseline = Get-AzPolicyBaseline -IncludeAlz -IncludeMcsb
        $assignments = Get-AzPolicyAssignmentScan -ManagementGroupId "MyMG"
         
        $result = Compare-AzPolicyCompliance -Baseline $baseline -Assignments $assignments
         
        Write-Host "Global Compliance Score: $($result.Scores.GlobalScore)%"
     
    .OUTPUTS
        PSCustomObject with properties:
        - Summary: Array of per-assignment summary objects
        - Details: PSCustomObject with Missing, Extra, VersionMismatches, InCommon arrays
        - Scores: PSCustomObject with AlzScore, McsbScore, GlobalScore
        - Metrics: PSCustomObject with detailed metrics
    #>

    
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [PSCustomObject]$Baseline,
        
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [object[]]$Assignments,
        
[bool]$MatchByNameOnly = $false,
        
        [string]$InitiativesFolder,
        
        [Parameter()]
        [switch]$ExcludeExtraPolicies
    )
    
    begin {
        Write-Debug "Compare-AzPolicyCompliance: Starting"
        Write-Debug " Baseline policies: $($Baseline.Policies.Count)"
        Write-Debug " Assignments: $($Assignments.Count)"
        Write-Debug " MatchByNameOnly: $MatchByNameOnly"
        
        # Validate baseline structure (support hashtable and PSCustomObject)
        $hasPolicies = $Baseline.PSObject.Properties['Policies'] -or $Baseline.ContainsKey('Policies')
        $hasIndex = $Baseline.PSObject.Properties['Index'] -or $Baseline.ContainsKey('Index')
        
        if (-not $hasPolicies -or -not $hasIndex) {
            throw "Invalid baseline object. Must contain .Policies and .Index properties."
        }
        
        # Allow empty Baseline.Policies but validate it exists
        if ($null -eq $Baseline.Policies) {
            throw "Invalid baseline object. Baseline.Policies cannot be null."
        }
    }
    
    process {
    # ✅ DÉDUPLICATION DES ASSIGNMENTS AVANT ANALYSE
    Write-Verbose "Checking for duplicate assignments before comparison..."
    
    $originalAssignmentCount = $Assignments.Count
    
    # Grouper par AssignmentId et garder la première occurrence
    $uniqueAssignments = $Assignments | Group-Object -Property AssignmentId | ForEach-Object {
        if ($_.Count -gt 1) {
            # Log détaillé du doublon
            $scopes = $_.Group | ForEach-Object { 
                if ($_.Scope) { $_.Scope } 
                elseif ($_.ResourceId) { $_.ResourceId }
                else { "Unknown scope" }
            }
            Write-Verbose "⚠️ Duplicate assignment detected:"
            Write-Verbose " AssignmentId: $($_.Name)"
            Write-Verbose " DisplayName: $($_.Group[0].AssignmentDisplayName)"
            Write-Verbose " Found at $($_.Count) scopes: $($scopes -join ' | ')"
            Write-Verbose " Keeping first occurrence: $($scopes[0])"
        }
        $_.Group[0]  # Garder la première occurrence
    }
    
    $deduplicatedAssignmentCount = $uniqueAssignments.Count
    $removedAssignments = $originalAssignmentCount - $deduplicatedAssignmentCount
    
    if ($removedAssignments -gt 0) {
        Write-Host " ℹ️ Deduplicated assignments: $originalAssignmentCount → $deduplicatedAssignmentCount (removed $removedAssignments duplicates)" -ForegroundColor Cyan
        Write-Verbose " Removed $removedAssignments duplicate assignments before comparison"
    } else {
        Write-Verbose " No duplicate assignments found"
    }
    
    # ✅ Remplacer par la version dédupliquée
    $Assignments = @($uniqueAssignments)
    
    Write-Verbose "Proceeding with $($Assignments.Count) unique assignments for comparison"
    
        try {
            $summary = @()
            $allMissing = @()
            $allExtra = @()
            $allVersionMismatches = @()
            $allInCommon = @()
            
            Write-Verbose "Analyzing $($Assignments.Count) assignments against baseline..."
            
            # Step 1: Compare each assignment against baseline
            $progressCounter = 0
            foreach ($assignment in $Assignments) {
                $progressCounter++
                
                if ($progressCounter % 5 -eq 0) {
                    Write-Progress -Activity "Comparing Policy Assignments" `
                                   -Status "Processing assignment $progressCounter of $($Assignments.Count)" `
                                   -PercentComplete (($progressCounter / $Assignments.Count) * 100)
                }
                
                try {
                    # Get expanded policies for this assignment
                    $assignedPolicies = $assignment.ExpandedPolicies
                    
                    if (-not $assignedPolicies -or $assignedPolicies.Count -eq 0) {
                        Write-Warning "Assignment '$($assignment.AssignmentDisplayName)' has no expanded policies. Skipping."
                        continue
                    }
                    
                    # Create index for this assignment's policies
                    $assignmentIndexById = New-PolicyIndex -Items $assignedPolicies -NameProperty 'PolicyDefinitionId'
                    $assignmentIndexByNorm = New-PolicyIndex -Items $assignedPolicies -NameProperty 'PolicyDisplayName'
                    
                    # Perform comparison
                    $comparisonResult = Compare-ByMatcher -Baseline $Baseline.Policies `
                                                          -AssignedPolicies $assignedPolicies `
                                                          -BaselineIndex $Baseline.Index `
                                                          -AssignmentIndex $(if ($MatchByNameOnly) { $assignmentIndexByNorm } else { $assignmentIndexById }) `
                                                          -MatchByNameOnly $MatchByNameOnly
                    
                    # Create summary for this assignment
                    $assignmentSummary = New-ComparisonSummary -Assignment $assignment `
                                                               -PolicySet $null `
                                                               -AssignedPolicies $assignedPolicies `
                                                               -ComparisonResult $comparisonResult `
                                                               -MatchByNameOnly $MatchByNameOnly `
                                                               -BaselineIndex $Baseline.Index
                    
                    $summary += $assignmentSummary
                    
                    # Export initiative CSV if InitiativesFolder is provided
                    if ($InitiativesFolder -and (Test-Path $InitiativesFolder)) {
                        Export-InitiativeCsv -Assignment $assignment `
                                             -Policies $assignedPolicies `
                                             -OutputFolder $InitiativesFolder
                    }
                    
                    # ========== ✅ OPTIMISATION: Créer des HashSet pour InCommon ==========
                    Write-Debug "Building fast lookup indexes for InCommon calculation..."
                    
                    # Créer des HashSet pour Missing, Extra, VersionDiffs
                    $missingSet = [System.Collections.Generic.HashSet[string]]::new()
                    foreach ($item in $comparisonResult.Missing) {
                        [void]$missingSet.Add($item.PolicyDefinitionId)
                    }
                    
                    $extraSet = [System.Collections.Generic.HashSet[string]]::new()
                    foreach ($item in $comparisonResult.Extra) {
                        [void]$extraSet.Add($item.PolicyDefinitionId)
                    }
                    
                    $versionDiffSet = [System.Collections.Generic.HashSet[string]]::new()
                    foreach ($item in $comparisonResult.VersionDiffs) {
                        [void]$versionDiffSet.Add($item.PolicyDefinitionId)
                    }
                    
                    Write-Debug " ├─ Missing index: $($missingSet.Count) entries"
                    Write-Debug " ├─ Extra index: $($extraSet.Count) entries"
                    Write-Debug " └─ VersionDiff index: $($versionDiffSet.Count) entries"
                    
                    # ✅ OPTIMISÉ: Calculer InCommon avec lookup O(1)
                    foreach ($assignedPolicy in $assignedPolicies) {
                        $policyId = $assignedPolicy.PolicyDefinitionId
                        
                        # Lookup O(1) au lieu de Where-Object O(n)
                        if (-not $missingSet.Contains($policyId) -and 
                            -not $extraSet.Contains($policyId) -and 
                            -not $versionDiffSet.Contains($policyId)) {
                            
                            # ✅ Utiliser l'index du Baseline (O(1))
                            $baselinePolicy = if ($MatchByNameOnly) {
                                $normName = Normalize-PolicyName $assignedPolicy.PolicyDisplayName
                                if ($normName -and $Baseline.Index.ByNormName.ContainsKey($normName)) {
                                    $Baseline.Index.ByNormName[$normName] | Select-Object -First 1
                                }
                            } else {
                                $Baseline.Index.ById[$policyId]
                            }
                            
                            if ($baselinePolicy) {
                                $allInCommon += [PSCustomObject]@{
                                    AssignmentDisplayName = $assignment.AssignmentDisplayName
                                    AssignmentName = $assignment.AssignmentName
                                    PolicyDisplayName = $assignedPolicy.PolicyDisplayName
                                    PolicyDefinitionId = $assignedPolicy.PolicyDefinitionId
                                    Version = $assignedPolicy.Version
                                    BaselineSources = $baselinePolicy.BaselineSources
                                }
                            }
                        }
                    }
                    

if (-not $ExcludeExtraPolicies) {
    foreach ($extra in $comparisonResult.Extra) {
        $allExtra += [PSCustomObject]@{
            AssignmentDisplayName = $assignment.AssignmentDisplayName
            AssignmentName = $assignment.AssignmentName
            DifferenceType = "ExtraInAssignment"
            PolicyDisplayName = $extra.PolicyDisplayName
            PolicyDefinitionId = $extra.PolicyDefinitionId
            BaselineVersion = $null
            AssignmentVersion = $extra.Version
            BaselineSources = $null
        }
    }
} else {
    Write-Debug " Extra policies excluded (ExcludeExtraPolicies=$true)"
}
                    
                    foreach ($versionDiff in $comparisonResult.VersionDiffs) {
                        $allVersionMismatches += [PSCustomObject]@{
                            AssignmentDisplayName = $assignment.AssignmentDisplayName
                            AssignmentName = $assignment.AssignmentName
                            DifferenceType = "VersionMismatch"
                            PolicyDisplayName = $versionDiff.PolicyDisplayName
                            PolicyDefinitionId = $versionDiff.PolicyDefinitionId
                            BaselineVersion = $versionDiff.BaselineVersion
                            AssignmentVersion = $versionDiff.AssignmentVersion
                            BaselineSources = $versionDiff.BaselineSources
                        }
                    }
                    
                } catch {
                    Write-Warning "Failed to compare assignment '$($assignment.AssignmentDisplayName)': $($_.Exception.Message)"
                    continue
                }
            }

            # Calculate globally missing policies (in baseline but not deployed anywhere)
            Write-Verbose "Calculating globally missing policies..."
            $allDeployedPolicyIds = $allInCommon | Select-Object -ExpandProperty PolicyDefinitionId -Unique

            $globalMissing = $Baseline.Policies | Where-Object {
                $policyId = $_.PolicyDefinitionId
                $allDeployedPolicyIds -notcontains $policyId
            }

            $allMissing = foreach ($missing in $globalMissing) {
                [PSCustomObject]@{
                    AssignmentDisplayName = "N/A (Not deployed)"
                    AssignmentName = "N/A"
                    DifferenceType = "MissingFromAllAssignments"
                    PolicyDisplayName = $missing.PolicyDisplayName
                    PolicyDefinitionId = $missing.PolicyDefinitionId
                    BaselineVersion = $missing.Version
                    AssignmentVersion = $null
                    BaselineSources = $missing.BaselineSources
                }
            }
            
            Write-Progress -Activity "Comparing Policy Assignments" -Completed
            
            Write-Verbose "Comparison complete: $($summary.Count) assignments analyzed"
            Write-Verbose " Total missing: $($allMissing.Count)"
            Write-Verbose " Total extra: $($allExtra.Count)"
            Write-Verbose " Total version mismatches: $($allVersionMismatches.Count)"
            
            # Step 2: Calculate compliance scores
            Write-Verbose "Calculating compliance scores..."
            
            $scores = $null
            $metrics = $null
            
            if ($InitiativesFolder -and (Test-Path $InitiativesFolder)) {
                try {
                    $scoreResult = Calculate-ComplianceScores -Baseline $Baseline.Policies `
                                                              -Summary $summary `
                                                              -InitiativesFolder $InitiativesFolder `
                                                              -PolicyDefinitionCache $script:policyDefinitionCache
                    
                    $scores = [PSCustomObject]@{
                        AlzScore = $scoreResult.AlzScore
                        McsbScore = $scoreResult.McsbScore
                        GlobalScore = $scoreResult.GlobalScore
                    }
                    
                    $metrics = [PSCustomObject]@{
                        AlzMetrics = $scoreResult.AlzMetrics
                        McsbMetrics = $scoreResult.McsbMetrics
                        GlobalMetrics = $scoreResult.GlobalMetrics
                    }
                    
                    Write-Verbose " ALZ Score: $($scores.AlzScore)%"
                    Write-Verbose " MCSB Score: $($scores.McsbScore)%"
                    Write-Verbose " Global Score: $($scores.GlobalScore)%"
                    
                } catch {
                    Write-Warning "Failed to calculate compliance scores: $($_.Exception.Message)"
                    # Return null scores
                }
            } else {
                Write-Verbose " InitiativesFolder not specified or does not exist. Skipping score calculation."
            }
            
            # Step 3: Build result object
            $result = [PSCustomObject]@{
                Summary = $summary
                Details = [PSCustomObject]@{
                    InCommon = $allInCommon
                    Missing = $allMissing
                    Extra = $allExtra
                    VersionMismatches = $allVersionMismatches
                }
                Scores = $scores
                Metrics = $metrics
            }
            
            Write-Debug "Compare-AzPolicyCompliance: Completed successfully"
            return $result
            
        } catch {
            Write-Error "Failed to compare policy compliance: $($_.Exception.Message)"
            throw
        }
    }
}