ForestManagement.psm1

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

# Detect whether at some level dotsourcing was enforced
$script:doDotSource = Get-PSFConfigValue -FullName ForestManagement.Import.DoDotSource -Fallback $false
if ($ForestManagement_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 ForestManagement.Import.IndividualFiles -Fallback $false
if ($ForestManagement_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 'ForestManagement' -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 terminate said calling command if relevant settings are missing
     
    .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
    {
        if ((Get-Variable -Name $Type -Scope Script -ValueOnly).Count -gt 0) { return }
        
        Write-PSFMessage -Level Warning -String 'Assert-Configuration.NotConfigured' -StringValues $Type -FunctionName $Cmdlet.CommandRuntime

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


function Compare-SchemaProperty {
    <#
    .SYNOPSIS
        Compares configuration vs. adobject of schema attributes.
     
    .DESCRIPTION
        Compares configuration vs. adobject of schema attributes.
        Designed for use when comparing schema attributes, for example in Test-FMSchemaLdif.
 
        Returns $true when the values are INEQUAL.
     
    .PARAMETER Setting
        The settings object containing the desired state for an attribute.
     
    .PARAMETER ADObject
        The ADObject of the attribute to compare.
     
    .PARAMETER PropertyName
        The property to compare.
     
    .PARAMETER RootDSE
        The RootDSE object connected to.
        Used for objectCategory comparisons.
 
    .PARAMETER Add
        Is satisfied with the defined items being part of the AD object property, without requiring an exact match between configuration and ad.
     
    .EXAMPLE
        PS C:\> Compare-SchemaProperty -Setting $setting -ADObject $adObject -PropertyName attributeSecurityGUID -RootDSE $rootDSE
 
        Returns, whether the values found in $setting and $adObject are different from each other.
    #>

    [OutputType([System.Boolean])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        $Setting,
        [Parameter(Mandatory=$true)]
        $ADObject,
        [Parameter(Mandatory=$true)]
        $PropertyName,
        [Parameter(Mandatory=$true)]
        $RootDSE,
        [switch]
        $Add
    )

    switch ($PropertyName) {
        'schemaIDGUID' {
            return (($Setting.$PropertyName.GuidData -join '|') -ne ($ADObject.$PropertyName -join '|'))
        }
        'attributeSecurityGUID' {
            return (($Setting.$PropertyName.GuidData -join '|') -ne ($ADObject.$PropertyName -join '|'))
        }
        'objectCategory' {
            return (($Setting.$PropertyName -replace '<SchemaContainerDN>',$RootDSE.schemaNamingContext) -ne ($ADObject.$PropertyName -join '|'))
        }
        'DistinguishedName' {
            # Don't compare identifiers!
            return $false
        }
        'Description' {
            # Prevent encoding errors / issues from falsifying the results
            if (($null -eq $Setting.$PropertyName) -and ($null -eq ($ADObject.$PropertyName | Select-Object -Unique))) { return $false }
            if ($null -eq $Setting.$PropertyName) { return $true }
            if ($null -eq ($ADObject.$PropertyName | Select-Object -Unique)) { return $true }
            return (($Setting.$PropertyName -replace "[^\d\w]","_") -ne ($ADObject.$PropertyName -replace "[^\d\w]","_"))
        }
        'mayContain' {
            if (($null -eq $Setting.$PropertyName) -and ($null -eq ($ADObject.$PropertyName | Select-Object -Unique))) { return $false }
            if ($null -eq $Setting.$PropertyName) { return $true }
            if ($null -eq ($ADObject.$PropertyName | Select-Object -Unique)) { return $true }
            return [bool](Compare-Object ($Setting.$PropertyName | Select-Object -Unique) ($ADObject.$PropertyName | Select-Object -Unique) | Where-Object SideIndicator -eq '<=')
        }
        default {
            if (($null -eq $Setting.$PropertyName) -and ($null -eq ($ADObject.$PropertyName | Select-Object -Unique))) { return $false }
            if ($null -eq $Setting.$PropertyName) { return $true }
            if ($null -eq ($ADObject.$PropertyName | Select-Object -Unique)) { return $true }
            if ($Add) { return [bool](Compare-Object ($Setting.$PropertyName | Select-Object -Unique) ($ADObject.$PropertyName | Select-Object -Unique) | Where-Object SideIndicator -eq '<=') }
            return [bool](Compare-Object $Setting.$PropertyName $ADObject.$PropertyName)
        }
    }
}

function Compare-SiteLink
{
    <#
    .SYNOPSIS
        Compares two sitelink objects.
     
    .DESCRIPTION
        Compares two sitelink objects.
        Returns the DifferenceSiteLink if it uses the same sites as the reference sitelink, no matter the order.
     
    .PARAMETER ReferenceSiteLink
        The sitelink to compare to input with.
     
    .PARAMETER DifferenceSiteLink
        The sitelink(s) to compare.
     
    .EXAMPLE
        $script:sitelinks.Values | Compare-SiteLink $refSiteLink
 
        Returns any registered sitelinks that span the same sites as $refSiteLink (Should never be more than 1!)
    #>

    
    [CmdletBinding()]
    Param (
        [Parameter(Position = 0)]
        $ReferenceSiteLink,

        [Parameter(ValueFromPipeline = $true)]
        $DifferenceSiteLink
    )
    
    process
    {
        foreach ($diffSiteLink in $DifferenceSiteLink) {
            if (($diffSiteLink.Site1 -eq $ReferenceSiteLink.Site1) -and ($diffSiteLink.Site2 -eq $ReferenceSiteLink.Site2)) {
                $diffSiteLink
                continue
            }
            if (($diffSiteLink.Site1 -eq $ReferenceSiteLink.Site2) -and ($diffSiteLink.Site2 -eq $ReferenceSiteLink.Site1)) {
                $diffSiteLink
                continue
            }
        }
    }
}


function ConvertTo-SchemaLdifPhase
{
    <#
    .SYNOPSIS
        Converts ldif files into a phased state index.
 
    .DESCRIPTION
        Converts ldif files into a phased state index.
        For each phase/file for each object it calculates the resulting state after ALL commands in the file have been executed.
        This allows stepping through the individual ldif files in the order they are to be applied and figure out the last applied deployment state.
         
    .PARAMETER LdifData
        The set of Ldif file definitions as returned by Get-FMSchemaLdif
 
    .EXAMPLE
        PS C:\> $ldifPhases = ConvertTo-SchemaLdifPhase -LdifData (Get-FMSchemaLdif)
 
        Returns the hashtable containing the different phases of all registered ldif files.
    #>

    [OutputType([Hashtable])]
    [CmdletBinding()]
    param (
        $LdifData
    )

    #region Utility Functions
    function Add-Node {
        [CmdletBinding()]
        param (
            [string]
            $DistinguishedName,

            [string]
            $LdifName,

            [Hashtable]
            $MappingTable
        )

        if (-not $MappingTable.ContainsKey($DistinguishedName)) {
            $MappingTable[$DistinguishedName] = @{ }
        }
        if (-not $MappingTable[$DistinguishedName][$LdifName]) {
            $MappingTable[$DistinguishedName][$LdifName] = @{
                State = @{ }
                Add = @{ }
                Replace = @{ }
            }
        }
    }
    function Write-Change {
        [CmdletBinding()]
        param (
            [string]
            $DistinguishedName,

            [string]
            $LdifName,

            $Change,

            [Hashtable]
            $MappingTable
        )

        Add-Node -DistinguishedName $DistinguishedName -LdifName $LdifName -MappingTable $MappingTable
        $datasheet = $MappingTable[$DistinguishedName][$LdifName]

        switch -regex ($Change.changetype) {
            'add' {
                $datasheet.State = @{ }
                foreach ($propertyName in $Change.PSObject.Properties.Name) {
                    if ($propertyName -in 'changeType', 'FM_OrderCount') { continue }
                    $datasheet.State[$propertyName] = $Change.$propertyName
                }
            }
            'modify' {
                #region We already have a defined state
                if ($datasheet.State.Count -gt 0) {
                    if ($Change.add) {
                        if ($datasheet.State.$($Change.add)) {
                            $datasheet.State.$($Change.add) = @($datasheet.State.$($Change.add)) + @($Change.$($Change.add))
                        }
                        else {
                            $datasheet.State[$Change.add] = $Change.$($Change.add)
                        }
                    }
                    elseif ($Change.replace) {
                        $datasheet.State[$Change.replace] = $Change.$($Change.replace)
                    }
                    else {
                        foreach ($propertyName in $Change.PSObject.Properties.Name) {
                            if ($propertyName -in 'DistinguishedName','changetype','FM_OrderCount') { continue }
                            $datasheet.State[$propertyName] = $Change.$propertyName
                        }
                    }
                }
                #endregion We already have a defined state

                #region Undefined state
                else {
                    if ($Change.add) {
                        if ($datasheet.Add.$($Change.add)) {
                            $datasheet.Add.$($Change.add) = @($datasheet.Add.$($Change.add)) + @($Change.$($Change.add))
                        }
                        else {
                            $datasheet.Add[$Change.add] = $Change.$($Change.add)
                        }
                    }
                    elseif ($Change.replace) {
                        $datasheet.Replace[$Change.replace] = $Change.$($Change.replace)
                    }
                    else {
                        foreach ($propertyName in $Change.PSObject.Properties.Name) {
                            if ($propertyName -in 'DistinguishedName','changetype','FM_OrderCount') { continue }
                            $datasheet.Replace[$propertyName] = $Change.$propertyName
                        }
                    }
                }
                #endregion Undefined state
            }
        }
    }

    function Copy-State {
        [CmdletBinding()]
        param (
            [Hashtable]
            $MappingTable,

            [string]
            $OldLdif,

            [string]
            $NewLdif
        )

        foreach ($name in $MappingTable.Keys) {
            Add-Node -DistinguishedName $name -LdifName $NewLdif -MappingTable $MappingTable

            foreach ($key in $MappingTable[$name][$OldLdif].State.Keys) {
                $MappingTable[$name][$NewLdif].State[$key] = $MappingTable[$name][$OldLdif].State[$key] | Write-Output
            }
            foreach ($key in $MappingTable[$name][$OldLdif].Add.Keys) {
                $MappingTable[$name][$NewLdif].Add[$key] = $MappingTable[$name][$OldLdif].Add[$key] | Write-Output
            }
            foreach ($key in $MappingTable[$name][$OldLdif].Replace.Keys) {
                $MappingTable[$name][$NewLdif].Replace[$key] = $MappingTable[$name][$OldLdif].Replace[$key] | Write-Output
            }
        }
    }

    function Remove-NoOp {
        [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
        [CmdletBinding()]
        param (
            $LdifData,

            [Hashtable]
            $MappingTable
        )

        $identities = $MappingTable.Keys | Write-Output
        foreach ($identity in $identities) {
            foreach ($ldifFile in $LdifData) {
                if (-not $MappingTable[$identity][$ldifFile.Name]) { continue }
                if ($ldifFile.Settings.DistinguishedName -contains $identity) { continue }
                $MappingTable[$identity].Remove($ldifFile.Name)
            }
        }
    }
    #endregion Utility Functions

    $mappingTable = @{ }

    $sortedLdif = $ldifData | Sort-Object Weight
    $previousLdif = ''
    foreach ($ldifItem in $sortedLdif) {
        if ($previousLdif) {
            Copy-State -MappingTable $mappingTable -OldLdif $previousLdif -NewLdif $ldifItem.Name
        }

        foreach ($setting in ($ldifItem.Settings | Sort-Object FM_OrderCount)) {
            Write-Change -DistinguishedName $setting.DistinguishedName -LdifName $ldifItem.Name -Change $setting -MappingTable $mappingTable
        }

        $previousLdif = $ldifItem.Name
    }

    Remove-NoOp -LdifData $sortedLdif -MappingTable $mappingTable
    $mappingTable
}

function Get-ADCertificate
{
<#
    .SYNOPSIS
        Returns forest certificates.
     
    .DESCRIPTION
        Returns forest certificates.
     
    .PARAMETER Parameters
        Hashtable containing AD connection values.
        May contain Server and Credential nodes but nothing else.
     
    .PARAMETER Type
        The kind of certificate to retrieve
     
    .EXAMPLE
        PS C:\> Get-ADCertificate -Parameters $parameters -Type NTAuthCA
     
        Returns all NTAuth certificates in the targeted forest.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [hashtable]
        $Parameters,
        
        [Parameter(Mandatory = $true)]
        [ValidateSet('NTAuthCA', 'RootCA', 'SubCA', 'CrossCA', 'KRA')]
        [string]
        $Type
    )
    
    begin
    {
        #region Utility Functions
        function Get-CertificateInternal
        {
            [CmdletBinding()]
            param (
                [string]
                $Object,
                
                [string]
                $Path,
                
                [string]
                $AltPath,
                
                [string]
                $NotInPath,
                
                [string]
                $AttributeName = 'cACertificate',
                
                [System.Collections.Hashtable]
                $Parameters
            )
            
            #region Single Object Processing
            if ($Object -eq 'Single')
            {
                try { $adObject = Get-ADObject @Parameters -Identity $Path -ErrorAction Stop -Properties $AttributeName }
                catch { return } # Object doesn't exist
                
                foreach ($certData in $adObject.$AttributeName)
                {
                    $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certData)
                    [pscustomobject]@{
                        Certificate = $certificate
                        Subject        = $certificate.Subject
                        Thumbprint  = $certificate.Thumbprint
                        ADObject    = $adObject
                        AltADObject = $null
                        AttributeName = $AttributeName
                    } | Add-Member -MemberType ScriptMethod -Name ToString -Value { '{0} (<{1:yyyy-MM-dd})' -f $this.Subject, $this.Certificate.NotAfter } -Force -PassThru
                }
                return
            }
            #endregion Single Object Processing
            
            $adObjects = Get-ADObject @Parameters -SearchBase $Path -SearchScope OneLevel -Filter * -ErrorAction Stop -Properties $AttributeName
            $existingCerts = foreach ($adObject in $adObjects)
            {
                foreach ($certData in $adObject.$AttributeName)
                {
                    if (-not $certData) { continue }
                    $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certData)
                    [pscustomobject]@{
                        Certificate = $certificate
                        Subject        = $certificate.Subject
                        Thumbprint  = $certificate.Thumbprint
                        ADObject    = $adObject
                        AltADObject = $null
                        AttributeName = $AttributeName
                    } | Add-Member -MemberType ScriptMethod -Name ToString -Value { '{0} (<{1:yyyy-MM-dd})' -f $this.Subject, $this.Certificate.NotAfter } -Force -PassThru
                }
            }
            
            #region AltPath
            # Contained in the original container and ALSO in the alternative container (e.g. RootCA Certificate)
            if ($AltPath)
            {
                $altAdObjects = Get-ADObject @Parameters -SearchBase $AltPath -SearchScope OneLevel -Filter * -ErrorAction Stop -Properties $AttributeName
                foreach ($adObject in $altAdObjects)
                {
                    $certificates = foreach ($certData in $adObject.$AttributeName)
                    {
                        if (-not $certData) { continue }
                        [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certData)
                    }
                    foreach ($existingCert in $existingCerts)
                    {
                        if ($existingCert.Thumbprint -notin $certificates.Thumbprint) { continue }
                        $existingCert.AltADObject = $adObject
                    }
                }
                $existingCerts = $existingCerts | Where-Object AltADObject
            }
            #endregion AltPath
            
            #region NotInPath
            # Contained in the original container and NOT in the alternative container (e.g. SubCA Certificate)
            if ($NotInPath)
            {
                $notInAdObjects = Get-ADObject @Parameters -SearchBase $NotInPath -SearchScope OneLevel -Filter * -ErrorAction Stop -Properties $AttributeName
                $certificates = foreach ($adObject in $notInAdObjects)
                {
                    foreach ($certData in $adObject.$AttributeName)
                    {
                        if (-not $certData) { continue }
                        [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certData)
                    }
                }
                $existingCerts = $existingCerts | Where-Object Thumbprint -NotIn $certificates.Thumbprint
            }
            #endregion NotInPath
            
            $existingCerts
        }
        #endregion Utility Functions
        
        $rootDSE = Get-ADRootDSE @parameters
        
        $mapping = @{
            NTAuthCA = @{
                Object = 'Single'
                Path   = "CN=NTAuthCertificates,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)"
            }
            RootCA   = @{
                Object = 'Multi'
                Path   = "CN=Certification Authorities,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)"
                AltPath = "CN=AIA,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)"
            }
            SubCA    = @{
                Object = 'Multi'
                Path   = "CN=AIA,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)"
                NotInPath = "CN=Certification Authorities,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)"
            }
            CrossCA  = @{
                Object = 'Multi'
                Path   = "CN=AIA,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)"
                AttributeName = 'crossCertificatePair'
            }
            KRA         = @{
                Object = 'Multi'
                Path   = "CN=KRA,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)"
                AttributeName = 'userCertificate'
            }
        }
    }
    process
    {
        $table = $mapping[$type]
        Get-CertificateInternal -Parameters $parameters @table
    }
}

function Get-ExchangeVersion
{
<#
    .SYNOPSIS
        Return Exchange Version Information.
     
    .DESCRIPTION
        Return Exchange Version Information.
     
    .PARAMETER Binding
        The Binding to use.
     
    .PARAMETER Name
        The name to filter by.
     
    .EXAMPLE
        PS C:\> Get-ExchangeVersion
     
        Return a list of all Exchange Versions
#>

    [CmdletBinding()]
    param (
        [string]
        $Binding,
        
        [string]
        $Name = '*'
    )
    
    begin
    {
        # Useful source: https://eightwone.com/references/schema-versions/
        $versionMapping = @{
            '2013RTM' = [PSCustomObject]@{ Name = 'Exchange 2013 RTM'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15449; RangeUpper = 15137; Binding = '2013RTM' }
            '2013CU1' = [PSCustomObject]@{ Name = 'Exchange 2013 CU1'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15614; RangeUpper = 15254; Binding = '2013CU1' }
            '2013CU2' = [PSCustomObject]@{ Name = 'Exchange 2013 CU2'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15688; RangeUpper = 15281; Binding = '2013CU2' }
            '2013CU3' = [PSCustomObject]@{ Name = 'Exchange 2013 CU3'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15763; RangeUpper = 15283; Binding = '2013CU3' }
            '2013SP1' = [PSCustomObject]@{ Name = 'Exchange 2013 SP1'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15844; RangeUpper = 15292; Binding = '2013SP1' }
            '2013CU5' = [PSCustomObject]@{ Name = 'Exchange 2013 CU5'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15870; RangeUpper = 15300; Binding = '2013CU5' }
            '2013CU6' = [PSCustomObject]@{ Name = 'Exchange 2013 CU6'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15965; RangeUpper = 15303; Binding = '2013CU6' }
            '2013CU7' = [PSCustomObject]@{ Name = 'Exchange 2013 CU7'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15965; RangeUpper = 15312; Binding = '2013CU7' }
            '2013CU8' = [PSCustomObject]@{ Name = 'Exchange 2013 CU8'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15965; RangeUpper = 15312; Binding = '2013CU8' }
            '2013CU9' = [PSCustomObject]@{ Name = 'Exchange 2013 CU9'; ObjectVersionDefault = 13236; ObjectVersionConfig = 15965; RangeUpper = 15312; Binding = '2013CU9' }
            '2013CU10' = [PSCustomObject]@{ Name = 'Exchange 2013 CU10'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU10' }
            '2013CU11' = [PSCustomObject]@{ Name = 'Exchange 2013 CU11'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU11' }
            '2013CU12' = [PSCustomObject]@{ Name = 'Exchange 2013 CU12'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU12' }
            '2013CU13' = [PSCustomObject]@{ Name = 'Exchange 2013 CU13'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU13' }
            '2013CU14' = [PSCustomObject]@{ Name = 'Exchange 2013 CU14'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU14' }
            '2013CU15' = [PSCustomObject]@{ Name = 'Exchange 2013 CU15'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU15' }
            '2013CU16' = [PSCustomObject]@{ Name = 'Exchange 2013 CU16'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU16' }
            '2013CU17' = [PSCustomObject]@{ Name = 'Exchange 2013 CU17'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU17' }
            '2013CU18' = [PSCustomObject]@{ Name = 'Exchange 2013 CU18'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU18' }
            '2013CU19' = [PSCustomObject]@{ Name = 'Exchange 2013 CU19'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU19' }
            '2013CU20' = [PSCustomObject]@{ Name = 'Exchange 2013 CU20'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU20' }
            '2013CU21' = [PSCustomObject]@{ Name = 'Exchange 2013 CU21'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16130; RangeUpper = 15312; Binding = '2013CU21' }
            '2013CU22' = [PSCustomObject]@{ Name = 'Exchange 2013 CU22'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16131; RangeUpper = 15312; Binding = '2013CU22' }
            '2013CU23' = [PSCustomObject]@{ Name = 'Exchange 2013 CU23'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16133; RangeUpper = 15312; Binding = '2013CU23' }
            '2016Preview' = [PSCustomObject]@{ Name = 'Exchange 2016 Preview'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16041; RangeUpper = 15317; Binding = '2016Preview' }
            '2016RTM' = [PSCustomObject]@{ Name = 'Exchange 2016 RTM'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16210; RangeUpper = 15317; Binding = '2016RTM' }
            '2016CU1' = [PSCustomObject]@{ Name = 'Exchange 2016 CU1'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16211; RangeUpper = 15323; Binding = '2016CU1' }
            '2016CU2' = [PSCustomObject]@{ Name = 'Exchange 2016 CU2'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16212; RangeUpper = 15325; Binding = '2016CU2' }
            '2016CU3' = [PSCustomObject]@{ Name = 'Exchange 2016 CU3'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16212; RangeUpper = 15326; Binding = '2016CU3' }
            '2016CU4' = [PSCustomObject]@{ Name = 'Exchange 2016 CU4'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15326; Binding = '2016CU4' }
            '2016CU5' = [PSCustomObject]@{ Name = 'Exchange 2016 CU5'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15326; Binding = '2016CU5' }
            '2016CU6' = [PSCustomObject]@{ Name = 'Exchange 2016 CU6'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15330; Binding = '2016CU6' }
            '2016CU7' = [PSCustomObject]@{ Name = 'Exchange 2016 CU7'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15332; Binding = '2016CU7' }
            '2016CU8' = [PSCustomObject]@{ Name = 'Exchange 2016 CU8'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15332; Binding = '2016CU8' }
            '2016CU9' = [PSCustomObject]@{ Name = 'Exchange 2016 CU9'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15332; Binding = '2016CU9' }
            '2016CU10' = [PSCustomObject]@{ Name = 'Exchange 2016 CU10'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15332; Binding = '2016CU10' }
            '2016CU11' = [PSCustomObject]@{ Name = 'Exchange 2016 CU11'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16214; RangeUpper = 15332; Binding = '2016CU11' }
            '2016CU12' = [PSCustomObject]@{ Name = 'Exchange 2016 CU12'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16215; RangeUpper = 15332; Binding = '2016CU12' }
            '2016CU13' = [PSCustomObject]@{ Name = 'Exchange 2016 CU13'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16217; RangeUpper = 15332; Binding = '2016CU13' }
            '2016CU14' = [PSCustomObject]@{ Name = 'Exchange 2016 CU14'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16217; RangeUpper = 15332; Binding = '2016CU14' }
            '2016CU15' = [PSCustomObject]@{ Name = 'Exchange 2016 CU15'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16217; RangeUpper = 15332; Binding = '2016CU15' }
            '2016CU16' = [PSCustomObject]@{ Name = 'Exchange 2016 CU16'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16217; RangeUpper = 15332; Binding = '2016CU16' }
            '2016CU17' = [PSCustomObject]@{ Name = 'Exchange 2016 CU17'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16217; RangeUpper = 15332; Binding = '2016CU17' }
            '2019Preview' = [PSCustomObject]@{ Name = 'Exchange 2019 Preview'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16213; RangeUpper = 15332; Binding = '2019Preview' }
            '2019RTM' = [PSCustomObject]@{ Name = 'Exchange 2019 RTM'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16751; RangeUpper = 17000; Binding = '2019RTM' }
            '2019CU1' = [PSCustomObject]@{ Name = 'Exchange 2019 CU1'; ObjectVersionDefault = 13236; ObjectVersionConfig = 16752; RangeUpper = 17000; Binding = '2019CU1' }
            '2019CU2' = [PSCustomObject]@{ Name = 'Exchange 2019 CU2'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16754; RangeUpper = 17001; Binding = '2019CU2' }
            '2019CU3' = [PSCustomObject]@{ Name = 'Exchange 2019 CU3'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16754; RangeUpper = 17001; Binding = '2019CU3' }
            '2019CU4' = [PSCustomObject]@{ Name = 'Exchange 2019 CU4'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16754; RangeUpper = 17001; Binding = '2019CU4' }
            '2019CU5' = [PSCustomObject]@{ Name = 'Exchange 2019 CU5'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16754; RangeUpper = 17001; Binding = '2019CU5' }
            '2019CU6' = [PSCustomObject]@{ Name = 'Exchange 2019 CU6'; ObjectVersionDefault = 13237; ObjectVersionConfig = 16754; RangeUpper = 17001; Binding = '2019CU6' }
        }
    }
    process
    {
        if ($Binding) { return $versionMapping[$Binding] }
        $versionMapping.Values | Where-Object Name -Like $Name
    }
}

function Get-SchemaAdminCredential
{
    <#
    .SYNOPSIS
        Returns the credentials for the account to use for schema administration.
     
    .DESCRIPTION
        Returns the credentials for the account to use for schema administration.
        The behavior of this command is heavily controlled by the configuration system:
        ForestManagement.Schema.*
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Get-SchemaAdminCredential @parameters
 
        Returns the configured schema credentials
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")]
    [OutputType([PSCredential])]
    [CmdletBinding()]
    Param (
        [PSFComputer]
        $Server,

        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        $script:temporarySchemaUpdateUser = $null
    }
    process
    {
        #region Case: Explicit Credentials
        if (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.Credential') {
            Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.Credential'
            return
        }
        #endregion Case: Explicit Credentials

        #region Case: Temporary Schema Admin Account
        if (Get-PSFConfigValue -FullName 'ForestManagement.Schema.AutoCreate.TempAdmin') {
            do
            {
                $newName = "$(Get-Random -Minimum 100000 -Maximum 999999)_$($env:USERNAME)"
            }
            while (Get-ADUser @parameters -LDAPFilter "(name=$newName)")
            $password = New-Password -Length 128 -AsSecureString

            Invoke-PSFProtectedCommand -ActionString 'Get-SchemaAdminCredential.Account.Creation' -Target $newName -ScriptBlock {
                $newUser = New-ADUser @parameters -Name $newName -Description 'Temporary Admin account used to update the schema' -AccountPassword $password -PassThru -Enabled $true -ErrorAction Stop
            } -EnableException $true -PSCmdlet $PSCmdlet
            if (-not $newUser) { return }

            $script:temporarySchemaUpdateUser = $newUser
            $domain = Get-ADDomain @parameters
            try { Get-ADGroup @parameters -Identity "$($domain.DomainSID)-518" | Add-ADGroupMember @parameters -Members $newUser -ErrorAction Stop }
            catch {
                Remove-ADUser -Identity $userObject @parameters
                $script:temporarySchemaUpdateUser = $null
                Stop-PSFFunction -String 'Get-SchemaAdminCredential.Account.Assignment.Failure' -StringValues $newName -EnableException $true -Cmdlet $PSCmdlet -ErrorRecord $_
            }
            New-Object System.Management.Automation.PSCredential("$($domain.NetBIOSName)\$($newName)", $password)
            return
        }
        #endregion Case: Temporary Schema Admin Account

        #region Case: Explicit Schema Admin Account
        if (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.Name') {
            $accountName = Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.Name'
            if ($accountName -like "*\*") { $accountName = $account.Split("\")[1] }
            $domain = Get-ADDomain @parameters
            
            $accountObject = Get-ADUser @parameters -LDAPFilter "(name=$accountName)"
            $schemaAdmins = Get-ADGroup @parameters -Identity "$($domain.DomainSID)-518" -Properties Members

            #region Scenario: Account does not exist
            if (-not $accountObject)
            {
                if (-not (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoCreate')) {
                    Stop-PSFFunction -String 'Get-SchemaAdminCredential.Account.ExistsNot' -StringValues $accountName -EnableException $true -Cmdlet $PSCmdlet -Category ObjectNotFound
                }

                $password = New-Password -Length 128 -AsSecureString
                Invoke-PSFProtectedCommand -ActionString 'Get-SchemaAdminCredential.Account.Creation' -Target $accountName -ScriptBlock {
                    $userObject = New-ADUser @parameters -Name $accountName -AccountPassword $password -Enabled $true -Description "Admin account for updating the schema. Created by $($env:USERDOMAIN)\$($env:USERNAME)" -PassThru -ErrorAction Stop
                } -EnableException $true -PSCmdlet $PSCmdlet
                if (-not $userObject) { return }
                
                try { Get-ADGroup @parameters -Identity "$($domain.DomainSID)-518" | Add-ADGroupMember @parameters -Members $userObject -ErrorAction Stop }
                catch {
                    Remove-ADUser -Identity $userObject @parameters
                    Stop-PSFFunction -String 'Get-SchemaAdminCredential.Account.GroupAssignment.Failure' -StringValues $accountName -EnableException $true -Cmdlet $PSCmdlet -ErrorRecord $_
                }
                New-Object System.Management.Automation.PSCredential("$($domain.NetBIOSName)\$($accountName)", $password)
                return
            }
            #endregion Scenario: Account does not exist
            
            #region Fail Fast
            if ($schemaAdmins.Members -notcontains $accountObject.DistinguishedName) {
                if (-not (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoGrant')) {
                    Stop-PSFFunction -String 'Get-SchemaAdminCredential.Account.Unprivileged' -StringValues $accountName -EnableException $true -Category ResourceUnavailable -Cmdlet $PSCmdlet
                }
            }
            if (-not $accountObject.Enabled) {
                if (-not (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoEnable')) {
                    Stop-PSFFunction -String 'Get-SchemaAdminCredential.Account.Disabled' -StringValues $accountName -EnableException $true -Category ResourceUnavailable -Cmdlet $PSCmdlet
                }
            }
            #endregion Fail Fast

            #region Prepare account for schema administration
            if ($schemaAdmins.Members -notcontains $accountObject.DistinguishedName) {
                Invoke-PSFProtectedCommand -ActionString 'Get-SchemaAdminCredential.Account.Group.Assignment' -Target $accountName -ScriptBlock {
                    $null = $schemaAdmins | Add-ADGroupMember @parameters -Members $accountObject -ErrorAction Stop
                } -EnableException $true -PSCmdlet $PSCmdlet
            }

            if (-not $accountObject.Enabled) {
                Invoke-PSFProtectedCommand -ActionString 'Get-SchemaAdminCredential.Account.Enable' -Target $accountName -ScriptBlock {
                    $null = Enable-ADAccount @parameters -Identity $accountObject -ErrorAction Stop
                } -EnableException $true -PSCmdlet $PSCmdlet
            }
            #endregion Prepare account for schema administration

            #region Handle Password
            if (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Password.AutoReset') {
                $password = New-Password -Length 128 -AsSecureString
                try {
                    Write-PSFMessage -String 'Get-SchemaAdminCredential.Password.Reset' -StringValues $accountName
                    $null = Set-ADAccountPassword @parameters -Identity $accountObject -NewPassword $password -ErrorAction Stop -Reset
                }
                catch { Stop-PSFFunction -String 'Get-SchemaAdminCredential.Password.Reset.Failed' -StringValues $accountName -EnableException $true -ErrorRecord $_ -Cmdlet $PSCmdlet }

                New-Object System.Management.Automation.PSCredential("$($domain.NetBIOSName)\$($accountName)", $password)
                return
            }
            else {
                try { $password = Read-Host -Prompt "Specify password for schema admin $accountName" -AsSecureString -ErrorAction Stop }
                catch { Stop-PSFFunction -String 'Get-SchemaAdminCredential.Password.InteractiveRead.Failed' -StringValues $accountName -EnableException $true -ErrorRecord $_ -Cmdlet $PSCmdlet }

                New-Object System.Management.Automation.PSCredential("$($domain.NetBIOSName)\$($accountName)", $password)
                return
            }
            #endregion Handle Password
        }
        #endregion Case: Explicit Schema Admin Account

        # Case: Current User Credential
        $Credential
    }
}


function Import-LdifFile
{
    <#
    .SYNOPSIS
        Parses an LDIF file and returns the changes it applies.
     
    .DESCRIPTION
        Parses an LDIF file and returns the changes it applies.
        Note: schemaupdatenow commands are skipped.
     
    .PARAMETER Path
        The path to the LDIF file to parse.
     
    .EXAMPLE
        PS C:\> Import-LdifFile -Path $ldifFile
 
        Parses the ldif file and returns changes it applies.
    #>

    [CmdletBinding()]
    param (
        [string]
        $Path
    )
    
    begin
    {
        #region Utility Functions
        function Resolve-AttributeName
        {
            [OutputType([string])]
            [CmdletBinding()]
            param (
                [string]
                $Name
            )
            
            switch ($Name)
            {
                'dn' { 'DistinguishedName' }
                default { $Name }
            }
        }
        function Resolve-AttributeValue
        {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "")]
            [CmdletBinding()]
            param (
                [string]
                $Value,
                
                [bool]
                $IsBase64,
                
                [string]
                $AttributeName
            )
            
            if ($IsBase64)
            {
                switch ($AttributeName)
                {
                    'schemaIDGUID' {
                        [PSCustomObject]@{
                            Guid = [System.Guid]::new([System.Convert]::FromBase64String($Value))
                            GuidData = [System.Convert]::FromBase64String($Value)
                        }
                    }
                    'attributeSecurityGUID' {
                        [PSCustomObject]@{
                            Guid = [System.Guid]::new([System.Convert]::FromBase64String($Value))
                            GuidData = [System.Convert]::FromBase64String($Value)
                        }
                    }
                    'omObjectClass' {
                        [System.Convert]::FromBase64String($Value)
                    }
                    default { [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Value)) }
                }
            }
            else
            {
                if ($Value -eq "TRUE") { return $true }
                if ($Value -eq "FALSE") { return $false }
                if ($Value -eq "") { return '' }
                if ($null -ne ($Value -as [int])) { return ($Value -as [int]) }
                $Value
            }
        }
        #endregion Utility Functions
        
        $lines = Get-Content -Path $Path
        $currentObject = @{ }
        $lastKey = ''
        $orderCount = 0
    }
    process
    {
        $isBase64 = $false
        foreach ($line in $lines)
        {
            if (-not $line) { continue }
            if ($line -like '#*') { continue }
            if ($line -like 'dn:*')
            {
                if (($currentObject.Keys.Count -gt 1) -and ($currentObject['replace'] -ne 'schemaupdatenow')) { [pscustomobject]$currentObject }
                $currentObject = @{
                    PSTypeName          = 'ForestManagement.Schema.Ldif.Setting'
                    DistinguishedName = ($line -replace '^dn:', '').Trim() -replace ',DC=X$' -replace ',CN=Schema,CN=Configuration$'
                    FM_OrderCount     = $orderCount
                }
                $orderCount++
                $lastKey = 'DistinguishedName'
                continue
            }
            if ($line -match '^([^:]+):(?<colon>:*) (.*)$')
            {
                $isBase64 = $matches['colon'] -eq ':'
                $attributeName = Resolve-AttributeName -Name $matches[1]
                $attributeValue = Resolve-AttributeValue -Value $matches[2] -IsBase64 $isBase64 -AttributeName $attributeName
                # Prevent duplicate object classes - top is redundant and not listed in AD
                if (($attributeName -eq 'ObjectClass') -and ($attributeValue -eq 'Top')) { continue }
                if ($currentObject.ContainsKey($attributeName))
                {
                    $values = @($currentObject[$attributeName])
                    $values += $attributeValue
                    $currentObject[$attributeName] = $values
                }
                else
                {
                    $currentObject[$attributeName] = $attributeValue
                }
                $lastKey = $attributeName
            }
            # Handle value continuation on the next line
            # Values break line when exceeding a total width of 80 characters
            elseif ($line -match '^ (.+)$')
            {
                $currentObject[$lastKey] = $currentObject[$lastKey] + (Resolve-AttributeValue -Value $matches[1] -IsBase64 $isBase64 -AttributeName $lastKey)
            }
        }
    }
    end
    {
        # Process last item
        if ($currentObject.Keys.Count -gt 0)
        {
            if ($currentObject['replace'] -ne 'schemaupdatenow') { [pscustomobject]$currentObject }
        }
    }
}


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_FM_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 Invoke-LdifFile
{
    <#
        .SYNOPSIS
            Invokes a LDIF file against a target server / forest.
         
        .DESCRIPTION
            Invokes a LDIF file against a target server / forest.
            Note: This command assumes schema updates executed against the schema master (and will automatically switch to target that server).
            LDIF files are not technically constrained to performing schema updates however.
            Thus this function is not suitable to performing domain NC changes in a subdomain.
         
        .PARAMETER Path
            Path to the ldif file to import
         
        .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.
         
        .EXAMPLE
            PS C:\> Invoke-LdifFile -Path .\schema.ldif
 
            Imports the schema.ldif file into the current forest's schema.
    #>

    
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param (
        [Parameter(Mandatory = $true)]
        [PsfValidateScript('ForestManagement.Validate.Path.SingleFile', ErrorString = 'ForestManagement.Validate.Path.SingleFile.Failed')]
        [string]
        $Path,

        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        $parameters['Server'] = (Get-ADForest @parameters).SchemaMaster
        $domain = Get-ADDomain @parameters
        
        $arguments = @()
        if ($Credential) {
            $arguments += "-b"
            $networkCredential = $Credential.GetNetworkCredential()
            $userName = $networkCredential.UserName
            $domain = $networkCredential.Domain
            if (-not $domain -and $userName -match '@') {
                $userName, $domain = $userName -split '@',2
            }
            $arguments += $userName
            if ($domain) { $arguments += $domain }
            $arguments += $networkCredential.Password
        }
        # Load target server
        $arguments += '-s'
        $arguments += "$Server"

        # Other settings
        $arguments += '-i' # Import
        $arguments += '-k' # Ignore errors for items that already exist
        $arguments += '-c'
        $arguments += 'DC=X'
        $arguments += $domain.DistinguishedName

        # Load File
        $arguments += '-f'
        $arguments += (Resolve-PSFPath -Path $Path -Provider FileSystem -SingleItem)
    }
    process
    {
        Invoke-PSFProtectedCommand -ActionString 'Invoke-LdifFile.Invoking.File' -ActionStringValues $Path -ScriptBlock {
            $procInfo = Start-Process -FilePath ldifde.exe -ArgumentList $arguments -Wait -PassThru -ErrorAction Stop -WindowStyle Hidden
            if ($procInfo.ExitCode) {
                $winError = [System.ComponentModel.Win32Exception]::new($procInfo.ExitCode)
                switch ($procInfo.ExitCode) {
                    8224 { $outerError = [System.InvalidOperationException]::new("Failed to apply ldif file. Validate domain health, especially FSMO assignment and replication health. $($winError.Message)", $winError) }
                    default { $outerError = [System.InvalidOperationException]::new("Failed to apply ldif file: $($winError.Message)", $winError) }
                }
                throw $outerError
            }
        } -EnableException $true -Target $Server -PSCmdlet $PSCmdlet
    }
}


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("PSAvoidUsingConvertToSecureStringWithPlainText", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [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 (1..$Length)) {
            $characters[(($number % 4) + (1..4 | Get-Random))] | 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.
 
    .PARAMETER Properties
        Additional properties to include in the testresult object.
     
    .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,

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

function Remove-SchemaAdminCredential
{
    <#
        .SYNOPSIS
            Implements the post processing of schema admin credentials.
         
        .DESCRIPTION
            Implements the post processing of schema admin credentials.
            This command is responsible for applying the schema admin credential configuration policies.
            For example, it will remove temporary admin accounts or perform the auto-reset auf admin credentials.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
         
        .PARAMETER SchemaAccountCredential
            The credential object of the schema admin that was returned by Get-SchemaAdminCredential.
 
        .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:\> Remove-SchemaAdminCredential @removeParameters
 
            Cleans up the credentials according to policy.
    #>

    
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    Param (
        [PSFComputer]
        $Server,

        [PSCredential]
        $Credential,

        [PSCredential]
        $SchemaAccountCredential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        $domain = Get-ADDomain @parameters
    }
    process
    {
        if ($SchemaAccountCredential) {
            $userName = $SchemaAccountCredential.GetNetworkCredential().UserName
            try {
                Write-PSFMessage -String 'Remove-SchemaAdminCredential.SchemaAccount.Resolve' -StringValues $userName
                $accountObject = Get-ADUser @parameters -Identity $userName -ErrorAction Stop
            }
            catch { Stop-PSFFunction -String 'Remove-SchemaAdminCredential.SchemaAccount.Resolve.Failed' -StringValues $userName -EnableException $true -Cmdlet $PSCmdlet -ErrorRecord $_ }
        }
        if ((Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoRevoke') -and ($accountObject)) {
            Invoke-PSFProtectedCommand -ActionString 'Remove-SchemaAdminCredential.Account.Group.Revoke' -Target $username -ScriptBlock {
                "$($domain.DomainSID)-518" | Remove-ADGroupMember @parameters -Members $accountObject -ErrorAction Stop -Confirm:$false
            } -EnableException $true -PSCmdlet $PSCmdlet
        }
        if ((Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoDisable') -and ($accountObject)) {
            $null = Invoke-PSFProtectedCommand -ActionString 'Remove-SchemaAdminCredential.SchemaAccount.Disable' -Target $username -ScriptBlock {
                Disable-ADAccount @parameters -Identity $accountObject -ErrorAction Stop -Confirm:$false
            } -EnableException $true -PSCmdlet $PSCmdlet
        }
        if ((Get-PSFConfigValue -FullName 'ForestManagement.Schema.Password.AutoReset') -and ($accountObject)) {
            $null = Invoke-PSFProtectedCommand -ActionString 'Remove-SchemaAdminCredential.SchemaAccount.PasswordReset' -Target $username -ScriptBlock {
                $password = New-Password -Length 128 -AsSecureString
                Set-ADAccountPassword @parameters -Identity $accountObject -ErrorAction Stop -NewPassword $password -Reset -Confirm:$false
            } -EnableException $true -PSCmdlet $PSCmdlet
        }
        if ((Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoDescription') -and ($accountObject)) {
            $null = Invoke-PSFProtectedCommand -ActionString 'Remove-SchemaAdminCredential.Account.AutoDescription' -Target $username -ScriptBlock {
                Set-ADUser @parameters -Identity $accountObject -Description (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.AutoDescription') -ErrorAction Stop
            } -EnableException $true -PSCmdlet $PSCmdlet
        }
        if ($script:temporarySchemaUpdateUser) {
            try {
                Write-PSFMessage -String 'Remove-SchemaAdminCredential.TemporaryAccount.Remove' -StringValues $script:temporarySchemaUpdateUser.Name
                Remove-ADUser @parameters -Identity $script:temporarySchemaUpdateUser -ErrorAction Stop -Confirm:$false
                $script:temporarySchemaUpdateUser = $null
            }
            catch { Stop-PSFFunction -String 'Remove-SchemaAdminCredential.TemporaryAccount.Remove.Failed' -StringValues $script:temporarySchemaUpdateUser.Name -EnableException $true -Cmdlet $PSCmdlet -ErrorRecord $_ }
        }
    }
}

function Resolve-SchemaAttribute {
    <#
    .SYNOPSIS
        Combines configuration and adobject into an attributes hashtable.
     
    .DESCRIPTION
        Combines configuration and adobject into an attributes hashtable.
        This is a helper function that allows to simplify the code used to create and update schema attributes.
     
    .PARAMETER Configuration
        The configuration object containing the desired schema attribute name.
     
    .PARAMETER ADObject
        The ADObject - if present - containing the current schema attribute configuration.
        Specifying this will cause it to return a delta hashtable useful for updating attributes.
     
    .PARAMETER Changes
        Changes to be applied to an existing attribute.
 
    .EXAMPLE
        PS C:\> Resolve-SchemaAttribute -Configuration $testItem.Configuration
 
        Returns the attributes hashtable for a new schema attribute.
         
    .EXAMPLE
        PS C:\> Resolve-SchemaAttribute -Configuration $testItem.Configuration -ADObject $testItem.ADObject
 
        Returns the attributes hashtable for attributes to update.
    #>

    [OutputType([hashtable])]
    [CmdletBinding()]
    param (
        $Configuration,
        
        $ADObject,

        $Changes
    )

    begin {
        function Convert-AttributeName {
            [OutputType([string])]
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                [AllowEmptyCollection()]
                [AllowEmptyString()]
                [AllowNull()]
                [string[]]
                $Name
            )

            process {
                foreach ($entry in $Name) {
                    if ($null -eq $entry) { continue }

                    switch ($entry) {
                        SingleValued { 'isSingleValued' }
                        OID { 'attributeID' }
                        PartialAttributeSet { 'isMemberOfPartialAttributeSet' }
                        AdvancedView { 'showInAdvancedViewOnly' }
                        default { $_ }
                    }
                }
            }
        }
    }
    
    process {
        #region Build out basic attribute hashtable
        $attributes = @{
            adminDisplayName              = $Configuration.AdminDisplayName
            lDAPDisplayName               = $Configuration.LdapDisplayName
            attributeId                   = $Configuration.OID
            oMSyntax                      = $Configuration.OMSyntax
            attributeSyntax               = $Configuration.AttributeSyntax
            isSingleValued                = ($Configuration.SingleValued -as [bool])
            adminDescription              = $Configuration.AdminDescription
            searchflags                   = $Configuration.SearchFlags
            isMemberOfPartialAttributeSet = $Configuration.PartialAttributeSet
            showInAdvancedViewOnly        = $Configuration.AdvancedView
        }
        #endregion Build out basic attribute hashtable

        #region Case: New Attribute
        if (-not $ADObject) {
            $badProperties = foreach ($pair in $attributes.GetEnumerator()) {
                if ($null -eq $pair.Value) { $pair.Key }
            }
            if ($null -eq $Configuration.SingleValued) { $badProperties = @($badProperties) + @('SingleValued') }
            if ($badProperties) {
                throw "Cannot create new attribute $($Configuration.AdminDisplayName), missing attributes: $($badProperties -join ',')"
            }

            return $attributes
        }
        #endregion Case: New Attribute

        #region Case: Update Settings
        $updates = @{ }
        foreach ($change in $Changes) {
            $updates[($change.Property | Convert-AttributeName)] = $change.New
        }
        
        $systemOnly = @(
            'isSingleValued'
            'oMSyntax'
            'attributeId'
            'attributeSyntax'
        )

        foreach ($attributeName in ($updates.Keys | Write-Output)) {
            
            if ($systemOnly -contains $attributeName) {
                Write-PSFMessage -Level Warning -String 'Resolve-SchemaAttribute.Update.SystemOnlyError' -StringValues $attributeName, $attributes.$attributeName, $ADObject
                $attributes.Remove($attributeName)
            }
        }
        #endregion Case: Update Settings
        
        $attributes
    }
}

function Set-FMDomainContext
{
    <#
        .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-FMDomainContext @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
            }
        }
        
        Register-StringMapping -Name '%DomainName%' -Value $domainObject.Name
        Register-StringMapping -Name '%DomainNetBIOSName%' -Value $domainObject.NetbiosName
        Register-StringMapping -Name '%DomainFqdn%' -Value $domainObject.DNSRoot
        Register-StringMapping -Name '%DomainDN%' -Value $domainObject.DistinguishedName
        Register-StringMapping -Name '%DomainSID%' -Value $domainObject.DomainSID.Value
        Register-StringMapping -Name '%RootDomainName%' -Value $forestRootDomain.Name
        Register-StringMapping -Name '%RootDomainFqdn%' -Value $forestRootDomain.DNSRoot
        Register-StringMapping -Name '%RootDomainDN%' -Value $forestRootDomain.DistinguishedName
        Register-StringMapping -Name '%RootDomainSID%' -Value $forestRootSID
        Register-StringMapping -Name '%ForestFqdn%' -Value $forestObject.Name
    }
}

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-SchemaAdminCredential {
    <#
    .SYNOPSIS
        Validates, whether the schema admin credential workflow should be executed.
     
    .DESCRIPTION
        Validates, whether the schema admin credential workflow should be executed.
        This is done using two checks:
        - Is the ForestManagement.Schema.Account.IgnoreOnCredentialProvider config setting set?
        - Is the command calling the caller of this command anything other than Invoke-AdmfForest with the CredentialProvider parameter set
        If the configuration is set and a crededntial provider was specified, it will return false.
     
    .EXAMPLE
        PS C:\> Test-SchemaAdminCredential
 
        Validates, whether the schema admin credential workflow should be executed.
    #>

    [OutputType([bool])]
    [CmdletBinding()]
    param ()

    if (Get-PSFConfigValue -FullName 'ForestManagement.Schema.Account.IgnoreOnCredentialProvider') {
        return $true
    }

    $invocation = (Get-PSCallstack)[2]
    -not ($invocation.Command -eq 'Invoke-AdmfForest' -and $invocation.InvocationInfo.BoundParameters.Keys -contains 'CredentialProvider')
}

function Update-Schema {
    <#
    .SYNOPSIS
        Forces a schema update.
     
    .DESCRIPTION
        Forces a schema update.
        This allows immediately assigning new attributes in schema.
        Generally, it is recommended targeting the schema master dc.
     
    .PARAMETER Server
        The server / domain to work with.
     
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Update-Schema -Server dc1.contoso.com
 
        Forces a schema update on dc1.contoso.com
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [string]
        $Server,

        [PSCredential]
        $Credential
    )

    $path = "LDAP://RootDSE"
    if ($Server) { $path = "LDAP://$Server/RootDSE" }
    if ($Credential) { $rootDSE = [adsi]::new($path, $Credential.UserName, $Credential.GetNetworkCredential().Password) }
    else { $rootDSE = [adsi]::new($path) }

    $null = $rootDSE.put("schemaUpdateNow", 1)
    $null = $rootDSE.SetInfo()
}

function ConvertTo-SubnetMask
{
    <#
        .SYNOPSIS
            Converts the size of a mask into the mask as IPAddress
         
        .DESCRIPTION
            Converts the size of a mask into the mask as IPAddress
         
        .PARAMETER MaskSize
            The size of the subnet. Valid between 1 and 32
         
        .EXAMPLE
            PS C:\> ConvertTo-SubnetMask -MaskSize 30
 
            Converts the size (30) into the mask as IPAddress
    #>

    [OutputType([IPAddress])]
    [CmdletBinding()]
    param (
        [ValidateRange(1, 32)]
        [int]
        $MaskSize
    )
    
    process
    {
        $binaryString = ("1") * $MaskSize + ("0") * (32 - $MaskSize)
        $bytes = foreach ($number in (0 .. 3))
        {
            [convert]::ToByte($binaryString.SubString(($number * 8), 8), 2)
        }
        [IPAddress]::new($bytes)
    }
}


function Test-Subnet
{
    <#
    .SYNOPSIS
        Tests whether a host fits into the specified subnet.
     
    .DESCRIPTION
        Tests whether a host fits into the specified subnet.
     
    .PARAMETER NetworkAddress
        The address of the subnet.
     
    .PARAMETER MaskAddress
        The subnet mask of the subnet.
     
    .PARAMETER MaskSize
        The size of the mask of the subnet.
     
    .PARAMETER HostAddress
        The address of the host to test
     
    .EXAMPLE
        PS C:\> Test-Subnet -NetworkAddress '192.168.2.0' -MaskSize 24 -HostAddress '192.168.20.255'
 
        Checks whether the address '192.168.20.255' is part of the subnet '192.168.2.0/24'
    #>

    
    [CmdletBinding()]
    Param (
        [IPAddress]
        $NetworkAddress,

        [IPAddress]
        $MaskAddress,

        [int]
        $MaskSize,

        [IPAddress]
        $HostAddress
    )
    
    process
    {
        if ($MaskSize) {
            $MaskAddress = ConvertTo-SubnetMask -MaskSize $MaskSize
        }
        $NetworkAddress.Address -eq ($MaskAddress.Address -band $HostAddress.Address)
    }
}


function Get-FMCertificate {
    <#
    .SYNOPSIS
        Returns registered Certificates.
     
    .DESCRIPTION
        Returns registered Certificates.
     
    .PARAMETER Thumbprint
        The thumbprint of the certificate to filter by.
     
    .PARAMETER Name
        The name of the certificate to filter by.
     
    .PARAMETER Type
        The type of certificate to look for
     
    .EXAMPLE
        PS C:\> Get-FMCertificate
 
        Returns all registered certificates intended for any of the forest certificate stores
    #>

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

        [string]
        $Name = '*',
        
        [string]
        $Type = '*'
    )
    
    process {
        ($script:dsCertificates.Values) | Where-Object { $_.Certificate.Thumbprint -like $Thumbprint } | Where-Object {
            $_.Certificate.Subject -like $Name -or
            $_.Certificate.Subject -like "CN=$Name" -or
            $_.Certificate.FriendlyName -like $Name
        } | Where-Object Type -Like $Type
    }
}



function Invoke-FMCertificate
{
<#
    .SYNOPSIS
        Applies the desired certificates to the NTAuth store.
     
    .DESCRIPTION
        Applies the desired certificates to the NTAuth store.
        This allows distributing certificates that are trusted across the entire forest.
     
    .PARAMETER InputObject
        The test results to apply.
        Only specify objects returned by Test-FMCertificate.
        By default, if you do not specify this parameter it will run the test and apply all deltas found.
     
    .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-FMCertificate -Server contoso.com
     
        Applies the defined NTAuthStore configuration to the contoso.com 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 dsCertificates -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-FMCertificate.WinRM.Failed' -StringValues $computerName -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName
            return
        }
        
        #region Add Certificate Scriptblock
        $addCertificateScript = {
            param (
                $Certificate
            )
            
            $certPath = "$env:temp\cert_$(Get-Random -Minimum 10000 -Maximum 99999).cer"
            
            try { $Certificate.Certificate.GetRawCertData() | Set-Content $certPath -Encoding Byte -ErrorAction Stop }
            catch
            {
                [pscustomobject]@{
                    Success = $false
                    Stage   = 'Writing certificate file'
                    Error   = $_
                }
                return
            }
            
            $res = certutil.exe -dspublish -f $certPath $Certificate.Type 2>&1
            if ($LASTEXITCODE -gt 0)
            {
                [pscustomobject]@{
                    Success = $false
                    Stage   = 'Applying certificate using certutil'
                    Output  = $res
                }
                Remove-Item -Path $certPath -ErrorAction Ignore
                return
            }
            Remove-Item -Path $certPath -ErrorAction Ignore
            
            [pscustomobject]@{
                Success = $true
                Stage   = 'Done'
                Output  = $null
            }
        }
        #endregion Add Certificate Scriptblock
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }
        
        # Test All Certificates if no specific test result was specified
        if (-not $InputObject)
        {
            $InputObject = Test-FMCertificate @parameters
        }
        
        :main foreach ($testResult in $InputObject)
        {
            # Catch invalid input - can only process test results
            if ($testResult.PSObject.TypeNames -notcontains 'ForestManagement.Certificate.TestResult')
            {
                Stop-PSFFunction -String 'Invoke-FMCertificate.Invalid.Input' -StringValues $testResult -Target $testResult -Continue -EnableException $EnableException
            }
            
            switch ($testResult.Type)
            {
                'Add' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMCertificate.Add' -ActionStringValues $testResult.Configuration.Certificate.Subject, $testResult.Configuration.Type -Target $testResult -ScriptBlock {
                        $result = Invoke-Command -Session $session -ArgumentList $testResult.Configuration -ScriptBlock $addCertificateScript
                        if (-not $result.Success)
                        {
                            throw "Error executing $($result.Stage) : $($result.Error)"
                        }
                        
                        $certificates = Get-ADCertificate -Parameters $parameters -Type $testResult.Configuration.Type
                        if ($testResult.Configuration.Certificate.Thumbprint -notin $certificates.Thumbprint)
                        {
                            throw "Certificate could not be applied successfully! Ensure you have the permissions needed for this operation. Certutil output:`n$($result.Output)"
                        }
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -ContinueLabel main
                }
                'Remove' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMCertificate.Remove' -ActionStringValues $testResult.ADObject.Subject, $testResult.ADObject.ADObject -Target $testResult -ScriptBlock {
                        try { Set-ADObject @parameters -Identity $testResult.ADObject.ADObject -Remove @{ $testResult.ADObject.AttributeName = $testResult.ADObject.Certificate.GetRawCertData() } -ErrorAction Stop }
                        catch
                        {
                            if ($_.Exception.ErrorCode -eq 8316) { Remove-ADObject @parameters -Identity $testResult.ADObject.ADObject -ErrorAction Stop -Confirm:$false }
                            else { throw }
                        }
                        
                        if ($testResult.ADObject.AltADObject)
                        {
                            try { Set-ADObject @parameters -Identity $testResult.ADObject.AltADObject -Remove @{ $testResult.ADObject.AttributeName = $testResult.ADObject.Certificate.GetRawCertData() } -ErrorAction Stop }
                            catch
                            {
                                if ($_.Exception.ErrorCode -eq 8316) { Remove-ADObject @parameters -Identity $testResult.ADObject.AltADObject -ErrorAction Stop -Confirm:$false }
                                else { throw }
                            }
                        }
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -ContinueLabel main
                }
            }
        }
    }
    end
    {
        if (Test-PSFFunctionInterrupt) { return }
        
        Remove-PSSession -Session $session -Confirm:$false -WhatIf:$false
    }
}



function Register-FMCertificate
{
    <#
        .SYNOPSIS
            Register directory services certificates
         
        .DESCRIPTION
            Register directory services certificates
         
        .PARAMETER Certificate
            The certifcate to apply.
     
        .PARAMETER Type
            The kind of certificate this is.
            Can be: NTAuthCA, RootCA, SubCA, CrossCA or KRA.
         
        .PARAMETER Authorative
            Should the certificate configuration overwrite the existing configuration, rather than adding to it (default).
     
        .PARAMETER Remove
            Thumbprint of a certificate to remove rather than add.
 
        .EXAMPLE
            PS C:\> Register-FMCertificate -Certificate $certificate -Type RootCA
 
            Register a certiciate as RootCA certificate.
         
        .EXAMPLE
            PS C:\> Register-FMCertificate -Authorative -Type RootCA
             
            Sets our current configuration as authorative, removing all non-listed certificates from the store.
     
        .EXAMPLE
            PS C:\> Register-FMCertificate -Remove $cert.Thumbprint -Type SubCA
     
            Registers a certificate for removal from the SubCA list.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('NTAuthCA', 'RootCA', 'SubCA', 'CrossCA', 'KRA')]
        [string]
        $Type,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Certificate")]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]
        $Certificate,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Authorative")]
        [bool]
        $Authorative,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Remove')]
        [string]
        $Remove
    )
    
    process
    {
        switch ($pscmdlet.ParameterSetName)
        {
            Certificate
            {
                $object = [pscustomobject]@{
                    Certificate = $Certificate
                    Type        = $Type
                    Action        = 'Add'
                }
                Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value {
                    '+ {0} > {1}' -f $this.Type, $this.Certificate.Subject
                } -Force
                $script:dsCertificates[$Certificate.Thumbprint] = $object
            }
            Authorative { $script:dsCertificatesAuthorative[$Type] = $Authorative }
            Remove
            {
                $object = [pscustomobject]@{
                    Thumbprint = $Remove
                    Type       = $Type
                    Action       = 'Remove'
                }
                Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value {
                    '- {0} > {1}' -f $this.Type, $this.Thumbprint
                } -Force
                $script:dsCertificates[$Remove] = $object
            }
        }
    }
}


function Test-FMCertificate
{
    <#
        .SYNOPSIS
            Tests, whether the certificate stores are in the desired state.
         
        .DESCRIPTION
            Tests, whether the certificate stores are in the desired state, that is, all defined certificates are already in place.
            Use Register-FMCertificate to define desired the desired state.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
     
        .EXAMPLE
            PS C:\> Test-FMCertificate -Server contoso.com
 
            Checks whether the contoso.com forest has all the certificates it should
    #>

    [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 dsCertificates -Cmdlet $PSCmdlet
    }
    process
    {
        $resDefault = @{
            Server = $Server
            ObjectType = 'Certificate'
        }
        
        foreach ($type in 'NTAuthCA', 'RootCA', 'SubCA', 'CrossCA', 'KRA')
        {
            $certificates = Get-ADCertificate -Parameters $parameters -Type $type
            $desiredState = Get-FMCertificate -Type $type
            
            foreach ($desiredCert in $desiredState)
            {
                if ($desiredCert.Action -eq 'Add' -and $desiredCert.Certificate.Thumbprint -in $certificates.Thumbprint) { continue }
                if ($desiredCert.Action -eq 'Remove' -and $desiredCert.Thumbprint -notin $certificates.Thumbprint) { continue }
                
                $adObject = $null
                if ($desiredCert.Action -eq 'Remove') { $adObject = $certificates | Where-Object Thumbprint -EQ $desiredCert.Thumbprint }
                
                New-TestResult @resDefault -Type $desiredCert.Action -Identity $desiredCert -Configuration $desiredCert -ADObject $adObject
            }
            
            if (-not $script:dsCertificatesAuthorative[$type]) { continue }
            
            foreach ($certificate in $certificates)
            {
                if ($certificate.Thumbprint -in $desiredState.Certificate.Thumbprint) { continue }
                if ($certificate.Thumbprint -in $desiredState.Thumbprint) { continue }
                
                New-TestResult @resDefault -Type 'Remove' -Identity $certificate -ADObject $certificate
            }
        }
    }
}

function Unregister-FMCertificate
{
    <#
    .SYNOPSIS
        Removes a certificate definition for the NTAuthStore.
     
    .DESCRIPTION
        Removes a certificate definition for the NTAuthStore.
        See Register-FMCertificate tfor details on defining a certificate.
     
    .PARAMETER Thumbprint
        The thumbprint of the certificate to remove.
     
    .PARAMETER Certificate
        The certificate to remove.
     
    .EXAMPLE
        PS C:\> Get-FMCertificate | Unregister-FMCertificate
 
        Clears all certificates from the list of defined NTAuth certificates
    #>

    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Thumbprint,
        
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [System.Security.Cryptography.X509Certificates.X509Certificate2[]]
        $Certificate
    )
    
    process
    {
        foreach ($thumbprintString in $Thumbprint) {
            $script:dsCertificates.Remove($thumbprintString)
        }
        foreach ($certificateObject in $Certificate)
        {
            $script:dsCertificates.Remove($certificateObject.Thumbprint)
        }
    }
}



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

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


function Invoke-FMExchangeSchema
{
<#
    .SYNOPSIS
        Applies the desired Exchange version to the tareted Forest.
     
    .DESCRIPTION
        Applies the desired Exchange version to the tareted Forest.
        Requires Schema Admin & Enterprise Admin privileges.
     
    .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-FMExchangeSchema -Server contoso.com
     
        Applies the desired Exchange version to the contoso.com Forest.
#>

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '')]
    [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 ExchangeSchema -Cmdlet $PSCmdlet
        $forestObject = Get-ADForest @parameters
        
        $psParameter = $PSBoundParameters | ConvertTo-PSFHashtable -Include Credential
        $psParameter.ComputerName = $Server
        
        try { $session = New-PSSession @psParameter -ErrorAction Stop }
        catch
        {
            Stop-PSFFunction -String 'Invoke-FMExchangeSchema.WinRM.Failed' -StringValues $computerName -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName
            return
        }
        
        #region 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-ExchangeSchemaUpdate
        {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
            [CmdletBinding()]
            param (
                [System.Management.Automation.Runspaces.PSSession]
                $Session,
                
                [string]
                $Path,
                
                [string]
                $OrganizationName,
                
                [switch]
                $SchemaOnly,

                [ValidateSet('InstallSchema', 'UpdateSchema', 'Install', 'Update', 'EnableSplit', 'DisableSplit')]
                [string]
                $Mode,

                [bool]
                $SplitPermission,

                [bool]
                $AllDomains
            )
            
            $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
                $limit = (Get-Date).AddMinutes(1)
                while (-not $volume.DriveLetter) {
                    $volume = Get-Volume -DiskImage $diskImage
                    if ($volume.DriveLetter) { break }
                    if ((Get-Date) -gt $limit) {
                        try { Dismount-DiskImage -ImagePath $exchangeIsoPath }
                        catch { }
                        throw "Timeout waiting for volume drive letter!"
                    }
                    Start-Sleep -Milliseconds 250
                }
                $installPath = "$($volume.DriveLetter):\setup.exe"
                
                #region Perform Installation
                $resultText = switch ($Parameters.Mode) {
                    'InstallSchema' { & $installPath /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /PrepareSchema 2>&1 }
                    'UpdateSchema' { & $installPath /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /PrepareSchema 2>&1 }
                    'Install' {
                        if (-not $Parameters.SplitPermission) { & $installPath /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /PrepareAD /OrganizationName:$($Parameters.OrganizationName) 2>&1 }
                        else { & $installPath /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /PrepareAD /ActiveDirectorySplitPermissions:true /OrganizationName:$($Parameters.OrganizationName) 2>&1 }
                    }
                    'Update' { & $installPath /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /PrepareAD /OrganizationName:$($Parameters.OrganizationName) 2>&1 }
                    'EnableSplit' {
                        & $installPath /PrepareAD /ActiveDirectorySplitPermissions:true /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1
                        if (-not $Parameters) { & $installPath /PrepareDomain /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1 }
                        else { & $installPath /PrepareAllDomains /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1 }
                    }
                    'DisableSplit' {
                        & $installPath /PrepareAD /ActiveDirectorySplitPermissions:false /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1
                        if (-not $Parameters) { & $installPath /PrepareDomain /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1 }
                        else { & $installPath /PrepareAllDomains /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF 2>&1 }
                    }
                }
                $results = [pscustomobject]@{
                    Success = $LASTEXITCODE -lt 1
                    Message = $resultText -join "`n"
                }
                #endregion Perform Installation
                
                # 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 Functions
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }

        if (-not $InputObject) {
            $InputObject = Test-FMExchangeSchema @parameters
        }

        foreach ($testItem in $InputObject)
        {
            #region Apply Updates if needed
            switch ($testItem.Type)
            {
                #region Install Exchange Schema
                'CreateSchema'
                {
                    if (-not (Test-ExchangeIsoPath -Session $session -Path $testItem.Configuration.LocalImagePath)) {
                        Stop-PSFFunction -String 'Invoke-FMExchangeSchema.IsoPath.Missing' -StringValues $testItem.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMExchangeSchema.Installing' -ActionStringValues $testItem.Configuration -Target $forestObject -ScriptBlock {
                        Invoke-ExchangeSchemaUpdate -Session $session -Mode InstallSchema -Path $testItem.Configuration.LocalImagePath -OrganizationName $testItem.Configuration.OrganizationName -ErrorAction Stop -SchemaOnly
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                #endregion Install Exchange Schema
                
                #region Update Exchange Schema
                'UpdateSchema'
                {
                    if (-not (Test-ExchangeIsoPath -Session $session -Path $testItem.Configuration.LocalImagePath)) {
                        Stop-PSFFunction -String 'Invoke-FMExchangeSchema.IsoPath.Missing' -StringValues $testItem.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMExchangeSchema.Updating' -ActionStringValues $testItem.ADObject, $testItem.Configuration -Target $forestObject -ScriptBlock {
                        Invoke-ExchangeSchemaUpdate -Session $session -Mode UpdateSchema -Path $testItem.Configuration.LocalImagePath -OrganizationName $testItem.ADObject.OrganizationName -ErrorAction Stop -SchemaOnly
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                #endregion Update Exchange Schema
                
                #region Install Exchange Schema & AD Objects
                'Create'
                {
                    if (-not (Test-ExchangeIsoPath -Session $session -Path $testItem.Configuration.LocalImagePath)) {
                        Stop-PSFFunction -String 'Invoke-FMExchangeSchema.IsoPath.Missing' -StringValues $testItem.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMExchangeSchema.Installing' -ActionStringValues $testItem.Configuration -Target $forestObject -ScriptBlock {
                        Invoke-ExchangeSchemaUpdate -Session $session -Mode Install -Path $testItem.Configuration.LocalImagePath -OrganizationName $testItem.Configuration.OrganizationName -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                #endregion Install Exchange Schema & AD Objects
                
                #region Update Exchange Schema & AD Objects
                'Update'
                {
                    if (-not (Test-ExchangeIsoPath -Session $session -Path $testItem.Configuration.LocalImagePath)) {
                        Stop-PSFFunction -String 'Invoke-FMExchangeSchema.IsoPath.Missing' -StringValues $testItem.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMExchangeSchema.Updating' -ActionStringValues $testItem.ADObject, $testItem.Configuration -Target $forestObject -ScriptBlock {
                        Invoke-ExchangeSchemaUpdate -Session $session -Mode Update -Path $testItem.Configuration.LocalImagePath -OrganizationName $testItem.ADObject.OrganizationName -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                #endregion Update Exchange Schema & AD Objects

                'DisableSplitP'
                {
                    if (-not (Test-ExchangeIsoPath -Session $session -Path $testItem.Configuration.LocalImagePath)) {
                        Stop-PSFFunction -String 'Invoke-FMExchangeSchema.IsoPath.Missing' -StringValues $testItem.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMExchangeSchema.DisablingSplitPermissions' -ActionStringValues $testItem.Configuration -Target $Server -ScriptBlock {
                        Invoke-ExchangeSchemaUpdate -Session $session -Mode DisableSplit -AllDomains $testItem.Configuration.AllDomains -Path $testItem.Configuration.LocalImagePath -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                'EnableSplitP'
                {
                    if (-not (Test-ExchangeIsoPath -Session $session -Path $testItem.Configuration.LocalImagePath)) {
                        Stop-PSFFunction -String 'Invoke-FMExchangeSchema.IsoPath.Missing' -StringValues $testItem.Configuration.LocalImagePath -EnableException $EnableException -Continue -Category ResourceUnavailable -Target $Server
                    }
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMExchangeSchema.EnablingSplitPermissions' -ActionStringValues $testItem.Configuration -Target $Server -ScriptBlock {
                        Invoke-ExchangeSchemaUpdate -Session $session -Mode EnableSplit -AllDomains $testItem.Configuration.AllDomains -Path $testItem.Configuration.LocalImagePath -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
            }
            #endregion Apply Updates if needed
        }
    }
    end
    {
        if ($session) { Remove-PSSession -Session $session -ErrorAction Ignore -Confirm:$false -WhatIf:$false }
    }
}


function Register-FMExchangeSchema {
    <#
    .SYNOPSIS
        Registers an exchange version to apply to the forest's schema and configuration.
     
    .DESCRIPTION
        Registers an exchange version to apply to the forest's schema and configuration.
        Updating both requires both Schema Admin and Enterprise Admin permissions.
         
        Domain-Level changes to Exchange are handled by the DomainManagement module.
     
    .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 identifiers in AD:
        RangeUpper in schema and ObjectVersion in configuration.
        This parameter is to help avoiding to have to look up those values.
        If your version is not supported by us yet, look up those numbers and explicitly bind it to -RangeUpper and -ObjectVersion isntead.
     
    .PARAMETER RangeUpper
        The explicit RangeUpper schema attribute property, found on the ms-Exch-Schema-Version-Pt class in schema.
     
    .PARAMETER ObjectVersion
        The object version on the msExchOrganizationContainer type object in the configuration.
        Do NOT confuse that with the ObjectVersion of the exchange object in the default Naming Context (regular domain space).
     
    .PARAMETER OrganizationName
        The name of the Exchange Organization.
        Only used for CREATING a new Exchange deployment.
        Make sure to customize this if you are picky about names like that.
     
    .PARAMETER SchemaOnly
        Whether to only apply the schema updates.
        Enabling this will mean no configuration scope changes are applied and the root domain also will not be pre-configured for Exchange.
     
    .PARAMETER SplitPermission
        Whether the exchange installation should implement Active Directory Split Permissions.
        With Split Permissions, Exchange Administrators will be less able to affect Active Directory.
        This provides more security, but imposes more administrative effort.
 
        For more details on Split Permissions, see this documentation:
        https://docs.microsoft.com/en-us/exchange/permissions/split-permissions/configure-exchange-for-split-permissions?view=exchserver-2019
 
    .PARAMETER AllDomains
        Whether the domain content changes to the root domain should be applied to ALL domains.
        Only applies to the SplitPermission change.
 
    .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-FMExchangeSchema -LocalImagePath 'C:\ISO\exchange-2019-cu6.iso' -ExchangeVersion '2019CU6'
         
        Registers the Exchange 2019 CU6 exchange version as exchange forest settings to be applied.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $LocalImagePath,
        
        [Parameter(Mandatory = $true, ParameterSetName = 'Version')]
        [PsfValidateSet(TabCompletion = 'ForestManagement.ExchangeVersion')]
        [PsfArgumentCompleter('ForestManagement.ExchangeVersion')]
        [string]
        $ExchangeVersion,
        
        [Parameter(Mandatory = $true, ParameterSetName = 'Details')]
        [int]
        $RangeUpper,
        
        [Parameter(ParameterSetName = 'Details')]
        [int]
        $ObjectVersion,
        
        [string]
        $OrganizationName = 'Exchange Organization',
        
        [switch]
        $SchemaOnly,

        [bool]
        $SplitPermission = $false,

        [switch]
        $AllDomains,
        
        [string]
        $ContextName = '<Undefined>'
    )
    
    process {
        $object = [pscustomobject]@{
            PSTypeName       = 'ForestManagement.Configuration.ExchangeSchema'
            RangeUpper       = $RangeUpper
            ObjectVersion    = $ObjectVersion
            LocalImagePath   = $LocalImagePath
            ExchangeVersion  = (Get-AdcExchangeVersion | Where-Object RangeUpper -EQ $RangeUpper | Where-Object ObjectVersionConfig -EQ $ObjectVersion | Sort-Object Name | Select-Object -Last 1).Name
            OrganizationName = $OrganizationName
            SchemaOnly       = $SchemaOnly.ToBool()
            SplitPermission  = $SplitPermission
            AllDomains       = $AllDomains
            ContextName      = $ContextName
        }
        
        if ($ExchangeVersion) {
            # Will always succeede, since the input validation prevents invalid exchange versions
            $exchangeVersionInfo = Get-AdcExchangeVersion -Binding $ExchangeVersion
            $object.RangeUpper = $exchangeVersionInfo.RangeUpper
            $object.ObjectVersion = $exchangeVersionInfo.ObjectVersionConfig
            $object.ExchangeVersion = $exchangeVersionInfo.Name
        }
        
        Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Value {
            if ($this.ExchangeVersion) { $this.ExchangeVersion }
            else { '{0} : {1}' -f $this.RangeUpper, $this.ObjectVersion }
        } -Force
        $script:exchangeschema = @($object)
    }
}


function Test-FMExchangeSchema {
    <#
    .SYNOPSIS
        Tests, whether the desired Exchange version has already been applied to the Forest.
     
    .DESCRIPTION
        Tests, whether the desired Exchange version has already been applied to the Forest.
     
    .PARAMETER Server
        The server / domain to work with.
         
    .PARAMETER Credential
        The credentials to use for this operation.
     
    .EXAMPLE
        PS C:\> Test-FMExchangeSchema -Server contoso.com
     
        Tests whether the desired Exchange version has already been applied to the contoso.com forest.
#>

    [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 ExchangeSchema -Cmdlet $PSCmdlet
        
        #region Utility Functions
        function Get-ExchangeRangeUpper {
            [CmdletBinding()]
            param (
                [hashtable]
                $Parameters
            )
            
            $rootDSE = Get-ADRootDSE @parameters
            (Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter "(name=ms-Exch-Schema-Version-Pt)" -Properties rangeUpper).rangeUpper
        }
        
        function Get-ExchangeObjectVersion {
            [CmdletBinding()]
            param (
                [hashtable]
                $Parameters
            )
            
            $rootDSE = Get-ADRootDSE @parameters
            (Get-ADObject @parameters -SearchBase $rootDSE.configurationNamingContext -LDAPFilter '(objectClass=msExchOrganizationContainer)' -Properties ObjectVersion).ObjectVersion
        }
        
        function Get-ExchangeOrganizationName {
            [CmdletBinding()]
            param (
                [hashtable]
                $Parameters
            )
            
            $rootDSE = Get-ADRootDSE @parameters
            (Get-ADObject @parameters -SearchBase $rootDSE.configurationNamingContext -LDAPFilter '(objectClass=msExchOrganizationContainer)').Name
        }
        #endregion Utility Functions
    }
    process {
        $forest = Get-ADForest @parameters
        $schemaVersion = Get-ExchangeRangeUpper -Parameters $parameters
        $objectVersion = Get-ExchangeObjectVersion -Parameters $parameters
        $displayName = (Get-AdcExchangeVersion | Where-Object RangeUpper -EQ $schemaVersion | Where-Object ObjectVersionConfig -EQ $objectVersion | Sort-Object Name | Select-Object -Last 1).Name
        $splitPermissionsEnabled = Test-ADObject @parameters -Identity ("OU=Microsoft Exchange Protected Groups,%DomainDN%" | Resolve-String)

        $adData = [pscustomobject]@{
            SchemaVersion    = $schemaVersion
            ObjectVersion    = $objectVersion
            DisplayName      = $displayName
            OrganizationName = Get-ExchangeOrganizationName -Parameters $parameters
            SplitPermissions = $splitPermissionsEnabled
        }
        Add-Member -InputObject $adData -MemberType ScriptMethod -Name ToString -Value {
            if ($this.DisplayName) { $this.DisplayName }
            else { '{0} : {1}' -f $this.SchemaVersion, $this.ObjectVersion }
        } -Force
        $configuredData = Get-FMExchangeSchema

        $common = @{
            ObjectType    = 'ExchangeSchema'
            Identity      = $forest
            Server        = $Server
            Configuration = $configuredData
            ADObject      = $adData
        }
        
        if ($configuredData.SchemaOnly) {
            if (-not $schemaVersion) {
                New-TestResult @common -Type CreateSchema
            }
            elseif ($configuredData.RangeUpper -gt $schemaVersion) {
                New-TestResult @common -Type UpdateSchema
            }
            return
        }
        
        if (-not $schemaVersion -or -not $objectVersion) {
            New-TestResult @common -Type Create
            return
        }
        if (($configuredData.RangeUpper -gt $schemaVersion) -or ($configuredData.ObjectVersion -gt $objectVersion)) {
            New-TestResult @common -Type Update
        }
        if ($splitPermissionsEnabled -and -not $configuredData.SplitPermission) {
            New-TestResult @common -Type DisableSplitP
        }
        if (-not $splitPermissionsEnabled -and $configuredData.SplitPermission) {
            New-TestResult @common -Type EnableSplitP
        }
    }
}


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

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


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

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


function Invoke-FMForestLevel
{
<#
    .SYNOPSIS
        Applies the desired forest level if needed.
     
    .DESCRIPTION
        Applies the desired forest level if needed.
     
    .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-FMForestLevel -Server contoso.com
     
        Raises the forest "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 ForestLevel -Cmdlet $PSCmdlet
    }
    process
    {
        if (-not $InputObject) {
            $InputObject = Test-FMForestLevel @parameters
        }

        foreach ($testItem in $InputObject)
        {
            switch ($testItem.Type)
            {
                'Raise'
                {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMForestLevel.Raise.Level' -ActionStringValues $testItem.Configuration.Level -Target $testItem.ADObject -ScriptBlock {
                        Set-ADForestMode @parameters -ForestMode $testItem.Configuration.DesiredLevel -Identity $testItem.ADObject -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
            }
        }
    }
}

function Register-FMForestLevel
{
<#
    .SYNOPSIS
        Register a forest functional level as desired state.
     
    .DESCRIPTION
        Register a forest 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-FMForestLevel -Level 2016
     
        Apply the desired forest level of 2016
#>

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

function Test-FMForestLevel
{
<#
    .SYNOPSIS
        Tests whether the target forest has at least the desired functional level.
     
    .DESCRIPTION
        Tests whether the target forest 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-FMForestLevel -Server contoso.com
     
        Tests whether the forest 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 ForestLevel -Cmdlet $PSCmdlet
    }
    process
    {
        $levelValues = @{
            '2008R2' = 4
            '2012'   = 5
            '2012R2' = 6
            '2016'   = 7
        }
        $level = Get-FMForestLevel
        $desiredLevel = $levelValues[$level.Level]
        $tempConfiguration = $level | ConvertTo-PSFHashtable
        $tempConfiguration['DesiredLevel'] = [Microsoft.ActiveDirectory.Management.ADForestMode]$desiredLevel
        $forest = Get-ADForest @parameters
        if ($forest.ForestMode -lt $desiredLevel)
        {
            New-TestResult -ObjectType ForestLevel -Type Raise -Identity $forest -Server $Server -Configuration ([pscustomobject]$tempConfiguration) -ADObject $forest
        }
    }
}


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

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


function Get-FMNTAuthStore {
    <#
    .SYNOPSIS
        Returns registered NTAuthStore Certificates.
     
    .DESCRIPTION
        Returns registered NTAuthStore Certificates.
     
    .PARAMETER Thumbprint
        The thumbprint of the certificate to filter by.
     
    .PARAMETER Name
        The name of the certificate to filter by.
     
    .EXAMPLE
        PS C:\> Get-FMNTAuthStore
 
        Returns all registered certificates intended for the NTAuthStore
    #>

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

        [string]
        $Name = '*'
    )
    
    process {
        ($script:ntAuthStoreCertificates.Values) | Where-Object Thumbprint -like $Thumbprint | Where-Object {
            $_.Subject -like $Name -or
            $_.Subject -like "CN=$Name" -or
            $_.FriendlyName -like $Name
        }
    }
}


function Invoke-FMNTAuthStore {
<#
    .SYNOPSIS
        Applies the desired certificates to the NTAuth store.
     
    .DESCRIPTION
        Applies the desired certificates to the NTAuth store.
        This allows distributing certificates that are trusted across the entire forest.
     
    .PARAMETER InputObject
        The test results to apply.
        Only specify objects returned by Test-FMNTAuthStore.
        By default, if you do not specify this parameter it will run the test and apply all deltas found.
     
    .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-FMNTAuthStore -Server contoso.com
     
        Applies the defined NTAuthStore configuration to the contoso.com 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 ntAuthStoreCertificates -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-FMNTAuthStore.WinRM.Failed' -StringValues $computerName -ErrorRecord $_ -EnableException $EnableException -Cmdlet $PSCmdlet -Target $computerName
            return
        }
        
        #region Add Certificate Scriptblock
        $addCertificateScript = {
            param (
                $Certificate
            )
            
            $certPath = "$env:temp\cert_$(Get-Random -Minimum 10000 -Maximum 99999).cer"
            
            try { $Certificate.GetRawCertData() | Set-Content $certPath -Encoding Byte -ErrorAction Stop }
            catch {
                [pscustomobject]@{
                    Success = $false
                    Stage   = 'Writing certificate file'
                    Error   = $_
                }
                return
            }
            
            $res = certutil.exe -dspublish -f $certPath NTAuthCA 2>&1
            if ($LASTEXITCODE -gt 0) {
                [pscustomobject]@{
                    Success = $false
                    Stage   = 'Applying certificate using certutil'
                    Error   = $res
                }
                Remove-Item -Path $certPath -ErrorAction Ignore
                return
            }
            Remove-Item -Path $certPath -ErrorAction Ignore
            
            [pscustomobject]@{
                Success = $true
                Stage   = 'Done'
                Error   = $null
            }
        }
        #endregion Add Certificate Scriptblock
    }
    process {
        if (Test-PSFFunctionInterrupt) { return }
        
        # Test All NTAuthStore Certificates if no specific test result was specified
        if (-not $InputObject) {
            $InputObject = Test-FMNTAuthStore @parameters
        }
        
        :main foreach ($testResult in $InputObject) {
            # Catch invalid input - can only process test results
            if ($testResult.PSObject.TypeNames -notcontains 'ForestManagement.NTAuthStore.TestResult') {
                Stop-PSFFunction -String 'Invoke-FMNTAuthStore.Invalid.Input' -StringValues $testResult -Target $testResult -Continue -EnableException $EnableException
            }
            
            switch ($testResult.Type) {
                'Add' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMNTAuthStore.Add' -ActionStringValues $testResult.Configuration.Subject -Target $testResult -ScriptBlock {
                        $result = Invoke-Command -Session $session -ArgumentList $testResult.Configuration -ScriptBlock $addCertificateScript
                        if (-not $result.Success) {
                            throw "Error executing $($result.Stage) : $($result.Error)"
                        }
                        $rootDSE = Get-ADRootDSE @parameters
                        $storeObject = Get-ADObject @parameters -Identity "CN=NTAuthCertificates,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)" -ErrorAction Stop -Properties cACertificate
                        $storedCertificates = $storeObject.cACertificate | ForEach-Object {
                            [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($_)
                        }
                        if ($testResult.Configuration.Thumbprint -notin $storedCertificates.Thumbprint)
                        {
                            throw "Certificate could not be applied successfully for unclarified reasons! Ensure you have the permissions needed for this operation."
                        }
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -ContinueLabel main
                }
                'Remove' {
                    $rootDSE = Get-ADRootDSE @parameters
                    
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMNTAuthStore.Remove' -ActionStringValues $testResult.ADObject.Subject -Target $testResult -ScriptBlock {
                        Set-ADObject @parameters -Identity "CN=NTAuthCertificates,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)" -Remove @{ cACertificate = $testResult.ADObject.GetRawCertData() } -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -ContinueLabel main
                }
            }
        }
    }
    end {
        if (Test-PSFFunctionInterrupt) { return }
        
        Remove-PSSession -Session $session -Confirm:$false -WhatIf:$false
    }
}


function Register-FMNTAuthStore {
    <#
        .SYNOPSIS
            Register NTAuthStore certificates
         
        .DESCRIPTION
            Register NTAuthStore certificates
            This is the ideal / desired state for the NTAuthStore certificate configuration.
            Forests will be brought into this state by using Invoke-FMNTAuthStore.
         
        .PARAMETER Certificate
            The certifcate to apply.
         
        .PARAMETER Authorative
            Should the NTAuthStore configuration overwrite the existing configuration, rather than adding to it (default).
 
        .EXAMPLE
            PS C:\> Register-FMNTAuthStore -Certificate $NTAuthStoreCertificate
 
            Register a certiciate.
         
        .EXAMPLE
            PS C:\> Register-FMNTAuthStore -Authorative
             
            Sets our current configuration as authorative, removing all non-listed certificates from the store.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Certificate")]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]
        $Certificate,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Authorative")]
        [switch]
        $Authorative
    )
    
    process {
        switch ($pscmdlet.ParameterSetName) {
            Certificate { $script:ntAuthStoreCertificates[$Certificate.Thumbprint] = $Certificate }
            Authorative { $script:ntAuthStoreAuthorative = $Authorative.ToBool() }
        }
    }
}

function Test-FMNTAuthStore {
    <#
        .SYNOPSIS
            Tests, whether the NTAuthStore is in the desired state.
         
        .DESCRIPTION
            Tests, whether the NTAuthStore is in the desired state, that is, all defined certificates are already in place.
            Use Register-FMNTAuthStore to define desired the desired state.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
     
        .EXAMPLE
            PS C:\> Test-FMNTAuthStore -Server contoso.com
 
            Checks whether the contoso.com forest has all the NTAuth certificates it should
    #>

    [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 ntAuthStoreCertificates -Cmdlet $PSCmdlet

        #region Utility Functions
        function New-TestResult {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            Param (
                [Parameter(Mandatory = $true)]
                [string]
                $Type,

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

                [object[]]
                $Changed,

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

                $Configuration,

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

        $rootDSE = Get-ADRootDSE @parameters
        $storeObject = $null
        $storedCertificates = $null
        try {
            $storeObject = Get-ADObject @parameters -Identity "CN=NTAuthCertificates,CN=Public Key Services,CN=Services,$($rootDSE.configurationNamingContext)" -ErrorAction Stop -Properties cACertificate
            $storedCertificates = $storeObject.cACertificate | ForEach-Object {
                [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($_)
            }
            $hasStore = $storeObject -as [bool]
        }
        catch {
            $hasStore = $false
        }
    }
    process {
        $resDefault = @{
            Server = $Server
        }
        $configuredCertificates = Get-FMNTAuthStore
        foreach ($configuredCertificate in $configuredCertificates) {
            if ($storeObject) { $resDefault.ADObject = $storeObject }

            if (-not $hasStore) {
                New-TestResult @resDefault -Type 'Add' -Identity $configuredCertificate.Thumbprint -Configuration $configuredCertificate
                continue
            }

            if ($configuredCertificate.Thumbprint -notin $storedCertificates.Thumbprint) {
                New-TestResult @resDefault -Type 'Add' -Identity $configuredCertificate.Thumbprint -Configuration $configuredCertificate
                continue
            }
        }
        if (-not $hasStore) { return }
        if (-not $script:ntAuthStoreAuthorative) { return }
        
        $resDefault = @{
            Server = $Server
        }
        foreach ($storedCertificate in $storedCertificates) {
            if ($storedCertificate.Thumbprint -notin $configuredCertificates.Thumbprint) {
                New-TestResult @resDefault -Type 'Remove' -Identity $storedCertificate.Thumbprint -ADObject $storedCertificate
            }
        }
    }
}


function Unregister-FMNTAuthStore
{
    <#
    .SYNOPSIS
        Removes a certificate definition for the NTAuthStore.
     
    .DESCRIPTION
        Removes a certificate definition for the NTAuthStore.
        See Register-FMNTAuthStore tfor details on defining a certificate.
     
    .PARAMETER Thumbprint
        The thumbprint of the certificate to remove.
     
    .EXAMPLE
        PS C:\> Get-FMNTAuthStore | Unregister-FMNTAuthStore
 
        Clears all certificates from the list of defined NTAuth certificates
    #>

    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Thumbprint
    )
    
    process
    {
        foreach ($thumbprintString in $Thumbprint) {
            $script:ntAuthStoreCertificates.Remove($thumbprintString)
        }
    }
}


function Get-FMSchema
{
    <#
    .SYNOPSIS
        Returns the list of registered Schema Extensions.
     
    .DESCRIPTION
        Returns the list of registered Schema Extensions.
     
    .PARAMETER Name
        Name to filter by.
        Defaults to '*'
     
    .EXAMPLE
        PS C:\> Get-FMSchema
 
        Returns a list of all schema extensions
    #>

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


function Invoke-FMSchema {
    <#
        .SYNOPSIS
            Updates the schema to conform to the desired state.
         
        .DESCRIPTION
            Updates the schema to conform to the desired state.
            Can add new attributes and update existing ones.
 
            Use Register-FMSchema to define the desired state.
            Use the module's configuration settings to govern schema admin credentials.
            The configuration can be read with Get-PSFConfig and updated with Set-PSFConfig.
         
        .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-FMSchema
 
            Updates the schema of the current forest according to the configured settings
    #>

    
    [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 Schema -Cmdlet $PSCmdlet
        try { $rootDSE = Get-ADRootDSE @parameters -ErrorAction Stop }
        catch {
            Stop-PSFFunction -String 'Invoke-FMSchema.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException()
            return
        }
        $forest = Get-ADForest @parameters
        $parameters["Server"] = $forest.SchemaMaster
        $removeParameters = $parameters.Clone()
        
        #region Resolve Credentials
        $cred = $null
        if (Test-SchemaAdminCredential) {
            Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Schema.Credentials' -Target $forest.SchemaMaster -ScriptBlock {
                [PSCredential]$cred = Get-SchemaAdminCredential @parameters | Write-Output | Select-Object -First 1
                if ($cred) { $parameters['Credential'] = $cred }
            } -EnableException $EnableException -PSCmdlet $PSCmdlet
            if (Test-PSFFunctionInterrupt) { return }
        }
        
        $null = Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Credentials.Test' -Target $forest.SchemaMaster -ScriptBlock {
            $null = Get-ADDomain @parameters -ErrorAction Stop
        } -EnableException $EnableException -PSCmdlet $PSCmdlet -RetryCount 5 -RetryWait 1
        if (Test-PSFFunctionInterrupt) { return }
        #endregion Resolve Credentials

        # Prepare parameters to use for when discarding the schema credentials
        if ($cred -and ($cred -ne $Credential)) { $removeParameters['SchemaAccountCredential'] = $cred }
    }
    process {
        if (Test-PSFFunctionInterrupt) { return }

        if (-not $InputObject) {
            $InputObject = Test-FMSchema @parameters
        }

        $testResultsSorted = $InputObject | Sort-Object {
            if ($_.Type -eq 'Decommission') { 0 }
            elseif ($_.Type -eq 'Rename') { 2 }
            elseif ($_.Type -eq 'ConfigurationOnly') { 3 }
            else { 1 }
        }
        :main foreach ($testItem in $testResultsSorted) {
            switch ($testItem.Type) {
                #region Create new Schema Attribute
                'Create' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Creating.Attribute' -Target $testItem.Identity -ScriptBlock {
                        New-ADObject @parameters -Type attributeSchema -Name $testItem.Configuration.AdminDisplayName -Path $rootDSE.schemaNamingContext -OtherAttributes (Resolve-SchemaAttribute -Configuration $testItem.Configuration) -ErrorAction Stop
                        Update-Schema @parameters
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                    
                    foreach ($class in  $testItem.Configuration.ObjectClass) {
                        try { $classObject = Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter "(name=$($class))" -ErrorAction Stop }
                        catch { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.Failed' -StringValues $class -EnableException $EnableException -Continue -ErrorRecord $_ }
                        if (-not $classObject) { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.NotFound' -StringValues $class -EnableException $EnableException -Continue }

                        Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Assigning.Attribute.ToObjectClass' -ActionStringValues $class -Target $testItem.Identity -ScriptBlock {
                            $classObject | Set-ADObject @parameters -Add @{ mayContain = $testItem.Configuration.LdapDisplayName } -ErrorAction Stop
                        } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -RetryCount 10
                    }
                }
                #endregion Create new Schema Attribute

                #region Decommission the unwanted Schema Attribute
                'Decommission' {
                    $values = @{
                        IsDefunct = $true
                        # PartialAttributeSet = $false
                    }
                    foreach ($adObject in (Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter "(mayContain=$($testItem.Configuration.OID))" -Properties ldapDisplayName)) {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Decommission.MayContain' -ActionStringValues $testItem.ADObject.LdapDisplayName, $adObject.LdapDisplayName -Target $testItem -ScriptBlock {
                            $adObject | Set-ADObject @parameters -Remove @{ mayContain = $testItem.ADObject.LdapDisplayName } -ErrorAction Stop
                        } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                    }

                    foreach ($adObject in (Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter "(mustContain=$($testItem.Configuration.OID))" -Properties ldapDisplayName)) {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Decommission.MustContain' -ActionStringValues $testItem.ADObject.LdapDisplayName, $adObject.LdapDisplayName -Target $testItem -ScriptBlock {
                            $adObject | Set-ADObject @parameters -Remove @{ mustContain = $testItem.ADObject.LdapDisplayName } -ErrorAction Stop
                        } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                    }

                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Decommission.Attribute' -ActionStringValues $testItem.ADObject.LdapDisplayName, $testItem.ADObject.AttributeID -Target $testItem -ScriptBlock {
                        $testItem.ADObject | Set-ADObject @parameters -Replace $values -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                    $rootDSE = Get-ADRootDSE @parameters
                }
                #endregion Decommission the unwanted Schema Attribute

                #region Update Schema Attribute
                'Update' {
                    $resolvedAttributes = Resolve-SchemaAttribute -Configuration $testItem.Configuration -ADObject $testItem.ADObject -Changes $testItem.Changed
                    if ($resolvedAttributes.Keys.Count -ge 1) {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Updating.Attribute' -ActionStringValues ($resolvedAttributes.Keys -join ', ') -Target $testItem.Identity -ScriptBlock {
                            $testItem.ADObject | Set-ADObject @parameters -Replace $resolvedAttributes -ErrorAction Stop
                        } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                    }

                    # Do not process MayContain for defunct attributes
                    if ($testItem.Configuration.IsDefunct) { continue }

                    # Only proceed if any Object Class changes are intended
                    $change = $testItem.Changed | Where-Object Property -EQ 'ObjectClass'
                    if (-not $change) { continue }

                    foreach ($class in $change.New | Where-Object { $_ -notin $change.Old }) {
                        try { $classObject = Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter "(name=$($class))" -ErrorAction Stop -Properties mayContain }
                        catch { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.Failed' -StringValues $class -EnableException $EnableException -Continue -ErrorRecord $_ }
                        if (-not $classObject) { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.NotFound' -StringValues $class -EnableException $EnableException -Continue }
    
                        if ($classObject.mayContain -notcontains $testItem.ADObject.LdapDisplayName) {
                            Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Assigning.Attribute.ToObjectClass' -ActionStringValues $class, $testItem.Identity -Target $testItem.Identity -ScriptBlock {
                                $classObject | Set-ADObject @parameters -Add @{ mayContain = $testItem.ADObject.LdapDisplayName } -ErrorAction Stop
                            } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                        }
                    }

                    foreach ($class in $change.Old | Where-Object { $_ -notin $change.New }) {
                        try { $classObject = Get-ADObject @parameters -SearchBase $rootDSE.schemaNamingContext -LDAPFilter "(name=$($class))" -ErrorAction Stop -Properties mayContain }
                        catch { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.Failed' -StringValues $class -EnableException $EnableException -Continue -ErrorRecord $_ }
                        if (-not $classObject) { Stop-PSFFunction -String 'Invoke-FMSchema.Reading.ObjectClass.NotFound' -StringValues $class -EnableException $EnableException -Continue }
    
                        if ($classObject.mayContain -contains $testItem.ADObject.LdapDisplayName) {
                            Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Removing.Attribute.FromObjectClass' -ActionStringValues $class, $testItem.Identity -Target $testItem.Identity -ScriptBlock {
                                $classObject | Set-ADObject @parameters -Remove @{ mayContain = $testItem.ADObject.LdapDisplayName } -ErrorAction Stop
                            } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                        }
                    }
                }
                #endregion Update Schema Attribute

                #region Rename Schema Attribute
                'Rename' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Rename.Attribute' -ActionStringValues $testItem.ADObject.cn, $testItem.Configuration.Name -Target $testItem -ScriptBlock {
                        $testItem.ADObject | Rename-ADObject -NewName $testItem.Configuration.Name -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                #endregion Rename Schema Attribute
            }
        }
    }
    end {
        if (Test-PSFFunctionInterrupt) { return }

        if (Test-SchemaAdminCredential) {
            Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Schema.Credentials.Release' -Target $forest.SchemaMaster -ScriptBlock {
                $null = Remove-SchemaAdminCredential @removeParameters -ErrorAction Stop
            } -EnableException $EnableException -PSCmdlet $PSCmdlet
        }
    }
}

function Register-FMSchema {
    <#
    .SYNOPSIS
        Registers a schema extension attribute.
     
    .DESCRIPTION
        Registers a schema extension attribute.
        These registered attributes will be applied / updated as needed when running Invoke-FMSchema.
        Use Test-FMSchema to verify, whether a forest is properly configured.
     
    .PARAMETER ObjectClass
        The class to assign the new attribute to.
     
    .PARAMETER OID
        The unique OID of the attribute.
     
    .PARAMETER AdminDisplayName
        The displayname of the attribute as admins see it.
     
    .PARAMETER LdapDisplayName
        The name of the attribute as LDAP sees it.
 
    .PARAMETER Name
        The name of the attribute.
        Defaults to the AdminDisplayName if not specified.
     
    .PARAMETER OMSyntax
        The OM Syntax of the attribute
     
    .PARAMETER AttributeSyntax
        The syntax rules of the attribute.
     
    .PARAMETER SingleValued
        Whether the attribute is singlevalued.
     
    .PARAMETER AdminDescription
        The human friendly description of the attribute.
     
    .PARAMETER SearchFlags
        The search flags for the attribute.
     
    .PARAMETER PartialAttributeSet
        Whether the attribute is part of a partial attribute set.
     
    .PARAMETER AdvancedView
        Whether this attribute is only shown in advanced view.
        Use this to hide it from the default display, used to simplify display by hiding information not needed for regulaar daily tasks.
 
    .PARAMETER IsDefunct
        Flag this attribute as defunct.
        It will be marked as such in AD, be delisted from the Global Catalog and removed from all its supposed memberships.
 
    .PARAMETER Optional
        By default, all defined schema attributes must exist.
        By setting a schema attribute optional, it will be tolerated if it exists, but not created if it does 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 .\schema.json | ConvertFrom-Json | Write-Output | Register-FMSchema
 
        Registers all extension attributes in the json file as schema settings to apply when running Invoke-FMSchema.
#>

    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [AllowEmptyCollection()]
        [string[]]
        $ObjectClass,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $OID,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $AdminDisplayName,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $LdapDisplayName,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [int]
        $OMSyntax,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $AttributeSyntax,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [switch]
        $SingleValued,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $AdminDescription,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [int]
        $SearchFlags,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $PartialAttributeSet,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [bool]
        $AdvancedView,

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

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

        [string]
        $ContextName = '<Undefined>'
    )
    
    process {
        $nameResult = $Name
        if (-not $Name) { $nameResult = $AdminDisplayName }

        $hashtable = $PSBoundParameters | ConvertTo-PSFHashtable
        $hashtable.ContextName = $ContextName
        $hashtable.PSTypeName = 'ForestManagement.Schema.Configuration'
        if ($nameResult) { $hashtable.Name = $nameResult }

        $script:schema[$OID] = [PSCustomObject]$hashtable
    }
}


function Test-FMSchema {
    <#
        .SYNOPSIS
            Compare the current schema with the configured / desired configuration state.
         
        .DESCRIPTION
            Compare the current schema with the configured / desired configuration state.
            Only compares the custom configured settings, ignores any changes outside.
            (So it's not a delta comparison to the AD baseline)
         
        .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-FMSchema
 
            Tests the current domain's schema 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 Schema -Cmdlet $PSCmdlet
        try { $rootDSE = Get-ADRootDSE @parameters -ErrorAction Stop }
        catch {
            Stop-PSFFunction -String 'Test-FMSchema.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException()
            return
        }
        $forest = Get-ADForest @parameters
        $parameters["Server"] = $forest.SchemaMaster
    }
    process {
        # Pick up termination flag from Stop-PSFFunction and interrupt if begin failed to connect
        if (Test-PSFFunctionInterrupt) { return }

        foreach ($schemaSetting in (Get-FMSchema)) {
            $schemaObject = $null
            $schemaObject = Get-ADObject @parameters -LDAPFilter "(attributeID=$($schemaSetting.OID))" -SearchBase $rootDSE.schemaNamingContext -ErrorAction Ignore -Properties *

            if (-not $schemaObject) {
                # If we already want to disable the attribute, no need to create it
                if ($schemaSetting.IsDefunct) { continue }
                if ($schemaSetting.Optional) { continue }

                [PSCustomObject]@{
                    PSTypeName    = 'ForestManagement.Schema.TestResult'
                    Type          = 'Create'
                    ObjectType    = 'Schema'
                    Identity      = $schemaSetting.AdminDisplayName
                    Changed       = $null
                    Server        = $forest.SchemaMaster
                    ADObject      = $null
                    Configuration = $schemaSetting
                }
                continue
            }

            if ($schemaSetting.IsDefunct -and -not $schemaObject.isDefunct) {
                [PSCustomObject]@{
                    PSTypeName    = 'ForestManagement.Schema.TestResult'
                    Type          = 'Decommission'
                    ObjectType    = 'Schema'
                    Identity      = $schemaSetting.AdminDisplayName
                    Changed       = @('IsDefunct')
                    Server        = $forest.SchemaMaster
                    ADObject      = $schemaObject
                    Configuration = $schemaSetting
                }
            }

            if ($schemaSetting.Name -cne $schemaObject.cn) {
                [PSCustomObject]@{
                    PSTypeName    = 'ForestManagement.Schema.TestResult'
                    Type          = 'Rename'
                    ObjectType    = 'Schema'
                    Identity      = $schemaSetting.AdminDisplayName
                    Changed       = @('Name')
                    Server        = $forest.SchemaMaster
                    ADObject      = $schemaObject
                    Configuration = $schemaSetting
                }
            }

            $changes = [System.Collections.ArrayList]@()

            $param = @{
                Configuration = $schemaSetting
                ADObject      = $schemaObject
                CaseSensitive = $true
                IfExists      = $true
                Changes       = $changes
                Type          = 'Schema'
            }
            Compare-AdcProperty @param -Property oMSyntax
            Compare-AdcProperty @param -Property attributeSyntax
            Compare-AdcProperty @param -Property SingleValued -ADProperty isSingleValued
            Compare-AdcProperty @param -Property adminDescription
            Compare-AdcProperty @param -Property adminDisplayName
            Compare-AdcProperty @param -Property ldapDisplayName
            Compare-AdcProperty @param -Property searchflags
            Compare-AdcProperty @param -Property PartialAttributeSet -ADProperty isMemberOfPartialAttributeSet
            Compare-AdcProperty @param -Property AdvancedView -ADProperty showInAdvancedViewOnly
            if (-not $schemaSetting.IsDefunct -and $schemaObject.isDefunct) {
                Compare-AdcProperty @param -Property isDefunct
            }

            if (-not $schemaSetting.IsDefunct -and $schemaSetting.PSObject.Properties.Name -contains 'Objectclass') {
                $mayContain = Get-ADObject @parameters -LDAPFilter "(mayContain=$($schemaSetting.LdapDisplayName))" -SearchBase $rootDSE.schemaNamingContext
                if (-not $mayContain -and $schemaSetting.ObjectClass) {
                    $null = $changes.Add((New-AdcChange -Property ObjectClass -NewValue $schemaSetting.ObjectClass -Identity $schemaObject.DistinguishedName -Type Schema))
                }
                elseif ($mayContain.Name -and -not $schemaSetting.ObjectClass) {
                    $null = $changes.Add((New-AdcChange -Property ObjectClass -OldValue $mayContain.Name -Identity $schemaObject.DistinguishedName -Type Schema))
                }
                elseif (-not $mayContain.Name -and -not $schemaSetting.ObjectClass) {
                    # Nothing wrong here
                }
                elseif ($mayContain.Name | Compare-Object $schemaSetting.ObjectClass) {
                    $null = $changes.Add((New-AdcChange -Property ObjectClass -OldValue $mayContain.Name -NewValue $schemaSetting.ObjectClass -Identity $schemaObject.DistinguishedName -Type Schema))
                }
            }

            if ($changes.Count -gt 0) {
                [PSCustomObject]@{
                    PSTypeName    = 'ForestManagement.Schema.TestResult'
                    Type          = 'Update'
                    ObjectType    = 'Schema'
                    Identity      = $schemaSetting.AdminDisplayName
                    Changed       = $changes.ToArray()
                    Server        = $forest.SchemaMaster
                    ADObject      = $schemaObject
                    Configuration = $schemaSetting
                }
            }
        }
    }
}

function Unregister-FMSchema
{
    <#
    .SYNOPSIS
        Removes a configured schema extension.
     
    .DESCRIPTION
        Removes a configured schema extension.
     
    .PARAMETER Name
        Name(s) of the schema extensions to unregister.
     
    .EXAMPLE
        PS C:\> Unregister-FMSchema -Name $names
 
        Removes the list of names stored in $names from the registered schema extension configurations.
    #>

    
    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('AdminDisplayName')]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameLabel in $Name) {
            $script:schema.Remove($nameLabel)
        }
    }
}


function Get-FMSchemaDefaultPermission
{
<#
    .SYNOPSIS
        Returns the list of registered default schema permissions.
     
    .DESCRIPTION
        Returns the list of registered default schema permissions.
     
    .PARAMETER ClassName
        The name of the affected objectclass to filter by.
        Defaults to '*'.
     
    .EXAMPLE
        PS C:\> Get-FMSchemaDefaultPermission
     
        Returns the list of all registered default schema permissions.
#>

    [CmdletBinding()]
    param (
        [string]
        $ClassName = '*'
    )
    
    process
    {
        foreach ($key in $script:schemaDefaultPermissions.Keys)
        {
            if ($key -notlike $ClassName) { continue }
            
            $script:schemaDefaultPermissions[$key].Values
        }
    }
}


function Invoke-FMSchemaDefaultPermission
{
<#
    .SYNOPSIS
        Brings the target forest into compliance with the defined default permissions in its schema.
     
    .DESCRIPTION
        Brings the target forest into compliance with the defined default permissions in its schema.
     
        Use the module's configuration settings to govern schema admin credentials.
        The configuration can be read with Get-PSFConfig and updated with Set-PSFConfig.
     
    .PARAMETER InputObject
        Test results from Test-FMSchemaDefaultPermission to apply.
     
    .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-FMSchemaDefaultPermission -Server contoso.com
     
        Brings the contoso.com forest into compliance with the defined default permissions in its schema.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")]
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    Param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject,
        
        [PSFComputer]
        $Server,
        
        [PSCredential]
        $Credential,
        
        [switch]
        $EnableException
    )
    
    begin
    {
        #region Utility Functions
        function Add-AccessRule
        {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")]
            [CmdletBinding()]
            param (
                $Change,
                
                $Session,
                
                [Hashtable]
                $Tracking
            )
            
            Invoke-Command -Session $Session -ArgumentList $Change -ScriptBlock {
                param ($Change)
                
                $rule = New-Object System.DirectoryServices.ActiveDirectoryAccessRule(
                    [System.Security.Principal.SecurityIdentifier]$Change.Configuration.Principal,
                    $Change.Configuration.ActiveDirectoryRights,
                    $Change.Configuration.AccessControlType,
                    $Change.Configuration.ObjectTypeGuid,
                    $Change.Configuration.InheritanceType,
                    $Change.Configuration.InheritedObjectTypeGuid
                )
                $null = $acl.AddAccessRule($rule)
            } -ErrorAction Stop
            $Tracking[$Change] = $Change
        }
        
        function Remove-AccessRule
        {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")]
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                $Change,
                
                $Session,
                
                [Hashtable]
                $Tracking
            )
            
            Invoke-Command -Session $Session -ArgumentList $Change -ScriptBlock {
                param ($Change)
                $rules = $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
                foreach ($rule in $rules)
                {
                    if ($rule.ActiveDirectoryRights -ne $Change.ADObject.ActiveDirectoryRights) { continue }
                    if ($rule.InheritanceType -ne $Change.ADObject.InheritanceType) { continue }
                    if ($rule.ObjectType -ne $Change.ADObject.ObjectType) { continue }
                    if ($rule.InheritedObjectType -ne $Change.ADObject.InheritedObjectType) { continue }
                    if ($rule.AccessControlType -ne $Change.ADObject.AccessControlType) { continue }
                    if ("$($rule.IdentityReference)" -ne "$($Change.ADObject.IdentityReference)") { continue }
                    $null = $acl.RemoveAccessRule($rule)
                }
            } -ErrorAction Stop
            $Tracking[$Change] = $Change
        }
        
        function Write-SchemaDefaultPermission
        {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseUsingScopeModifierInNewRunspaces", "")]
            [CmdletBinding()]
            param (
                $Session,
                
                [hashtable]
                $SchemaParameters
            )
            
            $newSddl, $schemaObjectDN = Invoke-Command -Session $Session -ScriptBlock { $acl.Sddl, $schemaObject.DistinguishedName }
            
            Set-ADObject @SchemaParameters -Identity $schemaObjectDN -Replace @{ defaultSecurityDescriptor = $newSddl } -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 SchemaDefaultPermissions -Cmdlet $PSCmdlet
        try { $rootDSE = Get-ADRootDSE @parameters -ErrorAction Stop }
        catch
        {
            Stop-PSFFunction -String 'Invoke-FMSchemaDefaultPermission.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException()
            return
        }
        $forest = Get-ADForest @parameters
        $parameters["Server"] = $forest.SchemaMaster
        #region WinRM
        Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaDefaultPermission.WinRM.Connect' -Target $forest.SchemaMaster -ScriptBlock {
            $psParameters = $parameters.Clone()
            $psParameters.Remove('Server')
            $psParameters.ComputerName = $forest.SchemaMaster
            $session = New-PSSession @psParameters -ErrorAction Stop
        } -EnableException $EnableException -PSCmdlet $PSCmdlet -WhatIf:$false -Confirm:$false
        #endregion WinRM
        
        #region Resolve Credentials
        $cred = $null
        $schemaParameters = $parameters.Clone()
        Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaDefaultPermission.Schema.Credentials' -Target $forest.SchemaMaster -ScriptBlock {
            [PSCredential]$cred = Get-SchemaAdminCredential @parameters | Write-Output | Select-Object -First 1
            if ($cred) { $schemaParameters['Credential'] = $cred }
        } -EnableException $EnableException -PSCmdlet $PSCmdlet
        if (Test-PSFFunctionInterrupt) { return }
        $null = Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaDefaultPermission.Credentials.Test' -Target $forest.SchemaMaster -ScriptBlock {
            $null = Get-ADDomain @schemaParameters -ErrorAction Stop
        } -EnableException $EnableException -PSCmdlet $PSCmdlet -RetryCount 5 -RetryWait 1
        if (Test-PSFFunctionInterrupt) { return }
        #endregion Resolve Credentials
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }
        
        if (-not $InputObject)
        {
            $InputObject = Test-FMSchemaDefaultPermission @parameters -EnableException:$EnableException
        }
        
        foreach ($testItem in $InputObject)
        {
            # Catch invalid input - can only process test results
            if ($testItem.PSObject.TypeNames -notcontains 'ForestManagement.SchemaDefaultPermission.TestResult')
            {
                Stop-PSFFunction -String 'General.Invalid.Input' -StringValues 'Test-FMSchemaDefaultPermission', $testItem -Target $testItem -Continue -EnableException $EnableException
            }
            
            switch ($testItem.Type)
            {
                'Update'
                {
                    #region Load Acl from SDDL
                    Invoke-Command -Session $session -ArgumentList $rootDSE.schemaNamingContext, $testItem.Identity -ScriptBlock {
                        param ($SchemaNC, $ClassName)
                        $schemaObject = Get-ADObject -Server localhost -SearchBase $SchemaNC -LDAPFilter "(name=$ClassName)" -Properties defaultSecurityDescriptor
                        $acl = New-Object System.DirectoryServices.ActiveDirectorySecurity
                        $acl.SetSecurityDescriptorSddlForm($schemaObject.defaultSecurityDescriptor)
                    }
                    #endregion Load Acl from SDDL
                    
                    #region Apply individual changes to in-memory ACL
                    $tracking = @{ }
                    # Apply remove changes
                    foreach ($change in $testItem.Changed)
                    {
                        if ($change.Type -ne 'Remove') { continue }
                        
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaDefaultPermission.AccessRule.Remove' -ActionStringValues $change.Identity, $change.Privilege, $change.Access -Target $testItem -ScriptBlock {
                            Remove-AccessRule -Change $change -Session $session -Tracking $tracking -ErrorAction Stop
                        } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -Tag invoke
                        
                    }
                    
                    # Apply add changes
                    foreach ($change in $testItem.Changed)
                    {
                        if ($change.Type -ne 'Add') { continue }
                        
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaDefaultPermission.AccessRule.Add' -ActionStringValues $change.Identity, $change.Privilege, $change.Access -Target $testItem -ScriptBlock {
                            Add-AccessRule -Change $change -Session $session -Tracking $tracking -ErrorAction Stop
                        } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -Tag invoke
                    }
                    #endregion Apply individual changes to in-memory ACL
                    
                    # Write SDDL back to schema object
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaDefaultPermission.Permissions.Update' -ActionStringValues $tracking.Count, $testItem.Changed.Count, $testItem.Identity -Target $testItem -ScriptBlock {
                        Write-SchemaDefaultPermission -Session $session -SchemaParameters $schemaParameters -ErrorAction Stop
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue -Tag invoke
                }
                'NotFound'
                {
                    Write-PSFMessage -Level Warning -String 'Invoke-FMSchemaDefaultPermission.NotFound' -StringValues $testItem.Identity -Target $testItem
                }
                'IdentityError'
                {
                    Write-PSFMessage -Level Warning -String 'Invoke-FMSchemaDefaultPermission.IdentityError' -StringValues $testItem.Identity -Target $testItem
                }
            }
        }
    }
    end
    {
        if ($session) { Remove-PSSession -Session $session -ErrorAction Ignore -WhatIf:$false -Confirm:$false }
        
        if (Test-PSFFunctionInterrupt) { return }
        
        Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaDefaultPermission.Schema.Credentials.Release' -Target $forest.SchemaMaster -ScriptBlock {
            $null = Remove-SchemaAdminCredential @parameters -ErrorAction Stop
        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet
    }
}

