internal/functions/Build-HydrationPolicySetPlan.ps1

function Build-HydrationPolicySetPlan {
    [CmdletBinding()]
    param (
        [string] $DefinitionsRootFolder,
        [hashtable] $PacEnvironment,
        [hashtable] $DeployedDefinitions,
        [hashtable] $Definitions,
        [hashtable] $AllDefinitions,
        [hashtable] $ReplaceDefinitions,
        [hashtable] $PolicyRoleIds,
        [System.Collections.Specialized.OrderedDictionary] $DetailedRecord,
        [switch] $ExtendedReporting
    )

    Write-Information "==================================================================================================="
    Write-Information "Processing Policy Set JSON files in folder '$DefinitionsRootFolder'"
    Write-Information "==================================================================================================="

    if($ExtendedReporting){
        $allPolicySetRecords = [ordered]@{}
        $rRoot = (Resolve-Path (Split-Path (Split-Path $DefinitionsRootFolder))).Path
    }

    # Process Policy Set JSON files if any
    $definitionFiles = @()
    $definitionFiles += Get-ChildItem -Path $DefinitionsRootFolder -Recurse -File -Filter "*.json"
    $definitionFiles += Get-ChildItem -Path $DefinitionsRootFolder -Recurse -File -Filter "*.jsonc"
    if ($definitionFiles.Length -gt 0) {
        Write-Information "Number of Policy Set files = $($definitionFiles.Length)"
    }
    else {
        Write-Warning "No Policy Set files found! Deleting any custom Policy Set definitions."
    }

    $managedDefinitions = $DeployedDefinitions.managed
    $deleteCandidates = $managedDefinitions.Clone()
    $deploymentRootScope = $PacEnvironment.deploymentRootScope
    $policyDefinitionsScopes = $PacEnvironment.policyDefinitionsScopes
    $duplicateDefinitionTracking = @{}
    $thisPacOwnerId = $PacEnvironment.pacOwnerId

    foreach ($file in $definitionFiles) {
        $Json = Get-Content -Path $file.FullName -Raw -ErrorAction Stop
        $definitionObject = $null
        try {
            $definitionObject = $Json | ConvertFrom-Json -Depth 100
        }
        catch {
            Write-Error "PolicySet JSON file '$($file.Name)' is not valid." -ErrorAction Stop
        }
        if($ExtendedReporting){
            Remove-Variable fileRecord -ErrorAction SilentlyContinue
            $fileRecord = Get-DeepCloneAsOrderedHashtable -InputObject $DetailedRecord
            $relativePath = -join(".",$file.FullName.Substring(($rRoot).Length))
        }
        Remove-Variable definitionProperties -ErrorAction SilentlyContinue
        $definitionProperties = Get-PolicyResourceProperties -PolicyResource $definitionObject
        $name = $definitionObject.name
        $id = "$deploymentRootScope/providers/Microsoft.Authorization/policySetDefinitions/$name"
        $displayName = $definitionProperties.displayName
        $description = $definitionProperties.description
        Remove-Variable metadata -ErrorAction SilentlyContinue
        $metadata = Get-DeepCloneAsOrderedHashtable $definitionProperties.metadata
        $parameters = $definitionProperties.parameters
        $policyDefinitions = $definitionProperties.policyDefinitions
        $policyDefinitionGroups = $definitionProperties.policyDefinitionGroups
        $importPolicyDefinitionGroups = $definitionProperties.importPolicyDefinitionGroups
        if ($metadata) {
            $metadata.pacOwnerId = $thisPacOwnerId
        }
        else {
            $metadata = @{ pacOwnerId = $thisPacOwnerId }
        }
        if ($metadata.epacCloudEnvironments) {
            if ($pacEnvironment.cloud -notIn $metadata.epacCloudEnvironments) {
                #Need to come back and add this file to deleteCandidates
                continue
            }
        }
        if (!$metadata.ContainsKey("deployedBy")) {
            $metadata.deployedBy = $PacEnvironment.deployedBy
        }

        # Core syntax error checking
        if ($null -eq $name) {
            Write-Error "Policy Set from file '$($file.Name)' requires a name" -ErrorAction Stop
        }
        if (-not (Confirm-ValidPolicyResourceName -Name $name)) {
            Write-Error "Policy Set from file '$($file.Name) has a name '$name' containing invalid characters <>*%&:?.+/ or ends with a space." -ErrorAction Stop
        }
        if ($null -eq $displayName) {
            Write-Error "Policy Set '$name' from file '$($file.Name)' requires a displayName" -ErrorAction Stop
        }
        if ($null -eq $policyDefinitions) {
            Write-Error "Policy Set '$displayName' from file '$($file.Name)' requires a policyDefinitions entry; it is null. Did you misspell policyDefinitions (it is case sensitive)?" -ErrorAction Stop
        }
        elseif ($policyDefinitions -isnot [System.Collections.IList]) {
            Write-Error "Policy Set '$displayName' from file '$($file.Name)' requires a policyDefinitions array; it is not an array." -ErrorAction Stop
        }
        elseif ($policyDefinitions.Count -eq 0) {
            Write-Error "Policy Set '$displayName' from file '$($file.Name)' requires a policyDefinitions array with at least one entry; it has zero entries." -ErrorAction Stop
        }
        if ($duplicateDefinitionTracking.ContainsKey($id)) {
            Write-Error "Duplicate Policy Set with name '$($name)' in '$($duplicateDefinitionTracking[$id])' and '$($file.FullName)'" -ErrorAction Stop
        }
        else {
            $null = $duplicateDefinitionTracking.Add($id, $file.FullName)
        }

        # Calculate included policyDefinitions
        Remove-Variable validPolicyDefinitions -ErrorAction SilentlyContinue
        Remove-Variable policyDefinitionsFinal -ErrorAction SilentlyContinue
        Remove-Variable policyRoleIdsInSet -ErrorAction SilentlyContinue
        Remove-Variable usedPolicyGroupDefinitions -ErrorAction SilentlyContinue
        $validPolicyDefinitions, $policyDefinitionsFinal, $policyRoleIdsInSet, $usedPolicyGroupDefinitions = Build-PolicySetPolicyDefinitionIds `
            -DisplayName $displayName `
            -PolicyDefinitions $policyDefinitions `
            -PolicyDefinitionsScopes $policyDefinitionsScopes `
            -AllDefinitions $AllDefinitions.policydefinitions `
            -PolicyRoleIds $PolicyRoleIds
        $policyDefinitions = $policyDefinitionsFinal.ToArray()
        if ($policyRoleIdsInSet.psbase.Count -gt 0) {
            $null = $PolicyRoleIds.Add($id, $policyRoleIdsInSet.Keys)
        }


        # Process policyDefinitionGroups
        $policyDefinitionGroupsHashTable = @{}
        if ($null -ne $policyDefinitionGroups) {
            # Check for group defined as policyDefinitionGroups but not used in policies and add them to a new object
            # Add each group to the object as Azure allows non used groups
            $policyDefinitionGroups | ForEach-Object {
                $policyDefinitionGroupsHashTable.Add($_.name, $_)
            }
            # Now check each used group defined by policyDefinitions to make sure that it exists in the policyDefinitionGroups as this causes an error when deploying
            $usedPolicyGroupDefinitions.Keys | ForEach-Object {
                if (!$policyDefinitionGroupsHashTable.ContainsKey($_)) {
                    Write-Error "$($displayName): PolicyDefinitionGroup '$_' not found in policyDefinitionGroups." -ErrorAction Stop
                }
            }
        }

        # Importing policyDefinitionGroups from built-in PolicySets?
        if ($null -ne $importPolicyDefinitionGroups) {
            $limitReachedPolicyDefinitionGroups = $false

            # Trying to import missing policyDefinitionGroups entries
            foreach ($importPolicyDefinitionGroup in $importPolicyDefinitionGroups) {
                if ($usedPolicyGroupDefinitions.psbase.Count -eq 0 -or $limitReachedPolicyDefinitionGroups) {
                    break
                }
                $importPolicySetId = $importPolicyDefinitionGroup
                if (!($importPolicyDefinitionGroup.StartsWith("/providers/Microsoft.Authorization/policySetDefinitions/", [System.StringComparison]::OrdinalIgnoreCase))) {
                    $importPolicySetId = "/providers/Microsoft.Authorization/policySetDefinitions/$importPolicyDefinitionGroup"
                }
                if (!($DeployedDefinitions.readOnly.ContainsKey($importPolicySetId))) {
                    Write-Error "$($displayName): Policy Set '$importPolicySetId' for group name import not found." -ErrorAction Stop
                }
                $importedPolicySetDefinition = $DeployedDefinitions.readOnly[$importPolicySetId]
                $importedPolicyDefinitionGroups = $importedPolicySetDefinition.properties.policyDefinitionGroups
                if ($null -ne $importedPolicyDefinitionGroups -and $importedPolicyDefinitionGroups.Count -gt 0) {
                    # Write-Information "$($displayName): Importing PolicyDefinitionGroups from '$($importedPolicySetDefinition.displayName)'"
                    foreach ($importedPolicyDefinitionGroup in $importedPolicyDefinitionGroups) {
                        $groupName = $importedPolicyDefinitionGroup.name
                        if ($usedPolicyGroupDefinitions.ContainsKey($groupName)) {
                            $usedPolicyGroupDefinitions.Remove($groupName)
                            $policyDefinitionGroupsHashTable.Add($groupName, $importedPolicyDefinitionGroup)
                            if ($policyDefinitionGroupsHashTable.psbase.Count -ge 1000) {
                                $limitReachedPolicyDefinitionGroups = $true
                                if ($usedPolicyGroupDefinitions.psbase.Count -gt 0) {
                                    Write-Warning "$($displayName): Too many PolicyDefinitionGroups (1000+) - ignore remaining imports."
                                }
                                break
                            }
                        }
                    }
                    # Write-Information "$($displayName): Imported $($policyDefinitionGroupsHashTable.psbase.psbase.Count) PolicyDefinitionGroups from '$($importedPolicySetDefinition.displayName)'."
                }
                else {
                    Write-Error "$($displayName): Policy Set $($importedPolicySet.displayName) does not contain PolicyDefinitionGroups to import." -ErrorAction Stop
                }
            }
        }
        $policyDefinitionGroupsFinal = $null
        if ($policyDefinitionGroupsHashTable.Count -gt 0) {
            $policyDefinitionGroupsFinal = @() + ($policyDefinitionGroupsHashTable.Values | Sort-Object -Property "name")
        }

        if (!$validPolicyDefinitions) {
            Write-Error "$($displayName): One or more invalid Policy entries referenced in Policy Set '$($displayName)' from '$($file.Name)'." -ErrorAction Stop
        }

        # Constructing Policy Set parameters for splatting
        $definition = @{
            id                     = $id
            name                   = $name
            scopeId                = $deploymentRootScope
            displayName            = $displayName
            description            = $description
            metadata               = $metadata
            parameters             = $parameters
            policyDefinitions      = $policyDefinitionsFinal
            policyDefinitionGroups = $policyDefinitionGroupsFinal
        }
        # Remove-NullFields $definition
        $AllDefinitions.policysetdefinitions[$id] = $definition

        if ($managedDefinitions.ContainsKey($id)) {
            # Update or replace scenarios
            Remove-Variable deployedDefinition -ErrorAction SilentlyContinue
            $deployedDefinition = $managedDefinitions[$id]
            $deployedDefinition = Get-PolicyResourceProperties -PolicyResource $deployedDefinition

            # Remove defined Policy Set entry from deleted hashtable (the hashtable originally contains all custom Policy Sets in the scope)
            $null = $deleteCandidates.Remove($id)

            # Check if Policy Set in Azure is the same as in the JSON file
            $displayNameMatches = $deployedDefinition.displayName -eq $displayName
            $descriptionMatches = $deployedDefinition.description -eq $description
            Remove-Variable metadataMatches -ErrorAction SilentlyContinue
            Remove-Variable changePacOwnerId -ErrorAction SilentlyContinue
            $metadataMatches, $changePacOwnerId = Confirm-MetadataMatches `
                -ExistingMetadataObj $deployedDefinition.metadata `
                -DefinedMetadataObj $metadata
            Remove-Variable parametersMatch -ErrorAction SilentlyContinue
            Remove-Variable incompatible -ErrorAction SilentlyContinue
            $parametersMatch, $incompatible = Confirm-ParametersDefinitionMatch `
                -ExistingParametersObj $deployedDefinition.parameters `
                -DefinedParametersObj $parameters
            Remove-Variable policyDefinitionsMatch -ErrorAction SilentlyContinue
            $policyDefinitionsMatch = Confirm-PolicyDefinitionsInPolicySetMatch `
                $deployedDefinition.policyDefinitions `
                $policyDefinitionsFinal
            Remove-Variable policyDefinitionGroupsMatch -ErrorAction SilentlyContinue
            $policyDefinitionGroupsMatch = Confirm-ObjectValueEqualityDeep `
                $deployedDefinition.policyDefinitionGroups `
                $policyDefinitionGroupsFinal
            $deletedPolicyDefinitionGroups = !$policyDefinitionGroupsMatch -and ($null -eq $policyDefinitionGroupsFinal -or $policyDefinitionGroupsFinal.Length -eq 0)            
            # Update Policy Set in Azure if necessary
            $containsReplacedPolicy = $false
            $replacedPolicyList = @()
            foreach ($policyDefinitionEntry in $policyDefinitionsFinal) {
                $policyId = $policyDefinitionEntry.policyDefinitionId
                if ($ReplaceDefinitions.ContainsKey($policyId)) {
                    $containsReplacedPolicy = $true
                    if(!$ExtendedReporting){
                        break
                    }
                    else{
                        # Capture full list of replaced policies for ExtendedReporting
                        $replacedPolicyList += $policyId
                    }
                }
            }
            if (!$containsReplacedPolicy -and $displayNameMatches -and $descriptionMatches -and $metadataMatches -and !$changePacOwnerId -and $parametersMatch -and $policyDefinitionsMatch -and $policyDefinitionGroupsMatch) {
                # Write-Information "Unchanged '$($displayName)'"
                $Definitions.numberUnchanged++
            }
            else {
                $Definitions.numberOfChanges++
                $changesStrings = @()
                if ($incompatible) {
                    $changesStrings += "paramIncompat"
                }
                if ($containsReplacedPolicy) {
                    $changesStrings += "replacedPolicy"
                }
                if (!$displayNameMatches) {
                    $changesStrings += "displayName"
                }
                if (!$descriptionMatches) {
                    $changesStrings += "description"
                }
                if ($changePacOwnerId) {
                    $changesStrings += "owner"
                }
                if (!$metadataMatches) {
                    $changesStrings += "metadata"
                }
                if (!$parametersMatch -and !$incompatible) {
                    $changesStrings += "param"
                }
                if (!$policyDefinitionsMatch) {
                    $changesStrings += "policies"
                }
                if (!$policyDefinitionGroupsMatch) {
                    if ($deletedPolicyDefinitionGroups) {
                        $changesStrings += "groupsDeleted"
                    }
                    else {
                        $changesStrings += "groups"
                    }
                }
                $changesString = $changesStrings -join ","

                if ($incompatible -or $containsReplacedPolicy) {
                    # Check if parameters are compatible with an update or id the set includes at least one Policy which is being replaced.
                    Write-Information "Replace ($changesString) '$($displayName)'"
                    $null = $Definitions.replace.Add($id, $definition)
                    $null = $ReplaceDefinitions.Add($id, $definition)
                }
                else {
                    Write-Information "Update ($changesString) '$($displayName)'"
                    $null = $Definitions.update.Add($id, $definition)
                }
                if($ExtendedReporting){
                    # Define Changed Object Data for ExtendedReporting
                    # Populate Data Fields
                    $fileRecord.Set_Item('name', $name)
                    $fileRecord.Set_Item('definitionType', 'policySet')
                    $fileRecord.Set_Item('id', $id)
                    $fileRecord.Set_Item('changes', $changesString)
                    $fileRecord.Set_Item('changeList', $changesStrings)
                    $fileRecord.Set_Item('fileRelativePath', $relativePath)
                    if ($incompatible) {
                        $fileRecord.Set_Item('parametersChanged', $incompatible)
                        $fileRecord.Set_Item('oldParameters', $deployedDefinition.parameters)
                        $fileRecord.Set_Item('newParameters', $parameters)
                    }
                    if($containsReplacedPolicy){
                        $fileRecord.Set_Item('replacedPolicy', $containsReplacedPolicy)
                        $fileRecord.Set_Item('replacedPolicyList', $replacedPolicyList)
                    }
                    if (!$policyDefinitionsMatch) {
                        $fileRecord.Set_Item('updatedMemberPolicyDefinitions',!($policyDefinitionsMatch))
                        $fileRecord.Set_Item('oldPolicyDefinitions', "Review replacedPolicyList contents for a list of definitions to review specific changes, review current deployed definition for policySet in Azure.")
                        $fileRecord.Set_Item('newPolicyDefinitions', "Review replacedPolicyList contents for a list of definitions to review specific changes, review definition file for policySet in repo.")
                    }
                    if (!$displayNameMatches) {
                        $fileRecord.Set_Item('displayNameChanged', (!($displayNameMatches)))
                        $fileRecord.Set_Item('oldDisplayName', $deployedDefinition.displayName)
                        $fileRecord.Set_Item('newDisplayName', $displayName)
                    }
                    if (!$descriptionMatches) {
                        $fileRecord.Set_Item('descriptionChanged', !($descriptionMatches))
                        $fileRecord.Set_Item('oldDescription', $deployedDefinition.description)
                        $fileRecord.Set_Item('newDescription', $description)
                    }
                    if ($changePacOwnerId) {
                        $fileRecord.Set_Item('ownerChanged', $changePacOwnerId)
                        $fileRecord.Set_Item('oldOwner', $deployedDefinition.metadata.pacOwnerId)
                        $fileRecord.Set_Item('newOwner', $metadata.pacOwnerId)
                    }
                    if (!$metadataMatches) {
                        $fileRecord.Set_Item('metadataChanged', !($metadataMatches))
                        $fileRecord.Set_Item('oldMetadata', $deployedDefinition.metadata)
                        $fileRecord.Set_Item('newMetadata', $metadata)
                    }
                    if (!$parametersMatch -and !$incompatible) { # I don't think this is really useful, we don't test on it anywhere, we test on incompatible... which appears to be the same intended outcome.
                        $fileRecord.Set_Item('parametersChanged', !($parametersMatch))
                        $fileRecord.Set_Item('oldParameters', $deployedDefinition.parameters)
                        $fileRecord.Set_Item('newParameters', $parameters)
                    }
                    if (!$policyDefinitionGroupsMatch) {
                        if ($deletedPolicyDefinitionGroups) {
                            $fileRecord.Set_Item('deletedPolicyDefinitionGroups', $deletedPolicyDefinitionGroups)
                        }
                        $fileRecord.Set_Item('oldPolicyDefinitionGroups', $deployedDefinition.policyDefinitionGroups)
                        $fileRecord.Set_Item('newPolicyDefinitionGroups', $policyDefinitionGroupsFinal)
                    }
                    # Update evaluationResult
                    if ($incompatible -or $containsReplacedPolicy) {
                        # Check if parameters are compatible with an update or id the set includes at least one Policy which is being replaced.
                        $fileRecord.Set_Item('evaluationResult', 'replace')
                    }
                    else {
                        $fileRecord.Set_Item('evaluationResult', 'update')
                    }
                    $allPolicySetRecords.add($(@($relativePath,$fileRecord.id) -join "_"),$fileRecord)                    
                }
            }
        }
        else {
            Write-Information "New '$($displayName)'"
            $null = $Definitions.new.Add($id, $definition)
            $Definitions.numberOfChanges++
            if($ExtendedReporting){
                $fileRecord.Set_Item('name', $name)
                $fileRecord.Set_Item('definitionType', 'policySet')
                $fileRecord.Set_Item('evaluationResult', 'new')
                $fileRecord.Set_Item('id', $id)
                $fileRecord.Set_Item('fileRelativePath', $relativePath)
                $allPolicySetRecords.add($(@($relativePath,$fileRecord.id) -join "_"),$fileRecord)
            }
        }
    }

    $strategy = $PacEnvironment.desiredState.strategy
    foreach ($id in $deleteCandidates.Keys) {
        $deleteCandidate = $deleteCandidates.$id
        $deleteCandidateProperties = Get-PolicyResourceProperties $deleteCandidate
        $displayName = $deleteCandidateProperties.displayName
        $pacOwner = $deleteCandidate.pacOwner
        $shallDelete = Confirm-DeleteForStrategy -PacOwner $pacOwner -Strategy $strategy
        if ($shallDelete) {
            # always delete if owned by this Policy as Code solution
            # never delete if owned by another Policy as Code solution
            # if strategy is "full", delete with unknown owner (missing pacOwnerId)
            Write-Information "Delete '$($deleteCandidateProperties.displayName)'"
            $splat = @{
                id          = $id
                name        = $deleteCandidate.name
                scopeId     = $deploymentRootScope
                displayName = $displayName
            }
            $null = $Definitions.delete.Add($id, $splat)
            $Definitions.numberOfChanges++
            if ($AllDefinitions.policydefinitions.ContainsKey($id)) {
                # should always be true
                $null = $AllDefinitions.policydefinitions.Remove($id)
            }
            if($ExtendedReporting){
                # Add record for any items that remain that will be deleted
                Remove-Variable detailRecord -ErrorAction SilentlyContinue
                $detailRecord = Get-DeepCloneAsOrderedHashtable -InputObject $DetailedRecord
                $detailRecord.Set_Item('name', $name)
                $detailRecord.Set_Item('id', $id)
                $detailRecord.Set_Item('evaluationResult', 'delete')
                $detailRecord.Set_Item('fileRelativePath', "n/a")
                $detailRecord.Set_Item('definitionType', 'policySet')
                $detailRecord.Set_Item('fileRelativePath', 'noPolicySetFile')
                $allPolicySetRecords.add($(@($relativePath,$detailRecord.id) -join "_"),$detailRecord)

            }
        }
        else {
            if ($VerbosePreference -eq "Continue") {
                Write-Information "No delete($pacOwner,$strategy) '$($displayName)'"
            }
            if($ExtendedReporting){
                # Add record for any items that remain that will not be deleted
                Remove-Variable detailRecord -ErrorAction SilentlyContinue
                $detailRecord = Get-DeepCloneAsOrderedHashtable -InputObject $DetailedRecord
                $detailRecord.Set_Item('name', $name)
                $detailRecord.Set_Item('id', $id)
                $detailRecord.Set_Item('evaluationResult', 'outOfScope-notFullDesiredState')
                $detailRecord.Set_Item('fileRelativePath', "n/a")
                $detailRecord.Set_Item('definitionType', 'policySet')
                $detailRecord.Set_Item('fileRelativePath', 'noPolicySetFile')
                $allPolicySetRecords.add($(@("NoPolicySetFile",$detailRecord.id) -join "_"),$detailRecord)
            }
        }
    }
    foreach($pSetRec in $allPolicySetRecords.keys){
        $detailedRecordList.Add($pSetRec,$allPolicySetRecords.$pSetRec)
    }
    Write-Information "Number of unchanged Policy SetPolicy Sets definition = $($Definitions.numberUnchanged)"
    Write-Information ""
}