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)]
        [ValidateSet('Schema', 'SchemaLdif', 'SiteLinks', 'Sites', 'Subnets')]
        [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-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()
            $arguments += $networkCredential.UserName
            $arguments += $networkCredential.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 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.
     
    .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
    )
    
    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 If ADObject is present: Remove attributes that are already present
        $attributeNames = 'isSingleValued', 'searchflags', 'isMemberOfPartialAttributeSet', 'oMSyntax', 'attributeId', 'adminDescription', 'adminDisplayName', 'showInAdvancedViewOnly', 'lDAPDisplayName', 'attributeSyntax'
        
        if ($ADObject)
        {
            foreach ($attributeName in $attributeNames)
            {
                if ($ADobject.$attributeName -eq $attributes[$attributeName])
                {
                    $attributes.Remove($attributeName)
                }
            }
        }
        #endregion If ADObject is present: Remove attributes that are already present
        
        $attributes
    }
}


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-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 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 (
        [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
        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 }
        #endregion Resolve Credentials

        $testResult = Test-FMSchema @parameters

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

        :main foreach ($testItem in $testResult) {
            switch ($testItem.Type) {
                #region Create new Schema Attribute
                'ConfigurationOnly' {
                    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.ToBool() -PSCmdlet $PSCmdlet -Continue -RetryCount 10
                    }
                }
                #endregion Create new Schema Attribute

                #region Update Schema Attribute
                'InEqual' {
                    $resolvedAttributes = Resolve-SchemaAttribute -Configuration $testItem.Configuration -ADObject $testItem.ADObject
                    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.ToBool() -PSCmdlet $PSCmdlet -Continue
                    }

                    foreach ($class in  $testItem.Configuration.ObjectClass) {
                        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 -Target $testItem.Identity -ScriptBlock {
                                $classObject | Set-ADObject @parameters -Add @{ mayContain = $testItem.ADObject.LdapDisplayName } -ErrorAction Stop
                            } -EnableException $EnableException.ToBool() -PSCmdlet $PSCmdlet -Continue
                        }
                    }
                }
                #endregion Update Schema Attribute
            }
        }
    }
    end
    {
        if (Test-PSFFunctionInterrupt) { return }

        Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchema.Schema.Credentials.Release' -Target $forest.SchemaMaster -ScriptBlock {
            $null = Remove-SchemaAdminCredential @removeParameters -ErrorAction Stop
        } -EnableException $EnableException.ToBool() -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 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.
     
    .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(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $ObjectClass,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $OID,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $AdminDisplayName,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $LdapDisplayName,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [int]
        $OMSyntax,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $AttributeSyntax,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [switch]
        $SingleValued,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $AdminDescription,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [int]
        $SearchFlags,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [bool]
        $PartialAttributeSet,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [bool]
        $AdvancedView
    )
    
    process
    {
        $script:schema[$AdminDisplayName] = [PSCustomObject]@{
            PSTypeName = 'ForestManagement.Schema.Configuration'
            ObjectClass = $ObjectClass
            OID = $OID
            AdminDisplayName = $AdminDisplayName
            LdapDisplayName = $LdapDisplayName
            OMSyntax = $OMSyntax
            AttributeSyntax = $AttributeSyntax
            SingleValued = $SingleValued
            AdminDescription = $AdminDescription
            SearchFlags = $SearchFlags
            PartialAttributeSet = $PartialAttributeSet
            AdvancedView = $AdvancedView
        }
    }
}


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 "(name=$($schemaSetting.AdminDisplayName))" -SearchBase $rootDSE.schemaNamingContext -ErrorAction Ignore -Properties *

            if (-not $schemaObject) {
                [PSCustomObject]@{
                    PSTypeName = 'ForestManagement.Schema.TestResult'
                    Type = 'ConfigurationOnly'
                    ObjectType = 'Schema'
                    Identity = $schemaSetting.AdminDisplayName
                    Changed = $null
                    Server = $forest.SchemaMaster
                    ADObject = $null
                    Configuration = $schemaSetting
                }
                continue
            }
            
            $isEqual = $true
            $deltaProperties = @()

            if ($schemaSetting.LdapDisplayName -ne $schemaObject.lDAPDisplayName) {
                Write-PSFMessage -Level Warning -String 'Test-FMSchema.ReadOnly.Delta' -StringValues 'LdapDisplayName', $schemaObject.lDAPDisplayName, $schemaSetting.LdapDisplayName
            }
            if ($schemaSetting.OID -ne $schemaObject.attributeId) {
                Write-PSFMessage -Level Warning -String 'Test-FMSchema.ReadOnly.Delta' -StringValues 'OID/AttributeID', $schemaObject.lDAPDisplayName, $schemaSetting.OID
            }
            
            if ($schemaSetting.OMSyntax -ne $schemaObject.oMSyntax) { $isEqual = $false; $deltaProperties += 'OMSyntax' }
            if ($schemaSetting.AttributeSyntax -ne $schemaObject.attributeSyntax) { $isEqual = $false; $deltaProperties += 'AttributeSyntax' }
            if ($schemaSetting.SingleValued -ne $schemaObject.isSingleValued) { $isEqual = $false; $deltaProperties += 'SingleValued' }
            if ($schemaSetting.AdminDescription -ne $schemaObject.adminDescription) { $isEqual = $false; $deltaProperties += 'AdminDescription' }
            if ($schemaSetting.SearchFlags -ne $schemaObject.searchflags) { $isEqual = $false; $deltaProperties += 'SearchFlags' }
            if ($schemaSetting.PartialAttributeSet -ne $schemaObject.isMemberOfPartialAttributeSet) { $isEqual = $false; $deltaProperties += 'PartialAttributeSet' }
            if ($schemaSetting.AdvancedView -ne $schemaObject.showInAdvancedViewOnly) { $isEqual = $false; $deltaProperties += 'AdvancedView' }

            $mayContain = Get-ADObject @parameters -LDAPFilter "(mayContain=$($schemaSetting.LdapDisplayName))" -SearchBase $rootDSE.schemaNamingContext
            if (-not $mayContain -and $schemaSetting.ObjectClass) {
                $isEqual = $false
                $deltaProperties += 'ObjectClass'
            }
            elseif ($mayContain.Name | Compare-Object $schemaSetting.ObjectClass) {
                $isEqual = $false
                $deltaProperties += 'ObjectClass'
            }

            if (-not $isEqual) {
                [PSCustomObject]@{
                    PSTypeName = 'ForestManagement.Schema.TestResult'
                    Type = 'InEqual'
                    ObjectType = 'Schema'
                    Identity = $schemaSetting.AdminDisplayName
                    Changed = $deltaProperties
                    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-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 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 (
        [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
        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 }

        # Grab test results to get list of items to process
        $testResult = Test-FMSchemaLdif @parameters -EnableException:$EnableException
    }
    process
    {
        if (Test-PSFFunctionInterrupt) { return }

        foreach ($testItem in $testResult) {
            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 }

        Invoke-PSFProtectedCommand -ActionString 'Invoke-FMSchemaLdif.Schema.Credentials.Release' -Target $forest.SchemaMaster -ScriptBlock {
            Remove-SchemaAdminCredential @removeParameters -ErrorAction Stop
        } -EnableException $EnableException.ToBool() -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 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 (
        [PSFComputer]
        $Server,

        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        $testResult = Test-FMServer @parameters
    }
    process
    {
        foreach ($testItem in $testResult) {
            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 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) {
            #region No IP Address
            if (-not $domainController.IPAddress) {
                [PSCustomObject]@{
                    PSTypeName = 'ForestManagement.Server.TestResult'
                    Type = 'AddressNotFound'
                    ObjectType = 'Server'
                    Identity = $domainController.Name
                    Changed = $null
                    Server = $Server
                    CurrentSite = $domainController.SiteName
                    SupposedSite = $null
                    FoundSubnet = $null
                    ADObject = $domainController
                }
                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) {
                [PSCustomObject]@{
                    PSTypeName = 'ForestManagement.Server.TestResult'
                    Type = 'NoMatchingSubnet'
                    ObjectType = 'Server'
                    Identity = $domainController.Name
                    Changed = $null
                    Server = $Server
                    CurrentSite = $domainController.SiteName
                    SupposedSite = $null
                    FoundSubnet = $null
                    ADObject = $domainController
                }
                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
                    }
                }

                [PSCustomObject]@{
                    PSTypeName = 'ForestManagement.Server.TestResult'
                    Type = 'BadSite'
                    ObjectType = 'Server'
                    Identity = $domainController.Name
                    Changed = $foundSubnet.SiteName
                    Server = $Server
                    CurrentSite = $domainController.SiteName
                    SupposedSite = $foundSubnet.SiteName
                    FoundSubnet = $foundSubnet
                    ADObject = $domainController
                }
            }
        }
    }
}


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.
    #>

    [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 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 (
        [PSFComputer]
        $Server,

        [PSCredential]
        $Credential,

        [switch]
        $EnableException
    )
    
    begin
    {
        $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Server, Credential
        Assert-ADConnection @parameters -Cmdlet $PSCmdlet
        $testResult = Test-FMSiteLink @parameters
        Invoke-Callback @parameters -Cmdlet $PSCmdlet
        Assert-Configuration -Type SiteLinks -Cmdlet $PSCmdlet
    }
    process
    {
        foreach ($testItem in $testResult) {
            switch ($testItem.Type) {
                #region Delete undesired Sitelink
                'ForestOnly' {
                    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
                'ConfigurationOnly' {
                    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
                'InEqual' {
                    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
                    }
                    if ($testItem.Cost -ne $testItem.ADObject.Cost) { $parametersUpdate['Cost'] = $testItem.Cost }
                    if ($testItem.Description -ne ([string]($testItem.ADObject.Description))) { $parametersUpdate['Description'] = $testItem.Description }
                    if ($testItem.Options -ne ([int]($testItem.ADObject.Options))) { $parametersUpdate['Replace'] = @{ Options = $testItem.Options } }
                    if ($testItem.ReplicationInterval -ne $testItem.ADObject.replInterval) { $parametersUpdate['ReplicationFrequencyInMinutes'] = $testItem.replInterval }

                    # 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
    {
        $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 = @()
        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
                    [pscustomobject]@{
                        PSTypeName = 'ForestManagement.SiteLink.Information.MultipleSites'
                        Type = 'SiteLink.MultipleSites'
                        ObjectType = 'SiteLink'
                        Identity = $siteLink.Name
                        Changed = $null
                        Server = $Server
                        DistinguishedName = $siteLink.DistinguishedName
                        Name = $siteLink.Name
                        ADObject = $siteLink
                    }
                    $linksToExclude += $siteLink
                }
                else {
                    Write-PSFMessage -Level Warning -String 'Test-FMSiteLink.Critical.TooManySites' -StringValues $siteLink.DistinguishedName, $siteLink.siteList.Count -Tag sitelink, critical, panic -Target $siteLink.DistinguishedName
                    [pscustomobject]@{
                        PSTypeName = 'ForestManagement.SiteLink.Critical.TooManySites'
                        Type = 'SiteLink.TooManySites'
                        ObjectType = 'SiteLink'
                        Identity = $siteLink.Name
                        Changed = $null
                        Server = $Server
                        DistinguishedName = $siteLink.DistinguishedName
                        Name = $siteLink.Name
                        ADObject = $siteLink
                    }
                    $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)) {
                [PSCustomObject]@{
                    PSTypeName = 'ForestManagement.SiteLink.TestResult'
                    Type = 'ForestOnly'
                    ObjectType = 'SiteLink'
                    Identity = $siteLink.Name
                    Changed = $null
                    Server = $Server
                    Name = $siteLink.Name
                    Site1 = $siteLink.Site1
                    Site2 = $siteLink.Site2
                    IdealName = $siteLink.IdealName
                    Cost = $siteLink.Cost
                    Description = $siteLink.Description
                    Options = $siteLink.Options
                    ReplicationInterval = $siteLink.replInterval
                    Configuration = $null
                    ADObject = $siteLink
                }
                continue
            }

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

            if ($configuredSiteLink.Name -ne $siteLink.Name) { $isEqual = $false; $deltaProperties += 'Name' }
            if ($configuredSiteLink.Cost -ne $siteLink.Cost) { $isEqual = $false; $deltaProperties += 'Cost' }
            if ($configuredSiteLink.Description -ne ([string]($siteLink.Description))) { $isEqual = $false; $deltaProperties += 'Description' }
            if ($configuredSiteLink.Option -ne ([int]($siteLink.Options))) { $isEqual = $false; $deltaProperties += 'Options' }
            if ($configuredSiteLink.Interval -ne $siteLink.replInterval) { $isEqual = $false; $deltaProperties += 'ReplicationInterval' }

            if (-not $isEqual)
            {
                [PSCustomObject]@{
                    PSTypeName = 'ForestManagement.SiteLink.TestResult'
                    Type = 'InEqual'
                    ObjectType = 'SiteLink'
                    Identity = $siteLink.Name
                    Changed = $deltaProperties
                    Server = $Server
                    Name = $configuredSitelink.Name
                    Site1 = $configuredSitelink.Site1
                    Site2 = $configuredSitelink.Site2
                    IdealName = $configuredSitelink.Name
                    Cost = $configuredSitelink.Cost
                    Description = $configuredSitelink.Description
                    Options = $configuredSitelink.Option
                    ReplicationInterval = $configuredSitelink.Interval
                    Configuration = $configuredSitelink
                    ADObject = $siteLink
                }
            }
        }
        #endregion Test all sitelinks found in the forest

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

            [PSCustomObject]@{
                PSTypeName = 'ForestManagement.SiteLink.TestResult'
                Type = 'ConfigurationOnly'
                ObjectType = 'SiteLink'
                Identity = $configuredSitelink.Name
                Changed = $null
                Server = $Server
                Name = $configuredSitelink.Name
                Site1 = $configuredSitelink.Site1
                Site2 = $configuredSitelink.Site2
                IdealName = $configuredSitelink.Name
                Cost = $configuredSitelink.Cost
                Description = $configuredSitelink.Description
                Options = $configuredSitelink.Option
                ReplicationInterval = $configuredSitelink.Interval
                Configuration = $configuredSitelink
                ADObject = $null
            }
        }
    }
}


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 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 (
        [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
        $testResult = Test-FMSite @parameters
    }
    process
    {
        foreach ($testItem in $testResult) {
            switch ($testItem.Type) {
                'ForestOnly' {
                    $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
                    }
                }
                'ConfigurationOnly' {
                    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
                }
                'InEqual' {
                    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
                }
                'RenamePending' {
                    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
    {
        $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  = @{}
        
        foreach ($site in $allSites) {
            if ($renameMapping.Keys -contains $site.Name) {
                [PSCustomObject]@{
                    PSTypeName = 'ForestManagement.Site.TestResult'
                    Type = 'RenamePending'
                    ObjectType = 'Site'
                    Identity = $site.Name
                    Changed = 'Name'
                    Server = $Server
                    Name = $site.Name
                    Description = $site.Description
                    Location = $site.Location
                    NewName = $renameMapping[$site.Name]
                    ADObject = $site
                }
            }
            elseif ($script:sites.Keys -contains $site.Name) {
                $foundSites[$site.Name] = $site
            }
            else {
                [PSCustomObject]@{
                    PSTypeName = 'ForestManagement.Site.TestResult'
                    Type = 'ForestOnly'
                    ObjectType = 'Site'
                    Identity = $site.Name
                    Changed = $null
                    Server = $Server
                    Name = $site.Name
                    Description = $site.Description
                    Location = $site.Location
                    ADObject = $site
                }
            }
        }
        foreach ($site in $script:sites.Values) {
            if ($site.Name -notin $allSites.Name) {
                [PSCustomObject]@{
                    PSTypeName = 'ForestManagement.Site.TestResult'
                    Type = 'ConfigurationOnly'
                    ObjectType = 'Site'
                    Identity = $site.Name
                    Changed = $null
                    Server = $Server
                    Name = $site.Name
                    Description = $site.Description
                    Location = $site.Location
                    ADObject = $null
                }
            }
        }

        foreach ($site in $foundSites.Values) {
            $isEqual = $true
            $deltaProperties = @()
            if ([string]($site.Location) -ne $script:sites[$site.Name].Location) { $isEqual = $false; $deltaProperties += 'Location' }
            if ([string]($site.Description) -ne $script:sites[$site.Name].Description) { $isEqual = $false; $deltaProperties += 'Description' }

            if ($isEqual) { continue }

            [PSCustomObject]@{
                PSTypeName = 'ForestManagement.Site.TestResult'
                Type = 'InEqual'
                ObjectType = 'Site'
                Identity = $site.Name
                Changed = $deltaProperties
                Server = $Server
                Name = $site.Name
                Description = $script:sites[$site.Name].Description
                Location = $script:sites[$site.Name].Location
                ADObject = $site
            }
        }
    }
}


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 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 (
        [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
        $testResult = Test-FMSubnet @parameters | Sort-Object {
            switch ($_.Type) {
                'ForestOnly' { 1 }
                'InEqual' { 2 }
                default { 3 }
            }
        }
    }
    process
    {
        foreach ($testItem in $testResult) {
            switch ($testItem.Type) {
                'ForestOnly' {
                    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
                }
                'ConfigurationOnly' {
                    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
                }
                'InEqual' {
                    $parametersSetSplat = $parameters.Clone()
                    $parametersSetSplat['Identity'] = $testItem.Identity
                    if ($testItem.SiteName -ne $testItem.ADObject.SiteName) { $parametersSetSplat['Site'] = $testItem.SiteName }
                    if ($testItem.Description -ne ([string]($testItem.ADObject.Description))) { $parametersSetSplat['Description'] = $testItem.Description }
                    if ($testItem.Location -ne ([string]($testItem.ADObject.Location))) { $parametersSetSplat['Location'] = $testItem.Location }

                    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
    {
        $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
    {
        #region Test all Subnets found in the forest
        foreach ($subnetItem in $allSubnets) {
            if ($script:subnets.Keys -notcontains $subnetItem.Name) {
                [PSCustomObject]@{
                    PSTypeName = 'ForestManagement.Subnet.TestResult'
                    Type = 'ForestOnly'
                    ObjectType = 'Subnet'
                    Identity = $subnetItem.Name
                    Changed = $null
                    Server = $Server
                    SiteName = $subnetItem.SiteName
                    Name = $subnetItem.Name
                    Description = $subnetItem.Description
                    Location = $subnetItem.Location
                    ADObject = $subnetItem
                }
                continue
            }

            $configuredSubnet = $script:subnets[$subnetItem.Name]
            $isEqual = $true
            $deltaProperties = @()
            if ($subnetItem.SiteName -ne $configuredSubnet.SiteName) { $isEqual = $false; $deltaProperties += 'SiteName' }
            if ([string]($subnetItem.Description) -ne $configuredSubnet.Description) { $isEqual = $false; $deltaProperties += 'Description' }
            if ([string]($subnetItem.Location) -ne $configuredSubnet.Location) { $isEqual = $false; $deltaProperties += 'Location' }

            if (-not $isEqual)
            {
                [PSCustomObject]@{
                    PSTypeName = 'ForestManagement.Subnet.TestResult'
                    Type = 'InEqual'
                    ObjectType = 'Subnet'
                    Identity = $subnetItem.Name
                    Changed = $deltaProperties
                    Server = $Server
                    SiteName = $configuredSubnet.SiteName
                    Name = $configuredSubnet.Name
                    Description = $configuredSubnet.Description
                    Location = $configuredSubnet.Location
                    ADObject = $subnetItem
                }
            }
        }
        #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 }

            [PSCustomObject]@{
                PSTypeName = 'ForestManagement.Subnet.TestResult'
                Type = 'ConfigurationOnly'
                ObjectType = 'Subnet'
                Identity = $configuredSubnet.Name
                Changed = $null
                Server = $Server
                SiteName = $configuredSubnet.SiteName
                Name = $configuredSubnet.Name
                Description = $configuredSubnet.Description
                Location = $configuredSubnet.Location
                ADObject = $null
            }
        }
        #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"
    #>

    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Mandatory = $true)]
        [string[]]
        $Name
    )
    
    process
    {
        foreach ($nameItem in $SiteName) {
            $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.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.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.
"@


# Site Configurations
$script:sites = @{ }

# Subnet Configurations
$script:subnets = @{ }

# Sitelink Configurations
$script:sitelinks = @{ }

# Schema Definition
$script:schema = @{ }

# Schema Definitions for external LDIF files
$script:schemaLdif = @{ }
#endregion Load compiled code