function Register-FMSchemaDefaultPermission
{
<#
    .SYNOPSIS
        Registers a new desired schema default permission access rule.
     
    .DESCRIPTION
        Registers a new desired schema default permission access rule.
        These access rules are then used / applied when when creating a new object of the class affected.
     
        These settings apply only to new objects created of the affected class, not already existing ones.
        Using this you could for example add a group to have full control over all newly created group policy objects.
     
    .PARAMETER ClassName
        The name of the object class in schema this applies to.
     
    .PARAMETER Identity
        The principal to which the access rule applies.
        Supports limited string resolution.
     
    .PARAMETER ActiveDirectoryRights
        The rights granted.
     
    .PARAMETER AccessControlType
        Allow or Deny?
        Defaults to: Allow
     
    .PARAMETER InheritanceType
        How is this privilege inherited by child objects?
     
    .PARAMETER ObjectType
        What object types does this permission apply to?
     
    .PARAMETER InheritedObjectType
        What object types does this permission apply to?
        Used for extended properties.
     
    .PARAMETER Mode
        How access rules are actually applied:
        - Additive: Only add new access rules, but do not touch existing ones
        - Defined: Add new access rules, remove access rules not defined in configuration that apply to a principal that has access rules defined.
        - Constrained: Add new access rules, remove all access rules not defined in configuration
     
        All Modes of all settings for a given class are used when determining the effective Mode applied to that class.
        The most restrictive Mode applies.
     
    .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 .\sdp.json | ConvertFrom-Json | Write-Output | Register-FMSchemaDefaultPermission
     
        Loads all entries from the specified json file and registers them.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ClassName,
        
        [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)]
        [ValidateSet('Constrained', 'Defined', 'Additive')]
        [string]
        $Mode,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        $ContextName = '<Undefined>'
    )
    
    process
    {
        if (-not $script:schemaDefaultPermissions[$ClassName]) { $script:schemaDefaultPermissions[$ClassName] = @{ } }
        $script:schemaDefaultPermissions[$ClassName]["$($Identity)þ$($ActiveDirectoryRights)þ$($ObjectType)þ$($InheritedObjectType)þ$($InheritanceType)þ$($AccessControlType)"] = [PSCustomObject]@{
            PSTypeName              = 'ForestManagement.SchemaDefaultPermission.Configuration'
            ClassName              = $ClassName
            Identity              = $Identity
            ActiveDirectoryRights = $ActiveDirectoryRights
            AccessControlType      = $AccessControlType
            InheritanceType          = $InheritanceType
            ObjectType              = $ObjectType
            InheritedObjectType   = $InheritedObjectType
            Mode                  = $Mode
            ContextName              = $ContextName
        }
    }
}

