DomainManagement.psm1

$script:ModuleRoot = $PSScriptRoot
$script:ModuleVersion = (Import-PowerShellDataFile -Path "$($script:ModuleRoot)\DomainManagement.psd1").ModuleVersion

# Detect whether at some level dotsourcing was enforced
$script:doDotSource = Get-PSFConfigValue -FullName DomainManagement.Import.DoDotSource -Fallback $false
if ($DomainManagement_dotsourcemodule) { $script:doDotSource = $true }

<#
Note on Resolve-Path:
All paths are sent through Resolve-Path/Resolve-PSFPath in order to convert them to the correct path separator.
This allows ignoring path separators throughout the import sequence, which could otherwise cause trouble depending on OS.
Resolve-Path can only be used for paths that already exist, Resolve-PSFPath can accept that the last leaf my not exist.
This is important when testing for paths.
#>


# Detect whether at some level loading individual module files, rather than the compiled module was enforced
$importIndividualFiles = Get-PSFConfigValue -FullName DomainManagement.Import.IndividualFiles -Fallback $false
if ($DomainManagement_importIndividualFiles) { $importIndividualFiles = $true }
if (Test-Path (Resolve-PSFPath -Path "$($script:ModuleRoot)\..\.git" -SingleItem -NewChild)) { $importIndividualFiles = $true }
if ("<was compiled>" -eq '<was not compiled>') { $importIndividualFiles = $true }
    
function Import-ModuleFile
{
    <#
        .SYNOPSIS
            Loads files into the module on module import.
         
        .DESCRIPTION
            This helper function is used during module initialization.
            It should always be dotsourced itself, in order to proper function.
             
            This provides a central location to react to files being imported, if later desired
         
        .PARAMETER Path
            The path to the file to load
         
        .EXAMPLE
            PS C:\> . Import-ModuleFile -File $function.FullName
     
            Imports the file stored in $function according to import policy
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Path
    )
    
    $resolvedPath = $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($Path).ProviderPath
    if ($doDotSource) { . $resolvedPath }
    else { $ExecutionContext.InvokeCommand.InvokeScript($false, ([scriptblock]::Create([io.file]::ReadAllText($resolvedPath))), $null, $null) }
}

#region Load individual files
if ($importIndividualFiles)
{
    # Execute Preimport actions
    . Import-ModuleFile -Path "$ModuleRoot\internal\scripts\preimport.ps1"
    
    # Import all internal functions
    foreach ($function in (Get-ChildItem "$ModuleRoot\internal\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore))
    {
        . Import-ModuleFile -Path $function.FullName
    }
    
    # Import all public functions
    foreach ($function in (Get-ChildItem "$ModuleRoot\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore))
    {
        . Import-ModuleFile -Path $function.FullName
    }
    
    # Execute Postimport actions
    . Import-ModuleFile -Path "$ModuleRoot\internal\scripts\postimport.ps1"
    
    # End it here, do not load compiled code below
    return
}
#endregion Load individual files

#region Load compiled code
<#
This file loads the strings documents from the respective language folders.
This allows localizing messages and errors.
Load psd1 language files for each language you wish to support.
Partial translations are acceptable - when missing a current language message,
it will fallback to English or another available language.
#>

Import-PSFLocalizedString -Path "$($script:ModuleRoot)\en-us\*.psd1" -Module 'DomainManagement' -Language 'en-US'

function Assert-ADConnection
{
    <#
    .SYNOPSIS
        Ensures connection to AD is possible before performing actions.
     
    .DESCRIPTION
        Ensures connection to AD is possible before performing actions.
        Should be the first things all commands connecting to AD should call.
        Do this before invoking callbacks, as the configuration change becomes pointless if the forest is unavailable to begin with,
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER Cmdlet
        The $PSCmdlet variable of the calling command.
        Used to safely terminate the calling command in case of failure.
     
    .EXAMPLE
        PS C:\> Assert-ADConnection @parameters -Cmdlet $PSCmdlet
 
        Kills the calling command if AD is not available.
    #>

    [CmdletBinding()]
    Param (
        [PSFComputer]
        $Server,

        [PSCredential]
        $Credential,

        [Parameter(Mandatory = $true)]
        [System.Management.Automation.PSCmdlet]
        $Cmdlet
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
    }
    process
    {
        # A domain being unable to retrieve its own object can really only happen if the service is down
        try { $null = Get-ADDomain @parameters -ErrorAction Stop }
        catch {
            Write-PSFMessage -Level Warning -String 'Assert-ADConnection.Failed' -StringValues $Server -Tag 'failed' -ErrorRecord $_
            $Cmdlet.ThrowTerminatingError($_)
        }
    }
}


function Assert-Configuration
{
    <#
    .SYNOPSIS
        Ensures a set of configuration settings has been provided for the specified setting type.
     
    .DESCRIPTION
        Ensures a set of configuration settings has been provided for the specified setting type.
        This maps to the configuration variables defined in variables.ps1
        Note: Not ALL variables defined in that file should be mapped, only those storing individual configuration settings!
     
    .PARAMETER Type
        The setting type to assert.
 
    .PARAMETER Cmdlet
        The $PSCmdlet variable of the calling command.
        Used to safely terminate the calling command in case of failure.
     
    .EXAMPLE
        PS C:\> Assert-Configuration -Type Users
 
        Asserts, that users have already been specified.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string[]]
        $Type,

        [Parameter(Mandatory = $true)]
        [System.Management.Automation.PSCmdlet]
        $Cmdlet
    )
    
    process
    {
        foreach ($typeName in $type) {
            if ((Get-Variable -Name $typeName -Scope Script -ValueOnly -ErrorAction SilentlyContinue).Count -gt 0) { return }
        }
        
        Write-PSFMessage -Level Warning -String 'Assert-Configuration.NotConfigured' -StringValues ($Type -join ", ") -FunctionName $Cmdlet.CommandRuntime

        $exception = New-Object System.Data.DataException("No configuration data provided for: $($Type -join ", ")")
        $errorID = 'NotConfigured'
        $category = [System.Management.Automation.ErrorCategory]::NotSpecified
        $recordObject = New-Object System.Management.Automation.ErrorRecord($exception, $errorID, $category, ($Type -join ", "))
        $cmdlet.ThrowTerminatingError($recordObject)
    }
}


function Compare-Array
{
    <#
    .SYNOPSIS
        Compares two arrays.
     
    .DESCRIPTION
        Compares two arrays.
     
    .PARAMETER ReferenceObject
        The first array to compare with the second array.
     
    .PARAMETER DifferenceObject
        The second array to compare with the first array.
     
    .PARAMETER OrderSpecific
        Makes the comparison order specific.
        By default, the command does not care for the order the objects are stored in.
     
    .PARAMETER Quiet
        Rather than returning a delta report object, return a single truth statement:
        - $true if the two arrays are equal
        - $false if the two arrays are NOT equal.
     
    .EXAMPLE
        PS C:\> Compare-Array -ReferenceObject $currentStateSorted.DisplayName -DifferenceObject $desiredStateSorted.PolicyName -Quiet -OrderSpecific
 
        Compares the two sets of names, and returns ...
        - $true if both sets contains the same names in the same order
        - $false if they do not
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "")]
    [CmdletBinding()]
    Param (
        [object[]]
        $ReferenceObject,

        [object[]]
        $DifferenceObject,

        [switch]
        $OrderSpecific,

        [switch]
        $Quiet
    )
    
    process
    {
        # Not as default value to avoid null-bind dilemma
        if (-not $ReferenceObject) { $ReferenceObject = @() }
        if (-not $DifferenceObject) { $DifferenceObject = @() }

        #region Not Order Specific
        if (-not $OrderSpecific) {
            $delta = Compare-Object -ReferenceObject $ReferenceObject -DifferenceObject $DifferenceObject
            if ($delta) {
                if ($Quiet) { return $false }
                [PSCustomObject]@{
                    ReferenceObject = $ReferenceObject
                    DifferenceObject = $DifferenceObject
                    Delta = $delta
                    IsEqual = $false
                }
                return
            }
            else {
                if ($Quiet) { return $true }
                [PSCustomObject]@{
                    ReferenceObject = $ReferenceObject
                    DifferenceObject = $DifferenceObject
                    Delta = $delta
                    IsEqual = $true
                }
                return
            }
        }
        #endregion Not Order Specific

        #region Order Specific
        else {
            if ($Quiet -and ($ReferenceObject.Count -ne $DifferenceObject.Count)) { return $false }
            $result = [PSCustomObject]@{
                ReferenceObject = $ReferenceObject
                DifferenceObject = $DifferenceObject
                Delta = @()
                IsEqual = $true
            }
            
            $maxCount = [math]::Max($ReferenceObject.Count, $DifferenceObject.Count)
            [System.Collections.ArrayList]$indexes = @()

            foreach ($number in (0..($maxCount - 1))) {
                if ($number -ge $ReferenceObject.Count) {
                    $null = $indexes.Add($number)
                    continue
                }
                if ($number -ge $DifferenceObject.Count) {
                    $null = $indexes.Add($number)
                    continue
                }
                if ($ReferenceObject[$number] -ne $DifferenceObject[$number]) {
                    if ($Quiet) { return $false }
                    $null = $indexes.Add($number)
                    continue
                }
            }

            if ($indexes.Count -gt 0) {
                $result.IsEqual = $false
                $result.Delta = $indexes.ToArray()
            }

            $result
        }
        #endregion Order Specific
    }
}


function Compare-ObjectProperty {
    <#
        .SYNOPSIS
            Compares whether the input item is contained in the list of reference items.
 
        .DESCRIPTION
            Compares whether the input item is contained in the list of reference items.
            For this comparison, we use the defined propertynames.
            The input object is only returned, if there is at least one object with the same values for the specified properties.
 
        .PARAMETER ReferenceObject
            The list of objects the input is compared to.
 
        .PARAMETER PropertyName
            The list of properties used to establish the equality comparison.
 
        .PARAMETER DifferenceObject
            The input objects that are compared to the list in -ReferenceObject and only returned if at least one match exists.
 
        .EXAMPLE
            PS C:\> $_ | Compare-ObjectProperty -ReferenceObject $ADRules -PropertyName Identity, Permission, Allow
 
            Compares the current item ($_) with the content of $ADRules whether a match exists that shares all of Identity, Permission and Allow.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Position = 0)]
        [PSObject[]]
        $ReferenceObject,
        
        [Parameter(Position = 1)]
        [PSFramework.Parameter.SelectParameter[]]
        $PropertyName,

        [Parameter(ValueFromPipeline = $true)]
        [PSObject[]]
        $DifferenceObject
    )
    begin {
        $comparer = $ReferenceObject | Select-PSFObject $PropertyName
        $select = { Select-PSFObject $PropertyName }.GetSteppablePipeline()
        $select.Begin($true)
        $properties = $PropertyName | ForEach-Object {
            if ($_.Value -is [string]) { return $_.Value }
            else { $_.Value.Name }
        } | Remove-PSFNull
    }
    process {
        :dif foreach ($inputObject in $DifferenceObject) {
            $inputConverted = $select.Process($inputObject)
            :ref foreach ($reference in $comparer) {
                foreach ($property in $properties) {
                    if ($reference.$property -ne $inputConverted.$property) { continue ref }
                }
                $inputObject
                continue dif
            }
        }
    }
    end {
        $select.End()
    }
}

function Compare-Property
{
<#
    .SYNOPSIS
        Helper function simplifying the changes processing.
     
    .DESCRIPTION
        Helper function simplifying the changes processing.
     
    .PARAMETER Property
        The property to use for comparison.
     
    .PARAMETER Configuration
        The object that was used to define the desired state.
     
    .PARAMETER ADObject
        The AD Object containing the actual state.
     
    .PARAMETER Changes
        An arraylist where changes get added to.
        The content of -Property will be added if the comparison fails.
     
    .PARAMETER Resolve
        Whether the value on the configured object's property should be string-resolved.
     
    .PARAMETER ADProperty
        The property on the ad object to use for the comparison.
        If this parameter is not specified, it uses the value from -Property.
     
    .PARAMETER Parameters
        AD Parameters to pass through for Resolve-String.
     
    .PARAMETER AsString
        Compare properties as string.
        Will convert all $null values to "".
 
    .PARAMETER AsUpdate
        The result added to the changes arraylist is a custom object with greater details than the default.
 
    .PARAMETER Type
        What kind of component is the compared object part of.
        Used together with the -AsUpdate parameter to name the resulting object.
     
    .EXAMPLE
        PS C:\> Compare-Property -Property Description -Configuration $ouDefinition -ADObject $adObject -Changes $changes -Resolve
         
        Compares the description on the configuration object (after resolving it) with the one on the ADObject and adds to $changes if they are inequal.
#>

    
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Property,

        [Parameter(Mandatory = $true)]
        [object]
        $Configuration,

        [Parameter(Mandatory = $true)]
        [object]
        $ADObject,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.ArrayList]
        $Changes,

        [switch]
        $Resolve,

        [string]
        $ADProperty,
        
        [hashtable]
        $Parameters = @{ },
        
        [switch]
        $AsString,

        [switch]
        $AsUpdate,

        [string]
        $Type = 'Unknown'
    )
    
    begin
    {
        if (-not $ADProperty) { $ADProperty = $Property }
    }
    process
    {
        $param = @{
            Property = $Property
            Identity = $ADObject.DistinguishedName
            Type = $Type
        }
        $propValue = $Configuration.$Property
        if ($Resolve) { $propValue = $propValue | Resolve-String @parameters }

        if (($propValue -is [System.Collections.ICollection]) -and ($ADObject.$ADProperty -is [System.Collections.ICollection])) {
            if (Compare-Object $propValue $ADObject.$ADProperty) {
                if ($AsUpdate) { $null = $Changes.Add((New-Change @param -NewValue $propValue -OldValue $ADObject.$ADProperty)) }
                else { $null = $Changes.Add($Property) }
            }
        }
        elseif ($AsString) {
            if ("$propValue" -ne "$($ADObject.$ADProperty)") {
                if ($AsUpdate) { $null = $Changes.Add((New-Change @param -NewValue "$propValue" -OldValue "$($ADObject.$ADProperty)")) }
                else { $null = $Changes.Add($Property) }
            }
        }
        elseif ($propValue -ne $ADObject.$ADProperty) {
            if ($AsUpdate) { $null = $Changes.Add((New-Change @param -NewValue $propValue -OldValue $ADObject.$ADProperty)) }
            else { $null = $Changes.Add($Property) }
        }
    }
}

function Convert-Principal {
    <#
    .SYNOPSIS
        Converts a principal to either SID or NTAccount format.
     
    .DESCRIPTION
        Converts a principal to either SID or NTAccount format.
        It caches all resolutions, uses Convert-BuiltInToSID to resolve default builtin account names,
        uses Get-Domain to resolve foreign domain SIDs and names.
 
        Basically, it is a best effort attempt to resolve principals in a useful manner.
     
    .PARAMETER Name
        The name of the entity to convert.
     
    .PARAMETER OutputType
        Whether to return an NTAccount or SID.
        Defaults to SID
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Convert-Principal @parameters -Name contoso\administrator
 
        Tries to convert the user contoso\administrator into a SID
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string]
        $Name,

        [ValidateSet('SID','NTAccount')]
        [string]
        $OutputType = 'SID',

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
    }
    process {
        Write-PSFMessage -Level Debug -String 'Convert-Principal.Processing' -StringValues $Name
        
        # Terminate if already cached
        if ($OutputType -eq 'SID' -and $script:cache_PrincipalToSID[$Name]) { return $script:cache_PrincipalToSID[$Name] }
        if ($OutputType -eq 'NTAccount' -and $script:cache_PrincipalToNT[$Name]) { return $script:cache_PrincipalToNT[$Name] }

        $builtInIdentity = Convert-BuiltInToSID -Identity $Name
        if ($builtInIdentity -ne $Name) { return $builtInIdentity }

        #region Processing Input SID
        if ($Name -as [System.Security.Principal.SecurityIdentifier]) {
            Write-PSFMessage -Level Debug -String 'Convert-Principal.Processing.InputSID' -StringValues $Name
            if ($OutputType -eq 'SID') {
                $script:cache_PrincipalToSID[$Name] = $Name -as [System.Security.Principal.SecurityIdentifier]
                return $script:cache_PrincipalToSID[$Name]
            }

            $script:cache_PrincipalToNT[$Name] = Get-Principal @parameters -Sid $Name -Domain $Name -OutputType NTAccount
            return $script:cache_PrincipalToNT[$Name]
        }
        #endregion Processing Input SID
        
        Write-PSFMessage -Level Debug -String 'Convert-Principal.Processing.InputNT' -StringValues $Name
        $ntAccount = $Name -as [System.Security.Principal.NTAccount]
        if ($OutputType -eq 'NTAccount') {
            $script:cache_PrincipalToNT[$Name] = $ntAccount
            return $script:cache_PrincipalToNT[$Name]
        }

        try {
            $script:cache_PrincipalToSID[$Name] = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier])
            return $script:cache_PrincipalToSID[$Name]
        }
        catch {
            $domainPart, $namePart = $ntAccount.Value.Split("\", 2)
            $domain = Get-Domain @parameters -DnsName $domainPart
            Write-PSFMessage -Level Debug -String 'Convert-Principal.Processing.NTDetails' -StringValues $domainPart, $namePart

            $param = @{
                Server = $domain.DNSRoot
            }
            $cred = Get-DMDomainCredential -Domain $domain.DNSRoot
            if ($cred) { $param['Credential'] = $cred }
            Write-PSFMessage -Level Debug -String 'Convert-Principal.Processing.NT.LdapFilter' -StringValues "(samAccountName=$namePart)"
            $adObject = Get-ADObject @param -LDAPFilter "(samAccountName=$namePart)" -Properties ObjectSID
            $script:cache_PrincipalToSID[$Name] = $adObject.ObjectSID
            $adObject.ObjectSID
        }
    }
}

function Get-ADWmiFilter {
    <#
    .SYNOPSIS
        Parses WMI filter objects from the active directory.
     
    .DESCRIPTION
        Parses WMI filter objects from the active directory.
     
    .PARAMETER Server
        The server / domain to work with.
         
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER Name
        Name of the WMI filter to retrieve.
        Defaults to: *
     
    .EXAMPLE
        PS C:\> Get-ADWmiFilter
 
        Returns all WMI Filters in the current domain.
    #>

    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [string]
        $Name = '*'
    )

    begin {
        #region Functions
        function ConvertFrom-WmiFilterQuery {
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                [string]
                $Query
            )

            process {
                $segments = $Query.Trim(';') -split ";"
                $index = 1
                while ($index -lt $segments.Count) {
                    $item = [PSCustomObject]@{
                        Namespace = $segments[$index + 4]
                        Query     = $segments[$index + 5]
                    }
                    Add-Member -InputObject $item -MemberType ScriptMethod -Name ToString -Value { $this.Query } -Force
                    Add-Member -InputObject $item -MemberType ScriptMethod -Name ToQuery -Value {
                        '3;{0};{1};WQL;{2};{3};' -f $this.Namespace.Length, $this.Query.Length, $this.Namespace, $this.Query
                    }
                    $item
                    $index = $index + 6
                }
            }
        }
        
        function ConvertFrom-WmiFilterTime {
            [OutputType([DateTime])]
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                [string]
                $Time
            )

            process {
                if ($Time -notmatch '000-000$') { return }
                [datetime]::ParseExact(($Time -replace '000-000$'), 'yyyyMMddHHmmss.fff', $null)
            }
        }
        #endregion Functions

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
    }
    process {
        $wmiFilterObjects = Get-ADObject @parameters -LDAPFilter "(&(objectClass=msWMI-Som)(msWMI-Name=$Name))" -Properties msWMI-Name, msWMI-Author, msWMI-CreationDate, msWMI-ChangeDate, msWMI-Parm1, msWMI-Parm2, msWMI-ID

        foreach ($wmiFilterObject in $wmiFilterObjects) {
            [PSCustomObject]@{
                Name              = $wmiFilterObject.'msWMI-Name'
                Author            = $wmiFilterObject.'msWMI-Author'
                CreationDate      = $wmiFilterObject.'msWMI-CreationDate' | ConvertFrom-WmiFilterTime
                ChangeDate        = $wmiFilterObject.'msWMI-ChangeDate' | ConvertFrom-WmiFilterTime
                Description       = $wmiFilterObject.'msWMI-Parm1'
                Query             = $wmiFilterObject.'msWMI-Parm2' | ConvertFrom-WmiFilterQuery
                DistinguishedName = $wmiFilterObject.DistinguishedName
                ID                = $wmiFilterObject.'msWMI-ID'
            }
        }
    }
}

function Get-Domain
{
    <#
    .SYNOPSIS
        Returns the domain object associated with a SID or fqdn.
     
    .DESCRIPTION
        Returns the domain object associated with a SID or fqdn.
 
        This command uses caching to avoid redundant and expensive lookups & searches.
     
    .PARAMETER Sid
        The domain SID to search by.
     
    .PARAMETER DnsName
        The domain FQDN / full dns name.
        May _also_ be just the Netbios name, but DNS name will take precedence!
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-Domain @parameters -Sid $sid
 
        Returns the domain object associated with the $sid
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Sid')]
        [string]
        $Sid,

        [Parameter(Mandatory = $true, ParameterSetName = 'Name')]
        [string]
        $DnsName,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        
        # Define variable to prevent superscope lookup
        $internalSid = $null
        $domainObject = $null
    }
    process
    {
        if ($Sid) {
            $internalSid = ([System.Security.Principal.SecurityIdentifier]$Sid).AccountDomainSid.Value
        }
        if ($internalSid -and $script:SIDtoDomain[$internalSid]) { return $script:SIDtoDomain[$internalSid] }
        if ($DnsName -and $script:DNStoDomain[$DnsName]) { return $script:DNStoDomain[$DnsName] }
        if ($DnsName -and $script:DNStoDomainName[$DnsName]) { return $script:DNStoDomainName[$DnsName] }
        if ($DnsName -and $script:NetBiostoDomain[$DnsName]) { return $script:NetBiostoDomain[$DnsName] }

        $identity = $internalSid
        if ($DnsName) { $identity = $DnsName }

        $credsToUse = $PSBoundParameters | ConvertTo-PSFHashtable -Include Credential
        $forestObject = Get-ADForest @parameters
        foreach ($domainName in $forestObject.Domains) {
            if ($script:DNSToDomain.Keys -contains $domainName) { continue }
            try {
                $domainObject = Get-ADDomain -Server $domainName @credsToUse -ErrorAction Stop
                $script:SIDtoDomain["$($domainObject.DomainSID)"] = $domainObject
                $script:DNStoDomain["$($domainObject.DNSRoot)"] = $domainObject
                $script:DNStoDomainName["$($domainObject.Name)"] = $domainObject
                $script:NetBiostoDomain["$($domainObject.NetBIOSName)"] = $domainObject
            }
            catch { }
        }
        $domainObject = $null
        
        if ($script:SIDtoDomain[$identity]) { return $script:SIDtoDomain[$identity] }
        if ($script:DNStoDomain[$identity]) { return $script:DNStoDomain[$identity] }
        if ($script:DNStoDomainName[$identity]) { return $script:DNStoDomainName[$identity] }
        if ($script:NetBiostoDomain[$identity]) { return $script:NetBiostoDomain[$identity] }

        try { $domainObject = Get-ADDomain @parameters -Identity $identity -ErrorAction Stop }
        catch {
            if (-not $domainObject) {
                try { $domainObject = Get-ADDomain -Identity $identity -ErrorAction Stop }
                catch { }
            }
            if (-not $domainObject) { throw }
        }

        if ($domainObject) {
            $script:SIDtoDomain["$($domainObject.DomainSID)"] = $domainObject
            $script:DNStoDomain["$($domainObject.DNSRoot)"] = $domainObject
            $script:DNStoDomainName["$($domainObject.Name)"] = $domainObject
            $script:NetBiostoDomain["$($domainObject.NetBIOSName)"] = $domainObject
            if ($DnsName) { $script:DNStoDomain[$DnsName] = $domainObject }
            $domainObject
        }
    }
}

function Get-Domain2
{
    <#
    .SYNOPSIS
        Returns the direct domain object accessible via the server/credential parameter connection.
     
    .DESCRIPTION
        Returns the direct domain object accessible via the server/credential parameter connection.
        Caches data for subsequent calls.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-Domain2 @parameters
 
        Returns the domain associated with the specified connection information
    #>

    [CmdletBinding()]
    Param (
        [PSFComputer]
        [Alias('ComputerName')]
        $Server = '<Default>',

        [PSCredential]
        $Credential
    )
    
    begin
    {
        # Note: Module Scope variable solely maintained in this file
        # Scriptscope for data persistence only
        if (-not ($script:directDomainObjectCache)) {
            $script:directDomainObjectCache = @{ }
        }
    }
    process
    {
        if ($script:directDomainObjectCache["$Server"]) {
            return $script:directDomainObjectCache["$Server"]
        }

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $adObject = Get-ADDomain @parameters
        $script:directDomainObjectCache["$Server"] = $adObject
        $adObject
    }
}


function Get-Principal
{
    <#
    .SYNOPSIS
        Returns a principal's resolved AD object if able to.
     
    .DESCRIPTION
        Returns a principal's resolved AD object if able to.
        Will throw an exception if the AD connection fails.
        Will return nothing if the target domain does not contain the specified principal.
        Uses the credentials provided by Set-DMDomainCredential if available.
 
        Results will be cached automatically, subsequent callls returning the cached results.
     
    .PARAMETER Sid
        The SID of the principal to search.
 
    .PARAMETER Name
        The name of the principal to search for.
 
    .PARAMETER ObjectClass
        The objectClass of the principal to search for.
     
    .PARAMETER Domain
        The domain in which to look for the principal.
 
    .PARAMETER OutputType
        The format in which the output is being returned.
        - ADObject: Returns the full AD object with full information from AD
        - NTAccount: Returns a simple NT Account notation.
 
    .PARAMETER Refresh
        Do not use cached data, reload fresh data.
 
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-Principal -Sid $adObject.ObjectSID -Domain $redForestDomainFQDN
 
        Tries to return the principal from the specified domain based on the SID offered.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ParameterSetName = 'SID')]
        [string]
        $Sid,

        [Parameter(Mandatory = $true, ParameterSetName = 'Name')]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ParameterSetName = 'Name')]
        [string]
        $ObjectClass,

        [Parameter(Mandatory = $true)]
        [string]
        $Domain,

        [ValidateSet('ADObject','NTAccount')]
        [string]
        $OutputType = 'ADObject',

        [switch]
        $Refresh,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parametersAD = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
    }
    process
    {
        $identity = $Sid
        if (-not $Sid) { $identity = "$($Domain)þ$($objectClass)þ$($Name)" }

        if ($script:resolvedPrincipals[$identity] -and -not $Refresh) {
            switch ($OutputType) {
                'ADObject' { return $script:resolvedPrincipals[$identity] }
                'NTAccount'
                {
                    if ($script:resolvedPrincipals[$identity].objectSID.AccountDomainSid) { return [System.Security.Principal.NTAccount]"$((Get-Domain @parametersAD -Sid $script:resolvedPrincipals[$identity].objectSID.AccountDomainSid).Name)\$($script:resolvedPrincipals[$identity].SamAccountName)" }
                    else { return [System.Security.Principal.NTAccount]"BUILTIN\$($script:resolvedPrincipals[$identity].SamAccountName)" }
                }
            }
        }

        try {
            if ($Domain -as [System.Security.Principal.SecurityIdentifier]) {
                $domainObject = Get-Domain @parametersAD -Sid $Domain
            }
            else {
                $domainObject = Get-Domain @parametersAD -DnsName $Domain
            }
            $parameters = @{
                Server = $domainObject.DNSRoot
            }
            $domainName = $domainObject.DNSRoot
        }
        catch {
            $parameters = @{
                Server = $Domain
            }
            $domainName = $Domain
        }
        if ($credentials = Get-DMDomainCredential -Domain $domainName) { $parameters['Credential'] = $credentials }

        $filter = "(objectSID=$Sid)"
        if (-not $Sid) { $filter = "(&(objectClass=$ObjectClass)(|(name=$Name)(samAccountName=$Name)(distinguishedName=$Name)))" }

        try { $adObject = Get-ADObject @parameters -LDAPFilter $filter -ErrorAction Stop -Properties * | Select-Object -First 1 }
        catch {
            try { $adObject = Get-ADObject @parametersAD -LDAPFilter $filter -ErrorAction Stop -Properties * | Select-Object -First 1 }
            catch { }
            if (-not $adObject) {
                Write-PSFMessage -Level Warning -String 'Get-Principal.Resolution.Failed' -StringValues $Sid, $Name, $ObjectClass, $Domain -Target $PSBoundParameters
                throw
            }
        }
        if ($adObject) {
            $script:resolvedPrincipals[$identity] = $adObject
            switch ($OutputType) {
                'ADObject' { return $adObject }
                'NTAccount'
                {
                    if ($adObject.objectSID.AccountDomainSid) { return [System.Security.Principal.NTAccount]"$((Get-Domain @parametersAD -Sid $adObject.objectSID.AccountDomainSid).Name)\$($adObject.SamAccountName)" }
                    else { [System.Security.Principal.NTAccount]"BUILTIN\$($adObject.SamAccountName)" }
                }
            }
        }
    }
}

function Invoke-Callback
{
    <#
    .SYNOPSIS
        Invokes registered callbacks.
     
    .DESCRIPTION
        Invokes registered callbacks.
        Should be placed inside the begin block of every single Test-* and Invoke-* command.
 
        For more details on this system, call:
        Get-Help about_DM_callbacks
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER Cmdlet
        The $PSCmdlet variable of the calling command
     
    .EXAMPLE
        PS C:\> Invoke-Callback @parameters -Cmdlet $PSCmdlet
 
        Executes all callbacks against the specified server using the specified credentials.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
    [CmdletBinding()]
    Param (
        [string]
        $Server,

        [PSCredential]
        $Credential,

        [Parameter(Mandatory = $true)]
        [System.Management.Automation.PSCmdlet]
        $Cmdlet
    )
    
    begin
    {
        if (-not $script:callbacks) { return }

        if (-not $script:callbackDomains) { $script:callbackDomains = @{ } }
        if (-not $script:callbackForests) { $script:callbackForests = @{ } }

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false

        $serverName = '<Default Domain>'
        if ($Server) { $serverName = $Server }
    }
    process
    {
        if (-not $script:callbacks) { return }

        if (-not $script:callbackDomains[$serverName]) {
            try { $script:callbackDomains[$serverName] = Get-ADDomain @parameters -ErrorAction Stop }
            catch { } # Ignore errors, might not work yet
        }
        if (-not $script:callbackForests[$serverName]) {
            try { $script:callbackForests[$serverName] = Get-ADForest @parameters -ErrorAction Stop }
            catch { } # Ignore errors, might not work yet
        }

        foreach ($callback in $script:callbacks.Values) {
            Write-PSFMessage -Level Debug -String 'Invoke-Callback.Invoking' -StringValues $callback.Name
            try {
                $param = @($serverName, $Credential, $script:callbackDomains[$serverName], $script:callbackForests[$serverName])
                $callback.Scriptblock.Invoke($param)
                Write-PSFMessage -Level Debug -String 'Invoke-Callback.Invoking.Success' -StringValues $callback.Name
            }
            catch {
                Write-PSFMessage -Level Debug -String 'Invoke-Callback.Invoking.Failed' -StringValues $callback.Name -ErrorRecord $_
                $Cmdlet.ThrowTerminatingError($_)
            }
        }
    }
}


function New-Change {
    <#
    .SYNOPSIS
        Create a new change object.
     
    .DESCRIPTION
        Create a new change object.
        Used for test results in cases where no specialized change objects are intended.
        Mostly used from the internal Compare-Property command.
     
    .PARAMETER Property
        The property being updated
     
    .PARAMETER OldValue
        The previous value the property had
     
    .PARAMETER NewValue
        The new value the property should receive
     
    .PARAMETER Identity
        Identity of the object being updated
     
    .PARAMETER Type
        The object/component type of the object being changed
     
    .EXAMPLE
        PS C:\> New-Change -Property Path -OldValue $adObject.DistinguishedName -NewValue $path -Identity $adObject -Type Object
 
        Creates a new change object for the path of an object
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Property,

        $OldValue,

        $NewValue,

        [string]
        $Identity,
        
        [string]
        $Type = 'Unknown'
    )

    $change = [PSCustomObject]@{
        PSTypeName = "DomainManagement.$Type.Change"
        Property   = $Property
        Old        = $OldValue
        New        = $NewValue
        Identity   = $Identity
    }
    Add-Member -InputObject $change -MemberType ScriptMethod -Name ToString -Value { '{0} -> {1}' -f $this.Property, $this.New } -Force -PassThru
}

function New-Password
{
    <#
        .SYNOPSIS
            Generate a new, complex password.
         
        .DESCRIPTION
            Generate a new, complex password.
         
        .PARAMETER Length
            The length of the password calculated.
            Defaults to 32
 
        .PARAMETER AsSecureString
            Returns the password as secure string.
         
        .EXAMPLE
            PS C:\> New-Password
 
            Generates a new 32v character password.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")]
    [CmdletBinding()]
    Param (
        [int]
        $Length = 32,

        [switch]
        $AsSecureString
    )
    
    begin
    {
        $characters = @{
            0 = @('A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z')
            1 = @('a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z')
            2 = @(0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9)
            3 = @('#','$','%','&',"'",'(',')','*','+',',','-','.','/',':',';','<','=','>','?','@')
            4 = @('A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z')
            5 = @('a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z')
            6 = @(0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9)
            7 = @('#','$','%','&',"'",'(',')','*','+',',','-','.','/',':',';','<','=','>','?','@')
        }
    }
    process
    {
        $letters = foreach ($number in (5..$Length)) {
            $characters[(($number % 4) + (0..4 | Get-Random))] | Get-Random
        }
        0,1,2,3 | ForEach-Object {
            $letters += $characters[$_] | Get-Random
        }
        $letters = $letters | Sort-Object { Get-Random }
        if ($AsSecureString) { $letters -join "" | ConvertTo-SecureString -AsPlainText -Force }
        else { $letters -join "" }
    }
}


function New-TestResult {
    <#
    .SYNOPSIS
        Generates a new test result object.
     
    .DESCRIPTION
        Generates a new test result object.
        Helper function that slims down the Test- commands.
     
    .PARAMETER ObjectType
        What kind of object is being processed (e.g.: User, OrganizationalUnit, Group, ...)
     
    .PARAMETER Type
        What kind of change needs to be performed
     
    .PARAMETER Identity
        Identity of the change item
     
    .PARAMETER Changed
        What properties - if any - need to be changed
     
    .PARAMETER Server
        The server the test was performed against
     
    .PARAMETER Configuration
        The configuration object containing the desired state.
     
    .PARAMETER ADObject
        The AD Object(s) containing the actual state.
     
    .EXAMPLE
        PS C:\> New-TestResult -ObjectType User -Type Changed -Identity $resolvedDN -Changed Description -Server $Server -Configuration $userDefinition -ADObject $adObject
 
        Creates a new test result object using the specified information.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $ObjectType,

        [Parameter(Mandatory = $true)]
        [string]
        $Type,

        [Parameter(Mandatory = $true)]
        [string]
        $Identity,

        [object[]]
        $Changed,

        [Parameter(Mandatory = $true)]
        [AllowNull()]
        [PSFComputer]
        $Server,

        $Configuration,

        $ADObject
    )
    
    process {
        $object = [PSCustomObject]@{
            PSTypeName    = "DomainManagement.$ObjectType.TestResult"
            Type          = $Type
            ObjectType    = $ObjectType
            Identity      = $Identity
            Changed       = $Changed
            Server        = $Server
            Configuration = $Configuration
            ADObject      = $ADObject
        }
        Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value { $this.Identity } -Force
        $object
    }
}

function Resolve-ADObject {
    <#
    .SYNOPSIS
        Resolves AD Objects from wildcard-patterned DNs.
     
    .DESCRIPTION
        Resolves AD Objects from wildcard-patterned Distinguished Names.
     
    .PARAMETER Filter
        The wildcard-patterned DN
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER ObjectClass
        Only return objects of the specified object class.
        Default: *
     
    .EXAMPLE
        PS C:\> Resolve-ADObject -OUFilter '*,*,OU=Contoso,DC=contoso,DC=com' -ObjectClass user
 
        Resolves all user objects two steps under the Contoso OU.
    #>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
        [string]
        $Filter,

        [PSFComputer]
        $Server,

        [PSCredential]
        $Credential,

        [string]
        $ObjectClass = '*'
    )

    begin {
        function Get-AdNextStep {
            [CmdletBinding()]
            param (
                $Parameters,
    
                $Fragments,
    
                $BasePath
            )
    
            $nameFilter = (@($Fragments)[0] -split "=",2)[-1]
            $nameLdapFilter = $nameFilter -replace '\[.+?\]|\?','*'
            $adObjects = Get-ADObject @Parameters -SearchBase $BasePath -SearchScope OneLevel -LDAPFilter "(name=$nameLdapFilter)" | Where-Object Name -like $nameFilter
            if (@($Fragments).Count -eq 1) {
                return $adObjects
            }
    
            foreach ($adObject in $adObjects) {
                Get-AdNextStep -Parameters $Parameters -BasePath $adObject.DistinguishedName -Fragments $Fragments[1..$Fragments.Length]
            }
        }
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
    }
    
    process {
        $filterSegments = ($Filter -replace ",DC=.+$" -split "(?<=[^\\],)").TrimEnd(",")
        $basePath = $Filter -replace '^.+?,DC=','DC='
        [array]::Reverse($filterSegments)
    
        Get-AdNextStep -Parameters $parameters -Fragments $filterSegments -BasePath $basePath | Where-Object ObjectClass -Like $ObjectClass
    }
}

function Resolve-ContentSearchBase
{
<#
    .SYNOPSIS
        Resolves the ruleset for content enforcement into actionable search data.
     
    .DESCRIPTION
        Resolves the ruleset for content enforcement into actionable search data.
        This ensures that both Include and Exclude rules are properly translated into AD search queries.
        This command is designed to be called by all Test- commands across the entire module.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER NoContainer
        By defaults, containers are returned as well.
        Using this parameter prevents container processing.
     
    .PARAMETER IgnoreMissingSearchbase
        Disables warnings if a defined searchbase is missing.
        For use in OU tests.
     
    .EXAMPLE
        PS C:\> Resolve-ContentSearchBase @parameters
         
        Resolves the configured filters into searchbases for the targeted domain.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [CmdletBinding()]
    Param (
        [string]
        $Server,

        [pscredential]
        $Credential,
        
        [switch]
        $NoContainer,
        
        [switch]
        $IgnoreMissingSearchbase
    )
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false

        #region Utility Functions
        function Convert-DistinguishedName {
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                [string[]]
                $Name,

                [switch]
                $Exclude
            )
            process {
                foreach ($nameItem in $Name) {
                    [PSCustomObject]@{
                        Name = $nameItem
                        Depth = ($nameItem -split "," | Where-Object { $_ -notlike "DC=*" }).Count
                        Elements = ($nameItem -split "," | Where-Object { $_ -notlike "DC=*" })
                        Exclude = $Exclude.ToBool()
                    }
                }
            }
        }

        function Get-ChildRelationship {
            [CmdletBinding()]
            param (
                [Parameter(Mandatory = $true)]
                $Parent,

                [Parameter(Mandatory = $true)]
                $Items
            )

            foreach ($item in $Items) {
                if ($item.Name -notlike "*,$($Parent.Name)") { continue }

                [PSCustomObject]@{
                    Child = $item
                    Parent = $Parent
                    Delta = $item.Depth - $Parent.Depth
                }
            }
        }

        function New-SearchBase {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                [string]
                $Name,

                [ValidateSet('OneLevel', 'Subtree')]
                [string]
                $Scope = 'Subtree'
            )

            [PSCustomObject]@{
                SearchBase = $Name
                SearchScope = $Scope
            }
        }

        function Resolve-SearchBase {
            [CmdletBinding()]
            Param (
                [Parameter(Mandatory = $true)]
                $Parent,

                [Parameter(Mandatory = $true)]
                $Children,

                [string]
                $Server,

                [pscredential]
                $Credential
            )
            New-SearchBase -Name $Parent.Name -Scope OneLevel

            $childPaths = @{
                $Parent.Name = @{}
            }
            foreach ($childItem in $Children) {
                $subPath = $childItem.Name.Replace($Parent.Name, '').Trim(",")
                $subPathSegments = $subPath.Split(",")
                [System.Array]::Reverse($subPathSegments)

                $basePath = $Parent.Name
                foreach ($pathSegment in $subPathSegments) {
                    $newDN = $pathSegment, $basePath -join ","
                    $childPaths[$basePath][$newDN] = $newDN
                    if (-not $childPaths[$newDN]) { $childPaths[$newDN] = @{ } }
                    $basePath = $newDN
                }
            }

            $currentPath = ''
            [System.Collections.ArrayList]$pathsToProcess = @($Parent.Name)
            while ($pathsToProcess.Count -gt 0) {
                $currentPath = $pathsToProcess[0]
                $nextContainerObjects = Get-ADObject @parameters -SearchBase $currentPath -SearchScope OneLevel -LDAPFilter '(|(objectCategory=container)(objectCategory=organizationalUnit))'
                foreach ($containerObject in $nextContainerObjects) {
                    # Skip the actual children, as those (and their children) have already been processed
                    if ($containerObject.DistinguishedName -in $Children.Name) { continue }
                    if ($childPaths.ContainsKey($containerObject.DistinguishedName)) {
                        New-SearchBase -Name $containerObject.DistinguishedName -Scope OneLevel
                        $null = $pathsToProcess.Add($containerObject.DistinguishedName)
                    }
                    else {
                        New-SearchBase -Name $containerObject.DistinguishedName
                    }
                }
                $pathsToProcess.Remove($currentPath)
            }
        }
        #endregion Utility Functions

        Set-DMDomainContext @parameters
        $warningLevel = 'Warning'
        $domain = Get-Domain2 @parameters
        if (@(Get-ADOrganizationalUnit @parameters -ErrorAction Ignore -ResultSetSize 2 -Filter * -SearchBase $domain).Count -eq 1) { $warningLevel = 'Verbose' }
    }
    process
    {
        #region preprocessing and early termination
        # Don't process any OUs if in Additive Mode
        if ($script:contentMode.Mode -eq 'Additive') { return }

        # If already processed, return previous results
        if (($Server -eq $script:contentSearchBases.Server) -and (-not (Compare-Object $script:contentMode.Include $script:contentSearchBases.Include)) -and (-not (Compare-Object $script:contentMode.Exclude $script:contentSearchBases.Exclude))) {
            if ($NoContainer) { $script:contentSearchBases.Bases | Where-Object SearchBase -notlike "CN=*" }
            else { $script:contentSearchBases.Bases }
            return
        }

        # Parse Includes and excludes
        $include = $script:contentMode.Include | Resolve-String | Convert-DistinguishedName
        $exclude = $script:contentMode.Exclude | Resolve-String | Convert-DistinguishedName -Exclude
        
        # If no todo: Terminate
        if (-not ($include -or $exclude)) { return }

        # Implicitly include domain when no custom include rules
        if ($exclude -and -not $include) {
            $include = $script:domainContext.DN | Convert-DistinguishedName
        }
        $allItems = @{}
        foreach ($item in $include) {
            if (-not (Test-ADObject @parameters -Identity $item.Name)) {
                if ($IgnoreMissingSearchbase) { continue }
                Write-PSFMessage -Level $warningLevel -String 'Resolve-ContentSearchBase.Include.NotFound' -StringValues $item.Name -Tag notfound, container -Target $Server
                continue
            }
            $allItems[$item.Name] = $item
        }
        foreach ($item in $exclude) {
            if (-not (Test-ADObject @parameters -Identity $item.Name)) {
                if ($IgnoreMissingSearchbase) { continue }
                Write-PSFMessage -Level $warningLevel -String 'Resolve-ContentSearchBase.Exclude.NotFound' -StringValues $item.Name -Tag notfound, container -Target $Server
                continue
            }
            $allItems[$item.Name] = $item
        }
        $relationship_All = foreach ($item in $allItems.Values) {
            Get-ChildRelationship -Parent $item -Items $allItems.Values
        }
        # Remove multiple include/exclude nestings producing reddundant inheritance detection
        $relationship_Relevant = $relationship_All | Group-Object { $_.Child.Name } | ForEach-Object {
            $_.Group | Sort-Object Delta | Select-Object -First 1
        }
        #endregion preprocessing and early termination

        [System.Collections.ArrayList]$itemsProcessed = @()
        [System.Collections.ArrayList]$targetOUsFound = @()

        foreach ($item in ($allItems.Values | Sort-Object Depth -Descending)) {
            $children = $relationship_Relevant | Where-Object { $_.Parent.Name -eq $item.Name }
            $allChildren = $relationship_All | Where-Object { $_.Parent.Name -eq $item.Name }

            # Case: Exclude Rule - will not be scanned
            if ($item.Exclude) {
                $null = $itemsProcessed.Add($item)
                continue
            }

            # Casse: No Children - Just add a plain searchbase
            if (-not $children) {
                $null = $targetOUsFound.Add((New-SearchBase -Name $item.Name))
                $null = $itemsProcessed.Add($item)
                continue
            }

            # Case: No recursive Children that would exclude something - Add plain searchbase and remove all entries from all children as not needed
            if (-not ($allChildren.Child | Where-Object Exclude)) {
                $redundantFindings = $targetOUsFound | Where-Object SearchBase -in $allChildren.Child.Name
                foreach ($finding in $redundantFindings) { $targetOUsFound.Remove($finding) }
                $null = $targetOUsFound.Add((New-SearchBase -Name $item.Name))
                $null = $itemsProcessed.Add($item)
                continue
            }

            # Case: Children that require processing
            foreach ($searchbase in (Resolve-SearchBase @parameters -Parent $item -Children $children.Child)) {
                $null = $targetOUsFound.Add($searchbase)
            }
            $null = $itemsProcessed.Add($item)
        }

        $script:contentSearchBases.Include = $script:contentMode.Include
        $script:contentSearchBases.Exclude = $script:contentMode.Exclude
        $script:contentSearchBases.Server = $Server
        $script:contentSearchBases.Bases = $targetOUsFound.ToArray()

        foreach ($searchBase in $script:contentSearchBases.Bases) {
            if ($NoContainer -and ($searchBase.SearchBase -like 'CN=*')) { continue }
            Write-PSFMessage -String 'Resolve-ContentSearchBase.Searchbase.Found' -StringValues $searchBase.SearchScope, $searchBase.SearchBase, $script:domainContext.Fqdn
            $searchBase
        }
    }
}

function Resolve-String {
    <#
        .SYNOPSIS
            Resolves a string, inserting all registered placeholders as appropriate.
         
        .DESCRIPTION
            Resolves a string, inserting all registered placeholders as appropriate.
            Use Register-DMNameMapping to configure your own replacements.
         
        .PARAMETER Text
            The string on which to perform the replacements.
     
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
 
        .EXAMPLE
            PS C:\> Resolve-String -Text $_.GroupName
 
            Returns the resolved name of the input string (probably the finalized name of a new group to add).
    #>

    [OutputType([string])]
    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
        [AllowEmptyString()]
        [AllowNull()]
        [string[]]
        $Text,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential

        $replacementScript = {
            param (
                [string]
                $Match
            )

            if ($Match -like "%!*%") {
                try { (Invoke-DMDomainData -Name $Match.Trim('%!') @parameters -EnableException).Data }
                catch { throw }
            }
            if ($script:nameReplacementTable[$Match]) { $script:nameReplacementTable[$Match] }
            else { $Match }
        }

        $pattern = $script:nameReplacementTable.Keys -join "|"
        if ($Server) { $pattern += '|{0}' -f ($script:domainDataScripts.Values.Placeholder -join "|") }
    }
    process {
        foreach ($textItem in $Text) {
            if (-not $textItem) { return $textItem }
            try { [regex]::Replace($textItem, $pattern, $replacementScript, 'IgnoreCase') }
            catch { throw }
        }
    }
}


function Test-ADObject
{
    <#
    .SYNOPSIS
        Tests, whether a given AD object already exists.
     
    .DESCRIPTION
        Tests, whether a given AD object already exists.
     
    .PARAMETER Identity
        Identity of the object to test.
        Must be a unique identifier accepted by Get-ADObject.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Test-ADObject -Identity $distinguishedName
 
        Tests whether the object referenced in $distinguishedName exists in the current domain.
    #>

    [OutputType([bool])]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Identity,

        [string]
        $Server,

        [pscredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
    }
    process
    {
        try {
            $null = Get-ADObject -Identity $Identity @parameters -ErrorAction Stop
            return $true
        }
        catch {
            return $false
        }
    }
}


function Test-DmKdsRootKey
{
<#
    .SYNOPSIS
        Tests whether the KDS Root Key has been set up.
     
    .DESCRIPTION
        Tests whether the KDS Root Key has been set up.
        Prompts the user whether to set it up if not done yet.
        A valid KDS Root Key is required for using group Managed Service Accounts.
     
    .PARAMETER ComputerName
        The server / domain to work with.
         
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Test-DmKdsRootKey -ComputerName contoso.com
     
        Tests whether the contoso.com domain has been set up for gMSA.
#>

    [OutputType([bool])]
    [CmdletBinding()]
    Param (
        [PSFComputer]
        [Alias('Server')]
        $ComputerName,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include ComputerName, Credential
        $adParameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include ComputerName, Credential -Remap @{ ComputerName = 'Server' }
    }
    process
    {
        if (Get-PSFConfigValue -FullName 'DomainManagement.ServiceAccount.SkipKdsCheck') { return $true }

        # If at least one gMSA exists, we can know it exists, even if we cannot see the KDS Root Key
        if (Get-ADServiceAccount @adParameters -Filter * -ResultSetSize 1) {
            return $true
        }

        $domain = Get-Domain2 @parameters
        $rootKeys = Invoke-Command @parameters { Get-KdsRootKey }
        if ($rootKeys | Where-Object {
            $_.EffectiveTime -LT (Get-Date).AddHours(-10) -and
            $_.DomainController -match ",OU=[^,]+,$($domain.DistinguishedName)$"
        }) { return $true }
        
        $paramGetPSFUserChoice = @{
            Caption = 'No active KDS Root Key Detected'
            Message = 'Do you want to create a KDS Rootkey backdated to be instantly applicable?'
            Options = 'Yes', 'No'
            DefaultChoice = 1
        }
        $choice = Get-PSFUserChoice @paramGetPSFUserChoice
        if ($choice -eq 1) { return $false }
        
        try {
            Write-PSFMessage -Level Host -String 'Test-KdsRootKey.Adding'
            $null = Invoke-Command @parameters -ScriptBlock {
                Add-KdsRootKey -EffectiveTime (Get-Date).AddHours(-10) -ErrorAction Stop
            } -ErrorAction Stop
            return $true
        }
        catch {
            Write-PSFMessage -Level Warning -String 'Test-KdsRootKey.Failed' -ErrorRecord $_
        }
        $false
    }
}

function Compare-Identity {
    <#
    .SYNOPSIS
        Compares two sets of identity references, similar to Compare-Object.
     
    .DESCRIPTION
        Compares two sets of identity references, similar to Compare-Object.
        Only real difference: Performs identity resolution and compares at the SID level.
     
    .PARAMETER ReferenceIdentity
        One set of identities to compare.
     
    .PARAMETER DifferenceIdentity
        The other set of identities to compare.
     
    .PARAMETER Parameters
        AD connection parameters.
        Offer a hashtable containing server or credentials in any combination.
     
    .PARAMETER IncludeEqual
        Return identities that occur in both sets.
     
    .PARAMETER ExcludeDifferent
        Do not return identities that only occur in one set
     
    .EXAMPLE
        PS C:\> $relevantADRule.IdentityReference | Compare-Identity -Parameters $parameters -ReferenceIdentity $ConfiguredRules.IdentityReference -IncludeEqual -ExcludeDifferent
 
        Compares all identities between the accessrule already existing on the AD object and the ones defined for it.
        Only returns existing Active Directory-existing rules if there also is at least one configured rule for its identity.
    #>

    [CmdletBinding()]
    param (
        $ReferenceIdentity,

        [Parameter(ValueFromPipeline = $true)]
        $DifferenceIdentity,

        [Hashtable]
        $Parameters = @{ },

        [switch]
        $IncludeEqual,

        [switch]
        $ExcludeDifferent
    )

    begin {
        #region Utility Functions
        function ConvertTo-SID {
            [CmdletBinding()]
            param (
                $IdentityReference,

                [Hashtable]
                $Parameters
            )

            $resolved = Convert-BuiltInToSID -Identity $IdentityReference
            if ($resolved -is [System.Security.Principal.SecurityIdentifier]) { return $resolved }
            
            # NTAccount
            try { Convert-Principal -Name $resolved -OutputType SID @Parameters }
            catch { $resolved }
        }
        #endregion Utility Functions

        $referenceItems = foreach ($identity in $ReferenceIdentity) {
            $sid = ConvertTo-SID -IdentityReference $identity -Parameters $Parameters
            [PSCustomObject]@{
                Type = "Reference"
                Original = $identity
                SID = $sid
                SIDString = "$sid"
            }
        }

        [System.Collections.ArrayList]$differenceItems = @()
    }
    process {
        foreach ($item in $DifferenceIdentity) {
            $sid = ConvertTo-SID -IdentityReference $item -Parameters $Parameters
            $result = [PSCustomObject]@{
                Type = "Difference"
                Original = $identity
                SID = $sid
                SIDString = "$sid"
            }
            $null = $differenceItems.Add($result)
        }
    }
    end {
        if (-not $ExcludeDifferent) {
            foreach ($differenceItem in $differenceItems) {
                if ($differenceItem.SIDString -in $referenceItems.SIDString) { continue }

                [PSCustomObject]@{
                    Identity = $differenceItem.Original
                    SID = $differenceItem.SID
                    Direction = '<='
                }
            }
            foreach ($referenceItem in $referenceItems) {
                if ($referenceItem.SIDString -in $differenceItems.SIDString) { continue }

                [PSCustomObject]@{
                    Identity = $referenceItem.Original
                    SID = $referenceItem.SID
                    Direction = '=>'
                }
            }
        }
        if ($IncludeEqual) {
            foreach ($differenceItem in $differenceItems) {
                if ($differenceItem.SIDString -notin $referenceItems.SIDString) { continue }

                [PSCustomObject]@{
                    Identity = $differenceItem.Original
                    SID = $differenceItem.SID
                    Direction = '=='
                }
            }
        }
    }
}

function Convert-BuiltInToSID
{
    <#
    .SYNOPSIS
        Converts pre-configured built in accounts into SID form.
     
    .DESCRIPTION
        Converts pre-configured built in accounts into SID form.
        These must be registered using Register-DMBuiltInSID.
        Returns all identity references that are not a BuiltIn account that was registered.
     
    .PARAMETER Identity
        The identity reference to translate.
     
    .EXAMPLE
        Convert-BuiltInToSID -Identity $Rule1.IdentityReference
         
        Converts to IdentityReference of $Rule1 if necessary
    #>

    [CmdletBinding()]
    Param (
        $Identity
    )
    
    process
    {
        if ($Identity -as [System.Security.Principal.SecurityIdentifier]) { return ($Identity -as [System.Security.Principal.SecurityIdentifier]) }
        if ($script:builtInSidMapping["$Identity"]) { return $script:builtInSidMapping["$Identity"] }
        $Identity
    }
}

function Get-AdminSDHolderRules {
    <#
    .SYNOPSIS
        Returns the access rules applied to the AdminSDHolder object.
     
    .DESCRIPTION
        Returns the access rules applied to the AdminSDHolder object.
        Used in workflows comparing privileges on the AdminSDHolder object.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-AdminSDHolderRules
 
        Returns the access rules applied to the AdminSDHolder object of the current domain.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,

        [PSCredential]
        $Credential
    )

    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
    }
    process {
        $systemContainer = (Get-ADDomain @parameters).SystemsContainer
        (Get-AdsAcl -Path "CN=AdminSDHolder,$systemContainer" @parameters).Access
    }
}

function Get-PermissionGuidMapping
{
    <#
    .SYNOPSIS
        Retrieve a hashtable mapping permission guids to their respective name.
     
    .DESCRIPTION
        Retrieve a hashtable mapping permission guids to their respective name.
        This is retrieved from the target forest on first request, then cached for subsequent calls.
        The cache is specific to the targeted server and maintained as long as the process runs.
     
    .PARAMETER NameToGuid
        Rather than returning a hashtable mapping guid to name, return a hashtable mapping name to guid.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-PermissionGuidMapping -Server contoso.com
 
        Returns a hashtable mapping guids to rights from the contoso.com forest.
    #>

    [CmdletBinding()]
    Param (
        [switch]
        $NameToGuid,

        [PSFComputer]
        $Server = 'default',
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        # Script scope variables declared and maintained in this file only
        if (-not $script:schemaGuidToRightMapping) {
            $script:schemaGuidToRightMapping = @{ }
        }
        if (-not $script:schemaRightToGuidMapping) {
            $script:schemaRightToGuidMapping = @{ }
        }
    }
    process
    {
        [string]$identity = $Server
        if ($script:schemaGuidToRightMapping[$identity]) {
            if ($NameToGuid) { return $script:schemaRightToGuidMapping[$identity] }
            else { return $script:schemaGuidToRightMapping[$identity] }
        }
        Write-PSFMessage -Level Host -String 'Get-PermissionGuidMapping.Processing' -StringValues $identity
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false

        $configurationNC = (Get-ADRootDSE @parameters).configurationNamingContext
        $objects = Get-ADObject @parameters -SearchBase "CN=Extended-Rights,$configurationNC" -Properties Name,rightsGUID -LDAPFilter '(objectCategory=controlAccessRight)' # Exclude the schema object itself
        $processed = $objects | Select-PSFObject Name, 'rightsGUID to Guid as ID' | Select-PSFObject Name, 'ID to string'

        if (-not $processed) { return }
        $script:schemaGuidToRightMapping[$identity] = @{ "$([guid]::Empty)" = '<All>' }
        $script:schemaRightToGuidMapping[$identity] = @{ '<All>' = "$([guid]::Empty)" }
        
        foreach ($processedItem in $processed) {
            $script:schemaGuidToRightMapping[$identity][$processedItem.ID] = $processedItem.Name
            $script:schemaRightToGuidMapping[$identity][$processedItem.Name] = $processedItem.ID
        }
        if ($NameToGuid) { return $script:schemaRightToGuidMapping[$identity] }
        else { return $script:schemaGuidToRightMapping[$identity] }
    }
}


function Get-SchemaGuidMapping
{
    <#
    .SYNOPSIS
        Returns a hashtable mapping schema guids to the name of an attribute / class.
     
    .DESCRIPTION
        Returns a hashtable mapping schema guids to the name of an attribute / class.
        This hashtable is being generated (and cached) on a per-Server basis.
     
    .PARAMETER NameToGuid
        Return a hashtable mapping name to guid, rather than one mapping guid to name.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-SchemaGuidMapping @parameters
 
        Returns a hashtable mapping Guid of attributes or classes to their humanly readable name.
    #>

    [CmdletBinding()]
    Param (
        [switch]
        $NameToGuid,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    process
    {
        [string]$identity = '<default>'
        if ($Server) { $identity = $Server }

        if (Test-PSFTaskEngineCache -Module DomainManagement -Name "SchemaGuidCache.$Identity") {
            if ($NameToGuid) { return (Get-PSFTaskEngineCache -Module DomainManagement -Name "SchemaGuidCache.$Identity").NameToGuid }
            else { return (Get-PSFTaskEngineCache -Module DomainManagement -Name "SchemaGuidCache.$Identity").GuidToName }
        }

        Write-PSFMessage -Level Host -String 'Get-SchemaGuidMapping.Processing' -StringValues $identity
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false

        $schemaNC = (Get-ADRootDSE @parameters).schemaNamingContext
        $objects = Get-ADObject @parameters -SearchBase $schemaNC -Properties Name,SchemaIDGuid -LDAPFilter '(schemaIDGUID=*)' # Exclude the schema object itself
        $processed = $objects | Select-PSFObject Name, 'SchemaIDGuid to Guid as ID' | Select-PSFObject Name, 'ID to string'

        if (-not $processed) { return }
        $data = [PSCustomObject]@{
            NameToGuid = @{ '<All>' = "$([guid]::Empty)" }
            GuidToName = @{ "$([guid]::Empty)" = '<All>' }
        }
        foreach ($processedItem in $processed) {
            $data.GuidToName[$processedItem.ID] = $processedItem.Name
            $data.NameToGuid[$processedItem.Name] = $processedItem.ID
        }
        Set-PSFTaskEngineCache -Module DomainManagement -Name "SchemaGuidCache.$Identity" -Value $data
        if ($NameToGuid) { return $data.NameToGuid }
        else { return $data.GuidToName }
    }
}


function Remove-RedundantAce {
    <#
    .SYNOPSIS
        Removes redundant Access Rule entries.
     
    .DESCRIPTION
        Removes redundant Access Rule entries.
        This only considers explicit rules for the specified identity reference.
        It compares the highest privileged access rule with other rules only.
 
        This is designed to help prevent an explicit "GenericAll" privilege making redundant other entries.
        This function is explicitly called in Invoke-DMAccessRule, in case of a planned ACE removal failing (and only for the failing identity).
        That will only lead to trouble if a conflicting ACE is in the desired state (and who would desire something like that??)
     
    .PARAMETER AccessControlList
        The access control list to remove redundant ACE from.
     
    .PARAMETER IdentityReference
        The identity for which to do the removing.
     
    .EXAMPLE
        PS C:\> Remove-RedundantAce -AccessControlList $aclObject -IdentityReference $identity
 
        Removes all redundant access rules on $aclobject that apply to $identity.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [System.DirectoryServices.ActiveDirectorySecurity]
        $AccessControlList,

        $IdentityReference
    )

    $relevantRules = $AccessControlList.Access | Where-Object {
        ($_.IsInherited -eq $false) -and ($_.IdentityReference -eq $IdentityReference)
    } | Sort-Object ActiveDirectoryRights -Descending
    if (-not $relevantRules) { return }

    $master = $null
    $results = foreach ($rule in $relevantRules) {
        if ($null -eq $master) {
            $master = $rule
            $rule
            continue
        }

        # If rights are not a subset of master: It's not redundant
        if (($master.ActiveDirectoryRights -band $rule.ActiveDirectoryRights) -ne $rule.ActiveDirectoryRights) {
            $rule
            continue
        }

        if ($master.InheritanceType -ne $rule.InheritanceType) {
            $rule
            continue
        }
        if ($master.AccessControlType -ne $rule.AccessControlType) {
            $rule
            continue
        }
        if (($master.ObjectType -ne $rule.ObjectType) -and ('00000000-0000-0000-0000-000000000000' -ne $master.ObjectType)) {
            $rule
            continue
        }
        if (($master.InheritedObjectType -ne $rule.InheritedObjectType) -and ('00000000-0000-0000-0000-000000000000' -ne $master.InheritedObjectType)) {
            $rule
            continue
        }
    }

    # If none were filtered out: Don't do anything
    if ($results.Count -eq $relevantRules.Count) { return }

    foreach ($rule in $relevantRules) { $null = $AccessControlList.RemoveAccessRule($rule) }
    foreach ($rule in $results) { $AccessControlList.AddAccessRule($rule) }
}

function Test-AccessRuleEquality {
    <#
    .SYNOPSIS
        Compares two access rules with each other.
     
    .DESCRIPTION
        Compares two access rules with each other.
     
    .PARAMETER Rule1
        The first rule to compare
     
    .PARAMETER Rule2
        The second rule to compare
     
    .PARAMETER Parameters
        Hashtable containing server and credential informations.
     
    .EXAMPLE
        PS C:\> Test-AccessRuleEquality -Rule1 $rule -Rule2 $rule2
 
        Compares $rule with $rule2
    #>

    [OutputType([System.Boolean])]
    [CmdletBinding()]
    param (
        $Rule1,
        $Rule2,
        $Parameters
    )

    function Get-SID {
        [CmdletBinding()]
        param (
            $Rule,
            $Parameters
        )

        if ($Rule.SID) { return $Rule.SID }
        if ($Rule.IdentityReference -is [System.Security.Principal.SecurityIdentifier]) { return $Rule.IdentityReference }

        # NTAccount
        Convert-Principal -Name $Rule.IdentityReference -OutputType SID @Parameters
    }
    
    if ($Rule1.ActiveDirectoryRights -ne $Rule2.ActiveDirectoryRights) { return $false }
    if ($Rule1.InheritanceType -ne $Rule2.InheritanceType) { return $false }
    if ($Rule1.ObjectType -ne $Rule2.ObjectType) { return $false }
    if ($Rule1.InheritedObjectType -ne $Rule2.InheritedObjectType) { return $false }
    if ($Rule1.AccessControlType -ne $Rule2.AccessControlType) { return $false }
    if ("$(Convert-BuiltInToSID -Identity $Rule1.IdentityReference)" -ne "$(Convert-BuiltInToSID -Identity $Rule2.IdentityReference)")
    {
        $oneSID = Get-SID -Rule $Rule1 -Parameters $Parameters
        $twoSID = Get-SID -Rule $Rule2 -Parameters $Parameters
        if ("$oneSID" -ne "$twoSID") { return $false }
    }
    return $true
}

function ConvertTo-FilterName {
    <#
        .SYNOPSIS
            Converts a GP permission filter string into a list of the names of conditions included in the filter.
 
        .DESCRIPTION
            Converts a GP permission filter string into a list of the names of conditions included in the filter.
            Deduplicates results.
 
        .PARAMETER Filter
            The filter to parse.
 
        .EXAMPLE
            C:\> ConvertTo-FilterName -Filter $Filter
 
            Converts the filter in $Filter into the deduplicated names of the conditions to apply.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Filter
    )

    $tokens = $null
    $errors = $null
    $null = [System.Management.Automation.Language.Parser]::ParseInput($Filter, [ref]$tokens, [ref]$errors)

    $tokens | Where-Object Kind -eq Identifier | Select-Object -ExpandProperty Text -Unique
}

function ConvertTo-GPLink {
    <#
    .SYNOPSIS
        Parses the gPLink property on ad objects.
     
    .DESCRIPTION
        Parses the gPLink property on ad objects.
        This allows analyzing gPLinkOrder without consulting the GPO API.
     
    .PARAMETER ADObject
        The adobject from which to take the gPLink property.
     
    .PARAMETER PolicyMapping
        Hashtable mapping distinguished names of group policies to their respective displayname.
     
    .EXAMPLE
        PS C:\> $adObjects | ConvertTo-GPLink
 
        Converts all objects in $adObjects to GPLink metadata.
    #>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $ADObject,

        [Hashtable]
        $PolicyMapping = @{ }
    )

    begin {
        $statusMapping = @{
            "0" = 'Enabled'
            "1" = 'Disabled'
            "2" = 'Enforced'
        }
    }
    process {
        foreach ($adItem in $ADObject) {
            if (-not $adItem.gPLink) { continue }
            if ([string]::IsNullOrWhiteSpace($adItem.gPLink)) { continue }

            $pieces = $adItem.gPLink -Split "\[" | Remove-PSFNull
            $index = ($pieces | Measure-Object).Count

            foreach ($gpLink in $pieces) {
                $linkObject = [PSCustomObject]@{
                    ADObject = $adItem
                    DistinguishedName = ($gpLink -replace '^LDAP://|;\d\]$')
                    Status = $statusMapping[($gpLink -replace '^.+;|\]$')]
                    DisplayName = $PolicyMapping[($gpLink -replace '^LDAP://|;\d\]$')]
                    Precedence = $index
                }
                Add-Member -InputObject $linkObject -MemberType ScriptMethod -Name ToString -Value {
                    switch ($this.Status) {
                        'Enabled' { $this.DisplayName }
                        'Disabled' { '~|{0}' -f $this.DisplayName }
                        'Enforced' { '*|{0}' -f $this.DisplayName }
                    }
                } -Force
                Add-Member -InputObject $linkObject -MemberType ScriptMethod -Name ToLink -Value {
                    # [LDAP://cn={F4A6ADB1-BEDE-497D-901F-F24B19394951},cn=policies,cn=system,DC=contoso,DC=com;0][LDAP://cn={2036B9B6-D5C1-4756-B7AB-8291A9B26521},cn=policies,cn=system,DC=contoso,DC=com;0]
                    $status = '0'
                    if ($this.Status -eq 'Disabled') { $status = '1' }
                    if ($this.Status -eq 'Enforced') { $status = '2' }
                    '[LDAP://{0};{1}]' -f $this.DistinguishedName, $status
                }
                $linkObject
                $index--
            }
        }
    }
}

function Get-LinkedPolicy {
    <#
    .SYNOPSIS
        Scans all managed OUs and returns linked GPOs.
     
    .DESCRIPTION
        Scans all managed OUs and returns linked GPOs.
        Use Set-DMContentMode to define what OUs are considered "managed".
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-LinkedPolicy @parameters
 
        Returns all group policy objects that are linked to OUs under management.
    #>

    [CmdletBinding()]
    param (
        [string]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        
        # OneLevel needs to be converted to base, as searching for OUs with "OneLevel" would return unmanaged OUs.
        # This search however is targeted at GPOs linked to managed OUs only.
        $translateScope = @{
            'Subtree'  = 'Subtree'
            'OneLevel' = 'Base'
            'Base'     = 'Base'
        }
        
        $gpoProperties = 'DisplayName', 'Description', 'DistinguishedName', 'CN', 'Created', 'Modified', 'gPCFileSysPath', 'ObjectGUID', 'isCriticalSystemObject', 'VersionNumber', 'gPCWQLFilter'

        $wmiFilters = Get-ADWmiFilter @parameters
    }
    process {
        $adObjects = foreach ($searchBase in (Resolve-ContentSearchBase @parameters)) {
            Get-ADObject @parameters -LDAPFilter '(gPLink=*)' -SearchBase $searchBase.SearchBase -SearchScope $translateScope[$searchBase.SearchScope] -Properties gPLink
        }
        foreach ($adObject in $adObjects) {
            Add-Member -InputObject $adObject -MemberType NoteProperty -Name LinkedGroupPolicyObjects -Value ($adObject.gPLink | Split-GPLink) -Force
        }
        foreach ($adPolicyObject in ($adObjects.LinkedGroupPolicyObjects | Select-Object -Unique | Get-ADObject @parameters -Properties $gpoProperties)) {
            $result = [PSCustomObject]@{
                PSTypeName        = 'DomainManagement.GroupPolicy.Linked'
                DisplayName       = $adPolicyObject.DisplayName
                Description       = $adPolicyObject.Description
                DistinguishedName = $adPolicyObject.DistinguishedName
                LinkedTo          = $adObjects | Where-Object LinkedGroupPolicyObjects -Contains $adPolicyObject.DistinguishedName
                CN                = $adPolicyObject.CN
                Created           = $adPolicyObject.Created
                Modified          = $adPolicyObject.Modified
                Path              = $adPolicyObject.gPCFileSysPath
                ObjectGUID        = $adPolicyObject.ObjectGUID
                IsCritical        = $adPolicyObject.isCriticalSystemObject
                ADVersion         = $adPolicyObject.VersionNumber
                ExportID          = $null
                ImportTime        = $null
                WmiFilter         = $null
                Version           = -1
                State             = "Unknown"
            }

            if ($adPolicyObject.gPCWQLFilter) {
                $result.WmiFilter = "<unknown: $($adPolicyObject.gPCWQLFilter))=>"
                $registeredID = ($adPolicyObject.gPCWQLFilter -split ";")[1]
                $wmiFilter = $wmiFilters | Where-Object ID -eq $registeredID
                if ($wmiFilter) { $result.WmiFilter = $wmiFilter.Name }
            }
            $result
        }
    }
}


function Install-GroupPolicy {
    <#
    .SYNOPSIS
        Uses PowerShell remoting to install a GPO into the target domain.
     
    .DESCRIPTION
        Uses PowerShell remoting to install a GPO into the target domain.
        Installation does not support using a Migration Table.
        Overwrites an existing GPO, if one with the same name exists.
        Also includes a tracking file to detect drift and when an update becomes necessary.
     
    .PARAMETER Session
        The PowerShell remoting session to the domain controller on which to import the GPO.
     
    .PARAMETER Configuration
        The configuration object representing the desired state for the GPO
     
    .PARAMETER WorkingDirectory
        The folder on the target machine where GPO-related working files are stored.
        Everything inside this folder is subject to deletion.
     
    .EXAMPLE
        PS C:\> Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop
 
        Installs the specified group policy on the remote system connected to via $session.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")]
    [CmdletBinding()]
    param (
        [System.Management.Automation.Runspaces.PSSession]
        $Session,
        
        [PSObject]
        $Configuration,
        
        [string]
        $WorkingDirectory
    )
    
    begin {
        $timestamp = (Get-Date).AddMinutes(-5)
        
        $stopDefault = @{
            Target          = $Configuration
            Cmdlet          = $PSCmdlet
            EnableException = $true
        }
    }
    process {
        Write-PSFMessage -Level Debug -String 'Install-GroupPolicy.CopyingFiles' -StringValues $Configuration.DisplayName -Target $Configuration
        try { Copy-Item -Path $Configuration.Path -Destination $WorkingDirectory -Recurse -ToSession $Session -ErrorAction Stop -Force -Confirm:$false }
        catch { Stop-PSFFunction @stopDefault -String 'Install-GroupPolicy.CopyingFiles.Failed' -StringValues $Configuration.DisplayName -ErrorRecord $_ }
        
        #region Installing Group Policy Object
        Write-PSFMessage -Level Debug -String 'Install-GroupPolicy.ImportingConfiguration' -StringValues $Configuration.DisplayName -Target $Configuration
        try {
            Invoke-Command -Session $session -ArgumentList $Configuration, $WorkingDirectory -ScriptBlock {
                param (
                    $Configuration,
                    
                    $WorkingDirectory
                )
                try {
                    $domain = Get-ADDomain -Server localhost
                    $paramImportGPO = @{
                        Domain         = $domain.DNSRoot
                        Server         = $env:COMPUTERNAME
                        BackupGpoName  = $Configuration.DisplayName
                        TargetName     = $Configuration.DisplayName
                        Path           = $WorkingDirectory
                        CreateIfNeeded = $true
                        ErrorAction    = 'Stop'
                    }
                    $null = Import-GPO @paramImportGPO
                }
                catch { throw }
            } -ErrorAction Stop
        }
        catch { Stop-PSFFunction @stopDefault -String 'Install-GroupPolicy.ImportingConfiguration.Failed' -StringValues $Configuration.DisplayName -ErrorRecord $_ }
        #endregion Installing Group Policy Object
        
        #region Applying Registry Settings
        $resolvedName = $Configuration.DisplayName | Resolve-String @parameters
        $applicableRegistrySettings = Get-DMGPRegistrySetting | Where-Object {
            $resolvedName -eq ($_.PolicyName | Resolve-String @parameters)
        }
        if ($applicableRegistrySettings) {
            $registryData = foreach ($applicableRegistrySetting in $applicableRegistrySettings) {
                if ($applicableRegistrySetting.PSObject.Properties.Name -contains 'Value') {
                    [PSCustomObject]@{
                        GPO       = $resolvedName
                        Key       = Resolve-String @parameters -Text $applicableRegistrySetting.Key
                        ValueName = Resolve-String @parameters -Text $applicableRegistrySetting.ValueName
                        Type      = $applicableRegistrySetting.Type
                        Value     = $applicableRegistrySetting.Value
                    }
                }
                else {
                    [PSCustomObject]@{
                        GPO       = $resolvedName
                        Key       = Resolve-String @parameters -Text $applicableRegistrySetting.Key
                        ValueName = Resolve-String @parameters -Text $applicableRegistrySetting.ValueName
                        Type      = $applicableRegistrySetting.Type
                        Value     = ((Invoke-DMDomainData @parameters -Name $applicableRegistrySetting.DomainData).Data | Write-Output)
                    }
                }
            }
            Write-PSFMessage -Level Debug -String 'Install-GroupPolicy.Importing.RegistryValues' -StringValues $Configuration.DisplayName -Target $Configuration
            foreach ($registryDatum in $registryData) {
                try {
                    Invoke-Command -Session $session -ArgumentList $registryDatum -ScriptBlock {
                        param ($RegistryDatum)
                        $domain = Get-ADDomain -Server localhost
                        $null = Get-GPO -Server localhost -Domain $domain.DNSRoot -Name $RegistryDatum.GPO -ErrorAction Stop | Set-GPRegistryValue -Server localhost -Domain $domain.DNSRoot -Key $RegistryDatum.Key -ValueName $RegistryDatum.ValueName -Type $RegistryDatum.Type -Value $RegistryDatum.Value -ErrorAction Stop
                    } -ErrorAction Stop
                }
                catch {
                    Stop-PSFFunction @stopDefault -String 'Install-GroupPolicy.Importing.RegistryValues.Failed' -StringValues $Configuration.DisplayName, $registryDatum.Key, $registryDatum.ValueName -ErrorRecord $_
                }
            }
        }
        #endregion Applying Registry Settings
        
        Write-PSFMessage -Level Debug -String 'Install-GroupPolicy.ReadingADObject' -StringValues $Configuration.DisplayName -Target $Configuration
        try {
            $policyObject = Invoke-Command -Session $session -ArgumentList $Configuration -ScriptBlock {
                param ($Configuration)
                Get-ADObject -Server localhost -LDAPFilter "(&(objectCategory=groupPolicyContainer)(DisplayName=$($Configuration.DisplayName)))" -Properties Modified, gPCFileSysPath, gPCWQLFilter, versionNumber -ErrorAction Stop
            } -ErrorAction Stop
        }
        catch { Stop-PSFFunction @stopDefault -String 'Install-GroupPolicy.ReadingADObject.Failed.Error' -StringValues $Configuration.DisplayName -ErrorRecord $_ }
        if (-not $policyObject) { Stop-PSFFunction @stopDefault -String 'Install-GroupPolicy.ReadingADObject.Failed.NoObject' -StringValues $Configuration.DisplayName }
        if ($policyObject.Modified -lt $timestamp) { Stop-PSFFunction @stopDefault -String 'Install-GroupPolicy.ReadingADObject.Failed.Timestamp' -StringValues $Configuration.DisplayName, $policyObject.Modified, $timestamp }

        #region Apply WMI Filters
        if ($Configuration.WmiFilter -or $policyObject.gPCWQLFilter) {
            $code = {
                param ($Configuration, $PolicyObject)
                $adParam = @{ Server = 'localhost' }

                if (-not $Configuration.WmiFilter) {
                    try {
                        Set-ADObject @adParam -Identity $PolicyObject.DistinguishedName -Clear 'gPCWQLFilter' -ErrorAction Stop
                        [PSCustomObject]@{
                            Success = $true
                            Message = ''
                        }
                    }
                    catch {
                        [PSCustomObject]@{
                            Success = $false
                            Message = "Error clearing WMI Filter: $_"
                        }
                    }
                    return
                }

                $wmiFilter = Get-ADObject @adParam -LDAPFilter "(&(objectClass=msWMI-Som)(msWMI-Name=$($Configuration.WmiFilter)))" -Properties msWMI-ID
                if (-not $wmiFilter) {
                    [PSCustomObject]@{
                        Success = $false
                        Message = "WMI Filter does not exist! $($Configuration.WmiFilter)"
                    }
                    return
                }

                $domain = Get-ADDomain @adParam
                $filterProperty = '[{0};{1};0]' -f $domain.DnsRoot, $wmiFilter.'msWMI-ID'
                try {
                    Set-ADObject @adParam -Identity $PolicyObject.DistinguishedName -Replace @{ 'gPCWQLFilter' = $filterProperty } -ErrorAction Stop
                    [PSCustomObject]@{
                        Success = $true
                        Message = ''
                    }
                }
                catch {
                    [PSCustomObject]@{
                        Success = $false
                        Message = "Error applying WMI Filter: $_"
                    }
                }
            }
            Invoke-PSFProtectedCommand -ActionString 'Install-GroupPolicy.WmiFilter' -ActionStringValues $Configuration.DisplayName, $Configuration.WmiFilter -ScriptBlock {
                $wmiResult = Invoke-Command -Session $session -ArgumentList $Configuration,$policyObject -ScriptBlock $code -ErrorAction Stop
            } -Target $Configuration -EnableException $true -PSCmdlet $PSCmdlet

            if (-not $wmiResult.Success) {
                Write-PSFMessage -Level Warning -String 'Install-GroupPolicy.WmiFilter.Failed' -StringValues $Configuration.DisplayName, $Configuration.WmiFilter, $wmiResult.Message -Target $Configuration
            }
        }
        #endregion Apply WMI Filters
        
        #region Create/Update ADMF Tracking File
        Write-PSFMessage -Level Debug -String 'Install-GroupPolicy.UpdatingConfigurationFile' -StringValues $Configuration.DisplayName -Target $Configuration
        try {
            Invoke-Command -Session $session -ArgumentList $Configuration, $policyObject -ScriptBlock {
                param (
                    $Configuration,
                    
                    $PolicyObject
                )
                $object = [PSCustomObject]@{
                    ExportID  = $Configuration.ExportID
                    Timestamp = $PolicyObject.Modified
                    Version   = $PolicyObject.VersionNumber
                }
                $object | Export-Clixml -Path "$($PolicyObject.gPCFileSysPath)\dm_config.xml" -Force -ErrorAction Stop
            } -ErrorAction Stop
        }
        catch { Stop-PSFFunction @stopDefault -String 'Install-GroupPolicy.UpdatingConfigurationFile.Failed' -StringValues $Configuration.DisplayName -ErrorRecord $_ }
        #endregion Create/Update ADMF Tracking File

        Write-PSFMessage -Level Debug -String 'Install-GroupPolicy.DeletingImportFiles' -StringValues $Configuration.DisplayName -Target $Configuration
        Invoke-Command -Session $session -ArgumentList $WorkingDirectory -ScriptBlock {
            param ($WorkingDirectory)
            Remove-Item -Path "$WorkingDirectory\*" -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
}

function New-GpoWorkingDirectory
{
    <#
    .SYNOPSIS
        Creates a new temporary folder for GPO import.
     
    .DESCRIPTION
        Creates a new temporary folder for GPO import.
        Used during Invoke-DMGroupPolicy to ennsure a local working directory.
     
    .PARAMETER Session
        The powershell session to the target server operations are performed on.
     
    .EXAMPLE
        PS C:\> $workingFolder = New-GpoWorkingDirectory -Session $session
 
        Ensures the working folder exists and stores the session-local path in the $workingFolder variable.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [OutputType([string])]
    [CmdletBinding()]
    Param (
        [System.Management.Automation.Runspaces.PSSession]
        $Session
    )
    
    process
    {
        try
        {
            Invoke-Command -Session $Session -ScriptBlock {
                if ($env:temp) {
                    try {
                        $item = New-Item -Path $env:temp -Name DM_GPOImport -ItemType Directory -ErrorAction Stop -Force
                        $item.FullName
                    }
                    catch { throw "Failed to create folder in %temp%: $_" }
                }
                elseif (Test-Path C:\temp) {
                    try {
                        $item = New-Item -Path C:\temp -Name DM_GPOImport -ItemType Directory -ErrorAction Stop -Force
                        $item.FullName
                    }
                    catch { throw "Failed to create folder in C:\temp: $_" }
                }
                else {
                    try {
                        $item = New-Item -Path C:\ -Name temp_DM_GPOImport -ItemType Directory -ErrorAction Stop -Force
                        $item.FullName
                    }
                    catch { throw "Failed to create folder in C:\: $_" }
                }
            } -ErrorAction Stop
        }
        catch { throw }
    }
}


function Remove-GroupPolicy
{
    <#
    .SYNOPSIS
        Removes the specified group policy object.
     
    .DESCRIPTION
        Removes the specified group policy object.
     
    .PARAMETER Session
        PowerShell remoting session to the server on which to perform the operation.
     
    .PARAMETER ADObject
        AD object data retrieved when scanning the domain using Get-LinkedPolicy.
     
    .EXAMPLE
        PS C:\> Remove-GroupPolicy -Session $session -ADObject $testItem.ADObject -ErrorAction Stop
 
        Removes the specified group policy object.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    Param (
        [System.Management.Automation.Runspaces.PSSession]
        $Session,

        [PSObject]
        $ADObject
    )
    
    process
    {
        Write-PSFMessage -Level Debug -String 'Remove-GroupPolicy.Deleting' -StringValues $ADObject.DisplayName -Target $ADobject
        try {
            Invoke-Command -Session $Session -ArgumentList $ADObject -ScriptBlock {
                param (
                    $ADObject
                )
                $domainObject = Get-ADDomain -Server localhost

                Remove-GPO -Name $ADObject.DisplayName -ErrorAction Stop -Confirm:$false -Server $domainObject.PDCEmulator -Domain $domainObject.DNSRoot
            } -ErrorAction Stop
        }
        catch { Stop-PSFFunction -String 'Remove-GroupPolicy.Deleting.Failed' -StringValues $ADObject.DisplayName -ErrorRecord $_ -EnableException $true -Cmdlet $PSCmdlet }
    }
}


function Resolve-GPFilterMapping {
    <#
    .SYNOPSIS
        Determines which filter conditions apply to which GPO
     
    .DESCRIPTION
        Determines which filter conditions apply to which GPO
        Used by components that apply rules based on GPOs, such as GP Permissions and GP Ownership.
     
    .PARAMETER Conditions
        The list of conditions that need to be evaluated.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Resolve-GPFilterMapping @parameters -Conditions ($ownerConfig.FilterConditions | Remove-PSFNull -Enumerate | Sort-Object -Unique)
 
        Returns a mapping of which of the conditions needed and what GPOs they apply to.
    #>

    [CmdletBinding()]
    param (
        [AllowEmptyCollection()]
        [string[]]
        $Conditions,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )

    process {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential

        $result = [PSCustomObject]@{
            Success          = $true
            Mapping          = @{ }
            Conditions       = $Conditions
            AllGpos = @()
            MissingCondition = $null
            ErrorType        = 'None'
            ErrorData        = @()
            ErrorTarget      = $null
        }

        $allFilters = @{ }
        foreach ($filterObject in Get-DMGPPermissionFilter) {
            $allFilters[$filterObject.Name] = $filterObject
        }

        $result.MissingCondition = $Conditions | Where-Object { $_ -notin $allFilters.Keys }
        if ($result.MissingCondition) {
            $result.ErrorType = 'MissingCondition'
            $result.Success = $false
            $result
            return
        }

        if ($Conditions) { $relevantFilters = $allFilters | ConvertTo-PSFHashtable -Include $Conditions }
        else { $relevantFilters = @() }

        $allGpos = Get-ADObject @parameters -LDAPFilter '(objectCategory=groupPolicyContainer)' -Properties DisplayName
        $result.AllGpos = $allGpos
        $filterToGPOMapping = @{ }
        $managedGPONames = (Get-DMGroupPolicy).DisplayName | Resolve-String
        #region Process individual filter conditions
        :conditions foreach ($condition in $relevantFilters.Values) {
            switch ($condition.Type) {
                #region Managed - Do we define the policy using the GroupPolicy Component?
                'Managed' {
                    if ($condition.Reverse -xor (-not $condition.Managed)) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -NotIn $managedGPONames }
                    else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -In $managedGPONames }
                }
                #endregion Managed - Do we define the policy using the GroupPolicy Component?

                #region Path - Resolve by where GPOs are linked
                'Path' {
                    $searchBase = Resolve-String -Text $condition.Path
                    if (-not (Test-ADObject @parameters -Identity $searchBase)) {
                        if ($condition.Optional) {
                            Write-PSFMessage -String 'Resolve-GPFilterMapping.Filter.Path.DoesNotExist.SilentlyContinue' -StringValues $Condition.Name, $searchBase -Target $condition
                            continue conditions
                        }
                        $result.Success = $false
                        $result.ErrorType = 'PathNotFound'
                        $result.ErrorData = $searchBase
                        $result.ErrorTarget = $condition
                        $result
                        return
                    }

                    $objects = Get-ADObject @parameters -SearchBase $searchBase -SearchScope $condition.Scope -LDAPFilter '(|(objectCategory=OrganizationalUnit)(objectCategory=domainDNS))' -Properties gPLink
                    $allLinkedGpoDNs = $objects | ConvertTo-GPLink | Select-Object -ExpandProperty DistinguishedName -Unique
                    if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DistinguishedName -NotIn $allLinkedGpoDNs }
                    else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DistinguishedName -In $allLinkedGpoDNs }
                }
                #endregion Path - Resolve by where GPOs are linked

                #region GPName - Match by name, using either direct comparison, wildcard or regex
                'GPName' {
                    $resolvedGpoName = Resolve-String -Text $condition.GPName
                    switch ($condition.Mode) {
                        'Explicit' {
                            if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -NE $resolvedGpoName }
                            else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -EQ $resolvedGpoName }
                        }
                        'Wildcard' {
                            if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -NotLike $resolvedGpoName }
                            else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -Like $resolvedGpoName }
                        }
                        'Regex' {
                            if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -NotMatch $resolvedGpoName }
                            else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -Match $resolvedGpoName }
                        }
                    }
                }
                #endregion GPName - Match by name, using either direct comparison, wildcard or regex
            }
        }
        #endregion Process individual filter conditions
        $result.Mapping = $filterToGPOMapping
        $result
    }
}

function Resolve-PolicyRevision
{
    <#
    .SYNOPSIS
        Checks the management state information of the specified policy object.
     
    .DESCRIPTION
        Checks the management state information of the specified policy object.
        It uses PowerShell remoting to read the configuration file with the associated group policy.
        This configuration file is stored when deploying a group policy using Invoke-DMGroupPolicy.
 
        This process is required to ensure only policies that need updating are thus updated.
     
    .PARAMETER Policy
        The policy object to validate and add the state information to.
     
    .PARAMETER Session
        The PowerShell Session to the PDCEmulator of the domain the GPO is part of.
     
    .EXAMPLE
        PS C:\> Resolve-PolicyRevision -Policy $managedPolicy -Session $session
 
        Checks the management state information of the specified policy object.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")]
    [CmdletBinding()]
    Param (
        [psobject]
        $Policy,

        [System.Management.Automation.Runspaces.PSSession]
        $Session
    )
    
    process
    {
        #region Remote Call - Resolve GPO data => $result
        $result = Invoke-Command -Session $Session -ArgumentList $Policy.Path -ScriptBlock {
            param (
                $Path
            )

            $testPath = Join-Path -Path $Path -ChildPath gpt.ini
            $configPath = Join-Path -Path $Path -ChildPath dm_config.xml

            if (-not (Test-Path $testPath)) {
                [pscustomobject]@{
                    Success = $false
                    Exists = $null
                    ExportID = $null
                    Timestamp = $null
                    Version = -1
                    Error = $null
                }
                return
            }
            if (-not (Test-Path $configPath)) {
                [pscustomobject]@{
                    Success = $true
                    Exists = $false
                    ExportID = $null
                    Timestamp = $null
                    Version   = -1
                    Error = $null
                }
                return
            }
            try { $data = Import-Clixml -Path $configPath -ErrorAction Stop }
            catch {
                [pscustomobject]@{
                    Success = $false
                    Exists = $true
                    ExportID = $null
                    Timestamp = $null
                    Version   = -1
                    Error = $_
                }
                return
            }
            [pscustomobject]@{
                Success = $true
                Exists = $true
                ExportID = $data.ExportID
                Timestamp = $data.Timestamp
                Version  = $data.Version
                Error = $null
            }
        }
        #endregion Remote Call - Resolve GPO data => $result

        #region Process results
        $Policy.ExportID = $result.ExportID
        $Policy.ImportTime = $result.Timestamp
        $Policy.Version = $result.Version

        if (-not $result.Success) {
            if ($result.Exists) {
                $Policy.State = 'ConfigError'
                Write-PSFMessage -Level Debug -String 'Resolve-PolicyRevision.Result.ErrorOnConfigImport' -StringValues $Policy.DisplayName, $result.Error.Exception.Message -Target $Policy }
                throw $result.Error
            else {
                $Policy.State = 'CriticalError'
                Write-PSFMessage -Level Debug -String 'Resolve-PolicyRevision.Result.PolicyError' -StringValues $Policy.DisplayName -Target $Policy
                throw "Policy object not found in filesystem. Check existence and permissions!"
            }
        }
        else {
            if ($result.Exists) {
                $Policy.State  = 'Healthy'
                Write-PSFMessage -Level Debug -String 'Resolve-PolicyRevision.Result.Success' -StringValues $Policy.DisplayName, $result.ExportID, $result.Timestamp -Target $Policy }
            else {
                $Policy.State = 'Unmanaged'
                Write-PSFMessage -Level Debug -String 'Resolve-PolicyRevision.Result.Result.SuccessNotYetManaged' -StringValues $Policy.DisplayName -Target $Policy
            }
        }
        #endregion Process results
    }
}


function Split-GPLink {
    <#
    .SYNOPSIS
        Splits up the gPLink string on an AD object.
     
    .DESCRIPTION
        Splits up the gPLink string on an AD object.
        Returns the distinguishedname of the linked policies in the order they are linked.
     
    .PARAMETER LinkText
        The text from the gPLink property
     
    .EXAMPLE
        PS C:\> $adObject.gPLink | Split-GPLink
 
        Returns the distinguishednames of all linked group policies.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string[]]
        $LinkText
    )
    process
    {
        foreach ($line in $LinkText) {
            $lines = $line -split "\]\[" -replace '\]|\[' -replace '^LDAP://|;\d$'
            foreach ($lineItem in $lines) {
                if ([string]::IsNullOrWhiteSpace($lineItem)) { continue }
                $lineItem
            }
        }
    }
}

function Test-GPPermissionFilter {
    <#
        .SYNOPSIS
            Tests, whether a GP Permission Filter applies to a specific GPO.
 
        .DESCRIPTION
            Tests, whether a GP Permission Filter applies to a specific GPO.
            Used primarily by Test-DMGPPermission to resolve applicable permissions that have target selection through filters.
 
        .PARAMETER GpoName
            The name of the GPO that is tested against.
 
        .PARAMETER Filter
            The filter string the represents the condition on which it applies.
 
        .PARAMETER Conditions
            The list of filter conditions contained in the filter-string.
            These are processed/parsed out when registering the filter using Register-DMGPPermissionFilter.
 
        .PARAMETER FilterHash
            The hashtable mapping filter to list of GPOs that the filter applies to.
 
        .EXAMPLE
            PS C:\> Test-GPPermissionFilter -GpoName $permissionObject.Name -Filter $_.Filter -Conditions $_.FilterConditions -FilterHash $filterToGPOMapping
 
            Tests, whether a GP Permission Filter applies to the specified GPO.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '')]
    [OutputType([System.Boolean])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $GpoName,

        [Parameter(Mandatory = $true)]
        [AllowNull()]
        [AllowEmptyString()]
        [string]
        $Filter,

        [Parameter(Mandatory = $true)]
        [AllowNull()]
        [AllowEmptyString()]
        [string[]]
        $Conditions,

        [Parameter(Mandatory = $true)]
        [hashtable]
        $FilterHash
    )

    if (-not $Filter.Trim()) { return $false }

    $testResults = @{ }
    foreach ($condition in $Conditions) {
        $testResults[$condition] = $FilterHash[$condition].DisplayName -contains $GpoName
    }

    $predicate = {
        param (
            $MatchInfo
        )

        "`$testResults['$($MatchInfo.Value)']"
    }
    $pattern = $Conditions -join "|"
    $resolvedFilter = [regex]::Replace($Filter, $pattern, $predicate)

    <#
    This is actually a safe operation:
    - The filter condition is tokenized and parsed for a very limited set of legal tokens (logical operators, parenthesis and filter names)
    - The filter names are constrained so that only letters, numbers and underscores can be used, making them safe for regex and injection purposes.
    These safety measures have been implemented in the parameter validations of Register-DMGPPermission and Register-DMGPPermissionFilter
    #>

    Invoke-Expression $resolvedFilter
}

function Get-DMAccessRule
{
    <#
    .SYNOPSIS
        Returns the list of configured access rules.
     
    .DESCRIPTION
        Returns the list of configured access rules.
        These access rules define the desired state where delegation in a domain is concerned.
        This is consumed by Test-DMAccessRule, see the help on that command for more details.
     
    .PARAMETER Identity
        The Identity to filter by.
        This allows swiftly filtering by who is being granted permission.
     
    .EXAMPLE
        PS C:\> Get-DMAccessRule
 
        Returns a list of all registered accessrules
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Identity = '*'
    )
    
    process
    {
        ($script:accessRules.Values | Write-Output | Where-Object IdentityReference -like $Identity)
        ($script:accessCategoryRules.Values | Write-Output | Where-Object IdentityReference -like $Identity)
    }
}


function Invoke-DMAccessRule {
    <#
    .SYNOPSIS
        Applies the desired state of accessrule configuration.
     
    .DESCRIPTION
        Applies the desired state of accessrule configuration.
        Define the desired state with Register-DMAccessRule.
        Test the desired state with Test-DMAccessRule.
     
    .PARAMETER InputObject
        Test results provided by the associated test command.
        Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Invoke-DMAccessRule -Server contoso.com
 
        Applies the desired access rule configuration to the contoso.com domain.
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type accessRules -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters

        $alternativeRemoval = Get-PSFConfigValue -FullName 'DomainManagement.AccessRules.Remove.Option2' -Fallback $false
    }
    process {
        if (-not $InputObject) {
            $InputObject = Test-DMAccessRule @parameters
        }
        
        foreach ($testItem in $InputObject) {
            # Catch invalid input - can only process test results
            if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.AccessRule.TestResult') {
                Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMAccessRule', $testItem -Target $testItem -Continue -EnableException $EnableException
            }
            
            switch ($testItem.Type) {
                'Update' {
                    Write-PSFMessage -Level Debug -String 'Invoke-DMAccessRule.Processing.Rules' -StringValues $testItem.Identity, $testItem.Changed.Count -Target $testItem

                    try { $aclObject = Get-AdsAcl @parameters -Path $testItem.Identity -EnableException }
                    catch { Stop-PSFFunction -String 'Invoke-DMAccessRule.Access.Failed' -StringValues $testItem.Identity -EnableException $EnableException -Target $testItem -Continue -ErrorRecord $_ }
                    $failedCount = 0
                    foreach ($changeEntry in $testItem.Changed) {
                        #region Remove Access Rules
                        if ($changeEntry.Type -eq 'Delete') {
                            Write-PSFMessage -Level InternalComment -String 'Invoke-DMAccessRule.AccessRule.Remove' -StringValues $changeEntry.ADObject.IdentityReference, $changeEntry.ADObject.ActiveDirectoryRights, $changeEntry.ADObject.AccessControlType, $changeEntry.DistinguishedName -Target $changeEntry
                            $aclObject.RemoveAccessRuleSpecific($changeEntry.ADObject.OriginalRule)
                            Remove-RedundantAce -AccessControlList $aclObject -IdentityReference $changeEntry.ADObject.OriginalRule.IdentityReference
                            
                            $stillThere = $false
                            foreach ($rule in $aclObject.GetAccessRules($true, $false, [System.Security.Principal.NTAccount])) {
                                if (Test-AccessRuleEquality -Parameters $parameters -Rule1 $rule -Rule2 $changeEntry.ADObject.OriginalRule) {
                                    $stillThere = $true
                                    $failedCount = $failedCount + 1
                                    break
                                }
                            }

                            if ($stillThere -and $alternativeRemoval) {
                                $null = $aclObject.RemoveAccessRule($changeEntry.ADObject.OriginalRule)
                                Remove-RedundantAce -AccessControlList $aclObject -IdentityReference $changeEntry.ADObject.OriginalRule.IdentityReference
                            
                                $stillThere = $false
                                foreach ($rule in $aclObject.GetAccessRules($true, $false, [System.Security.Principal.NTAccount])) {
                                    if (Test-AccessRuleEquality -Parameters $parameters -Rule1 $rule -Rule2 $changeEntry.ADObject.OriginalRule) {
                                        $stillThere = $true
                                        $failedCount = $failedCount + 1
                                        break
                                    }
                                }
                            }

                            if ($stillThere) {
                                Write-PSFMessage -Level Warning -String 'Invoke-DMAccessRule.AccessRule.Remove.Failed' -StringValues $changeEntry.ADObject.IdentityReference, $changeEntry.ADObject.ActiveDirectoryRights, $changeEntry.ADObject.AccessControlType, $changeEntry.DistinguishedName -Target $changeEntry -Debug:$false
                            }
                            continue
                        }
                        #endregion Remove Access Rules

                        #region Add Access Rules
                        if ($changeEntry.Type -eq 'Create') {
                            Write-PSFMessage -Level InternalComment -String 'Invoke-DMAccessRule.AccessRule.Create' -StringValues $changeEntry.Configuration.IdentityReference, $changeEntry.Configuration.ActiveDirectoryRights, $changeEntry.Configuration.AccessControlType -Target $changeEntry
                            try {
                                if (-not $changeEntry.Configuration.ObjectType) { throw "Unknown ObjectType! Unable to translate $($changeEntry.Configuration.ObjectTypeName). Validate the configuration and ensure pending schema updates (e.g. Exchange, Skype, etc.) have been applied." }
                                if (-not $changeEntry.Configuration.InheritedObjectType) { throw "Unknown InheritedObjectType! Unable to translate $($changeEntry.Configuration.InheritedObjectTypeName). Validate the configuration and ensure pending schema updates (e.g. Exchange, Skype, etc.) have been applied." }
                                $accessRule = [System.DirectoryServices.ActiveDirectoryAccessRule]::new((Convert-Principal @parameters -Name $changeEntry.Configuration.IdentityReference), $changeEntry.Configuration.ActiveDirectoryRights, $changeEntry.Configuration.AccessControlType, $changeEntry.Configuration.ObjectType, $changeEntry.Configuration.InheritanceType, $changeEntry.Configuration.InheritedObjectType)
                            }
                            catch {
                                $failedCount = $failedCount + 1
                                Stop-PSFFunction -String 'Invoke-DMAccessRule.AccessRule.Creation.Failed' -StringValues $testItem.Identity, $changeEntry.Configuration.IdentityReference -EnableException $EnableException -Target $changeEntry -Continue -ErrorRecord $_
                            }
                            $null = $aclObject.AddAccessRule($accessRule)
                            #TODO: Validation and remediation of success. Adding can succeed but not do anything, when accessrules are redundant. Potentially flag it for full replacement?
                            continue
                        }
                        #endregion Add Access Rules

                        #region Restore Default Access Rules
                        if ($changeEntry.Type -eq 'Restore') {
                            Write-PSFMessage -Level InternalComment -String 'Invoke-DMAccessRule.AccessRule.Restore' -StringValues $changeEntry.Configuration.IdentityReference, $changeEntry.Configuration.ActiveDirectoryRights, $changeEntry.Configuration.AccessControlType -Target $changeEntry
                            try {
                                if (-not $changeEntry.Configuration.ObjectType) { throw "Unknown ObjectType! Unable to translate $($changeEntry.Configuration.ObjectTypeName). Validate the configuration and ensure pending schema updates (e.g. Exchange, Skype, etc.) have been applied." }
                                if (-not $changeEntry.Configuration.InheritedObjectType) { throw "Unknown InheritedObjectType! Unable to translate $($changeEntry.Configuration.InheritedObjectTypeName). Validate the configuration and ensure pending schema updates (e.g. Exchange, Skype, etc.) have been applied." }
                                $accessRule = [System.DirectoryServices.ActiveDirectoryAccessRule]::new((Convert-Principal @parameters -Name $changeEntry.Configuration.IdentityReference), $changeEntry.Configuration.ActiveDirectoryRights, $changeEntry.Configuration.AccessControlType, $changeEntry.Configuration.ObjectType, $changeEntry.Configuration.InheritanceType, $changeEntry.Configuration.InheritedObjectType)
                            }
                            catch {
                                $failedCount = $failedCount + 1
                                Stop-PSFFunction -String 'Invoke-DMAccessRule.AccessRule.Creation.Failed' -StringValues $testItem.Identity, $changeEntry.Configuration.IdentityReference -EnableException $EnableException -Target $changeEntry -Continue -ErrorRecord $_
                            }
                            $null = $aclObject.AddAccessRule($accessRule)
                            #TODO: Validation and remediation of success. Adding can succeed but not do anything, when accessrules are redundant. Potentially flag it for full replacement?
                            continue
                        }
                        #endregion Restore Default Access Rules
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMAccessRule.Processing.Execute' -ActionStringValues ($testItem.Changed.Count - $failedCount), $testItem.Changed.Count -Target $testItem -ScriptBlock {
                        Set-AdsAcl @parameters -Path $testItem.Identity -AclObject $aclObject -EnableException -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                'MissingADObject' {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMAccessRule.ADObject.Missing' -StringValues $testItem.Identity -Target $testItem -Debug:$false
                }
            }
        }
    }
}


function Register-DMAccessRule {
    <#
    .SYNOPSIS
        Registers a new access rule as a desired state.
     
    .DESCRIPTION
        Registers a new access rule as a desired state.
        These are then compared with a domain's configuration when executing Test-DMAccessRule.
        See that command for more details on this procedure.
     
    .PARAMETER Path
        The path to the AD object to govern.
        This should be a distinguishedname.
        This path uses name resolution.
        For example %DomainDN% will be replaced with the DN of the target domain itself (and should probably be part of everyy single path).
     
    .PARAMETER ObjectCategory
        Instead of a path, define a category to apply the rule to.
        Categories are defined using Register-DMObjectCategory.
        This allows you to apply rules to a category of objects, rather than a specific path.
        With this you could apply a rule to all domain controller objects, for example.
     
    .PARAMETER Identity
        The identity to apply the rule to.
        Use the string '<Parent>' to apply the rule to the parent object of the object affected by this rule.
     
    .PARAMETER AccessControlType
        Whether this is an Allow or Deny rule.
     
    .PARAMETER ActiveDirectoryRights
        The actual rights to grant.
        This is a [string] type to allow some invalid values that happen in the field and are still applied by AD.
     
    .PARAMETER InheritanceType
        How the Access Rule is being inherited.
     
    .PARAMETER InheritedObjectType
        Name or Guid of property or right affected by this rule.
        Access Rules are governed by ObjectType and InheritedObjectType to affect what objects to affect (e.g. Computer, User, ...),
        what properties to affect (e.g.: User-Account-Control) or what extended rights to grant.
        Which in what combination applies depends on the ActiveDirectoryRights set.
     
    .PARAMETER ObjectType
        Name or Guid of property or right affected by this rule.
        Access Rules are governed by ObjectType and InheritedObjectType to affect what objects to affect (e.g. Computer, User, ...),
        what properties to affect (e.g.: User-Account-Control) or what extended rights to grant.
        Which in what combination applies depends on the ActiveDirectoryRights set.
 
    .PARAMETER Optional
        The path this access rule object is assigned to is optional and need not exist.
        This makes the rule apply only if the object exists, without triggering errors if it doesn't.
        It will also ignore access errors on the object.
        Note: Only if all access rules assigned to an object are set to $true, will the object be considered optional.
 
    .PARAMETER Present
        Whether the access rule should exist or not.
        By default, it should.
        Set this to $false in order to explicitly delete an existing access rule.
        Set this to 'Undefined' to neither create nor delete it, in which case it will simply be accepted if it exists.
     
    .PARAMETER NoFixConfig
        By default, Test-DMAccessRule will generate a "FixConfig" result for accessrules that have been explicitly defined but are also part of the Schema Default permissions.
        If this setting is enabled, this result object is suppressed.
 
    .EXAMPLE
        PS C:\> Register-DMAccessRule -ObjectCategory DomainControllers -Identity '%DomainName%\Domain Admins' -ActiveDirectoryRights GenericAll
 
        Grants the domain admins of the target domain FullControl over all domain controllers, without any inheritance.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Path')]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Path')]
        [string]
        $Path,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Category')]
        [string]
        $ObjectCategory,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Identity,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ActiveDirectoryRights,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [System.Security.AccessControl.AccessControlType]
        $AccessControlType = 'Allow',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [System.DirectoryServices.ActiveDirectorySecurityInheritance]
        $InheritanceType = 'None',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $ObjectType = '<All>',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $InheritedObjectType = '<All>',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $Optional = $false,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [PSFramework.Utility.TypeTransformationAttribute([string])]
        [DomainManagement.TriBool]
        $Present = 'true',

        [bool]
        $NoFixConfig = $false
    )
    
    process {
        switch ($PSCmdlet.ParameterSetName) {
            'Path' {
                if (-not $script:accessRules[$Path]) { $script:accessRules[$Path] = @() }
                $script:accessRules[$Path] += [PSCustomObject]@{
                    PSTypeName            = 'DomainManagement.AccessRule'
                    Path                  = $Path
                    IdentityReference     = $Identity
                    AccessControlType     = $AccessControlType
                    ActiveDirectoryRights = $ActiveDirectoryRights
                    InheritanceType       = $InheritanceType
                    InheritedObjectType   = $InheritedObjectType
                    ObjectType            = $ObjectType
                    Optional              = $Optional
                    Present               = $Present
                    NoFixConfig           = $NoFixConfig
                }
            }
            'Category' {
                if (-not $script:accessCategoryRules[$ObjectCategory]) { $script:accessCategoryRules[$ObjectCategory] = @() }
                $script:accessCategoryRules[$ObjectCategory] += [PSCustomObject]@{
                    PSTypeName            = 'DomainManagement.AccessRule'
                    Category              = $ObjectCategory
                    IdentityReference     = $Identity
                    AccessControlType     = $AccessControlType
                    ActiveDirectoryRights = $ActiveDirectoryRights
                    InheritanceType       = $InheritanceType
                    InheritedObjectType   = $InheritedObjectType
                    ObjectType            = $ObjectType
                    Optional              = $Optional
                    Present               = $Present
                    NoFixConfig           = $NoFixConfig
                }
            }
        }
    }
}


function Test-DMAccessRule
{
    <#
    .SYNOPSIS
        Validates the targeted domain's Access Rule configuration.
     
    .DESCRIPTION
        Validates the targeted domain's Access Rule configuration.
        This is done by comparing each relevant object's non-inherited permissions with the Schema-given default permissions for its object type.
        Then the remaining explicit permissions that are not part of the schema default are compared with the configured desired state.
 
        The desired state can be defined using Register-DMAccessRule.
        Basically, two kinds of rules are supported:
        - Path based access rules - point at a DN and tell the system what permissions should be applied.
        - Rule based access rules - All objects matching defined conditions will be affected by the defined rules.
        To define rules - also known as Object Categories - use Register-DMObjectCategory.
        Example rules could be "All Domain Controllers" or "All Service Connection Points with the name 'Virtual Machine'"
 
        This command will test all objects that ...
        - Have at least one path based rule.
        - Are considered as "under management", as defined using Set-DMContentMode
        It uses a definitive approach - any access rule not defined will be flagged for deletion!
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Test-DMAccessRule -Server fabrikam.com
 
        Tests, whether the fabrikam.com domain conforms to the configured, desired state.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "")]
    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        #region Utility Functions
        function Compare-AccessRules {
            [CmdletBinding()]
            param (
                [AllowEmptyCollection()]
                $ADRules,
                [AllowEmptyCollection()]
                $ConfiguredRules,
                [AllowEmptyCollection()]
                $DefaultRules,
                $ADObject,

                [PSFComputer]
                $Server,
                
                [PSCredential]
                $Credential
            )

            $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential

            # Resolve the mode under which it will be evaluated. Either 'Additive' or 'Constrained'
            $processingMode = Resolve-DMAccessRuleMode @parameters -ADObject $adObject

            function Write-Result {
                [CmdletBinding()]
                param (
                    [ValidateSet('Create', 'Delete', 'FixConfig', 'Restore')]
                    [Parameter(Mandatory = $true)]
                    $Type,

                    $Identity,

                    [AllowNull()]
                    $ADObject,

                    [AllowNull()]
                    $Configuration,

                    [string]
                    $DistinguishedName
                )

                $item = [PSCustomObject]@{
                    PSTypeName = 'DomainManagement.AccessRule.Change'
                    Type = $Type
                    ACT = $ADObject.AccessControlType
                    Identity = $Identity
                    Rights = $ADObject.ActiveDirectoryRights
                    DistinguishedName = $DistinguishedName
                    ADObject = $ADObject
                    Configuration = $Configuration
                }
                if (-not $ADObject) {
                    $item.ACT = $Configuration.AccessControlType
                    $item.Rights = $Configuration.ActiveDirectoryRights
                }
                Add-Member -InputObject $item -MemberType ScriptMethod ToString -Value { '{0}: {1}' -f $this.Type, $this.Identity } -Force -PassThru
            }

            $defaultRulesPresent = [System.Collections.ArrayList]::new()
            $relevantADRules = :outer foreach ($adRule in $ADRules) {
                if ($adRule.OriginalRule.IsInherited) { continue }
                #region Skip OUs' "Protect from Accidential Deletion" ACE
                if (($adRule.AccessControlType -eq 'Deny') -and ($ADObject.ObjectClass -eq 'organizationalUnit')) {
                    if ($adRule.IdentityReference -eq 'everyone') { continue }
                    $eSid = [System.Security.Principal.SecurityIdentifier]'S-1-1-0'
                    $eName = $eSid.Translate([System.Security.Principal.NTAccount])
                    if ($adRule.IdentityReference -eq $eName) { continue }
                    if ($adRule.IdentityReference -eq $eSid) { continue }
                }
                #endregion Skip OUs' "Protect from Accidential Deletion" ACE

                foreach ($defaultRule in $DefaultRules) {
                    if (Test-AccessRuleEquality -Parameters $parameters -Rule1 $adRule -Rule2 $defaultRule) {
                        $null = $defaultRulesPresent.Add($defaultRule)
                        continue outer
                    }
                }
                $adRule
            }

            #region Foreach non-default AD Rule: Check whether configured and delete if not so
            :outer foreach ($relevantADRule in $relevantADRules) {
                foreach ($configuredRule in $ConfiguredRules) {
                    if (Test-AccessRuleEquality -Parameters $parameters -Rule1 $relevantADRule -Rule2 $configuredRule) {
                        # If explicitly defined for deletion, do so
                        if ('False' -eq $configuredRule.Present) {
                            Write-Result -Type Delete -Identity $relevantADRule.IdentityReference -ADObject $relevantADRule -DistinguishedName $ADObject
                        }
                        continue outer
                    }
                }

                # Don't generate delete changes
                if ($processingMode -eq 'Additive') { continue }
                # Don't generate delete changes, unless we have configured a permission level for the affected identity
                if ($processingMode -eq 'Defined') {
                    if (-not ($relevantADRule.IdentityReference | Compare-Identity -Parameters $parameters -ReferenceIdentity $ConfiguredRules.IdentityReference -IncludeEqual -ExcludeDifferent)) {
                        continue
                    }
                }

                Write-Result -Type Delete -Identity $relevantADRule.IdentityReference -ADObject $relevantADRule -DistinguishedName $ADObject
            }
            #endregion Foreach non-default AD Rule: Check whether configured and delete if not so

            #region Foreach configured rule: Check whether it exists as defined or make it so
            :outer foreach ($configuredRule in $ConfiguredRules) {
                foreach ($defaultRule in $DefaultRules) {
                    if ('True' -ne $configuredRule.Present) { break }
                    if ($configuredRule.NoFixConfig) { break }
                    if (Test-AccessRuleEquality -Parameters $parameters -Rule1 $defaultRule -Rule2 $configuredRule) {
                        Write-Result -Type FixConfig -Identity $defaultRule.IdentityReference -ADObject $defaultRule -Configuration $configuredRule -DistinguishedName $ADObject
                        continue outer
                    }
                }
                foreach ($relevantADRule in $relevantADRules) {
                    if (Test-AccessRuleEquality -Parameters $parameters -Rule1 $relevantADRule -Rule2 $configuredRule) {
                        continue outer
                    }
                }
                # Do not generate Create rules for any rule not configured for creation
                if ('True' -ne $configuredRule.Present) { continue }
                Write-Result -Type Create -Identity $configuredRule.IdentityReference -Configuration $configuredRule -DistinguishedName $ADObject
            }
            #endregion Foreach configured rule: Check whether it exists as defined or make it so

            #region Foreach non-existent default rule: Create unless configured otherwise
            $domainControllersOUFilter = '*{0}' -f ('OU=Domain Controllers,%DomainDN%' | Resolve-String)
            :outer foreach ($defaultRule in $DefaultRules | Where-Object { $_ -notin $defaultRulesPresent.ToArray() }) {
                # Do not apply restore to Domain Controllers OU, as it is already deployed intentionally diverging from the OU defaults
                if ($ADObject -like $domainControllersOUFilter) { break }

                # Skip 'CREATOR OWNER' Rules, as those should never be restored.
                # When creating an AD object that has this group as default permissions, it will instead
                # Translate those to the identity creating the object
                if ('S-1-3-0' -eq $defaultRule.SID) { continue }

                foreach ($configuredRule in $ConfiguredRules) {
                    if (Test-AccessRuleEquality -Parameters $parameters -Rule1 $defaultRule -Rule2 $configuredRule) {
                        # If we explicitly don't want the rule: Skip and do NOT create a restoration action
                        if ('True' -ne $configuredRule.Present) { continue outer }
                    }
                }

                Write-Result -Type Restore -Identity $defaultRule.IdentityReference -Configuration $defaultRule -DistinguishedName $ADObject
            }
            #endregion Foreach non-existent default rule: Create unless configured otherwise
        }

        function Convert-AccessRule {
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                $Rule,

                [Parameter(Mandatory = $true)]
                $ADObject,

                [PSFComputer]
                $Server,

                [PSCredential]
                $Credential
            )
            begin {
                $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
                $convertCmdName = { Convert-DMSchemaGuid @parameters -OutType Name }.GetSteppablePipeline()
                $convertCmdName.Begin($true)
                $convertCmdGuid = { Convert-DMSchemaGuid @parameters -OutType Guid }.GetSteppablePipeline()
                $convertCmdGuid.Begin($true)
            }
            process {
                foreach ($ruleObject in $Rule) {
                    $objectTypeGuid = $convertCmdGuid.Process($ruleObject.ObjectType)[0]
                    $objectTypeName = $convertCmdName.Process($ruleObject.ObjectType)[0]
                    $inheritedObjectTypeGuid = $convertCmdGuid.Process($ruleObject.InheritedObjectType)[0]
                    $inheritedObjectTypeName = $convertCmdName.Process($ruleObject.InheritedObjectType)[0]

                    try { $identity = Resolve-Identity @parameters -IdentityReference $ruleObject.IdentityReference -ADObject $ADObject }
                    catch {
                        if ('True' -ne $ruleObject.Present) { continue }
                        Stop-PSFFunction -String 'Convert-AccessRule.Identity.ResolutionError' -Target $ruleObject -ErrorRecord $_ -Continue
                    }

                    [PSCustomObject]@{
                        PSTypeName = 'DomainManagement.AccessRule.Converted'
                        IdentityReference = $identity
                        AccessControlType = $ruleObject.AccessControlType
                        ActiveDirectoryRights = $ruleObject.ActiveDirectoryRights
                        InheritanceFlags = $ruleObject.InheritanceFlags
                        InheritanceType = $ruleObject.InheritanceType
                        InheritedObjectType = $inheritedObjectTypeGuid
                        InheritedObjectTypeName = $inheritedObjectTypeName
                        ObjectFlags = $ruleObject.ObjectFlags
                        ObjectType = $objectTypeGuid
                        ObjectTypeName = $objectTypeName
                        PropagationFlags = $ruleObject.PropagationFlags
                        Present = $ruleObject.Present
                    }
                }
            }
            end {
                #region Inject Category-Based rules
                Get-CategoryBasedRules -ADObject $ADObject @parameters -ConvertNameCommand $convertCmdName -ConvertGuidCommand $convertCmdGuid
                #endregion Inject Category-Based rules

                $convertCmdName.End()
                $convertCmdGuid.End()
            }
        }

        function Convert-AccessRuleIdentity {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingEmptyCatchBlock', '')]
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                [System.DirectoryServices.ActiveDirectoryAccessRule[]]
                $InputObject,

                [PSFComputer]
                $Server,

                [PSCredential]
                $Credential
            )
            begin {
                $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
                $domainObject = Get-Domain2 @parameters
            }
            process {
                :main foreach ($accessRule in $InputObject) {
                    if ($accessRule.IdentityReference -is [System.Security.Principal.NTAccount]) {
                        Add-Member -InputObject $accessRule -MemberType NoteProperty -Name OriginalRule -Value $accessRule -PassThru
                        continue main
                    }
                    
                    if (-not $accessRule.IdentityReference.AccountDomainSid) {
                        try { $identity = Get-Principal @parameters -Sid $accessRule.IdentityReference -Domain $domainObject.DNSRoot -OutputType NTAccount }
                        catch {
                            # Empty Catch is OK here, warning happens in command
                        }
                    }
                    else {
                        try { $identity = Get-Principal @parameters -Sid $accessRule.IdentityReference -Domain $accessRule.IdentityReference -OutputType NTAccount }
                        catch {
                            # Empty Catch is OK here, warning happens in command
                        }
                    }
                    if (-not $identity) {
                        $identity = $accessRule.IdentityReference
                    }

                    $newRule = [System.DirectoryServices.ActiveDirectoryAccessRule]::new($identity, $accessRule.ActiveDirectoryRights, $accessRule.AccessControlType, $accessRule.ObjectType, $accessRule.InheritanceType, $accessRule.InheritedObjectType)
                    # Include original object as property in order to facilitate removal if needed.
                    Add-Member -InputObject $newRule -MemberType NoteProperty -Name OriginalRule -Value $accessRule -PassThru
                }
            }
        }

        function Resolve-Identity {
            [CmdletBinding()]
            param (
                [string]
                $IdentityReference,

                $ADObject,

                [PSFComputer]
                $Server,

                [PSCredential]
                $Credential
            )

            #region Parent Resolution
            if ($IdentityReference -eq '<Parent>') {
                $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
                $domainObject = Get-Domain2 @parameters
                $parentPath = ($ADObject.DistinguishedName -split ",",2)[1]
                $parentObject = Get-ADObject @parameters -Identity $parentPath -Properties SamAccountName, Name, ObjectSID
                if (-not $parentObject.ObjectSID) {
                    Stop-PSFFunction -String 'Resolve-Identity.ParentObject.NoSecurityPrincipal' -StringValues $ADObject, $parentObject.Name, $parentObject.ObjectClass -EnableException $true -Cmdlet $PSCmdlet
                }
                if ($parentObject.SamAccountName) { return [System.Security.Principal.NTAccount]('{0}\{1}' -f $domainObject.Name, $parentObject.SamAccountName) }
                else { return [System.Security.Principal.NTAccount]('{0}\{1}' -f $domainObject.Name, $parentObject.Name) }
            }
            #endregion Parent Resolution

            #region Default Resolution
            $identity = Resolve-String -Text $IdentityReference
            if ($identity -as [System.Security.Principal.SecurityIdentifier]) {
                $identity = $identity -as [System.Security.Principal.SecurityIdentifier]
            }
            else {
                $identity = $identity -as [System.Security.Principal.NTAccount]
            }
            if ($null -eq $identity) { $identity = (Resolve-String -Text $IdentityReference) -as [System.Security.Principal.NTAccount] }

            $identity
            #endregion Default Resolution
        }

        function Get-CategoryBasedRules {
            [CmdletBinding()]
            param (
                [Parameter(Mandatory = $true)]
                $ADObject,

                [PSFComputer]
                $Server,

                [PSCredential]
                $Credential,

                $ConvertNameCommand,

                $ConvertGuidCommand
            )

            $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include ADObject, Server, Credential

            $resolvedCategories = Resolve-DMObjectCategory @parameters
            foreach ($resolvedCategory in $resolvedCategories) {
                foreach ($ruleObject in $script:accessCategoryRules[$resolvedCategory.Name]) {
                    $objectTypeGuid = $ConvertGuidCommand.Process($ruleObject.ObjectType)[0]
                    $objectTypeName = $ConvertNameCommand.Process($ruleObject.ObjectType)[0]
                    $inheritedObjectTypeGuid = $ConvertGuidCommand.Process($ruleObject.InheritedObjectType)[0]
                    $inheritedObjectTypeName = $ConvertNameCommand.Process($ruleObject.InheritedObjectType)[0]

                    try { $identity = Resolve-Identity @parameters -IdentityReference $ruleObject.IdentityReference }
                    catch { Stop-PSFFunction -String 'Convert-AccessRule.Identity.ResolutionError' -Target $ruleObject -ErrorRecord $_ -Continue }

                    [PSCustomObject]@{
                        PSTypeName = 'DomainManagement.AccessRule.Converted'
                        IdentityReference = $identity
                        AccessControlType = $ruleObject.AccessControlType
                        ActiveDirectoryRights = $ruleObject.ActiveDirectoryRights
                        InheritanceFlags = $ruleObject.InheritanceFlags
                        InheritanceType = $ruleObject.InheritanceType
                        InheritedObjectType = $inheritedObjectTypeGuid
                        InheritedObjectTypeName = $inheritedObjectTypeName
                        ObjectFlags = $ruleObject.ObjectFlags
                        ObjectType = $objectTypeGuid
                        ObjectTypeName = $objectTypeName
                        PropagationFlags = $ruleObject.PropagationFlags
                        Present = $ruleObject.Present
                    }
                }
            }
        }
        #endregion Utility Functions

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type accessRules -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters

        try { $null = Get-DMObjectDefaultPermission -ObjectClass top @parameters }
        catch {
            Stop-PSFFunction -String 'Test-DMAccessRule.DefaultPermission.Failed' -StringValues $Server -Target $Server -EnableException $false -ErrorRecord $_
            return
        }
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }

        #region Process Configured Objects
        foreach ($key in $script:accessRules.Keys) {
            $resolvedPath = Resolve-String -Text $key

            $resultDefaults = @{
                Server = $Server
                ObjectType = 'AccessRule'
                Identity = $resolvedPath
                Configuration = $script:accessRules[$key]
            }

            if (-not (Test-ADObject @parameters -Identity $resolvedPath)) {
                if ($script:accessRules[$key].Optional -notcontains $false) { continue }
                New-TestResult @resultDefaults -Type 'MissingADObject'
                continue
            }
            try { $adAclObject = Get-AdsAcl @parameters -Path $resolvedPath -EnableException }
            catch {
                if ($script:accessRules[$key].Optional -notcontains $false) { continue }
                Write-PSFMessage -String 'Test-DMAccessRule.NoAccess' -StringValues $resolvedPath -Tag 'panic','failed' -Target $script:accessRules[$key] -ErrorRecord $_
                New-TestResult @resultDefaults -Type 'NoAccess'
                Continue
            }

            $adObject = Get-ADObject @parameters -Identity $resolvedPath -Properties adminCount
            
            if ($adObject.adminCount) {
                $defaultPermissions = @()
                $desiredPermmissions = Get-AdminSDHolderRules @parameters
            }
            else {
                $defaultPermissions = Get-DMObjectDefaultPermission @parameters -ObjectClass $adObject.ObjectClass
                $desiredPermmissions = $script:accessRules[$key] | Convert-AccessRule @parameters -ADObject $adObject
            }

            $delta = Compare-AccessRules @parameters -ADRules ($adAclObject.Access | Convert-AccessRuleIdentity @parameters) -ConfiguredRules $desiredPermmissions -DefaultRules $defaultPermissions -ADObject $adObject

            if ($delta) {
                New-TestResult @resultDefaults -Type Update -Changed $delta -ADObject $adAclObject
                continue
            }
        }
        #endregion Process Configured Objects

        #region Process Non-Configured AD Objects
        $resolvedConfiguredObjects = $script:accessRules.Keys | Resolve-String

        $foundADObjects = foreach ($searchBase in (Resolve-ContentSearchBase @parameters -NoContainer)) {
            Get-ADObject @parameters -LDAPFilter '(objectCategory=*)' -SearchBase $searchBase.SearchBase -SearchScope $searchBase.SearchScope -Properties adminCount
        }

        $resultDefaults = @{
            Server = $Server
            ObjectType = 'AccessRule'
        }

        $convertCmdName = { Convert-DMSchemaGuid @parameters -OutType Name }.GetSteppablePipeline()
        $convertCmdName.Begin($true)
        $convertCmdGuid = { Convert-DMSchemaGuid @parameters -OutType Guid }.GetSteppablePipeline()
        $convertCmdGuid.Begin($true)

        $processed = @{ }
        foreach ($foundADObject in $foundADObjects) {
            # Prevent duplicate processing
            if ($processed[$foundADObject.DistinguishedName]) { continue }
            $processed[$foundADObject.DistinguishedName] = $true

            # Skip items that were defined in configuration, they were already processed
            if ($foundADObject.DistinguishedName -in $resolvedConfiguredObjects) { continue }

            $adAclObject = Get-AdsAcl @parameters -Path $foundADObject.DistinguishedName
            $compareParam = @{
                ADRules = $adAclObject.Access | Convert-AccessRuleIdentity @parameters
                DefaultRules = Get-DMObjectDefaultPermission @parameters -ObjectClass $foundADObject.ObjectClass
                ConfiguredRules = Get-CategoryBasedRules -ADObject $foundADObject @parameters -ConvertNameCommand $convertCmdName -ConvertGuidCommand $convertCmdGuid
                ADObject = $foundADObject
            }

            # Protected Objects
            if ($foundADObject.AdminCount) {
                $compareParam.DefaultRules = @()
                $compareParam.ConfiguredRules = Get-AdminSDHolderRules @parameters
            }

            $compareParam += $parameters
            $delta = Compare-AccessRules @compareParam

            if ($delta) {
                New-TestResult @resultDefaults -Type Update -Changed $delta -ADObject $adAclObject -Identity $foundADObject.DistinguishedName
                continue
            }
        }

        $convertCmdName.End()
        $convertCmdGuid.End()
        #endregion Process Non-Configured AD Objects
    }
}


function Unregister-DMAccessRule
{
    <#
    .SYNOPSIS
        Removes a registered accessrule from the list of desired rules.
     
    .DESCRIPTION
        Removes a registered accessrule from the list of desired rules.
     
    .PARAMETER RuleObject
        The rule object to remove.
        Must be returned by Get-DMAccessRule
     
    .EXAMPLE
        PS C:\> Get-DMAccessRule | Unregister-DMAccessRule
 
        Removes all registered Access Rules, clearing the desired state of rules.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true)]
        [PsfValidateScript('DomainManagement.Validate.TypeName.AccessRule', ErrorString = 'DomainManagement.Validate.TypeName.AccessRule.Failed')]
        $RuleObject
    )
    
    process
    {
        foreach ($ruleItem in $RuleObject) {
            if ($ruleItem.Path) {
                $script:accessRules[$ruleItem.Path] = $script:accessRules[$ruleItem.Path] | Where-Object { $_ -ne $ruleItem}
                if (-not $script:accessRules[$ruleItem.Path]) {
                    $script:accessRules.Remove($ruleItem.Path)
                }
            }
            if ($ruleItem.Category) {
                $script:accessCategoryRules[$ruleItem.Category] = $script:accessCategoryRules[$ruleItem.Category] | Where-Object { $_ -ne $ruleItem}
                if (-not $script:accessCategoryRules[$ruleItem.Category]) {
                    $script:accessCategoryRules.Remove($ruleItem.Category)
                }
            }
        }
    }
}

function Get-DMAccessRuleMode
{
    <#
    .SYNOPSIS
        Retrieve registered AccessRule processing modes.
     
    .DESCRIPTION
        Retrieve registered AccessRule processing modes.
        These are used to define, how AccessRules will be processed.
     
    .PARAMETER Path
        Filter by the path the AccessRule processing mode applies to.
     
    .PARAMETER ObjectCategory
        Filter by the object category the AccessRule processing mode applies to.
     
    .EXAMPLE
        PS C:\> Get-DMAccessRuleMode
 
        List all registered AccessRule processing modes.
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Path = '*',

        [string]
        $ObjectCategory = '*'
    )
    
    process
    {
        $script:accessRuleMode.Values | Where-Object Path -like $Path | Where-Object ObjectCategory -like $ObjectCategory
    }
}


function Register-DMAccessRuleMode {
    <#
    .SYNOPSIS
        Register the processing mode for access rules on a specified object.
     
    .DESCRIPTION
        Register the processing mode for access rules on a specified object.
        This is used by the AccessRule Component exclusively.
     
    .PARAMETER Path
        The path to the AD object to govern.
        This should be a distinguishedname.
        This path uses name resolution.
        For example %DomainDN% will be replaced with the DN of the target domain itself (and should probably be part of everyy single path).
     
    .PARAMETER PathMode
        Whether to only target a specific path or the target path and all items beneath it.
     
    .PARAMETER ObjectCategory
        Instead of a path, define a category to apply the processing mode to.
        Categories are defined using Register-DMObjectCategory.
        This allows you to apply processing mode to a category of objects, rather than a specific path.
        With this you could apply a processing mode to all domain controller objects, for example.
     
    .PARAMETER Mode
        Determines, how the AccessRules are applied on the target object:
        - Constrained: All non-defined AccessRules will be removed.
        - Defined: Only non-defined AccessRules with identities for which a configuration exists on the object will be deleted.
        - Additive: Non-defined AccessRules on the targeted object will be ignored.
        By default, with no AccessRuleMode defined, all objects are considered to be in Constrained mode.
     
    .EXAMPLE
        PS C:\> Register-DMAccessRuleMode -Path 'OU=Company,%DomainDN%' -PathMode SubTree -Mode Additive
 
        Configures the specified OU and all items beneath it to be in additive mode.
        Defined AccessRules will be applied if missing, but previously existing rules remain untouched.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Path')]
        [string]
        $Path,

        [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Path')]
        [ValidateSet('SingleItem', 'SubTree')]
        [string]
        $PathMode = 'SingleItem',

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Category')]
        [string]
        $ObjectCategory,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('Constrained', 'Defined', 'Additive')]
        [string]
        $Mode
    )
    
    process {
        $identity = 'Path:{0}:{1}' -f $PathMode,$Path
        if ($ObjectCategory) { $identity = 'Category:{0}' -f $ObjectCategory }
        
        $script:accessRuleMode[$identity] = [PSCustomObject]@{
            PSTypeName     = 'DomainManagement.AccessRuleMode'
            Identity       = $identity
            Type           = $PSCmdlet.ParameterSetName
            Path           = $Path
            PathMode       = $PathMode
            ObjectCategory = $ObjectCategory
            Mode           = $Mode
        }
    }
}


function Resolve-DMAccessRuleMode
{
    <#
    .SYNOPSIS
        Resolves the AccessRule processing mode that applies to the specified ADObject.
     
    .DESCRIPTION
        Resolves the AccessRule processing mode that applies to the specified ADObject.
     
    .PARAMETER ADObject
        The AD Object for which to resolve the AccessRule processing mode.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Resolve-DMAccessRuleMode @parameters -ADObject $adObject
 
        Resolves the AccessRule processing mode that applies to the specified ADObject.
    #>

    [OutputType([string])]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        $ADObject,

        [PSFComputer]
        $Server,

        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
    }
    process
    {
        if ($script:accessRuleMode.Count -lt 1) { return 'Constrained' }

        $relevantCategories = @()
        if ($script:accessRuleMode.Values.ObjectCategory) {
            $relevantCategories = Resolve-DMObjectCategory -ADObject $ADObject @parameters
        }

        $applicableModes = :main foreach ($mode in $script:accessRuleMode.Values) {
            if ($mode.Path) {
                try { $resolvedPath = $mode.Path | Resolve-String @parameters }
                catch {
                    Write-PSFMessage -Level Warning -String 'Resolve-DMAccessRuleMode.PathResolution.Failed' -StringValues $mode.Path -ErrorRecord $_
                    $resolvedPath = $mode.Path | Resolve-String
                }
                switch ($mode.PathMode) {
                    'SingleItem' {
                        if ($ADObject.DistinguishedName -eq $resolvedPath) { $mode }
                        continue main
                    }
                    'SubTree' {
                        if ($ADObject.DistinguishedName -like "*$resolvedPath") { $mode }
                        continue main
                    }
                }
            }
            if ($mode.ObjectCategory -and ($mode.ObjectCategory -in $relevantCategories.Name)) {
                $mode
            }
        }
        
        if ($primaryMode = $applicableModes | Where-Object { $_.Type -eq 'Path' -and $_.PathMode -eq 'SingleItem'}) {
            return $primaryMode.Mode
        }
        if ($secondaryMode = $applicableModes | Where-Object Type -eq 'Category' | Select-Object -First 1) {
            return $secondaryMode.Mode
        }
        if ($tertiaryMode = $applicableModes | Where-Object { $_.Type -eq 'Path' -and $_.PathMode -eq 'SubTree'} | Sort-Object { $_.Path.Length } -Descending | Select-Object -First 1) {
            return $tertiaryMode.Mode
        }
        return 'Constrained'
    }
}


function Unregister-DMAccessRuleMode
{
    <#
    .SYNOPSIS
        Removes previously registered AccessRule processing modes.
     
    .DESCRIPTION
        Removes previously registered AccessRule processing modes.
        Prioritizes Identity over Path over ObjectCategory.
     
    .PARAMETER Identity
        The Identity of the AccessRule processing mode to remove.
     
    .PARAMETER Path
        The Path of the AccessRule processing mode to remove.
     
    .PARAMETER ObjectCagegory
        The ObjectCategory of the AccessRule processing mode to remove.
     
    .EXAMPLE
        PS C:\> Get-DMAccessRuleMode | Unregister-DMAccessRuleMode
 
        Clears all registered AccessRule processing modes.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]
        $Identity,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]
        $Path,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]
        $ObjectCagegory
    )
    
    process
    {
        if ($Identity) { $script:accessRuleMode.Remove($Identity) }
        elseif ($Path) { $script:accessRuleMode.Remove("Path:$Path") }
        elseif ($ObjectCagegory) { $script:accessRuleMode.Remove("Category:$ObjectCategory") }
    }
}


function Get-DMAcl
{
    <#
        .SYNOPSIS
            Lists registered acls.
         
        .DESCRIPTION
            Lists registered acls.
         
        .PARAMETER Path
            The name to filter by.
            Defaults to '*'
         
        .EXAMPLE
            PS C:\> Get-DMAcls
 
            Lists all registered acls.
    #>

    [CmdletBinding()]
    param (
        [string]
        $Path = '*'
    )
    
    process
    {
        ($script:acls.Values) | Where-Object Path -like $Path
        ($script:aclsByCategory.Values) | Where-Object Category -like $Path
        $script:aclDefaultOwner | Where-Object Path -like $Path
    }
}


function Invoke-DMAcl
{
    <#
    .SYNOPSIS
        Applies the desired ACL configuration.
     
    .DESCRIPTION
        Applies the desired ACL configuration.
        To define the desired acl state, use Register-DMAcl.
         
        Note: The ACL suite of commands only manages the ACL itself, not the rules assigned to it!
        Explicitly, this makes this suite the tool to manage inheritance and ownership over an object.
        To manage AccessRules, look at the *-DMAccessRule commands.
     
    .PARAMETER InputObject
        Test results provided by the associated test command.
        Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMAcl -Server contoso.com
 
        Applies the configured, desired state of object Acl to all managed objects in contoso.com
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Acls, AclByCategory, AclDefaultOwner -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process{
        if (-not $InputObject) {
            $InputObject = Test-DMAcl @parameters
        }
        
        foreach ($testItem in $InputObject) {
            # Catch invalid input - can only process test results
            if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.Acl.TestResult') {
                Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMAcl', $testItem -Target $testItem -Continue -EnableException $EnableException
            }
            
            switch ($testItem.Type) {
                'MissingADObject'
                {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMAcl.MissingADObject' -StringValues $testItem.Identity -Target $testItem
                    continue
                }
                'NoAccess'
                {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMAcl.NoAccess' -StringValues $testItem.Identity -Target $testItem
                    continue
                }
                'OwnerNotResolved'
                {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMAcl.OwnerNotResolved' -StringValues $testItem.Identity, $testItem.ADObject.GetOwner([System.Security.Principal.SecurityIdentifier]) -Target $testItem
                    continue
                }
                'Update'
                {
                    if ($testItem.Changed.Type -contains 'Owner') {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMAcl.UpdatingOwner' -ActionStringValues ($testItem.Configuration.Owner | Resolve-String) -Target $testItem -ScriptBlock {
                            Set-AdsOwner @parameters -Path $testItem.Identity -Identity (Convert-Principal @parameters -Name ($testItem.Configuration.Owner | Resolve-String)) -EnableException -Confirm:$false
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                    if ($testItem.Changed.Type -contains 'NoInheritance') {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMAcl.UpdatingInheritance' -ActionStringValues $testItem.Configuration.NoInheritance -Target $testItem -ScriptBlock {
                            if ($testItem.Configuration.NoInheritance) {
                                Disable-AdsInheritance @parameters -Path $testItem.Identity -EnableException -Confirm:$false
                            }
                            else { Enable-AdsInheritance @parameters -Path $testItem.Identity -EnableException -Confirm:$false }
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                }
                'ShouldManage'
                {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMAcl.ShouldManage' -StringValues $testItem.Identity -Target $testItem
                    continue
                }
            }
        }
    }
}

function Register-DMAcl
{
    <#
    .SYNOPSIS
        Registers an active directory acl.
     
    .DESCRIPTION
        Registers an active directory acl.
        This acl will be maintained as configured during Invoke-DMAcl.
     
    .PARAMETER Path
        Path (distinguishedName) of the ADObject the acl is assigned to.
        Subject to string insertion.
 
    .PARAMETER ObjectCategory
        Assign ACL settings based on the ObjectCategory of an object.
     
    .PARAMETER Owner
        Owner of the ADObject.
        Subject to string insertion.
     
    .PARAMETER NoInheritance
        Whether inheritance should be disabled on the ADObject.
        Defaults to $false
 
    .PARAMETER Optional
        The path this acl object is assigned to is optional and need not exist.
        This makes the rule apply only if the object exists, without triggering errors if it doesn't.
        It will also ignore access errors on the object.
 
    .PARAMETER DefaultOwner
        Whether to make this the default owner for objects not specified under either a path or an object category.
 
    .PARAMETER ContextName
        The name of the context defining the setting.
        This allows determining the configuration set that provided this setting.
        Used by the ADMF, available to any other configuration management solution.
     
    .EXAMPLE
        PS C:\> Get-Content .\groups.json | ConvertFrom-Json | Write-Output | Register-DMAcl
 
        Reads a json configuration file containing a list of objects with appropriate properties to import them as acl configuration.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
    [CmdletBinding(DefaultParameterSetName = 'path')]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'path')]
        [string]
        $Path,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'category')]
        [string]
        $ObjectCategory,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Owner,

        [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'path')]
        [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'category')]
        [bool]
        $NoInheritance = $false,

        [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'path')]
        [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'category')]
        [bool]
        $Optional = $false,

        [Parameter(ParameterSetName = 'DefaultOwner')]
        [switch]
        $DefaultOwner,

        [string]
        $ContextName = '<Undefined>'
    )
    process
    {
        switch ($PSCmdlet.ParameterSetName) {
            'path'
            {
                $script:acls[$Path] = [PSCustomObject]@{
                    PSTypeName = 'DomainManagement.Acl'
                    Path = $Path
                    Owner = $Owner
                    NoInheritance = $NoInheritance
                    Optional = $Optional
                    ContextName = $ContextName
                }
            }
            'category'
            {
                $script:aclByCategory[$ObjectCategory] = [PSCustomObject]@{
                    PSTypeName = 'DomainManagement.Acl'
                    Category = $ObjectCategory
                    Owner = $Owner
                    NoInheritance = $NoInheritance
                    Optional = $Optional
                    ContextName = $ContextName
                }
            }
            'DefaultOwner'
            {
                # Array to appease Assert-Configuration
                $script:aclDefaultOwner = @([PSCustomObject]@{
                    PSTypeName = 'DomainManagement.Acl'
                    Path = '<default>'
                    Owner = $Owner
                    NoInheritance = $false
                    Optional = $null
                    ContextName = $ContextName
                })
            }
        }
    }
}

function Test-DMAcl {
    <#
        .SYNOPSIS
            Tests whether the configured groups match a domain's configuration.
         
        .DESCRIPTION
            Tests whether the configured groups match a domain's configuration.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
         
        .EXAMPLE
            PS C:\> Test-DMGroup
 
            Tests whether the configured groups' state matches the current domain group setup.
    #>

    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Acls, AclByCategory, AclDefaultOwner -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters

        #region Functions
        function New-Change {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                $Type,
                $OldValue,
                $NewValue,
                [string]
                $Identity,
                $OldSID,
                $NewSID
            )

            $changeItem = [PSCustomObject]@{
                PSTypeName = 'DomainManagement.Acl.Change'
                Type       = $Type
                Old        = $OldValue
                New        = $NewValue
                Identity   = $Identity
                OldSID     = $OldSID
                NewSID     = $NewSID
            }
            Add-Member -InputObject $changeItem -MemberType ScriptMethod -Name ToString -Value { '{0}->{1}' -f $this.Type, $this.New } -Force -PassThru
        }
        function Get-ChangeByCategory {
            [CmdletBinding()]
            param (
                $ADObject,

                $Category,

                $ResultDefaults,

                $Parameters
            )

            $aclObject = Get-AdsAcl @Parameters -Path $ADObject -EnableException

            # Ensure Owner Name is present - may not always resolve
            $ownerSID = $aclObject.GetOwner([System.Security.Principal.SecurityIdentifier])
            $configuredSID = $Category.Owner | Resolve-String | Convert-Principal @parameters -OutputType SID

            [System.Collections.ArrayList]$changes = @()
            if ("$ownerSID" -ne "$configuredSID") {
                $null = $changes.Add((New-Change -Identity $ADObject -Type Owner -OldValue $aclObject.Owner -NewValue ($Category.Owner | Resolve-String) -OldSID $ownerSID -NewSID $configuredSID))
            }
            if ($Category.NoInheritance -ne $aclObject.AreAccessRulesProtected) {
                # If AdminCount -eq 1, then inheritance should be disabled, no matter the configuration
                if (-not ($aclObject.AreAccessRulesProtected -and $ADObject.AdminCount)) {
                    $null = $changes.Add((New-Change -Identity $ADObject -Type NoInheritance -OldValue $aclObject.AreAccessRulesProtected -NewValue $Category.NoInheritance))
                }
            }

            if ($changes.Count) {
                New-TestResult @resultDefaults -Identity $ADObject -Configuration $Category -Type Update -Changed $changes.ToArray() -ADObject $aclObject
            }
        }
        #endregion Functions
    }
    process {
        #region processing configuration
        foreach ($aclDefinition in $script:acls.Values) {
            $resolvedPath = Resolve-String -Text $aclDefinition.Path

            $resultDefaults = @{
                Server        = $Server
                ObjectType    = 'Acl'
                Identity      = $resolvedPath
                Configuration = $aclDefinition
            }

            
            if (-not (Test-ADObject @parameters -Identity $resolvedPath)) {
                if ($aclDefinition.Optional) { continue }
                Write-PSFMessage -String 'Test-DMAcl.ADObjectNotFound' -StringValues $resolvedPath -Tag 'panic', 'failed' -Target $aclDefinition
                New-TestResult @resultDefaults -Type 'MissingADObject'
                Continue
            }

            try { $aclObject = Get-AdsAcl @parameters -Path $resolvedPath -EnableException }
            catch {
                if ($aclDefinition.Optional) { continue }
                Write-PSFMessage -String 'Test-DMAcl.NoAccess' -StringValues $resolvedPath -Tag 'panic', 'failed' -Target $aclDefinition -ErrorRecord $_
                New-TestResult @resultDefaults -Type 'NoAccess'
                Continue
            }
            # Ensure Owner Name is present - may not always resolve
            $ownerSID = $aclObject.GetOwner([System.Security.Principal.SecurityIdentifier])
            $configuredSID = $aclDefinition.Owner | Resolve-String | Convert-Principal @parameters -OutputType SID

            [System.Collections.ArrayList]$changes = @()
            if ("$ownerSID" -ne "$configuredSID") {
                $null = $changes.Add((New-Change -Identity $resolvedPath -Type Owner -OldValue $aclObject.Owner -NewValue ($aclDefinition.Owner | Resolve-String) -OldSID $ownerSID -NewSID $configuredSID))
            }
            if ($aclDefinition.NoInheritance -ne $aclObject.AreAccessRulesProtected) {
                $null = $changes.Add((New-Change -Identity $resolvedPath -Type NoInheritance -OldValue $aclObject.AreAccessRulesProtected -NewValue $aclDefinition.NoInheritance))
            }

            if ($changes.Count) {
                New-TestResult @resultDefaults -Type Update -Changed $changes.ToArray() -ADObject $aclObject
            }
        }
        #endregion processing configuration

        #region check if all ADObjects are managed
        $foundADObjects = foreach ($searchBase in (Resolve-ContentSearchBase @parameters -NoContainer)) {
            Get-ADObject @parameters -LDAPFilter '(objectCategory=*)' -SearchBase $searchBase.SearchBase -SearchScope $searchBase.SearchScope -Properties AdminCount
        }
        
        $resolvedConfiguredPaths = $script:acls.Values.Path | Resolve-String
        $resultDefaults = @{
            Server     = $Server
            ObjectType = 'Acl'
        }

        $processed = @{ }
        foreach ($foundADObject in $foundADObjects) {
            # Prevent duplicate processing
            if ($processed[$foundADObject.DistinguishedName]) { continue }
            $processed[$foundADObject.DistinguishedName] = $true

            # Skip items that were defined in configuration, they were already processed
            if ($foundADObject.DistinguishedName -in $resolvedConfiguredPaths) { continue }
            
            if ($script:aclByCategory.Count -gt 0) {
                $category = Resolve-DMObjectCategory -ADObject $foundADObject @parameters
                if ($matchingCategory = $category | Where-Object Name -In $script:aclByCategory.Keys | Select-Object -First 1) {
                    Get-ChangeByCategory -ADObject $foundADObject -Category $script:aclByCategory[$matchingCategory.Name] -ResultDefaults $resultDefaults -Parameters $parameters
                    continue
                }
            }

            if ($script:aclDefaultOwner) { Get-ChangeByCategory -ADObject $foundADObject -Category $script:aclDefaultOwner[0] -ResultDefaults $resultDefaults -Parameters $parameters }
            else { New-TestResult @resultDefaults -Type ShouldManage -ADObject $foundADObject -Identity $foundADObject.DistinguishedName }
        }
        #endregion check if all ADObjects are managed
    }
}

function Unregister-DMAcl
{
    <#
    .SYNOPSIS
        Removes a acl that had previously been registered.
     
    .DESCRIPTION
        Removes a acl that had previously been registered.
     
    .PARAMETER Path
        The path (distinguishedName) of the acl to remove.
 
    .PARAMETER Category
        The object category the acl settings apply to
     
    .EXAMPLE
        PS C:\> Get-DMAcl | Unregister-DMAcl
 
        Clears all registered acls.
    #>

    
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Path,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Category
    )
    
    process
    {
        foreach ($pathItem in $Path) {
            if ($pathItem -eq '<default>') { $script:aclDefaultOwner = $null }
            else { $script:acls.Remove($pathItem) }
        }
        foreach ($categoryItem in $Category) {
            $script:aclByCategory.Remove($categoryItem)
        }
    }
}


function Get-DMDomainData {
    <#
    .SYNOPSIS
        Returns registered domain data gathering scripts.
     
    .DESCRIPTION
        Returns registered domain data gathering scripts.
     
    .PARAMETER Name
        The name to filter by, accepts wildcards.
        Defaults to '*'
     
    .EXAMPLE
        PS C:\> Get-DomainData
 
        Returns all registered domain data gathering scripts
    #>

    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true)]
        [string]
        $Name = '*'
    )
    
    process {
        $script:domainDataScripts.Values | Where-Object Name -like $Name
    }
}


function Invoke-DMDomainData {
    <#
    .SYNOPSIS
        Gathers domain specific data.
     
    .DESCRIPTION
        Gathers domain specific data.
        The gathering scripts are supplied using Register-DMDomainData.
        The data is currently consumed only by the extended group policy Component.
     
    .PARAMETER Name
        Name of the registered scriptblock to invoke.
     
    .PARAMETER Reset
        Disable retrieving data from cache.
        By default, all data is cached on a per-domain basis.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Invoke-DMDomainData @parameters -Name PKIServer
 
        Executes the scriptblock stored as PKIServer against the targeted domain.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true)]
        [PsfValidatePattern('^[\d\w_]+$', ErrorString = 'DomainManagement.Validate.DomainData.Pattern')]
        [string]
        $Name,

        [switch]
        $Reset,
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process {
        #region Script not found
        if (-not $script:domainDataScripts[$Name]) {
            $result = [PSCustomObject]@{
                Name      = $Name
                Data      = $null
                Error     = "Script not found, check configuration"
                Success   = $false
                Type      = "ScriptNotFound"
                Timestamp = Get-Date
            }
            Write-PSFMessage -Level Warning -String 'Invoke-DMDomainData.Script.NotFound' -StringValues $Name -Target $result
            if ($EnableException) {
                Stop-PSFFunction -String 'Invoke-DMDomainData.Script.NotFound.Error' -StringValues $Name -Target $result -EnableException $EnableException -Category ObjectNotFound
            }

            $result
            return
        }
        #endregion Script not found

        $domainObject = Get-Domain2 @parameters
        if (-not $script:cache_DomainData[$domainObject.DNSRoot]) { $script:cache_DomainData[$domainObject.DNSRoot] = @{ } }
        if ($script:cache_DomainData[$domainObject.DNSRoot][$Name] -and -not $Reset) { return $script:cache_DomainData[$domainObject.DNSRoot][$Name] }
        
        $scriptTask = $script:domainDataScripts[$Name]
        $result = [PSCustomObject]@{
            Name      = $Name
            Data      = $null
            Error     = $null
            Success   = $false
            Type      = $null
            Timestamp = Get-Date
        }

        try {
            $result.data = $scriptTask.Scriptblock.Invoke($parameters.Clone())
            $result.Success = $true
            $result.Type = 'Success'
            $result.Timestamp = Get-Date
            $script:cache_DomainData[$domainObject.DNSRoot][$Name] = $result
            $result
        }
        catch {
            $result.Error = $_
            $result.Timestamp = Get-Date
            $result.Type = $_.CategoryInfo.Category

            Write-PSFMessage -String 'Invoke-DMDomainData.Invocation.Error' -StringValues $Name -ErrorRecord $_ -Target $result
            if ($EnableException) {
                Stop-PSFFunction -String 'Invoke-DMDomainData.Invocation.Error.Terminate' -StringValues $Name -ErrorRecord $_ -Target $result -EnableException $EnableException
            }
            $result
        }
    }
}


function Register-DMDomainData {
    <#
    .SYNOPSIS
        Registers a domain data gathering script.
     
    .DESCRIPTION
        Registers a domain data gathering script.
        These can be used to provide domain specific data (in contrast to the usual context specific data, which might be applied to multiple domains).
     
    .PARAMETER Name
        Name under which to register the data gathering script.
        Can only contain letters, numbers and underscores.
     
    .PARAMETER Scriptblock
        The scriptblock performing the actual gathering.
        Receives a hashtable containing Server and - possibly - Credentials.
 
    .PARAMETER ContextName
        The name of the context defining the setting.
        This allows determining the configuration set that provided this setting.
        Used by the ADMF, available to any other configuration management solution.
     
    .EXAMPLE
        PS C:\> Import-PowerShellDataFile .\config.psd1 | ForEach-Object { Register-DMDomainData @_ }
 
        Registers all configuration settings stored in config.psd1
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [PsfValidatePattern('^[\d\w_]+$', ErrorString = 'DomainManagement.Validate.DomainData.Pattern')]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [scriptblock]
        $Scriptblock,

        [string]
        $ContextName = '<Undefined>'
    )
    
    process {
        $script:domainDataScripts[$Name] = [PSCustomObject]@{
            Name        = $Name
            Placeholder = '%!{0}%' -f $Name
            Scriptblock = $Scriptblock
            ContextName = $ContextName
        }
    }
}


function Unregister-DMDomainData {
    <#
    .SYNOPSIS
        Removes registered domain data gathering scripts.
     
    .DESCRIPTION
        Removes registered domain data gathering scripts.
        Also deletes all associated cached data.
     
    .PARAMETER Name
        Name of the domain data gathering script to remove.
     
    .EXAMPLE
        PS C:\> Get-DMDomainData | Unregister-DMDomainData
 
        Clears all domain data gathering scripts.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process {
        foreach ($nameString in $Name) {
            $script:domainDataScripts.Remove($nameString)
            foreach ($domainDataHash in $script:cache_DomainData.Values) {
                $domainDataHash.Remove($nameString)
            }
        }
    }
}


function Get-DMDomainLevel
{
<#
    .SYNOPSIS
        Returns the defined desired state if configured.
     
    .DESCRIPTION
        Returns the defined desired state if configured.
     
    .EXAMPLE
        PS C:\> Get-DMDomainLevel
     
        Returns the defined desired state if configured.
#>

    [CmdletBinding()]
    Param (
    
    )
    process
    {
        $script:domainLevel
    }
}


function Invoke-DMDomainLevel
{
<#
    .SYNOPSIS
        Applies the desired domain level if needed.
     
    .DESCRIPTION
        Applies the desired domain level if needed.
     
    .PARAMETER Server
        The server / domain to work with.
         
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER InputObject
        Test results provided by the associated test command.
        Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMDomainLevel -Server contoso.com
     
        Raises the domain "contoso.com" to the desired level if needed.
#>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,
        
        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type DomainLevel -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process
    {
        if (-not $InputObject) {
            $InputObject = Test-DMDomainLevel @parameters
        }

        foreach ($testItem in $InputObject)
        {
            # Catch invalid input - can only process test results
            if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.DomainLevel.TestResult') {
                Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMDomainLevel', $testItem -Target $testItem -Continue -EnableException $EnableException
            }

            switch ($testItem.Type)
            {
                'Raise'
                {
                    # Raising the Domain Functional Level MUST target the PDC Emulator
                    $clonedParam = $parameters.Clone()
                    $clonedParam.Server = (Resolve-Domain @parameters).PDCEmulator

                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMDomainLevel.Raise.Level' -ActionStringValues $testItem.Configuration.Level -Target $testItem.ADObject -ScriptBlock {
                        Set-ADDomainMode @clonedParam -DomainMode $testItem.Configuration.DesiredLevel -Identity $testItem.ADObject -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
            }
        }
    }
}

function Register-DMDomainLevel
{
<#
    .SYNOPSIS
        Register a domain functional level as desired state.
     
    .DESCRIPTION
        Register a domain functional level as desired state.
     
    .PARAMETER Level
        The level to apply.
     
    .PARAMETER ContextName
        The name of the context defining the setting.
        This allows determining the configuration set that provided this setting.
        Used by the ADMF, available to any other configuration management solution.
     
    .EXAMPLE
        PS C:\> Register-DMDomainLevel -Level 2016
     
        Apply the desired domain level of 2016
#>

    [CmdletBinding()]
    param (
        [ValidateSet('2008R2', '2012', '2012R2', '2016')]
        [string]
        $Level,
        
        [string]
        $ContextName = '<Undefined>'
    )
    
    process
    {
        $script:domainLevel = @([PSCustomObject]@{
            PSTypeName  = 'DomainManagement.Configuration.DomainLevel'
            Level        = $Level
            ContextName = $ContextName
        })
    }
}

function Test-DMDomainLevel
{
<#
    .SYNOPSIS
        Tests whether the target domain has at least the desired functional level.
     
    .DESCRIPTION
        Tests whether the target domain has at least the desired functional level.
     
    .PARAMETER Server
        The server / domain to work with.
         
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Test-DMDomainLevel -Server contoso.com
     
        Tests whether the domain contoso.com has at least the desired functional level.
#>

    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type DomainLevel -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process
    {
        $levelValues = @{
            '2008R2' = 4
            '2012'   = 5
            '2012R2' = 6
            '2016'   = 7
        }
        $level = Get-DMDomainLevel
        $desiredLevel = $levelValues[$level.Level]
        $tempConfiguration = $level | ConvertTo-PSFHashtable
        $tempConfiguration['DesiredLevel'] = [Microsoft.ActiveDirectory.Management.ADDomainMode]$desiredLevel
        $domain = Get-ADDomain @parameters
        if ($domain.DomainMode -lt $desiredLevel)
        {
            New-TestResult -ObjectType DomainLevel -Type Raise -Identity $domain -Server $Server -Configuration ([pscustomobject]$tempConfiguration) -ADObject $domain -Changed (
                New-AdcChange -Property DomainLevel -OldValue $domain.DomainMode -NewValue $tempConfiguration['DesiredLevel'] -Identity $domain -Type DomainLevel -ToString {
                    { '{0}: {1} -> {2}' -f $this.Identity, $this.Old, $this.New }
                }
            )
        }
    }
}

function Unregister-DMDomainLevel
{
<#
    .SYNOPSIS
        Removes the domain level configuration if present.
     
    .DESCRIPTION
        Removes the domain level configuration if present.
     
    .EXAMPLE
        PS C:\> Unregister-DMDomainLevel
     
        Removes the domain level configuration if present.
#>

    [CmdletBinding()]
    Param (
    
    )
    
    process
    {
        $script:domainLevel = $null
    }
}


function Get-DMExchange
{
<#
    .SYNOPSIS
        Returns the defined Exchange domain configuration to apply.
     
    .DESCRIPTION
        Returns the defined Exchange domain configuration to apply.
     
    .EXAMPLE
        PS C:\> Get-DMExchange
     
        Returns the defined Exchange domain configuration to apply.
#>

    [CmdletBinding()]
    param (
        
    )
    
    process {
        $script:exchangeVersion
    }
}


function Invoke-DMExchange
{
<#
    .SYNOPSIS
        Apply the desired exchange domain content update.
     
    .DESCRIPTION
        Apply the desired exchange domain content update.
        Use Register-DMExchange to define the exchange update.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMExchange -Server dc1.emea.contoso.com
     
        Apply the desired exchange domain content update to the emea.contoso.com domain.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '')]
    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [Parameter(Mandatory = $true)]
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,
        
        [switch]
        $EnableException
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type ExchangeVersion -Cmdlet $PSCmdlet
        $domainObject = Get-ADDomain @parameters
        
        #region Utility Functions
        function Test-ExchangeIsoPath {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
            [CmdletBinding()]
            param (
                [System.Management.Automation.Runspaces.PSSession]
                $Session,
                
                [string]
                $Path
            )
            
            Invoke-Command -Session $Session -ScriptBlock {
                Test-Path -Path $using:Path
            }
        }
        
        function Invoke-ExchangeDomainUpdate {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
            [CmdletBinding()]
            param (
                [System.Management.Automation.Runspaces.PSSession]
                $Session,
                
                [string]
                $Path,

                [ValidateSet('Install', 'Update')]
                [string]
                $Mode
            )
            
            $result = Invoke-Command -Session $Session -ScriptBlock {
                param (
                    $Parameters
                )
                $exchangeIsoPath = Resolve-Path -Path $Parameters.Path
                
                # Mount Volume
                $diskImage = Mount-DiskImage -ImagePath $exchangeIsoPath -PassThru
                $volume = Get-Volume -DiskImage $diskImage
                $installPath = "$($volume.DriveLetter):\setup.exe"
                
                #region Execute
                $resultText = switch ($Parameters.Mode) {
                    'Install' { & $installPath /PrepareDomain /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1 }
                    'Update' { & $installPath /PrepareDomain /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1 }
                }
                $results = [pscustomobject]@{
                    Success = $LASTEXITCODE -lt 1
                    Message = $resultText -join "`n"
                }
                #endregion Execute
                
                # Dismount Volume
                try { Dismount-DiskImage -ImagePath $exchangeIsoPath }
                catch { }
                
                # Report result
                $results
            } -ArgumentList ($PSBoundParameters | ConvertTo-PSFHashtable -Exclude Session)
            Write-PSFMessage -Message ($result.Message -join "`n") -Tag exchange, result
            if (-not $result.Success) {
                throw "Error applying exchange update: $($result.Message)"
            }
        }
        #endregion Utility Functions
    }
    process
    {
        $testResult = Test-DMExchange @parameters
        
        if (-not $testResult) { return }
        
        #region PS Remoting
        $psParameter = $PSBoundParameters | ConvertTo-PSFHashtable -Include Credential
        $psParameter.ComputerName = $Server
        try { $session = New-PSSession @psParameter -ErrorAction Stop }
        catch {
            Stop-PSFFunction -String 'Invoke-DMExchange.WinRM.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $Server
            return
        }
        #endregion PS Remoting
        
        #region Execute
        try {
            switch ($testResult.Type) {
                'Install'
                {
                    if (-not (Test-ExchangeIsoPath -Session $session -Path $testResult.Configuration.LocalImagePath)) {
                        Stop-PSFFunction -String 'Invoke-DMExchange.IsoPath.Missing' -StringValues $testResult.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMExchange.Installing' -ActionStringValues $testResult.Configuration -Target $domainObject -ScriptBlock {
                        Invoke-ExchangeDomainUpdate -Session $session -Mode Install -Path $testResult.Configuration.LocalImagePath -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                'Update'
                {
                    if (-not (Test-ExchangeIsoPath -Session $session -Path $testResult.Configuration.LocalImagePath)) {
                        Stop-PSFFunction -String 'Invoke-DMExchange.IsoPath.Missing' -StringValues $testResult.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMExchange.Updating' -ActionStringValues $testResult.Configuration -Target $domainObject -ScriptBlock {
                        Invoke-ExchangeDomainUpdate -Session $session -Mode Update -Path $testResult.Configuration.LocalImagePath -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
            }
        }
        #endregion Execute
        finally {
            if ($session) { Remove-PSSession -Session $session -ErrorAction Ignore -Confirm:$false -WhatIf:$false }
        }
    }
}

function Register-DMExchange
{
<#
    .SYNOPSIS
        Registers an exchange version to apply to the domain's exchange objects.
     
    .DESCRIPTION
        Registers an exchange version to apply to the domain's exchange objects.
        Updating this requires Enterprise Admin permissions.
     
    .PARAMETER LocalImagePath
        The path where to find the Exchange ISO file
        Must be local on the remote server connected to!
        Updating the Exchange AD settings is only supported when executed through the installer contained in that ISO file without exceptions.
     
    .PARAMETER ExchangeVersion
        The version of the Exchange server to apply.
        E.g. 2016CU6
        We map Exchange versions to their respective identifier in AD:
        ObjectVersion in the domain's Microsoft Exchange System Objects container.
        This parameter is to help avoiding to have to look up that value.
        If your version is not supported by us yet, look up the version number and explicitly bind it to -ObjectVersion instead.
     
    .PARAMETER ObjectVersion
        The object version on the "Microsoft Exchange System Objects" container in the domain.
 
    .PARAMETER ContextName
        The name of the context defining the setting.
        This allows determining the configuration set that provided this setting.
        Used by the ADMF, available to any other configuration management solution.
     
    .EXAMPLE
        PS C:\> Register-DMExchange -LocalImagePath 'C:\ISO\exchange-2019-cu6.iso' -ExchangeVersion '2019CU6'
         
        Registers the Exchange 2019 CU6 exchange version as exchange domain settings to be applied.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $LocalImagePath,
        
        [Parameter(Mandatory = $true, ParameterSetName = 'Version')]
        [PsfValidateSet(TabCompletion = 'ADMF.Core.ExchangeVersion')]
        [PsfArgumentCompleter('ADMF.Core.ExchangeVersion')]
        [string]
        $ExchangeVersion,
        
        [Parameter(ParameterSetName = 'Details')]
        [int]
        $ObjectVersion,
        
        [string]
        $ContextName = '<Undefined>'
    )
    
    process
    {
        $object = [pscustomobject]@{
            PSTypeName        = 'DomainManagement.Configuration.Exchange'
            ObjectVersion   = $ObjectVersion
            LocalImagePath  = $LocalImagePath
            ExchangeVersion = (Get-AdcExchangeVersion | Where-Object DomainVersion -eq $ObjectVersion | Sort-Object Name | Select-Object -Last 1).Name
            ContextName        = $ContextName
        }
        
        if ($ExchangeVersion)
        {
            # Will always succeede, since the input validation prevents invalid exchange versions
            $exchangeVersionInfo = Get-AdcExchangeVersion -Binding $ExchangeVersion
            $object.ObjectVersion = $exchangeVersionInfo.DomainVersion
            $object.ExchangeVersion = $exchangeVersionInfo.Name
        }
        
        Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value {
            if ($this.ExchangeVersion) { $this.ExchangeVersion }
            else { $this.ObjectVersion }
        } -Force
        $script:exchangeVersion = @($object)
    }
}


function Test-DMExchange
{
<#
    .SYNOPSIS
        Check whether the targeted domain has the desired exchange object update version.
     
    .DESCRIPTION
        Check whether the targeted domain has the desired exchange object update version.
        Use Register-DMExchange to define the desired version.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Test-DMExchange
     
        Check whether the current domain has the desired exchange object update version.
#>

    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type ExchangeVersion -Cmdlet $PSCmdlet
    }
    process
    {
        $desiredState = Get-DMExchange
        $adObject = Get-ADObject @parameters -LDAPFilter '(objectClass=msExchSystemObjectsContainer)' -Properties objectVersion
        
        $resultDefaults = @{
            ObjectType = 'ExchangeVersion'
            Server       = $parameters.Server
            Configuration = $desiredState
        }
        
        if (-not $adObject) {
            New-TestResult @resultDefaults -Type Install -Identity 'Exchange Domain Objects'
            return
        }
        
        if (($adObject.objectVersion -as [int]) -lt $desiredState.ObjectVersion) {
            New-TestResult @resultDefaults -Type Update -Identity 'Exchange Domain Objects' -ADObject $adObject
        }
    }
}

function Unregister-DMExchange
{
<#
    .SYNOPSIS
        Clears the defined exchange domain configuration from the loaded configuration set.
     
    .DESCRIPTION
        Clears the defined exchange domain configuration from the loaded configuration set.
     
    .EXAMPLE
        PS C:\> Unregister-DMExchange
     
        Clears the defined exchange domain configuration from the loaded configuration set.
#>

    [CmdletBinding()]
    param (
        
    )
    
    process {
        $script:exchangeVersion = $null
    }
}


function Get-DMGPLink
{
    <#
    .SYNOPSIS
        Returns the list of registered group policy links.
     
    .DESCRIPTION
        Returns the list of registered group policy links.
        Use Register-DMGPLink to register new group policy links.
     
    .PARAMETER PolicyName
        The name of the GPO to filter by.
     
    .PARAMETER OrganizationalUnit
        The name of the OU the GPO is assigned to.
     
    .EXAMPLE
        PS C:\> Get-DMGPLink
 
        Returns all registered GPLinks
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [CmdletBinding()]
    param (
        [string]
        $PolicyName = '*',
        
        [string]
        $OrganizationalUnit = '*'
    )
    
    process
    {
        ($script:groupPolicyLinks.Values.Values) | Where-Object {
            ($_.PolicyName -like $PolicyName) -and ($_.OrganizationalUnit -like $OrganizationalUnit)
        } | Remove-PSFNull
        ($script:groupPolicyLinksDynamic.Values.Values) | Where-Object {
            ($_.PolicyName -like $PolicyName) -and ($_.OrganizationalUnit -like $OrganizationalUnit)
        } | Remove-PSFNull
    }
}


function Invoke-DMGPLink {
    <#
    .SYNOPSIS
        Applies the desired group policy linking configuration.
     
    .DESCRIPTION
        Applies the desired group policy linking configuration.
        Use Register-DMGPLink to define the desired state.
         
        Note: Invoke-DMGroupPolicy uses links to safely determine GPOs it can delete!
        It will look for GPOs that have been linked to managed folders in order to avoid fragile name lookups.
        Removing the old links before cleaning up the associated GPOs might leave orphaned GPOs in your domain.
        To avoid deleting old links, use the -Disable parameter.
 
        Recommended execution order:
        - Invoke GPOs (without deletion)
        - Invoke GPLinks (with -Disable)
        - Invoke GPOs (with deletion)
        - Invoke GPLinks (without -Disable)
     
    .PARAMETER InputObject
        Test results provided by the associated test command.
        Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER Disable
        By default, undesired links are removed.
        With this parameter set it will instead disable undesired links.
        Use this in order to not lose track of previously linked GPOs.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMGPLink
 
        Configures the current domain's group policy links as desired.
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $Disable,

        [switch]
        $EnableException
    )
    
    begin {
        #region Utility Functions
        function Clear-Link {
            [CmdletBinding()]
            param (
                [PSFComputer]
                $Server,
                
                [PSCredential]
                $Credential,

                $ADObject,

                [bool]
                $Disable
            )
            $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential

            if (-not $Disable) {
                Set-ADObject @parameters -Identity $ADObject -Clear gPLink -ErrorAction Stop
                return
            }
            Set-ADObject @parameters -Identity $ADObject -Replace @{ gPLink = ($ADObject.gPLink -replace ";\d\]", ";1]") } -ErrorAction Stop -Confirm:$false
        }

        function New-Link {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                [PSFComputer]
                $Server,
                
                [PSCredential]
                $Credential,

                $ADObject,

                $Configuration,

                [Hashtable]
                $GpoNameMapping
            )
            $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential

            $gpLinkString = ($Configuration.Include | Sort-Object -Property @{ Expression = { $_.Tier }; Descending = $false }, Precedence -Descending | ForEach-Object {
                    $gpoDN = $GpoNameMapping[(Resolve-String -Text $_.PolicyName)]
                    if (-not $gpoDN) {
                        Write-PSFMessage -Level Warning -String 'Invoke-DMGPLink.New.GpoNotFound' -StringValues (Resolve-String -Text $_.PolicyName) -Target $ADObject -FunctionName Invoke-DMGPLink
                        return
                    }
                    $stateID = "0"
                    if ($_.State -eq 'Enforced') { $stateID = "2" }
                    if ($_.State -eq 'Disabled') { $stateID = "1" }
                    "[LDAP://$gpoDN;$stateID]"
                }) -Join ""
            Write-PSFMessage -Level Debug -String 'Invoke-DMGPLink.New.NewGPLinkString' -StringValues $ADObject.DistinguishedName, $gpLinkString -Target $ADObject -FunctionName Invoke-DMGPLink
            Set-ADObject @parameters -Identity $ADObject -Replace @{ gPLink = $gpLinkString } -ErrorAction Stop -Confirm:$false
        }

        function Update-Link {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                [PSFComputer]
                $Server,
                
                [PSCredential]
                $Credential,

                $ADObject,

                $Configuration,

                [bool]
                $Disable,

                [Hashtable]
                $GpoNameMapping,

                $Changes
            )
            $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential

            $gpLinkString = ''
            if ($Disable) {
                $desiredDNs = $Configuration.ExtendedInclude.PolicyName | Resolve-String | ForEach-Object { $GpoNameMapping[$_] }
                $gpLinkString += ($ADobject.LinkedGroupPolicyObjects | Where-Object DistinguishedName -NotIn $desiredDNs | Sort-Object -Property Precedence -Descending | ForEach-Object {
                        "[LDAP://$($_.DistinguishedName);1]"
                    }) -join ""
            }
            
            $gpLinkString += ($Configuration.ExtendedInclude | Where-Object DistinguishedName | Sort-Object -Property @{ Expression = { $_.Tier }; Descending = $false }, Precedence -Descending | ForEach-Object {
                    $_.ToLink()
                }) -Join ""
            $msgParam = @{
                Level        = 'SomewhatVerbose'
                Tag          = 'change'
                Target       = $ADObject
                FunctionName = 'Invoke-DMGPLink'
            }
            Write-PSFMessage @msgParam -String 'Invoke-DMGPLink.Update.OldGPLinkString' -StringValues $ADObject.DistinguishedName, $ADObject.gPLink
            foreach ($change in $Changes) {
                Write-PSFMessage @msgParam -String 'Invoke-DMGPLink.Update.Change' -StringValues $change.Action, $change.Policy, $ADObject.DistinguishedName
            }
            Write-PSFMessage @msgParam -String 'Invoke-DMGPLink.Update.NewGPLinkString' -StringValues $ADObject.DistinguishedName, $gpLinkString
            Set-ADObject @parameters -Identity $ADObject -Replace @{ gPLink = $gpLinkString } -ErrorAction Stop -Confirm:$false
        }
        #endregion Utility Functions

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupPolicyLinks, GroupPolicyLinksDynamic -Cmdlet $PSCmdlet
        
        $gpoDisplayToDN = @{ }
        $gpoDNToDisplay = @{ }
        foreach ($adPolicyObject in (Get-ADObject @parameters -LDAPFilter '(objectCategory=groupPolicyContainer)' -Properties DisplayName, DistinguishedName)) {
            $gpoDisplayToDN[$adPolicyObject.DisplayName] = $adPolicyObject.DistinguishedName
            $gpoDNToDisplay[$adPolicyObject.DistinguishedName] = $adPolicyObject.DisplayName
        }
    }
    process {
        if (-not $InputObject) {
            $InputObject = Test-DMGPLink @parameters
        }
        
        #region Executing Test-Results
        foreach ($testItem in $InputObject) {
            # Catch invalid input - can only process test results
            if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.GPLink.TestResult') {
                Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMGPLink', $testItem -Target $testItem -Continue -EnableException $EnableException
            }
            
            $countConfigured = ($testItem.Configuration | Measure-Object).Count
            $countActual = ($testItem.ADObject.LinkedGroupPolicyObjects | Measure-Object).Count
            $countNotInConfig = ($testItem.ADObject.LinkedGroupPolicyObjects | Where-Object DistinguishedName -NotIn ($testItem.Configuration.PolicyName | Remove-PSFNull | Resolve-String | ForEach-Object { $gpoDisplayToDN[$_] }) | Measure-Object).Count

            switch ($testItem.Type) {
                'Delete' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPLink.Delete.AllEnabled' -ActionStringValues $countActual -Target $testItem -ScriptBlock {
                        Clear-Link @parameters -ADObject $testItem.ADObject -Disable $Disable -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Create' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPLink.New' -ActionStringValues $countConfigured -Target $testItem -ScriptBlock {
                        New-Link @parameters -ADObject $testItem.ADObject -Configuration $testItem.Configuration -GpoNameMapping $gpoDisplayToDN -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Update' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPLink.Update.AllEnabled' -ActionStringValues $countConfigured, $countActual, $countNotInConfig -Target $testItem -ScriptBlock {
                        Update-Link @parameters -ADObject $testItem.ADObject -Configuration $testItem.Configuration -Disable $Disable -GpoNameMapping $gpoDisplayToDN -Changes $testItem.Changed -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'GpoMissing' {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMGPLink.GpoMissing' -StringValues $testItem.ADObject, (($testItem.Changed | Where-Object Action -EQ 'GpoMissing').Policy -join ", ")
                }
            }
        }
        #endregion Executing Test-Results
    }
}


function Register-DMGPLink {
    <#
    .SYNOPSIS
        Registers a group policy link as a desired state.
     
    .DESCRIPTION
        Registers a group policy link as a desired state.
     
    .PARAMETER PolicyName
        The name of the group policy being linked.
        Supports string expansion.
     
    .PARAMETER OrganizationalUnit
        The organizational unit (or domain root) being linked to.
        Supports string expansion.
 
    .PARAMETER OUFilter
        A filter string for an organizational unit.
        The filter must be a wildcard-pattern supporting distinguishedname.
     
    .PARAMETER Precedence
        Numeric value representing the order it is linked in.
        The lower the number, the higher on the list, the more relevant the setting.
 
    .PARAMETER Tier
        The tier of a link is a priority ordering on top of Precedence.
        While precedence determines order within a given tier, each tier is processed separately.
        The higher the tier number, the higher the priority.
        In additive mode, already existing linked policies have a Tier 0 priority.
        If you want your own policies to be prepended, use Tier 1 or higher.
        If you want your own policies to have the least priority however, user Tier -1 or lower.
        Default: 1
 
    .PARAMETER State
        The state the link should be in.
        Supported states:
        + Enabled: Link should be enabled
        + Disabled: Link should be disabled
        + Enforced: Link is being enforced
        + Undefined: The current state of the link is ignored
        Defaults to: Enabled
 
    .PARAMETER ProcessingMode
        In which way GPO links are being processed:
        - Additive: Add provided links, but do not modify the existing ones.
        - Constrained: Replace existing links that are undesired
        By default, constrained mode is being used.
        If any single link for a given Organizational Unit is in constrained mode, the entire OU is processed under constraind mode.
 
    .PARAMETER Present
        Whether the link should be present at all.
        Relevant in additive mode, to retain the capability to delete undesired links.
     
    .EXAMPLE
        PS C:\> Get-Content $configPath | ConvertFrom-Json | Write-Output | Register-DMGPLink
 
        Import all GPLinks stored in the json file located at $configPath.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $PolicyName,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Path')]
        [Alias('OU')]
        [string]
        $OrganizationalUnit,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')]
        [string]
        $OUFilter,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [int]
        $Precedence,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [int]
        $Tier = 1,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('Enabled', 'Disabled', 'Enforced', 'Undefined')]
        [string]
        $State = 'Enabled',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('Constrained', 'Additive')]
        [string]
        $ProcessingMode = 'Constrained',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $Present = $true
    )
    
    process {
        switch ($PSCmdlet.ParameterSetName) {
            'Path' {
                if (-not $script:groupPolicyLinks[$OrganizationalUnit]) {
                    $script:groupPolicyLinks[$OrganizationalUnit] = @{ }
                }
                $script:groupPolicyLinks[$OrganizationalUnit][$PolicyName] = [PSCustomObject]@{
                    PSTypeName         = 'DomainManagement.GPLink'
                    PolicyName         = $PolicyName
                    OrganizationalUnit = $OrganizationalUnit
                    Precedence         = $Precedence
                    Tier               = $Tier
                    State              = $State
                    ProcessingMode     = $ProcessingMode
                    Present            = $Present
                }
            }
            'Filter' {
                if (-not $script:groupPolicyLinksDynamic[$OUFilter]) {
                    $script:groupPolicyLinksDynamic[$OUFilter] = @{ }
                }
                $script:groupPolicyLinksDynamic[$OUFilter][$PolicyName] = [PSCustomObject]@{
                    PSTypeName     = 'DomainManagement.GPLink'
                    PolicyName     = $PolicyName
                    OUFilter       = $OUFilter
                    Precedence     = $Precedence
                    Tier           = $Tier
                    State          = $State
                    ProcessingMode = $ProcessingMode
                    Present        = $Present
                }
            }
        }
    }
}

function Test-DMGPLink {
    <#
    .SYNOPSIS
        Tests, whether the configured group policy linking matches the desired state.
     
    .DESCRIPTION
        Tests, whether the configured group policy linking matches the desired state.
        Define the desired state using the Register-DMGPLink command.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Test-DMGPLink -Server contoso.com
 
        Tests, whether the group policy links of contoso.com match the configured state
    #>

    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupPolicyLinks, GroupPolicyLinksDynamic -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters

        #region Utility Functions
        function Get-OUData {
            [CmdletBinding()]
            param (
                $Parameters
            )

            $ous = @{ }
            #region Explicit OUs
            foreach ($organizationalUnit in $script:groupPolicyLinks.Keys) {
                $resolvedOU = Resolve-String -Text $organizationalUnit
                $ous[$resolvedOU] = [PSCustomObject]@{
                    OrganizationalUnit = $resolvedOU
                    ProcessingMode     = 'Additive'
                    Include            = @()
                    Exclude            = @()
                    ExtendedInclude    = @()
                }
                $ous[$resolvedOU].Include = $script:groupPolicyLinks[$organizationalUnit].Values | Where-Object Present
                $ous[$resolvedOU].Exclude = $script:groupPolicyLinks[$organizationalUnit].Values | Where-Object Present -EQ $false
                if ($ous[$resolvedOU].Include.ProcessingMode -contains 'Constrained') {
                    $ous[$resolvedOU].ProcessingMode = 'Constrained'
                }
            }
            #region Explicit OUs
            
            #region Filter-Based OUs
            foreach ($filter in $script:groupPolicyLinksDynamic.Keys) {
                $adObjects = Resolve-ADObject @Parameters -Filter (Resolve-String -Text $filter) -ObjectClass organizationalUnit
                $values = $script:groupPolicyLinksDynamic[$filter].Values
                
                foreach ($adObject in $adObjects) {
                    if (-not $ous[$adObject.DistinguishedName]) {
                        $ous[$adObject.DistinguishedName] = [PSCustomObject]@{
                            OrganizationalUnit = $adObject.DistinguishedName
                            ProcessingMode     = 'Additive'
                            Include            = @()
                            Exclude            = @()
                            ExtendedInclude    = @()
                        }
                    }
                    $container = $ous[$adObject.DistinguishedName]
                    $container.Include = $container.Include, $values | Remove-PSFNull -Enumerate | Where-Object Present
                    $container.Exclude = $container.Exclude, $values | Remove-PSFNull -Enumerate | Where-Object Present -EQ $false
                    if ($container.Include.ProcessingMode -contains 'Constrained') {
                        $container.ProcessingMode = 'Constrained'
                    }
                }
            }
            #endregion Filter-Based OUs
            
            $ous.Values
        }
        
        function New-Update {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
            [CmdletBinding()]
            param (
                $PolicyName,
                $Status,
                $Action,
                $Identity
            )

            $update = [PSCustomObject]@{
                PSTypeName = 'DomainManagement.GPLink.Update'
                Action     = $Action
                Policy     = $PolicyName
                Status     = $Status
                Identity   = $Identity
            }
            Add-Member -InputObject $update -MemberType ScriptMethod -Name ToString -Value {
                '{0}: {1}' -f $this.Action, $this.Policy
            } -Force -PassThru
        }
        
        function ConvertTo-LinkConfigWithState {
            <#
            .SYNOPSIS
                Convert config object to new object and match it with the state of the current item.
            #>

            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                $LinkObject,

                [AllowNull()]
                $CurrentLinks,

                [hashtable]
                $GpoDisplayToDN = @{ }
            )

            process {
                foreach ($linkItem in $LinkObject) {
                    if (-not $linkItem) { continue }
                    $currentLink = $CurrentLinks | Where-Object DisplayName -EQ $linkItem.PolicyName

                    $itemHash = $linkItem | ConvertTo-PSFHashtable
                    $itemHash.PSTypeName = 'DomainManagement.GPLink'
                    $itemHash.StateValid = ($linkItem.State -eq $currentLink.Status) -or ($currentLink -and $linkItem.State -eq 'Undefined')
                    $itemHash.CurrentState = $currentLink.Status
                    $itemHash.PolicyName = $itemHash.PolicyName | Resolve-String
                    $itemHash.DistinguishedName = $GpoDisplayToDN[$itemHash.PolicyName]

                    $object = [PSCustomObject]$itemHash

                    Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value {
                        switch ($this.State) {
                            'Enabled' { $this.PolicyName }
                            'Disabled' { '~|{0}' -f $this.PolicyName }
                            'Enforced' { '*|{0}' -f $this.PolicyName }
                        }
                    } -Force
                    Add-Member -InputObject $object -MemberType ScriptMethod -Name ToLink -Value {
                        # [LDAP://cn={F4A6ADB1-BEDE-497D-901F-F24B19394951},cn=policies,cn=system,DC=contoso,DC=com;0][LDAP://cn={2036B9B6-D5C1-4756-B7AB-8291A9B26521},cn=policies,cn=system,DC=contoso,DC=com;0]
                        $statusLabel = $this.State
                        if ($statusLabel -eq 'Undefined' -and $this.CurrentState) { $statusLabel = $this.CurrentState }
                        elseif ($statusLabel -eq 'Undefined') { $statusLabel = 'Enabled' }

                        $status = switch ($statusLabel) {
                            'Enabled' { "0" }
                            'Disabled' { "1" }
                            'Enforced' { "2" }
                        }
                        '[LDAP://{0};{1}]' -f $this.DistinguishedName, $status
                    }

                    $object
                }
            }
        }

        function ConvertFrom-ADLink {
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                $LinkObject
            )
            process {
                foreach ($object in $LinkObject) {
                    $objectHash = $object | ConvertTo-PSFHashtable
                    $objectHash.Tier = 0
                    $objectHash.PolicyName = $objectHash.DisplayName
                    $objectHash.StateValid = $true
                    $objectHash.CurrentState = $objectHash.Status
                    $objectHash.State = $objectHash.Status

                    $item = [PSCustomObject]$objectHash

                    Add-Member -InputObject $item -MemberType ScriptMethod -Name ToString -Value {
                        switch ($this.Status) {
                            'Enabled' { $this.DisplayName }
                            'Disabled' { '~|{0}' -f $this.DisplayName }
                            'Enforced' { '*|{0}' -f $this.DisplayName }
                        }
                    } -Force
                    Add-Member -InputObject $item -MemberType ScriptMethod -Name ToLink -Value {
                        # [LDAP://cn={F4A6ADB1-BEDE-497D-901F-F24B19394951},cn=policies,cn=system,DC=contoso,DC=com;0][LDAP://cn={2036B9B6-D5C1-4756-B7AB-8291A9B26521},cn=policies,cn=system,DC=contoso,DC=com;0]
                        $status = '0'
                        if ($this.Status -eq 'Disabled') { $status = '1' }
                        if ($this.Status -eq 'Enforced') { $status = '2' }
                        '[LDAP://{0};{1}]' -f $this.DistinguishedName, $status
                    }

                    $item
                }
            }
        }

        function Get-LinkUpdate {
            [CmdletBinding()]
            param (
                $Configuration,
                $ADObject,
                $GpoDisplayToDN
            )

            $currentSorted = $ADObject.LinkedGroupPolicyObjects | Sort-Object Precedence
            $includeSorted = $Configuration.Include | Sort-Object @{ Expression = { $_.Tier }; Descending = $true }, Precedence | Where-Object PolicyName -NotIn $Configuration.Exclude.PolicyName | ConvertTo-LinkConfigWithState -CurrentLinks $currentSorted -GpoDisplayToDN $GpoDisplayToDN

            if ($Configuration.ProcessingMode -eq 'Additive') {
                $currentAdditive = $ADObject.LinkedGroupPolicyObjects | Where-Object DisplayName -NotIn $includeSorted.PolicyName | Where-Object DisplayName -NotIn $Configuration.Exclude.PolicyName | Sort-Object Precedence | ConvertFrom-ADLink
                $newDesiredState = @($currentAdditive) + @($includeSorted) | Write-Output | Remove-PSFNull | Sort-Object @{ Expression = { $_.Tier }; Descending = $true }, Precedence
            }
            else { $newDesiredState = $includeSorted }
            $Configuration.ExtendedInclude = $newDesiredState
            
            $orderCorrect = Compare-Array -ReferenceObject $newDesiredState.PolicyName -DifferenceObject $currentSorted.DisplayName -OrderSpecific -Quiet
            if ($orderCorrect -and $newDesiredState.StateValid -notcontains $false) {
                return
            }

            $index = 0
            foreach ($desired in $newDesiredState) {
                if ($currentSorted.DisplayName -notcontains $desired.PolicyName) {
                    if ($desired.DistinguishedName) {
                        New-Update -Action Add -PolicyName $desired.PolicyName -Status 'Enabled' -Identity $ADObject.DistinguishedName
                        $index = $index + 1
                    }
                    else {
                        New-Update -Action GpoMissing -PolicyName $desired.PolicyName -Status 'Enabled' -Identity $ADObject.DistinguishedName
                    }
                    continue
                }
                if ($index -gt @($currentSorted).Count -or $desired.PolicyName -ne $currentSorted[$index].DisplayName) {
                    New-Update -Action Reorder -PolicyName $desired.PolicyName -Status 'Enabled' -Identity $ADObject.DistinguishedName
                    $index = $index + 1
                    continue
                }
                if (-not $desired.StateValid) {
                    New-Update -Action State -PolicyName $desired.PolicyName -Status $desired.State -Identity $ADObject.DistinguishedName
                    $index = $index + 1
                    continue
                }
                $index = $index + 1
            }
            foreach ($current in $currentSorted) {
                if ($current.DisplayName -notin $newDesiredState.PolicyName) {
                    New-Update -Action Delete -PolicyName $current.DisplayName -Status $current.Status -Identity $ADObject.DistinguishedName
                }
            }
        }
        #endregion Utility Functions

        $gpoDisplayToDN = @{ }
        $gpoDNToDisplay = @{ }
        foreach ($adPolicyObject in (Get-ADObject @parameters -LDAPFilter '(objectCategory=groupPolicyContainer)' -Properties DisplayName, DistinguishedName)) {
            $gpoDisplayToDN[$adPolicyObject.DisplayName] = $adPolicyObject.DistinguishedName
            $gpoDNToDisplay[$adPolicyObject.DistinguishedName] = $adPolicyObject.DisplayName
        }
    }
    process {
        #region Process Configuration
        $ouData = Get-OUData -Parameters $parameters
        foreach ($ouDatum in $ouData) {
            $resultDefaults = @{
                Server        = $Server
                ObjectType    = 'GPLink'
                Identity      = $ouDatum.OrganizationalUnit
                Configuration = $ouDatum
            }

            #region Handle AD Object doesn't exist
            try {
                $adObject = Get-ADObject @parameters -Identity $ouDatum.OrganizationalUnit -ErrorAction Stop -Properties gPLink, Name, DistinguishedName
                $resultDefaults['ADObject'] = $adObject
            }
            catch {
                Write-PSFMessage -String 'Test-DMGPLink.OUNotFound' -StringValues $ouDatum.OrganizationalUnit -ErrorRecord $_ -Tag 'panic', 'failed'
                New-TestResult @resultDefaults -Type 'MissingParent'
                Continue
            }
            #endregion Handle AD Object doesn't exist

            #region Handle AD Object does not contain any links
            $currentState = $adObject | ConvertTo-GPLink -PolicyMapping $gpoDNToDisplay
            Add-Member -InputObject $adObject -MemberType NoteProperty -Name LinkedGroupPolicyObjects -Value $currentState -Force
            if (-not $currentState) {
                $updates = foreach ($includedLink in $ouDatum.Include) {
                    New-Update -Action Create -PolicyName $includedLink.PolicyName -Status $includedLink.State -Identity $ouDatum.OrganizationalUnit
                }
                New-TestResult @resultDefaults -Type 'Create' -Changed $updates
                continue
            }
            #endregion Handle AD Object does not contain any links

            $updates = Get-LinkUpdate -Configuration $ouDatum -ADObject $adObject -GpoDisplayToDN $gpoDisplayToDN | Sort-Object {
                if ($_.Action -eq "Delete") { 0 }
                elseif ($_.Action -eq "Reorder") { 1 }
                else { 2 }
            }
            if ($updates.Action -contains 'GpoMissing') {
                New-TestResult @resultDefaults -Type 'GpoMissing' -Changed $updates
                continue
            }
            if ($updates) {
                New-TestResult @resultDefaults -Type 'Update' -Changed $updates
            }
        }

        #region Process Managed Estate
        # OneLevel needs to be converted to base, as searching for OUs with "OneLevel" would return unmanaged OUs.
        # This search however is targeted at GPOs linked to managed OUs only.
        $translateScope = @{
            'Subtree'  = 'Subtree'
            'OneLevel' = 'Base'
            'Base'     = 'Base'
        }
        $adObjects = foreach ($searchBase in (Resolve-ContentSearchBase @parameters)) {
            Get-ADObject @parameters -LDAPFilter '(gPLink=*)' -SearchBase $searchBase.SearchBase -SearchScope $translateScope[$searchBase.SearchScope] -Properties gPLink, Name, DistinguishedName
        }

        foreach ($adObject in $adObjects) {
            # If we have a configuration on it, it has already been processed
            if ($adObject.DistinguishedName -in $ouData.OrganizationalUnit) { continue }
            if ([string]::IsNullOrWhiteSpace($adObject.GPLink)) { continue }

            $linkObjects = $adObject | ConvertTo-GPLink -PolicyMapping $gpoDNToDisplay
            Add-Member -InputObject $adObject -MemberType NoteProperty -Name LinkedGroupPolicyObjects -Value $linkObjects -Force

            $changes = foreach ($linkedObject in $linkObjects) {
                New-Update -PolicyName $linkedObject.DisplayName -Status $linkedObject.Status -Action Delete -Identity $adObject.DistinguishedName
            }
            New-TestResult -ObjectType GPLink -Type 'Delete' -Identity $adObject.DistinguishedName -Server $Server -ADObject $adObject -Changed $changes
        }
        #endregion Process Managed Estate
    }
}

function Unregister-DMGPLink
{
    <#
    .SYNOPSIS
        Removes a group policy link from the configured desired state.
     
    .DESCRIPTION
        Removes a group policy link from the configured desired state.
     
    .PARAMETER PolicyName
        The name of the policy to unregister.
     
    .PARAMETER OrganizationalUnit
        The name of the organizational unit the policy should be unregistered from.
 
    .PARAMETER OUFilter
        The filter of the filterbased policy link to remove
     
    .EXAMPLE
        PS C:\> Get-DMGPLink | Unregister-DMGPLink
 
        Clears all configured Group policy links.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Path')]
    param (
        [parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $PolicyName,

        [parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Path')]
        [Alias('OU')]
        [string]
        $OrganizationalUnit,

        [parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')]
        [string]
        $OUFilter
    )
    
    process
    {
        switch ($PSCmdlet.ParameterSetName) {
            'Path'
            {
                $script:groupPolicyLinks[$OrganizationalUnit].Remove($PolicyName)
                if ($script:groupPolicyLinks[$OrganizationalUnit].Keys.Count -lt 1) { $script:groupPolicyLinks.Remove($OrganizationalUnit) }
            }
            'Filter'
            {
                $script:groupPolicyLinksDynamic[$OUFilter].Remove($PolicyName)
                if ($script:groupPolicyLinksDynamic[$OUFilter].Keys.Count -lt 1) { $script:groupPolicyLinksDynamic.Remove($OUFilter) }
            }
        }
        
    }
}

function Get-DMGPOwner
{
    <#
    .SYNOPSIS
        Returns the list of defined group policy ownerships.
     
    .DESCRIPTION
        Returns the list of defined group policy ownerships.
        This represents the _desired_ state in your domain, not the one that actually pertains.
     
    .PARAMETER GpoName
        The name of the by which to filter.
     
    .PARAMETER Identity
        The identity reference to be made owner.
     
    .PARAMETER Filter
        The actual filter logic that determines, whether a policy should be affected by the given rule.
     
    .PARAMETER IsGlobal
        Only return the global / default owner setting
     
    .EXAMPLE
        PS C:\> Get-DMGPOwner
 
        Returns all configured GP ownerships
    #>

    [CmdletBinding()]
    Param (
        [string]
        $GpoName,

        [string]
        $Identity,

        [string]
        $Filter,

        [switch]
        $IsGlobal
    )
    
    process
    {
        $results = foreach ($rule in $script:groupPolicyOwners.Values) {
            if ((Test-PSFParameterBinding -ParameterName GpoName) -and ($rule.GpoName -notlike $GpoName)) { continue }
            if ((Test-PSFParameterBinding -ParameterName Identity) -and ($rule.Identity -notlike $Identity)) { continue }
            if ((Test-PSFParameterBinding -ParameterName Filter) -and ($rule.Filter -notlike $Filter)) { continue }
            if ($IsGlobal -and -not $rule.All) { continue }

            $rule
        }
        $results
    }
}


function Invoke-DMGPOwner {
    <#
    .SYNOPSIS
        Brings all group ownerships into the desired state.
     
    .DESCRIPTION
        Brings all group ownerships into the desired state.
        Use Register-DMGPOwner to define a desired state.
        Use Test-DMGPOwner to test/preview changes.
     
    .PARAMETER InputObject
        Test results provided by the associated test command.
        Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMGPOwner -Server corp.contoso.com
         
        Bringsgs the domain corp.contoso.com into the desired state where group policy ownership is concerned
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupPolicyOwners -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process {
        # Test All GPO Ownerships if no specific test result was specified
        if (-not $InputObject) {
            $InputObject = Test-DMGPOwner @parameters -EnableException:$EnableException
        }

        #region Process Test results
        foreach ($testResult in $InputObject) {
            # Catch invalid input - can only process test results
            if ($testResult.PSObject.TypeNames -notcontains 'DomainManagement.GPOwner.TestResult') {
                Stop-PSFFunction -String 'Invoke-DMGPOwner.Invalid.Input' -StringValues $testResult -Target $testResult -Continue -EnableException $EnableException
            }

            switch ($testResult.Type) {
                'Update' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPOwner.Update.Owner' -ActionStringValues $testResult.Changed.Old, $testResult.Changed.New, $testResult.Identity -ScriptBlock {
                        Set-AdsOwner @parameters -Path $testResult.ADObject -Identity $testResult.Changed.NewObject.ObjectSID -EnableException -Confirm:$false
                    } -Target $testResult.Identity -PSCmdlet $PSCmdlet -EnableException $EnableException
                }
            }
        }
        #endregion Process Test results
    }
}


function Register-DMGPOwner {
    <#
    .SYNOPSIS
        Define the desired state for group policy ownership.
     
    .DESCRIPTION
        Define the desired state for group policy ownership.
        Afterwards use Test-DMGPOwner to determine, whether reality matches desire.
        Or Invoke-DMGPOwner to bring reality into the desired state.
 
        You can define ownership in three ways:
        - Explicitly to a specific group policy object
        - By filter, using the same filter syntax as used for GP Permissions
        - Global, a default setting for when the other two do not apply
 
        In Case multiple rules apply to a GPO, this precedence will be adhered to:
        Explicit > Filter > Global
        In case multiple filters apply, the one with the lowest Weight value applies.
     
    .PARAMETER GpoName
        The name of the GPO this rule applies to.
        This parameter uses name resolution.
     
    .PARAMETER Filter
        The filter by which to determine which GPO this rule applies to.
        Examples:
        - "IsManaged -and Tier0"
        - "-not (IsManaged) -or (Tier1 -and UserScope)"
        Each condition (e.g. "IsManaged" or "Tier0") needs to be defined as a condition separately.
 
        Conditions are documented here:
        - https://admf.one/documentation/components/domain/gppermissionfilters.html
        Examples on how to use them can be found in the "Filter" parameter description here:
        - https://admf.one/documentation/components/domain/gppermissions.html
     
    .PARAMETER Weight
        The precedence order when multiple filter conditions apply.
        The lower the number, the higher the priority.
     
    .PARAMETER All
        Define a global default rule.
        There can always only be one global default value.
     
    .PARAMETER Identity
        The identity that should be the owner of the affected GPO(s).
        Can be a sid or an NT identity reference.
        This parameter supports name resolution.
     
    .PARAMETER ContextName
        The name of the context defining the setting.
        This allows determining the configuration set that provided this setting.
        Used by the ADMF, available to any other configuration management solution.
     
    .EXAMPLE
        PS C:\> Get-Content .\gpoowners.json | ConvertFrom-Json | Write-Output | Register-DMGPOwner
 
        Reads all settings from the provided json file and registers them.
     
    .NOTES
    General notes
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Explicit')]
        [string]
        $GpoName,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')]
        [PsfValidateScript('DomainManagement.Validate.GPPermissionFilter', ErrorString = 'DomainManagement.Validate.GPPermissionFilter')]
        [string]
        $Filter,

        [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')]
        [int]
        $Weight = 50,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'All')]
        [switch]
        $All,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Explicit')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'All')]
        [PsfValidateScript('DomainManagement.Validate.Identity', ErrorString = 'DomainManagement.Validate.Identity')]
        [string]
        $Identity,

        [string]
        $ContextName = '<Undefined>'
    )
    
    process {
        switch ($PSCmdlet.ParameterSetName) {
            'Explicit' {
                $permIdentity = 'Explicit|{0}' -f $GpoName

                $script:groupPolicyOwners[$permIdentity] = [PSCustomObject]@{
                    PSTypeName    = 'DomainManagement.Configuration.GPOwner'
                    EntryIdentity = $permIdentity
                    Type          = $PSCmdlet.ParameterSetName
                    GpoName       = $GpoName
                    Identity      = $Identity
                    ContextName   = $ContextName
                }
            }
            'Filter' {
                $permIdentity = 'Filter|{0}' -f $Filter

                $script:groupPolicyOwners[$permIdentity] = [PSCustomObject]@{
                    PSTypeName       = 'DomainManagement.Configuration.GPOwner'
                    EntryIdentity    = $permIdentity
                    Type             = $PSCmdlet.ParameterSetName
                    Filter           = $Filter
                    FilterConditions = ConvertTo-FilterName -Filter $Filter
                    Weight           = $Weight
                    Identity         = $Identity
                    ContextName      = $ContextName
                }
            }
            'All' {
                $script:groupPolicyOwners['All'] = [PSCustomObject]@{
                    PSTypeName    = 'DomainManagement.Configuration.GPOwner'
                    EntryIdentity = 'All'
                    Type          = $PSCmdlet.ParameterSetName
                    All           = $true
                    Identity      = $Identity
                    ContextName   = $ContextName
                }
            }
        }
    }
}

function Test-DMGPOwner {
    <#
    .SYNOPSIS
        Tests, whether a domain's group policy ownerships are in the desired state.
     
    .DESCRIPTION
        Tests, whether a domain's group policy ownerships are in the desired state.
 
        Use Register-DMGPOwner to define the desired state.
        Use Invoke-DMGPOwner to bring the domain into the desired state.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Test-DMGPOwner -Server corp.contoso.com
 
        Tests whether the domain of corp.contoso.com has the desired GP Owner configuration.
    #>

    [CmdletBinding()]
    Param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupPolicyOwners -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process {
        $ownerConfig = Get-DMGPOwner

        #region Resolve which condition maps to which GPO
        $filterMapping = Resolve-GPFilterMapping @parameters -Conditions ($ownerConfig.FilterConditions | Remove-PSFNull -Enumerate | Sort-Object -Unique)
        if (-not $filterMapping.Success) {
            switch ($filterMapping.ErrorType) {
                MissingCondition {
                    Stop-PSFFunction -String Test-DMGPOwner.Filter.Error.MissingCondition -StringValues ($filterMapping.MissingCondition -join ", ") -EnableException $EnableException -Category ObjectNotFound -Tag fail, panic -Target $filterMapping
                    return
                }
                PathNotFound {
                    Stop-PSFFunction -String Test-DMGPOwner.Filter.Error.PathNotFound -StringValues $filterMapping.ErrorData -EnableException $EnableException -Category ObjectNotFound -Tag fail, panic -Target $filterMapping
                    return
                }
            }
        }
        #endregion Resolve which condition maps to which GPO

        foreach ($gpoADObject in $filterMapping.AllGpos) {
            $ownerCfg = $null
            #region Resolve applicable config item
            $ownerCfg = $ownerConfig | Where-Object {
                $_.Type -eq 'Explicit' -and
                $gpoADObject.DisplayName -eq (Resolve-String -Text $_.GpoName)
            } | Select-Object -First 1
            if (-not $ownerCfg) {
                $ownerCfg = $ownerConfig | Where-Object {
                    $_.Type -eq 'Filter' -and
                    (Test-GPPermissionFilter -GpoName $gpoADObject.DisplayName -Filter $_.Filter -Conditions $_.FilterConditions -FilterHash $filterMapping.Mapping)
                } | Sort-Object Weight | Select-Object -First 1
            }
            if (-not $ownerCfg) {
                $ownerCfg = $ownerConfig | Where-Object {
                    $_.Type -eq 'All'
                }
            }
            # If nothing is configured, ignore GPO
            if (-not $ownerCfg) { continue }
            #endregion Resolve applicable config item

            try { $desiredOwner = Resolve-Principal @parameters -Name (Resolve-String -Text $ownerCfg.Identity) -OutputType ADObject -ErrorAction Stop }
            catch {
                Write-PSFMessage -Level Warning -String 'Test-DMGPOwner.Identity.NotFound' -StringValues $ownerCfg.Identity, $gpoADObject.DisplayName -Target $ownerCfg
                New-TestResult -ObjectType GPOwner -Type IdentityNotFound -Identity $gpoADObject.DisplayName -Server $Server -Configuration $ownerCfg -ADObject $gpoADObject
                continue
            }
            $actualAcl = Get-AdsAcl @parameters -Path $gpoADObject
            Add-Member -InputObject $gpoADObject -MemberType NoteProperty -Name Acl -Value $actualAcl -Force
            $actualOwner = $actualAcl.GetOwner([System.Security.Principal.SecurityIdentifier])
            if ("$actualOwner" -eq "$($desiredOwner.ObjectSID)") { continue }

            try { $actualOwnerAD = Resolve-Principal @parameters -Name $actualOwner -OutputType ADObject }
            catch { $actualOwnerAD = $null }

            $change = [PSCustomObject]@{
                PSTypeName = 'DomainManagement.GPOwner.Change'
                Type       = 'ChangeOwner'
                New        = $desiredOwner.SamAccountName
                Old        = $actualOwnerAD.SamAccountName
                NewObject  = $desiredOwner
                Policy     = $gpoADObject.DisplayName
            }
            if (-not $change.Old) { $change.Old = $actualOwner }
            Add-Member -InputObject $change -MemberType ScriptMethod -Name ToString -Value {
                '{0} -> {1}' -f $this.Old, $this.New
            } -Force

            New-TestResult -ObjectType GPOwner -Type Update -Identity $gpoADObject.DisplayName -Changed $change -Server $Server -Configuration $ownerCfg -ADObject $gpoADObject
        }
    }
}


function Unregister-DMGPOwner
{
    <#
    .SYNOPSIS
        Removes entries from the desired state for group policy ownership.
     
    .DESCRIPTION
        Removes entries from the desired state for group policy ownership.
     
    .PARAMETER EntryIdentity
        The identity of the entry.
     
    .EXAMPLE
        PS C:\> Get-DMGPOwner | Unregister-DMGPOwner
 
        Clears all defines group policy ownerships
    #>

    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true)]
        [string[]]
        $EntryIdentity
    )
    
    process
    {
        foreach ($identityString in $EntryIdentity) {
            $script:groupPolicyOwners.Remove($identityString)
        }
    }
}


function Get-DMGPPermissionFilter
{
    <#
        .SYNOPSIS
            Lists the registered Group Policy permission filters.
             
        .DESCRIPTION
            Lists the registered Group Policy permission filters.
 
        .PARAMETER Name
            The name to filter by.
            Default: '*'
 
        .EXAMPLE
            PS C:\> Get-DMGPPermissionFilter
 
            Lists all registered Group Policy permission filters
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Name = '*'
    )
    
    process
    {
        ($script:groupPolicyPermissionFilters.Values) | Where-Object Name -like $Name
    }
}


function Register-DMGPPermissionFilter {
    <#
    .SYNOPSIS
        Registers a GP Permission filter rule.
     
    .DESCRIPTION
        Registers a GP Permission filter rule.
        These rules are used to apply GP Permissions not on any one specific object but on any number of GPOs that match the defined rule.
        For example it is possible to define rules that match GPOs by name, that apply to all GPOs defined in configuration or to GPOs linked under a specific OU structure.
     
    .PARAMETER Name
        Name of the filter rule.
        Must only contain letters, numbers and underscore.
     
    .PARAMETER Reverse
        Reverses the result of the rule.
        This combined with another condition allows reversing the result.
        For example, combined with a Path condition, this would make the filter match any GPO NOT linked to that path.
     
    .PARAMETER Managed
        Matches GPOs that are defined by ADMF ($true) or not so ($false).
     
    .PARAMETER Path
        Matches GPOs that have been linked to the specified organizational unit (or potentially OUs beneath it).
        Subject to name insertion.
     
    .PARAMETER PathScope
        Defines how the path rule is applied:
        - Base: Only the specified OU's linked GPOs are evaluated (default).
        - OneLevel: Only the OU's directly beneath the specified OU are evaluated for linked GPOs.
        - SubTree: All OUs under the specified path are avaluated for linked GPOs.
 
    .PARAMETER PathOptional
        Whether the path is optional.
        By default, when evaluating a path filter, processing of GP permission terminates if the designated path does not exist, as we cannot guarantee a consistent permission-set being applied.
        With this setting enabled, instead processing silently continues.
        (Even if this is enabled, a silent log entry will be added for tracking purposes!)
     
    .PARAMETER GPName
        Name of the GP to filter for.
        This can be a wildcard or regex match, depending on the -GPNameMode parameter, however by default an exact match is required.
        Subject to name insertion.
     
    .PARAMETER GPNameMode
        How exactly the GPName parameter is applied:
        - Explicit: An exact name equality is required (default)
        - Wildcard: Supports wildcard comparisons (using the -like operator)
        - Regex: Supports regex matching (using the -match operator)
        None of the three options is case sensitive.
 
    .PARAMETER ContextName
        The name of the context defining the setting.
        This allows determining the configuration set that provided this setting.
        Used by the ADMF, available to any other configuration management solution.
     
    .EXAMPLE
        PS C:\> Get-Content .\gppermissionfilter.json | ConvertFrom-Json | Write-Output | Register-DMGPPermissionFilter
 
        Reads all registered filters from the input file and registers them for use in testing Group Policy Permissionss.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [PsfValidatePattern('^[\w\d_]+$', ErrorString = 'DomainManagement.Validate.PermissionFilterName')]
        [string]
        $Name,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [switch]
        $Reverse,

        [Parameter(Mandatory = $true, ParameterSetName = 'Managed', ValueFromPipelineByPropertyName = $true)]
        [bool]
        $Managed,

        [Parameter(Mandatory = $true, ParameterSetName = 'Path', ValueFromPipelineByPropertyName = $true)]
        [string]
        $Path,

        [Parameter(ParameterSetName = 'Path', ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('Base', 'OneLevel', 'SubTree')]
        [string]
        $PathScope = 'Base',

        [Parameter(ParameterSetName = 'Path', ValueFromPipelineByPropertyName = $true)]
        [switch]
        $PathOptional,

        [Parameter(Mandatory = $true, ParameterSetName = 'GPName', ValueFromPipelineByPropertyName = $true)]
        [string]
        $GPName,

        [Parameter(ParameterSetName = 'GPName', ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('Explicit', 'Wildcard', 'Regex')]
        [string]
        $GPNameMode = 'Explicit',

        [string]
        $ContextName = '<Undefined>'
    )
    
    process {
        switch ($PSCmdlet.ParameterSetName) {
            'Managed' {
                $script:groupPolicyPermissionFilters[$Name] = [PSCustomObject]@{
                    PSTypeName  = 'DomainManagement.Configuration.GPPermissionFilter'
                    Type        = 'Managed'
                    Name        = $Name
                    Reverse     = $Reverse
                    Managed     = $Managed
                    ContextName = $ContextName
                }
            }
            'Path' {
                $script:groupPolicyPermissionFilters[$Name] = [PSCustomObject]@{
                    PSTypeName  = 'DomainManagement.Configuration.GPPermissionFilter'
                    Type        = 'Path'
                    Name        = $Name
                    Reverse     = $Reverse
                    Path        = $Path
                    Optional    = $PathOptional
                    Scope       = $PathScope
                    ContextName = $ContextName
                }
            }
            'GPName' {
                $script:groupPolicyPermissionFilters[$Name] = [PSCustomObject]@{
                    PSTypeName  = 'DomainManagement.Configuration.GPPermissionFilter'
                    Type        = 'GPName'
                    Name        = $Name
                    Reverse     = $Reverse
                    GPName      = $GPName
                    Mode        = $GPNameMode
                    ContextName = $ContextName
                }
            }
        }
    }
}

function Unregister-DMGPPermissionFilter
{
    <#
    .SYNOPSIS
        Removes a GP Permission Filter.
     
    .DESCRIPTION
        Removes a GP Permission Filter.
     
    .PARAMETER Name
        The name of the filter to remove.
     
    .EXAMPLE
        PS C:\> Get-DMGPPermissionFilter | Unregister-DMGPPermissionFilter
 
        Removes all registered GP Permission Filter.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($filterName in $Name) {
            $script:groupPolicyPermissionFilters.Remove($filterName)
        }
    }
}


function Get-DMGPPermission
{
    <#
        .SYNOPSIS
            Lists registered GP permission rules.
 
        .DESCRIPTION
            Lists registered GP permission rules.
            These represent the desired state for how access to Group Policy Objects should be configured.
 
        .PARAMETER GpoName
            The name of the GPO the rule was assigned to.
 
        .PARAMETER Identity
            The name of trustee receiving permissions.
 
        .PARAMETER Filter
            The filter string assigned to the access rule to return.
 
        .PARAMETER IsGlobal
            Only return rules that apply to ALL GPOs globally.
 
        .EXAMPLE
            PS C:\> Get-DMGPPermmission
 
            Returns all registered permissions.
    #>

    [CmdletBinding()]
    param (
        [string]
        $GpoName,

        [string]
        $Identity,

        [string]
        $Filter,

        [switch]
        $IsGlobal
    )
    
    process
    {
        $results = foreach ($rule in $script:groupPolicyPermissions.Values) {
            if ((Test-PSFParameterBinding -ParameterName GpoName) -and ($rule.GpoName -notlike $GpoName)) { continue }
            if ((Test-PSFParameterBinding -ParameterName Identity) -and ($rule.Identity -notlike $Identity)) { continue }
            if ((Test-PSFParameterBinding -ParameterName Filter) -and ($rule.Filter -notlike $Filter)) { continue }
            if ($IsGlobal -and -not $rule.All) { continue }

            $rule
        }
        $results
    }
}

function Invoke-DMGPPermission
{
    <#
    .SYNOPSIS
        Brings the current Group Policy Permissions into compliance with the desired state defined in configuration.
     
    .DESCRIPTION
        Brings the current Group Policy Permissions into compliance with the desired state defined in configuration.
        - Use Register-DMGPPermission and Register-DMGPPermissionFilter to define the desired state
        - Use Test-DMGPPermission to preview the changes it would apply
         
        This command accepts the output objects of Test-DMGPPermission as input, allowing you to precisely define, which changes to actually apply.
        If you do not do so, ALL deviations from the desired state will be corrected.
     
    .PARAMETER InputObject
        Test results provided by the associated test command.
        Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMGPPermission -Server corp.contoso.com
 
        Brings the group policy object permissions of the domain corp.contoso.com into compliance with the desired state.
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupPolicyPermissions -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
        $computerName = (Get-ADDomain @parameters).PDCEmulator
        $psParameter = $PSBoundParameters | ConvertTo-PSFHashtable -Include ComputerName, Credential -Inherit
        try { $session = New-PSSession @psParameter -ErrorAction Stop }
        catch {
            Stop-PSFFunction -String 'Invoke-DMGPPermission.WinRM.Failed' -StringValues $computerName -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName
            return
        }

        #region Utility Functions
        function ConvertTo-ADAccessRule {
            [OutputType([System.DirectoryServices.ActiveDirectoryAccessRule])]
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                $ChangeEntry
            )

            begin {
                $guidEmpty = [System.Guid]::Empty
                $guidApplyGpoRight = [System.Guid]'edacfd8f-ffb3-11d1-b41d-00a0c968f939'
                $inheritanceType = 'All'

                $rightsMap = @{
                    'GpoRead' = ([System.DirectoryServices.ActiveDirectoryRights]'GenericRead')
                    'GpoApply' = ([System.DirectoryServices.ActiveDirectoryRights]'GenericRead')
                    'GpoEdit' = ([System.DirectoryServices.ActiveDirectoryRights]' CreateChild, DeleteChild, ReadProperty, WriteProperty, GenericExecute')
                    'GpoEditDeleteModifySecurity' = ([System.DirectoryServices.ActiveDirectoryRights]'CreateChild, DeleteChild, Self, WriteProperty, DeleteTree, Delete, GenericRead, WriteDacl, WriteOwner')
                    'GpoCustom' = ([System.DirectoryServices.ActiveDirectoryRights]'CreateChild, Self, WriteProperty, GenericRead, WriteDacl, WriteOwner')
                }

                <#
                System.Security.Principal.IdentityReference identity
                System.DirectoryServices.ActiveDirectoryRights adRights
                System.Security.AccessControl.AccessControlType type
                guid objectType
                System.DirectoryServices.ActiveDirectorySecurityInheritance inheritanceType
                guid inheritedObjectType
                #>

            }
            process {
                foreach ($change in $ChangeEntry) {
                    # Identity property might be 'deserialized'
                    $identityReference = $change.Identity -as [string] -as [System.Security.Principal.SecurityIdentifier]
                    [System.Security.AccessControl.AccessControlType]$type = 'Allow'
                    if (-not $change.Allow) { $type = 'Deny' }
                    [System.DirectoryServices.ActiveDirectoryAccessRule]::new(
                        $identityReference,
                        $rightsMap[$change.Permission],
                        $type,
                        $guidEmpty,
                        $inheritanceType,
                        $guidEmpty
                    )
                    if ($change.Permission -eq 'GpoApply') {
                        [System.DirectoryServices.ActiveDirectoryAccessRule]::new(
                            $identityReference,
                            ([System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight),
                            $type,
                            $guidApplyGpoRight,
                            $inheritanceType,
                            $guidEmpty
                        )
                    }
                }
            }
        }
        #endregion Utility Functions
    }
    process
    {
        try {
            # Test All GPO permissions if no specific test result was specified
            if (-not $InputObject) {
                $InputObject = Test-DMGPPermission @parameters -EnableException:$EnableException
            }

            #region Process Test results
            foreach ($testResult in $InputObject) {
                # Catch invalid input - can only process test results
                if ($testResult.PSObject.TypeNames -notcontains 'DomainManagement.GPPermission.TestResult') {
                    Stop-PSFFunction -String 'Invoke-DMGPPermission.Invalid.Input' -StringValues $testResult -Target $testResult -Continue -EnableException $EnableException
                }

                if ($testResult.Type -eq 'AccessError') {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMGPPermission.Result.Access.Error' -StringValues $testResult.Identity -Target $testResult
                    continue
                }

                try { $acl = Get-AdsAcl -Path $testResult.AdObject.DistinguishedName @parameters -ErrorAction Stop }
                catch { Stop-PSFFunction -String 'Invoke-DMGPPermission.AD.Access.Error' -StringValues $testResult, $testResult.ADObject.DistinguishedName -ErrorRecord $_ -Continue -EnableException $EnableException }
                
                [string[]]$applicableIdentities = $acl.Access.Identity | Remove-PSFNull | Resolve-String | Convert-Principal @parameters
                
                # Process Remove actions first, as they might interfere when processed last and replacing permissions.
                foreach ($change in ($testResult.Changed | Sort-Object Action -Descending)) {
                    #region Remove
                    if ($change.Action -eq 'Remove') {
                        if (($change.Permission -eq 'GpoCustom') -or ($applicableIdentities -notcontains $change.Identity)) {
                            $rulesToRemove = $acl.Access | Where-Object {
                                $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).ToString() -eq $change.Identity
                            }
                        }
                        else {
                            $accessRulesToRemove = ConvertTo-ADAccessRule -ChangeEntry $change
                            $rulesToRemove = $acl.Access | Compare-ObjectProperty -ReferenceObject $accessRulesToRemove -PropertyName ActiveDirectoryRights, AccessControlType, 'IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) to String as IdentityReference'
                        }
                        foreach ($rule in $rulesToRemove) { $null = $acl.RemoveAccessRule($rule) }
                    }
                    #endregion Remove

                    #region Add
                    else {
                        if ($change.Permission -eq 'GpoCustom') {
                            $acl.Access | Where-Object {
                                $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).ToString() -eq $change.Identity
                            } | ForEach-Object { $null = $acl.RemoveAccessRule($_) }
                        }
                        try { $accessRulesToAdd = ConvertTo-ADAccessRule -ChangeEntry $change }
                        catch {
                            Stop-PSFFunction -String 'Invoke-DMGPPermission.AccessRule.Error' -StringValues $change.Identity, $change.DisplayName -ErrorRecord $_ -Continue -EnableException $EnableException -Cmdlet $PSCmdlet
                        }
                        foreach ($rule in $accessRulesToAdd) { $null = $acl.AddAccessRule($rule) }
                    }
                    #endregion Add
                }

                Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPPermission.AD.UpdatingPermission' -ActionStringValues $testResult.Changed.Count -ScriptBlock {
                    $acl | Set-AdsAcl @parameters -Confirm:$false -EnableException
                } -Continue -EnableException $EnableException -PSCmdlet $PSCmdlet -Target $testResult
                Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGPPermission.Gpo.SyncingPermission' -ActionStringValues $testResult.Changed.Count -ScriptBlock {
                    $domainObject = Get-Domain2 @parameters
                    Invoke-Command -Session $session -ScriptBlock {
                        $gpoObject = Get-Gpo -Server localhost -DisplayName $using:testResult.Identity -Domain $using:domainObject.DNSRoot -ErrorAction Stop
                        $gpoObject.MakeAclConsistent()
                    } -ErrorAction Stop
                } -Continue -EnableException $EnableException -PSCmdlet $PSCmdlet -Target $testResult
            }
            #endregion Process Test results
        }
        
        finally {
            if ($session) { $session | Remove-PSSession -WhatIf:$false -Confirm:$false }
        }
    }
}


function Register-DMGPPermission {
    <#
    .SYNOPSIS
        Registers a GP permission as the desired state.
     
    .DESCRIPTION
        Registers a GP permission as the desired state.
 
        Permissions can be applied in three ways:
        - Explicitly to a specific GPO
        - To ALL GPOs
        - To GPOs that match a specific filter string.
 
        For defining filter conditions, see the help on Register-DMGPPermissionFilter.
 
        Another important concept is the "Managed" concept.
        By default, all GPOs are considered unmanaged, where GP Permissions are concerned.
        This means, any additional permissionss that have been applied are ok.
        By setting a GPO's permissions under management - by applying a permission rule that uses the -Managed parameter - any permissions not defined for it will be removed.
     
    .PARAMETER GpoName
        Name of the GPO this permission applies to.
        Subject to string insertion.
     
    .PARAMETER Filter
        The filter condition governing, what GPOs these permissions apply to.
        A filter string can consist of the following elements:
        - Names of filter conditions
        - Logical operators
        - Parenthesis
 
        Example filter strings:
        - 'IsManaged'
        - 'IsManaged -and -not (IsDomainDefault -or IsDomainControllerDefault)'
        - '-not (IsManaged) -and (IsTier1 -or IsSupport)'
     
    .PARAMETER All
        This access rule applies to ALL GPOs.
     
    .PARAMETER Identity
        The group or user to assign permissions to.
        Subject to string insertion.
     
    .PARAMETER ObjectClass
        What kind of object the assigned identity is.
        Can be any legal object class in AD.
        Only object classes that have a SID should be chosen though (otherwise, assigning permissions to it gets kind of difficult).
     
    .PARAMETER Permission
        What kind of permission to grant.
     
    .PARAMETER Deny
        Whether to create a Deny rule, rather than an Allow rule.
 
    .PARAMETER NoPermissionChange
        Disable application of a set of permissions.
        Setting this flag allows defining a rule that only applies the "Managed" state (see below).
     
    .PARAMETER Managed
        Whether the affected GPOs should be considered "Under Management".
        A GPO "Under Management" will have all non-defined permissions removed.
 
    .PARAMETER ContextName
        The name of the context defining the setting.
        This allows determining the configuration set that provided this setting.
        Used by the ADMF, available to any other configuration management solution.
     
    .EXAMPLE
        PS C:\> Get-Content .\gpopermissions.json | ConvertFrom-Json | Write-Output | Register-DMGPPermission
 
        Reads all settings from the provided json file and registers them.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Explicit')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ExplicitNoChange')]
        [string]
        $GpoName,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'FilterNoChange')]
        [PsfValidateScript('DomainManagement.Validate.GPPermissionFilter', ErrorString = 'DomainManagement.Validate.GPPermissionFilter')]
        [string]
        $Filter,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'All')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'AllNoChange')]
        [switch]
        $All,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Explicit')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'All')]
        [PsfValidateScript('DomainManagement.Validate.Identity',  ErrorString = 'DomainManagement.Validate.Identity')]
        [string]
        $Identity,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Explicit')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'All')]
        [string]
        $ObjectClass,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Explicit')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'All')]
        [ValidateSet('GpoApply', 'GpoRead', 'GpoEdit', 'GpoEditDeleteModifySecurity', 'GpoCustom')]
        [string]
        $Permission,

        [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Explicit')]
        [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')]
        [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'All')]
        [switch]
        $Deny,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ExplicitNoChange')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'FilterNoChange')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'AllNoChange')]
        [switch]
        $NoPermissionChange,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [switch]
        $Managed,

        [string]
        $ContextName = '<Undefined>'
    )
    
    begin {
        $allowHash = @{
            $false = "Allow"
            $true  = "Deny"
        }
    }
    process {
        switch ($PSCmdlet.ParameterSetName) {
            'Explicit' {
                $permIdentity = 'Explicit|{0}|{1}|{2}|{3}' -f $GpoName, $Identity, $Permission, $allowHash[$Deny.ToBool()]

                $script:groupPolicyPermissions[$permIdentity] = [PSCustomObject]@{
                    PSTypeName         = 'DomainManagement.Configuration.GPPermission'
                    PermissionIdentity = $permIdentity
                    Type               = $PSCmdlet.ParameterSetName
                    GpoName            = $GpoName
                    Identity           = $Identity
                    ObjectClass        = $ObjectClass
                    Permission         = $Permission
                    Deny               = $Deny.ToBool()
                    Managed            = $Managed.ToBool()
                    ContextName        = $ContextName
                }
            }
            'Filter' {
                $permIdentity = 'Filter|{0}|{1}|{2}|{3}' -f $Filter, $Identity, $Permission, $allowHash[$Deny.ToBool()]

                $script:groupPolicyPermissions[$permIdentity] = [PSCustomObject]@{
                    PSTypeName         = 'DomainManagement.Configuration.GPPermission'
                    PermissionIdentity = $permIdentity
                    Type               = $PSCmdlet.ParameterSetName
                    Filter             = $Filter
                    FilterConditions   = (ConvertTo-FilterName -Filter $Filter)
                    Identity           = $Identity
                    ObjectClass        = $ObjectClass
                    Permission         = $Permission
                    Deny               = $Deny.ToBool()
                    Managed            = $Managed.ToBool()
                    ContextName        = $ContextName
                }
            }
            'All' {
                $permIdentity = 'All|{0}|{1}|{2}' -f $Identity, $Permission, $allowHash[$Deny.ToBool()]

                $script:groupPolicyPermissions[$permIdentity] = [PSCustomObject]@{
                    PSTypeName         = 'DomainManagement.Configuration.GPPermission'
                    PermissionIdentity = $permIdentity
                    Type               = $PSCmdlet.ParameterSetName
                    All                = $true
                    Identity           = $Identity
                    ObjectClass        = $ObjectClass
                    Permission         = $Permission
                    Deny               = $Deny.ToBool()
                    Managed            = $Managed.ToBool()
                    ContextName        = $ContextName
                }
            }
            'ExplicitNoChange' {
                $permIdentity = 'NoChange|Explicit|{0}' -f $GpoName

                $script:groupPolicyPermissions[$permIdentity] = [PSCustomObject]@{
                    PSTypeName         = 'DomainManagement.Configuration.GPPermission'
                    PermissionIdentity = $permIdentity
                    Type               = $PSCmdlet.ParameterSetName
                    GpoName            = $GpoName
                    Managed            = $Managed.ToBool()
                    ContextName        = $ContextName
                }
            }
            'FilterNoChange' {
                $permIdentity = 'NoChange|Filter|{0}' -f $Filter
                $script:groupPolicyPermissions[$permIdentity] = [PSCustomObject]@{
                    PSTypeName         = 'DomainManagement.Configuration.GPPermission'
                    PermissionIdentity = $permIdentity
                    Type               = $PSCmdlet.ParameterSetName
                    Filter             = $Filter
                    FilterConditions   = (ConvertTo-FilterName -Filter $Filter)
                    Managed            = $Managed.ToBool()
                    ContextName        = $ContextName
                }
            }
            'AllNoChange' {
                $script:groupPolicyPermissions['NoChange|All'] = [PSCustomObject]@{
                    PSTypeName         = 'DomainManagement.Configuration.GPPermission'
                    PermissionIdentity = 'NoChange|All'
                    Type               = $PSCmdlet.ParameterSetName
                    All                = $true
                    Managed            = $Managed.ToBool()
                    ContextName        = $ContextName
                }
            }
        }
    }
}

function Test-DMGPPermission {
    <#
    .SYNOPSIS
        Tests whether the existing Group Policy permissions reflect the desired state.
 
    .DESCRIPTION
        Tests whether the existing Group Policy permissions reflect the desired state.
        Use Register-DMGPPermission and Register-DMGPPermissionFilter to define the desired state.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
 
    .EXAMPLE
        PS C:\> Test-DMGPPermission -Server corp.contoso.com
 
        Tests whether the domain of corp.contoso.com has the desired GP Permission configuration.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")]
    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupPolicyPermissions -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
        $computerName = (Get-ADDomain @parameters).PDCEmulator
        $psParameter = $PSBoundParameters | ConvertTo-PSFHashtable -Include ComputerName, Credential -Inherit
        try { $session = New-PSSession @psParameter -ErrorAction Stop }
        catch {
            Stop-PSFFunction -String 'Test-DMGPPermission.WinRM.Failed' -StringValues $computerName -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName
            return
        }

        #region Utility Functions
        function Compare-GPAccessRules {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
            [CmdletBinding()]
            param (
                [AllowNull()]
                [AllowEmptyCollection()]
                $ADRules,

                [AllowNull()]
                [AllowEmptyCollection()]
                $ConfiguredRules,

                [bool]
                $Managed
            )
            if (-not $Managed -and -not $ConfiguredRules) { return }
            
            $configuredRules | Where-Object {
                $_ -and
                -not ($_ | Compare-ObjectProperty -ReferenceObject $ADRules -PropertyName 'Identity to String', Permission, Allow)
            } | ForEach-Object {
                $_.Action = 'Add'
                $_
            }
            if (-not $Managed) { return }
            $ADRules | Where-Object {
                $_ -and
                -not ($_ | Compare-ObjectProperty -ReferenceObject $ConfiguredRules -PropertyName 'Identity to String', Permission, Allow)
            } | ForEach-Object {
                $_.Action = 'Remove'
                $_
            }
        }
        function Convert-GPAccessRuleIdentity {
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                $InputObject,

                $ADObject,

                [PSFComputer]
                $Server,
                
                [PSCredential]
                $Credential
            )

            begin {
                $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
                $parameters['Debug'] = $false
            }
            process {
                foreach ($inputItem in $InputObject) {
                    #region Case: Input from AD
                    if ($inputItem.PSObject.TypeNames -like "*Microsoft.GroupPolicy.GPPermission") {
                        $result = [PSCustomObject]@{
                            PSTypeName = 'DomainManagement.Result.GPPermission.Action'
                            Identity = $inputItem.Trustee.Sid
                            DisplayName = '{0}\{1}' -f $inputItem.Trustee.Domain, $inputItem.Trustee.Name
                            Permission = $inputItem.Permission -as [string]
                            Allow = -not $inputItem.Denied
                            Action = $null
                            ADObject = $ADObject
                        }
                        $result | Add-Member -MemberType ScriptMethod -Name ToString -Force -PassThru -Value {
                            '{0}: {1}' -f $this.Action, $this.DisplayName
                        }
                    }
                    #endregion Case: Input from AD

                    #region Case: Input from Configuration
                    else {
                        # Convert to SecurityIdentifier (preferred) or NT Account
                        $identity = Resolve-Identity -IdentityReference $inputItem.Identity
                        if ($identity -is [System.Security.Principal.NTAccount]) {
                            $domainName, $identityName = $identity -replace '^(.+)@(.+)$','$2\$1' -split "\\"
                            try { $principal = Get-Principal @parameters -Name $identityName -Domain $domainName -ObjectClass $inputItem.ObjectClass }
                            catch { throw }
                            $identity = $principal.ObjectSID
                        }
                        $result = [PSCustomObject]@{
                            PSTypeName = 'DomainManagement.Result.GPPermission.Action'
                            Identity = $identity
                            DisplayName = $inputItem.Identity | Resolve-String
                            Permission = $inputItem.Permission
                            Allow = -not $inputItem.Deny
                            Action = $null
                            ADObject = $ADObject
                        }
                        $result | Add-Member -MemberType ScriptMethod -Name ToString -Force -PassThru -Value {
                            '{0}: {1}' -f $this.Action, $this.DisplayName
                        }
                    }
                    #endregion Case: Input from Configuration
                }
            }
        }
        function Resolve-Identity {
            [CmdletBinding()]
            param (
                [string]
                $IdentityReference
            )

            #region Default Resolution
            $identity = Resolve-String -Text $IdentityReference
            if ($identity -as [System.Security.Principal.SecurityIdentifier]) {
                $identity = $identity -as [System.Security.Principal.SecurityIdentifier]
            }
            else {
                $identity = $identity -as [System.Security.Principal.NTAccount]
                try { $identity = $identity.Translate([System.Security.Principal.SecurityIdentifier])  }
                catch { $null = $null } # Do nothing intentionally, but shut up PSSA anyway
            }
            if ($null -eq $identity) { $identity = (Resolve-String -Text $IdentityReference) -as [System.Security.Principal.NTAccount] }

            $identity
            #endregion Default Resolution
        }
        #endregion Utility Functions
    }
    process {
        if (Test-PSFFunctionInterrupt) { return }
        
        try {
            #region Data Preparation
            $allFilters = @{ }
            foreach ($filterObject in (Get-DMGPPermissionFilter)) {
                $allFilters[$filterObject.Name] = $filterObject
            }

            $allPermissions = Get-DMGPPermission
            $allConditions = $allPermissions | Where-Object FilterConditions | Select-Object -ExpandProperty FilterConditions | Write-Output | Select-Object -Unique
            $missingConditions = $allConditions | Where-Object { $_ -notin $allFilters.Keys }
            if ($missingConditions) {
                Stop-PSFFunction -String 'Test-DMGPPermission.Validate.MissingFilterConditions' -StringValues ($missingConditions -join ", ") -EnableException $EnableException -Cmdlet $PSCmdlet -Tag Error, Panic
                return
            }
            if ($allConditions) { $relevantFilters = $allFilters | ConvertTo-PSFHashtable -Include $allConditions }
            else { $relevantFilters = @() }

            $allGpos = Get-ADObject @parameters -LDAPFilter '(objectCategory=groupPolicyContainer)' -Properties DisplayName

            #region Process relevant filters
            $filterToGPOMapping = @{ }
            $managedGPONames = (Get-DMGroupPolicy).DisplayName | Resolve-String
            :conditions foreach ($condition in $relevantFilters.Values) {
                switch ($condition.Type) {
                    #region Managed - Do we define the policy using the GroupPolicy Component?
                    'Managed' {
                        if ($condition.Reverse -xor (-not $condition.Managed)) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -notin $managedGPONames }
                        else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -in $managedGPONames }
                    }
                    #endregion Managed - Do we define the policy using the GroupPolicy Component?

                    #region Path - Resolve by where GPOs are linked
                    'Path' {
                        $searchBase = Resolve-String -Text $condition.Path
                        if (-not (Test-ADObject @parameters -Identity $searchBase)) {
                            if ($condition.Optional) {
                                Write-PSFMessage -String 'Test-DMGPPermission.Filter.Path.DoesNotExist.SilentlyContinue' -StringValues $Condition.Name, $searchBase -Target $condition
                                continue conditions
                            }
                            Stop-PSFFunction -String 'Test-DMGPPermission.Filter.Path.DoesNotExist.Stop' -StringValues $searchBase -Target $Condition.Name, $condition -EnableException $EnableException -Tag Panic, Error
                            return
                        }

                        $objects = Get-ADObject @parameters -SearchBase $searchBase -SearchScope $condition.Scope -LDAPFilter '(|(objectCategory=OrganizationalUnit)(objectCategory=domainDNS))' -Properties gPLink
                        $allLinkedGpoDNs = $objects | ConvertTo-GPLink | Select-Object -ExpandProperty DistinguishedName -Unique
                        if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DistinguishedName -notin $allLinkedGpoDNs }
                        else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DistinguishedName -in $allLinkedGpoDNs }
                    }
                    #endregion Path - Resolve by where GPOs are linked

                    #region GPName - Match by name, using either direct comparison, wildcard or regex
                    'GPName' {
                        $resolvedGpoName = Resolve-String -Text $condition.GPName
                        switch ($condition.Mode) {
                            'Explicit' {
                                if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -ne $resolvedGpoName }
                                else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -eq $resolvedGpoName }
                            }
                            'Wildcard' {
                                if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -notlike $resolvedGpoName }
                                else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -like $resolvedGpoName }
                            }
                            'Regex' {
                                if ($condition.Reverse) { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -notmatch $resolvedGpoName }
                                else { $filterToGPOMapping[$condition.Name] = $allGpos | Where-Object DisplayName -match $resolvedGpoName }
                            }
                        }
                    }
                    #endregion GPName - Match by name, using either direct comparison, wildcard or regex
                }
            }
            foreach ($key in $filterToGPOMapping.Keys) {
                Write-PSFMessage -Level Debug -String 'Test-DMGPPermission.Filter.Result' -StringValues $key, ($filterToGPOMapping[$key].DisplayName -join ', ')
            }
            #endregion Process relevant filters

            #endregion Data Preparation

            #region Process GPO Permissions
            $domainObject = Get-Domain2 @parameters
            $permissionObjects = Invoke-Command -Session $session -ScriptBlock {
                Update-TypeData -TypeName Microsoft.GroupPolicy.GPPermission -SerializationDepth 4
                foreach ($policyObject in $using:allGpos) {
                    $resultObject = [PSCustomObject]@{
                        Name        = $policyObject.DisplayName
                        Permissions = @()
                        Error       = $null
                    }

                    try { $resultObject.Permissions = Get-GPPermission -All -Name $resultObject.Name -Server localhost -Domain $using:domainObject.DNSRoot -ErrorAction Stop }
                    catch { $resultObject.Error = $_ }
                    $resultObject
                }
            }

            $resultDefaults = @{
                Server = $Server
                ObjectType = 'GPPermission'
            }

            foreach ($permissionObject in $permissionObjects) {
                $applicableSettings = $allPermissions | Where-Object {
                    $_.All -or
                    (Resolve-String -Text $_.GpoName) -eq $permissionObject.Name -or
                    ($_.Filter -and (Test-GPPermissionFilter -GpoName $permissionObject.Name -Filter $_.Filter -Conditions $_.FilterConditions -FilterHash $filterToGPOMapping))
                }
                $adObject = $allGpos | Where-Object DisplayName -eq $permissionObject.Name
                Add-Member -InputObject $permissionObject -MemberType ScriptMethod -Name ToString -Value { $this.Name } -Force

                if ($permissionObject.Error) {
                    New-TestResult @resultDefaults -Type AccessError -Identity $permissionObject -Configuration $applicableSettings -ADObject $adObject -Changed $permissionObject
                    continue
                }

                $shouldManage = $applicableSettings.Managed -contains $true
                try {
                    $compareParameter = @{
                        ADRules = ($permissionObject.Permissions | Convert-GPAccessRuleIdentity @parameters -ADObject $adObject)
                        ConfiguredRules = ($applicableSettings | Where-Object Identity | Convert-GPAccessRuleIdentity @parameters -ADObject $adObject)
                        Managed = $shouldManage
                    }
                }
                catch {
                    Stop-PSFFunction -String 'Test-DMGPPermission.Identity.Resolution.Error' -StringValues $adObject.DisplayName -Target $permissionObject -Continue -EnableException $EnableException -Tag Panic, Error
                }
                $delta = Compare-GPAccessRules @compareParameter

                if ($delta) {
                    New-TestResult @resultDefaults -Type Update -Identity $permissionObject -Changed $delta -Configuration $applicableSettings -ADObject $adObject
                    continue
                }
            }
            #endregion Process GPO Permissions
        }
        finally {
            if ($session) { $session | Remove-PSSession -WhatIf:$false -Confirm:$false}
        }
    }
}

function Unregister-DMGPPermission
{
    <#
    .SYNOPSIS
        Removes a registered GP Permission.
     
    .DESCRIPTION
        Removes a registered GP Permission.
     
    .PARAMETER PermissionIdentity
        The identity string of a GP permission.
        This is NOT the user/group assigned permission (Identity property) but instead the unique identifier of the permission setting (PermissionIdentity property).
     
    .EXAMPLE
        PS C:\> Get-DMGPPermission | Unregister-DMGPPermission
 
        Clear all defined configuration.
    #>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true)]
        [string[]]
        $PermissionIdentity
    )
    
    process
    {
        foreach ($identityString in $PermissionIdentity) {
            $script:groupPolicyPermissions.Remove($identityString)
        }
    }
}


function Get-DMGPRegistrySetting {
    <#
    .SYNOPSIS
        Returns the registered group policy registry settings.
     
    .DESCRIPTION
        Returns the registered group policy registry settings.
     
    .PARAMETER PolicyName
        The name of the policy to filter by.
     
    .PARAMETER Key
        Filter by the key affected.
     
    .PARAMETER ValueName
        Filter by the name of the value set.
     
    .EXAMPLE
        PS C:\> Get-DMGPRegistrySetting
 
        Returns all registered group policy registry settings.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $PolicyName = '*',

        [string]
        $Key = '*',

        [string]
        $ValueName = '*'
    )
    
    process {
        $script:groupPolicyRegistrySettings.Values | Where-Object {
            $_.PolicyName -like $PolicyName -and
            $_.Key -like $Key -and
            $_.ValueName -like $ValueName
        }
    }
}

function Register-DMGPRegistrySetting {
    <#
    .SYNOPSIS
        Register a registry setting that should be applied to a group policy object.
     
    .DESCRIPTION
        Register a registry setting that should be applied to a group policy object.
        Note: These settings are only applied to group policy objects deployed through the GroupPolicy Component
     
    .PARAMETER PolicyName
        Name of the group policy object to attach this setting to.
        Subject to advanced string insertion.
     
    .PARAMETER Key
        The registry key affected.
        Subject to advanced string insertion.
     
    .PARAMETER ValueName
        The name of the value to modify.
        Subject to advanced string insertion.
     
    .PARAMETER Value
        The value to insert into the specified registry-key-value.
     
    .PARAMETER DomainData
        Instead of offering an explicit value, have the resulting value calculated by a scriptblock executed against the target domain.
        In opposite to ADMF Contexts, DomainData data gathering scriptblocks are executed on a per-domain basis.
        While a Context supports integrating logic, Contexts themselves are not re-run when switching to another domain with the same Context choice.
        DomainData gathering logic can be configured using Register-DMDomainData or defining appropriate configuration in ADMF Contexts.
     
    .PARAMETER Type
        What kind of registry value should be defined?
        Supported types: 'Binary', 'DWord', 'ExpandString', 'MultiString', 'QWord', 'String'
     
    .EXAMPLE
        PS C:\> Get-Content .\registrysettings.json | ConvertFrom-Json | Write-Output | Register-DMGPRegistrySetting
 
        Imports all the registry value definitions configured in the specified file.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Value')]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $PolicyName,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Key,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ValueName,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Value')]
        $Value,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'DomainData')]
        [string]
        $DomainData,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('Binary', 'DWord', 'ExpandString', 'MultiString', 'QWord', 'String')]
        [string]
        $Type
    )
    
    process {
        $identity = $PolicyName, $Key, $ValueName -join "þ"
        $data = @{
            PSTypeName = 'DomainManagement.Configuration.GPRegistrySetting'
            Identity   = $identity
            PolicyName = $PolicyName
            Key        = $Key
            ValueName  = $ValueName
            Type       = $Type
        }
        switch ($PSCmdlet.ParameterSetName) {
            'Value' { $data['Value'] = $Value }
            'DomainData' { $data['DomainData'] = $DomainData }
        }
        $script:groupPolicyRegistrySettings[$identity] = [PSCustomObject]$data
    }
}


function Test-DMGPRegistrySetting {
    <#
    .SYNOPSIS
        Validates, whether a GPO's defined registry settings have been applied.
     
    .DESCRIPTION
        Validates, whether a GPO's defined registry settings have been applied.
        To define a GPO, use Register-DMGroupPolicy
        To define a GPO's associated registry settings, use Register-DMGPRegistrySetting
 
        Note: While it is theoretically possible to define a GPO registry setting without defining the GPO it is attached to, these settings will not be applied anyway, as processing is directly tied into the Group Policy invocation process.
     
    .PARAMETER PolicyName
        Name of the GPO to scan for compliance.
        Subject to advanced string insertion.
 
    .PARAMETER PassThru
        Returns result objects, rather than boolean values.
        Useful for better reporting and integration into the test-* workflow.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Test-DMGPRegistrySetting @parameters -PolicyName $policy
 
        Tests, whether the specified GPO has all the desired registry keys configured.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $PolicyName,

        [switch]
        $PassThru,
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false

        #region Utility Functions
        function Write-Result {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')]
            [CmdletBinding()]
            param (
                [bool]
                $Success,

                [string]
                $Status,

                [AllowEmptyCollection()]
                [object]
                $Changes,

                [bool]
                $PassThru
            )

            if (-not $PassThru) { return $Success }

            [PSCustomObject]@{
                Success = $Success
                Status  = $Status
                Changes = $Changes
            }
        }
        #endregion Utility Functions

        #region WinRM Session Handling
        $reUseSession = $false
        if ($Server.Type -eq 'PSSession') {
            $session = $Server.InputObject
            $reUseSession = $true
        }
        elseif (($Server.Type -eq 'Container') -and ($Server.InputObject.Connections.PSSession)) {
            $session = $Server.InputObject.Connections.PSSession
            $reUseSession = $true
        }
        else {
            $pdcParameter = $parameters.Clone()
            $pdcParameter.ComputerName = (Get-Domain2 @parameters).PDCEmulator
            $pdcParameter.Remove('Server')
            try { $session = New-PSSession @pdcParameter -ErrorAction Stop }
            catch {
                Stop-PSFFunction -String 'Test-DMGPRegistrySetting.WinRM.Failed' -StringValues $parameters.Server -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $parameters.Server
                return
            }
        }
        #endregion WinRM Session Handling
    }
    process {
        if (Test-PSFFunctionInterrupt) { return }

        #region Processing the Configuration
        $resolvedName = $PolicyName | Resolve-String @parameters
        $applicableRegistrySettings = Get-DMGPRegistrySetting | Where-Object {
            $resolvedName -eq ($_.PolicyName | Resolve-String @parameters)
        }
        if (-not $applicableRegistrySettings) {
            Write-Result -Success $true -Status 'No Registry Settings Defined' -PassThru $PassThru
            return
        }

        $registryData = foreach ($applicableRegistrySetting in $applicableRegistrySettings) {
            if ($applicableRegistrySetting.PSObject.Properties.Name -contains 'Value') {
                [PSCustomObject]@{
                    GPO       = $resolvedName
                    Key       = Resolve-String @parameters -Text $applicableRegistrySetting.Key
                    ValueName = Resolve-String @parameters -Text $applicableRegistrySetting.ValueName
                    Type      = $applicableRegistrySetting.Type
                    Value     = $applicableRegistrySetting.Value
                }
            }
            else {
                [PSCustomObject]@{
                    GPO       = $resolvedName
                    Key       = Resolve-String @parameters -Text $applicableRegistrySetting.Key
                    ValueName = Resolve-String @parameters -Text $applicableRegistrySetting.ValueName
                    Type      = $applicableRegistrySetting.Type
                    Value     = ((Invoke-DMDomainData @parameters -Name $applicableRegistrySetting.DomainData).Data | Write-Output)
                }
            }
        }
        #endregion Processing the Configuration

        #region Executing the Query
        $regArgument = @{
            GPO          = $resolvedName
            RegistryData = $registryData
        }

        $result = Invoke-Command -Session $session -ArgumentList $regArgument -ScriptBlock {
            param (
                $RegData
            )

            $result = [PSCustomObject]@{
                PolicyName = $RegData.GPO
                Success    = $false
                Status     = 'NotStarted'
                Changes    = @()
            }

            try {
                if (-not ($gpo = Get-GPO -Server Localhost -Domain (Get-ADDomain -Server localhost).DNSRoot -Name $RegData.GPO -ErrorAction Stop)) {
                    $result.Status = "PolicyNotFound"
                    return $result
                }
            }
            catch {
                $result.Status = "Error: $_"
                return $result
            }

            $domain = Get-ADDomain -Server localhost
            $changes = foreach ($registryDatum in $RegData.RegistryData) {
                $data = $null
                $data = $gpo | Get-GPRegistryValue -Server localhost -Domain $domain.DNSRoot -Key $registryDatum.Key -ValueName $registryDatum.ValueName -ErrorAction Ignore
                if (-not $data) {
                    [PSCustomObject]@{
                        PSTypeName  = 'DomainManagement.Change.GPRegistry'
                        PolicyName  = $RegData.GPO
                        Key         = $registryDatum.Key
                        ValueName   = $registryDatum.ValueName
                        ShouldValue = $registryDatum.Value
                        IsValue     = $null
                    }
                    continue
                }
                if ($data.Value -ne $registryDatum.Value) {
                    [PSCustomObject]@{
                        PSTypeName  = 'DomainManagement.Change.GPRegistry'
                        PolicyName  = $RegData.GPO
                        Key         = $registryDatum.Key
                        ValueName   = $registryDatum.ValueName
                        ShouldValue = $registryDatum.Value
                        IsValue     = $data.Value
                    }
                }
            }
            if ($changes) {
                foreach ($change in $changes) {
                    $change.PSObject.TypeNames.Clear()
                    $change.PSObject.TypeNames.Add("DomainManagement.Change.GPRegistry")
                    $change.PSObject.TypeNames.Add("System.Management.Automation.PSCustomObject")
                    $change.PSObject.TypeNames.Add("System.Object")
                }
                $result.Changes = $changes
                $result.Status = 'BadSettings'
            }
            else { $result.Success = $true }
            return $result
        }
        $level = 'Verbose'
        if ($result.Status -like 'Error:*') { $level = 'Warning' }
        Write-PSFMessage -Level $level -String 'Test-DMGPRegistrySetting.TestResult' -StringValues $resolvedName, $result.Success, $result.Status -Target $PolicyName
        #endregion Executing the Query

        # Result
        Write-Result -Success $result.Success -Status $result.Status -Changes $result.Changes -PassThru $PassThru
    }
    end {
        if (Test-PSFFunctionInterrupt) { return }
        if (-not $reUseSession) {
            $session | Remove-PSSession -Confirm:$false -WhatIf:$false
        }
    }
}


function Unregister-DMGPRegistrySetting
{
    <#
    .SYNOPSIS
        Removes defined group policy registry settings.
     
    .DESCRIPTION
        Removes defined group policy registry settings.
     
    .PARAMETER PolicyName
        The name of the GPO the registry setting has been applied to.
     
    .PARAMETER Key
        The registry key affected.
     
    .PARAMETER ValueName
        The name of the value this applies to.
     
    .EXAMPLE
        PS C:\> Get-DMGPRegistrySetting | Unregister-DMGPRegistrySetting
 
        Clears all defined group policy registry settings.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $PolicyName,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Key,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ValueName
    )
    
    process
    {
        $identity = $PolicyName, $Key, $ValueName -join "þ"
        $script:groupPolicyRegistrySettings.Remove($identity)
    }
}


function Get-DMGroupMembership
{
    <#
    .SYNOPSIS
        Returns the list of configured group memberships.
     
    .DESCRIPTION
        Returns the list of configured group memberships.
     
    .PARAMETER Group
        Name of the group to filter by.
     
    .PARAMETER Name
        Name of the entity being granted groupmembership to filter by.
     
    .EXAMPLE
        PS C:\> Get-DMGroupMembership
 
        List all configured group memberships.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [CmdletBinding()]
    param (
        [string]
        $Group = '*',

        [string]
        $Name = '*'
    )
    
    process
    {
        $results = foreach ($key in $script:groupMemberShips.Keys) {
            if ($key -notlike $Group) { continue }

            if ($script:groupMemberShips[$key].Count -gt 0) {
                foreach ($innerKey in $script:groupMemberShips[$key].Keys) {
                    $script:groupMemberShips[$key][$innerKey]
                }
            }
            else {
                [PSCustomObject]@{
                    PSTypeName = 'DomainManagement.GroupMembership'
                    Name = '<Empty>'
                    Domain = '<Empty>'
                    ItemType = '<Empty>'
                    Group = $key
                }
            }
        }
        $results | Sort-Object Group
    }
}


function Invoke-DMGroupMembership {
    <#
    .SYNOPSIS
        Applies the desired group memberships to the target domain.
     
    .DESCRIPTION
        Applies the desired group memberships to the target domain.
        Use Register-DMGroupMembership to configure just what is considered desired.
        Use Set-DMDomainCredential to prepare authentication as needed for remote domains, when principals from that domain must be resolved.
     
    .PARAMETER InputObject
        Test results provided by the associated test command.
        Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed.
     
    .PARAMETER RemoveUnidentified
        By default, existing permissions for foreign security principals that cannot be resolved will only be deleted, if every single configured membership was resolveable.
        In cases where that is not possible, these memberships are flagged as "Unidentified"
        Using this parameter you can enforce deleting them anyway.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMGroupMembership -Server contoso.com
 
        Applies the desired group membership configuration to the contoso.com domain
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,
        
        [switch]
        $RemoveUnidentified,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupMemberShips -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters

        #region Utility Functions
        function Add-GroupMember {
            [CmdletBinding()]
            param (
                [string]
                $GroupDN,
                [string]
                $SID,
                [string]
                $Server,
                [PSCredential]
                $Credential
            )

            if ($Server) { $path = "LDAP://$Server/$GroupDN" }
            else { $path = "LDAP://$GroupDN" }
            if ($Credential) {
                $group = New-Object DirectoryServices.DirectoryEntry($path, $Credential.UserName, $Credential.GetNetworkCredential().Password)
            }
            else {
                $group = New-Object DirectoryServices.DirectoryEntry($path)
            }
            [void]$group.member.Add("<SID=$SID>")
            try { $group.CommitChanges() }
            catch
            {
                if (-not $Credential) { throw }
                $group.Password = $Credential.GetNetworkCredential().Password
                $group.CommitChanges()
            }
            finally { $group.Close() }
        }
        
        function Remove-GroupMember {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                [string]
                $GroupDN,
                [string]
                $SID,
                [string]
                $TargetDN,
                [string]
                $Server,
                [PSCredential]
                $Credential
            )

            if ($Server) { $path = "LDAP://$Server/$GroupDN" }
            else { $path = "LDAP://$GroupDN" }
            if ($Credential) {
                $group = New-Object DirectoryServices.DirectoryEntry($path, $Credential.UserName, $Credential.GetNetworkCredential().Password)
            }
            else {
                $group = New-Object DirectoryServices.DirectoryEntry($path)
            }
            $group.member.Remove("<SID=$SID>")
            $group.member.Remove($TargetDN)
            try {
                $group.CommitChanges()
            }
            catch {
                $group.Close()

                if ($Credential) {
                    $group = New-Object DirectoryServices.DirectoryEntry($path, $Credential.UserName, $Credential.GetNetworkCredential().Password)
                }
                else {
                    $group = New-Object DirectoryServices.DirectoryEntry($path)
                }
                $group.member.Remove($TargetDN)
                $group.CommitChanges()
            }
            finally {
                $group.Close()
            }
        }
        #endregion Utility Functions
    }
    process {
        if (-not $InputObject) {
            $InputObject = Test-DMGroupMembership @parameters
        }
        
        foreach ($testItem in $InputObject) {
            # Catch invalid input - can only process test results
            if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.GroupMembership.TestResult') {
                Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMGroupMembership', $testItem -Target $testItem -Continue -EnableException $EnableException
            }
            
            switch ($testItem.Type) {
                'Add' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupMembership.GroupMember.Add' -ActionStringValues $testItem.ADObject.Name -Target $testItem -ScriptBlock {
                        Add-GroupMember @parameters -SID $testItem.Configuration.ADMember.ObjectSID -GroupDN $testItem.ADObject.DistinguishedName
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Delete' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupMembership.GroupMember.Remove' -ActionStringValues $testItem.ADObject.Name -Target $testItem -ScriptBlock {
                        Remove-GroupMember @parameters -SID $testItem.Configuration.ADMember.ObjectSID -TargetDN $testItem.Configuration.ADMember.DistinguishedName -GroupDN $testItem.ADObject.DistinguishedName
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Unresolved' {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMGroupMembership.Unresolved' -StringValues $testItem.Identity -Target $testItem
                }
                'Unidentified' {
                    if ($RemoveUnidentified) {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupMembership.GroupMember.RemoveUnidentified' -ActionStringValues $testItem.ADObject.Name -Target $testItem -ScriptBlock {
                            Remove-GroupMember @parameters -SID $testItem.Configuration.ADMember.ObjectSID -GroupDN $testItem.ADObject.DistinguishedName
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                    else {
                        Write-PSFMessage -Level Warning -String 'Invoke-DMGroupMembership.Unidentified' -StringValues $testItem.Identity -Target $testItem
                    }
                }
            }
        }
    }
}

function Register-DMGroupMembership {
    <#
    .SYNOPSIS
        Registers a group membership assignment as desired state.
     
    .DESCRIPTION
        Registers a group membership assignment as desired state.
        Any group with configured membership will be considered "managed" where memberships are concerned.
        This will causse all non-registered memberships to be configured for purging.
     
    .PARAMETER Name
        The name of the user or group to grant membership in the target group.
        This parameter also accepts SIDs instead of names.
        Note: %DomainSID% is the placeholder for the domain SID, %RootDomainSID% the one for the forest root domain.
     
    .PARAMETER Domain
        Domain the entity is from, that is being granted group membership.
     
    .PARAMETER ItemType
        The type of object being granted membership.
 
    .PARAMETER ObjectCategory
        Rather than specifying an explicit entity, assign all principals in a given Object Cagtegory as group member.
        Note: In order to be applicable, each category member must be a security principal (that has the ObjectSID property).
     
    .PARAMETER Group
        The group to define members for.
     
    .PARAMETER Empty
        Whether the specified group should be empty.
        By default, groups are only considered when at least one member has been defined.
        Flagging a group for being empty will clear all members from it.
     
    .PARAMETER Mode
        How the defined group membership will be processed:
        - Default: Member must exist and be member of the group.
        - MayBeMember: Principal must exist but may be a member. No add action will be generated if not a member, but also no remove action if it already is a member.
        - MemberIfExists: If Principal exists, make it a member.
        - MayBeMemberIfExists: Both existence and membership are optional for this principal.
     
    .PARAMETER GroupProcessingMode
        Governs how ALL group memberships on the targeted group will be processed.
        Supported modes:
        - Constrained: Existing Group Memberships not defined will be removed
        - Additive: Group Memberships defined will be applied, but non-configured memberships will be ignored.
        If no setting is defined, it will default to 'Constrained'
     
    .PARAMETER ContextName
        The name of the context defining the setting.
        This allows determining the configuration set that provided this setting.
        Used by the ADMF, available to any other configuration management solution.
     
    .EXAMPLE
        PS C:\> Get-Content $configPath | ConvertFrom-Json | Write-Output | Register-DMGroupMembership
         
        Imports all defined groupmemberships from the targeted json configuration file.
#>

    
    [CmdletBinding(DefaultParameterSetName = 'Entry')]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Entry')]
        [string]
        $Name,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Entry')]
        [string]
        $Domain,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Entry')]
        [ValidateSet('User', 'Group', 'foreignSecurityPrincipal', 'Computer', 'msDS-GroupManagedServiceAccount')]
        [string]
        $ItemType,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Category')]
        [string]
        $ObjectCategory,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Entry')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Category')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Empty')]
        [string]
        $Group,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Empty')]
        [bool]
        $Empty,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('Default', 'MayBeMember', 'MemberIfExists', 'MayBeMemberIfExists')]
        [string]
        $Mode = 'Default',
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('Constrained', 'Additive')]
        [string]
        $GroupProcessingMode,
        
        [string]
        $ContextName = '<Undefined>'
    )
    
    process {
        if (-not $script:groupMemberShips[$Group]) {
            $script:groupMemberShips[$Group] = @{ }
        }
        if ($Name) {
            $script:groupMemberShips[$Group]["$($ItemType):$($Name)"] = [PSCustomObject]@{
                PSTypeName  = 'DomainManagement.GroupMembership'
                Name        = $Name
                Domain      = $Domain
                ItemType    = $ItemType
                Group       = $Group
                Mode        = $Mode
                ContextName = $ContextName
            }
        }
        elseif ($ObjectCategory) {
            $script:groupMemberShips[$Group]["ObjectCategory:$($ObjectCategory)"] = [PSCustomObject]@{
                PSTypeName  = 'DomainManagement.GroupMembership'
                Category    = $ObjectCategory
                Group       = $Group
                Mode        = $Mode
                ContextName = $ContextName
            }
        }
        elseif ($Empty) {
            $script:groupMemberShips[$Group] = @{ }
        }
        
        if ($GroupProcessingMode) {
            $script:groupMemberShips[$Group]['__Configuration'] = [PSCustomObject]@{
                PSTypeName     = 'DomainManagement.GroupMembership.Configuration'
                ProcessingMode = $GroupProcessingMode
            }
        }
    }
}


function Test-DMGroupMembership {
    <#
    .SYNOPSIS
        Tests, whether the target domain is compliant with the desired group membership assignments.
     
    .DESCRIPTION
        Tests, whether the target domain is compliant with the desired group membership assignments.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Test-DMGroupMembership -Server contoso.com
 
        Tests, whether the "contoso.com" domain is in compliance with the desired group membership assignments.
    #>

    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupMemberShips -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process {
        :main foreach ($groupMembershipName in $script:groupMemberShips.Keys) {
            $resolvedGroupName = Resolve-String -Text $groupMembershipName
            $processingMode = 'Constrained'
            if ($script:groupMemberShips[$groupMembershipName].__Configuration.ProcessingMode) {
                $processingMode = $script:groupMemberShips[$groupMembershipName].__Configuration.ProcessingMode
            }

            $resultDefaults = @{
                Server     = $Server
                ObjectType = 'GroupMembership'
            }

            #region Resolve Assignments
            $failedResolveAssignment = $false
            $assignments = foreach ($assignment in $script:groupMemberShips[$groupMembershipName].Values) {
                if ($assignment.PSObject.TypeNames -contains 'DomainManagement.GroupMembership.Configuration') { continue }
                
                #region Explicit Entity
                if ($assignment.Name) {
                    $param = @{
                        Domain = Resolve-String -Text $assignment.Domain
                    } + $parameters
                    if ((Resolve-String -Text $assignment.Name) -as [System.Security.Principal.SecurityIdentifier]) { $param['Sid'] = Resolve-String -Text $assignment.Name }
                    else {
                        $param['Name'] = Resolve-String -Text $assignment.Name
                        $param['ObjectClass'] = $assignment.ItemType
                    }
                    try { $adResult = Get-Principal @param }
                    catch {
                        # If it's a member that is allowed to NOT exist, simply skip the entry
                        if ($assignment.Mode -in 'MemberIfExists', 'MayBeMemberIfExists') { continue }
                        Write-PSFMessage -Level Warning -String 'Test-DMGroupMembership.Assignment.Resolve.Connect' -StringValues (Resolve-String -Text $assignment.Domain), (Resolve-String -Text $assignment.Name), $assignment.ItemType -ErrorRecord $_ -Target $assignment
                        $failedResolveAssignment = $true
                        [PSCustomObject]@{
                            Assignment = $assignment
                            ADMember   = $null
                            Type       = 'Explicit'
                        }
                        continue
                    }
                    if (-not $adResult) {
                        # If it's a member that is allowed to NOT exist, simply skip the entry
                        if ($assignment.Mode -in 'MemberIfExists', 'MayBeMemberIfExists') { continue }
                        Write-PSFMessage -Level Warning -String 'Test-DMGroupMembership.Assignment.Resolve.NotFound' -StringValues (Resolve-String -Text $assignment.Domain), (Resolve-String -Text $assignment.Name), $assignment.ItemType -Target $assignment
                        $failedResolveAssignment = $true
                        [PSCustomObject]@{
                            Assignment = $assignment
                            ADMember   = $null
                            Type       = 'Explicit'
                        }
                        continue
                    }
                    [PSCustomObject]@{
                        Assignment = $assignment
                        ADMember   = $adResult
                        Type       = 'Explicit'
                    }
                }
                #endregion Explicit Entity

                #region Object Category
                elseif ($assignment.Category) {
                    try { $adObjects = Find-DMObjectCategoryItem @parameters -Name $assignment.Category -Property ObjectSID, SamAccountName -EnableException }
                    catch {
                        Stop-PSFFunction -String 'Test-DMGroupMembership.Category.Error' -StringValues $assignment.Category, $assignment.Group -ErrorRecord $_ -Continue -ContinueLabel main -EnableException $EnableException -Target $assignment
                    }

                    foreach ($adObject in $adObjects) {
                        [PSCustomObject]@{
                            Assignment = $assignment
                            ADMember   = $adObject
                            Type       = 'Category'
                        }
                    }
                }
                #endregion Object Category
            }
            #endregion Resolve Assignments

            #region Check Current AD State
            try {
                $adObject = Get-ADGroup @parameters -Identity $resolvedGroupName -Properties Members -ErrorAction Stop
                $adMembers = $adObject.Members | ForEach-Object {
                    $distinguishedName = $_
                    try { Get-ADObject @parameters -Identity $_ -ErrorAction Stop -Properties SamAccountName, objectSid }
                    catch {
                        $objectDomainName = $distinguishedName.Split(",").Where{ $_ -like "DC=*" } -replace '^DC=' -join "."
                        $cred = $PSBoundParameters | ConvertTo-PSFHashtable -Include Credential
                        Get-ADObject -Server $objectDomainName @cred -Identity $distinguishedName -ErrorAction Stop -Properties SamAccountName, objectSid
                    }
                }
            }
            catch { Stop-PSFFunction -String 'Test-DMGroupMembership.Group.Access.Failed' -StringValues $resolvedGroupName -ErrorRecord $_ -EnableException $EnableException -Continue }
            #endregion Check Current AD State

            #region Compare Assignments to existing state
            foreach ($assignment in $assignments) {
                if (-not $assignment.ADMember) {
                    # Principal that should be member could not be found
                    New-TestResult @resultDefaults -Type Unresolved -Identity "$(Resolve-String -Text $assignment.Assignment.Group) þ $($assignment.Assignment.ItemType) þ $(Resolve-String -Text $assignment.Assignment.Name)" -Configuration $assignment -ADObject $adObject
                    continue
                }

                # Skip if membership is optional
                if ($assignment.Assignment.Mode -in 'MayBeMember', 'MayBeMemberIfExists') { continue }

                if ($adMembers | Where-Object ObjectSID -EQ $assignment.ADMember.objectSID) {
                    continue
                }
                $change = [PSCustomObject]@{
                    PSTypeName = 'DomainManagement.GroupMember.Change'
                    Action     = 'Add'
                    Group      = Resolve-String -Text $assignment.Assignment.Group
                    Member     = Resolve-String -Text $assignment.ADMember.SamAccountName
                    Type       = $assignment.ADMember.ObjectClass
                }
                Add-Member -InputObject $change -MemberType ScriptMethod -Name ToString -Value { 'Add: {0} -> {1}' -f $this.Member, $this.Group } -Force
                New-TestResult @resultDefaults -Type Add -Identity "$(Resolve-String -Text $assignment.Assignment.Group) þ $($assignment.ADMember.ObjectClass) þ $(Resolve-String -Text $assignment.ADMember.SamAccountName)" -Configuration $assignment -ADObject $adObject -Changed $change
            }
            #endregion Compare Assignments to existing state
            
            if ($processingMode -eq 'Additive') { continue }
            
            #region Compare existing state to assignments
            foreach ($adMember in $adMembers) {
                if ("$($adMember.ObjectSID)" -in ($assignments.ADMember.ObjectSID | ForEach-Object { "$_" })) {
                    continue
                }
                $configObject = [PSCustomObject]@{
                    Assignment = $null
                    ADMember   = $adMember
                }

                $identifier = $adMember.SamAccountName
                if (-not $identifier) {
                    try { $identifier = Resolve-Principal -Name $adMember.ObjectSid -OutputType SamAccountName -ErrorAction Stop }
                    catch { $identifier = $adMember.ObjectSid }
                }
                if (-not $identifier) { $identifier = $adMember.ObjectSid }
                if ($failedResolveAssignment -and ($adMember.ObjectClass -eq 'foreignSecurityPrincipal')) {
                    # Currently a member, is foreignSecurityPrincipal and we cannot be sure we resolved everything that should be member
                    New-TestResult @resultDefaults -Type Unidentified -Identity "$($adObject.Name) þ $($adMember.ObjectClass) þ $($identifier)" -Configuration $configObject -ADObject $adObject
                }
                else {
                    $change = [PSCustomObject]@{
                        PSTypeName = 'DomainManagement.GroupMember.Change'
                        Action     = 'Remove'
                        Group      = $adObject.Name
                        Member     = $identifier
                        Type       = $adMember.ObjectClass
                    }
                    Add-Member -InputObject $change -MemberType ScriptMethod -Name ToString -Value { 'Remove: {0} -> {1}' -f $this.Member, $this.Group } -Force
                    New-TestResult @resultDefaults -Type Delete -Identity "$($adObject.Name) þ $($adMember.ObjectClass) þ $($identifier)" -Configuration $configObject -ADObject $adObject -Changed $change
                }
            }
            #endregion Compare existing state to assignments
        }
    }
}

function Unregister-DMGroupMembership
{
    <#
    .SYNOPSIS
        Removes entries from the list of desired group memberships.
     
    .DESCRIPTION
        Removes entries from the list of desired group memberships.
     
    .PARAMETER Name
        Name of the identity being granted group membership
     
    .PARAMETER ItemType
        The type of object the identity being granted group membership is.
     
    .PARAMETER Group
        The group being granted membership in.
     
    .EXAMPLE
        PS C:\> Get-DMGroupMembership | Unregister-DMGroupMembership
 
        Removes all configured desired group memberships.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('User', 'Group', 'foreignSecurityPrincipal', 'Computer', '<Empty>')]
        [string]
        $ItemType,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Group
    )
    
    process
    {
        if (-not $script:groupMemberShips[$Group]) { return }
        if ($Name -eq '<empty>') {
            $null = $script:groupMemberShips.Remove($Group)
            return
        }
        if (-not $script:groupMemberShips[$Group]["$($ItemType):$($Name)"]) { return }
        $null = $script:groupMemberShips[$Group].Remove("$($ItemType):$($Name)")
        if (-not $script:groupMemberShips[$Group].Count) {
            $null = $script:groupMemberShips.Remove($Group)
        }
    }
}

function Get-DMGroupPolicy
{
    <#
    .SYNOPSIS
        Returns all registered GPO objects.
     
    .DESCRIPTION
        Returns all registered GPO objects.
        Thsi represents the _desired_ state, not any actual state.
     
    .PARAMETER Name
        The name to filter by.
     
    .EXAMPLE
        PS C:\> Get-DMGroupPolicy
 
        Returns all registered GPOs
    #>

    [CmdletBinding()]
    param (
        [string]
        $Name = '*'
    )
    
    process
    {
        ($script:groupPolicyObjects.Values | Where-Object DisplayName -like $name)
    }
}


function Invoke-DMGroupPolicy
{
    <#
    .SYNOPSIS
        Brings the group policy settings into compliance with the desired state.
     
    .DESCRIPTION
        Brings the group policy settings into compliance with the desired state.
        Define the desired state by using Register-DMGroupPolicy.
        Note: The original export will need to be carefully crafted to fit this system.
        Use the ADMF module's Export-AdmfGpo command to generate the gpo definition from an existing deployment.
     
    .PARAMETER InputObject
        Test results provided by the associated test command.
        Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed.
     
    .PARAMETER Delete
        By default, this command will NOT delete group policies, in order to avoid accidentally locking yourself out of the system.
        Use this parameter to delete group policies that are no longer needed.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMGroupPolicy -Server fabrikam.com
 
        Brings the group policy settings from the domain fabrikam.com into compliance with the desired state.
 
    .EXAMPLE
        PS C:\> Invoke-DMGroupPolicy -Server fabrikam.com -Delete
 
        Brings the group policy settings from the domain fabrikam.com into compliance with the desired state.
        Will also delete all deprecated policies linked to the managed infrastructure.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")]
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,
        
        [switch]
        $Delete,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupPolicyObjects -Cmdlet $PSCmdlet
        $computerName = (Get-ADDomain @parameters).PDCEmulator
        $psParameter = $PSBoundParameters | ConvertTo-PSFHashtable -Include ComputerName, Credential -Inherit
        try { $session = New-PSSession @psParameter -ErrorAction Stop }
        catch {
            Stop-PSFFunction -String 'Invoke-DMGroupPolicy.WinRM.Failed' -StringValues $computerName -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName
            return
        }
        Set-DMDomainContext @parameters

        try { $gpoRemotePath = New-GpoWorkingDirectory -Session $session -ErrorAction Stop }
        catch {
            Remove-PSSession -Session $session -WhatIf:$false -Confirm:$false -ErrorAction SilentlyContinue
            Stop-PSFFunction -String 'Invoke-DMGroupPolicy.Remote.WorkingDirectory.Failed' -StringValues $computerName -Target $computerName -ErrorRecord $_ -EnableException $EnableException
            return
        }
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }
        
        if (-not $InputObject) {
            $InputObject = Test-DMGroupPolicy @parameters
        }
        
        foreach ($testItem in $InputObject) {
            # Catch invalid input - can only process test results
            if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.GroupPolicy.TestResult') {
                Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMGroupPolicy', $testItem -Target $testItem -Continue -EnableException $EnableException
            }
            
            switch ($testItem.Type) {
                'Delete' {
                    if (-not $Delete) { continue }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Delete' -ActionStringValues $testItem.Identity -Target $testItem -ScriptBlock {
                        Remove-GroupPolicy -Session $session -ADObject $testItem.ADObject -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                'ConfigError' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Install.OnConfigError' -ActionStringValues $testItem.Identity -Target $testItem -ScriptBlock {
                        Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                'CriticalError' {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMGroupPolicy.Skipping.InCriticalState' -StringValues $testItem.Identity -Target $testItem
                }
                'Update' {
                    foreach ($change in $testItem.Changed) {
                        Write-PSFMessage -Level Verbose -String 'Invoke-DMGroupPolicy.Update.Detail' -StringValues $change.Property, $change.Old, $change.New, $change.Identity -Target $testItem -Tag gpoUpdateDetail
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Install.OnUpdate' -ActionStringValues $testItem.Identity -Target $testItem -ScriptBlock {
                        Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                'Manage' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Install.OnManage' -ActionStringValues $testItem.Identity -Target $testItem -ScriptBlock {
                        Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                'Create' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroupPolicy.Install.OnNew' -ActionStringValues $testItem.Identity -Target $testItem -ScriptBlock {
                        Install-GroupPolicy -Session $session -Configuration $testItem.Configuration -WorkingDirectory $gpoRemotePath -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
            }
        }
    }
    end
    {
        if ($gpoRemotePath) {
            Invoke-Command -Session $session -ArgumentList $gpoRemotePath -ScriptBlock {
                param ($GpoRemotePath)
                Remove-Item -Path $GpoRemotePath -Recurse -Force -Confirm:$false -ErrorAction SilentlyContinue -WhatIf:$false
            }
        }
        if ($session) {
            Remove-PSSession -Session $session -WhatIf:$false -Confirm:$false -ErrorAction SilentlyContinue
        }
    }
}

function Register-DMGroupPolicy {
    <#
    .SYNOPSIS
        Adds a group policy object to the list of desired GPOs.
     
    .DESCRIPTION
        Adds a group policy object to the list of desired GPOs.
        These are then tested for using Test-DMGroupPolicy and applied by using Invoke-DMGroupPolicy.
     
    .PARAMETER DisplayName
        Name of the GPO to add.
         
    .PARAMETER Description
        Description of the GPO in question,.
     
    .PARAMETER ID
        The GPO Id GUID.
     
    .PARAMETER Path
        Path to where the GPO export can be found.
     
    .PARAMETER ExportID
        The tracking ID assigned to the GPO in order to detect its revision.
 
    .PARAMETER WmiFilter
        The WmiFilter to apply to the group policy object.
 
    .PARAMETER ContextName
        The name of the context defining the setting.
        This allows determining the configuration set that provided this setting.
        Used by the ADMF, available to any other configuration management solution.
     
    .EXAMPLE
        PS C:\> Get-Content gpos.json | ConvertFrom-Json | Write-Output | Register-DMGroupPolicy
 
        Reads all gpos defined in gpos.json and registers each as a GPO object.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $DisplayName,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [AllowEmptyString()]
        [string]
        $Description,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ID,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Path,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ExportID,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $WmiFilter,
        
        [string]
        $ContextName = '<Undefined>'
    )
    
    process {
        $script:groupPolicyObjects[$DisplayName] = [PSCustomObject]@{
            PSTypeName  = 'DomainManagement.GroupPolicyObject'
            DisplayName = $DisplayName
            Description = $Description
            ID          = $ID
            Path        = $Path
            ExportID    = $ExportID
            WmiFilter   = $WmiFilter
            ContextName = $ContextName
        }
    }
}

function Test-DMGroupPolicy {
    <#
    .SYNOPSIS
        Tests whether the current domain has the desired group policy setup.
     
    .DESCRIPTION
        Tests whether the current domain has the desired group policy setup.
        Based on timestamps and IDs it will detect for existing OUs, whether the currently deployed version:
        - Is based on the latest GPO version
        - has been changed since being last deployed (In which case it is configured to restore itself to its intended state)
        Ignores GPOs not linked to managed OUs.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Test-DMGroupPolicy -Server contoso.com
 
        Validates that the contoso domain's group policies are in the desired state
    #>

    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type GroupPolicyObjects -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
        $computerName = (Get-ADDomain @parameters).PDCEmulator

        # DomainData retrieval
        $domainDataNames = ((Get-DMGroupPolicy).DisplayName | Get-DMGPRegistrySetting | Where-Object DomainData).DomainData | Select-Object -Unique
        try { $null = $domainDataNames | Invoke-DMDomainData @parameters -EnableException }
        catch {
            Stop-PSFFunction -String 'Test-DMGroupPolicy.DomainData.Failed' -StringValues ($domainDataNames -join ",") -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName
            return
        }

        # PS Remoting
        $psParameter = $PSBoundParameters | ConvertTo-PSFHashtable -Include ComputerName, Credential -Inherit
        try { $session = New-PSSession @psParameter -ErrorAction Stop }
        catch {
            Stop-PSFFunction -String 'Test-DMGroupPolicy.WinRM.Failed' -StringValues $computerName -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName
            return
        }
    }
    process {
        if (Test-PSFFunctionInterrupt) { return }

        $resultDefaults = @{
            Server     = $Server
            ObjectType = 'GroupPolicy'
        }

        #region Gather data
        $desiredPolicies = Get-DMGroupPolicy
        $managedPolicies = Get-LinkedPolicy @parameters
        foreach ($managedPolicy in $managedPolicies) {
            if (-not $managedPolicy.DisplayName) {
                Write-PSFMessage -Level Warning -String 'Test-DMGroupPolicy.ADObjectAccess.Failed' -StringValues $managedPolicy.DistinguishedName -Target $managedPolicy
                New-TestResult @resultDefaults -Type 'ADAccessFailed' -Identity $managedPolicy.DistinguishedName -ADObject $managedPolicy
                continue
            }
            # Resolve-PolicyRevision updates the content of $managedPolicy without producing output
            try { Resolve-PolicyRevision -Policy $managedPolicy -Session $session }
            catch { Write-PSFMessage -Level Warning -String 'Test-DMGroupPolicy.PolicyRevision.Lookup.Failed' -StringValues $managedPolicies.DisplayName -ErrorRecord $_ -EnableException $EnableException.ToBool() }
        }
        $desiredHash = @{ }
        $managedHash = @{ }
        foreach ($desiredPolicy in $desiredPolicies) { $desiredHash[$desiredPolicy.DisplayName] = $desiredPolicy }
        foreach ($managedPolicy in $managedPolicies) {
            if (-not $managedPolicy.DisplayName) { continue }
            $managedHash[$managedPolicy.DisplayName] = $managedPolicy
        }
        #endregion Gather data

        #region Compare configuration to actual state
        foreach ($desiredPolicy in $desiredHash.Values) {
            $resultUpdateDefaults = $resultDefaults.Clone()
            $resultUpdateDefaults +=  @{
                Identity = $desiredPolicy.DisplayName
                Configuration = $desiredPolicy
            }

            if (-not $managedHash[$desiredPolicy.DisplayName]) {
                New-TestResult @resultUpdateDefaults -Type 'Create'
                continue
            }

            $resultUpdateDefaults.ADObject = $managedHash[$desiredPolicy.DisplayName]

            switch ($managedHash[$desiredPolicy.DisplayName].State) {
                'ConfigError' { New-TestResult @resultUpdateDefaults -Type 'ConfigError' }
                'CriticalError' { New-TestResult @resultUpdateDefaults -Type 'CriticalError' }
                'Healthy' {
                    $changes = [System.Collections.ArrayList]@()
                    $policyObject = $managedHash[$desiredPolicy.DisplayName]
                    if ($policyObject.Version -ne $policyObject.ADVersion) {
                        $change = New-Change -Property Modified -OldValue $policyObject.Version -NewValue $policyObject.ADVersion -Identity $desiredPolicy.DisplayName -Type AdmfVersion
                        $null = $changes.Add($change)
                    }
                    if ($desiredPolicy.ExportID -ne $policyObject.ExportID) {
                        $change = New-Change -Property Update -OldValue $policyObject.ExportID -NewValue $desiredPolicy.ExportID -Identity $desiredPolicy.DisplayName -Type AdmfVersion
                        $null = $changes.Add($change)
                    }
                    $registryTest = Test-DMGPRegistrySetting -Server $session -PolicyName $desiredPolicy.DisplayName -PassThru
                    if (-not $registryTest.Success) {
                        foreach ($changeItem in $registryTest.Changes) {
                            $change = New-Change -Property RegistryData -OldValue $changeItem.IsValue -NewValue $changeItem.ShouldValue -Identity ('{0}: {1} > {2}' -f $desiredPolicy.DisplayName, $changeItem.Key, $changeItem.ValueName) -Type AdmfVersion
                            $null = $changes.Add($change)
                        }
                    }
                    if ("$($desiredPolicy.WmiFilter)" -ne "$($managedHash[$desiredPolicy.DisplayName].WmiFilter)") {
                        $change = New-Change -Property WmiFilter -OldValue $managedHash[$desiredPolicy.DisplayName].WmiFilter -NewValue $desiredPolicy.WmiFilter -Identity $desiredPolicy.DisplayName -Type WmiFilterAssignment
                        $null = $changes.Add($change)
                    }
                    if ($changes.Count -gt 0) {
                        New-TestResult @resultUpdateDefaults -Type 'Update' -Changed $changes
                    }
                }
                'Unmanaged' {
                    New-TestResult @resultUpdateDefaults -Type 'Manage'
                }
            }
        }
        #endregion Compare configuration to actual state

        #region Compare actual state to configuration
        foreach ($managedPolicy in $managedHash.Values) {
            if ($desiredHash[$managedPolicy.DisplayName]) { continue }
            if ($managedPolicy.IsCritical) { continue }
            New-TestResult @resultDefaults -Type 'Delete' -Identity $managedPolicy.DisplayName -ADObject $managedPolicy
        }
        #endregion Compare actual state to configuration
    }
    end {
        if ($session) { Remove-PSSession $session -WhatIf:$false -Confirm:$false }
    }
}


function Unregister-DMGroupPolicy
{
    <#
        .SYNOPSIS
            Removes a group policy object from the list of desired gpos.
         
        .DESCRIPTION
            Removes a group policy object from the list of desired gpos.
         
        .PARAMETER Name
            The name of the GPO to remove from the list of ddesired gpos
         
        .EXAMPLE
            PS C:\> Get-DMGroupPolicy | Unregister-DMGroupPolicy
 
            Clears all configured GPOs
    #>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $true)]
        [Alias('DisplayName')]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameItem in $Name) {
            $script:groupPolicyObjects.Remove($nameItem)
        }
    }
}


function Get-DMGroup
{
    <#
        .SYNOPSIS
            Lists registered ad groups.
         
        .DESCRIPTION
            Lists registered ad groups.
         
        .PARAMETER Name
            The name to filter by.
            Defaults to '*'
         
        .EXAMPLE
            PS C:\> Get-DMGroup
 
            Lists all registered ad groups.
    #>

    [CmdletBinding()]
    param (
        [string]
        $Name = '*'
    )
    
    process
    {
        ($script:groups.Values | Where-Object Name -like $Name)
    }
}


function Invoke-DMGroup {
    <#
        .SYNOPSIS
            Updates the group configuration of a domain to conform to the configured state.
         
        .DESCRIPTION
            Updates the group configuration of a domain to conform to the configured state.
     
        .PARAMETER InputObject
            Test results provided by the associated test command.
            Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
 
        .PARAMETER EnableException
            This parameters disables user-friendly warnings and enables the throwing of exceptions.
            This is less user friendly, but allows catching exceptions in calling scripts.
 
        .PARAMETER Confirm
            If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
         
        .PARAMETER WhatIf
            If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
         
        .EXAMPLE
            PS C:\> Innvoke-DMGroup -Server contoso.com
 
            Updates the groups in the domain contoso.com to conform to configuration
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Groups -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process {
        if (-not $InputObject) {
            $InputObject = Test-DMGroup @parameters
        }
        foreach ($testItem in $InputObject) {
            # Catch invalid input - can only process test results
            if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.Group.TestResult') {
                Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMGroup', $testItem -Target $testItem -Continue -EnableException $EnableException
            }
            
            switch ($testItem.Type) {
                'Delete' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroup.Group.Delete' -Target $testItem -ScriptBlock {
                        Remove-ADGroup @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                'Create' {
                    $targetOU = Resolve-String -Text $testItem.Configuration.Path
                    try { $null = Get-ADObject @parameters -Identity $targetOU -ErrorAction Stop }
                    catch { Stop-PSFFunction -String 'Invoke-DMGroup.Group.Create.OUExistsNot' -StringValues $targetOU, $testItem.Identity -Target $testItem -EnableException $EnableException -Continue }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroup.Group.Create' -Target $testItem -ScriptBlock {
                        $newParameters = $parameters.Clone()
                        $newParameters += @{
                            Name          = (Resolve-String -Text $testItem.Configuration.Name)
                            Description   = (Resolve-String -Text $testItem.Configuration.Description)
                            Path          = $targetOU
                            GroupCategory = $testItem.Configuration.Category
                            GroupScope    = $testItem.Configuration.Scope
                            Confirm       = $false
                        }
                        New-ADGroup @newParameters
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                'MultipleOldGroups' {
                    Stop-PSFFunction -String 'Invoke-DMGroup.Group.MultipleOldGroups' -StringValues $testItem.Identity, ($testItem.ADObject.Name -join ', ') -Target $testItem -EnableException $EnableException -Continue -Tag 'group', 'critical', 'panic'
                }
                'Rename' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroup.Group.Rename' -ActionStringValues (Resolve-String -Text $testItem.Configuration.Name) -Target $testItem -ScriptBlock {
                        Set-ADGroup @parameters -Identity $testItem.ADObject.ObjectGUID -SamAccountName (Resolve-String -Text $testItem.Configuration.SamAccountName) -ErrorAction Stop -Confirm:$false
                        if ((Resolve-String -Text $testItem.Configuration.Name) -cne $testItem.ADObject.Name) {
                            Rename-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -NewName (Resolve-String -Text $testItem.Configuration.Name) -ErrorAction Stop -Confirm:$false
                        }
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                'Update' {
                    if ($change = $testItem.Changed | Where-Object Property -eq 'Path') {
                        $targetOU = $change.New
                        try { $null = Get-ADObject @parameters -Identity $targetOU -ErrorAction Stop }
                        catch { Stop-PSFFunction -String 'Invoke-DMGroup.Group.Update.OUExistsNot' -StringValues $testItem.Identity, $targetOU -Target $testItem -EnableException $EnableException -Continue }

                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroup.Group.Move' -ActionStringValues $targetOU -Target $testItem -ScriptBlock {
                            $null = Move-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -TargetPath $targetOU -ErrorAction Stop -Confirm:$false
                        } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                    }
                    $changes = @{ }
                    foreach ($change in $testItem.Changed | Where-Object Property -in 'Description', 'Category') {
                        if ($change.Property -eq 'Description') { $changes['Description'] = $change.New }
                        if ($change.Property -eq 'Category') { $changes['GroupCategory'] = $change.New }
                    }
                    
                    if ($changes.Keys.Count -gt 0) {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroup.Group.Update' -ActionStringValues ($changes.Keys -join ", ") -Target $testItem -ScriptBlock {
                            $null = Set-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Replace $changes -Confirm:$false
                        } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                    }
                    if ($testItem.Changed.Property -contains 'Scope') {
                        $targetScope = Resolve-String -Text $testItem.Configuration.Scope
                        if ($targetScope -notin ([Enum]::GetNames([Microsoft.ActiveDirectory.Management.ADGroupScope]))) {
                            Stop-PSFFunction -String 'Invoke-DMGroup.Group.InvalidScope' -StringValues $testItem, $targetScope -Continue -EnableException $EnableException -Target $testItem
                        }

                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroup.Group.Update.Scope' -ActionStringValues $testItem, $testItem.ADObject.GroupScope, $targetScope -Target $testItem -ScriptBlock {
                            $null = Set-ADGroup @parameters -Identity $testItem.ADObject.ObjectGUID -GroupScope Universal -ErrorAction Stop -Confirm:$false
                            $null = Set-ADGroup @parameters -Identity $testItem.ADObject.ObjectGUID -GroupScope $targetScope -ErrorAction Stop -Confirm:$false
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                    if ($change = $testItem.Changed | Where-Object Property -eq 'Name') {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMGroup.Group.Update.Name' -ActionStringValues $change.NewValue -Target $testItem -ScriptBlock {
                            $testItem.ADObject | Rename-ADObject @parameters -NewName $change.New -ErrorAction Stop -Confirm:$false
                        } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                    }
                }
            }
        }
    }
}

function Register-DMGroup {
    <#
    .SYNOPSIS
        Registers an active directory group.
     
    .DESCRIPTION
        Registers an active directory group.
        This group will be maintained as configured during Invoke-DMGroup.
     
    .PARAMETER Name
        The name of the group.
        Subject to string insertion.
 
    .PARAMETER SamAccountName
        The SamAccountName of the group.
        Defaults to the Name if not otherwise specified.
     
    .PARAMETER Path
        Path (distinguishedName) of the OU to place the group in.
        Subject to string insertion.
     
    .PARAMETER Description
        Description of the group.
        Subject to string insertion.
     
    .PARAMETER Scope
        The scope of the group.
        Use DomainLocal for groups that grant direct permissions and Global for role groups.
 
    .PARAMETER Category
        Whether the group should be a security group or a distribution group.
        Defaults to security.
 
    .PARAMETER OldNames
        Previous names the group used to have.
        By specifying this name, groups will be renamed if still using an old name.
        Conflicts may require resolving.
     
    .PARAMETER Present
        Whether the group should exist.
        Defaults to $true
        Set to $false for explicitly deleting groups, rather than creating them.
 
    .PARAMETER Optional
        Group is tolerated if it exists, but will not be created if not.
 
    .PARAMETER ContextName
        The name of the context defining the setting.
        This allows determining the configuration set that provided this setting.
        Used by the ADMF, available to any other configuration management solution.
     
    .EXAMPLE
        PS C:\> Get-Content .\groups.json | ConvertFrom-Json | Write-Output | Register-DMGroup
 
        Reads a json configuration file containing a list of objects with appropriate properties to import them as group configuration.
    #>

    
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $SamAccountName,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Path,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Description,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('DomainLocal', 'Global', 'Universal')]
        [string]
        $Scope,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('Security', 'Distribution')]
        [string]
        $Category = 'Security',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $OldNames = @(),

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $Present = $true,

        [bool]
        $Optional,

        [string]
        $ContextName = '<Undefined>'
    )
    
    process {
        $script:groups[$Name] = [PSCustomObject]@{
            PSTypeName     = 'DomainManagement.Group'
            Name           = $Name
            SamAccountName = $(if ($SamAccountName) { $SamAccountName } else { $Name })
            Path           = $Path
            Description    = $Description
            Scope          = $Scope
            Category       = $Category
            OldNames       = $OldNames
            Present        = $Present
            Optional       = $Optional
            ContextName    = $ContextName
        }
    }
}


function Test-DMGroup {
    <#
        .SYNOPSIS
            Tests whether the configured groups match a domain's configuration.
         
        .DESCRIPTION
            Tests whether the configured groups match a domain's configuration.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
         
        .EXAMPLE
            PS C:\> Test-DMGroup
 
            Tests whether the configured groups' state matches the current domain group setup.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Groups -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process {
        $oldNamesFound = @()
        :main foreach ($groupDefinition in $script:groups.Values) {
            $resolvedName = Resolve-String -Text $groupDefinition.SamAccountName

            $resultDefaults = @{
                Server        = $Server
                ObjectType    = 'Group'
                Identity      = $resolvedName
                Configuration = $groupDefinition
            }

            #region Group that needs to be removed
            if (-not $groupDefinition.Present) {
                try { $adObject = Get-ADGroup @parameters -Identity $resolvedName -ErrorAction Stop }
                catch { continue } # Only errors when group not present = All is well
                
                New-TestResult @resultDefaults -Type Delete -ADObject $adObject
                continue
            }
            #endregion Group that needs to be removed

            #region Groups that don't exist but should | Groups that need to be renamed
            # Flag to avoid duplicate renames in case of OldNames
            $noNameUpdate = $false
            try { $adObject = Get-ADGroup @parameters -Identity $resolvedName -Properties Description -ErrorAction Stop }
            catch {
                $oldGroups = foreach ($oldName in ($groupDefinition.OldNames | Resolve-String)) {
                    try { Get-ADGroup @parameters -Identity $oldName -Properties Description -ErrorAction Stop }
                    catch { }
                }

                switch (($oldGroups | Measure-Object).Count) {
                    #region Case: No old version present
                    0 {
                        if (-not $groupDefinition.Optional) {
                            New-TestResult @resultDefaults -Type Create
                        }
                        continue main
                    }
                    #endregion Case: No old version present

                    #region Case: One old version present
                    1 {
                        New-TestResult @resultDefaults -Type Rename -ADObject $oldGroups -Changed (New-AdcChange -Identity $adObject -Property Name -OldValue $oldGroups.Name -NewValue $resolvedName)
                        $oldNamesFound += $oldGroups.Name
                        $noNameUpdate = $true
                        $adObject = $oldGroups
                    }
                    #endregion Case: One old version present

                    #region Case: Too many old versions present
                    default {
                        New-TestResult @resultDefaults -Type MultipleOldGroups -ADObject $oldGroups
                        $oldNamesFound += $oldGroups.Name
                        continue main
                    }
                    #endregion Case: Too many old versions present
                }
            }
            #endregion Groups that don't exist but should | Groups that need to be renamed

            #region Existing Groups, might need updates
            # $adObject contains the relevant object

            [System.Collections.ArrayList]$changes = @()
            $compare = @{
                Configuration = $groupDefinition
                ADObject      = $adObject
                Changes       = $changes
                AsUpdate      = $true
                Type          = 'Group'
            }
            Compare-Property @compare -Property Description -Resolve
            Compare-Property @compare -Property Category -ADProperty GroupCategory
            Compare-Property @compare -Property Scope -ADProperty GroupScope
            if (-not $noNameUpdate) {
                Compare-Property @compare -Property Name -Resolve
            }
            $ouPath = ($adObject.DistinguishedName -split ",", 2)[1]
            if ($ouPath -ne (Resolve-String -Text $groupDefinition.Path)) {
                $null = $changes.Add((New-Change -Property Path -OldValue $ouPath -NewValue (Resolve-String -Text $groupDefinition.Path) -Identity $adObject -Type Group))
            }
            if ($changes.Count) {
                New-TestResult @resultDefaults -Type Update -Changed $changes.ToArray() -ADObject $adObject
            }
            #endregion Existing Groups, might need updates
        }

        $foundGroups = foreach ($searchBase in (Resolve-ContentSearchBase @parameters)) {
            Get-ADGroup @parameters -LDAPFilter '(!(isCriticalSystemObject=TRUE))' -SearchBase $searchBase.SearchBase -SearchScope $searchBase.SearchScope
        }

        $resolvedConfiguredNames = $script:groups.Values.Name | Resolve-String
        $resultDefaults = @{
            Server     = $Server
            ObjectType = 'Group'
        }

        foreach ($existingGroup in $foundGroups) {
            if ($existingGroup.Name -in $oldNamesFound) { continue }
            if ($existingGroup.Name -in $resolvedConfiguredNames) { continue }
            if (1000 -ge ($existingGroup.SID -split "-")[-1]) { continue } # Ignore BuiltIn default groups

            New-TestResult @resultDefaults -Type Delete -ADObject $existingGroup -Identity $existingGroup.Name
        }
    }
}


function Unregister-DMGroup
{
    <#
    .SYNOPSIS
        Removes a group that had previously been registered.
     
    .DESCRIPTION
        Removes a group that had previously been registered.
     
    .PARAMETER Name
        The name of the group to remove.
     
    .EXAMPLE
        PS C:\> Get-DMGroup | Unregister-DMGroup
 
        Clears all registered groups.
    #>

    
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameItem in $Name) {
            $script:groups.Remove($nameItem)
        }
    }
}


function Get-DMNameMapping
{
    <#
        .SYNOPSIS
            List the registered name mappings
         
        .DESCRIPTION
            List the registered name mappings
            Mapped names are used for stringr replacement when invoking domain configurations.
         
        .PARAMETER Name
            The name to filter by.
            Defaults to '*'
         
        .EXAMPLE
            PS C:\> Get-DMNameMapping
 
            List all registered mappings
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Name = '*'
    )
    
    process
    {
        foreach ($key in $script:nameReplacementTable.Keys) {
            if ($key -notlike $Name) { continue }

            [PSCustomObject]@{
                PSTypeName = 'DomainManagement.Name.Mapping'
                Name = $key
                Value = $script:nameReplacementTable[$key]
            }
        }
    }
}


function Register-DMNameMapping
{
    <#
        .SYNOPSIS
            Register a new name mapping.
         
        .DESCRIPTION
            Register a new name mapping.
            Mapped names are used for stringr replacement when invoking domain configurations.
         
        .PARAMETER Name
            The name of the placeholder to register.
            This label will be replaced with the content specified in -Value.
            Be aware that all labels must be enclosed in % and only contain letters, underscore and numbers.
         
        .PARAMETER Value
            The value to insert in place of the label.
         
        .EXAMPLE
            PS C:\> Register-DMNameMapping -Name '%ManagementGroup%' -Value 'Mgmt-Team-1234'
 
            Registers the string 'Mgmt-Team-1234' under the label '%ManagementGroup%'
    #>

    
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [PsfValidatePattern('^%[\d\w_]+%$', ErrorString = 'DomainManagement.Validate.Name.Pattern')]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Value
    )
    
    process
    {
        $script:nameReplacementTable[$Name] = $Value
        Register-StringMapping -Name $Name -Value $Value
    }
}


function Unregister-DMNameMapping
{
    <#
    .SYNOPSIS
        Removes a registered name mapping.
     
    .DESCRIPTION
        Removes a registered name mapping.
        Mapped names are used for stringr replacement when invoking domain configurations.
     
    .PARAMETER Name
        The name(s) of the mapping to purge.
     
    .EXAMPLE
        PS C:\> Get-DMNameMapping | Unregister-DMNameMapping
 
        Removes all registered name mappings.
    #>

    
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameItem in $Name) {
            $script:nameReplacementTable.Remove($nameItem)
            Unregister-StringMapping -Name $nameItem
        }
    }
}


function Get-DMObject
{
    <#
    .SYNOPSIS
        Returns configured active directory objects.
     
    .DESCRIPTION
        Returns configured active directory objects.
     
    .PARAMETER Path
        The path to filter by.
 
    .PARAMETER Name
        The name to filter by.
     
    .EXAMPLE
        PS C:\> Get-DMObject
 
        Returns all registered objects
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Path = '*',

        [string]
        $Name = '*'
    )
    
    process
    {
        ($script:objects.Values | Where-Object Path -like $Path | Where-Object Name -like $Name)
    }
}

function Invoke-DMObject
{
    <#
        .SYNOPSIS
            Updates the generic ad object configuration of a domain to conform to the configured state.
         
        .DESCRIPTION
            Updates the generic ad object configuration of a domain to conform to the configured state.
     
        .PARAMETER InputObject
            Test results provided by the associated test command.
            Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
 
        .PARAMETER EnableException
            This parameters disables user-friendly warnings and enables the throwing of exceptions.
            This is less user friendly, but allows catching exceptions in calling scripts.
 
        .PARAMETER Confirm
            If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
         
        .PARAMETER WhatIf
            If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
         
        .EXAMPLE
            PS C:\> Invoke-DMObject -Server contoso.com
 
            Updates the generic objects in the domain contoso.com to conform to configuration
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Objects -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process{
        if (-not $InputObject) {
            $InputObject = Test-DMObject @parameters
        }
        
        foreach ($testItem in ($InputObject | Sort-Object { $_.Identity.Length })) {
            # Catch invalid input - can only process test results
            if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.Object.TestResult') {
                Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMObject', $testItem -Target $testItem -Continue -EnableException $EnableException
            }
            
            switch ($testItem.Type) {
                'Create' {
                    $createParam = $parameters.Clone()
                    $createParam += @{
                        Path = Resolve-String -Text $testItem.Configuration.Path
                        Name = Resolve-String -Text $testItem.Configuration.Name
                        Type = Resolve-String -Text $testItem.Configuration.ObjectClass
                    }
                    if ($testItem.Configuration.Attributes.Count -gt 0) {
                        $hash = @{ }
                        foreach ($key in $testItem.Configuration.Attributes.Keys) {
                            if ($key -notin $testItem.Configuration.AttributesToResolve) { $hash[$key] = $testItem.Configuration.Attributes[$key] }
                            else { $hash[$key] = $testItem.Configuration.Attributes[$key] | Resolve-String }
                        }
                        $createParam['OtherAttributes'] = $hash
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMObject.Object.Create' -ActionStringValues $testItem.Configuration.ObjectClass, $testItem.Identity -Target $testItem -ScriptBlock {
                        New-ADObject @createParam -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                'Update' {
                    $setParam = $parameters.Clone()
                    $setParam += @{
                        Identity = $testItem.Identity
                    }
                    $replaceHash = @{ }
                    foreach ($change in $testItem.Changed) {
                        $replaceHash[$change.Property] = $change.New
                    }
                    $setParam['Replace'] = $replaceHash
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMObject.Object.Change' -ActionStringValues ($testItem.Changed.Property -join ", ") -Target $testItem -ScriptBlock {
                        Set-ADObject @setParam -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
            }
        }
    }
}

function Register-DMObject
{
    <#
    .SYNOPSIS
        Registers a generic object as the desired state for active directory.
     
    .DESCRIPTION
        Registers a generic object as the desired state for active directory.
        This allows defining custom objects not implemented as a commonly supported type.
     
    .PARAMETER Path
        The Path to the OU in which to place the object.
        Subject to string insertion.
     
    .PARAMETER Name
        Name of the object to define.
        Subject to string insertion.
     
    .PARAMETER ObjectClass
        The class of the object to define.
     
    .PARAMETER Attributes
        Attributes to include in the object.
        If you specify a hashtable, keys are mapped to attributes.
        If you specify another arbitrary object type, properties are mapped to attributes.
 
    .PARAMETER AttributesToResolve
        The names of all attributes in configuration, for which you want to perform string insertion, before comparing with the actual object in AD.
     
    .EXAMPLE
        PS C:\> Get-Content .\objects.json | ConvertFrom-Json | Write-Output | Register-DMObject
 
        Imports all objects defined in objects.json.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Path,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ObjectClass,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        $Attributes,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $AttributesToResolve
    )
    
    process
    {
        $identity = "CN=$Name,$Path"
        if (-not $Name) { $identity = $Path }
        $script:objects[$identity] = [PSCustomObject]@{
            PSTypeName = 'DomainManagement.Object'
            Identity = $identity
            Path = $Path
            Name = $Name
            ObjectClass = $ObjectClass
            Attributes = ($Attributes | ConvertTo-PSFHashtable)
            AttributesToResolve = $AttributesToResolve
        }
    }
}

function Test-DMObject {
    <#
        .SYNOPSIS
            Tests, whether the desired objects have been defined correctly in AD.
         
        .DESCRIPTION
            Tests, whether the desired objects have been defined correctly in AD.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
     
        .EXAMPLE
            PS C:\> Test-DMObject
 
            Tests whether the current domain has all the custom objects as defined.
    #>

    [CmdletBinding()]
    Param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Objects -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process {
        foreach ($objectDefinition in $script:objects.Values) {
            $resolvedPath = Resolve-String -Text $objectDefinition.Identity

            $resultDefaults = @{
                Server        = $Server
                ObjectType    = 'Object'
                Identity      = $resolvedPath
                Configuration = $objectDefinition
            }

            #region Does not exist
            if (-not (Test-ADObject @parameters -Identity $resolvedPath)) {
                New-TestResult @resultDefaults -Type Create
                continue
            }
            #endregion Does not exist

            #region Exists
            if ($objectDefinition.Attributes.Keys) {
                try { $adObject = Get-ADObject @parameters -Identity $resolvedPath -Properties ($objectDefinition.Attributes.Keys | Write-Output) }
                catch { Stop-PSFFunction -String 'Test-DMObject.ADObject.Access.Error' -StringValues $resolvedPath, ($objectDefinition.Attributes.Keys -join ",") -Continue -ErrorRecord $_ -Tag error, baddata }
            }
            else {
                try { $adObject = Get-ADObject @parameters -Identity $resolvedPath }
                catch { Stop-PSFFunction -String 'Test-DMObject.ADObject.Access.Error2' -StringValues $resolvedPath -Continue -ErrorRecord $_ -Tag error }
            }
                
            [System.Collections.ArrayList]$changes = @()
            foreach ($propertyName in $objectDefinition.Attributes.Keys) {
                Compare-Property -Property $propertyName -Configuration $objectDefinition.Attributes -ADObject $adObject -Changes $changes -Resolve:$($objectDefinition.AttributesToResolve -contains $propertyName) -AsUpdate -Type Object
            }
            if ($changes.Count) {
                New-TestResult @resultDefaults -Type Update -Changed $changes.ToArray() -ADObject $adObject
            }
            #endregion Exists
        }
    }
}

function Unregister-DMObject
{
    <#
    .SYNOPSIS
        Unregisters a configured active directory objects.
     
    .DESCRIPTION
        Unregisters a configured active directory objects.
     
    .PARAMETER Identity
        The paths to the object to unregister.
        Requires the full, unresolved identity as dn (CN=<Name>,<Path>).
     
    .EXAMPLE
        PS C:\> Get-DMObject | Unregister-DMObject
 
        Clears all configured AD objects.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Identity
    )
    
    process
    {
        foreach ($pathString in $Identity) {
            $script:objects.Remove($pathString)
        }
    }
}

function Find-DMObjectCategoryItem {
<#
    .SYNOPSIS
        Searches for items that are part of an object category.
     
    .DESCRIPTION
        Searches for items that are part of an object category.
        Caution: A combination of inefficient filters and large scope can lead to significant performance delays in large environments!
     
    .PARAMETER Name
        The name of the object category to search items for.
     
    .PARAMETER Property
        Properties to include when retrieving matching items.
        Ensure the property is legal for all potential matches.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Find-DMObjectCategoryItem -Name 'CAServer'
     
        Find all objects that are part of the CAServer category.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Name,
        
        [string[]]
        $Property,
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,
        
        [switch]
        $EnableException
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
    }
    process {
        $category = $script:objectCategories[$Name]
        if (-not $category) {
            Stop-PSFFunction -String 'Find-DMObjectCategoryItem.Category.NotFound' -StringValues $Name -EnableException $EnableException -Cmdlet $PSCmdlet
            return
        }
        
        $searchBase = Resolve-String -Text $category.SearchBase @parameters
        if ($category.LdapFilter) {
            $filter = '(&(objectClass={0})({1}))' -f $category.ObjectClass, (Resolve-String -Text $category.LdapFilter @parameters)
            if ($Property) { $parameters.Properties = $Property }
            try { Get-ADObject @parameters -LDAPFilter $filter -SearchBase $searchBase -SearchScope $category.SearchScope -ErrorAction Stop }
            catch {
                Stop-PSFFunction -String 'Find-DMObjectCategoryItem.ADError' -StringValues $Name -EnableException $EnableException -Cmdlet $PSCmdlet
                return
            }
        }
        else {
            if ($Property) { $parameters.Properties = $Property }
            try { Get-ADObject @parameters -Filter $category.Filter -SearchBase $searchBase -SearchScope $category.SearchScope -ErrorAction Stop }
            catch {
                Stop-PSFFunction -String 'Find-DMObjectCategoryItem.ADError' -StringValues $Name -EnableException $EnableException -Cmdlet $PSCmdlet
                return
            }
        }
    }
}

function Get-DMObjectCategory
{
    <#
    .SYNOPSIS
        Returns registered object category objects.
     
    .DESCRIPTION
        Returns registered object category objects.
        See description on Register-DMObjectCategory for details on object categories in general.
     
    .PARAMETER Name
        The name to filter by.4
     
    .EXAMPLE
        PS C:\> Get-DMObjectCategory
 
        Returns all registered object categories.
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Name = '*'
    )
    
    process
    {
        ($script:objectCategories.Values | Where-Object Name -like $Name)
    }
}


function Register-DMObjectCategory
{
<#
    .SYNOPSIS
        Registers a new object category.
     
    .DESCRIPTION
        Registers a new object category.
        Object categories are a way to apply settings to a type of object based on a ruleset / filterset.
        For example, by registering an object category "Domain Controllers" (with appropriate filters / conditions),
        it becomes possible to define access rules that apply to all domain controllers, but not all computers.
         
        Note: Not all setting types support categories yet.
     
    .PARAMETER Name
        The name of the category. Must be unique.
        Will NOT be resolved.
     
    .PARAMETER ObjectClass
        The ObjectClass of the object.
        This is the AD attribute of the object.
        Each object category can only apply to one class of object, in order to protect system performance.
     
    .PARAMETER Property
        The properties needed for this category.
        This attribute is used to optimize object reetrieval in case of multiple categories applying to the same class of object.
     
    .PARAMETER TestScript
        Scriptblock used to determine, whether the input object is part of the category.
        Receives the AD object with the requested attributes as input object / argument.
     
    .PARAMETER Filter
        A filter used to find all objects in AD that match this category.
     
    .PARAMETER LdapFilter
        An LDAP filter used to find all objects in AD that match this category.
     
    .PARAMETER SearchBase
        The path under which to look for objects of this category.
        Defaults to domain wide.
        Supports string resolution.
     
    .PARAMETER SearchScope
        How deep to search for objects of this category under the chosen searchbase.
        Supported Values:
        - Subtree: All items under the searchbase. (default)
        - OneLevel: All items directly under the searchbase.
        - Base: Only the searchbase itself is inspected.
 
    .PARAMETER ContextName
        The name of the context defining the setting.
        This allows determining the configuration set that provided this setting.
        Used by the ADMF, available to any other configuration management solution.
     
    .EXAMPLE
        PS C:\> Register-DMObjectCategory -Name DomainController -ObjectClass computer -Property PrimaryGroupID -TestScript { $args[0].PrimaryGroupID -eq 516 } -LDAPFilter '(&(objectCategory=computer)(primaryGroupID=516))'
         
        Registers an object category applying to all domain controller's computer object in AD.
#>

    [CmdletBinding(DefaultParameterSetName = 'Filter')]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ObjectClass,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Property,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [scriptblock]
        $TestScript,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Filter')]
        [string]
        $Filter,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'LdapFilter')]
        [string]
        $LdapFilter,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $SearchBase = '%DomainDN%',
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('Subtree', 'OneLevel', 'Base')]
        [string]
        $SearchScope = 'Subtree',
        
        [string]
        $ContextName = '<Undefined>'
    )
    
    process
    {
        $script:objectCategories[$Name] = [PSCustomObject]@{
            Name        = $Name
            ObjectClass = $ObjectClass
            Property    = $Property
            TestScript  = $TestScript
            Filter        = $Filter
            LdapFilter  = $LdapFilter
            SearchBase  = $SearchBase
            SearchScope = $SearchScope
            ContextName = $ContextName
        }
    }
}


function Resolve-DMObjectCategory
{
    <#
    .SYNOPSIS
        Resolves what object categories apply to a given AD Object.
     
    .DESCRIPTION
        Resolves what object categories apply to a given AD Object.
     
    .PARAMETER ADObject
        The AD Object for which to resolve the object categories.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Resolve-DMObjectCategory @parameters -ADObject $adobject
 
        Resolves the object categories that apply to $adobject
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        $ADObject,
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
    }
    process
    {
        if ($script:objectCategories.Values.ObjectClass -notcontains $ADObject.ObjectClass)
        {
            return
        }
        
        $filteredObjectCategories = $script:objectCategories.Values | Where-Object ObjectClass -eq $ADobject.ObjectClass
        $propertyNames = $filteredObjectCategories.Property | Select-Object -Unique
        $adObjectReloaded = Get-Adobject @parameters -Identity $ADObject.DistinguishedName -Properties $propertyNames
        :main foreach ($filteredObjectCategory in $filteredObjectCategories)
        {
            #region Consider Searchbase
            $resolvedBase = Resolve-String -Text $filteredObjectCategory.SearchBase @parameters
            switch ($filteredObjectCategory.SearchScope)
            {
                'Base' { if ($adObjectReloaded.DistinguishedName -ne $resolvedBase) { continue main } }
                'OneLevel'
                {
                    if ($adObjectReloaded.DistinguishedName -notlike "*,$resolvedBase") { continue main }
                    if (($adObjectReloaded.DistinguishedName -split ",").Count -ne (($resolvedBase -split ",").Count + 1)) { continue main }
                }
                'Subtree' { if ($adObjectReloaded.DistinguishedName -notlike "*,$resolvedBase") { continue main } }
            }
            #endregion Consider Searchbase
            if ($filteredObjectCategory.Testscript.Invoke($adObjectReloaded))
            {
                $filteredObjectCategory
            }
        }
    }
}


function Unregister-DMObjectCategory
{
    <#
    .SYNOPSIS
        Removes an object category from the list of registered object categories.
     
    .DESCRIPTION
        Removes an object category from the list of registered object categories.
        See description on Register-DMObjectCategory for details on object categories in general.
     
    .PARAMETER Name
        The exact name of the object category to unregister.
     
    .EXAMPLE
        PS C:\> Get-DMObjectCategory | Unregister-DMObjectCategory
 
        Clears all registered object categories.
    #>

    [CmdletBinding()]
    Param (
        [parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameItem in $Name) {
            $script:objectCategories.Remove($nameItem)
        }
    }
}


function Get-DMOrganizationalUnit
{
    <#
    .SYNOPSIS
        Returns the list of configured Organizational Units.
     
    .DESCRIPTION
        Returns the list of configured Organizational Units.
        Does not in any way retrieve data from a domain.
        The returned list of OUs represent the desired state for each domain of the current context.
     
    .PARAMETER Name
        Name of the OU to filter by.
     
    .PARAMETER Path
        Path of the OU to filter by.
     
    .EXAMPLE
        PS C:\> Get-DMOrganizationalUnit
 
        Return all configured OUs.
    #>

    [CmdletBinding()]
    param (
        [string]
        $Name = '*',

        [string]
        $Path = '*'
    )
    
    process
    {
        ($script:organizationalUnits.Values | Where-Object Name -like $Name | Where-Object Path -like $Path)
    }
}


function Invoke-DMOrganizationalUnit
{
    <#
    .SYNOPSIS
        Updates the organizational units of a domain to be compliant with the desired state.
     
    .DESCRIPTION
        Updates the organizational units of a domain to be compliant with the desired state.
        Use Register-DMOrganizationalUnit to define a desired state before using this command.
        Use Test-DMorganizationalUnit to receive details about the changes it will perform.
     
    .PARAMETER Delete
        Implement deletion commands.
        By default, when updating an existing deployment you would need to creaate missing OUs first, then move other objects and only delete OUs as the final step.
        In order to prevent accidents, by default NO OUs will be deleted.
        To enable OU deletion, you must specify this parameter.
        This parameter allows you to call it twice in your workflow: Once to prepare it for other objects, and another time to do the cleanup.
     
    .PARAMETER InputObject
        Test results provided by the associated test command.
        Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMOrganizationalUnit -Server contoso.com
 
        Brings the domain contoso.com into OU compliance.
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,
        
        [switch]
        $Delete,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type OrganizationalUnits -Cmdlet $PSCmdlet
        $everyone = ([System.Security.Principal.SecurityIdentifier]'S-1-1-0').Translate([System.Security.Principal.NTAccount])
        Set-DMDomainContext @parameters
    }
    process
    {
        #region Sort Script
        $sortScript = {
            if ($_.Type -eq 'ShouldDelete') { $_.ADObject.DistinguishedName.Split(",").Count }
            else { 1000 - $_.Identity.Split(",").Count }
        }
        #endregion Sort Script
        if (-not $InputObject) {
            $InputObject = Test-DMOrganizationalUnit @parameters | Sort-Object $sortScript -Descending
        }
        
        :main foreach ($testItem in $InputObject) {
            # Catch invalid input - can only process test results
            if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.OrganizationalUnit.TestResult') {
                Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMOrganizationalUnit', $testItem -Target $testItem -Continue -EnableException $EnableException
            }
            
            switch ($testItem.Type) {
                'Delete' {
                    if (-not $Delete) {
                        Write-PSFMessage -String 'Invoke-DMOrganizationalUnit.OU.Delete.NoAction' -StringValues $testItem.Identity -Target $testItem
                        continue main
                    }
                    $childObjects = Get-ADObject @parameters -SearchBase $testItem.ADObject.DistinguishedName -LDAPFilter '(!(objectCategory=OrganizationalUnit))'
                    if ($childObjects) {
                        Write-PSFMessage -Level Warning -String 'Invoke-DMOrganizationalUnit.OU.Delete.HasChildren' -StringValues $testItem.ADObject.DistinguishedName, ($childObjects | Measure-Object).Count -Target $testItem -Tag 'ou','critical','panic'
                        continue main
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMOrganizationalUnit.OU.Delete' -Target $testItem -ScriptBlock {
                        # Remove "Protect from accidental deletion" if neccessary
                        if ($accidentProtectionRule = ($testItem.ADObject.nTSecurityDescriptor.Access | Where-Object { ($_.IdentityReference -eq $everyone) -and ($_.AccessControlType -eq 'Deny') }))
                        {
                            $null = $testItem.ADObject.nTSecurityDescriptor.RemoveAccessRule($accidentProtectionRule)
                            Set-ADObject @parameters -Identity $testItem.ADObject.DistinguishedName -Replace @{ nTSecurityDescriptor = $testItem.ADObject.nTSecurityDescriptor } -ErrorAction Stop -Confirm:$false
                        }
                        Remove-ADOrganizationalUnit @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Create' {
                    $targetOU = Resolve-String -Text $testItem.Configuration.Path
                    try { $null = Get-ADObject @parameters -Identity $targetOU -ErrorAction Stop }
                    catch { Stop-PSFFunction -String 'Invoke-DMOrganizationalUnit.OU.Create.OUExistsNot' -StringValues $targetOU, $testItem.Identity -Target $testItem -EnableException $EnableException -Continue -ContinueLabel main }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMOrganizationalUnit.OU.Create' -Target $testItem -ScriptBlock {
                        $newParameters = $parameters.Clone()
                        $newParameters += @{
                            Name = (Resolve-String -Text $testItem.Configuration.Name)
                            Description = (Resolve-String -Text $testItem.Configuration.Description)
                            Path = $targetOU
                            Confirm = $false
                        }
                        New-ADOrganizationalUnit @newParameters -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'MultipleOldOUs' {
                    Stop-PSFFunction -String 'Invoke-DMOrganizationalUnit.OU.MultipleOldOUs' -StringValues $testItem.Identity, ($testItem.ADObject.Name -join ', ') -Target $testItem -EnableException $EnableException -Continue -Tag 'ou','critical','panic'
                }
                'Rename' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMOrganizationalUnit.OU.Rename' -ActionStringValues (Resolve-String -Text $testItem.Configuration.Name) -Target $testItem -ScriptBlock {
                        Rename-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -NewName (Resolve-String -Text $testItem.Configuration.Name) -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Update' {
                    $changes = @{ }
                    if ($change = $testItem.Changed | Where-Object Property -eq 'Description') {
                        $changes['Description'] = $change.New
                    }
                    
                    if ($changes.Keys.Count -gt 0)
                    {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMOrganizationalUnit.OU.Update' -ActionStringValues ($changes.Keys -join ", ") -Target $testItem -ScriptBlock {
                            $null = Set-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Replace $changes -Confirm:$false
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                }
            }
        }
    }
    end
    {
        # Reset Content Searchbases
        $script:contentSearchBases = [PSCustomObject]@{
            Include = @()
            Exclude = @()
            Bases   = @()
            Server = ''
        }
    }
}


function Register-DMOrganizationalUnit {
    <#
    .SYNOPSIS
        Registers an organizational unit, defining it as a desired state.
     
    .DESCRIPTION
        Registers an organizational unit, defining it as a desired state.
     
    .PARAMETER Name
        Name of the OU to register.
        Subject to string insertion.
     
    .PARAMETER Description
        Description for the OU to register.
        Subject to string insertion.
     
    .PARAMETER Path
        The path to where the OU should be.
        Subject to string insertion.
 
    .PARAMETER Optional
        By default, organizational units must exist if defined.
        Setting this to true makes them optional instead - they will not be created but are tolerated if they exist.
     
    .PARAMETER OldNames
        Previous names the OU had.
        During invocation, if it is not found but an OU in the same path with a listed old name IS, it will be renamed.
        Subject to string insertion.
     
    .PARAMETER Present
        Whether the OU should be present.
        Defaults to $true
     
    .EXAMPLE
        PS C:\> Get-Content .\organizationalUnits.json | ConvertFrom-Json | Write-Output | Register-DMOrganizationalUnit
 
        Reads a json configuration file containing a list of objects with appropriate properties to import them as organizational unit configuration.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [AllowEmptyString()]
        [string]
        $Description,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Path,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $Optional,

        [string[]]
        $OldNames = @(),

        [bool]
        $Present = $true
    )
    
    process {
        $distinguishedName = 'OU={0},{1}' -f $Name, $Path
        $script:organizationalUnits[$distinguishedName] = [PSCustomObject]@{
            PSTypeName        = 'DomainManagement.OrganizationalUnit'
            DistinguishedName = $distinguishedName
            Name              = $Name
            Description       = $Description
            Path              = $Path
            Optional          = $Optional
            OldNames          = $OldNames
            Present           = $Present
        }
    }
}


function Test-DMOrganizationalUnit
{
    <#
        .SYNOPSIS
            Tests whether the configured OrganizationalUnit match a domain's configuration.
         
        .DESCRIPTION
            Tests whether the configured OrganizationalUnit match a domain's configuration.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
         
        .EXAMPLE
            PS C:\> Test-DMOrganizationalUnit
 
            Tests whether the configured OrganizationalUnits' state matches the current domain OrganizationalUnit setup.
    #>

    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type OrganizationalUnits -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process
    {
        #region Process Configured OUs
        :main foreach ($ouDefinition in $script:organizationalUnits.Values) {
            $resolvedDN = Resolve-String -Text $ouDefinition.DistinguishedName

            $resultDefaults = @{
                Server = $Server
                ObjectType = 'OrganizationalUnit'
                Identity = $resolvedDN
                Configuration = $ouDefinition
            }

            if (-not $ouDefinition.Present) {
                if ($adObject = Get-ADOrganizationalUnit @parameters -LDAPFilter "(distinguishedName=$resolvedDN)" -Properties Description, nTSecurityDescriptor) {
                    New-TestResult @resultDefaults -Type Delete -ADObject $adObject
                }
                continue main
            }
            
            #region Case: Does not exist
            if (-not (Test-ADObject @parameters -Identity $resolvedDN)) {
                $oldNamedOUs = foreach ($oldDN in ($ouDefinition.OldNames | Resolve-String)) {
                    foreach ($adOrgUnit in (Get-ADOrganizationalUnit @parameters -LDAPFilter "(distinguishedName=$oldDN)" -Properties Description, nTSecurityDescriptor)) {
                        $adOrgUnit
                    }
                }

                switch (($oldNamedOUs | Measure-Object).Count) {
                    #region Case: No old version present
                    0
                    {
                        if (-not $ouDefinition.Optional) {
                            New-TestResult @resultDefaults -Type Create
                        }
                        continue main
                    }
                    #endregion Case: No old version present

                    #region Case: One old version present
                    1
                    {
                        New-TestResult @resultDefaults -Type Rename -ADObject $oldNamedOUs
                        continue main
                    }
                    #endregion Case: One old version present

                    #region Case: Too many old versions present
                    default
                    {
                        New-TestResult @resultDefaults -Type MultipleOldOUs -ADObject $oldNamedOUs
                        continue main
                    }
                    #endregion Case: Too many old versions present
                }
            }
            #endregion Case: Does not exist

            $adObject = Get-ADOrganizationalUnit @parameters -Identity $resolvedDN -Properties Description, nTSecurityDescriptor
            
            [System.Collections.ArrayList]$changes = @()
            Compare-Property -Property Description -Configuration $ouDefinition -ADObject $adObject -Changes $changes -Resolve -AsUpdate -Type OrganizationalUnit

            if ($changes.Count) {
                New-TestResult @resultDefaults -Type Update -Changed $changes.ToArray() -ADObject $adObject
            }
        }
        #endregion Process Configured OUs

        #region Process Managed Containers
        $foundOUs = foreach ($searchBase in (Resolve-ContentSearchBase @parameters -IgnoreMissingSearchbase)) {
            Get-ADOrganizationalUnit @parameters -LDAPFilter '(!(isCriticalSystemObject=TRUE))' -SearchBase $searchBase.SearchBase -SearchScope $searchBase.SearchScope -Properties nTSecurityDescriptor | Where-Object DistinguishedName -Ne $searchBase.SearchBase
        }

        $resolvedConfiguredNames = $script:organizationalUnits.Values.DistinguishedName | Resolve-String

        $resultDefaults = @{
            Server = $Server
            ObjectType = 'OrganizationalUnit'
        }

        foreach ($existingOU in $foundOUs) {
            if ($existingOU.DistinguishedName -in $resolvedConfiguredNames) { continue } # Ignore configured OUs - they were previously configured for moving them, if they should not be in these containers
            
            New-TestResult @resultDefaults -Type Delete -ADObject $existingOU -Identity $existingOU.DistinguishedName
        }
        #endregion Process Managed Containers
    }
}


function Unregister-DMOrganizationalUnit
{
    <#
    .SYNOPSIS
        Removes an organizational unit from the list of registered OUs.
     
    .DESCRIPTION
        Removes an organizational unit from the list of registered OUs.
        This effectively removes it from the definition of the desired OU state.
     
    .PARAMETER Name
        The name of the OU to unregister.
     
    .PARAMETER Path
        The path of the OU to unregister.
     
    .PARAMETER DistinguishedName
        The full Distinguished name of the OU to unregister.
     
    .EXAMPLE
        PS C:\> Get-DMOrganizationalUnit | Unregister-DMOrganizationalUnit
 
        Removes all registered organizational units from the configuration
    #>

    
    [CmdletBinding(DefaultParameterSetName = 'DN')]
    param (
        [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $true, ParameterSetName = 'NamePath')]
        [string]
        $Name,

        [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $true, ParameterSetName = 'NamePath')]
        [string]
        $Path,

        [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $true, ParameterSetName = 'DN')]
        [string]
        $DistinguishedName
    )
    
    process
    {
        if ($DistinguishedName) {
            $script:organizationalUnits.Remove($DistinguishedName)
        }
        if ($Name) {
            $distName = 'OU={0},{1}' -f $Name, $Path
            $script:organizationalUnits.Remove($distName)
        }
    }
}


function Convert-DMSchemaGuid
{
    <#
    .SYNOPSIS
        Converts names to guid and guids to name as defined in the active directory schema.
     
    .DESCRIPTION
        Converts names to guid and guids to name as defined in the active directory schema.
        Can handle both attributes as well as rights.
        Uses mapping data generated from active directory.
     
    .PARAMETER Name
        The name to convert. Can be both string or guid.
     
    .PARAMETER OutType
        The data tape to emit:
        - Name: Humanly readable name
        - Guid: Guid object
        - GuidString: Guid as a string
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Convert-DMSchemaGuid -Name Public-Information -OutType GuidString
 
        Converts the right "Public-Information" into its guid representation (guid returned as a string type)
    #>

    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true)]
        [Alias('Guid')]
        [string[]]
        $Name,

        [ValidateSet('Name', 'Guid', 'GuidString')]
        [string]
        $OutType = 'Guid',

        [PSFComputer]
        $Server,

        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false

        $guidToName = Get-SchemaGuidMapping @parameters
        $nameToGuid = Get-SchemaGuidMapping @parameters -NameToGuid
        $guidToRight = Get-PermissionGuidMapping @parameters
        $rightToGuid = Get-PermissionGuidMapping @parameters -NameToGuid
    }
    process
    {
        :main foreach ($nameString in $Name) {
            switch ($OutType) {
                'Name'
                {
                    if ($nameString -as [Guid]) {
                        if ($guidToName[$nameString]) {
                            $guidToName[$nameString]
                            continue main
                        }
                        if ($guidToRight[$nameString]) {
                            $guidToRight[$nameString]
                            continue main
                        }
                    }
                    else { $nameString }
                }
                'Guid'
                {
                    if ($nameString -as [Guid]) {
                        $nameString -as [Guid]
                        continue main
                    }
                    if ($nameToGuid[$nameString]) {
                        $nameToGuid[$nameString] -as [guid]
                        continue main
                    }
                    if ($rightToGuid[$nameString]) {
                        $rightToGuid[$nameString] -as [guid]
                        continue main
                    }
                }
                'GuidString'
                {
                    if ($nameString -as [Guid]) {
                        $nameString
                        continue main
                    }
                    if ($nameToGuid[$nameString]) {
                        $nameToGuid[$nameString]
                        continue main
                    }
                    if ($rightToGuid[$nameString]) {
                        $rightToGuid[$nameString]
                        continue main
                    }
                }
            }
        }
    }
}


function Get-DMObjectDefaultPermission
{
    <#
    .SYNOPSIS
        Gathers the default object permissions in AD.
     
    .DESCRIPTION
        Gathers the default object permissions in AD.
        Uses PowerShell remoting against the SchemaMaster to determine the default permissions, as local identity resolution is not reliable.
     
    .PARAMETER ObjectClass
        The object class to look up.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-DMObjectDefaultPermission -ObjectClass user
 
        Returns the default permissions for a user.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingEmptyCatchBlock', '')]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $ObjectClass,

        [PSFComputer]
        $Server = '<Default>',

        [PSCredential]
        $Credential
    )
    
    begin
    {
        if (-not $script:schemaObjectDefaultPermission) {
            $script:schemaObjectDefaultPermission = @{ }
        }

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential

        #region Scriptblock that gathers information on default permission
        $gatherScript = {
            #$domain = Get-ADDomain -Server localhost
            #$forest = Get-ADForest -Server localhost
            #$rootDomain = Get-ADDomain -Server $forest.RootDomain
            $commonAce = @()
            <#
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '4c164200-20c0-11d0-a768-00aa006e0529', 'Descendents', '4828cc14-1437-45bc-9b07-ad6f015e5f28')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', 'bc0ac240-79a9-11d0-9020-00c04fc2d4cf', 'Descendents', '4828cc14-1437-45bc-9b07-ad6f015e5f28')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', 'bc0ac240-79a9-11d0-9020-00c04fc2d4cf', 'Descendents', 'bf967aba-0de6-11d0-a285-00aa003049e2')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '59ba2f42-79a2-11d0-9020-00c04fc2d3cf', 'Descendents', '4828cc14-1437-45bc-9b07-ad6f015e5f28')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '59ba2f42-79a2-11d0-9020-00c04fc2d3cf', 'Descendents', 'bf967aba-0de6-11d0-a285-00aa003049e2')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '037088f8-0ae1-11d2-b422-00a0c968f939', 'Descendents', '4828cc14-1437-45bc-9b07-ad6f015e5f28')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '037088f8-0ae1-11d2-b422-00a0c968f939', 'Descendents', 'bf967aba-0de6-11d0-a285-00aa003049e2')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '4c164200-20c0-11d0-a768-00aa006e0529', 'Descendents', 'bf967aba-0de6-11d0-a285-00aa003049e2')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '5f202010-79a5-11d0-9020-00c04fc2d4cf', 'Descendents', 'bf967aba-0de6-11d0-a285-00aa003049e2')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ReadProperty', 'Allow', '5f202010-79a5-11d0-9020-00c04fc2d4cf', 'Descendents', '4828cc14-1437-45bc-9b07-ad6f015e5f28')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'GenericRead', 'Allow', '00000000-0000-0000-0000-000000000000', 'Descendents', 'bf967aba-0de6-11d0-a285-00aa003049e2')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'GenericRead', 'Allow', '00000000-0000-0000-0000-000000000000', 'Descendents', 'bf967a9c-0de6-11d0-a285-00aa003049e2')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'GenericRead', 'Allow', '00000000-0000-0000-0000-000000000000', 'Descendents', '4828cc14-1437-45bc-9b07-ad6f015e5f28')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'BUILTIN\Pre-Windows 2000 Compatible Access'), 'ListChildren', 'Allow', '00000000-0000-0000-0000-000000000000', 'All', '00000000-0000-0000-0000-000000000000')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]"$($domain.NetBIOSName)\Key Admins"), 'ReadProperty, WriteProperty', 'Allow', '5b47d60f-6090-40b2-9f37-2a4de88f3063', 'All', '00000000-0000-0000-0000-000000000000')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]"$($rootDomain.NetBIOSName)\Enterprise Key Admins"), 'ReadProperty, WriteProperty', 'Allow', '5b47d60f-6090-40b2-9f37-2a4de88f3063', 'All', '00000000-0000-0000-0000-000000000000')
            $commonAce += New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]"$($rootDomain.NetBIOSName)\Enterprise Admins"), 'GenericAll', 'Allow', '00000000-0000-0000-0000-000000000000', 'All', '00000000-0000-0000-0000-000000000000')
            #>

            $parameters = @{ Server = $env:COMPUTERNAME }
            $rootDSE = Get-ADRootDSE @parameters
            $classes = Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter '(objectCategory=classSchema)' -Properties defaultSecurityDescriptor, lDAPDisplayName
            foreach ($class in $classes) {
                $acl = [System.DirectoryServices.ActiveDirectorySecurity]::new()
                $acl.SetSecurityDescriptorSddlForm($class.defaultSecurityDescriptor)
                foreach ($rule in $commonAce) { $acl.AddAccessRule($rule) }
                
                <#
                if ($class.lDAPDisplayName -eq 'organizationalUnit') {
                    $acl.AddAccessRule((New-Object System.DirectoryServices.ActiveDirectoryAccessRule(([System.Security.Principal.NTAccount]'Everyone'), 'DeleteTree, Delete', 'Deny', '00000000-0000-0000-0000-000000000000', 'None', '00000000-0000-0000-0000-000000000000')))
                }
                #>

                $access = foreach ($accessRule in $acl.Access) {
                    try { Add-Member -InputObject $accessRule -MemberType NoteProperty -Name SID -Value $accessRule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) }
                    catch {
                        # Do nothing, don't want the property if no SID is to be had
                    }
                    $accessRule
                }
                [PSCustomObject]@{
                    Class = $class.lDAPDisplayName
                    Access = $access
                }
            }
        }
        #endregion Scriptblock that gathers information on default permission
    }
    process
    {
        if ($script:schemaObjectDefaultPermission["$Server"]) {
            return $script:schemaObjectDefaultPermission["$Server"].$ObjectClass
        }

        #region Process Gathering logic
        if ($Server -ne '<Default>') {
            $parameters['ComputerName'] = $parameters.Server
            $parameters.Remove("Server")
        }
        
        try { $data = Invoke-PSFCommand @parameters -ScriptBlock $gatherScript -ErrorAction Stop }
        catch { throw }
        $script:schemaObjectDefaultPermission["$Server"] = @{ }
        foreach ($datum in $data) {
            $script:schemaObjectDefaultPermission["$Server"][$datum.Class] = $datum.Access
        }
        $script:schemaObjectDefaultPermission["$Server"].$ObjectClass
        #endregion Process Gathering logic
    }
}

function Register-DMBuiltInSID
{
    <#
    .SYNOPSIS
        Register a name that points at a well-known SID.
     
    .DESCRIPTION
        Register a name that points at a well-known SID.
        This is used to reliably be able to compare access rules where built-in SIDs fail (e.g. for Sub-Domains).
        This functionality is exposed, in order to be able to resolve these identities, irrespective of name resolution and localization.
     
    .PARAMETER Name
        The name of the builtin entity to map.
     
    .PARAMETER SID
        The SID associated with the builtin entity.
     
    .EXAMPLE
        PS C:\> Register-DMBuiltInSID -Name 'BUILTIN\Incoming Forest Trust Builders' -SID 'S-1-5-32-557'
 
        Maps the group 'BUILTIN\Incoming Forest Trust Builders' to the SID 'S-1-5-32-557'
        Note: This mapping is pre-defined in the module and needs not be applied
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true, Position =1, ValueFromPipelineByPropertyName = $true)]
        [System.Security.Principal.SecurityIdentifier]
        $SID
    )
    
    process
    {
        $script:builtInSidMapping[$Name] = $SID
    }
}


function Get-DMPasswordPolicy
{
    <#
    .SYNOPSIS
        Returns the list of configured Finegrained Password policies defined as the desired state.
     
    .DESCRIPTION
        Returns the list of configured Finegrained Password policies defined as the desired state.
     
    .PARAMETER Name
        The name of the password policy to filter by.
     
    .EXAMPLE
        PS C:\> Get-DMPasswordPolicy
 
        Returns all defined PSO objects.
    #>

    
    [CmdletBinding()]
    param (
        [string]
        $Name = '*'
    )
    
    process
    {
        ($script:passwordPolicies.Values | Where-Object Name -like $Name)
    }
}


function Invoke-DMPasswordPolicy {
    <#
    .SYNOPSIS
        Applies the defined, desired state for finegrained password policies (PSOs)
     
    .DESCRIPTION
        Applies the defined, desired state for finegrained password policies (PSOs)
        Define the desired state using Register-DMPasswordPolicy.
     
    .PARAMETER InputObject
        Test results provided by the associated test command.
        Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Invoke-DMPasswordPolicy
 
        Applies the currently defined baseline for password policies to the current domain.
    #>

    
    [CmdletBinding(SupportsShouldProcess = $true)]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,
        
        [switch]
        $EnableException
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type PasswordPolicies -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process {
        if (-not $InputObject) {
            $InputObject = Test-DMPasswordPolicy @parameters
        }
        
        foreach ($testItem in $InputObject) {
            # Catch invalid input - can only process test results
            if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.PSO.TestResult') {
                Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMPasswordPolicy', $testItem -Target $testItem -Continue -EnableException $EnableException
            }
            
            switch ($testItem.Type) {
                #region Delete
                'Delete' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMPasswordPolicy.PSO.Delete' -Target $testItem -ScriptBlock {
                        Remove-ADFineGrainedPasswordPolicy @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                #endregion Delete
                
                #region Create
                'Create' {
                    
                    $parametersNew = $parameters.Clone()
                    $parametersNew += @{
                        Name = (Resolve-String -Text $testItem.Configuration.Name)
                        Precedence = $testItem.Configuration.Precedence
                        ComplexityEnabled = $testItem.Configuration.ComplexityEnabled
                        LockoutDuration = $testItem.Configuration.LockoutDuration
                        LockoutObservationWindow = $testItem.Configuration.LockoutObservationWindow
                        LockoutThreshold = $testItem.Configuration.LockoutThreshold
                        MaxPasswordAge = $testItem.Configuration.MaxPasswordAge
                        MinPasswordAge = $testItem.Configuration.MinPasswordAge
                        MinPasswordLength = $testItem.Configuration.MinPasswordLength
                        DisplayName = (Resolve-String -Text $testItem.Configuration.DisplayName)
                        Description = (Resolve-String -Text $testItem.Configuration.Description)
                        PasswordHistoryCount = $testItem.Configuration.PasswordHistoryCount
                        ReversibleEncryptionEnabled = $testItem.Configuration.ReversibleEncryptionEnabled
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMPasswordPolicy.PSO.Create' -Target $testItem -ScriptBlock {
                        $adObject = New-ADFineGrainedPasswordPolicy @parametersNew -ErrorAction Stop -PassThru
                        Add-ADFineGrainedPasswordPolicySubject @parameters -Identity $adObject -Subjects (Resolve-String -Text $testItem.Configuration.SubjectGroup)
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                #endregion Create
                
                #region Changed
                'Update' {
                    $changes = @{ }
                    $updateAssignment = $false
                    
                    foreach ($change in $testItem.Changed) {
                        switch ($change.Property) {
                            'SubjectGroup' { $updateAssignment = $true }
                            default { $changes[$change.Property] = $change.New }
                        }
                    }
                    
                    if ($changes.Keys.Count -gt 0) {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMPasswordPolicy.PSO.Update' -ActionStringValues ($changes.Keys -join ", ") -Target $testItem -ScriptBlock {
                            $parametersUpdate = $parameters.Clone()
                            $parametersUpdate += $changes
                            $null = Set-ADFineGrainedPasswordPolicy -Identity $testItem.ADObject.ObjectGUID @parametersUpdate -ErrorAction Stop -Confirm:$false
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                    
                    if ($updateAssignment) {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMPasswordPolicy.PSO.Update.GroupAssignment' -ActionStringValues (Resolve-String -Text $testItem.Configuration.SubjectGroup) -Target $testItem -ScriptBlock {
                            if ($testItem.ADObject.AppliesTo) {
                                Remove-ADFineGrainedPasswordPolicySubject @parameters -Identity $testItem.ADObject.ObjectGUID -Subjects $testItem.ADObject.AppliesTo -ErrorAction Stop -Confirm:$false
                            }
                            $null = Add-ADFineGrainedPasswordPolicySubject @parameters -Identity $testItem.ADObject.ObjectGUID -Subjects (Resolve-String -Text $testItem.Configuration.SubjectGroup) -ErrorAction Stop
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                }
                #endregion Changed
            }
        }
    }
}


function Register-DMPasswordPolicy
{
    <#
    .SYNOPSIS
        Register a new Finegrained Password Policy as the desired state.
     
    .DESCRIPTION
        Register a new Finegrained Password Policy as the desired state.
        These policies are then compared to the current state in a domain.
     
    .PARAMETER Name
        The name of the PSO.
     
    .PARAMETER DisplayName
        The display name of the PSO.
     
    .PARAMETER Description
        The description for the PSO.
     
    .PARAMETER Precedence
        The precedence rating of the PSO.
        The lower the precedence number, the higher the priority.
     
    .PARAMETER MinPasswordLength
        The minimum number of characters a password must have.
     
    .PARAMETER SubjectGroup
        The group that the PSO should be assigned to.
     
    .PARAMETER LockoutThreshold
        How many bad password entries will lead to account lockout?
     
    .PARAMETER MaxPasswordAge
        The maximum age a password may have before it must be changed.
     
    .PARAMETER ComplexityEnabled
        Whether complexity rules are applied to users affected by this policy.
        By default, complexity rules requires 3 out of: "Lowercase letter", "Uppercase letter", "number", "special character".
        However, custom password filters may lead to very validation rules.
     
    .PARAMETER LockoutDuration
        If the account is being locked out, how long will the lockout last.
     
    .PARAMETER LockoutObservationWindow
        What is the time window before the bad password count is being reset.
     
    .PARAMETER MinPasswordAge
        How soon may a password be changed again after updating the password.
     
    .PARAMETER PasswordHistoryCount
        How many passwords are kept in memory to prevent going back to a previous password.
     
    .PARAMETER ReversibleEncryptionEnabled
        Whether the password should be stored in a manner that allows it to be decrypted into cleartext.
        By default, only un-reversible hashes are being stored.
     
    .PARAMETER SubjectDomain
        The domain the group is part of.
        Defaults to the target domain.
     
    .PARAMETER Present
        Whether the PSO should exist.
        Defaults to $true.
        If this is set to $false, no PSO will be created, instead the PSO will be removed if it exists.
     
    .EXAMPLE
        PS C:\> Get-Content $configPath | ConvertFrom-Json | Write-Output | Register-DMPasswordPolicy
 
        Imports all the configured policies from the defined config json file.
    #>

    
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $DisplayName,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Description,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [int]
        $Precedence,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [int]
        $MinPasswordLength,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $SubjectGroup,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [int]
        $LockoutThreshold,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [PSFTimespan]
        $MaxPasswordAge,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $ComplexityEnabled = $true,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [PSFTimespan]
        $LockoutDuration = '1h',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [PSFTimespan]
        $LockoutObservationWindow = '1h',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [PSFTimespan]
        $MinPasswordAge = '30m',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [int]
        $PasswordHistoryCount = 24,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $ReversibleEncryptionEnabled = $false,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $SubjectDomain = '%DomainFqdn%',

        [bool]
        $Present = $true
    )
    
    process
    {
        $script:passwordPolicies[$Name] = [PSCustomObject]@{
            PSTypeName = 'DomainManagement.PasswordPolicy'
            Name = $Name
            Precedence = $Precedence
            ComplexityEnabled = $ComplexityEnabled
            LockoutDuration = $LockoutDuration.Value
            LockoutObservationWindow = $LockoutObservationWindow.Value
            LockoutThreshold = $LockoutThreshold
            MaxPasswordAge = $MaxPasswordAge.Value
            MinPasswordAge = $MinPasswordAge.Value
            MinPasswordLength = $MinPasswordLength
            DisplayName = $DisplayName
            Description = $Description
            PasswordHistoryCount = $PasswordHistoryCount
            ReversibleEncryptionEnabled = $ReversibleEncryptionEnabled
            SubjectDomain = $SubjectDomain
            SubjectGroup = $SubjectGroup
            Present = $Present
        }
    }
}


function Test-DMPasswordPolicy
{
    <#
    .SYNOPSIS
        Tests, whether the deployed PSOs match the desired PSOs.
     
    .DESCRIPTION
        Tests, whether the deployed PSOs match the desired PSOs.
        Use Register-DMPasswordPolicy to define the desired PSOs.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Test-DMPasswordPolicy -Server contoso.com
 
        Checks, whether the contoso.com domain's password policies match the desired state.
    #>

    
    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type PasswordPolicies -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process
    {
        :main foreach ($psoDefinition in $script:passwordPolicies.Values) {
            $resolvedName = Resolve-String -Text $psoDefinition.Name

            $resultDefaults = @{
                Server = $Server
                ObjectType = 'PSO'
                Identity = $resolvedName
                Configuration = $psoDefinition
            }

            #region Password Policy that needs to be removed
            if (-not $psoDefinition.Present) {
                try { $adObject = Get-ADFineGrainedPasswordPolicy @parameters -Identity $resolvedName -Properties DisplayName, Description -ErrorAction Stop }
                catch { continue main } # Only errors when PSO not present = All is well
                
                New-TestResult @resultDefaults -Type Delete -ADObject $adObject
                continue
            }
            #endregion Password Policy that needs to be removed

            #region Password Policies that don't exist but should : $adObject
            try { $adObject = Get-ADFineGrainedPasswordPolicy @parameters -Identity $resolvedName -Properties Description, DisplayName -ErrorAction Stop }
            catch
            {
                New-TestResult @resultDefaults -Type Create
                continue main
            }
            #endregion Password Policies that don't exist but should : $adObject

            [System.Collections.ArrayList]$changes = @()
            $compare = @{
                Configuration = $psoDefinition
                ADObject = $adObject
                Changes = $changes
                Type = 'PSO'
                AsUpdate = $true
            }
            Compare-Property @compare -Property ComplexityEnabled
            Compare-Property @compare -Property Description -Resolve
            Compare-Property @compare -Property DisplayName -Resolve
            Compare-Property @compare -Property LockoutDuration -Resolve
            Compare-Property @compare -Property LockoutObservationWindow
            Compare-Property @compare -Property LockoutThreshold
            Compare-Property @compare -Property MaxPasswordAge
            Compare-Property @compare -Property MinPasswordAge
            Compare-Property @compare -Property MinPasswordLength
            Compare-Property @compare -Property PasswordHistoryCount
            Compare-Property @compare -Property Precedence
            Compare-Property @compare -Property ReversibleEncryptionEnabled
            $groupObjects = foreach ($groupName in $psoDefinition.SubjectGroup) {
                try { Get-ADGroup @parameters -Identity (Resolve-String -Text $groupName) }
                catch { Write-PSFMessage -Level Warning -String 'Test-DMPasswordPolicy.SubjectGroup.NotFound' -StringValues $groupName, $resolvedName }
            }
            if (-not $groupObjects -or -not $adObject.AppliesTo -or (Compare-Object $groupObjects.DistinguishedName $adObject.AppliesTo)) {
                $null = $changes.Add((New-Change -Property 'SubjectGroup' -OldValue $adObject.AppliesTo -NewValue $groupObjects -Identity $adObject.DistinguishedName -Type PSO))
            }

            if ($changes.Count) {
                New-TestResult @resultDefaults -Type Update -Changed $changes.ToArray() -ADObject $adObject
            }
        }

        $passwordPolicies = Get-ADFineGrainedPasswordPolicy @parameters -Filter *
        $resolvedPolicies = $script:passwordPolicies.Values.Name | Resolve-String

        $resultDefaults = @{
            Server = $Server
            ObjectType = 'PSO'
        }

        foreach ($passwordPolicy in $passwordPolicies) {
            if ($passwordPolicy.Name -in $resolvedPolicies) { continue }
            New-TestResult @resultDefaults -Type Delete -ADObject $passwordPolicy -Identity $passwordPolicy.Name
        }
    }
}


function Unregister-DMPasswordPolicy
{
    <#
    .SYNOPSIS
        Remove a PSO from the list of desired PSOs that are applied to a domain.
     
    .DESCRIPTION
        Remove a PSO from the list of desired PSOs that are applied to a domain.
     
    .PARAMETER Name
        The name of the PSO to remove.
     
    .EXAMPLE
        PS C:\> Unregister-DMPasswordPolicy -Name "T0 Admin Policy"
 
        Removes the "T0 Admin Policy" policy.
    #>

    
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($entry in $Name) {
            $script:passwordPolicies.Remove($entry)
        }
    }
}


function Get-DMServiceAccount
{
<#
    .SYNOPSIS
        List the configured service accounts.
     
    .DESCRIPTION
        List the configured service accounts.
     
    .PARAMETER Name
        Name to filter by.
        Defaults to '*'
     
    .EXAMPLE
        PS C:\> Get-DMServiceAccount
     
        List all configured service accounts.
#>

    [CmdletBinding()]
    Param (
        [string]
        $Name = '*'
    )
    process
    {
        $($script:serviceAccounts.Values | Where-Object Name -like $Name)
    }
}


function Invoke-DMServiceAccount {
<#
    .SYNOPSIS
        Applies the desired state of Service Accounts to the target domain.
     
    .DESCRIPTION
        Applies the desired state of Service Accounts to the target domain.
        Use Register-DMServiceAccount to define the desired state.
     
    .PARAMETER InputObject
        Individual test results to apply.
        Use Test-DMServiceAccount to generate these test result objects.
        If none are specified, it will instead execute its own test and apply all test results.
     
    .PARAMETER Server
        The server / domain to work with.
         
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMServiceAccount -Server fabrikam.org
     
        Brings the fabrikam.org domain into compliance with the defined service account configuration.
#>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,
        
        [switch]
        $EnableException
    )
    
    begin {
        #region Utility Functions
        function Get-ObjectCategoryContent {
            [CmdletBinding()]
            param (
                [System.Collections.Hashtable]
                $Categories,
                
                [string]
                $Name,
                
                [System.Collections.Hashtable]
                $Parameters
            )
            
            if (-not $Categories.ContainsKey($Name)) {
                $Categories[$Name] = (Find-DMObjectCategoryItem -Name $Name @parameters -Property SamAccountName).SamAccountName
            }
            $Categories[$Name]
        }
        
        function New-ServiceAccount {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                $TestItem,
                
                [System.Collections.Hashtable]
                $Parameters,
                
                [System.Collections.Hashtable]
                $Categories
            )
            
            $resolvedPath = $TestItem.Configuration.Path | Resolve-String @parameters
            
            $newParam = $Parameters.Clone()
            $newParam += @{
                Name = $TestItem.Configuration.Name | Resolve-String @parameters | Set-String -OldValue '\$$'
                SamAccountName = $TestItem.Configuration.Name | Resolve-String @parameters | Set-String -OldValue '\$$' | Add-String '' '$'
                DNSHostName = $TestItem.Configuration.DNSHostName | Resolve-String @parameters
                Description = $TestItem.Configuration.Description | Resolve-String @parameters
                KerberosEncryptionType = $TestItem.Configuration.KerberosEncryptionType
            }
            if ($TestItem.Configuration.ServicePrincipalName) { $newParam.ServicePrincipalNames = $TestItem.Configuration.ServicePrincipalName | Resolve-String @parameters }
            if ($TestItem.Configuration.DisplayName) { $newParam.DisplayName = $TestItem.Configuration.DisplayName | Resolve-String @parameters }
            if ($TestItem.Configuration.Attributes) { $newParam.OtherAttributes = $TestItem.Configuration.Attributes | ConvertTo-PSFHashtable }
            
            #region Calculate desired principals
            $desiredPrincipals = @()
            
            foreach ($category in $TestItem.Configuration.ObjectCategory) {
                Get-ObjectCategoryContent -Categories $Categories -Name $category -Parameters $Parameters | ForEach-Object {
                    $desiredPrincipals += $_
                }
            }
            
            # Direct Assignment
            foreach ($name in $TestItem.Configuration.ComputerName | Resolve-String @Parameters) {
                if ($name -notlike '*$') { $name = "$($name)$" }
                try {
                    $null = Get-ADComputer @Parameters -Identity $name -ErrorAction Stop
                    $desiredPrincipals += $name
                }
                catch {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMServiceAccount.Computer.NotFound' -StringValues $name, $resolvedName -Target $TestItem.Configuration -Tag error, failed, serviceaccount, computer
                    continue
                }
            }
            
            # Optional Direct Assignment
            foreach ($name in $TestItem.Configuration.ComputerNameOptional | Resolve-String @Parameters) {
                if ($name -notlike '*$') { $name = "$($name)$" }
                try {
                    $null = Get-ADComputer @Parameters -Identity $name -ErrorAction Stop
                    $desiredPrincipals += $name
                }
                catch {
                    Write-PSFMessage -Level Verbose -String 'Invoke-DMServiceAccount.Computer.Optional.NotFound' -StringValues $name, $resolvedName -Target $TestItem.Configuration -Tag error, failed, serviceaccount, computer
                    continue
                }
            }

            # Direct Assignment
            foreach ($name in $TestItem.Configuration.GroupName | Resolve-String @Parameters) {
                try {
                    $null = Get-ADGroup @Parameters -Identity $name -ErrorAction Stop
                    $desiredPrincipals += $name
                }
                catch {
                    Write-PSFMessage -Level Warning -String 'Invoke-DMServiceAccount.Group.NotFound' -StringValues $name, $resolvedName -Target $TestItem.Configuration -Tag error, failed, serviceaccount, computer
                    continue
                }
            }

            if ($desiredPrincipals) {
                $newParam.PrincipalsAllowedToRetrieveManagedPassword = $desiredPrincipals
            }
            #endregion Calculate desired principals
            
            New-ADServiceAccount @newParam -ErrorAction Stop -Confirm:$false -Path $resolvedPath
        }
        
        function Set-ServiceAccount {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                $TestItem,
                
                [System.Collections.Hashtable]
                $Parameters
            )
            
            $setParam = $Parameters.Clone()
            $properties = @{ }
            $clear = @()
            foreach ($change in $testItem.Changed) {
                if (-not $change.NewValue -and 0 -ne $change.NewValue) { $clear += $change.Property }
                elseif ($change.Property -eq 'KerberosEncryptionType') {
                    $setParam.KerberosEncryptionType = $change.NewValue
                }
                else { $properties[$change.Property] = $change.NewValue }
            }
            if ($properties.Count -gt 0) { $setParam.Replace = $properties }
            if ($clear) { $setParam.Clear = $clear }
            
            Set-ADServiceAccount @setParam -Identity $testItem.ADObject.ObjectGuid -Confirm:$false -ErrorAction Stop
        }
        #endregion Utility Functions
        
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type ServiceAccounts -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
        
        $categories = @{ }
    }
    process {
        if (-not $InputObject) {
            $InputObject = Test-DMServiceAccount @parameters
        }
        
        if (-not (Test-DmKdsRootKey @parameters)) {
            Write-PSFMessage -Level Warning -String 'Invoke-DMServiceAccount.NoKdsRootKey'
            return
        }
        
        :main foreach ($testItem in $InputObject) {
            # Catch invalid input - can only process test results
            if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.ServiceAccount.TestResult') {
                Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMServiceAccount', $testItem -Target $testItem -Continue -EnableException $EnableException
            }
            
            switch ($testItem.Type) {
                'Delete'
                {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.Deleting' -ActionStringValues $testItem.Identity -Target $testItem.Identity -ScriptBlock {
                        Remove-ADServiceAccount @parameters -Identity $testItem.ADObject.SamAccountName -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet
                }
                'Create'
                {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.Creating' -ActionStringValues $testItem.Identity -Target $testItem.Identity -ScriptBlock {
                        New-ServiceAccount -TestItem $testItem -Parameters $parameters -Categories $categories
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet
                }
                'Update'
                {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.Updating' -ActionStringValues $testItem.Identity -Target $testItem.Identity -ScriptBlock {
                        Set-ServiceAccount -TestItem $testItem -Parameters $parameters
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet
                }
                'PrincipalUpdate'
                {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.UpdatingPrincipal' -ActionStringValues $testItem.Identity -Target $testItem.Identity -ScriptBlock {
                        $principals = ($testItem.Changed | Where-Object Type -EQ Update).NewValue
                        Set-ADServiceAccount @parameters -Identity $testItem.ADObject.ObjectGuid -PrincipalsAllowedToRetrieveManagedPassword $principals
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet
                }
                'Move'
                {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.Moving' -ActionStringValues $testItem.Identity, $testItem.Changed.NewValue -Target $testItem.Identity -ScriptBlock {
                        Move-ADObject @parameters -Identity $testItem.ADObject.ObjectGuid -TargetPath $testItem.Changed.NewValue -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet
                }
                'Rename'
                {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.Renaming' -ActionStringValues $testItem.Identity, $testItem.Changed.NewValue -Target $testItem.Identity -ScriptBlock {
                        Rename-ADObject @parameters -Identity $testItem.ADObject.ObjectGuid -NewName $testItem.Changed.NewValue -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet
                }
                'RenameSam'
                {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.RenamingSam' -ActionStringValues $testItem.Identity, $testItem.ADObject.SamAccountName -Target $testItem.Identity -ScriptBlock {
                        Set-ADObject @parameters -Identity $testItem.ADObject.ObjectGuid -Replace @{ samAccountName = $testItem.Identity } -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet
                }
                'Enable'
                {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.Enabling' -ActionStringValues $testItem.Identity -Target $testItem.Identity -ScriptBlock {
                        Enable-ADAccount @parameters -Identity $testItem.ADObject.ObjectGuid -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet
                }
                'Disable'
                {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMServiceAccount.Disabling' -ActionStringValues $testItem.Identity -Target $testItem.Identity -ScriptBlock {
                        Disable-ADAccount @parameters -Identity $testItem.ADObject.ObjectGuid -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet
                }
            }
        }
    }
}

function Register-DMServiceAccount {
    <#
    .SYNOPSIS
        Register a Group Managed Service Account as a desired state object.
     
    .DESCRIPTION
        Register a Group Managed Service Account as a desired state object.
        This will then be tested for during Test-DMServiceAccount and ensured during Invoke-DMServiceAccount.
     
    .PARAMETER Name
        Name of the Service Account.
        This must be a legal name, 15 characters or less (no trailing $ needed).
        The SamAccountName will be automatically calculated based off this setting (by appending a $).
        Supports string resolution.
     
    .PARAMETER DNSHostName
        The DNSHostName of the gMSA.
        Supports string resolution.
     
    .PARAMETER Description
        Describe what the gMSA is supposed to be used for.
        Supports string resolution.
     
    .PARAMETER Path
        The path where to place the gMSA.
        Supports string resolution.
     
    .PARAMETER ServicePrincipalName
        Any service principal names to add to the gMSA.
        Supports string resolution.
     
    .PARAMETER DisplayName
        A custom DisplayName for the gMSA.
        Note, this setting will be ignored in the default dsa.msc console!
        It only affects other applications that might be gMSA aware and support it.
        Supports string resolution.
     
    .PARAMETER ObjectCategory
        Only thus designated principals are allowed to retrieve the password to the gMSA.
        Using this you can grant access to any members of given Object Categories.
     
    .PARAMETER ComputerName
        Only thus designated principals are allowed to retrieve the password to the gMSA.
        Using this you can grant access to an explicit list of computer accounts.
        A missing computer will cause a warning, but not otherwise fail the process.
        Supports string resolution.
     
    .PARAMETER ComputerNameOptional
        Only thus designated principals are allowed to retrieve the password to the gMSA.
        Using this you can grant access to an explicit list of computer accounts.
        A missing computer will be logged but not otherwise noted.
        Supports string resolution.
 
    .PARAMETER GroupName
        Only thus designated principals are allowed to retrieve the password to the gMSA.
        Using this you can grant access to an explicit list of ActiveDirectory groups.
        Supports string resolution.
 
    .PARAMETER KerberosEncryptionType
        The supported Kerberos encryption types.
        Can be any combination of 'AES128', 'AES256', 'DES', 'RC4'
        Default: 'AES128','AES256'
     
    .PARAMETER Enabled
        Whether the account should be enabled or disabled.
        By default, this is 'Undefined', causing the workflow to ignore its enablement state.
     
    .PARAMETER Present
        Whether the account should exist or not.
        By default, it should.
        Set this to $false in order to explicitly delete an existing gMSA.
        Set this to 'Undefined' to neither create nor delete it, in which case it will only modify properties if the service account exists.
     
    .PARAMETER Attributes
        Offer additional attributes to define.
        This can be either a hashtable or an object and can contain any writeable properties a gMSA can have in your organization.
 
    .PARAMETER OldNames
        A list of previous names the gMSA held.
        This causes the ADMF to trigger rename actions.
 
    .PARAMETER ContextName
        The name of the context defining the setting.
        This allows determining the configuration set that provided this setting.
        Used by the ADMF, available to any other configuration management solution.
     
    .EXAMPLE
        PS C:\> Get-Content .\serviceaccounts.json | ConvertFrom-Json | Write-Output | Register-DMServiceAccount
     
        Load up all settings defined in serviceaccounts.json
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $DNSHostName,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Description,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Path,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $ServicePrincipalName = @(),
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $DisplayName,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $ObjectCategory,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $ComputerName,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $ComputerNameOptional,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $GroupName,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('AES128', 'AES256', 'DES', 'RC4')]
        [string[]]
        $KerberosEncryptionType = @('AES128', 'AES256'),
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [PSFramework.Utility.TypeTransformationAttribute([string])]
        [DomainManagement.TriBool]
        $Enabled = 'Undefined',
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [PSFramework.Utility.TypeTransformationAttribute([string])]
        [DomainManagement.TriBool]
        $Present = 'true',
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        $Attributes,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $OldNames = @(),
        
        [string]
        $ContextName = '<Undefined>'
    )
    
    process {
        $script:serviceAccounts[$Name] = [PSCustomObject]@{
            PSTypeName             = 'DomainManagement.Configuration.ServiceAccount'
            Name                   = $Name
            SamAccountName         = $Name
            DNSHostName            = $DNSHostName
            Description            = $Description
            Path                   = $Path
            ServicePrincipalName   = $ServicePrincipalName
            DisplayName            = $DisplayName
            ObjectCategory         = $ObjectCategory
            ComputerName           = $ComputerName
            ComputerNameOptional   = $ComputerNameOptional
            GroupName              = $GroupName
            KerberosEncryptionType = $KerberosEncryptionType
            Enabled                = $Enabled
            Present                = $Present
            Attributes             = $Attributes | ConvertTo-PSFHashtable
            OldNames               = $OldNames
            ContextName            = $ContextName
        }
    }
}

function Test-DMServiceAccount {
    <#
    .SYNOPSIS
        Tests whether the currently deployed service accoaunts match the configured desired state.
     
    .DESCRIPTION
        Tests whether the currently deployed service accoaunts match the configured desired state.
        Use Register-DMServiceAccount to define the desired state.
     
    .PARAMETER Server
        The server / domain to work with.
         
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Test-DMServiceAccount -Server contoso.com
     
        Tests whether the service accounts in the contoso.com domain are compliant with the desired state.
#>

    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin {
        #region Utility Functions
        function New-Change {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                $Identity,
                
                $Type,
                
                $Property,
                
                $Previous,
                
                $NewValue
            )
            
            $object = [pscustomobject]@{
                PSTypeName = 'DomainManagement.Change.ServiceAccount'
                Identity   = $Identity
                Type       = $Type
                Property   = $Property
                Old        = $Previous
                New        = $NewValue
            }
            Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value {
                switch ($this.Type) {
                    'Create' { "Create: $($this.Identity)" }
                    'Delete' { "Delete: $($this.Identity)" }
                    # Move, Rename, Update
                    default { "$($this.Type): $($this.Old) -> $($this.New)" }
                }
            } -Force -PassThru
        }
        #endregion Utility Functions
        
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type serviceAccounts -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process {
        #region Prepare Object Categories
        $rawCategories = $script:serviceAccounts.Values.ObjectCategory | Remove-PSFNull -Enumerate | Sort-Object -Unique
        $categories = @{ }
        foreach ($rawCategory in $rawCategories) {
            $categories[$rawCategory] = Find-DMObjectCategoryItem -Name $rawCategory @parameters -Property SamAccountName
        }
        $renameCurrentSAM = @()
        #endregion Prepare Object Categories
        
        #region Process Configured Objects
        foreach ($serviceAccountDefinition in $script:serviceAccounts.Values) {
            $resolvedName = (Resolve-String -Text $serviceAccountDefinition.SamAccountName @parameters) -replace '\$$'
            $resolvedPath = Resolve-String -Text $serviceAccountDefinition.Path @parameters
            
            $resultDefaults = @{
                Server        = $Server
                ObjectType    = 'ServiceAccount'
                Identity      = $resolvedName
                Configuration = $serviceAccountDefinition
            }
            $adObject = $null
            
            try { $adObject = Get-ADServiceAccount @parameters -Identity $resolvedName -ErrorAction Stop -Properties * }
            catch {
                foreach ($oldName in $serviceAccountDefinition.OldNames) {
                    try { $adObject = Get-ADServiceAccount @parameters -Identity ($oldName | Resolve-String @parameters) -ErrorAction Stop -Properties * }
                    catch { continue }
                    # No Need to rename when deleting it anyway
                    if (-not $serviceAccountDefinition.Present) { break }
                    New-TestResult -Type RenameSam @resultDefaults -ADObject $adObject -Changed (New-AdcChange -Property SamAccountName -OldValue $adObject.SamAccountName -NewValue $resolvedName -Identity $adObject)
                    $renameCurrentSAM += $adObject.SamAccountName
                    break
                }
            }

            if (-not $adObject) {
                # .Present is of type TriBool, so itself would be $true for both 'true' and 'undefined' cases,
                # and we do not want to create if undefined
                if ($serviceAccountDefinition.Present -eq 'true') {
                    New-TestResult -Type Create @resultDefaults (New-Change -Identity $resolvedName -Type Create)
                }
                continue
            }
            $resultDefaults.ADObject = $adObject
            
            if (-not $serviceAccountDefinition.Present) {
                New-TestResult -Type Delete @resultDefaults -Changed (New-Change -Identity $adObject.SamAccountName -Type Delete)
                continue
            }
            
            #region Compare Common Properties
            $parentPath = $adObject.DistinguishedName -split ",", 2 | Select-Object -Last 1
            if ($parentPath -ne $resolvedPath) {
                New-TestResult -Type Move @resultDefaults -Changed (New-Change -Type Move -Property 'Path' -Previous $parentPath -NewValue $resolvedPath -Identity $resolvedName)
            }
            
            if ($adObject.Name -ne $resolvedName) {
                New-TestResult -Type Rename @resultDefaults -Changed (New-Change -Type Rename -Property 'Name' -Previous $adObject.Name -NewValue $resolvedName -Identity $resolvedName)
            }
            
            [System.Collections.ArrayList]$changes = @()
            Compare-Property -Property DNSHostName -Configuration $serviceAccountDefinition -ADObject $adObject -Changes $changes -Resolve -Parameters $parameters
            Compare-Property -Property Description -Configuration $serviceAccountDefinition -ADObject $adObject -Changes $changes -Resolve -Parameters $parameters -AsString
            Compare-Property -Property DisplayName -Configuration $serviceAccountDefinition -ADObject $adObject -Changes $changes -Resolve -Parameters $parameters -AsString
            if ($adObject.ServicePrincipalName -or $serviceAccountDefinition.ServicePrincipalName) {
                Compare-Property -Property ServicePrincipalName -Configuration $serviceAccountDefinition -ADObject $adObject -Changes $changes -Resolve -Parameters $parameters
            }
            if ($adObject.KerberosEncryptionType[0] -ne $serviceAccountDefinition.KerberosEncryptionType) {
                $null = $changes.Add('KerberosEncryptionType')
            }
            
            if ($serviceAccountDefinition.Attributes.Count -gt 0) {
                $attributesObject = [PSCustomObject]$serviceAccountDefinition.Attributes
                foreach ($key in $serviceAccountDefinition.Attributes.Keys) {
                    Compare-Property -Property $key -Configuration $attributesObject -ADObject $adObject -Changes $changes
                }
            }
            $defaultProperties = 'DNSHostName', 'Description', 'ServicePrincipalName', 'DisplayName'
            $unresolvedProperties = 'KerberosEncryptionType'
            $changeObjects = foreach ($change in $changes) {
                if ($change -in $defaultProperties) { New-Change -Type Update -Property $change -Previous $adObject.$change -NewValue ($serviceAccountDefinition.$change | Resolve-String @parameters) -Identity $resolvedName }
                elseif ($change -in $unresolvedProperties) { New-Change -Type Update -Property $change -Previous $adObject.$change -NewValue $serviceAccountDefinition.$change -Identity $resolvedName }
                else { New-Change -Type Update -Property $change -Previous $adObject.$change -NewValue $attributesObject.$change -Identity $resolvedName }
            }
            if ($changes) {
                New-TestResult -Type Update @resultDefaults -Changed $changeObjects
            }
            #endregion Compare Common Properties
            
            #region Enabled
            if ($serviceAccountDefinition.Enabled -ne 'Undefined') {
                if ($adObject.Enabled -and -not $serviceAccountDefinition.Enabled) {
                    New-TestResult -Type Disable @resultDefaults -Changed (New-Change -Type Disable -Property Enabled -Previous $true -NewValue $false -Identity $resolvedName)
                }
                if (-not $adObject.Enabled -and $serviceAccountDefinition.Enabled) {
                    New-TestResult -Type Enable @resultDefaults -Changed (New-Change -Type Enable -Property Enabled -Previous $false -NewValue $true -Identity $resolvedName)
                }
            }
            #endregion Enabled
            
            #region PrincipalsAllowedToRetrieveManagedPassword
            # Use SamAccountName rather than DistinguishedName as accounts may not yet have been moved to their correct container so DN might fail
            $currentPrincipals = ($adObject.PrincipalsAllowedToRetrieveManagedPassword | Get-ADObject @parameters -Properties SamAccountName).SamAccountName
            
            # Object Category
            $desiredPrincipals = @()
            foreach ($category in $serviceAccountDefinition.ObjectCategory) {
                $categories[$category].SamAccountName | ForEach-Object {
                    $desiredPrincipals += $_
                }
            }
            
            # Direct Assignment
            foreach ($name in $serviceAccountDefinition.ComputerName | Resolve-String @parameters) {
                if ($name -notlike '*$') { $name = "$($name)$" }
                try {
                    $null = Get-ADComputer @parameters -Identity $name -ErrorAction Stop
                    $desiredPrincipals += $name
                }
                catch {
                    Write-PSFMessage -Level Warning -String 'Test-DMServiceAccount.Computer.NotFound' -StringValues $name, $resolvedName -Target $serviceAccountDefinition -Tag error, failed, serviceaccount, computer
                    continue
                }
            }
            
            # Optional Direct Assignment
            foreach ($name in $serviceAccountDefinition.ComputerNameOptional | Resolve-String @parameters) {
                if ($name -notlike '*$') { $name = "$($name)$" }
                try {
                    $null = Get-ADComputer @parameters -Identity $name -ErrorAction Stop
                    $desiredPrincipals += $name
                }
                catch {
                    Write-PSFMessage -Level Verbose -String 'Test-DMServiceAccount.Computer.Optional.NotFound' -StringValues $name, $resolvedName -Target $serviceAccountDefinition -Tag error, failed, serviceaccount, computer
                    continue
                }
            }
            
            # Direct Group Assignment
            foreach ($name in $serviceAccountDefinition.GroupName | Resolve-String @parameters) {
                try {
                    $null = Get-ADGroup @parameters -Identity $name -ErrorAction Stop
                    $desiredPrincipals += $name
                }
                catch {
                    Write-PSFMessage -Level Warning -String 'Test-DMServiceAccount.Group.NotFound' -StringValues $name, $resolvedName -Target $serviceAccountDefinition -Tag error, failed, serviceaccount, group
                    continue
                }
            }
            
            $principalChanges = @()
            foreach ($principal in $currentPrincipals) {
                if ($principal -in $desiredPrincipals) { continue }
                $principalChanges += New-Change -Type Remove -Property Principal -Previous $principal -Identity $resolvedName
            }
            foreach ($principal in $desiredPrincipals) {
                if ($principal -in $currentPrincipals) { continue }
                $principalChanges += New-Change -Type Add -Property Principal -NewValue $principal -Identity $resolvedName
            }
            if (-not $principalChanges) { continue }
            
            $principalChanges += New-Change -Type Update -Property Principal -Previous $currentPrincipals -NewValue $desiredPrincipals -Identity $resolvedName
            New-TestResult -Type PrincipalUpdate @resultDefaults -Changed $principalChanges
            #endregion PrincipalsAllowedToRetrieveManagedPassword
        }
        #endregion Process Configured Objects
        
        #region Process Non-Configuted AD-Objects
        $foundServiceAccounts = foreach ($searchBase in (Resolve-ContentSearchBase @parameters)) {
            Get-ADServiceAccount @parameters -LDAPFilter '(!(isCriticalSystemObject=TRUE))' -SearchBase $searchBase.SearchBase -SearchScope $searchBase.SearchScope
        }
        
        $configuredNames = $script:serviceAccounts.Values.SamAccountName | Resolve-String @parameters | ForEach-Object {
            if ($_ -like '*$') { $_ }
            else { "$($_)$" }
        }
        
        $resultDefaults = @{
            Server     = $Server
            ObjectType = 'ServiceAccount'
        }
        
        foreach ($foundServiceAccount in $foundServiceAccounts) {
            if ($foundServiceAccount.SamAccountName -in $configuredNames) { continue }
            if ($foundServiceAccount.SamAccountName -in $renameCurrentSAM) { continue }
            
            New-TestResult @resultDefaults -Type Delete -Identity $foundServiceAccount.SamAccountName -ADObject $foundServiceAccount -Changed (New-Change -Identity $foundServiceAccount.SamAccountName -Type Delete)
        }
        #endregion Process Non-Configuted AD-Objects
    }
}


function Unregister-DMServiceAccount
{
<#
    .SYNOPSIS
        Removes a service account from the list of registered service accounts.
     
    .DESCRIPTION
        Removes a service account from the list of registered service accounts.
     
    .PARAMETER Name
        The account to remove.
     
    .EXAMPLE
        PS C:\> Get-DMServiceAccount | Unregister-DMServiceAccount
     
        Clear all configured service accounts.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameItem in $Name)
        {
            $script:serviceAccounts.Remove($nameItem)
        }
    }
}


function Clear-DMConfiguration
{
    <#
        .SYNOPSIS
            Clears the configuration, removing all registered settings.
         
        .DESCRIPTION
            Clears the configuration, removing all registered settings.
            Use this to clean up, e.g. when switching to a new configuration set.
         
        .EXAMPLE
            PS C:\> Clear-DMConfiguration
 
            Clears the configuration, removing all registered settings.
    #>

    [CmdletBinding()]
    Param (
    
    )
    
    process
    {
        . "$script:ModuleRoot\internal\scripts\variables.ps1"
    }
}


function Get-DMCallback
{
    <#
    .SYNOPSIS
        Returns the list of registered callbacks.
     
    .DESCRIPTION
        Returns the list of registered callbacks.
 
        For more details on this system, call:
        Get-Help about_DM_callbacks
     
    .PARAMETER Name
        The name of the callback.
        Supports wildcard filtering.
     
    .EXAMPLE
        PS C:\> Get-DMCallback
 
        Returns a list of all registered callbacks
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Name = '*'
    )
    
    process
    {
        $script:callbacks.Values | Where-Object Name -like $Name
    }
}


function Get-DMContentMode
{
    <#
    .SYNOPSIS
        Returns the current domain content mode / content handling policy.
     
    .DESCRIPTION
        Returns the current domain content mode / content handling policy.
        For more details on the content mode and how it behaves, see the description on Set-DMContentMode
     
    .EXAMPLE
        PS C:\> Get-DMContentMode
 
        Returns the current domain content mode / content handling policy.
    #>

    [CmdletBinding()]
    Param ()
    
    process
    {
        $script:contentMode
    }
}


function Get-DMDomainCredential
{
    <#
    .SYNOPSIS
        Retrieve credentials stored for accessing the targeted domain.
     
    .DESCRIPTION
        Retrieve credentials stored for accessing the targeted domain.
        Returns nothing when no credentials were stored.
        This is NOT used by the main commands, but internally for retrieving data regarding foreign principals in one-way trusts.
        Generally, these credentials should never have more than reading access to the target domain.
     
    .PARAMETER Domain
        The domain to retrieve credentials for.
        Does NOT accept wildcards.
     
    .EXAMPLE
        PS C:\> Get-DMDomainCredential -Domain contoso.com
 
        Returns the credentials for accessing contoso.com, as long as those have previously been stored.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Domain
    )
    
    process
    {
        if (-not $script:domainCredentialCache) { return }
        $script:domainCredentialCache[$Domain]
    }
}


function Register-DMCallback
{
    <#
    .SYNOPSIS
        Registers a scriptblock to be called when invoking any Test- or Invoke- command.
     
    .DESCRIPTION
        Registers a scriptblock to be called when invoking any Test- or Invoke- command.
        This enables extending the module and ensuring correct configuration loading.
        The scriptblock will receive four arguments:
        - The Server targeted (if any)
        - The credentials used to do the targeting (if any)
        - The Forest the two earlier pieces of information map to (if any)
        - The Domain the two earlier pieces of information map to (if any)
        Any and all of these pieces of information may be empty.
        Any exception in a callback scriptblock will block further execution!
 
        For more details on this system, call:
        Get-Help about_DM_callbacks
     
    .PARAMETER Name
        The name of the callback to register (multiple can be active at any given moment).
     
    .PARAMETER ScriptBlock
        The scriptblock containing the callback logic.
     
    .EXAMPLE
        PS C:\> Register-DMCallback -Name MyCompany -Scriptblock $scriptblock
 
        Registers the scriptblock stored in $scriptblock under the name 'MyCompany'
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ScriptBlock]
        $ScriptBlock
    )
    
    begin
    {
        if (-not $script:callbacks) {
            $script:callbacks = @{ }
        }
    }
    process
    {
        $script:callbacks[$Name] = [PSCustomObject]@{
            Name = $Name
            ScriptBlock = $ScriptBlock
        }
    }
}


function Reset-DMDomainCredential
{
<#
    .SYNOPSIS
        Resets cached credentials for contacting domains.
     
    .DESCRIPTION
        Resets cached credentials for contacting domains.
        Use this command when invalidating credentials you used.
        For example in ADMF the credential provider:
        If you create one that uses a temporary account, then delete it when done, you need to reset the cache when connecting with your default credentials.
     
    .PARAMETER Credential
        Clear all cache entries using this credential object.
     
    .PARAMETER Domain
        Clear the cached credentials for the target domain.
     
    .PARAMETER UserName
        Clear all cached credentials using this username.
     
    .PARAMETER All
        Clear ALL cached credentials
     
    .EXAMPLE
        PS C:\> Reset-DMDomainCredential -All
     
        Clear all cached credentials
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Credential')]
        [PSCredential]
        $Credential,
        
        [Parameter(Mandatory = $true, ParameterSetName = 'Domain')]
        [string]
        $Domain,
        
        [Parameter(Mandatory = $true, ParameterSetName = 'Name')]
        [string]
        $UserName,
        
        [Parameter(Mandatory = $true, ParameterSetName = 'All')]
        [switch]
        $All
    )
    
    process
    {
        switch ($PSCmdlet.ParameterSetName)
        {
            'Credential'
            {
                [string[]]$keys = $script:domainCredentialCache.Keys
                foreach ($key in $keys)
                {
                    if ($script:domainCredentialCache[$key] -eq $Credential) { $script:domainCredentialCache.Remove($key) }
                }
            }
            'Domain'
            {
                $script:domainCredentialCache.Remove($Domain)
            }
            'Name'
            {
                [string[]]$keys = $script:domainCredentialCache.Keys
                foreach ($key in $keys)
                {
                    if ($script:domainCredentialCache[$key].UserName -eq $UserName) { $script:domainCredentialCache.Remove($key) }
                }
            }
            'All'
            {
                $script:domainCredentialCache = @{ }
            }
        }
    }
}

function Set-DMContentMode
{
    <#
    .SYNOPSIS
        Configures the way the module handles domain level objects not defined in configuration.
     
    .DESCRIPTION
        Configures the way the module handles domain level objects not defined in configuration.
        Depending on the desired domain configuration, dealing with undesired objects may be desirable.
 
        This module handles the following configurations:
        Mode Additive: In this mode, all configured content is considered in addition to what is already there. Objects not in scope of the configuration are ignored.
        Mode Constrained: In this mode, objects not configured are handled based on OU rules:
        - Include: If Include OUs are configured, only objects in the specified OUs are under management. Only objects in these OUs will be considered for deletion if not configured.
        - Exclude: If Exclude OUs are configured, objects in the excluded OUs are ignored, all objects outside of these OUs will be considered for deletion if not configured.
        If both Include and Exclude OUs are configured, they are merged without applying the implied top-level Include of an Exclude-only configuration.
        In this scenario, if a top-level Include is desired, it needs to be explicitly set.
 
        When specifying Include and Exclude OUs, specify the full DN, inserting '%DomainDN%' (without the quotes) for the domain root.
     
    .PARAMETER Mode
        The mode to operate under.
        In Additive mode, objects not configured are being ignored.
        In Constrained mode, objects not configured may still be under maanagement, depending on Include and Exclude rules.
     
    .PARAMETER Include
        OUs in which to look for objects under management.
        Use this to explicitly list which OUs should be inspected for objects to delete.
        Only applied in Constrained mode.
        Specify the full DN, inserting '%DomainDN%' (without the quotes) for the domain root.
     
    .PARAMETER Exclude
        OUs in which to NOT look for objects under management.
        All other OUs are subject to management and having undesired objects deleted.
        Only applied in Constrained mode.
        Specify the full DN, inserting '%DomainDN%' (without the quotes) for the domain root.
 
    .PARAMETER UserExcludePattern
        Regex expressions that are applied to the name property of user objects found in AD.
        By default, in Constrained mode, all users found in paths resolved to be under management (through -Include and -Exclude specified in this command) that are not configured will be flagged for deletion.
        Using this parameter, it becomes possible to exempt specific accounts or accounts according to a specific pattern from this.
 
    .PARAMETER RemoveUnknownWmiFilter
        Whether to remove unknown, undefined WMI Filters.
        Only relevant when defining the WMI Filter component.
        By default, WMI filters defined outside of the configuration will not be deleted if found.
     
    .EXAMPLE
        PS C:\> Set-DMContentMode -Mode 'Constrained' -Include 'OU=Administration,%DomainDN%'
 
        Enables Constrained mode and configures the top-level OU "Administration" as an OU under management.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    Param (
        [ValidateSet('Additive', 'Constrained')]
        [string]
        $Mode,

        [AllowEmptyCollection()]
        [string[]]
        $Include,

        [AllowEmptyCollection()]
        [string[]]
        $Exclude,

        [AllowEmptyCollection()]
        [string[]]
        $UserExcludePattern,

        [bool]
        $RemoveUnknownWmiFilter
    )
    
    process
    {
        if ($Mode) { $script:contentMode.Mode = $Mode }
        if (Test-PSFParameterBinding -ParameterName Include) { $script:contentMode.Include = $Include }
        if (Test-PSFParameterBinding -ParameterName Exclude) { $script:contentMode.Exclude = $Exclude }
        if (Test-PSFParameterBinding -ParameterName UserExcludePattern) { $script:contentMode.UserExcludePattern = $UserExcludePattern }
        if (Test-PSFParameterBinding -ParameterName RemoveUnknownWmiFilter) { $script:contentMode.RemoveUnknownWmiFilter = $RemoveUnknownWmiFilter }
    }
}


function Set-DMDomainContext
{
    <#
        .SYNOPSIS
            Updates the domain settings for string replacement.
         
        .DESCRIPTION
            Updates the domain settings for string replacement.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
         
        .EXAMPLE
            PS C:\> Set-DMDomainContext @parameters
 
            Updates the current domain context
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
    }
    process
    {
        $domainObject = Get-ADDomain @parameters
        $forestObject = Get-ADForest @parameters
        if ($forestObject.RootDomain -eq $domainObject.DNSRoot) {
            $forestRootDomain = $domainObject
            $forestRootSID = $forestRootDomain.DomainSID.Value
        }
        else {
            try {
                $cred = $PSBoundParameters | ConvertTo-PSFHashtable -Include Credential
                $forestRootDomain = Get-ADDomain @cred -Server $forestObject.RootDomain -ErrorAction Stop
                $forestRootSID = $forestRootDomain.DomainSID.Value
            }
            catch {
                $forestRootDomain = [PSCustomObject]@{
                    Name = $forestObject.RootDomain.Split(".",2)[0]
                    DNSRoot = $forestObject.RootDomain
                    DistinguishedName = 'DC={0}' -f ($forestObject.RootDomain.Split(".") -join ",DC=")
                }
                $forestRootSID = (Get-ADObject @parameters -SearchBase "CN=System,$($domainObject.DistinguishedName)" -SearchScope OneLevel -LDAPFilter "(&(objectClass=trustedDomain)(trustPartner=$($forestObject.RootDomain)))" -Properties securityIdentifier).securityIdentifier.Value
            }
        }

        $script:domainContext.Name = $domainObject.Name
        $script:domainContext.Fqdn = $domainObject.DNSRoot
        $script:domainContext.DN = $domainObject.DistinguishedName
        $script:domainContext.ForestFqdn = $forestObject.Name

        Register-DMNameMapping -Name '%DomainName%' -Value $domainObject.Name
        Register-DMNameMapping -Name '%DomainNetBIOSName%' -Value $domainObject.NetbiosName
        Register-DMNameMapping -Name '%DomainFqdn%' -Value $domainObject.DNSRoot
        Register-DMNameMapping -Name '%DomainDN%' -Value $domainObject.DistinguishedName
        Register-DMNameMapping -Name '%DomainSID%' -Value $domainObject.DomainSID.Value
        Register-DMNameMapping -Name '%RootDomainName%' -Value $forestRootDomain.Name
        Register-DMNameMapping -Name '%RootDomainFqdn%' -Value $forestRootDomain.DNSRoot
        Register-DMNameMapping -Name '%RootDomainDN%' -Value $forestRootDomain.DistinguishedName
        Register-DMNameMapping -Name '%RootDomainSID%' -Value $forestRootSID
        Register-DMNameMapping -Name '%ForestFqdn%' -Value $forestObject.Name

        if ($Credential) {
            Set-DMDomainCredential -Domain $domainObject.DNSRoot -Credential $Credential
            Set-DMDomainCredential -Domain $domainObject.Name -Credential $Credential
            Set-DMDomainCredential -Domain $domainObject.DistinguishedName -Credential $Credential
        }
    }
}

function Set-DMDomainCredential
{
    <#
    .SYNOPSIS
        Stores credentials stored for accessing the targeted domain.
     
    .DESCRIPTION
        Stores credentials stored for accessing the targeted domain.
        This is NOT used by the main commands, but internally for retrieving data regarding foreign principals in one-way trusts.
        Generally, these credentials should never have more than reading access to the target domain.
     
    .PARAMETER Domain
        The domain to store credentials for.
        Does NOT accept wildcards.
 
    .PARAMETER Credential
        The credentials to store.
     
    .EXAMPLE
        PS C:\> Set-DMDomainCredential -Domain contoso.com -Credential $cred
 
        Stores the credentials for accessing contoso.com.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Domain,

        [Parameter(Mandatory = $true)]
        [PSCredential]
        $Credential
    )
    
    process
    {
        if (-not $script:domainCredentialCache) {
            $script:domainCredentialCache = @{ }
        }

        $script:domainCredentialCache[$Domain] = $Credential
    }
}


function Set-DMRedForestContext
{
    <#
    .SYNOPSIS
        Sets the basic information of the red forest.
     
    .DESCRIPTION
        Sets the basic information of the red forest.
        This is used to provide for replacement variables usable on all properties of all domain objects supporting string resolution.
 
        There are two ways to gather this information:
        - Collect it from a forest (default; Collects from the current user's forest by default)
        - Explicitly provide the values.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER FQDN
        FQDN of the forest.
     
    .PARAMETER Name
        Name of the forest (usually the same as the FQDN)
 
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .EXAMPLE
        PS C:\> Set-DMRedForestContext
 
        Configures the current forest as red forest.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding(DefaultParameterSetName = 'Access')]
    Param (
        [Parameter(ParameterSetName = 'Access')]
        [string]
        $Server,

        [Parameter(ParameterSetName = 'Access')]
        [pscredential]
        $Credential,

        [Parameter(Mandatory = $true, ParameterSetName = 'Name')]
        [string]
        $FQDN,

        [Parameter(Mandatory = $true, ParameterSetName = 'Name')]
        [string]
        $Name,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
    }
    process
    {
        switch ($PSCmdlet.ParameterSetName) {
            'Access'
            {
                try { $forest = Get-ADForest @parameters -ErrorAction Stop }
                catch {
                    Stop-PSFFunction -String 'Set-DMRedForestContext.Connection.Failed' -StringValues $Server -Target $Server -EnableException $EnableException -ErrorRecord $_
                    return
                }
                $script:redForestContext.Name = $forest.Name
                $script:redForestContext.Fqdn = $forest.Name
                $script:redForestContext.RootDomainName = ($forest.RootDomain -split "\.")[0]
                $script:redForestContext.RootDomainFqdn = $forest.RootDomain

                Register-DMNameMapping -Name '%RedForestName%' -Value $forest.Name
                Register-DMNameMapping -Name '%RedForestFqdn%' -Value $forest.Name
                Register-DMNameMapping -Name '%RedForestRootDomainName%' -Value ($forest.RootDomain -split "\.")[0]
                Register-DMNameMapping -Name '%RedForestRootDomainFqdn%' -Value $forest.RootDomain
            }
            'Name'
            {
                $script:redForestContext.Name = $Name
                $script:redForestContext.Fqdn = $FQDN
                $script:redForestContext.RootDomainName = ($FQDN -split "\.")[0]
                $script:redForestContext.RootDomainFqdn = $FQDN

                Register-DMNameMapping -Name '%RedForestName%' -Value $Name
                Register-DMNameMapping -Name '%RedForestFqdn%' -Value $FQDN
                Register-DMNameMapping -Name '%RedForestRootDomainName%' -Value ($FQDN -split "\.")[0]
                Register-DMNameMapping -Name '%RedForestRootDomainFqdn%' -Value $FQDN
            }
        }
    }
}


function Unregister-DMCallback
{
    <#
    .SYNOPSIS
        Removes a callback from the list of registered callbacks.
     
    .DESCRIPTION
        Removes a callback from the list of registered callbacks.
 
        For more details on this system, call:
        Get-Help about_DM_callbacks
     
    .PARAMETER Name
        The name of the callback to remove.
     
    .EXAMPLE
        PS C:\> Get-DMCallback | Unregister-DMCallback
 
        Unregisters all callback scriptblocks that have been registered.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameItem in $Name) {
            $script:callbacks.Remove($nameItem)
        }
    }
}


function Get-DMUser
{
    <#
        .SYNOPSIS
            Lists registered ad users.
         
        .DESCRIPTION
            Lists registered ad users.
         
        .PARAMETER Name
            The name to filter by.
            Defaults to '*'
         
        .EXAMPLE
            PS C:\> Get-DMUser
 
            Lists all registered ad users.
    #>

    [CmdletBinding()]
    param (
        [string]
        $Name = '*'
    )
    
    process
    {
        ($script:users.Values | Where-Object SamAccountName -like $Name)
    }
}


function Invoke-DMUser {
    <#
        .SYNOPSIS
            Updates the user configuration of a domain to conform to the configured state.
         
        .DESCRIPTION
            Updates the user configuration of a domain to conform to the configured state.
     
        .PARAMETER InputObject
            Test results provided by the associated test command.
            Only the provided changes will be executed, unless none were specified, in which ALL pending changes will be executed.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
 
        .PARAMETER EnableException
            This parameters disables user-friendly warnings and enables the throwing of exceptions.
            This is less user friendly, but allows catching exceptions in calling scripts.
 
        .PARAMETER Confirm
            If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
         
        .PARAMETER WhatIf
            If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
         
        .EXAMPLE
            PS C:\> Innvoke-DMUser -Server contoso.com
 
            Updates the users in the domain contoso.com to conform to configuration
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,
        
        [switch]
        $EnableException
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Users -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process {
        if (-not $InputObject) {
            $InputObject = Test-DMUser @parameters
        }
        
        :main foreach ($testItem in $InputObject) {
            # Catch invalid input - can only process test results
            if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.User.TestResult') {
                Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMUser', $testItem -Target $testItem -Continue -EnableException $EnableException
            }
            
            switch ($testItem.Type) {
                'Delete' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Delete' -Target $testItem -ScriptBlock {
                        Remove-ADUser @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Create' {
                    $targetOU = Resolve-String -Text $testItem.Configuration.Path
                    try { $null = Get-ADObject @parameters -Identity $targetOU -ErrorAction Stop }
                    catch { Stop-PSFFunction -String 'Invoke-DMUser.User.Create.OUExistsNot' -StringValues $targetOU, $testItem.Identity -Target $testItem -EnableException $EnableException -Continue -ContinueLabel main }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Create' -Target $testItem -ScriptBlock {
                        $newParameters = $parameters.Clone()
                        $newParameters += @{
                            Name = (Resolve-String -Text $testItem.Configuration.SamAccountName)
                            SamAccountName = (Resolve-String -Text $testItem.Configuration.SamAccountName)
                            UserPrincipalName = (Resolve-String -Text $testItem.Configuration.UserPrincipalName)
                            PasswordNeverExpires = $testItem.Configuration.PasswordNeverExpires
                            Path = $targetOU
                            AccountPassword = (New-Password -Length 128 -AsSecureString)
                            Enabled = $testItem.Configuration.Enabled # Both True and Undefined will result in $true
                            Confirm = $false
                        }
                        if ($testItem.Configuration.Description) { $newParameters['Description'] = Resolve-String -Text $testItem.Configuration.Description }
                        if ($testItem.Configuration.GivenName) { $newParameters['GivenName'] = Resolve-String -Text $testItem.Configuration.GivenName }
                        if ($testItem.Configuration.Surname) { $newParameters['Surname'] = Resolve-String -Text $testItem.Configuration.Surname }
                        New-ADUser @newParameters
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'MultipleOldUsers' {
                    Stop-PSFFunction -String 'Invoke-DMUser.User.MultipleOldUsers' -StringValues $testItem.Identity, ($testItem.ADObject.Name -join ', ') -Target $testItem -EnableException $EnableException -Continue -Tag 'user', 'critical', 'panic'
                }
                'Rename' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Rename' -ActionStringValues (Resolve-String -Text $testItem.Configuration.SamAccountName) -Target $testItem -ScriptBlock {
                        Set-ADUser @parameters -Identity $testItem.ADObject.ObjectGUID -SamAccountName $testItem.Configuration.SamAccountName -ErrorAction Stop
                        if ($testItem.ADObject.Name -ne (Resolve-String -Text $testItem.Configuration.Name)) {
                            Rename-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -NewName (Resolve-String -Text $testItem.Configuration.Name) -ErrorAction Stop
                        }
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                }
                'Changed' {
                    if ($testItem.Changed.Property -contains 'Path') {
                        $targetOU = Resolve-String -Text $testItem.Configuration.Path
                        try { $null = Get-ADObject @parameters -Identity $targetOU -ErrorAction Stop }
                        catch { Stop-PSFFunction -String 'Invoke-DMUser.User.Update.OUExistsNot' -StringValues $testItem.Identity, $targetOU -Target $testItem -EnableException $EnableException -Continue -ContinueLabel main }
                        
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Move' -ActionStringValues $targetOU -Target $testItem -ScriptBlock {
                            $null = Move-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -TargetPath $targetOU -ErrorAction Stop -Confirm:$false
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }
                    $changes = @{ }
                    foreach ($change in $testItem.Changed) {
                        if ($change.Property -notin 'GivenName','Surname','Description','UserPrincipalName') { continue }
                        switch ($change.Property) {
                            Surname { $changes['sn'] = $change.New }
                            default { $changes[$change.Property] = $change.New }
                        }
                    }
                    if ($changes.Keys.Count -gt 0) {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Update' -ActionStringValues ($changes.Keys -join ", ") -Target $testItem -ScriptBlock {
                            $null = Set-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Replace $changes -Confirm:$false
                        } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                    }
                    
                    if ($testItem.Changed.Property -contains 'Enabled') {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Update.EnableDisable' -ActionStringValues $testItem.Configuration.Enabled -Target $testItem -ScriptBlock {
                            $null = Set-ADUser @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -Enabled $testItem.Configuration.Enabled -Confirm:$false
                        } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                    }
                    if ($testItem.Changed.Property -contains 'Name') {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Update.Name' -ActionStringValues (Resolve-String -Text $testItem.Configuration.Name) -Target $testItem -ScriptBlock {
                            Rename-ADObject @parameters -Identity $testItem.ADObject.ObjectGUID -NewName (Resolve-String -Text $testItem.Configuration.Name) -ErrorAction Stop -Confirm:$false
                        } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                    }
                    if ($testItem.Changed.Property -contains 'PasswordNeverExpires') {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-DMUser.User.Update.PasswordNeverExpires' -ActionStringValues $testItem.Configuration.PasswordNeverExpires -Target $testItem -ScriptBlock {
                            $null = Set-ADUser @parameters -Identity $testItem.ADObject.ObjectGUID -ErrorAction Stop -PasswordNeverExpires $testItem.Configuration.PasswordNeverExpires -Confirm:$false
                        } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                    }
                }
            }
        }
    }
}

function Register-DMUser {
    <#
    .SYNOPSIS
        Registers a user definition into the configuration domains are compared to.
     
    .DESCRIPTION
        Registers a user definition into the configuration domains are compared to.
        This configuration is then compared to the configuration in AD when using Test-ADUser.
 
        Note: Many properties can be set up for string replacement at runtime.
        For example to insert the domain DN into the path, insert "%DomainDN%" (without the quotes) where the domain DN would be placed.
        Use Register-DMNameMapping to add additional values and the placeholder they will be inserted into.
        Use Get-DMNameMapping to retrieve a list of available mappings.
        This can be used to use the same content configuration across multiple environments, accounting for local naming differences.
     
    .PARAMETER SamAccountName
        SamAccountName of the user to manage.
        Subject to string insertion.
 
    .PARAMETER Name
        Name of the user to manage.
        Subject to string insertion.
     
    .PARAMETER GivenName
        Given Name of the user to manage.
        Subject to string insertion.
     
    .PARAMETER Surname
        Surname (Family Name) of the user to manage.
        Subject to string insertion.
     
    .PARAMETER Description
        Description of the user account.
        This is required and should describe the purpose / use of the account.
        Subject to string insertion.
     
    .PARAMETER PasswordNeverExpires
        Whether the password should never expire.
        By default it WILL expire.
     
    .PARAMETER UserPrincipalName
        The user principal name the account should have.
        Subject to string insertion.
     
    .PARAMETER Path
        The organizational unit the user should be placed in.
        Subject to string insertion.
 
    .PARAMETER Enabled
        Whether the user object should be enabled or disabled.
        Defaults to: Undefined
 
    .PARAMETER Optional
        By default, all defined user accounts must exist.
        By setting a user account optional, it will be tolerated if it exists, but not created if it does not.
     
    .PARAMETER OldNames
        Previous names the user object had.
        Will trigger a rename if a user is found under one of the old names but not the current one.
        Subject to string insertion.
     
    .PARAMETER Present
        Whether the user should be present.
        This can be used to trigger deletion of a managed account.
        When set to 'Undefined', this will act exactly as if -Optional were set to $true
 
    .PARAMETER ContextName
        The name of the context defining the setting.
        This allows determining the configuration set that provided this setting.
        Used by the ADMF, available to any other configuration management solution.
     
    .EXAMPLE
        PS C:\> Get-Content .\users.json | ConvertFrom-Json | Write-Output | Register-DMUser
 
        Reads a json configuration file containing a list of objects with appropriate properties to import them as user configuration.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $SamAccountName,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $GivenName,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $Surname,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $Description,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [switch]
        $PasswordNeverExpires,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $UserPrincipalName,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Path,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [PSFramework.Utility.TypeTransformationAttribute([string])]
        [DomainManagement.TriBool]
        $Enabled = 'Undefined',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $Optional,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $OldNames = @(),
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [PSFramework.Utility.TypeTransformationAttribute([string])]
        [DomainManagement.TriBool]
        $Present = 'true',
        
        [string]
        $ContextName = '<Undefined>'
    )
    
    process {
        
        $userHash = @{
            PSTypeName           = 'DomainManagement.User'
            SamAccountName       = $SamAccountName
            Name                 = $Name
            GivenName            = $GivenName
            Surname              = $Surname
            Description          = $null
            PasswordNeverExpires = $PasswordNeverExpires.ToBool()
            UserPrincipalName    = $UserPrincipalName
            Path                 = $Path
            Enabled              = $Enabled
            Optional             = $Optional
            OldNames             = $OldNames
            Present              = $Present
            ContextName          = $ContextName
        }
        if ($Description) {
            $userHash['Description'] = $Description
        }
        if (-not $Name) {
            $userHash['Name'] = $SamAccountName
        }
        $script:users[$SamAccountName] = [PSCustomObject]$userHash
    }
}


function Test-DMUser
{
    <#
        .SYNOPSIS
            Tests whether the configured users match a domain's configuration.
         
        .DESCRIPTION
            Tests whether the configured users match a domain's configuration.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
         
        .EXAMPLE
            PS C:\> Test-DMUser
 
            Tests whether the configured users' state matches the current domain user setup.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
    [CmdletBinding()]
    param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Users -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process
    {
        #region Process Configured Users
        :main foreach ($userDefinition in $script:users.Values) {
            $resolvedSamAccName = Resolve-String -Text $userDefinition.SamAccountName

            $resultDefaults = @{
                Server = $Server
                ObjectType = 'User'
                Identity = $resolvedSamAccName
                Configuration = $userDefinition
            }

            #region User that needs to be removed
            if (-not $userDefinition.Present) {
                try { $adObject = Get-ADUser @parameters -Identity $resolvedSamAccName -Properties Description, PasswordNeverExpires -ErrorAction Stop }
                catch { continue } # Only errors when user not present = All is well
                
                New-TestResult @resultDefaults -Type Delete -ADObject $adObject
                continue
            }
            #endregion User that needs to be removed

            #region Users that don't exist but should | Users that need to be renamed
            try { $adObject = Get-ADUser @parameters -Identity $resolvedSamAccName -Properties Description, PasswordNeverExpires -ErrorAction Stop }
            catch
            {
                $oldUsers = foreach ($oldName in ($userDefinition.OldNames | Resolve-String)) {
                    try { Get-ADUser @parameters -Identity $oldName -Properties Description, PasswordNeverExpires -ErrorAction Stop }
                    catch { }
                }

                switch (($oldUsers | Measure-Object).Count) {
                    #region Case: No old version present
                    0
                    {
                        if (-not ($userDefinition.Optional -or ($userDefinition.Present -eq 'Undefined'))) {
                            New-TestResult @resultDefaults -Type Create
                        }
                        continue main
                    }
                    #endregion Case: No old version present

                    #region Case: One old version present
                    1
                    {
                        New-TestResult @resultDefaults -Type Rename -ADObject $oldUsers
                        continue main
                    }
                    #endregion Case: One old version present

                    #region Case: Too many old versions present
                    default
                    {
                        New-TestResult @resultDefaults -Type MultipleOldUsers -ADObject $oldUsers
                        continue main
                    }
                    #endregion Case: Too many old versions present
                }
            }
            #endregion Users that don't exist but should | Users that need to be renamed

            #region Existing Users, might need updates
            # $adObject contains the relevant object

            [System.Collections.ArrayList]$changes = @()
            $compare = @{
                Configuration = $userDefinition
                ADObject = $adObject
                Changes = $changes
                AsUpdate = $true
                Type = 'User'
            }
            Compare-Property @compare -Property GivenName -Resolve
            Compare-Property @compare -Property Surname -Resolve
            if ($null -ne $userDefinition.Description) { Compare-Property @compare -Property Description -Resolve }
            Compare-Property @compare -Property PasswordNeverExpires
            Compare-Property @compare -Property UserPrincipalName -Resolve
            Compare-Property @compare -Property Name -Resolve
            $ouPath = ($adObject.DistinguishedName -split ",",2)[1]
            if ($ouPath -ne (Resolve-String -Text $userDefinition.Path)) {
                $null = $changes.Add((New-Change -Property Path -OldValue $ouPath -NewValue (Resolve-String -Text $userDefinition.Path) -Identity $adObject -Type User))
            }
            if ($userDefinition.Enabled -ne "Undefined") {
                Compare-Property @compare -Property Enabled
            }
            if ($changes.Count) {
                New-TestResult @resultDefaults -Type Changed -Changed $changes.ToArray() -ADObject $adObject
            }
            #endregion Existing Users, might need updates
        }
        #endregion Process Configured Users

        #region Process Managed Containers
        $foundUsers = foreach ($searchBase in (Resolve-ContentSearchBase @parameters)) {
            Get-ADUser @parameters -LDAPFilter '(!(isCriticalSystemObject=TRUE))' -SearchBase $searchBase.SearchBase -SearchScope $searchBase.SearchScope
        }

        $resolvedConfiguredNames = $script:users.Values.SamAccountName | Resolve-String
        $exclusionPattern = $script:contentMode.UserExcludePattern -join "|"

        $resultDefaults = @{
            Server = $Server
            ObjectType = 'User'
        }

        foreach ($existingUser in $foundUsers) {
            if ($existingUser.SamAccountName -in $resolvedConfiguredNames) { continue } # Ignore configured users - they were previously configured for moving them, if they should not be in these containers
            if (1000 -ge ($existingUser.SID -split "-")[-1]) { continue } # Ignore BuiltIn default users
            if ($exclusionPattern -and $existingUser.Name -match $exclusionPattern) { continue } # Skip whitelisted usernames

            New-TestResult @resultDefaults -Type Delete -ADObject $existingUser -Identity $existingUser.Name
        }
        #endregion Process Managed Containers
    }
}

function Unregister-DMUser
{
    <#
    .SYNOPSIS
        Removes a user that had previously been registered.
     
    .DESCRIPTION
        Removes a user that had previously been registered.
     
    .PARAMETER Name
        The name of the user to remove.
     
    .EXAMPLE
        PS C:\> Get-DMUser | Unregister-DMUser
 
        Clears all registered users.
    #>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('SamAccountName')]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameItem in $Name) {
            $script:users.Remove($nameItem)
        }
    }
}


function Get-DMWmiFilter {
    <#
    .SYNOPSIS
        Returns all registered WMI filter definitions.
     
    .DESCRIPTION
        Returns all registered WMI filter definitions.
     
    .PARAMETER Name
        Name of the definition to filter by.
        Defaults to: *
     
    .EXAMPLE
        PS C:\> Get-DMWmiFilter
 
        Returns all registered WMI filter definitions.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name = '*'
    )
    
    process {
        ($script:wmifilter.Values | Where-Object Name -Like $Name)
    }
}


function Invoke-DMWmiFilter {
    <#
    .SYNOPSIS
        Applies the desired state of WMI Filters to the target domain.
     
    .DESCRIPTION
        Applies the desired state of WMI Filters to the target domain.
        Use Register-DMWmiFilter to define the desired state.
     
    .PARAMETER InputObject
        Individual test results to apply.
        Use Test-DMWmiFilter to generate these test result objects.
        If none are specified, it will instead execute its own test and apply all test results.
     
    .PARAMETER Server
        The server / domain to work with.
         
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .EXAMPLE
        PS C:\> Invoke-DMWmiFilter -Server fabrikam.org
     
        Brings the fabrikam.org domain into compliance with the defined wmi filter configuration.
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,
        
        [switch]
        $EnableException
    )
    
    begin {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type wmifilter -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process {
        if (-not $InputObject) {
            $InputObject = Test-DMWmiFilter @parameters
        }

        :main foreach ($testItem in $InputObject) {
            # Catch invalid input - can only process test results
            if ($testItem.PSObject.TypeNames -notcontains 'DomainManagement.WmiFilter.TestResult') {
                Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-DMWmiFilter', $testItem -Target $testItem -Continue -EnableException $EnableException
            }
            
            switch ($testItem.Type) {
                'Create' {
                    $newID = ('{{{0}}}' -f ([Guid]::NewGuid())).ToUpper()
                    $newParam = @{
                        Path            = 'CN=SOM,CN=WMIPolicy,CN=System,%DomainDN%' | Resolve-String
                        Name            = $newID
                        Type            = 'msWMI-Som'
                        OtherAttributes = @{
                            'msWMI-Name'         = $testItem.Configuration.Name
                            'msWMI-Author'       = $testItem.Configuration.Author
                            'msWMI-CreationDate' = '{0:yyyyMMddHHmmss.fff}000-000' -f $testItem.Configuration.CreatedOn
                            'msWMI-ChangeDate'   = '{0:yyyyMMddHHmmss.fff}000-000' -f $testItem.Configuration.CreatedOn
                            'msWMI-Parm1'        = $testItem.Configuration.Description | Resolve-String
                            'msWMI-Parm2'        = $testItem.Configuration.GetQueryString()
                            'msWMI-ID'           = $newID
                        }
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMWmiFilter.Creating' -ActionStringValues $testItem.Identity -Target $testItem.Identity -ScriptBlock {
                        New-ADObject @parameters @newParam -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet
                }
                'Update' {
                    $replaceHash = @{ }
                    foreach ($change in $testItem.Changed) {
                        switch ($change.Property) {
                            Author { $replaceHash['msWMI-Author'] = $change.New }
                            Description { $replaceHash['msWMI-Parm1'] = $change.New }
                            CreatedOn {
                                $replaceHash['msWMI-CreationDate'] = '{0:yyyyMMddHHmmss.fff}000-000' -f $change.New
                                $replaceHash['msWMI-ChangeDate'] = '{0:yyyyMMddHHmmss.fff}000-000' -f $change.New
                            }
                            Query { $replaceHash['msWMI-Parm2'] = $testItem.Configuration.GetQueryString() }
                        }
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMWmiFilter.Updating' -ActionStringValues $testItem.Identity -Target $testItem.Identity -ScriptBlock {
                        Set-ADObject @parameters -Replace $replaceHash -Identity $testItem.ADObject.DistinguishedName -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet
                }
                'Delete' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-DMWmiFilter.Deleting' -ActionStringValues $testItem.Identity -Target $testItem.Identity -ScriptBlock {
                        Remove-ADObject @parameters -Identity $testItem.ADObject.DistinguishedName -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet
                }
            }
        }
    }
}

function Register-DMWmiFilter {
    <#
    .SYNOPSIS
        Registers the definition of a WMI Filter as desired state.
     
    .DESCRIPTION
        Registers the definition of a WMI Filter as desired state.
     
    .PARAMETER Name
        Name of the WMI Filter (must be unique in domain).
     
    .PARAMETER Description
         A description of the WMI filter
     
    .PARAMETER Query
        The filter query/ies to apply.
        Can be multiple queries, defaults to the WMI namespace defined in the namespace parameter.
        To specify a namespace with the query, use this notation: {namespace};{query}
        (without the curly braces).
        Examples:
        SELECT * FROM Win32_OperatingSystem WHERE Caption like "Microsoft Windows 10%"
        root\CIMv2;SELECT * FROM Win32_OperatingSystem WHERE Caption like "Microsoft Windows 10%"
     
    .PARAMETER Namespace
        The WMI namespace in which the queries will be executed by default.
        Defaults to: root\CIMv2
     
    .PARAMETER Author
        The author of the WMI filter. Purely documentational.
        Defaults to: undefined
     
    .PARAMETER CreatedOn
        The timestamp the WMI filter was defined at. Purely documentational.
        Defaults to: Get-Date
     
    .PARAMETER ContextName
        The name of the context defining the setting.
        This allows determining the configuration set that provided this setting.
        Used by the ADMF, available to any other configuration management solution.
     
    .EXAMPLE
        PS C:\> Get-Content .\wmifilters.json | ConvertFrom-Json | Write-Output | Register-DMWmiFilter
     
        Load up all settings defined in wmifilters.json
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Description,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Query,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $Namespace = 'root\CIMv2',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $Author = 'undefined',

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [DateTime]
        $CreatedOn = (Get-Date),
        
        [string]
        $ContextName = '<Undefined>'
    )
    
    process {
        $queries = foreach ($entry in $Query) {
            $currentNamespace = $Namespace
            $currentQuery = $entry
            if ($entry -like "*;*") {
                $currentNamespace, $currentQuery = $entry -split ";"
            }
            $item = [PSCustomObject]@{
                Namespace = $currentNamespace
                Query = $currentQuery
            }
            Add-Member -InputObject $item -MemberType ScriptMethod -Name ToQuery -Value {
                '3;{0};{1};WQL;{2};{3};' -f $this.Namespace.Length, $this.Query.Length, $this.Namespace, $this.Query
            }
            Add-Member -InputObject $item -MemberType ScriptMethod -Name ToString -Value {
                '{0}: {1}' -f $this.Namespace, $this.Query
            } -Force -PassThru
        }

        $script:wmifilter[$Name] = [PSCustomObject]@{
            PSTypeName  = 'DomainManagement.Configuration.WmiFilter'
            Name        = $Name
            Description = $Description
            Query       = $queries
            Author      = $Author
            CreatedOn   = $CreatedOn
            ContextName = $ContextName
        }

        Add-Member -InputObject $script:wmifilter[$Name] -MemberType ScriptMethod -Name GetQueryString -Value {
            '{0};{1}' -f $this.Query.Count, ($this.Query | ForEach-Object ToQuery | Join-String "")
        }
    }
}

function Test-DMWmiFilter {
    <#
    .SYNOPSIS
        Tests, whether the WMI Filter conform to the desired state
     
    .DESCRIPTION
        Tests, whether the WMI Filter conform to the desired state
         
        Use Register-DMWmiFilter to define the desired state.
        Use Invoke-DMWmiFilter to bring the target domain into the desired state.
     
    .PARAMETER Server
        The server / domain to work with.
         
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Test-DMWmiFilter -Server contoso.com
 
        Checks whether the "contoso.com"-domain's WMI filters are in the desired state.
    #>

    [CmdletBinding()]
    Param (
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin {
        #region Utility Functions
        function Compare-WmiFilter {
            [CmdletBinding()]
            param (
                $Configuration,

                $ADFilters,

                [Hashtable]
                $Parameters
            )

            $tresult = @{
                ObjectType    = 'WmiFilter'
                Identity      = $Configuration.Name
                Server        = $Parameters.Server
                Configuration = $Configuration
            }

            if ($Configuration.Name -notin $ADFilters.Name) {
                New-TestResult @tresult -Type Create
                return
            }
            $adFilter = $ADFilters | Where-Object Name -EQ $Configuration.Name | Select-Object -First 1
            $tresult.ADObject = $adFilter

            $changes = [System.Collections.ArrayList]::new()
            $compare = @{
                Configuration = $Configuration
                ADObject      = $adFilter
                Changes       = $changes
                AsUpdate      = $true
                Type          = 'WmiFilter'
            }
            Compare-Property @compare -Property Author
            Compare-Property @compare -Property Description -Resolve
            Compare-Property @compare -Property CreatedOn -ADProperty CreationDate

            #region Compare WMI Filter Conditions
            #region Verify whether all intended queries are already applied
            foreach ($query in $Configuration.Query) {
                if ($adFilter.Query | Where-Object { $_.Query -eq $query.Query -and $_.Namespace -eq $query.Namespace }) {
                    continue
                }
                $change = New-Change -Property Query -OldValue $adFilter.Query -NewValue $Configuration.Query -Identity $Configuration.name -Type WmiFilter
                $changes.Add($change)
                break
            }
            #endregion Verify whether all intended queries are already applied

            #region Check for extra queries in existing WMI Filter
            if ($changes.Property -notcontains 'Query') {
                foreach ($query in $adFilter.Query) {
                    if ($Configuration.Query | Where-Object { $_.Query -eq $query.Query -and $_.Namespace -eq $query.Namespace }) {
                        continue
                    }
                    $change = New-Change -Property Query -OldValue $adFilter.Query -NewValue $Configuration.Query -Identity $Configuration.name -Type WmiFilter
                    $changes.Add($change)
                    break
                }
            }
            #endregion Check for extra queries in existing WMI Filter
            #endregion Compare WMI Filter Conditions

            if ($changes.Count -lt 1) { return }

            New-TestResult @tresult -Type Update -Changed $changes
        }
        #endregion Utility Functions

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type wmifilter -Cmdlet $PSCmdlet
        Set-DMDomainContext @parameters
    }
    process {
        $adWmiFilter = Get-ADWmiFilter @parameters
        $configWmiFilter = Get-DMWmiFilter

        foreach ($filter in $configWmiFilter) {
            Compare-WmiFilter -Configuration $filter -ADFilters $adWmiFilter -Parameters $parameters
        }

        #region Process Undefined WmiFilters that exist in AD
        if (-not $script:contentMode.RemoveUnknownWmiFilter) { return }

        foreach ($filter in $adWmiFilter) {
            if ($filter.Name -in $configWmiFilter.Name) { continue }

            New-TestResult -ObjectType WmiFilter -Type Delete -Identity $filter.Name -Server $Server -ADObject $filter
        }
        #endregion Process Undefined WmiFilters that exist in AD
    }
}


function Unregister-DMWmiFilter {
    <#
    .SYNOPSIS
        Removes a WMI filter definition from the desired state.
     
    .DESCRIPTION
        Removes a WMI filter definition from the desired state.
     
    .PARAMETER Name
        Name of the WMI filter definition to remove.
     
    .EXAMPLE
        PS c:\> Get-DMWmiFilter | Unregister-DMWmiFilter
 
        Clears all WMI filter definitions from the desired state.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process {
        foreach ($entry in $Name) {
            $script:wmifilter.Remove($entry)
        }
    }
}


<#
This is an example configuration file
 
By default, it is enough to have a single one of them,
however if you have enough configuration settings to justify having multiple copies of it,
feel totally free to split them into multiple files.
#>


<#
# Example Configuration
Set-PSFConfig -Module 'DomainManagement' -Name 'Example.Setting' -Value 10 -Initialize -Validation 'integer' -Handler { } -Description "Example configuration setting. Your module can then use the setting using 'Get-PSFConfigValue'"
#>


Set-PSFConfig -Module 'DomainManagement' -Name 'Import.DoDotSource' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be dotsourced on import. By default, the files of this module are read as string value and invoked, which is faster but worse on debugging."
Set-PSFConfig -Module 'DomainManagement' -Name 'Import.IndividualFiles' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be imported individually. During the module build, all module code is compiled into few files, which are imported instead by default. Loading the compiled versions is faster, using the individual files is easier for debugging and testing out adjustments."

Set-PSFConfig -Module 'DomainManagement' -Name 'ServiceAccount.SkipKdsCheck' -Value $false -Initialize -Validation bool -Description 'Whether the check for a KDS Root Key should be skipped. By default, Invoke-DMServiceAccount will validate the necessary key exists before creating gMSA. However, reading the key requires Domain Admin privileges, which may not always be available. Skipping the check will cause gMSA creation to fail with an error, if the KDSRootKey does not yet exist.'
Set-PSFConfig -Module 'DomainManagement' -Name 'AccessRules.Remove.Option2' -Value $false -Initialize -Validation bool -Description 'In some environments, the default way of removing access rules have proved to not work out. Using this option enables a second way for removing access rules.'

Set-PSFScriptblock -Name 'DomainManagement.Validate.GPPermissionFilter' -Scriptblock {
    $tokens = $null
    $errors = $null
    $null = [System.Management.Automation.Language.Parser]::ParseInput($_, [ref]$tokens, [ref]$errors)

    if ($errors) {
        Write-PSFMessage -Level Warning -String 'Validate.GPPermissionFilter.SyntaxError' -StringValues $_ -ModuleName 'DomainManagement' -FunctionName 'Validate-GPPermissionFilter'
        return $false
    }

    $validTokenTypes = 'Identifier', 'Parameter', 'LParen', 'RParen', 'EndOfInput', 'And', 'Not', 'Or', 'Xor'
    $invalidTokenTypes = $tokens | Where-Object Kind -notin $validTokenTypes | Select-Object -ExpandProperty  Kind -Unique
    if ($invalidTokenTypes) {
        Write-PSFMessage -Level Warning -String 'Validate.GPPermissionFilter.InvalidTokenType' -StringValues $_, ($invalidTokenTypes -join ', ') -ModuleName 'DomainManagement' -FunctionName 'Validate-GPPermissionFilter'
        return $false
    }

    $validParameters = '-and', '-or', '-not', '-xor'
    $invalidParameters = $tokens | Where-Object Kind -eq Parameter | Where-Object Text -notin $validParameters | Select-Object -ExpandProperty Text -Unique
    if ($invalidParameters) {
        Write-PSFMessage -Level Warning -String 'Validate.GPPermissionFilter.InvalidParameters' -StringValues $_, ($invalidParameters -join ', ') -ModuleName 'DomainManagement' -FunctionName 'Validate-GPPermissionFilter'
        return $false
    }

    $validIdentifierPattern = '^[\w\d_]+$'
    $invalidIdentifiers = $tokens | Where-Object Kind -eq Identifier | Where-Object Text -notmatch $validIdentifierPattern | Select-Object -ExpandProperty Text -Unique
    if ($invalidIdentifiers) {
        Write-PSFMessage -Level Warning -String 'Validate.GPPermissionFilter.InvalidIdentifiers' -StringValues $_, ($invalidIdentifiers -join ', ') -ModuleName 'DomainManagement' -FunctionName 'Validate-GPPermissionFilter'
        return $false
    }
    return $true
}

Set-PSFScriptblock -Name 'DomainManagement.Validate.Identity' -Scriptblock {
    if ($_ -as [System.Security.Principal.SecurityIdentifier]) { return $true }
    if (($_ -replace '%[\d\w_]+%','S-1-0-00-0000000000-0000000000-0000000000') -as [System.Security.Principal.SecurityIdentifier]) { return $true }
    if ($_ -like "*@*") { return $true }
    if ($_ -like "*\*") { return $true }
    $false
}

Set-PSFScriptblock -Name 'DomainManagement.Validate.TypeName.AccessRule' -Scriptblock {
    ($_.PSObject.TypeNames -contains 'DomainManagement.AccessRule')
}

<#
Stored scriptblocks are available in [PsfValidateScript()] attributes.
This makes it easier to centrally provide the same scriptblock multiple times,
without having to maintain it in separate locations.
 
It also prevents lengthy validation scriptblocks from making your parameter block
hard to read.
 
Set-PSFScriptblock -Name 'DomainManagement.ScriptBlockName' -Scriptblock {
     
}
#>


Register-PSFTeppScriptblock -Name 'DomainManagement.GPPermission.GpoName' -ScriptBlock {
    (Get-DMGPPermission).GpoName
}
Register-PSFTeppScriptblock -Name 'DomainManagement.GPPermission.Identity' -ScriptBlock {
    (Get-DMGPPermission).Identity
}
Register-PSFTeppScriptblock -Name 'DomainManagement.GPPermission.Filter' -ScriptBlock {
    (Get-DMGPPermission).Filter
}

Register-PSFTeppArgumentCompleter -Command Get-DMGPPermission -Parameter GpoName -Name 'DomainManagement.GPPermission.GpoName'
Register-PSFTeppArgumentCompleter -Command Get-DMGPPermission -Parameter Identity -Name 'DomainManagement.GPPermission.Identity'
Register-PSFTeppArgumentCompleter -Command Get-DMGPPermission -Parameter Filter -Name 'DomainManagement.GPPermission.Filter'

Register-PSFTeppScriptblock -Name 'DomainManagement.GPPermissionFilter.Name' -ScriptBlock {
    (Get-DMGPPermissionFilter).Name
}
Register-PSFTeppArgumentCompleter -Command Get-DMGPPermissionFilter -Parameter Name -Name 'DomainManagement.GPPermissionFilter.Name'
Register-PSFTeppArgumentCompleter -Command Unregister-DMGPPermissionFilter -Parameter Name -Name 'DomainManagement.GPPermissionFilter.Name'

<#
# Example:
Register-PSFTeppArgumentCompleter -Command Get-Alcohol -Parameter Type -Name DomainManagement.alcohol
#>


New-PSFLicense -Product 'DomainManagement' -Manufacturer 'Friedrich Weinmann' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2019-08-09") -Text @"
Copyright (c) 2019 Friedrich Weinmann
 
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
 
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
 
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"@


$PSDefaultParameterValues['Resolve-String:ModuleName'] = 'ADMF.Core'
$PSDefaultParameterValues['Register-StringMapping:ModuleName'] = 'ADMF.Core'
$PSDefaultParameterValues['Clear-StringMapping:ModuleName'] = 'ADMF.Core'
$PSDefaultParameterValues['Unregister-StringMapping:ModuleName'] = 'ADMF.Core'

Register-PSFCallback -Name DomainManagement.ConfigurationReset -ModuleName ADMF.Core -CommandName Clear-AdcConfiguration -ScriptBlock {
    Clear-DMConfiguration
}

# NOTE: All variables in this file will be cleared when using Clear-DMConfiguration
# That generally happens when switching between sets of configuration

 #----------------------------------------------------------------------------#
 # Configuration #
 #----------------------------------------------------------------------------#

# Mapping table of values to insert
$script:nameReplacementTable = @{ }

# Configured Organizational Units
$script:organizationalUnits = @{ }

# Configured groups
$script:groups = @{ }

# Configured users
$script:users = @{ }

# Configured Group Memberships
$script:groupMemberShips = @{ }

# Configured Finegrained Password Policies
$script:passwordPolicies = @{ }

# Configured group policy objects
$script:groupPolicyObjects = @{ }

# Configured group policy registry settings
$script:groupPolicyRegistrySettings = @{ }

# Configured group policy links
$script:groupPolicyLinks = @{ }
$script:groupPolicyLinksDynamic = @{ }

# Configured group policy permission filters
$script:groupPolicyPermissionFilters = @{ }

# Configured group policy permissions
$script:groupPolicyPermissions = @{ }

# Configured owners of group policy objects
$script:groupPolicyOwners = @{ }

# Configured ACLs
$script:acls = @{ }
$script:aclByCategory = @{ }
$script:aclDefaultOwner = $null

# Configured Access Rules - Based on OU / Path
$script:accessRules = @{ }

# Configured Access Rule processing Modes
$script:accessRuleMode = @{ }

# Configured Access Rules - Based on Object Category
$script:accessCategoryRules = @{ }

# Configured Object Categories
$script:objectCategories = @{ }

# Configured generic objects
$script:objects = @{ }

# Configured data gathering scripts
$script:domainDataScripts = @{ }

# Configured domain functional level
$script:domainLevel = $null

# Configured Exchange Domain Setting Versions
$script:exchangeVersion = $null

# Configured Group Managed Service Accounts
$script:serviceAccounts = @{ }

# Configured WMI Filter
$script:wmifilter = @{ }


#----------------------------------------------------------------------------#
 # Cached Data #
 #----------------------------------------------------------------------------#

# Cached security principals, used by Get-Principal. Mapping to AD Objects
$script:resolvedPrincipals = @{ }

# More principal caching, used by Convert-Principal. Mapping to SID or NT Account
$script:cache_PrincipalToSID = @{ }
$script:cache_PrincipalToNT = @{ }

# Cached domain data, used by Invoke-DMDomainData. Can be any script logic result
$script:cache_DomainData = @{ }

# Domain mapping cache, used by Get-Domain
$script:SIDtoDomain = @{ }
$script:DNStoDomain = @{ }
$script:DNStoDomainName = @{ }
$script:NetBiostoDomain = @{ }


 #----------------------------------------------------------------------------#
 # Context Data #
 #----------------------------------------------------------------------------#

# Content Mode
$script:contentMode = [PSCustomObject]@{
    PSTypeName = 'DomainManagement.Content.Mode'
    Mode    = 'Additive'
    Include = @()
    Exclude = @()
    UserExcludePattern = @()
    RemoveUnknownWmiFilter = $false
}
$script:contentSearchBases = [PSCustomObject]@{
    Include = @()
    Exclude = @()
    Bases   = @()
    Server = ''
}

# Domain Context
$script:domainContext = [PSCustomObject]@{
    Name = ''
    Fqdn = ''
    DN   = ''
    ForestFqdn = ''
}

# Red Forest Context
$script:redForestContext = [PSCustomObject]@{
    Name = ''
    Fqdn = ''
    RootDomainFqdn = ''
    RootDomainName = ''
}

# File for variables that should NOT be reset on context changes
$script:builtInSidMapping = @{
    # English
    'BUILTIN\Account Operators'                                         = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-548'
    'BUILTIN\Server Operators'                                          = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-549'
    'BUILTIN\Print Operators'                                           = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-550'
    'BUILTIN\Pre-Windows 2000 Compatible Access'                        = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-554'
    'BUILTIN\Incoming Forest Trust Builders'                            = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-557'
    'BUILTIN\Windows Authorization Access Group'                        = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-560'
    'BUILTIN\Terminal Server License Servers'                           = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-561'
    'BUILTIN\Certificate Service DCOM Access'                           = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-574'
    'BUILTIN\RDS Remote Access Servers'                                 = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-575'
    'BUILTIN\RDS Endpoint Servers'                                      = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-576'
    'BUILTIN\RDS Management Servers'                                    = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-577'
    'BUILTIN\Storage Replica Administrators'                            = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-582'

    # Deutsch
    'BUILTIN\Konten-Operatoren'                                         = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-548'
    'BUILTIN\Server-Operatoren'                                         = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-549'
    'BUILTIN\Druck-Operatoren'                                          = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-550'
    'BUILTIN\Prä-Windows 2000 kompatibler Zugriff'                      = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-554'
    'BUILTIN\Erstellungen eingehender Gesamtstrukturvertrauensstellung' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-557'
    'BUILTIN\Windows-Autorisierungszugriffsgruppe'                      = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-560'
    'BUILTIN\Terminalserver-Lizenzserver'                               = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-561'
    'BUILTIN\Zertifikatdienst-DCOM-Zugriff'                             = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-574'
    # 'BUILTIN\RDS Remote Access Servers' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-575'
    # 'BUILTIN\RDS Endpoint Servers' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-576'
    # 'BUILTIN\RDS Management Servers' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-577'
    # 'BUILTIN\Storage Replica Administrators' = [System.Security.Principal.SecurityIdentifier]'S-1-5-32-582'
}
#endregion Load compiled code