function Test-FMSchemaDefaultPermission
{
<#
    .SYNOPSIS
        Validates, whether the target forest has the defined default permissions applied in its schema.
     
    .DESCRIPTION
        Validates, whether the target forest has the defined default permissions applied in its schema.
        Returns a list of all actions that would be taken by the associated Invoke-* command.
     
    .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-FMSchemaDefaultPermission -Server contoso.com
     
        Validates, whether the contoso.com forest has the defined default permissions applied in its schema.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")]
    [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 SchemaDefaultPermissions -Cmdlet $PSCmdlet
        Set-FMDomainContext @parameters
        try { $rootDSE = Get-ADRootDSE @parameters -ErrorAction Stop }
        catch
        {
            Stop-PSFFunction -String 'Test-FMSchemaDefaultPermission.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException()
            return
        }
        $forest = Get-ADForest @parameters
        $parameters["Server"] = $forest.SchemaMaster
        Invoke-PSFProtectedCommand -ActionString 'Test-FMSchemaDefaultPermission.WinRM.Connect' -Target $forest.SchemaMaster -ScriptBlock {
            $psParameters = $parameters.Clone()
            $psParameters.Remove('Server')
            $psParameters.ComputerName = $forest.SchemaMaster
            $session = New-PSSession @psParameters -ErrorAction Stop
        } -EnableException $EnableException -PSCmdlet $PSCmdlet -WhatIf:$false -Confirm:$false
        
        #region Default Permissions Scriptblock
        $defaultPermissionScriptblock = {
            param (
                $ClassName,
                
                $SchemaNC
            )
            $object = Get-ADObject -LDAPFilter "(name=$ClassName)" -SearchBase $SchemaNC -Server localhost -Properties defaultSecurityDescriptor
            if (-not $object) { throw "Object class '$ClassName' not found!" }
            $acl = New-Object System.DirectoryServices.ActiveDirectorySecurity
            $acl.SetSecurityDescriptorSddlForm($object.defaultSecurityDescriptor)
            $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
        }
        #endregion Default Permissions Scriptblock
        
        #region Utility Functions
        function Convert-ConfiguredAccessRule
        {
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                $AccessRule,
                
                [System.Collections.Hashtable]
                $Parameters
            )
            process
            {
                $basicHash = $AccessRule | ConvertTo-PSFHashtable
                $basicHash.IdentityResolved = $true
                $basicHash.Error = $null
                $basicHash.ObjectTypeGuid = Convert-DMSchemaGuid @Parameters -Name $basicHash.ObjectType -OutType GuidString
                $basicHash.ObjectTypeName = Convert-DMSchemaGuid @Parameters -Name $basicHash.ObjectType -OutType Name
                $basicHash.InheritedObjectTypeGuid = Convert-DMSchemaGuid @Parameters -Name $basicHash.InheritedObjectType -OutType GuidString
                $basicHash.InheritedObjectTypeName = Convert-DMSchemaGuid @Parameters -Name $basicHash.InheritedObjectType -OutType Name
                
                # Namensauflösung
                $basicHash.ResolvedIdentity = $AccessRule.Identity | Resolve-String -Mode Lax -ArgumentList $Parameters
                
                # Principal Auflösung
                try { $basicHash.Principal = [string]($basicHash.ResolvedIdentity | Resolve-Principal -OutputType SID @Parameters -ErrorAction Stop) }
                catch
                {
                    Write-PSFMessage -Level Warning -String 'Test-FMSchemaDefaultPermission.Principal.ResolutionError' -StringValues $AccessRule.Identity, $basicHash.ResolvedIdentity -Target $AccessRule
                    $basicHash.IdentityResolved = $false
                    $basicHash.Error = $_
                }
                
                [pscustomobject]$basicHash
            }
        }
        
        function Compare-AccessRule
        {
            [CmdletBinding()]
            param (
                $Configuration,
                
                $Applied,
                
                [PSFComputer]
                $Server,
                
                [Hashtable]
                $Parameters
            )
            
            #region Utility Functions
            function New-Change
            {
                [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
                [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingEmptyCatchBlock", "")]
                [CmdletBinding()]
                param (
                    [Parameter(Mandatory = $true)]
                    [ValidateSet('Add', 'Remove')]
                    [string]
                    $Type,
                    
                    $Configuration,
                    
                    $ADObject,
                    
                    [string]
                    $ClassName,
                    
                    [hashtable]
                    $Parameters
                )
                
                $object = [pscustomobject]@{
                    PSTypeName = 'ForestManagement.SchemaDefaultPermission.Change'
                    Type       = $Type
                    Identity   = $Configuration.ResolvedIdentity
                    Privilege  = $Configuration.ActiveDirectoryRights
                    Access       = $Configuration.AccessControlType -as [string]
                    Configuration = $Configuration
                    ADObject   = $ADObject
                    ClassName  = $ClassName
                }
                if ($ADObject)
                {
                    $object.Identity = $ADObject.IdentityReference
                    if (($ADObject.IdentityReference -as [System.Security.Principal.SecurityIdentifier]).AccountDomainSid)
                    {
                        try { $object.Identity = $ADObject.IdentityReference | Resolve-Principal @Parameters -ErrorAction Stop -OutputType NTAccount }
                        catch { } # No Action Needed
                    }
                    $object.Privilege = $ADObject.ActiveDirectoryRights
                    $object.Access = $ADObject.AccessControlType -as [string]
                }
                
                Add-Member -InputObject $object -MemberType ScriptMethod -Name ToString -Force -Value {
                    '{0}: {1}>{2}({3})' -f $this.Type, $this.Identity, $this.Privilege, $this.Access.SubString(0, 1)
                } -PassThru
            }
            #endregion Utility Functions
            
            #region Process ProcessingMode
            $processingMode = 'Additive'
            if ($Configuration.Mode -contains 'Defined') { $processingMode = 'Defined' }
            if ($Configuration.Mode -contains 'Constrained') { $processingMode = 'Constrained' }
            
            if ($processingMode -eq 'Constrained' -and ($Configuration | Where-Object IdentityResolved -EQ $false))
            {
                Write-PSFMessage -Level Warning -String 'Test-FMSchemaDefaultPermission.Class.IdentityUncertain' -StringValues $Configuration[0].ClassName -Target $Configuration[0].ClassName -Data @{
                    Configured = $Configuration
                    Applied    = $Applied
                }
                New-TestResult -ObjectType SchemaDefaultPermission -Type IdentityError -Identity $Configuration[0].ClassName -Server $Server -Configuration $Configuration -ADObject $Applied
                return
            }
            #endregion Process ProcessingMode
            
            $changes = @()
            $matchedRules = @()
            #region Check configured rules against applied rules
            :outer foreach ($ruleDefinition in $Configuration)
            {
                if (-not $ruleDefinition.IdentityResolved) { continue }
                foreach ($appliedRule in $Applied)
                {
                    if ($ruleDefinition.ActiveDirectoryRights -ne $appliedRule.ActiveDirectoryRights) { continue }
                    if ($ruleDefinition.InheritanceType -ne $appliedRule.InheritanceType) { continue }
                    if ($ruleDefinition.ObjectTypeGuid -ne $appliedRule.ObjectType) { continue }
                    if ($ruleDefinition.InheritedObjectTypeGuid -ne $appliedRule.InheritedObjectType) { continue }
                    if ($ruleDefinition.AccessControlType -ne $appliedRule.AccessControlType) { continue }
                    if ($ruleDefinition.Principal -ne $appliedRule.IdentityReference) { continue }
                    
                    # Existing rule is a match
                    $matchedRules += $appliedRule
                    continue outer
                }
                
                $changes += New-Change -Type Add -Configuration $ruleDefinition -ClassName $Configuration[0].ClassName -Parameters $Parameters
            }
            #endregion Check configured rules against applied rules
            
            #region Check applied rules against configured rules
            foreach ($appliedRule in $Applied)
            {
                if ($processingMode -eq 'Additive') { break }
                if ($appliedRule -in $matchedRules) { continue }
                if ($processingMode -eq 'Defined' -and $Configuration.Principal -notcontains $Applied.Identity) { continue }
                
                $changes += New-Change -Type Remove -ADObject $appliedRule -ClassName $Configuration[0].ClassName -Parameters $Parameters
            }
            #endregion Check applied rules against configured rules
            
            if ($changes)
            {
                New-TestResult -ObjectType SchemaDefaultPermission -Type Update -Identity $Configuration[0].ClassName -Server $Server -Configuration $Configuration -ADObject $Applied -Changed $changes
            }
        }
        #endregion Utility Functions
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }
        
        foreach ($className in $script:schemaDefaultPermissions.Keys)
        {
            $definedAccessRules = $script:schemaDefaultPermissions[$className].Values | Convert-ConfiguredAccessRule -Parameters $parameters
            
            try { $actualAccessRules = Invoke-Command -Session $session -ScriptBlock $defaultPermissionScriptblock -ArgumentList $className, $rootDSE.schemaNamingContext }
            catch
            {
                Write-PSFMessage -Level Warning -String 'Test-FMSchemaDefaultPermission.Class.NotFound' -StringValues $className -Target $className
                New-TestResult -ObjectType SchemaDefaultPermission -Type NotFound -Identity $className -Server $Server -Configuration $definedAccessRules
                continue
            }
            
            Compare-AccessRule -Configuration $definedAccessRules -Applied $actualAccessRules -Server $Server -Parameters $parameters
        }
    }
    end
    {
        if ($session) { Remove-PSSession -Session $session -ErrorAction Ignore -WhatIf:$false -Confirm:$false }
    }
}


function Unregister-FMSchemaDefaultPermission
{
<#
    .SYNOPSIS
        Removes schema default permissions from the list of registered configurationsets.
     
    .DESCRIPTION
        Removes schema default permissions from the list of registered configurationsets.
     
    .PARAMETER ClassName
        The name of the object class in schema this applies to.
     
    .PARAMETER Identity
        The principal to which the access rule applies.
     
    .PARAMETER ActiveDirectoryRights
        The rights granted.
     
    .PARAMETER AccessControlType
        Allow or Deny?
     
    .PARAMETER InheritanceType
        How is this privilege inherited by child objects?
     
    .PARAMETER ObjectType
        What object types does this permission apply to?
     
    .PARAMETER InheritedObjectType
        What object types does this permission apply to?
        Used for extended properties.
     
    .EXAMPLE
        PS C:\> Get-FMSchemaDefaultPermission | Unregister-FMSchemaDefaultPermission
     
        Clear all configured default schema permissions.
#>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ClassName,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Identity,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ActiveDirectoryRights,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [System.Security.AccessControl.AccessControlType]
        $AccessControlType,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [System.DirectoryServices.ActiveDirectorySecurityInheritance]
        $InheritanceType,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $ObjectType,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $InheritedObjectType
    )
    
    process
    {
        if (-not $script:schemaDefaultPermissions[$ClassName]) { return }
        $script:schemaDefaultPermissions[$ClassName].Remove("$($Identity)þ$($ActiveDirectoryRights)þ$($ObjectType)þ$($InheritedObjectType)þ$($InheritanceType)þ$($AccessControlType)")
        if ($script:schemaDefaultPermissions[$ClassName].Count -lt 1) { $script:schemaDefaultPermissions.Remove($ClassName) }
    }
}


function Get-FMSchemaLdif
{
    <#
    .SYNOPSIS
        Returns the registered schema ldif files.
     
    .DESCRIPTION
        Returns the registered schema ldif files.
     
    .PARAMETER Name
        The name to filter byy.
     
    .EXAMPLE
        PS C:\> Get-FMSchemaLdif
 
        List all registered ldif files.
    #>

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


function Invoke-FMSchemaLdif
{
    <#
        .SYNOPSIS
            Applies missing LDIF files to a forest's schema.
         
        .DESCRIPTION
            Applies missing LDIF files to a forest's schema.
         
        .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-FMSchemaLdif
 
            Tests the configured LDIF schema files and applies all still missing updates.
    #>

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

        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        #region Resolve Schema Master
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type SchemaLdif -Cmdlet $PSCmdlet
        try {
            $forest = Get-ADForest @parameters -ErrorAction Stop
        }
        catch {
            Stop-PSFFunction -String 'Invoke-FMSchemaLdif.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException()
            return
        }
        $parameters["Server"] = $forest.SchemaMaster
        $removeParameters = $parameters.Clone()
        #endregion Resolve Schema Master

        #region Resolve Credentials
        $cred = $null
        if (Test-SchemaAdminCredential) {
            Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaLdif.Schema.Credentials' -Target $forest.SchemaMaster -ScriptBlock {
                [PSCredential]$cred = Get-SchemaAdminCredential @parameters | Write-Output | Select-Object -First 1
                if ($cred) { $parameters['Credential'] = $cred }
            } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet
            if (Test-PSFFunctionInterrupt) { return }
        }
        #endregion Resolve Credentials

        # Prepare parameters to use for when discarding the schema credentials
        if ($cred -and ($cred -ne $Credential)) { $removeParameters['SchemaAccountCredential'] = $cred }
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }

        if (-not $InputObject) {
            $InputObject = Test-FMSchemaLdif @parameters -EnableException:$EnableException
        }

        foreach ($testItem in $InputObject) {
            Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaLdif.Invoke.File' -ActionStringValues $testItem.Identity -Target $forest.SchemaMaster -ScriptBlock {
                Invoke-LdifFile @parameters -Path $testItem.Configuration.Path -ErrorAction Stop
            } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
        }
    }
    end
    {
        if (Test-PSFFunctionInterrupt) { return }

        if (Test-SchemaAdminCredential) {
            Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaLdif.Schema.Credentials.Release' -Target $forest.SchemaMaster -ScriptBlock {
                Remove-SchemaAdminCredential @removeParameters -ErrorAction Stop
            } -EnableException $EnableException -PSCmdlet $PSCmdlet
        }
    }
}


function Register-FMSchemaLdif
{
    <#
        .SYNOPSIS
            Registers an ldif file for validation and application.
         
        .DESCRIPTION
            Registers an ldif file for validation and application.
         
        .PARAMETER Name
            The name to register the file under.
         
        .PARAMETER Path
            The path to the file to register.
 
        .PARAMETER Weight
            Ldif files will be applied in a certain order.
            The weight of an Ldif file determines, the order it is applied in.
            The lower the number, the earlier the file will be applied.
 
            Default: 50
 
        .PARAMETER MissingObjectExemption
            Testing in a forest will cause it to complain about all objects the ldif file tries to modify, not create and doesn't exist.
            Using this parameter you can exempt individual classes from triggering this warning.
 
        .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-FMSchemaLdif -Name Skype -Path "$PSScriptRoot\skype.ldif"
 
            Registers the Skype for Business schema extensions.
    #>

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

        [Parameter(Mandatory = $true)]
        [PsfValidateScript('ForestManagement.Validate.Path.SingleFile', ErrorString = 'ForestManagement.Validate.Path.SingleFile.Failed')]
        [string]
        $Path,

        [int]
        $Weight = 50,

        [string[]]
        $MissingObjectExemption,

        [string]
        $ContextName = '<Undefined>'
    )
    
    begin
    {
        $resolvedPath = Resolve-PSFPath -Path $Path -Provider FileSystem -SingleItem
    }
    process
    {
        $script:schemaLdif[$Name] = [PSCustomObject]@{
            PSTypeName = 'ForestManagement.SchemaLdif.Configuration'
            Name = $Name
            Path = $resolvedPath
            Settings = (Import-LdifFile -Path $Path)
            MissingObjectExemption = ($MissingObjectExemption | ForEach-Object { $_ -replace '(^CN=)|(^)','CN=' })
            Weight = $Weight
            ContextName = $ContextName
        }
    }
}

function Test-FMSchemaLdif
{
    <#
    .SYNOPSIS
        Tests whether the configured ldif-file-based schema extension has been applied.
     
    .DESCRIPTION
        Tests whether the configured ldif-file-based schema extension has been applied.
     
    .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-FMSchemaLdif
 
        Checks the current forest against all configured schema extension files
    #>

    
    [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 SchemaLdif -Cmdlet $PSCmdlet
        try {
            $rootDSE = Get-ADRootDSE @parameters -ErrorAction Stop
            $forest = Get-ADForest @parameters -ErrorAction Stop
        }
        catch {
            Stop-PSFFunction -String 'Test-FMSchemaLdif.Connect.Failed' -StringValues $Server -ErrorRecord $_ -EnableException $EnableException -Exception $_.Exception.GetBaseException()
            return
        }
        $parameters["Server"] = $forest.SchemaMaster
    }
    process
    {
        $ldifMapping = ConvertTo-SchemaLdifPhase -LdifData (Get-FMSchemaLdif)
        $ldifSorted = Get-FMSchemaLdif | Sort-Object Weight
        $changes = @{ }
        $missingEntities = @()

        foreach ($ldifFile in $ldifSorted) {
            $changes[$ldifFile.Name] = @()
        }

        foreach ($distinguishedName in $ldifMapping.Keys) {
            $hasDefinedState = $ldifMapping[$distinguishedName].Values.State.Count -gt 0
            $attributeName = '{0},{1}' -f $distinguishedName, $rootDSE.schemaNamingContext

            #region Retrieve AD Object ($adObject)
            try { $adObject = Get-ADObject @parameters -Identity $attributeName -ErrorAction Stop -Properties * }
            catch {
                if ($hasDefinedState) {
                    foreach ($file in $ldifMapping[$distinguishedName].Keys) {
                        $changes[$file] += [PSCustomObject]@{
                                DN = $distinguishedName
                                Property = '<FailsToExist>'
                                File = $file
                                Setting = $ldifMapping[$distinguishedName][$file]
                                ADObject = $null
                                ValueS = $null
                                ValueA = $null
                            }
                    }
                }
                else {
                    if ($distinguishedName -notin ($ldifSorted.MissingObjectExemption | Write-Output)) {
                        Write-PSFMessage -Level Warning -String 'Test-FMSchemaLdif.Missing.SchemaItem' -StringValues $attributeName -Tag 'panic'
                        $missingEntities += $attributeName
                    }
                }
                continue
            }
            #endregion Retrieve AD Object ($adObject)

            #region Compare configured with real state ($offStateLdifName)
            $offStateLdif = foreach ($ldifFile in $ldifSorted) {
                # Skip files that do not yet contain the taret object
                if (-not $ldifMapping[$distinguishedName][$ldifFile.Name]) { continue }

                $definedState = $ldifMapping[$distinguishedName][$ldifFile.Name]
                if ($definedState.State.Count -gt 0) {
                    foreach ($propertyName in $definedState.State.Keys) {
                        if (Compare-SchemaProperty -Setting $definedState.State -ADObject $adObject -PropertyName $propertyName -RootDSE $rootDSE) {
                            [PSCustomObject]@{
                                DN = $distinguishedName
                                Property = $propertyName
                                File = $ldifFile.Name
                                Setting = $definedState
                                ADObject = $adObject
                                ValueS = $definedState.State.$propertyName
                                ValueA = $adObject.$propertyName
                            }
                        }
                    }
                }
                else {
                    foreach ($propertyName in $definedState.Add.Keys) {
                        if (Compare-SchemaProperty -Setting $definedState.Add -ADObject $adObject -PropertyName $propertyName -RootDSE $rootDSE -Add) {
                            [PSCustomObject]@{
                                DN = $distinguishedName
                                Property = $propertyName
                                File = $ldifFile.Name
                                Setting = $definedState
                                ADObject = $adObject
                                ValueS = $definedState.Add.$propertyName
                                ValueA = $adObject.$propertyName
                            }
                        }
                    }
                    foreach ($propertyName in $definedState.Replace.Keys) {
                        if (Compare-SchemaProperty -Setting $definedState.Replace -ADObject $adObject -PropertyName $propertyName -RootDSE $rootDSE) {
                            [PSCustomObject]@{
                                DN = $distinguishedName
                                Property = $propertyName
                                File = $ldifFile.Name
                                Setting = $definedState
                                ADObject = $adObject
                                ValueS = $definedState.Replace.$propertyName
                                ValueA = $adObject.$propertyName
                            }
                        }
                    }
                }
            }
            #endregion Compare configured with real state ($offStateLdifName)

            $applicableLdif = $ldifSorted | Where-Object Name -in $ldifMapping[$distinguishedName].Keys
            $lastAppliedItem = $applicableLdif |
                Where-Object Name -notin $offStateLdif.File |
                    Sort-Object Weight -Descending |
                        Select-Object -First 1
            
            foreach ($ldifFile in $applicableLdif) {
                if ($ldifFile.Weight -lt $lastAppliedItem.Weight) { continue }
                if ($lastAppliedItem.Name -eq $ldifFile.Name) { continue }
                foreach ($entry in $offStateLdif) {
                    if ($entry.File -ne $ldifFile.Name) { continue }
                    $changes[$ldifFile.Name] += $entry
                }
            }
        }
        $ldifResult = foreach ($schemaName in $changes.Keys) {
            if (-not $changes[$schemaName]) { continue }

            [PSCustomObject]@{
                PSTypeName = 'ForestManagement.SchemaLdif.TestResult'
                Type = 'InEqual'
                ObjectType = 'SchemaLdif'
                Identity = $schemaName
                Changed = $changes[$schemaName]
                Server = $forest.SchemaMaster
                DeltaCount = $changes[$schemaName].Count
                ADObject = $null
                Configuration = ($ldifSorted | Where-Object Name -eq $schemaName)
            }
        }
        $ldifResult | Sort-Object { $_.Configuration.Weight }
    }
}

function Unregister-FMSchemaLdif
{
    <#
        .SYNOPSIS
            Removes a registered ldif file from the configured state.
         
        .DESCRIPTION
            Removes a registered ldif file from the configured state.
         
        .PARAMETER Name
            The name to select the ldif file by.
         
        .EXAMPLE
            PS C:\> Get-FMSchemaLdif | Unregister-FMSchemaLdif
 
            Unregisters all registered ldif files.
    #>

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


function Invoke-FMServer
{
    <#
    .SYNOPSIS
        Ensures domain controllers are assigned to sites suitable for their IP addresses.
     
    .DESCRIPTION
        Ensures domain controllers are assigned to sites suitable for their IP addresses.
     
    .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-FMServer
 
        Ensures all domain controllers in the current forest are in the correct site.
    #>

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

        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
    }
    process
    {
        if (-not $InputObject) {
            $InputObject = Test-FMServer @parameters
        }

        foreach ($testItem in $InputObject) {
            switch ($testItem.Type) {
                'AddressNotFound'
                {
                    if (-not $testItem.ADObject.DNSHostName) {
                        Write-PSFMessage -Level Warning -String 'Invoke-FMServer.Server.NotFound' -StringValues $testItem.Identity -Target $testItem.Identity
                    }
                    else {
                        Write-PSFMessage -Level Warning -String 'Invoke-FMServer.Server.FailedToResolve' -StringValues $testItem.Identity -Target $testItem.Identity
                    }
                }
                'NoMatchingSubnet'
                {
                    Write-PSFMessage -Level Warning -String 'Invoke-FMServer.Server.NoSubnet' -StringValues $testItem.Identity, $testItem.ADObject.IPAddress -Target $testItem.Identity
                }
                'BadSite'
                {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMServer.Server.Moving' -ActionStringValues $testItem.SupposedSite -Target $testItem.Identity -ScriptBlock {
                        Move-ADDirectoryServer @parameters -Identity $testItem.ADobject.DistinguishedName -Site $testItem.SupposedSite -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet
                }
            }
        }
    }
}


function Register-FMServer {
    <#
    .SYNOPSIS
        Configure the server-site assignment.
     
    .DESCRIPTION
        Configure the server-site assignment.
     
    .PARAMETER NoAutoAssignment
        Setting this to true will disable any automatically calculated site assignments.
        When enabled, only explicitly configured site assignments will be applied.
     
    .EXAMPLE
        PS C:\> Get-Content .\servers.json | ConvertFrom-Json | Write-Output | Register-FMServer
         
        Apply all configuration settings stored in servers.json
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Auto')]
        [bool]
        $NoAutoAssignment
    )

    process {
        switch ($PSCmdlet.ParameterSetName) {
            #region Auto Assignment
            'Auto'
            {
                $script:serverAutoAssignment = -not $NoAutoAssignment
            }
            #endregion Auto Assignment
        }
    }
}

function Test-FMServer
{
    <#
        .SYNOPSIS
            Checks whether the Domain Controller in a forest are in the correct site.
         
        .DESCRIPTION
            Checks whether the Domain Controller in a forest are in the correct site.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
         
        .EXAMPLE
            PS C:\> Test-FMServer
 
            Tests, whethether all domain controllers in the current forest are up-to-date.
    #>

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

        [PSCredential]
        $Credential
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        $rootDSE = Get-ADRootDSE @parameters
        $searchBase = "CN=Sites,$($rootDSE.configurationNamingContext)"
        $domainControllers = Get-ADObject @parameters -LDAPFilter '(objectClass=server)' -SearchBase $searchBase -Properties * | Select-Object *, IPAddress, @{
            Name = 'SiteName'
            Expression = { $_.DistinguishedName -replace ".+,CN=(.+?),CN=Sites,CN=Configuration,DC=.+",'$1' }
        }
        foreach ($domainController in $domainControllers) {
            if ($domainController.DNSHostName) {
                $domainController.IPAddress = [IPAddress](Resolve-DnsName -Name $domainController.DNSHostName -ErrorAction Ignore -Debug:$false | Where-Object Type -eq A | Select-Object -First 1).IPAddress
            }
        }
        $allSubnets = Get-ADReplicationSubnet @parameters -Filter * -Properties Description | Select-PSFObject 'Name',  @{
            Name = "SiteName"
            Expression = { ($_.Site | Get-ADObject @parameters).Name }
        }, 'Name.Split("/")[0] AS IPBase TO IPAddress', 'Name.Split("/")[1].Split("´n")[0] AS MaskSize To Int', Mask, site | Where-Object Name -notlike "*CNF*" | Sort-Object MaskSize -Descending
        foreach ($subnet in $allSubnets) {
            $subnet.Mask = ConvertTo-SubnetMask -MaskSize $subnet.MaskSize
        }
    }
    process
    {
        :main foreach ($domainController in $domainControllers) {
            $resultDefaults = @{
                ObjectType = 'Server'
                Identity = $domainController.Name
                Server = $Server
                ADObject = $domainController
            }

            if (-not $script:serverAutoAssignment) { continue }

            #region No IP Address
            if (-not $domainController.IPAddress) {
                New-TestResult @resultDefaults -Type AddressNotFound -Properties @{
                    CurrentSite = $domainController.SiteName
                }
                continue
            }
            #endregion No IP Address

            #region Resolving Subnet
            $foundSubnet = $null
            foreach ($subnet in $allSubnets) {
                if (Test-Subnet -NetworkAddress $subnet.IPBase -MaskAddress $subnet.Mask -HostAddress $domainController.IPAddress) {
                    $foundSubnet = $subnet
                    break
                }
            }

            if (-not $foundSubnet) {
                New-TestResult @resultDefaults -Type NoMatchingSubnet -Properties @{
                    CurrentSite = $domainController.SiteName
                }
                continue
            }
            #endregion Resolving Subnet

            if ($domainController.SiteName -ne $foundSubnet.SiteName) {
                $currentSiteSubnets = $allSubnets | Where-Object SiteName -eq $domainController.SiteName
                foreach ($subnet in $currentSiteSubnets) {
                    # Domain Controller is legally in his current site
                    if (Test-Subnet -NetworkAddress $subnet.IPBase -MaskAddress $subnet.Mask -HostAddress $domainController.IPAddress) {
                        Write-PSFMessage -Level InternalComment -String 'Test-FMServer.SiteConflict' -StringValues $domainController.Name, $foundSubnet.SiteName, $domainController.SiteName, $foundSubnet.Name -Tag 'note' -Target $domainController.Name
                        continue main
                    }
                }
                
                New-TestResult @resultDefaults -Type BadSite -Changed $foundSubnet.SiteName -Properties @{
                    CurrentSite = $domainController.SiteName
                    SupposedSite = $foundSubnet.SiteName
                    FoundSubnet = $foundSubnet
                }
            }
        }
    }
}


function Get-FMSiteLink
{
    <#
    .SYNOPSIS
        Returns the configured link between two sites.
     
    .DESCRIPTION
        Returns the configured link between two sites.
     
    .PARAMETER SiteName
        The site to filter by.
        Defaults to '*'
     
    .EXAMPLE
        PS C:\> Get-FMSiteLink
 
        Returns all configured sitelinks.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [CmdletBinding()]
    Param (
        [string]
        $SiteName = "*"
    )
    
    process
    {
        ($script:sitelinks.Values | Where-Object {
            ($_.Site1 -like $SiteName) -or ($_.Site2 -like $SiteName)
        })
    }
}


function Invoke-FMSiteLink {
    <#
        .SYNOPSIS
            Update a forest's sitelink to conform to the defined configuration.
         
        .DESCRIPTION
            Update a forest's sitelink to conform to the defined configuration.
            Configuration is defined using Register-FMSiteLink.
         
        .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-FMSiteLink
 
            Updates the current forest's sitelinks to conform to the defined 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
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type SiteLinks -Cmdlet $PSCmdlet
    }
    process {
        if (-not $InputObject) {
            $InputObject = Test-FMSiteLink @parameters
        }
        
        foreach ($testItem in $InputObject) {
            switch ($testItem.Type) {
                #region Delete undesired Sitelink
                'Delete' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSiteLink.Removing.SiteLink' -Target $testItem.Name -ScriptBlock {
                        Remove-ADReplicationSiteLink @parameters -Identity $testItem.Name -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet
                }
                #endregion Delete undesired Sitelink

                #region Create new Sitelink
                'Create' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSiteLink.Creating.SiteLink' -Target $testItem.Name -ScriptBlock {
                        $parametersCreate = $parameters.Clone()
                        $parametersCreate += @{
                            ErrorAction                   = 'Stop'
                            Name                          = $testItem.Name
                            Description                   = $testItem.Description
                            Cost                          = $testItem.Cost
                            ReplicationFrequencyInMinutes = $testItem.ReplicationInterval
                            SitesIncluded                 = $testItem.Site1, $testItem.Site2
                        }
                        if ($testItem.Options) { $parametersCreate['OtherAttributes'] = @{ Options = $testItem.Options } }
                        New-ADReplicationSiteLink @parametersCreate
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet
                }
                #endregion Create new Sitelink

                #region Update existing Sitelink
                'Update' {
                    if ($testItem.ADObject.Name -ne $testItem.IdealName) {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSiteLink.Renaming.SiteLink' -ActionStringValues $testItem.IdealName -Target $testItem.Name -ScriptBlock {
                            Rename-ADObject @parameters -Identity $testItem.ADObject.DistinguishedName -NewName $testItem.IdealName -ErrorAction Stop
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet
                    }

                    $parametersUpdate = $parameters.Clone()
                    $parametersUpdate += @{
                        ErrorAction = 'Stop'
                        Identity    = $testItem.ADObject.ObjectGUID
                    }
                    foreach ($change in $testItem.Changed) {
                        switch ($change.Property) {
                            'Cost' { $parametersUpdate['Cost'] = $change.NewValue }
                            'Description' { $parametersUpdate['Description'] = $change.NewValue }
                            'Options' { $parametersUpdate['Replace'] = @{ Options = $change.NewValue } }
                            'ReplicationInterval' { $parametersUpdate['ReplicationFrequencyInMinutes'] = $change.NewValue }
                        }
                    }

                    # If the only change pending was the name, don't call a meaningles Set-ADReplicationSiteLink
                    if ($parametersUpdate.Keys.Count -le (2 + $parameters.Keys.Count)) { continue }

                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSiteLink.Updating.SiteLink' -ActionStringValues ($testItem.Changed -join ", ") -Target $testItem.Name -ScriptBlock {
                        Set-ADReplicationSiteLink @parametersUpdate
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet
                }
                #endregion Update existing Sitelink
            }
        }
    }
}

function Register-FMSiteLink
{
    <#
    .SYNOPSIS
        Register a new sitelink configuration.
     
    .DESCRIPTION
        Register a new sitelink configuration.
     
    .PARAMETER Site1
        The first sitename in the pair of sites to be linked.
     
    .PARAMETER Site2
        The second sitename in the pair of sites to be linked.
     
    .PARAMETER Cost
        The cost of the connection between the two sites.
     
    .PARAMETER Interval
        The replication interval (in minutes) between two sites.
        Defaults to 15 minutes.
        Cannot be less than 15 minutes.
     
    .PARAMETER Description
        A description to add to the sitelink.
        For example, consider including a timestamp and the available bandwidth.
     
    .PARAMETER Option
        Any options for the sitelink.
        This is a bitmap with currently only one relevant setting:
        00000001 : Change Notify (Changes replicate instantly, rather than the configured interval. Only use for high-bandwidth connections)
     
    .EXAMPLE
        PS C:\> Register-FMSiteLink -Site1 MySite -Site2 MyOtherSite -Cost 80 -Description '2019 | 1GB/s' -Option 1
 
        Registers a new sitelink between MySite and MyOtherSite at a cost of 80, registering it as instant replication and adding docs on its bandwidth.
    #>

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

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

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateRange(1,[int]::MaxValue)]
        [int]
        $Cost,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateRange(15,[int]::MaxValue)]
        [int]
        $Interval = 15,

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

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [int]
        $Option
    )
    
    process
    {
        $sitelinkName = "{0}-{1}" -f $Site1, $Site2
        $script:sitelinks[$sitelinkName] = [PSCustomObject]@{
            PSTypeName = 'ForestManagement.SiteLink.Configuration'
            Name = $sitelinkName
            Site1 = $Site1
            Site2 = $Site2
            Cost = $Cost
            Interval = $Interval
            Description = $Description
            Option = $Option
        }
    }
}


function Test-FMSiteLink {
    <#
        .SYNOPSIS
            Compares a live sitelink setup with the configured desired state.
         
        .DESCRIPTION
            Compares a live sitelink setup with the configured desired state.
     
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
     
        .EXAMPLE
            PS C:\> Test-FMSiteLink
 
            Tests the current forest for compliance with the sitelink configuration
    #>

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

        [PSCredential]
        $Credential
    )
    
    begin {
        #region Functions
        function New-Update {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                $Identity,

                $Property,

                $OldValue,

                $NewValue
            )

            $datum = [PSCustomObject]@{
                PSTypeName = 'ForestManagement.SiteLink.Update'
                Identity = $Identity
                Property = $Property
                OldValue = $OldValue
                NewValue = $NewValue
            }
            Add-Member -InputObject $datum -MemberType ScriptMethod -Name ToString -Value {
                '{0}: {1} -> {2}' -f $this.Property, $this.OldValue, $this.NewValue
            } -Force
            $datum
        }
        #endregion Functions

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type SiteLinks -Cmdlet $PSCmdlet
        $allSiteLinks = Get-ADReplicationSiteLink @parameters -Filter * -Properties Cost, Description, Options, Name, replInterval, siteList | Select-Object *
        $linksToExclude = @()

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

        foreach ($siteLink in $allSiteLinks) {
            $count = 1
            foreach ($site in $siteLink.siteList) {
                try { Add-Member -InputObject $siteLink -MemberType NoteProperty -Name "Site$($count)" -Value (Get-ADObject @parameters -Identity $site -Properties Name).Name }
                catch { Add-Member -InputObject $siteLink -MemberType NoteProperty -Name "Site$($count)" -Value $site }
                $count++
            }
            #region More than 2 sites in Sitelink
            if ($siteLink.siteList.Count -ge 3) {
                if (Get-PSFConfigValue -FullName 'ForestManagement.SiteLink.MultilateralLinks') {
                    Write-PSFMessage -Level Verbose -String 'Test-FMSiteLink.Information.MultipleSites' -StringValues $siteLink.DistinguishedName, $siteLink.siteList.Count -Tag sitelink, multiple_sites -Target $siteLink.DistinguishedName
                    New-TestResult @resultDefaults -Type MultipleSites -Identity $siteLink.Name -ADObject $siteLink -Properties @{
                        Name              = $siteLink.Name
                        DistinguishedName = $siteLink.DistinguishedName
                    }
                }
                else {
                    Write-PSFMessage -Level Warning -String 'Test-FMSiteLink.Critical.TooManySites' -StringValues $siteLink.DistinguishedName, $siteLink.siteList.Count -Tag sitelink, critical, panic -Target $siteLink.DistinguishedName
                    New-TestResult @resultDefaults -Type TooManySites -Identity $siteLink.Name -ADObject $siteLink -Properties @{
                        Name              = $siteLink.Name
                        DistinguishedName = $siteLink.DistinguishedName
                    }
                }
                $linksToExclude += $siteLink
            }
            #endregion More than 2 sites in Sitelink
            Add-Member -InputObject $siteLink -MemberType NoteProperty -Name IdealName -Value ('{0}-{1}' -f $siteLink.Site1, $siteLink.Site2)
        }
        $allSiteLinks = $allSiteLinks | Where-Object { $_ -notin $linksToExclude }
    }
    process {
        #region Test all sitelinks found in the forest
        foreach ($siteLink in $allSiteLinks) {
            if (-not (Get-FMSiteLink | Compare-SiteLink $siteLink)) {
                New-TestResult @resultDefaults -Type Delete -Identity $siteLink.Name -ADObject $siteLink -Properties @{
                    Name                = $siteLink.Name
                    Site1               = $siteLink.Site1
                    Site2               = $siteLink.Site2
                    IdealName           = $siteLink.IdealName
                    Cost                = $siteLink.Cost
                    Description         = $siteLink.Description
                    Options             = $siteLink.Options
                    ReplicationInterval = $siteLink.replInterval
                }
                continue
            }

            $configuredSitelink = Get-FMSiteLink | Compare-SiteLink $siteLink | Select-Object -First 1
            $deltaProperties = @()

            #region Compare Properties
            if ($configuredSiteLink.Name -ne $siteLink.Name) {
                $deltaProperties += New-Update -Identity $siteLink.Name -OldValue $siteLink.Name -NewValue $configuredSitelink.Name -Property 'Name'
            }
            if ($configuredSiteLink.Cost -ne $siteLink.Cost) {
                $deltaProperties += New-Update -Identity $siteLink.Name -OldValue $siteLink.Cost -NewValue $configuredSitelink.Cost -Property 'Cost'
            }
            if ($configuredSiteLink.Description -ne ([string]($siteLink.Description))) {
                $deltaProperties += New-Update -Identity $siteLink.Name -OldValue ([string]($siteLink.Description)) -NewValue $configuredSitelink.Description -Property 'Description'
            }
            if ($configuredSiteLink.Option -ne ([int]($siteLink.Options))) {
                $deltaProperties += New-Update -Identity $siteLink.Name -OldValue ([int]($siteLink.Options)) -NewValue $configuredSitelink.Option -Property 'Options'
            }
            if ($configuredSiteLink.Interval -ne $siteLink.replInterval) {
                $deltaProperties += New-Update -Identity $siteLink.Name -OldValue $siteLink.replInterval -NewValue $configuredSitelink.Interval -Property 'ReplicationInterval'
            }
            #endregion Compare Properties

            if ($deltaProperties) {
                New-TestResult @resultDefaults -Type Update -Identity $siteLink.Name -ADObject $siteLink -Configuration $configuredSitelink -Properties @{
                    Name                = $configuredSitelink.Name
                    Site1               = $configuredSitelink.Site1
                    Site2               = $configuredSitelink.Site2
                    IdealName           = $configuredSitelink.Name
                    Cost                = $configuredSitelink.Cost
                    Description         = $configuredSitelink.Description
                    Options             = $configuredSitelink.Option
                    ReplicationInterval = $configuredSitelink.Interval
                } -Changed $deltaProperties
            }
        }
        #endregion Test all sitelinks found in the forest

        foreach ($configuredSitelink in (Get-FMSiteLink)) {
            if ($allSiteLinks | Compare-SiteLink $configuredSitelink) { continue }

            New-TestResult @resultDefaults -Type Create -Identity $configuredSitelink.Name -Configuration $configuredSitelink -Properties @{
                Name                = $configuredSitelink.Name
                Site1               = $configuredSitelink.Site1
                Site2               = $configuredSitelink.Site2
                IdealName           = $configuredSitelink.Name
                Cost                = $configuredSitelink.Cost
                Description         = $configuredSitelink.Description
                Options             = $configuredSitelink.Option
                ReplicationInterval = $configuredSitelink.Interval
            }
        }
    }
}

function Unregister-FMSiteLink
{
    <#
        .SYNOPSIS
            Removes a link between two sites from configuration.
         
        .DESCRIPTION
            Removes a link between two sites from configuration.
         
        .PARAMETER Site1
            The site1 of the link.
         
        .PARAMETER Site2
            The site2 of the link.
         
        .EXAMPLE
            PS C:\> Unregister-FMSiteLink -Site1 MySite -Site2 MyOtherSite
 
            Removes a sitelink from configuration.
    #>

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

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Site2
    )
    
    process
    {
        $sitelinkName = "{0}-{1}" -f $Site1, $Site2
        $sitelinkName2 = "{1}-{0}" -f $Site1, $Site2
        $script:sitelinks.Remove($sitelinkName)
        $script:sitelinks.Remove($sitelinkName2)
    }
}


function Get-FMSite
{
<#
.SYNOPSIS
    Returns the list of configured sites.
 
.DESCRIPTION
    Returns the list of configured sites.
    Sites can be configured using Register-FMSite.
    Those configurations represent the "Should be" state as defined for the entire organization.
 
.PARAMETER Name
    Name to filter by.
    Defaults to "*"
 
.EXAMPLE
    PS C:\> Get-FMSite
 
    Returns all configured sites.
#>

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


function Invoke-FMSite
{
    <#
        .SYNOPSIS
            Adjusts the targeted forest to comply with the site configuration.
         
        .DESCRIPTION
            Adjusts the targeted forest to comply with the site configuration.
            Use Register-FMSiteConfiguration to register configuration settings.
         
        .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-FMSite
 
            Scans the forest for discrepancies from the desired state
            Then attempts to rectify the state.
    #>

    
    [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 Sites -Cmdlet $PSCmdlet
    }
    process
    {
        if (-not $InputObject) {
            $InputObject = Test-FMSite @parameters
        }

        foreach ($testItem in $InputObject) {
            switch ($testItem.Type) {
                'Delete' {
                    $siteObject = Get-ADReplicationSite @parameters -Identity $testItem.Name
                    $servers = Get-ADObject @parameters -LDAPFilter '(objectClass=server)' -SearchBase $siteObject.DistinguishedName
                    if ($servers) {
                        Write-PSFMessage -Level Warning -String 'Invoke-FMSite.Removing.Site.ChildServers' -StringValues ($servers.Name -join ", ") -Tag 'failed','sites'
                    }
                    else {
                        Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSite.Removing.Site' -Target $testItem.Name -ScriptBlock {
                            Remove-ADReplicationSite @parameters -Identity $testItem.Name -ErrorAction Stop -Confirm:$false
                        } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet
                    }
                }
                'Create' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSite.Creating.Site' -Target $testItem.Name -ScriptBlock {
                        New-ADReplicationSite @parameters -Name $testItem.Name -Description $testItem.Description -OtherAttributes @{ Location = $testItem.Location } -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet
                }
                'Update' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSite.Updating.Site' -ActionStringValues ($testItem.Changed -join ", ") -Target $testItem.Name -ScriptBlock {
                        Set-ADReplicationSite @parameters -Identity $testItem.Name -Description $testItem.Description -Replace @{ Location = $testItem.Location } -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet
                }
                'Rename' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSite.Renaming.Site' -ActionStringValues $testItem.NewName -Target $testItem.Name -ScriptBlock {
                        Get-ADReplicationSite @parameters -Identity $testItem.Name | Rename-ADObject @parameters -NewName $testItem.NewName
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet
                }
            }
        }
    }
}


function Register-FMSite
{
    <#
        .SYNOPSIS
            Register a new site configuration.
         
        .DESCRIPTION
            Register a new site configuration.
            This is the ideal / desired state for the site setup.
            Forests will be brought into this state by using Invoke-FMSite.
         
        .PARAMETER Name
            Name of the site to apply.
         
        .PARAMETER Description
            Description the site should have.
         
        .PARAMETER Location
            Location the site should be part of.
         
        .PARAMETER OldNames
            Previous names for this site.
            Forests that have a site still using one of these names will have those sites renamed.
         
        .EXAMPLE
            PS C:\> Register-FMSite -Name ABCDE -Description "Some Site" -Location 'Atlantis'
 
            Registers a new desired site.
    #>

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

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

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Location,
        
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $OldNames
    )
    
    process
    {
        $hashtable = @{
            PSTypeName = 'ForestManagement.Site.Configuration'
            Name = $Name
            Description = $Description
            Location = $Location
        }
        if ($OldNames) { $hashtable["OldNames"] = $OldNames }
        $script:sites[$Name] = [PSCustomObject]$hashtable
    }
}


function Test-FMSite {
    <#
        .SYNOPSIS
            Tests a target foret's site configuration with the desired state.
         
        .DESCRIPTION
            Tests a target foret's site configuration with the desired state.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
         
        .EXAMPLE
            PS C:\> Test-FMSite
 
            Checks whether the current forest is compliant with the desired site configuration.
    #>

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

        [PSCredential]
        $Credential
    )
    
    begin {
        #region Functions
        function New-Update {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                $Identity,

                $Property,

                $OldValue,

                $NewValue
            )

            $datum = [PSCustomObject]@{
                PSTypeName = 'ForestManagement.Site.Update'
                Identity   = $Identity
                Property   = $Property
                OldValue   = $OldValue
                NewValue   = $NewValue
            }
            Add-Member -InputObject $datum -MemberType ScriptMethod -Name ToString -Value {
                '{0}: {1} -> {2}' -f $this.Property, $this.OldValue, $this.NewValue
            } -Force
            $datum
        }
        #endregion Functions

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Sites -Cmdlet $PSCmdlet
        $allSites = Get-ADReplicationSite @parameters -Filter * -Properties Location
        $renameMapping = @{}
        $script:sites.Values | Where-Object OldNames | ForEach-Object {
            foreach ($oldName in $_.OldNames) {
                $renameMapping[$oldName] = $_.Name
            }
        }
    }
    process {
        $foundSites = @{}

        $resultDefaults = @{
            Server     = $Server
            ObjectType = 'Site'
        }
        
        foreach ($site in $allSites) {
            if ($renameMapping.Keys -contains $site.Name) {
                New-TestResult @resultDefaults -Type Rename -Identity $site.Name -Properties @{
                    Name        = $site.Name
                    Description = $site.Description
                    Location    = $site.Location
                    NewName     = $renameMapping[$site.Name]
                } -ADObject $site -Changed (New-Update -Identity $site.Name -Property Name -OldValue $site.Name -NewValue $renameMapping[$site.Name])
                continue
            }
            elseif ($script:sites.Keys -contains $site.Name) {
                $foundSites[$site.Name] = $site
            }
            else {
                New-TestResult @resultDefaults -Type Delete -Identity $site.Name -Properties @{
                    Name        = $site.Name
                    Description = $site.Description
                    Location    = $site.Location
                } -ADObject $site
            }
        }
        foreach ($site in $script:sites.Values) {
            if ($site.Name -in $allSites.Name) { continue }

            New-TestResult @resultDefaults -Type Create -Identity $site.Name -Properties @{
                Name        = $site.Name
                Description = $site.Description
                Location    = $site.Location
            } -Configuration $site
        }

        foreach ($site in $foundSites.Values) {
            $deltaProperties = @()
            if ([string]($site.Location) -ne $script:sites[$site.Name].Location) {
                $deltaProperties += New-Update -Identity $site.Name -OldValue $site.Location -NewValue $script:sites[$site.Name].Location -Property 'Location'
            }
            if ([string]($site.Description) -ne $script:sites[$site.Name].Description) {
                $deltaProperties += New-Update -Identity $site.Name -OldValue $site.Description -NewValue $script:sites[$site.Name].Description -Property 'Description'
            }

            if (-not $deltaProperties) { continue }

            New-TestResult @resultDefaults -Type Update -Identity $site.Name -Properties @{
                Name        = $site.Name
                Description = $script:sites[$site.Name].Description
                Location    = $script:sites[$site.Name].Location
            } -ADObject $site -Configuration $script:sites[$site.Name] -Changed $deltaProperties
        }
    }
}

function Unregister-FMSite
{
    <#
        .SYNOPSIS
            Removes a site from the list of registered sites.
         
        .DESCRIPTION
            Removes a site from the list of registered sites.
         
        .PARAMETER Name
            Name of the site to unregister
         
        .EXAMPLE
            PS C:\> Unregister-FMSite -Name "MySite"
 
            Removes the site "MySite" from the list of registered sites
    #>

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


function Get-FMSubnet
{
<#
    .SYNOPSIS
        Returns the list of configured subnets.
 
    .DESCRIPTION
        Returns the list of configured subnets.
        Subnets can be configured using Register-FMSubnet.
        Those configurations represent the "Should be" state as defined for the entire organization.
 
    .PARAMETER Name
        Name of the subnet to filter by.
        Defaults to "*"
 
    .EXAMPLE
        PS C:\> Get-FMSubnet
 
        Returns all configured subnets.
#>

    [CmdletBinding()]
    Param (
        [string]
        $Name = "*"
    )

    process
    {
        ($script:subnets.Values | Where-Object Name -like $Name)
    }
}

function Invoke-FMSubnet
{
    <#
        .SYNOPSIS
            Corrects the subnet configuration of a forest.
         
        .DESCRIPTION
            Corrects the subnet configuration of a forest.
         
        .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-FMSubnet
 
            Corrects the subnet configuration of the current forest.
    #>

    [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 Subnets -Cmdlet $PSCmdlet
    }
    process
    {
        if (-not $InputObject) {
            $InputObject = Test-FMSubnet @parameters
        }

        $testResult = $InputObject | Sort-Object {
            switch ($_.Type) {
                'ForestOnly' { 1 }
                'InEqual' { 2 }
                default { 3 }
            }
        }

        foreach ($testItem in $testResult) {
            switch ($testItem.Type) {
                'Delete' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSubnet.Deleting.Subnet' -Target $testItem.Name -ScriptBlock {
                        Remove-ADReplicationSubnet @parameters -Identity $testItem.Name -ErrorAction Stop -Confirm:$false
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet
                }
                'Create' {
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSubnet.Creating.Subnet' -Target $testItem.Name -ScriptBlock {
                        New-ADReplicationSubnet @parameters -Name $testItem.Name -Site $testItem.SiteName -Description $testItem.Description -Location $testItem.Location -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet
                }
                'Update' {
                    $parametersSetSplat = $parameters.Clone()
                    $parametersSetSplat['Identity'] = $testItem.Identity

                    foreach ($change in $testItem.Changed) {
                        $parametersSetSplat[$change.Property] = $change.NewValue
                    }
                    
                    Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSubnet.Updating.Subnet' -ActionStringValues ($testItem.Changed -join ", ") -Target $testItem.Name -ScriptBlock {
                        Set-ADReplicationSubnet @parametersSetSplat -ErrorAction Stop
                    } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet
                }
            }
        }
    }
}


function Register-FMSubnet
{
    <#
        .SYNOPSIS
            Registers a new subnet assignment.
         
        .DESCRIPTION
            Registers a new subnet assignment.
            Subnets are assigned to sites.
         
        .PARAMETER SiteName
            Name of the site to which subnets are being assigned.
         
        .PARAMETER Name
            Subnet to assign.
            Must be a subnet in the following notation:
            <ipv4address>/<subnetsize>
            E.g.: 1.2.3.4/24
 
        .PARAMETER Description
            Description to add to the subnet
 
        .PARAMETER Location
            Location, where the subnet is at.
         
        .EXAMPLE
            PS C:\> Register-FMSubnet -SiteName MySite -Name '1.2.3.4/32'
 
            Assigns the subnet '1.2.3.4/32' to the site 'MySite'
    #>

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

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [PsfValidateScript('ForestManagement.Validate.Subnet', ErrorString = 'ForestManagement.Validate.Subnet.Failed')]
        [string]
        $Name,

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

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [AllowEmptyString()]
        [string]
        $Location
    )
    
    process
    {
        $hashtable = @{
            PSTypeName = 'ForestManagement.Subnet.Configuration'
            SiteName = $SiteName
            Name = $Name
            Description = $Description
            Location = $Location
        }

        $script:subnets[$Name] = [PSCustomObject]$hashtable
    }
}


function Test-FMSubnet {
    <#
        .SYNOPSIS
            Compares a forest's Subnet configuration against its desired state.
         
        .DESCRIPTION
            Compares a forest's Subnet configuration against its desired state.
         
        .PARAMETER Server
            The server / domain to work with.
         
        .PARAMETER Credential
            The credentials to use for this operation.
     
        .EXAMPLE
            PS C:\> Test-FMSubnet
 
            Compares the current forest's Subnet configuration against its desired state.
    #>

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

        [PSCredential]
        $Credential
    )
    
    begin {
        #region Functions
        function New-Update {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                $Identity,

                $Property,

                $OldValue,

                $NewValue
            )

            $datum = [PSCustomObject]@{
                PSTypeName = 'ForestManagement.Subnet.Update'
                Identity   = $Identity
                Property   = $Property
                OldValue   = $OldValue
                NewValue   = $NewValue
            }
            Add-Member -InputObject $datum -MemberType ScriptMethod -Name ToString -Value {
                '{0}: {1} -> {2}' -f $this.Property, $this.OldValue, $this.NewValue
            } -Force
            $datum
        }
        #endregion Functions

        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        $parameters['Debug'] = $false
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type Subnets -Cmdlet $PSCmdlet
        $allSubnets = Get-ADReplicationSubnet @parameters -Filter * -Properties Description | Select-Object *, @{
            Name       = "SiteName"
            Expression = { ($_.Site | Get-ADObject @parameters).Name }
        }
    }
    process {
        $resultDefaults = @{
            ObjectType = 'Subnet'
            Server     = $Server
        }

        #region Test all Subnets found in the forest
        foreach ($subnetItem in $allSubnets) {
            if ($script:subnets.Keys -notcontains $subnetItem.Name) {
                New-TestResult @resultDefaults -Type Delete -Identity $subnetItem.Name -ADObject $subnetItem -Properties @{
                    SiteName    = $subnetItem.SiteName
                    Name        = $subnetItem.Name
                    Description = $subnetItem.Description
                    Location    = $subnetItem.Location
                }
                continue
            }

            $configuredSubnet = $script:subnets[$subnetItem.Name]
            
            $deltaProperties = @()
            if ($subnetItem.SiteName -ne $configuredSubnet.SiteName) {
                $deltaProperties += New-Update -Identity $subnetItem.Name -OldValue $subnetItem.SiteName -NewValue $configuredSubnet.SiteName -Property 'Site'
            }
            if ([string]($subnetItem.Description) -ne $configuredSubnet.Description) {
                $deltaProperties += New-Update -Identity $subnetItem.Name -OldValue ([string]$subnetItem.Description) -NewValue $configuredSubnet.Description -Property 'Description'
            }
            if ([string]($subnetItem.Location) -ne $configuredSubnet.Location) {
                $deltaProperties += New-Update -Identity $subnetItem.Name -OldValue ([string]$subnetItem.Location) -NewValue $configuredSubnet.Location -Property 'Location'
            }

            if (-not $deltaProperties) { continue }
            New-TestResult @resultDefaults -Type Update -Identity $subnetItem.Name -ADObject $subnetItem -Configuration $configuredSubnet -Properties @{
                SiteName    = $configuredSubnet.SiteName
                Name        = $configuredSubnet.Name
                Description = $configuredSubnet.Description
                Location    = $configuredSubnet.Location
            } -Changed $deltaProperties
        }
        #endregion Test all Subnets found in the forest

        #region Catch subnets only in configuration but NOT in forest
        foreach ($configuredSubnet in $script:subnets.Values) {
            if ($allSubnets.Name -contains $configuredSubnet.Name) { continue }

            New-TestResult @resultDefaults -Type Create -Identity $configuredSubnet.Name -Configuration $configuredSubnet -Properties @{
                SiteName    = $configuredSubnet.SiteName
                Name        = $configuredSubnet.Name
                Description = $configuredSubnet.Description
                Location    = $configuredSubnet.Location
            }
        }
        #endregion Catch subnets only in configuration but NOT in forest
    }
}


function Unregister-FMSubnet
{
    <#
        .SYNOPSIS
            Removes a subnet mapping.
         
        .DESCRIPTION
            Removes a subnet mapping.
         
        .PARAMETER Name
            Name of the subnets to unregister
         
        .EXAMPLE
            PS C:\> Unregister-FMSubnet -Name "1.2.3.4/32"
 
            Removes the subnet "1.2.3.4/32"
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")]
    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Mandatory = $true)]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameItem in $Name) {
            $script:subnets.Remove($nameItem)
        }
    }
}


function Clear-FMConfiguration
{
    <#
        .SYNOPSIS
            Clears the stored configuration data.
         
        .DESCRIPTION
            Clears the stored configuration data.
         
        .EXAMPLE
            PS C:\> Clear-FMConfiguration
 
            Clears the stored configuration data.
    #>

    [CmdletBinding()]
    Param (
    
    )
    
    process
    {
        # Site Configurations
        $script:sites = @{ }

        # Subnet Configurations
        $script:subnets = @{ }

        # Sitelink Configurations
        $script:sitelinks = @{ }

        # Schema Definition
        $script:schema = @{ }

        # Schema Definitions for external LDIF files
        $script:schemaLdif = @{ }
    }
}


function Get-FMCallback
{
    <#
    .SYNOPSIS
        Returns the list of registered callbacks.
     
    .DESCRIPTION
        Returns the list of registered callbacks.
 
        For more details on this system, call:
        Get-Help about_FM_callbacks
     
    .PARAMETER Name
        The name of the callback.
        Supports wildcard filtering.
     
    .EXAMPLE
        PS C:\> Get-FMCallback
 
        Returns a list of all registered callbacks
    #>

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


function Register-FMCallback
{
    <#
    .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_FM_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-FMCallback -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 Unregister-FMCallback
{
    <#
    .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_FM_callbacks
     
    .PARAMETER Name
        The name of the callback to remove.
     
    .EXAMPLE
        PS C:\> Get-FMCallback | Unregister-FMCallback
 
        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)
        }
    }
}


<#
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 'ForestManagement' -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 'ForestManagement' -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 'ForestManagement' -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."

# Sitelinks
Set-PSFConfig -Module 'ForestManagement' -Name 'SiteLink.MultilateralLinks' -Value $false -Initialize -Validation 'bool' -Description 'Whether sitelinks should be allowed to contain more than two sites. Enabling this will suppress all error messages when finding those.'

# Schema
Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.AutoCreate.TempAdmin' -Value $false -Initialize -Validation 'bool' -Description 'Schema Updates require special privileges not usually granted. Enabling this setting will have the task automatically create a temporary schema admin account with the permissions to execute the planned updates.'
Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.IgnoreOnCredentialProvider' -Value $false -Initialize -Validation 'bool' -Description 'Whether the Schema Admin Credential workflow should be ignored when called from ADMF with the Credential Provider specified.'
Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.Credential' -Value $null -Initialize -Validation credential -Description 'Credentials to use for performing schema updates'
Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.Name' -Value '' -Initialize -Validation string -Description 'The name of the account to use'
Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoDescription' -Value '' -Initialize -Validation string -Description 'The description for the account used. If specified, this is what the description will be updated to after successfully using the account.'
Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoCreate' -Value $false -Initialize -Validation bool -Description 'Whether the account should be created automatically if it isn''t present'
Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoEnable' -Value $false -Initialize -Validation bool -Description 'Whether the account to use for performing the schema update should be enabled for use if disabled.'
Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoDisable' -Value $false -Initialize -Validation bool -Description 'Whether the account to use for performing the schema update should be disabled after use.'
Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoGrant' -Value $false -Initialize -Validation bool -Description 'Whether the account to use for performing the schema update should be added to the schema admins group before use.'
Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Account.AutoRevoke' -Value $false -Initialize -Validation bool -Description 'Whether the account to use for performing the schema update should be removed from the schema admins group after use.'
Set-PSFConfig -Module 'ForestManagement' -Name 'Schema.Password.AutoReset' -Value $false -Initialize -Validation bool -Description 'Whether the password of the used account should be reset before & after use.'


<#
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 'ForestManagement.ScriptBlockName' -Scriptblock {
     
}
#>


Set-PSFScriptblock -Name 'ForestManagement.Validate.Path.SingleFile' -Scriptblock {
    try {
        Resolve-PSFPath -Path $_ -Provider FileSystem -SingleItem
        return $true
    }
    catch { return $false }
}

Set-PSFScriptblock -Name 'ForestManagement.Validate.Subnet' -Scriptblock {
    if (-not $_.Contains("/")) { return $false }
    if (($_ -split "/").Count -gt 2) { return $false }
    
    $base, $range = $_ -split "/"

    $ipv4Pattern = '^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$'
    if ($base -notmatch $ipv4Pattern) { return $false }
    
    $rangeNumber = $range -as [int]
    if (-not $rangeNumber) { return $false }
    if ($rangeNumber -lt 1) { return $false }
    if ($rangeNumber -gt 32) { return $false }
    $true
}

<#
# Example:
Register-PSFTeppScriptblock -Name "ForestManagement.alcohol" -ScriptBlock { 'Beer','Mead','Whiskey','Wine','Vodka','Rum (3y)', 'Rum (5y)', 'Rum (7y)' }
#>


Register-PSFTeppScriptblock -Name 'ForestManagement.ExchangeVersion' -ScriptBlock {
    (Get-AdcExchangeVersion).Binding
} -Global

Register-PSFTeppScriptblock -Name 'ForestManagement.ForestName' -ScriptBlock {
    (Get-ADTrust -Filter *).Target
}

Register-PSFTeppScriptblock -Name "ForestManagement.Sites" -ScriptBlock {
    $module = Get-Module ForestManagement
    & $module { $script:sites.Keys }
}

Register-PSFTeppScriptblock -Name "ForestManagement.Site2New" -ScriptBlock {
    $module = Get-Module ForestManagement
    $sites = & $module { $script:sites.Keys }
    $sitelinks = & $module { $script:sitelinks.Values }

    if (-not $fakeBoundParameter.Site1) {
        return $sites | Sort-Object -Unique
    }

    $results = foreach ($site in $sites) {
        if ($site -eq $fakeBoundParameter.Site1) { continue }
        if ($siteLinks | Where-Object { ($_.Site1 -eq $fakeBoundParameter.Site1) -and ($_.Site2 -eq $site) }) { continue }
        if ($siteLinks | Where-Object { ($_.Site2 -eq $fakeBoundParameter.Site1) -and ($_.Site1 -eq $site) }) { continue }
        $site
    }
    $results | Sort-Object -Unique
}

Register-PSFTeppScriptblock -Name "ForestManagement.Linked.Site1" -ScriptBlock {
    $module = Get-Module ForestManagement
    $siteLinks = & $module { $script:sitelinks.Values }

    if (-not $fakeBoundParameter.Site2) {
        return $siteLinks.Site1 | Sort-Object -Unique
    }
    ($siteLinks | Where-Object Site2 -eq $fakeBoundParameter.Site2).Site1 | Sort-Object -Unique
}

Register-PSFTeppScriptblock -Name "ForestManagement.Linked.Site2" -ScriptBlock {
    $module = Get-Module ForestManagement
    $siteLinks = & $module { $script:sitelinks.Values }

    if (-not $fakeBoundParameter.Site1) {
        return $siteLinks.Site2 | Sort-Object -Unique
    }
    ($siteLinks | Where-Object Site1 -eq $fakeBoundParameter.Site1).Site2 | Sort-Object -Unique
}

<#
# Example:
Register-PSFTeppArgumentCompleter -Command Get-Alcohol -Parameter Type -Name ForestManagement.alcohol
#>


Register-PSFTeppArgumentCompleter -Command Get-FMSite -Parameter Name -Name 'ForestManagement.Sites'
Register-PSFTeppArgumentCompleter -Command Register-FMSite -Parameter Name -Name 'ForestManagement.Sites'
Register-PSFTeppArgumentCompleter -Command Unregister-FMSite -Parameter Name -Name 'ForestManagement.Sites'

Register-PSFTeppArgumentCompleter -Command Get-FMSubnet -Parameter SiteName -Name 'ForestManagement.Sites'
Register-PSFTeppArgumentCompleter -Command Register-FMSubnet -Parameter SiteName -Name 'ForestManagement.Sites'

Register-PSFTeppArgumentCompleter -Command Get-FMSiteLink -Parameter SiteName -Name 'ForestManagement.Sites'
Register-PSFTeppArgumentCompleter -Command Register-FMSiteLink -Parameter Site1 -Name 'ForestManagement.Sites'
Register-PSFTeppArgumentCompleter -Command Register-FMSiteLink -Parameter Site2 -Name 'ForestManagement.Site2New'
Register-PSFTeppArgumentCompleter -Command Unregister-FMSiteLink -Parameter Site1 -Name "ForestManagement.Linked.Site1"
Register-PSFTeppArgumentCompleter -Command Unregister-FMSiteLink -Parameter Site2 -Name "ForestManagement.Linked.Site2"

Register-PSFTeppArgumentCompleter -Command Invoke-FMSchema -Parameter Server -Name 'ForestManagement.ForestName'
Register-PSFTeppArgumentCompleter -Command Invoke-FMSchemaLdif -Parameter Server -Name 'ForestManagement.ForestName'
Register-PSFTeppArgumentCompleter -Command Invoke-FMServer -Parameter Server -Name 'ForestManagement.ForestName'
Register-PSFTeppArgumentCompleter -Command Invoke-FMSite -Parameter Server -Name 'ForestManagement.ForestName'
Register-PSFTeppArgumentCompleter -Command Invoke-FMSiteLink -Parameter Server -Name 'ForestManagement.ForestName'
Register-PSFTeppArgumentCompleter -Command Invoke-FMSubnet -Parameter Server -Name 'ForestManagement.ForestName'
Register-PSFTeppArgumentCompleter -Command Test-FMSchema -Parameter Server -Name 'ForestManagement.ForestName'
Register-PSFTeppArgumentCompleter -Command Test-FMSchemaLdif -Parameter Server -Name 'ForestManagement.ForestName'
Register-PSFTeppArgumentCompleter -Command Test-FMServer -Parameter Server -Name 'ForestManagement.ForestName'
Register-PSFTeppArgumentCompleter -Command Test-FMSite -Parameter Server -Name 'ForestManagement.ForestName'
Register-PSFTeppArgumentCompleter -Command Test-FMSiteLink -Parameter Server -Name 'ForestManagement.ForestName'
Register-PSFTeppArgumentCompleter -Command Test-FMSubnet -Parameter Server -Name 'ForestManagement.ForestName'

New-PSFLicense -Product 'ForestManagement' -Manufacturer 'Friedrich Weinmann' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2019-08-05") -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.
"@


# Directory Certificate Stores
$script:dsCertificates = @{ }
$script:dsCertificatesAuthorative = @{ }

# Exchange Schema Version
$script:exchangeschema = $null

# Forest Level
$script:forestlevel = $null

# NT Auth Store Configuration
$script:ntAuthStoreCertificates = @{ }
$script:ntAuthStoreAuthorative = $false

# Server Auto Assignment - whether domain controllers will be automatically moved to valid sites without any configuration needed
$script:serverAutoAssignment = $true

# Site Configurations
$script:sites = @{ }

# Subnet Configurations
$script:subnets = @{ }

# Sitelink Configurations
$script:sitelinks = @{ }

# Schema Definition
$script:schema = @{ }

# Schema Default Permission
$script:schemaDefaultPermissions = @{ }

# Schema Definitions for external LDIF files
$script:schemaLdif = @{ }

$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 ForestManagement.ConfigurationReset -ModuleName ADMF.Core -CommandName Clear-AdcConfiguration -ScriptBlock {
    Clear-FMConfiguration
}
#endregion Load compiled code