commands.ps1


<#
    .SYNOPSIS
        Assign D365 Security configuration
         
    .DESCRIPTION
        Assign the same security configuration as the ADMIN user in the D365FO database
         
    .PARAMETER sqlCommand
        The SQL Command object that should be used when assigning the permissions
         
    .PARAMETER Id
        Id of the user inside the D365FO database
         
    .EXAMPLE
        PS C:\> $SqlParams = @{
        DatabaseServer = "localhost"
        DatabaseName = "AXDB"
        SqlUser = "sqladmin"
        SqlPwd = "Pass@word1"
        TrustedConnection = $false
        }
         
        PS C:\> $SqlCommand = Get-SqlCommand @SqlParams
        PS C:\> Add-AadUserSecurity -SqlCommand $SqlCommand -Id "TestUser"
         
        This will create a new Sql Command object using the Get-SqlCommand cmdlet and the $SqlParams hashtable containing all the needed parameters.
        With the $SqlCommand in place it calls the Add-AadUserSecurity cmdlet and instructs it to update the "TestUser" to have the same security configuration as the ADMIN user.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Add-AadUserSecurity {
    [OutputType('System.Boolean')]
    param (
        [Parameter(Mandatory = $true)]
        [System.Data.SqlClient.SqlCommand] $SqlCommand,

        [Parameter(Mandatory = $true)]
        [string] $Id
    )

    $commandText = (Get-Content "$script:ModuleRoot\internal\sql\Set-AadUserSecurityInD365FO.sql") -join [Environment]::NewLine
   
    $sqlCommand.CommandText = $commandText

    $null = $sqlCommand.Parameters.Add("@Id", $Id)

    Write-PSFMessage -Level Verbose -Message "Setting security roles in D365FO database"

    Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

    $differenceBetweenNewUserAndAdmin = $sqlCommand.ExecuteScalar()
    
    Write-PSFMessage -Level Verbose -Message "Difference between new user and admin security roles $differenceBetweenNewUserAndAdmin" -Target $differenceBetweenNewUserAndAdmin
    
    $SqlCommand.Parameters.Clear()

    $differenceBetweenNewUserAndAdmin -eq 0
}


<#
    .SYNOPSIS
        Backup a file
         
    .DESCRIPTION
        Backup a file in the same directory as the original file with a suffix
         
    .PARAMETER File
        Path to the file that you want to backup
         
    .PARAMETER Suffix
        The suffix value that you want to append to the file name when backing it up
         
    .EXAMPLE
        PS C:\> Backup-File -File c:\temp\d365fo.tools\test.txt -Suffix "Original"
         
        This will backup the "test.txt" file as "test_Original.txt" inside "c:\temp\d365fo.tools\"
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Backup-File {
    [CmdletBinding()]

    param (
        [Parameter(Mandatory = $true)]
        [string] $File,
        
        [Parameter(Mandatory = $true)]
        [string] $Suffix
        )

    $FileBackup = Get-BackupName $File $Suffix
    Write-PSFMessage -Level Verbose -Message "Backing up $File to $FileBackup" -Target (@($File, $FileBackup))
    (Get-Content -Path $File) | Set-Content -path $FileBackup
}


<#
    .SYNOPSIS
        Complete the upload action in LCS
         
    .DESCRIPTION
        Signal to LCS that the upload of the blob has completed
         
    .PARAMETER Token
        The token to be used for the http request against the LCS API
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
    .PARAMETER AssetId
        The unique id of the asset / file that you are trying to upload to LCS
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
    .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:\> Complete-LcsUpload -Token "Bearer JldjfafLJdfjlfsalfd..." -ProjectId 123456789 -AssetId "958ae597-f089-4811-abbd-c1190917eaae" -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will commit the upload process for the AssetId "958ae597-f089-4811-abbd-c1190917eaae" in the LCS project with Id 123456789.
        The http request will be using the "Bearer JldjfafLJdfjlfsalfd..." token for authentication against the LCS API.
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, LCS, Upload, Api, AAD, Token
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Complete-LcsUpload {
    [CmdletBinding()]
    [OutputType()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Token,

        [Parameter(Mandatory = $true)]
        [int]$ProjectId,

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

        [Parameter(Mandatory = $false)]
        [string]$LcsApiUri,

        [switch] $EnableException
    )
    
    Invoke-TimeSignal -Start

    $client = New-Object -TypeName System.Net.Http.HttpClient
    $client.DefaultRequestHeaders.Clear()
    $client.DefaultRequestHeaders.UserAgent.ParseAdd("d365fo.tools via PowerShell")
    
    $commitFileUri = "$LcsApiUri/box/fileasset/CommitFileAsset/$($ProjectId)?assetId=$AssetId"

    $request = New-JsonRequest -Uri $commitFileUri -Token $Token
    
    Write-PSFMessage -Level Verbose -Message "Sending the commit request against LCS" -Target $request

    try {
        $commitResult = Get-AsyncResult -Task $client.SendAsync($request)

        Write-PSFMessage -Level Verbose -Message "Parsing the commitResult for success" -Target $commitResult
        if (($commitResult.StatusCode -ne [System.Net.HttpStatusCode]::NoContent) -and ($commitResult.StatusCode -ne [System.Net.HttpStatusCode]::OK)) {
            Write-PSFMessage -Level Host -Message "The LCS API returned an http error code" -Exception $PSItem.Exception -Target $commitResult
            Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the LCS API" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        return
    }

    Invoke-TimeSignal -End

    $commitResult
}


<#
    .SYNOPSIS
        Convert HashTable into an array
         
    .DESCRIPTION
        Convert HashTable with switches inside into an array of Key:Value
         
    .PARAMETER InputObject
        The HashTable object that you want to work against
         
        Shold only contain Key / Vaule, where value is $true or $false
         
    .PARAMETER KeyPrefix
        The prefix that you want to append to the key of the HashTable
         
        The default value is "-"
         
    .PARAMETER ValuePrefix
        The prefix that you want to append to the value of the HashTable
         
        The default value is ":"
         
    .PARAMETER KeepCase
        Instruct the cmdlet to keep the naming case of the properties from the hashtable
         
        Default value is: $true
         
    .EXAMPLE
        PS C:\> $params = @{NoPrompt = $true; CreateParents = $false}
        PS C:\> $arguments = Convert-HashToArgStringSwitch -Inputs $params
         
        This will convert the $params into an array of strings, each with the "-Key:Value" pattern.
         
    .EXAMPLE
        PS C:\> $params = @{NoPrompt = $true; CreateParents = $false}
        PS C:\> $arguments = Convert-HashToArgStringSwitch -InputObject $params -KeyPrefix "&" -ValuePrefix "="
         
        This will convert the $params into an array of strings, each with the "&Key=Value" pattern.
         
    .NOTES
        Tags: HashTable, Arguments
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Convert-HashToArgStringSwitch {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidDefaultValueSwitchParameter", "")]
    [CmdletBinding()]
    [OutputType([System.String])]
    param (
        [HashTable] $InputObject,

        [string] $KeyPrefix = "-",

        [string] $ValuePrefix = ":",

        [switch] $KeepCase = $true
    )

    foreach ($key in $InputObject.Keys) {
        $value = "{0}" -f $InputObject.Item($key).ToString()
        if (-not $KeepCase) {$value = $value.ToLower()}
        "$KeyPrefix$($key)$ValuePrefix$($value)"
    }
}


<#
    .SYNOPSIS
        Convert an object to boolean
         
    .DESCRIPTION
        Convert an object to boolean or default it to the specified boolean value
         
    .PARAMETER Object
        Input object that you want to work against
         
    .PARAMETER Default
        The default boolean value you want returned if the convert / cast fails
         
    .EXAMPLE
        PS C:\> ConvertTo-BooleanOrDefault -Object "1" -Default $true
         
        This will try and convert the "1" value to a boolean value.
        If the convert would fail, it would return the default value $true.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function ConvertTo-BooleanOrDefault {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingEmptyCatchBlock', '')]
    [CmdletBinding()]
    [OutputType('System.Boolean')]
    param (
        [Object] $Object,

        [Boolean] $Default
    )

    [boolean] $result = $Default;
    $stringTrue = @("yes", "true", "ok", "y")

    $stringFalse = @( "no", "false", "n")

    try {
        if (-not ($null -eq $Object) ) {
            switch ($Object.ToString().ToLower()) {
                {$stringTrue -contains $_} {
                    $result = $true
                    break
                }
                {$stringFalse -contains $_} {
                    $result = $false
                    break
                }
                default {
                    $result = [System.Boolean]::Parser($Object.ToString())
                    break
                }
            }
        }
    }
    catch {
    }

    $result
}


<#
    .SYNOPSIS
        Convert an object into a HashTable
         
    .DESCRIPTION
        Convert an object into a HashTable, can be used with json objects to create a HashTable
         
    .PARAMETER InputObject
        The object you want to convert
         
    .EXAMPLE
        PS C:\> $jsonString = '{"Test1": "Test1","Test2": "Test2"}'
        PS C:\> $jsonString | ConvertFrom-Json | ConvertTo-Hashtable
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
        Original Author: Adam Bertram (@techsnips_io)
         
        Original blog post with the function explained:
        https://4sysops.com/archives/convert-json-to-a-powershell-hash-table/
         
#>


function ConvertTo-Hashtable {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCmdletCorrectly', '')]
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline)]
        $InputObject
    )

    process {
        ## Return null if the input is null. This can happen when calling the function
        ## recursively and a property is null
        if ($null -eq $InputObject) {
            return $null
        }

        ## Check if the input is an array or collection. If so, we also need to convert
        ## those types into hash tables as well. This function will convert all child
        ## objects into hash tables (if applicable)
        if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
            $collection = @(
                foreach ($object in $InputObject) {
                    ConvertTo-Hashtable -InputObject $object
                }
            )

            ## Return the array but don't enumerate it because the object may be pretty complex
            Write-Output -NoEnumerate $collection
        }
        elseif ($InputObject -is [psobject]) {
            ## If the object has properties that need enumeration
            ## Convert it to its own hash table and return it
            $hash = @{}
            foreach ($property in $InputObject.PSObject.Properties) {
                $hash[$property.Name] = ConvertTo-Hashtable -InputObject $property.Value
            }
            $hash
        }
        else {
            ## If the object isn't an array, collection, or other object, it's already a hash table
            ## So just return it.
            $InputObject
        }
    }
}


<#
    .SYNOPSIS
        Convert a Hashtable into a PSCustomObject
         
    .DESCRIPTION
        Convert a Hashtable into a PSCustomObject
         
    .PARAMETER InputObject
        The hashtable you want to convert
         
    .EXAMPLE
        PS C:\> $params = @{SqlUser = ""; SqlPwd = ""}
        PS C:\> $params | ConvertTo-PsCustomObject
         
        This will create a hashtable with 2 properties.
        It will convert the hashtable into a PSCustomObject
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
        Original blog post with the function explained:
        https://blogs.msdn.microsoft.com/timid/2013/03/05/converting-pscustomobject-tofrom-hashtables/
#>


function ConvertTo-PsCustomObject {
    [OutputType('[PsCustomObject]')]
    param (
        [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [object[]] $InputObject
    )
    
    begin { $i = 0 }
    
    process {
        foreach ($myHashtable in $InputObject) {
            if ($myHashtable.GetType().Name -eq 'hashtable') {
                $output = New-Object -TypeName PsObject
                Add-Member -InputObject $output -MemberType ScriptMethod -Name AddNote -Value {
                    Add-Member -InputObject $this -MemberType NoteProperty -Name $args[0] -Value $args[1]
                }

                $myHashtable.Keys | Sort-Object | ForEach-Object {
                    $output.AddNote($_, $myHashtable.$_)
                }

                $output
            }
            elseif ($myHashtable.GetType().Name -eq 'OrderedDictionary') {
                $output = New-Object -TypeName PsObject
                Add-Member -InputObject $output -MemberType ScriptMethod -Name AddNote -Value {
                    Add-Member -InputObject $this -MemberType NoteProperty -Name $args[0] -Value $args[1]
                }

                $myHashtable.Keys | ForEach-Object {
                    $output.AddNote($_, $myHashtable.$_)
                }

                $output
            }
            else {
                Write-PSFMessage -Level Warning -Message "Index `$i is not of type [hashtable]" -Target $i
            }

            $i += 1
        }
    }
}


<#
    .SYNOPSIS
        Copy local file to Azure Blob Storage
         
    .DESCRIPTION
        Copy local file to Azure Blob Storage that is used by LCS
         
    .PARAMETER FilePath
        Path to the file you want to upload to the Azure Blob storage
         
    .PARAMETER FullUri
        The full URI, including SAS token and Policy Permissions to the blob
         
    .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:\> Copy-FileToLcsBlob -FilePath "C:\temp\d365fo.tools\GOLDEN.bacpac" -FullUri "https://uswedpl1catalog.blob.core.windows.net/...."
         
        This will upload the "C:\temp\d365fo.tools\GOLDEN.bacpac" to the "https://uswedpl1catalog.blob.core.windows.net/...." Blob Storage location.
        It is required that the FullUri contains all the needed SAS tokens and Policy Permissions for the upload to succeed.
         
    .NOTES
        Tags: Azure Blob, LCS, Upload
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Copy-FileToLcsBlob {
    [CmdletBinding()]
    [OutputType()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$FilePath,
        
        [Parameter(Mandatory = $true)]
        [System.Uri]$FullUri,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    Write-PSFMessage -Level Verbose -Message "Initializing the needed .net objects to work against Azure Blob." -Target $FullUri
    $cloudblob = New-Object -TypeName Microsoft.WindowsAzure.Storage.Blob.CloudBlockBlob -ArgumentList @($FullUri)

    try {
        $uploadResult = Get-AsyncResult -Task $cloudblob.UploadFromFileAsync([System.String]$FilePath)
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while uploading the desired file to Azure Blob." -Exception $PSItem.Exception -Target $FullUri
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
 
    Invoke-TimeSignal -End
    
    $uploadResult
}


<#
    .SYNOPSIS
        Load all necessary information about the D365 instance
         
    .DESCRIPTION
        Load all servicing dll files from the D365 instance into memory
         
    .EXAMPLE
        PS C:\> Get-ApplicationEnvironment
         
        This will load all the different dll files into memory.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-ApplicationEnvironment {
    [System.Collections.ArrayList] $Files2Process = New-Object -TypeName "System.Collections.ArrayList"

    $AOSPath = Join-Path $script:ServiceDrive "\AOSService\webroot\bin"
    
    Write-PSFMessage -Level Verbose -Message "Testing if we are running on a AOS server or not."
    if (-not (Test-Path -Path $AOSPath -PathType Container)) {
        Write-PSFMessage -Level Verbose -Message "The machine is NOT an AOS server."
        
        $MRPath = Join-Path $script:ServiceDrive "MRProcessService\MRInstallDirectory\Server\Services"
        
        Write-PSFMessage -Level Verbose -Message "Testing if we are running on a BI / MR server or not."
        if (-not (Test-Path -Path $MRPath -PathType Container)) {
            Write-PSFMessage -Level Verbose -Message "It seems that you ran this cmdlet on a machine that doesn't have the assemblies needed to obtain system details. Most likely you ran it on a <c='em'>personal workstation / personal computer</c>."
            return
        }
        else {
            Write-PSFMessage -Level Verbose -Message "The machine is a BI / MR server."
            $BasePath = $MRPath

            $null = $Files2Process.Add((Join-Path $script:ServiceDrive "Monitoring\Instrumentation\Microsoft.Dynamics.AX.Authentication.Instrumentation.dll"))
        }
    }
    else {
        Write-PSFMessage -Level Verbose -Message "The machine is an AOS server."
        $BasePath = $AOSPath

        $null = $Files2Process.Add((Join-Path $BasePath "Microsoft.Dynamics.AX.Authentication.Instrumentation.dll"))
    }

    Write-PSFMessage -Level Verbose -Message "Shadow cloning all relevant assemblies to the Microsoft.Dynamics.ApplicationPlatform.Environment.dll to avoid locking issues. This enables us to install updates while having d365fo.tools loaded"
        
    $null = $Files2Process.Add((Join-Path $BasePath "Microsoft.Dynamics.AX.Configuration.Base.dll"))
    $null = $Files2Process.Add((Join-Path $BasePath "Microsoft.Dynamics.BusinessPlatform.SharedTypes.dll"))
    $null = $Files2Process.Add((Join-Path $BasePath "Microsoft.Dynamics.AX.Framework.EncryptionEngine.dll"))
    $null = $Files2Process.Add((Join-Path $BasePath "Microsoft.Dynamics.AX.Security.Instrumentation.dll"))
    $null = $Files2Process.Add((Join-Path $BasePath "Microsoft.Dynamics.ApplicationPlatform.Environment.dll"))

    Import-AssemblyFileIntoMemory -Path $($Files2Process.ToArray())

    if (Test-PSFFunctionInterrupt) { return }

    Write-PSFMessage -Level Verbose -Message "All assemblies loaded. Getting environment details."
    $environment = [Microsoft.Dynamics.ApplicationPlatform.Environment.EnvironmentFactory]::GetApplicationEnvironment()
    
    $environment
}


<#
    .SYNOPSIS
        Simple abstraction to handle asynchronous executions
         
    .DESCRIPTION
        Simple abstraction to handle asynchronous executions for several other cmdlets
         
    .PARAMETER Task
        The task you want to work / wait for to complete
         
    .EXAMPLE
        PS C:\> $client = New-Object -TypeName System.Net.Http.HttpClient
        PS C:\> Get-AsyncResult -Task $client.SendAsync($request)
         
        This will take the client (http) and have it send a request using the asynchronous pattern.
         
    .NOTES
        Tags: Async, Waiter, Wait
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Get-AsyncResult {
    [CmdletBinding()]
    [OutputType('Object')]
    param (
        [Parameter(Mandatory = $true, Position = 1)]
        [object] $Task
    )

    Write-PSFMessage -Level Verbose -Message "Building the Task Waiter and start waiting." -Target $Task
    $Task.GetAwaiter().GetResult()
}


<#
    .SYNOPSIS
        Get the Azure Service Objectives
         
    .DESCRIPTION
        Get the current tiering details from the Azure SQL Database instance
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .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:\> Get-AzureServiceObjective -DatabaseServer dbserver1.database.windows.net -DatabaseName AxDB -SqlUser User123 -SqlPwd "Password123"
         
        This will get the Azure service objective details from the Azure SQL Database instance located at "dbserver1.database.windows.net"
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-AzureServiceObjective {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $DatabaseServer,

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

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

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

        [switch] $EnableException
    )
        
    $sqlCommand = Get-SqlCommand @PsBoundParameters -TrustedConnection $false

    $commandText = (Get-Content "$script:ModuleRoot\internal\sql\get-azureserviceobjective.sql") -join [Environment]::NewLine

    $sqlCommand.CommandText = $commandText

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()

        $reader = $sqlCommand.ExecuteReader()
        
        if ($reader.Read() -eq $true) {
            Write-PSFMessage -Level Verbose "Extracting details from the result retrieved from the Azure DB instance"

            $edition = $reader.GetString(1)
            $serviceObjective = $reader.GetString(2)

            $reader.close()
            
            $sqlCommand.Connection.Close()
            $sqlCommand.Dispose()
            
            [PSCustomObject]@{
                DatabaseEdition          = $edition
                DatabaseServiceObjective = $serviceObjective
            }
        }
        else {
            $messageString = "The query to detect <c='em'>edition</c> and <c='em'>service objectives</c> from the Azure DB instance <c='em'>failed</c>."
            Write-PSFMessage -Level Host -Message $messageString -Target (Get-SqlString $SqlCommand)
            Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>','')))
            return
        }
    }
    catch {
        $messageString = "Something went wrong while working against the database."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_
        return
    }
}


<#
    .SYNOPSIS
        Get a backup name for the file
         
    .DESCRIPTION
        Generate a backup name for the file parsed
         
    .PARAMETER File
        Path to the file that you want a backup name for
         
    .PARAMETER Suffix
        The name that you want to put into the new backup file name
         
    .EXAMPLE
        PS C:\> Get-BackupName -File "C:\temp\d365do.tools\Test.txt" -Suffix "Original"
         
        The function will return "C:\temp\d365do.tools\Test_Original.txt"
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-BackupName {
    [CmdletBinding()]
    [OutputType([System.String])]
    param (
        [Parameter(Mandatory = $true)]
        [string] $File,

        [Parameter(Mandatory = $true)]
        [string] $Suffix
    )

    Write-PSFMessage -Level Verbose -Message "Getting backup name for file: $File" -Tag $File

    $FileInfo = [System.IO.FileInfo]::new($File)

    $BackupName = "{0}{1}_{2}{3}" -f $FileInfo.Directory, $FileInfo.BaseName, $Suffix, $FileInfo.Extension
    
    Write-PSFMessage -Level Verbose -Message "Backup name for the file will be $BackupName" -Tag $BackupName
    
    $BackupName
}


<#
    .SYNOPSIS
        Load the Canonical Identity Provider
         
    .DESCRIPTION
        Load the necessary dll files from the D365 instance to get the Canonical Identity Provider object
         
    .EXAMPLE
        PS C:\> Get-CanonicalIdentityProvider
         
        This will get the Canonical Identity Provider from the D365 instance
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-CanonicalIdentityProvider {
    [CmdletBinding()]
    param ()
    try {
        Write-PSFMessage -Level Verbose "Loading dll files to do some work against the CanonicalIdentityProvider."

        Add-Type -Path "$Script:AOSPath\bin\Microsoft.Dynamics.AX.Framework.EncryptionEngine.dll"
        Add-Type -Path "$Script:AOSPath\bin\Microsoft.Dynamics.AX.Security.AuthenticationCommon.dll"

        Write-PSFMessage -Level Verbose "Executing the CanonicalIdentityProvider lookup logic."
        $Identity = [Microsoft.Dynamics.AX.Security.AuthenticationCommon.AadHelper]::GetIdentityProvider()
        $Provider = [Microsoft.Dynamics.AX.Security.AuthenticationCommon.AadHelper]::GetCanonicalIdentityProvider($Identity)

        Write-PSFMessage -Level Verbose "CanonicalIdentityProvider is: $Provider" -Tag $Provider

        return $Provider
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the CanonicalIdentityProvider." -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
}


<#
    .SYNOPSIS
        Parse the compiler output
         
    .DESCRIPTION
        Parse the output log files from the compiler and show the number of warnings and errors
         
    .PARAMETER Path
        The path to where the compiler output log file is located
         
         
    .EXAMPLE
        PS C:\> Get-CompilerResult -Path c:\temp\d365fo.tools\Dynamics.AX.Custom.xppc.log
         
        This will analaze the Dynamics.AX.Custom.xppc.log compiler output file.
        Will create a summarize object with number of errors and warnings.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
        This cmdlet is inspired by the work of "Vilmos Kintera" (twitter: @DAXRunBase)
         
        All credits goes to him for showing how to extract these information
         
        His blog can be found here:
        https://www.daxrunbase.com/blog/
         
        The specific blog post that we based this cmdlet on can be found here:
        https://www.daxrunbase.com/2020/03/31/interpreting-compiler-results-in-d365fo-using-powershell/
         
        The github repository containing the original scrips can be found here:
        https://github.com/DAXRunBase/PowerShell-and-Azure
         
#>

function Get-CompilerResult {
    [CmdletBinding()]
    [OutputType('[PsCustomObject]')]
    param (
        [parameter(Mandatory = $true)]
        [string] $Path
    )

    Invoke-TimeSignal -Start

    if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }

    $errorText = Select-String -LiteralPath $Path -Pattern ^Errors: | ForEach-Object { $_.Line }
    $errorCount = [int]$errorText.Split()[-1]

    $warningText = Select-String -LiteralPath $Path -Pattern ^Warnings: | ForEach-Object { $_.Line }
    $warningCount = [int]$warningText.Split()[-1]

    [PsCustomObject][Ordered]@{
        File       = "$Path"
        Warnings   = $warningCount
        Errors     = $errorCount
        PSTypeName = 'D365FO.TOOLS.CompilerOutput'
    }
}


<#
    .SYNOPSIS
        Clone a hashtable
         
    .DESCRIPTION
        Create a deep clone of a hashtable for you to work on it without updating the original object
         
    .PARAMETER InputObject
        The hashtable you want to clone
         
    .EXAMPLE
        PS C:\> Get-DeepClone -InputObject $HashTable
         
        This will clone the $HashTable variable into a new object and return it to you.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-DeepClone {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')]
    [CmdletBinding()]
    param(
        [parameter(Mandatory = $true)]
        $InputObject
    )
    process
    {
        if($InputObject -is [hashtable]) {

            $clone = @{}

            foreach($key in $InputObject.keys)
            {
                if($key -eq "EnableException") {continue}
                
                $clone[$key] = Get-DeepClone $InputObject[$key]
            }

            $clone
        } else {
            $InputObject
        }
    }
}


<#
    .SYNOPSIS
        Get the file version details
         
    .DESCRIPTION
        Get the file version details for any given file
         
    .PARAMETER Path
        Path to the file that you want to extract the file version details from
         
    .EXAMPLE
        PS C:\> Get-FileVersion -Path "C:\Program Files\Microsoft Dynamics AX\60\Server\MicrosoftDynamicsAX\Bin\AxServ32.exe"
         
        This will get the file version details for the AX AOS executable (AxServ32.exe).
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
        Inspired by https://blogs.technet.microsoft.com/askpfeplat/2014/12/07/how-to-correctly-check-file-versions-with-powershell/
         
#>

function Get-FileVersion {
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $true)]
        [string] $Path
    )

    if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }

    Write-PSFMessage -Level Verbose -Message "Extracting the file properties for: $Path" -Target $Path
    
    $Filepath = Get-Item -Path $Path

    [PSCustomObject]@{
        FileVersion           = $Filepath.VersionInfo.FileVersion
        ProductVersion        = $Filepath.VersionInfo.ProductVersion
        FileVersionUpdated    = "$($Filepath.VersionInfo.FileMajorPart).$($Filepath.VersionInfo.FileMinorPart).$($Filepath.VersionInfo.FileBuildPart).$($Filepath.VersionInfo.FilePrivatePart)"
        ProductVersionUpdated = "$($Filepath.VersionInfo.ProductMajorPart).$($Filepath.VersionInfo.ProductMinorPart).$($Filepath.VersionInfo.ProductBuildPart).$($Filepath.VersionInfo.ProductPrivatePart)"
    }
}


<#
    .SYNOPSIS
        Get the identity provider
         
    .DESCRIPTION
        Execute a web request to get the identity provider for the given email address
         
    .PARAMETER Email
        Email address on the account that you want to get the Identity Provider details about
         
    .EXAMPLE
        PS C:\> Get-IdentityProvider -Email "Claire@contoso.com"
         
        This will get the Identity Provider details for the user account with the email address "Claire@contoso.com"
         
    .NOTES
        Author : Rasmus Andersen (@ITRasmus)
        Author : M�tz Jensen (@splaxi)
         
#>

function Get-IdentityProvider {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 1)]
        [string]$Email
    )
    $tenant = Get-TenantFromEmail $Email

    try {
        $webRequest = New-WebRequest "https://login.windows.net/$tenant/.well-known/openid-configuration" $null "GET"

        $response = $WebRequest.GetResponse()

        if ($response.StatusCode -eq [System.Net.HttpStatusCode]::Ok) {

            $stream = $response.GetResponseStream()
    
            $streamReader = New-Object System.IO.StreamReader($stream);
        
            $openIdConfig = $streamReader.ReadToEnd()
            $streamReader.Close();
        }
        else {
            $statusDescription = $response.StatusDescription
            throw "Https status code : $statusDescription"
        }

        $openIdConfigJSON = ConvertFrom-Json $openIdConfig

        $openIdConfigJSON.issuer
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while executing the web request" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
}


<#
    .SYNOPSIS
        Get the instance provider from the D365FO instance
         
    .DESCRIPTION
        Get the instance provider from the dll files used for encryption and authentication for D365FO
         
    .EXAMPLE
        PS C:\> Get-InstanceIdentityProvider
         
        This will return the Instance Identity Provider based on the D365FO instance.
         
    .NOTES
        Author : Rasmus Andersen (@ITRasmus)
        Author : M�tz Jensen (@splaxi)
         
#>

function Get-InstanceIdentityProvider {
    [CmdletBinding()]
    [OutputType([System.String])]
    
    param()

    $files = @("$Script:AOSPath\bin\Microsoft.Dynamics.AX.Framework.EncryptionEngine.dll",
        "$Script:AOSPath\bin\Microsoft.Dynamics.AX.Security.AuthenticationCommon.dll")

    if (-not (Test-PathExists -Path $files -Type Leaf)) {
        return
    }

    try {
        Add-Type -Path $files

        $Identity = [Microsoft.Dynamics.AX.Security.AuthenticationCommon.AadHelper]::GetIdentityProvider()
        
        Write-PSFMessage -Level Verbose -Message "The found instance identity provider is: $Identity" -Target $Identity

        $Identity
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the Identity provider" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
}


<#
    .SYNOPSIS
        Get the Azure Database instance values
         
    .DESCRIPTION
        Extract the PlanId, TenantId and PlanCapability from the Azure Database instance
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER TrustedConnection
        Should the connection use a Trusted Connection or not
         
    .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:\> Get-InstanceValues -DatabaseServer SQLServer -DatabaseName AXDB -SqlUser "SqlAdmin" -SqlPwd "Pass@word1"
         
        This will extract the PlanId, TenantId and PlanCapability from the AXDB on the SQLServer, using the "SqlAdmin" credentials to do so.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-InstanceValues {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    [OutputType('System.Collections.Hashtable')]
    param (
        [Parameter(Mandatory = $true)]
        [string] $DatabaseServer,

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

        [Parameter(Mandatory = $false)]
        [string] $SqlUser,

        [Parameter(Mandatory = $false)]
        [string] $SqlPwd,

        [Parameter(Mandatory = $false)]
        [boolean] $TrustedConnection,

        [switch] $EnableException
    )
        
    $sqlCommand = Get-SqlCommand @PsBoundParameters

    $commandText = (Get-Content "$script:ModuleRoot\internal\sql\get-instancevalues.sql") -join [Environment]::NewLine

    $sqlCommand.CommandText = $commandText

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()

        $reader = $sqlCommand.ExecuteReader()
        
        if ($reader.Read() -eq $true) {
            Write-PSFMessage -Level Verbose "Extracting details from the result retrieved from the DB instance"

            $tenantId = $reader.GetString(0)
            $planId = $reader.GetGuid(1)
            $planCapability = $reader.GetString(2)

            @{
                TenantId       = $tenantId
                PlanId         = $planId
                PlanCapability = $planCapability
            }
        }
        else {
            $messageString = "The query to detect <c='em'>TenantId</c>, <c='em'>PlanId</c> and <c='em'>PlanCapability</c> from the database <c='em'>failed</c>."
            Write-PSFMessage -Level Host -Message $messageString -Target (Get-SqlString $SqlCommand)
            Stop-PSFFunction -Message "Stopping because of missing parameters." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>','')))
            return
        }
    }
    catch {
        $messageString = "Something went wrong while working against the database."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_
        return
    }
    finally {
        $reader.close()
            
        $sqlCommand.Connection.Close()
        $sqlCommand.Dispose()
    }
}


<#
    .SYNOPSIS
        Get file from the Asset library inside the LCS project
         
    .DESCRIPTION
        Get the available files from the Asset Library in LCS project
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
    .PARAMETER FileType
        Type of file you want to upload
         
        Valid options:
        "Model"
        "Process Data Package"
        "Software Deployable Package"
        "GER Configuration"
        "Data Package"
        "PowerBI Report Model"
        "E-Commerce Package"
        "NuGet Package"
        "Retail Self-Service Package"
        "Commerce Cloud Scale Unit Extension"
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
    .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:\> Get-LcsAssetFile -ProjectId 123456789 -FileType SoftwareDeployablePackage -BearerToken "JldjfafLJdfjlfsalfd..." -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will get all software deployable packages from the Asset Library inside LCS.
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The FileType is Software Deployable Packages, with the FileType parameter.
        The request will authenticate with the BearerToken "JldjfafLJdfjlfsalfd...".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .NOTES
        Tags: Environment, LCS, Api, AAD, Token, Asset, File, Files
         
        Author: M�tz Jensen (@Splaxi)
#>


function Get-LcsAssetFile {
    [Cmdletbinding()]
    param(
        [Parameter(Mandatory = $true)]
        [int] $ProjectId,

        [LcsAssetFileType] $FileType,

        [Alias('Token')]
        [string] $BearerToken,
        
        [Parameter(Mandatory = $true)]
        [string] $LcsApiUri,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    Write-PSFMessage -Level Verbose -Message "Json payload for LCS generated." -Target $jsonFile
    
    $client = New-Object -TypeName System.Net.Http.HttpClient
    $client.DefaultRequestHeaders.Clear()
    $client.DefaultRequestHeaders.UserAgent.ParseAdd("d365fo.tools via PowerShell")
    
    $fileTypeValue = [int]$FileType
    $lcsRequestUri = "$LcsApiUri/box/fileasset/GetAssets/$($ProjectId)?fileType=$($fileTypeValue)"
    
    $request = New-JsonRequest -Uri $lcsRequestUri -Token $BearerToken -HttpMethod "GET"

    try {
        Write-PSFMessage -Level Verbose -Message "Invoke LCS request."
        $result = Get-AsyncResult -task $client.SendAsync($request)

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS."
        $responseString = Get-AsyncResult -task $result.Content.ReadAsStringAsync()

        try {
            $lcsResponseObject = ConvertFrom-Json -InputObject $responseString -ErrorAction SilentlyContinue
        }
        catch {
            Write-PSFMessage -Level Critical -Message "$responseString"
        }

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS." -Target $lcsResponseObject
        
        if (-not ($result.StatusCode -eq [System.Net.HttpStatusCode]::OK)) {
            if (($lcsResponseObject) -and ($lcsResponseObject.Message)) {
                $errorText = "Error $( $lcsResponseObject.Message) in request for listing all files from the asset library of LCS: '$( $lcsResponseObject.Message)'"
            }
            else {
                $errorText = "API Call returned $($result.StatusCode): $($result.ReasonPhrase)"
            }

            Write-PSFMessage -Level Host -Message "Error listing bacpacs and backups from asset library." -Target $($lcsResponseObject.Message)
            Write-PSFMessage -Level Host -Message $errorText -Target $($result.ReasonPhrase)
            Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the LCS API." -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        return
    }

    Invoke-TimeSignal -End
    
    $lcsResponseObject
}


<#
    .SYNOPSIS
        Get the validation status from LCS
         
    .DESCRIPTION
        Get the validation status for a given file in the Asset Library in LCS
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
    .PARAMETER AssetId
        The unique id of the asset / file that you are trying to deploy from LCS
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
    .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:\> Get-LcsAssetValidationStatus -ProjectId 123456789 -BearerToken "JldjfafLJdfjlfsalfd..." -AssetId "958ae597-f089-4811-abbd-c1190917eaae" -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will check the file with the AssetId "958ae597-f089-4811-abbd-c1190917eaae" in validated or not.
        It will test against the Asset Library located under the LCS project 123456789.
        The BearerToken "JldjfafLJdfjlfsalfd..." is used to authenticate against the LCS API endpoint.
        The file will be named "ReadyForTesting" inside the Asset Library in LCS.
        The file is validated against the NON-EUROPE LCS API.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
#>


function Get-LcsAssetValidationStatus {
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $true, Position = 1)]
        [int] $ProjectId,
        
        [Parameter(Mandatory = $true, Position = 2)]
        [Alias('Token')]
        [string] $BearerToken,

        [Parameter(Mandatory = $true, Position = 3)]
        [string] $AssetId,

        [Parameter(Mandatory = $true, Position = 4)]
        [string] $LcsApiUri,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start
    
    $client = New-Object -TypeName System.Net.Http.HttpClient
    $client.DefaultRequestHeaders.Clear()
    $client.DefaultRequestHeaders.UserAgent.ParseAdd("d365fo.tools via PowerShell")
    
    $checkUri = "$LcsApiUri/box/fileasset/GetFileAssetValidationStatus/$($ProjectId)?assetId=$AssetId"

    $request = New-JsonRequest -Uri $checkUri -Token $BearerToken -HttpMethod "GET"

    try {
        Write-PSFMessage -Level Verbose -Message "Invoke LCS request." -Target $request
        $result = Get-AsyncResult -task $client.SendAsync($request)

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS."
        $responseString = Get-AsyncResult -task $result.Content.ReadAsStringAsync()

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS." -Target $responseString
        
        try {
            $asset = ConvertFrom-Json -InputObject $responseString -ErrorAction SilentlyContinue
        }
        catch {
            Write-PSFMessage -Level Critical -Message "$responseString"
        }

        Write-PSFMessage -Level Verbose -Message "Extracting the asset json response received from LCS." -Target $asset
        
        if (-not ($result.StatusCode -eq [System.Net.HttpStatusCode]::OK)) {
            if (($asset) -and ($asset.Message)) {
                Write-PSFMessage -Level Host -Message "Error getting the validation status of the file asset." -Target $($asset.Message)
                Stop-PSFFunction -Message "Stopping because of errors"
            }
            else {
                Write-PSFMessage -Level Host -Message "API Call returned $($result.StatusCode)." -Target $($result.ReasonPhrase)
                Stop-PSFFunction -Message "Stopping because of errors"
            }
        }

        if (-not ($asset.Id)) {
            if ($asset.Message) {
                Write-PSFMessage -Level Host -Message "Error getting the validation status of the file asset." -Target $($asset.Message)
                Stop-PSFFunction -Message "Stopping because of errors"
            }
            else {
                Write-PSFMessage -Level Host -Message "Unknown error getting the validation status of the file asset." -Target $asset
                Stop-PSFFunction -Message "Stopping because of errors"
            }
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the LCS API." -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }

    Invoke-TimeSignal -End

    $asset
}


<#
    .SYNOPSIS
        Get database backups from LCS project
         
    .DESCRIPTION
        Get the available database backups from the Asset Library in LCS project
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
    .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:\> Get-D365LcsDatabaseBackups -ProjectId 123456789 -BearerToken "JldjfafLJdfjlfsalfd..." -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will get all available database backups from the Asset Library inside LCS.
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The request will authenticate with the BearerToken "JldjfafLJdfjlfsalfd...".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .NOTES
        Tags: Environment, LCS, Api, AAD, Token, Bacpac, Backup
         
        Author: M�tz Jensen (@Splaxi)
#>


function Get-LcsDatabaseBackups {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [Cmdletbinding()]
    param(
        [Parameter(Mandatory = $true)]
        [int] $ProjectId,
    
        [Alias('Token')]
        [string] $BearerToken,
        
        [Parameter(Mandatory = $true)]
        [string] $LcsApiUri,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    Write-PSFMessage -Level Verbose -Message "Json payload for LCS generated." -Target $jsonFile
    
    $client = New-Object -TypeName System.Net.Http.HttpClient
    $client.DefaultRequestHeaders.Clear()
    $client.DefaultRequestHeaders.UserAgent.ParseAdd("d365fo.tools via PowerShell")
    
    $deployStatusUri = "$LcsApiUri/databasemovement/v1/databases/project/$($ProjectId)"
    
    $request = New-JsonRequest -Uri $deployStatusUri -Token $BearerToken -HttpMethod "GET"

    try {
        Write-PSFMessage -Level Verbose -Message "Invoke LCS request."
        $result = Get-AsyncResult -task $client.SendAsync($request)

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS."
        $responseString = Get-AsyncResult -task $result.Content.ReadAsStringAsync()

        try {
            $databasesObject = ConvertFrom-Json -InputObject $responseString -ErrorAction SilentlyContinue
        }
        catch {
            Write-PSFMessage -Level Critical -Message "$responseString"
        }

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS." -Target $databasesObject
        
        if (-not ($result.StatusCode -eq [System.Net.HttpStatusCode]::OK)) {
            if (($databasesObject) -and ($databasesObject.ErrorMessage)) {
                $errorText = ""
                if ($databasesObject.OperationActivityId) {
                    $errorText = "Error $( $databasesObject.ErrorMessage) in request for listing all bacpacs and backup from the asset library of LCS: '$( $databasesObject.ErrorMessage)' (Activity Id: '$( $databasesObject.OperationActivityId)')"
                }
                else {
                    $errorText = "Error $( $databasesObject.ErrorMessage) in request for listing all bacpacs and backup from the asset library of LCS: '$( $databasesObject.ErrorMessage)'"
                }
            }
            elseif ($databasesObject.OperationActivityId) {
                $errorText = "API Call returned $($result.StatusCode): $($result.ReasonPhrase) (Activity Id: '$($databasesObject.OperationActivityId)')"
            }
            else {
                $errorText = "API Call returned $($result.StatusCode): $($result.ReasonPhrase)"
            }

            Write-PSFMessage -Level Host -Message "Error listing bacpacs and backups from asset library." -Target $($databasesObject.ErrorMessage)
            Write-PSFMessage -Level Host -Message $errorText -Target $($result.ReasonPhrase)
            Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        }

        
        if (-not ( $databasesObject.IsSuccess)) {
            if ( $databasesObject.ErrorMessage) {
                $errorText = "Error in request for listing all bacpacs and backup from the asset library of LCS: '$( $databasesObject.ErrorMessage)' (Activity Id: '$( $databasesObject.OperationActivityId)')"

            }
            elseif ( $databasesObject.OperationActivityId) {
                $errorText = "Error in request for listing all bacpacs and backup from the asset library of LCS. Activity Id: '$($activity.OperationActivityId)'"
            }
            else {
                $errorText = "Unknown error in request for listing all bacpacs and backup from the asset library of LCS"
            }

            Write-PSFMessage -Level Host -Message "Unknown error while listing all bacpacs and backups from asset library." -Target $databasesObject
            Write-PSFMessage -Level Host -Message $errorText -Target $($result.ReasonPhrase)
            Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the LCS API." -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        return
    }

    Invoke-TimeSignal -End
    
    $databasesObject
}


<#
    .SYNOPSIS
        Get the status of a LCS database operation
         
    .DESCRIPTION
        Get the database operation status for an environment in LCS
         
    .PARAMETER Token
        The token to be used for the http request against the LCS API
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
    .PARAMETER OperationActivityId
        The unique id of the action you got from when starting the database operation against the environment
         
    .PARAMETER EnvironmentId
        The unique id of the environment that you want to work against
         
        The Id can be located inside the LCS portal
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
    .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:\> Get-LcsDatabaseOperationStatus -ProjectId 123456789 -OperationActivityId 123456789 -EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e" -Token "JldjfafLJdfjlfsalfd..." -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will check the database operation status of a specific OperationActivityId against an environment.
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The OperationActivityId is identified by the OperationActivityId 123456789, which is obtained from executing either the Invoke-D365LcsDatabaseExport or Invoke-D365LcsDatabaseRefresh cmdlets.
        The environment is identified by the EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
        The request will authenticate with the BearerToken "JldjfafLJdfjlfsalfd...".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .LINK
        Start-LcsDatabaseRefresh
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, LCS, Upload, Api, AAD, Token, Deployment, Deployable Package
         
        Author: M�tz Jensen (@Splaxi)
#>


function Get-LcsDatabaseOperationStatus {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Cmdletbinding()]
    param(
        [Parameter(Mandatory = $true)]
        [int] $ProjectId,
    
        [Alias('Token')]
        [string] $BearerToken,

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

        [Parameter(Mandatory = $true)]
        [string] $EnvironmentId,
        
        [Parameter(Mandatory = $true)]
        [string] $LcsApiUri,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    Write-PSFMessage -Level Verbose -Message "Json payload for LCS generated." -Target $jsonFile
    
    $client = New-Object -TypeName System.Net.Http.HttpClient
    $client.DefaultRequestHeaders.Clear()
    $client.DefaultRequestHeaders.UserAgent.ParseAdd("d365fo.tools via PowerShell")
    
    $databaseOperationStatusUri = "$LcsApiUri/databasemovement/v1/fetchstatus/project/$($ProjectId)/environment/$($EnvironmentId)/operationactivity/$($OperationActivityId)"
    
    $request = New-JsonRequest -Uri $databaseOperationStatusUri -Token $BearerToken -HttpMethod "GET"

    try {
        Write-PSFMessage -Level Verbose -Message "Invoke LCS request."
        $result = Get-AsyncResult -task $client.SendAsync($request)

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS."
        $responseString = Get-AsyncResult -task $result.Content.ReadAsStringAsync()

        try {
            $operationStatus = ConvertFrom-Json -InputObject $responseString -ErrorAction SilentlyContinue
        }
        catch {
            Write-PSFMessage -Level Critical -Message "$responseString"
        }
    
        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS." -Target $operationStatus

        if (-not ($result.StatusCode -eq [System.Net.HttpStatusCode]::OK)) {
            if (($operationStatus) -and ($operationStatus.ErrorMessage)) {
                $errorText = ""
                if ($operationStatus.OperationActivityId) {
                    $errorText = "Error in request for database refresh status of environment: '$( $operationStatus.ErrorMessage)' (Activity Id: '$( $operationStatus.OperationActivityId)')"
                }
                else {
                    $errorText = "Error in request for database refresh status of environment: '$( $operationStatus.ErrorMessage)'"
                }
            }
            elseif ($operationStatus.OperationActivityId) {
                $errorText = "API Call returned $($result.StatusCode): $($result.ReasonPhrase) (Activity Id: '$($operationStatus.OperationActivityId)')"
            }
            else {
                $errorText = "API Call returned $($result.StatusCode): $($result.ReasonPhrase)"
            }

            Write-PSFMessage -Level Host -Message "Error getting database refresh status." -Target $($operationStatus.ErrorMessage)
            Write-PSFMessage -Level Host -Message $errorText -Target $($result.ReasonPhrase)
            Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        }

        
        if (-not ($operationStatus.IsSuccess)) {
            if ($operationStatus.ErrorMessage) {
                $errorText = "Error in request for database refresh status of environment: '$( $operationStatus.ErrorMessage)' (Activity Id: '$( $operationStatus.OperationActivityId)')"
            }
            elseif ( $operationStatus.OperationActivityId) {
                $errorText = "Error in request for database refresh status of environment. Activity Id: '$($activity.OperationActivityId)'"
            }
            else {
                $errorText = "Unknown error in request for database refresh status."
            }

            Write-PSFMessage -Level Host -Message "Unknown error requesting database refresh status." -Target $operationStatus
            Write-PSFMessage -Level Host -Message $errorText -Target $($result.ReasonPhrase)
            Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the LCS API." -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        return
    }

    Invoke-TimeSignal -End
    
    $operationStatus
}


<#
    .SYNOPSIS
        Get the status of a LCS deployment
         
    .DESCRIPTION
        Get the deployment status for an environment in LCS
         
    .PARAMETER Token
        The token to be used for the http request against the LCS API
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
    .PARAMETER ActivityId
        The unique id of the action you got from when starting the deployment to the environment
         
    .PARAMETER EnvironmentId
        The unique id of the environment that you want to work against
         
        The Id can be located inside the LCS portal
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
    .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:\> Get-LcslcsResponseObject -Token "Bearer JldjfafLJdfjlfsalfd..." -ProjectId 123456789 -ActivityId 123456789 -EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e" -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will start the deployment of the file located in the Asset Library with the AssetId "958ae597-f089-4811-abbd-c1190917eaae" in the LCS project with Id 123456789.
        The http request will be using the "Bearer JldjfafLJdfjlfsalfd..." token for authentication against the LCS API.
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .LINK
        Start-LcsDeployment
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, LCS, Upload, Api, AAD, Token, Deployment, Deployable Package
         
        Author: M�tz Jensen (@Splaxi)
#>


function Get-LcsDeploymentStatus {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Cmdletbinding()]
    param(
        [Parameter(Mandatory = $true)]
        [int] $ProjectId,
    
        [Alias('Token')]
        [string] $BearerToken,

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

        [Parameter(Mandatory = $true)]
        [string] $EnvironmentId,
        
        [Parameter(Mandatory = $true)]
        [string] $LcsApiUri,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    Write-PSFMessage -Level Verbose -Message "Json payload for LCS generated." -Target $jsonFile
    
    $client = New-Object -TypeName System.Net.Http.HttpClient
    $client.DefaultRequestHeaders.Clear()
    $client.DefaultRequestHeaders.UserAgent.ParseAdd("d365fo.tools via PowerShell")
    
    $lcsRequestUri = "$LcsApiUri/environment/v2/fetchstatus/project/$($ProjectId)/environment/$($EnvironmentId)/operationactivity/$($ActivityId)"

    $request = New-JsonRequest -Uri $lcsRequestUri -Token $BearerToken -HttpMethod "GET"

    try {
        Write-PSFMessage -Level Verbose -Message "Invoke LCS request."
        $result = Get-AsyncResult -task $client.SendAsync($request)

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS."
        $responseString = Get-AsyncResult -task $result.Content.ReadAsStringAsync()

        try {
            $lcsResponseObject = ConvertFrom-Json -InputObject $responseString -ErrorAction SilentlyContinue
        }
        catch {
            Write-PSFMessage -Level Critical -Message "$responseString"
        }

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS." -Target $lcsResponseObject
        
        #This IF block might be obsolute based on the V2 implementation
        if (-not ($result.StatusCode -eq [System.Net.HttpStatusCode]::OK)) {
            if ($lcsResponseObject) {
                $errorText = ""
                if ($lcsResponseObject.ActivityId) {
                    $errorText = "Error $( $lcsResponseObject.ErrorMessage) in request for status of environment servicing action: '$($lcsResponseObject.ErrorMessage)' (Activity Id: '$($lcsResponseObject.ActivityId)')"
                }
                else {
                    $errorText = "Error $( $lcsResponseObject.ErrorMessage) in request for status of environment servicing action: '$($lcsResponseObject.ErrorMessage)'"
                }
            }
            elseif ($lcsResponseObject.ActivityId) {
                $errorText = "API Call returned $($result.StatusCode): $($result.ReasonPhrase) (Activity Id: '$($lcsResponseObject.ActivityId)')"
            }
            else {
                $errorText = "API Call returned $($result.StatusCode): $($result.ReasonPhrase)"
            }

            Write-PSFMessage -Level Host -Message "Error fetching environment servicing status." -Target $($lcsResponseObject.Message)
            Write-PSFMessage -Level Host -Message $errorText -Target $($result.ReasonPhrase)
            Stop-PSFFunction -Message "Stopping because of errors"
        }

        if (-not ($lcsResponseObject.OperationStatus)) {
            if ($lcsResponseObject.Message) {
                $errorText = "Error in request for status of environment servicing action: '$($lcsResponseObject.Message)')"
            }
            elseif ($lcsResponseObject.ErrorMessage) {
                $errorText = "Error in request for status of environment servicing action: '$($lcsResponseObject.ErrorMessage)' (ActivityId: '$($lcsResponseObject.ActivityId)' - OperationActivityId: '$($lcsResponseObject.OperationActivityId)')"
            }
            elseif ($lcsResponseObject.OperationActivityId -or $lcsResponseObject.ActivityId) {
                $errorText = "Error in request for status of environment servicing action. (ActivityId: '$($lcsResponseObject.ActivityId)' - OperationActivityId: '$($lcsResponseObject.OperationActivityId)')"
            }
            else {
                $errorText = "Unknown error in request for status of environment servicing action"
            }

            Write-PSFMessage -Level Host -Message "Unknown error fetching environment servicing status." -Target $lcsResponseObject
            Write-PSFMessage -Level Host -Message $errorText -Target $($result.ReasonPhrase)
            Stop-PSFFunction -Message "Stopping because of errors"
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the LCS API." -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }

    Invoke-TimeSignal -End
    
    $lcsResponseObject
}


<#
    .SYNOPSIS
        Get the login name from the e-mail address
         
    .DESCRIPTION
        Extract the login name from the e-mail address by substring everything before the @ character
         
    .PARAMETER Email
        The e-mail address that you want to get the login name from
         
    .EXAMPLE
        PS C:\> Get-LoginFromEmail -Email Claire@contoso.com
         
        This will substring the e-mail address and return "Claire" as the result
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-LoginFromEmail {
    [CmdletBinding()]
    [OutputType('System.String')]
    param (
        [string]$Email
    )

    $email.Substring(0, $Email.LastIndexOf('@')).Trim()
}


<#
    .SYNOPSIS
        Get the network domain from the e-mail
         
    .DESCRIPTION
        Get the network domain provider (Azure) for the e-mail / user
         
    .PARAMETER Email
        The e-mail that you want to retrieve the provider for
         
    .EXAMPLE
        PS C:\> Get-NetworkDomain -Email "Claire@contoso.com"
         
        This will return the provider registered with the "Claire@contoso.com" e-mail address.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-NetworkDomain {
    [CmdletBinding()]
    [OutputType('System.String')]
    param(
        [Parameter(Mandatory = $true, Position = 1)]
        [string]$Email
    )

    $tenant = Get-TenantFromEmail $Email
    $provider = Get-InstanceIdentityProvider
    $canonicalIdentityProvider = Get-CanonicalIdentityProvider

    if ($Provider.ToLower().Contains($Tenant.ToLower()) -eq $True) {
        $canonicalIdentityProvider
    }
    else {
        "$canonicalIdentityProvider$Tenant"
    }
}


<#
    .SYNOPSIS
        Get the product information
         
    .DESCRIPTION
        Get the product information object from the environment
         
    .EXAMPLE
        PS C:\> Get-ProductInfoProvider
         
        This will get the product information object and return it
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-ProductInfoProvider {
    Add-Type -Path "$Script:AOSPath\bin\Microsoft.Dynamics.BusinessPlatform.ProductInformation.Provider.dll"

    [Microsoft.Dynamics.BusinessPlatform.ProductInformation.Provider.ProductInfoProvider]::get_Provider()
}


<#
    .SYNOPSIS
        Get the list of Dynamics 365 services
         
    .DESCRIPTION
        Get the list of Dynamics 365 service names based on the parameters
         
    .PARAMETER All
        Switch to instruct the cmdlet to output all service names
         
    .PARAMETER Aos
        Switch to instruct the cmdlet to output the aos service name
         
    .PARAMETER Batch
        Switch to instruct the cmdlet to output the batch service name
         
    .PARAMETER FinancialReporter
        Switch to instruct the cmdlet to output the financial reporter service name
         
    .PARAMETER DMF
        Switch to instruct the cmdlet to output the data management service name
         
    .EXAMPLE
        PS C:\> Get-ServiceList -All
         
        This will return all services for an D365 environment
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

Function Get-ServiceList {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidDefaultValueSwitchParameter", "")]
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 2 )]
        [switch] $All = $true,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 2 )]
        [switch] $Aos,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 3 )]
        [switch] $Batch,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 4 )]
        [switch] $FinancialReporter,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 5 )]
        [switch] $DMF
    )

    if ($PSCmdlet.ParameterSetName -eq "Specific") {
        $All = $false
    }

    Write-PSFMessage -Level Verbose -Message "The PSBoundParameters was" -Target $PSBoundParameters

    $aosname = "w3svc"
    $batchname = "DynamicsAxBatch"
    $financialname = "MR2012ProcessService"
    $dmfname = "Microsoft.Dynamics.AX.Framework.Tools.DMF.SSISHelperService.exe"

    [System.Collections.ArrayList]$Services = New-Object -TypeName "System.Collections.ArrayList"

    if ($All) {
        $null = $Services.AddRange(@($aosname, $batchname, $financialname, $dmfname))
    }
    else {
        if ($Aos) {
            $null = $Services.Add($aosname)
        }
        if ($Batch) {
            $null = $Services.Add($batchname)
        }
        if ($FinancialReporter) {
            $null = $Services.Add($financialname)
        }
        if ($DMF) {
            $null = $Services.Add($dmfname)
        }
    }

    $Services.ToArray()
}


<#
    .SYNOPSIS
        Get a SqlCommand object
         
    .DESCRIPTION
        Get a SqlCommand object initialized with the passed parameters
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER TrustedConnection
        Should the connection use a Trusted Connection or not
         
    .EXAMPLE
        PS C:\> Get-SqlCommand -DatabaseServer localhost -DatabaseName AxDB -SqlUser User123 -SqlPwd "Password123" -TrustedConnection $true
         
        This will initialize a new SqlCommand object (.NET type) with localhost as the server name, AxDB as the database and the User123 sql credentials.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-SQLCommand {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $DatabaseServer,

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

        [Parameter(Mandatory = $false)]
        [string] $SqlUser,

        [Parameter(Mandatory = $false)]
        [string] $SqlPwd,

        [Parameter(Mandatory = $false)]
        [boolean] $TrustedConnection
    )

    Write-PSFMessage -Level Debug -Message "Writing the bound parameters" -Target $PsBoundParameters
    [System.Collections.ArrayList]$Params = New-Object -TypeName "System.Collections.ArrayList"

    $null = $Params.Add("Server='$DatabaseServer';")
    $null = $Params.Add("Database='$DatabaseName';")

    if ($null -eq $TrustedConnection -or (-not $TrustedConnection)) {
        $null = $Params.Add("User='$SqlUser';")
        $null = $Params.Add("Password='$SqlPwd';")
    }
    else {
        $null = $Params.Add("Integrated Security='SSPI';")
    }

    $null = $Params.Add("Application Name='d365fo.tools'")
    
    Write-PSFMessage -Level Verbose -Message "Building the SQL connection string." -Target ($Params -join ",")
    $sqlConnection = New-Object System.Data.SqlClient.SqlConnection

    try {
        $sqlConnection.ConnectionString = ($Params -join "")

        $sqlCommand = New-Object System.Data.SqlClient.SqlCommand
        $sqlCommand.Connection = $sqlConnection
        $sqlCommand.CommandTimeout = 0
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working with the sql server connection objects" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
    
    $sqlCommand
}


<#
    .SYNOPSIS
        Get the size from the parameter
         
    .DESCRIPTION
        Get the size from the parameter based on its datatype and value
         
    .PARAMETER SqlParameter
        The SqlParameter object that you want to get the size from
         
    .EXAMPLE
        PS C:\> $SqlCmd = New-Object System.Data.SqlClient.SqlCommand
        PS C:\> $SqlCmd.Parameters.AddWithValue("@Parm1", "1234")
        PS C:\> Get-SqlParameterSize -SqlParameter $SqlCmd.Parameters[0]
         
        This will extract the size from the first parameter from the SqlCommand object and return it as a formatted string.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-SqlParameterSize {
    [CmdletBinding()]
    [OutputType('System.String')]
    param (
        [System.Data.SqlClient.SqlParameter] $SqlParameter
    )

    $res = ""

    $stringSizeTypes = @(
        [System.Data.SqlDbType]::Char,
        [System.Data.SqlDbType]::NChar,
        [System.Data.SqlDbType]::NText,
        [System.Data.SqlDbType]::NVarChar,
        [System.Data.SqlDbType]::Text,
        [System.Data.SqlDbType]::VarChar
    )

    if ( $stringSizeTypes -contains $SqlParameter.SqlDbType) {
        $res = "($($SqlParameter.Size))"
    }

    $res
}


<#
    .SYNOPSIS
        Get the value from the parameter
         
    .DESCRIPTION
        Get the value that is assigned to the SqlParameter object
         
    .PARAMETER SqlParameter
        The SqlParameter object that you want to work against
         
    .EXAMPLE
        PS C:\> $SqlCmd = New-Object System.Data.SqlClient.SqlCommand
        PS C:\> $SqlCmd.Parameters.AddWithValue("@Parm1", "1234")
        PS C:\> Get-SqlParameterValue -SqlParameter $SqlCmd.Parameters[0]
         
        This will extract the value from the first parameter from the SqlCommand object.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-SqlParameterValue {
    [CmdletBinding()]
    [OutputType('System.String')]
    param (
        [System.Data.SqlClient.SqlParameter] $SqlParameter
    )

    $result = $null

    $stringEscaped = @(
        [System.Data.SqlDbType]::Char,
        [System.Data.SqlDbType]::DateTime,
        [System.Data.SqlDbType]::NChar,
        [System.Data.SqlDbType]::NText,
        [System.Data.SqlDbType]::NVarChar,
        [System.Data.SqlDbType]::Text,
        [System.Data.SqlDbType]::VarChar,
        [System.Data.SqlDbType]::Xml,
        [System.Data.SqlDbType]::Date,
        [System.Data.SqlDbType]::Time,
        [System.Data.SqlDbType]::DateTime2,
        [System.Data.SqlDbType]::DateTimeOffset
    )
    
    $stringNumbers = @([System.Data.SqlDbType]::Float, [System.Data.SqlDbType]::Decimal)
    
    switch ($SqlParameter.SqlDbType) {
        { $stringEscaped -contains $_ } {
            $result = "'{0}'" -f $SqlParameter.Value.ToString().Replace("'", "''")
            break
        }

        { [System.Data.SqlDbType]::Bit } {
            if ((ConvertTo-BooleanOrDefault -Object $SqlParameter.Value.ToString() -Default $true)) {
                $result = '1'
            }
            else {
                $result = '0'
            }
                        
            break
        }
        
        { $stringNumbers -contains $_ } {
            $SqlParameter.Value
            $result = ([System.Double]$SqlParameter.Value).ToString([System.Globalization.CultureInfo]::InvariantCulture).Replace("'", "''")
            break
        }

        default {
            $result = $SqlParameter.Value.ToString().Replace("'", "''")
            break
        }
    }

    $result
}


<#
    .SYNOPSIS
        Get an executable string from a SqlCommand object
         
    .DESCRIPTION
        Get an formatted and valid string from a SqlCommand object that contains all variables
         
    .PARAMETER SqlCommand
        The SqlCommand object that you want to retrieve the string from
         
    .EXAMPLE
        PS C:\> $SqlCmd = New-Object System.Data.SqlClient.SqlCommand
        PS C:\> $SqlCmd.CommandText = "SELECT * FROM Table WHERE Column = @Parm1"
        PS C:\> $SqlCmd.Parameters.AddWithValue("@Parm1", "1234")
        PS C:\> Get-SqlString -SqlCommand $SqlCmd
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-SqlString {
    [CmdletBinding()]
    [OutputType('System.String')]
    param (
        [System.Data.SqlClient.SqlCommand] $SqlCommand
    )

    $sbDeclare = [System.Text.StringBuilder]::new()
    $sbAssignment = [System.Text.StringBuilder]::new()
    $sbRes = [System.Text.StringBuilder]::new()

    if ($SqlCommand.CommandType -eq [System.Data.CommandType]::Text) {
        if (-not ($null -eq $SqlCommand.Connection)) {
            $null = $sbDeclare.Append("USE [").Append($SqlCommand.Connection.Database).AppendLine("]")
        }

        foreach ($parameter in $SqlCommand.Parameters) {
            if ($parameter.Direction -eq [System.Data.ParameterDirection]::Input) {
                $null = $sbDeclare.Append("DECLARE ").Append($parameter.ParameterName).Append("`t")
                $null = $sbDeclare.Append($parameter.SqlDbType.ToString().ToUpper())
                $null = $sbDeclare.AppendLine((Get-SqlParameterSize -SqlParameter $parameter))

                $null = $sbAssignment.Append("SET ").Append($parameter.ParameterName).Append(" = ").AppendLine((Get-SqlParameterValue -SqlParameter $parameter))
            }
        }
        
        $null = $sbRes.AppendLine($sbDeclare.ToString())
        $null = $sbRes.AppendLine($sbAssignment.ToString())
        $null = $sbRes.AppendLine($SqlCommand.CommandText)
    }

    $sbRes.ToString()
}


<#
    .SYNOPSIS
        Retrieve sync base and extension elements based on a modulename
         
    .DESCRIPTION
        Retrieve the list of installed packages / modules where the name fits the ModuleName parameter.
        For every model retrieved: collect all base sync and extension sync elements.
         
    .PARAMETER ModuleName
        Name of the module that you are looking for
         
        Accepts wildcards for searching. E.g. -Name "Application*Adaptor"
         
        Default value is "*" which will search for all modules
         
    .EXAMPLE
        PS C:\> Get-SyncElements -ModuleName "Application*Adaptor"
         
        Retrieve the list of installed packages / modules where the name fits the search "Application*Adaptor".
        For every model retrieved: collect all base sync and extension sync elements.
         
    .NOTES
        Tags: Database
         
        Author: Jasper Callens - Cegeka
         
#>

function Get-SyncElements {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string] $ModuleName
    )

    begin {
        $assemblies2Process = New-Object -TypeName "System.Collections.ArrayList"
                
        $null = $assemblies2Process.Add((Join-Path $BinDirTools "Microsoft.Dynamics.AX.Metadata.dll"))
        $null = $assemblies2Process.Add((Join-Path $BinDirTools "Microsoft.Dynamics.AX.Metadata.Core.dll"))
        $null = $assemblies2Process.Add((Join-Path $BinDirTools "Microsoft.Dynamics.AX.Metadata.Storage.dll"))
        $null = $assemblies2Process.Add((Join-Path $BinDirTools "Microsoft.Dynamics.ApplicationPlatform.XppServices.Instrumentation.dll"))
        $null = $assemblies2Process.Add((Join-Path $BinDirTools "Microsoft.Dynamics.AX.Metadata.Management.Delta.dll"))
        $null = $assemblies2Process.Add((Join-Path $BinDirTools "Microsoft.Dynamics.AX.Metadata.Management.Core.dll"))
        $null = $assemblies2Process.Add((Join-Path $BinDirTools "Microsoft.Dynamics.AX.Metadata.Management.Merge.dll"))
        $null = $assemblies2Process.Add((Join-Path $BinDirTools "Microsoft.Dynamics.AX.Metadata.Management.Diff.dll"))

        Import-AssemblyFileIntoMemory -Path $($assemblies2Process.ToArray())

        $diskMetadataProvider = (New-Object Microsoft.Dynamics.AX.Metadata.Storage.MetadataProviderFactory).CreateDiskProvider($Script:PackageDirectory)

        $baseSyncElements = New-Object -TypeName "System.Collections.ArrayList"
        $extensionSyncElements = New-Object -TypeName "System.Collections.ArrayList"

        $extensionToBaseSyncElements = New-Object -TypeName "System.Collections.ArrayList"
    }

    process {
        Write-PSFMessage -Level Debug -Message "Collecting $ModuleName AOT elements to sync"

        $baseSyncElements.AddRange($diskMetadataProvider.Tables.ListObjects($ModuleName));
        $baseSyncElements.AddRange($diskMetadataProvider.Views.ListObjects($ModuleName));
        $baseSyncElements.AddRange($diskMetadataProvider.DataEntityViews.ListObjects($ModuleName));

        $extensionSyncElements.AddRange($diskMetadataProvider.TableExtensions.ListObjects($ModuleName));

        # Some Extension elements have to be 'converted' to their base element that has to be passed to the SyncList of the syncengine
        # Add these elements to an ArrayList
        $extensionToBaseSyncElements.AddRange($diskMetadataProvider.ViewExtensions.ListObjects($ModuleName));
        $extensionToBaseSyncElements.AddRange($diskMetadataProvider.DataEntityViewExtensions.ListObjects($ModuleName));
    }

    end {
        # Loop every extension element, convert it to its base element and add the base element to another list
        Foreach ($extElement in $extensionToBaseSyncElements) {
            $null = $baseSyncElements.Add($extElement.Substring(0, $extElement.IndexOf('.')))
        }

        Write-PSFMessage -Level Debug -Message "Elements from $ModuleName retrieved: $(($baseSyncElements + $extensionToBaseSyncElements) -join ",")"

        [PSCustomObject]@{
            BaseSyncElements = $baseSyncElements.ToArray();
            ExtensionSyncElements = $extensionSyncElements.ToArray();
        }
    }
}


<#
    .SYNOPSIS
        Get the tenant from e-mail address
         
    .DESCRIPTION
        Get the tenant (domain) from an e-mail address
         
    .PARAMETER Email
        The e-mail address you want to get the tenant from
         
    .EXAMPLE
        PS C:\> Get-TenantFromEmail -Email "Claire@contoso.com"
         
        This will return the tenant (domain) from the "Claire@contoso.com" e-mail address.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-TenantFromEmail {
    [CmdletBinding()]
    [OutputType('System.String')]
    param (
        [string] $email
    )

    $email.Substring($email.LastIndexOf('@') + 1).Trim();
}


<#
    .SYNOPSIS
        Get time zone
         
    .DESCRIPTION
        Extract the time zone object from the supplied parameter
         
        Uses regex to determine whether or not the parameter is the ID or the DisplayName of a time zone
         
    .PARAMETER InputObject
        String value that you want converted into a time zone object
         
    .EXAMPLE
        PS C:\> Get-TimeZone -InputObject "UTC"
         
        This will return the time zone object based on the UTC id.
         
    .NOTES
        Tag: Time, TimeZone,
         
        Author: M�tz Jensen (@Splaxi)
#>


function Get-TimeZone {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidOverwritingBuiltInCmdlets", "")]
    [CmdletBinding()]
    [OutputType('System.TimeZoneInfo')]
    param (
        [Parameter(Mandatory = $true, Position = 1)]
        [string] $InputObject
    )

    if ($InputObject -match "\s\-\s\[") {
        $search = [regex]::Split($InputObject, "\s\-\s\[")[0]

        [System.TimeZoneInfo]::GetSystemTimeZones() | Where-Object {$PSItem.DisplayName -eq $search} | Select-Object -First 1
    }
    else {
        try {
            [System.TimeZoneInfo]::FindSystemTimeZoneById($InputObject)
        }
        catch {
            Write-PSFMessage -Level Host -Message "Unable to translate the <c='em'>$InputObject</c> to a known .NET timezone value. Please make sure you filled in a valid timezone."
            Stop-PSFFunction -Message "Stopping because timezone wasn't found." -StepsUpward 1
            return
        }
    }
}


<#
    .SYNOPSIS
        Get the SID from an Azure Active Directory (AAD) user
         
    .DESCRIPTION
        Get the generated SID that an Azure Active Directory (AAD) user will get in relation to Dynamics 365 Finance & Operations environment
         
    .PARAMETER SignInName
        The sign in name (email address) for the user that you want the SID from
         
    .PARAMETER Provider
        The provider connected to the sign in name
         
    .EXAMPLE
        PS C:\> Get-UserSIDFromAad -SignInName "Claire@contoso.com" -Provider "ZXY"
         
        This will get the SID for Azure Active Directory user "Claire@contoso.com"
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-UserSIDFromAad {
    [CmdletBinding()]
    [OutputType('System.String')]
    param     (
        [string] $SignInName,
        
        [string] $Provider
    )

    try {

        $productDetails = Get-ProductInfoProvider

        Add-Type -Path "$Script:AOSPath\bin\Microsoft.Dynamics.BusinessPlatform.SharedTypes.dll"
        Add-Type -Path "$Script:AOSPath\bin\Microsoft.Dynamics.ApplicationPlatform.PerformanceCounters.dll"
        Add-Type -Path "$Script:AOSPath\bin\Microsoft.Dynamics.ApplicationPlatform.XppServices.Instrumentation.dll"
        Add-Type -Path "$Script:AOSPath\bin\Microsoft.Dynamics.AX.Security.SidGenerator.dll"

        if ($([Version]$productDetails.ApplicationVersion) -ge $([Version]"10.0.13")) {
            $SID = [Microsoft.Dynamics.Ax.Security.SidGenerator]::Generate($SignInName, $Provider, [Microsoft.Dynamics.Ax.Security.SidGenerator+SidAlgorithm]::Sha1)
        }
        else {
            $SID = [Microsoft.Dynamics.Ax.Security.SidGenerator]::Generate($SignInName, $Provider)
        }
        
        Write-PSFMessage -Level Verbose -Message "Generated SID: $SID" -Target $SID

        $SID

    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
}


<#
    .SYNOPSIS
        Get Windows Defender Status
         
    .DESCRIPTION
        Will get the current status of the Windows Defender
         
    .PARAMETER Silent
        Instruct the cmdlet to silence the output written to the console
         
        If set the output will be silenced, if not set, the output will be written to the console
         
    .EXAMPLE
        PS C:\> Get-WindowsDefenderStatus
         
        This will get the status of Windows Defender.
        It will write the output to the console.
         
    .EXAMPLE
        PS C:\> Get-WindowsDefenderStatus -Silent
         
        This will get the status of Windows Defender.
        All outputs will be silenced.
         
    .NOTES
        Inspired by https://gallery.technet.microsoft.com/scriptcenter/PowerShell-to-Check-if-811b83bc
         
        Author: Robin Kretzschmar (@darksmile92)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-WindowsDefenderStatus {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    [OutputType('System.Boolean')]
    param (
        [switch] $Silent
    )
    try {
        $defenderOptions = Get-MpComputerStatus
     
        if ([string]::IsNullOrEmpty($defenderOptions)) {
            if ($Silent -eq $false) {
                Write-PSFMessage -Level Host -Message "Windows Defender was not found running on the Server: $($env:computername)"
            }

            $false
        }
        else {
            if ($Silent -eq $false) {
                Write-PSFHostColor -DefaultColor "Cyan" -String "Windows Defender was found on the Server: $($env:computername)"
                Write-PSFHostColor -DefaultColor "Yellow" -String " Is Windows Defender Enabled? $($defenderOptions.AntivirusEnabled)"
                Write-PSFHostColor -DefaultColor "Yellow" -String " Is Windows Defender Service Enabled? $($defenderOptions.AMServiceEnabled)"
                Write-PSFHostColor -DefaultColor "Yellow" -String " Is Windows Defender Antispyware Enabled? $($defenderOptions.AntispywareEnabled)"
                Write-PSFHostColor -DefaultColor "Yellow" -String " Is Windows Defender OnAccessProtection Enabled? $($defenderOptions.OnAccessProtectionEnabled)"
                Write-PSFHostColor -DefaultColor "Yellow" -String " Is Windows Defender RealTimeProtection Enabled? $($defenderOptions.RealTimeProtectionEnabled)"
            }
            if ($defenderOptions.AntivirusEnabled -eq $true) {
                $true
            }
            else {
                $false
            }
        }
    }
    catch {
        if ($Silent -eq $false) {
            Write-PSFMessage -Level Host -Message "Windows Defender was not found running on the Server: $($env:computername)"
        }

        $false
    }
}


<#
    .SYNOPSIS
        Import an Azure Active Directory (AAD) application
         
    .DESCRIPTION
        Import an Azure Active Directory (AAD) application into a Dynamics 365 for Finance & Operations environment
         
    .PARAMETER SqlCommand
        The SQL Command object that should be used when importing the AAD application
         
    .PARAMETER Name
        The name that the imported application should have inside the D365FO environment
         
    .PARAMETER UserId
        The id of the user linked to the application inside the D365FO environment
         
    .PARAMETER ClientId
        The Client ID that the imported application should use inside the D365FO environment
         
    .EXAMPLE
        PS C:\> $SqlCommand = Get-SqlCommand -DatabaseServer localhost -DatabaseName AxDB -SqlUser User123 -SqlPwd "Password123"
        PS C:\> Import-AadApplicationIntoD365FO -SqlCommand $SqlCommand -Name "Application1" -UserId "admin" -ClientId "aef2e67c-64a3-4c72-9294-d288c5bf503d"
        This will get a SqlCommand object that will connect to the localhost server and the AXDB database, with the sql credential "User123".
        The SqlCommand object is passed to the Import-AadApplicationIntoD365FO along with all the necessary details for importing Application1 as an application linked to user admin into the D365FO environment.
         
    .NOTES
        Author: Gert Van Der Heyden (@gertvdheyden)
         
#>

function Import-AadApplicationIntoD365FO {
    [CmdletBinding()]
    param
    (
        [System.Data.SqlClient.SqlCommand] $SqlCommand,

        [string] $Name,

        [string] $UserId,

        [string] $ClientId
    )

    Write-PSFMessage -Level Verbose -Message "Testing the userid $UserId"

    $idExists = Test-AadUserIdInD365FO $sqlCommand $UserId

    if ($idExists -eq $true) {

        New-D365FOAadApplication $sqlCommand $Name $UserId $ClientId

        Write-PSFMessage -Level Host -Message "Application $Name for user $UserId added to D365FO"
    }
    else {
        Write-PSFMessage -Level Host -Message "An User with ID = '$UserId' does not exists"
    }
}


<#
    .SYNOPSIS
        Import an Azure Active Directory (AAD) user
         
    .DESCRIPTION
        Import an Azure Active Directory (AAD) user into a Dynamics 365 for Finance & Operations environment
         
    .PARAMETER SqlCommand
        The SQL Command object that should be used when importing the AAD user
         
    .PARAMETER SignInName
        The sign in name (email address) for the user that you want to import
         
    .PARAMETER Name
        The name that the imported user should have inside the D365FO environment
         
    .PARAMETER Id
        The ID that the imported user should have inside the D365FO environment
         
    .PARAMETER SID
        The SID that correlates to the imported user inside the D365FO environment
         
    .PARAMETER StartUpCompany
        The default company (legal entity) for the imported user
         
    .PARAMETER IdentityProvider
        The provider for the imported to validated against
         
    .PARAMETER NetworkDomain
        The network domain of the imported user
         
    .PARAMETER ObjectId
        The Azure Active Directory object id for the imported user
         
    .PARAMETER Language
        Language that should be configured for the user, for when they sign-in to the D365 environment
         
    .EXAMPLE
        PS C:\> $SqlCommand = Get-SqlCommand -DatabaseServer localhost -DatabaseName AxDB -SqlUser User123 -SqlPwd "Password123"
        PS C:\> Import-AadUserIntoD365FO -SqlCommand $SqlCommand -SignInName "Claire@contoso.com" -Name "Claire" -Id "claire" -SID "123XYZ" -StartupCompany "DAT" -IdentityProvider "XYZ" -NetworkDomain "Contoso.com" -ObjectId "123XYZ"
        This will get a SqlCommand object that will connect to the localhost server and the AXDB database, with the sql credential "User123".
        The SqlCommand object is passed to the Import-AadUserIntoD365FO along with all the necessary details for importing Claire@contoso.com as an user into the D365FO environment.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Import-AadUserIntoD365FO {
    [CmdletBinding()]
    param
    (
        [System.Data.SqlClient.SqlCommand] $SqlCommand,

        [string] $SignInName,

        [string] $Name,

        [string] $Id,

        [string] $SID,

        [string] $StartUpCompany,

        [string] $IdentityProvider,

        [string] $NetworkDomain,

        [string] $ObjectId,

        [string] $Language
    )

    Write-PSFMessage -Level Verbose -Message "Testing the Email $signInName" -Target $signInName

    $UserFound = Test-AadUserInD365FO $sqlCommand $SignInName

    if ($UserFound -eq $false) {

        Write-PSFMessage -Level Verbose -Message "Testing the userid $Id" -Target $Id

        $idTaken = Test-AadUserIdInD365FO $sqlCommand $id

        if (Test-PSFFunctionInterrupt) { return }

        if ($idTaken -eq $false) {

            $userAdded = New-D365FOUser $sqlCommand $SignInName $Name $Id $Sid $StartUpCompany $IdentityProvider $NetworkDomain $ObjectId $Language

            if ($userAdded -eq $true) {

                $securityAdded = Add-AadUserSecurity $sqlCommand $Id

                Write-PSFMessage -Level Host -Message "User $SignInName Imported"

                if ($securityAdded -eq $false) {
                    Write-PSFMessage -Level Host -Message "User $SignInName did not get securityRoles"
                    #Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
                    #return
                }
            }
            else {
                Write-PSFMessage -Level Host -Message "User $SignInName, not added to D365FO"
                #Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
                #return
            }
        }
        else {
            Write-PSFMessage -Level Host -Message "An User with ID = '$ID' already exists"
            #Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
            #return
        }

    }
    else {
        Write-PSFMessage -Level Host -Message "An User with Email $SignInName already exists in D365FO"
        #Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        #return
    }
}


<#
    .SYNOPSIS
        Imports a .NET dll file into memory
         
    .DESCRIPTION
        Imports a .NET dll file into memory, by creating a copy (temporary file) and imports it using reflection
         
    .PARAMETER Path
        Path to the dll file you want to import
         
        Accepts an array of strings
         
    .EXAMPLE
        PS C:\> Import-AssemblyFileIntoMemory -Path "C:\AOSService\PackagesLocalDirectory\Bin\Microsoft.Dynamics.BusinessPlatform.ProductInformation.Framework.dll"
         
        This will create an new file named "C:\AOSService\PackagesLocalDirectory\Bin\Microsoft.Dynamics.BusinessPlatform.ProductInformation.Framework.dll_shawdow.dll"
        The new file is then imported into memory using .NET Reflection.
        After the file has been imported, it will be deleted from disk.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>


function Import-AssemblyFileIntoMemory {
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $true, Position = 1)]
        [string[]] $Path
    )

    if (-not (Test-PathExists -Path $Path -Type Leaf)) {
        Stop-PSFFunction -Message "Stopping because unable to locate file." -StepsUpward 1
        return
    }

    Invoke-TimeSignal -Start

    foreach ($itemPath in $Path) {

        $shadowClonePath = "$itemPath`_shadow.dll"

        try {
            Write-PSFMessage -Level Verbose -Message "Cloning $itemPath to $shadowClonePath"
            Copy-Item -Path $itemPath -Destination $shadowClonePath -Force
    
            Write-PSFMessage -Level Verbose -Message "Loading $shadowClonePath into memory"
            $null = [AppDomain]::CurrentDomain.Load(([System.IO.File]::ReadAllBytes($shadowClonePath)))
        }
        catch {
            Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }
        finally {
            Write-PSFMessage -Level Verbose -Message "Removing $shadowClonePath"
            Remove-Item -Path $shadowClonePath -Force -ErrorAction SilentlyContinue
        }
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Create a database copy in Azure SQL Database instance
         
    .DESCRIPTION
        Create a new database by cloning a database in Azure SQL Database instance
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER NewDatabaseName
        Name of the new / cloned database in the Azure SQL Database instance
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-AzureBackupRestore -DatabaseServer TestServer.database.windows.net -DatabaseName AxDB -SqlUser User123 -SqlPwd "Password123" -NewDatabaseName ExportClone
         
        This will create a database named "ExportClone" in the "TestServer.database.windows.net" Azure SQL Database instance.
        It uses the SQL credential "User123" to preform the needed actions.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

Function Invoke-AzureBackupRestore {
    [CmdletBinding()]
    [OutputType('System.Boolean')]
    param (
        [Parameter(Mandatory = $true)]
        [string] $DatabaseServer,

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

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

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

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

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    $StartTime = Get-Date
    
    $SqlConParams = @{DatabaseServer = $DatabaseServer; SqlUser = $SqlUser; SqlPwd = $SqlPwd; TrustedConnection = $false}
    $sqlCommand = Get-SqlCommand @SqlConParams -DatabaseName $DatabaseName
    
    $commandText = (Get-Content "$script:ModuleRoot\internal\sql\newazuredbfromcopy.sql") -join [Environment]::NewLine
    
    $commandText = $commandText.Replace('@CurrentDatabase', $DatabaseName)
    $commandText = $commandText.Replace('@NewName', $NewDatabaseName)

    $sqlCommand.CommandText = $commandText

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)
        Write-PSFMessage -Level Verbose -Message "Starting the cloning process of the Azure DB." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()
        
        $null = $sqlCommand.ExecuteNonQuery()
    }
    catch {
        $messageString = "Something went wrong while <c='em'>cloning</c> the Azure DB database."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_ -StepsUpward 1
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
    }
   
    $sqlCommand = Get-SqlCommand @SqlConParams -DatabaseName "master"

    $commandText = (Get-Content "$script:ModuleRoot\internal\sql\checkfornewazuredb.sql") -join [Environment]::NewLine

    $sqlCommand.CommandText = $commandText

    $null = $sqlCommand.Parameters.Add("@NewName", $NewDatabaseName)
    $null = $sqlCommand.Parameters.Add("@Time", $StartTime)

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)
        Write-PSFMessage -Level Verbose -Message "Start to wait for the cloning process of the Azure DB to complete."

        $sqlCommand.Connection.Open()

        $operation_row_count = 0
        #Loop every minute until we get a row, if we get a row copy is done
        while ($operation_row_count -eq 0) {
            $Reader = $sqlCommand.ExecuteReader()
            $Datatable = New-Object System.Data.DataTable
            $Datatable.Load($Reader)
            $operation_row_count = $Datatable.Rows.Count
            $time = (Get-Date).ToString("HH:mm:ss")
            Write-PSFMessage -Level Verbose -Message "Cloning not complete Sleeping for 60 seconds. [$time]"
            Start-Sleep -s 60
        }

        $true
    }
    catch {
        $messageString = "Something went wrong while <c='em'>waiting</c> for the clone process of the Azure DB database to complete."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_ -StepsUpward 1
        return
    }
    finally {
        $Reader.close()

        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
        $Datatable.Dispose()
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Clear Azure SQL Database specific objects
         
    .DESCRIPTION
        Clears all the objects that can only exists inside an Azure SQL Database instance or disable things that will require rebuilding on the receiving system
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-ClearAzureSpecificObjects -DatabaseServer TestServer.database.windows.net -DatabaseName ExportClone -SqlUser User123 -SqlPwd "Password123"
         
        This will execute all necessary scripts against the "ExportClone" database that exists in the "TestServer.database.windows.net" Azure SQL Database instance.
        It uses the SQL credential "User123" to preform the needed actions.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

Function Invoke-ClearAzureSpecificObjects {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param (
        [Parameter(Mandatory = $true)]
        [string] $DatabaseServer,

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

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

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

        [switch] $EnableException
    )
        
    $sqlCommand = Get-SQLCommand @PsBoundParameters -TrustedConnection $false

    $commandText = (Get-Content "$script:ModuleRoot\internal\sql\clear-azurebacpacdatabase.sql") -join [Environment]::NewLine

    $commandText = $commandText.Replace("@NewDatabase", $DatabaseName)
    
    $sqlCommand.CommandText = $commandText

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()

        $null = $sqlCommand.ExecuteNonQuery()

        $true
    }
    catch {
        $messageString = "Something went wrong while <c='em'>clearing</c> the <c='em'>Azure</c> specific objects in the database."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_ -StepsUpward 1
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
    }
}


<#
    .SYNOPSIS
        Clear SQL Server (on-premises) specific objects
         
    .DESCRIPTION
        Clears all the objects that can only exists inside a SQL Server (on-premises) instance or disable things that will require rebuilding on the receiving system
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER TrustedConnection
        Should the connection use a Trusted Connection or not
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-ClearSqlSpecificObjects -DatabaseServer localhost -DatabaseName ExportClone -SqlUser User123 -SqlPwd "Password123"
         
        This will execute all necessary scripts against the "ExportClone" database that exists in the localhost SQL Server instance.
        It uses the SQL credential "User123" to preform the needed actions.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

Function Invoke-ClearSqlSpecificObjects {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param (
        [Parameter(Mandatory = $true)]
        [string] $DatabaseServer,

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

        [Parameter(Mandatory = $false)]
        [string] $SqlUser,

        [Parameter(Mandatory = $false)]
        [string] $SqlPwd,
        
        [Parameter(Mandatory = $false)]
        [boolean] $TrustedConnection,

        [switch] $EnableException
    )
    
    $sqlCommand = Get-SQLCommand @PsBoundParameters

    $commandText = (Get-Content "$script:ModuleRoot\internal\sql\clear-sqlbacpacdatabase.sql") -join [Environment]::NewLine

    $sqlCommand.CommandText = $commandText

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()

        $null = $sqlCommand.ExecuteNonQuery()

        $true
    }
    catch {
        $messageString = "Something went wrong while <c='em'>clearing</c> the <c='em'>SQL</c> specific objects in the database."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_ -StepsUpward 1
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
    }
}


<#
    .SYNOPSIS
        Analyze the compiler output log
         
    .DESCRIPTION
        Analyze the compiler output log and generate an excel file contain worksheets per type: Errors, Warnings, Tasks
         
        It could be a Visual Studio compiler log or it could be a Invoke-D365ModuleCompile log you want analyzed
         
    .PARAMETER Path
        Path to the compiler log file that you want to work against
         
        A BuildModelResult.log or a Dynamics.AX.*.xppc.log file will both work
         
    .PARAMETER Identifier
        Identifier used to name the error output when hitting parsing errors
         
    .PARAMETER OutputPath
        Path where you want the excel file (xlsx-file) saved to
         
    .PARAMETER SkipWarnings
        Instructs the cmdlet to skip warnings while analyzing the compiler output log file
         
    .PARAMETER SkipTasks
        Instructs the cmdlet to skip tasks while analyzing the compiler output log file
         
    .PARAMETER PackageDirectory
        Path to the directory containing the installed package / module
         
    .EXAMPLE
        PS C:\> Invoke-CompilerResultAnalyzer -Path "c:\temp\d365fo.tools\Custom\Dynamics.AX.Custom.xppc.log" -Identifier "Custom" -OutputPath "C:\Temp\d365fo.tools\custom-CompilerResults.xslx" -PackageDirectory "J:\AOSService\PackagesLocalDirectory"
         
        This will analyze the compiler log file and generate a compiler result excel file.
         
    .NOTES
        Tags: Compiler, Build, Errors, Warnings, Tasks
         
        Author: M�tz Jensen (@Splaxi)
         
        This cmdlet is inspired by the work of "Vilmos Kintera" (twitter: @DAXRunBase)
         
        All credits goes to him for showing how to extract these information
         
        His blog can be found here:
        https://www.daxrunbase.com/blog/
         
        The specific blog post that we based this cmdlet on can be found here:
        https://www.daxrunbase.com/2020/03/31/interpreting-compiler-results-in-d365fo-using-powershell/
         
        The github repository containing the original scrips can be found here:
        https://github.com/DAXRunBase/PowerShell-and-Azure
#>

function Invoke-CompilerResultAnalyzer {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidAssignmentToAutomaticVariable", "")]
    [CmdletBinding()]
    [OutputType('')]
    param (
        [string] $Path,

        [string] $Identifier,

        [string] $OutputPath,

        [switch] $SkipWarnings,

        [switch] $SkipTasks,

        [string] $PackageDirectory
    )

    Invoke-TimeSignal -Start

    if (-not (Test-PathExists -Path $PackageDirectory -Type Container)) { return }

    $positionRegex = '(?=\[\().*(?=\)\])'
    $positionSplitRegex = '(.*)(?=\[\().*(?:\)\]: )(.*)'

    $warningRegex = '(?:Compile Fatal|MetadataProvider|Metadata|Compile|Unspecified|Generation|ExternalReference|BestPractices) (Warning): (Query Method|Interface Method|Form Method LocalFunction|Form Control Method|Form Datasource Method|Form DataSource Method|Form DataSource DataField Method|Form Method|Map Method|Class Delegate|Table Method LocalFunction|Class Method LocalFunction|Table Method|Class Method|Table|Class|View|Form|)(?: |)(?:dynamics:|)(.*)(?:: )(.*)'
    $taskRegex = '(TaskListItem Information): (Query Method|Interface Method|Form Method LocalFunction|Form Control Method|Form Datasource Method|Form DataSource Method|Form DataSource DataField Method|Form Method|Map Method|Class Delegate|Table Method LocalFunction|Class Method LocalFunction|Table Method|Class Method|Table|Class|View|Form|)(?: |)(?:dynamics:|)(.*)(?:: )(.*)'
    $errorRegex = '(?:Compile Fatal|MetadataProvider|Metadata|Compile|Unspecified|Generation) (Error): (Query Method|Interface Method|Form Method LocalFunction|Form Control Method|Form Datasource Method|Form DataSource Method|Form DataSource DataField Method|Form Method|Map Method|Class Delegate|Table Method LocalFunction|Class Method LocalFunction|Table Method|Class Method|Table|Class|View|Form|)(?: |)(?:dynamics:|)(.*)(?:: )(.*)'

    $warningObjects = New-Object System.Collections.Generic.List[System.Object]
    $errorObjects = New-Object System.Collections.Generic.List[System.Object]
    $taskObjects = New-Object System.Collections.Generic.List[System.Object]
    
    if (-not $SkipWarnings) {
        Write-PSFMessage -Level Verbose -Message "Will analyze for warnings in the log file." -Target $SkipWarnings

        try {
            $warningText = Select-String -LiteralPath $Path -Pattern '(^.*) Warning: (.*)' | ForEach-Object { $_.Line }
            
            # Skip modules that do not have warnings
            if ($warningText) {
                Write-PSFMessage -Level Verbose -Message "Found warning lines in the log file."

                foreach ($line in $warningText) {
                    $lineLocal = $line
                        
                    # Remove positioning text in the format of "[(5,5),(5,39)]: " for methods
                    if ($lineLocal -match $positionRegex) {
                        Write-PSFMessage -Level Verbose -Message "Position notation was found in the warning line. Will remove it."

                        $lineReplaced = [regex]::Split($lineLocal, $positionSplitRegex)
                        $lineLocal = $lineReplaced[1] + $lineReplaced[2]
                    }
    
                    try {
                        Write-PSFMessage -Level Verbose -Message "Will split the warning line, and create result object."
                        # Regular expression matching to split line details into groups
                        $Matches = [regex]::split($lineLocal, $warningRegex)
                        $object = [PSCustomObject]@{
                            OutputType = $Matches[1].trim()
                            ObjectType = $Matches[2].trim()
                            Path       = $Matches[3].trim()
                            Text       = $Matches[4].trim()
                        }

                        $warningObjects.Add($object)
                    }
                    catch {
                        Write-PSFHostColor -Level Host "<c='Yellow'>($Identifier) Error during processing line for warnings <</c><c='Red'>$line</c><c='Yellow'>></c>"
                    }
                }
            }
        }
        catch {
            Write-PSFMessage -Level Host "Error while processing warnings"
        }
    }

    if (-not $SkipTasks) {
        Write-PSFMessage -Level Verbose -Message "Will analyze for tasks in the log file." -Target $SkipTasks

        try {
            $taskText = Select-String -LiteralPath $Path -Pattern '(^.*)TaskListItem Information: (.*)' | ForEach-Object { $_.Line }

            # Skip modules that do not have tasks
            if ($taskText) {
                Write-PSFMessage -Level Verbose -Message "Found task lines in the log file."

                foreach ($line in $taskText) {
                    $lineLocal = $line
                        
                    # Remove positioning text in the format of "[(5,5),(5,39)]: " for methods
                    if ($lineLocal -match $positionRegex) {
                        Write-PSFMessage -Level Verbose -Message "Position notation was found in the task line. Will remove it."

                        $lineReplaced = [regex]::Split($lineLocal, $positionSplitRegex)
                        $lineLocal = $lineReplaced[1] + $lineReplaced[2]
                    }

                    # Remove TODO part
                    if ($lineLocal -match '(?:TODO :|TODO:|TODO)') {
                        Write-PSFMessage -Level Verbose -Message "TODO prefix string value was found in the line. Will remove it."

                        $lineReplaced = [regex]::Split($lineLocal, '(.*)(?:TODO :|TODO:|TODO)(.*)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
                        $lineLocal = $lineReplaced[1] + $lineReplaced[2]
                    }

                    try {
                        Write-PSFMessage -Level Verbose -Message "Will split the task line, and create result object."

                        # Regular expression matching to split line details into groups
                        $Matches = [regex]::split($lineLocal, $taskRegex)
                        $object = [PSCustomObject]@{
                            OutputType = $Matches[1].trim()
                            ObjectType = $Matches[2].trim()
                            Path       = $Matches[3].trim()
                            Text       = $Matches[4].trim()
                        }

                        $taskObjects.Add($object)
                    }
                    catch {
                        Write-PSFHostColor -Level Host "<c='Yellow'>($Identifier) Error during processing line for tasks <</c><c='Red'>$line</c><c='Yellow'>></c>"
                    }
                }
            }
        }
        catch {
            Write-PSFMessage -Level Host -Message "Error during processing tasks"
        }
    }

    try {
        $errorText = Select-String -LiteralPath $Path -Pattern '(^.*) Error: (.*)' | ForEach-Object { $_.Line }

        # Skip modules that do not have errors
        if ($errorText) {
            foreach ($line in $errorText) {
                $lineLocal = $line

                # Remove positioning text in the format of "[(5,5),(5,39)]: " for methods
                if ($lineLocal -match $positionRegex) {
                    Write-PSFMessage -Level Verbose -Message "Position notation was found in the error line. Will remove it."

                    $lineReplaced = [regex]::Split($lineLocal, $positionSplitRegex)
                    $lineLocal = $lineReplaced[1] + $lineReplaced[2]
                }

                try {
                    Write-PSFMessage -Level Verbose -Message "Will split the error line, and create result object."

                    # Regular expression matching to split line details into groups
                    $Matches = [regex]::split($lineLocal, $errorRegex)
                    $object = [PSCustomObject]@{
                        ErrorType  = $Matches[1].trim()
                        ObjectType = $Matches[2].trim()
                        Path       = $Matches[3].trim()
                        Text       = $Matches[4].trim()
                    }

                    $errorObjects.Add($object)
                }
                catch {
                    Write-PSFHostColor -Level Host "<c='Yellow'>($Identifier) Error during processing line for errors <</c><c='Red'>$line</c><c='Yellow'>></c>"
                }
            }
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Error during processing errors"
    }

    Write-PSFMessage -Level Verbose -Message "Will start exporting the details to the excel file." -Target $OutputPath

    $errorObjects.ToArray() | Export-Excel -Path $OutputPath -WorksheetName "Errors" -ClearSheet -AutoFilter -AutoSize -BoldTopRow

    $groupErrorTexts = $errorObjects.ToArray() | Group-Object -Property Text | Sort-Object -Property "Count" -Descending | Select-PSFObject Count, "Name as DistinctErrorText"
    $groupErrorTexts | Export-Excel -Path $OutputPath -WorksheetName "Errors-Summary" -ClearSheet -AutoFilter -AutoSize -BoldTopRow
        
    if (-not $SkipWarnings) {
        Write-PSFMessage -Level Verbose -Message "Building the warning details and saving them to the excel file." -Target $SkipWarnings
        
        $warningObjects.ToArray() | Export-Excel -Path $OutputPath -WorksheetName "Warnings" -ClearSheet -AutoFilter -AutoSize -BoldTopRow

        $groupWarningTexts = $warningObjects.ToArray() | Group-Object -Property Text | Sort-Object -Property "Count" -Descending | Select-PSFObject Count, "Name as DistinctWarningText"
        $groupWarningTexts | Export-Excel -Path $OutputPath -WorksheetName "Warnings-Summary" -ClearSheet -AutoFilter -AutoSize -BoldTopRow
    }
    else {
        Remove-Worksheet -Path $OutputPath -WorksheetName "Warnings"
        Remove-Worksheet -Path $OutputPath -WorksheetName "Warnings-Summary"
    }

    if (-not $SkipTasks) {
        Write-PSFMessage -Level Verbose -Message "Building the task details and saving them to the excel file." -Target $SkipTasks

        $taskObjects.ToArray() | Export-Excel -Path $OutputPath -WorksheetName "Tasks" -ClearSheet -AutoFilter -AutoSize -BoldTopRow

        $groupTaskTexts = $taskObjects.ToArray() | Group-Object -Property Text | Sort-Object -Property "Count" -Descending | Select-PSFObject Count, "Name as DistinctTaskText"
        $groupTaskTexts | Export-Excel -Path $OutputPath -WorksheetName "Tasks-Summary" -ClearSheet -AutoFilter -AutoSize -BoldTopRow
    }
    else {
        Remove-Worksheet -Path $OutputPath -WorksheetName "Tasks"
        Remove-Worksheet -Path $OutputPath -WorksheetName "Tasks-Summary"
    }

    [PSCustomObject]@{
        File     = $OutputPath
        Filename = $(Split-Path -Path $OutputPath -Leaf)
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Invoke the ModelUtil.exe
         
    .DESCRIPTION
        A cmdlet that wraps some of the cumbersome work into a streamlined process
         
    .PARAMETER Command
        Instruct the cmdlet to what process you want to execute against the ModelUtil tool
         
        Valid options:
        Import
        Export
        Delete
        Replace
         
    .PARAMETER Path
        Used for import to point where to import from
        Used for export to point where to export the model to
         
        The cmdlet only supports an already extracted ".axmodel" file
         
    .PARAMETER Model
        Name of the model that you want to work against
         
        Used for export to select the model that you want to export
        Used for delete to select the model that you want to delete
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the AOS service PackagesLocalDirectory\bin
         
        Default value is fetched from the current configuration on the machine
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Invoke-ModelUtil -Command Import -Path "c:\temp\d365fo.tools\CustomModel.axmodel"
         
        This will execute the import functionality of ModelUtil.exe and have it import the "CustomModel.axmodel" file.
         
    .EXAMPLE
        PS C:\> Invoke-ModelUtil -Command Export -Path "c:\temp\d365fo.tools" -Model CustomModel
         
        This will execute the export functionality of ModelUtil.exe and have it export the "CustomModel" model.
        The file will be placed in "c:\temp\d365fo.tools".
         
    .EXAMPLE
        PS C:\> Invoke-ModelUtil -Command Delete -Model CustomModel
         
        This will execute the delete functionality of ModelUtil.exe and have it delete the "CustomModel" model.
        The folders in PackagesLocalDirectory for the "CustomModel" will NOT be deleted
         
    .EXAMPLE
        PS C:\> Invoke-ModelUtil -Command Replace -Path "c:\temp\d365fo.tools\CustomModel.axmodel"
         
        This will execute the replace functionality of ModelUtil.exe and have it replace the "CustomModel" model.
         
    .NOTES
        Tags: AXModel, Model, ModelUtil, Servicing, Import, Export, Delete, Replace
         
        Author: M�tz Jensen (@Splaxi)
#>


function Invoke-ModelUtil {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidDefaultValueSwitchParameter", "")]
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    
    param (
        [Parameter(Mandatory = $true)]
        [ValidateSet('Import', 'Export', 'Delete', 'Replace')]
        [string] $Command,

        [Parameter(Mandatory = $True, ParameterSetName = 'Import', Position = 1 )]
        [Parameter(Mandatory = $True, ParameterSetName = 'Export', Position = 1 )]
        [Alias('File')]
        [string] $Path,

        [Parameter(Mandatory = $True, ParameterSetName = 'Export', Position = 2 )]
        [Parameter(Mandatory = $True, ParameterSetName = 'Delete', Position = 1 )]
        [string] $Model,

        [string] $BinDir = "$Script:PackageDirectory\bin",

        [string] $MetaDataDir = "$Script:MetaDataDir",

        [string] $LogPath,

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly
    )

    Invoke-TimeSignal -Start
    
    if (-not (Test-PathExists -Path $MetaDataDir, $BinDir -Type Container)) {
        Stop-PSFFunction -Message "Stopping because of missing paths." -StepsUpward 1
    }

    $executable = Join-Path -Path $BinDir -ChildPath "ModelUtil.exe"
    if (-not (Test-PathExists -Path $executable -Type Leaf)) {
        Stop-PSFFunction -Message "Stopping because of missing paths." -StepsUpward 1
    }

    $params = New-Object System.Collections.Generic.List[string]

    Write-PSFMessage -Level Verbose -Message "Building the parameter options."
    switch ($Command.ToLowerInvariant()) {
        'import' {
            if (-not (Test-PathExists -Path $Path -Type Leaf)) {
                Stop-PSFFunction -Message "Stopping because of missing paths." -StepsUpward 1
            }

            $params.Add("-import")
            $params.Add("-metadatastorepath=`"$MetaDataDir`"")
            $params.Add("-file=`"$Path`"")
        }
        'export' {
            $params.Add("-export")
            $params.Add("-metadatastorepath=`"$MetaDataDir`"")
            $params.Add("-outputpath=`"$Path`"")
            $params.Add("-modelname=`"$Model`"")
        }
        'delete' {
            $params.Add("-delete")
            $params.Add("-metadatastorepath=`"$MetaDataDir`"")
            $params.Add("-modelname=`"$Model`"")
        }
        'replace' {
            if (-not (Test-PathExists -Path $Path -Type Leaf)) {
                Stop-PSFFunction -Message "Stopping because of missing paths." -StepsUpward 1
            }

            $params.Add("-replace")
            $params.Add("-metadatastorepath=`"$MetaDataDir`"")
            $params.Add("-file=`"$Path`"")
        }
        
    }

    Write-PSFMessage -Level Verbose -Message "Starting the $executable with the parameter options." -Target $($params.ToArray() -join " ")
    
    Invoke-Process -Executable $executable -Params $params.ToArray() -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath

    if (Test-PSFFunctionInterrupt) {
        Stop-PSFFunction -Message "Stopping because of 'ModelUtil.exe' failed its execution." -StepsUpward 1
        return
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Invoke a process
         
    .DESCRIPTION
        Invoke a process and pass the needed parameters to it
         
    .PARAMETER Path
        Path to the program / executable that you want to start
         
    .PARAMETER Params
        Array of string parameters that you want to pass to the executable
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-Process -Path "C:\AOSService\PackagesLocalDirectory\Bin\xppc.exe" -Params "-metadata=`"C:\AOSService\PackagesLocalDirectory\Bin`"", "-modelmodule=`"ApplicationSuite`"", "-output=`"C:\AOSService\PackagesLocalDirectory\Bin`"", "-referencefolder=`"C:\AOSService\PackagesLocalDirectory\Bin`"", "-log=`"C:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.$Module.xppc.log`"", "-xmlLog=`"C:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.ApplicationSuite.xppc.xml`"", "-verbose"
         
        This will invoke the "C:\AOSService\PackagesLocalDirectory\Bin\xppc.exe" executable.
        All parameters will be passed to it.
        The standard output will be redirected to a local variable.
        The error output will be redirected to a local variable.
        The standard output will be written to the verbose stream before exiting.
         
        If an error should occur, both the standard output and error output will be written to the console / host.
         
    .EXAMPLE
        PS C:\> Invoke-Process -ShowOriginalProgress -Path "C:\AOSService\PackagesLocalDirectory\Bin\xppc.exe" -Params "-metadata=`"C:\AOSService\PackagesLocalDirectory\Bin`"", "-modelmodule=`"ApplicationSuite`"", "-output=`"C:\AOSService\PackagesLocalDirectory\Bin`"", "-referencefolder=`"C:\AOSService\PackagesLocalDirectory\Bin`"", "-log=`"C:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.$Module.xppc.log`"", "-xmlLog=`"C:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.ApplicationSuite.xppc.xml`"", "-verbose"
         
        This will invoke the "C:\AOSService\PackagesLocalDirectory\Bin\xppc.exe" executable.
        All parameters will be passed to it.
        The standard output will be outputted directly to the console / host.
        The error output will be outputted directly to the console / host.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
#>


function Invoke-Process {
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $true)]
        [Alias('Executable')]
        [string] $Path,

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

        [string] $LogPath,

        [switch] $ShowOriginalProgress,
        
        [switch] $OutputCommandOnly,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }
    
    if (Test-PSFFunctionInterrupt) { return }

    $tool = Split-Path -Path $Path -Leaf

    $pinfo = New-Object System.Diagnostics.ProcessStartInfo
    $pinfo.FileName = "$Path"
    $pinfo.WorkingDirectory = Split-Path -Path $Path -Parent

    if (-not $ShowOriginalProgress) {
        Write-PSFMessage -Level Verbose "Output and Error streams will be redirected (silence mode)"

        $pinfo.RedirectStandardError = $true
        $pinfo.RedirectStandardOutput = $true
    }

    $pinfo.UseShellExecute = $false
    $pinfo.Arguments = "$($Params -join " ")"
    $p = New-Object System.Diagnostics.Process
    $p.StartInfo = $pinfo

    Write-PSFMessage -Level Verbose "Starting the $tool" -Target "$($params -join " ")"

    if ($OutputCommandOnly) {
        Write-PSFMessage -Level Host "$Path $($pinfo.Arguments)"
        return
    }
    
    $p.Start() | Out-Null
    
    if (-not $ShowOriginalProgress) {
        $stdout = $p.StandardOutput.ReadToEnd()
        $stderr = $p.StandardError.ReadToEnd()
    }

    Write-PSFMessage -Level Verbose "Waiting for the $tool to complete"
    $p.WaitForExit()

    if ($p.ExitCode -ne 0 -and (-not $ShowOriginalProgress)) {
        Write-PSFMessage -Level Host "Exit code from $tool indicated an error happened. Will output both standard stream and error stream."
        Write-PSFMessage -Level Host "Standard output was: \r\n $stdout"
        Write-PSFMessage -Level Host "Error output was: \r\n $stderr"

        $messageString = "Stopping because an Exit Code from $tool wasn't 0 (zero) like expected."
        Stop-PSFFunction -Message "Stopping because of Exit Code." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -StepsUpward 1
        return
    }
    else {
        Write-PSFMessage -Level Verbose "Standard output was: \r\n $stdout"
    }

    if ((-not $ShowOriginalProgress) -and (-not ([string]::IsNullOrEmpty($LogPath)))) {
        if (-not (Test-PathExists -Path $LogPath -Type Container -Create)) { return }

        $stdOutputPath = Join-Path -Path $LogPath -ChildPath "$tool`_StdOutput.log"
        $errOutputPath = Join-Path -Path $LogPath -ChildPath "$tool`_ErrOutput.log"

        $stdout | Out-File -FilePath $stdOutputPath -Encoding utf8 -Force
        $stderr | Out-File -FilePath $errOutputPath -Encoding utf8 -Force
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Backup & Restore SQL Server database
         
    .DESCRIPTION
        Backup a database and restore it back into the SQL Server
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER TrustedConnection
        Should the connection use a Trusted Connection or not
         
    .PARAMETER NewDatabaseName
        Name of the new (restored) database
         
    .PARAMETER BackupDirectory
        Path to a directory that can store the backup file
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-SqlBackupRestore -DatabaseServer localhost -DatabaseName AxDB -SqlUser User123 -SqlPwd "Password123" -NewDatabaseName "ExportClone" -BackupDirectory "C:\temp\d365fo.tools\sqlbackup"
         
        This will backup the AxDB database and place the backup file inside the "c:\temp\d365fo.tools\sqlbackup" directory.
        The backup file will the be used to restore into a new database named "ExportClone".
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

Function Invoke-SqlBackupRestore {
    [CmdletBinding()]
    [OutputType('System.Boolean')]
    param (
        [Parameter(Mandatory = $true)]
        [string] $DatabaseServer,

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

        [Parameter(Mandatory = $false)]
        [string] $SqlUser,

        [Parameter(Mandatory = $false)]
        [string] $SqlPwd,
        
        [Parameter(Mandatory = $false)]
        [boolean] $TrustedConnection,

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

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

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    $Params = @{DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd; TrustedConnection = $TrustedConnection;
    }

    $sqlCommand = Get-SQLCommand @Params

    $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\backuprestoredb.sql") -join [Environment]::NewLine
    $null = $sqlCommand.Parameters.Add("@CurrentDatabase", $DatabaseName)
    $null = $sqlCommand.Parameters.Add("@NewName", $NewDatabaseName)
    $null = $sqlCommand.Parameters.Add("@BackupDirectory", $BackupDirectory)

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()
        
        $null = $sqlCommand.ExecuteNonQuery()
        
        $true
    }
    catch {
        $messageString = "Something went wrong while doing <c='em'>backup / restore</c> against the database."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_
        return
    }
    finally {
        
        $sqlCommand.Connection.Close()
        $sqlCommand.Dispose()
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Invoke the sqlpackage executable
         
    .DESCRIPTION
        Invoke the sqlpackage executable and pass the necessary parameters to it
         
    .PARAMETER Action
        Can either be import or export
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER TrustedConnection
        Should the sqlpackage work with TrustedConnection or not
         
    .PARAMETER FilePath
        Path to the file, used for either import or export
         
    .PARAMETER Properties
        Array of all the properties that needs to be parsed to the sqlpackage.exe
         
    .PARAMETER DiagnosticFile
        Path to where you want the SqlPackage to output a diagnostics file to assist you in troubleshooting
         
    .PARAMETER ModelFile
        Path to the model file that you want the SqlPackage.exe to use instead the one being part of the bacpac file
         
        This is used to override SQL Server options, like collation and etc
         
    .PARAMETER MaxParallelism
        Sets SqlPackage.exe's degree of parallelism for concurrent operations running against a database. The default value is 8.
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .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:\> $BaseParams = @{
        DatabaseServer = $DatabaseServer
        DatabaseName = $DatabaseName
        SqlUser = $SqlUser
        SqlPwd = $SqlPwd
        }
         
        PS C:\> $ImportParams = @{
        Action = "import"
        FilePath = $BacpacFile
        }
         
        PS C:\> Invoke-SqlPackage @BaseParams @ImportParams
         
        This will start the sqlpackage.exe file and pass all the needed parameters.
         
    .NOTES
        Author: M�tz Jensen (@splaxi)
         
#>

function Invoke-SqlPackage {
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param (
        [ValidateSet('Import', 'Export')]
        [string] $Action,
        
        [string] $DatabaseServer,
        
        [string] $DatabaseName,
        
        [string] $SqlUser,
        
        [string] $SqlPwd,
        
        [string] $TrustedConnection,
        
        [string] $FilePath,
        
        [string[]] $Properties,

        [string] $DiagnosticFile,

        [string] $ModelFile,

        [int] $MaxParallelism,

        [string] $LogPath,

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly,

        [switch] $EnableException
    )
              
    $executable = $Script:SqlPackagePath

    Invoke-TimeSignal -Start

    if (!(Test-PathExists -Path $executable -Type Leaf)) { return }

    Write-PSFMessage -Level Verbose -Message "Starting to prepare the parameters for sqlpackage.exe"

    [System.Collections.ArrayList]$Params = New-Object -TypeName "System.Collections.ArrayList"

    if ($Action -eq "export") {
        $null = $Params.Add("/Action:export")
        $null = $Params.Add("/SourceServerName:$DatabaseServer")
        $null = $Params.Add("/SourceDatabaseName:$DatabaseName")
        $null = $Params.Add("/TargetFile:`"$FilePath`"")
        $null = $Params.Add("/Properties:CommandTimeout=0")
    
        if (!$UseTrustedConnection) {
            $null = $Params.Add("/SourceUser:$SqlUser")
            $null = $Params.Add("/SourcePassword:$SqlPwd")
        }
        
        Remove-Item -Path $FilePath -ErrorAction SilentlyContinue -Force
    }
    else {
        $null = $Params.Add("/Action:import")
        $null = $Params.Add("/TargetServerName:$DatabaseServer")
        $null = $Params.Add("/TargetDatabaseName:$DatabaseName")
        $null = $Params.Add("/SourceFile:`"$FilePath`"")
        $null = $Params.Add("/Properties:CommandTimeout=0")
        
        if (!$UseTrustedConnection) {
            $null = $Params.Add("/TargetUser:$SqlUser")
            $null = $Params.Add("/TargetPassword:$SqlPwd")
        }
    }

    foreach ($item in $Properties) {
        $null = $Params.Add("/Properties:$item")
    }

    if (-not [system.string]::IsNullOrEmpty($DiagnosticFile)) {
        $null = $Params.Add("/Diagnostics:true")
        $null = $Params.Add("/DiagnosticsFile:`"$DiagnosticFile`"")
    }
    
    if (-not [system.string]::IsNullOrEmpty($ModelFile)) {
        $null = $Params.Add("/ModelFilePath:`"$ModelFile`"")
    }

    if (-not [system.string]::IsNullOrEmpty($MaxParallelism)) {
        $null = $Params.Add("/MaxParallelism:$MaxParallelism")
    }

    Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath
    
    if (Test-PSFFunctionInterrupt) {
        Write-PSFMessage -Level Critical -Message "The SqlPackage.exe exited with an error."
        Stop-PSFFunction -Message "Stopping because of errors." -StepsUpward 1
        return
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Handle time measurement
         
    .DESCRIPTION
        Handle time measurement from when a cmdlet / function starts and ends
         
        Will write the output to the verbose stream (Write-PSFMessage -Level Verbose)
         
    .PARAMETER Start
        Switch to instruct the cmdlet that a start time registration needs to take place
         
    .PARAMETER End
        Switch to instruct the cmdlet that a time registration has come to its end and it needs to do the calculation
         
    .EXAMPLE
        PS C:\> Invoke-TimeSignal -Start
         
        This will start the time measurement for any given cmdlet / function
         
    .EXAMPLE
        PS C:\> Invoke-TimeSignal -End
         
        This will end the time measurement for any given cmdlet / function.
        The output will go into the verbose stream.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Invoke-TimeSignal {
    [CmdletBinding(DefaultParameterSetName = 'Start')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Start', Position = 1 )]
        [switch] $Start,
        
        [Parameter(Mandatory = $True, ParameterSetName = 'End', Position = 2 )]
        [switch] $End
    )

    $Time = (Get-Date)

    $Command = (Get-PSCallStack)[1].Command

    if ($Start) {
        if ($Script:TimeSignals.ContainsKey($Command)) {
            Write-PSFMessage -Level Verbose -Message "The command '$Command' was already taking part in time measurement. The entry has been update with current date and time."
            $Script:TimeSignals[$Command] = $Time
        }
        else {
            $Script:TimeSignals.Add($Command, $Time)
        }
    }
    else {
        if ($Script:TimeSignals.ContainsKey($Command)) {
            $TimeSpan = New-TimeSpan -End $Time -Start (($Script:TimeSignals)[$Command])

            Write-PSFMessage -Level Verbose -Message "Total time spent inside the function was $TimeSpan" -Target $TimeSpan -FunctionName $Command -Tag "TimeSignal"
            $null = $Script:TimeSignals.Remove($Command)
        }
        else {
            Write-PSFMessage -Level Verbose -Message "The command '$Command' was never started to take part in time measurement."
        }
    }
}


<#
    .SYNOPSIS
        Creates a new Azure Active Directory (AAD) application
         
    .DESCRIPTION
        Creates a new Azure Active Directory (AAD) application in a Dynamics 365 for Finance & Operations instance
         
    .PARAMETER sqlCommand
        The SQL Command object that should be used when creating the new application
         
    .PARAMETER Name
        The name that the imported application should have inside the D365FO environment
         
    .PARAMETER UserId
        The id of the user linked to the application inside the D365FO environment
         
    .PARAMETER ClientId
        The Client ID that the imported application should use inside the D365FO environment
         
    .EXAMPLE
        PS C:\> $SqlCommand = Get-SqlCommand -DatabaseServer localhost -DatabaseName AxDB -SqlUser User123 -SqlPwd "Password123"
        PS C:\> New-D365FOAadApplication -SqlCommand $SqlCommand -Name "Application1" -UserId "admin" -ClientId "aef2e67c-64a3-4c72-9294-d288c5bf503d"
        This will get a SqlCommand object that will connect to the localhost server and the AXDB database, with the sql credential "User123".
        The SqlCommand object is passed to the New-D365FOAadApplication along with all the necessary details for importing Application1 as an application linked to user admin into the D365FO environment.
         
    .NOTES
        Author: Gert Van Der Heyden (@gertvdheyden)
         
#>

function New-D365FOAadApplication {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    Param (
        [System.Data.SqlClient.SqlCommand] $SqlCommand,

        [string] $Name,

        [string] $UserId,

        [string] $ClientId
    )
    
    $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\Add-AadApplicationIntoD365FO.sql") -join [Environment]::NewLine

    Write-PSFMessage -Level Verbose -Message "Adding Application : $Name,$UserId,$ClientId"
    
    $null = $sqlCommand.Parameters.Add("@Name", $Name)
    $null = $sqlCommand.Parameters.Add("@UserId", $UserId)
    $null = $sqlCommand.Parameters.Add("@ClientId", $ClientId)

    Write-PSFMessage -Level Verbose -Message "Creating the application in database"

    Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

    $null = $sqlCommand.ExecuteNonQuery()
    
    Write-PSFMessage -Level Verbose -Message "Added application"
}


<#
    .SYNOPSIS
        Creates a new user
         
    .DESCRIPTION
        Creates a new user in a Dynamics 365 for Finance & Operations instance
         
    .PARAMETER sqlCommand
        The SQL Command object that should be used when creating the new user
         
    .PARAMETER SignInName
        The sign in name (email address) for the user that you want the SID from
         
    .PARAMETER Name
        The name that the imported user should have inside the D365FO environment
         
    .PARAMETER Id
        The ID that the imported user should have inside the D365FO environment
         
    .PARAMETER SID
        The SID that correlates to the imported user inside the D365FO environment
         
    .PARAMETER StartUpCompany
        The default company (legal entity) for the imported user
         
    .PARAMETER IdentityProvider
        The provider for the imported to validated against
         
    .PARAMETER NetworkDomain
        The network domain of the imported user
         
    .PARAMETER ObjectId
        The Azure Active Directory object id for the imported user
         
    .PARAMETER Language
        Language that should be configured for the user, for when they sign-in to the D365 environment
         
    .EXAMPLE
        PS C:\> $SqlCommand = Get-SqlCommand -DatabaseServer localhost -DatabaseName AxDB -SqlUser User123 -SqlPwd "Password123"
        PS C:\> New-D365FOUser -SqlCommand $SqlCommand -SignInName "Claire@contoso.com" -Name "Claire" -Id "claire" -SID "123XYZ" -StartupCompany "DAT" -IdentityProvider "XYZ" -NetworkDomain "Contoso.com" -ObjectId "123XYZ"
         
        This will get a SqlCommand object that will connect to the localhost server and the AXDB databae, with the sql credential "User123".
        The SqlCommand object is passed to the Import-AadUserIntoD365FO along with all the necessary details for importing Claire@contoso.com as an user into the D365FO environment.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function New-D365FOUser {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    Param (
        [System.Data.SqlClient.SqlCommand] $SqlCommand,

        [string] $SignInName,
        
        [string] $Name,
        
        [string] $Id,
        
        [string] $SID,
        
        [string] $StartUpCompany,
        
        [string] $IdentityProvider,
        
        [string] $NetworkDomain,
        
        [string] $ObjectId,
        
        [string] $Language
    )
    
    $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\Add-AadUserIntoD365FO.sql") -join [Environment]::NewLine

    Write-PSFMessage -Level Verbose -Message "Adding User : $SignInName,$Name,$Id,$SID,$StartUpCompany,$IdentityProvider,$NetworkDomain"

    $null = $sqlCommand.Parameters.Add("@SignInName", $SignInName)
    $null = $sqlCommand.Parameters.Add("@Name", $Name)
    $null = $sqlCommand.Parameters.Add("@SID", $SID)
    $null = $sqlCommand.Parameters.Add("@NetworkDomain", $NetworkDomain)
    $null = $sqlCommand.Parameters.Add("@IdentityProvider", $IdentityProvider)
    $null = $sqlCommand.Parameters.Add("@StartUpCompany", $StartUpCompany)
    $null = $sqlCommand.Parameters.Add("@Id", $Id)
    $null = $sqlCommand.Parameters.Add("@ObjectId", $ObjectId)
    $null = $sqlCommand.Parameters.Add("@Language", $Language)

    Write-PSFMessage -Level Verbose -Message "Creating the user in database"

    Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

    $rowsCreated = $sqlCommand.ExecuteScalar()
    
    Write-PSFMessage -Level Verbose -Message "Rows inserted $rowsCreated for user $SignInName"
    
    $SqlCommand.Parameters.Clear()

    $rowsCreated -eq 1
}


<#
    .SYNOPSIS
        Create a new self signed certificate
         
    .DESCRIPTION
        Create a new self signed certificate and have it password protected
         
    .PARAMETER CertificateFileName
        Path to the location where you want to store the CER file for the certificate
         
    .PARAMETER PrivateKeyFileName
        Path to the location where you want to store the PFX file for the certificate
         
    .PARAMETER Password
        The password that you want to use to protect your different certificates with
         
    .EXAMPLE
        PS C:\> New-D365SelfSignedCertificate -CertificateFileName "C:\temp\d365fo.tools\TestAuth.cer" -PrivateKeyFileName "C:\temp\d365fo.tools\TestAuth.pfx" -Password (ConvertTo-SecureString -String "pass@word1" -Force -AsPlainText)
         
        This will generate a new CER certificate that is stored at "C:\temp\d365fo.tools\TestAuth.cer".
        This will generate a new PFX certificate that is stored at "C:\temp\d365fo.tools\TestAuth.pfx".
        Both certificates will be password protected with "pass@word1".
         
    .NOTES
        Author: Kenny Saelen (@kennysaelen)
        Author: M�tz Jensen (@Splaxi)
         
#>

function New-D365SelfSignedCertificate {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false, Position = 1)]
        [string] $CertificateFileName = (Join-Path $env:TEMP "TestAuthCert.cer"),

        [Parameter(Mandatory = $false, Position = 2)]
        [string] $PrivateKeyFileName = (Join-Path $env:TEMP "TestAuthCert.pfx"),

        [Parameter(Mandatory = $false, Position = 3)]
        [Security.SecureString] $Password = (ConvertTo-SecureString -String "Password1" -Force -AsPlainText)
    )

    try {
        # First generate a self-signed certificate and place it in the local store on the machine
        $certificate = New-SelfSignedCertificate -dnsname 127.0.0.1 -CertStoreLocation cert:\LocalMachine\My -FriendlyName "D365 Automated testing certificate" -Provider "Microsoft Strong Cryptographic Provider"
        $certificatePath = 'cert:\localMachine\my\' + $certificate.Thumbprint

        # Export the private key
        Export-PfxCertificate -cert $certificatePath -FilePath $PrivateKeyFileName -Password $Password

        # Import the certificate into the local machine's trusted root certificates store
        $importedCertificate = Import-PfxCertificate -FilePath $PrivateKeyFileName -CertStoreLocation Cert:\LocalMachine\Root -Password $Password
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while generating the self-signed certificate and installing it into the local machine's trusted root certificates store." -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        return
    }

    return $importedCertificate
}


<#
    .SYNOPSIS
        Decrypt web.config file
         
    .DESCRIPTION
        Utilize the built in encryptor utility to decrypt the web.config file from inside the AOS
         
    .PARAMETER File
        Path to the file that you want to work against
         
        Please be careful not to point to the original file from inside the AOS directory
         
    .PARAMETER DropPath
        Path to the directory where you want save the file after decryption is completed
         
    .EXAMPLE
        PS C:\> New-DecryptedFile -File "C:\temp\d365fo.tools\web.config" -DropPath "c:\temp\d365fo.tools\decrypted.config"
         
        This will take the "C:\temp\d365fo.tools\web.config" and decrypt it.
        After decryption the output file will be stored in "c:\temp\d365fo.tools\decrypted.config".
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function New-DecryptedFile {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    Param (
        [string] $File,
        
        [string] $DropPath
    )
    
    $Decrypter = Join-Path  $AosServiceWebRootPath -ChildPath "bin\Microsoft.Dynamics.AX.Framework.ConfigEncryptor.exe"

    if (-not (Test-PathExists -Path $Decrypter -Type Leaf)) { return }

    $fileInfo = [System.IO.FileInfo]::new($File)
    $DropFile = Join-Path $DropPath $FileInfo.Name
    
    Write-PSFMessage -Level Verbose -Message "Extracted file path is: $DropFile" -Target $DropFile
    Copy-Item $File $DropFile -Force -ErrorAction Stop

    if (-not (Test-PathExists -Path $DropFile -Type Leaf)) { return }
    
    & $Decrypter -decrypt $DropFile
}


<#
    .SYNOPSIS
        Create a new Json HttpRequestMessage
         
    .DESCRIPTION
        Create a new HttpRequestMessage with the ContentType = application/json
         
    .PARAMETER Uri
        The URI / URL for the web site you want to work against
         
    .PARAMETER Token
        The token that contains the needed authorization permission
         
    .PARAMETER Content
        The content that you want to include in the HttpRequestMessage
         
    .PARAMETER HttpMethod
        The method of the HTTP request you wanne make
         
        Valid options are:
        GET
        POST
         
    .EXAMPLE
        PS C:\> New-JsonRequest -Token "Bearer JldjfafLJdfjlfsalfd..." -Uri "https://lcsapi.lcs.dynamics.com/box/fileasset/CommitFileAsset/123456789?assetId=958ae597-f089-4811-abbd-c1190917eaae"
         
        This will create a new HttpRequestMessage what will work against the "https://lcsapi.lcs.dynamics.com/box/fileasset/CommitFileAsset/123456789?assetId=958ae597-f089-4811-abbd-c1190917eaae".
        It attaches the Token "Bearer JldjfafLJdfjlfsalfd..." to the request.
         
    .NOTES
        Tags: Json, Http, HttpRequestMessage, POST
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function New-JsonRequest {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $Uri,
        
        [Parameter(Mandatory = $true)]
        [string] $Token,

        [Parameter(Mandatory = $false)]
        [string] $Content,

        [Parameter(Mandatory = $false)]
        [ValidateSet('POST', 'GET')]
        [string] $HttpMethod = "POST"
    )

    $httpMethodObject = [System.Net.Http.HttpMethod]::New($HttpMethod)

    Write-PSFMessage -Level Verbose -Message "Building a HttpRequestMessage." -Target $Uri
    $request = New-Object -TypeName System.Net.Http.HttpRequestMessage -ArgumentList @($httpMethodObject, $Uri)
    
    if (-not ($Content -eq "")) {
        Write-PSFMessage -Level Verbose -Message "Adding content to the HttpRequestMessage." -Target $Content
        $request.Content = New-Object -TypeName System.Net.Http.StringContent -ArgumentList @($Content, [System.Text.Encoding]::UTF8, "application/json")
    }

    Write-PSFMessage -Level Verbose -Message "Adding Authorization token to the HttpRequestMessage." -Target $Token
    $request.Headers.Authorization = $Token

    $request
}


<#
    .SYNOPSIS
        Get a web request object
         
    .DESCRIPTION
        Get a prepared web request object with all necessary headers and tokens in place
         
    .PARAMETER RequestUrl
        The URL you want to work against
         
    .PARAMETER AuthorizationHeader
        The Authorization Header object that you want to use for you web request
         
    .PARAMETER Action
        The HTTP action you want to preform
         
    .EXAMPLE
        PS C:\> New-WebRequest -RequestUrl "https://login.windows.net/contoso/.well-known/openid-configuration" -AuthorizationHeader $null -Action GET
         
        This will create a new web request object that will work against the "https://login.windows.net/contoso/.well-known/openid-configuration" URL.
        The HTTP action is GET and in this case we don't need an Authorization Header in place.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function New-WebRequest {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]

    param    (
        $RequestUrl,
        $AuthorizationHeader,
        $Action
    )
    
    Write-PSFMessage -Level Verbose -Message "New Request $RequestUrl, $Action"
    $request = [System.Net.WebRequest]::Create($RequestUrl)

    if ($null -ne $AuthorizationHeader) {
        $request.Headers["Authorization"] = $AuthorizationHeader.CreateAuthorizationHeader()
    }

    $request.Method = $Action
    
    $request
}


<#
    .SYNOPSIS
        Rename the value in the web.config file
         
    .DESCRIPTION
        Replace the old value with the new value inside a web.config file
         
    .PARAMETER File
        Path to the file that you want to update/rename/replace
         
    .PARAMETER NewValue
        The new value that replaces the old value
         
    .PARAMETER OldValue
        The old value that needs to be replaced
         
    .EXAMPLE
        PS C:\> Rename-ConfigValue -File "C:\temp\d365fo.tools\web.config" -NewValue "Demo-8.1" -OldValue "usnconeboxax1aos"
         
        This will open the "C:\temp\d365fo.tools\web.config" file and replace all "usnconeboxax1aos" entries with "Demo-8.1"
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Rename-ConfigValue {
    param (
        [string] $File,
        [string] $NewValue,
        [string] $OldValue
    )

    Write-PSFMessage -Level Verbose -Message "Replace content from $File. Old value is $OldValue. New value is $NewValue." -Target (@($File, $OldValue, $NewValue))
    
    (Get-Content $File).replace($OldValue, $NewValue) | Set-Content $File
}


<#
    .SYNOPSIS
        Short description
         
    .DESCRIPTION
        Long description
         
    .PARAMETER InputObject
        Parameter description
         
    .PARAMETER Property
        Parameter description
         
    .PARAMETER ExcludeProperty
        Parameter description
         
    .PARAMETER TypeName
        Parameter description
         
    .EXAMPLE
        PS C:\> Select-DefaultView -InputObject $result -Property CommandName, Synopsis
         
        This will help you do it right.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Select-DefaultView {
    <#
 
    This command enables us to send full on objects to the pipeline without the user seeing it
     
    a lot of this is from boe, thanks boe!
    https://learn-powershell.net/2013/08/03/quick-hits-set-the-default-property-display-in-powershell-on-custom-objects/
 
    TypeName creates a new type so that we can use ps1xml to modify the output
    #>

    
    [CmdletBinding()]
    param (
        [parameter(ValueFromPipeline)]
        [object]
        $InputObject,
        
        [string[]]
        $Property,
        
        [string[]]
        $ExcludeProperty,
        
        [string]
        $TypeName
    )
    process {
        
        if ($null -eq $InputObject) { return }
        
        if ($TypeName) {
            $InputObject.PSObject.TypeNames.Insert(0, "d365fo.tools.$TypeName")
        }
        
        if ($ExcludeProperty) {
            if ($InputObject.GetType().Name.ToString() -eq 'DataRow') {
                $ExcludeProperty += 'Item', 'RowError', 'RowState', 'Table', 'ItemArray', 'HasErrors'
            }
            
            $props = ($InputObject | Get-Member | Where-Object MemberType -in 'Property', 'NoteProperty', 'AliasProperty' | Where-Object { $_.Name -notin $ExcludeProperty }).Name
            $defaultset = New-Object System.Management.Automation.PSPropertySet('DefaultDisplayPropertySet', [string[]]$props)
        }
        else {
            # property needs to be string
            if ("$property" -like "* as *") {
                $newproperty = @()
                foreach ($p in $property) {
                    if ($p -like "* as *") {
                        $old, $new = $p -isplit " as "
                        # Do not be tempted to not pipe here
                        $inputobject | Add-Member -Force -MemberType AliasProperty -Name $new -Value $old -ErrorAction SilentlyContinue
                        $newproperty += $new
                    }
                    else {
                        $newproperty += $p
                    }
                }
                $property = $newproperty
            }
            $defaultset = New-Object System.Management.Automation.PSPropertySet('DefaultDisplayPropertySet', [string[]]$Property)
        }
        
        $standardmembers = [System.Management.Automation.PSMemberInfo[]]@($defaultset)
        
        # Do not be tempted to not pipe here
        $inputobject | Add-Member -Force -MemberType MemberSet -Name PSStandardMembers -Value $standardmembers -ErrorAction SilentlyContinue
        
        $inputobject
    }
}


<#
    .SYNOPSIS
        Provision an user to be the administrator of a Dynamics 365 for Finance & Operations environment
         
    .DESCRIPTION
        Provision an user to be the administrator by using the supplied tools from Microsoft (AdminUserProvisioning.exe)
         
    .PARAMETER SignInName
        The sign in name (email address) for the user that you want to be the administrator
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Set-AdminUser -SignInName "Claire@contoso.com" -DatabaseServer localhost -DatabaseName AxDB -SqlUser User123 -SqlPwd "Password123"
         
        This will provision the user with the e-mail "Claire@contoso.com" to be the administrator of the D365 for Finance & Operations instance.
        It will handle if the tenant is switching also, and update the necessary details.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
        Author: Mark Furrer (@devax_mf)
         
#>

function Set-AdminUser {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    Param (
        [string] $SignInName,

        [string] $DatabaseServer,

        [string] $DatabaseName,

        [string] $SqlUser,

        [string] $SqlPwd,

        [switch] $EnableException
    )

    $WebConfigFile = Join-Path $Script:AOSPath $Script:WebConfig

    $MetaDataNode = Select-Xml -XPath "/configuration/appSettings/add[@key='Aos.MetadataDirectory']/@value" -Path $WebConfigFile

    $MetaDataNodeDirectory = $MetaDataNode.Node.Value
    
    Write-PSFMessage -Level Verbose -Message "MetaDataDirectory: $MetaDataNodeDirectory" -Target $MetaDataNodeDirectory

    $AdminFileLocationPu29AndUp  = "$MetaDataNodeDirectory\Bin\Microsoft.Dynamics.AdminUserProvisioningLib.dll"
    $AdminFileLocationBeforePu29 = "$MetaDataNodeDirectory\Bin\AdminUserProvisioning.exe"
    if ( Test-Path -Path $AdminFileLocationPu29AndUp -PathType Leaf ) {
        $AdminFile = $AdminFileLocationPu29AndUp
        $AdminLibNameSpace = "Microsoft.Dynamics.AdminUserProvisioningLib"
    } else {
        $AdminFile = $AdminFileLocationBeforePu29
        $AdminLibNameSpace = "Microsoft.Dynamics.AdminUserProvisioning"
    }
    Write-PSFMessage -Level Verbose -Message "Path to AdminFile: $AdminFile"

    $TempFileName = New-TemporaryFile
    $TempFileName = $TempFileName.BaseName

    $AdminDll = "$env:TEMP\$TempFileName.dll"

    copy-item -Path $AdminFile -Destination $AdminDll

    $adminAssembly = [System.Reflection.Assembly]::LoadFile($AdminDll)

    $AdminUserUpdater = $adminAssembly.GetType("$AdminLibNameSpace.AdminUserUpdater")

    $PublicBinding = [System.Reflection.BindingFlags]::Public
    $StaticBinding = [System.Reflection.BindingFlags]::Static
    $CombinedBinding = $PublicBinding -bor $StaticBinding

    $UpdateAdminUser = $AdminUserUpdater.GetMethod("UpdateAdminUser", $CombinedBinding)
    
    Write-PSFMessage -Level Verbose -Message "Adjusting parameter set to the PU that is in use in this environment."
    if((($UpdateAdminUser.GetParameters()).Name) -contains "hostUrl") {
        Write-PSFMessage -Level Verbose -Message "PU29 or higher found. Will adjust parameters."
        $params = $SignInName, "AAD-Global", $null, $null, $DatabaseServer, $DatabaseName, $SqlUser, $SqlPwd, "$Script:AOSPath\", $Script:Url
    }
    elseif((($UpdateAdminUser.GetParameters()).Name) -contains "providerName") {
        Write-PSFMessage -Level Verbose -Message "PU26/27/28 found. Will adjust parameters."
        $params = $SignInName, "AAD-Global", $null, $null, $DatabaseServer, $DatabaseName, $SqlUser, $SqlPwd
    }
    else {
        Write-PSFMessage -Level Verbose -Message "PU below PU26 found. Will adjust parameters."
        $params = $SignInName, $null, $null, $DatabaseServer, $DatabaseName, $SqlUser, $SqlPwd
    }

    try {
        $paramsString = $params -join ", "
        Write-PSFMessage -Level Verbose -Message "Updating Admin using the values $paramsString"
        $UpdateAdminUser.Invoke($null, $params)
    }
    catch {
        $messageString = "Something went wrong while <c='em'>provisioning</c> the environment to the new administrator: $SignInName."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target $SignInName
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_ -StepsUpward 1
        return
    }
}


<#
    .SYNOPSIS
        Change the different Azure SQL Database details
         
    .DESCRIPTION
        When preparing an Azure SQL Database to be the new database for an Tier 2+ environment you need to set different details
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER AxDeployExtUserPwd
        Password obtained from LCS
         
    .PARAMETER AxDbAdminPwd
        Password obtained from LCS
         
    .PARAMETER AxRuntimeUserPwd
        Password obtained from LCS
         
    .PARAMETER AxMrRuntimeUserPwd
        Password obtained from LCS
         
    .PARAMETER AxRetailRuntimeUserPwd
        Password obtained from LCS
         
    .PARAMETER AxRetailDataSyncUserPwd
        Password obtained from LCS
         
    .PARAMETER AxDbReadonlyUserPwd
        Password obtained from LCS
         
    .PARAMETER TenantId
        The ID of tenant that the Azure SQL Database instance is going to be run under
         
    .PARAMETER PlanId
        The ID of the type of plan that the Azure SQL Database is going to be using
         
    .PARAMETER PlanCapability
        The capabilities that the Azure SQL Database instance will be running with
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Set-AzureBacpacValues -DatabaseServer dbserver1.database.windows.net -DatabaseName Import -SqlUser User123 -SqlPwd "Password123" -AxDeployExtUserPwd "Password123" -AxDbAdminPwd "Password123" -AxRuntimeUserPwd "Password123" -AxMrRuntimeUserPwd "Password123" -AxRetailRuntimeUserPwd "Password123" -AxRetailDataSyncUserPwd "Password123" -AxDbReadonlyUserPwd "Password123" -TenantId "TenantIdFromAzure" -PlanId "PlanIdFromAzure" -PlanCapability "Capabilities"
         
        This will set all the needed details inside the "Import" database that is located in the "dbserver1.database.windows.net" Azure SQL Database instance.
        All service accounts and their passwords will be updated accordingly.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Set-AzureBacpacValues {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param (
        [Parameter(Mandatory = $true)]
        [string] $DatabaseServer,

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

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

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

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

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

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

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

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

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

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

        [Parameter(Mandatory = $true)]
        [string] $TenantId,
        
        [Parameter(Mandatory = $true)]
        [string] $PlanId,
        
        [Parameter(Mandatory = $true)]
        [string] $PlanCapability,

        [switch] $EnableException
    )
        
    $sqlCommand = Get-SQLCommand -DatabaseServer $DatabaseServer -DatabaseName $DatabaseName -SqlUser $SqlUser -SqlPwd $SqlPwd -TrustedConnection $false

    $commandText = (Get-Content "$script:ModuleRoot\internal\sql\set-bacpacvaluesazure.sql") -join [Environment]::NewLine

    $commandText = $commandText.Replace('@axdeployextuser', $AxDeployExtUserPwd)
    $commandText = $commandText.Replace('@axdbadmin', $AxDbAdminPwd)
    $commandText = $commandText.Replace('@axruntimeuser', $AxRuntimeUserPwd)
    $commandText = $commandText.Replace('@axmrruntimeuser', $AxMrRuntimeUserPwd)
    $commandText = $commandText.Replace('@axretailruntimeuser', $AxRetailRuntimeUserPwd)
    $commandText = $commandText.Replace('@axretaildatasyncuser', $AxRetailDataSyncUserPwd)
    $commandText = $commandText.Replace('@axdbreadonlyuser', $AxDbReadonlyUserPwd)

    $sqlCommand.CommandText = $commandText

    $null = $sqlCommand.Parameters.Add("@TenantId", $TenantId)
    $null = $sqlCommand.Parameters.Add("@PlanId", $PlanId)
    $null = $sqlCommand.Parameters.Add("@PlanCapability ", $PlanCapability)

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()

        $null = $sqlCommand.ExecuteNonQuery()
        
        $true
    }
    catch {
        $messageString = "Something went wrong while working against the database."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
    }
}


<#
    .SYNOPSIS
        Set the SQL Server specific values
         
    .DESCRIPTION
        Set the SQL Server specific values when restoring a bacpac file
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER TrustedConnection
        Should the connection use a Trusted Connection or not
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Set-SqlBacpacValues -DatabaseServer localhost -DatabaseName "AxDB" -SqlUser "User123" -SqlPwd "Password123"
         
        This will connect to the "AXDB" database that is available in the SQL Server instance running on the localhost.
        It will use the "User123" SQL Server credentials to connect to the SQL Server instance.
        This will set all the necessary SQL Server database options and create the needed objects in side the "AxDB" database.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Set-SqlBacpacValues {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    [OutputType('System.Boolean')]
    param (
        [Parameter(Mandatory = $true)]
        [string] $DatabaseServer,

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

        [Parameter(Mandatory = $false)]
        [string] $SqlUser,

        [Parameter(Mandatory = $false)]
        [string] $SqlPwd,
        
        [Parameter(Mandatory = $false)]
        [bool] $TrustedConnection,

        [switch] $EnableException
    )
    
    $Params = @{DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd; TrustedConnection = $TrustedConnection;
    }

    $sqlCommand = Get-SQLCommand @Params

    $commandText = (Get-Content "$script:ModuleRoot\internal\sql\set-bacpacvaluessql.sql") -join [Environment]::NewLine
    $commandText = $commandText.Replace('@DATABASENAME', $DatabaseName)

    $sqlCommand.CommandText = $commandText

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()

        $sqlCommand.ExecuteNonQuery()

        $true
    }
    catch {
        $messageString = "Something went wrong while working against the database."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
    }
}


<#
    .SYNOPSIS
        Start a database export from an environment
         
    .DESCRIPTION
        Start a database export from an environment from a LCS project
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
    .PARAMETER SourceEnvironmentId
        The unique id of the environment that you want to use as the source for the database export
         
        The Id can be located inside the LCS portal
         
    .PARAMETER BackupName
        Name of the backup file when it is being exported from the environment
         
        The file shouldn't contain any extension at all, just the desired file name
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
    .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:\> Start-LcsDatabaseExport -ProjectId 123456789 -SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae" -BackupName "BackupViaApi" -BearerToken "JldjfafLJdfjlfsalfd..." -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will start the database export from the Source environment.
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The source environment is identified by the SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae", which can be obtained in the LCS portal.
        The backup name is identified by the BackupName "BackupViaApi", which instructs the API to save the backup with that filename.
        The request will authenticate with the BearerToken "JldjfafLJdfjlfsalfd...".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .LINK
        Get-LcsDatabaseOperationStatus
         
    .NOTES
        Tags: Environment, Config, Configuration, LCS, Database backup, Api, Backup, Bacpac
         
        Author: M�tz Jensen (@Splaxi)
#>


function Start-LcsDatabaseExport {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Cmdletbinding()]
    param(
        [Parameter(Mandatory = $true)]
        [int] $ProjectId,
    
        [Parameter(Mandatory = $true)]
        [Alias('Token')]
        [string] $BearerToken,
        
        [Parameter(Mandatory = $true)]
        [string] $SourceEnvironmentId,
        
        [Parameter(Mandatory = $true)]
        [string] $BackupName,

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

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    Write-PSFMessage -Level Verbose -Message "Json payload for LCS generated." -Target $jsonFile
    
    $client = New-Object -TypeName System.Net.Http.HttpClient
    $client.DefaultRequestHeaders.Clear()
    $client.DefaultRequestHeaders.UserAgent.ParseAdd("d365fo.tools via PowerShell")
    
    $deployUri = "$LcsApiUri/databasemovement/v1/export/project/$($ProjectId)/environment/$($SourceEnvironmentId)/backupName/$($BackupName)"

    $request = New-JsonRequest -Uri $deployUri -Token $BearerToken -HttpMethod "POST"

    try {
        Write-PSFMessage -Level Verbose -Message "Invoke LCS request: $($request.RequestUri)" -Target $request.RequestUri
        $result = Get-AsyncResult -task $client.SendAsync($request)

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS."
        $responseString = Get-AsyncResult -task $result.Content.ReadAsStringAsync()

        Write-PSFMessage -Level Verbose -Message "Parsing the response string into a json object." -Target $responseString
        
        try {
            $exportJob = ConvertFrom-Json -InputObject $responseString -ErrorAction SilentlyContinue
        }
        catch {
            Write-PSFMessage -Level Critical -Message "$responseString"
        }
    
        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS." -Target $exportJob

        if (-not ($result.StatusCode -eq [System.Net.HttpStatusCode]::OK)) {
            if (($exportJob) -and ($exportJob.ErrorMessage)) {
                $errorText = ""
                if ($exportJob.OperationActivityId) {
                    $errorText = "Error in request for database refresh of environment: '$( $exportJob.ErrorMessage)' (Activity Id: '$( $exportJob.OperationActivityId)')"
                }
                else {
                    $errorText = "Error in request for database refresh of environment: '$( $exportJob.ErrorMessage)'"
                }
            }
            elseif ($exportJob.OperationActivityId) {
                $errorText = "API Call returned $($result.StatusCode): $($result.ReasonPhrase) (Activity Id: '$($exportJob.OperationActivityId)')"
            }
            else {
                $errorText = "API Call returned $($result.StatusCode): $($result.ReasonPhrase)"
            }

            Write-PSFMessage -Level Host -Message "Error performing database refresh of environment." -Target $($exportJob.ErrorMessage)
            Write-PSFMessage -Level Host -Message $errorText -Target $($result.ReasonPhrase)
            Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        }

        
        if (-not ($exportJob.IsSuccess)) {
            if ( $exportJob.ErrorMessage) {
                $errorText = "Error in request for database refresh of environment: '$( $exportJob.ErrorMessage)' (Activity Id: '$( $exportJob.OperationActivityId)')"
            }
            elseif ( $exportJob.OperationActivityId) {
                $errorText = "Error in request for database refresh of environment. Activity Id: '$($activity.OperationActivityId)'"
            }
            else {
                $errorText = "Unknown error in request for database refresh."
            }

            Write-PSFMessage -Level Host -Message "Unknown error requesting database refresh." -Target $exportJob
            Write-PSFMessage -Level Host -Message $errorText -Target $($result.ReasonPhrase)
            Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the LCS API." -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        return
    }

    Invoke-TimeSignal -End
    
    $exportJob
}


<#
    .SYNOPSIS
        Start a database refresh between 2 environments
         
    .DESCRIPTION
        Start a database refresh between 2 environments from a LCS project
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
    .PARAMETER SourceEnvironmentId
        The unique id of the environment that you want to use as the source for the database refresh
         
        The Id can be located inside the LCS portal
         
    .PARAMETER TargetEnvironmentId
        The unique id of the environment that you want to use as the target for the database refresh
         
        The Id can be located inside the LCS portal
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
    .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:\> Start-LcsDatabaseRefresh -ProjectId 123456789 -SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae" -TargetEnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e" -BearerToken "JldjfafLJdfjlfsalfd..." -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will start the database refresh between the Source and Target environments.
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The source environment is identified by the SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae", which can be obtained in the LCS portal.
        The target environment is identified by the TargetEnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
        The request will authenticate with the BearerToken "JldjfafLJdfjlfsalfd...".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .LINK
        Get-LcsDatabaseOperationStatus
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, LCS, Upload, Api, AAD, Token, Deployment, Deployable Package
         
        Author: M�tz Jensen (@Splaxi)
#>


function Start-LcsDatabaseRefresh {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Cmdletbinding()]
    param(
        [Parameter(Mandatory = $true)]
        [int] $ProjectId,
    
        [Parameter(Mandatory = $true)]
        [Alias('Token')]
        [string] $BearerToken,
        
        [Parameter(Mandatory = $true)]
        [string] $SourceEnvironmentId,
        
        [Parameter(Mandatory = $true)]
        [string] $TargetEnvironmentId,

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

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    Write-PSFMessage -Level Verbose -Message "Json payload for LCS generated." -Target $jsonFile
    
    $client = New-Object -TypeName System.Net.Http.HttpClient
    $client.DefaultRequestHeaders.Clear()
    $client.DefaultRequestHeaders.UserAgent.ParseAdd("d365fo.tools via PowerShell")
    
    $deployUri = "$LcsApiUri/databasemovement/v1/refresh/project/$($ProjectId)/source/$($SourceEnvironmentId)/target/$($TargetEnvironmentId)"

    $request = New-JsonRequest -Uri $deployUri -Token $BearerToken -HttpMethod "POST"

    try {
        Write-PSFMessage -Level Verbose -Message "Invoke LCS request."
        $result = Get-AsyncResult -task $client.SendAsync($request)

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS."
        $responseString = Get-AsyncResult -task $result.Content.ReadAsStringAsync()

        try {
            $refreshJob = ConvertFrom-Json -InputObject $responseString -ErrorAction SilentlyContinue
        }
        catch {
            Write-PSFMessage -Level Critical -Message "$responseString"
        }

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS." -Target $refreshJob
        
        if (-not ($result.StatusCode -eq [System.Net.HttpStatusCode]::OK)) {
            if (($refreshJob) -and ($refreshJob.ErrorMessage)) {
                $errorText = ""
                if ($refreshJob.OperationActivityId) {
                    $errorText = "Error in request for database refresh of environment: '$( $refreshJob.ErrorMessage)' (Activity Id: '$( $refreshJob.OperationActivityId)')"
                }
                else {
                    $errorText = "Error in request for database refresh of environment: '$( $refreshJob.ErrorMessage)'"
                }
            }
            elseif ($refreshJob.OperationActivityId) {
                $errorText = "API Call returned $($result.StatusCode): $($result.ReasonPhrase) (Activity Id: '$($refreshJob.OperationActivityId)')"
            }
            else {
                $errorText = "API Call returned $($result.StatusCode): $($result.ReasonPhrase)"
            }

            Write-PSFMessage -Level Host -Message "Error performing database refresh of environment." -Target $($refreshJob.ErrorMessage)
            Write-PSFMessage -Level Host -Message $errorText -Target $($result.ReasonPhrase)
            Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        }

        
        if (-not ($refreshJob.IsSuccess)) {
            if ( $refreshJob.ErrorMessage) {
                $errorText = "Error in request for database refresh of environment: '$( $refreshJob.ErrorMessage)' (Activity Id: '$( $refreshJob.OperationActivityId)')"
            }
            elseif ( $refreshJob.OperationActivityId) {
                $errorText = "Error in request for database refresh of environment. Activity Id: '$($activity.OperationActivityId)'"
            }
            else {
                $errorText = "Unknown error in request for database refresh."
            }

            Write-PSFMessage -Level Host -Message "Unknown error requesting database refresh." -Target $refreshJob
            Write-PSFMessage -Level Host -Message $errorText -Target $($result.ReasonPhrase)
            Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the LCS API." -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        return
    }

    Invoke-TimeSignal -End
    
    $refreshJob
}


<#
    .SYNOPSIS
        Start LCS deployment
         
    .DESCRIPTION
        Start the deployment of a deployable package from the LCS API
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
    .PARAMETER AssetId
        The unique id of the asset / file that you are trying to deploy from LCS
         
    .PARAMETER EnvironmentId
        The unique id of the environment that you want to work against
         
        The Id can be located inside the LCS portal
         
    .PARAMETER UpdateName
        Name of the update when you are working against Self-Service environments
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
    .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:\> Start-LcsDeployment -BearerToken "Bearer JldjfafLJdfjlfsalfd..." -ProjectId 123456789 -AssetId "958ae597-f089-4811-abbd-c1190917eaae" -EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e" -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will start the deployment of the file located in the Asset Library.
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The file is identified by the AssetId "958ae597-f089-4811-abbd-c1190917eaae", which is obtained either by earlier upload or simply looking in the LCS portal.
        The environment is identified by the EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
        The request will authenticate with the BearerToken "Bearer JldjfafLJdfjlfsalfd...".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, LCS, Upload, Api, AAD, Token, Deployment, Deployable Package
         
        Author: M�tz Jensen (@Splaxi)
#>


function Start-LcsDeployment {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Cmdletbinding()]
    param(
        [Parameter(Mandatory = $true)]
        [int] $ProjectId,
    
        [Parameter(Mandatory = $true)]
        [Alias('Token')]
        [string] $BearerToken,

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

        [Parameter(Mandatory = $true)]
        [string] $EnvironmentId,
        
        [Parameter(Mandatory = $false)]
        [string] $UpdateName,

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

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    Write-PSFMessage -Level Verbose -Message "Json payload for LCS generated." -Target $jsonFile
    
    $client = New-Object -TypeName System.Net.Http.HttpClient
    $client.DefaultRequestHeaders.Clear()
    $client.DefaultRequestHeaders.UserAgent.ParseAdd("d365fo.tools via PowerShell")
    
    $lcsRequestUri = "$LcsApiUri/environment/v2/applyupdate/project/$($ProjectId)/environment/$($EnvironmentId)/asset/$($AssetId)?updateName=$($UpdateName)"

    $request = New-JsonRequest -Uri $lcsRequestUri -Token $BearerToken -HttpMethod "POST"

    try {
        Write-PSFMessage -Level Verbose -Message "Invoke LCS request."
        $result = Get-AsyncResult -task $client.SendAsync($request)

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS."
        $responseString = Get-AsyncResult -task $result.Content.ReadAsStringAsync()

        try {
            $lcsResponseObject = ConvertFrom-Json -InputObject $responseString -ErrorAction SilentlyContinue
        }
        catch {
            Write-PSFMessage -Level Critical -Message "$responseString"
        }

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS." -Target $lcsResponseObject
        
        #This IF block might be obsolute based on the V2 implementation
        if (-not ($result.StatusCode -eq [System.Net.HttpStatusCode]::OK)) {
            if (($lcsResponseObject) -and ($lcsResponseObject.Message)) {
        
                if ($lcsResponseObject.ActivityId) {
                    $errorText = "Error $( $lcsResponseObject.LcsErrorCode) in request for status of environment servicing action: '$( $lcsResponseObject.Message)' (Activity Id: '$( $lcsResponseObject.ActivityId)')"
                }
                else {
                    $errorText = "Error $( $lcsResponseObject.LcsErrorCode) in request for status of environment servicing action: '$( $lcsResponseObject.Message)'"
                }
            }
            elseif ($lcsResponseObject.ActivityId) {
                $errorText = "API Call returned $($result.StatusCode): $($result.ReasonPhrase) (Activity Id: '$($lcsResponseObject.ActivityId)')"
            }
            else {
                $errorText = "API Call returned $($result.StatusCode): $($result.ReasonPhrase)"
            }

            Write-PSFMessage -Level Host -Message "Error creating new file lcsResponseObject." -Target $($lcsResponseObject.Message)
            Write-PSFMessage -Level Host -Message $errorText -Target $($result.ReasonPhrase)
            Stop-PSFFunction -Message "Stopping because of errors"
        }

        
        if (-not ( $lcsResponseObject.OperationStatus)) {
            if ( $lcsResponseObject.Message) {
                $errorText = "Error in request for deploying asset to enviroment: '$($lcsResponseObject.Message)')"
            }
            elseif ( $lcsResponseObject.ErrorMessage) {
                $errorText = "Error in request for deploying asset to enviroment: '$($lcsResponseObject.ErrorMessage)' (OperationActivityId: '$($lcsResponseObject.OperationActivityId)')"
            }
            elseif ($lcsResponseObject.OperationActivityId -or $lcsResponseObject.ActivityId) {
                $errorText = "Error in request for deploying asset to environment. (OperationActivityId: '$($lcsResponseObject.OperationActivityId)')"
            }
            else {
                $errorText = "Unknown in request for deploying asset to environment."
            }

            Write-PSFMessage -Level Host -Message "Unknown in request for deploying asset to environment." -Target $lcsResponseObject
            Write-PSFMessage -Level Host -Message $errorText -Target $($result.ReasonPhrase)
            Stop-PSFFunction -Message "Stopping because of errors"
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the LCS API." -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }

    Invoke-TimeSignal -End
    
    $lcsResponseObject
}


<#
    .SYNOPSIS
        Start the upload process to LCS
         
    .DESCRIPTION
        Start the flow of actions to upload a file to LCS
         
    .PARAMETER Token
        The token to be used for the http request against the LCS API
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
    .PARAMETER FileType
        Type of file you want to upload
         
        Valid options:
        "Model"
        "Process Data Package"
        "Software Deployable Package"
        "GER Configuration"
        "Data Package"
        "PowerBI Report Model"
        "E-Commerce Package"
        "NuGet Package"
        "Retail Self-Service Package"
        "Commerce Cloud Scale Unit Extension"
         
    .PARAMETER Name
        Name to be assigned / shown on LCS
         
    .PARAMETER Description
        Description to be assigned / shown on LCS
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
    .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:\> Start-LcsUpload -Token "Bearer JldjfafLJdfjlfsalfd..." -ProjectId 123456789 -FileType "SoftwareDeployablePackage" -Name "ReadyForTesting" -Description "Latest release that fixes it all" -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will contact the NON-EUROPE LCS API and instruct it that we want to upload a new file to the Asset Library.
        The token "Bearer JldjfafLJdfjlfsalfd..." is used to the authorize against the LCS API.
        The ProjectId is 123456789 and FileType is "SoftwareDeployablePackage".
        The file will be named "ReadyForTesting" and the Description will be "Latest release that fixes it all".
         
    .NOTES
        Tags: Url, LCS, Upload, Api, Token
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Start-LcsUpload {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Cmdletbinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string] $Token,

        [Parameter(Mandatory = $true)]
        [int] $ProjectId,

        [Parameter(Mandatory = $true)]
        [LcsAssetFileType] $FileType,

        [Parameter(Mandatory = $false)]
        [string] $Name,

        [Parameter(Mandatory = $false)]
        [string] $Description,

        [Parameter(Mandatory = $false)]
        [string] $LcsApiUri,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    if ($Description -eq "") {
        $jsonDescription = "null"
    }
    else {
        $jsonDescription = "`"$Description`""
    }

    $fileTypeValue = [int]$FileType
    $jsonFile = "{ `"Name`": `"$Name`", `"FileName`": `"$fileName`", `"FileDescription`": $jsonDescription, `"SizeByte`": 0, `"FileType`": $fileTypeValue }"

    Write-PSFMessage -Level Verbose -Message "Json payload for LCS generated." -Target $jsonFile
    
    $client = New-Object -TypeName System.Net.Http.HttpClient
    $client.DefaultRequestHeaders.Clear()
    $client.DefaultRequestHeaders.UserAgent.ParseAdd("d365fo.tools via PowerShell")
    
    $createUri = "$LcsApiUri/box/fileasset/CreateFileAsset/$ProjectId"

    $request = New-JsonRequest -Uri $createUri -Content $jsonFile -Token $Token

    try {
        Write-PSFMessage -Level Verbose -Message "Invoke LCS request." -Target $request
        $result = Get-AsyncResult -task $client.SendAsync($request)

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS."
        $responseString = Get-AsyncResult -task $result.Content.ReadAsStringAsync()

        Write-PSFMessage -Level Verbose -Message "Extracting the response received from LCS." -Target $responseString
        
        try {
            $asset = ConvertFrom-Json -InputObject $responseString -ErrorAction SilentlyContinue
        }
        catch {
            Write-PSFMessage -Level Critical -Message "$responseString"
        }
    
        Write-PSFMessage -Level Verbose -Message "Extracting the asset json response received from LCS." -Target $asset
        
        if (-not ($result.StatusCode -eq [System.Net.HttpStatusCode]::OK)) {
            if (($asset) -and ($asset.Message)) {
                Write-PSFMessage -Level Host -Message "Error creating new file asset." -Target $($asset.Message)
                Stop-PSFFunction -Message "Stopping because of errors"
            }
            else {
                Write-PSFMessage -Level Host -Message "API Call returned $($result.StatusCode)." -Target $($result.ReasonPhrase)
                Stop-PSFFunction -Message "Stopping because of errors"
            }
        }

        if (-not ($asset.Id)) {
            if ($asset.Message) {
                Write-PSFMessage -Level Host -Message "Error creating new file asset." -Target $($asset.Message)
                Stop-PSFFunction -Message "Stopping because of errors"
            }
            else {
                Write-PSFMessage -Level Host -Message "Unknown error creating new file asset." -Target $asset
                Stop-PSFFunction -Message "Stopping because of errors"
            }
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the LCS API." -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }

    Invoke-TimeSignal -End
    
    $asset
}


<#
    .SYNOPSIS
        Test to see if a given user ID exists
         
    .DESCRIPTION
        Test to see if a given user ID exists in the Dynamics 365 for Finance & Operations instance
         
    .PARAMETER SqlCommand
        The SQL Command object that should be used when testing the user ID
         
    .PARAMETER Id
        Id of the user that you want to test exists or not
         
    .EXAMPLE
        PS C:\> $SqlCommand = Get-SqlCommand -DatabaseServer localhost -DatabaseName AxDB -SqlUser User123 -SqlPwd "Password123"
        PS C:\> Test-AadUserIdInD365FO -SqlCommand $SqlCommand -Id "TestUser"
         
        This will get a SqlCommand object that will connect to the localhost server and the AXDB database, with the sql credential "User123".
        It will query the the database for any user with the Id "TestUser".
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>


function Test-AadUserIdInD365FO {

    param (
        [System.Data.SqlClient.SqlCommand] $SqlCommand,
        [string] $Id
    )

    $commandText = (Get-Content "$script:ModuleRoot\internal\sql\test-aaduseridind365fo.sql") -join [Environment]::NewLine

    $sqlCommand.CommandText = $commandText

    $null = $sqlCommand.Parameters.Add("@Id", $Id)

    Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

    $NumFound = $sqlCommand.ExecuteScalar()

    Write-PSFMessage -Level Verbose -Message  "Number of user rows found in database $NumFound" -Target $NumFound
    $SqlCommand.Parameters.Clear()

    $NumFound -ne 0
}


<#
    .SYNOPSIS
        Test to see if a given user already exists
         
    .DESCRIPTION
        Test to see if a given user already exists in the Dynamics 365 for Finance & Operations instance
         
    .PARAMETER SqlCommand
        The SQL Command object that should be used when testing the user
         
    .PARAMETER SignInName
        The sign in name (email address) for the user that you want test
         
    .EXAMPLE
        PS C:\> $SqlCommand = Get-SqlCommand -DatabaseServer localhost -DatabaseName AxDB -SqlUser User123 -SqlPwd "Password123"
        PS C:\> Test-AadUserInD365FO -SqlCommand $SqlCommand -SignInName "Claire@contoso.com"
         
        This will get a SqlCommand object that will connect to the localhost server and the AXDB database, with the sql credential "User123".
        It will query the the database for the user with the e-mail address "Claire@contoso.com".
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Test-AadUserInD365FO {
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.Data.SqlClient.SqlCommand] $SqlCommand,

        [Parameter(Mandatory = $true)]
        [string] $SignInName
    )

    $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\test-aaduserind365fo.sql") -join [Environment]::NewLine

    $null = $sqlCommand.Parameters.Add("@Email", $SignInName)

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $NumFound = $sqlCommand.ExecuteScalar()

        Write-PSFMessage -Level Verbose -Message "Number of user rows found in database $NumFound" -Target $NumFound
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        return
    }
    finally {
        $SqlCommand.Parameters.Clear()
    }

    $NumFound -ne 0
}


<#
    .SYNOPSIS
        Test if any D365 assemblies are loaded
         
    .DESCRIPTION
        Test if any D365 assemblies are loaded into memory and will be a blocking issue
         
    .EXAMPLE
        PS C:\> Test-AssembliesLoaded
         
        This will test in any D365 specific assemblies are loaded into memory.
        If is, a Stop-PSFFunction test will state that we should stop execution.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>


function Test-AssembliesLoaded {
    [CmdletBinding()]
    [OutputType()]
    param (
    )

    Invoke-TimeSignal -Start

    $assembliesLoaded = [System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object Location -ne $null

    $assembliesBlocking = $assembliesLoaded.location -match "AOSService|Dynamics|PackagesLocalDirectory"

    if ($assembliesBlocking.Count -gt 0) {
        Stop-PSFFunction -Message "Stopping because some assembly (DLL) files seems to be loaded into memory." -StepsUpward 1
        return
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Test accessible to the configuration storage
         
    .DESCRIPTION
        Test if the desired configuration storage is accessible with the current user context
         
    .PARAMETER ConfigStorageLocation
        Parameter used to instruct where to store the configuration objects
         
        The default value is "User" and this will store all configuration for the active user
         
        Valid options are:
        "User"
        "System"
         
        "System" will store the configuration so all users can access the configuration objects
         
    .EXAMPLE
        PS C:\> Test-ConfigStorageLocation -ConfigStorageLocation "System"
         
        This will test if the current executing user has enough privileges to save to the system wide configuration storage.
        The system wide configuration storage requires administrator rights.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Test-ConfigStorageLocation {
    [CmdletBinding()]
    [OutputType('System.String')]
    param (
        [ValidateSet('User', 'System')]
        [string] $ConfigStorageLocation = "User"
    )
    
    $configScope = "UserDefault"

    if ($ConfigStorageLocation -eq "System") {
        if ($Script:IsAdminRuntime) {
            $configScope = "SystemDefault"
        }
        else {
            Write-PSFMessage -Level Host -Message "Unable to locate save the <c='em'>configuration objects</c> in the <c='em'>system wide configuration store</c> on the machine. Please start an elevated session and run the cmdlet again."
            Stop-PSFFunction -Message "Elevated permissions needed. Please start an elevated session and run the cmdlet again." -StepsUpward 1
            return
        }
    }

    $configScope
}


<#
    .SYNOPSIS
        Test multiple paths
         
    .DESCRIPTION
        Easy way to test multiple paths for public functions and have the same error handling
         
    .PARAMETER Path
        Array of paths you want to test
         
        They have to be the same type, either file/leaf or folder/container
         
    .PARAMETER Type
        Type of path you want to test
         
        Either 'Leaf' or 'Container'
         
    .PARAMETER Create
        Instruct the cmdlet to create the directory if it doesn't exist
         
    .PARAMETER ShouldNotExist
        Instruct the cmdlet to return true if the file doesn't exists
         
    .EXAMPLE
        PS C:\> Test-PathExists "c:\temp","c:\temp\dir" -Type Container
         
        This will test if the mentioned paths (folders) exists and the current context has enough permission.
         
    .NOTES
        Author: M�tz Jensen (@splaxi)
         
#>

function Test-PathExists {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param (
        [Parameter(Mandatory = $True)]
        [AllowEmptyString()]
        [string[]] $Path,

        [ValidateSet('Leaf', 'Container')]
        [Parameter(Mandatory = $True)]
        [string] $Type,

        [switch] $Create,

        [switch] $ShouldNotExist
    )
    
    $res = $false

    $arrList = New-Object -TypeName "System.Collections.ArrayList"
         
    foreach ($item in $Path) {

        if ([string]::IsNullOrEmpty($item)) {
            Stop-PSFFunction -Message "Stopping because path was either null or empty string." -StepsUpward 1
            return
        }

        Write-PSFMessage -Level Verbose -Message "Testing the path: $item" -Target $item
        $temp = Test-Path -Path $item -Type $Type

        if ((-not $temp) -and ($Create) -and ($Type -eq "Container")) {
            Write-PSFMessage -Level Verbose -Message "Creating the path: $item" -Target $item
            $null = New-Item -Path $item -ItemType Directory -Force -ErrorAction Stop
            $temp = $true
        }
        elseif ($ShouldNotExist) {
            Write-PSFMessage -Level Verbose -Message "The should NOT exists: $item" -Target $item
        }
        elseif ((-not $temp) -and ($WarningPreference -ne [System.Management.Automation.ActionPreference]::SilentlyContinue)) {
            Write-PSFMessage -Level Host -Message "The <c='em'>$item</c> path wasn't found. Please ensure the path <c='em'>exists</c> and you have enough <c='em'>permission</c> to access the path."
        }
        
        $null = $arrList.Add($temp)
    }

    if ($arrList.Contains($false) -and (-not $ShouldNotExist)) {
        # The $ErrorActionPreference variable determines the behavior we are after, but the "Stop-PSFFunction -WarningAction" is where we need to put in the value.
        Stop-PSFFunction -Message "Stopping because of missing paths." -StepsUpward 1 -WarningAction $ErrorActionPreference
        
    }
    elseif ($arrList.Contains($true) -and $ShouldNotExist) {
        # The $ErrorActionPreference variable determines the behavior we are after, but the "Stop-PSFFunction -WarningAction" is where we need to put in the value.
        Stop-PSFFunction -Message "Stopping because file exists." -StepsUpward 1 -WarningAction $ErrorActionPreference
    }
    else {
        $res = $true
    }

    $res
}


<#
    .SYNOPSIS
        Test if a given registry key exists or not
         
    .DESCRIPTION
        Test if a given registry key exists in the path specified
         
    .PARAMETER Path
        Path to the registry hive and sub directories you want to work against
         
    .PARAMETER Name
        Name of the registry key that you want to test for
         
    .EXAMPLE
        PS C:\> Test-RegistryValue -Path "HKLM:\SOFTWARE\Microsoft\Dynamics\Deployment\" -Name "InstallationInfoDirectory"
         
        This will query the LocalMachine hive and the sub directories "HKLM:\SOFTWARE\Microsoft\Dynamics\Deployment\" for a registry key with the name of "InstallationInfoDirectory".
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

Function Test-RegistryValue {
    [OutputType('System.Boolean')]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,
        
        [Parameter(Mandatory = $true)]
        [string]$Name
    )

    if (Test-Path -Path $Path -PathType Any) {
        $null -ne (Get-ItemProperty $Path).$Name
    }
    else {
        $false
    }
}


<#
    .SYNOPSIS
        Test PSBoundParameters whether or not to support TrustedConnection
         
    .DESCRIPTION
        Test callers PSBoundParameters (HashTable) for details that determines whether or not a SQL Server connection should support TrustedConnection or not
         
    .PARAMETER Inputs
        HashTable ($PSBoundParameters) with the parameters from the callers invocation
         
    .EXAMPLE
        PS C:\> $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters
         
        This will send the entire HashTable from the callers invocation, containing all explicit defined parameters to be analyzed whether or not the SQL Server connection should support TrustedConnection or not.
         
    .NOTES
        Author: M�tz Jensen (@splaxi)
         
#>

function Test-TrustedConnection {
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param (
        [HashTable] $Inputs
    )

    if (($Inputs.ContainsKey("ImportModeTier2")) -or ($Inputs.ContainsKey("ExportModeTier2"))){
        Write-PSFMessage -Level Verbose -Message "Not capable of using Trusted Connection based on Tier validation."
        $false
    }
    elseif (($Inputs.ContainsKey("SqlUser")) -or ($Inputs.ContainsKey("SqlPwd"))) {
        Write-PSFMessage -Level Verbose -Message "Not capable of using Trusted Connection based on supplied SQL login details."
        $false
    }
    elseif ($Inputs.ContainsKey("TrustedConnection")) {
        Write-PSFMessage -Level Verbose -Message "The script was calling with TrustedConnection directly. This overrides all other logic in respect that the caller should know what it is doing. Value was: $($Inputs.TrustedConnection)" -Tag $Inputs.TrustedConnection
        $Inputs.TrustedConnection
    }
    else {
        Write-PSFMessage -Level Verbose -Message "Capabilities based on the centralized logic in the psm1 file." -Target $Script:CanUseTrustedConnection
        $Script:CanUseTrustedConnection
    }
}


<#
    .SYNOPSIS
        Update the Azure Storage config variables
         
    .DESCRIPTION
        Update the active Azure Storage config variables that the module will use as default values
         
    .EXAMPLE
        PS C:\> Update-AzureStorageVariables
         
        This will update the Azure Storage variables.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
#>


function Update-AzureStorageVariables {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    [OutputType()]
    param ( )
    
    $hashParameters = Get-D365ActiveAzureStorageConfig

    foreach ($item in $hashParameters.Keys) {
            
        $name = "AzureStorage" + (Get-Culture).TextInfo.ToTitleCase($item)
        
        Write-PSFMessage -Level Verbose -Message "$name - $($hashParameters[$item])" -Target $hashParameters[$item]
        Set-Variable -Name $name -Value $hashParameters[$item] -Scope Script
    }
}


<#
    .SYNOPSIS
        Update the broadcast message config variables
         
    .DESCRIPTION
        Update the active broadcast message config variables that the module will use as default values
         
    .EXAMPLE
        PS C:\> Update-BroadcastVariables
         
        This will update the broadcast variables.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
#>


function Update-BroadcastVariables {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    [OutputType()]
    param ( )

    $configName = (Get-PSFConfig -FullName "d365fo.tools.active.broadcast.message.config.name").Value.ToString().ToLower()
    if (-not ($configName -eq "")) {
        $hashParameters = Get-D365ActiveBroadcastMessageConfig -OutputAsHashtable
        foreach ($item in $hashParameters.Keys) {
            if ($item -eq "name") { continue }
            
            $name = "Broadcast" + (Get-Culture).TextInfo.ToTitleCase($item)
        
            $valueMessage = $hashParameters[$item]

            if ($item -like "*client*" -and $valueMessage.Length -gt 20)
            {
                $valueMessage = $valueMessage.Substring(0,18) + "[...REDACTED...]"
            }

            Write-PSFMessage -Level Verbose -Message "$name - $valueMessage" -Target $valueMessage
            Set-Variable -Name $name -Value $hashParameters[$item] -Scope Script
        }
    }
}


<#
    .SYNOPSIS
        Update the LCS API config variables
         
    .DESCRIPTION
        Update the active LCS API config variables that the module will use as default values
         
    .EXAMPLE
        PS C:\> Update-LcsApiVariables
         
        This will update the LCS API variables.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
#>


function Update-LcsApiVariables {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    [OutputType()]
    param ( )
    
    $hashParameters = Get-D365LcsApiConfig -OutputAsHashtable

    foreach ($item in $hashParameters.Keys) {
            
        $name = "LcsApi" + (Get-Culture).TextInfo.ToTitleCase($item)
        
        $valueMessage = $hashParameters[$item]

        if ($item -like "*client*" -and $valueMessage.Length -gt 20)
        {
            $valueMessage = $valueMessage.Substring(0,18) + "[...REDACTED...]"
        }

        Write-PSFMessage -Level Verbose -Message "$name - $valueMessage" -Target $valueMessage
        Set-Variable -Name $name -Value $hashParameters[$item] -Scope Script
    }
}


<#
    .SYNOPSIS
        Update module variables
         
    .DESCRIPTION
        Loads configuration variables again, to make sure things are updated based on changed configuration
         
    .EXAMPLE
        PS C:\> Update-ModuleVariables
         
        This will update internal variables that the module is dependent on.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
#>


function Update-ModuleVariables {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    [OutputType()]
    param ( )

    Update-PsfConfigVariables

    $Script:AADOAuthEndpoint = Get-PSFConfigValue -FullName "d365fo.tools.azure.common.oauth.token"
}


<#
    .SYNOPSIS
        Update the module variables based on the PSF Configuration store
         
    .DESCRIPTION
        Will read the current PSF Configuration store and create local module variables
         
    .EXAMPLE
        PS C:\> Update-PsfConfigVariables
         
        This will read all relevant PSF Configuration values and create matching module variables.
         
    .NOTES
        Author: M�tz Jensen (@splaxi)
#>


function Update-PsfConfigVariables {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]

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

    foreach ($config in Get-PSFConfig -FullName "d365fo.tools.path.*") {
        $item = $config.FullName.Replace("d365fo.tools.path.", "")
        $name = (Get-Culture).TextInfo.ToTitleCase($item) + "Path"
        
        Set-Variable -Name $name -Value $config.Value -Scope Script
    }
}


<#
    .SYNOPSIS
        Update the topology file
         
    .DESCRIPTION
        Update the topology file based on the already installed list of services on the machine
         
    .PARAMETER Path
        Path to the folder where the topology XML file that you want to work against is placed
         
        Should only contain a path to a folder, not a file
         
    .EXAMPLE
        PS C:\> Update-TopologyFile -Path "c:\temp\d365fo.tools\DefaultTopologyData.xml"
         
        This will update the "c:\temp\d365fo.tools\DefaultTopologyData.xml" file with all the installed services on the machine.
         
    .NOTES
        # Credit http://dev.goshoom.net/en/2016/11/installing-deployable-packages-with-powershell/
         
        Author: Tommy Skaue (@Skaue)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Update-TopologyFile {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param (
        [Parameter(Mandatory = $true)]
        [string]$Path
    )
    
    $topologyFile = Join-Path $Path 'DefaultTopologyData.xml'
                
    Write-PSFMessage -Level Verbose "Creating topology file: $topologyFile"
                
    [xml]$xml = Get-Content $topologyFile
    $machine = $xml.TopologyData.MachineList.Machine
    $machine.Name = $env:computername
                
    $serviceModelList = $machine.ServiceModelList
    $null = $serviceModelList.RemoveAll()
 
    [System.Collections.ArrayList] $Files2Process = New-Object -TypeName "System.Collections.ArrayList"

    $null = $Files2Process.Add((Join-Path $Path 'Microsoft.Dynamics.AX.AXInstallationInfo.dll'))
    Import-AssemblyFileIntoMemory -Path $($Files2Process.ToArray())
 
    $models = [Microsoft.Dynamics.AX.AXInstallationInfo.AXInstallationInfo]::GetInstalledServiceModel()

    foreach ($name in $models.Name) {
        $element = $xml.CreateElement('string')
        $element.InnerText = $name
        $serviceModelList.AppendChild($element)
    }
    
    $xml.Save($topologyFile)
    
    $true
}


<#
    .SYNOPSIS
        Save an Azure Storage Account config
         
    .DESCRIPTION
        Adds an Azure Storage Account config to the configuration store
         
    .PARAMETER Name
        The logical name of the Azure Storage Account you are about to registered in the configuration store
         
    .PARAMETER AccountId
        The account id for the Azure Storage Account you want to register in the configuration store
         
    .PARAMETER AccessToken
        The access token for the Azure Storage Account you want to register in the configuration store
         
    .PARAMETER SAS
        The SAS key that you have created for the storage account or blob container
         
    .PARAMETER Container
        The name of the blob container inside the Azure Storage Account you want to register in the configuration store
         
    .PARAMETER Temporary
        Instruct the cmdlet to only temporarily add the azure storage account configuration in the configuration store
         
    .PARAMETER Force
        Switch to instruct the cmdlet to overwrite already registered Azure Storage Account entry
         
    .EXAMPLE
        PS C:\> Add-D365AzureStorageConfig -Name "UAT-Exports" -AccountId "1234" -AccessToken "dafdfasdfasdf" -Container "testblob"
         
        This will add an entry into the list of Azure Storage Accounts that is stored with the name "UAT-Exports" with AccountId "1234", AccessToken "dafdfasdfasdf" and blob container "testblob".
         
    .EXAMPLE
        PS C:\> Add-D365AzureStorageConfig -Name UAT-Exports -SAS "sv2018-03-28&siunlisted&src&sigAUOpdsfpoWE976ASDhfjkasdf(5678sdfhk" -AccountId "1234" -Container "testblob"
         
        This will add an entry into the list of Azure Storage Accounts that is stored with the name "UAT-Exports" with AccountId "1234", SAS "sv=2018-03-28&si=unlisted&sr=c&sig=AUOpdsfpoWE976ASDhfjkasdf(5678sdfhk" and blob container "testblob".
        The SAS key enables you to provide explicit access to a given blob container inside an Azure Storage Account.
        The SAS key can easily be revoked and that way you have control over the access to the container and its content.
         
    .EXAMPLE
        PS C:\> Add-D365AzureStorageConfig -Name UAT-Exports -SAS "sv2018-03-28&siunlisted&src&sigAUOpdsfpoWE976ASDhfjkasdf(5678sdfhk" -AccountId "1234" -Container "testblob" -Temporary
         
        This will add an entry into the list of Azure Storage Accounts that is stored with the name "UAT-Exports" with AccountId "1234", SAS "sv=2018-03-28&si=unlisted&sr=c&sig=AUOpdsfpoWE976ASDhfjkasdf(5678sdfhk" and blob container "testblob".
        The SAS key enables you to provide explicit access to a given blob container inside an Azure Storage Account.
        The SAS key can easily be revoked and that way you have control over the access to the container and its content.
         
        The configuration will only last for the rest of this PowerShell console session.
         
    .NOTES
        Tags: Azure, Azure Storage, Config, Configuration, Token, Blob, Container
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Add-D365AzureStorageConfig {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $Name,

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

        [Parameter(Mandatory = $true, ParameterSetName = "AccessToken")]
        [string] $AccessToken,

        [Parameter(Mandatory = $true, ParameterSetName = "SAS")]
        [string] $SAS,

        [Parameter(Mandatory = $true)]
        [Alias('Blob')]
        [Alias('Blobname')]
        [string] $Container,

        [switch] $Temporary,

        [switch] $Force
    )
    
    $Details = @{AccountId = $AccountId.ToLower();
        Container          = $Container.ToLower();
    }

    if ($PSCmdlet.ParameterSetName -eq "AccessToken") { $Details.AccessToken = $AccessToken }
    if ($PSCmdlet.ParameterSetName -eq "SAS") {
        if ($SAS.StartsWith("?")) {
            $SAS = $SAS.Substring(1)
        }

        $Details.SAS = $SAS
    }

    $Accounts = [hashtable](Get-PSFConfigValue -FullName "d365fo.tools.azure.storage.accounts")

    if ($Accounts.ContainsKey($Name)) {
        if ($Force) {
            $Accounts[$Name] = $Details

            Set-PSFConfig -FullName "d365fo.tools.azure.storage.accounts" -Value $Accounts
        }
        else {
            Write-PSFMessage -Level Host -Message "An Azure Storage Account with that name <c='em'>already exists</c>. If you want to <c='em'>overwrite</c> the already registered details please supply the <c='em'>-Force</c> parameter."
            Stop-PSFFunction -Message "Stopping because an Azure Storage Account already exists with that name."
            return
        }
    }
    else {
        $null = $Accounts.Add($Name, $Details)

        Set-PSFConfig -FullName "d365fo.tools.azure.storage.accounts" -Value $Accounts
    }

    if (-not $Temporary) { Register-PSFConfig -FullName "d365fo.tools.azure.storage.accounts" -Scope UserDefault }
}


<#
    .SYNOPSIS
        Save a broadcast message config
         
    .DESCRIPTION
        Adds a broadcast message config to the configuration store
         
    .PARAMETER Name
        The logical name of the broadcast configuration you are about to register in the configuration store
         
    .PARAMETER Tenant
        Azure Active Directory (AAD) tenant id (Guid) that the D365FO environment is connected to, that you want to send a message to
         
    .PARAMETER URL
        URL / URI for the D365FO environment you want to send a message to
         
    .PARAMETER ClientId
        The ClientId obtained from the Azure Portal when you created a Registered Application
         
    .PARAMETER ClientSecret
        The ClientSecret obtained from the Azure Portal when you created a Registered Application
         
    .PARAMETER TimeZone
        Id of the Time Zone your environment is running in
         
        You might experience that the local VM running the D365FO is running another Time Zone than the computer you are running this cmdlet from
         
        All available .NET Time Zones can be traversed with tab for this parameter
         
        The default value is "UTC"
         
    .PARAMETER EndingInMinutes
        Specify how many minutes into the future you want this message / maintenance window to last
         
        Default value is 60 minutes
         
        The specified StartTime will always be based on local Time Zone. If you specify a different Time Zone than the local computer is running, the start and end time will be calculated based on your selection.
         
    .PARAMETER OnPremise
        Specify if environnement is an D365 OnPremise
         
        Default value is "Not set" (= Cloud Environnement)
         
    .PARAMETER Temporary
        Instruct the cmdlet to only temporarily add the broadcast message configuration in the configuration store
         
    .PARAMETER Force
        Instruct the cmdlet to overwrite the broadcast message configuration with the same name
         
    .EXAMPLE
        PS C:\> Add-D365BroadcastMessageConfig -Name "UAT" -Tenant "e674da86-7ee5-40a7-b777-1111111111111" -URL "https://usnconeboxax1aos.cloud.onebox.dynamics.com" -ClientId "dea8d7a9-1602-4429-b138-111111111111" -ClientSecret "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522"
         
        This will create a new broadcast message configuration with the name "UAT".
        It will save "e674da86-7ee5-40a7-b777-1111111111111" as the Azure Active Directory guid.
        It will save "https://usnconeboxax1aos.cloud.onebox.dynamics.com" as the D365FO environment.
        It will save "dea8d7a9-1602-4429-b138-111111111111" as the ClientId.
        It will save "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522" as ClientSecret.
        It will use the default value "UTC" Time Zone for converting the different time and dates.
        It will use the default end time which is 60 minutes.
         
    .EXAMPLE
        PS C:\> Add-D365BroadcastMessageConfig -Name "UAT" -OnPremise -Tenant "https://adfs.local/adfs" -URL "https://ax-sandbox.d365fo.local" -ClientId "dea8d7a9-1602-4429-b138-111111111111" -ClientSecret "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522"
         
        This will create a new broadcast message configuration with the name "UAT".
        It will target an OnPremise environment.
        It will save "https://adfs.local/adfs" as the OAuth Tenant Provider.
        It will save "https://ax-sandbox.d365fo.local" as the D365FO environment.
        It will save "dea8d7a9-1602-4429-b138-111111111111" as the ClientId.
        It will save "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522" as ClientSecret.
        It will use the default value "UTC" Time Zone for converting the different time and dates.
        It will use the default end time which is 60 minutes.
         
    .NOTES
        Tags: Servicing, Broadcast, Message, Users, Environment, Config, Configuration, ClientId, ClientSecret
         
        Author: M�tz Jensen (@Splaxi)
         
    .LINK
        Clear-D365ActiveBroadcastMessageConfig
         
    .LINK
        Get-D365ActiveBroadcastMessageConfig
         
    .LINK
        Get-D365BroadcastMessageConfig
         
    .LINK
        Remove-D365BroadcastMessageConfig
         
    .LINK
        Send-D365BroadcastMessage
         
    .LINK
        Set-D365ActiveBroadcastMessageConfig
#>


function Add-D365BroadcastMessageConfig {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $Name,

        [Alias('$AADGuid')]
        [string] $Tenant,

        [Alias('URI')]
        [string] $URL,

        [string] $ClientId,

        [string] $ClientSecret,

        [string] $TimeZone = "UTC",

        [int] $EndingInMinutes = 60,

        [switch] $OnPremise,

        [switch] $Temporary,

        [switch] $Force
    )

    if (((Get-PSFConfig -FullName "d365fo.tools.broadcast.*.name").Value -contains $Name) -and (-not $Force)) {
        Write-PSFMessage -Level Host -Message "A broadcast message configuration with <c='em'>$Name</c> as name <c='em'>already exists</c>. If you want to <c='em'>overwrite</c> the current configuration, please supply the <c='em'>-Force</c> parameter."
        Stop-PSFFunction -Message "Stopping because a broadcast message configuration already exists with that name."
        return
    }

    $configName = ""

    #The ':keys' label is used to have a continue inside the switch statement itself
    :keys foreach ($key in $PSBoundParameters.Keys) {
        
        $configurationValue = $PSBoundParameters.Item($key)
        $configurationName = $key.ToLower()
        $fullConfigName = ""

        Write-PSFMessage -Level Verbose -Message "Working on $key with $configurationValue" -Target $configurationValue
        
        switch ($key) {
            "Name" {
                $configName = $Name.ToLower()
                $fullConfigName = "d365fo.tools.broadcast.$configName.name"
            }

            {"Temporary","Force" -contains $_} {
                continue keys
            }

            "TimeZone" {
                $timeZoneFound = Get-TimeZone -InputObject $TimeZone

                if (Test-PSFFunctionInterrupt) { return }
                
                $fullConfigName = "d365fo.tools.broadcast.$configName.$configurationName"
                $configurationValue = $timeZoneFound.Id
            }

            Default {
                $fullConfigName = "d365fo.tools.broadcast.$configName.$configurationName"
            }
        }

        Write-PSFMessage -Level Verbose -Message "Setting $fullConfigName to $configurationValue" -Target $configurationValue
        Set-PSFConfig -FullName $fullConfigName -Value $configurationValue
        if (-not $Temporary) { Register-PSFConfig -FullName $fullConfigName -Scope UserDefault }
    }
}


<#
    .SYNOPSIS
        Save an environment config
         
    .DESCRIPTION
        Adds an environment config to the configuration store
         
    .PARAMETER Name
        The logical name of the environment you are about to registered in the configuration
         
    .PARAMETER URL
        The URL to the environment you want the module to use when possible
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER Company
        The company you want to work against when calling any browser based cmdlets
         
        The default value is "DAT"
    .PARAMETER TfsUri
        The URI for the TFS / VSTS account that you are working against.
         
    .PARAMETER ConfigStorageLocation
        Parameter used to instruct where to store the configuration objects
         
        The default value is "User" and this will store all configuration for the active user
         
        Valid options are:
        "User"
        "System"
         
        "System" will store the configuration so all users can access the configuration objects
         
    .PARAMETER Force
        Switch to instruct the cmdlet to overwrite already registered environment entry
         
    .EXAMPLE
        PS C:\> Add-D365EnvironmentConfig -Name "Customer-UAT" -URL "https://usnconeboxax1aos.cloud.onebox.dynamics.com/?cmp=USMF" -Company "DAT"
         
        This will add an entry into the list of environments that is stored with the name "Customer-UAT" and with the URL "https://usnconeboxax1aos.cloud.onebox.dynamics.com/?cmp=USMF".
        The company is registered "DAT".
         
    .EXAMPLE
        PS C:\> Add-D365EnvironmentConfig -Name "Customer-UAT" -URL "https://usnconeboxax1aos.cloud.onebox.dynamics.com/?cmp=USMF" -Company "DAT" -SqlUser "SqlAdmin" -SqlPwd "Pass@word1"
         
        This will add an entry into the list of environments that is stored with the name "Customer-UAT" and with the URL "https://usnconeboxax1aos.cloud.onebox.dynamics.com/?cmp=USMF".
        It will register the SqlUser as "SqlAdmin" and the SqlPassword to "Pass@word1".
         
        This it useful for working on Tier 2 environments where the SqlUser and SqlPassword cannot be extracted from the environment itself.
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, Tfs, Vsts, Sql, SqlUser, SqlPwd
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Add-D365EnvironmentConfig {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $Name,
        
        [string] $URL,

        [string] $SqlUser = "sqladmin",

        [string] $SqlPwd,

        [string] $Company = "DAT",

        [string] $TfsUri,

        [switch] $Force
    )

    $Details = @{URL = $URL; Company = $Company;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd;
        TfsUri = $TfsUri;
    }

    $Environments = [hashtable](Get-PSFConfigValue -FullName "d365fo.tools.environments")

    if ($Environments.ContainsKey($Name)) {
        if ($Force) {
            $Environments[$Name] = $Details

            Set-PSFConfig -FullName "d365fo.tools.environments" -Value $Environments
            Register-PSFConfig -FullName "d365fo.tools.environments"
        }
        else {
            Write-PSFMessage -Level Host -Message "An environment with that name <c='em'>already exists</c>. You want to <c='em'>overwrite</c> the already registered details please supply the <c='em'>-Force</c> parameter."
            Stop-PSFFunction -Message "Stopping because an environment already exists with that name."
            return
        }
    }
    else {
        $null = $Environments.Add($Name, $Details)

        Set-PSFConfig -FullName "d365fo.tools.environments" -Value $Environments
        Register-PSFConfig -FullName "d365fo.tools.environments"
    }
}


<#
    .SYNOPSIS
        Save a lcs environment
         
    .DESCRIPTION
        Adds a lcs environment to the configuration store
         
    .PARAMETER Name
        The logical name of the lcs environment you are about to register in the configuration store
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
    .PARAMETER EnvironmentId
        The unique id of the environment that you want to work against
         
        The Id can be located inside the LCS portal
         
    .PARAMETER Temporary
        Instruct the cmdlet to only temporarily add the broadcast message configuration in the configuration store
         
    .PARAMETER Force
        Instruct the cmdlet to overwrite the broadcast message configuration with the same name
         
    .EXAMPLE
        PS C:\> Add-D365LcsEnvironment -Name "UAT" -ProjectId 123456789 -EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e"
         
        This will create a new lcs environment entry.
        The name of the registration is determined by the Name "UAT".
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The environment is identified by the EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
         
    .LINK
        Get-D365LcsApiConfig
         
    .LINK
        Get-D365LcsApiToken
         
    .LINK
        Get-D365LcsAssetValidationStatus
         
    .LINK
        Get-D365LcsDeploymentStatus
         
    .LINK
        Invoke-D365LcsApiRefreshToken
         
    .LINK
        Invoke-D365LcsUpload
         
    .LINK
        Set-D365LcsApiConfig
         
    .NOTES
        Tags: Servicing, Broadcast, Message, Users, Environment, Config, Configuration, ClientId, ClientSecret
         
        Author: M�tz Jensen (@Splaxi)
#>


function Add-D365LcsEnvironment {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $Name,

        [Parameter(Mandatory = $false)]
        [int] $ProjectId,

        [Parameter(Mandatory = $false)]
        [string] $EnvironmentId,

        [switch] $Temporary,

        [switch] $Force
    )
    
    if (((Get-PSFConfig -FullName "d365fo.tools.lcs.environment.*.name").Value -contains $Name) -and (-not $Force)) {
        Write-PSFMessage -Level Host -Message "A LCS environment configuration with <c='em'>$Name</c> as name <c='em'>already exists</c>. If you want to <c='em'>overwrite</c> the current configuration, please supply the <c='em'>-Force</c> parameter."
        Stop-PSFFunction -Message "Stopping because a environment configuration already exists with that name."
        return
    }

    $configName = ""

    #The ':keys' label is used to have a continue inside the switch statement itself
    :keys foreach ($key in $PSBoundParameters.Keys) {
        
        $configurationValue = $PSBoundParameters.Item($key)
        $configurationName = $key.ToLower()
        $fullConfigName = ""

        Write-PSFMessage -Level Verbose -Message "Working on $key with $configurationValue" -Target $configurationValue
        
        switch ($key) {
            "Name" {
                $configName = $Name.ToLower()
                $fullConfigName = "d365fo.tools.lcs.environment.$configName.name"
            }

            {"Temporary","Force" -contains $_} {
                continue keys
            }

            Default {
                $fullConfigName = "d365fo.tools.lcs.environment.$configName.$configurationName"
            }
        }

        Write-PSFMessage -Level Verbose -Message "Setting $fullConfigName to $configurationValue" -Target $configurationValue
        Set-PSFConfig -FullName $fullConfigName -Value $configurationValue
        if (-not $Temporary) { Register-PSFConfig -FullName $fullConfigName -Scope UserDefault }
    }
}


<#
    .SYNOPSIS
        Add a certificate thumbprint to the wif.config.
         
    .DESCRIPTION
        Register a certificate thumbprint in the wif.config file.
        This can be useful for example when configuring RSAT on a local machine and add the used certificate thumbprint to that AOS.s
         
    .PARAMETER CertificateThumbprint
        The thumbprint value of the certificate that you want to register in the wif.config file
         
    .EXAMPLE
        PS C:\> Add-D365RsatWifConfigAuthorityThumbprint -CertificateThumbprint "12312323r424"
         
        This will open the wif.config file and insert the "12312323r424" thumbprint value into the file.
         
    .NOTES
        Tags: RSAT, Certificate, Testing, Regression Suite Automation Test, Regression, Test, Automation
         
        Author: Kenny Saelen (@kennysaelen)
         
        Author: M�tz Jensen (@Splaxi)
#>

function Add-D365RsatWifConfigAuthorityThumbprint {
    [Alias("Add-D365WIFConfigAuthorityThumbprint")]

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 1)]
        [string]$CertificateThumbprint
    )
      

    try
    {
        $wifConfigFile = Join-Path $script:ServiceDrive "\AOSService\webroot\wif.config"

        if($true -eq (Test-Path -Path $wifConfigFile))
        {
            [xml]$wifXml = Get-Content $wifConfigFile

            $authorities = $wifXml.SelectNodes('//system.identityModel//identityConfiguration//securityTokenHandlers//securityTokenHandlerConfiguration//issuerNameRegistry//authority[@name="https://fakeacs.accesscontrol.windows.net/"]')
            
            if($authorities.Count -lt 1)
            {
                Write-PSFMessage -Level Critical -Message "Only one authority should be found with the name https://fakeacs.accesscontrol.windows.net/"
                Stop-PSFFunction -Message  "Stopping because an invalid authority structure was found in the wif.config file."
                return
            }
            else
            {
                foreach ($authority in $authorities)
                {
                    $addElem = $wifXml.CreateElement("add")
                    $addAtt = $wifXml.CreateAttribute("thumbprint")
                    $addAtt.Value = $CertificateThumbprint
                    $addElem.Attributes.Append($addAtt)
                    $authority.FirstChild.AppendChild($addElem)
                    $wifXml.Save($wifConfigFile)
                }
            }
        }
        else
        {
            Write-PSFMessage -Level Critical -Message "The wif.config file would not be located on the system."
            Stop-PSFFunction -Message  "Stopping because the wif.config file could not be located."
            return
        }
    }
    catch
    {
        Write-PSFMessage -Level Host -Message "Something went wrong while configuring the certificates and the Windows Identity Foundation configuration for the AOS" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors" -StepsUpward 1
        return
    }
}


<#
    .SYNOPSIS
        Add rules to Windows Defender to enhance performance during development.
         
    .DESCRIPTION
        Add rules to the Windows Defender to exclude Visual Studio, D365 Batch process, D365 Sync process, XPP related processes and SQL Server processes from scans and monitoring.
        This will lead to performance gains because the Windows Defender stops to scan every file accessed by e.g. the MSBuild process, the cache and things around Visual Studio.
        Supports rules for VS 2015 and VS 2019.
         
    .PARAMETER Silent
        Instruct the cmdlet to silence the output written to the console
         
        If set the output will be silenced, if not set, the output will be written to the console
         
    .EXAMPLE
        PS C:\> Add-D365WindowsDefenderRules
         
        This will add the most common rules to the Windows Defender as exceptions.
        All output will be written to the console.
         
    .EXAMPLE
        PS C:\> Add-D365WindowsDefenderRules -Silent
         
        This will add the most common rules to the Windows Defender as exceptions.
        All output will be silenced and not outputted to the console.
         
    .NOTES
        Tags: DevTools, Developer, Performance
         
        Author: Robin Kretzschmar (@darksmile92)
         
        Author: M�tz Jensen (@Splaxi)
#>

function Add-D365WindowsDefenderRules {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    param (
        [switch] $Silent
    )

    $DefenderEnabled = Get-WindowsDefenderStatus -Silent:$Silent

    if ($DefenderEnabled -eq $false) {
        Write-PSFMessage -Level Host -Message "Windows Defender is not enabled on this machine."
        Stop-PSFFunction -Message "Stopping because of errors."
        return
    }

    try {
        $AOSServicePath = Join-Path $script:ServiceDrive "\AOSService"
        $AOSPath = Join-Path $script:ServiceDrive "\AOSService\webroot\bin"

        if (-not (Test-PathExists -Path $AOSServicePath -Type Container)) { return }
        
        # visual studio & tools
        Add-MpPreference -ExclusionProcess "C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\Common7\IDE\devenv.exe"
        Add-MpPreference -ExclusionProcess "C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\Common7\IDE\devenv.exe"
        Add-MpPreference -ExclusionProcess "C:\Program Files (x86)\Microsoft Visual Studio 14.0\Common7\IDE\devenv.exe"
        Add-MpPreference -ExclusionProcess "C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe"
        Add-MpPreference -ExclusionProcess "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe"
        Add-MpPreference -ExclusionProcess "C:\Program Files (x86)\MSBuild\14.0\Bin\MSBuild.exe"
        Add-MpPreference -ExclusionProcess "C:\Program Files\dotnet\dotnet.exe"
        # customize path for cloud machines
        Add-MpPreference -ExclusionProcess "$Script:BinDir\xppcAgent.exe"
        Add-MpPreference -ExclusionProcess "$Script:BinDir\SyncEngine.exe"
        Add-MpPreference -ExclusionProcess "$AOSPath\Batch.exe"
        # add SQLServer
        Add-MpPreference -ExclusionProcess "C:\Program Files\Microsoft SQL Server\130\LocalDB\Binn\sqlservr.exe"
        Add-MpPreference -ExclusionProcess "C:\Program Files\Microsoft SQL Server\MSSQL13.MSSQLSERVER\MSSQL\Binn\sqlservr.exe"

        #Compile kicks off the defender. Exclude base path to AOS helps on that.
        Add-MpPreference -ExclusionPath $AOSServicePath

        # cache folders
        Add-MpPreference -ExclusionPath "C:\Program Files (x86)\Microsoft Visual Studio 10.0"
        Add-MpPreference -ExclusionPath "C:\Program Files (x86)\Microsoft Visual Studio 14.0"
        Add-MpPreference -ExclusionPath "C:\Program Files (x86)\Microsoft Visual Studio"
        Add-MpPreference -ExclusionPath "C:\Windows\assembly"
        Add-MpPreference -ExclusionPath "C:\Windows\Microsoft.NET"
        Add-MpPreference -ExclusionPath "C:\Program Files (x86)\MSBuild"
        Add-MpPreference -ExclusionPath "C:\Program Files\dotnet"
        Add-MpPreference -ExclusionPath "C:\Program Files (x86)\Microsoft SDKs"
        Add-MpPreference -ExclusionPath "C:\Program Files\Microsoft SDKs"
        Add-MpPreference -ExclusionPath "C:\Program Files (x86)\Common Files\Microsoft Shared\MSEnv"
        Add-MpPreference -ExclusionPath "C:\Program Files (x86)\Microsoft Office"
        Add-MpPreference -ExclusionPath "C:\ProgramData\Microsoft\VisualStudio\Packages"
        Add-MpPreference -ExclusionPath "C:\Program Files (x86)\Microsoft SDKs\NuGetPackages"
        Add-MpPreference -ExclusionPath "C:\Windows\Microsoft.NET\Framework\v4.0.30319\Temporary ASP.NET Files"
        Add-MpPreference -ExclusionPath "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\Temporary ASP.NET Files"
        Add-MpPreference -ExclusionPath "C:\Users\Administrator\AppData\Local\Microsoft\VisualStudio"
        Add-MpPreference -ExclusionPath "C:\Users\Administrator\AppData\Local\Microsoft\WebsiteCache"
        Add-MpPreference -ExclusionPath "C:\Users\Administrator\AppData\Roaming\Microsoft\VisualStudio"
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while configuring Windows Defender rules." -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
}


<#
    .SYNOPSIS
        Create a backup of the Metadata directory
         
    .DESCRIPTION
        Creates a backup of all the files and folders from the Metadata directory
         
    .PARAMETER MetaDataDir
        Path to the Metadata directory
         
        Default value is the PackagesLocalDirectory
         
    .PARAMETER BackupDir
        Path where you want the backup to be place
         
    .EXAMPLE
        PS C:\> Backup-D365MetaDataDir
         
        This will backup the PackagesLocalDirectory and create an PackagesLocalDirectory_backup next to it
         
    .NOTES
        Tags: PackagesLocalDirectory, MetaData, MetaDataDir, MeteDataDirectory, Backup, Development
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Backup-D365MetaDataDir {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 1 )]
        [string] $MetaDataDir = "$Script:MetaDataDir",

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 1 )]
        [string] $BackupDir = "$($Script:MetaDataDir)_backup"
        
    )

    if (!(Test-Path -Path $MetaDataDir -Type Container)) {
        Write-PSFMessage -Level Host -Message "The <c='em'>$MetaDataDir</c> path wasn't found. Please ensure the path <c='em'>exists </c> and you have enough <c='em'>permission/c> to access the directory."
        Stop-PSFFunction -Message "Stopping because the path is missing."
        return
    }

    Invoke-TimeSignal -Start

    $Params = @($MetaDataDir, $BackupDir, "/MT:4", "/E", "/NFL",
        "/NDL", "/NJH", "/NC", "/NS", "/NP")

    #! We should consider to redirect the standard output & error like this: https://stackoverflow.com/questions/8761888/capturing-standard-out-and-error-with-start-process
    #Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly
    Start-Process -FilePath "Robocopy.exe" -ArgumentList $Params -NoNewWindow -Wait

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Backup a runbook file
         
    .DESCRIPTION
        Backup a runbook file for you to persist it for later analysis
         
    .PARAMETER File
        Path to the file you want to backup
         
    .PARAMETER DestinationPath
        Path to the folder where you want the backup file to be placed
         
    .PARAMETER Force
        Instructs the cmdlet to overwrite the destination file if it already exists
         
    .EXAMPLE
        PS C:\> Backup-D365Runbook -File "C:\DynamicsAX\InstallationRecords\Runbooks\Runbook_20190327.xml"
         
        This will backup the "C:\DynamicsAX\InstallationRecords\Runbooks\Runbook_20190327.xml".
        The default destination folder is used, "c:\temp\d365fo.tools\runbookbackups\".
         
    .EXAMPLE
        PS C:\> Backup-D365Runbook -File "C:\DynamicsAX\InstallationRecords\Runbooks\Runbook_20190327.xml" -Force
         
        This will backup the "C:\DynamicsAX\InstallationRecords\Runbooks\Runbook_20190327.xml".
        The default destination folder is used, "c:\temp\d365fo.tools\runbookbackups\".
        If the file already exists in the destination folder, it will be overwritten.
         
    .EXAMPLE
        PS C:\> Get-D365Runbook | Backup-D365Runbook
         
        This will backup all runbook files found with the "Get-D365Runbook" cmdlet.
        The default destination folder is used, "c:\temp\d365fo.tools\runbookbackups\".
         
    .NOTES
        Tags: Runbook, Backup, Analysis
         
        Author: M�tz Jensen (@Splaxi)
#>


function Backup-D365Runbook {
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('Path')]
        [string] $File,

        [string] $DestinationPath = $(Join-Path $Script:DefaultTempPath "RunbookBackups"),

        [switch] $Force
    )

    begin {
        if (-not (Test-PathExists -Path $DestinationPath -Type Container -Create)) { return }
    }
    
    process {

        if (-not (Test-PathExists -Path $File -Type Leaf)) { return }

        if (Test-PSFFunctionInterrupt) { return }
    
        $fileName = Split-Path -Path $File -Leaf
        $destinationFile = $(Join-Path $DestinationPath $fileName)

        if (-not $Force) {
            if ((-not (Test-PathExists -Path $destinationFile -Type Leaf -ShouldNotExist -ErrorAction SilentlyContinue -WarningAction SilentlyContinue))) {
                Write-PSFMessage -Level Host -Message "The <c='em'>$destinationFile</c> already exists. Consider changing the <c='em'>destination</c> path or set the <c='em'>Force</c> parameter to overwrite the file."
                return
            }
        }

        Write-PSFMessage -Level Verbose -Message "Copying from: $File" -Target $item
        Copy-Item -Path $File -Destination $destinationFile -Force:$Force -PassThru | Select-PSFObject "Name as Filename", "LastWriteTime as LastModified", "Fullname as File"
    }
}


<#
    .SYNOPSIS
        Clear the active broadcast message config
         
    .DESCRIPTION
        Clear the active broadcast message config from the configuration store
         
    .PARAMETER Temporary
        Instruct the cmdlet to only temporarily clear the active broadcast message configuration in the configuration store
         
    .EXAMPLE
        PS C:\> Clear-D365ActiveBroadcastMessageConfig
         
        This will clear the active broadcast message configuration from the configuration store.
         
    .NOTES
        Tags: Servicing, Broadcast, Message, Users, Environment, Config, Configuration, ClientId, ClientSecret
         
        Author: M�tz Jensen (@Splaxi)
         
    .LINK
        Add-D365BroadcastMessageConfig
         
    .LINK
        Get-D365ActiveBroadcastMessageConfig
         
    .LINK
        Get-D365BroadcastMessageConfig
         
    .LINK
        Remove-D365BroadcastMessageConfig
         
    .LINK
        Send-D365BroadcastMessage
         
    .LINK
        Set-D365ActiveBroadcastMessageConfig
#>


function Clear-D365ActiveBroadcastMessageConfig {
    [CmdletBinding()]
    [OutputType()]
    param (
        [switch] $Temporary
    )

    $configurationName = "d365fo.tools.active.broadcast.message.config.name"
    
    Reset-PSFConfig -FullName $configurationName

    if (-not $Temporary) { Register-PSFConfig -FullName $configurationName -Scope UserDefault }
}


<#
    .SYNOPSIS
        Clear the monitoring data from a Dynamics 365 for Finance & Operations machine
         
    .DESCRIPTION
        Clear the monitoring data that is filling up the service drive on a Dynamics 365 for Finance & Operations
         
    .PARAMETER Path
        The path to where the monitoring data is located
         
        The default value is the "ServiceDrive" (j:\ | k:\) and the \MonAgentData\SingleAgent\Tables folder structure
         
    .EXAMPLE
        PS C:\> Clear-D365MonitorData
         
        This will delete all the files that are located in the default path on the machine.
        Some files might be locked by a process, but the cmdlet will attemp to delete all files.
         
    .NOTES
        Tags: Monitor, MonitorData, MonitorAgent, CleanUp, Servicing
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Clear-D365MonitorData {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Position = 1, ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true)]
        [string] $Path = (Join-Path $script:ServiceDrive "\MonAgentData\SingleAgent\Tables")
    )
    
    process {
        Get-ChildItem -Path $Path | Remove-Item -Force -ErrorAction SilentlyContinue
    }
}


<#
    .SYNOPSIS
        Clear out data for a table inside the bacpac file
         
    .DESCRIPTION
        Remove all data for a table inside a bacpac file, before restoring it into your SQL Server / Azure SQL DB
         
        It will extract the bacpac file as a zip archive, locate the desired table and remove the data that otherwise would have been loaded
         
        It will re-zip / compress a new bacpac file for you
         
    .PARAMETER Path
        Path to the bacpac file that you want to work against
         
        It can also be a zip file
         
    .PARAMETER TableName
        Name of the table that you want to delete the data for
         
        Supports an array of table names
         
        If a schema name isn't supplied as part of the table name, the cmdlet will prefix it with "dbo."
         
        Supports wildcard searching e.g. "Sales*" will delete all "dbo.Sales*" tables in the bacpac file
         
    .PARAMETER OutputPath
        Path to where you want the updated bacpac file to be saved
         
    .PARAMETER ClearFromSource
        Instruct the cmdlet to delete tables directly from the source file
         
        It will save disk space and time, because it doesn't have to create a copy of the bacpac file, before deleting tables from it
         
    .EXAMPLE
        PS C:\> Clear-D365TableDataFromBacpac -Path "C:\Temp\AxDB.bacpac" -TableName "BATCHJOBHISTORY" -OutputPath "C:\Temp\AXBD_Cleaned.bacpac"
         
        This will remove the data from the BatchJobHistory table from inside the bacpac file.
         
        It uses "C:\Temp\AxDB.bacpac" as the Path for the bacpac file.
        It uses "BATCHJOBHISTORY" as the TableName to delete data from.
        It uses "C:\Temp\AXBD_Cleaned.bacpac" as the OutputPath to where it will store the updated bacpac file.
         
    .EXAMPLE
        PS C:\> Clear-D365TableDataFromBacpac -Path "C:\Temp\AxDB.bacpac" -TableName "dbo.BATCHHISTORY","BATCHJOBHISTORY" -OutputPath "C:\Temp\AXBD_Cleaned.bacpac"
         
        This will remove the data from the dbo.BatchHistory and BatchJobHistory table from inside the bacpac file.
         
        It uses "C:\Temp\AxDB.bacpac" as the Path for the bacpac file.
        It uses "dbo.BATCHHISTORY","BATCHJOBHISTORY" as the TableName to delete data from.
        It uses "C:\Temp\AXBD_Cleaned.bacpac" as the OutputPath to where it will store the updated bacpac file.
         
    .EXAMPLE
        PS C:\> Clear-D365TableDataFromBacpac -Path "C:\Temp\AxDB.bacpac" -TableName "dbo.BATCHHISTORY","BATCHJOBHISTORY" -ClearFromSource
         
        This will remove the data from the dbo.BatchHistory and BatchJobHistory table from inside the bacpac file.
         
        It uses "C:\Temp\AxDB.bacpac" as the Path for the bacpac file.
        It uses "dbo.BATCHHISTORY","BATCHJOBHISTORY" as the TableName to delete data from.
         
        Caution:
        It will remove from the source "C:\Temp\AxDB.bacpac" directly. So if the original file is important for further processing, please consider the risks carefully.
         
    .EXAMPLE
        PS C:\> Clear-D365TableDataFromBacpac -Path "C:\Temp\AxDB.bacpac" -TableName "CustomTableNameThatDoesNotExists","BATCHJOBHISTORY" -OutputPath "C:\Temp\AXBD_Cleaned.bacpac" -ErrorAction SilentlyContinue
         
        This will remove the data from the BatchJobHistory table from inside the bacpac file.
         
        It uses "C:\Temp\AxDB.bacpac" as the Path for the bacpac file.
        It uses "CustomTableNameThatDoesNotExists","BATCHJOBHISTORY" as the TableName to delete data from.
        It respects the respects the ErrorAction "SilentlyContinue", and will continue removing tables from the bacpac file, even when some tables are missing.
        It uses "C:\Temp\AXBD_Cleaned.bacpac" as the OutputPath to where it will store the updated bacpac file.
         
    .NOTES
        Tags: Bacpac, Servicing, Data, Deletion, SqlPackage
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Clear-D365TableDataFromBacpac {
    [CmdletBinding(DefaultParameterSetName = "Copy")]
    param (
        [Parameter(Mandatory = $true)]
        [Alias('File')]
        [Alias('BacpacFile')]
        [string] $Path,

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

        [Parameter(Mandatory = $true, ParameterSetName = "Copy")]
        [string] $OutputPath,

        [Parameter(Mandatory = $true, ParameterSetName = "Keep")]
        [switch] $ClearFromSource
    )
    
    begin {
        if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }

        $compressPath = ""
        $newFilename = ""

        if ($ClearFromSource) {
            $compressPath = $Path.Replace(".bacpac", ".zip")
            $newFilename = Split-Path -Path $compressPath -Leaf

            Write-PSFMessage -Level Verbose -Message "Renaming the file '$Path' to '$compressPath'."
            Rename-Item -Path $Path -NewName $newFilename

            $newFilename = $newFilename.Replace(".zip", ".bacpac")
        }
        else {
            if ($OutputPath -like "*.bacpac") {
                $compressPath = $OutputPath.Replace(".bacpac", ".zip")
                $newFilename = Split-Path -Path $OutputPath -Leaf
            }
            else {
                $compressPath = $OutputPath
            }

            if (-not (Test-PathExists -Path $compressPath -Type Leaf -ShouldNotExist)) {
                Write-PSFMessage -Level Host -Message "The <c='em'>$compressPath</c> already exists. Consider changing the <c='em'>OutputPath</c> or <c='em'>delete</c> the <c='em'>$compressPath</c> file."
                return
            }

            if (-not (Test-PathExists -Path $OutputPath -Type Leaf -ShouldNotExist)) {
                Write-PSFMessage -Level Host -Message "The <c='em'>$OutputPath</c> already exists. Consider changing the <c='em'>OutputPath</c> or <c='em'>delete</c> the <c='em'>$OutputPath</c> file."
                return
            }

            Write-PSFMessage -Level Verbose -Message "Copying the file from '$Path' to '$compressPath'"
            Copy-Item -Path $Path -Destination $compressPath
            Write-PSFMessage -Level Verbose -Message "Copying was completed."

            if (Test-PSFFunctionInterrupt) { return }
        }

        Write-PSFMessage -Level Verbose -Message "Opening the file '$Path'."
        $zipFileMetadata = [System.IO.Compression.ZipFile]::Open($compressPath, [System.IO.Compression.ZipArchiveMode]::Update)
        Write-PSFMessage -Level Verbose -Message "File '$Path' was read succesfully."

        if ($null -eq $zipFileMetadata) {
            $messageString = "Unable to open the file <c='em'>$compressPath</c>."
            Write-PSFMessage -Level Host -Message $messageString
            Stop-PSFFunction -Message "Stopping because the file couldn't be opened." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
            return
        }
    }
    
    process {
        if (Test-PSFFunctionInterrupt) { return }
        
        foreach ($table in $TableName) {
            $fullTableName = ""

            if (-not ($table -like "*.*")) {
                $fullTableName = "dbo.$table"
            }
            else {
                $fullTableName = $table
            }

            Write-PSFMessage -Level Verbose -Message "Looking for $fullTableName."

            $entries = $zipFileMetadata.Entries | Where-Object Fullname -like "Data/$fullTableName/*"

            if ($entries.Count -lt 1) {
                Write-PSFMessage -Level Host -Message "The <c='em'>$table</c> wasn't found. Please ensure that the <c='em'>schema</c> or <c='em'>name</c> is correct."
                
                $parms = @{Message = "Stopping because table was not present." }
                if ($ErrorActionPreference -eq "SilentlyContinue") {
                    $parms.WarningAction = $ErrorActionPreference
                    $parms.ErrorAction = $null
                }

                Stop-PSFFunction @parms
            }
            else {
                for ($i = 0; $i -lt $entries.Count; $i++) {
                    Write-PSFMessage -Level Verbose -Message "Removing $($entries[$i]) from the file."

                    $entries[$i].delete()
                }
            }
        }
    }
    
    end {
        Write-PSFMessage -Level Verbose -Message "Search completed."

        $res = @{ }

        if ($null -ne $zipFileMetadata) {
            Write-PSFMessage -Level Verbose -Message "Closing and saving the file."
            $zipFileMetadata.Dispose()
        }
        
        if ($newFilename -ne "") {
            Rename-Item -Path $compressPath -NewName $newFilename
            $res.File = Join-path -Path $(Split-Path -Path $compressPath -Parent) -ChildPath $newFilename
            $res.Filename = $newFilename
        }
        else {
            $res.File = $compressPath
            $res.Filename = $(Split-Path -Path $compressPath -Leaf)
        }

        if (Test-PSFFunctionInterrupt) { return }

        [PSCustomObject]$res
    }
}


<#
    .SYNOPSIS
        Cleanup TempDB tables in Microsoft Dynamics 365 for Finance and Operations environment
         
    .DESCRIPTION
        This will cleanup X days of TempDB tables
         
        The reason behind this process is that sp_updatestats takes significantly longer depending on the number of TempDB tables in the system
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER Days
        Temp tables older than this Days input will be dropped
         
        The default value is 7 (days)
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> invoke-D365CleanupTempDBTables -Days 7
         
        This will cleanup old tempdb tables.
        It will use 7 as the Days parameter.
         
        The remaining parameters will use their default values, which are provided by the tools.
         
    .LINK
        https://msdyn365fo.wordpress.com/2019/12/18/cleanup-tempdb-tables-in-a-msdyn365fo-sandbox-environment/
         
    .LINK
        https://github.com/PaulHeisterkamp/d365fo.blog/blob/master/Tools/SQL/DropTempDBTables.sql
         
    .NOTES
         
        Author: Alex Kwitny (@AlexOnDAX)
         
        Author: M�tz Jensen (@Splaxi)
         
        This cmdlet is based on the findings from Paul Heisterkamp (@braul)
         
        See his blog for more info:
        https://msdyn365fo.wordpress.com/2019/12/18/cleanup-tempdb-tables-in-a-msdyn365fo-sandbox-environment/
#>


function Clear-D365TempDbTables {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    param
    (
        [string] $DatabaseServer = $Script:DatabaseServer,

        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [int] $Days = 7,
        
        [switch] $EnableException
    )
    
    Invoke-TimeSignal -Start

    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters
    
    $Params = @{DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd; TrustedConnection = $UseTrustedConnection;
    }

    $sqlCommand = Get-SQLCommand @Params

    $commandText = (Get-Content "$script:ModuleRoot\internal\sql\clear-d365tempdbtables.sql") -join [Environment]::NewLine
    $commandText = $commandText.Replace('@Days', $Days)

    $sqlCommand.CommandText = $commandText

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()

        $null = $sqlCommand.ExecuteNonQuery()
    }
    catch {
        $messageString = "Something went wrong while working against the database."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Used to disable a flight
         
    .DESCRIPTION
        Provides a method for disabling a flight in D365FO.
         
    .PARAMETER FlightName
        Name of the flight to disable
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .EXAMPLE
        PS C:\> Disable-D365Flight -FlightName DMFEnableAllCompanyExport
         
        Disables the flight DMFEnableAllCompanyExport
         
    .LINK
        https://docs.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/data-entities/data-entities-data-packages#features-flighted-in-data-management-and-enabling-flighted-features
         
    .NOTES
        Tags: Flight, Flighting
         
        Author: Frank H�ther (@FrankHuether)
         
        The DataAccess.FlightingServiceCatalogID must already be set in the web.config file.
         
        At no circumstances can this cmdlet be used to enable a flight in a PROD environment.
#>

function Disable-D365Flight {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 1)]
        [String] $FlightName,

        [Parameter(Mandatory = $false, Position = 2)]
        [string] $DatabaseServer = $Script:DatabaseServer,

        [Parameter(Mandatory = $false, Position = 3)]
        [string] $DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, Position = 4)]
        [string] $SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, Position = 5)]
        [string] $SqlPwd = $Script:DatabaseUserPassword
    )

    try {
        $WebConfigFile = join-Path -path $Script:AOSPath $Script:WebConfig
        Write-PSFMessage -Level Verbose -Message "Retrieve the FlightingServiceCatalogID" -Target $WebConfigFile

        $FlightServiceNode = Select-Xml -XPath "/configuration/appSettings/add[@key='DataAccess.FlightingServiceCatalogID']/@value" -Path $WebConfigFile
        $FlightServiceId = $FlightServiceNode.Node.Value
    
        Write-PSFMessage -Level Verbose -Message "FlightingServiceCatalogID: $FlightServiceId" -Target $WebConfigFile
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while reading from the web.config file" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }

    if ($null -eq $FlightServiceId) {
        Write-PSFMessage -Level Host -Message "The DataAccess.FlightingServiceCatalogID setting must be set in the web.config file. See https://docs.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/data-entities/data-entities-data-packages#features-flighted-in-data-management-and-enabling-flighted-features for details"
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }

    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

    $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd
    }

    $SqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection
    $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\disable-flight.sql") -join [Environment]::NewLine

    try {
        $sqlCommand.Connection.Open()

        Write-PSFMessage -Level Verbose -Message "Disabling flight: $FlightName"

        $null = $sqlCommand.Parameters.Add("@FlightName", $FlightName)
        $null = $sqlCommand.Parameters.Add("@FlightServiceId", $FlightServiceId)

        Write-PSFMessage -Level Verbose -Message "Disable the flight in database"

        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $null = $sqlCommand.ExecuteNonQuery()
    
        Write-PSFMessage -Level Verbose -Message "Flight $FlightName disabled with service ID $FlightServiceId"
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }
        $sqlCommand.Dispose()
    }
}


<#
    .SYNOPSIS
        Sets the environment back into operating state
         
    .DESCRIPTION
        Sets the Dynamics 365 environment back into operating / running state after been in maintenance mode
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory\bin
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Disable-D365MaintenanceMode
         
        This will execute the Microsoft.Dynamics.AX.Deployment.Setup.exe with the default values that was pulled from the environment and put the environment into the operate / running state.
         
    .EXAMPLE
        PS C:\> Disable-D365MaintenanceMode -ShowOriginalProgress
         
        This will execute the Microsoft.Dynamics.AX.Deployment.Setup.exe with the default values that was pulled from the environment and put the environment into the operate / running state.
        The output from stopping the services will be written to the console / host.
        The output from the "deployment" process will be written to the console / host.
        The output from starting the services will be written to the console / host.
         
    .NOTES
        Tags: MaintenanceMode, Maintenance, License, Configuration, Servicing
         
        Author: M�tz Jensen (@splaxi)
        Author: Tommy Skaue (@skaue)
         
        With administrator privileges:
        The cmdlet wraps the execution of Microsoft.Dynamics.AX.Deployment.Setup.exe and parses the parameters needed.
         
        Without administrator privileges:
        Will stop all services, execute a Sql script and start all services.
         
    .LINK
        Enable-D365MaintenanceMode
         
    .LINK
        Get-D365MaintenanceMode
         
#>

function Disable-D365MaintenanceMode {
    [CmdletBinding()]
    param (
        [string] $MetaDataDir = "$Script:MetaDataDir",

        [string] $BinDir = "$Script:BinDir",

        [string] $DatabaseServer = $Script:DatabaseServer,

        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\MaintenanceMode"),

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly

    )
    
    if ((Get-Process -Name "devenv" -ErrorAction SilentlyContinue).Count -gt 0) {
        Write-PSFMessage -Level Host -Message "It seems that you have a <c='em'>Visual Studio</c> running. Please <c='em'>exit</c> Visual Studio and run the cmdlet again."
        Stop-PSFFunction -Message "Stopping because of running Visual Studio."
        return
    }

    if (-not $OutputCommandOnly) {
        Stop-D365Environment -All -ShowOriginalProgress:$ShowOriginalProgress | Format-Table
    }

    if (-not ($Script:IsAdminRuntime)) {
        Write-PSFMessage -Level Verbose -Message "Setting Maintenance Mode without using executable (which requires local admin)."
        
        $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

        $Params = @{
            DatabaseServer = $DatabaseServer
            DatabaseName   = $DatabaseName
            SqlUser        = $SqlUser
            SqlPwd         = $SqlPwd
        }

        if ($OutputCommandOnly) {
            $scriptContent = Get-content -Path $("$script:ModuleRoot\internal\sql\disable-maintenancemode.sql") -Raw
            Write-PSFMessage -Level Host -Message "It seems that you're want the command, but you're running in a non-elevated console. Will output the SQL script that is avaiable."
            Write-PSFMessage -Level Host -Message "$scriptContent"
        }
        else {
            Invoke-D365SqlScript @Params -FilePath $("$script:ModuleRoot\internal\sql\disable-maintenancemode.sql") -TrustedConnection $UseTrustedConnection
        }
    }
    else {
        Write-PSFMessage -Level Verbose -Message "Setting Maintenance Mode using executable."

        $executable = Join-Path -Path $BinDir -ChildPath "bin\Microsoft.Dynamics.AX.Deployment.Setup.exe"

        if (-not (Test-PathExists -Path $MetaDataDir, $BinDir -Type Container)) { return }
        if (-not (Test-PathExists -Path $executable -Type Leaf)) { return }

        $params = @("-isemulated", "true",
            "-sqluser", "$SqlUser",
            "-sqlpwd", "$SqlPwd",
            "-sqlserver", "$DatabaseServer",
            "-sqldatabase", "$DatabaseName",
            "-metadatadir", "$MetaDataDir",
            "-bindir", "$BinDir",
            "-setupmode", "maintenancemode",
            "-isinmaintenancemode", "false")

        Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath
    }

    if ($OutputCommandOnly) { return }

    Start-D365Environment -All -ShowOriginalProgress:$ShowOriginalProgress | Format-Table

}


<#
    .SYNOPSIS
        Disable Change Tracking for the environment
         
    .DESCRIPTION
        Disables the SQL Server Change Tracking for the environments database and all tables inside the database
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .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:\> Disable-D365SqlChangeTracking
         
        This will disable the Change Tracking on the Sql Server.
         
    .NOTES
        Tags: MaintenanceMode, Maintenance, License, Configuration, Servicing
         
        Author: M�tz Jensen (@splaxi)
#>

function Disable-D365SqlChangeTracking {
    [CmdletBinding()]
    param (
        [string] $DatabaseServer = $Script:DatabaseServer,

        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters
    
    $Params = @{DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd; TrustedConnection = $UseTrustedConnection;
    }

    $sqlCommand = Get-SQLCommand @Params

    $commandText = (Get-Content "$script:ModuleRoot\internal\sql\disable-changetracking.sql") -join [Environment]::NewLine
    $commandText = $commandText.Replace('@DATABASENAME', $DatabaseName)

    $sqlCommand.CommandText = $commandText

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()

        $null = $sqlCommand.ExecuteNonQuery()
    }
    catch {
        $messageString = "Something went wrong while working against the database."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
    }

    Invoke-TimeSignal -End

}


<#
    .SYNOPSIS
        Disables the user in D365FO
         
    .DESCRIPTION
        Sets the enabled to 0 in the userinfo table.
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER Email
        The search string to select which user(s) should be disabled.
         
        The parameter supports wildcards. E.g. -Email "*@contoso.com*"
         
    .EXAMPLE
        PS C:\> Disable-D365User
         
        This will Disable all users for the environment
         
    .EXAMPLE
        PS C:\> Disable-D365User -Email "claire@contoso.com"
         
        This will Disable the user with the email address "claire@contoso.com"
         
    .EXAMPLE
        PS C:\> Disable-D365User -Email "*contoso.com"
         
        This will Disable all users that matches the search "*contoso.com" in their email address
         
    .NOTES
        Tags: User, Users, Security, Configuration, Permission
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Disable-D365User {

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false, Position = 1)]
        [string]$DatabaseServer = $Script:DatabaseServer,

        [Parameter(Mandatory = $false, Position = 2)]
        [string]$DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, Position = 3)]
        [string]$SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, Position = 4)]
        [string]$SqlPwd = $Script:DatabaseUserPassword,

        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true, Position = 5)]
        [string]$Email = "*"

    )

    begin {
        Invoke-TimeSignal -Start

        $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

        $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
            SqlUser = $SqlUser; SqlPwd = $SqlPwd
        }

        $SqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection

        try {
            $sqlCommand.Connection.Open()
        }
        catch {
            Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }
    }

    process {
        if (Test-PSFFunctionInterrupt) { return }
        
        $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\disable-user.sql") -join [Environment]::NewLine
    
        $null = $sqlCommand.Parameters.AddWithValue('@Email', $Email.Replace("*", "%"))

        try {
            Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

            $reader = $sqlCommand.ExecuteReader()
            $NumAffected = 0

            while ($reader.Read() -eq $true) {
                Write-PSFMessage -Level Verbose -Message "User $($reader.GetString(0)), $($reader.GetString(1)), $($reader.GetString(2)) Updated"
                $NumAffected++
            }

            $reader.Close()
            Write-PSFMessage -Level Verbose -Message "Users updated : $NumAffected"
        }
        catch {
            Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }
        finally {
            $reader.close()
            $sqlCommand.Parameters.Clear()
        }
    }

    end {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()

        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Enable exceptions to be thrown
         
    .DESCRIPTION
        Change the default exception behavior of the module to support throwing exceptions
         
        Useful when the module is used in an automated fashion, like inside Azure DevOps pipelines and large PowerShell scripts
         
    .EXAMPLE
        PS C:\>Enable-D365Exception
         
        This will for the rest of the current PowerShell session make sure that exceptions will be thrown.
         
    .NOTES
        Tags: Exception, Exceptions, Warning, Warnings
         
        Author: M�tz Jensen (@Splaxi)
#>


function Enable-D365Exception {
    [CmdletBinding()]
    param ()

    Write-PSFMessage -Level Verbose -Message "Enabling exception across the entire module." -Target $configurationValue

    Set-PSFFeature -Name 'PSFramework.InheritEnableException' -Value $true -ModuleName "D365fo.tools"
    Set-PSFFeature -Name 'PSFramework.InheritEnableException' -Value $true -ModuleName "PSOAuthHelper"
    $PSDefaultParameterValues['*:EnableException'] = $true
}


<#
    .SYNOPSIS
        Used to enable a flight
         
    .DESCRIPTION
        Provides a method for enabling a flight in D365FO.
         
    .PARAMETER FlightName
        Name of the flight to enable
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .EXAMPLE
        PS C:\> Enable-D365Flight -FlightName DMFEnableAllCompanyExport
         
        Enables the flight DMFEnableAllCompanyExport
         
    .NOTES
        Tags: Flight, Flighting
         
        Author: Frank H�ther (@FrankHuether)
         
        The DataAccess.FlightingServiceCatalogID must already be set in the web.config file.
        https://docs.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/data-entities/data-entities-data-packages#features-flighted-in-data-management-and-enabling-flighted-features
         
        At no circumstances can this cmdlet be used to enable a flight in a PROD environment.
#>

function Enable-D365Flight {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 1)]
        [String] $FlightName,

        [Parameter(Mandatory = $false, Position = 2)]
        [string] $DatabaseServer = $Script:DatabaseServer,

        [Parameter(Mandatory = $false, Position = 3)]
        [string] $DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, Position = 4)]
        [string] $SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, Position = 5)]
        [string] $SqlPwd = $Script:DatabaseUserPassword
    )

    try {
        $WebConfigFile = join-Path -path $Script:AOSPath $Script:WebConfig
        Write-PSFMessage -Level Verbose -Message "Retrieve the FlightingServiceCatalogID" -Target $WebConfigFile

        $FlightServiceNode = Select-Xml -XPath "/configuration/appSettings/add[@key='DataAccess.FlightingServiceCatalogID']/@value" -Path $WebConfigFile
        $FlightServiceId = $FlightServiceNode.Node.Value
    
        Write-PSFMessage -Level Verbose -Message "FlightingServiceCatalogID: $FlightServiceId" -Target $WebConfigFile
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while reading from the web.config file" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }

    if ($null -eq $FlightServiceId) {
        Write-PSFMessage -Level Host -Message "The DataAccess.FlightingServiceCatalogID setting must be set in the web.config file. See https://docs.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/data-entities/data-entities-data-packages#features-flighted-in-data-management-and-enabling-flighted-features for details"
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }

    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

    $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd
    }

    $SqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection
    $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\enable-flight.sql") -join [Environment]::NewLine

    try {
        $sqlCommand.Connection.Open()

        Write-PSFMessage -Level Verbose -Message "Enabling flight: $FlightName"

        $null = $sqlCommand.Parameters.Add("@FlightName", $FlightName)
        $null = $sqlCommand.Parameters.Add("@FlightServiceId", $FlightServiceId)

        Write-PSFMessage -Level Verbose -Message "Enable the flight in database"

        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $null = $sqlCommand.ExecuteNonQuery()
    
        Write-PSFMessage -Level Verbose -Message "Flight $FlightName enabled with service ID $FlightServiceId"
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }
        $sqlCommand.Dispose()
    }
}


<#
    .SYNOPSIS
        Sets the environment into maintenance mode
         
    .DESCRIPTION
        Sets the Dynamics 365 environment into maintenance mode to enable the user to update the license configuration
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory\bin
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Enable-D365MaintenanceMode
         
        This will execute the Microsoft.Dynamics.AX.Deployment.Setup.exe with the default values that was pulled from the environment and put the environment into the operate / running state
         
    .EXAMPLE
        PS C:\> Enable-D365MaintenanceMode -ShowOriginalProgress
         
        This will execute the Microsoft.Dynamics.AX.Deployment.Setup.exe with the default values that was pulled from the environment and put the environment into the operate / running state
        The output from stopping the services will be written to the console / host.
        The output from the "deployment" process will be written to the console / host.
        The output from starting the services will be written to the console / host.
         
    .NOTES
        Tags: MaintenanceMode, Maintenance, License, Configuration, Servicing
         
        Author: M�tz Jensen (@splaxi)
        Author: Tommy Skaue (@skaue)
         
        With administrator privileges:
        The cmdlet wraps the execution of Microsoft.Dynamics.AX.Deployment.Setup.exe and parses the parameters needed.
         
        Without administrator privileges:
        Will stop all services, execute a Sql script and start all services.
         
    .LINK
        Get-D365MaintenanceMode
         
    .LINK
        Disable-D365MaintenanceMode
#>

function Enable-D365MaintenanceMode {
    [CmdletBinding()]
    param (
        [string] $MetaDataDir = "$Script:MetaDataDir",

        [string] $BinDir = "$Script:BinDir",

        [string] $DatabaseServer = $Script:DatabaseServer,

        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\MaintenanceMode"),

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly
    )

    if ((Get-Process -Name "devenv" -ErrorAction SilentlyContinue).Count -gt 0) {
        Write-PSFMessage -Level Host -Message "It seems that you have a <c='em'>Visual Studio</c> running. Please <c='em'>exit</c> Visual Studio and run the cmdlet again."
        Stop-PSFFunction -Message "Stopping because of running Visual Studio."
        return
    }
    
    if (-not $OutputCommandOnly) {
        Stop-D365Environment -All -ShowOriginalProgress:$ShowOriginalProgress | Format-Table
    }

    if (-not ($Script:IsAdminRuntime)) {
        Write-PSFMessage -Level Verbose -Message "Setting Maintenance Mode without using executable (which requires local admin)."

        $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

        $Params = @{
            DatabaseServer = $DatabaseServer
            DatabaseName   = $DatabaseName
            SqlUser        = $SqlUser
            SqlPwd         = $SqlPwd
        }

        if ($OutputCommandOnly) {
            $scriptContent = Get-content -Path $("$script:ModuleRoot\internal\sql\disable-maintenancemode.sql") -Raw
            Write-PSFMessage -Level Host -Message "It seems that you're want the command, but you're running in a non-elevated console. Will output the SQL script that is avaiable."
            Write-PSFMessage -Level Host -Message "$scriptContent"
        }
        else {
            Invoke-D365SqlScript @Params -FilePath $("$script:ModuleRoot\internal\sql\enable-maintenancemode.sql") -TrustedConnection $UseTrustedConnection
        }
    }
    else {
        Write-PSFMessage -Level Verbose -Message "Setting Maintenance Mode using executable."

        $executable = Join-Path -Path $BinDir -ChildPath "bin\Microsoft.Dynamics.AX.Deployment.Setup.exe"

        if (-not (Test-PathExists -Path $MetaDataDir, $BinDir -Type Container)) { return }
        if (-not (Test-PathExists -Path $executable -Type Leaf)) { return }

        $params = @("-isemulated", "true",
            "-sqluser", "$SqlUser",
            "-sqlpwd", "$SqlPwd",
            "-sqlserver", "$DatabaseServer",
            "-sqldatabase", "$DatabaseName",
            "-metadatadir", "$MetaDataDir",
            "-bindir", "$BinDir",
            "-setupmode", "maintenancemode",
            "-isinmaintenancemode", "true")

        Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath
    }

    if ($OutputCommandOnly) { return }
    
    Start-D365Environment -Aos -ShowOriginalProgress:$ShowOriginalProgress | Format-Table
}


<#
    .SYNOPSIS
        Enable Change Tracking for the environment
         
    .DESCRIPTION
        Enable the SQL Server Change Tracking for the environments database
         
        It is a requirement for the Data Entities refresh to be able to complete correctly
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .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:\> Enable-D365SqlChangeTracking
         
        This will enable the Change Tracking on the Sql Server.
         
    .NOTES
        Tags: MaintenanceMode, Maintenance, License, Configuration, Servicing
         
        Author: M�tz Jensen (@splaxi)
#>

function Enable-D365SqlChangeTracking {
    [CmdletBinding()]
    param (
        [string] $DatabaseServer = $Script:DatabaseServer,

        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters
    
    $Params = @{DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd; TrustedConnection = $UseTrustedConnection;
    }

    $sqlCommand = Get-SQLCommand @Params

    $commandText = (Get-Content "$script:ModuleRoot\internal\sql\enable-changetracking.sql") -join [Environment]::NewLine
    $commandText = $commandText.Replace('@DATABASENAME', $DatabaseName)

    $sqlCommand.CommandText = $commandText

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()

        $null = $sqlCommand.ExecuteNonQuery()
    }
    catch {
        $messageString = "Something went wrong while working against the database."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
    }

    Invoke-TimeSignal -End

}


<#
    .SYNOPSIS
        Enables the user in D365FO
         
    .DESCRIPTION
        Sets the enabled to 1 in the userinfo table
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER Email
        The search string to select which user(s) should be enabled
         
        The parameter supports wildcards. E.g. -Email "*@contoso.com*"
         
        Default value is "*" to update all users
         
    .EXAMPLE
        PS C:\> Enable-D365User
         
        This will enable all users for the environment
         
    .EXAMPLE
        PS C:\> Enable-D365User -Email "claire@contoso.com"
         
        This will enable the user with the email address "claire@contoso.com"
         
    .EXAMPLE
        PS C:\> Enable-D365User -Email "*contoso.com"
         
        This will enable all users that matches the search "*contoso.com" in their email address
         
    .NOTES
        Tags: User, Users, Security, Configuration, Permission
         
        Author: M�tz Jensen
         
#>

function Enable-D365User {

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false, Position = 1)]
        [string]$DatabaseServer = $Script:DatabaseServer,

        [Parameter(Mandatory = $false, Position = 2)]
        [string]$DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, Position = 3)]
        [string]$SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, Position = 4)]
        [string]$SqlPwd = $Script:DatabaseUserPassword,

        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true, Position = 5)]
        [string]$Email = "*"

    )

    begin {
        Invoke-TimeSignal -Start

        $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

        $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
            SqlUser = $SqlUser; SqlPwd = $SqlPwd
        }

        $SqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection

        try {
            $sqlCommand.Connection.Open()
        }
        catch {
            Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }
    }

    process {
        if (Test-PSFFunctionInterrupt) { return }

        $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\enable-user.sql") -join [Environment]::NewLine
            
        $null = $sqlCommand.Parameters.AddWithValue('@Email', $Email.Replace("*", "%"))

        try {
            Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

            $reader = $sqlCommand.ExecuteReader()
            $NumAffected = 0
            while ($reader.Read() -eq $true) {
                Write-PSFMessage -Level Verbose -Message "User $($reader.GetString(0)), $($reader.GetString(1)), $($reader.GetString(2)) Updated"
                $NumAffected++
            }

            $reader.Close()
            Write-PSFMessage -Level Verbose -Message "Users updated : $NumAffected"
        }
        catch {
            Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }
        finally {
            $reader.close()
            $sqlCommand.Parameters.Clear()
        }
    }

    end {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()

        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Extract the "model.xml" from the bacpac file
         
    .DESCRIPTION
        Extract the "model.xml" file from inside the bacpac file
         
        This can be used to update SQL Server options for how the SqlPackage.exe should import the bacpac file into your SQL Server / Azure SQL DB
         
    .PARAMETER Path
        Path to the bacpac file that you want to work against
         
        It can also be a zip file
         
    .PARAMETER OutputPath
        Path to where you want the updated bacpac file to be saved
         
        Default value is: "c:\temp\d365fo.tools"
         
    .PARAMETER ExtractionPath
        Path to where you want the cmdlet to extract the files from the bacpac file while it deletes data
         
        The default value is "c:\temp\d365fo.tools\BacpacExtractions"
         
        When working the cmdlet will create a sub-folder named like the bacpac file
         
    .PARAMETER Force
        Switch to instruct the cmdlet to overwrite the "model.xml" specified in the OutputPath
         
    .EXAMPLE
        PS C:\> Export-D365BacpacModelFile -Path "c:\Temp\AxDB.bacpac"
         
        This will extract the "model.xml" file from inside the bacpac file.
         
        It uses "c:\Temp\AxDB.bacpac" as the Path for the bacpac file.
        It uses the default value "c:\temp\d365fo.tools" as the OutputPath to where it will store the extracted "bacpac.model.xml" file.
        It uses the default ExtractionPath folder "c:\Temp\d365fo.tools\BacpacExtractions".
         
    .EXAMPLE
        PS C:\> Export-D365BacpacModelFile -Path "c:\Temp\AxDB.bacpac" -OutputPath "c:\Temp\model.xml" -Force
         
        This will extract the "model.xml" file from inside the bacpac file.
         
        It uses "c:\Temp\AxDB.bacpac" as the Path for the bacpac file.
        It uses "c:\Temp\model.xml" as the OutputPath to where it will store the extracted "model.xml" file.
        It uses the default ExtractionPath folder "c:\Temp\d365fo.tools\BacpacExtractions".
         
        It will override the "c:\Temp\model.xml" if already present.
         
    .EXAMPLE
        PS C:\> Export-D365BacpacModelFile -Path "c:\Temp\AxDB.bacpac" | Get-D365BacpacSqlOptions
         
        This will display all the SQL Server options configured in the bacpac file.
        First it will export the bacpac.model.xml from the "c:\Temp\AxDB.bacpac" file, using the Export-D365BacpacModelFile function.
        The output from Export-D365BacpacModelFile will be piped into the Get-D365BacpacSqlOptions function.
         
    .NOTES
        Tags: Bacpac, Servicing, Data, SqlPackage, Sql Server Options, Collation
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Export-D365BacpacModelFile {
    [Alias("Export-D365ModelFileFromBacpac")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [Alias('File')]
        [Alias('BacpacFile')]
        [string] $Path,

        [string] $OutputPath = $Script:DefaultTempPath,

        [switch] $Force
    )
    
    begin {
        if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }

        if (Test-PSFFunctionInterrupt) { return }

        $originalExtension = ""

        $fileName = [System.IO.Path]::GetFileNameWithoutExtension($Path)
        
        if ([System.IO.File]::GetAttributes($OutputPath).HasFlag([System.IO.FileAttributes]::Directory)) {
            $OutputPath = Join-Path -Path $OutputPath -ChildPath "bacpac.model.xml"
        }

        if ($Path -like "*.bacpac") {
            Write-PSFMessage -Level Verbose -Message "Renaming the bacpac file to zip, to be able to extract the file. $($fileName).zip" -Target $Path

            Rename-Item -Path $Path -NewName "$($fileName).zip"

            $originalExtension = "bacpac"

            $archivePath = Join-Path -Path (Split-Path -Path $Path -Parent) -ChildPath "$($fileName).zip"
        }
        else {
            $archivePath = $Path
        }

        if (-not $Force) {
            if (-not (Test-PathExists -Path $OutputPath -Type Leaf -ShouldNotExist)) {
                Write-PSFMessage -Level Host -Message "The <c='em'>$OutputPath</c> already exists. Consider changing the <c='em'>OutputPath</c> or set the <c='em'>Force</c> parameter to overwrite the file."
                Stop-PSFFunction -Message "Stopping because output path was already present."
                return
            }
        }

        if (Test-PSFFunctionInterrupt) { return }

        $zipFileMetadata = [System.IO.Compression.ZipFile]::OpenRead($archivePath)
        
        $modelFile = $zipFileMetadata.Entries | Where-Object { $_.Name -like "model.xml" } | Select-Object -First 1

        [System.IO.Compression.ZipFileExtensions]::ExtractToFile($modelFile, $OutputPath, $true)
        $zipFileMetadata.Dispose()

        [PSCustomObject]@{
            File     = $OutputPath
            Filename = $(Split-Path -Path $OutputPath -Leaf)
        }
    }
    
    end {
        if ($originalExtension -eq "bacpac") {
            Rename-Item -Path $archivePath -NewName "$($fileName).bacpac"
        }
    }
}


<#
    .SYNOPSIS
        Export a model from Dynamics 365 for Finance & Operations
         
    .DESCRIPTION
        Export a model from a Dynamics 365 for Finance & Operations environment
         
    .PARAMETER Path
        Path to the folder where you want to save the model file
         
    .PARAMETER Model
        Name of the model that you want to work against
         
    .PARAMETER Force
        Instruct the cmdlet to overwrite already existing file
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the AOS service PackagesLocalDirectory\bin
         
        Default value is fetched from the current configuration on the machine
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Export-D365Model -Path c:\temp\d365fo.tools -Model CustomModelName
         
        This will export the "CustomModelName" model from the default PackagesLocalDirectory path.
        It export the model to the "c:\temp\d365fo.tools" location.
         
    .NOTES
        Tags: ModelUtil, Axmodel, Model, Export
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Export-D365Model {
    [CmdletBinding()]
    
    param (
        [Parameter(Mandatory = $true)]
        [Alias('File')]
        [string] $Path,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('Modelname')]
        [string] $Model,

        [switch] $Force,

        [string] $BinDir = "$Script:PackageDirectory\bin",

        [string] $MetaDataDir = "$Script:MetaDataDir",

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\ModelUtilExport"),

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly
    )

    begin {
        Invoke-TimeSignal -Start
    
        if ($Path.EndsWith("\")) {
            $Path = $Path.Substring(0, $Path.Length - 1)
        }
    }

    process {

        if($Force){
            Get-ChildItem -Path "$Path\$Model-*.axmodel" | Select-Object -First 1 | Remove-Item -Force -ErrorAction SilentlyContinue
        }

        Invoke-ModelUtil -Command "Export" -Path $Path -BinDir $BinDir -MetaDataDir $MetaDataDir -Model $Model -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath

        if (Test-PSFFunctionInterrupt) { return }

        $file = Get-ChildItem -Path "$Path\$Model-*.axmodel" | Select-Object -First 1
        
        [PSCustomObject]@{
            File     = $file.FullName
            Filename = (Split-Path $file.FullName -Leaf)
        }
    }
    
    end {
        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Extract details from a User Interface Security file
         
    .DESCRIPTION
        Extracts and partitions the security details from an User Interface Security file into the same structure as AOT security files
         
    .PARAMETER FilePath
        Path to the User Interface Security XML file you want to work against
         
    .PARAMETER OutputDirectory
        Path to the folder where the cmdlet will output and structure the details from the file.
        The cmdlet will create a sub folder named like the input file.
         
        Default value is: "C:\temp\d365fo.tools\security-extraction"
         
    .EXAMPLE
        PS C:\> Export-D365SecurityDetails -FilePath C:\temp\d365fo.tools\SecurityDatabaseCustomizations.xml
         
        This will grab all the details inside the "C:\temp\d365fo.tools\SecurityDatabaseCustomizations.xml" file and extract that into the default path "C:\temp\d365fo.tools\security-extraction"
         
    .NOTES
         
        Tags: Security, Configuration, Permission, Development
         
        Author: M�tz Jensen (@splaxi)
         
        The work and design of this cmdlet is based on the findings by Alex Meyer (@alexmeyer_ITGuy).
         
        He wrote about his findings on his blog:
        https://alexdmeyer.com/2018/09/26/converting-d365fo-user-interface-security-customizations-export-to-aot-security-xml-files/
         
        He published a github repository:
         
        https://github.com/ameyer505/D365FOSecurityConverter
         
        All credits goes to Alex Meyer
#>

function Export-D365SecurityDetails {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [Alias('Path')]
        [string]$FilePath,

        [Parameter(Mandatory = $false)]
        [Alias('Output')]
        [string]$OutputDirectory = "C:\temp\d365fo.tools\security-extraction"
    )
    
    begin { }
    
    process {

        if (-not (Test-PathExists -Path $FilePath -Type Leaf)) { return }
        if (-not (Test-PathExists -Path $OutputDirectory -Type Container)) { return }

        [xml] $xdoc = Get-Content $FilePath
        
        $fileName = [System.IO.Path]::GetFileNameWithoutExtension($FilePath)
        
        $OutputDirectory = Join-Path $OutputDirectory $fileName

        Write-PSFMessage -Level Verbose -Message "Creating the output directory for the extraction" -Target $OutputDirectory
        $null = New-Item -Path $OutputDirectory -ItemType Directory -Force -ErrorAction SilentlyContinue

        Write-PSFMessage -Level Verbose -Message "Getting all the security objects."
        $secObjects = $xdoc.SelectNodes("/*/*/*/*/*[starts-with(name(),'AxSec')]")

        if ($secObjects.Count -gt 0) {

            Write-PSFMessage -Level Verbose -Message "Looping through all the security objects we found"
            foreach ( $secObject in $secObjects) {
                
                $secPath = Join-Path $OutputDirectory $secObject.LocalName
                
                $null = New-Item -Path $secPath -ItemType Directory -Force -ErrorAction SilentlyContinue

                $secObjectName = $secObject.Name
                
                if (-not ([string]::IsNullOrEmpty($secObjectName))) {
                    $filePathOut = Join-Path $secPath $secObjectName
                    $filePathOut += ".xml"

                    Write-PSFMessage -Level Verbose -Message "Generating the output file: $filePathOut" -Target $filePathOut
                    $secObject.OuterXml | Out-File $filePathOut
                }
            }
        }
    }
    
    end {
    }
}

#ValidationTags#Messaging,FlowControl,Pipeline,CodeStyle#
function Find-D365Command {
<#
    .SYNOPSIS
        Finds d365fo.tools commands searching through the inline help text
         
    .DESCRIPTION
        Finds d365fo.tools commands searching through the inline help text, building a consolidated json index and querying it because Get-Help is too slow
         
    .PARAMETER Tag
        Finds all commands tagged with this auto-populated tag
         
    .PARAMETER Author
        Finds all commands tagged with this author
         
    .PARAMETER MinimumVersion
        Finds all commands tagged with this auto-populated minimum version
         
    .PARAMETER MaximumVersion
        Finds all commands tagged with this auto-populated maximum version
         
    .PARAMETER Rebuild
        Rebuilds the index
         
    .PARAMETER Pattern
        Searches help for all commands in d365fo.tools for the specified pattern and displays all results
         
    .PARAMETER Confirm
        Confirms overwrite of index
         
    .PARAMETER WhatIf
        Displays what would happen if the command is run
         
    .PARAMETER EnableException
        By default, when something goes wrong we try to catch it, interpret it and give you a friendly warning message.
        This avoids overwhelming you with "sea of red" exceptions, but is inconvenient because it basically disables advanced scripting.
        Using this switch turns this "nice by default" feature off and enables you to catch exceptions with your own try/catch.
         
    .EXAMPLE
        PS C:\> Find-D365Command "snapshot"
         
        For lazy typers: finds all commands searching the entire help for "snapshot"
         
    .EXAMPLE
        PS C:\> Find-D365Command -Pattern "snapshot"
         
        For rigorous typers: finds all commands searching the entire help for "snapshot"
         
    .EXAMPLE
        PS C:\> Find-D365Command -Tag copy
         
        Finds all commands tagged with "copy"
         
    .EXAMPLE
        PS C:\> Find-D365Command -Tag copy,user
         
        Finds all commands tagged with BOTH "copy" and "user"
         
    .EXAMPLE
        PS C:\> Find-D365Command -Author M�tz
         
        Finds every command whose author contains "M�tz"
         
    .EXAMPLE
        PS C:\> Find-D365Command -Author M�tz -Tag copy
         
        Finds every command whose author contains "M�tz" and it tagged as "copy"
         
    .EXAMPLE
        PS C:\> Find-D365Command -Pattern snapshot -Rebuild
         
        Finds all commands searching the entire help for "snapshot", rebuilding the index (good for developers)
         
    .NOTES
        Tags: Find, Help, Command
        Author: M�tz Jensen (@Splaxi)
         
        License: MIT https://opensource.org/licenses/MIT
         
        This cmdlet / function is copy & paste implementation based on the Find-DbaCommand from the dbatools.io project
         
        Original author: Simone Bizzotto (@niphold)
         
#>

        [CmdletBinding(SupportsShouldProcess = $true)]
        param (
            [String]$Pattern,
            [String[]]$Tag,
            [String]$Author,
            [String]$MinimumVersion,
            [String]$MaximumVersion,
            [switch]$Rebuild,
            [Alias('Silent')]
            [switch]$EnableException
        )
        begin {
            function Get-D365TrimmedString($Text) {
                return $Text.Trim() -replace '(\r\n){2,}', "`n"
            }
    
            $tagsRex = ([regex]'(?m)^[\s]{0,15}Tags:(.*)$')
            $authorRex = ([regex]'(?m)^[\s]{0,15}Author:(.*)$')
            $minverRex = ([regex]'(?m)^[\s]{0,15}MinimumVersion:(.*)$')
            $maxverRex = ([regex]'(?m)^[\s]{0,15}MaximumVersion:(.*)$')
    
            function Get-D365Help([String]$commandName) {
                $thishelp = Get-Help $commandName -Full
                $thebase = @{ }
                $thebase.CommandName = $commandName
                $thebase.Name = $thishelp.Name
    
                $alias = Get-Alias -Definition $commandName -ErrorAction SilentlyContinue
                $thebase.Alias = $alias.Name -Join ','
    
                ## fetch the description
                $thebase.Description = $thishelp.Description.Text
    
                ## fetch examples
                $thebase.Examples = Get-D365TrimmedString -Text ($thishelp.Examples | Out-String -Width 200)
    
                ## fetch help link
                $thebase.Links = ($thishelp.relatedLinks).NavigationLink.Uri
    
                ## fetch the synopsis
                $thebase.Synopsis = $thishelp.Synopsis
    
                ## fetch the syntax
                $thebase.Syntax = Get-D365TrimmedString -Text ($thishelp.Syntax | Out-String -Width 600)
    
                ## store notes
                $as = $thishelp.AlertSet | Out-String -Width 600
    
                ## fetch the tags
                $tags = $tagsrex.Match($as).Groups[1].Value
                if ($tags) {
                    $thebase.Tags = $tags.Split(',').Trim()
                }
                ## fetch the author
                $author = $authorRex.Match($as).Groups[1].Value
                if ($author) {
                    $thebase.Author = $author.Trim()
                }
    
                ## fetch MinimumVersion
                $MinimumVersion = $minverRex.Match($as).Groups[1].Value
                if ($MinimumVersion) {
                    $thebase.MinimumVersion = $MinimumVersion.Trim()
                }
    
                ## fetch MaximumVersion
                $MaximumVersion = $maxverRex.Match($as).Groups[1].Value
                if ($MaximumVersion) {
                    $thebase.MaximumVersion = $MaximumVersion.Trim()
                }
    
                ## fetch Parameters
                $parameters = $thishelp.parameters.parameter
                $command = Get-Command $commandName
                $params = @()
                foreach($p in $parameters) {
                    $paramAlias = $command.parameters[$p.Name].Aliases
                    $paramDescr = Get-D365TrimmedString -Text ($p.Description | Out-String -Width 200)
                    $params += , @($p.Name, $paramDescr, ($paramAlias -Join ','), ($p.Required -eq $true), $p.PipelineInput, $p.DefaultValue)
                }
    
                $thebase.Params = $params
    
                [pscustomobject]$thebase
            }
    
            function Get-D365Index() {
                if ($Pscmdlet.ShouldProcess($dest, "Recreating index")) {
                    $dbamodule = Get-Module -Name d365fo.tools
                    $allCommands = $dbamodule.ExportedCommands.Values | Where-Object CommandType -EQ 'Function'
    
                    $helpcoll = New-Object System.Collections.Generic.List[System.Object]
                    foreach ($command in $allCommands) {
                        $x = Get-D365Help "$command"
                        $helpcoll.Add($x)
                    }
                    # $dest = Get-DbatoolsConfigValue -Name 'Path.TagCache' -Fallback "$(Resolve-Path $PSScriptRoot\..)\dbatools-index.json"
                    $dest = "$moduleDirectory\bin\d365fo.tools-index.json"
                    $helpcoll | ConvertTo-Json -Depth 4 | Out-File $dest -Encoding UTF8
                }
            }
    
            $moduleDirectory = (Get-Module -Name d365fo.tools).ModuleBase
        }
        process {
            $Pattern = $Pattern.TrimEnd("s")
            $idxFile = "$moduleDirectory\bin\d365fo.tools-index.json"
            if (!(Test-Path $idxFile) -or $Rebuild) {
                Write-PSFMessage -Level Verbose -Message "Rebuilding index into $idxFile"
                $swRebuild = [system.diagnostics.stopwatch]::StartNew()
                Get-D365Index
                Write-PSFMessage -Level Verbose -Message "Rebuild done in $($swRebuild.ElapsedMilliseconds)ms"
            }
            $consolidated = Get-Content -Raw $idxFile | ConvertFrom-Json
            $result = $consolidated
            if ($Pattern.Length -gt 0) {
                $result = $result | Where-Object { $_.PsObject.Properties.Value -like "*$Pattern*" }
            }
    
            if ($Tag.Length -gt 0) {
                foreach ($t in $Tag) {
                    $result = $result | Where-Object Tags -Contains $t
                }
            }
    
            if ($Author.Length -gt 0) {
                $result = $result | Where-Object Author -Like "*$Author*"
            }
    
            if ($MinimumVersion.Length -gt 0) {
                $result = $result | Where-Object MinimumVersion -GE $MinimumVersion
            }
    
            if ($MaximumVersion.Length -gt 0) {
                $result = $result | Where-Object MaximumVersion -LE $MaximumVersion
            }
    
            Select-DefaultView -InputObject $result -Property CommandName, Synopsis
        }
    }


<#
    .SYNOPSIS
        Get active Azure Storage Account configuration
         
    .DESCRIPTION
        Get active Azure Storage Account configuration object from the configuration store
         
    .PARAMETER OutputAsPsCustomObject
        Instruct the cmdlet to return a PsCustomObject object
         
    .EXAMPLE
        PS C:\> Get-D365ActiveAzureStorageConfig
         
        This will get the active Azure Storage configuration.
         
    .EXAMPLE
        PS C:\> Get-D365ActiveAzureStorageConfig -OutputAsPsCustomObject
         
        This will get the active Azure Storage configuration.
        The object will be output as a PsCustomObject, for you to utilize across your scripts.
         
    .NOTES
        Tags: Azure, Azure Storage, Config, Configuration, Token, Blob, Container
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-D365ActiveAzureStorageConfig {
    [CmdletBinding()]
    param (
        [switch] $OutputAsPsCustomObject
    )

    $res = Get-PSFConfigValue -FullName "d365fo.tools.active.azure.storage.account"

    if ($OutputAsPsCustomObject) {
        [PSCustomObject]$res
    }
    else {
        $res
    }
}


<#
    .SYNOPSIS
        Get active broadcast message configuration
         
    .DESCRIPTION
        Get active broadcast message configuration from the configuration store
         
    .PARAMETER OutputAsHashtable
        Instruct the cmdlet to return a hastable object
         
    .EXAMPLE
        PS C:\> Get-D365ActiveBroadcastMessageConfig
         
        This will get the active broadcast message configuration.
         
    .NOTES
        Tags: Servicing, Message, Users, Environment, Config, Configuration, ClientId, ClientSecret
         
        Author: M�tz Jensen (@Splaxi)
         
    .LINK
        Add-D365BroadcastMessageConfig
         
    .LINK
        Clear-D365ActiveBroadcastMessageConfig
         
    .LINK
        Get-D365BroadcastMessageConfig
         
    .LINK
        Remove-D365BroadcastMessageConfig
         
    .LINK
        Send-D365BroadcastMessage
         
    .LINK
        Set-D365ActiveBroadcastMessageConfig
#>


function Get-D365ActiveBroadcastMessageConfig {
    [CmdletBinding()]
    [OutputType()]
    param (
        [switch] $OutputAsHashtable
    )

    $configName = (Get-PSFConfig -FullName "d365fo.tools.active.broadcast.message.config.name").Value

    if ($configName -eq "") {
        Write-PSFMessage -Level Host -Message "It looks like there <c='em'>isn't configured</c> an active broadcast message configuration."
        Stop-PSFFunction -Message "Stopping because an active broadcast message configuration wasn't found."
        return
    }

    Get-D365BroadcastMessageConfig -Name $configName -OutputAsHashtable:$OutputAsHashtable
}


<#
    .SYNOPSIS
        Get active environment configuration
         
    .DESCRIPTION
        Get active environment configuration object from the configuration store
         
    .EXAMPLE
        PS C:\> Get-D365ActiveEnvironmentConfig
         
        This will get the active environment configuration
         
    .EXAMPLE
        PS C:\> $params = @{}
        PS C:\> $params.SqlUser = (Get-D365ActiveEnvironmentConfig).SqlUser
        PS C:\> $params.SqlPwd = (Get-D365ActiveEnvironmentConfig).SqlPwd
         
        This gives you a hashtable with the SqlUser and SqlPwd values from the active environment.
        This enables you to use the $params as splatting for other cmdlets.
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, Tfs, Vsts, Sql, SqlUser, SqlPwd
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-D365ActiveEnvironmentConfig {
    [CmdletBinding()]
    param ()

    (Get-PSFConfigValue -FullName "d365fo.tools.active.environment")
}


<#
    .SYNOPSIS
        Search for AOT object
         
    .DESCRIPTION
        Enables you to search for different AOT objects
         
    .PARAMETER Path
        Path to the package that you want to work against
         
    .PARAMETER ObjectType
        The type of AOT object you're searching for
         
    .PARAMETER Name
        Name of the object that you're looking for
         
        Accepts wildcards for searching. E.g. -Name "Work*status"
         
        Default value is "*" which will search for all objects
         
    .PARAMETER SearchInPackages
        Switch to instruct the cmdlet to search in packages directly instead
        of searching in the XppMetaData directory under a given package
         
    .PARAMETER IncludePath
        Switch to instruct the cmdlet to include the path for the object found
         
    .EXAMPLE
        PS C:\> Get-D365AOTObject -Name *flush* -ObjectType AxClass -Path "C:\AOSService\PackagesLocalDirectory\ApplicationFoundation"
         
        This will search inside the ApplicationFoundation package for all AxClasses that matches the search *flush*.
         
    .EXAMPLE
        PS C:\> Get-D365AOTObject -Name *flush* -ObjectType AxClass -IncludePath -Path "C:\AOSService\PackagesLocalDirectory\ApplicationFoundation"
         
        This will search inside the ApplicationFoundation package for all AxClasses that matches the search *flush* and include the full path to the files.
         
    .EXAMPLE
        PS C:\> Get-D365InstalledPackage -Name Application* | Get-D365AOTObject -Name *flush* -ObjectType AxClass
         
        This searches for all packages that matches Application* and pipes them into Get-D365AOTObject which will search for all AxClasses that matches the search *flush*.
         
    .EXAMPLE
        PS C:\> Get-D365AOTObject -Path "C:\AOSService\PackagesLocalDirectory\*" -Name *flush* -ObjectType AxClass -SearchInPackages
         
        This is an advanced example and shouldn't be something you resolve to every time.
         
        This will search across all packages and will look for the all AxClasses that matches the search *flush*.
        It will NOT search in the XppMetaData directory for each package.
         
        This can stress your system.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-D365AOTObject {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('PackageDirectory')]
        [string] $Path,

        [ValidateSet('AxAggregateDataEntity', 'AxClass', 'AxCompositeDataEntityView',
            'AxDataEntityView', 'AxForm', 'AxMap', 'AxQuery', 'AxTable', 'AxView')]
        [Alias('Type')]
        [string[]] $ObjectType = @("AxClass"),

        [string] $Name = "*",

        [switch] $SearchInPackages,

        [switch] $IncludePath
    )
    
    begin {
        
    }
    
    process {
        $SearchList = New-Object -TypeName "System.Collections.ArrayList"

        foreach ($item in $ObjectType) {
            if ($SearchInPackages) {
                $SearchParent = Split-Path $Path -Leaf

                $null = $SearchList.Add((Join-Path "$Path" "\$SearchParent\$item\*.xml"))
                $SearchParent = $item #* Hack to make the logic when selecting the output work as expected
            }
            else {
                $SearchParent = "XppMetadata"

                $null = $SearchList.Add((Join-Path "$Path" "\$SearchParent\*\$item\*.xml"))
            }
        }
        
        #* We are searching files - so the last character has to be a *
        if($Name.Substring($Name.Length -1, 1) -ne "*") {$Name = "$Name*"}

        $Files = Get-ChildItem -Path ($SearchList.ToArray()) -Filter $Name

        if($IncludePath) {
            $Files | Select-PSFObject -TypeName "D365FO.TOOLS.AotObject" "BaseName as Name",
            @{Name = "AotType"; Expression = {Split-Path(Split-Path -Path $_.Fullname -Parent) -leaf }},
            @{Name = "Model"; Expression = {Split-Path(($_.Fullname -Split $SearchParent)[0] ) -leaf }},
            "Fullname as Path"
        }
        else {
            $Files | Select-PSFObject -TypeName "D365FO.TOOLS.AotObject" "BaseName as Name",
            @{Name = "AotType"; Expression = {Split-Path(Split-Path -Path $_.Fullname -Parent) -leaf }},
            @{Name = "Model"; Expression = {Split-Path(($_.Fullname -Split $SearchParent)[0] ) -leaf }}
        }
    }
    
    end {
    }
}


<#
    .SYNOPSIS
        Get Azure Storage Account configs
         
    .DESCRIPTION
        Get all Azure Storage Account configuration objects from the configuration store
         
    .PARAMETER Name
        The name of the Azure Storage Account you are looking for
         
        Default value is "*" to display all Azure Storage Account configs
         
    .PARAMETER OutputAsHashtable
        Instruct the cmdlet to return a hastable object
         
    .EXAMPLE
        PS C:\> Get-D365AzureStorageConfig
         
        This will show all Azure Storage Account configs
         
    .EXAMPLE
        PS C:\> Get-D365AzureStorageConfig -OutputAsHashtable
         
        This will show all Azure Storage Account configs.
        Every object will be output as a hashtable, for you to utilize as parameters for other cmdlets.
         
    .NOTES
        Tags: Azure, Azure Storage, Config, Configuration, Token, Blob, Container
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-D365AzureStorageConfig {
    [CmdletBinding()]
    param (
        [string] $Name = "*",

        [switch] $OutputAsHashtable
    )
    
    $StorageAccounts = [hashtable](Get-PSFConfigValue -FullName "d365fo.tools.azure.storage.accounts")
        
    foreach ($item in $StorageAccounts.Keys) {
        if ($item -NotLike $Name) { continue }
        $res = [ordered]@{Name = $item }
        $res += $StorageAccounts[$item]

        if ($OutputAsHashtable) {
            $res
        }
        else {
            [PSCustomObject]$res
        }
    }
}


<#
    .SYNOPSIS
        Get a file from Azure
         
    .DESCRIPTION
        Get all files from an Azure Storage Account
         
    .PARAMETER AccountId
        Storage Account Name / Storage Account Id where you want to look for files
         
    .PARAMETER AccessToken
        The token that has the needed permissions for the search action
         
    .PARAMETER SAS
        The SAS key that you have created for the storage account or blob container
         
    .PARAMETER Container
        Name of the blob container inside the storage account you want to look for files
         
    .PARAMETER Name
        Name of the file you are looking for
         
        Accepts wildcards for searching. E.g. -Name "Application*Adaptor"
         
        Default value is "*" which will search for all packages
         
    .PARAMETER Latest
        Instruct the cmdlet to only fetch the latest file from the Azure Storage Account
         
    .EXAMPLE
        PS C:\> Get-D365AzureStorageFile -AccountId "miscfiles" -AccessToken "xx508xx63817x752xx74004x30705xx92x58349x5x78f5xx34xxxxx51" -Container "backupfiles"
         
        This will get all files in the blob container "backupfiles".
        It will use the AccessToken "xx508xx63817x752xx74004x30705xx92x58349x5x78f5xx34xxxxx51" to gain access.
         
    .EXAMPLE
        PS C:\> Get-D365AzureStorageFile -AccountId "miscfiles" -AccessToken "xx508xx63817x752xx74004x30705xx92x58349x5x78f5xx34xxxxx51" -Container "backupfiles" -Latest
         
        This will get the latest (newest) file from the blob container "backupfiles".
        It will use the AccessToken "xx508xx63817x752xx74004x30705xx92x58349x5x78f5xx34xxxxx51" to gain access to the container.
         
    .EXAMPLE
        PS C:\> Get-D365AzureStorageFile -AccountId "miscfiles" -AccessToken "xx508xx63817x752xx74004x30705xx92x58349x5x78f5xx34xxxxx51" -Container "backupfiles" -Name "*UAT*"
         
        This will get all files in the blob container "backupfiles" that fits the "*UAT*" search value.
        It will use the AccessToken "xx508xx63817x752xx74004x30705xx92x58349x5x78f5xx34xxxxx51" to gain access to the container.
         
    .EXAMPLE
        PS C:\> Get-D365AzureStorageFile -AccountId "miscfiles" -SAS "sv2018-03-28&siunlisted&src&sigAUOpdsfpoWE976ASDhfjkasdf(5678sdfhk" -Container "backupfiles" -Latest
         
        This will get the latest (newest) file from the blob container "backupfiles".
        It will use the SAS key "sv2018-03-28&siunlisted&src&sigAUOpdsfpoWE976ASDhfjkasdf(5678sdfhk" to gain access to the container.
         
    .NOTES
        Tags: Azure, Azure Storage, Token, Blob, File, Container
         
        Author: M�tz Jensen (@Splaxi)
#>

function Get-D365AzureStorageFile {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [string] $AccountId = $Script:AzureStorageAccountId,

        [string] $AccessToken = $Script:AzureStorageAccessToken,

        [string] $SAS = $Script:AzureStorageSAS,

        [Alias('Blob')]
        [Alias('Blobname')]
        [string] $Container = $Script:AzureStorageContainer,

        [Parameter(ParameterSetName = 'Default')]
        [Alias('FileName')]
        [string] $Name = "*",

        [Parameter(Mandatory = $true, ParameterSetName = 'Latest')]
        [Alias('GetLatest')]
        [switch] $Latest
    )

    if (([string]::IsNullOrEmpty($AccountId) -eq $true) -or
        ([string]::IsNullOrEmpty($Container)) -or
        (([string]::IsNullOrEmpty($AccessToken)) -and ([string]::IsNullOrEmpty($SAS)))) {
        Write-PSFMessage -Level Host -Message "It seems that you are missing some of the parameters. Please make sure that you either supplied them or have the right configuration saved."
        Stop-PSFFunction -Message "Stopping because of missing parameters"
        return
    }

    Invoke-TimeSignal -Start

    if ([string]::IsNullOrEmpty($SAS)) {
        Write-PSFMessage -Level Verbose -Message "Working against Azure Storage Account with AccessToken"

        $storageContext = new-AzureStorageContext -StorageAccountName $AccountId.ToLower() -StorageAccountKey $AccessToken
    }
    else {
        Write-PSFMessage -Level Verbose -Message "Working against Azure Storage Account with SAS"

        $conString = $("BlobEndpoint=https://{0}.blob.core.windows.net/;QueueEndpoint=https://{0}.queue.core.windows.net/;FileEndpoint=https://{0}.file.core.windows.net/;TableEndpoint=https://{0}.table.core.windows.net/;SharedAccessSignature={1}" -f $AccountId.ToLower(), $SAS)
        $storageContext = new-AzureStorageContext -ConnectionString $conString
    }

    $cloudStorageAccount = [Microsoft.WindowsAzure.Storage.CloudStorageAccount]::Parse($storageContext.ConnectionString)

    $blobClient = $cloudStorageAccount.CreateCloudBlobClient()

    $blobcontainer = $blobClient.GetContainerReference($Container);

    try {
        $files = $blobcontainer.ListBlobs() | Sort-Object -Descending { $_.Properties.LastModified }

        if ($Latest) {
            $files | Select-Object -First 1 | Select-PSFObject -TypeName D365FO.TOOLS.Azure.Blob "name", @{Name = "Size"; Expression = {[PSFSize]$_.Properties.Length}}, "IsDeleted", @{Name = "LastModified"; Expression = {[Datetime]::Parse($_.Properties.LastModified)}}
        }
        else {
    
            foreach ($obj in $files) {
                if ($obj.Name -NotLike $Name) { continue }

                $obj | Select-PSFObject -TypeName D365FO.TOOLS.Azure.Blob "name", @{Name = "Size"; Expression = {[PSFSize]$_.Properties.Length}}, "IsDeleted", @{Name = "LastModified"; Expression = {[Datetime]::Parse($_.Properties.LastModified)}}
            }
        }
    }
    catch {
        Write-PSFMessage -Level Warning -Message "Something broke" -ErrorRecord $_
    }
}


<#
    .SYNOPSIS
        Get a blob Url from Azure Storage account
         
    .DESCRIPTION
        Get a valid blob container url from an Azure Storage Account
         
    .PARAMETER AccountId
        Storage Account Name / Storage Account Id you want to work against
         
    .PARAMETER SAS
        The SAS key that you have created for the storage account or blob container
         
    .PARAMETER Container
        Name of the blob container inside the storage account you want to work against
         
    .PARAMETER OutputAsHashtable
        Instruct the cmdlet to return a hastable object
         
    .EXAMPLE
        PS C:\> Get-D365AzureStorageUrl -AccountId "miscfiles" -SAS "sv2018-03-28&siunlisted&src&sigAUOpdsfpoWE976ASDhfjkasdf(5678sdfhk" -Container "backupfiles"
         
        This will generate a valid Url for the blob container in the Azure Storage Account.
        It will use the AccountId "miscfiles" as the name of the storage account.
        It will use the SAS key "sv2018-03-28&siunlisted&src&sigAUOpdsfpoWE976ASDhfjkasdf(5678sdfhk" to add the SAS token/key to the Url.
        It will use the Container "backupfiles" as the container name in the Url.
         
    .EXAMPLE
        PS C:\> Get-D365AzureStorageUrl
         
        This will generate a valid Url for the blob container in the Azure Storage Account.
        It will use the default values that are configured using the Set-D365ActiveAzureStorageConfig cmdlet and view using the Get-D365ActiveAzureStorageConfig cmdlet.
         
    .EXAMPLE
        PS C:\> Get-D365AzureStorageUrl -OutputAsHashtable
         
        This will generate a valid Url for the blob container in the Azure Storage Account.
        It will use the default values that are configured using the Set-D365ActiveAzureStorageConfig cmdlet and view using the Get-D365ActiveAzureStorageConfig cmdlet.
         
        The output object will be a Hashtable, which you can use as a parameter for other cmdlets.
         
    .EXAMPLE
        PS C:\> $DestinationParms = Get-D365AzureStorageUrl -OutputAsHashtable
        PS C:\> $BlobFileDetails = Get-D365LcsDatabaseBackups -Latest | Invoke-D365AzCopyTransfer @DestinationParms
        PS C:\> $BlobFileDetails | Invoke-D365AzCopyTransfer -DestinationUri "C:\Temp" -DeleteOnTransferComplete
         
        This will transfer the lastest backup file from LCS Asset Library to your local "C:\Temp".
        It will get a destination Url, for it to transfer the backup file between the LCS storage account and your own.
        The newly transfered file, that lives in your own storage account, will then be downloaded to your local "c:\Temp".
         
        After the file has been downloaded to your local "C:\Temp", it will be deleted from your own storage account.
         
    .NOTES
        Tags: Azure, Azure Storage, Token, Blob, File, Container, LCS, Asset, Bacpac, Backup
         
        Author: M�tz Jensen (@Splaxi)
#>

function Get-D365AzureStorageUrl {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')]
    [CmdletBinding()]
    param (
        [string] $AccountId = $Script:AzureStorageAccountId,

        [string] $SAS = $Script:AzureStorageSAS,

        [Alias('Blob')]
        [Alias('Blobname')]
        [string] $Container = $Script:AzureStorageContainer,

        [switch] $OutputAsHashtable
    )

    if (([string]::IsNullOrEmpty($AccountId) -eq $true) -or
        ([string]::IsNullOrEmpty($Container)) -or
        ([string]::IsNullOrEmpty($SAS))) {
        Write-PSFMessage -Level Host -Message "It seems that you are missing some of the parameters. Please make sure that you either supplied them or have the right configuration saved."
        Stop-PSFFunction -Message "Stopping because of missing parameters"
        return
    }

    Invoke-TimeSignal -Start

    if ($SAS.StartsWith("?")) {
        $SAS = $SAS.Substring(1)
    }

    $res = @{
        DestinationUri = $("https://{0}.blob.core.windows.net/{1}?{2}" -f $AccountId.ToLower(), $Container, $SAS)
    }

    if ($OutputAsHashtable) {
        $res
    }
    else {
        [PSCustomObject]$res
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Get the SQL Server options from the bacpac model.xml file
         
    .DESCRIPTION
        Extract the SQL Server options that are listed inside the model.xml file originating from a bacpac file
         
    .PARAMETER Path
        Path to the extracted model.xml file that you want to work against
         
    .EXAMPLE
        PS C:\> Get-D365BacpacSqlOptions -Path "c:\temp\d365fo.tools\bacpac.model.xml"
         
        This will display all the SQL Server options configured in the bacpac model file.
         
    .EXAMPLE
        PS C:\> Export-D365BacpacModelFile -Path "c:\Temp\AxDB.bacpac" | Get-D365BacpacSqlOptions
         
        This will display all the SQL Server options configured in the bacpac file.
        First it will export the model.xml from the "c:\Temp\AxDB.bacpac" file, using the Export-D365BacpacModelFile function.
        The output from Export-D365BacpacModelFile will be piped into the Get-D365BacpacSqlOptions function.
         
    .NOTES
        Tags: Bacpac, Servicing, Data, SqlPackage, Sql Server Options, Collation
         
        Author: M�tz Jensen (@Splaxi)
#>


function Get-D365BacpacSqlOptions {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [Alias("Get-D365SqlOptionsFromBacpacModelFile")]
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias('ModelFile')]
        [Alias('File')]
        [string] $Path
    )

    begin {
        Invoke-TimeSignal -Start
    }

    process {
        if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }

        if (Test-PSFFunctionInterrupt) { return }
    
        $reader = [System.Xml.XmlReader]::Create($Path)

        $break = $false
        while ($reader.read() -and -not ($break)) {
            switch ($reader.NodeType) {
                ([System.Xml.XmlNodeType]::Element) {
                    if ($reader.Name -eq "Element") {
                        if ($reader.GetAttribute("Type") -eq "SqlDatabaseOptions") {
                            if ($reader.ReadToDescendant("Property")) {
                                do {
                                    [PSCustomObject]@{OptionName = $reader.GetAttribute("Name")
                                        OptionValue              = $reader.GetAttribute("Value")
                                    }

                                } while ($reader.ReadToNextSibling("Property"))

                            }

                            $break = $true
                            break
                        }
                    }

                    break
                }
            }
        }
    }

    end {
        if ($reader) {
            $reader.Close()
            $reader.Dispose()
        }

        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Get tables from the bacpac file
         
    .DESCRIPTION
        Get tables and their metadata from the bacpac file
         
        Metadata as in original size and compressed size, which are what size the bulk files are and will only indicate what you can expect of the table size
         
    .PARAMETER Path
        Path to the bacpac file that you want to work against
         
        It can also be a zip file
         
    .PARAMETER Table
        Name of the table that you want to delete the data for
         
        Supports an array of table names
         
        If a schema name isn't supplied as part of the table name, the cmdlet will prefix it with "dbo."
         
        Supports wildcard searching e.g. "Sales*" will locate all "dbo.Sales*" tables in the bacpac file
         
    .PARAMETER Top
        Instruct the cmdlet with how many tables you want returned
         
        Default is [int]::max, which translates into all tables present inside the bapcac file
         
    .PARAMETER SortSizeAsc
        Instruct the cmdlet to sort the output by size (original) ascending
         
    .PARAMETER SortSizeDesc
        Instruct the cmdlet to sort the output by size (original) descending
         
    .EXAMPLE
        PS C:\> Get-D365BacpacTable -Path "c:\Temp\AxDB.bacpac"
         
        This will return all tables from inside the bacpac file.
         
        It uses "c:\Temp\AxDB.bacpac" as the Path for the bacpac file.
        It uses the default value "*" as the Table parameter, to output all tables.
        It uses the default value "[int]::max" as the Top parameter, to output all tables.
        It uses the default sort, which is by name acsending.
         
        A result set example:
         
        Name OriginalSize CompressedSize BulkFiles
        ---- ------------ -------------- ---------
        ax.DBVERSION 62 B 52 B 1
        crt.RETAILUPGRADEHISTORY 13,49 MB 13,41 MB 3
        dbo.__AOSMESSAGEREGISTRATION 1,80 KB 540 B 2
        dbo.__AOSSTARTUPVERSION 4 B 6 B 1
        dbo.ACCOUNTINGDISTRIBUTION 48,60 MB 4,50 MB 95
        dbo.ACCOUNTINGEVENT 11,16 MB 1,51 MB 128
        dbo.AGREEMENTPARAMETERS_RU 366 B 113 B 1
        dbo.AIFSQLCDCENABLEDTABLES 13,63 KB 2,19 KB 1
        dbo.AIFSQLCHANGETRACKINGENABLEDTABLES 9,89 KB 1,42 KB 1
        dbo.AIFSQLCTTRIGGERS 44,75 KB 6,29 KB 1
         
    .EXAMPLE
        PS C:\> Get-D365BacpacTable -Path "c:\Temp\AxDB.bacpac" -SortSizeAsc
         
        This will return all tables from inside the bacpac file, sorted by the original size, ascending.
         
        It uses "c:\Temp\AxDB.bacpac" as the Path for the bacpac file.
        It uses the default value "*" as the Table parameter, to output all tables.
        It uses the default value "[int]::max" as the Top parameter, to output all tables.
        It uses the SortSizeAsc parameter, which is by original size acsending.
         
        A result set example:
         
        Name OriginalSize CompressedSize BulkFiles
        ---- ------------ -------------- ---------
        dbo.__AOSSTARTUPVERSION 4 B 6 B 1
        dbo.SYSSORTORDER 20 B 20 B 1
        dbo.SECURITYDATABASESETTINGS 20 B 12 B 1
        dbo.SYSPOLICYSEQUENCEGROUP 24 B 10 B 1
        dbo.SYSFILESTOREPARAMETERS 26 B 10 B 1
        dbo.SYSHELPCPSSETUP 28 B 15 B 1
        dbo.DATABASELOGPARAMETERS 28 B 10 B 1
        dbo.FEATUREMANAGEMENTPARAMETERS 28 B 10 B 1
        dbo.AIFSQLCTVERSION 28 B 24 B 1
        dbo.SYSHELPSETUP 28 B 15 B 1
         
    .EXAMPLE
        PS C:\> Get-D365BacpacTable -Path "c:\Temp\AxDB.bacpac" -SortSizeDesc
         
        This will return all tables from inside the bacpac file, sorted by the original size, descending.
         
        It uses "c:\Temp\AxDB.bacpac" as the Path for the bacpac file.
        It uses the default value "*" as the Table parameter, to output all tables.
        It uses the default value "[int]::max" as the Top parameter, to output all tables.
        It uses the SortSizeDesc parameter, which is by original size descending.
         
        A result set example:
         
        Name OriginalSize CompressedSize BulkFiles
        ---- ------------ -------------- ---------
        dbo.TSTIMESHEETLINESTAGING 35,31 GB 2,44 GB 9077
        dbo.RESROLLUP 13,30 GB 367,19 MB 3450
        dbo.PROJECTSTAGING 11,31 GB 508,70 MB 2929
        dbo.TSTIMESHEETTABLESTAGING 5,93 GB 246,65 MB 1564
        dbo.BATCHHISTORY 5,80 GB 234,99 MB 1529
        dbo.HCMPOSITIONHIERARCHYSTAGING 5,16 GB 222,18 MB 1358
        dbo.ERLCSFILEASSETTABLE 3,15 GB 217,68 MB 302
        dbo.EVENTINBOX 2,92 GB 105,63 MB 747
        dbo.HCMPOSITIONV2STAGING 2,79 GB 200,27 MB 755
        dbo.HCMEMPLOYEESTAGING 2,49 GB 218,69 MB 677
         
    .EXAMPLE
        PS C:\> Get-D365BacpacTable -Path "c:\Temp\AxDB.bacpac" -SortSizeDesc -Top 5
         
        This will return all tables from inside the bacpac file, sorted by the original size, descending.
         
        It uses "c:\Temp\AxDB.bacpac" as the Path for the bacpac file.
        It uses the default value "*" as the Table parameter, to output all tables.
        It uses the value 5 as the Top parameter, to output only 5 tables, based on the sorting selected.
        It uses the SortSizeDesc parameter, which is by original size descending.
         
        A result set example:
         
        Name OriginalSize CompressedSize BulkFiles
        ---- ------------ -------------- ---------
        dbo.TSTIMESHEETLINESTAGING 35,31 GB 2,44 GB 9077
        dbo.RESROLLUP 13,30 GB 367,19 MB 3450
        dbo.PROJECTSTAGING 11,31 GB 508,70 MB 2929
        dbo.TSTIMESHEETTABLESTAGING 5,93 GB 246,65 MB 1564
        dbo.BATCHHISTORY 5,80 GB 234,99 MB 1529
         
    .EXAMPLE
        PS C:\> Get-D365BacpacTable -Path "c:\Temp\AxDB.bacpac" -Table "Sales*"
         
        This will return all tables which matches the "Sales*" wildcard search from inside the bacpac file.
         
        It uses "c:\Temp\AxDB.bacpac" as the Path for the bacpac file.
        It uses the default value "Sales*" as the Table parameter, to output all tables that matches the wildcard pattern.
        It uses the default value "[int]::max" as the Top parameter, to output all tables.
        It uses the default sort, which is by name acsending.
         
        A result set example:
         
        Name OriginalSize CompressedSize BulkFiles
        ---- ------------ -------------- ---------
        dbo.SALESPARAMETERS 4,29 KB 310 B 1
        dbo.SALESPARMUPDATE 273,48 KB 24,21 KB 1
        dbo.SALESQUOTATIONTOLINEPARAMETERS 4,18 KB 596 B 1
        dbo.SALESSUMMARYPARAMETERS 2,95 KB 425 B 1
        dbo.SALESTABLE 1,20 KB 313 B 1
        dbo.SALESTABLE_W 224 B 60 B 1
        dbo.SALESTABLE2LINEPARAMETERS 4,46 KB 637 B 1
         
    .EXAMPLE
        PS C:\> Get-D365BacpacTable -Path "c:\Temp\AxDB.bacpac" -Table "Sales*","CUSTINVOICE*"
         
        This will return all tables which matches the "Sales*" and "CUSTINVOICE*" wildcard searches from inside the bacpac file.
         
        It uses "c:\Temp\AxDB.bacpac" as the Path for the bacpac file.
        It uses the default value "Sales*" and "CUSTINVOICE*" as the Table parameter, to output all tables that matches the wildcard pattern.
        It uses the default value "[int]::max" as the Top parameter, to output all tables.
        It uses the default sort, which is by name acsending.
         
        A result set example:
         
        Name OriginalSize CompressedSize BulkFiles
        ---- ------------ -------------- ---------
        dbo.CUSTINVOICEJOUR 2,01 MB 118,87 KB 1
        dbo.CUSTINVOICELINE 14,64 MB 975,30 KB 4
        dbo.CUSTINVOICELINEINTERPROJ 6,58 MB 477,97 KB 2
        dbo.CUSTINVOICETABLE 1,06 MB 56,56 KB 1
        dbo.CUSTINVOICETRANS 32,34 MB 1,51 MB 54
        dbo.SALESPARAMETERS 4,29 KB 310 B 1
        dbo.SALESPARMUPDATE 273,48 KB 24,21 KB 1
        dbo.SALESQUOTATIONTOLINEPARAMETERS 4,18 KB 596 B 1
        dbo.SALESSUMMARYPARAMETERS 2,95 KB 425 B 1
        dbo.SALESTABLE 1,20 KB 313 B 1
        dbo.SALESTABLE_W 224 B 60 B 1
        dbo.SALESTABLE2LINEPARAMETERS 4,46 KB 637 B 1
         
    .EXAMPLE
        PS C:\> Get-D365BacpacTable -Path "c:\Temp\AxDB.bacpac" -Table "SalesTable","CustTable"
         
        This will return the tables "dbo.SalesTable" and "dbo.CustTable" from inside the bacpac file.
         
        It uses "c:\Temp\AxDB.bacpac" as the Path for the bacpac file.
        It uses the default value "SalesTable" and "CustTable" as the Table parameter, to output the tables that matches the names.
        It uses the default value "[int]::max" as the Top parameter, to output all tables.
        It uses the default sort, which is by name acsending.
         
        A result set example:
         
        Name OriginalSize CompressedSize BulkFiles
        ---- ------------ -------------- ---------
        dbo.CUSTTABLE 154,91 KB 8,26 KB 1
        dbo.SALESTABLE 1,20 KB 313 B 1
         
    .NOTES
        Tags: Bacpac, Servicing, Data, SqlPackage, Table, Size, Troubleshooting
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-D365BacpacTable {
    [Alias("Export-D365ModelFileFromBacpac")]
    [CmdletBinding(DefaultParameterSetName = "Default")]
    param (
        [Parameter(Mandatory = $true)]
        [Alias('File')]
        [Alias('BacpacFile')]
        [string] $Path,

        [string[]] $Table = "*",

        [int] $Top = [int]::MaxValue,

        [Parameter(ParameterSetName = "SortSizeAsc")]
        [switch] $SortSizeAsc,

        [Parameter(ParameterSetName = "SortSizeDesc")]
        [switch] $SortSizeDesc

    )
    
    begin {
        if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }

        if (Test-PSFFunctionInterrupt) { return }

        $originalExtension = ""

        $fileName = [System.IO.Path]::GetFileNameWithoutExtension($Path)
        
        if ($Path -like "*.bacpac") {
            Write-PSFMessage -Level Verbose -Message "Renaming the bacpac file to zip, to be able to extract the file. $($fileName).zip" -Target $Path

            Rename-Item -Path $Path -NewName "$($fileName).zip"

            $originalExtension = "bacpac"

            $archivePath = Join-Path -Path (Split-Path -Path $Path -Parent) -ChildPath "$($fileName).zip"
        }
        else {
            $archivePath = $Path
        }

        if (Test-PSFFunctionInterrupt) { return }

        $zipFileMetadata = [System.IO.Compression.ZipFile]::OpenRead($archivePath)
    }

    process {
        if (Test-PSFFunctionInterrupt) { return }

        $bulkFilesArray = New-Object System.Collections.Generic.List[System.Object]

        foreach ($item in $table) {

            $fullTableName = ""

            if ($item -eq "*") {
                $fullTableName = $item
            }
            elseif (-not ($item -like "*.*")) {
                $fullTableName = "dbo.$item"
            }
            else {
                $fullTableName = $item
            }
            
            Write-PSFMessage -Level Verbose -Message "Looking for $fullTableName."

            $entries = $zipFileMetadata.Entries | Where-Object Fullname -like "Data/$fullTableName/*"

            $bulkFilesArray.AddRange(@($($entries | Select-Object -Property *, @{Name = "Table"; Expression = { $_.FullName.Split("/")[1] } })))
        }

        $bulkFiles = $bulkFilesArray.ToArray() | Sort-Object -Property Fullname -Unique

        $grouped = $bulkFiles | Group-Object -Property Table

        $res = $grouped | ForEach-Object {
            [pscustomobject]@{ Name = $_.Name
                OriginalSize        = [PSFSize]$($_.Group.Length | Measure-Object -sum | Select-Object -ExpandProperty sum)
                CompressedSize      = [PSFSize]$($_.Group.CompressedLength | Measure-Object -sum | Select-Object -ExpandProperty sum)
                BulkFiles           = $_.Count
                PSTypeName          = 'D365FO.TOOLS.Bacpac.Table'
            }
        }

        if ($SortSizeAsc) {
            $res | Sort-Object OriginalSize | Select-Object -First $Top
        }
        elseif ($SortSizeDesc) {
            $res | Sort-Object OriginalSize -Descending | Select-Object -First $Top
        }
        else {
            $res | Sort-Object Name | Select-Object -First $Top
        }
    }
    
    end {
        if ($zipFileMetadata) {
            $bulkFilesArray.Clear()
            $bulkFilesArray = $null
            $zipFileMetadata.Dispose()
        }

        if ($originalExtension -eq "bacpac") {
            Rename-Item -Path $archivePath -NewName "$($fileName).bacpac"
        }
    }
}


<#
    .SYNOPSIS
        Get broadcast message from the D365FO environment
         
    .DESCRIPTION
        Get broadcast message from the D365FO environment by looking into the database table
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER ExcludeExpired
        Exclude all the records that has already expired
         
    .EXAMPLE
        PS C:\> Get-D365BroadcastMessage
         
        This will display all the broadcast message records from the SysBroadcastMessage table.
         
    .EXAMPLE
        PS C:\> Get-D365BroadcastMessage -ExcludeExpired
         
        This will display all active the broadcast message records from the SysBroadcastMessage table.
         
    .NOTES
        Tags: Broadcast, Message, SysBroadcastMessage, Servicing, Message, Users, Environment
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Get-D365BroadcastMessage {
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $false, Position = 1)]
        [string] $DatabaseServer = $Script:DatabaseServer,

        [Parameter(Mandatory = $false, Position = 2)]
        [string] $DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, Position = 3)]
        [string] $SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, Position = 4)]
        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [switch] $ExcludeExpired
    )

    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

    $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd
    }

    $SqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection

    if ($ExcludeExpired) {
        $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\get-broadcastmessageactive.sql") -join [Environment]::NewLine
    }
    else {
        $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\get-broadcastmessage.sql") -join [Environment]::NewLine
    }

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()
    
        $reader = $sqlCommand.ExecuteReader()

        while ($reader.Read() -eq $true) {
            [PSCustomObject]@{
                StartTime    = [System.TimeZoneInfo]::ConvertTimeFromUtc($($reader.GetDateTime($($reader.GetOrdinal("FROMDATETIME")))), [System.TimeZoneInfo]::Local)
                EndTime      = [System.TimeZoneInfo]::ConvertTimeFromUtc($($reader.GetDateTime($($reader.GetOrdinal("TODATETIME")))), [System.TimeZoneInfo]::Local)
                StartTimeUtc = [System.TimeZoneInfo]::ConvertTimeFromUtc($($reader.GetDateTime($($reader.GetOrdinal("TODATETIME")))), [System.TimeZoneInfo]::Utc)
                EndTimeUtc   = [System.TimeZoneInfo]::ConvertTimeFromUtc($($reader.GetDateTime($($reader.GetOrdinal("TODATETIME")))), [System.TimeZoneInfo]::Utc)
                AOSId        = "$($reader.GetString($($reader.GetOrdinal("AOSID"))))"
            }
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
    finally {
        $reader.close()

        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
    }
}


<#
    .SYNOPSIS
        Get broadcast message configs
         
    .DESCRIPTION
        Get all broadcast message configuration objects from the configuration store
         
    .PARAMETER Name
        The name of the broadcast message configuration you are looking for
         
        Default value is "*" to display all broadcast message configs
         
    .PARAMETER OutputAsHashtable
        Instruct the cmdlet to return a hastable object
         
    .EXAMPLE
        PS C:\> Get-D365BroadcastMessageConfig
         
        This will display all broadcast message configurations on the machine.
         
    .EXAMPLE
        PS C:\> Get-D365BroadcastMessageConfig -OutputAsHashtable
         
        This will display all broadcast message configurations on the machine.
        Every object will be output as a hashtable, for you to utilize as parameters for other cmdlets.
         
    .EXAMPLE
        PS C:\> Get-D365BroadcastMessageConfig -Name "UAT"
         
        This will display the broadcast message configuration that is saved with the name "UAT" on the machine.
         
    .NOTES
        Tags: Servicing, Message, Users, Environment, Config, Configuration, ClientId, ClientSecret
         
        Author: M�tz Jensen (@Splaxi)
         
    .LINK
        Add-D365BroadcastMessageConfig
         
    .LINK
        Clear-D365ActiveBroadcastMessageConfig
         
    .LINK
        Get-D365ActiveBroadcastMessageConfig
         
    .LINK
        Remove-D365BroadcastMessageConfig
         
    .LINK
        Send-D365BroadcastMessage
         
    .LINK
        Set-D365ActiveBroadcastMessageConfig
#>


function Get-D365BroadcastMessageConfig {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')]
    [CmdletBinding()]
    [OutputType('PSCustomObject')]
    param (
        [string] $Name = "*",

        [switch] $OutputAsHashtable
    )
    
    Write-PSFMessage -Level Verbose -Message "Fetch all configurations based on $Name" -Target $Name

    $Name = $Name.ToLower()
    $configurations = Get-PSFConfig -FullName "d365fo.tools.broadcast.$Name.name"

    foreach ($configName in $configurations.Value.ToLower()) {
        Write-PSFMessage -Level Verbose -Message "Working against the $configName configuration" -Target $configName
        $res = @{}

        $configName = $configName.ToLower()

        foreach ($config in Get-PSFConfig -FullName "d365fo.tools.broadcast.$configName.*") {
            $propertyName = $config.FullName.ToString().Replace("d365fo.tools.broadcast.$configName.", "")
            $res.$propertyName = $config.Value
        }
        
        if($OutputAsHashtable) {
            $res
        } else {
            [PSCustomObject]$res
        }
    }
}


<#
    .SYNOPSIS
        Get the ClickOnce configuration
         
    .DESCRIPTION
        Creates the needed registry keys and values for ClickOnce to work on the machine
         
    .EXAMPLE
        PS C:\> Get-D365ClickOnceTrustPrompt
         
        This will get the current ClickOnce configuration
         
    .NOTES
        Tags: ClickOnce, Registry, TrustPrompt
         
        Author: M�tz Jensen (@Splaxi)
#>

function Get-D365ClickOnceTrustPrompt {
    [CmdletBinding()]
    param (
    
    )
    
    begin {
    }
    
    process {
        Write-PSFMessage -Level Verbose -Message "Testing if the registry key exists or not"

        if ((Test-Path -Path "HKLM:\SOFTWARE\MICROSOFT\.NETFramework\Security\TrustManager\PromptingLevel") -eq $false) {
            Write-PSFMessage -Level Host -Message "It looks like ClickOnce trust prompt has never been configured on this machine. Run Set-D365ClickOnceTrustPrompt to fix that"
        }
        else {
            Write-PSFMessage -Level Verbose -Message "Gathering the details from registry"

            [PSCustomObject]@{
                UntrustedSites = (Get-ItemProperty -Path "HKLM:\SOFTWARE\MICROSOFT\.NETFramework\Security\TrustManager\PromptingLevel" "UntrustedSites").UntrustedSites
                Internet       = (Get-ItemProperty -Path "HKLM:\SOFTWARE\MICROSOFT\.NETFramework\Security\TrustManager\PromptingLevel" "Internet").Internet
                MyComputer     = (Get-ItemProperty -Path "HKLM:\SOFTWARE\MICROSOFT\.NETFramework\Security\TrustManager\PromptingLevel" "MyComputer").MyComputer
                LocalIntranet  = (Get-ItemProperty -Path "HKLM:\SOFTWARE\MICROSOFT\.NETFramework\Security\TrustManager\PromptingLevel" "LocalIntranet").LocalIntranet
                TrustedSites   = (Get-ItemProperty -Path "HKLM:\SOFTWARE\MICROSOFT\.NETFramework\Security\TrustManager\PromptingLevel" "TrustedSites").TrustedSites
            }
        }

    }
    
    end {
    }
}


<#
    .SYNOPSIS
        Get the compiler outputs presented
         
    .DESCRIPTION
        Get the compiler outputs presented in a structured manner on the screen
         
        It could be a Visual Studio compiler log or it could be a Invoke-D365ModuleCompile log you want analyzed
         
    .PARAMETER Path
        Path to the compiler log file that you want to work against
         
        A BuildModelResult.log or a Dynamics.AX.*.xppc.log file will both work
         
    .PARAMETER ErrorsOnly
        Instructs the cmdlet to only output compile results where there was errors detected
         
    .PARAMETER OutputTotals
        Instructs the cmdlet to output the total errors and warnings after the analysis
         
    .PARAMETER OutputAsObjects
        Instructs the cmdlet to output the objects instead of formatting them
         
        If you don't assign the output, it will be formatted the same way as the original output, but without the coloring of the column values
         
    .EXAMPLE
        PS C:\> Get-D365CompilerResult -Path "c:\temp\d365fo.tools\Custom\Dynamics.AX.Custom.xppc.log"
         
        This will analyze the compiler log file for warning and errors.
         
        A result set example:
         
        File Warnings Errors
        ---- -------- ------
        c:\temp\d365fo.tools\Custom\Dynamics.AX.Custom.xppc.log 2 1
         
    .EXAMPLE
        PS C:\> Get-D365CompilerResult -Path "c:\temp\d365fo.tools\Custom\Dynamics.AX.Custom.xppc.log" -ErrorsOnly
         
        This will analyze the compiler log file for warning and errors, but only output if it has errors.
         
        A result set example:
         
        File Warnings Errors
        ---- -------- ------
        c:\temp\d365fo.tools\Custom\Dynamics.AX.Custom.xppc.log 2 1
         
    .EXAMPLE
        PS C:\> Get-D365CompilerResult -Path "c:\temp\d365fo.tools\Custom\Dynamics.AX.Custom.xppc.log" -ErrorsOnly -OutputAsObjects
         
        This will analyze the compiler log file for warning and errors, but only output if it has errors.
        The output will be PSObjects, which can be assigned to a variable and used for futher analysis.
         
        A result set example:
         
        File Warnings Errors
        ---- -------- ------
        c:\temp\d365fo.tools\Custom\Dynamics.AX.Custom.xppc.log 2 1
         
    .EXAMPLE
        PS C:\> Get-D365Module -Name *Custom* | Invoke-D365ModuleCompile | Get-D365CompilerResult -OutputTotals
         
        This will find all modules with Custom in their name.
        It will pass thoses modules into the Invoke-D365ModuleCompile, which will compile them.
        It will pass the paths to each compile output log to Get-D365CompilerResult, which will analyze them for warning and errors.
        It will output the total number of warning and errors found.
         
        File Warnings Errors
        ---- -------- ------
        c:\temp\d365fo.tools\Custom\Dynamics.AX.Custom.xppc.log 2 1
         
        Total Errors: 1
        Total Warnings: 2
         
    .NOTES
        Tags: Compiler, Build, Errors, Warnings, Tasks
         
        Author: M�tz Jensen (@Splaxi)
         
        This cmdlet is inspired by the work of "Vilmos Kintera" (twitter: @DAXRunBase)
         
        All credits goes to him for showing how to extract these information
         
        His blog can be found here:
        https://www.daxrunbase.com/blog/
         
        The specific blog post that we based this cmdlet on can be found here:
        https://www.daxrunbase.com/2020/03/31/interpreting-compiler-results-in-d365fo-using-powershell/
         
        The github repository containing the original scrips can be found here:
        https://github.com/DAXRunBase/PowerShell-and-Azure
         
#>

function Get-D365CompilerResult {
    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')]
    [OutputType('[PsCustomObject]')]
    param (
        [parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('LogFile')]
        [string] $Path,

        [switch] $ErrorsOnly,

        [switch] $OutputTotals,

        [switch] $OutputAsObjects
    )

    begin {
        Invoke-TimeSignal -Start
    
        $outputCollection = New-Object System.Collections.Generic.List[System.Object]
    }

    process {
        if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }

        $res = Get-CompilerResult -Path $Path

        if ($null -ne $res) {
            $outputCollection.Add($res)
        }
    }

    end {
        
        $totalErrors = 0
        $totalWarnings = 0
    
        $resCol = @($outputCollection.ToArray())
        
        $totalWarnings = ($resCol | Measure-Object -Property Warnings -Sum).Sum
        $totalErrors = ($resCol | Measure-Object -Property Errors -Sum).Sum

        if($ErrorsOnly) {
            $resCol = @($resCol | Where-Object Errors -gt 0)
        }

        if($OutputAsObjects){
            $resCol
        }
        else {
            $resCol | format-table File, @{Label = "Warnings"; Expression = { $e = [char]27; $color = "93"; "$e[${color}m$($_.Warnings)${e}[0m" }; Align = 'right' }, @{Label = "Errors"; Expression = { $e = [char]27; $color = "91"; "$e[${color}m$($_.Errors)${e}[0m" }; Align = 'right' }
        }

        if ($OutputTotals) {
            Write-PSFHostColor -String "<c='Red'>Total Errors: $totalErrors</c>"
            Write-PSFHostColor -String "<c='Yellow'>Total Warnings: $totalWarnings</c>"
        }

        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Get databases from the server
         
    .DESCRIPTION
        Get the names of databases on either SQL Server or in Azure SQL Database instance
         
    .PARAMETER Name
        Name of the database that you are looking for
         
        Default value is "*" which will show all databases
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .EXAMPLE
        PS C:\> Get-D365Database
         
        This will show all databases on the default SQL Server / Azure SQL Database instance.
         
    .EXAMPLE
        PS C:\> Get-D365Database -Name AXDB_ORIGINAL
         
        This will show if the AXDB_ORIGINAL database exists on the default SQL Server / Azure SQL Database instance.
         
    .NOTES
        Tags: Database, DB, Servicing
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Get-D365Database {
    [CmdletBinding()]
    [OutputType('[PsCustomObject]')]
    param (
        [string[]] $Name = "*",

        [string] $DatabaseServer = $Script:DatabaseServer,

        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword
    )

    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

    $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = "master";
        SqlUser = $SqlUser; SqlPwd = $SqlPwd
    }

    $sqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection

    $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\get-database.sql") -join [Environment]::NewLine

    try {
        $sqlCommand.Connection.Open()
    
        $reader = $sqlCommand.ExecuteReader()

        while ($reader.Read() -eq $true) {
            $res = [PSCustomObject]@{
                Name             = "$($reader.GetString($($reader.GetOrdinal("NAME"))))"
            }

            if ($res.Name -NotLike $Name) { continue }

            $res
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
    finally {
        $reader.close()

        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
    }
}


<#
    .SYNOPSIS
        Shows the Database Access information for the D365 Environment
         
    .DESCRIPTION
        Gets all database information from the D365 environment
         
    .EXAMPLE
        PS C:\> Get-D365DatabaseAccess
         
        This will get all relevant details, including connection details, for the database configured for the environment
         
    .NOTES
        Tags: Database, Connection, Sql, SqlUser, SqlPwd
         
        Author: Rasmus Andersen (@ITRasmus)
         
        The cmdlet wraps the call against a dll file that is shipped with Dynamics 365 for Finance & Operations.
        The call to the dll file gets all relevant connections details for the database server.
         
#>

function Get-D365DatabaseAccess {
    [CmdletBinding()]
    param ()

    $environment = Get-ApplicationEnvironment
    
    return $environment.DataAccess
}


<#
    .SYNOPSIS
        Decrypts the AOS config file
         
    .DESCRIPTION
        Function used for decrypting the config file used by the D365 Finance & Operations AOS service
         
    .PARAMETER DropPath
        Place where the decrypted files should be placed
         
    .PARAMETER AosServiceWebRootPath
        Location of the D365 webroot folder
         
    .EXAMPLE
        PS C:\> Get-D365DecryptedConfigFile -DropPath "c:\temp\d365fo.tools"
         
        This will get the config file from the instance, decrypt it and save it to "c:\temp\d365fo.tools"
         
    .NOTES
        Tags: Configuration, Service Account, Sql, SqlUser, SqlPwd, WebConfig, Web.Config, Decryption
         
        Author : Rasmus Andersen (@ITRasmus)
        Author : M�tz Jensen (@splaxi)
         
        Used for getting the Password for the database and other service accounts used in environment
#>

function Get-D365DecryptedConfigFile {
    param(
        [Parameter(Mandatory = $false, Position = 1)]
        [Alias('ExtractFolder')]
        [string]$DropPath = "C:\temp\d365fo.tools\ConfigFile_Decrypted",

        [Parameter(Mandatory = $false, Position = 2)]
        [string]$AosServiceWebRootPath = $Script:AOSPath
    )

    $WebConfigFile = Join-Path $AosServiceWebRootPath $Script:WebConfig

    if (!(Test-PathExists -Path $WebConfigFile -Type Leaf)) {return}
    if (!(Test-PathExists -Path $DropPath -Type Container -Create)) {return}

    Write-PSFMessage -Level Verbose -Message "Starting the decryption logic"
    New-DecryptedFile $WebConfigFile $DropPath
}


<#
    .SYNOPSIS
        Get the default model used creating new projects in Visual Studio
         
    .DESCRIPTION
        Get the registered default model that is used across all new projects that are created inside Visual Studio when working with D365FO project types
         
    .EXAMPLE
        PS C:\> Get-D365DefaultModelForNewProjects
         
        This will display the current default module registered in the "..Documents\Visual Studio 2015\Settings\DynamicsDevConfig.xml" file.
         
    .NOTES
        Tag: Model, Models, Development, Default Model, Module, Project
         
        Author: M�tz Jensen (@Splaxi)
         
        The work for this cmdlet / function was inspired by Robin Kretzschmar (@DarkSmile92) blog post about changing the default model.
         
        The direct link for his blog post is: https://robscode.onl/d365-set-default-model-for-new-projects/
         
        His main blog can found here: https://robscode.onl/
         
#>


function Get-D365DefaultModelForNewProjects {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    param ()

    $filePath = "C:\Users\$env:UserName\Documents\Visual Studio 2015\Settings\DynamicsDevConfig.xml"

    if (-not (Test-PathExists -Path $filePath -Type Leaf)) { return }

    if (Test-PSFFunctionInterrupt) { return }

    $namespace = @{ns = "http://schemas.microsoft.com/dynamics/2012/03/development/configuration" }
    $defaultModel = Select-Xml -XPath "/ns:DynamicsDevConfig/ns:DefaultModelForNewProjects" -Path $filePath -Namespace $namespace

    $modelName = $defaultModel.Node.InnerText
    [PSCustomObject] @{DefaultModelForNewProjects = $modelName }
}


<#
    .SYNOPSIS
        Get a .NET class from the Dynamics 365 for Finance and Operations installation
         
    .DESCRIPTION
        Get a .NET class from an assembly file (dll) from the package directory
         
    .PARAMETER Name
        Name of the .NET class that you are looking for
         
        Accepts wildcards for searching. E.g. -Name "ER*Excel*"
         
        Default value is "*" which will search for all classes
         
    .PARAMETER Assembly
        Name of the assembly file that you want to search for the .NET class
         
        Accepts wildcards for searching. E.g. -Name "*AX*Framework*.dll"
         
        Default value is "*.dll" which will search for assembly files
         
    .PARAMETER PackageDirectory
        Path to the directory containing the installed packages
         
        Normally it is located under the AOSService directory in "PackagesLocalDirectory"
         
        Default value is fetched from the current configuration on the machine
         
    .EXAMPLE
        PS C:\> Get-D365DotNetClass -Name "ERText*"
         
        Will search across all assembly files (*.dll) that are located in the default package directory after
        any class that fits the search "ERText*"
         
    .EXAMPLE
        PS C:\> Get-D365DotNetClass -Name "ERText*" -Assembly "*LocalizationFrameworkForAx.dll*"
         
        Will search across all assembly files (*.dll) that are fits the search "*LocalizationFrameworkForAx.dll*",
        that are located in the default package directory, after any class that fits the search "ERText*"
         
    .EXAMPLE
        PS C:\> Get-D365DotNetClass -Name "ERText*" | Export-Csv -Path c:\temp\results.txt -Delimiter ";"
         
        Will search across all assembly files (*.dll) that are located in the default package directory after
        any class that fits the search "ERText*"
         
        The output is saved to a file to make it easier to search inside the result set
         
    .NOTES
        Tags: .Net, DotNet, Class, Development
         
        Author: M�tz Jensen (@Splaxi)
         
        The cmdlet supports piping and can be used in advanced scenarios. See more on github and the wiki pages.
         
#>

function Get-D365DotNetClass {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 1 )]
        [string] $Name = "*",

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 2 )]
        [string] $Assembly = "*.dll",

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 3 )]
        [string] $PackageDirectory = $Script:PackageDirectory
    )
    
    begin {
    }
    
    process {
        Invoke-TimeSignal -Start

        $files = (Get-ChildItem -Path $PackageDirectory -Filter $Assembly -Recurse -Exclude "*Resources*" | Where-Object Fullname -Notlike "*Resources*" )

        $files | ForEach-Object {
            $path = $_.Fullname
            try {
                Write-PSFMessage -Level Verbose -Message "Loading the dll file: $path" -Target $path
                
                [Reflection.Assembly]$ass = [Reflection.Assembly]::LoadFile($path)

                $res = $ass.GetTypes()

                Write-PSFMessage -Level Verbose -Message "Looping through all types from the assembly"
                foreach ($obj in $res) {
                    if ($obj.Name -NotLike $Name) { continue }
                    [PSCustomObject]@{
                        IsPublic = $obj.IsPublic
                        IsSerial = $obj.IsSerial
                        Name     = $obj.Name
                        BaseType = $obj.BaseType
                        File     = $path
                    }
                }
            }
            catch {
                Write-PSFMessage -Level Host -Message "Something went wrong while trying to load the path: $path" -Exception $PSItem.Exception
                Stop-PSFFunction -Message "Stopping because of errors"
                return
            }
        }

        Invoke-TimeSignal -End
    }

    end {
    }

}


<#
    .SYNOPSIS
        Get a .NET method from the Dynamics 365 for Finance and Operations installation
         
    .DESCRIPTION
        Get a .NET method from an assembly file (dll) from the package directory
         
    .PARAMETER Assembly
        Name of the assembly file that you want to search for the .NET method
         
        Provide the full path for the assembly file you want to work against
         
    .PARAMETER Name
        Name of the .NET method that you are looking for
         
        Accepts wildcards for searching. E.g. -Name "parmER*Excel*"
         
        Default value is "*" which will search for all methods
         
    .PARAMETER TypeName
        Name of the .NET class that you want to work against
         
        Accepts wildcards for searching. E.g. -Name "*ER*Excel*"
         
        Default value is "*" which will work against all classes
         
    .EXAMPLE
        PS C:\> Get-D365DotNetMethod -Assembly "C:\AOSService\PackagesLocalDirectory\ElectronicReporting\bin\Microsoft.Dynamics365.LocalizationFrameworkForAx.dll"
         
        Will get all methods, across all classes, from the assembly file
         
    .EXAMPLE
        PS C:\> Get-D365DotNetMethod -Assembly "C:\AOSService\PackagesLocalDirectory\ElectronicReporting\bin\Microsoft.Dynamics365.LocalizationFrameworkForAx.dll" -TypeName "ERTextFormatExcelFileComponent"
         
        Will get all methods, from the "ERTextFormatExcelFileComponent" class, from the assembly file
         
    .EXAMPLE
        PS C:\> Get-D365DotNetMethod -Assembly "C:\AOSService\PackagesLocalDirectory\ElectronicReporting\bin\Microsoft.Dynamics365.LocalizationFrameworkForAx.dll" -TypeName "ERTextFormatExcelFileComponent" -Name "*parm*"
         
        Will get all methods that fits the search "*parm*", from the "ERTextFormatExcelFileComponent" class, from the assembly file
         
    .EXAMPLE
        PS C:\> Get-D365DotNetClass -Name "ERTextFormatExcelFileComponent" -Assembly "*LocalizationFrameworkForAx.dll*" | Get-D365DotNetMethod
         
        Will get all methods, from the "ERTextFormatExcelFileComponent" class, from any assembly file that fits the search "*LocalizationFrameworkForAx.dll*"
         
    .NOTES
        Tags: .Net, DotNet, Class, Method, Methods, Development
         
        Author: M�tz Jensen (@Splaxi)
         
        The cmdlet supports piping and can be used in advanced scenarios. See more on github and the wiki pages.
         
#>

function Get-D365DotNetMethod {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Default', ValueFromPipelineByPropertyName = $true, Position = 1 )]
        [Alias('File')]
        [string] $Assembly,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 2 )]
        [Alias('MethodName')]
        [string] $Name = "*",

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 3 )]
        [Alias('ClassName')]
        [string] $TypeName = "*"

    )
    
    begin {
    }
    
    process {
        Invoke-TimeSignal -Start

        try {
            Write-PSFMessage -Level Verbose -Message "Loading the file" -Target $Assembly
            [Reflection.Assembly]$ass = [Reflection.Assembly]::LoadFile($Assembly)

            $types = $ass.GetTypes()

            foreach ($obj in $types) {
                Write-PSFMessage -Level Verbose -Message "Type name loaded" -Target $obj.Name

                if ($obj.Name -NotLike $TypeName) {continue}

                $members = $obj.GetMethods()

                foreach ($objI in $members) {
                    if ($objI.Name -NotLike $Name) { continue }
                    [PSCustomObject]@{
                        TypeName     = $obj.Name
                        TypeIsPublic = $obj.IsPublic
                        MethodName   = $objI.Name
                    }

                }
            }
        }
        catch {
            Write-PSFMessage -Level Warning -Message "Something went wrong while working on: $Assembly" -ErrorRecord $_
        }
        
        Invoke-TimeSignal -End
    }

    end {
    }

}


<#
    .SYNOPSIS
        Cmdlet to get the current status for the different services in a Dynamics 365 Finance & Operations environment
         
    .DESCRIPTION
        List status for all relevant services that is running in a D365FO environment
         
    .PARAMETER ComputerName
        An array of computers that you want to query for the services status on.
         
    .PARAMETER All
        Set when you want to query all relevant services
         
        Includes:
        Aos
        Batch
        Financial Reporter
        DMF
         
    .PARAMETER Aos
        Instruct the cmdlet to query the AOS (IIS) service
         
    .PARAMETER Batch
        Instruct the cmdlet query the batch service
         
    .PARAMETER FinancialReporter
        Instruct the cmdlet query the financial reporter (Management Reporter 2012)
         
    .PARAMETER DMF
        Instruct the cmdlet query the DMF service
         
    .PARAMETER OnlyStartTypeAutomatic
        Instruct the cmdlet to filter out services that are set to manual start or disabled
         
    .PARAMETER OutputServiceDetailsOnly
        Instruct the cmdlet to exclude the server name from the output
         
    .EXAMPLE
        PS C:\> Get-D365Environment
         
        Will query all D365FO service on the machine.
         
    .EXAMPLE
        PS C:\> Get-D365Environment -All
         
        Will query all D365FO service on the machine.
         
    .EXAMPLE
        PS C:\> Get-D365Environment -OnlyStartTypeAutomatic
         
        Will query all D365FO service on the machine.
        It will filter out all services that are either configured as manual or disabled.
         
    .EXAMPLE
        PS C:\> Get-D365Environment -ComputerName "TEST-SB-AOS1","TEST-SB-AOS2","TEST-SB-BI1" -All
         
        Will query all D365FO service on the different machines.
         
    .EXAMPLE
        PS C:\> Get-D365Environment -Aos -Batch
         
        Will query the Aos & Batch services on the machine.
         
    .EXAMPLE
        PS C:\> Get-D365Environment -FinancialReporter -DMF
         
        Will query the FinancialReporter & DMF services on the machine.
         
    .EXAMPLE
        PS C:\> Get-D365Environment -OutputServiceDetailsOnly
         
        Will query all D365FO service on the machine.
        Will omit the servername from the output.
         
    .EXAMPLE
        PS C:\> Get-D365Environment -FinancialReporter | Set-Service -StartupType Manual
         
        This will configure the Financial Reporter services to be start type manual.
         
    .NOTES
        Tags: Environment, Service, Services, Aos, Batch, Servicing
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-D365Environment {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidDefaultValueSwitchParameter", "")]
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 1 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 1 )]
        [string[]] $ComputerName = @($env:computername),

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

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific')]
        [switch] $Aos,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific')]
        [switch] $Batch,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific')]
        [switch] $FinancialReporter,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific')]
        [switch] $DMF,

        [switch] $OnlyStartTypeAutomatic,

        [switch] $OutputServiceDetailsOnly
    )

    if ($PSCmdlet.ParameterSetName -eq "Specific") {
        $All = $false
    }

    if ( (-not ($All)) -and (-not ($Aos)) -and (-not ($Batch)) -and (-not ($FinancialReporter)) -and (-not ($DMF))) {
        Write-PSFMessage -Level Host -Message "You have to use at least one switch when running this cmdlet. Please run the cmdlet again."
        Stop-PSFFunction -Message "Stopping because of missing parameters"
        return
    }

    $Params = Get-DeepClone $PSBoundParameters
    if($Params.ContainsKey("ComputerName")){$null = $Params.Remove("ComputerName")}
    if($Params.ContainsKey("OutputServiceDetailsOnly")){$null = $Params.Remove("OutputServiceDetailsOnly")}
    if($Params.ContainsKey("OnlyStartTypeAutomatic")){$null = $Params.Remove("OnlyStartTypeAutomatic")}

    $Services = Get-ServiceList @Params

    $Results = foreach ($server in $ComputerName) {
        Get-Service -ComputerName $server -Name $Services -ErrorAction SilentlyContinue | Select-Object @{Name = "Server"; Expression = {$Server}}, Name, Status, StartType, DisplayName
    }
    
    $outputTypeName = "D365FO.TOOLS.Environment.Service"

    if($OutputServiceDetailsOnly) {
        $outputTypeName = "D365FO.TOOLS.Environment.Service.Minimal"
    }

    if($OnlyStartTypeAutomatic){
        $Results = $Results | Where-Object StartType -eq "Automatic"
    }

    $Results | Select-PSFObject -TypeName $outputTypeName Server, DisplayName, Status, StartType, Name
}


<#
    .SYNOPSIS
        Get environment configs
         
    .DESCRIPTION
        Get all environment configuration objects from the configuration store
         
    .PARAMETER Name
        The name of the environment you are looking for
         
        Default value is "*" to display all environment configs
         
    .EXAMPLE
        PS C:\> Get-D365EnvironmentConfig
         
        This will show all environment configs
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, Tfs, Vsts, Sql, SqlUser, SqlPwd
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-D365EnvironmentConfig {
    [CmdletBinding()]
    param (
        [string] $Name = "*"

    )
    
    $Environments = [hashtable](Get-PSFConfigValue -FullName "d365fo.tools.environments")
        
    foreach ($item in $Environments.Keys) {
        if ($item -NotLike $Name) { continue }
        $temp = [ordered]@{Name = $item}
        $temp += $Environments[$item]
        [PSCustomObject]$temp
    }
}


<#
    .SYNOPSIS
        Get the D365FO environment settings
         
    .DESCRIPTION
        Gets all settings the Dynamics 365 for Finance & Operations environment uses.
         
    .EXAMPLE
        PS C:\> Get-D365EnvironmentSettings
         
        This will get all details available for the environment
         
    .EXAMPLE
        PS C:\> Get-D365EnvironmentSettings | Format-Custom -Property *
         
        This will get all details available for the environment and format it to show all details in a long custom object.
         
    .NOTES
        Tags: Environment, Configuration, WebConfig, Web.Config, Decryption
         
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
        The cmdlet wraps the call against a dll file that is shipped with Dynamics 365 for Finance & Operations.
        The call to the dll file gets all relevant details for the installation.
#>

function Get-D365EnvironmentSettings {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    param ()

    Get-ApplicationEnvironment
}


<#
    .SYNOPSIS
        Get D365FO Event Trace Provider
         
    .DESCRIPTION
        Get the full list of available Event Trace Providers for Dynamics 365 for Finance and Operations
         
    .PARAMETER Name
        Name of the provider that you are looking for
         
        Default value is "*" to show all Event Trace Providers
         
        Accepts an array of names, and will automatically add wildcard searching characters for each entry
         
    .EXAMPLE
        PS C:\> Get-D365EventTraceProvider
         
        Will list all available Event Trace Providers on a D365FO server.
        It will use the default option for the "Name" parameter.
         
    .EXAMPLE
        PS C:\> Get-D365EventTraceProvider -Name Tax
         
        Will list all available Event Trace Providers on a D365FO server which contains the keyvword "Tax".
        It will use the Name parameter value "Tax" while searching for Event Trace Providers.
         
         
    .EXAMPLE
        PS C:\> Get-D365EventTraceProvider -Name Tax,MR
         
        Will list all available Event Trace Providers on a D365FO server which contains the keyvword "Tax" or "MR".
        It will use the Name parameter array value ("Tax","MR") while searching for Event Trace Providers.
         
    .NOTES
        Tags: ETL, EventTracing, EventTrace
         
        Author: M�tz Jensen (@Splaxi)
         
        This cmdlet/function was inspired by the work of Michael Stashwick (@D365Stuff)
         
        He blog is located here: https://www.d365stuff.co/
         
        and the blogpost that pointed us in the right direction is located here: https://www.d365stuff.co/trace-batch-jobs-and-more-via-cmd-logman/
#>


function Get-D365EventTraceProvider {
    [CmdletBinding()]
    param (
        [string[]] $Name = @("*")
    )
    
    begin{
        $providers = Get-NetEventProvider -ShowInstalled | Where-Object name -like "Microsoft-Dynamics*" | Sort-Object name
    }

    process {
        foreach ($searchName in $Name) {
            $providers | Where-Object name -Like "*$searchName*" | Select-PSFObject "Name as ProviderName"
        }
    }
}


<#
    .SYNOPSIS
        Get installed hotfix
         
    .DESCRIPTION
        Get all relevant details for installed hotfix
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the AOS Service PackagesLocalDirectory\bin
         
    .PARAMETER PackageDirectory
        Path to the PackagesLocalDirectory
         
        Default path is the same as the AOS Service PackagesLocalDirectory
         
    .PARAMETER Model
        Name of the model that you want to work against
         
        Accepts wildcards for searching. E.g. -Model "*Retail*"
         
        Default value is "*" which will search for all models
         
    .PARAMETER Name
        Name of the hotfix that you are looking for
         
        Accepts wildcards for searching. E.g. -Name "7045*"
         
        Default value is "*" which will search for all hotfixes
         
    .PARAMETER KB
        KB number of the hotfix that you are looking for
         
        Accepts wildcards for searching. E.g. -KB "4045*"
         
        Default value is "*" which will search for all KB's
         
    .EXAMPLE
        PS C:\> Get-D365InstalledHotfix
         
        This will display all installed hotfixes found on this machine
         
    .EXAMPLE
        PS C:\> Get-D365InstalledHotfix -Model "*retail*"
         
        This will display all installed hotfixes found for all models that matches the search for "*retail*" found on this machine
         
    .EXAMPLE
        PS C:\> Get-D365InstalledHotfix -Model "*retail*" -KB "*43*"
         
        This will display all installed hotfixes found for all models that matches the search for "*retail*" and only with KB's that matches the search for "*43*" found on this machine
         
    .NOTES
        Tags: Hotfix, Servicing, Model, Models, KB, Patch, Patching, PackagesLocalDirectory
         
        Author: M�tz Jensen (@Splaxi)
         
        This cmdlet is inspired by the work of "Ievgen Miroshnikov" (twitter: @IevgenMir)
         
        All credits goes to him for showing how to extract these information
         
        His blog can be found here:
        https://ievgensaxblog.wordpress.com
         
        The specific blog post that we based this cmdlet on can be found here:
        https://ievgensaxblog.wordpress.com/2017/11/17/d365foe-get-list-of-installed-metadata-hotfixes-using-metadata-api/
         
#>

function Get-D365InstalledHotfix {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 1 )]
        [string] $BinDir = "$Script:BinDir\bin",

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 2 )]
        [string] $PackageDirectory = $Script:PackageDirectory,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 3 )]
        [string] $Model = "*",

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 4 )]
        [string] $Name = "*",

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 5 )]
        [string] $KB = "*"

    )

    begin {
    }

    process {
        $files = @((Join-Path -Path $BinDir -ChildPath "Microsoft.Dynamics.AX.Metadata.Storage.dll"),
            (Join-Path -Path $BinDir -ChildPath "Microsoft.Dynamics.ApplicationPlatform.XppServices.Instrumentation.dll"))
        
        if(-not (Test-PathExists -Path $files -Type Leaf)) {
            return
        }

        Add-Type -Path $files

        Write-PSFMessage -Level Verbose -Message "Testing if the cmdlet is running on a OneBox or not." -Target $Script:IsOnebox
        if ($Script:IsOnebox) {
            Write-PSFMessage -Level Verbose -Message "Machine is onebox. Will continue with DiskProvider."

            $diskProviderConfiguration = New-Object Microsoft.Dynamics.AX.Metadata.Storage.DiskProvider.DiskProviderConfiguration
            $diskProviderConfiguration.AddMetadataPath($PackageDirectory)
            $metadataProviderFactory = New-Object Microsoft.Dynamics.AX.Metadata.Storage.MetadataProviderFactory
            $metadataProvider = $metadataProviderFactory.CreateDiskProvider($diskProviderConfiguration)

            Write-PSFMessage -Level Verbose -Message "MetadataProvider initialized." -Target $metadataProvider
        }
        else {
            Write-PSFMessage -Level Verbose -Message "Machine is NOT onebox. Will continue with RuntimeProvider."

            $runtimeProviderConfiguration = New-Object Microsoft.Dynamics.AX.Metadata.Storage.Runtime.RuntimeProviderConfiguration -ArgumentList $Script:PackageDirectory
            $metadataProviderFactory = New-Object Microsoft.Dynamics.AX.Metadata.Storage.MetadataProviderFactory
            $metadataProvider = $metadataProviderFactory.CreateRuntimeProvider($runtimeProviderConfiguration)

            Write-PSFMessage -Level Verbose -Message "MetadataProvider initialized." -Target $metadataProvider
        }

        Write-PSFMessage -Level Verbose -Message "Initializing the UpdateProvider from the MetadataProvider."
        $updateProvider = $metadataProvider.Updates

        Write-PSFMessage -Level Verbose -Message "Looping through all modules from the MetadataProvider."
        foreach ($obj in $metadataProvider.ModelManifest.ListModules()) {
            Write-PSFMessage -Level Verbose -Message "Filtering out all modules that doesn't match the model search." -Target $obj
            if ($obj.Name -NotLike $Model) {continue}

            Write-PSFMessage -Level Verbose -Message "Looping through all hotfixes for the module from the UpdateProvider." -Target $obj
            foreach ($objUpdate in $updateProvider.ListObjects($obj.Name)) {
                Write-PSFMessage -Level Verbose -Message "Reading all details for the hotfix through UpdateProvider." -Target $objUpdate
                
                $axUpdateObject = $updateProvider.Read($objUpdate)

                Write-PSFMessage -Level Verbose -Message "Filtering out all hotfixes that doesn't match the name search." -Target $axUpdateObject
                if ($axUpdateObject.Name -NotLike $Name) {continue}

                Write-PSFMessage -Level Verbose -Message "Filtering out all hotfixes that doesn't match the KB search." -Target $axUpdateObject
                if ($axUpdateObject.KBNumbers -NotLike $KB) {continue}

                [PSCustomObject]@{
                    Model   = $obj.Name
                    Hotfix  = $axUpdateObject.Name
                    Applied = $axUpdateObject.AppliedDateTime
                    KBs     = $axUpdateObject.KBNumbers
                }
            }
        }
    }

    end {
    }
}


<#
    .SYNOPSIS
        Get installed package from Dynamics 365 Finance & Operations environment
         
    .DESCRIPTION
        Get installed package from the machine running the AOS service for Dynamics 365 Finance & Operations
         
    .PARAMETER Name
        Name of the package that you are looking for
         
        Accepts wildcards for searching. E.g. -Name "Application*Adaptor"
         
        Default value is "*" which will search for all packages
         
    .PARAMETER PackageDirectory
        Path to the directory containing the installed packages
         
        Normally it is located under the AOSService directory in "PackagesLocalDirectory"
         
        Default value is fetched from the current configuration on the machine
         
    .EXAMPLE
        PS C:\> Get-D365InstalledPackage
         
        Shows the entire list of installed packages located in the default location on the machine
         
    .EXAMPLE
        PS C:\> Get-D365InstalledPackage -Name "Application*Adaptor"
         
        Shows the list of installed packages where the name fits the search "Application*Adaptor"
         
        A result set example:
        ApplicationFoundationFormAdaptor
        ApplicationPlatformFormAdaptor
        ApplicationSuiteFormAdaptor
        ApplicationWorkspacesFormAdaptor
         
    .EXAMPLE
        PS C:\> Get-D365InstalledPackage -PackageDirectory "J:\AOSService\PackagesLocalDirectory"
         
        Shows the entire list of installed packages located in "J:\AOSService\PackagesLocalDirectory" on the machine
         
    .NOTES
        Tags: PackagesLocalDirectory, Servicing, Model, Models, Package, Packages
         
        Author: M�tz Jensen (@Splaxi)
         
        The cmdlet supports piping and can be used in advanced scenarios. See more on github and the wiki pages.
         
#>

function Get-D365InstalledPackageOld {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 1 )]
        [string] $Name = "*",

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 2 )]
        [string] $PackageDirectory = $Script:PackageDirectory
    )

    Write-PSFMessage -Level Verbose -Message "Package directory is: $PackageDirectory" -Target $PackageDirectory
    Write-PSFMessage -Level Verbose -Message "Name is: $Name" -Target $Name

    $Packages = Get-ChildItem -Path $PackageDirectory -Directory -Exclude bin

    foreach ($obj in $Packages) {
        if ($obj.Name -NotLike $Name) { continue }
        [PSCustomObject]@{
            PackageName      = $obj.Name
            PackageDirectory = $obj.FullName
        }
    }
}


<#
    .SYNOPSIS
        Get installed D365 services
         
    .DESCRIPTION
        Get installed Dynamics 365 for Finance & Operations services that are installed on the machine
         
    .PARAMETER Path
        Path to the folder that contains the "InstallationRecords" folder
         
    .EXAMPLE
        PS C:\> Get-D365InstalledService
         
        This will get all installed services on the machine.
         
    .NOTES
        Tags: Services, Servicing, Topology
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-D365InstalledService {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 1 )]
        [string] $Path = $Script:InstallationRecordsDir
    )
    
    begin {
    }
    
    process {
        $servicePath = Join-Path $Path "ServiceModelInstallationRecords"

        Write-PSFMessage -Level Verbose -Message "Service installation log path is: $servicePath" -Target $servicePath
        $ServiceFiles = Get-ChildItem -Path $servicePath -Filter "*_current.xml" -Recurse

        foreach ($obj in $ServiceFiles) {
            [PSCustomObject]@{
                ServiceName = ($obj.Name.Split("_")[0])
                Version     = (Select-Xml -XPath "/ServiceModelInstallationInfo/Version" -Path $obj.fullname).Node."#Text"
            }
        }
    }
    
    end {
    }
}


<#
    .SYNOPSIS
        Gets the instance name
         
    .DESCRIPTION
        Get the instance name that is registered in the environment
         
    .EXAMPLE
        PS C:\> Get-D365InstanceName
         
        This will get the service name that the environment has configured
         
    .NOTES
        Tags: Instance, Servicing
         
        Author: Rasmus Andersen (@ITRasmus)
         
        The cmdlet wraps the call against a dll file that is shipped with Dynamics 365 for Finance & Operations.
        The call to the dll file gets HostedServiceName that is registered in the environment.
         
#>

function Get-D365InstanceName {
    [CmdletBinding()]
    param ()

    [PSCustomObject]@{
        InstanceName = "$($(Get-D365EnvironmentSettings).Infrastructure.HostedServiceName)"
    }
}


<#
    .SYNOPSIS
        Get Json based service
         
    .DESCRIPTION
        Get Json based services that are available from a Dynamics 365 Finance & Operations environment
         
    .PARAMETER Name
        The name of the json service that you are looking for
         
        Default value is "*" to display all json services
         
    .PARAMETER Url
        URL / URI for the D365FO environment you want to access
         
        If you are working against a D365FO instance, it will be the URL / URI for the instance itself
         
        If you are working against a D365 Talent / HR instance, this will have to be "http://hr.talent.dynamics.com"
         
    .PARAMETER Tenant
        Azure Active Directory (AAD) tenant id (Guid) that the D365FO environment is connected to, that you want to access
         
    .PARAMETER ClientId
        The ClientId obtained from the Azure Portal when you created a Registered Application
         
    .PARAMETER ClientSecret
        The ClientSecret obtained from the Azure Portal when you created a Registered Application
         
    .PARAMETER RawOutput
        Instructs the cmdlet to include the outer structure of the response received from the endpoint
         
        The output will still be a PSCustomObject
         
    .PARAMETER OutputAsJson
        Instructs the cmdlet to convert the output to a Json string
         
    .EXAMPLE
        PS C:\> Get-D365JsonService -Url "https://usnconeboxax1aos.cloud.onebox.dynamics.com" -Tenant "e674da86-7ee5-40a7-b777-1111111111111" -ClientId "dea8d7a9-1602-4429-b138-111111111111" -ClientSecret "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522"
         
        This will get all available service groups for the D365FO instance.
        It will contact the D365FO instance specified in the Url parameter: "https://usnconeboxax1aos.cloud.onebox.dynamics.com".
        It will authenticate againt the "https://login.microsoftonline.com/e674da86-7ee5-40a7-b777-1111111111111/oauth2/token" url with the specified Tenant parameter: "e674da86-7ee5-40a7-b777-1111111111111".
        It will authenticate with the specified ClientId parameter: "dea8d7a9-1602-4429-b138-111111111111".
        It will authenticate with the specified ClientSecret parameter: "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522".
         
    .EXAMPLE
        PS C:\> Get-D365JsonService -Name "*TS*" -Url "https://usnconeboxax1aos.cloud.onebox.dynamics.com" -Tenant "e674da86-7ee5-40a7-b777-1111111111111" -ClientId "dea8d7a9-1602-4429-b138-111111111111" -ClientSecret "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522"
         
        This will get all available service groups for the D365FO instance, which matches the "*TS*" as a name.
        It will contact the D365FO instance specified in the Url parameter: "https://usnconeboxax1aos.cloud.onebox.dynamics.com".
        It will authenticate againt the "https://login.microsoftonline.com/e674da86-7ee5-40a7-b777-1111111111111/oauth2/token" url with the specified Tenant parameter: "e674da86-7ee5-40a7-b777-1111111111111".
        It will authenticate with the specified ClientId parameter: "dea8d7a9-1602-4429-b138-111111111111".
        It will authenticate with the specified ClientSecret parameter: "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522".
        It will limit the output to only those matching the specified Name parameter: "*TS*"
         
    .EXAMPLE
        PS C:\> Get-D365JsonService -Url "https://usnconeboxax1aos.cloud.onebox.dynamics.com" -Tenant "e674da86-7ee5-40a7-b777-1111111111111" -ClientId "dea8d7a9-1602-4429-b138-111111111111" -ClientSecret "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522" -RawOutput
         
        This will get all available service groups for the D365FO instance with the outer most hierarchy.
        It will contact the D365FO instance specified in the Url parameter: "https://usnconeboxax1aos.cloud.onebox.dynamics.com".
        It will authenticate againt the "https://login.microsoftonline.com/e674da86-7ee5-40a7-b777-1111111111111/oauth2/token" url with the specified Tenant parameter: "e674da86-7ee5-40a7-b777-1111111111111".
        It will authenticate with the specified ClientId parameter: "dea8d7a9-1602-4429-b138-111111111111".
        It will authenticate with the specified ClientSecret parameter: "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522".
         
    .EXAMPLE
        PS C:\> Get-D365JsonService -Url "https://usnconeboxax1aos.cloud.onebox.dynamics.com" -Tenant "e674da86-7ee5-40a7-b777-1111111111111" -ClientId "dea8d7a9-1602-4429-b138-111111111111" -ClientSecret "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522" -OutputAsJson
         
        This will get all available service groups for the D365FO instance and display the result as json.
        It will contact the D365FO instance specified in the Url parameter: "https://usnconeboxax1aos.cloud.onebox.dynamics.com".
        It will authenticate againt the "https://login.microsoftonline.com/e674da86-7ee5-40a7-b777-1111111111111/oauth2/token" url with the specified Tenant parameter: "e674da86-7ee5-40a7-b777-1111111111111".
        It will authenticate with the specified ClientId parameter: "dea8d7a9-1602-4429-b138-111111111111".
        It will authenticate with the specified ClientSecret parameter: "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522".
         
    .NOTES
        Tags: DMF, OData, RestApi, Data Management Framework
         
        Author: M�tz Jensen (@Splaxi)
         
        Idea taken from http://www.ksaelen.be/wordpresses/dynamicsaxblog/2016/01/dynamics-ax-7-tip-what-services-are-exposed/
         
#>

function Get-D365JsonService {
    [CmdletBinding()]
    [OutputType([System.String])]
    param (
        [string] $Name = "*",

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

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

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

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

        [switch] $RawOutput,

        [switch] $OutputAsJson
    )

    $bearerParms = @{
        Resource        = $Url
        ClientId        = $ClientId
        ClientSecret    = $ClientSecret
        AuthProviderUri = "https://login.microsoftonline.com/$Tenant/oauth2/token"
    }

    $bearer = Invoke-ClientCredentialsGrant @bearerParms | Get-BearerToken

    $headers = @{Authorization = $bearer }
    $Url = $Url + "/api/services"

    $res = Invoke-RestMethod -Method Get -Uri $Url -Headers $headers

    if (-not $RawOutput) {
        $res = $res.ServiceGroups | Where-Object { $_.Name -Like $Name -or $_.Name -eq $Name } | Sort-Object Name
    }
    else {
        $res.ServiceGroups = @($res.ServiceGroups | Where-Object { $_.Name -Like $Name -or $_.Name -eq $Name }) | Sort-Object Name
    }

    if ($OutputAsJson) {
        $res | ConvertTo-Json -Depth 10
    }
    else {
        $res
    }
}


<#
    .SYNOPSIS
        Get label from the label file from Dynamics 365 Finance & Operations environment
         
    .DESCRIPTION
        Get label from the label file from the running the Dynamics 365 Finance & Operations instance
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the AOS service PackagesLocalDirectory\bin
         
        Default value is fetched from the current configuration on the machine
         
    .PARAMETER LabelFileId
        Name / Id of the label "file" that you want to work against
         
    .PARAMETER Language
        Name / string representation of the language / culture you want to work against
         
        Default value is "en-US"
         
    .PARAMETER Name
        Name of the label that you are looking for
         
        Accepts wildcards for searching. E.g. -Name "@PRO59*"
         
        Default value is "*" which will search for all labels
         
    .EXAMPLE
        PS C:\> Get-D365Label -LabelFileId PRO
         
        Shows the entire list of labels that are available from the PRO label file.
        The language is defaulted to "en-US".
         
    .EXAMPLE
        PS C:\> Get-D365Label -LabelFileId PRO -Language da
         
        Shows the entire list of labels that are available from the PRO label file.
        Shows only all "da" (Danish) labels.
         
    .EXAMPLE
        PS C:\> Get-D365Label -LabelFileId PRO -Name "@PRO59*"
         
        Shows the labels available from the PRO label file where the name fits the search "@PRO59*"
         
        A result set example:
         
        Name Value Language
        ---- ----- --------
        @PRO59 Indicates if the type of the rebate value. en-US
        @PRO594 Pack consumption en-US
        @PRO595 Pack qty now being released to production in the BOM unit. en-US
        @PRO596 Pack unit. en-US
        @PRO597 Pack proposal for release in the packing unit. en-US
        @PRO590 Constant pack qty en-US
        @PRO593 Pack proposal release in BOM unit. en-US
        @PRO598 Pack quantity now being released for the production in the packing unit. en-US
         
    .EXAMPLE
        PS C:\> Get-D365Label -LabelFileId PRO -Name "@PRO59*" -Language da,en-us
         
        Shows the labels available from the PRO label file where the name fits the search "@PRO59*".
        Shows for both "da" (Danish) and en-US (English)
         
    .NOTES
        Tags: PackagesLocalDirectory, Servicing, Language, Labels, Label
         
        Author: M�tz Jensen (@Splaxi)
         
        This cmdlet is inspired by the work of "Pedro Tornich" (twitter: @ptornich)
         
        All credits goes to him for showing how to extract these information
         
        His github repository can be found here:
        https://github.com/ptornich/LabelFileGenerator
         
#>

function Get-D365Label {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 1 )]
        [string] $BinDir = "$Script:BinDir\bin",

        [Parameter(Mandatory = $true, ParameterSetName = 'Default', ValueFromPipelineByPropertyName = $true, Position = 2 )]
        [string] $LabelFileId,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', ValueFromPipelineByPropertyName = $true, Position = 3 )]
        [string[]] $Language = "en-US",

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 4 )]
        [string] $Name = "*"
    )

    begin {
    }

    process {
        $files = @((Join-Path -Path $BinDir -ChildPath "Microsoft.Dynamics.AX.Xpp.AxShared.dll"))
        
        if (-not (Test-PathExists -Path $files -Type Leaf)) {
            return
        }

        Add-Type -Path $files

        foreach ($item in $Language) {
            $culture = New-Object System.Globalization.CultureInfo -ArgumentList $item

            Write-PSFMessage -Level Verbose -Message "Searching for label" -Target $culture
            
            $labels = [Microsoft.Dynamics.Ax.Xpp.LabelHelper]::GetAllLabels($LabelFileId, $culture)
            
            foreach ($itemLabel in $labels) {
                foreach ($key in $itemLabel.Keys) {
                    if ($key -notlike $Name) { continue }

                    [PSCustomObject]@{
                        Name     = $Key
                        Value    = $itemLabel[$key]
                        Language = $item
                        PSTypeName = 'D365FO.TOOLS.Label'
                    }
                }
            }
        }
    }

    end {
    }
}


<#
    .SYNOPSIS
        Get label file (ids) for packages / modules from Dynamics 365 Finance & Operations environment
         
    .DESCRIPTION
        Get label file (ids) for packages / modules from the machine running the AOS service for Dynamics 365 Finance & Operations
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the AOS service PackagesLocalDirectory\bin
         
        Default value is fetched from the current configuration on the machine
         
    .PARAMETER PackageDirectory
        Path to the directory containing the installed package / module
         
        Normally it is located under the AOSService directory in "PackagesLocalDirectory"
         
        Default value is fetched from the current configuration on the machine
         
    .PARAMETER Module
        Name of the module that you want to work against
         
        Default value is "*" which will search for all modules
         
    .PARAMETER Name
        Name of the label file (id) that you are looking for
         
        Accepts wildcards for searching. E.g. -Name "Acc*Receivable*"
         
        Default value is "*" which will search for all label file (ids)
         
    .EXAMPLE
        PS C:\> Get-D365LabelFile
         
        Shows the entire list of label file (ids) for all installed packages / modules located in the default location on the machine
         
    .EXAMPLE
        PS C:\> Get-D365LabelFile -Name "Acc*Receivable*"
         
        Shows the list of label file (ids) for all installed packages / modules where the label file (ids) name fits the search "Acc*Receivable*"
         
        A result set example:
         
        LabelFileId Languages Module
        ----------- --------- ------
        AccountsReceivable {ar-AE, ar, cs, da...} ApplicationSuite
        AccountsReceivable_SalesTaxCodesSA {en-US} ApplicationSuite
         
         
    .EXAMPLE
        PS C:\> Get-D365LabelFile -PackageDirectory "J:\AOSService\PackagesLocalDirectory"
         
        Shows the list of label file (ids) for all installed packages / modules located in "J:\AOSService\PackagesLocalDirectory" on the machine
         
    .NOTES
        Tags: PackagesLocalDirectory, Servicing, Language, Labels, Label
         
        Author: M�tz Jensen (@Splaxi)
         
        This cmdlet is inspired by the work of "Pedro Tornich" (twitter: @ptornich)
         
        All credits goes to him for showing how to extract these information
         
        His github repository can be found here:
        https://github.com/ptornich/LabelFileGenerator
         
#>

function Get-D365LabelFile {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 1 )]
        [string] $BinDir = "$Script:BinDir\bin",

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 2 )]
        [string] $PackageDirectory = $Script:PackageDirectory,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', ValueFromPipelineByPropertyName = $true, Position = 3 )]
        [Alias("ModuleName")]
        [string] $Module = "*",

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 4 )]
        [string] $Name = "*"
    )

    begin {
    }

    process {
        $files = @((Join-Path -Path $BinDir -ChildPath "Microsoft.Dynamics.AX.Metadata.Storage.dll"),
            (Join-Path -Path $BinDir -ChildPath "Microsoft.Dynamics.ApplicationPlatform.XppServices.Instrumentation.dll"))
        
        if(-not (Test-PathExists -Path $files -Type Leaf)) {
            return
        }

        Add-Type -Path $files

        Write-PSFMessage -Level Verbose -Message "Testing if the cmdlet is running on a OneBox or not." -Target $Script:IsOnebox
        if ($Script:IsOnebox) {
            Write-PSFMessage -Level Verbose -Message "Machine is onebox. Will continue with DiskProvider."

            $diskProviderConfiguration = New-Object Microsoft.Dynamics.AX.Metadata.Storage.DiskProvider.DiskProviderConfiguration
            $diskProviderConfiguration.AddMetadataPath($PackageDirectory)
            $metadataProviderFactory = New-Object Microsoft.Dynamics.AX.Metadata.Storage.MetadataProviderFactory
            $metadataProvider = $metadataProviderFactory.CreateDiskProvider($diskProviderConfiguration)

            Write-PSFMessage -Level Verbose -Message "MetadataProvider initialized." -Target $metadataProvider
        }
        else {
            Write-PSFMessage -Level Verbose -Message "Machine is NOT onebox. Will continue with RuntimeProvider."

            $runtimeProviderConfiguration = New-Object Microsoft.Dynamics.AX.Metadata.Storage.Runtime.RuntimeProviderConfiguration -ArgumentList $Script:PackageDirectory
            $metadataProviderFactory = New-Object Microsoft.Dynamics.AX.Metadata.Storage.MetadataProviderFactory
            $metadataProvider = $metadataProviderFactory.CreateRuntimeProvider($runtimeProviderConfiguration)

            Write-PSFMessage -Level Verbose -Message "MetadataProvider initialized." -Target $metadataProvider
        }

        Write-PSFMessage -Level Verbose -Message "Initializing the LabelProvider from the MetadataProvider."
        $labelProvider = $metadataProvider.LabelFiles

        $res = New-Object 'System.Collections.Generic.Dictionary[string, System.Collections.ArrayList]'

        Write-PSFMessage -Level Verbose -Message "Looping through all modules from the MetadataProvider."
        foreach ($obj in $metadataProvider.ModelManifest.ListModules()) {
            Write-PSFMessage -Level Verbose -Message "Filtering out all modules that doesn't match the model search." -Target $obj
            if ($obj.Name -NotLike $Module) {continue}

            Write-PSFMessage -Level Verbose -Message "$($obj.Name)"

            $labelFiles = $labelProvider.ListObjects($obj.Name)

            foreach ($objLabelFile in $labelFiles) {
                Write-PSFMessage -Level Verbose -Message "$($objLabelFile)"

                if($objLabelFile -like "*.*") {
                    $chars = $objLabelFile.ToCharArray()
                    $chars[$objLabelFile.LastIndexOf(".")] = "_"
                    $objLabelFile = $chars -join ""
                }

                $labelId = $objLabelFile.Substring(0, $objLabelFile.LastIndexOf("_"))
                $langString = $objLabelFile.Substring($objLabelFile.LastIndexOf("_") + 1)

                if ($labelId -NotLike $Name) {continue}

                if(-not ($res.ContainsKey($labelId))) {
                    $null = $res.Add($labelId, (New-object -TypeName "System.Collections.ArrayList"))
                }

                $null = $res[$labelId].Add($langString)
            }

            foreach ($item in $res.Keys) {

                [PSCustomObject]@{
                    LabelFileId   = $item
                    Languages  = $res[$item]
                    Module = $obj.Name
                }
            }
            
        }
    }

    end {
    }
}


<#
    .SYNOPSIS
        Get label from the resource file
         
    .DESCRIPTION
        Get label details from the resource file
         
    .PARAMETER FilePath
        The path to resource file that you want to get label details from
         
    .PARAMETER Name
        Name of the label you are looking for
         
        Accepts wildcards for searching. E.g. -Name "@PRO*"
         
        Default value is "*" which will search for all labels in the resource file
         
    .PARAMETER Value
        Value of the label you are looking for
         
        Accepts wildcards for searching. E.g. -Name "*Qty*"
         
        Default value is "*" which will search for all values in the resource file
         
    .PARAMETER IncludePath
        Switch to indicate whether you want the result set to include the path to the resource file or not
         
        Default is OFF - path details will not be part of the output
         
    .EXAMPLE
        PS C:\> Get-D365Label -Path "C:\AOSService\PackagesLocalDirectory\ApplicationSuite\Resources\en-US\PRO.resources.dll"
         
        Will get all labels from the "PRO.resouce.dll" file
         
        The language is determined by the path to the resource file and nothing else
         
    .EXAMPLE
        PS C:\> Get-D365Label -Path "C:\AOSService\PackagesLocalDirectory\ApplicationSuite\Resources\en-US\PRO.resources.dll" -Name "@PRO505"
         
        Will get the label with the name "@PRO505" from the "PRO.resouce.dll" file
         
        The language is determined by the path to the resource file and nothing else
         
    .EXAMPLE
        PS C:\> Get-D365Label -Path "C:\AOSService\PackagesLocalDirectory\ApplicationSuite\Resources\en-US\PRO.resources.dll" -Value "*qty*"
         
        Will get all the labels where the value fits the search "*qty*" from the "PRO.resouce.dll" file
         
        The language is determined by the path to the resource file and nothing else
         
    .EXAMPLE
        PS C:\> Get-D365InstalledPackage -Name "ApplicationSuite" | Get-D365PackageLabelFile -Language "da" | Get-D365Label -value "*batch*" -IncludePath
         
        Will get all the labels, across all label files, for the "ApplicationSuite", where the language is "da" and where the label value fits the search "*batch*".
         
        The path to the label file is included in the output.
         
    .NOTES
        Tags: PackagesLocalDirectory, Label, Labels, Language, Development, Servicing
         
        Author: M�tz Jensen (@Splaxi)
         
        There are several advanced scenarios for this cmdlet. See more on github and the wiki pages.
         
#>

function Get-D365LabelOld {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Default', ValueFromPipelineByPropertyName = $true, Position = 1 )]
        [Parameter(Mandatory = $true, ParameterSetName = 'Specific', Position = 1 )]
        [Alias('Path')]
        [string] $FilePath,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 2 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 2 )]
        [string] $Name = "*",

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 2 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 2 )]
        [string] $Value = "*",

        [switch] $IncludePath
    )

    BEGIN {}

    PROCESS {
        $assembly = [Reflection.Assembly]::LoadFile($FilePath)

        $resNames = $assembly.GetManifestResourceNames()
        $resname = $resNames[0].Replace(".resources", "")
        $resLanguage = $resname.Split(".")[1]

        $resMan = New-Object -TypeName System.Resources.ResourceManager -ArgumentList $resname, $assembly

        $language = New-Object System.Globalization.CultureInfo -ArgumentList "en-US"
        $resources = $resMan.GetResourceSet($language, $true, $true)

        foreach ($obj in $resources) {
            if ($obj.Name -NotLike $Name) { continue }
            if ($obj.Value -NotLike $Value) { continue }
            $res = [PSCustomObject]@{
                Name     = $obj.Name
                Language = $resLanguage
                Value    = $obj.Value
            }

            if ($IncludePath.IsPresent) {
                $res | Add-Member -MemberType NoteProperty -Name 'Path' -Value $FilePath
            }

            $res
        }
    }

    END {}
}


<#
    .SYNOPSIS
        Get installed languages from Dynamics 365 Finance & Operations environment
         
    .DESCRIPTION
        Get installed languages from the running the Dynamics 365 Finance & Operations instance
         
    .PARAMETER BinDir
        Path to the directory containing the BinDir and its assemblies
         
        Normally it is located under the AOSService directory in "PackagesLocalDirectory"
         
        Default value is fetched from the current configuration on the machine
         
    .PARAMETER Name
        Name of the language that you are looking for
         
        Accepts wildcards for searching. E.g. -Name "fr*"
         
        Default value is "*" which will search for all languages
         
    .EXAMPLE
        PS C:\> Get-D365Language
         
        Shows the entire list of installed languages that are available from the running instance
         
    .EXAMPLE
        PS C:\> Get-D365Language -Name "fr*"
         
        Shows the list of installed languages where the name fits the search "fr*"
         
        A result set example:
        fr French
        fr-BE French (Belgium)
        fr-CA French (Canada)
        fr-CH French (Switzerland)
         
    .NOTES
        Tags: PackagesLocalDirectory, Servicing, Language, Labels, Label
         
        Author: M�tz Jensen (@Splaxi)
         
        This cmdlet is inspired by the work of "Pedro Tornich" (twitter: @ptornich)
         
        All credits goes to him for showing how to extract these information
         
        His github repository can be found here:
        https://github.com/ptornich/LabelFileGenerator
         
#>

function Get-D365Language {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 1 )]
        [string] $BinDir = "$Script:BinDir\bin",

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 2 )]
        [string] $Name = "*"
    )

    begin {
    }

    process {
        $files = @((Join-Path -Path $BinDir -ChildPath "Microsoft.Dynamics.AX.Xpp.AxShared.dll"))
        
        if(-not (Test-PathExists -Path $files -Type Leaf)) {
            return
        }

        Add-Type -Path $files

        $languages = [Microsoft.Dynamics.Ax.Xpp.LabelHelper]::GetInstalledLanguages()

        foreach ($obj in $languages) {
            Write-PSFMessage -Level Verbose -Message "Filtering out all modules that doesn't match the model search." -Target $obj
            if ($obj -NotLike $Name) {continue}

            $lang = New-Object System.Globalization.CultureInfo -ArgumentList $obj
            [PSCustomObject]@{
                Name        = $obj
                LanguageName  = $lang.DisplayName
            }
        }
    }

    end {
    }
}


<#
    .SYNOPSIS
        Get the LCS configuration details
         
    .DESCRIPTION
        Get the LCS configuration details from the configuration store
         
        All settings retrieved from this cmdlets is to be considered the default parameter values across the different cmdlets
         
    .PARAMETER OutputAsHashtable
        Instruct the cmdlet to return a hashtable object
         
    .EXAMPLE
        PS C:\> Get-D365LcsApiConfig
         
        This will output the current LCS API configuration.
        The object returned will be a PSCustomObject.
         
    .EXAMPLE
        PS C:\> Get-D365LcsApiConfig -OutputAsHashtable
         
        This will output the current LCS API configuration.
        The object returned will be a Hashtable.
         
    .LINK
        Get-D365LcsApiToken
         
    .LINK
        Get-D365LcsAssetValidationStatus
         
    .LINK
        Get-D365LcsDeploymentStatus
         
    .LINK
        Invoke-D365LcsApiRefreshToken
         
    .LINK
        Invoke-D365LcsDeployment
         
    .LINK
        Invoke-D365LcsUpload
         
    .LINK
        Set-D365LcsApiConfig
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, LCS, Upload, ClientId
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Get-D365LcsApiConfig {
    [CmdletBinding()]
    [OutputType()]
    param (
        [switch] $OutputAsHashtable
    )

    Invoke-TimeSignal -Start

    $res = [Ordered]@{}

    Write-PSFMessage -Level Verbose -Message "Extracting all the LCS configuration and building the result object."

    foreach ($config in Get-PSFConfig -FullName "d365fo.tools.lcs.*") {
        if($config.FullName.ToString() -like "d365fo.tools.lcs.environment*") { continue }
        
        $propertyName = $config.FullName.ToString().Replace("d365fo.tools.lcs.", "")
        $res.$propertyName = $config.Value
    }

    if($OutputAsHashtable) {
        $res
    } else {
        [PSCustomObject]$res
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Upload a file to a LCS project
         
    .DESCRIPTION
        Upload a file to a LCS project using the API provided by Microsoft
         
    .PARAMETER ClientId
        The Azure Registered Application Id / Client Id obtained while creating a Registered App inside the Azure Portal
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER Username
        The username of the account that you want to impersonate
         
        It can either be your personal account or a service account
         
    .PARAMETER Password
        The password of the account that you want to impersonate
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .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:\> Get-D365LcsApiToken -ClientId "9b4f4503-b970-4ade-abc6-2c086e4c4929" -Username "serviceaccount@domain.com" -Password "TopSecretPassword" -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will obtain a valid OAuth 2.0 access token from Azure Active Directory.
        The ClientId "9b4f4503-b970-4ade-abc6-2c086e4c4929" is used in the OAuth 2.0 Grant Flow to authenticate.
        The Username "serviceaccount@domain.com" and Password "TopSecretPassword" is used in the OAuth 2.0 Grant Flow, to approved that the application should impersonate like "serviceaccount@domain.com".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .EXAMPLE
        PS C:\> Get-D365LcsApiToken -ClientId "9b4f4503-b970-4ade-abc6-2c086e4c4929" -Username "serviceaccount@domain.com" -Password "TopSecretPassword" -LcsApiUri "https://lcsapi.lcs.dynamics.com" | Set-D365LcsApiConfig -ProjectId 123456789
         
        This will obtain a valid OAuth 2.0 access token from Azure Active Directory.
        The ClientId "9b4f4503-b970-4ade-abc6-2c086e4c4929" is used in the OAuth 2.0 Grant Flow to authenticate.
        The Username "serviceaccount@domain.com" and Password "TopSecretPassword" is used in the OAuth 2.0 Grant Flow, to approved that the application should impersonate like "serviceaccount@domain.com".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
        The output object received from Get-D365LcsApiToken is piped directly to Set-D365LcsApiConfig.
         
        Set-D365LcsApiConfig will save the ClientId, LcsApiUri, ProjectId, access_token(BearerToken), refresh_token(RefreshToken), expires_on(ActiveTokenExpiresOn) details for the module to use them across other LCS cmdlets.
         
        This should be your default approach in using and leveraging the module, so you don't have to supply the same parameters for every single cmdlet.
         
    .EXAMPLE
        PS C:\> Get-D365LcsApiToken -Username "serviceaccount@domain.com" -Password "TopSecretPassword"
         
        This will obtain a valid OAuth 2.0 access token from Azure Active Directory.
        The Username "serviceaccount@domain.com" and Password "TopSecretPassword" is used in the OAuth 2.0 Grant Flow, to approved that the application should impersonate like "serviceaccount@domain.com".
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .EXAMPLE
        PS C:\> Get-D365LcsApiToken -Username "serviceaccount@domain.com" -Password "TopSecretPassword" | Set-D365LcsApiConfig
         
        This will obtain a valid OAuth 2.0 access token from Azure Active Directory and save the needed details.
        The Username "serviceaccount@domain.com" and Password "TopSecretPassword" is used in the OAuth 2.0 Grant Flow, to approved that the application should impersonate like "serviceaccount@domain.com".
        The output object received from Get-D365LcsApiToken is piped directly to Set-D365LcsApiConfig.
        Set-D365LcsApiConfig will save the access_token(BearerToken), refresh_token(RefreshToken) and expires_on(ActiveTokenExpiresOn).
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .LINK
        Get-D365LcsApiConfig
         
    .LINK
        Get-D365LcsAssetValidationStatus
         
    .LINK
        Get-D365LcsDeploymentStatus
         
    .LINK
        Invoke-D365LcsApiRefreshToken
         
    .LINK
        Invoke-D365LcsDeployment
         
    .LINK
        Invoke-D365LcsUpload
         
    .LINK
        Set-D365LcsApiConfig
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, LCS, Upload, Api, AAD, Token
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Get-D365LcsApiToken {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingUserNameAndPassWordParams", "")]
    [CmdletBinding()]
    [OutputType()]
    param(
        [Parameter(Mandatory = $false)]
        [string] $ClientId = $Script:LcsApiClientId,

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

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

        [Parameter(Mandatory = $false)]
        [string] $LcsApiUri = $Script:LcsApiApiUri,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    $tokenParms = @{ }
    $tokenParms.Resource = $LcsApiUri
    $tokenParms.ClientId = $ClientId
    $tokenParms.Username = $Username
    $tokenParms.Password = $Password
    $tokenParms.Scope = "openid"
    $tokenParms.AuthProviderUri = $Script:AADOAuthEndpoint

    Invoke-PasswordGrant @tokenParms

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Get file from the Asset library inside the LCS project
         
    .DESCRIPTION
        Get the available files from the Asset Library in LCS project
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER FileType
        Type of file you want to list from the LCS Asset Library
         
        Valid options:
        "Model"
        "Process Data Package"
        "Software Deployable Package"
        "GER Configuration"
        "Data Package"
        "PowerBI Report Model"
        "E-Commerce Package"
        "NuGet Package"
        "Retail Self-Service Package"
        "Commerce Cloud Scale Unit Extension"
         
         
        Default value is "Software Deployable Package"
         
    .PARAMETER AssetName
        Name of the file that you are looking for
         
        Accepts wildcards for searching. E.g. -AssetName "*ISV*"
         
        Default value is "*" which will search for all files
         
    .PARAMETER AssetVersion
        Version of the Asset file that you are looking for
         
        It does a simple compare against the response from LCS and only lists the ones that matches
         
        Accepts wildcards for searching. E.g. -AssetName "*ISV*"
         
        Default value is "*" which will search for all files
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER Latest
        Instruct the cmdlet to only fetch the latest file from the Asset Library from LCS
         
    .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:\> Get-D365LcsAssetFile -ProjectId 123456789 -FileType SoftwareDeployablePackage -BearerToken "JldjfafLJdfjlfsalfd..." -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will start the database refresh between the Source and Target environments.
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The source environment is identified by the SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae", which can be obtained in the LCS portal.
        The target environment is identified by the TargetEnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
        The request will authenticate with the BearerToken "JldjfafLJdfjlfsalfd...".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .EXAMPLE
        PS C:\> Get-D365LcsAssetFile -FileType SoftwareDeployablePackage
         
        This will list all Software Deployable Packages.
        It will search for SoftwareDeployablePackage by using the FileType parameter.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .EXAMPLE
        PS C:\> Get-D365LcsAssetFile -FileType SoftwareDeployablePackage -Latest | Invoke-D365AzCopyTransfer -DestinationUri C:\Temp\d365fo.tools -FileName "Main.zip" -ShowOriginalProgress
         
        This will download the latest Software Deployable Package from the Asset Library in LCS onto your on machine.
        It will list Software Deployable Packages based on the FileType parameter.
        It will list the latest (newest) Software Deployable Package.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .LINK
        Get-D365LcsApiConfig
         
    .LINK
        Get-D365LcsApiToken
         
    .LINK
        Invoke-D365LcsApiRefreshToken
         
    .LINK
        Set-D365LcsApiConfig
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>


function Get-D365LcsAssetFile {
    [CmdletBinding()]
    [OutputType()]
    param (
        [int] $ProjectId = $Script:LcsApiProjectId,

        [LcsAssetFileType] $FileType = [LcsAssetFileType]::SoftwareDeployablePackage,

        [string] $AssetName = "*",

        [string] $AssetVersion = "*",
        
        [Alias('Token')]
        [string] $BearerToken = $Script:LcsApiBearerToken,

        [string] $LcsApiUri = $Script:LcsApiLcsApiUri,

        [Alias('GetLatest')]
        [switch] $Latest,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    if (-not ($BearerToken.StartsWith("Bearer "))) {
        $BearerToken = "Bearer $BearerToken"
    }

    $assets = Get-LcsAssetFile -BearerToken $BearerToken -ProjectId $ProjectId -LcsApiUri $LcsApiUri -FileType $([int]$FileType)

    if (Test-PSFFunctionInterrupt) { return }

    if ($Latest) {
        $assets | Sort-Object -Property "ModifiedDate" -Descending | Select-Object -First 1 | Select-PSFObject -TypeName "D365FO.TOOLS.Lcs.Asset.File" "*","Id as AssetId"
    }
    else {
        foreach ($obj in $assets) {
            if ($obj.Name -NotLike $AssetName) { continue }
            if ($obj.Version -NotLike $AssetVersion) { continue }

            $obj | Select-PSFObject -TypeName "D365FO.TOOLS.Lcs.Asset.File" "*","Id as AssetId"
        }
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Get the validation status from LCS
         
    .DESCRIPTION
        Get the validation status for a given file in the Asset Library in LCS
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER AssetId
        The unique id of the asset / file that you are trying to deploy from LCS
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER WaitForValidation
        Instruct the cmdlet to wait for the validation process to complete
         
        The cmdlet will sleep for 60 seconds, before requesting the status of the validation process from LCS
         
    .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:\> Get-D365LcsAssetValidationStatus -ProjectId 123456789 -BearerToken "JldjfafLJdfjlfsalfd..." -AssetId "958ae597-f089-4811-abbd-c1190917eaae" -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will check the validation status for the file in the Asset Library.
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The file is identified by the AssetId "958ae597-f089-4811-abbd-c1190917eaae", which is obtained either by earlier upload or simply looking in the LCS portal.
        The request will authenticate with the BearerToken "Bearer JldjfafLJdfjlfsalfd...".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .EXAMPLE
        PS C:\> Get-D365LcsAssetValidationStatus -AssetId "958ae597-f089-4811-abbd-c1190917eaae"
         
        This will check the validation status for the file in the Asset Library.
        The file is identified by the AssetId "958ae597-f089-4811-abbd-c1190917eaae", which is obtained either by earlier upload or simply looking in the LCS portal.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .EXAMPLE
        PS C:\> Get-D365LcsAssetValidationStatus -AssetId "958ae597-f089-4811-abbd-c1190917eaae" -WaitForValidation
         
        This will check the validation status for the file in the Asset Library.
        The file is identified by the AssetId "958ae597-f089-4811-abbd-c1190917eaae", which is obtained either by earlier upload or simply looking in the LCS portal.
        The cmdlet will every 60 seconds contact the LCS API endpoint and check if the status of the validation is either success or failure.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .EXAMPLE
        PS C:\> Invoke-D365LcsUpload -FilePath "C:\temp\d365fo.tools\Release-2019-05-05.zip" | Get-D365LcsAssetValidationStatus -WaitForValidation
         
        This will start the upload of a file to the Asset Library and check the validation status for the file in the Asset Library.
        The file that will be uploaded is based on the FilePath "C:\temp\d365fo.tools\Release-2019-05-05.zip".
        The output object received from Invoke-D365LcsUpload is piped directly to Get-D365LcsAssetValidationStatus.
        The cmdlet will every 60 seconds contact the LCS API endpoint and check if the status of the validation is either success or failure.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .LINK
        Get-D365LcsApiConfig
         
    .LINK
        Get-D365LcsApiToken
         
    .LINK
        Get-D365LcsDeploymentStatus
         
    .LINK
        Invoke-D365LcsApiRefreshToken
         
    .LINK
        Invoke-D365LcsDeployment
         
    .LINK
        Invoke-D365LcsUpload
         
    .LINK
        Set-D365LcsApiConfig
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>


function Get-D365LcsAssetValidationStatus {
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $false)]
        [int] $ProjectId = $Script:LcsApiProjectId,
        
        [Parameter(Mandatory = $false)]
        [Alias('Token')]
        [string] $BearerToken = $Script:LcsApiBearerToken,

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

        [Parameter(Mandatory = $false)]
        [string] $LcsApiUri = $Script:LcsApiLcsApiUri,

        [switch] $WaitForValidation,

        [switch] $EnableException
    )


    process {
        Invoke-TimeSignal -Start

        if (-not ($BearerToken.StartsWith("Bearer "))) {
            $BearerToken = "Bearer $BearerToken"
        }

        do {
            Write-PSFMessage -Level Verbose -Message "Sleeping before hitting the LCS API for Asset Validation Status"
            Start-Sleep -Seconds 60
            $status = Get-LcsAssetValidationStatus -BearerToken $BearerToken -ProjectId $ProjectId -AssetId $AssetId -LcsApiUri $LcsApiUri
        }
        while (($status.DisplayStatus -eq "Process") -and $WaitForValidation)

        Invoke-TimeSignal -End

        $status | Select-PSFObject "ID as AssetId", "DisplayStatus as Status"
    }
}


<#
    .SYNOPSIS
        Get database backups from LCS project
         
    .DESCRIPTION
        Get the available database backups from the Asset Library in LCS project
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER Latest
        Instruct the cmdlet to only fetch the latest file from the Azure Storage Account
         
    .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:\> Get-D365LcsDatabaseBackups -ProjectId 123456789 -BearerToken "JldjfafLJdfjlfsalfd..." -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will get all available database backups from the Asset Library inside LCS.
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The request will authenticate with the BearerToken "Bearer JldjfafLJdfjlfsalfd...".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .EXAMPLE
        PS C:\> Get-D365LcsDatabaseBackups
         
        This will get all available database backups from the Asset Library inside LCS.
        It will use default values for all parameters.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .EXAMPLE
        PS C:\> Get-D365LcsDatabaseBackups -Latest
         
        This will get the latest available database backup from the Asset Library inside LCS.
        It will use default values for all parameters.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .LINK
        Get-D365LcsApiConfig
         
    .LINK
        Get-D365LcsApiToken
         
    .LINK
        Invoke-D365LcsApiRefreshToken
         
    .LINK
        Set-D365LcsApiConfig
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>


function Get-D365LcsDatabaseBackups {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    [OutputType()]
    param (
        [int] $ProjectId = $Script:LcsApiProjectId,
        
        [Alias('Token')]
        [string] $BearerToken = $Script:LcsApiBearerToken,

        [string] $LcsApiUri = $Script:LcsApiLcsApiUri,

        [Alias('GetLatest')]
        [switch] $Latest,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    if (-not ($BearerToken.StartsWith("Bearer "))) {
        $BearerToken = "Bearer $BearerToken"
    }

    $backups = Get-LcsDatabaseBackups -BearerToken $BearerToken -ProjectId $ProjectId -LcsApiUri $LcsApiUri

    if (Test-PSFFunctionInterrupt) { return }

    if ($Latest) {
        $backups.DatabaseAssets | Sort-Object -Property "CreatedDateTime" -Descending | Select-Object -First 1
    }
    else {
        $backups.DatabaseAssets
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Get the status of a database operation from LCS
         
    .DESCRIPTION
        Get the current status of a database operation against an environment from a LCS project
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER OperationActivityId
        The unique id of the operaction activity that identitfies the database operation
         
        It will be part of the output from the different Invoke-D365LcsDatabaseExport or Invoke-D365LcsDatabaseRefresh cmdlets
         
    .PARAMETER EnvironmentId
        The unique id of the environment that you want to work against
         
        The Id can be located inside the LCS portal
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER WaitForCompletion
        Instruct the cmdlet to wait for the deployment process to complete
         
        The cmdlet will sleep for 300 seconds, before requesting the status of the deployment process from LCS
         
    .PARAMETER SleepInSeconds
        Time in secounds that you want the cmdlet to use as the sleep timer between each request against the LCS endpoint
         
        Default value is 300
         
    .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:\> Get-D365LcsDatabaseOperationStatus -ProjectId 123456789 -OperationActivityId 123456789 -EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e" -BearerToken "JldjfafLJdfjlfsalfd..." -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will check the database operation status of a specific OperationActivityId against an environment.
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The OperationActivityId is identified by the OperationActivityId 123456789, which is obtained from executing either the Invoke-D365LcsDatabaseExport or Invoke-D365LcsDatabaseRefresh cmdlets.
        The environment is identified by the EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
        The request will authenticate with the BearerToken "JldjfafLJdfjlfsalfd...".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .EXAMPLE
        PS C:\> Get-D365LcsDatabaseOperationStatus -OperationActivityId 123456789 -EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e"
         
        This will check the database operation status of a specific OperationActivityId against an environment.
        The OperationActivityId is identified by the OperationActivityId 123456789, which is obtained from executing either the Invoke-D365LcsDatabaseExport or Invoke-D365LcsDatabaseRefresh cmdlets.
        The environment is identified by the EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .EXAMPLE
        PS C:\> Get-D365LcsDatabaseOperationStatus -OperationActivityId 123456789 -EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e" -WaitForCompletion
         
        This will check the database operation status of a specific OperationActivityId against an environment.
        The OperationActivityId is identified by the OperationActivityId 123456789, which is obtained from executing either the Invoke-D365LcsDatabaseExport or Invoke-D365LcsDatabaseRefresh cmdlets.
        The environment is identified by the EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
        The cmdlet will every 300 seconds contact the LCS API endpoint and check if the status of the database operation status is either success or failure.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .LINK
        Get-D365LcsApiConfig
         
    .LINK
        Get-D365LcsApiToken
         
    .LINK
        Get-D365LcsAssetValidationStatus
         
    .LINK
        Invoke-D365LcsApiRefreshToken
         
    .LINK
        Invoke-D365LcsDeployment
         
    .LINK
        Invoke-D365LcsUpload
         
    .LINK
        Set-D365LcsApiConfig
         
    .NOTES
        Tags: Environment, Config, Configuration, LCS, Database backup, Api, Backup, Restore, Refresh
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Get-D365LcsDatabaseOperationStatus {
    [CmdletBinding()]
    [OutputType('PSCustomObject')]
    param(
        [int] $ProjectId = $Script:LcsApiProjectId,
        
        [Alias('Token')]
        [string] $BearerToken = $Script:LcsApiBearerToken,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('ActivityId')]
        [string] $OperationActivityId,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('SourceEnvironmentId')]
        [string] $EnvironmentId,

        [Parameter(Mandatory = $false)]
        [string] $LcsApiUri = $Script:LcsApiLcsApiUri,

        [switch] $WaitForCompletion,

        [int] $SleepInSeconds = 300,

        [switch] $EnableException
    )

    process {
        Invoke-TimeSignal -Start

        if (-not ($BearerToken.StartsWith("Bearer "))) {
            $BearerToken = "Bearer $BearerToken"
        }

        do {
            Write-PSFMessage -Level Verbose -Message "Sleeping before hitting the LCS API for Deployment Status"

            Start-Sleep -Seconds $SleepInSeconds
            $databaseOperationStatus = Get-LcsDatabaseOperationStatus -BearerToken $BearerToken -ProjectId $ProjectId -OperationActivityId $OperationActivityId -EnvironmentId $EnvironmentId -LcsApiUri $LcsApiUri

            Write-PSFMessage -Level Verbose -Message "Database Operation Status is: $($databaseOperationStatus.OperationStatus)"
        }
        while ((($databaseOperationStatus.OperationStatus -eq "InProgress") -or ($databaseOperationStatus.OperationStatus -eq "NotStarted") -or ($databaseOperationStatus.OperationStatus -eq "RollbackInProgress")) -and $WaitForCompletion)

        Invoke-TimeSignal -End

        $databaseOperationStatus | Select-PSFObject * -TypeName "D365FO.TOOLS.LCS.Database.Operation.Status"
    }
}


<#
    .SYNOPSIS
        Get the Deployment status from LCS
         
    .DESCRIPTION
        Get the Deployment status for activity against an environment from the Dynamics LCS Portal
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER ActivityId
        The unique id of the action that you started from the Invoke-D365LcsDeployment cmdlet
         
    .PARAMETER EnvironmentId
        The unique id of the environment that you want to work against
         
        The Id can be located inside the LCS portal
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER WaitForCompletion
        Instruct the cmdlet to wait for the deployment process to complete
         
        The cmdlet will sleep for 300 seconds, before requesting the status of the deployment process from LCS
         
    .PARAMETER SleepInSeconds
        Time in secounds that you want the cmdlet to use as the sleep timer between each request against the LCS endpoint
         
        Default value is 300
         
    .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:\> Get-D365LcsDeploymentStatus -ProjectId 123456789 -ActivityId 123456789 -EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e" -BearerToken "Bearer JldjfafLJdfjlfsalfd..." -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will check the deployment status of specific activity against an environment.
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The activity is identified by the ActivityId 123456789, which is obtained from the Invoke-D365LcsDeployment execution.
        The environment is identified by the EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
        The request will authenticate with the BearerToken "Bearer JldjfafLJdfjlfsalfd...".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .EXAMPLE
        PS C:\> Get-D365LcsDeploymentStatus -ActivityId 123456789 -EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e"
         
        This will check the deployment status of specific activity against an environment.
        The activity is identified by the ActivityId 123456789, which is obtained from the Invoke-D365LcsDeployment execution.
        The environment is identified by the EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .EXAMPLE
        PS C:\> Get-D365LcsDeploymentStatus -ActivityId 123456789 -EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e" -WaitForCompletion
         
        This will check the deployment status of specific activity against an environment.
        The activity is identified by the ActivityId 123456789, which is obtained from the Invoke-D365LcsDeployment execution.
        The environment is identified by the EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
        The cmdlet will every 300 seconds contact the LCS API endpoint and check if the status of the deployment is either success or failure.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .LINK
        Get-D365LcsApiConfig
         
    .LINK
        Get-D365LcsApiToken
         
    .LINK
        Get-D365LcsAssetValidationStatus
         
    .LINK
        Invoke-D365LcsApiRefreshToken
         
    .LINK
        Invoke-D365LcsDeployment
         
    .LINK
        Invoke-D365LcsUpload
         
    .LINK
        Set-D365LcsApiConfig
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, LCS, Upload, Api, AAD, Token, Deployment, Deploy
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Get-D365LcsDeploymentStatus {
    [CmdletBinding()]
    [OutputType('PSCustomObject')]
    param(
        [Parameter(Mandatory = $false)]
        [int] $ProjectId = $Script:LcsApiProjectId,
        
        [Parameter(Mandatory = $false)]
        [Alias('Token')]
        [string] $BearerToken = $Script:LcsApiBearerToken,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('ActionHistoryId')]
        [string] $ActivityId,

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

        [Parameter(Mandatory = $false)]
        [string] $LcsApiUri = $Script:LcsApiLcsApiUri,

        [switch] $WaitForCompletion,

        [int] $SleepInSeconds = 300,

        [switch] $EnableException
    )

    process {
        Invoke-TimeSignal -Start

        if (-not ($BearerToken.StartsWith("Bearer "))) {
            $BearerToken = "Bearer $BearerToken"
        }

        do {
            Write-PSFMessage -Level Verbose -Message "Sleeping before hitting the LCS API for Deployment Status"

            Start-Sleep -Seconds $SleepInSeconds
            $deploymentStatus = Get-LcsDeploymentStatus -BearerToken $BearerToken -ProjectId $ProjectId -ActivityId $ActivityId -EnvironmentId $EnvironmentId -LcsApiUri $LcsApiUri
        }
        while ((($deploymentStatus.OperationStatus -eq "InProgress") -or ($deploymentStatus.OperationStatus -eq "NotStarted") -or ($deploymentStatus.OperationStatus -eq "PreparingEnvironment")) -and $WaitForCompletion)

        Invoke-TimeSignal -End

        $deploymentStatus
    }
}


<#
    .SYNOPSIS
        Get lcs environment
         
    .DESCRIPTION
        Get all lcs environment objects from the configuration store
         
    .PARAMETER Name
        The name of the lcs environment you are looking for
         
        Default value is "*" to display all lcs environments
         
    .PARAMETER OutputAsHashtable
        Instruct the cmdlet to return a hashtable object
         
    .EXAMPLE
        PS C:\> Get-D365LcsEnvironment
         
        This will display all lcs environments on the machine.
         
    .EXAMPLE
        PS C:\> Get-D365LcsEnvironment -OutputAsHashtable
         
        This will display all lcs environments on the machine.
        Every object will be output as a hashtable, for you to utilize as parameters for other cmdlets.
         
    .EXAMPLE
        PS C:\> Get-D365LcsEnvironment -Name "UAT"
         
        This will display the lcs environment that is saved with the name "UAT" on the machine.
         
    .NOTES
        Tags: Servicing, Environment, Config, Configuration
         
        Author: M�tz Jensen (@Splaxi)
         
    .LINK
        Get-D365LcsApiConfig
         
    .LINK
        Get-D365LcsApiToken
         
    .LINK
        Get-D365LcsAssetValidationStatus
         
    .LINK
        Get-D365LcsDeploymentStatus
         
    .LINK
        Invoke-D365LcsApiRefreshToken
         
    .LINK
        Invoke-D365LcsUpload
         
    .LINK
        Set-D365LcsApiConfig
#>


function Get-D365LcsEnvironment {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')]
    [CmdletBinding()]
    [OutputType('PSCustomObject')]
    param (
        [string] $Name = "*",

        [switch] $OutputAsHashtable
    )
    
    Write-PSFMessage -Level Verbose -Message "Fetch all configurations based on $Name" -Target $Name

    $Name = $Name.ToLower()
    $configurations = Get-PSFConfig -FullName "d365fo.tools.lcs.environment.$Name.name"

    foreach ($configName in $configurations.Value.ToLower()) {
        Write-PSFMessage -Level Verbose -Message "Working against the $configName configuration" -Target $configName
        $res = @{}

        $configName = $configName.ToLower()

        foreach ($config in Get-PSFConfig -FullName "d365fo.tools.lcs.environment.$configName.*") {
            $propertyName = $config.FullName.ToString().Replace("d365fo.tools.lcs.environment.$configName.", "")
            $res.$propertyName = $config.Value
        }
        
        if($OutputAsHashtable) {
            $res
        } else {
            [PSCustomObject]$res
        }
    }
}


<#
    .SYNOPSIS
        Get the registered details for Azure Logic App
         
    .DESCRIPTION
        Get the details that are stored for the module when
        it has to invoke the Azure Logic App
         
    .EXAMPLE
        PS C:\> Get-D365LogicAppConfig
         
        This will fetch the current registered Azure Logic App details on the machine.
         
    .NOTES
        Tags: LogicApp, Logic App, Configuration, Url, Email
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-D365LogicAppConfig {
    [CmdletBinding()]
    param ()
    
    $Details = [hashtable](Get-PSFConfigValue -FullName "d365fo.tools.active.logic.app")
        
    $temp = [ordered]@{Email = $Details.Email;
        Subject = $Details.Subject; URL = $Details.URL
    }
    
    [PSCustomObject]$temp
}


<#
    .SYNOPSIS
        Get the maintenance mode status of the environment
         
    .DESCRIPTION
        Get the maintenance mode status of the Dynamics 365 environment to make sure that things are in the correct state
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .EXAMPLE
        PS C:\> Get-D365MaintenanceMode
         
        This will get the current state of the maintenance mode of the environment
         
    .NOTES
        Tags: MaintenanceMode, Maintenance, License, Configuration, Servicing
         
        Author: M�tz Jensen (@splaxi)
         
    .LINK
        Enable-D365MaintenanceMode
         
    .LINK
        Disable-D365MaintenanceMode
#>

function Get-D365MaintenanceMode {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 3 )]
        [string] $DatabaseServer = $Script:DatabaseServer,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 4 )]
        [string] $DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 5 )]
        [string] $SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 6 )]
        [string] $SqlPwd = $Script:DatabaseUserPassword
    )

        Write-PSFMessage -Level Verbose -Message "Getting Maintenance Mode using SQL scripts."

        $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

    $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd
    }

    $sqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection

    $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\get-maintenancemode.sql") -join [Environment]::NewLine

    try {
        $sqlCommand.Connection.Open()
    
        $reader = $sqlCommand.ExecuteReader()

        while ($reader.Read() -eq $true) {
            [PSCustomObject]@{
                MaintenanceModeEnabled          = [bool][int]"$($reader.GetString($($reader.GetOrdinal("VALUE"))))"
            }
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
    finally {
        $reader.close()

        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
    }
}


<#
    .SYNOPSIS
        Get available model from Dynamics 365 Finance & Operations environment
         
    .DESCRIPTION
        Get available model from the machine running the AOS service for Dynamics 365 Finance & Operations
         
    .PARAMETER Name
        Name of the model that you are looking for
         
        Accepts wildcards for searching. E.g. -Name "Application*Adaptor"
         
        Default value is "*" which will search for all models
         
    .PARAMETER Module
        Name of the module that you want to list models from
         
        Accepts wildcards for searchinf. E.g. -Module "Application*Adaptor"
         
        Default value is "*" which will search across all modules
         
    .PARAMETER CustomizableOnly
        Instructs the cmdlet to filter out all models that cannot be customized
         
    .PARAMETER ExcludeMicrosoftModels
        Instructs the cmdlet to exclude all models that has Microsoft as the publisher from the output
         
    .PARAMETER ExcludeBinaryModels
        Instruct the cmdlet to exclude binary models from the output
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the AOS service PackagesLocalDirectory\bin
         
        Default value is fetched from the current configuration on the machine
         
    .PARAMETER PackageDirectory
        Path to the directory containing the installed package / module
         
        Default path is the same as the AOS service "PackagesLocalDirectory" directory
         
        Default value is fetched from the current configuration on the machine
         
    .EXAMPLE
        PS C:\> Get-D365Model
         
        Shows the entire list of installed models located in the default location on the machine.
         
        A result set example:
         
        ModelName Module IsBinary Customization Id Publisher
        --------- ------ -------- ------------- -- ---------
        AccountsPayableMobile AccountsPayableMobile False DoNotAllow 895571380 Microsoft Corporation
        ApplicationCommon ApplicationCommon False DoNotAllow 8956718 Microsoft
        ApplicationFoundation ApplicationFoundation False Allow 450 Microsoft Corporation
        IsvFoundation IsvFoundation True Allow 895972027 Isv Corp
        IsvLicense IsvLicense True DoNotAllow 895972028 Isv Corp
         
    .EXAMPLE
        PS C:\> Get-D365Model -CustomizableOnly
         
        Shows only the models that are marked as customizable.
        Will only include models that is Customization = "Allow".
         
        A result set example:
         
        ModelName Module IsBinary Customization Id Publisher
        --------- ------ -------- ------------- -- ---------
        ApplicationFoundation ApplicationFoundation False Allow 450 Microsoft Corporation
        ApplicationPlatform ApplicationPlatform False Allow 400 Microsoft Corporation
        ApplicationPlatformFormAdaptor ApplicationPlatformFormAdaptor False Allow 855030 Microsoft Corporation
        IsvFoundation IsvFoundation True Allow 895972027 Isv Corp
         
    .EXAMPLE
        PS C:\> Get-D365Model -ExcludeMicrosoftModels
         
        Shows only the models that doesn't have "Microsoft" in the publisher.
        Will only include models that is Publisher -NotLike "Microsoft*".
         
        A result set example:
         
        ModelName Module IsBinary Customization Id Publisher
        --------- ------ -------- ------------- -- ---------
        IsvFoundation IsvFoundation True Allow 895972027 Isv Corp
        IsvLicense IsvLicense True DoNotAllow 895972028 Isv Corp
         
    .EXAMPLE
        PS C:\> Get-D365Model -ExcludeBinaryModels
         
        Shows only the models that are NOT binary.
        Will only include models that is IsBinary = "False".
         
        A result set example:
         
        ModelName Module IsBinary Customization Id Publisher
        --------- ------ -------- ------------- -- ---------
        AccountsPayableMobile AccountsPayableMobile False DoNotAllow 895571380 Microsoft Corporation
        ApplicationCommon ApplicationCommon False DoNotAllow 8956718 Microsoft
        ApplicationFoundation ApplicationFoundation False Allow 450 Microsoft Corporation
         
         
    .EXAMPLE
        PS C:\> Get-D365Model -CustomizableOnly -ExcludeMicrosoftModels
         
        Shows only the models that are marked as customizable and NOT from Microsoft.
        Will only include models that is Customization = "Allow".
        Will only include models that is Publisher -NotLike "Microsoft*".
         
        A result set example:
         
        ModelName Module IsBinary Customization Id Publisher
        --------- ------ -------- ------------- -- ---------
        IsvFoundation IsvFoundation True Allow 895972027 Isv Corp
         
    .EXAMPLE
        PS C:\> Get-D365Model -Name "Application*Adaptor"
         
        Shows the list of models where the name fits the search "Application*Adaptor".
         
        A result set example:
         
        ModelName Module IsBinary Customization Id Publisher
        --------- ------ -------- ------------- -- ---------
        ApplicationFoundationFormAd... ApplicationFoundationFormAd... False DoNotAllow 855029 Microsoft Corporation
        ApplicationPlatformFormAdaptor ApplicationPlatformFormAdaptor False Allow 855030 Microsoft Corporation
        ApplicationSuiteFormAdaptor ApplicationSuiteFormAdaptor False DoNotAllow 855028 Microsoft Corporation
        ApplicationWorkspacesFormAd... ApplicationWorkspacesFormAd... False DoNotAllow 855066 Microsoft Corporation
         
    .EXAMPLE
        PS C:\> Get-D365Model -Module ApplicationSuite
         
        Shows only the models that are inside the ApplicationSuite module.
         
        A result set example:
         
        ModelName Module IsBinary Customization Id Publisher
        --------- ------ -------- ------------- -- ---------
        Electronic Reporting Applic... ApplicationSuite False DoNotAllow 855009 Microsoft Corporation
        Foundation ApplicationSuite False DoNotAllow 17 Microsoft Corporation
        SCMControls ApplicationSuite False DoNotAllow 855891 Microsoft Corporation
        Tax Books Application Suite... ApplicationSuite False DoNotAllow 895570102 Microsoft Corporation
        Tax Engine Application Suit... ApplicationSuite False DoNotAllow 8957001 Microsoft Corporation
         
    .EXAMPLE
        PS C:\> Get-D365Model -Name "*Application*" -Module "*Suite*"
         
        Shows the list of models where the name fits the search "*Application*" and the module name fits the search "*Suite*".
         
        A result set example:
         
        ModelName Module IsBinary Customization Id Publisher
        --------- ------ -------- ------------- -- ---------
        ApplicationSuiteFormAdaptor ApplicationSuiteFormAdaptor False DoNotAllow 855028 Microsoft Corporation
        AtlApplicationSuite AtlApplicationSuite False DoNotAllow 895972466 Microsoft Corporation
        Electronic Reporting Applic... ApplicationSuite False DoNotAllow 855009 Microsoft Corporation
        Tax Books Application Suite... ApplicationSuite False DoNotAllow 895570102 Microsoft Corporation
        Tax Engine Application Suit... ApplicationSuite False DoNotAllow 8957001 Microsoft Corporation
         
    .NOTES
        Tags: PackagesLocalDirectory, Servicing, Model, Models, Module, Modules
         
        Author: M�tz Jensen (@Splaxi)
         
        The cmdlet supports piping and can be used in advanced scenarios. See more on github and the wiki pages.
         
#>

function Get-D365Model {
    [CmdletBinding()]
    param (
        [string] $Name = "*",

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias("ModuleName")]
        [string] $Module = "*",

        [switch] $CustomizableOnly,
        
        [switch] $ExcludeMicrosoftModels,

        [switch] $ExcludeBinaryModels,

        [string] $BinDir = "$Script:BinDir\bin",

        [string] $PackageDirectory = $Script:PackageDirectory
    )

    begin {
        [System.Collections.ArrayList] $Files2Process = New-Object -TypeName "System.Collections.ArrayList"
        
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.AX.Metadata.Management.Delta.dll"))
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.AX.Metadata.Management.Diff.dll"))
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.AX.Metadata.Management.Merge.dll"))
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.AX.Metadata.Management.Core.dll"))
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.AX.Metadata.dll"))
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.AX.Metadata.Core.dll"))
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.AX.Metadata.Storage.dll"))
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.ApplicationPlatform.XppServices.Instrumentation.dll"))

        Import-AssemblyFileIntoMemory -Path $($Files2Process.ToArray())

        Write-PSFMessage -Level Verbose -Message "Intializing RuntimeProvider."

        $runtimeProviderConfiguration = New-Object Microsoft.Dynamics.AX.Metadata.Storage.Runtime.RuntimeProviderConfiguration -ArgumentList $Script:PackageDirectory
        $metadataProviderFactoryViaRuntime = New-Object Microsoft.Dynamics.AX.Metadata.Storage.MetadataProviderFactory
        $metadataProviderViaRuntime = $metadataProviderFactoryViaRuntime.CreateRuntimeProvider($runtimeProviderConfiguration)

        Write-PSFMessage -Level Verbose -Message "MetadataProvider initialized." -Target $metadataProviderViaRuntime

        $models = $metadataProviderViaRuntime.ModelManifest.ListModelInfos()
        $models | ForEach-Object {
            $_ | Add-Member -MemberType NoteProperty -Name 'IsBinary' -Value $false
        }

        Write-PSFMessage -Level Verbose -Message "Testing if the cmdlet is running on a OneBox or not." -Target $Script:IsOnebox
    
        if ($Script:IsOnebox) {
            Write-PSFMessage -Level Verbose -Message "Machine is onebox. Initializing DiskProvider too."

            $diskProviderConfiguration = New-Object Microsoft.Dynamics.AX.Metadata.Storage.DiskProvider.DiskProviderConfiguration
            $diskProviderConfiguration.AddMetadataPath($PackageDirectory)
            $metadataProviderFactoryViaDisk = New-Object Microsoft.Dynamics.AX.Metadata.Storage.MetadataProviderFactory
            $metadataProviderViaDisk = $metadataProviderFactoryViaDisk.CreateDiskProvider($diskProviderConfiguration)

            Write-PSFMessage -Level Verbose -Message "MetadataProvider initialized." -Target $metadataProviderViaDisk

            $diskModels = $metadataProviderViaDisk.ModelManifest.ListModelInfos()

            foreach($model in $models) {
                if ($diskModels.Name -NotContains $model.Name) {
                    $model.IsBinary = $true
                }
            }
        }

        if ($CustomizableOnly) {
            $models = $models | Where-Object Customization -eq "Allow"
        }

        if($ExcludeBinaryModels -eq $true){
            $models = $models | Where-Object IsBinary -eq $false
        }
    }

    process {
        if (Test-PSFFunctionInterrupt) { return }
        
        $modelsLocal = $models
        
        $modelsLocal = $modelsLocal | Where-Object Module -like $Module

        Write-PSFMessage -Level Verbose -Message "Looping through all models."

        foreach ($obj in $($modelsLocal | Sort-Object Name, Module)) {
            Write-PSFMessage -Level Verbose -Message "Filtering out all models that doesn't match the model search." -Target $obj
            if ($obj.Name -NotLike $Name) { continue }

            if ($ExcludeMicrosoftModels -and $obj.Publisher -like "Microsoft*") { continue }
            
            $obj | Select-PSFObject "Name as ModelName", * -ExcludeProperty Name -TypeName "D365FO.TOOLS.ModelInfo"
        }
    }
}


<#
    .SYNOPSIS
        Get installed package / module from Dynamics 365 Finance & Operations environment
         
    .DESCRIPTION
        Get installed package / module from the machine running the AOS service for Dynamics 365 Finance & Operations
         
    .PARAMETER Name
        Name of the package / module that you are looking for
         
        Accepts wildcards for searching. E.g. -Name "Application*Adaptor"
         
        Default value is "*" which will search for all packages / modules
         
    .PARAMETER ExcludeBinaryModules
        Instruct the cmdlet to exclude binary modules from the output
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the AOS service PackagesLocalDirectory\bin
         
        Default value is fetched from the current configuration on the machine
         
    .PARAMETER PackageDirectory
        Path to the directory containing the installed package / module
         
        Normally it is located under the AOSService directory in "PackagesLocalDirectory"
         
        Default value is fetched from the current configuration on the machine
         
    .EXAMPLE
        PS C:\> Get-D365Module
         
        Shows the entire list of installed packages / modules located in the default location on the machine.
         
        A result set example:
         
        ModuleName IsBinary Version References
        ---------- -------- ------- ----------
        AccountsPayableMobile False 10.0.9107.14827 {ApplicationFoundation, ApplicationPlatform, Appli...
        ApplicationCommon False 10.0.8008.26462 {ApplicationFoundation, ApplicationPlatform}
        ApplicationFoundation False 7.0.5493.35504 {ApplicationPlatform}
        ApplicationFoundationFormAdaptor False 7.0.4841.35227 {ApplicationPlatform, ApplicationFoundation, TestE...
        Custom True 10.0.0.0 {ApplicationPlatform}
         
    .EXAMPLE
        PS C:\> Get-D365Module -ExcludeBinaryModules
         
        Outputs the all packages / modules that are NOT binary.
        Will only include modules that is IsBinary = "False".
         
        A result set example:
         
        ModuleName IsBinary Version References
        ---------- -------- ------- ----------
        AccountsPayableMobile False 10.0.9107.14827 {ApplicationFoundation, ApplicationPlatform, Appli...
        ApplicationCommon False 10.0.8008.26462 {ApplicationFoundation, ApplicationPlatform}
        ApplicationFoundation False 7.0.5493.35504 {ApplicationPlatform}
        ApplicationFoundationFormAdaptor False 7.0.4841.35227 {ApplicationPlatform, ApplicationFoundation, TestE...
         
    .EXAMPLE
        PS C:\> Get-D365Module -Name "Application*Adaptor"
         
        Shows the list of installed packages / modules where the name fits the search "Application*Adaptor".
         
        A result set example:
         
        ModuleName IsBinary Version References
        ---------- -------- ------- ----------
        ApplicationFoundationFormAdaptor False 7.0.4841.35227 {ApplicationPlatform, ApplicationFoundation, TestE...
        ApplicationPlatformFormAdaptor False 7.0.4841.35227 {ApplicationPlatform, TestEssentials}
        ApplicationSuiteFormAdaptor False 10.0.9107.14827 {ApplicationFoundation, ApplicationPlatform, Appli...
        ApplicationWorkspacesFormAdaptor False 10.0.9107.14827 {ApplicationFoundation, ApplicationPlatform, Appli...
         
    .NOTES
        Tags: PackagesLocalDirectory, Servicing, Model, Models, Package, Packages
         
        Author: M�tz Jensen (@Splaxi)
         
        The cmdlet supports piping and can be used in advanced scenarios. See more on github and the wiki pages.
         
#>

function Get-D365Module {
    [CmdletBinding()]
    param (
        [string] $Name = "*",

        [switch] $ExcludeBinaryModules,

        [string] $BinDir = "$Script:BinDir\bin",

        [string] $PackageDirectory = $Script:PackageDirectory
    )

    begin {
        [System.Collections.ArrayList] $Files2Process = New-Object -TypeName "System.Collections.ArrayList"
        
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.AX.Metadata.Management.Delta.dll"))
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.AX.Metadata.Management.Diff.dll"))
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.AX.Metadata.Management.Merge.dll"))
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.AX.Metadata.Management.Core.dll"))
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.AX.Metadata.dll"))
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.AX.Metadata.Core.dll"))
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.AX.Metadata.Storage.dll"))
        $null = $Files2Process.Add((Join-Path $BinDir "Microsoft.Dynamics.ApplicationPlatform.XppServices.Instrumentation.dll"))

        Import-AssemblyFileIntoMemory -Path $($Files2Process.ToArray())
    }

    process {
        if (Test-PSFFunctionInterrupt) { return }

        Write-PSFMessage -Level Verbose -Message "Intializing RuntimeProvider."

        $runtimeProviderConfiguration = New-Object Microsoft.Dynamics.AX.Metadata.Storage.Runtime.RuntimeProviderConfiguration -ArgumentList $Script:PackageDirectory
        $metadataProviderFactoryViaRuntime = New-Object Microsoft.Dynamics.AX.Metadata.Storage.MetadataProviderFactory
        $metadataProviderViaRuntime = $metadataProviderFactoryViaRuntime.CreateRuntimeProvider($runtimeProviderConfiguration)

        Write-PSFMessage -Level Verbose -Message "MetadataProvider initialized." -Target $metadataProviderViaRuntime

        $modules = $metadataProviderViaRuntime.ModelManifest.ListModules()
        $modules | ForEach-Object {
            $_ | Add-Member -MemberType NoteProperty -Name 'IsBinary' -Value $false
        }

        Write-PSFMessage -Level Verbose -Message "Testing if the cmdlet is running on a OneBox or not." -Target $Script:IsOnebox
    
        if ($Script:IsOnebox) {
            Write-PSFMessage -Level Verbose -Message "Machine is onebox. Initializing DiskProvider too."

            $diskProviderConfiguration = New-Object Microsoft.Dynamics.AX.Metadata.Storage.DiskProvider.DiskProviderConfiguration
            $diskProviderConfiguration.AddMetadataPath($PackageDirectory)
            $metadataProviderFactoryViaDisk = New-Object Microsoft.Dynamics.AX.Metadata.Storage.MetadataProviderFactory
            $metadataProviderViaDisk = $metadataProviderFactoryViaDisk.CreateDiskProvider($diskProviderConfiguration)

            Write-PSFMessage -Level Verbose -Message "MetadataProvider initialized." -Target $metadataProviderViaDisk

            $diskModules = $metadataProviderViaDisk.ModelManifest.ListModules()

            foreach($module in $modules) {
                if ($diskModules.Name -NotContains $module.Name) {
                    $module.IsBinary = $true
                }
            }
        }

        if($ExcludeBinaryModules -eq $true){
            $modules = $modules | Where-Object IsBinary -eq $false
        }

        Write-PSFMessage -Level Verbose -Message "Looping through all modules."

        foreach ($obj in $($modules | Sort-Object Name)) {
            Write-PSFMessage -Level Verbose -Message "Filtering out all modules that doesn't match the model search." -Target $obj
            if ($obj.Name -NotLike $Name) { continue }

            $res = [Ordered]@{
                Module = $obj.Name
                ModuleName = $obj.Name
                IsBinary = $obj.IsBinary
                PSTypeName = 'D365FO.TOOLS.ModuleInfo'
            }

            $modulepath = Join-Path (Join-Path $PackageDirectory $obj.Name) "bin"

            if (Test-Path -Path $modulepath -PathType Container) {
                $fileversion = Get-FileVersion -Path (Get-ChildItem $modulepath -Filter "Dynamics.AX.$($obj.Name).dll").FullName
                $version = $fileversion.FileVersion
                $versionUpdated = $fileversion.FileVersionUpdated
            }
            else {
                $version = ""
                $versionUpdated = ""
            }

            $res.Version = $version
            $res.VersionUpdated = $versionUpdated
            $res.References = $obj.References
            
            [PSCustomObject]$res
        }
    }
}


<#
    .SYNOPSIS
        Gets the registered offline administrator e-mail configured
         
    .DESCRIPTION
        Get the registered offline administrator from the "DynamicsDevConfig.xml" file located in the default Package Directory
         
    .EXAMPLE
        PS C:\> Get-D365OfflineAuthenticationAdminEmail
         
        Will read the DynamicsDevConfig.xml and display the registered Offline Administrator E-mail address.
         
    .NOTES
        Tags: Development, Email, DynamicsDevConfig, Offline, Authentication
         
        This cmdlet is inspired by the work of "Sheikh Sohail Hussain" (twitter: @SSohailHussain)
         
        His blog can be found here:
        http://d365technext.blogspot.com
         
        The specific blog post that we based this cmdlet on can be found here:
        http://d365technext.blogspot.com/2018/07/offline-authentication-admin-email.html
#>

function Get-D365OfflineAuthenticationAdminEmail {
    [CmdletBinding()]
    param ()

    $filePath = Join-Path (Join-Path $Script:PackageDirectory "bin") "DynamicsDevConfig.xml"

    if(-not (Test-PathExists -Path $filePath -Type Leaf)) {return}

    $namespace = @{ns="http://schemas.microsoft.com/dynamics/2012/03/development/configuration"}
    $OfflineAuthAdminEmail = Select-Xml -XPath "/ns:DynamicsDevConfig/ns:OfflineAuthenticationAdminEmail" -Path $filePath -Namespace $namespace

    $AdminEmail = $OfflineAuthAdminEmail.Node.InnerText
    [PSCustomObject] @{Email = $AdminEmail}
}


<#
    .SYNOPSIS
        Get the details from an axscdppkg file
         
    .DESCRIPTION
        Get the details from an axscdppkg file by extracting it like a zip file.
         
        Capable of extracting the manifest details from the inner packages as well
         
    .PARAMETER Path
        Path to the axscdppkg file you want to analyze
         
    .PARAMETER ExtractionPath
        Path where you want the cmdlet to work with extraction of all the files
         
        Default value is: C:\Users\Username\AppData\Local\Temp
         
    .PARAMETER KB
        KB number of the hotfix that you are looking for
         
        Accepts wildcards for searching. E.g. -KB "4045*"
         
        Default value is "*" which will search for all KB's
         
    .PARAMETER Hotfix
        Package Id / Hotfix number the hotfix that you are looking for
         
        Accepts wildcards for searching. E.g. -Hotfix "7045*"
         
        Default value is "*" which will search for all hotfixes
         
    .PARAMETER Traverse
        Switch to instruct the cmdlet to traverse the inner packages and extract their details
         
    .PARAMETER KeepFiles
        Switch to instruct the cmdlet to keep the files for further manual analyze
         
    .PARAMETER IncludeRawManifest
        Switch to instruct the cmdlet to include the raw content of the manifest file
         
        Only works with the -Traverse option
         
    .EXAMPLE
        PS C:\> Get-D365PackageBundleDetail -Path "c:\temp\HotfixPackageBundle.axscdppkg" -Traverse
         
        This will extract all the content from the "HotfixPackageBundle.axscdppkg" file and extract all inner packages. For each inner package it will find the manifest file and fetch the KB numbers. The raw manifest file content is included to be analyzed.
         
    .EXAMPLE
        PS C:\> Get-D365PackageBundleDetail -Path "c:\temp\HotfixPackageBundle.axscdppkg" -ExtractionPath C:\Temp\20180905 -Traverse -KeepFiles
         
        This will extract all the content from the "HotfixPackageBundle.axscdppkg" file and extract all inner packages. It will extract the content into C:\Temp\20180905 and keep the files after completion.
         
    .EXAMPLE
        PS C:\> Get-D365PackageBundleDetail -Path C:\temp\HotfixPackageBundle.axscdppkg -Traverse -IncludeRawManifest
         
        This is an advanced scenario.
         
        This will traverse the "HotfixPackageBundle.axscdppkg" file and will include the raw manifest file details in the output.
         
    .EXAMPLE
        PS C:\> Get-D365PackageBundleDetail -Path C:\temp\HotfixPackageBundle.axscdppkg -Traverse -IncludeRawManifest | ForEach-Object {$_.RawManifest | Out-File "C:\temp\$($_.PackageId).txt"}
         
        This is an advanced scenario.
         
        This will traverse the "HotfixPackageBundle.axscdppkg" file and save the manifest files into c:\temp. Everything else is omitted and cleaned up.
         
    .NOTES
        Tags: Hotfix, KB, Manifest, HotfixPackageBundle, axscdppkg, Package, Bundle, Deployable
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-D365PackageBundleDetail {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $True)]
        [Alias('File')]
        [string] $Path,

        [string] $ExtractionPath = ([System.IO.Path]::GetTempPath()),

        [string] $KB = "*",

        [string] $Hotfix = "*",

        [switch] $Traverse,

        [switch] $KeepFiles,

        [switch] $IncludeRawManifest
    )
    
    begin {
        Invoke-TimeSignal -Start

        if (!(Test-Path -Path $Path -PathType Leaf)) {
            Write-PSFMessage -Level Host -Message "The <c='em'>$Path</c> file wasn't found. Please ensure the file <c='em'>exists </c> and you have enough <c='em'>permission/c> to access the file."
            Stop-PSFFunction -Message "Stopping because a file is missing."
            return
        }

        Unblock-File -Path $Path

        if(!(Test-Path -Path $ExtractionPath)) {
            Write-PSFMessage -Level Verbose -Message "The extract path didn't exists. Creating it." -Target $ExtractionPath
            $null = New-Item -Path $ExtractionPath -Force -ItemType Directory
        }

        if ($Path -notlike "*.zip") {
            $tempPathZip = Join-Path $ExtractionPath "$($(New-Guid).ToString()).zip"

            Write-PSFMessage -Level Verbose -Message "The file isn't a zip file. Copying the file to $tempPathZip" -Target $tempPathZip
            Copy-Item -Path $Path -Destination $tempPathZip -Force
            $Path = $tempPathZip
        }

        $packageTemp = Join-Path $ExtractionPath ((Get-Random -Maximum 99999).ToString())

        $oldprogressPreference = $global:progressPreference
        $global:progressPreference = 'silentlyContinue'

    }
    
    process {
        if (Test-PSFFunctionInterrupt) {return}

        Write-PSFMessage -Level Verbose -Message "Extracting the zip file to $packageTemp" -Target $packageTemp
        Expand-Archive -Path $Path -DestinationPath $packageTemp

        if ($Traverse) {
            $files = Get-ChildItem -Path $packageTemp -Filter "*.axscdp"
            
            foreach ($item in $files) {
                $filename = [System.IO.Path]::GetFileNameWithoutExtension($item.Name)
                $tempFile = Join-Path $packageTemp "$filename.zip"
        
                Write-PSFMessage -Level Verbose -Message "Coping $($item.FullName) to $tempFile" -Target $tempFile
                Copy-Item -Path $item.FullName -Destination $tempFile

                $tempDir = (Join-Path $packageTemp ($filename.Replace("DynamicsAX_", "")))
                $null = New-Item -Path $tempDir -ItemType Directory -Force

                Write-PSFMessage -Level Verbose -Message "Extracting the zip file $tempFile to $tempDir" -Target $tempDir
                Expand-Archive -Path $tempFile -DestinationPath $tempDir
            }

            $manifestFiles = Get-ChildItem -Path $packageTemp -Recurse -Filter "PackageManifest.xml"

            $namespace = @{ns = "http://schemas.datacontract.org/2004/07/Microsoft.Dynamics.AX.Servicing.SCDP.Packaging";
                           nsKB = "http://schemas.microsoft.com/2003/10/Serialization/Arrays"}

            Write-PSFMessage -Level Verbose -Message "Getting all the information from the manifest file"

            foreach ($item in $manifestFiles) {
                $raw = (Get-Content -Path $item.FullName) -join [Environment]::NewLine
                $xmlDoc = [xml]$raw
                $kbs = Select-Xml -Xml $xmlDoc -XPath "//ns:UpdatePackageManifest/ns:KBNumbers/nsKB:string" -Namespace $namespace
                $packageId = Select-Xml -Xml $xmlDoc -XPath "//ns:UpdatePackageManifest/ns:PackageId/ns:PackageId" -Namespace $namespace
                
                $strPackage = $packageId.Node.InnerText
                $arrKbs = $kbs.node.InnerText

                if($packageId.Node.InnerText -notlike $Hotfix) {continue}
                if(@($arrKbs) -notlike $KB) {continue} #* Search across an array with like

                $Obj = [PSCustomObject]@{Hotfix = $strPackage
                KBs = ($arrKbs -Join ";")}

                if($IncludeRawManifest) {$Obj.RawManifest = $raw}

                $Obj | Select-PSFObject -TypeName "D365FO.TOOLS.PackageBundleManifestDetail"
            }
        }
        else {
            Get-ChildItem -Path $packageTemp -Filter "*.*" | Select-PSFObject -TypeName "D365FO.TOOLS.PackageBundleDetail" "BaseName as Name"
        }
    }
    
    end {
        if(!$Keepfiles) {
            Remove-Item -Path $packageTemp -Recurse -Force -ErrorAction SilentlyContinue

            if(![system.string]::IsNullOrEmpty($tempPathZip)) {
                Remove-Item -Path $tempPathZip -Recurse -Force -ErrorAction SilentlyContinue
            }
        }

        $global:progressPreference = $oldprogressPreference

        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Get label file from a package
         
    .DESCRIPTION
        Get label file (resource file) from the package directory
         
    .PARAMETER PackageDirectory
        Path to the package that you want to get a label file from
         
    .PARAMETER Name
        Name of the label file you are looking for
         
        Accepts wildcards for searching. E.g. -Name "Fixed*Accounting"
         
        Default value is "*" which will search for all label files
         
    .PARAMETER Language
        The language of the label file you are looking for
         
        Accepts wildcards for searching. E.g. -Language "en*"
         
        Default value is "en-US" which will search for en-US language files
         
    .EXAMPLE
        PS C:\> Get-D365PackageLabelFile -PackageDirectory "C:\AOSService\PackagesLocalDirectory\ApplicationSuite"
         
        Shows all the label files for ApplicationSuite package
         
    .EXAMPLE
        PS C:\> Get-D365PackageLabelFile -PackageDirectory "C:\AOSService\PackagesLocalDirectory\ApplicationSuite" -Name "Fixed*Accounting"
         
        Shows the label files for ApplicationSuite package where the name fits the search "Fixed*Accounting"
         
    .EXAMPLE
        PS C:\> Get-D365InstalledPackage -Name "ApplicationSuite" | Get-D365PackageLabelFile
         
        Shows all label files (en-US) for the ApplicationSuite package
         
    .NOTES
        Tags: PackagesLocalDirectory, Label, Labels, Language, Development, Servicing, Module, Package, Packages
         
        Author: M�tz Jensen (@Splaxi)
         
        The cmdlet supports piping and can be used in advanced scenarios. See more on github and the wiki pages.
         
#>

function Get-D365PackageLabelFileOld {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Default', ValueFromPipelineByPropertyName = $true, Position = 1 )]
        [Parameter(Mandatory = $true, ParameterSetName = 'Specific', Position = 1 )]
        [Alias('Path')]
        [string] $PackageDirectory,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 2 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 2 )]
        [string] $Name = "*",

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 3 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 3 )]
        [string] $Language = "en-US"
    )

    BEGIN {}

    PROCESS {
        $Path = $PackageDirectory

        if (Test-Path "$Path\Resources\$Language") {
        
            $files = Get-ChildItem -Path ("$Path\Resources\$Language\*.resources.dll")

            foreach ($obj in $files) {
                if ($obj.Name.Replace(".resources.dll", "") -NotLike $Name) { continue }
                [PSCustomObject]@{
                    LabelName    = ($obj.Name).Replace(".resources.dll", "")
                    LanguageName = (Get-Command $obj.FullName).FileVersionInfo.Language
                    Language     = $obj.directory.basename
                    FilePath     = $obj.FullName
                }
            }
        }
        else {
            Write-PSFMessage -Level Verbose -Message "Skipping `"$("$Path\Resources\$Language")`" because it doesn't exist."
        }
    }

    END {}
}


<#
    .SYNOPSIS
        Returns information about D365FO
         
    .DESCRIPTION
        Gets detailed information about application and platform
         
    .EXAMPLE
        PS C:\> Get-D365ProductInformation
         
        This will get product, platform and application version details for the environment
         
    .NOTES
        Tags: Build, Version, Reference, ProductVersion, ProductDetails, Product
         
        Author: Rasmus Andersen (@ITRasmus)
         
        The cmdlet wraps the call against a dll file that is shipped with Dynamics 365 for Finance & Operations.
        The call to the dll file gets all relevant product details for the environment.
         
         
#>

function Get-D365ProductInformation {
    [CmdletBinding()]
    param ()
    
    return Get-ProductInfoProvider
}


<#
    .SYNOPSIS
        Get the thumbprint from the RSAT certificate
         
    .DESCRIPTION
        Locate the thumbprint for the certificate created during the RSAT installation
         
    .EXAMPLE
        PS C:\> Get-D365RsatCertificateThumbprint
         
        This will locate any certificates that has 127.0.0.1 in its name.
        It will show the subject and the thumbprint values.
         
    .NOTES
        Tags: RSAT, Certificate, Testing, Regression Suite Automation Test, Regression, Test, Automation.
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Get-D365RsatCertificateThumbprint {
    [CmdletBinding()]
    [OutputType()]
    param ( )
    
    Get-ChildItem -Path Cert:\LocalMachine\My | Where-Object Subject -like "*127.0.0.1*" | Format-Table Thumbprint, Subject, FriendlyName, NotAfter
}


<#
    .SYNOPSIS
        Get the RSAT playback files
         
    .DESCRIPTION
        Get all the RSAT playback files from the last executions
         
    .PARAMETER Path
        The path where the RSAT tool will be writing the files
         
        The default path is:
        "C:\Users\USERNAME\AppData\Roaming\regressionTool\playback"
         
    .PARAMETER Name
        Name of Test Case that you are looking for
         
        Default value is "*" which will search for all Test Cases and their corresponding files
         
    .PARAMETER ExecutionUsername
        Name of the user account has been running the RSAT tests on a machine that isn't the same as the current user
         
        Will enable you to log on to RSAT server that is running the tests from a console, automated, and is other account than the current user
         
    .EXAMPLE
        PS C:\> Get-D365RsatPlaybackFile
         
        This will get all the RSAT playback files.
        It will search for the files in the current user AppData system folder.
         
    .EXAMPLE
        PS C:\> Get-D365RsatPlaybackFile -Name *4080*
         
        This will get all the RSAT playback files which has "4080" as part of its name.
        It will search for the files in the current user AppData system folder.
         
    .EXAMPLE
        PS C:\> Get-D365RsatPlaybackFile -ExecutionUsername RSAT-ServiceAccount
         
        This will get all the RSAT playback files that were executed by the RSAT-ServiceAccount user.
        It will search for the files in the RSAT-ServiceAccount user AppData system folder.
         
    .NOTES
        Tags: RSAT, Testing, Regression Suite Automation Test, Regression, Test, Automation, Playback
         
        Author: M�tz Jensen (@Splaxi)
#>


function Get-D365RsatPlaybackFile {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    [OutputType()]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = "Default")]
        [string] $Path = $Script:RsatplaybackPath,
        
        [Parameter(Mandatory = $false)]
        [string] $Name = "*",

        [Parameter(Mandatory = $false, ParameterSetName = "ExecutionUser")]
        [string] $ExecutionUsername
    )
    
    if ($PSCmdlet.ParameterSetName -eq "ExecutionUser") {
        $Path = $Path.Replace("$($env:UserName)", $ExecutionUsername)

        if (-not (Test-PathExists -Path $Path -Type Container)) { return }
    }

    Get-ChildItem -Path $Path -Recurse | Where-Object { $_.Name -like $Name } | Select-PSFObject "LastWriteTime as LastRuntime", "Name as Filename", "Fullname as File"
}


<#
    .SYNOPSIS
        Get the SOAP hostname for the D365FO environment
         
    .DESCRIPTION
        Get the SOAP hostname from the IIS configuration, to be used during the Rsat configuration
         
    .EXAMPLE
        PS C:\> Get-D365RsatSoapHostname
         
        This will get the SOAP hostname from IIS.
        It will display the SOAP URL / URI correctly formatted, to be used during the configuration of Rsat.
         
    .NOTES
        Tags: RSAT, Testing, Regression Suite Automation Test, Regression, Test, Automation, SOAP
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Get-D365RsatSoapHostname {
    [CmdletBinding()]
    [OutputType()]
    param ()

    [PSCustomObject]@{
        SoapHostname = (Get-WebBinding | Where-Object bindingInformation -like *soap*).bindingInformation.Replace("*:443:", "")
    }
}


<#
    .SYNOPSIS
        Get a Dynamics 365 Runbook
         
    .DESCRIPTION
        Get the full path and filename of a Dynamics 365 Runbook
         
    .PARAMETER Path
        Path to the folder containing the runbook files
         
        The default path is "InstallationRecord" which is normally located on the "C:\DynamicsAX\InstallationRecords"
         
    .PARAMETER Name
        Name of the runbook file that you are looking for
         
        The parameter accepts wildcards. E.g. -Name *hotfix-20181024*
         
    .PARAMETER Latest
        Instruct the cmdlet to only get the latest runbook file, based on the last written attribute
         
    .EXAMPLE
        PS C:\> Get-D365Runbook
         
        This will list all runbooks that are available in the default location.
         
    .EXAMPLE
        PS C:\> Get-D365Runbook -Latest
         
        This will get the latest runbook file from the default InstallationRecords directory on the machine.
         
    .EXAMPLE
        PS C:\> Get-D365Runbook -Latest | Invoke-D365RunbookAnalyzer
         
        This will find the latest runbook file and have it analyzed by the Invoke-D365RunbookAnalyzer cmdlet to output any error details.
         
    .EXAMPLE
        PS C:\> Get-D365Runbook -Latest | Invoke-D365RunbookAnalyzer | Out-File "C:\Temp\d365fo.tools\runbook-analyze-results.xml"
         
        This will find the latest runbook file and have it analyzed by the Invoke-D365RunbookAnalyzer cmdlet to output any error details.
        The output will be saved into the "C:\Temp\d365fo.tools\runbook-analyze-results.xml" file.
         
    .EXAMPLE
        PS C:\> Get-D365Runbook | Backup-D365Runbook
         
        This will save a copy of all runbooks from the default location and save them to "c:\temp\d365fo.tools\runbookbackups"
         
    .EXAMPLE
        PS C:\> notepad.exe (Get-D365Runbook -Latest).File
         
        This will find the latest runbook file and open it with notepad.
         
    .NOTES
        Tags: Runbook, Servicing, Hotfix, DeployablePackage, Deployable Package, InstallationRecordsDirectory, Installation Records Directory
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-D365Runbook {
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true)]
        [string] $Path = (Join-Path $Script:InstallationRecordsDir "Runbooks"),

        [string] $Name = "*",

        [switch] $Latest
    )

    begin {
        if (-not (Test-PathExists -Path $Path -Type Container -WarningAction $WarningPreference -ErrorAction $ErrorActionPreference)) { return }
    }
    
    process {
        if (Test-PSFFunctionInterrupt) { return }

        $files = Get-ChildItem -Path "$Path\*.xml" | Sort-Object -Descending { $_.LastWriteTime }

        if ($Latest) {
            $obj = $files | Select-Object -First 1

            $obj | Select-PSFObject "Name as Filename", "LastWriteTime as LastModified", "Fullname as File"
        }
        else {
            foreach ($obj in $files) {
                if ($obj.Name -NotLike $Name) { continue }

                $obj | Select-PSFObject "Name as Filename", "LastWriteTime as LastModified", "Fullname as File"
            }
        }
    }
}


<#
    .SYNOPSIS
        Get runbook id
         
    .DESCRIPTION
        Get the runbook id from inside a runbook file
         
    .PARAMETER Path
        Path to the runbook file that you want to analyse
         
        Accepts value from pipeline, also by property
         
    .EXAMPLE
        PS C:\> Get-D365RunbookId -Path "C:\DynamicsAX\InstallationRecords\Runbooks\Runbook.xml"
         
        This will inspect the Runbook.xml file and output the runbookid from inside the XML document.
         
    .EXAMPLE
        PS C:\> Get-D365Runbook | Get-D365RunbookId
         
        This will find all runbook file(s) and have them analyzed by the Get-D365RunbookId cmdlet to output the runbookid(s).
         
    .EXAMPLE
        PS C:\> Get-D365Runbook -Latest | Get-D365RunbookId
         
        This will find the latest runbook file and have it analyzed by the Get-D365RunbookId cmdlet to output the runbookid.
         
    .NOTES
        Tags: Runbook, Analyze, RunbookId, Runbooks
         
        Author: M�tz Jensen (@Splaxi)
#>


function Get-D365RunbookId {
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true)]
        [Alias('File')]
        [string] $Path
    )

    process {
        if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }

        [xml]$xmlRunbook = Get-Content $Path

        [PSCustomObject]@{
            RunbookId = $xmlRunbook.SelectSingleNode("/RunbookData/RunbookID")."#text"
        }
    }
}


<#
    .SYNOPSIS
        Get log file from a Runbook step
         
    .DESCRIPTION
        Get the log files for a specific Runbook step
         
    .PARAMETER Path
        Path to Software Deployable Package that was run in connection with the runbook
         
    .PARAMETER Step
        Step id for the step that you want to locate the log files for
         
    .PARAMETER Latest
        Instruct the cmdlet to only work with the latest log file
         
        Is based on the last written attribute on the log file
         
    .PARAMETER OpenInEditor
        Instruct the cmdlet to open the log file in the default text editor
         
    .EXAMPLE
        PS C:\> Get-D365RunbookLogFile -Path "C:\Temp\PU35" -Step 34
         
        This will locate all logfiles that has been outputted from the Step 34 from the PU35 installation.
        The output will list the complete path to the log files.
         
        An output example:
         
        Filename : AutoUpdateDIXFService.ps1-2020-07-8--12-40-34.log
        LastModified : 8/7/2020 12:40:34 PM
        File : C:\Temp\PU35\RunbookWorkingFolder\Runbook\MININT-F36S5EH\DIXFService\34\Log\AutoUpdateDIXFService.ps1-2020-07-8--12-40-34.log
         
        Filename : AutoUpdateDIXFService.ps1-2020-07-8--12-36-22.log
        LastModified : 8/7/2020 12:36:22 PM
        File : C:\Temp\PU35\RunbookWorkingFolder\Runbook\MININT-F36S5EH\DIXFService\34\Log\AutoUpdateDIXFService.ps1-2020-07-8--12-36-22.log
         
        Filename : AutoUpdateDIXFService.ps1-2020-05-8--19-15-07.log
        LastModified : 8/5/2020 7:15:07 PM
        File : C:\Temp\PU35\RunbookWorkingFolder\Runbook\MININT-F36S5EH\DIXFService\34\Log\AutoUpdateDIXFService.ps1-2020-05-8--19-15-07.log
         
    .EXAMPLE
        PS C:\> Get-D365RunbookLogFile -Path "C:\Temp\PU35" -Step 34 -Latest
         
        This will locate all logfiles that has been outputted from the Step 34 from the PU35 installation.
        The output will be limited to the latest log, based on last write time.
        The output will list the complete path to the log file.
         
        An output example:
         
        Filename : AutoUpdateDIXFService.ps1-2020-07-8--12-40-34.log
        LastModified : 8/7/2020 12:40:34 PM
        File : C:\Temp\PU35\RunbookWorkingFolder\Runbook\MININT-F36S5EH\DIXFService\34\Log\AutoUpdateDIXFService.ps1-2020-07-8--12-40-34.log
         
    .EXAMPLE
        PS C:\> Get-D365RunbookLogFile -Path "C:\Temp\PU35" -Step 34 -OpenInEditor
         
        This will locate all logfiles that has been outputted from the Step 34 from the PU35 installation.
        The Get-D365RunbookLogFile will open all log files in the default text editor.
         
    .EXAMPLE
        PS C:\> Get-D365RunbookLogFile -Path "C:\Temp\PU35" -Step 34 -Latest -OpenInEditor
         
        This will locate all logfiles that has been outputted from the Step 34 from the PU35 installation.
        The output will be limited to the latest log, based on last write time.
        The Get-D365RunbookLogFile will open the log file in the default text editor.
         
    .EXAMPLE
        PS C:\> Get-D365Runbook -Latest | Invoke-D365RunbookAnalyzer -FailedOnlyAsObjects | Get-D365RunbookLogFile -Path "C:\Temp\PU35" -OpenInEditor
         
        This will find the latest runbook file and have it analyzed by the Invoke-D365RunbookAnalyzer cmdlet to output any error details.
        The output from Invoke-D365RunbookAnalyzer will only contain failed steps.
        The Get-D365RunbookLogFile will open all log files for the failed step.
         
    .NOTES
        Tags: Runbook, Servicing, Hotfix, DeployablePackage, Deployable Package, InstallationRecordsDirectory, Installation Records Directory
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-D365RunbookLogFile {
    [CmdletBinding()]
    [OutputType('System.String')]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true)]
        [Alias('File')]
        [string] $Path,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true)]
        [Alias('StepId')]
        [string] $Step,

        [switch] $Latest,

        [switch] $OpenInEditor

    )
    
    process {
        if (-not (Test-PathExists -Path $Path -Type Container)) { return }

        $stepPath = Get-ChildItem -Path $Path -Filter $step -Recurse -Directory | Select-Object -First 1

        if ($null -eq $stepPath) {
            $messageString = "Couldn't locate a folder with the specified step id."
            Write-PSFMessage -Level Host -Message $messageString
            Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
        }

        $files = @(Get-ChildItem -Path "$($stepPath.FullName)\Log\*.log" -Recurse | Sort-Object -Descending { $_.LastWriteTime })

        if ($files.Count -lt 1) {
            $messageString = "Couldn't locate any log files in the folder associated with the step."
            Write-PSFMessage -Level Host -Message $messageString
            Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
        }

        if ($Latest) {
            $files = $files | Select-Object -First 1
        }
        
        foreach ($obj in $files) {
            $obj | Select-PSFObject "Name as Filename", "LastWriteTime as LastModified", "Fullname as File" -TypeName "D365FO.TOOLS.FileObject"
        }

        if($OpenInEditor) {
            foreach ($obj in $files) {
                & "$($obj.Fullname)"
            }
        }
    }
}


<#
    .SYNOPSIS
        Get the cleanup retention period
         
    .DESCRIPTION
        Gets the configured retention period before updates are deleted
         
    .EXAMPLE
        PS C:\> Get-D365SDPCleanUp
         
        This will get the configured retention period from the registry
         
    .NOTES
        Tags: CleanUp, Retention, Servicing, Cut Off, DeployablePackage, Deployable Package
         
        Author: M�tz Jensen (@Splaxi)
         
        This cmdlet is based on the findings from Alex Kwitny (@AlexOnDAX)
         
        See his blog for more info:
        http://www.alexondax.com/2018/04/msdyn365fo-how-to-adjust-your.html
         
#>

function Get-D365SDPCleanUp {
    [CmdletBinding()]
    param (
        
    )
    
    $RegSplat = @{
        Path = "HKLM:\SOFTWARE\Microsoft\Dynamics\Deployment\"
        Name = "CutoffDaysForCleanup"
    }
    
    [PSCustomObject] @{
        CutoffDaysForCleanup = $( if (Test-RegistryValue @RegSplat) {Get-ItemPropertyValue @RegSplat} else {""} )
    }
}


<#
    .SYNOPSIS
        Get a table
         
    .DESCRIPTION
        Get a table either by TableName (wildcard search allowed) or by TableId
         
    .PARAMETER Name
        Name of the table that you are looking for
         
        Accepts wildcards for searching. E.g. -Name "Cust*"
         
        Default value is "*" which will search for all tables
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER Id
        The specific id for the table you are looking for
         
    .EXAMPLE
        PS C:\> Get-D365Table -Name CustTable
         
        Will get the details for the CustTable
         
    .EXAMPLE
        PS C:\> Get-D365Table -Id 10347
         
        Will get the details for the table with the id 10347.
         
    .NOTES
        Tags: Table, Tables, AOT, TableId, Development
         
        Author: M�tz Jensen (@splaxi)
         
        The cmdlet supports piping and can be used in advanced scenarios. See more on github and the wiki pages.
         
#>

function Get-D365Table {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 1 )]
        [string[]] $Name = "*",

        [Parameter(Mandatory = $true, ParameterSetName = 'TableId', Position = 1 )]
        [int] $Id,

        [Parameter(Mandatory = $false, Position = 2 )]
        [string] $DatabaseServer = $Script:DatabaseServer,

        [Parameter(Mandatory = $false, Position = 3 )]
        [string] $DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, Position = 4 )]
        [string] $SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, Position = 5 )]
        [string] $SqlPwd = $Script:DatabaseUserPassword
    )

    BEGIN {}

    PROCESS {

        $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

        $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
            SqlUser = $SqlUser; SqlPwd = $SqlPwd
        }

        $sqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection

        $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\get-tables.sql") -join [Environment]::NewLine

        $dataTable = New-Object system.Data.DataSet
        $dataAdapter = New-Object system.Data.SqlClient.SqlDataAdapter($sqlCommand)
        $dataAdapter.fill($dataTable) | Out-Null

        foreach ($localName in $Name) {
            if ($PSCmdlet.ParameterSetName -eq "Default") {
                foreach ($obj in $dataTable.Tables.Rows) {
                    if ($obj.AotName -NotLike $localName) { continue }
                    [PSCustomObject]@{
                        TableId   = $obj.TableId
                        TableName = $obj.AotName
                        SqlName   = $obj.SqlName
                    }
                }
            }
            else {
                $obj = $dataTable.Tables.Rows | Where-Object TableId -eq $Id | Select-Object -First 1
                [PSCustomObject]@{
                    TableId   = $obj.TableId
                    TableName = $obj.AotName
                    SqlName   = $obj.SqlName
                }
            }
        }
    }

    END {}
}


<#
    .SYNOPSIS
        Get a field from table
         
    .DESCRIPTION
        Get a field either by FieldName (wildcard search allowed) or by FieldId
         
    .PARAMETER TableId
        The id of the table that the field belongs to
         
    .PARAMETER Name
        Name of the field that you are looking for
         
        Accepts wildcards for searching. E.g. -Name "Account*"
         
        Default value is "*" which will search for all fields
         
    .PARAMETER FieldId
        Id of the field that you are looking for
         
        Type is integer
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER TableName
        Name of the table that the field belongs to
         
        Search will only return the first hit (unordered) and work against that hit
         
    .PARAMETER IncludeTableDetails
        Switch options to enable the result set to include extended details
         
    .PARAMETER SearchAcrossTables
        Switch options to force the cmdlet to search across all tables when looking for the field
         
    .EXAMPLE
        PS C:\> Get-D365TableField -TableId 10347
         
        Will get all field details for the table with id 10347.
         
    .EXAMPLE
        PS C:\> Get-D365TableField -TableName CustTable
         
        Will get all field details for the CustTable table.
         
    .EXAMPLE
        PS C:\> Get-D365TableField -TableId 10347 -FieldId 175
         
        Will get the details for the field with id 175 that belongs to the table with id 10347.
         
    .EXAMPLE
        PS C:\> Get-D365TableField -TableId 10347 -Name "VATNUM"
         
        Will get the details for the "VATNUM" that belongs to the table with id 10347.
         
    .EXAMPLE
        PS C:\> Get-D365TableField -TableId 10347 -Name "VAT*"
         
        Will get the details for all fields that fits the search "VAT*" that belongs to the table with id 10347.
         
    .EXAMPLE
        PS C:\> Get-D365TableField -Name AccountNum -SearchAcrossTables
         
        Will search for the AccountNum field across all tables.
         
    .EXAMPLE
        PS C:\> Get-D365TableField -TableName CustTable -IncludeTableDetails
         
        Will get all field details for the CustTable table.
        Will include table details in the output.
         
    .NOTES
        Tags: Table, Tables, Fields, TableField, Table Field, TableName, TableId
         
        Author: M�tz Jensen (@splaxi)
         
        The cmdlet supports piping and can be used in advanced scenarios. See more on github and the wiki pages.
         
         
#>

function Get-D365TableField {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Default', ValueFromPipelineByPropertyName = $true, Position = 1 )]
        [int] $TableId,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 2 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'TableName', Position = 2 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'SearchByNameForce', Position = 1 )]
        [string] $Name = "*",

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', ValueFromPipelineByPropertyName = $true, Position = 3 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'TableName', ValueFromPipelineByPropertyName = $true, Position = 3 )]
        [int] $FieldId,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 4 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'TableName', Position = 4 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'SearchByNameForce', Position = 3 )]
        [string] $DatabaseServer = $Script:DatabaseServer,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 5 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'TableName', Position = 5 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'SearchByNameForce', Position = 4 )]
        [string] $DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 6 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'TableName', Position = 6 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'SearchByNameForce', Position = 5 )]
        [string] $SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 7 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'TableName', Position = 7 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'SearchByNameForce', Position = 6 )]
        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [Parameter(Mandatory = $true, ParameterSetName = 'TableName', Position = 1 )]
        [string] $TableName,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default')]
        [Parameter(Mandatory = $false, ParameterSetName = 'TableName')]
        [switch] $IncludeTableDetails,

        [Parameter(Mandatory = $true, ParameterSetName = 'SearchByNameForce', Position = 2 )]
        [switch] $SearchAcrossTables
    )
    BEGIN {
        $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

        $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
            SqlUser = $SqlUser; SqlPwd = $SqlPwd
        }

        $sqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection

    }
    
    PROCESS {
        if ($PSCmdlet.ParameterSetName -eq "TableName") {
            $TableId = (Get-D365Table -Name $TableName | Select-Object -First 1).TableId
        }

        if ($SearchAcrossTables) {
            $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\get-alltablefields.sql") -join [Environment]::NewLine
        }
        else {
            $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\get-tablefields.sql") -join [Environment]::NewLine
            $null = $sqlCommand.Parameters.Add("@TableId", $TableId)
        }

        $dataTable = New-Object system.Data.DataSet
        $dataAdapter = New-Object system.Data.SqlClient.SqlDataAdapter($sqlCommand)
        $dataAdapter.fill($dataTable) | Out-Null

        foreach ($obj in $dataTable.Tables.Rows) {
            if ($obj.FieldId -eq 0) {
                $TableName = $obj.AotName

                continue
            }

            if ($PSBoundParameters.ContainsKey("FieldId")) {
                if ($obj.FieldId -NotLike $FieldId) { continue }
            }
            else {
                if ($obj.AotName -NotLike $Name) { continue }
            }
            $res = [PSCustomObject]@{
                FieldId   = $obj.FieldId
                FieldName = $obj.AotName
                SqlName   = $obj.SqlName
            }

            if ($IncludeTableDetails) {
                $res | Add-Member -MemberType NoteProperty -Name 'TableId' -Value $obj.TableId
                $res | Add-Member -MemberType NoteProperty -Name 'TableName' -Value $TableName
            }
            if ($SearchAcrossTables) {
                $res | Add-Member -MemberType NoteProperty -Name 'TableId' -Value $obj.TableId
            }

            $res
        }
    }

    END {}
}


<#
    .SYNOPSIS
        Get the sequence object for table
         
    .DESCRIPTION
        Get the sequence details for tables
         
    .PARAMETER TableName
        Name of the table that you want to work against
         
        Accepts wildcards for searching. E.g. -TableName "Cust*"
         
        Default value is "*" which will search for all tables
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .EXAMPLE
        PS C:\> Get-D365TableSequence | Format-Table
         
        This will get all the sequence details for all tables inside the database.
        It will format the output as a table for better overview.
         
    .EXAMPLE
        PS C:\> Get-D365TableSequence -TableName "Custtable" | Format-Table
         
        This will get the sequence details for the CustTable in the database.
        It will format the output as a table for better overview.
         
    .EXAMPLE
        PS C:\> Get-D365TableSequence -TableName "Cust*" | Format-Table
         
        This will get the sequence details for all tables that matches the search "Cust*" in the database.
        It will format the output as a table for better overview.
         
    .EXAMPLE
        PS C:\> Get-D365Table -Name CustTable | Get-D365TableSequence | Format-Table
         
        This will get the table details from the Get-D365Table cmdlet and pipe that into Get-D365TableSequence.
        This will get the sequence details for the CustTable in the database.
        It will format the output as a table for better overview.
         
    .NOTES
        Tags: Table, RecId, Sequence, Record Id
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Get-D365TableSequence {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true, Position = 1 )]
        [Alias('Name')]
        [string] $TableName = "*",

        [Parameter(Mandatory = $false, Position = 2 )]
        [string] $DatabaseServer = $Script:DatabaseServer,

        [Parameter(Mandatory = $false, Position = 3 )]
        [string] $DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, Position = 4 )]
        [string] $SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, Position = 5 )]
        [string] $SqlPwd = $Script:DatabaseUserPassword
    )
    BEGIN {}
    
    PROCESS {
        $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

        $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
            SqlUser = $SqlUser; SqlPwd = $SqlPwd
        }

        $SqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection
        $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\get-tablesequence.sql") -join [Environment]::NewLine
        $null = $sqlCommand.Parameters.AddWithValue('@TableName', $TableName.Replace("*", "%"))
        
        $datatable = New-Object system.Data.DataSet
        $dataadapter = New-Object system.Data.SqlClient.SqlDataAdapter($sqlcommand)
        $dataadapter.fill($datatable) | Out-Null

        foreach ($obj in $datatable.Tables.Rows) {
            $res = [PSCustomObject]@{
                SequenceName   = $obj.sequence_name
                TableName = $obj.table_name
                StartValue   = $obj.start_value
                Increment = $obj.increment
                MinimumValue = $obj.minimum_value
                MaximumValue = $obj.maximum_value
                IsCached = $obj.is_cached
                CacheSize = $obj.cache_size
                CurrentValue = $obj.current_value
            }

            $res
        }
    }

    END {}
}


<#
    .SYNOPSIS
        Get table that is taking part of Change Tracking
         
    .DESCRIPTION
        Get table(s) that is taking part of the SQL Server Change Tracking mechanism
         
    .PARAMETER Name
        Name of the table that you are looking for
         
        Accepts wildcards for searching. E.g. -Name "Cust*"
         
        Default value is "*" which will search for all tables
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .EXAMPLE
        PS C:\> Get-D365TablesInChangedTracking
         
        This will list all tables that are taking part in the SQL Server Change Tracking.
         
    .EXAMPLE
        PS C:\> Get-D365TablesInChangedTracking -Name CustTable
         
        This will search for a table in the list of tables that are taking part in the SQL Server Change Tracking.
        It will use the CustTable as the search pattern while searching for the table.
         
    .NOTES
        Tags: Table, Change Tracking, Tablename, DMF, DIXF
         
        Author: M�tz Jensen (@splaxi)
         
#>

function Get-D365TablesInChangedTracking {
    [CmdletBinding()]
    param (
        [string] $Name = "*",

        [string] $DatabaseServer = $Script:DatabaseServer,

        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword
    )

    PROCESS {

        $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

        $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
            SqlUser = $SqlUser; SqlPwd = $SqlPwd
        }

        $sqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection

        $commandText = (Get-Content "$script:ModuleRoot\internal\sql\get-tablesinchangedtracking.sql") -join [Environment]::NewLine
        $commandText = $commandText.Replace('@DATABASENAME', $DatabaseName)

        $sqlCommand.CommandText = $commandText

        $dataTable = New-Object system.Data.DataSet
        $dataAdapter = New-Object system.Data.SqlClient.SqlDataAdapter($sqlCommand)
        $dataAdapter.fill($dataTable) | Out-Null

        foreach ($obj in $dataTable.Tables.Rows) {
            if ($obj.name -NotLike $Name) { continue }

            [PSCustomObject]@{
                TableName = $obj.name
            }
        }
    }
}


<#
    .SYNOPSIS
        Get the TFS / VSTS registered URL / URI
         
    .DESCRIPTION
        Gets the URI from the configuration of the local tfs connection in visual studio
         
    .PARAMETER Path
        Path to the tf.exe file that the cmdlet will invoke
         
    .EXAMPLE
        PS C:\> Get-D365TfsUri
         
        This will invoke the default tf.exe client located in the Visual Studio 2015 directory
        and fetch the configured URI.
         
    .NOTES
        Tags: TFS, VSTS, URL, URI, Servicing, Development
         
        Author: M�tz Jensen (@Splaxi)
#>

function Get-D365TfsUri {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 1 )]
        [string]$Path = $Script:TfDir
    )
    
    $executable = Join-Path $Path "tf.exe"
    if (!(Test-PathExists -Path $executable -Type Leaf)) {return}

    Write-PSFMessage -Level Verbose -Message "Invoking tf.exe"
    #* Small hack to get the output from the execution into a variable.
    $res = & $executable "settings" "connections" 2>$null
    Write-PSFMessage -Level Verbose -Message "Result from tf.exe: $res" -Target $res

    if (![string]::IsNullOrEmpty($res)) {
        [PSCustomObject]@{
            TfsUri = $res[2].Split(" ")[0]
        }
    }
    else {
        Write-PSFMessage -Level Host -Message "No TFS / VSTS connections found. It looks like you haven't configured the server connection and workspace yet."
    }
}


<#
    .SYNOPSIS
        Get the TFS / VSTS registered workspace path
         
    .DESCRIPTION
        Gets the workspace path from the configuration of the local tfs in visual studio
         
    .PARAMETER Path
        Path to the directory where the Team Foundation Client executable is located
         
    .PARAMETER TfsUri
        Uri to the TFS / VSTS that the workspace is connected to
         
    .EXAMPLE
        PS C:\> Get-D365TfsWorkspace -TfsUri https://PROJECT.visualstudio.com
         
        This will invoke the default tf.exe client located in the Visual Studio 2015 directory
        and fetch the configured URI.
         
    .NOTES
        Tags: TFS, VSTS, URL, URI, Servicing, Development
         
        Author: M�tz Jensen (@Splaxi)
#>

function Get-D365TfsWorkspace {
    [CmdletBinding()]
    param (
        [string] $Path = $Script:TfDir,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string] $TfsUri = $Script:TfsUri
    )
    
    process {
        $executable = Join-Path $Path "tf.exe"
        if (!(Test-PathExists -Path $executable -Type Leaf)) { return }

        if ([system.string]::IsNullOrEmpty($TfsUri)) {
            Write-PSFMessage -Level Host -Message "The supplied uri <c='em'>was empty</c>. Please update the active d365 environment configuration or simply supply the -TfsUri to the cmdlet."
            Stop-PSFFunction -Message "Stopping because TFS URI is missing."
            return
        }

        Write-PSFMessage -Level Verbose -Message "Invoking tf.exe"
        #* Small hack to get the output from the execution into a variable.
        $res = & $executable "vc" "workspaces" "/collection:$TfsUri" "/format:detailed" 2>$null

        if (![string]::IsNullOrEmpty($res)) {
            [PSCustomObject]@{
                TfsWorkspacePath = ($res | select-string "meta").ToString().Trim().Split(" ")[1]
            }
        }
        else {
            Write-PSFMessage -Level Host -Message "No matching workspace configuration found for the specified URI. Either the URI is wrong or you haven't configured the server connection / workspace details correctly."
        }
    }
}


<#
    .SYNOPSIS
        Get a hashtable with all the stored parameters
         
    .DESCRIPTION
        Gets a hashtable with all the stored parameters to be used with Import-D365Bacpac or New-D365Bacpac for Tier 2 environments
         
    .PARAMETER OutputType
        Used to specify the desired object type of the output
         
        The default value is: HashTable
         
        Valid options are:
        HashTable
        PSCustomObject
         
    .EXAMPLE
        PS C:\> $params = Get-D365Tier2Params
         
        This will extract the stored parameters and create a hashtable object.
        The hashtable is assigned to the $params variable.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
#>


function Get-D365Tier2Params {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $false, Position = 1)]
        [ValidateSet("HashTable", "PSCustomObject")]
        [string] $OutputType = "HashTable"
       )

    $jsonString = Get-PSFConfigValue -FullName "d365fo.tools.tier2.bacpac.params"

    Write-PSFMessage -Level Verbose -Message "Retrieved json string" -Target $jsonString

    if($OutputType -eq "HashTable") {
        $jsonString | ConvertFrom-Json | ConvertTo-Hashtable
    }
    else {
        $jsonString | ConvertFrom-Json | ConvertTo-Hashtable | ConvertTo-PsCustomObject
    }
}


<#
    .SYNOPSIS
        Get the url for accessing the instance
         
    .DESCRIPTION
        Get the complete URL for accessing the Dynamics 365 Finance & Operations instance running on this machine
         
    .PARAMETER Force
        Switch to instruct the cmdlet to retrieve the name from the system files
        instead of the name stored in memory after loading this module.
         
    .EXAMPLE
        PS C:\> Get-D365Url
         
        This will get the correct URL to access the environment
         
    .NOTES
        Tags: URL, URI, Servicing
         
        Author: Rasmus Andersen (@ITRasmus)
         
        The cmdlet wraps the call against a dll file that is shipped with Dynamics 365 for Finance & Operations.
        The call to the dll file gets all registered URL for the environment.
         
#>

function Get-D365Url {
    [CmdletBinding()]
    param (
        [switch] $Force
    )
    
    if ($Force) {
        $Url = "https://$($(Get-D365EnvironmentSettings).Infrastructure.FullyQualifiedDomainName)"
    }
    else {
        $Url = $Script:Url
        
    }
    [PSCustomObject]@{
        Url = $Url
    }
}


<#
    .SYNOPSIS
        Get users from the environment
         
    .DESCRIPTION
        Get all relevant user details from the Dynamics 365 for Finance & Operations
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER Email
        The search string to select which user(s) should be updated
         
        The parameter supports wildcards. E.g. -Email "*@contoso.com*"
         
        Default value is "*" to get all users
         
    .PARAMETER ExcludeSystemUsers
        Instructs the cmdlet to filter out all known system users
         
    .EXAMPLE
        PS C:\> Get-D365User
         
        This will get all users from the environment.
         
    .EXAMPLE
        PS C:\> Get-D365User -ExcludeSystemUsers
         
        This will get all users from the environment, but filter out all known system user accounts.
         
    .EXAMPLE
        PS C:\> Get-D365User -Email "*contoso.com"
         
        This will search for all users with an e-mail address containing 'contoso.com' from the environment.
         
    .NOTES
        Tags: User, Users
         
        Author: M�tz Jensen (@Splaxi)
        Author: Rasmus Andersen (@ITRasmus)
#>

function Get-D365User {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false, Position = 1)]
        [string]$DatabaseServer = $Script:DatabaseServer,

        [Parameter(Mandatory = $false, Position = 2)]
        [string]$DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, Position = 3)]
        [string]$SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, Position = 4)]
        [string]$SqlPwd = $Script:DatabaseUserPassword,

        [Parameter(Mandatory = $false, Position = 5)]
        [string]$Email = "*",

        [switch]$ExcludeSystemUsers

    )

    $exclude = @("DAXMDSRunner.com", "dynamics.com")

    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

    $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd
    }

    $SqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection

    $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\get-user.sql") -join [Environment]::NewLine

    $null = $sqlCommand.Parameters.Add("@Email", $Email.Replace("*", "%"))

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()
    
        $reader = $sqlCommand.ExecuteReader()

        while ($reader.Read() -eq $true) {
            $res = [PSCustomObject]@{
                UserId           = "$($reader.GetString($($reader.GetOrdinal("ID"))))"
                Name             = "$($reader.GetString($($reader.GetOrdinal("NAME"))))"
                NetworkAlias     = "$($reader.GetString($($reader.GetOrdinal("NETWORKALIAS"))))"
                NetworkDomain    = "$($reader.GetString($($reader.GetOrdinal("NETWORKDOMAIN"))))"
                Sid              = "$($reader.GetString($($reader.GetOrdinal("SID"))))"
                IdentityProvider = "$($reader.GetString($($reader.GetOrdinal("IDENTITYPROVIDER"))))"
                Enabled          = [bool][int]"$($reader.GetInt32($($reader.GetOrdinal("ENABLE"))))"
                Email            = "$($reader.GetString($($reader.GetOrdinal("NETWORKALIAS"))))"
                Company          = "$($reader.GetString($($reader.GetOrdinal("COMPANY"))))"
            }

            if ($ExcludeSystemUsers) {
                $temp = $res.Email.Split("@")[1]
                if ($exclude -contains $temp) {
                    continue
                }
                elseif ($res.UserId -eq 'Guest') {
                    continue
                }
            }

            $res
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
    finally {
        $reader.close()

        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
    }
}


<#
    .SYNOPSIS
        Cmdlet used to get authentication details about a user
         
    .DESCRIPTION
        The cmdlet will take the e-mail parameter and use it to lookup all the needed details for configuring authentication against Dynamics 365 Finance & Operations
         
    .PARAMETER Email
        The e-mail address / login name of the user that the cmdlet must gather details about
         
    .EXAMPLE
        PS C:\> Get-D365UserAuthenticationDetail -Email "Claire@contoso.com"
         
        This will get all the authentication details for the user account with the email address "Claire@contoso.com"
         
    .NOTES
        Tags: User, Users, Security, Configuration, Authentication
         
        Author : Rasmus Andersen (@ITRasmus)
        Author : M�tz Jensen (@splaxi)
         
#>

function Get-D365UserAuthenticationDetail {
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string] $Email
    )

    process {
        $instanceProvider = Get-InstanceIdentityProvider

        [string]$identityProvider = Get-CanonicalIdentityProvider
    
        $networkDomain = Get-NetworkDomain $Email

        $instanceProviderName = $instanceProvider.TrimEnd('/')
        $instanceProviderName = $instanceProviderName.Substring($instanceProviderName.LastIndexOf('/') + 1)
        $instanceProviderIdentityProvider = Get-IdentityProvider "sample@$instanceProviderName"
        $emailIdentityProvider = Get-IdentityProvider $Email

        if ($instanceProviderIdentityProvider -ne $emailIdentityProvider) {
            $identityProvider = $emailIdentityProvider
        }

        $SID = Get-UserSIDFromAad $Email $identityProvider

        @{"SID"                = $SID
            "NetworkDomain"    = $networkDomain
            "IdentityProvider" = $identityProvider
            "InstanceProvider" = $instanceProvider
        }
    }
}


<#
    .SYNOPSIS
        Get the compiler outputs presented
         
    .DESCRIPTION
        Get the Visual Studio compiler outputs presented in a structured manner on the screen
         
    .PARAMETER Module
        Name of the module that you want to work against
         
        Default value is "*" which will search for all modules
         
    .PARAMETER ErrorsOnly
        Instructs the cmdlet to only output compile results where there was errors detected
         
    .PARAMETER OutputTotals
        Instructs the cmdlet to output the total errors and warnings after the analysis
         
    .PARAMETER OutputAsObjects
        Instructs the cmdlet to output the objects instead of formatting them
         
        If you don't assign the output, it will be formatted the same way as the original output, but without the coloring of the column values
         
    .PARAMETER PackageDirectory
        Path to the directory containing the installed package / module
         
        Default path is the same as the AOS service "PackagesLocalDirectory" directory
         
        Default value is fetched from the current configuration on the machine
         
    .EXAMPLE
        PS C:\> Get-D365VisualStudioCompilerResult
         
        This will return the compiler output for all modules.
         
        A result set example:
         
        File Warnings Errors
        ---- -------- ------
        K:\AosService\PackagesLocalDirectory\ApplicationCommon\BuildModelResult.log 55 0
        K:\AosService\PackagesLocalDirectory\ApplicationFoundation\BuildModelResult.log 692 0
        K:\AosService\PackagesLocalDirectory\ApplicationPlatform\BuildModelResult.log 155 0
        K:\AosService\PackagesLocalDirectory\ApplicationSuite\BuildModelResult.log 10916 0
        K:\AosService\PackagesLocalDirectory\CustomModule\BuildModelResult.log 1 2
         
    .EXAMPLE
        PS C:\> Get-D365VisualStudioCompilerResult -ErrorsOnly
         
        This will return the compiler output for all modules where there was errors in.
         
        A result set example:
         
        File Warnings Errors
        ---- -------- ------
        K:\AosService\PackagesLocalDirectory\CustomModule\BuildModelResult.log 1 2
         
    .EXAMPLE
        PS C:\> Get-D365VisualStudioCompilerResult -ErrorsOnly -OutputAsObjects
         
        This will return the compiler output for all modules where there was errors in.
        The output will be PSObjects, which can be assigned to a variable and used for futher analysis.
         
        A result set example:
         
        File Warnings Errors
        ---- -------- ------
        K:\AosService\PackagesLocalDirectory\CustomModule\BuildModelResult.log 1 2
         
    .EXAMPLE
        PS C:\> Get-D365VisualStudioCompilerResult -OutputTotals
         
        This will return the compiler output for all modules and write a total overview to the console.
         
        A result set example:
         
        File Warnings Errors
        ---- -------- ------
        K:\AosService\PackagesLocalDirectory\ApplicationCommon\BuildModelResult.log 55 0
        K:\AosService\PackagesLocalDirectory\ApplicationFoundation\BuildModelResult.log 692 0
        K:\AosService\PackagesLocalDirectory\ApplicationPlatform\BuildModelResult.log 155 0
        K:\AosService\PackagesLocalDirectory\ApplicationSuite\BuildModelResult.log 10916 0
        K:\AosService\PackagesLocalDirectory\CustomModule\BuildModelResult.log 1 2
         
         
        Total Errors: 2
        Total Warnings: 11819
         
    .NOTES
        Tags: Compiler, Build, Errors, Warnings, Tasks
         
        Author: M�tz Jensen (@Splaxi)
         
        This cmdlet is inspired by the work of "Vilmos Kintera" (twitter: @DAXRunBase)
         
        All credits goes to him for showing how to extract these information
         
        His blog can be found here:
        https://www.daxrunbase.com/blog/
         
        The specific blog post that we based this cmdlet on can be found here:
        https://www.daxrunbase.com/2020/03/31/interpreting-compiler-results-in-d365fo-using-powershell/
         
        The github repository containing the original scrips can be found here:
        https://github.com/DAXRunBase/PowerShell-and-Azure
#>

function Get-D365VisualStudioCompilerResult {
    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')]
    [OutputType('[PsCustomObject]')]
    param (
        [Alias("ModuleName")]
        [string] $Module = "*",

        [switch] $ErrorsOnly,

        [switch] $OutputTotals,

        [switch] $OutputAsObjects,

        [string] $PackageDirectory = $Script:PackageDirectory
    )

    Invoke-TimeSignal -Start

    if (-not (Test-PathExists -Path $PackageDirectory -Type Container)) { return }

    $buildOutputFiles = Get-ChildItem -Path "$PackageDirectory\$Module\BuildModelResult.log" -ErrorAction SilentlyContinue -Force

    $outputCollection = New-Object System.Collections.Generic.List[System.Object]

    foreach ($result in $buildOutputFiles) {
        
        $res = Get-CompilerResult -Path $result.FullName

        if ($null -ne $res) {
            $outputCollection.Add($res)
        }
    }

    $totalErrors = 0
    $totalWarnings = 0

    $resCol = @($outputCollection.ToArray())
    
    $totalWarnings = ($resCol | Measure-Object -Property Warnings -Sum).Sum
    $totalErrors = ($resCol | Measure-Object -Property Errors -Sum).Sum

    if ($ErrorsOnly) {
        $resCol = @($resCol | Where-Object Errors -gt 0)
    }

    if ($OutputAsObjects) {
        $resCol
    }
    else {
        $resCol | format-table File, @{Label = "Warnings"; Expression = { $e = [char]27; $color = "93"; "$e[${color}m$($_.Warnings)${e}[0m" }; Align = 'right' }, @{Label = "Errors"; Expression = { $e = [char]27; $color = "91"; "$e[${color}m$($_.Errors)${e}[0m" }; Align = 'right' }
    }
    
    if ($OutputTotals) {
        Write-PSFHostColor -String "<c='Red'>Total Errors: $totalErrors</c>"
        Write-PSFHostColor -String "<c='Yellow'>Total Warnings: $totalWarnings</c>"
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Get activation status
         
    .DESCRIPTION
        Get all the important license and activation information from the machine
         
    .EXAMPLE
        PS C:\> Get-D365WindowsActivationStatus
         
        This will get the remaining grace and rearm activation information for the machine
         
    .NOTES
        Tags: Windows, License, Activation, Arm, Rearm
         
        Author: M�tz Jensen (@Splaxi)
         
        The cmdlet uses CIM objects to access the activation details
#>

function Get-D365WindowsActivationStatus {
    [CmdletBinding()]
    param ()

    begin {}

    process {
        $a = Get-CimInstance -Class SoftwareLicensingProduct -Namespace root/cimv2 -ComputerName . -Filter "Name LIKE '%Windows%'"
        $b = Get-CimInstance -Class SoftwareLicensingService -Namespace root/cimv2 -ComputerName .

        $res = [PSCustomObject]@{ Name = $a.Name
            Description = $a.Description
            "Grace Periode (days)" =  [math]::Round(($a.graceperiodremaining / 1440))
        }

        $res | Add-Member -MemberType NoteProperty -Name 'ReArms left' -Value $b.RemainingWindowsReArmCount
 
        $res
    }

    end {}
}


<#
    .SYNOPSIS
        Used to import Aad applications into D365FO
         
    .DESCRIPTION
        Provides a method for importing a AAD application into D365FO.
         
    .PARAMETER Name
        The name that the imported application should have inside the D365FO environment
         
    .PARAMETER UserId
        The id of the user linked to the application inside the D365FO environment
         
    .PARAMETER ClientId
        The Client ID that the imported application should use inside the D365FO environment
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .EXAMPLE
        PS C:\> Import-D365AadApplication -Name "Application1" -UserId "admin" -ClientId "aef2e67c-64a3-4c72-9294-d288c5bf503d"
         
        Imports Application1 as an application linked to user admin into the D365FO environment.
         
    .NOTES
        Tags: User, Users, Security, Configuration, Permission, AAD, Azure Active Directory, Group, Groups
         
        Author: Gert Van Der Heyden (@gertvdheyden)
         
        At no circumstances can this cmdlet be used to import users into a PROD environment.
         
#>


function Import-D365AadApplication {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [String] $Name,

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

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

        [string] $DatabaseServer = $Script:DatabaseServer,

        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword
    )

    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

    $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd
    }

    $SqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection

    try {
        $sqlCommand.Connection.Open()

        Import-AadApplicationIntoD365FO $SqlCommand $Name $UserId $ClientId
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }
        $sqlCommand.Dispose()
    }
}


<#
    .SYNOPSIS
        Used to import Aad users into D365FO
         
    .DESCRIPTION
        Provides a method for importing a AAD UserGroup or a comma separated list of AadUsers into D365FO.
         
    .PARAMETER AadGroupName
        Azure Active directory user group containing users to be imported
         
    .PARAMETER Users
        Array of users that you want to import into the D365FO environment
         
    .PARAMETER StartupCompany
        Startup company of users imported.
         
        Default is DAT
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER IdPrefix
        A text that will be prefixed into the ID field. E.g. -IdPrefix "EXT-" will import users and set ID starting with "EXT-..."
         
    .PARAMETER NameSuffix
        A text that will be suffixed into the NAME field. E.g. -NameSuffix "(Contoso)" will import users and append "(Contoso)"" to the NAME
         
    .PARAMETER IdValue
        Specify which field to use as ID value when importing the users.
        Available options 'Login' / 'FirstName'
         
        Default is 'Login'
         
    .PARAMETER NameValue
        Specify which field to use as NAME value when importing the users.
        Available options 'FirstName' / 'DisplayName'
         
        Default is 'DisplayName'
         
    .PARAMETER AzureAdCredential
        Use a PSCredential object for connecting with AzureAd
         
    .PARAMETER SkipAzureAd
        Switch to instruct the cmdlet to skip validating against the Azure Active Directory
         
    .PARAMETER ForceExactAadGroupName
        Force to find the exact name of the Azure Active Directory Group
         
    .PARAMETER AadGroupId
        Azure Active directory user group ID containing users to be imported
         
    .EXAMPLE
        PS C:\> Import-D365AadUser -Users "Claire@contoso.com","Allen@contoso.com"
         
        Imports Claire and Allen as users
         
    .EXAMPLE
        PS C:\> $myPassword = ConvertTo-SecureString "MyPasswordIsSecret" -AsPlainText -Force
        PS C:\> $myCredentials = New-Object System.Management.Automation.PSCredential ("MyEmailIsAlso", $myPassword)
         
        PS C:\> Import-D365AadUser -Users "Claire@contoso.com","Allen@contoso.com" -AzureAdCredential $myCredentials
         
        This will import Claire and Allen as users.
         
    .EXAMPLE
        PS C:\> Import-D365AadUser -AadGroupName "CustomerTeam1"
         
        if more than one group match the AadGroupName, you can use the ExactAadGroupName parameter
        Import-D365AadUser -AadGroupName "CustomerTeam1" -ForceExactAadGroupName
         
    .EXAMPLE
        PS C:\> Import-D365AadUser -AadGroupName "CustomerTeam1" -ForceExactAadGroupName
         
        This is used to force the cmdlet to find the exact named group in Azure Active Directory.
         
    .EXAMPLE
        PS C:\> Import-D365AadUser -AadGroupId "99999999-aaaa-bbbb-cccc-9999999999"
         
        Imports all the users that is present in the AAD Group called CustomerTeam1
         
    .EXAMPLE
        PS C:\> Import-D365AadUser -Users "Claire@contoso.com","Allen@contoso.com" -SkipAzureAd
         
        Imports Claire and Allen as users.
        Will NOT make you connect to the Azure Active Directory(AAD).
        The needed details will be based on the e-mail address only, and the rest will be blanked.
         
    .NOTES
        Tags: User, Users, Security, Configuration, Permission, AAD, Azure Active Directory, Group, Groups
         
        Author: Rasmus Andersen (@ITRasmus)
        Author: Charles Colombel (@dropshind)
        Author: M�tz Jensen (@Splaxi)
        Author: Mikl�s Moln�r (@scifimiki)
         
        At no circumstances can this cmdlet be used to import users into a PROD environment.
         
        Only users from an Azure Active Directory that you have access to, can be imported.
        Use AAD B2B implementation if you want to support external people.
         
        Every imported users will get the System Administration / Administrator role assigned on import
         
#>


function Import-D365AadUser {
    [CmdletBinding(DefaultParameterSetName = 'UserListImport')]
    param (
        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = "GroupNameImport")]
        [String] $AadGroupName,

        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = "UserListImport")]
        [string[]]$Users,

        [Parameter(Mandatory = $false, Position = 2)]
        [string] $StartupCompany = 'DAT',

        [Parameter(Mandatory = $false, Position = 3)]
        [string] $DatabaseServer = $Script:DatabaseServer,

        [Parameter(Mandatory = $false, Position = 4)]
        [string] $DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, Position = 5)]
        [string] $SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, Position = 6)]
        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [Parameter(Mandatory = $false, Position = 7)]
        [string] $IdPrefix = "",

        [Parameter(Mandatory = $false, Position = 8)]
        [string] $NameSuffix = "",

        [Parameter(Mandatory = $false, Position = 9)]
        [ValidateSet('Login', 'FirstName')]
        [string] $IdValue = "Login",

        [Parameter(Mandatory = $false, Position = 10)]
        [ValidateSet('FirstName', 'DisplayName')]
        [string] $NameValue = "DisplayName",

        [Parameter(Mandatory = $false, Position = 11)]
        [PSCredential] $AzureAdCredential,

        [Parameter(Mandatory = $false, Position = 12, ParameterSetName = "UserListImport")]
        [switch] $SkipAzureAd,

        [Parameter(Mandatory = $false, Position = 13, ParameterSetName = "GroupNameImport")]
        [switch] $ForceExactAadGroupName,

        [Parameter(Mandatory = $true, Position = 14, ParameterSetName = "GroupIdImport")]
        [string] $AadGroupId
    )

    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

    $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd
    }

    $SqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection

    $instanceProvider = Get-InstanceIdentityProvider
    $canonicalProvider = Get-CanonicalIdentityProvider

    try {
        Write-PSFMessage -Level Verbose -Message "Trying to connect to the Azure Active Directory"

        if ($PSBoundParameters.ContainsKey("AzureAdCredential") -eq $true) {
            $null = Connect-AzureAD  -ErrorAction Stop -Credential $AzureAdCredential
        }
        else {
            if ($SkipAzureAd -eq $false) {
                $null = Connect-AzureAD  -ErrorAction Stop
            }
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while connecting to Azure Active Directory" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }

    $azureAdUsers = New-Object -TypeName "System.Collections.ArrayList"

    if (( $PSCmdlet.ParameterSetName -eq "GroupNameImport") -or ($PSCmdlet.ParameterSetName -eq "GroupIdImport")) {

        if ($PSCmdlet.ParameterSetName -eq 'GroupIdImport') {
            Write-PSFMessage -Level Verbose -Message "Search AadGroup by its ID : $AadGroupId"
            $group = Get-AzureADGroup -ObjectId $AadGroupId
        }
        else {
            if ($ForceExactAadGroupName) {
                Write-PSFMessage -Level Verbose -Message "Search AadGroup by its exactly name : $AadGroupName"
                $group = Get-AzureADGroup -Filter "DisplayName eq '$AadGroupName'"
            }
            else {
                Write-PSFMessage -Level Verbose -Message "Search AadGroup by searching with its name : $AadGroupName"
                $group = Get-AzureADGroup -SearchString $AadGroupName
            }
        }

        if ($null -eq $group) {
            Write-PSFMessage -Level Host -Message "Unable to find the specified group in the AAD. Please ensure the group exists and that you have enough permissions to access it."
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }
        else {
            Write-PSFMessage -Level Host -Message "Processing Azure AD user Group `"$($group[0].DisplayName)`""
        }

        if ($group.Length -gt 1) {
            Write-PSFMessage -Level Host -Message "More than one group found"
            foreach ($foundGroup in $group) {
                Write-PSFMessage -Level Host -Message "Group found $($foundGroup.DisplayName)"
            }
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }

        $userlist = Get-AzureADGroupMember -ObjectId $group[0].ObjectId

        foreach ($user in $userlist) {
            if ($user.ObjectType -eq "User") {
                $azureAdUser = Get-AzureADUser -ObjectId $user.ObjectId
                if ($null -eq $azureAdUser.Mail) {
                    Write-PSFMessage -Level Critical "User $($user.ObjectId) did not have an Mail"
                }
                else {
                    $null = $azureAdUsers.Add((Get-AzureADUser -ObjectId $user.ObjectId))
                }
            }
        }
    }
    else {
        foreach ($user in $Users) {

            if ($SkipAzureAd -eq $true) {
                $name = Get-LoginFromEmail $user
                $null = $azureAdUsers.Add([PSCustomObject]@{
                        Mail        = $user
                        GivenName   = $name
                        DisplayName = $name
                        ObjectId    = ''
                    })
            }
            else {
                $aadUser = Get-AzureADUser -SearchString $user

                if ($null -eq $aadUser) {
                    Write-PSFMessage -Level Critical "Could not find user $user in AzureAAd"
                }
                else {
                    $null = $azureAdUsers.Add($aadUser)
                }
            }
        }
    }

    try {
        $sqlCommand.Connection.Open()

        foreach ($user in $azureAdUsers) {

            $identityProvider = $canonicalProvider

            Write-PSFMessage -Level Verbose -Message "Getting tenant from $($user.Mail)."
            $tenant = Get-TenantFromEmail $user.Mail

            Write-PSFMessage -Level Verbose -Message "Getting domain from $($user.Mail)."
            $networkDomain = Get-NetworkDomain $user.Mail

            Write-PSFMessage -Level Verbose -Message "InstanceProvider : $InstanceProvider"
            Write-PSFMessage -Level Verbose -Message "Tenant : $Tenant"

            if ($user.Mail.ToLower().Contains("outlook.com") -eq $true) {
                $identityProvider = "live.com"
            }
            else {
                if ($instanceProvider.ToLower().Contains($tenant.ToLower()) -ne $True) {
                    Write-PSFMessage -Level Verbose -Message "Getting identity provider from $($user.Mail)."
                    $identityProvider = Get-IdentityProvider $user.Mail
                }
            }

            Write-PSFMessage -Level Verbose -Message "Getting sid from $($user.Mail) and identity provider : $identityProvider."
            $sid = Get-UserSIDFromAad $user.Mail $identityProvider
            Write-PSFMessage -Level Verbose -Message "Generated SID : $sid"
            $id = ""
            if ($IdValue -eq 'Login') {
                $id = $IdPrefix + $(Get-LoginFromEmail $user.Mail)
            }
            else {
                $id = $IdPrefix + $user.GivenName
            }
            
            if ($id.Length -gt 20) {
                $oldId = $id
                $id = $id -replace '^(.{0,20}).*','$1'
                Write-PSFMessage -Level Host -Message "The id <c='em'>'$oldId'</c> does not fit the <c='em'>20 character limit</c> on UserInfo table's ID field and will be truncated to <c='em'>'$id'</c>"
            }
            
            Write-PSFMessage -Level Verbose -Message "Id for user $($user.Mail) : $id"

            $name = ""
            if ($NameValue -eq 'DisplayName') {
                $name = $user.DisplayName + $NameSuffix
            }

            else {
                $name = $user.GivenName + $NameSuffix
            }
            Write-PSFMessage -Level Verbose -Message "Name for user $($user.Mail) : $name"
            Write-PSFMessage -Level Verbose -Message "Importing $($user.Mail) - SID $sid - Provider $identityProvider"

            Import-AadUserIntoD365FO $SqlCommand $user.Mail $name $id $sid $StartupCompany $identityProvider $networkDomain $user.ObjectId

            if (Test-PSFFunctionInterrupt) { return }
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }
        $sqlCommand.Dispose()
    }
}


<#
    .SYNOPSIS
        Import a bacpac file
         
    .DESCRIPTION
        Import a bacpac file to either a Tier1 or Tier2 environment
         
    .PARAMETER ImportModeTier1
        Switch to instruct the cmdlet that it will import into a Tier1 environment
         
        The cmdlet will expect to work against a SQL Server instance
         
    .PARAMETER ImportModeTier2
        Switch to instruct the cmdlet that it will import into a Tier2 environment
         
        The cmdlet will expect to work against an Azure DB instance
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER BacpacFile
        Path to the bacpac file you want to import into the database server
         
    .PARAMETER NewDatabaseName
        Name of the new database that will be created while importing the bacpac file
         
        This will create a new database on the database server and import the content of the bacpac into
         
    .PARAMETER AxDeployExtUserPwd
        Password that is obtained from LCS
         
    .PARAMETER AxDbAdminPwd
        Password that is obtained from LCS
         
    .PARAMETER AxRuntimeUserPwd
        Password that is obtained from LCS
         
    .PARAMETER AxMrRuntimeUserPwd
        Password that is obtained from LCS
         
    .PARAMETER AxRetailRuntimeUserPwd
        Password that is obtained from LCS
         
    .PARAMETER AxRetailDataSyncUserPwd
        Password that is obtained from LCS
         
    .PARAMETER AxDbReadonlyUserPwd
        Password that is obtained from LCS
         
    .PARAMETER CustomSqlFile
        Path to the sql script file that you want the cmdlet to execute against your data after it has been imported
         
    .PARAMETER ModelFile
        Path to the model file that you want the SqlPackage.exe to use instead the one being part of the bacpac file
         
        This is used to override SQL Server options, like collation and etc
         
    .PARAMETER DiagnosticFile
        Path to where you want the import to output a diagnostics file to assist you in troubleshooting the import
         
    .PARAMETER ImportOnly
        Switch to instruct the cmdlet to only import the bacpac into the new database
         
        The cmdlet will create a new database and import the content of the bacpac file into this
         
        Nothing else will be executed
         
    .PARAMETER MaxParallelism
        Sets SqlPackage.exe's degree of parallelism for concurrent operations running against a database
         
        The default value is 8
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-D365InstallSqlPackage
         
        You should always install the latest version of the SqlPackage.exe, which is used by New-D365Bacpac.
         
        This will fetch the latest .Net Core Version of SqlPackage.exe and install it at "C:\temp\d365fo.tools\SqlPackage".
         
    .EXAMPLE
        PS C:\> Import-D365Bacpac -ImportModeTier1 -BacpacFile "C:\temp\uat.bacpac" -NewDatabaseName "ImportedDatabase"
        PS C:\> Switch-D365ActiveDatabase -NewDatabaseName "ImportedDatabase"
         
        This will instruct the cmdlet that the import will be working against a SQL Server instance.
        It will import the "C:\temp\uat.bacpac" file into a new database named "ImportedDatabase".
        The next thing to do is to switch the active database out with the new one you just imported.
        "ImportedDatabase" will be switched in as the active database, while the old one will be named "AXDB_original".
         
    .EXAMPLE
        PS C:\> Import-D365Bacpac -ImportModeTier2 -SqlUser "sqladmin" -SqlPwd "XyzXyz" -BacpacFile "C:\temp\uat.bacpac" -AxDeployExtUserPwd "XxXx" -AxDbAdminPwd "XxXx" -AxRuntimeUserPwd "XxXx" -AxMrRuntimeUserPwd "XxXx" -AxRetailRuntimeUserPwd "XxXx" -AxRetailDataSyncUserPwd "XxXx" -AxDbReadonlyUserPwd "XxXx" -NewDatabaseName "ImportedDatabase"
        PS C:\> Switch-D365ActiveDatabase -NewDatabaseName "ImportedDatabase" -SqlUser "sqladmin" -SqlPwd "XyzXyz"
         
        This will instruct the cmdlet that the import will be working against an Azure DB instance.
        It requires all relevant passwords from LCS for all the builtin user accounts used in a Tier 2 environment.
        It will import the "C:\temp\uat.bacpac" file into a new database named "ImportedDatabase".
        The next thing to do is to switch the active database out with the new one you just imported.
        "ImportedDatabase" will be switched in as the active database, while the old one will be named "AXDB_original".
         
    .EXAMPLE
        PS C:\> Import-D365Bacpac -ImportModeTier1 -BacpacFile "C:\temp\uat.bacpac" -NewDatabaseName "ImportedDatabase" -DiagnosticFile "C:\temp\ImportLog.txt"
         
        This will instruct the cmdlet that the import will be working against a SQL Server instance.
        It will import the "C:\temp\uat.bacpac" file into a new database named "ImportedDatabase".
        It will output a diagnostic file to "C:\temp\ImportLog.txt".
         
    .EXAMPLE
        PS C:\> Import-D365Bacpac -ImportModeTier1 -BacpacFile "C:\temp\uat.bacpac" -NewDatabaseName "ImportedDatabase" -DiagnosticFile "C:\temp\ImportLog.txt" -MaxParallelism 32
         
        This will instruct the cmdlet that the import will be working against a SQL Server instance.
        It will import the "C:\temp\uat.bacpac" file into a new database named "ImportedDatabase".
        It will output a diagnostic file to "C:\temp\ImportLog.txt".
         
        It will use 32 connections against the database server while importing the bacpac file.
         
    .EXAMPLE
        PS C:\> Import-D365Bacpac -ImportModeTier1 -BacpacFile "C:\temp\uat.bacpac" -NewDatabaseName "ImportedDatabase" -ImportOnly
         
        This will instruct the cmdlet that the import will be working against a SQL Server instance.
        It will import the "C:\temp\uat.bacpac" file into a new database named "ImportedDatabase".
        No cleanup or prepping jobs will be executed, because this is for importing only.
         
        This would be something that you can use when extract a bacpac file from a Tier1 and want to import it into a Tier1.
        You would still need to execute the Switch-D365ActiveDatabase cmdlet, to get the newly imported database to be the AXDB database.
         
    .NOTES
        Tags: Database, Bacpac, Tier1, Tier2, Golden Config, Config, Configuration
         
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Import-D365Bacpac {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseProcessBlockForPipelineCommand", "")]
    [CmdletBinding(DefaultParameterSetName = 'ImportTier1')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'ImportTier1', Position = 0)]
        [switch] $ImportModeTier1,

        [Parameter(Mandatory = $true, ParameterSetName = 'ImportTier2', Position = 0)]
        [Parameter(Mandatory = $true, ParameterSetName = 'ImportOnlyTier2', Position = 0)]
        [switch] $ImportModeTier2,

        [Parameter(Position = 1 )]
        [string] $DatabaseServer = $Script:DatabaseServer,

        [Parameter(Position = 2 )]
        [string] $DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, Position = 3 )]
        [Parameter(Mandatory = $true, ParameterSetName = 'ImportTier2', ValueFromPipelineByPropertyName = $true, Position = 3)]
        [Parameter(Mandatory = $false, ParameterSetName = 'ImportTier1', Position = 3)]
        [Parameter(Mandatory = $true, ParameterSetName = 'ImportOnlyTier2', ValueFromPipelineByPropertyName = $true, Position = 3)]
        [string] $SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, Position = 4 )]
        [Parameter(Mandatory = $true, ParameterSetName = 'ImportTier2', ValueFromPipelineByPropertyName = $true, Position = 4)]
        [Parameter(Mandatory = $false, ParameterSetName = 'ImportTier1', Position = 4)]
        [Parameter(Mandatory = $true, ParameterSetName = 'ImportOnlyTier2', ValueFromPipelineByPropertyName = $true, Position = 4)]
        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, Position = 5 )]
        [Alias('File')]
        [string] $BacpacFile,

        [Parameter(Mandatory = $true, Position = 6 )]
        [string] $NewDatabaseName,

        [Parameter(Mandatory = $true, ParameterSetName = 'ImportTier2', ValueFromPipelineByPropertyName = $true, Position = 7)]
        [Parameter(Mandatory = $false, ParameterSetName = 'ImportOnlyTier2', Position = 7)]
        [string] $AxDeployExtUserPwd,

        [Parameter(Mandatory = $true, ParameterSetName = 'ImportTier2', ValueFromPipelineByPropertyName = $true, Position = 8)]
        [Parameter(Mandatory = $false, ParameterSetName = 'ImportOnlyTier2', Position = 8)]
        [string] $AxDbAdminPwd,

        [Parameter(Mandatory = $true, ParameterSetName = 'ImportTier2', ValueFromPipelineByPropertyName = $true, Position = 9)]
        [Parameter(Mandatory = $false, ParameterSetName = 'ImportOnlyTier2', Position = 9)]
        [string] $AxRuntimeUserPwd,

        [Parameter(Mandatory = $true, ParameterSetName = 'ImportTier2', ValueFromPipelineByPropertyName = $true, Position = 10)]
        [Parameter(Mandatory = $false, ParameterSetName = 'ImportOnlyTier2', Position = 10)]
        [string] $AxMrRuntimeUserPwd,

        [Parameter(Mandatory = $true, ParameterSetName = 'ImportTier2', ValueFromPipelineByPropertyName = $true, Position = 11)]
        [Parameter(Mandatory = $false, ParameterSetName = 'ImportOnlyTier2', Position = 11)]
        [string] $AxRetailRuntimeUserPwd,

        [Parameter(Mandatory = $true, ParameterSetName = 'ImportTier2', ValueFromPipelineByPropertyName = $true, Position = 12)]
        [Parameter(Mandatory = $false, ParameterSetName = 'ImportOnlyTier2', Position = 12)]
        [string] $AxRetailDataSyncUserPwd,
        
        [Parameter(Mandatory = $true, ParameterSetName = 'ImportTier2', ValueFromPipelineByPropertyName = $true, Position = 13)]
        [Parameter(Mandatory = $false, ParameterSetName = 'ImportOnlyTier2', Position = 13)]
        [string] $AxDbReadonlyUserPwd,
        
        [string] $CustomSqlFile,

        [string] $ModelFile,

        [string] $DiagnosticFile,
 
        [Parameter(Mandatory = $false, ParameterSetName = 'ImportTier1')]
        [Parameter(Mandatory = $true, ParameterSetName = 'ImportOnlyTier2')]
        [switch] $ImportOnly,
        
        [int] $MaxParallelism = 8,

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\ImportBacpac"),

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly,

        [switch] $EnableException
    )

    if (-not (Test-PathExists -Path $BacpacFile -Type Leaf)) {
        return
    }

    if ($PSBoundParameters.ContainsKey("CustomSqlFile")) {
        if (-not (Test-PathExists -Path $CustomSqlFile -Type Leaf)) {
            return
        }
        else {
            $ExecuteCustomSQL = $true
        }
    }

    Invoke-TimeSignal -Start
    
    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

    $BaseParams = @{
        DatabaseServer = $DatabaseServer
        DatabaseName   = $DatabaseName
        SqlUser        = $SqlUser
        SqlPwd         = $SqlPwd
    }

    $ImportParams = @{
        Action   = "import"
        FilePath = $BacpacFile
        MaxParallelism = $MaxParallelism
    }

    if (-not [system.string]::IsNullOrEmpty($DiagnosticFile)) {
        if (-not (Test-PathExists -Path (Split-Path $DiagnosticFile -Parent) -Type Container -Create)) { return }
        $ImportParams.DiagnosticFile = $DiagnosticFile
    }

    if (-not [system.string]::IsNullOrEmpty($ModelFile)) {
        if (-not (Test-PathExists -Path $ModelFile -Type Leaf)) { return }

        $ImportParams.ModelFile = $ModelFile
    }

    Write-PSFMessage -Level Verbose "Testing if we are working against a Tier2 / Azure DB"
    if ($ImportModeTier2) {
        Write-PSFMessage -Level Verbose "Start collecting the current Azure DB instance settings"

        $Objectives = Get-AzureServiceObjective @BaseParams

        if ($null -eq $Objectives) { return }

        [System.Collections.ArrayList] $Properties = New-Object -TypeName "System.Collections.ArrayList"
        $null = $Properties.Add("DatabaseEdition=$($Objectives.DatabaseEdition)")
        $null = $Properties.Add("DatabaseServiceObjective=$($Objectives.DatabaseServiceObjective)")

        $ImportParams.Properties = $Properties.ToArray()
    }
    
    $Params = Get-DeepClone $BaseParams
    $Params.DatabaseName = $NewDatabaseName
    
    Write-PSFMessage -Level Verbose "Start importing the bacpac with a new database name and current settings"
    Invoke-SqlPackage @Params @ImportParams -TrustedConnection $UseTrustedConnection -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath

    if ($OutputCommandOnly) { return }

    if ($ImportOnly) { return }

    if (Test-PSFFunctionInterrupt) { return }
    
    Write-PSFMessage -Level Verbose "Importing completed"

    Write-PSFMessage -Level Verbose -Message "Start working on the configuring the new database"

    if ($ImportModeTier2) {
        Write-PSFMessage -Level Verbose "Building sql statement to update the imported Azure database"

        $InstanceValues = Get-InstanceValues @BaseParams -TrustedConnection $UseTrustedConnection

        if ($null -eq $InstanceValues) { return }

        $AzureParams = @{
            AxDeployExtUserPwd = $AxDeployExtUserPwd; AxDbAdminPwd = $AxDbAdminPwd;
            AxRuntimeUserPwd = $AxRuntimeUserPwd; AxMrRuntimeUserPwd = $AxMrRuntimeUserPwd;
            AxRetailRuntimeUserPwd = $AxRetailRuntimeUserPwd; AxRetailDataSyncUserPwd = $AxRetailDataSyncUserPwd;
            AxDbReadonlyUserPwd = $AxDbReadonlyUserPwd;
        }

        $res = Set-AzureBacpacValues @Params @AzureParams @InstanceValues

        if (-not ($res)) { return }
    }
    else {
        Write-PSFMessage -Level Verbose "Building sql statement to update the imported SQL database"

        $res = Set-SqlBacpacValues @Params -TrustedConnection $UseTrustedConnection
            
        if (-not ($res)) { return }
    }

    if ($ExecuteCustomSQL) {
        Write-PSFMessage -Level Verbose -Message "Invoking the Execution of custom SQL script"
        $res = Invoke-D365SqlScript @Params -FilePath $CustomSqlFile -TrustedConnection $UseTrustedConnection

        if (-not ($res)) { return }
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Import an user from an external Azure Active Directory (AAD)
         
    .DESCRIPTION
        Imports an user from an AAD that is NOT the same as the AAD tenant that the D365FO environment is running under
         
    .PARAMETER Id
        The internal Id that the user must be imported with
         
        The Id has to unique across the entire user base
         
    .PARAMETER Name
        The display name of the user inside the D365FO environment
         
    .PARAMETER Email
        The email address of the user that you want to import
         
        This is also the sign-in user name / e-mail address to gain access to the system
         
        If the external AAD tenant has multiple custom domain names, you have to use the domain that they have configured as default
         
    .PARAMETER Company
        Default company that should be configured for the user, for when they sign-in to the D365 environment
         
        Default value is "DAT"
         
    .PARAMETER Language
        Language that should be configured for the user, for when they sign-in to the D365 environment
         
        Default value is "en-US"
         
    .PARAMETER Enabled
        Should the imported user be enabled or not?
         
        Default value is 1, which equals true / yes
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .EXAMPLE
        PS C:\> Import-D365ExternalUser -Id "John" -Name "John Doe" -Email "John@contoso.com"
         
        This will import an user from an external Azure Active Directory.
        The new user will get the system wide Id "John".
        The name of the new user will be "John Doe".
        The e-mail address / sign-in e-mail address will be registered as "John@contoso.com".
         
    .NOTES
        Tags: User, Users, Security, Configuration, Permission, AAD, Azure Active Directory
         
        Author: Anderson Joyle (@AndersonJoyle)
         
        Author: M�tz Jensen (@Splaxi)
#>


function Import-D365ExternalUser {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $Id,

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

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

        [Parameter(Mandatory = $false)]
        [int] $Enabled = 1,

        [Parameter(Mandatory = $false)]
        [string] $Company = "DAT",

        [Parameter(Mandatory = $false)]
        [string] $Language = "en-us",

        [Parameter(Mandatory = $false)]
        [string]$DatabaseServer = $Script:DatabaseServer,

        [Parameter(Mandatory = $false)]
        [string]$DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false)]
        [string]$SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false)]
        [string]$SqlPwd = $Script:DatabaseUserPassword
    )

    begin {
        Invoke-TimeSignal -Start

        $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

        $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
            SqlUser = $SqlUser; SqlPwd = $SqlPwd
        }

        $SqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection

        try {
            $sqlCommand.Connection.Open()
        }
        catch {
            Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }
    }

    process {
        if (Test-PSFFunctionInterrupt) { return }
        
        try {
            $userAuth = Get-D365UserAuthenticationDetail $Email

            $provider = $userAuth.NetworkDomain
            $sid = $userAuth.SID
            
            Write-PSFMessage -Level Verbose -Message "Extracted sid: $sid"

            Import-AadUserIntoD365FO -SqlCommand $SqlCommand -SignInName $Email -Name $Name -Id $Id -SID $SID -StartUpCompany $Company -IdentityProvider $provider -NetworkDomain $provider -Language $Language

            if (Test-PSFFunctionInterrupt) { return }
        }
        catch {
            Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }
        finally {
            if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
                $sqlCommand.Connection.Close()
            }
            $sqlCommand.Dispose()
        }
    }

    end {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()

        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Import a model into Dynamics 365 for Finance & Operations
         
    .DESCRIPTION
        Import a model into a Dynamics 365 for Finance & Operations environment
         
    .PARAMETER Path
        Path to the axmodel file that you want to import
         
    .PARAMETER Model
        Name of the model that you want to work against
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the AOS service PackagesLocalDirectory\bin
         
        Default value is fetched from the current configuration on the machine
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER Replace
        Instruct the cmdlet to replace an already existing model
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Import-D365Model -Path c:\temp\d365fo.tools\CustomModel.axmodel
         
        This will import the "c:\temp\d365fo.tools\CustomModel.axmodel" model into the PackagesLocalDirectory location.
         
    .EXAMPLE
        PS C:\> Import-D365Model -Path c:\temp\d365fo.tools\CustomModel.axmodel -Replace
         
        This will import the "c:\temp\d365fo.tools\CustomModel.axmodel" model into the PackagesLocalDirectory location.
        If the model already exists it will replace it.
         
    .NOTES
        Tags: ModelUtil, Axmodel, Model, Import, Replace, Source Control, Vsts, Azure DevOps
         
        Author: M�tz Jensen (@Splaxi)
#>


function Import-D365Model {
    # [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidDefaultValueSwitchParameter", "")]
    [CmdletBinding()]
    
    param (
        [Parameter(Mandatory = $True, Position = 1 )]
        [Alias('File')]
        [string] $Path,

        [Parameter(Mandatory = $false, Position = 2 )]
        [string] $BinDir = "$Script:PackageDirectory\bin",

        [Parameter(Mandatory = $false, Position = 3 )]
        [string] $MetaDataDir = "$Script:MetaDataDir",

        [switch] $Replace,

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\ModelUtilImport"),

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly
    )

    Invoke-TimeSignal -Start
    
    if($Replace) {
        Invoke-ModelUtil -Command "Replace" -Path $Path -BinDir $BinDir -MetaDataDir $MetaDataDir -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath
    }
    else {
        Invoke-ModelUtil -Command "Import" -Path $Path -BinDir $BinDir -MetaDataDir $MetaDataDir -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Import certificates for RSAT
         
    .DESCRIPTION
        Import the certificates for RSAT into the correct stores and display the thumbprint
         
        When working with self-service environments you need to download a zip file from LCS. The zip file needs to be unblocked and then extracted into a folder, with only the .cer and the .pxf files inside
         
    .PARAMETER Path
        Path to the folder where the .cer and .pxf files are located
         
        The files needs to be extracted from the zip archive
         
    .PARAMETER Password
        Password for the .pxf file
         
        Working with self-service environments, the password will be displayed during the download of the zip archive
         
    .EXAMPLE
        PS C:\> Import-D365RsatSelfServiceCertificates -Path "C:\Temp\UAT" -Password "123456789"
         
        This will import the .cer and .pxf files into the correct stored, bases on the files located in "C:\Temp\UAT".
        After import it will display the thumbprint for both certificates.
         
        Sample output:
        [23:43:05][Import-D365RsatSelfServiceCertificates] Pfx Thumbprint: B4D6921321434235463463414312343253523A05
        [23:43:05][Import-D365RsatSelfServiceCertificates] Cert Thumbprint: B4D6921321434235463463414312343253523A05
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
#>

function Import-D365RsatSelfServiceCertificates {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        $Path,

        [Parameter(Mandatory = $true)]
        $Password
    )
    
    begin {
        [Security.SecureString] $PasswordSecure = (ConvertTo-SecureString -String $Password -Force -AsPlainText)

        if (-not (Test-PathExists -Path $Path -Type Container)) { return }
    }
    
    process {
        if (Test-PSFFunctionInterrupt) { return }

        $pathCertFile = (Get-ChildItem -Path "$Path\*.cer" | Select-Object -First 1).FullName
        $pathPfxFile = (Get-ChildItem -Path "$Path\*.pfx" | Select-Object -First 1).FullName

        if (-not $pathCertFile -or -not $pathPfxFile) {
            $messageString = "One of the certificate files are <c='em'>missing</c>. Make sure that the path you supplied contains a set of <c='em'>.cer</c> and <c='em'>.pxf</c> certificate files."
            Write-PSFMessage -Level Host -Message $messageString
            Stop-PSFFunction -Message "Stopping because an generic error message." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
            return
        }

        $pxfCert = Import-PfxCertificate -FilePath $pathPfxFile -CertStoreLocation "Cert:\LocalMachine\Root" -Password $PasswordSecure
        Import-PfxCertificate -FilePath $pathPfxFile -CertStoreLocation "Cert:\LocalMachine\My" -Password $PasswordSecure > $null
        $cert = Import-Certificate -FilePath $pathCertFile -CertStoreLocation "Cert:\LocalMachine\Root"
        Import-Certificate -FilePath $pathCertFile -CertStoreLocation "Cert:\LocalMachine\My" > $null

        Write-PSFMessage -Level Host -Message "Pfx Thumbprint: <c='em'>$($pxfCert.Thumbprint)</c>"
        Write-PSFMessage -Level Host -Message "Cert Thumbprint: <c='em'>$($cert.Thumbprint)</c>"

    }
    
    end {
        
    }
}


<#
    .SYNOPSIS
        Create and configure test automation certificate
         
    .DESCRIPTION
        Creates a new self signed certificate for automated testing and reconfigures the AOS Windows Identity Foundation configuration to trust the certificate
         
    .PARAMETER CertificateFileName
        Filename to be used when exporting the cer file
         
    .PARAMETER PrivateKeyFileName
        Filename to be used when exporting the pfx file
         
    .PARAMETER Password
        The password that you want to use to protect your certificate with
         
        The default value is: "Password1"
         
    .PARAMETER CertificateOnly
        Switch specifying if only the certificate needs to be created
         
        If specified, then only the certificate is created and the thumbprint is not added to the wif.config on the AOS side
        If not specified (default) then the certificate is created and installed and the corresponding thumbprint is added to the wif.config on the local machine
         
    .PARAMETER KeepCertificateFile
        Instruct the cmdlet to copy the certificate file from the working directory into the desired location specified with OutputPath parameter
         
    .PARAMETER OutputPath
        Path to where you want the certificate file exported to, when using the KeepCertificateFile parameter switch
         
        Default value is: "c:\temp\d365fo.tools"
         
    .EXAMPLE
        PS C:\> Initialize-D365RsatCertificate
         
        This will generate a certificate for issuer 127.0.0.1 and install it in the trusted root certificates and modify the wif.config of the AOS to include the thumbprint and trust the certificate.
         
    .EXAMPLE
        PS C:\> Initialize-D365RsatCertificate -CertificateOnly
         
        This will generate a certificate for issuer 127.0.0.1 and install it in the trusted root certificates.
        No actions will be taken regarding modifying the AOS wif.config file.
         
        Use this when installing RSAT on a machine different from the AOS where RSAT is pointing to.
         
    .EXAMPLE
        PS C:\> Initialize-D365RsatCertificate -CertificateOnly -KeepCertificateFile
         
        This will generate a certificate for issuer 127.0.0.1 and install it in the trusted root certificates.
        No actions will be taken regarding modifying the AOS wif.config file.
        The pfx will be copied into the default "c:\temp\d365fo.tools" folder after creation.
         
        Use this when installing RSAT on a machine different from the AOS where RSAT is pointing to.
         
        The pfx file enables you to import the same certificate across your entire network, instead of creating one per machine.
         
    .NOTES
        Tags: Automated Test, Test, Regression, Certificate, Thumbprint
         
        Author: Kenny Saelen (@kennysaelen)
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Initialize-D365RsatCertificate {
    [Alias("Initialize-D365TestAutomationCertificate")]

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingCmdletAliases", "")]
    [CmdletBinding()]
    param (
        [string] $CertificateFileName = (Join-Path $env:TEMP "TestAuthCert.cer"),

        [string] $PrivateKeyFileName = (Join-Path $env:TEMP "TestAuthCert.pfx"),

        [Security.SecureString] $Password = (ConvertTo-SecureString -String "Password1" -Force -AsPlainText),

        [switch] $CertificateOnly,

        [Parameter(ParameterSetName = "KeepCertificateFile")]
        [switch] $KeepCertificateFile,

        [Parameter(ParameterSetName = "KeepCertificateFile")]
        [string] $OutputPath = $Script:DefaultTempPath
    )

    if (-not $Script:IsAdminRuntime) {
        Write-PSFMessage -Level Critical -Message "The cmdlet needs administrator permission (Run As Administrator) to be able to update the configuration. Please start an elevated session and run the cmdlet again."
        Stop-PSFFunction -Message "Elevated permissions needed. Please start an elevated session and run the cmdlet again."
        return
    }

    try {
        # Create the certificate and place it in the right stores
        $X509Certificate = New-D365SelfSignedCertificate -CertificateFileName $CertificateFileName -PrivateKeyFileName $PrivateKeyFileName -Password $Password

        if (Test-PSFFunctionInterrupt) {
            Write-PSFMessage -Level Critical -Message "The self signed certificate creation was interrupted."
            Stop-PSFFunction -Message "Stopping because of errors."
            return
        }

        if($false -eq $CertificateOnly)
        {
            # Modify the wif.config of the AOS to have this thumbprint added to the https://fakeacs.accesscontrol.windows.net/ authority
            Add-D365RsatWifConfigAuthorityThumbprint -CertificateThumbprint $X509Certificate.Thumbprint
        }

        # Write-PSFMessage -Level Host -Message "Generated certificate: $X509Certificate"

        if($KeepCertificateFile){
            $PrivateKeyFileName = ($PrivateKeyFileName | Copy-Item -Destination $OutputPath -PassThru).FullName
        }

        [PSCustomObject]@{
            File = $PrivateKeyFileName
            Filename = $(Split-Path -Path $PrivateKeyFileName -Leaf)
        }

        $X509Certificate | Format-Table Thumbprint, Subject, FriendlyName, NotAfter
    }

    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while configuring the certificates and the Windows Identity Foundation configuration for the AOS" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
}


<#
    .SYNOPSIS
        Transfer a file using AzCopy
         
    .DESCRIPTION
        Transfer a file using the AzCopy tool
         
        You can upload a local file to an Azure Storage Blob Container
         
        You can download a file located in an Azure Storage Blob Container to a local folder
         
        You can transfer a file located in an Azure Storage Blob Container to another Azure Storage Blob Container, across regions and subscriptions, if you have SAS tokens/keys as part of your uri
         
    .PARAMETER SourceUri
        Source file uri that you want to transfer
         
    .PARAMETER DestinationUri
        Destination file uri that you want to transfer the file to
         
    .PARAMETER FileName
        You might only pass a blob container or folder name in the DestinationUri parameter and want to give the transfered file another name than the original file name
         
    .PARAMETER DeleteOnTransferComplete
        Instruct the cmdlet to delete the source file when done transfering
         
        Default is $false which will leave the source file
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .PARAMETER Force
        Instruct the cmdlet to overwrite already existing file
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-D365AzCopyTransfer -SourceUri "https://123.blob.core.windows.net/containername/filename?sv=2015-12-11&sr=..." -DestinationUri "c:\temp\d365fo.tools\GOLDER.bacpac"
         
        This will transfer a file from an Azure Storage Blob Container to a local folder/file on the machine.
        The file that will be transfered/downloaded is SourceUri "https://123.blob.core.windows.net/containername/filename?sv=2015-12-11&sr=...".
        The file will be transfered/downloaded to DestinationUri "c:\temp\d365fo.tools\GOLDER.bacpac".
         
        If there exists a file already, the file will NOT be overwritten.
         
    .EXAMPLE
        PS C:\> Invoke-D365AzCopyTransfer -SourceUri "https://123.blob.core.windows.net/containername/filename?sv=2015-12-11&sr=..." -DestinationUri "c:\temp\d365fo.tools\GOLDER.bacpac" -Force
         
        This will transfer a file from an Azure Storage Blob Container to a local folder/file on the machine.
        The file that will be transfered/downloaded is SourceUri "https://123.blob.core.windows.net/containername/filename?sv=2015-12-11&sr=...".
        The file will be transfered/downloaded to DestinationUri "c:\temp\d365fo.tools\GOLDER.bacpac".
        If there exists a file already, the file will be overwritten, because Force has been supplied.
         
    .EXAMPLE
        PS C:\> Invoke-D365AzCopyTransfer -SourceUri "https://123.blob.core.windows.net/containername/filename?sv=2015-12-11&sr=..." -DestinationUri "https://456.blob.core.windows.net/targetcontainer/filename?sv=2015-12-11&sr=..."
         
        This will transfer a file from an Azure Storage Blob Container to another Azure Storage Blob Container.
        The file that will be transfered/downloaded is SourceUri "https://123.blob.core.windows.net/containername/filename?sv=2015-12-11&sr=...".
        The file will be transfered/downloaded to DestinationUri "https://456.blob.core.windows.net/targetcontainer/filename?sv=2015-12-11&sr=...".
         
        For this to work, you need to make sure both SourceUri and DestinationUri has an valid SAS token/key included.
         
        If there exists a file already, the file will NOT be overwritten.
         
    .EXAMPLE
        PS C:\> Invoke-D365AzCopyTransfer -SourceUri "https://123.blob.core.windows.net/containername/filename?sv=2015-12-11&sr=..." -DestinationUri "c:\temp\d365fo.tools\GOLDER.bacpac" -DeleteOnTransferComplete
         
        This will transfer a file from an Azure Storage Blob Container to a local folder/file on the machine.
        The file that will be transfered/downloaded is SourceUri "https://123.blob.core.windows.net/containername/filename?sv=2015-12-11&sr=...".
        The file will be transfered/downloaded to DestinationUri "c:\temp\d365fo.tools\GOLDER.bacpac".
         
        After the file has been transfered to your local "c:\temp\d365fo.tools\GOLDER.bacpac", it will be deleted from the SourceUri "https://123.blob.core.windows.net/containername/filename?sv=2015-12-11&sr=...".
         
    .EXAMPLE
        PS C:\> $DestinationParms = Get-D365AzureStorageUrl -OutputAsHashtable
        PS C:\> $BlobFileDetails = Get-D365LcsDatabaseBackups -Latest | Invoke-D365AzCopyTransfer @DestinationParms
        PS C:\> $BlobFileDetails | Invoke-D365AzCopyTransfer -DestinationUri "C:\Temp" -DeleteOnTransferComplete
         
        This will transfer the lastest backup file from LCS Asset Library to your local "C:\Temp".
        It will get a destination Url, for it to transfer the backup file between the LCS storage account and your own.
        The newly transfered file, that lives in your own storage account, will then be downloaded to your local "c:\Temp".
         
        After the file has been downloaded to your local "C:\Temp", it will be deleted from your own storage account.
         
    .NOTES
        Tags: Azure, Azure Storage, Config, Configuration, Token, Blob, File, Files, Latest, Bacpac, Container, LCS, Asset, Library
         
        Author: M�tz Jensen (@Splaxi)
         
        The cmdlet supports piping and can be used in advanced scenarios. See more on github and the wiki pages.
         
#>

function Invoke-D365AzCopyTransfer {
    [CmdletBinding()]
    param (
        [Alias('SourceUrl')]
        [Alias('FileLocation')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string] $SourceUri,

        [Alias('DestinationFile')]
        [Parameter(Mandatory = $true)]
        [string] $DestinationUri,

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

        [switch] $DeleteOnTransferComplete,

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\AzCopy"),

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly,

        [switch] $Force,

        [switch] $EnableException
    )

    process {
        $executable = $Script:AzCopyPath

        Invoke-TimeSignal -Start

        if (-not [string]::IsNullOrEmpty($FileName)) {
            if ($DestinationUri -like "*?*") {
                $DestinationUri = $DestinationUri.Replace("?", "/$FileName`?")
            }
            else {
                if ([System.IO.File]::GetAttributes($DestinationUri).HasFlag([System.IO.FileAttributes]::Directory)) {
                    $DestinationUri = Join-Path -Path $DestinationUri -ChildPath $FileName
                }
            }
        }

        $params = New-Object System.Collections.Generic.List[string]

        $params.Add("copy")
        $params.Add("`"$SourceUri`"")
        $params.Add("`"$DestinationUri`"")

        if (-not $Force) {
            $params.Add("--overwrite=false")
        }

        Invoke-Process -Executable $executable -Params $params.ToArray() -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly

        if (Test-PSFFunctionInterrupt) { return }

        if ($DeleteOnTransferComplete) {

            $params = New-Object System.Collections.Generic.List[string]

            $params.Add("remove")
            $params.Add("`"$SourceUri`"")

            Invoke-Process -Executable $executable -Params $params.ToArray() -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly
        }

        if ($DestinationUri -notlike "*https*") {
            $filePath = Get-ChildItem -Path $DestinationUri -Recurse -File | Sort-Object CreationTime -Descending | Select-Object -First 1
            $FileName = $filePath.Name
        }
        else {
            $filePath = $DestinationUri
        }

        #Filename is missing. If Https / SAS, we need some work.
        #If local file, it should be easy to solve
        $res = @{
            File       = $filePath
            SourceUri  = $filePath
            PSTypeName = 'D365FO.TOOLS.AZCOPYTRANSFER'
        }

        if (-not [string]::IsNullOrEmpty($FileName)) {
            $res.FileName = $FileName
        }

        [PSCustomObject]$res

        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Download a file to Azure
         
    .DESCRIPTION
        Download any file to an Azure Storage Account
         
    .PARAMETER AccountId
        Storage Account Name / Storage Account Id where you want to fetch the file from
         
    .PARAMETER AccessToken
        The token that has the needed permissions for the download action
         
    .PARAMETER SAS
        The SAS key that you have created for the storage account or blob container
         
    .PARAMETER Container
        Name of the blob container inside the storage account you where the file is
         
    .PARAMETER FileName
        Name of the file that you want to download
         
    .PARAMETER Path
        Path to the folder / location you want to save the file
         
        The default path is "c:\temp\d365fo.tools"
         
    .PARAMETER Latest
        Instruct the cmdlet to download the latest file from Azure regardless of name
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-D365AzureStorageDownload -AccountId "miscfiles" -AccessToken "xx508xx63817x752xx74004x30705xx92x58349x5x78f5xx34xxxxx51" -Container "backupfiles" -FileName "OriginalUAT.bacpac" -Path "c:\temp"
         
        Will download the "OriginalUAT.bacpac" file from the storage account and save it to "c:\temp\OriginalUAT.bacpac"
         
    .EXAMPLE
        PS C:\> Invoke-D365AzureStorageDownload -AccountId "miscfiles" -AccessToken "xx508xx63817x752xx74004x30705xx92x58349x5x78f5xx34xxxxx51" -Container "backupfiles" -Path "c:\temp" -Latest
         
        Will download the file with the latest modified datetime from the storage account and save it to "c:\temp\".
        The complete path to the file will returned as output from the cmdlet.
         
    .EXAMPLE
        PS C:\> $AzureParams = Get-D365ActiveAzureStorageConfig
        PS C:\> Invoke-D365AzureStorageDownload @AzureParams -Path "c:\temp" -Latest
         
        This will get the current Azure Storage Account configuration details
        and use them as parameters to download the latest file from an Azure Storage Account
         
        Will download the file with the latest modified datetime from the storage account and save it to "c:\temp\".
        The complete path to the file will returned as output from the cmdlet.
         
    .EXAMPLE
        PS C:\> Invoke-D365AzureStorageDownload -Latest
         
        This will use the default parameter values that are based on the configuration stored inside "Get-D365ActiveAzureStorageConfig".
        Will download the file with the latest modified datetime from the storage account and save it to "c:\temp\d365fo.tools".
         
    .EXAMPLE
        PS C:\> Invoke-D365AzureStorageDownload -AccountId "miscfiles" -SAS "sv2018-03-28&siunlisted&src&sigAUOpdsfpoWE976ASDhfjkasdf(5678sdfhk" -Container "backupfiles" -Path "c:\temp" -Latest
         
        Will download the file with the latest modified datetime from the storage account and save it to "c:\temp\".
        A SAS key is used to gain access to the container and downloading the file from it.
        The complete path to the file will returned as output from the cmdlet.
         
    .NOTES
        Tags: Azure, Azure Storage, Config, Configuration, Token, Blob, File, Files, Latest, Bacpac, Container
         
        Author: M�tz Jensen (@Splaxi)
         
        The cmdlet supports piping and can be used in advanced scenarios. See more on github and the wiki pages.
         
#>

function Invoke-D365AzureStorageDownload {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $false)]
        [string] $AccountId = $Script:AzureStorageAccountId,

        [Parameter(Mandatory = $false)]
        [string] $AccessToken = $Script:AzureStorageAccessToken,

        [Parameter(Mandatory = $false)]
        [string] $SAS = $Script:AzureStorageSAS,

        [Parameter(Mandatory = $false)]
        [Alias('Blob')]
        [Alias('Blobname')]
        [string] $Container = $Script:AzureStorageContainer,

        [Parameter(Mandatory = $true, ParameterSetName = 'Default', ValueFromPipelineByPropertyName = $true)]
        [Alias('Name')]
        [string] $FileName,

        [Parameter(Mandatory = $false)]
        [string] $Path = $Script:DefaultTempPath,

        [Parameter(Mandatory = $true, ParameterSetName = 'Latest', Position = 4 )]
        [Alias('GetLatest')]
        [switch] $Latest,

        [switch] $EnableException
    )

    BEGIN {
        if (-not (Test-PathExists -Path $Path -Type Container -Create)) {
            return
        }

        if (([string]::IsNullOrEmpty($AccountId) -eq $true) -or
            ([string]::IsNullOrEmpty($Container)) -or
            (([string]::IsNullOrEmpty($AccessToken)) -and ([string]::IsNullOrEmpty($SAS)))) {
            Write-PSFMessage -Level Host -Message "It seems that you are missing some of the parameters. Please make sure that you either supplied them or have the right configuration saved."
            Stop-PSFFunction -Message "Stopping because of missing parameters"
            return
        }
    }
    PROCESS {
        if (Test-PSFFunctionInterrupt) {return}

        Invoke-TimeSignal -Start

        try {

            if ([string]::IsNullOrEmpty($SAS)) {
                Write-PSFMessage -Level Verbose -Message "Working against Azure Storage Account with AccessToken"

                $storageContext = new-AzureStorageContext -StorageAccountName $AccountId.ToLower() -StorageAccountKey $AccessToken
            }
            else {
                Write-PSFMessage -Level Verbose -Message "Working against Azure Storage Account with SAS"

                $conString = $("BlobEndpoint=https://{0}.blob.core.windows.net/;QueueEndpoint=https://{0}.queue.core.windows.net/;FileEndpoint=https://{0}.file.core.windows.net/;TableEndpoint=https://{0}.table.core.windows.net/;SharedAccessSignature={1}" -f $AccountId.ToLower(), $SAS)
                $storageContext = new-AzureStorageContext -ConnectionString $conString
            }

            $cloudStorageAccount = [Microsoft.WindowsAzure.Storage.CloudStorageAccount]::Parse($storageContext.ConnectionString)

            $blobClient = $cloudStorageAccount.CreateCloudBlobClient()

            $blobContainer = $blobClient.GetContainerReference($Container.ToLower());

            Write-PSFMessage -Level Verbose -Message "Start download from Azure Storage Account"

            if ($Latest) {
                $files = $blobContainer.ListBlobs()
                $File = ($files | Sort-Object -Descending { $_.Properties.LastModified } | Select-Object -First 1)
    
                $NewFile = Join-Path $Path $($File.Name)

                $File.DownloadToFile($NewFile, [System.IO.FileMode]::Create)

                $FileName = $File.Name
            }
            else {
                $NewFile = Join-Path $Path $FileName

                $blockBlob = $blobContainer.GetBlockBlobReference($FileName);
                $blockBlob.DownloadToFile($NewFile, [System.IO.FileMode]::Create)
            }

            Get-Item -Path $NewFile | Select-PSFObject "Name as Filename", @{Name = "Size"; Expression = {[PSFSize]$_.Length}}, "LastWriteTime as LastModified", "Fullname as File"
        }
        catch {
            $messageString = "Something went wrong while <c='em'>downloading</c> the file from Azure."
            Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target $NewFile
            Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_
            return
        }
        finally {
            Invoke-TimeSignal -End
        }
    }

    END {}
}


<#
    .SYNOPSIS
        Upload a file to Azure
         
    .DESCRIPTION
        Upload any file to an Azure Storage Account
         
    .PARAMETER AccountId
        Storage Account Name / Storage Account Id where you want to store the file
         
    .PARAMETER AccessToken
        The token that has the needed permissions for the upload action
         
    .PARAMETER SAS
        The SAS key that you have created for the storage account or blob container
         
    .PARAMETER Container
        Name of the blob container inside the storage account you want to store the file
         
    .PARAMETER Filepath
        Path to the file you want to upload
         
    .PARAMETER ContentType
        Media type of the file that is going to be uploaded
         
        The value will be used for the blob property "Content Type".
        If the parameter is left empty, the commandlet will try to automatically determined the value based on the file's extension.
        If the parameter is left empty and the value cannot be automatically be determined, Azure storage will automatically assign "application/octet-stream" as the content type.
        Valid media type values can be found here: https://www.iana.org/assignments/media-types/media-types.xhtml
         
    .PARAMETER DeleteOnUpload
        Switch to tell the cmdlet if you want the local file to be deleted after the upload completes
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-D365AzureStorageUpload -AccountId "miscfiles" -AccessToken "xx508xx63817x752xx74004x30705xx92x58349x5x78f5xx34xxxxx51" -Container "backupfiles" -Filepath "c:\temp\bacpac\UAT_20180701.bacpac" -DeleteOnUpload
         
        This will upload the "c:\temp\bacpac\UAT_20180701.bacpac" up to the "backupfiles" container, inside the "miscfiles" Azure Storage Account that is access with the "xx508xx63817x752xx74004x30705xx92x58349x5x78f5xx34xxxxx51" token.
        After upload the local file will be deleted.
         
    .EXAMPLE
        PS C:\> $AzureParams = Get-D365ActiveAzureStorageConfig
        PS C:\> New-D365Bacpac | Invoke-D365AzureStorageUpload @AzureParams
         
        This will get the current Azure Storage Account configuration details and use them as parameters to upload the file to an Azure Storage Account.
         
    .EXAMPLE
        PS C:\> New-D365Bacpac | Invoke-D365AzureStorageUpload
         
        This will generate a new bacpac file using the "New-D365Bacpac" cmdlet.
        The file will be uploaded to an Azure Storage Account using the "Invoke-D365AzureStorageUpload" cmdlet.
        This will use the default parameter values that are based on the configuration stored inside "Get-D365ActiveAzureStorageConfig" for the "Invoke-D365AzureStorageUpload" cmdlet.
         
    .EXAMPLE
        PS C:\> Invoke-D365AzureStorageUpload -AccountId "miscfiles" -SAS "sv2018-03-28&siunlisted&src&sigAUOpdsfpoWE976ASDhfjkasdf(5678sdfhk" -Container "backupfiles" -Filepath "c:\temp\bacpac\UAT_20180701.bacpac" -DeleteOnUpload
         
        This will upload the "c:\temp\bacpac\UAT_20180701.bacpac" up to the "backupfiles" container, inside the "miscfiles" Azure Storage Account.
        A SAS key is used to gain access to the container and uploading the file to it.
         
    .NOTES
        Tags: Azure, Azure Storage, Config, Configuration, Token, Blob, File, Files, Bacpac, Container
         
        Author: M�tz Jensen (@Splaxi)
         
        The cmdlet supports piping and can be used in advanced scenarios. See more on github and the wiki pages.
         
#>

function Invoke-D365AzureStorageUpload {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $false)]
        [string] $AccountId = $Script:AzureStorageAccountId,

        [Parameter(Mandatory = $false)]
        [string] $AccessToken = $Script:AzureStorageAccessToken,

        [Parameter(Mandatory = $false)]
        [string] $SAS = $Script:AzureStorageSAS,

        [Parameter(Mandatory = $false)]
        [Alias('Blob')]
        [Alias('Blobname')]
        [string] $Container = $Script:AzureStorageContainer,

        [Parameter(Mandatory = $true, ParameterSetName = 'Default', ValueFromPipeline = $true)]
        [Parameter(Mandatory = $true, ParameterSetName = 'Pipeline', ValueFromPipelineByPropertyName = $true)]
        [Alias('File')]
        [Alias('Path')]
        [string] $Filepath,

        [Parameter(Mandatory = $false)]
        [string] $ContentType,

        [switch] $DeleteOnUpload,

        [switch] $EnableException
    )
    BEGIN {
        if (([string]::IsNullOrEmpty($AccountId) -eq $true) -or
            ([string]::IsNullOrEmpty($Container)) -or
            (([string]::IsNullOrEmpty($AccessToken)) -and ([string]::IsNullOrEmpty($SAS)))) {
            Write-PSFMessage -Level Host -Message "It seems that you are missing some of the parameters. Please make sure that you either supplied them or have the right configuration saved."
            Stop-PSFFunction -Message "Stopping because of missing parameters"
            return
        }
    }
    PROCESS {
        if (Test-PSFFunctionInterrupt) { return }

        Invoke-TimeSignal -Start
        try {

            if ([string]::IsNullOrEmpty($SAS)) {
                Write-PSFMessage -Level Verbose -Message "Working against Azure Storage Account with AccessToken"

                $storageContext = new-AzureStorageContext -StorageAccountName $AccountId.ToLower() -StorageAccountKey $AccessToken
            }
            else {
                $conString = $("BlobEndpoint=https://{0}.blob.core.windows.net/;QueueEndpoint=https://{0}.queue.core.windows.net/;FileEndpoint=https://{0}.file.core.windows.net/;TableEndpoint=https://{0}.table.core.windows.net/;SharedAccessSignature={1}" -f $AccountId.ToLower(), $SAS)

                Write-PSFMessage -Level Verbose -Message "Working against Azure Storage Account with SAS" -Target $conString
                
                $storageContext = new-AzureStorageContext -ConnectionString $conString
            }

            $cloudStorageAccount = [Microsoft.WindowsAzure.Storage.CloudStorageAccount]::Parse($storageContext.ConnectionString)

            $blobClient = $cloudStorageAccount.CreateCloudBlobClient()

            $blobContainer = $blobClient.GetContainerReference($Container.ToLower());

            if ([string]::IsNullOrEmpty($ContentType)) {
                $ContentType = [System.Web.MimeMapping]::GetMimeMapping($Filepath) # Available since .NET4.5, so it can be used with PowerShell 5.0 and higher.

                Write-PSFMessage -Level Verbose -Message "Content Type is automatically set to value: $ContentType"
            }

            Write-PSFMessage -Level Verbose -Message "Start uploading the file to Azure"

            $FileName = Split-Path $Filepath -Leaf
            $blockBlob = $blobContainer.GetBlockBlobReference($FileName)

            if (![string]::IsNullOrEmpty($ContentType)) {
                $blockBlob.Properties.ContentType = $ContentType
            }

            $blockBlob.UploadFromFile($Filepath)

            if ($DeleteOnUpload) {
                Remove-Item $Filepath -Force
            }

            [PSCustomObject]@{
                File     = $Filepath
                Filename = $FileName
            }
        }
        catch {
            $messageString = "Something went wrong while <c='em'>uploading</c> the file to Azure."
            Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target $FileName
            Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_
            return
        }
        finally {
            Invoke-TimeSignal -End
        }
    }

    END {}
}


<#
    .SYNOPSIS
        Run the Best Practice
         
    .DESCRIPTION
        Run the Best Practice checks against modules and models
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the AOS service PackagesLocalDirectory\bin
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER Module
        Name of the Module to analyse
         
    .PARAMETER Model
        Name of the Model to analyse
         
    .PARAMETER PackagesRoot
        Instructs the cmdlet to use binary metadata
         
    .PARAMETER LogPath
        Path where you want to store the log outputs generated from the best practice analyser
         
        Also used as the path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER RunFixers
        Instructs the cmdlet to invoke the fixers for the identified warnings
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Invoke-D365BestPractice -module "ApplicationSuite" -model "MyOverLayerModel"
         
        This will execute the best practice checks against MyOverLayerModel in the ApplicationSuite Module.
        The default output will be silenced.
        The XML log file will be written to "c:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.MyOverLayerModel.xppbp.xml".
        The log file will be written to "c:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.MyOverLayerModel.xppbp.log".
         
    .EXAMPLE
        PS C:\> Invoke-D365BestPractice -module "ApplicationSuite" -model "MyOverLayerModel" -PackagesRoot
         
        This will execute the best practice checks against MyOverLayerModel in the ApplicationSuite Module.
        We use the binary metadata to look for the module and model.
        The default output will be silenced.
        The XML log file will be written to "c:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.MyOverLayerModel.xppbp.xml".
        The log file will be written to "c:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.MyOverLayerModel.xppbp.log".
         
    .EXAMPLE
        PS C:\> Invoke-D365BestPractice -module "ApplicationSuite" -model "MyOverLayerModel" -ShowOriginalProgress
         
        This will execute the best practice checks against MyOverLayerModel in the ApplicationSuite Module.
        The output from the best practice check process will be written to the console / host.
        The XML log file will be written to "c:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.MyOverLayerModel.xppbp.xml".
        The log file will be written to "c:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.MyOverLayerModel.xppbp.log".
         
    .EXAMPLE
        PS C:\> Invoke-D365BestPractice -module "ApplicationSuite" -model "MyOverLayerModel" -RunFixers
         
        This will execute the best practice checks against MyOverLayerModel in the ApplicationSuite Module.
        The default output will be silenced.
        The XML log file will be written to "c:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.MyOverLayerModel.xppbp.xml".
        The log file will be written to "c:\temp\d365fo.tools\ApplicationSuite\Dynamics.AX.MyOverLayerModel.xppbp.log".
        Instructs the xppbp tool to run the fixers for all identified warnings.
         
    .NOTES
        Tags: Best Practice, BP, BPs, Module, Model, Quality
         
        Author: Gert Van Der Heyden (@gertvdheyden)
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Invoke-D365BestPractice {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    [OutputType('[PsCustomObject]')]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias("ModuleName")]
        [string] $Module,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('ModelName')]
        [string] $Model,

        [string] $BinDir = "$Script:PackageDirectory\bin",

        [string] $MetaDataDir = "$Script:MetaDataDir",

        [switch] $PackagesRoot,

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\BestPractice"),

        [switch] $ShowOriginalProgress,

        [switch] $RunFixers,

        [switch] $OutputCommandOnly
    )

    begin {
        Invoke-TimeSignal -Start

        $tool = "xppbp.exe"
        $executable = Join-Path -Path $BinDir -ChildPath $tool

        if (-not (Test-PathExists -Path $MetaDataDir, $BinDir -Type Container)) { return }
        if (-not (Test-PathExists -Path $executable -Type Leaf)) { return }
    
    }
    
    process {
        if (Test-PSFFunctionInterrupt) { return }

        $logDirModule = (Join-Path -Path $LogPath -ChildPath $Module)

        if (-not (Test-PathExists -Path $logDirModule -Type Container -Create)) { return }

        if (Test-PSFFunctionInterrupt) { return }

        $logFile = Join-Path -Path $logDirModule -ChildPath "Dynamics.AX.$Model.xppbp.log"
        $logXmlFile = Join-Path -Path $logDirModule -ChildPath "Dynamics.AX.$Model.xppbp.xml"

        $params = @(
            "-metadata=`"$MetaDataDir`"",
            "-all",
            "-module=`"$Module`"",
            "-model=`"$Model`"",
            "-xmlLog=`"$logXmlFile`"",
            "-log=`"$logFile`""
        )
    
        if ($PackagesRoot -eq $true) {
            $params += "-packagesroot=`"$MetaDataDir`""
        }

        if ($RunFixers -eq $true) {
            $params += "-runfixers"
        }

        Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $logDirModule

        if ($OutputCommandOnly) { return }
        
        [PSCustomObject]@{
            LogFile    = $logFile
            XmlLogFile = $logXmlFile
        }
    }

    end {
        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Analyze the compiler output log
         
    .DESCRIPTION
        Analyze the compiler output log and generate an excel file contain worksheets per type: Errors, Warnings, Tasks
         
        It could be a Visual Studio compiler log or it could be a Invoke-D365ModuleCompile log you want analyzed
         
    .PARAMETER Path
        Path to the compiler log file that you want to work against
         
        A BuildModelResult.log or a Dynamics.AX.*.xppc.log file will both work
         
    .PARAMETER OutputPath
        Path where you want the excel file (xlsx-file) saved to
         
    .PARAMETER SkipWarnings
        Instructs the cmdlet to skip warnings while analyzing the compiler output log file
         
    .PARAMETER SkipTasks
        Instructs the cmdlet to skip tasks while analyzing the compiler output log file
         
    .PARAMETER PackageDirectory
        Path to the directory containing the installed package / module
         
        Default path is the same as the AOS service "PackagesLocalDirectory" directory
         
        Default value is fetched from the current configuration on the machine
         
    .EXAMPLE
        PS C:\> Invoke-D365CompilerResultAnalyzer -Path "c:\temp\d365fo.tools\Custom\Dynamics.AX.Custom.xppc.log"
         
        This will analyse all compiler output log files generated from Visual Studio.
        It will use the default path for the OutputPath parameter.
         
        It will build error and error summary worksheets.
        It will build warning and warning summary worksheets.
        It will build task and task summary worksheets.
         
        A result set example:
         
        File Filename
        ---- --------
        c:\temp\d365fo.tools\Custom-CompilerResults.xlsx Custom-CompilerResults.xlsx
         
    .EXAMPLE
        PS C:\> Invoke-D365CompilerResultAnalyzer -Path "c:\temp\d365fo.tools\Custom\Dynamics.AX.Custom.xppc.log" -SkipWarnings
         
        This will analyse all compiler output log files generated from Visual Studio.
        It will use the default path for the OutputPath parameter.
         
        It will build error and error summary worksheets.
        It will build task and task summary worksheets.
         
        A result set example:
         
        File Filename
        ---- --------
        c:\temp\d365fo.tools\Custom-CompilerResults.xlsx Custom-CompilerResults.xlsx
         
    .EXAMPLE
        PS C:\> Invoke-D365CompilerResultAnalyzer -Path "c:\temp\d365fo.tools\Custom\Dynamics.AX.Custom.xppc.log" -SkipTasks
         
        This will analyse all compiler output log files generated from Visual Studio.
        It will use the default path for the OutputPath parameter.
         
        It will build error and error summary worksheets.
        It will build warning and warning summary worksheets.
         
        A result set example:
         
        File Filename
        ---- --------
        c:\temp\d365fo.tools\Custom-CompilerResults.xlsx Custom-CompilerResults.xlsx
         
    .NOTES
        Tags: Compiler, Build, Errors, Warnings, Tasks
         
        Author: M�tz Jensen (@Splaxi)
         
        This cmdlet is inspired by the work of "Vilmos Kintera" (twitter: @DAXRunBase)
         
        All credits goes to him for showing how to extract these information
         
        His blog can be found here:
        https://www.daxrunbase.com/blog/
         
        The specific blog post that we based this cmdlet on can be found here:
        https://www.daxrunbase.com/2020/03/31/interpreting-compiler-results-in-d365fo-using-powershell/
         
        The github repository containing the original scrips can be found here:
        https://github.com/DAXRunBase/PowerShell-and-Azure
#>

function Invoke-D365CompilerResultAnalyzer {
    [CmdletBinding()]
    [OutputType('')]
    param (
        [parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('LogFile')]
        [string] $Path,

        [string] $OutputPath = $Script:DefaultTempPath,

        [switch] $SkipWarnings,

        [switch] $SkipTasks,

        [string] $PackageDirectory = $Script:PackageDirectory
    )

    begin {
        Invoke-TimeSignal -Start

        if (-not (Test-PathExists -Path $PackageDirectory -Type Container)) { return }
    }

    process {

        $moduleName = ""

        if($Path -like "*Dynamics.AX.*.xppc.log"){
            $splittedString = $Path -split ".*Dynamics.AX.(.*).xppc.log"
            $moduleName = $splittedString[1]
        }elseif ($Path -like "*BuildModelResult.log"){
            $splittedString = $Path -split ".*\\(.*)\\BuildModelResult.log"
            $moduleName = $splittedString[1]
        }else{
            $moduleName = (New-Guid).Guid
        }

        $outputFilePath = Join-Path -Path $OutputPath -ChildPath "$moduleName-CompilerResults.xlsx"

        Invoke-CompilerResultAnalyzer -Path $Path -Identifier $moduleName -OutputPath $outputFilePath -SkipWarnings:$SkipWarnings -SkipTasks:$SkipTasks -PackageDirectory $PackageDirectory
    }
    
    end {
        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Invoke the one of the data flush classes
         
    .DESCRIPTION
        Invoke one of the runnable classes that is clearing cache, data or something else
         
    .PARAMETER URL
        URL to the Dynamics 365 instance you want to clear the AOD cache on
         
    .PARAMETER Class
        The class that you want to execute.
         
        Default value is "SysFlushAod"
         
    .EXAMPLE
        PS C:\> Invoke-D365DataFlush
         
        This will make a call against the default URL for the machine and
        have it execute the SysFlushAOD class.
         
    .EXAMPLE
        PS C:\> Invoke-D365DataFlush -Class SysFlushData,SysFlushAod
         
        This will make a call against the default URL for the machine and
        have it execute the SysFlushData and SysFlushAod classes.
         
    .NOTES
        Tags: Flush, Url, Servicing
         
        Author: M�tz Jensen (@Splaxi)
#>

function Invoke-D365DataFlush {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false, Position = 1 )]
        [string] $Url,

        [ValidateSet('SysFlushData', 'SysFlushAod', 'SysDataCacheParameters')]
        [string[]] $Class = "SysFlushAod"
    )

    if ($PSBoundParameters.ContainsKey("URL")) {
        foreach ($item in $Class) {
            Write-PSFMessage -Level Verbose -Message "Executing Invoke-D365SysRunnerClass with $item" -Target $item
            Invoke-D365SysRunnerClass -ClassName $item -Url $URL
        }
    }
    else {
        foreach ($item in $Class) {
            Write-PSFMessage -Level Verbose -Message "Executing Invoke-D365SysRunnerClass with $item" -Target $item
            Invoke-D365SysRunnerClass -ClassName $item
        }
    }
}


<#
    .SYNOPSIS
        Invoke the synchronization process used in Visual Studio
         
    .DESCRIPTION
        Uses the sync.exe (engine) to synchronize the database for the environment
         
    .PARAMETER BinDirTools
        Path to where the tools on the machine can be found
         
        Default value is normally the AOS Service PackagesLocalDirectory\bin
         
    .PARAMETER MetadataDir
        Path to where the tools on the machine can be found
         
        Default value is normally the AOS Service PackagesLocalDirectory
         
    .PARAMETER SyncMode
        The sync mode the sync engine will use
         
        Default value is: "FullAll"
    .PARAMETER Verbosity
        Parameter used to instruct the level of verbosity the sync engine has to report back
         
        Default value is: "Normal"
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Invoke-D365DBSync
         
        This will invoke the sync engine and have it work against the database.
         
    .EXAMPLE
        PS C:\> Invoke-D365DBSync -Verbose
         
        This will invoke the sync engine and have it work against the database. It will output the same level of details that Visual Studio would normally do.
         
    .NOTES
        Tags: Database, Sync, SyncDB, Synchronization, Servicing
         
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
        When running the 'FullAll' (default) the command requires an elevated console / Run As Administrator.
         
#>


function Invoke-D365DbSync {
    [CmdletBinding()]
    param (
        [string] $BinDirTools = $Script:BinDirTools,

        [string] $MetadataDir = $Script:MetaDataDir,

        #[ValidateSet('None', 'PartialList','InitialSchema','FullIds','PreTableViewSyncActions','FullTablesAndViews','PostTableViewSyncActions','KPIs','AnalysisEnums','DropTables','FullSecurity','PartialSecurity','CleanSecurity','ADEs','FullAll','Bootstrap','LegacyIds','Diag')]
        [string] $SyncMode = 'FullAll',
        
        [ValidateSet('Normal', 'Quiet', 'Minimal', 'Normal', 'Detailed', 'Diagnostic')]
        [string] $Verbosity = 'Normal',

        [string] $DatabaseServer = $Script:DatabaseServer,

        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword,
 
        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\DbSync"),

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly

    )

    Invoke-TimeSignal -Start

    #! The way the sync engine works is that it uses the connection string for some operations,
    #! but for FullSync / FullAll it depends on the database details from the same assemblies that
    #! we rely on. So the testing of how to run this cmdlet is a bit different than others

    Write-PSFMessage -Level Debug -Message "Testing if run on LocalHostedTier1 and console isn't elevated"
    if ($Script:EnvironmentType -eq [EnvironmentType]::LocalHostedTier1 -and !$script:IsAdminRuntime) {
        Write-PSFMessage -Level Host -Message "It seems that you ran this cmdlet <c='em'>non-elevated</c> and on a <c='em'>local VM / local vhd</c>. Being on a local VM / local VHD requires you to run this cmdlet from an elevated console. Please exit the current console and start a new with `"Run As Administrator`""
        Stop-PSFFunction -Message "Stopping because of missing parameters"
        return
    }
    elseif (!$script:IsAdminRuntime -and $Script:UserIsAdmin -and $Script:EnvironmentType -ne [EnvironmentType]::LocalHostedTier1) {
        Write-PSFMessage -Level Host -Message "It seems that you ran this cmdlet <c='em'>non-elevated</c> and as an <c='em'>administrator</c>. You should either logon as a non-admin user account on this machine or run this cmdlet from an elevated console. Please exit the current console and start a new with `"Run As Administrator`" or simply logon as another user"
        Stop-PSFFunction -Message "Stopping because of missing parameters"
        return
    }

    $executable = Join-Path -Path $BinDirTools -ChildPath "SyncEngine.exe"
    if (-not (Test-PathExists -Path $executable -Type Leaf)) { return }
    if (-not (Test-PathExists -Path $MetadataDir -Type Container)) { return }

    Write-PSFMessage -Level Debug -Message "Testing if the SyncEngine is already running."
    $syncEngine = Get-Process -Name "SyncEngine" -ErrorAction SilentlyContinue
    
    if ($null -ne $syncEngine) {
        Write-PSFMessage -Level Host -Message "A instance of SyncEngine is <c='em'>already running</c>. Please <c='em'>wait</c> for it to finish or <c='em'>kill it</c>."
        Stop-PSFFunction -Message "Stopping because SyncEngine.exe already running"
        return
    }
    
    Write-PSFMessage -Level Debug -Message "Build the parameters for the command to execute."
    $params = @("-syncmode=$($SyncMode.ToLower())",
        "-verbosity=$($Verbosity.ToLower())",
        "-metadatabinaries=`"$MetadataDir`"",
        "-connect=`"server=$DatabaseServer;Database=$DatabaseName; User Id=$SqlUser;Password=$SqlPwd;`""
    )

    Write-PSFMessage -Level Debug -Message "Starting the SyncEngine with the parameters." -Target $param
    #! We should consider to redirect the standard output & error like this: https://stackoverflow.com/questions/8761888/capturing-standard-out-and-error-with-start-process
    Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath
    
    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Synchronize all sync base and extension elements based on a modulename
         
    .DESCRIPTION
        Retrieve the list of installed packages / modules where the name fits the ModelName parameter.
         
        It will run loop over the list and start the sync process against all tables, views, data entities, table-extensions,
        view-extensions and data entities-extensions of every iterated model
         
    .PARAMETER Module
        Name of the model you want to sync tables and table extensions
         
        Supports an array of module names
         
    .PARAMETER LogPath
        The path where the log file will be saved
         
    .PARAMETER Verbosity
        Parameter used to instruct the level of verbosity the sync engine has to report back
         
        Default value is: "Normal"
         
    .PARAMETER BinDirTools
        Path to where the tools on the machine can be found
         
        Default value is normally the AOS Service PackagesLocalDirectory\bin
         
    .PARAMETER MetadataDir
        Path to where the tools on the machine can be found
         
        Default value is normally the AOS Service PackagesLocalDirectory
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Invoke-D365DbSyncModule -Module "MyModel1"
         
        It will start the sync process against all tables, views, data entities, table-extensions, view-extensions and data entities-extensions of MyModel1.
         
    .EXAMPLE
        PS C:\> Invoke-D365DbSyncModule -Module "MyModel1","MyModel2"
         
        It will run loop over the list and start the sync process against all tables, views, data entities, table-extensions, view-extensions and data entities-extensions of every iterated model.
         
    .EXAMPLE
        PS C:\> Get-D365Module -Name "MyModel*" | Invoke-D365DbSyncModule
         
        Retrieve the list of installed packages / modules where the name fits the search "MyModel*".
         
        The result is:
        MyModel1
        MyModel2
         
        It will run loop over the list and start the sync process against all tables, views, data entities, table-extensions, view-extensions and data entities-extensions of every iterated model.
         
    .NOTES
        Tags: Database, Sync, SyncDB, Synchronization, Servicing
         
        Author: Jasper Callens - Cegeka
         
        Author: Caleb Blanchard (@daxcaleb)
         
        Author: M�tz Jensen (@Splaxi)
#>


function Invoke-D365DbSyncModule {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias("ModuleName")]
        [string[]] $Module,

        [ValidateSet('Normal', 'Quiet', 'Minimal', 'Normal', 'Detailed', 'Diagnostic')]
        [string] $Verbosity = 'Normal',

        [string] $BinDirTools = $Script:BinDirTools,

        [string] $MetadataDir = $Script:MetaDataDir,

        [string] $DatabaseServer = $Script:DatabaseServer,

        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\DbSync"),

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly
    )

    begin {
        Invoke-TimeSignal -Start

        [System.Collections.Generic.List[System.String]] $modules = @()
    }

    process {
        foreach ($moduleLocal in $Module) {
            $modules.Add($moduleLocal)
        }
    }

    end {
        # Retrieve all sync elements of provided module name
        $allModelSyncElements = $modules.ToArray() | Get-SyncElements

        # Build parameters for the partial sync function
        $syncParams = @{
            SyncList             = $allModelSyncElements.BaseSyncElements;
            SyncExtensionsList   = $allModelSyncElements.ExtensionSyncElements;
            Verbosity            = $Verbosity;
            BinDirTools          = $BinDirTools;
            MetadataDir          = $MetadataDir;
            DatabaseServer       = $DatabaseServer;
            DatabaseName         = $DatabaseName;
            SqlUser              = $SqlUser;
            SqlPwd               = $SqlPwd;
            LogPath              = $LogPath;
            ShowOriginalProgress = $ShowOriginalProgress;
            OutputCommandOnly    = $OutputCommandOnly;
        }

        # Call the partial sync using required parameters
        $resSyncModule = Invoke-D365DBSyncPartial @syncParams

        $resSyncModule

        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Invoke the synchronization process used in Visual Studio
         
    .DESCRIPTION
        Uses the sync.exe (engine) to synchronize the database for the environment
         
    .PARAMETER SyncMode
        The sync mode the sync engine will use
         
        Default value is: "PartialList"
         
    .PARAMETER SyncList
        The list of objects that you want to pass on to the database synchronoziation engine
         
    .PARAMETER SyncExtensionsList
        The list of extension objects that you want to pass on to the database synchronoziation engine
         
    .PARAMETER Verbosity
        Parameter used to instruct the level of verbosity the sync engine has to report back
         
        Default value is: "Normal"
         
    .PARAMETER BinDirTools
        Path to where the tools on the machine can be found
         
        Default value is normally the AOS Service PackagesLocalDirectory\bin
         
    .PARAMETER MetadataDir
        Path to where the tools on the machine can be found
         
        Default value is normally the AOS Service PackagesLocalDirectory
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Invoke-D365DBSyncPartial -SyncList "CustCustomerEntity","SalesTable"
         
        This will invoke the sync engine and have it work against the database.
        It will run with the default value "PartialList" as the SyncMode.
        It will run the sync process against "CustCustomerEntity" and "SalesTable"
         
    .EXAMPLE
        PS C:\> Invoke-D365DBSyncPartial -SyncList "CustCustomerEntity","SalesTable" -Verbose
         
        This will invoke the sync engine and have it work against the database.
        It will run with the default value "PartialList" as the SyncMode.
        It will run the sync process against "CustCustomerEntity" and "SalesTable"
         
        It will output the same level of details that Visual Studio would normally do.
         
    .EXAMPLE
        PS C:\> Invoke-D365DBSyncPartial -SyncList "CustCustomerEntity","SalesTable" -SyncExtensionsList "CaseLog.Extension","CategoryTable.Extension" -Verbose
         
        This will invoke the sync engine and have it work against the database.
        It will run with the default value "PartialList" as the SyncMode.
        It will run the sync process against "CustCustomerEntity", "SalesTable", "CaseLog.Extension" and "CategoryTable.Extension"
         
        It will output the same level of details that Visual Studio would normally do.
         
    .NOTES
        Tags: Database, Sync, SyncDB, Synchronization, Servicing
         
        Author: M�tz Jensen (@Splaxi)
         
        Author: Jasper Callens - Cegeka
         
        Inspired by:
        https://axdynamx.blogspot.com/2017/10/how-to-synchronize-manually-database.html
         
#>


function Invoke-D365DbSyncPartial {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string[]] $SyncList,

        [string[]] $SyncExtensionsList,

        #[ValidateSet('None', 'PartialList','InitialSchema','FullIds','PreTableViewSyncActions','FullTablesAndViews','PostTableViewSyncActions','KPIs','AnalysisEnums','DropTables','FullSecurity','PartialSecurity','CleanSecurity','ADEs','FullAll','Bootstrap','LegacyIds','Diag')]
        [string] $SyncMode = 'PartialList',

        [ValidateSet('Normal', 'Quiet', 'Minimal', 'Normal', 'Detailed', 'Diagnostic')]
        [string] $Verbosity = 'Normal',

        [string] $BinDirTools = $Script:BinDirTools,

        [string] $MetadataDir = $Script:MetaDataDir,

        [string] $DatabaseServer = $Script:DatabaseServer,

        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\DbSync"),

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly
    )

    process {
        Invoke-TimeSignal -Start
        
        #! The way the sync engine works is that it uses the connection string for some operations,
        #! but for FullSync / FullAll it depends on the database details from the same assemblies that
        #! we rely on. So the testing of how to run this cmdlet is a bit different than others

        Write-PSFMessage -Level Debug -Message "Testing if run on LocalHostedTier1 and console isn't elevated"
        if ($Script:EnvironmentType -eq [EnvironmentType]::LocalHostedTier1 -and !$script:IsAdminRuntime) {
            Write-PSFMessage -Level Host -Message "It seems that you ran this cmdlet <c='em'>non-elevated</c> and on a <c='em'>local VM / local vhd</c>. Being on a local VM / local VHD requires you to run this cmdlet from an elevated console. Please exit the current console and start a new with `"Run As Administrator`""
            Stop-PSFFunction -Message "Stopping because of missing parameters"
            return
        }
        elseif (!$script:IsAdminRuntime -and $Script:UserIsAdmin -and $Script:EnvironmentType -ne [EnvironmentType]::LocalHostedTier1) {
            Write-PSFMessage -Level Host -Message "It seems that you ran this cmdlet <c='em'>non-elevated</c> and as an <c='em'>administrator</c>. You should either logon as a non-admin user account on this machine or run this cmdlet from an elevated console. Please exit the current console and start a new with `"Run As Administrator`" or simply logon as another user"
            Stop-PSFFunction -Message "Stopping because of missing parameters"
            return
        }

        $executable = Join-Path -Path $BinDirTools -ChildPath "SyncEngine.exe"
        if (-not (Test-PathExists -Path $executable -Type Leaf)) { return }
        if (-not (Test-PathExists -Path $MetadataDir -Type Container)) { return }

        Write-PSFMessage -Level Debug -Message "Testing if the SyncEngine is already running."
        $syncEngine = Get-Process -Name "SyncEngine" -ErrorAction SilentlyContinue
        
        if ($null -ne $syncEngine) {
            Write-PSFMessage -Level Host -Message "A instance of SyncEngine is <c='em'>already running</c>. Please <c='em'>wait</c> for it to finish or <c='em'>kill it</c>."
            Stop-PSFFunction -Message "Stopping because SyncEngine.exe already running"
            return
        }
        
        Write-PSFMessage -Level Debug -Message "Build the parameters for the command to execute."
        $params = @("-syncmode=$($SyncMode.ToLower())",
            "-synclist=`"$($SyncList -join ",")`"",
            "-tableextensionlist=`"$($SyncExtensionsList -join ',')`"",
            "-verbosity=$($Verbosity.ToLower())",
            "-metadatabinaries=`"$MetadataDir`"",
            "-connect=`"server=$DatabaseServer;Database=$DatabaseName; User Id=$SqlUser;Password=$SqlPwd;`""
        )

        Write-PSFMessage -Level Debug -Message "Starting the SyncEngine with the parameters." -Target $param
        #! We should consider to redirect the standard output & error like this: https://stackoverflow.com/questions/8761888/capturing-standard-out-and-error-with-start-process
        Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath
        
        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Download AzCopy.exe to your machine
         
    .DESCRIPTION
        Download and extract the AzCopy.exe to your machine
         
    .PARAMETER Url
        Url/Uri to where the latest AzCopy download is located
         
        The default value is for v10 as of writing
         
    .PARAMETER Path
        Path to where you want the AzCopy to be extracted to
         
        Default value is: "C:\temp\d365fo.tools\AzCopy\AzCopy.exe"
         
    .EXAMPLE
        PS C:\> Invoke-D365InstallAzCopy -Path "C:\temp\d365fo.tools\AzCopy\AzCopy.exe"
         
        This will update the path for the AzCopy.exe in the modules configuration
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
#>


function Invoke-D365InstallAzCopy {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    [OutputType()]
    param (
        [string] $Url = "https://aka.ms/downloadazcopy-v10-windows",

        [string] $Path = "C:\temp\d365fo.tools\AzCopy\AzCopy.exe"
    )

    $azCopyFolder = Split-Path $Path -Parent
    $downloadPath = Join-Path -Path $azCopyFolder -ChildPath "AzCopy.zip"

    if (-not (Test-PathExists -Path $azCopyFolder -Type Container -Create)) { return }

    if (Test-PSFFunctionInterrupt) { return }

    Write-PSFMessage -Level Verbose -Message "Downloading AzCopy.zip from the internet. $($Url)" -Target $Url
    (New-Object System.Net.WebClient).DownloadFile($Url, $downloadPath)

    if (-not (Test-PathExists -Path $downloadPath -Type Leaf)) { return }

    Unblock-File -Path $downloadPath

    $tempExtractPath = Join-Path -Path $azCopyFolder -ChildPath "Temp"

    Expand-Archive -Path $downloadPath -DestinationPath $tempExtractPath -Force

    $null = (Get-Item "$tempExtractPath\*\azcopy.exe").CopyTo($Path, $true)

    $tempExtractPath | Remove-Item -Force -Recurse
    $downloadPath | Remove-Item -Force -Recurse

    Set-D365AzCopyPath -Path $Path
}


<#
    .SYNOPSIS
        Install a license for a 3. party solution
         
    .DESCRIPTION
        Install a license for a 3. party solution using the builtin "Microsoft.Dynamics.AX.Deployment.Setup.exe" executable
         
    .PARAMETER Path
        Path to the license file
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory\bin
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Invoke-D365InstallLicense -Path c:\temp\d365fo.tools\license.txt
         
        This will use the default paths and start the Microsoft.Dynamics.AX.Deployment.Setup.exe with the needed parameters to import / install the license file.
         
    .EXAMPLE
        PS C:\> Invoke-D365InstallLicense -Path c:\temp\d365fo.tools\license.txt -ShowOriginalProgress
         
        This will use the default paths and start the Microsoft.Dynamics.AX.Deployment.Setup.exe with the needed parameters to import / install the license file.
        The output from the installation process will be written to the console / host.
         
    .NOTES
        Tags: License, Install, ISV, 3. Party, Servicing
         
        Author: M�tz Jensen (@splaxi)
         
#>

function Invoke-D365InstallLicense {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $True)]
        [Alias('File')]
        [string] $Path,

        [string] $DatabaseServer = $Script:DatabaseServer,

        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [string] $MetaDataDir = "$Script:MetaDataDir",

        [string] $BinDir = "$Script:BinDir",

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\InstallLicense"),

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly
    )

    $executable = Join-Path -Path $BinDir -ChildPath "bin\Microsoft.Dynamics.AX.Deployment.Setup.exe"

    if (-not (Test-PathExists -Path $MetaDataDir,$BinDir -Type Container)) {return}
    if (-not (Test-PathExists -Path $Path,$executable -Type Leaf)) {return}

    Invoke-TimeSignal -Start

    $params = @("-isemulated", "true",
        "-sqluser", "$SqlUser",
        "-sqlpwd", "$SqlPwd",
        "-sqlserver", "$DatabaseServer",
        "-sqldatabase", "$DatabaseName",
        "-metadatadir", "$MetaDataDir",
        "-bindir", "$BinDir",
        "-setupmode", "importlicensefile",
        "-licensefilename", "`"$Path`"")

    Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Download SqlPackage.exe to your machine
         
    .DESCRIPTION
        Download and extract the DotNet/.NET core x64 edition of the SqlPackage.exe to your machine
         
        It parses the raw html page and tries to extract the latest download link
         
    .PARAMETER Path
        Path to where you want the SqlPackage to be extracted to
         
        Default value is: "C:\temp\d365fo.tools\SqlPackage\SqlPackage.exe"
         
    .PARAMETER SkipExtractFromPage
        Instruct the cmdlet to skip trying to parse the download page and to rely on the Url parameter only
         
    .PARAMETER Url
        Url/Uri to where the latest SqlPackage download is located
         
        The default value is for v18.4.1 (15.0.4630.1) as of writing
         
    .EXAMPLE
        PS C:\> Invoke-D365InstallSqlPackage
         
        This will download and extract the latest SqlPackage.exe.
        It will use the default value for the Path parameter, for where to save the SqlPackage.exe.
        It will try to extract the latest download URL from the RAW html page.
        It will update the path for the SqlPackage.exe in configuration.
         
    .EXAMPLE
        PS C:\> Invoke-D365InstallSqlPackage -Path "C:\temp\SqlPackage"
         
        This will download and extract the latest SqlPackage.exe.
        It will try to extract the latest download URL from the RAW html page.
        It will update the path for the SqlPackage.exe in configuration.
         
    .EXAMPLE
        PS C:\> Invoke-D365InstallSqlPackage -SkipExtractFromPage
         
        This will download and extract the latest SqlPackage.exe.
        It will rely on the Url parameter to based the download from.
        It will use the default value of the Url parameter.
        It will update the path for the SqlPackage.exe in configuration.
         
    .EXAMPLE
        PS C:\> Invoke-D365InstallSqlPackage -SkipExtractFromPage -Url "https://go.microsoft.com/fwlink/?linkid=3030303"
         
        This will download and extract the latest SqlPackage.exe.
        It will rely on the Url parameter to based the download from.
        It will use the "https://go.microsoft.com/fwlink/?linkid=3030303" as value for the Url parameter.
        It will update the path for the SqlPackage.exe in configuration.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
#>


function Invoke-D365InstallSqlPackage {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    [OutputType()]
    param (
        [string] $Path = "C:\temp\d365fo.tools\SqlPackage",

        [switch] $SkipExtractFromPage,

        [string] $Url = "https://go.microsoft.com/fwlink/?linkid=2113704"
    )

    if (-not $SkipExtractFromPage) {
        $content = (Invoke-WebRequest -Uri "https://docs.microsoft.com/en-us/sql/tools/sqlpackage-download" -UseBasicParsing).content
        $res = $content -match '<td.*>Windows .NET Core<.*/td>\s*<td.*><a href="(https://.*)" .*'
        
        if ($res) {
            $Url = ([string]$Matches[1]).Trim()
        }
        else {
            Write-PSFMessage -Level Host -Message "Parsing the web page didn't succeed. Will fall back to the default download url." -Target "https://docs.microsoft.com/en-us/sql/tools/sqlpackage-download"
        }
    }

    $sqlPackageFolder = $Path
    $downloadPath = Join-Path -Path $sqlPackageFolder -ChildPath "SqlPackage.zip"

    if (-not (Test-PathExists -Path $sqlPackageFolder -Type Container -Create)) { return }

    if (Test-PSFFunctionInterrupt) { return }

    Write-PSFMessage -Level Verbose -Message "Downloading SqlPackage.zip from the internet. $($Url)" -Target $Url
    (New-Object System.Net.WebClient).DownloadFile($Url, $downloadPath)

    if (-not (Test-PathExists -Path $downloadPath -Type Leaf)) { return }

    Unblock-File -Path $downloadPath

    $tempExtractPath = Join-Path -Path $sqlPackageFolder -ChildPath "Temp"

    Expand-Archive -Path $downloadPath -DestinationPath $tempExtractPath -Force

    Get-ChildItem -Path $tempExtractPath | Move-Item -Destination { $_.Directory.Parent.FullName } -Force

    $tempExtractPath | Remove-Item -Force -Recurse
    $downloadPath | Remove-Item -Force -Recurse

    Set-D365SqlPackagePath $(Join-Path -Path $Path -ChildPath "SqlPackage.exe")
}


<#
    .SYNOPSIS
        Refresh the token for lcs communication
         
    .DESCRIPTION
        Invoke the refresh logic that refreshes the token object based on the ClientId and RefreshToken
         
    .PARAMETER ClientId
        The Azure Registered Application Id / Client Id obtained while creating a Registered App inside the Azure Portal
         
    .PARAMETER RefreshToken
        The Refresh Token that you want to use for the authentication process
         
    .PARAMETER InputObject
        The entire object that you received from the Get-D365LcsApiToken command, which contains the needed RefreshToken
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-D365LcsApiRefreshToken -ClientId "9b4f4503-b970-4ade-abc6-2c086e4c4929" -RefreshToken "Tsdljfasfe2j32324"
         
        This will refresh an OAuth 2.0 access token, and obtain a (new) valid OAuth 2.0 access token from Azure Active Directory.
        The ClientId "9b4f4503-b970-4ade-abc6-2c086e4c4929" is used in the OAuth 2.0 "Refresh Token" Grant Flow to authenticate.
        The RefreshToken "Tsdljfasfe2j32324" is used to prove to Azure Active Directoy that we are allowed to obtain a new valid Access Token.
         
    .EXAMPLE
        PS C:\> $temp = Get-D365LcsApiToken -LcsApiUri "https://lcsapi.eu.lcs.dynamics.com" -ClientId "9b4f4503-b970-4ade-abc6-2c086e4c4929" -Username "serviceaccount@domain.com" -Password "TopSecretPassword"
        PS C:\> $temp = Invoke-D365LcsApiRefreshToken -ClientId "9b4f4503-b970-4ade-abc6-2c086e4c4929" -InputObject $temp
         
        This will refresh an OAuth 2.0 access token, and obtain a (new) valid OAuth 2.0 access token from Azure Active Directory.
        This will obtain a new token object from the Get-D365LcsApiToken cmdlet and store it in $temp.
        Then it will pass $temp to the Invoke-D365LcsApiRefreshToken along with the ClientId "9b4f4503-b970-4ade-abc6-2c086e4c4929".
        The new token object will be save into $temp.
         
    .EXAMPLE
        PS C:\> Get-D365LcsApiConfig | Invoke-D365LcsApiRefreshToken | Set-D365LcsApiConfig
         
        This will refresh an OAuth 2.0 access token, and obtain a (new) valid OAuth 2.0 access token from Azure Active Directory.
        This will fetch the current LCS API details from Get-D365LcsApiConfig.
        The output from Get-D365LcsApiConfig is piped directly to Invoke-D365LcsApiRefreshToken, which will fetch a new token object.
        The new token object is piped directly into Set-D365LcsApiConfig, which will save the needed details into the configuration store.
         
    .LINK
        Get-D365LcsApiConfig
         
    .LINK
        Get-D365LcsApiToken
         
    .LINK
        Get-D365LcsAssetValidationStatus
         
    .LINK
        Get-D365LcsDeploymentStatus
         
    .LINK
        Invoke-D365LcsDeployment
         
    .LINK
        Invoke-D365LcsUpload
         
    .LINK
        Set-D365LcsApiConfig
         
    .NOTES
        Tags: LCS, API, Token, BearerToken
         
        Author: M�tz Jensen (@Splaxi)
#>


function Invoke-D365LcsApiRefreshToken {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseProcessBlockForPipelineCommand", "")]
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Simple")]
        [Parameter(Mandatory = $true, ParameterSetName = "Object")]
        [string] $ClientId,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "Simple")]
        [Alias('refresh_token')]
        [Alias('Token')]
        [string] $RefreshToken,

        [Parameter(Mandatory = $false, ParameterSetName = "Object")]
        [PSCustomObject] $InputObject,

        [switch] $EnableException
    )

    if ($PsCmdlet.ParameterSetName -eq "Simple") {
        Invoke-RefreshToken -AuthProviderUri $Script:AADOAuthEndpoint @PSBoundParameters
    }
    else {
        Invoke-RefreshToken -AuthProviderUri $Script:AADOAuthEndpoint -ClientId $ClientId -RefreshToken $InputObject.refresh_token
    }
}


<#
    .SYNOPSIS
        Start a database export from an environment
         
    .DESCRIPTION
        Start a database export from an environment from a LCS project
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER SourceEnvironmentId
        The unique id of the environment that you want to use as the source for the database export
         
        The Id can be located inside the LCS portal
         
    .PARAMETER BackupName
        Name of the backup file when it is being exported from the environment
         
        The file shouldn't contain any extension at all, just the desired file name
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER SkipInitialStatusFetch
        Instruct the cmdlet to skip the first fetch of the database refresh status
         
        Useful when you have a large script that handles this status validation and you don't want to spend time with this cmdlet
         
        Default output from this cmdlet is 2 (two) different objects. The first object is the response object for starting the export operation. The second object is the response object from fetching the status of the export operation.
         
        Setting this parameter (activate it), will affect the number of output objects. If you skip, only the first response object outputted.
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-D365LcsDatabaseExport -ProjectId 123456789 -SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae" -BackupName "BackupViaApi" -BearerToken "JldjfafLJdfjlfsalfd..." -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will start the database export from the Source environment.
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The source environment is identified by the SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae", which can be obtained in the LCS portal.
        The backup name is identified by the BackupName "BackupViaApi", which instructs the API to save the backup with that filename.
        The request will authenticate with the BearerToken "JldjfafLJdfjlfsalfd...".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .EXAMPLE
        PS C:\> Invoke-D365LcsDatabaseExport -SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae" -BackupName "BackupViaApi"
         
        This will start the database export from the Source environment.
        The source environment is identified by the SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae", which can be obtained in the LCS portal.
        The backup name is identified by the BackupName "BackupViaApi", which instructs the API to save the backup with that filename.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .EXAMPLE
        PS C:\> $databaseExport = Invoke-D365LcsDatabaseExport -SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae" -BackupName "BackupViaApi" -SkipInitialStatusFetch
        PS C:\> $databaseExport | Get-D365LcsDatabaseOperationStatus -EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e9" -SleepInSeconds 60
         
        This will start the database export from the Source environment.
        The source environment is identified by the SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae", which can be obtained in the LCS portal.
        The backup name is identified by the BackupName "BackupViaApi", which instructs the API to save the backup with that filename.
        It will skip the first database operation status fetch and only output the details from starting the export.
         
        The output from Invoke-D365LcsDatabaseExport is stored in the $databaseExport. This will enable you to pass the $databaseExport variable to other cmdlets which should make things easier for you.
         
        Will pipe the $databaseExport variable to the Get-D365LcsDatabaseOperationStatus cmdlet and get the status from the database export job.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .EXAMPLE
        PS C:\> Invoke-D365LcsDatabaseExport -SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae" -BackupName "BackupViaApi" -SkipInitialStatusFetch
         
        This will start the database export from the Source environment.
        The source environment is identified by the SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae", which can be obtained in the LCS portal.
        The backup name is identified by the BackupName "BackupViaApi", which instructs the API to save the backup with that filename.
        It will skip the first database operation status fetch and only output the details from starting the export.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .LINK
        Get-D365LcsDatabaseOperationStatus
         
    .LINK
        Get-D365LcsApiConfig
         
    .LINK
        Get-D365LcsApiToken
         
    .LINK
        Get-D365LcsAssetValidationStatus
         
    .LINK
        Get-D365LcsDeploymentStatus
         
    .LINK
        Invoke-D365LcsApiRefreshToken
         
    .LINK
        Invoke-D365LcsUpload
         
    .LINK
        Set-D365LcsApiConfig
         
    .NOTES
        The ActivityId property is a custom property that ISN'T part of the response from the LCS API. The ActivityId is always the same as the OperationActivityId (original LCS property).
        The EnvironmentId property is a custom property that ISN'T part of the response from the LCS API. The EnvironmentId is always the same as the SourceEnvironmentId parameter you have supplied to this cmdlet.
         
        Default output from this cmdlet is 2 (two) different objects. The first object is the response object for starting the export operation. The second object is the response object from fetching the status of the export operation.
         
        Setting the SkipInitialStatusFetch parameter (activate it), will affect the number of output objects. If you skip, only the first response object outputted.
         
        Running with the default (SkipInitialStatusFetch NOT being set), will instruct the cmdlet to call the Get-D365LcsDatabaseOperationStatus cmdlet. This will output a second object, with other properties than the first object outputted.
         
        Tags: Environment, Config, Configuration, LCS, Database backup, Api, Backup, Bacpac
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Invoke-D365LcsDatabaseExport {
    [CmdletBinding()]
    [OutputType()]
    param(
        [Parameter(Mandatory = $false)]
        [int] $ProjectId = $Script:LcsApiProjectId,
        
        [Parameter(Mandatory = $false)]
        [Alias('Token')]
        [string] $BearerToken = $Script:LcsApiBearerToken,

        [Parameter(Mandatory = $true)]
        [string] $SourceEnvironmentId,
        
        [Parameter(Mandatory = $true)]
        [string] $BackupName,

        [Parameter(Mandatory = $false)]
        [string] $LcsApiUri = $Script:LcsApiLcsApiUri,

        [switch] $SkipInitialStatusFetch,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    if (-not ($BearerToken.StartsWith("Bearer "))) {
        $BearerToken = "Bearer $BearerToken"
    }

    $exportJob = Start-LcsDatabaseExport -ProjectId $ProjectId -BearerToken $BearerToken -SourceEnvironmentId $SourceEnvironmentId -BackupName $BackupName -LcsApiUri $LcsApiUri

    if (Test-PSFFunctionInterrupt) { return }

    $temp = [PSCustomObject]@{ Value = "$SourceEnvironmentId" }
    #Hack to silence the PSScriptAnalyzer
    $temp | Out-Null
 
    $exportJob | Select-PSFObject *, "OperationActivityId as ActivityId", "Value from temp as EnvironmentId" -TypeName "D365FO.TOOLS.LCS.Database.Operation"

    if (-not $SkipInitialStatusFetch) {
        Get-D365LcsDatabaseOperationStatus -ProjectId $ProjectId -BearerToken $BearerToken -OperationActivityId $($exportJob.OperationActivityId) -EnvironmentId $SourceEnvironmentId -LcsApiUri $LcsApiUri -WaitForCompletion:$false -SleepInSeconds 60
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Start a database refresh between 2 environments
         
    .DESCRIPTION
        Start a database refresh between 2 environments from a LCS project
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER SourceEnvironmentId
        The unique id of the environment that you want to use as the source for the database refresh
         
        The Id can be located inside the LCS portal
         
    .PARAMETER TargetEnvironmentId
        The unique id of the environment that you want to use as the target for the database refresh
         
        The Id can be located inside the LCS portal
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER SkipInitialStatusFetch
        Instruct the cmdlet to skip the first fetch of the database refresh status
         
        Useful when you have a large script that handles this status validation and you don't want to spend time with this cmdlet
         
        Default output from this cmdlet is 2 (two) different objects. The first object is the response object for starting the refresh operation. The second object is the response object from fetching the status of the refresh operation.
         
        Setting this parameter (activate it), will affect the number of output objects. If you skip, only the first response object outputted.
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-D365LcsDatabaseRefresh -ProjectId 123456789 -SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae" -TargetEnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e" -BearerToken "JldjfafLJdfjlfsalfd..." -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will start the database refresh between the Source and Target environments.
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The source environment is identified by the SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae", which can be obtained in the LCS portal.
        The target environment is identified by the TargetEnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
        The request will authenticate with the BearerToken "JldjfafLJdfjlfsalfd...".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .EXAMPLE
        PS C:\> Invoke-D365LcsDatabaseRefresh -SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae" -TargetEnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e"
         
        This will start the database refresh between the Source and Target environments.
        The source environment is identified by the SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae", which can be obtained in the LCS portal.
        The target environment is identified by the TargetEnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .EXAMPLE
        PS C:\> $databaseRefresh = Invoke-D365LcsDatabaseRefresh -SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae" -TargetEnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e" -SkipInitialStatusFetch
        PS C:\> $databaseRefresh | Get-D365LcsDatabaseOperationStatus -EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e9" -SleepInSeconds 60
         
        This will start the database refresh between the Source and Target environments.
        The source environment is identified by the SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae", which can be obtained in the LCS portal.
        The target environment is identified by the TargetEnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
        It will skip the first database refesh status fetch and only output the details from starting the refresh.
         
        The output from Invoke-D365LcsDatabaseRefresh is stored in the $databaseRefresh. This will enable you to pass the $databaseRefresh variable to other cmdlets which should make things easier for you.
         
        Will pipe the $databaseRefresh variable to the Get-D365LcsDatabaseOperationStatus cmdlet and get the status from the database refresh job.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
         
        $databaseRefresh = Invoke-D365LcsDatabaseRefresh -SourceEnvironmentId be9aa4a4-7621-4b7e-b6f5-d518bf0012de -TargetEnvironmentId 43bcc00a-d94c-47cd-a20f-3c7aee98b5a9
         
    .EXAMPLE
        PS C:\> Invoke-D365LcsDatabaseRefresh -SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae" -TargetEnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e" -SkipInitialStatusFetch
         
        This will start the database refresh between the Source and Target environments.
        The source environment is identified by the SourceEnvironmentId "958ae597-f089-4811-abbd-c1190917eaae", which can be obtained in the LCS portal.
        The target environment is identified by the TargetEnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
        It will skip the first database refesh status fetch and only output the details from starting the refresh.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .LINK
        Get-D365LcsApiConfig
         
    .LINK
        Get-D365LcsApiToken
         
    .LINK
        Get-D365LcsAssetValidationStatus
         
    .LINK
        Get-D365LcsDeploymentStatus
         
    .LINK
        Invoke-D365LcsApiRefreshToken
         
    .LINK
        Invoke-D365LcsUpload
         
    .LINK
        Set-D365LcsApiConfig
         
    .NOTES
        The ActivityId property is a custom property that ISN'T part of the response from the LCS API. The ActivityId is always the same as the OperationActivityId (original LCS property).
        The EnvironmentId property is a custom property that ISN'T part of the response from the LCS API. The EnvironmentId is always the same as the SourceEnvironmentId parameter you have supplied to this cmdlet.
         
        Default output from this cmdlet is 2 (two) different objects. The first object is the response object for starting the refresh operation. The second object is the response object from fetching the status of the refresh operation.
         
        Setting the SkipInitialStatusFetch parameter (activate it), will affect the number of output objects. If you skip, only the first response object outputted.
         
        Running with the default (SkipInitialStatusFetch NOT being set), will instruct the cmdlet to call the Get-D365LcsDatabaseOperationStatus cmdlet. This will output a second object, with other properties than the first object outputted.
         
        Tags: Environment, Config, Configuration, LCS, Database backup, Api, Backup, Restore, Refresh
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Invoke-D365LcsDatabaseRefresh {
    [CmdletBinding()]
    [OutputType()]
    param(
        [Parameter(Mandatory = $false)]
        [int] $ProjectId = $Script:LcsApiProjectId,
        
        [Parameter(Mandatory = $false)]
        [Alias('Token')]
        [string] $BearerToken = $Script:LcsApiBearerToken,

        [Parameter(Mandatory = $true)]
        [string] $SourceEnvironmentId,
        
        [Parameter(Mandatory = $true)]
        [string] $TargetEnvironmentId,

        [Parameter(Mandatory = $false)]
        [string] $LcsApiUri = $Script:LcsApiLcsApiUri,

        [switch] $SkipInitialStatusFetch,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    if (-not ($BearerToken.StartsWith("Bearer "))) {
        $BearerToken = "Bearer $BearerToken"
    }

    $refreshJob = Start-LcsDatabaseRefresh -ProjectId $ProjectId -BearerToken $BearerToken -SourceEnvironmentId $SourceEnvironmentId -TargetEnvironmentId $TargetEnvironmentId -LcsApiUri $LcsApiUri

    if (Test-PSFFunctionInterrupt) { return }

    $temp = [PSCustomObject]@{ Value = "$TargetEnvironmentId" }
    #Hack to silence the PSScriptAnalyzer
    $temp | Out-Null
 
    $refreshJob | Select-PSFObject *, "OperationActivityId as ActivityId", "Value from temp as EnvironmentId" -TypeName "D365FO.TOOLS.LCS.Database.Operation"

    if (-not $SkipInitialStatusFetch) {
        Get-D365LcsDatabaseOperationStatus -ProjectId $ProjectId -BearerToken $BearerToken -OperationActivityId $($refreshJob.OperationActivityId) -EnvironmentId $TargetEnvironmentId -LcsApiUri $LcsApiUri -WaitForCompletion:$false -SleepInSeconds 60
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Start the deployment of a deployable package
         
    .DESCRIPTION
        Deploy a deployable package from the Asset Library from a LCS project using the API provided by Microsoft
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER AssetId
        The unique id of the asset / file that you are trying to deploy from LCS
         
    .PARAMETER EnvironmentId
        The unique id of the environment that you want to work against
         
        The Id can be located inside the LCS portal
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER UpdateName
        Name of the update when you are working against Self-Service environments
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-D365LcsDeployment -ProjectId 123456789 -AssetId "958ae597-f089-4811-abbd-c1190917eaae" -EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e" -BearerToken "Bearer JldjfafLJdfjlfsalfd..." -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will start the deployment of the file located in the Asset Library.
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The file is identified by the AssetId "958ae597-f089-4811-abbd-c1190917eaae", which is obtained either by earlier upload or simply looking in the LCS portal.
        The environment is identified by the EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
        The request will authenticate with the BearerToken "Bearer JldjfafLJdfjlfsalfd...".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .EXAMPLE
        PS C:\> Invoke-D365LcsDeployment -AssetId "958ae597-f089-4811-abbd-c1190917eaae" -EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e"
         
        This will start the deployment of the file located in the Asset Library.
        The file is identified by the AssetId "958ae597-f089-4811-abbd-c1190917eaae", which is obtained either by earlier upload or simply looking in the LCS portal.
        The environment is identified by the EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .EXAMPLE
        PS C:\> Invoke-D365LcsDeployment -AssetId "958ae597-f089-4811-abbd-c1190917eaae" -EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e" -UpdateName "Release_XYZ"
         
        This will start the deployment of the file located in the Asset Library against a Self-Service environment.
        The file is identified by the AssetId "958ae597-f089-4811-abbd-c1190917eaae", which is obtained either by earlier upload or simply looking in the LCS portal.
        The environment is identified by the EnvironmentId "13cc7700-c13b-4ea3-81cd-2d26fa72ec5e", which can be obtained in the LCS portal.
        The deployment is name "Release_XYZ" by setting the UpdateName parameter, which is mandatory when working against Self-Service environments.
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .LINK
        Get-D365LcsApiConfig
         
    .LINK
        Get-D365LcsApiToken
         
    .LINK
        Get-D365LcsAssetValidationStatus
         
    .LINK
        Get-D365LcsDeploymentStatus
         
    .LINK
        Invoke-D365LcsApiRefreshToken
         
    .LINK
        Invoke-D365LcsUpload
         
    .LINK
        Set-D365LcsApiConfig
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, LCS, Upload, Api, AAD, Token, Deployment, Deploy
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Invoke-D365LcsDeployment {
    [CmdletBinding(DefaultParameterSetName = "VM")]
    [OutputType()]
    param(
        [int] $ProjectId = $Script:LcsApiProjectId,
        
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string] $AssetId,

        [Parameter(Mandatory = $true)]
        [string] $EnvironmentId,
        
        [Parameter(ParameterSetName = "Self-Service", Mandatory = $true)]
        [string] $UpdateName,

        [Alias('Token')]
        [string] $BearerToken = $Script:LcsApiBearerToken,

        [string] $LcsApiUri = $Script:LcsApiLcsApiUri,

        [switch] $EnableException
    )

    process {
        Invoke-TimeSignal -Start

        if (-not ($BearerToken.StartsWith("Bearer "))) {
            $BearerToken = "Bearer $BearerToken"
        }

        $deploymentStatus = Start-LcsDeployment -BearerToken $BearerToken -ProjectId $ProjectId -AssetId $AssetId -EnvironmentId $EnvironmentId -UpdateName $UpdateName -LcsApiUri $LcsApiUri

        Invoke-TimeSignal -End

        $deploymentStatus
    }
}


<#
    .SYNOPSIS
        Upload a file to a LCS project
         
    .DESCRIPTION
        Upload a file to a LCS project using the API provided by Microsoft
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER FilePath
        Path to the file that you want to upload to the Asset Library on LCS
         
    .PARAMETER FileType
        Type of file you want to upload
         
        Valid options:
        "Model"
        "Process Data Package"
        "Software Deployable Package"
        "GER Configuration"
        "Data Package"
        "PowerBI Report Model"
        "E-Commerce Package"
        "NuGet Package"
        "Retail Self-Service Package"
        "Commerce Cloud Scale Unit Extension"
         
        Default value is "Software Deployable Package"
         
    .PARAMETER FileName
        Name to be assigned / shown on LCS
         
    .PARAMETER FileDescription
        Description to be assigned / shown on LCS
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
        Default value can be configured using Set-D365LcsApiConfig
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-D365LcsUpload -ProjectId 123456789 -BearerToken "Bearer JldjfafLJdfjlfsalfd..." -FilePath "C:\temp\d365fo.tools\Release-2019-05-05.zip" -FileType "SoftwareDeployablePackage" -FileName "Release-2019-05-05" -FileDescription "Build based on sprint: SuperSprint-1" -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will start the upload of a file to the Asset Library.
        The LCS project is identified by the ProjectId 123456789, which can be obtained in the LCS portal.
        The file that will be uploaded is based on the FilePath "C:\temp\d365fo.tools\Release-2019-05-05.zip".
        The file type "Software Deployable Package" determines where inside the Asset Library the file will end up.
        The name inside the Asset Library is based on the FileName "Release-2019-05-05".
        The description inside the Asset Library is based on the FileDescription "Build based on sprint: SuperSprint-1".
        The request will authenticate with the BearerToken "Bearer JldjfafLJdfjlfsalfd...".
        The http request will be going to the LcsApiUri "https://lcsapi.lcs.dynamics.com" (NON-EUROPE).
         
    .EXAMPLE
        PS C:\> Invoke-D365LcsUpload -FilePath "C:\temp\d365fo.tools\Release-2019-05-05.zip" -FileType "SoftwareDeployablePackage" -FileName "Release-2019-05-05"
         
        This will start the upload of a file to the Asset Library.
        The file that will be uploaded is based on the FilePath "C:\temp\d365fo.tools\Release-2019-05-05.zip".
        The file type "Software Deployable Package" determines where inside the Asset Library the file will end up.
        The name inside the Asset Library is based on the FileName "Release-2019-05-05".
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .EXAMPLE
        PS C:\> Invoke-D365LcsUpload -FilePath "C:\temp\d365fo.tools\Release-2019-05-05.zip"
         
        This will start the upload of a file to the Asset Library.
        The file that will be uploaded is based on the FilePath "C:\temp\d365fo.tools\Release-2019-05-05.zip".
         
        All default values will come from the configuration available from Get-D365LcsApiConfig.
         
        The default values can be configured using Set-D365LcsApiConfig.
         
    .LINK
        Get-D365LcsApiConfig
         
    .LINK
        Get-D365LcsApiToken
         
    .LINK
        Get-D365LcsAssetValidationStatus
         
    .LINK
        Get-D365LcsDeploymentStatus
         
    .LINK
        Invoke-D365LcsApiRefreshToken
         
    .LINK
        Invoke-D365LcsDeployment
         
    .LINK
        Set-D365LcsApiConfig
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, LCS, Upload, Api, AAD, Token
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Invoke-D365LcsUpload {
    [CmdletBinding()]
    [OutputType()]
    param(
        [int]$ProjectId = $Script:LcsApiProjectId,
        
        [Alias('Token')]
        [string] $BearerToken = $Script:LcsApiBearerToken,

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

        [LcsAssetFileType] $FileType = [LcsAssetFileType]::SoftwareDeployablePackage,

        [string] $FileName,

        [string] $FileDescription,

        [string] $LcsApiUri = $Script:LcsApiLcsApiUri,

        [switch] $EnableException
    )

    Invoke-TimeSignal -Start

    $fileNameExtracted = Split-Path $FilePath -Leaf

    if ($FileName -eq "") {
        $FileName = $fileNameExtracted
    }

    if (-not ($BearerToken.StartsWith("Bearer "))) {
        $BearerToken = "Bearer $BearerToken"
    }
    
    $blobDetails = Start-LcsUpload -Token $BearerToken -ProjectId $ProjectId -FileType $FileType -LcsApiUri $LcsApiUri -Name $FileName -Description $FileDescription

    if (Test-PSFFunctionInterrupt) { return }

    Write-PSFMessage -Level Verbose -Message "Start response" -Target $blobDetails

    $uploadResponse = Copy-FileToLcsBlob -FilePath $FilePath -FullUri $blobDetails.FileLocation

    if (Test-PSFFunctionInterrupt) { return }

    Write-PSFMessage -Level Verbose -Message "Upload response" -Target $uploadResponse

    $ackResponse = Complete-LcsUpload -Token $BearerToken -ProjectId $ProjectId -AssetId $blobDetails.Id -LcsApiUri $LcsApiUri

    if (Test-PSFFunctionInterrupt) { return }

    Write-PSFMessage -Level Verbose -Message "Commit response" -Target $ackResponse

    Invoke-TimeSignal -End

    [PSCustomObject]@{
        AssetId = $blobDetails.Id
        Name = $FileName
    }
}


<#
    .SYNOPSIS
        Invoke a http request for a Logic App
         
    .DESCRIPTION
        Invoke a Logic App using a http request and pass a json object with details about the calling function
         
    .PARAMETER Url
        The URL for the http endpoint that you want to invoke
         
    .PARAMETER Payload
        The data content you want to send to the LogicApp
         
    .EXAMPLE
        PS C:\> Invoke-D365SyncDB | Invoke-D365LogicApp
         
        This will execute the sync process and when it is done it will invoke a Azure Logic App with the default parameters that have been configured for the system.
         
    .NOTES
        Tags: LogicApp, Logic App, Configuration, Url, Notification
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Invoke-D365LogicApp {
    param (
        [string] $Url = (Get-D365LogicAppConfig).Url,

        [Parameter(Mandatory = $false)]
        [string] $Payload = "{}"
    )

    Invoke-PSNHttpEndpoint -Url $URL -Payload $Payload
}


<#
    .SYNOPSIS
        Invoke a http request for a Logic App
         
    .DESCRIPTION
        Invoke a Logic App using a http request and pass a json object with details about the calling function
         
    .PARAMETER Url
        The URL for the http endpoint that you want to invoke
         
    .PARAMETER Email
        The email address of the receiver of the message that the cmdlet will send
         
    .PARAMETER Subject
        Subject string to apply to the email and to the IM message
         
    .PARAMETER Message
        The message you want to pass onto the Logic App
         
    .PARAMETER IncludeAll
        Switch to instruct the cmdlet to include all cmdlets (names only) from the pipeline
         
    .PARAMETER AsJob
        Switch to instruct the cmdlet to run the invocation as a job (async)
         
    .EXAMPLE
        PS C:\> Invoke-D365SyncDB | Invoke-D365LogicAppMessage
         
        This will execute the sync process and when it is done it will invoke a Azure Logic App with the default parameters that have been configured for the system.
         
    .EXAMPLE
        PS C:\> Invoke-D365SyncDB | Invoke-D365LogicAppMessage -Email administrator@contoso.com -Subject "Work is done" -Url https://prod-35.westeurope.logic.azure.com:443/
         
        This will execute the sync process and when it is done it will invoke a Azure Logic App with the email, subject and URL parameters that are needed to invoke an Azure Logic App.
         
    .NOTES
        Tags: LogicApp, Logic App, Configuration, Url, Email, Notification, Message, Email
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Invoke-D365LogicAppMessage {
    param (
        [string] $Url = (Get-D365LogicAppConfig).Url,

        [string] $Email = (Get-D365LogicAppConfig).Email,

        [string] $Subject = (Get-D365LogicAppConfig).Subject,

        [string] $Message,

        [switch] $IncludeAll,
        
        [switch] $AsJob

    )

    begin {
    }
    
    process {
        $pipes = $MyInvocation.Line.Split("|")
         
        $arrList = New-Object -TypeName "System.Collections.ArrayList"
        foreach ($item in $pipes.Trim()) {
            $null = $arrList.Add( $item.Split(" ")[0])
        }

        $strMessage = "";

        if ($IncludeAll) {
            $strMessage = $arrList -Join ", "
            $strMessage = "The following list of cmdlets has executed: $strMessage"
        }
        elseif (-not ($null -eq $Message) -and (-not("" -eq $Message))) {
            $strMessage = $Message
        }
        else {
            $strMessage = $arrList[$MyInvocation.PipelinePosition - 2]
            $strMessage = "The following list of cmdlets has executed: $strMessage"
        }

        
        Invoke-PSNMessage -Url $URL -ReceiverEmail $Email -Subject $Subject -Message $strMessage -AsJob:$AsJob
    }
    
    end {
    }
}


<#
    .SYNOPSIS
        Compile a package / module / model
         
    .DESCRIPTION
        Compile a package / module / model using the builtin "xppc.exe" executable to compile source code
         
    .PARAMETER Module
        The package to compile
         
    .PARAMETER OutputDir
        The path to the folder to save generated artifacts
         
    .PARAMETER LogPath
        Path where you want to store the log outputs generated from the compiler
         
        Also used as the path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER ReferenceDir
        The full path of a folder containing all assemblies referenced from X++ code
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory\bin
         
    .PARAMETER XRefGeneration
        Instruct the cmdlet to enable the generation of XRef metadata while running the compile
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Invoke-D365ModuleCompile -Module MyModel
         
        This will use the default paths and start the xppc.exe with the needed parameters to compile MyModel package.
        The default output from the compile will be silenced.
         
        If an error should occur, both the standard output and error output will be written to the console / host.
         
    .EXAMPLE
        PS C:\> Invoke-D365ModuleCompile -Module MyModel -ShowOriginalProgress
         
        This will use the default paths and start the xppc.exe with the needed parameters to compile MyModel package.
        The output from the compile will be written to the console / host.
         
    .EXAMPLE
        PS C:\> Invoke-D365ModuleCompile -Module MyModel -XRefGeneration
         
        This will use the default paths and start the xppc.exe with the needed parameters to compile MyModel package.
        The default output from the compile will be silenced.
        The compiler will generate XRef metadata while compiling.
         
        If an error should occur, both the standard output and error output will be written to the console / host.
         
    .NOTES
        Tags: Compile, Model, Servicing, X++
         
        Author: Ievgen Miroshnikov (@IevgenMir)
         
        Author: M�tz Jensen (@Splaxi)
         
        Author: Frank H�ther (@FrankHuether)
#>


function Invoke-D365ModuleCompile {
    [CmdletBinding()]
    [OutputType('[PsCustomObject]')]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias("ModuleName")]
        [string] $Module,

        [Alias('Output')]
        [string] $OutputDir = $Script:MetaDataDir,

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\ModuleCompile"),

        [string] $MetaDataDir = $Script:MetaDataDir,

        [string] $ReferenceDir = $Script:MetaDataDir,

        [string] $BinDir = $Script:BinDirTools,

        [switch] $XRefGeneration,

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly
    )

    begin {
        Invoke-TimeSignal -Start

        $tool = "xppc.exe"
        $executable = Join-Path -Path $BinDir -ChildPath $tool

        if (-not (Test-PathExists -Path $MetaDataDir, $BinDir -Type Container)) { return }
        if (-not (Test-PathExists -Path $executable -Type Leaf)) { return }
        if (-not (Test-PathExists -Path $LogPath -Type Container -Create)) { return }

    }

    process {
        $logDirModule = Join-Path -Path $LogPath -ChildPath $Module
        $outputDirModule = Join-Path -Path $OutputDir -ChildPath $Module
        
        if (-not (Test-PathExists -Path $logDirModule -Type Container -Create)) { return }

        if (Test-PSFFunctionInterrupt) { return }
        
        $logFile = Join-Path -Path $logDirModule -ChildPath "Dynamics.AX.$Module.xppc.log"
        $logXmlFile = Join-Path -Path $logDirModule -ChildPath "Dynamics.AX.$Module.xppc.xml"

        $params = @("-metadata=`"$MetaDataDir`"",
            "-modelmodule=`"$Module`"",
            "-output=`"$outputDirModule\bin`"",
            "-referencefolder=`"$ReferenceDir`"",
            "-log=`"$logFile`"",
            "-xmlLog=`"$logXmlFile`"",
            "-verbose"
        )

        if ($XRefGeneration) {
            $params += "-xref"
        }

        Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $logDirModule

        if ($OutputCommandOnly) { return }

        [PSCustomObject]@{
            LogFile    = $logFile
            XmlLogFile = $logXmlFile
            PSTypeName = 'D365FO.TOOLS.ModuleCompileOutput'
        }
    
    }

    end {
        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Compile a package
         
    .DESCRIPTION
        Compile a package using the builtin "xppc.exe" executable to compile source code, "labelc.exe" to compile label files and "reportsc.exe" to compile reports
         
    .PARAMETER Module
        The package to compile
         
    .PARAMETER OutputDir
        The path to the folder to save assemblies
         
    .PARAMETER LogPath
        Path where you want to store the log outputs generated from the compiler
         
        Also used as the path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
    .PARAMETER ReferenceDir
        The full path of a folder containing all assemblies referenced from X++ code
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory\bin
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Invoke-D365ModuleFullCompile -Module MyModel
         
        This will use the default paths and start the xppc.exe with the needed parameters to compile MyModel package.
        The default output from all the different steps will be silenced.
         
    .EXAMPLE
        PS C:\> Invoke-D365ModuleFullCompile -Module MyModel -ShowOriginalProgress
         
        This will use the default paths and start the xppc.exe with the needed parameters to copmile MyModel package.
        The default output from the different steps will be written to the console / host.
         
    .NOTES
        Tags: Compile, Model, Servicing
         
        Author: Ievgen Miroshnikov (@IevgenMir)
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Invoke-D365ModuleFullCompile {
    [CmdletBinding()]
    [OutputType('[PsCustomObject]')]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias("ModuleName")]
        [string] $Module,

        [Alias('Output')]
        [string] $OutputDir = $Script:MetaDataDir,

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\ModuleCompile"),

        [string] $MetaDataDir = $Script:MetaDataDir,

        [string] $ReferenceDir = $Script:MetaDataDir,

        [string] $BinDir = $Script:BinDirTools,

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly
    )

    begin {

        Invoke-TimeSignal -Start

        if (-not (Test-PathExists -Path $MetaDataDir, $BinDir -Type Container)) { return }
        if (-not (Test-PathExists -Path $LogPath -Type Container -Create)) { return }
    }

    process {
        $resModuleCompile = Invoke-D365ModuleCompile @PSBoundParameters

        $resLabelGeneration = Invoke-D365ModuleLabelGeneration @PSBoundParameters

        $resReportsCompile = Invoke-D365ModuleReportsCompile @PSBoundParameters
    
        $resModuleCompile

        $resLabelGeneration

        $resReportsCompile
    }

    end {
        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Generate labels for a package / module / model
         
    .DESCRIPTION
        Generate labels for a package / module / model using the builtin "labelc.exe"
         
    .PARAMETER Module
        Name of the package that you want to work against
         
    .PARAMETER OutputDir
        The path to the folder to save generated artifacts
         
    .PARAMETER LogPath
        Path where you want to store the log outputs generated from the compiler
         
        Also used as the path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER ReferenceDir
        The full path of a folder containing all assemblies referenced from X++ code
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory\bin
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Invoke-D365ModuleLabelGeneration -Module MyModel
         
        This will use the default paths and start the labelc.exe with the needed parameters to labels from the MyModel package.
        The default output from the generation process will be silenced.
         
        If an error should occur, both the standard output and error output will be written to the console / host.
         
    .EXAMPLE
        PS C:\> Invoke-D365ModuleLabelGeneration -Module MyModel -ShowOriginalProgress
         
        This will use the default paths and start the labelc.exe with the needed parameters to labels from the MyModel package.
        The output from the compile will be written to the console / host.
         
    .NOTES
        Tags: Compile, Model, Servicing, Label, Labels
         
        Author: Ievgen Miroshnikov (@IevgenMir)
         
        Author: M�tz Jensen (@Splaxi)
#>


function Invoke-D365ModuleLabelGeneration {
    [CmdletBinding()]
    [OutputType('[PsCustomObject]')]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias("ModuleName")]
        [string] $Module,

        [Alias('Output')]
        [string] $OutputDir = $Script:MetaDataDir,

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\ModuleCompile"),

        [string] $MetaDataDir = $Script:MetaDataDir,

        [string] $ReferenceDir = $Script:MetaDataDir,

        [string] $BinDir = $Script:BinDirTools,

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly
    )

    begin {
        Invoke-TimeSignal -Start

        $tool = "labelc.exe"
        $executable = Join-Path -Path $BinDir -ChildPath $tool

        if (-not (Test-PathExists -Path $MetaDataDir, $BinDir -Type Container)) { return }
        if (-not (Test-PathExists -Path $executable -Type Leaf)) { return }
        if (-not (Test-PathExists -Path $LogPath -Type Container -Create)) { return }
    }

    process {
        $logDirModule = Join-Path -Path $LogPath -ChildPath $Module
        $outputDirModule = Join-Path -Path $OutputDir -ChildPath $Module
        
        if (-not (Test-PathExists -Path $logDirModule -Type Container -Create)) { return }

        if (Test-PSFFunctionInterrupt) { return }

        $logFile = Join-Path -Path $logDirModule -ChildPath "Dynamics.AX.$Module.labelc.log"
        $logErrorFile = Join-Path -Path $logDirModule -ChildPath "Dynamics.AX.$Module.labelc.err"
  
        $params = @("-metadata=`"$MetaDataDir`"",
            "-modelmodule=`"$Module`"",
            "-output=`"$outputDirModule\Resources`"",
            "-outlog=`"$logFile`"",
            "-errlog=`"$logErrorFile`""
        )
    
        Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $logDirModule

        if ($OutputCommandOnly) { return }
        
        [PSCustomObject]@{
            OutLogFile   = $logFile
            ErrorLogFile = $logErrorFile
            PSTypeName   = 'D365FO.TOOLS.ModuleLabelGenerationOutput'
        }
    }

    end {
        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Generate reports for a package / module / model
         
    .DESCRIPTION
        Generate reports for a package / module / model using the builtin "ReportsC.exe"
         
    .PARAMETER Module
        Name of the package that you want to work against
         
    .PARAMETER OutputDir
        The path to the folder to save generated artifacts
         
    .PARAMETER LogPath
        Path where you want to store the log outputs generated from the compiler
         
        Also used as the path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER ReferenceDir
        The full path of a folder containing all assemblies referenced from X++ code
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory\bin
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Invoke-D365ModuleReportsCompile -Module MyModel
         
        This will use the default paths and start the ReportsC.exe with the needed parameters to compile the reports from the MyModel package.
        The default output from the reports compile will be silenced.
         
        If an error should occur, both the standard output and error output will be written to the console / host.
         
    .EXAMPLE
        PS C:\> Invoke-D365ModuleReportsCompile -Module MyModel -ShowOriginalProgress
         
        This will use the default paths and start the ReportsC.exe with the needed parameters to compile the reports from the MyModel package.
        The output from the compile will be written to the console / host.
         
    .NOTES
        Tags: Compile, Model, Servicing, Report, Reports
         
        Author: Ievgen Miroshnikov (@IevgenMir)
         
        Author: M�tz Jensen (@Splaxi)
#>


function Invoke-D365ModuleReportsCompile {
    [CmdletBinding()]
    [OutputType('[PsCustomObject]')]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias("ModuleName")]
        [string] $Module,

        [Alias('Output')]
        [string] $OutputDir = $Script:MetaDataDir,

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\ModuleCompile"),

        [string] $MetaDataDir = $Script:MetaDataDir,

        [string] $ReferenceDir = $Script:MetaDataDir,

        [string] $BinDir = $Script:BinDirTools,

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly
    )

    begin {
        Invoke-TimeSignal -Start

        $tool = "ReportsC.exe"
        $executable = Join-Path -Path $BinDir -ChildPath $tool

        if (-not (Test-PathExists -Path $MetaDataDir, $BinDir -Type Container)) { return }
        if (-not (Test-PathExists -Path $executable -Type Leaf)) { return }
        if (-not (Test-PathExists -Path $LogPath -Type Container -Create)) { return }
    }
    
    process {
        $logDirModule = Join-Path -Path $LogPath -ChildPath $Module
        $outputDirModule = Join-Path -Path $OutputDir -ChildPath $Module
        
        if (-not (Test-PathExists -Path $logDirModule -Type Container -Create)) { return }

        if (Test-PSFFunctionInterrupt) { return }

        $logFile = Join-Path -Path $logDirModule -ChildPath "Dynamics.AX.$Module.ReportsC.log"
        $logXmlFile = Join-Path -Path $logDirModule -ChildPath "Dynamics.AX.$Module.ReportsC.xml"

        $params = @("-metadata=`"$MetaDataDir`"",
            "-modelmodule=`"$Module`"",
            "-LabelsPath=`"$MetaDataDir`"",
            "-output=`"$outputDirModule\Reports`"",
            "-log=`"$logFile`"",
            "-xmlLog=`"$logXmlFile`""
        )

        Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $logDirModule

        if ($OutputCommandOnly) { return }

        [PSCustomObject]@{
            LogFile    = $logFile
            XmlLogFile = $logXmlFile
            PSTypeName = 'D365FO.TOOLS.ModuleReportsCompileOutput'
        }
    }

    end {
        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Process a specific or multiple modules (compile, deploy reports and sync)
         
    .DESCRIPTION
        Process a specific or multiple modules by invoking the following functions (based on flags)
        - Invoke-D365ModuleFullCompile function
        - Publish-D365SsrsReport to deploy the reports of a module
        - Invoke-D365DBSyncPartial to sync the table and extension elements for module
         
    .PARAMETER Module
        Name of the module that you want to process
         
        Accepts wildcards for searching. E.g. -Module "Application*Adaptor"
         
        Default value is "*" which will search for all modules
         
    .PARAMETER ExecuteCompile
        Switch/flag to determine if the compile function should be executed for requested modules
         
    .PARAMETER ExecuteSync
        Switch/flag to determine if the databasesync function should be executed for requested modules
         
    .PARAMETER ExecuteDeployReports
        Switch/flag to determine if the deploy reports function should be executed for requested modules
         
    .PARAMETER OutputDir
        The path to the folder to save assemblies
         
    .PARAMETER LogPath
        Path where you want to store the log outputs generated from the compiler
         
        Also used as the path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
    .PARAMETER ReferenceDir
        The full path of a folder containing all assemblies referenced from X++ code
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory\bin
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Invoke-D365ProcessModule -Module "Application*Adaptor" -ExecuteCompile
         
        Retrieve the list of installed packages / modules where the name fits the search "Application*Adaptor".
         
        For every value of the list perform the following:
        * Invoke-D365ModuleFullCompile with the needed parameters to compile current module value package.
         
        The default output from all the different steps will be silenced.
         
    .EXAMPLE
        PS C:\> Invoke-D365ProcessModule -Module "Application*Adaptor" -ExecuteSync
         
        Retrieve the list of installed packages / modules where the name fits the search "Application*Adaptor".
         
        For every value of the list perform the following:
        * Invoke-D365DBSyncPartial with the needed parameters to sync current module value table and extension elements.
         
        The default output from all the different steps will be silenced.
         
    .EXAMPLE
        PS C:\> Invoke-D365ProcessModule -Module "Application*Adaptor" -ExecuteDeployReports
         
        Retrieve the list of installed packages / modules where the name fits the search "Application*Adaptor".
         
        For every value of the list perform the following:
        * Publish-D365SsrsReport with the required parameters to deploy all reports of current module
         
        The default output from all the different steps will be silenced.
         
    .EXAMPLE
        PS C:\> Invoke-D365ProcessModule -Module "Application*Adaptor" -ExecuteCompile -ExecuteSync -ExecuteDeployReports
         
        Retrieve the list of installed packages / modules where the name fits the search "Application*Adaptor".
         
        For every value of the list perform the following:
        * Invoke-D365ModuleFullCompile with the needed parameters to compile current module package.
        * Invoke-D365DBSyncPartial with the needed parameters to sync current module table and extension elements.
        * Publish-D365SsrsReport with the required parameters to deploy all reports of current module
         
        The default output from all the different steps will be silenced.
         
    .NOTES
        Tags: Compile, Model, Servicing, Database, Synchronization
         
        Author: Jasper Callens - Cegeka
#>


function Invoke-D365ProcessModule {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [Alias("ModuleName")]
        [string] $Module,

        [switch] $ExecuteCompile = $false,

        [switch] $ExecuteSync = $false,

        [switch] $ExecuteDeployReports = $false,

        [Alias('Output')]
        [string] $OutputDir = $Script:MetaDataDir,

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\ModuleCompile"),

        [string] $MetaDataDir = $Script:MetaDataDir,

        [string] $ReferenceDir = $Script:MetaDataDir,

        [string] $BinDir = $Script:BinDirTools,

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly
    )

    begin {
        Invoke-TimeSignal -Start
    }

    process {
        # Only execute the code if any of the flags are set
        if ($ExecuteCompile -or $ExecuteSync -or $ExecuteDeployReports) {
            # Retrieve all modules that match provided $Module
            $moduleResults = Get-D365Module -Name $Module

            # Output information on which modules that will be compiled and synced
            Write-PSFMessage -Level Host -Message "Modules to process: "
            $moduleResults | ForEach-Object {
                Write-PSFMessage -Level Host -Message " - $($_.Module) "
            }

            # Empty list for all modules that have to be compiled
            $modulesToCompile = @()
            
            # Empty list for all modules of which the reports have to be deployed
            $modulesToDeployReports = @()

            # Create empty lists for all sync-base and sync-extension elements
            $syncList = @()
            $syncExtensionsList = @()

            # Loop every resulting module result and fill the required 'processing' lists based on the flags
            foreach ($moduleElement in $moduleResults) {
                if ($ExecuteCompile) {
                    $modulesToCompile += $moduleElement
                }

                if ($ExecuteDeployReports) {
                    $modulesToDeployReports += $moduleElement
                }

                if ($ExecuteSync) {
                    # Retrieve the sync element of current module
                    $moduleSyncElements = Get-SyncElements -ModuleName $moduleElement.Module

                    # Add base and extensions elements to the sync lists
                    $syncList += $moduleSyncElements.BaseSyncElements
                    $syncExtensionsList += $moduleSyncElements.ExtensionSyncElements
                }
            }

            if ($ExecuteCompile) {
                # Loop over every module to compile and execute compile function
                foreach ($moduleToCompile in $modulesToCompile) {
                    # Build parameters for the full compile function
                    $fullCompileParams = @{
                        Module               = $moduleToCompile.Module;
                        OutputDir            = $OutputDir;
                        LogPath              = $LogPath;
                        MetaDataDir          = $MetaDataDir;
                        ReferenceDir         = $ReferenceDir;
                        BinDir               = $BinDir;
                        ShowOriginalProgress = $ShowOriginalProgress;
                        OutputCommandOnly    = $OutputCommandOnly
                    }

                    # Call the full compile using required parameters
                    $resModuleCompileFull = Invoke-D365ModuleFullCompile @fullCompileParams

                    # Output results of full compile
                    $resModuleCompileFull
                }
            }
            
            if ($ExecuteDeployReports) {
                # Loop over every module to deploy reports and execute deploy report function
                foreach ($moduleToDeployReports in $modulesToDeployReports) {
                    # Build parameters for the model report deployment
                    $fullDeployParams = @{
                        Module  = $moduleToDeployReports.Module;
                        LogFile = "$LogPath\$($moduleToDeployReports.Module).log";
                    }
                    
                    if ($OutputCommandOnly) {
                        Write-PSFMessage -Level Host -Message "Publish-D365SsrsReport $($fullDeployParams -join ' ')"
                    }
                    else {
                        $resModuleDeployReports = Publish-D365SsrsReport @fullDeployParams
                        $resModuleDeployReports
                    }
                }
            }

            if ($ExecuteSync) {
                # Build parameters for the partial sync function
                $syncParams = @{
                    SyncList             = $syncList;
                    SyncExtensionsList   = $syncExtensionsList;
                    BinDirTools          = $BinDir;
                    MetadataDir          = $MetaDataDir;
                    ShowOriginalProgress = $ShowOriginalProgress;
                    OutputCommandOnly    = $OutputCommandOnly
                }

                # Call the partial sync using required parameters
                $resSyncModule = Invoke-D365DBSyncPartial @syncParams
                $resSyncModule
            }
        }
        else {
            Write-PSFMessage -Level Output -Message "No process flags were set. Nothing will be processed"
        }
    }

    end {
        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Invokes the Rearm of Windows license
         
    .DESCRIPTION
        Function used for invoking the rearm functionality inside Windows
         
    .PARAMETER Restart
        Instruct the cmdlet to restart the machine
         
    .EXAMPLE
        PS C:\> Invoke-D365ReArmWindows
         
        This will re arm the Windows installation if there is any activation retries left
         
    .EXAMPLE
        PS C:\> Invoke-D365ReArmWindows -Restart
         
        This will re arm the Windows installation if there is any activation retries left and restart the computer.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>


function Invoke-D365ReArmWindows {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false, Position = 1)]
        [switch]$Restart
    )

    Write-PSFMessage -Level Verbose -Message "Invoking the rearm process."

    $instance = Get-CimInstance -Class SoftwareLicensingService -Namespace root/cimv2 -ComputerName .
    Invoke-CimMethod -InputObject $instance -MethodName ReArmWindows
    
    if ($Restart) {
        Restart-Computer -Force
    }
}


<#
    .SYNOPSIS
        Analyze the runbook
         
    .DESCRIPTION
        Get all the important details from a failed runbook
         
    .PARAMETER Path
        Path to the runbook file that you work against
         
    .PARAMETER FailedOnly
        Instruct the cmdlet to only output failed steps
         
    .PARAMETER FailedOnlyAsObjects
        Instruct the cmdlet to only output failed steps as objects
         
    .EXAMPLE
        PS C:\> Invoke-D365RunbookAnalyzer -Path "C:\DynamicsAX\InstallationRecords\Runbooks\Runbook.xml"
         
        This will analyze the Runbook.xml and output all the details about failed steps, the connected error logs and all the unprocessed steps.
         
    .EXAMPLE
        PS C:\> Get-D365Runbook -Latest | Invoke-D365RunbookAnalyzer
         
        This will find the latest runbook file and have it analyzed by the Invoke-D365RunbookAnalyzer cmdlet to output any error details.
         
    .EXAMPLE
        PS C:\> Get-D365Runbook -Latest | Invoke-D365RunbookAnalyzer -FailedOnly
         
        This will find the latest runbook file and have it analyzed by the Invoke-D365RunbookAnalyzer cmdlet to output any error details.
        The output from Invoke-D365RunbookAnalyzer will only contain failed steps.
         
    .EXAMPLE
        PS C:\> Get-D365Runbook -Latest | Invoke-D365RunbookAnalyzer -FailedOnlyAsObjects
         
        This will find the latest runbook file and have it analyzed by the Invoke-D365RunbookAnalyzer cmdlet to output any error details.
        The output from Invoke-D365RunbookAnalyzer will only contain failed steps.
        The output will be formatted as PSCustomObjects, to be used as variables or piping.
         
    .EXAMPLE
        PS C:\> Get-D365Runbook -Latest | Invoke-D365RunbookAnalyzer -FailedOnlyAsObjects | Get-D365RunbookLogFile -Path "C:\Temp\PU35" -OpenInEditor
         
        This will find the latest runbook file and have it analyzed by the Invoke-D365RunbookAnalyzer cmdlet to output any error details.
        The output from Invoke-D365RunbookAnalyzer will only contain failed steps.
        The Get-D365RunbookLogFile will open all log files for the failed step.
         
    .EXAMPLE
        PS C:\> Get-D365Runbook -Latest | Invoke-D365RunbookAnalyzer | Out-File "C:\Temp\d365fo.tools\runbook-analyze-results.xml"
         
        This will find the latest runbook file and have it analyzed by the Invoke-D365RunbookAnalyzer cmdlet to output any error details.
        The output will be saved into the "C:\Temp\d365fo.tools\runbook-analyze-results.xml" file.
         
    .EXAMPLE
        PS C:\> Get-D365Runbook -Latest | Backup-D365Runbook -Force | Invoke-D365RunbookAnalyzer
         
        This will get the latest runbook from the default location.
        This will backup the file onto the default "c:\temp\d365fo.tools\runbookbackups\".
        This will start the Runbook Analyzer on the backup file.
         
    .NOTES
        Tags: Runbook, Servicing, Hotfix, DeployablePackage, Deployable Package, InstallationRecordsDirectory, Installation Records Directory
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Invoke-D365RunbookAnalyzer {
    [CmdletBinding(DefaultParameterSetName="Default")]
    [OutputType('System.String')]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true, ParameterSetName = "Default")]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true, ParameterSetName = "FailedOnly")]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true, ParameterSetName = "FailedOnlyAsObjects")]
        [Alias('File')]
        [string] $Path,

        [Parameter(ParameterSetName = "FailedOnly")]
        [switch] $FailedOnly,

        [Parameter(ParameterSetName = "FailedOnlyAsObjects")]
        [switch] $FailedOnlyAsObjects
    )
    
    process {
        if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }

        $null = $sb = New-Object System.Text.StringBuilder
        $null = $sb.AppendLine("<D365FO.Tools.Runbook.Analyzer.Output>")

        [xml]$xmlRunbook = Get-Content $Path

        $failedObjs = New-Object System.Collections.Generic.List[System.Object]

        $failedSteps = $xmlRunbook.SelectNodes("//RunbookStepList/Step/StepState[text()='Failed']")

        $failedSteps | ForEach-Object {
            $null = $sb.AppendLine("<FailedStepInfo>")

            $stepId = $_.ParentNode | Select-Object -ExpandProperty childnodes | Where-Object { $_.name -like 'ID' } | Select-Object -ExpandProperty InnerText
            $failedLogs = $xmlRunbook.SelectNodes("//RunbookLogs/Log/StepID[text()='$stepId']")

            $failedObjs.Add([PsCustomObject]@{Step = "$stepId" })

            $null = $sb.AppendLine($_.ParentNode.OuterXml)

            $failedLogs | ForEach-Object { $null = $sb.AppendLine( $_.ParentNode.OuterXml) }

            $null = $sb.AppendLine("</FailedStepInfo>")
        }
        
        if ((-not $FailedOnly) -and (-not $FailedOnlyAsObjects)) {
            $inProgressSteps = $xmlRunbook.SelectNodes("//RunbookStepList/Step/StepState[text()='InProgress']")

            $null = $sb.AppendLine("<InProgressStepInfo>")

            $inProgressSteps | ForEach-Object { $null = $sb.AppendLine( $_.ParentNode.OuterXml) }

            $null = $sb.AppendLine("</InProgressStepInfo>")

            $unprocessedSteps = $xmlRunbook.SelectNodes("//RunbookStepList/Step/StepState[text()='NotStarted']")

            $null = $sb.AppendLine("<UnprocessedStepInfo>")

            $unprocessedSteps | ForEach-Object { $null = $sb.AppendLine( $_.ParentNode.OuterXml) }

            $null = $sb.AppendLine("</UnprocessedStepInfo>")
        }

        $null = $sb.AppendLine("</D365FO.Tools.Runbook.Analyzer.Output>")

        if ($FailedOnlyAsObjects) {
            $failedObjs.ToArray()
        }
        else {
            [xml]$xmlRaw = $sb.ToString()
        
            $stringWriter = New-Object System.IO.StringWriter;
            $xmlWriter = New-Object System.Xml.XmlTextWriter $stringWriter;
            $xmlWriter.Formatting = "indented";
            $xmlRaw.WriteTo($xmlWriter);
            $xmlWriter.Flush();
            $stringWriter.Flush();
            $stringWriter.ToString();
        }
    }
}


<#
    .SYNOPSIS
        Invoke the SCDPBundleInstall.exe file
         
    .DESCRIPTION
        A cmdlet that wraps some of the cumbersome work of installing updates / hotfixes into a streamlined process
         
    .PARAMETER InstallOnly
        Instructs the cmdlet to only run the Install option and ignore any TFS / VSTS folders and source control in general
         
        Use it when testing an update on a local development machine (VM) / onebox
         
    .PARAMETER Command
        The command / job you want the cmdlet to execute
         
        Valid options are:
        Prepare
        Install
         
        Default value is "Prepare"
         
    .PARAMETER Path
        Path to the update package that you want to install into the environment
         
        The cmdlet only supports an already extracted ".axscdppkg" file
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER TfsWorkspaceDir
        The path to the TFS Workspace directory that you want to work against
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER TfsUri
        The URI for the TFS Team Site / VSTS Portal that you want to work against
         
        Default URI is the one that is configured from inside Visual Studio
         
    .PARAMETER ShowModifiedFiles
        Switch to instruct the cmdlet to show all the modified files afterwards
         
    .PARAMETER ShowProgress
        Switch to instruct the cmdlet to output progress details while servicing the installation
         
    .EXAMPLE
        PS C:\> Invoke-D365SCDPBundleInstall -Path "c:\temp\HotfixPackageBundle.axscdppkg" -InstallOnly
         
        This will install the "HotfixPackageBundle.axscdppkg" into the default PackagesLocalDirectory location on the machine.
         
    .NOTES
        Tags: Hotfix, Hotfixes, Updates, Prepare, VSTS, axscdppkg
         
        Author: M�tz Jensen (@splaxi)
         
        Author: Tommy Skaue (@skaue)
#>

function Invoke-D365SCDPBundleInstall {
    [CmdletBinding(DefaultParameterSetName = 'InstallOnly')]
    param (
        [Parameter(Mandatory = $True, ParameterSetName = 'InstallOnly', Position = 0 )]
        [switch] $InstallOnly,

        [Parameter(Mandatory = $false, ParameterSetName = 'Tfs', Position = 0 )]
        [ValidateSet('Prepare', 'Install')]
        [string] $Command = 'Prepare',

        [Parameter(Mandatory = $True, Position = 1 )]
        [Alias('Hotfix')]
        [Alias('File')]
        [string] $Path,

        [Parameter(Mandatory = $False, Position = 2 )]
        [string] $MetaDataDir = "$Script:MetaDataDir",

        [Parameter(Mandatory = $False, ParameterSetName = 'Tfs', Position = 3 )]
        [string] $TfsWorkspaceDir = "$Script:MetaDataDir",

        [Parameter(Mandatory = $False, ParameterSetName = 'Tfs', Position = 4 )]
        [string] $TfsUri = "$Script:TfsUri",

        [Parameter(Mandatory = $False, Position = 4 )]
        [switch] $ShowModifiedFiles,

        [Parameter(Mandatory = $False, Position = 5 )]
        [switch] $ShowProgress

    )

    if (!$script:IsAdminRuntime) {
        Write-PSFMessage -Level Host -Message "The cmdlet needs <c='em'>administrator permission</c> (Run As Administrator) to be able to update the configuration. Please start an <c='em'>elevated</c> session and run the cmdlet again."
        Stop-PSFFunction -Message "Stopping because the function is not run elevated"
        return
    }

    
    Invoke-TimeSignal -Start
    $StartTime = Get-Date
    $executable = Join-Path $Script:BinDir "\bin\SCDPBundleInstall.exe"

    if (!(Test-PathExists -Path $Path, $executable -Type Leaf)) { return }
    if (!(Test-PathExists -Path $MetaDataDir -Type Container)) { return }
    
    Unblock-File -Path $Path #File is typically downloaded and extracted

    if ($InstallOnly) {
        $param = @("-install",
            "-packagepath=$Path",
            "-metadatastorepath=$MetaDataDir")
    }
    else {

        if ($TfsUri -eq "") {
            Write-PSFMessage -Level Host -Message "No TFS URI provided. Unable to complete the command."
            Stop-PSFFunction -Message "Stopping because missing TFS URI parameter."
            return
        }

        switch ($Command) {
            "Prepare" {
                $param = @("-prepare")
            }
            "Install" {
                $param = @("-install")
            }
        }
        $param = $param + @("-packagepath=`"$Path`"",
            "-metadatastorepath=`"$MetaDataDir`"",
            "-tfsworkspacepath=`"$TfsWorkspaceDir`"",
            "-tfsprojecturi=`"$TfsUri`"")
    }

    Write-PSFMessage -Level Verbose -Message "Invoking SCDPBundleInstall.exe with $Command" -Target $param
    
    if ($ShowProgress) {
        #! We should consider to redirect the standard output & error like this: https://stackoverflow.com/questions/8761888/capturing-standard-out-and-error-with-start-process
        #Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly
        $process = Start-Process -FilePath $executable -ArgumentList $param -PassThru

        while (-not ($process.HasExited)) {
            
            $timeout = New-TimeSpan -Days 1
            $stopwatch = [Diagnostics.StopWatch]::StartNew();
            $bundleRoot = "$env:localappdata\temp\SCDPBundleInstall"
            [xml]$manifest = Get-Content $(join-path $bundleRoot "PackageDependencies.dgml") -ErrorAction SilentlyContinue
            $bundleCounter = 0
            
            if ($manifest) {
                $bundleTotalCount = $manifest.DirectedGraph.Nodes.ChildNodes.Count
            }
            
            while ($manifest -and (-not ($process.HasExited)) -and $stopwatch.elapsed -lt $timeout) {
                $currentBundleFolder = Get-ChildItem $bundleRoot -Directory -ErrorAction SilentlyContinue
        
                if ($currentBundleFolder) {
                    $currentBundle = $currentBundleFolder.Name
        
                    if ($announcedBundle -ne $currentBundle) {
                        $announcedBundle = $currentBundle
                        $bundleCounter = $bundleCounter + 1
                        Write-PSFMessage -Level Verbose -Message "$bundleCounter/$bundleTotalCount : Processing hotfix package $announcedBundle"
                    }
                }
            }
            Start-Sleep -Milliseconds 100
        }
    }
    else {
        #! We should consider to redirect the standard output & error like this: https://stackoverflow.com/questions/8761888/capturing-standard-out-and-error-with-start-process
        #Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly
        Start-Process -FilePath $executable -ArgumentList $param -NoNewWindow -Wait
    }
    
    if ($ShowModifiedFiles) {
        $res = Get-ChildItem -Path $MetaDataDir -Recurse | Where-Object { $_.LastWriteTime -gt $StartTime }

        $res | ForEach-Object {
            Write-PSFMessage -Level Verbose -Message "Object modified by the install: $($_.FullName)"
        }

        $res
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Invoke the AxUpdateInstaller.exe file from Software Deployable Package (SDP)
         
    .DESCRIPTION
        A cmdlet that wraps some of the cumbersome work into a streamlined process.
        The process are detailed in the Microsoft documentation here:
        https://docs.microsoft.com/en-us/dynamics365/unified-operations/dev-itpro/deployment/install-deployable-package
         
    .PARAMETER Path
        Path to the update package that you want to install into the environment
         
        The cmdlet only supports a path to an unblocked zip-file
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER QuickInstallAll
        Use this switch to let the runbook reside in memory. You will not get a runbook on disc which you can examine for steps
         
    .PARAMETER DevInstall
        Use this when running on developer box without administrator privileges (Run As Administrator)
         
    .PARAMETER Command
        The command you want the cmdlet to execute when it runs the AXUpdateInstaller.exe
         
        Valid options are:
        SetTopology
        Generate
        Import
        Execute
        RunAll
        ReRunStep
        SetStepComplete
        Export
        VersionCheck
         
        The default value is "SetTopology"
         
    .PARAMETER Step
        The step number that you want to work against
         
    .PARAMETER RunbookId
        The runbook id of the runbook that you want to work against
         
        Default value is "Runbook"
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Invoke-D365SDPInstall -Path "c:\temp\" -QuickInstallAll
         
        This will install the extracted package in c:\temp\ using a runbook in memory while executing.
         
    .EXAMPLE
        PS C:\> Invoke-D365SDPInstall -Path "c:\temp\" -DevInstall
         
        This will install the extracted package in c:\temp\ using a runbook in memory while executing.
         
        This command is to be used on Microsoft Hosted Tier1 development environment, where you don't have access to the administrator user account on the vm.
         
    .EXAMPLE
        PS C:\> Invoke-D365SDPInstall -Path "c:\temp\" -Command SetTopology
        PS C:\> Invoke-D365SDPInstall -Path "c:\temp\" -Command Generate -RunbookId 'MyRunbook'
        PS C:\> Invoke-D365SDPInstall -Path "c:\temp\" -Command Import -RunbookId 'MyRunbook'
        PS C:\> Invoke-D365SDPInstall -Path "c:\temp\" -Command Execute -RunbookId 'MyRunbook'
         
        Manual operations that first create Topology XML from current environment, then generate runbook with id 'MyRunbook', then import it and finally execute it.
         
    .EXAMPLE
        PS C:\> Invoke-D365SDPInstall -Path "c:\temp\" -Command RunAll
         
        Create Topology XML from current environment. Using default runbook id 'Runbook' and run all the operations from generate, to import to execute.
         
    .EXAMPLE
        PS C:\> Invoke-D365SDPInstall -Path "c:\temp\" -Command RerunStep -Step 18 -RunbookId 'MyRunbook'
         
        Rerun runbook with id 'MyRunbook' from step 18.
         
    .EXAMPLE
        PS C:\> Invoke-D365SDPInstall -Path "c:\temp\" -Command SetStepComplete -Step 24 -RunbookId 'MyRunbook'
         
        Mark step 24 complete in runbook with id 'MyRunbook' and continue the runbook from the next step.
         
         
    .NOTES
        Author: Tommy Skaue (@skaue)
        Author: M�tz Jensen (@Splaxi)
         
        Inspired by blogpost http://dev.goshoom.net/en/2016/11/installing-deployable-packages-with-powershell/
         
#>

function Invoke-D365SDPInstall {
    [CmdletBinding(DefaultParameterSetName = 'QuickInstall')]
    param (
        [Parameter(Mandatory = $True, Position = 1 )]
        [Alias('Hotfix')]
        [Alias('File')]
        [string] $Path,

        [Parameter(Mandatory = $false, Position = 2 )]
        [string] $MetaDataDir = "$Script:MetaDataDir",

        [Parameter(Mandatory = $false, ParameterSetName = 'QuickInstall', Position = 3 )]
        [switch] $QuickInstallAll,

        [Parameter(Mandatory = $false, ParameterSetName = 'DevInstall', Position = 3 )]
        [switch] $DevInstall,

        [Parameter(Mandatory = $true, ParameterSetName = 'Manual', Position = 3 )]
        [ValidateSet('SetTopology', 'Generate', 'Import', 'Execute', 'RunAll', 'ReRunStep', 'SetStepComplete', 'Export', 'VersionCheck')]
        [string] $Command = 'SetTopology',

        [Parameter(Mandatory = $false, Position = 4 )]
        [int] $Step,
        
        [Parameter(Mandatory = $false, Position = 5 )]
        [string] $RunbookId = "Runbook",

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\SdpInstall"),

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly
    )
    
    if ((Get-Process -Name "devenv" -ErrorAction SilentlyContinue).Count -gt 0) {
        Write-PSFMessage -Level Host -Message "It seems that you have a <c='em'>Visual Studio</c> running. Please ensure <c='em'>exit</c> Visual Studio and run the cmdlet again."
        Stop-PSFFunction -Message "Stopping because of running Visual Studio."
        return
    }

    Test-AssembliesLoaded

    if (Test-PSFFunctionInterrupt) {
        Write-PSFMessage -Level Host -Message "It seems that you have executed some cmdlets that required to <c='em'>load</c> some Dynamics 356 Finance & Operations <c='em'>assemblies</c> into memory. Please <c='em'>close and restart</c> you PowerShell session / console, and <c='em'>start a fresh</c>. Please note that you should execute the failed command <c='em'>immediately</c> after importing the module."
        Stop-PSFFunction -Message "Stopping because of loaded assemblies."
        return
    }

    $arrRunbookIds = Get-D365Runbook -WarningAction SilentlyContinue -ErrorAction SilentlyContinue | Get-D365RunbookId

    if (($Command -eq "RunAll") -and ($arrRunbookIds.Runbookid -contains $RunbookId)) {
        Write-PSFMessage -Level Host -Message "It seems that you have entered an <c='em'>already used RunbookId</c>. Please consider if you are <c='em'>trying to re-run some steps</c> or simply pass <c='em'>another RunbookId</c>."
        Stop-PSFFunction -Message "Stopping because of RunbookId already used on this machine."
        return
    }

    Invoke-TimeSignal -Start

    #Test if input is a zipFile that needs to be extracted first
    if ($Path.EndsWith(".zip")) {
        Unblock-File -Path $Path
        
        $extractedPath = $path.Remove($path.Length - 4)
        if (!(Test-Path $extractedPath)) {
            Expand-Archive -Path $Path -DestinationPath $extractedPath
            
            #lets work with the extracted directory from now on
            $Path = $extractedPath
        }
    }

    # Input is a relative path, hence we set the path to the current directory
    if ($Path -eq ".") {
        $currentPath = Get-Location
        Write-PSFMessage -Level Verbose "Updating path to '$currentPath' as relative paths are not supported"
        $Path = $currentPath
    }

    # $Util = Join-Path $Path "AXUpdateInstaller.exe"
    $executable = Join-Path $Path "AXUpdateInstaller.exe"

    $topologyFile = Join-Path $Path 'DefaultTopologyData.xml'

    if (-not (Test-PathExists -Path $topologyFile, $executable -Type Leaf)) { return }
        
    Get-ChildItem -Path $Path -Recurse | Unblock-File

    if ($QuickInstallAll) {
        Write-PSFMessage -Level Verbose "Using QuickInstallAll mode"
        $params = "quickinstallall"

        Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath
    }
    elseif ($DevInstall) {
        Write-PSFMessage -Level Verbose "Using DevInstall mode"
        $params = "devinstall"

        Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath
    }
    else {
        $Command = $Command.ToLowerInvariant()
        $runbookFile = Join-Path $Path "$runbookId.xml"
        $serviceModelFile = Join-Path $Path 'DefaultServiceModelData.xml'
        $topologyFile = Join-Path $Path 'DefaultTopologyData.xml'
                        
        if ($Command -eq 'runall') {
            Write-PSFMessage -Level Verbose "Running all manual steps in one single operation"

            #Update topology file (first command)
            $ok = Update-TopologyFile -Path $Path

            if ($ok) {
                $params = @(
                    "generate"
                    "-runbookId=`"$runbookId`""
                    "-topologyFile=`"$topologyFile`""
                    "-serviceModelFile=`"$serviceModelFile`""
                    "-runbookFile=`"$runbookFile`""
                )
                
                #Generate (second command)
                Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath

                if (Test-PSFFunctionInterrupt) { return }

                $params = @(
                    "import"
                    "-runbookFile=`"$runbookFile`""
                )

                Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath

                if (Test-PSFFunctionInterrupt) { return }

                $params = @(
                    "execute"
                    "-runbookId=`"$runbookId`""
                )

                Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath

                if (Test-PSFFunctionInterrupt) { return }
            }

            Write-PSFMessage -Level Verbose "All manual steps complete."
        }
        else {
            $RunCommand = $true
            switch ($Command) {
                'settopology' {
                    Write-PSFMessage -Level Verbose "Updating topology file xml."
                   
                    $ok = Update-TopologyFile -Path $Path
                    $RunCommand = $false
                }
                'generate' {
                    Write-PSFMessage -Level Verbose "Generating runbook file."
                    
                    $params = @(
                        "generate"
                        "-runbookId=`"$runbookId`""
                        "-topologyFile=`"$topologyFile`""
                        "-serviceModelFile=`"$serviceModelFile`""
                        "-runbookFile=`"$runbookFile`""
                    )
                }
                'import' {
                    Write-PSFMessage -Level Verbose "Importing runbook file."
                    
                    $params = @(
                        "import"
                        "-runbookfile=`"$runbookFile`""
                    )
                }
                'execute' {
                    Write-PSFMessage -Level Verbose "Executing runbook file."
                   
                    $params = @(
                        "execute"
                        "-runbookId=`"$runbookId`""
                    )
                }
                'rerunstep' {
                    Write-PSFMessage -Level Verbose "Rerunning runbook step number $step."
                   
                    $params = @(
                        "execute"
                        "-runbookId=`"$runbookId`""
                        "-rerunstep=$step"
                    )
                }
                'setstepcomplete' {
                    Write-PSFMessage -Level Verbose "Marking step $step complete and continuing from next step."
                   
                    $params = @(
                        "execute"
                        "-runbookId=`"$runbookId`""
                        "-setstepcomplete=$step"
                    )
                }
                'export' {
                    Write-PSFMessage -Level Verbose "Exporting runbook for reuse."

                    $params = @(
                        "export"
                        "-runbookId=`"$runbookId`""
                        "-runbookfile=`"$runbookFile`""
                    )
                }
                'versioncheck' {
                    Write-PSFMessage -Level Verbose "Running version check on runbook."
                    
                    $params = @(
                        "execute"
                        "-runbookId=`"$runbookId`""
                        "-versioncheck=true"
                    )
                }
            }

            if ($RunCommand) {
                Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath

                if (Test-PSFFunctionInterrupt) { return }
            }
        }
    }

    Invoke-TimeSignal -End
    
}


<#
    .SYNOPSIS
        Downloads the Selenium web driver files and deploys them to the specified destinations.
         
    .DESCRIPTION
        Downloads the Selenium web driver files and deploys them to the specified destinations.
         
    .PARAMETER RegressionSuiteAutomationTool
        Switch to specify if the Selenium files need to be installed in the Regression Suite Automation Tool folder.
         
    .PARAMETER PerfSDK
        Switch to specify if the Selenium files need to be installed in the PerfSDK folder.
         
    .EXAMPLE
        PS C:\> Invoke-D365SeleniumDownload -RegressionSuiteAutomationTool -PerfSDK
         
        This will download the Selenium zip archives and extract the files into both the Regression Suite Automation Tool folder and the PerfSDK folder.
         
    .NOTES
        Author: Kenny Saelen (@kennysaelen)
         
#>

  function Invoke-D365SeleniumDownload
  {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false, Position = 0)]
        [switch]$RegressionSuiteAutomationTool,
        [Parameter(Mandatory = $false, Position = 1)]
        [switch]$PerfSDK
    )

    if(!$RegressionSuiteAutomationTool -and !$PerfSDK)
    {
        Write-PSFMessage -Level Critical -Message "Either the -RegressionSuiteAutomationTool or the -PerfSDK switch needs to be specified."
        Stop-PSFFunction -Message "Stopping because of no switch parameters speficied."
        return
    }
    
    $seleniumDllZipLocalPath = (Join-Path $env:TEMP "selenium-dotnet-strongnamed-2.42.0.zip")
    $ieDriverZipLocalPath = (Join-Path $env:TEMP "IEDriverServer_Win32_2.42.0.zip")
    $zipExtractionPath = (Join-Path $env:TEMP "D365Seleniumextraction")
    
    try
    {
        Write-PSFMessage -Level Host -Message "Downloading Selenium files"
        
        $WebClient = New-Object System.Net.WebClient
        $WebClient.DownloadFile("http://selenium-release.storage.googleapis.com/2.42/selenium-dotnet-strongnamed-2.42.0.zip", $seleniumDllZipLocalPath)
        $WebClient.DownloadFile("http://selenium-release.storage.googleapis.com/2.42/IEDriverServer_Win32_2.42.0.zip", $ieDriverZipLocalPath)

        Write-PSFMessage -Level Host -Message "Extracting zip files"
        Add-Type -AssemblyName System.IO.Compression.FileSystem
        [System.IO.Compression.ZipFile]::ExtractToDirectory($seleniumDllZipLocalPath, $zipExtractionPath)
        [System.IO.Compression.ZipFile]::ExtractToDirectory($ieDriverZipLocalPath, $zipExtractionPath)

        $targetPath = [String]::Empty
        $seleniumPath = [String]::Empty

        if($RegressionSuiteAutomationTool)
        {
            Write-PSFMessage -Level Host -Message "Making Selenium folder structure in the Regression Suite Automation Tool folder"
            $targetPath = Join-Path ([Environment]::GetEnvironmentVariable("ProgramFiles(x86)")) "Regression Suite Automation Tool"
            $seleniumPath = Join-Path $targetPath "Common\External\Selenium"

            # Check if the Regression Suite Automation Tool is installed on the machine and Selenium not already installed
            if (Test-PathExists -Path $targetPath -Type Container)
            {
                if(-not(Test-PathExists -Path $seleniumPath -Type Container -Create))
                {
                    Write-PSFMessage -Level Critical -Message [String]::Format("The folder for the Selenium files could not be created: {0}", $seleniumPath)
                }

                Write-PSFMessage -Level Host -Message "Copying Selenium files to destination folder"
                Copy-Item (Join-Path $zipExtractionPath "IEDriverServer.exe") $seleniumPath
                Copy-Item (Join-Path $zipExtractionPath "net40\*") $seleniumPath
                Write-PSFMessage -Level Host -Message ([String]::Format("Selenium files have been downloaded and installed in the following folder: {0}", $seleniumPath))
            }
            else
            {
                Write-PSFMessage -Level Warning -Message [String]::Format("The RegressionSuiteAutomationTool switch parameter is specified but the tool could not be located in the following folder: {0}", $targetPath)
            }
        }
        
        if($PerfSDK)
        {
            Write-PSFMessage -Level Host -Message "Making Selenium folder structure in the PerfSDK folder"
            $targetPath = [Environment]::GetEnvironmentVariable("PerfSDK")
            $seleniumPath = Join-Path $targetPath "Common\External\Selenium"

            # Check if the PerfSDK is installed on the machine and Selenium not already installed
            if (Test-PathExists -Path $targetPath -Type Container)
            {
                if(-not(Test-PathExists -Path $seleniumPath -Type Container -Create))
                {
                    Write-PSFMessage -Level Critical -Message [String]::Format("The folder for the Selenium files could not be created: {0}", $seleniumPath)
                }

                Write-PSFMessage -Level Host -Message "Copying Selenium files to destination folder"
                Copy-Item (Join-Path $zipExtractionPath "IEDriverServer.exe") $seleniumPath
                Copy-Item (Join-Path $zipExtractionPath "net40\*") $seleniumPath
                Write-PSFMessage -Level Host -Message ([String]::Format("Selenium files have been downloaded and installed in the following folder: {0}", $seleniumPath))
            }
            else
            {
                Write-PSFMessage -Level Warning -Message [String]::Format("The PerfSDK switch parameter is specified but the tool could not be located in the following folder: {0}", $targetPath)
            }
        }
    }
    catch
    {
        Write-PSFMessage -Level Host -Message "Something went wrong while downloading and installing the Selenium files." -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
    finally
    {
        Write-PSFMessage -Level Host -Message "Cleaning up temporary files"
        Remove-Item -Path $seleniumDllZipLocalPath -Recurse
        Remove-Item -Path $ieDriverZipLocalPath -Recurse
        Remove-Item -Path $zipExtractionPath -Recurse
    }
}


<#
    .SYNOPSIS
        Execute a SQL Script or a SQL Command
         
    .DESCRIPTION
        Execute a SQL Script or a SQL Command against the D365FO SQL Server database
         
    .PARAMETER FilePath
        Path to the file containing the SQL Script that you want executed
         
    .PARAMETER Command
        SQL command that you want executed
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER TrustedConnection
        Switch to instruct the cmdlet whether the connection should be using Windows Authentication or not
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-D365SqlScript -FilePath "C:\temp\d365fo.tools\DeleteUser.sql"
         
        This will execute the "C:\temp\d365fo.tools\DeleteUser.sql" against the registered SQL Server on the machine.
         
    .EXAMPLE
        PS C:\> Invoke-D365SqlScript -Command "DELETE FROM SALESTABLE WHERE RECID = 123456789"
         
        This will execute "DELETE FROM SALESTABLE WHERE RECID = 123456789" against the registered SQL Server on the machine.
         
    .NOTES
        Author: M�tz Jensen (@splaxi)
         
        Author: Caleb Blanchard (@daxcaleb)
#>

Function Invoke-D365SqlScript {
    [Alias("Invoke-D365SqlCmd")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = "FilePath" )]
        [string] $FilePath,

        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = "Command" )]
        [string] $Command,

        [string] $DatabaseServer = $Script:DatabaseServer,

        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword,
        
        [bool] $TrustedConnection = $false,

        [switch] $EnableException
    )

    if ($PSCmdlet.ParameterSetName -eq "FilePath") {
        if (-not (Test-PathExists -Path $FilePath -Type Leaf)) { return }
    }

    Invoke-TimeSignal -Start

    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

    $Params = @{}

    #Hack to get all variables for the function, regardless of they were assigned from the caller or with default values.
    #The TrustedConnection is the real deal breaker. If $true user and password are ignored in Get-SqlCommand.
    $MyInvocation.MyCommand.Parameters.Keys | Get-Variable -ErrorAction Ignore | ForEach-Object { $Params.Add($_.Name, $_.Value) };
    
    $null = $Params.Remove('FilePath')
    $null = $Params.Remove('Command')
    $null = $Params.Remove('EnableException')
    
    $Params.TrustedConnection = $UseTrustedConnection

    $sqlCommand = Get-SqlCommand @Params

    if ($PSCmdlet.ParameterSetName -eq "FilePath") {
        $sqlCommand.CommandText = (Get-Content "$FilePath") -join [Environment]::NewLine
    }
    if ($PSCmdlet.ParameterSetName -eq "Command") {
        $sqlCommand.CommandText = $Command
    }

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()

        $null = $sqlCommand.ExecuteNonQuery()
    }
    catch {
        $messageString = "Something went wrong while <c='em'>executing custom sql script</c> against the database."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_ -StepsUpward 1
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Invoke the SysFlushAos class
         
    .DESCRIPTION
        Invoke the runnable class SysFlushAos to clear the AOD cache
         
    .PARAMETER URL
        URL to the Dynamics 365 instance you want to clear the AOD cache on
         
    .EXAMPLE
        PS C:\> Invoke-D365SysFlushAodCache
         
        This will a call against the default URL for the machine and
        have it execute the SysFlushAOD class
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Invoke-D365SysFlushAodCache {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false, Position = 1 )]
        [string] $Url
    )

    if ($PSBoundParameters.ContainsKey("URL")) {
        Invoke-D365SysRunnerClass -ClassName "SysFlushAOD" -Url $URL
    }
    else {
        Invoke-D365SysRunnerClass -ClassName "SysFlushAOD"
    }
}


<#
    .SYNOPSIS
        Start a browser session that executes SysRunnerClass
         
    .DESCRIPTION
        Makes it possible to call any runnable class directly from the browser, without worrying about the details
         
    .PARAMETER ClassName
        The name of the class you want to execute
         
    .PARAMETER Company
        The company for which you want to execute the class against
         
        Default value is: "DAT"
         
    .PARAMETER Url
        The URL you want to execute against
         
        Default value is the Fully Qualified Domain Name registered on the machine
         
    .EXAMPLE
        PS C:\> Invoke-D365SysRunnerClass -ClassName SysFlushAOD
         
        Will execute the SysRunnerClass and have it execute the SysFlushAOD class and will run it against the "DAT" (default value) company
         
    .EXAMPLE
        PS C:\> Invoke-D365SysRunnerClass -ClassName SysFlushAOD -Company "USMF"
         
        Will execute the SysRunnerClass and have it execute the SysFlushAOD class and will run it against the "USMF" company
         
    .EXAMPLE
        PS C:\> Invoke-D365SysRunnerClass -ClassName SysFlushAOD -Url https://Test.cloud.onebox.dynamics.com
         
        Will execute the SysRunnerClass and have it execute the SysFlushAOD class and will run it against the "DAT" company, on the https://Test.cloud.onebox.dynamics.com URL
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Invoke-D365SysRunnerClass {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Default', Position = 1 )]
        [string] $ClassName,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 2 )]
        
        [string] $Company = $Script:Company,
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 3 )]
        [string] $Url = $Script:Url
    )

    $executingUrl = "$Url`?cmp=$Company&mi=SysClassRunner&cls=$ClassName"
    
    Start-Process $executingUrl
}


<#
    .SYNOPSIS
        Start a browser session that will show the table browser
         
    .DESCRIPTION
        Makes it possible to call the table browser for a given table directly from the web browser, without worrying about the details
         
    .PARAMETER TableName
        The name of the table you want to see the rows for
         
    .PARAMETER Company
        The company for which you want to see the data from in the given table
         
        Default value is: "DAT"
         
    .PARAMETER Url
        The URL you want to execute against
         
        Default value is the Fully Qualified Domain Name registered on the machine
         
    .EXAMPLE
        PS C:\> Invoke-D365TableBrowser -TableName SalesTable
         
        Will open the table browser and show all the records in Sales Table from the "DAT" company (default value).
         
    .EXAMPLE
        PS C:\> Invoke-D365TableBrowser -TableName SalesTable -Company "USMF"
         
        Will open the table browser and show all the records in Sales Table from the "USMF" company.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
        The cmdlet supports piping and can be used in advanced scenarios. See more on github and the wiki pages.
         
#>

function Invoke-D365TableBrowser {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Default', ValueFromPipelineByPropertyName = $true, Position = 1 )]
        [string] $TableName,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', ValueFromPipelineByPropertyName = $true, Position = 2 )]
        [string] $Company = $Script:Company,

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', ValueFromPipelineByPropertyName = $true, Position = 3 )]
        [string] $Url = $Script:Url
    )
    BEGIN {}

    PROCESS {
        Write-PSFMessage -Level Verbose -Message "Table name: $TableName" -Target $TableName
        $executingUrl = "$Url`?cmp=$Company&mi=SysTableBrowser&tablename=$TableName"

        Start-Process $executingUrl

        #* Allow the browser to start and process first request if it isn't running already
        Start-Sleep -Seconds 1
    }

    END {}
}


<#
    .SYNOPSIS
        Analyze the Visual Studio compiler output log
         
    .DESCRIPTION
        Analyze the Visual Studio compiler output log and generate an excel file contain worksheets per type: Errors, Warnings, Tasks
         
    .PARAMETER Module
        Name of the module that you want to work against
         
        Default value is "*" which will search for all modules
         
    .PARAMETER OutputPath
        Path where you want the excel file (xlsx-file) saved to
         
        Default value is: "c:\temp\d365fo.tools\"
         
    .PARAMETER SkipWarnings
        Instructs the cmdlet to skip warnings while analyzing the compiler output log file
         
    .PARAMETER SkipTasks
        Instructs the cmdlet to skip tasks while analyzing the compiler output log file
         
    .PARAMETER PackageDirectory
        Path to the directory containing the installed package / module
         
        Default path is the same as the AOS service "PackagesLocalDirectory" directory
         
        Default value is fetched from the current configuration on the machine
         
    .EXAMPLE
        PS C:\> Invoke-D365VisualStudioCompilerResultAnalyzer
         
        This will analyse all compiler output log files generated from Visual Studio.
         
        A result set example:
         
        File Filename
        ---- --------
        c:\temp\d365fo.tools\ApplicationCommon-CompilerResults.xlsx ApplicationCommon-CompilerResults.xlsx
        c:\temp\d365fo.tools\ApplicationFoundation-CompilerResults.xlsx ApplicationFoundation-CompilerResults.xlsx
        c:\temp\d365fo.tools\ApplicationPlatform-CompilerResults.xlsx ApplicationPlatform-CompilerResults.xlsx
        c:\temp\d365fo.tools\ApplicationSuite-CompilerResults.xlsx ApplicationSuite-CompilerResults.xlsx
        c:\temp\d365fo.tools\ApplicationWorkspaces-CompilerResults.xlsx ApplicationWorkspaces-CompilerResults.xlsx
         
    .EXAMPLE
        PS C:\> Invoke-D365VisualStudioCompilerResultAnalyzer -SkipWarnings
         
        This will analyse all compiler output log files generated from Visual Studio.
        It will exclude all warnings from the output.
         
        A result set example:
         
        File Filename
        ---- --------
        c:\temp\d365fo.tools\ApplicationCommon-CompilerResults.xlsx ApplicationCommon-CompilerResults.xlsx
        c:\temp\d365fo.tools\ApplicationFoundation-CompilerResults.xlsx ApplicationFoundation-CompilerResults.xlsx
        c:\temp\d365fo.tools\ApplicationPlatform-CompilerResults.xlsx ApplicationPlatform-CompilerResults.xlsx
        c:\temp\d365fo.tools\ApplicationSuite-CompilerResults.xlsx ApplicationSuite-CompilerResults.xlsx
        c:\temp\d365fo.tools\ApplicationWorkspaces-CompilerResults.xlsx ApplicationWorkspaces-CompilerResults.xlsx
         
    .EXAMPLE
        PS C:\> Invoke-D365VisualStudioCompilerResultAnalyzer -SkipTasks
         
        This will analyse all compiler output log files generated from Visual Studio.
        It will exclude all tasks from the output.
         
        A result set example:
         
        File Filename
        ---- --------
        c:\temp\d365fo.tools\ApplicationCommon-CompilerResults.xlsx ApplicationCommon-CompilerResults.xlsx
        c:\temp\d365fo.tools\ApplicationFoundation-CompilerResults.xlsx ApplicationFoundation-CompilerResults.xlsx
        c:\temp\d365fo.tools\ApplicationPlatform-CompilerResults.xlsx ApplicationPlatform-CompilerResults.xlsx
        c:\temp\d365fo.tools\ApplicationSuite-CompilerResults.xlsx ApplicationSuite-CompilerResults.xlsx
        c:\temp\d365fo.tools\ApplicationWorkspaces-CompilerResults.xlsx ApplicationWorkspaces-CompilerResults.xlsx
         
    .NOTES
        Tags: Compiler, Build, Errors, Warnings, Tasks
         
        Author: M�tz Jensen (@Splaxi)
         
        This cmdlet is inspired by the work of "Vilmos Kintera" (twitter: @DAXRunBase)
         
        All credits goes to him for showing how to extract these information
         
        His blog can be found here:
        https://www.daxrunbase.com/blog/
         
        The specific blog post that we based this cmdlet on can be found here:
        https://www.daxrunbase.com/2020/03/31/interpreting-compiler-results-in-d365fo-using-powershell/
         
        The github repository containing the original scrips can be found here:
        https://github.com/DAXRunBase/PowerShell-and-Azure
#>

function Invoke-D365VisualStudioCompilerResultAnalyzer {
    [CmdletBinding()]
    [OutputType('')]
    param (
        [Alias("ModuleName")]
        [string] $Module = "*",

        [string] $OutputPath = $Script:DefaultTempPath,

        [switch] $SkipWarnings,

        [switch] $SkipTasks,

        [string] $PackageDirectory = $Script:PackageDirectory
    )

    Invoke-TimeSignal -Start

    if (-not (Test-PathExists -Path $PackageDirectory -Type Container)) { return }
    
    $buildOutputFiles = Get-ChildItem -Path "$PackageDirectory\$Module\BuildModelResult.log" -ErrorAction SilentlyContinue -Force

    foreach ($result in $buildOutputFiles) {
        
        $moduleName = Split-Path -Path $result.DirectoryName -Leaf
        $outputFilePath = Join-Path -Path $OutputPath -ChildPath "$moduleName-CompilerResults.xlsx"

        Invoke-CompilerResultAnalyzer -Path $result.FullName -Identifier $moduleName -OutputPath $outputFilePath -SkipWarnings:$SkipWarnings -SkipTasks:$SkipTasks -PackageDirectory $PackageDirectory
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Rotate the certificate used for WinRM
         
    .DESCRIPTION
        There is a scenario where you might need to update the certificate that is being used for WinRM on your Tier1 environment
         
        1 year after you deploy your Tier1 environment, the original WinRM certificate expires and then LCS will be unable to communicate with your Tier1 environment
         
    .PARAMETER MachineName
        The DNS / Netbios name of the machine
         
        The default value is: "$env:COMPUTERNAME" which translates into the current name of the machine
         
    .EXAMPLE
        PS C:\> Invoke-D365WinRmCertificateRotation
         
        This will update the certificate that is being used by WinRM.
        A new certificate is created with the current computer name.
        The new certificate and its thumbprint will be configured for WinRM to use that going forward.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
        We recommend that you do a full restart of the Tier1 environment when done.
         
#>

function Invoke-D365WinRmCertificateRotation {
    [CmdletBinding()]
    [OutputType()]
    param(
        [string] $MachineName = $env:COMPUTERNAME
    )

    Write-PSFMessage -Level Verbose "Creating a new certificate."

    $CertStore = "Cert:\LocalMachine\My"
    $Thumbprint = (New-SelfSignedCertificate -DnsName $MachineName -CertStoreLocation $CertStore).Thumbprint

    $executable = "C:\Windows\System32\cmd.exe"

    $params = @("/C", "winrm", "set",
        "winrm/config/Listener?Address=*+Transport=HTTPS",
        "@{Hostname=""$DNSName""; CertificateThumbprint=""$Thumbprint""}"
    )

    Write-PSFMessage -Level Verbose "Configure WinRM to use the newly created certificate."

    Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$true
}


<#
    .SYNOPSIS
        Generate a bacpac file from a database
         
    .DESCRIPTION
        Takes care of all the details and steps that is needed to create a valid bacpac file to move between Tier 1 (onebox or Azure hosted) and Tier 2 (MS hosted), or vice versa
         
        Supports to create a raw bacpac file without prepping. Can be used to automate backup from Tier 2 (MS hosted) environment
         
    .PARAMETER ExportModeTier1
        Switch to instruct the cmdlet that the export will be done against a classic SQL Server installation
         
    .PARAMETER ExportModeTier2
        Switch to instruct the cmdlet that the export will be done against an Azure SQL DB instance
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER BackupDirectory
        The path where to store the temporary backup file when the script needs to handle that
         
    .PARAMETER NewDatabaseName
        The name for the database the script is going to create when doing the restore process
         
    .PARAMETER BacpacFile
        The path where you want the cmdlet to store the bacpac file that will be generated
         
    .PARAMETER CustomSqlFile
        The path to a custom sql server script file that you want executed against the database before it is exported
         
    .PARAMETER DiagnosticFile
        Path to where you want the export to output a diagnostics file to assist you in troubleshooting the export
         
    .PARAMETER ExportOnly
        Switch to instruct the cmdlet to either just create a dump bacpac file or run the prepping process first
         
    .PARAMETER MaxParallelism
        Sets SqlPackage.exe's degree of parallelism for concurrent operations running against a database. The default value is 8.
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Invoke-D365InstallSqlPackage
         
        You should always install the latest version of the SqlPackage.exe, which is used by New-D365Bacpac.
         
        This will fetch the latest .Net Core Version of SqlPackage.exe and install it at "C:\temp\d365fo.tools\SqlPackage".
         
    .EXAMPLE
        PS C:\> New-D365Bacpac -ExportModeTier1 -BackupDirectory c:\Temp\backup\ -NewDatabaseName Testing1 -BacpacFile "C:\Temp\Bacpac\Testing1.bacpac"
         
        Will backup the "AXDB" database and restore is as "Testing1" again the localhost SQL Server.
        Will run the prepping process against the restored database.
        Will export a bacpac file to "C:\Temp\Bacpac\Testing1.bacpac".
        Will delete the restored database.
        It will use trusted connection (Windows authentication) while working against the SQL Server.
         
    .EXAMPLE
        PS C:\> New-D365Bacpac -ExportModeTier2 -DatabaseServer localhost -DatabaseName AxDB -SqlUser User123 -SqlPwd "Password123" -NewDatabaseName Testing1 -BacpacFile C:\Temp\Bacpac\Testing1.bacpac
         
        Will create a copy the db database on the dbserver1 in Azure.
        Will run the prepping process against the copy database.
        Will export a bacpac file.
        Will delete the copy database.
         
    .EXAMPLE
        PS C:\> New-D365Bacpac -ExportModeTier2 -SqlUser User123 -SqlPwd "Password123" -NewDatabaseName Testing1 -BacpacFile "C:\Temp\Bacpac\Testing1.bacpac"
         
        Normally used for a Tier-2 export and preparation for Tier-1 import
         
        Will create a copy of the registered D365 database on the registered D365 Azure SQL DB instance.
        Will run the prepping process against the copy database.
        Will export a bacpac file.
        Will delete the copy database.
         
    .EXAMPLE
        PS C:\> New-D365Bacpac -ExportModeTier2 -SqlUser User123 -SqlPwd "Password123" -NewDatabaseName Testing1 -BacpacFile C:\Temp\Bacpac\Testing1.bacpac -ExportOnly
         
        Will export a bacpac file.
        The bacpac should be able to restore back into the database without any preparing because it is coming from the environment from the beginning
         
    .EXAMPLE
        PS C:\> New-D365Bacpac -ExportModeTier1 -BackupDirectory c:\Temp\backup\ -NewDatabaseName Testing1 -BacpacFile "C:\Temp\Bacpac\Testing1.bacpac" -DiagnosticFile "C:\temp\ExportLog.txt"
         
        Will backup the "AXDB" database and restore is as "Testing1" again the localhost SQL Server.
        Will run the prepping process against the restored database.
        Will export a bacpac file to "C:\Temp\Bacpac\Testing1.bacpac".
        Will delete the restored database.
        It will use trusted connection (Windows authentication) while working against the SQL Server.
         
        It will output a diagnostic file to "C:\temp\ExportLog.txt".
         
    .EXAMPLE
        PS C:\> New-D365Bacpac -ExportModeTier1 -BackupDirectory c:\Temp\backup\ -NewDatabaseName Testing1 -BacpacFile "C:\Temp\Bacpac\Testing1.bacpac" -MaxParallelism 32
         
        Will backup the "AXDB" database and restore is as "Testing1" again the localhost SQL Server.
        Will run the prepping process against the restored database.
        Will export a bacpac file to "C:\Temp\Bacpac\Testing1.bacpac".
        Will delete the restored database.
        It will use trusted connection (Windows authentication) while working against the SQL Server.
         
        It will use 32 connections against the database server while generating the bacpac file.
         
    .NOTES
        The cmdlet supports piping and can be used in advanced scenarios. See more on github and the wiki pages.
         
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function New-D365Bacpac {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseProcessBlockForPipelineCommand", "")]
    [CmdletBinding(DefaultParameterSetName = 'ExportTier2')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'ExportTier1', Position = 0)]
        [switch] $ExportModeTier1,

        [Parameter(Mandatory = $true, ParameterSetName = 'ExportTier2', Position = 0)]
        [switch] $ExportModeTier2,

        [Parameter(Position = 1 )]
        [string] $DatabaseServer = $Script:DatabaseServer,

        [Parameter(Position = 2 )]
        [string] $DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, Position = 3 )]
        [Parameter(Mandatory = $true, ParameterSetName = 'ExportTier2', ValueFromPipelineByPropertyName = $true, Position = 3)]
        [string] $SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, Position = 4 )]
        [Parameter(Mandatory = $true, ParameterSetName = 'ExportTier2', ValueFromPipelineByPropertyName = $true, Position = 4)]
        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [Parameter(ParameterSetName = 'ExportTier1', Position = 5 )]
        [string] $BackupDirectory = "C:\Temp\d365fo.tools\SqlBackups",

        [Parameter(Position = 6 )]
        [string] $NewDatabaseName = "$Script:DatabaseName`_export",

        [Parameter(Position = 7 )]
        [Alias('File')]
        [string] $BacpacFile = "C:\Temp\d365fo.tools\$DatabaseName.bacpac",

        [Parameter(Position = 8 )]
        [string] $CustomSqlFile,

        [string] $DiagnosticFile,

        [switch] $ExportOnly,

        [int] $MaxParallelism = 8,

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly,

        [switch] $EnableException
    )
    
    Invoke-TimeSignal -Start

    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters
    
    if ($PSBoundParameters.ContainsKey("CustomSqlFile")) {
        if (-not (Test-PathExists -Path $CustomSqlFile -Type Leaf)) { return }
        $ExecuteCustomSQL = $true
    }

    if ($BacpacFile -notlike "*.bacpac") {
        Write-PSFMessage -Level Host -Message "The path for the bacpac file must contain the <c='em'>.bacpac</c> extension. Please update the <c='em'>BacpacFile</c> parameter and try again."
        Stop-PSFFunction -Message "The BacpacFile path was not correct."
        return
    }

    if ($PSBoundParameters.ContainsKey("BackupDirectory") -or $ExportModeTier1) {
        if (-not (Test-PathExists -Path $BackupDirectory -Type Container -Create)) { return }
    }
    
    if (-not (Test-PathExists -Path (Split-Path $BacpacFile -Parent) -Type Container -Create)) { return }

    # Work around to make sure to keep Storage when using the non-core version of the SqlPackage
    $executable = $Script:SqlPackagePath
    $classicPattern = "C:\Program Files*\Microsoft SQL Server\1*0\DAC\bin\SqlPackage.exe"

    [System.Collections.ArrayList] $Properties = New-Object -TypeName "System.Collections.ArrayList"

    $null = $Properties.Add("VerifyFullTextDocumentTypesSupported=false")

    if($executable -like $classicPattern) {
        Write-PSFMessage -Level Verbose -Message "Looks like we are running against the non-core version of SqlPackage.exe. Then we need to support the Storage=File property."
        $null = $Properties.Add("Storage=File")
    }

    $BaseParams = @{
        DatabaseServer = $DatabaseServer
        DatabaseName   = $DatabaseName
        SqlUser        = $SqlUser
        SqlPwd         = $SqlPwd
    }

    $ExportParams = @{
        Action     = "export"
        FilePath   = $BacpacFile
        Properties = $Properties.ToArray()
        MaxParallelism = $MaxParallelism
    }

    if (-not [system.string]::IsNullOrEmpty($DiagnosticFile)) {
        if (-not (Test-PathExists -Path (Split-Path $DiagnosticFile -Parent) -Type Container -Create)) { return }
        $ExportParams.DiagnosticFile = $DiagnosticFile
    }

    if ($ExportOnly) {
        
        Write-PSFMessage -Level Verbose -Message "Invoking the export of the bacpac file only."

        Write-PSFMessage -Level Verbose -Message "Invoking the sqlpackage with parameters" -Target $BaseParams
        Invoke-SqlPackage @BaseParams @ExportParams -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly

        if ($OutputCommandOnly) { return }

        if (Test-PSFFunctionInterrupt) { return }

        [PSCustomObject]@{
            File     = $BacpacFile
            Filename = (Split-Path $BacpacFile -Leaf)
        }
    }
    else {
        if ($ExportModeTier1) {
            $Params = @{
                BackupDirectory   = $BackupDirectory
                NewDatabaseName   = $NewDatabaseName
                TrustedConnection = $UseTrustedConnection
            }
            
            if (-not $OutputCommandOnly) {
                Write-PSFMessage -Level Verbose -Message "Invoking the Tier 1 - SQL backup & restore process"
                $res = Invoke-SqlBackupRestore @BaseParams @Params

                if ((Test-PSFFunctionInterrupt) -or (-not $res)) { return }

                $Params = Get-DeepClone $BaseParams
                $Params.DatabaseName = $NewDatabaseName

                Write-PSFMessage -Level Verbose -Message "Invoking the Tier 1 - Clear SQL objects"
                $res = Invoke-ClearSqlSpecificObjects @Params

                if ((Test-PSFFunctionInterrupt) -or (-not $res)) { return }

                if ($ExecuteCustomSQL) {
                    Write-PSFMessage -Level Verbose -Message "Invoking the Tier 1 - Execution of custom SQL script"
                    $res = Invoke-D365SqlScript @Params -FilePath $CustomSqlFile

                    if (Test-PSFFunctionInterrupt) { return }
                }
            }
            else {
                $Params = Get-DeepClone $BaseParams
                $Params.DatabaseName = $NewDatabaseName
            }

            Write-PSFMessage -Level Verbose -Message "Invoking the Tier 1 - Export of the bacpac file from SQL"
            Invoke-SqlPackage @Params @ExportParams -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly
            
            if ($OutputCommandOnly) { return }

            if (Test-PSFFunctionInterrupt) { return }

            Write-PSFMessage -Level Verbose -Message "Invoking the Tier 1 - Remove database from SQL"
            Remove-D365Database @Params

            [PSCustomObject]@{
                File     = $BacpacFile
                Filename = (Split-Path $BacpacFile -Leaf)
            }
        }
        else {
            $Params = @{
                NewDatabaseName = $NewDatabaseName
            }

            if (-not $OutputCommandOnly) {
                Write-PSFMessage -Level Verbose -Message "Invoking the Tier 2 - Creation of Azure DB copy"
                $res = Invoke-AzureBackupRestore @BaseParams @Params
            
                if ((Test-PSFFunctionInterrupt) -or (-not $res)) { return }
            
                $Params = Get-DeepClone $BaseParams
                $Params.DatabaseName = $NewDatabaseName
                Write-PSFMessage -Level Verbose -Message "Invoking the Tier 2 - Clear Azure DB objects"
                $res = Invoke-ClearAzureSpecificObjects @Params

                if ((Test-PSFFunctionInterrupt) -or (-not $res)) { return }

                if ($ExecuteCustomSQL) {
                    Write-PSFMessage -Level Verbose -Message "Invoking the Tier 2 - Execution of custom SQL script"
                    $res = Invoke-D365SqlScript @Params -FilePath $CustomSqlFile -TrustedConnection $false

                    if (!$res) { return }
                }
            }

            Write-PSFMessage -Level Verbose -Message "Invoking the Tier 2 - Export of the bacpac file from Azure DB"
            Invoke-SqlPackage @Params @ExportParams -TrustedConnection $false -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly

            if ($OutputCommandOnly) { return }

            if (Test-PSFFunctionInterrupt) { return }
            
            Write-PSFMessage -Level Verbose -Message "Invoking the Tier 2 - Remove database from Azure DB"
            Remove-D365Database @Params

            if (Test-PSFFunctionInterrupt) {
                $messageString = "The bacpac file was created correctly, but there was an error while <c='em'>removing</c> the cloned database."
                Write-PSFMessage -Level Host -Message $messageString
            }

            [PSCustomObject]@{
                File     = $BacpacFile
                Filename = (Split-Path $BacpacFile -Leaf)
            }
        }
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Generate the Customization's Analysis Report (CAR)
         
    .DESCRIPTION
        A cmdlet that wraps some of the cumbersome work into a streamlined process
         
    .PARAMETER OutputPath
        Path where you want the CAR file (xlsx-file) saved to
         
        Default value is: "c:\temp\d365fo.tools\CAReport.xlsx"
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the AOS service PackagesLocalDirectory\bin
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER Module
        Name of the Module to analyse
         
    .PARAMETER Model
        Name of the Model to analyse
         
    .PARAMETER XmlLog
        Path where you want to store the Xml log output generated from the best practice analyser
         
    .PARAMETER PackagesRoot
        Instructs the cmdlet to use binary metadata
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .PARAMETER SuffixWithModule
        Instruct the cmdlet to append the module name as a suffix to the desired output file name
         
    .EXAMPLE
        PS C:\> New-D365CAReport -module "ApplicationSuite" -model "MyOverLayerModel"
         
        This will generate a CAR report against MyOverLayerModel in the ApplicationSuite Module.
        It will use the default value for the OutputPath parameter, which is "c:\temp\d365fo.tools\CAReport.xlsx".
         
    .EXAMPLE
        PS C:\> New-D365CAReport -OutputPath "c:\temp\CAReport.xlsx" -module "ApplicationSuite" -model "MyOverLayerModel"
         
        This will generate a CAR report against MyOverLayerModel in the ApplicationSuite Module.
        It will use the "c:\temp\CAReport.xlsx" value for the OutputPath parameter.
         
    .EXAMPLE
        PS C:\> New-D365CAReport -module "ApplicationSuite" -model "MyOverLayerModel" -SuffixWithModule
         
        This will generate a CAR report against MyOverLayerModel in the ApplicationSuite Module.
        It will use the default value for the OutputPath parameter, which is "c:\temp\d365fo.tools\CAReport.xlsx".
        It will append the module name to the desired output file, which will then be "c:\temp\d365fo.tools\CAReport-ApplicationSuite.xlsx".
         
    .EXAMPLE
        PS C:\> New-D365CAReport -OutputPath "c:\temp\CAReport.xlsx" -module "ApplicationSuite" -model "MyOverLayerModel" -PackagesRoot
         
        This will generate a CAR report against MyOverLayerModel in the ApplicationSuite Module.
        It will use the binary metadata to look for the module and model.
        It will use the "c:\temp\CAReport.xlsx" value for the OutputPath parameter.
         
    .NOTES
        Author: Tommy Skaue (@Skaue)
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function New-D365CAReport {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    param (
        [Alias('File')]
        [Alias('Path')]
        [string] $OutputPath = (Join-Path $Script:DefaultTempPath "CAReport.xlsx"),

        [Parameter(Mandatory = $true)]
        [Alias('Package')]
        [Alias("ModuleName")]
        [string] $Module,
        
        [Parameter(Mandatory = $true)]
        [string] $Model,

        [switch] $SuffixWithModule,

        [string] $BinDir = "$Script:PackageDirectory\bin",

        [string] $MetaDataDir = "$Script:MetaDataDir",

        [string] $XmlLog = (Join-Path $Script:DefaultTempPath "BPCheckLogcd.xml"),

        [switch] $PackagesRoot,

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\CAReport"),

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly
    )
    
    if (-not (Test-PathExists -Path $MetaDataDir, $BinDir -Type Container)) { return }

    $executable = Join-Path $BinDir "xppbp.exe"
    if (-not (Test-PathExists -Path $executable -Type Leaf)) { return }

    if ($SuffixWithModule) {
        $OutputPath = $OutputPath.Replace(".xlsx", "-$Module.xlsx")
    }

    $params = @(
        "-metadata=`"$MetaDataDir`"",
        "-all",
        "-module=`"$Module`"",
        "-model=`"$Model`"",
        "-xmlLog=`"$XmlLog`"",
        "-car=`"$OutputPath`""
    )

    if ($PackagesRoot -eq $true) {
        $params += "-packagesroot=`"$MetaDataDir`""
    }

    Write-PSFMessage -Level Verbose -Message "Starting the $executable with the parameter options." -Target $param

    Invoke-Process -Executable $executable -Params $params -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath

    if (Test-PSFFunctionInterrupt) { return }

    [PSCustomObject]@{
        File     = $OutputPath
        Filename = (Split-Path $OutputPath -Leaf)
    }
}


<#
    .SYNOPSIS
        Create a license deployable package
         
    .DESCRIPTION
        Create a deployable package with a license file inside
         
    .PARAMETER LicenseFile
        Path to the license file that you want to have inside a deployable package
         
    .PARAMETER Path
        Path to the template zip file for creating a deployable package with a license file
         
        Default path is the same as the aos service "PackagesLocalDirectory\bin\CustomDeployablePackage\ImportISVLicense.zip"
         
    .PARAMETER OutputPath
        Path where you want the generated deployable package stored
         
        Default value is: "C:\temp\d365fo.tools\ISVLicense.zip"
         
    .EXAMPLE
        PS C:\> New-D365ISVLicense -LicenseFile "C:\temp\ISVLicenseFile.txt"
         
        This will take the "C:\temp\ISVLicenseFile.txt" file and locate the "ImportISVLicense.zip" template file under the "PackagesLocalDirectory\bin\CustomDeployablePackage\".
        It will extract the "ImportISVLicense.zip", load the ISVLicenseFile.txt and compress (zip) the files into a deployable package.
        The package will be exported to "C:\temp\d365fo.tools\ISVLicense.zip"
         
    .NOTES
        Author: M�tz Jensen (@splaxi)
        Author: Szabolcs E�tv�s
         
#>

function New-D365ISVLicense {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (

        [Parameter(Mandatory = $true, Position = 1)]
        [string] $LicenseFile,

        [Alias('Template')]
        [string] $Path = "$Script:BinDirTools\CustomDeployablePackage\ImportISVLicense.zip",

        [string] $OutputPath = "C:\temp\d365fo.tools\ISVLicense.zip"

    )

    begin {
        $oldprogressPreference = $global:progressPreference
        $global:progressPreference = 'silentlyContinue'
    }
    
    process {

        if (-not (Test-PathExists -Path $Path, $LicenseFile -Type "Leaf")) { return }

        $null = New-Item -Path (Split-Path $OutputPath -Parent) -ItemType Directory -ErrorAction SilentlyContinue

        Unblock-File $Path
        Unblock-File $LicenseFile

        $ExtractionPath = [System.IO.Path]::GetTempPath()

        $packageTemp = Join-Path $ExtractionPath ((Get-Random -Maximum 99999).ToString())

        Write-PSFMessage -Level Verbose -Message "Extracting the template zip file to $packageTemp." -Target $packageTemp
        Expand-Archive -Path $Path -DestinationPath $packageTemp

        $licenseMergePath = Join-Path $packageTemp "AosService\Scripts\License"
        
        if (Test-Path -Path $licenseMergePath) {
            Get-ChildItem -Path $licenseMergePath | Remove-Item -Force -ErrorAction SilentlyContinue
        }
        else {
            $null = New-Item -Path $licenseMergePath -ItemType Directory -ErrorAction SilentlyContinue
        }
        
        Write-PSFMessage -Level Verbose -Message "Copying the license file into place."
        Copy-Item -Path $LicenseFile -Destination $licenseMergePath

        Write-PSFMessage -Level Verbose -Message "Compressing the folder into a zip file and storing it at $OutputPath" -Target $OutputPath
        Compress-Archive -Path "$packageTemp\*" -DestinationPath $OutputPath -Force

        [PSCustomObject]@{
            File = $OutputPath
        }
    }

    end {
        $global:progressPreference = $oldprogressPreference
    }
}


<#
    .SYNOPSIS
        Create a new topology file
         
    .DESCRIPTION
        Build a new topology file based on a template and update the ServiceModelList
         
    .PARAMETER Path
        Path to the template topology file
         
    .PARAMETER Services
        The array with all the service names that you want to fill into the topology file
         
    .PARAMETER NewPath
        Path to where you want to save the new file after it has been created
         
    .EXAMPLE
        PS C:\> New-D365TopologyFile -Path C:\Temp\DefaultTopologyData.xml -Services "ALMService","AOSService","BIService" -NewPath C:\temp\CurrentTopology.xml
         
        This will read the "DefaultTopologyData.xml" file and fill in "ALMService","AOSService" and "BIService"
        as the services in the ServiceModelList tag. The new file is stored at "C:\temp\CurrentTopology.xml"
         
    .EXAMPLE
        PS C:\> $Services = @(Get-D365InstalledService | ForEach-Object {$_.Servicename})
        PS C:\> New-D365TopologyFile -Path C:\Temp\DefaultTopologyData.xml -Services $Services -NewPath C:\temp\CurrentTopology.xml
         
        This will get all the services already installed on the machine. Afterwards the list is piped
        to New-D365TopologyFile where all services are import into the new topology file that is stored at "C:\temp\CurrentTopology.xml"
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function New-D365TopologyFile {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Default', Position = 1 )]
        [alias('File')]
        [string] $Path,

        [Parameter(Mandatory = $true, ParameterSetName = 'Default', Position = 2 )]
        [string[]] $Services,

        [Parameter(Mandatory = $true, ParameterSetName = 'Default', Position = 3 )]
        [alias('NewFile')]
        [string] $NewPath
    )
    
    begin {
    }
    
    process {

        if (Test-PathExists -Path $Path -Type Leaf) {
            Remove-Item -Path $NewPath -Force -ErrorAction SilentlyContinue
            
            [xml]$topology = [xml](Get-Content -Path $Path)

            [System.Collections.ArrayList] $ServicesList = New-Object -TypeName "System.Collections.ArrayList"
            
            foreach ($obj in $Services) {
                $null = $ServicesList.Add("<string>$obj</string>")
            }

            $topology.TopologyData.MachineList.Machine.ServiceModelList.InnerXml = (($ServicesList.ToArray()) -join [Environment]::NewLine )
            
            $sw = New-Object System.Io.Stringwriter
            $writer = New-Object System.Xml.XmlTextWriter($sw)
            $writer.Formatting = [System.Xml.Formatting]::Indented
            $writer.Indentation = 4;
            $topology.WriteContentTo($writer)

            $topology.LoadXml($sw.ToString())
            $topology.Save("$NewPath")
        }
        else {
            Write-PSFMessage -Level Critical -Message "The base topology file wasn't found at the specified location. Please check the path and run the cmdlet again."
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }
    }
    
    end {
    }
}


<#
    .SYNOPSIS
        Deploy Report
         
    .DESCRIPTION
        Deploy SSRS Report to SQL Server Reporting Services
         
    .PARAMETER Module
        Name of the module that you want to works against
         
        Accepts an array of strings
         
        Default value is "*" and will work against all modules loaded on the machine
         
    .PARAMETER ReportName
        Name of the report that you want to deploy
         
        Default value is "*" and will deploy all reports from the module(s) that you speficied
         
    .PARAMETER LogFile
        Path to the file that should contain the logging information
         
        Default value is "c:\temp\d365fo.tools\AxReportDeployment.log"
         
    .PARAMETER PackageDirectory
        Path to the PackagesLocalDirectory
         
        Default path is the same as the AOS Service PackagesLocalDirectory
         
    .PARAMETER ToolsBasePath
        Base path to the folder containing the needed PowerShell manifests that the cmdlet utilizes
         
        Default path is the same as the AOS Service PackagesLocalDirectory
         
    .PARAMETER ReportServerIp
        IP Address of the server that has SQL Reporting Services installed
         
        Default value is "127.0.01"
         
    .EXAMPLE
        PS C:\> Publish-D365SsrsReport -Module ApplicationSuite -ReportName TaxVatRegister.Report
         
        This will deploy the report which is named "TaxVatRegister.Report".
        The cmdlet will look for the report inside the ApplicationSuite module.
        The cmdlet will be using the default 127.0.0.1 while deploying the report.
         
    .EXAMPLE
        PS C:\> Publish-D365SsrsReport -Module ApplicationSuite -ReportName *
         
        This will deploy the all reports from the ApplicationSuite module.
        The cmdlet will be using the default 127.0.0.1 while deploying the report.
         
    .NOTES
        Tags: SSRS, Report, Reports, Deploy, Publish
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Publish-D365SsrsReport {
    [CmdletBinding()]
    [OutputType('[PsCustomObject]')]
    param (
        [Parameter(Mandatory = $false)]
        [string[]] $Module = "*",

        [Parameter(Mandatory = $false)]
        [string[]] $ReportName = "*",

        [Parameter(Mandatory = $false)]
        [string] $LogFile = (Join-Path $Script:DefaultTempPath "AxReportDeployment.log"),

        [Parameter(Mandatory = $false)]
        [string] $PackageDirectory = $Script:PackageDirectory,

        [Parameter(Mandatory = $false)]
        [string] $ToolsBasePath = $Script:PackageDirectory,

        [Parameter(Mandatory = $false)]
        [string[]]$ReportServerIp = "127.0.0.1"
    )

    Invoke-TimeSignal -Start

    $LogDirectory = Split-Path $LogFile -Parent
    $toolsPath = Join-Path $ToolsBasePath "Plugins\AxReportVmRoleStartupTask"
    
    if (-not (Test-PathExists -Path $toolsPath, $PackageDirectory -Type Container)) { return }
    if (-not (Test-PathExists -Path $LogDirectory -Type Container -Create)) { return }

    $aosCommonManifest = Join-Path $toolsPath "AosCommon.psm1"
    $reportingManifest = Join-Path $toolsPath "Reporting.psm1"

    if (-not (Test-PathExists -Path $aosCommonManifest, $reportingManifest -Type Leaf)) { return }

    Write-PSFMessage -Level Verbose -Message "Importing the Microsoft AosCommon PowerShell manifest file." -Target $aosCommonManifest
    Import-Module "$aosCommonManifest" -Force -DisableNameChecking
    
    Write-PSFMessage -Level Verbose -Message "Importing the Microsoft Reporting PowerShell manifest file." -Target $reportingManifest
    Import-Module "$reportingManifest" -Force -DisableNameChecking

    # create JSON config string for Deploy-AxReports
    $settings = New-Object -TypeName PSCustomObject -Property @{
        "BiReporting.ReportingServers"                       = $($ReportServerIp -join ",")
        "Microsoft.Dynamics.AX.AosConfig.AzureConfig.bindir" = $PackageDirectory
        "Module"                                             = $Module
        "ReportName"                                         = $ReportName
    }

    Write-PSFMessage -Level Verbose -Message "Done building the settings object that will be parsed." -Target $settings
    
    $jsonConfig = ConvertTo-Json $settings

    Write-PSFMessage -Level Verbose -Message "Settings object converted to json." -Target $jsonConfig

    $jsonConfig = [System.Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($jsonConfig))

    try {
    
        Write-PSFMessage -Level Verbose -Message "Invoking the 'Deploy-AxReport' cmdlet from Microsoft."

        Deploy-AxReport -Config $jsonConfig -Log $LogFile
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while deploying the SSRS Report(s)" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }

    Invoke-TimeSignal -End
    
    [PSCustomObject]@{
        LogFile = $LogFile
    }
}


<#
    .SYNOPSIS
        Register Azure Storage Configurations
         
    .DESCRIPTION
        Register all Azure Storage Configurations
         
    .PARAMETER ConfigStorageLocation
        Parameter used to instruct where to store the configuration objects
         
        The default value is "User" and this will store all configuration for the active user
         
        Valid options are:
        "User"
        "System"
         
        "System" will store the configuration as default for all users, so they can access the configuration objects
         
    .EXAMPLE
        PS C:\> Register-D365AzureStorageConfig -ConfigStorageLocation "System"
         
        This will store all Azure Storage Configurations as defaults for all users on the machine.
         
    .NOTES
        Tags: Configuration, Azure, Storage
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Register-D365AzureStorageConfig {
    [CmdletBinding()]
    [OutputType()]
    param (
        [ValidateSet('User', 'System')]
        [string] $ConfigStorageLocation = "User"
    )

    $configScope = Test-ConfigStorageLocation -ConfigStorageLocation $ConfigStorageLocation
    
    Register-PSFConfig -FullName "d365fo.tools.azure.storage.accounts" -Scope $configScope
}


<#
    .SYNOPSIS
        Remove broadcast message configuration
         
    .DESCRIPTION
        Remove a broadcast message configuration from the configuration store
         
    .PARAMETER Name
        Name of the broadcast message configuration you want to remove from the configuration store
         
    .PARAMETER Temporary
        Instruct the cmdlet to only temporarily remove the broadcast message configuration from the configuration store
         
    .EXAMPLE
        PS C:\> Remove-D365BroadcastMessageConfig -Name "UAT"
         
        This will remove the broadcast message configuration name "UAT" from the machine.
         
    .NOTES
        Tags: Servicing, Broadcast, Message, Users, Environment, Config, Configuration, ClientId, ClientSecret
         
        Author: M�tz Jensen (@Splaxi)
         
    .LINK
        Add-D365BroadcastMessageConfig
         
    .LINK
        Clear-D365ActiveBroadcastMessageConfig
         
    .LINK
        Get-D365ActiveBroadcastMessageConfig
         
    .LINK
        Get-D365BroadcastMessageConfig
         
    .LINK
        Send-D365BroadcastMessage
         
    .LINK
        Set-D365ActiveBroadcastMessageConfig
#>


function Remove-D365BroadcastMessageConfig {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $true, Position = 1)]
        [string] $Name,

        [switch] $Temporary
    )

    $Name = $Name.ToLower()

    if ($Name -match '\*') {
        Write-PSFMessage -Level Host -Message "The name cannot contain <c='em'>wildcard character</c>."
        Stop-PSFFunction -Message "Stopping because the name contains wildcard character."
        return
    }

    if (-not ((Get-PSFConfig -FullName "d365fo.tools.broadcast.*.name").Value -contains $Name)) {
        Write-PSFMessage -Level Host -Message "A broadcast message configuration with that name <c='em'>doesn't exists</c>."
        Stop-PSFFunction -Message "Stopping because a broadcast message configuration with that name doesn't exists."
        return
    }

    $res = (Get-PSFConfig -FullName "d365fo.tools.active.broadcast.message.config.name").Value

    if ($res -eq $Name) {
        Write-PSFMessage -Level Host -Message "The active broadcast message configuration is the <c='em'>same as the one you're trying to remove</c>. Please set another configuration as active, before removing this one. You could also call Clear-D365ActiveBroadcastMessageConfig."
        Stop-PSFFunction -Message "Stopping because the active broadcast message configuration is the same as the one trying to be removed."
        return
    }

    foreach ($config in Get-PSFConfig -FullName "d365fo.tools.broadcast.$Name.*") {
        Set-PSFConfig -FullName $config.FullName -Value ""

        if (-not $Temporary) { Unregister-PSFConfig -FullName $config.FullName -Scope UserDefault }
    }
}


<#
    .SYNOPSIS
        Removes a Database
         
    .DESCRIPTION
        Removes a Database
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .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:\> Remove-D365Database -DatabaseName "ExportClone"
         
        This will remove the "ExportClone" from the default SQL Server instance that is registered on the machine.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>


function Remove-D365Database {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [string] $DatabaseServer = $Script:DatabaseServer,

        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [switch] $EnableException
    )

    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters
    
    $null = [System.Reflection.Assembly]::LoadWithPartialName('Microsoft.SqlServer.SMO')

    $srv = new-object Microsoft.SqlServer.Management.Smo.Server("$DatabaseServer")

    if (-not $UseTrustedConnection) {
        $srv.ConnectionContext.set_LoginSecure($false)
        $srv.ConnectionContext.set_Login("$SqlUser")
        $srv.ConnectionContext.set_Password("$SqlPwd")
    }
    
    try {
        $db = $srv.Databases["$DatabaseName"]

        if (!$db) {
            Write-PSFMessage -Level Verbose -Message "Database $DatabaseName not found. Nothing to remove."
            return
        }

        if ($srv.ServerType -ne "SqlAzureDatabase") {
            $srv.KillAllProcesses("$DatabaseName")
        }
    
        Write-PSFMessage -Level Verbose -Message "Dropping $DatabaseName" -Target $DatabaseName
    
        $db.Drop()
    }
    catch {
        $messageString = "Something went wrong while <c='em'>removing the Database."
        Write-PSFMessage -Level Host -Message $messageString
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -StepsUpward 1
        return
    }
}


<#
    .SYNOPSIS
        Remove lcs environment
         
    .DESCRIPTION
        Remove a lcs environment from the configuration store
         
    .PARAMETER Name
        Name of the lcs environment you want to remove from the configuration store
         
    .PARAMETER Temporary
        Instruct the cmdlet to only temporarily remove the lcs environment from the configuration store
         
    .EXAMPLE
        PS C:\> Remove-D365LcsEnvironment -Name "UAT"
         
        This will remove the lcs environment named "UAT" from the machine.
         
    .NOTES
        Tags: Servicing, Environment, Config, Configuration,
         
        Author: M�tz Jensen (@Splaxi)
         
    .LINK
        Get-D365LcsApiConfig
         
    .LINK
        Get-D365LcsApiToken
         
    .LINK
        Get-D365LcsAssetValidationStatus
         
    .LINK
        Get-D365LcsDeploymentStatus
         
    .LINK
        Invoke-D365LcsApiRefreshToken
         
    .LINK
        Invoke-D365LcsUpload
         
    .LINK
        Set-D365LcsApiConfig
#>


function Remove-D365LcsEnvironment {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $true, Position = 1)]
        [string] $Name,

        [switch] $Temporary
    )

    $Name = $Name.ToLower()

    if ($Name -match '\*') {
        Write-PSFMessage -Level Host -Message "The name cannot contain <c='em'>wildcard character</c>."
        Stop-PSFFunction -Message "Stopping because the name contains wildcard character."
        return
    }

    if (-not ((Get-PSFConfig -FullName "d365fo.tools.lcs.environment.*.name").Value -contains $Name)) {
        Write-PSFMessage -Level Host -Message "A lcs environment with that name <c='em'>doesn't exists</c>."
        Stop-PSFFunction -Message "Stopping because a lcs environment with that name doesn't exists."
        return
    }

    foreach ($config in Get-PSFConfig -FullName "d365fo.tools.lcs.environment.$Name.*") {
        Set-PSFConfig -FullName $config.FullName -Value ""

        if (-not $Temporary) { Unregister-PSFConfig -FullName $config.FullName -Scope UserDefault }
    }
}


<#
    .SYNOPSIS
        Remove a model from Dynamics 365 for Finance & Operations
         
    .DESCRIPTION
        Remove a model from a Dynamics 365 for Finance & Operations environment
         
    .PARAMETER Model
        Name of the model that you want to work against
         
    .PARAMETER BinDir
        The path to the bin directory for the environment
         
        Default path is the same as the AOS service PackagesLocalDirectory\bin
         
        Default value is fetched from the current configuration on the machine
         
    .PARAMETER MetaDataDir
        The path to the meta data directory for the environment
         
        Default path is the same as the aos service PackagesLocalDirectory
         
    .PARAMETER DeleteFolders
        Instruct the cmdlet to delete the model folder
         
        This is useful when you are trying to clean up the folders in your source control / branch
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .EXAMPLE
        PS C:\> Remove-D365Model -Model CustomModelName
         
        This will remove the "CustomModelName" model from the D365FO environment.
        It will NOT remove the folders inside the PackagesLocalDirectory location.
         
    .EXAMPLE
        PS C:\> Remove-D365Model -Model CustomModelName -DeleteFolders
         
        This will remove the "CustomModelName" model from the D365FO environment.
        It will remove the folders inside the PackagesLocalDirectory location.
        This is helpful when dealing with source control and you want to remove the model entirely.
         
    .NOTES
        Tags: ModelUtil, Axmodel, Model, Remove, Delete, Source Control, Vsts, Azure DevOps
         
        Author: M�tz Jensen (@Splaxi)
#>


function Remove-D365Model {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    
    param (
        [Parameter(Mandatory = $true)]
        [string] $Model,

        [Parameter(Mandatory = $false)]
        [string] $BinDir = "$Script:PackageDirectory\bin",

        [Parameter(Mandatory = $false)]
        [string] $MetaDataDir = "$Script:MetaDataDir",

        [switch] $DeleteFolders,

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly
    )

    Invoke-TimeSignal -Start
    
    Invoke-ModelUtil -Command "Delete" -Path $Path -BinDir $BinDir -MetaDataDir $MetaDataDir -Model $Model -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly

    if (Test-PSFFunctionInterrupt) { return }

    $modelPath = Join-Path $MetaDataDir $Model

    if ($DeleteFolders) {
        if (-not (Test-PathExists -Path $modelPath -Type Container)) { return }

        Remove-Item $modelPath -Force  -Recurse -ErrorAction SilentlyContinue
    }

    Invoke-TimeSignal -End
}


<#
    .SYNOPSIS
        Delete an user from the environment
         
    .DESCRIPTION
        Deletes the user from the database, including security configuration
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER Email
        The search string to select which user(s) should be updated.
         
        You have to specific the explicit email address of the user you want to remove
         
        The cmdlet will not be able to delete the ADMIN user, this is to prevent you
        from being locked out of the system.
         
    .EXAMPLE
        PS C:\> Remove-D365User -Email "Claire@contoso.com"
         
        This will move all security and user details from the user with the email address
        "Claire@contoso.com"
         
    .EXAMPLE
        PS C:\> Get-D365User -Email *contoso.com | Remove-D365User
         
        This will first get all users from the database that matches the *contoso.com
        search and pipe their emails to Remove-D365User for it to delete them.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Remove-D365User {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false, Position = 1)]
        [string] $DatabaseServer = $Script:DatabaseServer,

        [Parameter(Mandatory = $false, Position = 2)]
        [string] $DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, Position = 3)]
        [string] $SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, Position = 4)]
        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, Position = 5)]
        [string] $Email

    )

    BEGIN {
        $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

        $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
            SqlUser = $SqlUser; SqlPwd = $SqlPwd
        }

        $SqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection

        try {
            $SqlCommand.Connection.Open()
        }
        catch {
            Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }
    }
    
    PROCESS {
        if(Test-PSFFunctionInterrupt) {return}

        $SqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\remove-user.sql") -join [Environment]::NewLine
    
        $null = $SqlCommand.Parameters.AddWithValue("@Email", $Email)
    
        try {
            Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

            $null = $SqlCommand.ExecuteNonQuery()
        }
        catch {
            Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }

        $SqlCommand.Parameters.Clear()
    }
    
    END {
        try {
            if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
                $sqlCommand.Connection.Close()
            }
            $sqlCommand.Dispose()
        }
        catch {
            Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }
    }
}


<#
    .SYNOPSIS
        Function for renaming computer.
        Renames Computer and changes the SSRS Configration
         
    .DESCRIPTION
        When doing development on-prem, there is as need for changing the Computername.
        Function both changes Computername and SSRS Configuration
         
    .PARAMETER NewName
        The new name for the computer
         
    .PARAMETER SSRSReportDatabase
        Name of the SSRS reporting database
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER LogPath
        The path where the log file(s) will be saved
         
        When running without the ShowOriginalProgress parameter, the log files will be the standard output and the error output from the underlying tool executed
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .PARAMETER OutputCommandOnly
        Instruct the cmdlet to only output the command that you would have to execute by hand
         
        Will include full path to the executable and the needed parameters based on your selection
         
    .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:\> Rename-D365ComputerName -NewName "Demo-8.1" -SSRSReportDatabase "ReportServer"
         
        This will rename the local machine to the "Demo-8.1" as the new Windows machine name.
        It will update the registration inside the SQL Server Reporting Services configuration to handle the new name of the machine.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Rename-D365ComputerName {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $NewName,

        [string] $SSRSReportDatabase = "DynamicsAxReportServer",

        [string] $DatabaseServer = $Script:DatabaseServer,

        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword,

        [Alias('LogDir')]
        [string] $LogPath = $(Join-Path -Path $Script:DefaultTempPath -ChildPath "Logs\RsConfig"),

        [switch] $ShowOriginalProgress,

        [switch] $OutputCommandOnly,

        [switch] $EnableException
    )

    Write-PSFMessage -Level Verbose -Message "Testing for elevated runtime"
    
    if (!$script:IsAdminRuntime) {
        Write-PSFMessage -Level Host -Message "The cmdlet needs <c='em'>administrator permission</c> (Run As Administrator) to be able to update the configuration. Please start an <c='em'>elevated</c> session and run the cmdlet again."
        Stop-PSFFunction -Message "Stopping because the function is not run elevated"
        return
    }

    $executable = "$Script:SQLTools\rsconfig.exe"

    if (-not (Test-PathExists -Path $executable -Type Leaf)) { return }

    if (Test-PSFFunctionInterrupt) { return }

    Write-PSFMessage -Level Verbose -Message "Renaming computer to $NewName"

    Rename-Computer -NewName $NewName -Force

    Write-PSFMessage -Level Verbose -Message "Renaming local server name inside SQL Server to $NewName"

    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters
    
    $Params = @{DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd; TrustedConnection = $UseTrustedConnection;
    }

    $oldComputerName = $env:COMPUTERNAME

    $sqlCommand = Get-SQLCommand @Params

    $commandText = (Get-Content "$script:ModuleRoot\internal\sql\rename-computer.sql") -join [Environment]::NewLine
    $commandText = $commandText.Replace('@OldComputerName', $oldComputerName)
    $commandText = $commandText.Replace('@NewComputerName', $NewName)

    $sqlCommand.CommandText = $commandText

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()

        $null = $sqlCommand.ExecuteNonQuery()
    }
    catch {
        $messageString = "Something went wrong while working against the database."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
    }

    $sqlCommand = Get-SQLCommand @Params

    Write-PSFMessage -Level Verbose -Message "Setting SSRS Reporting server database server to localhost"

    $params = New-Object System.Collections.Generic.List[string]
    $params.Add("-s")
    $params.Add("localhost")
    $params.Add("-a")
    $params.Add("Windows")
    $params.Add("-c")
    $params.Add("-d")
    $params.Add("`"$SSRSReportDatabase`"")

    Invoke-Process -Executable $executable -Params $params.ToArray() -ShowOriginalProgress:$ShowOriginalProgress -OutputCommandOnly:$OutputCommandOnly -LogPath $LogPath

    if (-not $OutputCommandOnly) {
        Write-PSFMessage -Level Host -Message "Computer has been <c='em'>renamed</c>. Please <c='em'>restart the computer</c> to make sure that all changes are being applied correctly."
    }
}


<#
    .SYNOPSIS
        Rename as D365FO Demo/Dev box
         
    .DESCRIPTION
        The Rename function, changes the config values used by a D365FO dev box for identifying its name. Standard it is called 'usnconeboxax1aos'
         
    .PARAMETER NewName
        The new name wanted for the D365FO instance
         
    .PARAMETER AosServiceWebRootPath
        Path to the webroot folder for the AOS service 'Default value : C:\AOSService\Webroot
         
    .PARAMETER IISServerApplicationHostConfigFile
        Path to the IISService Application host file, [Where the binding configurations is stored] 'Default value : C:\Windows\System32\inetsrv\Config\applicationHost.config'
         
    .PARAMETER HostsFile
        Place of the host file on the current system [Local DNS record] ' Default value C:\Windows\System32\drivers\etc\hosts'
         
    .PARAMETER BackupExtension
        Backup name for all the files that are changed
         
    .PARAMETER MRConfigFile
        Path to the Financial Reporter (Management Reporter) configuration file
         
    .EXAMPLE
        PS C:\> Rename-D365Instance -NewName "Demo1"
         
        This will rename the D365 for Finance & Operations instance to "Demo1".
        This IIS will be restarted while doing it.
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
        The function restarts the IIS Service.
        Elevated privileges are required.
         
#>

function Rename-D365Instance {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]$NewName,

        [string]$AosServiceWebRootPath = $Script:AOSPath,

        [string]$IISServerApplicationHostConfigFile = $Script:IISHostFile,

        [string]$HostsFile = $Script:Hosts,

        [string]$BackupExtension = "bak",

        [string]$MRConfigFile = $Script:MRConfigFile

    )

    Write-PSFMessage -Level Verbose -Message "Testing for elevated runtime"

    if ($Script:EnvironmentType -ne [EnvironmentType]::LocalHostedTier1) {
        Write-PSFMessage -Level Host -Message "It seems that you ran this cmdlet on a machine that is not a local hosted tier 1 / one box. This cmdlet is only supporting on a <c='em'>onebox / local tier 1</c> machine."
        Stop-PSFFunction -Message "Stopping because machine isn't a onebox"
        return
    }
    elseif (!$script:IsAdminRuntime) {
        Write-PSFMessage -Level Host -Message "The cmdlet needs <c='em'>administrator permission</c> (Run As Administrator) to be able to update the configuration. Please start an <c='em'>elevated</c> session and run the cmdlet again."
        Stop-PSFFunction -Message "Stopping because the function is not run elevated"
        return
    }

    $OldName = (Get-D365InstanceName).Instancename

    Write-PSFMessage -Level Verbose -Message "Old name collected and will be used to rename." -Target $OldName

    # Variables
    $replaceValue = $OldName
    $NewNameDot = "$NewName."
    $replaceValueDot = "$replaceValue."

    $WebConfigFile = join-Path -path $AosServiceWebRootPath $Script:WebConfig
    $WifServicesFile = Join-Path -Path $AosServiceWebRootPath $Script:WifServicesConfig

    $Files = @($WebConfigFile, $WifServicesFile, $IISServerApplicationHostConfigFile, $HostsFile, $MRConfigFile)
    if(-not (Test-PathExists -Path $Files -Type Leaf)) {
        return
    }

    Write-PSFMessage -Level Verbose -Message "Stopping the IIS."
    iisreset /stop

    # Backup files
    if ($null -ne $BackupExtension -and $BackupExtension -ne '') {
        foreach ($item in $Files) {
            Backup-File $item $BackupExtension
        }
    }

    # WebConfig - D365 web config file
    Rename-ConfigValue $WebConfigFile $NewName $replaceValue
    # Wif.Services - D365 web config file (services)
    Rename-ConfigValue $WifServicesFile $NewName $replaceValue
    #ApplicationHost - IIS Bindings
    Rename-ConfigValue $IISServerApplicationHostConfigFile $NewNameDot $replaceValueDot
    #Hosts file - local DNS cache
    Rename-ConfigValue $HostsFile $NewNameDot $replaceValueDot
    #Management Reporter
    Rename-ConfigValue $MRConfigFile $NewName $replaceValue

    #Start IIS again
    Write-PSFMessage -Level Verbose -Message "Starting the IIS."
    iisreset /start

    Get-D365Url -Force
}


<#
    .SYNOPSIS
        Restart the different services
         
    .DESCRIPTION
        Restart the different services in a Dynamics 365 Finance & Operations environment
         
    .PARAMETER ComputerName
        An array of computers that you want to work against
         
    .PARAMETER All
        Instructs the cmdlet work against all relevant services
         
        Includes:
        Aos
        Batch
        Financial Reporter
        DMF
         
    .PARAMETER Aos
        Instructs the cmdlet to work against the AOS (IIS) service
         
    .PARAMETER Batch
        Instructs the cmdlet to work against the Batch service
         
    .PARAMETER FinancialReporter
        Instructs the cmdlet to work against the Financial Reporter (Management Reporter 2012)
         
    .PARAMETER DMF
        Instructs the cmdlet to work against the DMF service
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .EXAMPLE
        PS C:\> Restart-D365Environment -All
         
        This will stop all services and then start all services again.
         
    .EXAMPLE
        PS C:\> Restart-D365Environment -All -ShowOriginalProgress
         
        This will stop all services and then start all services again.
        The progress of Stopping the different services will be written to the console / host.
        The progress of Starting the different services will be written to the console / host.
         
    .EXAMPLE
        PS C:\> Restart-D365Environment -ComputerName "TEST-SB-AOS1","TEST-SB-AOS2","TEST-SB-BI1" -All
         
        This will work against the machines: "TEST-SB-AOS1","TEST-SB-AOS2","TEST-SB-BI1".
        This will stop all services and then start all services again.
         
    .EXAMPLE
        PS C:\> Restart-D365Environment -Aos -Batch
         
        This will stop the AOS and Batch services and then start the AOS and Batch services again.
         
    .EXAMPLE
        PS C:\> Restart-D365Environment -FinancialReporter -DMF
         
        This will stop the FinancialReporter and DMF services and then start the FinancialReporter and DMF services again.
         
    .NOTES
        Tags: Environment, Service, Services, Aos, Batch, Servicing
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Restart-D365Environment {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidDefaultValueSwitchParameter", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 1 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 1 )]
        [string[]] $ComputerName = @($env:computername),

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 2 )]
        [switch] $All = $true,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 2 )]
        [switch] $Aos,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 3 )]
        [switch] $Batch,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 4 )]
        [switch] $FinancialReporter,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 5 )]
        [switch] $DMF,

        [Parameter(Mandatory = $False)]
        [switch] $ShowOriginalProgress
    )

    Stop-D365Environment @PSBoundParameters | Format-Table

    Start-D365Environment @PSBoundParameters | Format-Table
}


<#
    .SYNOPSIS
        Send broadcast message to online users in D365FO
         
    .DESCRIPTION
        Utilize the same messaging framework available from LCS and send a broadcast message to all online users in the environment
         
    .PARAMETER Tenant
        Azure Active Directory (AAD) tenant id (Guid) that the D365FO environment is connected to, that you want to send a message to
         
    .PARAMETER URL
        URL / URI for the D365FO environment you want to send a message to
         
    .PARAMETER ClientId
        The ClientId obtained from the Azure Portal when you created a Registered Application
         
    .PARAMETER ClientSecret
        The ClientSecret obtained from the Azure Portal when you created a Registered Application
         
    .PARAMETER TimeZone
        Id of the Time Zone your environment is running in
         
        You might experience that the local VM running the D365FO is running another Time Zone than the computer you are running this cmdlet from
         
        All available .NET Time Zones can be traversed with tab for this parameter
         
        The default value is "UTC"
         
    .PARAMETER StartTime
        The time and date you want the message to be displayed for the users
         
        Default value is NOW
         
        The specified StartTime will always be based on local Time Zone. If you specify a different Time Zone than the local computer is running, the start and end time will be calculated based on your selection.
         
    .PARAMETER EndingInMinutes
        Specify how many minutes into the future you want this message / maintenance window to last
         
        Default value is 60 minutes
         
        The specified StartTime will always be based on local Time Zone. If you specify a different Time Zone than the local computer is running, the start and end time will be calculated based on your selection.
         
    .PARAMETER OnPremise
        Specify if environnement is an D365 OnPremise
         
        Default value is "Not set" (= Cloud Environnement)
         
    .EXAMPLE
        PS C:\> Send-D365BroadcastMessage
         
        This will send a message to all active users that are working on default D365FO environment.
         
        See the RELATED LINKS section for the supporting cmdlets needed to store a default configuration.
         
    .EXAMPLE
        PS C:\> Send-D365BroadcastMessage -Tenant "e674da86-7ee5-40a7-b777-1111111111111" -URL "https://usnconeboxax1aos.cloud.onebox.dynamics.com" -ClientId "dea8d7a9-1602-4429-b138-111111111111" -ClientSecret "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522"
         
        This will send a message to all active users that are working on the D365FO environment located at "https://usnconeboxax1aos.cloud.onebox.dynamics.com".
        It will authenticate against the Azure Active Directory with the "e674da86-7ee5-40a7-b777-1111111111111" guid.
        It will use the ClientId "dea8d7a9-1602-4429-b138-111111111111" and ClientSecret "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522" go get access to the environment.
        It will use the default value "UTC" Time Zone for converting the different time and dates.
        It will use the default start time which is NOW.
        It will use the default end time which is 60 minutes.
         
    .EXAMPLE
        PS C:\> Send-D365BroadcastMessage -OnPremise -Tenant "https://adfs.local/adfs" -URL "https://ax-sandbox.d365fo.local" -ClientId "dea8d7a9-1602-4429-b138-111111111111" -ClientSecret "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522"
         
        This will send a message to all active users that are working on the D365FO OnPremise environment located at "https://ax-sandbox.d365fo.local".
        It will authenticate against Local ADFS with the "https://adfs.local/adfs" path
        It will use the ClientId "dea8d7a9-1602-4429-b138-111111111111" and ClientSecret "Vja/VmdxaLOPR+alkjfsadffelkjlfw234522" go get access to the environment.
        It will use the default value "UTC" Time Zone for converting the different time and dates.
        It will use the default start time which is NOW.
        It will use the default end time which is 60 minutes.
         
    .NOTES
         
        The specified StartTime will always be based on local Time Zone. If you specify a different Time Zone than the local computer is running, the start and end time will be calculated based on your selection.
         
        For OnPremise environnement use -OnPremise flag to added "namespaces/AXSF" path to D365 URL and allow to get token from local ADFS server
         
        Tags: Servicing, Message, Users, Environment
         
        Author: M�tz Jensen (@Splaxi)
         
    .LINK
        Add-D365BroadcastMessageConfig
         
    .LINK
        Clear-D365ActiveBroadcastMessageConfig
         
    .LINK
        Get-D365ActiveBroadcastMessageConfig
         
    .LINK
        Get-D365BroadcastMessageConfig
         
    .LINK
        Remove-D365BroadcastMessageConfig
         
    .LINK
        Set-D365ActiveBroadcastMessageConfig
         
#>


function Send-D365BroadcastMessage {
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $false, Position = 1)]
        [Alias('$AADGuid')]
        [string] $Tenant = $Script:BroadcastTenant,

        [Parameter(Mandatory = $false, Position = 2)]
        [Alias('URI')]
        [string] $URL = $Script:BroadcastUrl,

        [Parameter(Mandatory = $false, Position = 3)]
        [string] $ClientId = $Script:BroadcastClientId,

        [Parameter(Mandatory = $false, Position = 4)]
        [string] $ClientSecret = $Script:BroadcastClientSecret,

        [Parameter(Mandatory = $false, Position = 5)]
        [string] $TimeZone = $Script:BroadcastTimeZone,

        [Parameter(Mandatory = $false, Position = 6)]
        [datetime] $StartTime = (Get-Date),

        [Parameter(Mandatory = $false, Position = 7)]
        [int] $EndingInMinutes = $Script:BroadcastEndingInMinutes,

        [Parameter(Mandatory = $false, Position = 8)]
        [switch] $OnPremise = $Script:BroadcastOnPremise
    )

    $URL = $URL -replace "/$", ""

    $bearerParms = @{
        Resource     = $URL
        ClientId     = $ClientId
        ClientSecret = $ClientSecret
    }

    if ($OnPremise) {
        $bearerParms.AuthProviderUri = "$Tenant/oauth2/token"
    }
    else {
        $bearerParms.AuthProviderUri = "https://login.microsoftonline.com/$Tenant/oauth2/token"
    }

    $bearer = Invoke-ClientCredentialsGrant @bearerParms | Get-BearerToken

    $headerParms = @{
        URL         = $URL
        BearerToken = $bearer
    }

    $headers = New-AuthorizationHeaderBearerToken @headerParms

    [System.UriBuilder] $messageEndpoint = $URL

    if ($OnPremise) {
        $messageEndpoint.Path = "namespaces/AXSF/api/services/SysBroadcastMessageServices/SysBroadcastMessageService/AddMessage"
    }
    else {
        $messageEndpoint.Path = "api/services/SysBroadcastMessageServices/SysBroadcastMessageService/AddMessage"
    }

    $endTime = $StartTime.AddMinutes($EndingInMinutes)
    
    $timeZoneFound = Get-TimeZone -InputObject $TimeZone

    if (Test-PSFFunctionInterrupt) { return }
    
    $startTimeConverted = [System.TimeZoneInfo]::ConvertTime($startTime, [System.TimeZoneInfo]::Local, $timeZoneFound)
    $endTimeConverted = [System.TimeZoneInfo]::ConvertTime($endTime, [System.TimeZoneInfo]::Local, $timeZoneFound)

    $body = @"
{
    "request": {
        "FromDateTime": "$($startTimeConverted.ToString("s"))",
        "ToDateTime": "$($endTimeConverted.ToString("s"))"
    }
}
"@


    try {
        [PSCustomObject]@{
            MessageId = Invoke-RestMethod -Method Post -Uri $messageEndpoint.Uri.AbsoluteUri -Headers $headers -ContentType 'application/json' -Body $body
        }
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while trying to send a message to the users." -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors."
        return
    }
}


<#
    .SYNOPSIS
        Set the active Azure Storage Account configuration
         
    .DESCRIPTION
        Updates the current active Azure Storage Account configuration with a new one
         
    .PARAMETER Name
        The name the Azure Storage Account configuration you want to load into the active Azure Storage Account configuration
         
    .PARAMETER ConfigStorageLocation
        Parameter used to instruct where to store the configuration objects
         
        The default value is "User" and this will store all configuration for the active user
         
        Valid options are:
        "User"
        "System"
         
        "System" will store the configuration so all users can access the configuration objects
         
    .PARAMETER Temporary
        Instruct the cmdlet to only temporarily override the persisted settings in the configuration storage
         
    .EXAMPLE
        PS C:\> Set-D365ActiveAzureStorageConfig -Name "UAT-Exports"
         
        This will import the "UAT-Exports" set from the Azure Storage Account configurations.
        It will update the active Azure Storage Account configuration.
         
    .EXAMPLE
        PS C:\> Set-D365ActiveAzureStorageConfig -Name "UAT-Exports" -ConfigStorageLocation "System"
         
        This will import the "UAT-Exports" set from the Azure Storage Account configurations.
        It will update the active Azure Storage Account configuration.
        The data will be stored in the system wide configuration storage, which makes it accessible from all users.
         
    .EXAMPLE
        PS C:\> Set-D365ActiveAzureStorageConfig -Name "UAT-Exports" -Temporary
         
        This will import the "UAT-Exports" set from the Azure Storage Account configurations.
        It will update the active Azure Storage Account configuration.
        The update will only last for the rest of this PowerShell console session.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
        You will have to run the Initialize-D365Config cmdlet first, before this will be capable of working.
         
        You will have to run the Add-D365AzureStorageConfig cmdlet at least once, before this will be capable of working.
         
#>

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

        [ValidateSet('User', 'System')]
        [string] $ConfigStorageLocation = "User",
        
        [switch] $Temporary
    )

    $configScope = Test-ConfigStorageLocation -ConfigStorageLocation $ConfigStorageLocation

    if (Test-PSFFunctionInterrupt) { return }

    $azureStorageConfigs = [hashtable] (Get-PSFConfigValue -FullName "d365fo.tools.azure.storage.accounts")

    if (-not ($azureStorageConfigs.ContainsKey($Name))) {
        Write-PSFMessage -Level Host -Message "An Azure Storage Account with that name <c='em'>doesn't exists</c>."
        Stop-PSFFunction -Message "Stopping because an Azure Storage Account with that name doesn't exists."
        return
    }
    else {
        $azureDetails = $azureStorageConfigs[$Name]

        Set-PSFConfig -FullName "d365fo.tools.active.azure.storage.account" -Value $azureDetails
        if (-not $Temporary) { Register-PSFConfig -FullName "d365fo.tools.active.azure.storage.account"  -Scope $configScope }
        
        Update-AzureStorageVariables
    }
}


<#
         
    .SYNOPSIS
        Set the active broadcast message configuration
         
    .DESCRIPTION
        Updates the current active broadcast message configuration with a new one
         
    .PARAMETER Name
        Name of the broadcast message configuration you want to load into the active broadcast message configuration
         
    .PARAMETER Temporary
        Instruct the cmdlet to only temporarily override the persisted settings in the configuration store
         
    .EXAMPLE
        PS C:\> Set-D365ActiveBroadcastMessageConfig -Name "UAT"
         
        This will set the broadcast message configuration named "UAT" as the active configuration.
         
    .NOTES
        Tags: Servicing, Message, Users, Environment, Config, Configuration, ClientId, ClientSecret, OnPremise
         
        Author: M�tz Jensen (@Splaxi)
         
    .LINK
        Add-D365BroadcastMessageConfig
         
    .LINK
        Clear-D365ActiveBroadcastMessageConfig
         
    .LINK
        Get-D365ActiveBroadcastMessageConfig
         
    .LINK
        Get-D365BroadcastMessageConfig
         
    .LINK
        Remove-D365BroadcastMessageConfig
         
    .LINK
        Send-D365BroadcastMessage
         
#>


function Set-D365ActiveBroadcastMessageConfig {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $true, Position = 1)]
        [string] $Name,

        [switch] $Temporary
    )

    if($Name -match '\*') {
        Write-PSFMessage -Level Host -Message "The name cannot contain <c='em'>wildcard character</c>."
        Stop-PSFFunction -Message "Stopping because the name contains wildcard character."
        return
    }

    if (-not ((Get-PSFConfig -FullName "d365fo.tools.broadcast.*.name").Value -contains $Name)) {
        Write-PSFMessage -Level Host -Message "A broadcast message configuration with that name <c='em'>doesn't exists</c>."
        Stop-PSFFunction -Message "Stopping because a broadcast message configuration with that name doesn't exists."
        return
    }

    Set-PSFConfig -FullName "d365fo.tools.active.broadcast.message.config.name" -Value $Name
    if (-not $Temporary) { Register-PSFConfig -FullName "d365fo.tools.active.broadcast.message.config.name"  -Scope UserDefault }

    Update-BroadcastVariables
}


<#
    .SYNOPSIS
        Set the active environment configuration
         
    .DESCRIPTION
        Updates the current active environment configuration with a new one
         
    .PARAMETER Name
        The name the environment configuration you want to load into the active environment configuration
         
    .PARAMETER ConfigStorageLocation
        Parameter used to instruct where to store the configuration objects
         
        The default value is "User" and this will store all configuration for the active user
         
        Valid options are:
        "User"
        "System"
         
        "System" will store the configuration so all users can access the configuration objects
         
    .PARAMETER Temporary
        Switch to instruct the cmdlet to only temporarily override the persisted settings in the configuration storage
         
    .EXAMPLE
        PS C:\> Set-D365ActiveEnvironmentConfig -Name "UAT"
         
        This will import the "UAT-Exports" set from the Environment configurations.
        It will update the active Environment Configuration.
         
    .EXAMPLE
        PS C:\> Set-D365ActiveEnvironmentConfig -Name "UAT" -ConfigStorageLocation "System"
         
        This will import the "UAT-Exports" set from the Environment configurations.
        It will update the active Environment Configuration.
        The data will be stored in the system wide configuration storage, which makes it accessible from all users.
         
    .EXAMPLE
        PS C:\> Set-D365ActiveEnvironmentConfig -Name "UAT" -Temporary
         
        This will import the "UAT-Exports" set from the Environment configurations.
        It will update the active Environment Configuration.
        The update will only last for the rest of this PowerShell console session.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
        You will have to run the Initialize-D365Config cmdlet first, before this will be capable of working.
         
        You will have to run the Add-D365EnvironmentConfig cmdlet at least once, before this will be capable of working.
         
#>

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

        [ValidateSet('User', 'System')]
        [string] $ConfigStorageLocation = "User",
        
        [switch] $Temporary
    )

    $configScope = Test-ConfigStorageLocation -ConfigStorageLocation $ConfigStorageLocation

    if (Test-PSFFunctionInterrupt) { return }

    $environmentConfigs = [hashtable](Get-PSFConfigValue -FullName "d365fo.tools.environments")
        
    if (-not ($environmentConfigs.ContainsKey($Name))) {
        Write-PSFMessage -Level Host -Message "An environment with that name <c='em'>doesn't exists</c>."
        Stop-PSFFunction -Message "Stopping because an environment with that name doesn't exists."
        return
    }
    else {
        $environmentDetails = $environmentConfigs[$Name]

        Set-PSFConfig -FullName "d365fo.tools.active.environment" -Value $environmentDetails
        if (-not $Temporary) { Register-PSFConfig -FullName "d365fo.tools.active.environment" -Scope $configScope }

        $Script:Url = $environmentDetails.URL
        $Script:DatabaseUserName = $environmentDetails.SqlUser
        $Script:DatabaseUserPassword = $environmentDetails.SqlPwd
        $Script:Company = $environmentDetails.Company

        $Script:TfsUri = $environmentDetails.TfsUri
    }
}


<#
    .SYNOPSIS
        Powershell implementation of the AdminProvisioning tool
         
    .DESCRIPTION
        Cmdlet using the AdminProvisioning tool from D365FO
         
    .PARAMETER AdminSignInName
        Email for the Admin
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN)
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions
        This is less user friendly, but allows catching exceptions in calling scripts
         
    .EXAMPLE
        PS C:\> Set-D365Admin "claire@contoso.com"
         
        This will provision claire@contoso.com as administrator for the environment
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
        Author: Mark Furrer (@devax_mf)
#>

function Set-D365Admin {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        
        [Parameter(Mandatory = $true, Position = 1)]
        [Alias('Email')]
        [String]$AdminSignInName,

        [Parameter(Mandatory = $false, Position = 2)]
        [string]$DatabaseServer = $Script:DatabaseServer,

        [Parameter(Mandatory = $false, Position = 3)]
        [string]$DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, Position = 4)]
        [string]$SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, Position = 5)]
        [string]$SqlPwd = $Script:DatabaseUserPassword,

        [switch] $EnableException

    )

    if (-not ($script:IsAdminRuntime)) {
        Write-PSFMessage -Level Host -Message "The cmdlet needs <c='em'>administrator permission</c> (Run As Administrator) to be able to update the configuration. Please start an <c='em'>elevated</c> session and run the cmdlet again."
        Stop-PSFFunction -Message "Stopping because the function is not run elevated"
        return
    }

    Set-AdminUser $AdminSignInName $DatabaseServer $DatabaseName $SqlUser $SqlPwd
}


<#
    .SYNOPSIS
        Set the path for AzCopy.exe
         
    .DESCRIPTION
        Update the path where the module will be looking for the AzCopy.exe executable
         
    .PARAMETER Path
        Path to the AzCopy.exe
         
    .EXAMPLE
        PS C:\> Invoke-D365InstallAzCopy -Path "C:\temp\d365fo.tools\AzCopy\AzCopy.exe"
         
        This will update the path for the AzCopy.exe in the modules configuration
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
#>

function Set-D365AzCopyPath {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $Path
    )

    if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }

    if (Test-PSFFunctionInterrupt) { return }

    Set-PSFConfig -FullName "d365fo.tools.path.azcopy" -Value $Path
    Register-PSFConfig -FullName "d365fo.tools.path.azcopy"
    
    Update-ModuleVariables
}


<#
    .SYNOPSIS
        Set the ClickOnce needed configuration
         
    .DESCRIPTION
        Creates the needed registry keys and values for ClickOnce to work on the machine
         
    .EXAMPLE
        PS C:\> Set-D365ClickOnceTrustPrompt
         
        This will create / or update the current ClickOnce configuration.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Set-D365ClickOnceTrustPrompt {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param ( )
    
    begin { }
    
    process {
        Write-PSFMessage -Level Verbose -Message "Testing if the registry key exists or not"

        if (-not (Test-Path -Path "HKLM:\SOFTWARE\MICROSOFT\.NETFramework\Security\TrustManager\PromptingLevel")) {
            Write-PSFMessage -Level Verbose -Message "Registry key was not found. Will create it now."
            $null = New-Item -Path "HKLM:\SOFTWARE\MICROSOFT\.NETFramework\Security\TrustManager" -Name "PromptingLevel" -Force
        }
        
        Write-PSFMessage -Level Verbose -Message "Setting all necessary registry keys."

        Set-ItemProperty -Path "HKLM:\SOFTWARE\MICROSOFT\.NETFramework\Security\TrustManager\PromptingLevel" -Name "UntrustedSites" -Type STRING -Value "Disabled" -Force
        Set-ItemProperty -Path "HKLM:\SOFTWARE\MICROSOFT\.NETFramework\Security\TrustManager\PromptingLevel" -Name "Internet" -Type STRING -Value "Enabled" -Force
        Set-ItemProperty -Path "HKLM:\SOFTWARE\MICROSOFT\.NETFramework\Security\TrustManager\PromptingLevel" -Name "MyComputer" -Type STRING -Value "Enabled" -Force
        Set-ItemProperty -Path "HKLM:\SOFTWARE\MICROSOFT\.NETFramework\Security\TrustManager\PromptingLevel" -Name "LocalIntranet" -Type STRING -Value "Enabled" -Force
        Set-ItemProperty -Path "HKLM:\SOFTWARE\MICROSOFT\.NETFramework\Security\TrustManager\PromptingLevel" -Name "TrustedSites" -Type STRING -Value "Enabled" -Force
    }
    
    end { }
}


<#
    .SYNOPSIS
        Set the default model used creating new projects in Visual Studio
         
    .DESCRIPTION
        Set the registered default model that is used across all new projects that are created inside Visual Studio when working with D365FO project types
         
        It will backup the current "DynamicsDevConfig.xml" file, for you to revert the changes if anything should go wrong
         
    .PARAMETER Module
        The name of the module / model that you want to be the default model for all new projects used inside Visual Studio when working with D365FO project types
         
    .EXAMPLE
        PS C:\> Set-D365DefaultModelForNewProjects -Model "FleetManagement"
         
        This will update the current default module registered in the "..Documents\Visual Studio 2015\Settings\DynamicsDevConfig.xml" file.
        It will backup the current "DynamicsDevConfig.xml" file.
        It will replace the value inside the "DefaultModelForNewProjects" tag.
         
    .NOTES
        Tag: Model, Models, Development, Default Model, Module, Project
         
        Author: M�tz Jensen (@Splaxi)
         
        The work for this cmdlet / function was inspired by Robin Kretzschmar (@DarkSmile92) blog post about changing the default model.
         
        The direct link for his blog post is: https://robscode.onl/d365-set-default-model-for-new-projects/
         
        His main blog can found here: https://robscode.onl/
         
#>


function Set-D365DefaultModelForNewProjects {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true)]
        [Alias('Model')]
        [string] $Module
    )

    begin {
        $filePath = "C:\Users\$env:UserName\Documents\Visual Studio 2015\Settings\DynamicsDevConfig.xml"

        if (-not (Test-PathExists -Path $filePath -Type Leaf)) { return }
    }

    process {
        if (Test-PSFFunctionInterrupt) { return }
        
        $filePathBackup = $filePath.Replace(".xml", ".xml$((Get-Date).Ticks)")
        Copy-Item -Path $filePath -Destination $filePathBackup -Force

        $namespace = @{ns = "http://schemas.microsoft.com/dynamics/2012/03/development/configuration" }
    
        $xmlDoc = [xml] (Get-Content -Path $filePath)
        $defaultModel = Select-Xml -Xml $xmlDoc -XPath "/ns:DynamicsDevConfig/ns:DefaultModelForNewProjects" -Namespace $namespace

        $oldValue = $defaultModel.Node.InnerText
    
        Write-PSFMessage -Level Verbose -Message "Old value found in the file was: $oldValue" -Target $oldValue

        $defaultModel.Node.InnerText = $Module
        $xmlDoc.Save($filePath)
    }

    end {
        Get-D365DefaultModelForNewProjects
    }
}


<#
    .SYNOPSIS
        Enable the favorite bar and add an URL
         
    .DESCRIPTION
        Enable the favorite bar in internet explorer and put in the URL as a favorite
         
    .PARAMETER URL
        The URL of the shortcut you want to add to the favorite bar
         
    .PARAMETER D365FO
        Instruct the cmdlet that you want the populate the D365FO favorite entry based on the URL provided
         
    .PARAMETER AzureDevOps
        Instruct the cmdlet that you want the populate the AzureDevOps favorite entry based on the URL provided
         
    .EXAMPLE
        PS C:\> Set-D365FavoriteBookmark -Url "https://usnconeboxax1aos.cloud.onebox.dynamics.com"
         
        This will add the "https://usnconeboxax1aos.cloud.onebox.dynamics.com" to the favorite bar, enable the favorite bar and lock it.
        This will be interpreted as the using the -D365FO parameter also, because that is the expected behavior.
         
    .EXAMPLE
        PS C:\> Set-D365FavoriteBookmark -Url "https://usnconeboxax1aos.cloud.onebox.dynamics.com" -D365FO
         
        This will add the "https://usnconeboxax1aos.cloud.onebox.dynamics.com" to the favorite bar, enable the favorite bar and lock it.
        The bookmark will be mapped as the one for the Dynamics 365 Finance & Operations instance.
         
    .EXAMPLE
        PS C:\> Set-D365FavoriteBookmark -Url "https://CUSTOMERNAME.visualstudio.com/" -AzureDevOps
         
        This will add the "https://CUSTOMERNAME.visualstudio.com/" to the favorite bar, enable the favorite bar and lock it.
        The bookmark will be mapped as the one for the Azure DevOps instance.
         
    .EXAMPLE
        PS C:\> Get-D365Url | Set-D365FavoriteBookmark
         
        This will get the URL from the environment and add that to the favorite bar, enable the favorite bar and lock it.
        This will be interpreted as the using the -D365FO parameter also, because that is the expected behavior.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Set-D365FavoriteBookmark {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding(DefaultParameterSetName="D365FO")]
    param (
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string] $URL,

        [Parameter(Mandatory = $false, ParameterSetName = "D365FO")]
        [switch] $D365FO,

        [Parameter(Mandatory = $false, ParameterSetName = "AzureDevOps")]
        [switch] $AzureDevOps
    )
    
    begin {
    }
    
    process {
        if ($PSCmdlet.ParameterSetName -eq "D365FO") {
            $fileName = "D365FO.url"
        }
        else{
            $fileName = "AzureDevOps.url"
        }
        
        $filePath = Join-Path (Join-Path $Home "Favorites\Links") $fileName

        $pathShowBar = 'HKCU:\Software\Microsoft\Internet Explorer\MINIE\'
        $propShowBar = 'LinksBandEnabled'
        
        $pathLockBar = 'HKCU:\Software\Microsoft\Internet Explorer\Toolbar\'
        $propLockBar = 'Locked'

        $value = "00000001"
    
        Write-PSFMessage -Level Verbose -Message "Setting the show bar and lock bar registry values."
        Set-ItemProperty -Path $pathShowBar -Name $propShowBar -Value $value -Type "DWord"
        Set-ItemProperty -Path $pathLockBar -Name $propLockBar -Value $value -Type "DWord"

        $null = New-Item -Path $filePath -Force -ErrorAction SilentlyContinue

        $LinkContent = (Get-Content "$script:ModuleRoot\internal\misc\$fileName") -Join [Environment]::NewLine
        $LinkContent.Replace("##URL##", $URL) | Out-File $filePath -Force
    }
    
    end {
    }
}


<#
    .SYNOPSIS
        Set the FlightingServiceCatalogID
         
    .DESCRIPTION
        Set the FlightingServiceCatalogID element in the web.config file used by D365FO
         
    .PARAMETER AosServiceWebRootPath
        Path to the root folder where to locate the web.config file
         
    .PARAMETER FlightServiceCatalogId
        Flighting catalog ID to be set
         
    .EXAMPLE
        PS C:\> Set-D365FlightServiceCatalogId
         
        This will set the FlightingServiceCatalogID element the web.config to the default value "12719367".
         
    .NOTES
        Tags: Flight, Flighting
         
        Author: Frank H�ther(@FrankHuether))
         
        The DataAccess.FlightingServiceCatalogID element must already exist in the web.config file, which is expected to be the case in newer environments.
        https://docs.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/data-entities/data-entities-data-packages#features-flighted-in-data-management-and-enabling-flighted-features
#>


function Set-D365FlightServiceCatalogId {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [string]$FlightServiceCatalogId = "12719367",
        
        [string]$AosServiceWebRootPath = $Script:AOSPath
    )

    try {
        $WebConfigFile = Join-Path -Path $AosServiceWebRootPath -ChildPath $Script:WebConfig
        
        Write-PSFMessage -Level Verbose -Message "Retrieve the FlightingServiceCatalogID" -Target $WebConfigFile

        [xml]$WebConfigContents = Get-Content $WebConfigFile
        $FlightServiceNode = $WebConfigContents.SelectSingleNode("/configuration/appSettings/add[@key='DataAccess.FlightingServiceCatalogID']/@value")
        
        if($null -eq $FlightServiceNode){
            Write-PSFMessage -Level Host -Message "The <c='em'>DataAccess.FlightingServiceCatalogID</c> child element under the <c='em'>AppSettings</c> element is missing. See <c='em'>https://docs.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/data-entities/data-entities-data-packages#features-flighted-in-data-management-and-enabling-flighted-features</c> for details."
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }

        $FlightServiceNode.Value = $FlightServiceCatalogId

        Write-PSFMessage -Level Verbose -Message "Write the FlightingServiceCatalogID" -Target $WebConfigFile
        $WebConfigContents.Save($WebConfigFile)
        
        Write-PSFMessage -Level Verbose -Message "New FlightingServiceCatalogID: $($FlightServiceNode.Value)" -Target $WebConfigFile
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while updating the web.config file" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
}


<#
    .SYNOPSIS
        Set the LCS configuration details
         
    .DESCRIPTION
        Set the LCS configuration details and save them into the configuration store
         
    .PARAMETER ProjectId
        The project id for the Dynamics 365 for Finance & Operations project inside LCS
         
    .PARAMETER ClientId
        The Azure Registered Application Id / Client Id obtained while creating a Registered App inside the Azure Portal
         
    .PARAMETER BearerToken
        The token you want to use when working against the LCS api
         
    .PARAMETER ActiveTokenExpiresOn
        The point in time where the current bearer token will expire
         
        The time is measured in Unix Time, total seconds since 1970-01-01
         
    .PARAMETER RefreshToken
        The Refresh Token that you want to use for the authentication process
         
    .PARAMETER LcsApiUri
        URI / URL to the LCS API you want to use
         
        Depending on whether your LCS project is located in europe or not, there is 2 valid URI's / URL's
         
        Valid options:
        "https://lcsapi.lcs.dynamics.com"
        "https://lcsapi.eu.lcs.dynamics.com"
         
    .PARAMETER Temporary
        Instruct the cmdlet to only temporarily override the persisted settings in the configuration storage
         
    .EXAMPLE
        PS C:\> Set-D365LcsApiConfig -ProjectId 123456789 -ClientId "9b4f4503-b970-4ade-abc6-2c086e4c4929" -BearerToken "JldjfafLJdfjlfsalfd..." -ActiveTokenExpiresOn 1556909205 -RefreshToken "Tsdljfasfe2j32324" -LcsApiUri "https://lcsapi.lcs.dynamics.com"
         
        This will set the LCS API configuration.
        The ProjectId 123456789 will be saved as the default ProjectId for all cmdlets that will interact with LCS, if they require a ProjectId.
        The ClientId "9b4f4503-b970-4ade-abc6-2c086e4c4929" will be saved as the default ClientId for all cmdlets that will interact with LCS, if they require a ClientId.
        The BearerToken "JldjfafLJdfjlfsalfd..." will be saved as the default BearerToken. Remember the BearerToken will expire, so you should fill in the ActiveTokenExpiresOn and RefreshToken parameters also.
        The ActiveTokenExpiresOn 1556909205 will be saved to assist the module in determine whether the BearerToken is still valid or not.
        The RefreshToken "Tsdljfasfe2j32324" will be saved as the default RefreshToken for all cmdlets that will interact with tokens.
        The LcsApiUri "https://lcsapi.lcs.dynamics.com" will be saved as the default LCS HTTP endpoint for all cmdlets that will interact with LCS.
         
    .EXAMPLE
        PS C:\> Get-D365LcsApiToken -Username "serviceaccount@domain.com" -Password "TopSecretPassword" | Set-D365LcsApiConfig
         
        This will obtain a valid OAuth 2.0 access token from Azure Active Directory and save the needed details.
        The Username "serviceaccount@domain.com" and Password "TopSecretPassword" is used in the OAuth 2.0 Grant Flow, to approved that the application should impersonate like "serviceaccount@domain.com".
        The output object received from Get-D365LcsApiToken is piped directly to Set-D365LcsApiConfig.
        Set-D365LcsApiConfig will save the access_token(BearerToken), refresh_token(RefreshToken) and expires_on(ActiveTokenExpiresOn).
         
        These values will then be available as default values for all LCS cmdlets across the module.
         
        You can validate the current default values by calling Get-D365LcsApiConfig.
         
    .NOTES
        Tags: Environment, Url, Config, Configuration, LCS, Upload, ClientId
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Set-D365LcsApiConfig {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    [OutputType()]
    param(
        [int] $ProjectId,

        [string] $ClientId,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias('access_token')]
        [Alias('AccessToken')]
        [string] $BearerToken,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias('expires_on')]
        [long] $ActiveTokenExpiresOn,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias('refresh_token')]
        [string] $RefreshToken,

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias('resource')]
        [string] $LcsApiUri = "https://lcsapi.lcs.dynamics.com",
        
        [switch] $Temporary


    )

    process {
        #The ':keys' label is used to have a continue inside the switch statement itself
        :keys foreach ($key in $PSBoundParameters.Keys) {
        
            $configurationValue = $PSBoundParameters.Item($key)
            $configurationName = $key.ToLower()
            $fullConfigName = ""

            Write-PSFMessage -Level Verbose -Message "Working on $key with $configurationValue" -Target $configurationValue
        
            switch ($key) {
                "Temporary" {
                    continue keys
                }

                Default {
                    $fullConfigName = "d365fo.tools.lcs.$configurationName"
                }
            }

            Write-PSFMessage -Level Verbose -Message "Setting $fullConfigName to $configurationValue" -Target $configurationValue
        
            Set-PSFConfig -FullName $fullConfigName -Value $configurationValue
            if (-not $Temporary) { Register-PSFConfig -FullName $fullConfigName -Scope UserDefault }
        }

        Update-LcsApiVariables
    }
}


<#
    .SYNOPSIS
        Set the details for the logic app invoke cmdlet
         
    .DESCRIPTION
        Store the needed details for the module to execute an Azure Logic App using a HTTP request
         
    .PARAMETER Url
        The URL for the http request endpoint of the desired
        logic app
         
    .PARAMETER Email
        The receiving email address that should be notified
         
    .PARAMETER Subject
        The subject of the email that you want to send
         
    .PARAMETER ConfigStorageLocation
        Parameter used to instruct where to store the configuration objects
         
        The default value is "User" and this will store all configuration for the active user
         
        Valid options are:
        "User"
        "System"
         
        "System" will store the configuration so all users can access the configuration objects
         
    .PARAMETER Temporary
        Switch to instruct the cmdlet to only temporarily override the persisted settings in the configuration storage
         
    .EXAMPLE
        PS C:\> Set-D365LogicAppConfig -Email administrator@contoso.com -Subject "Work is done" -Url https://prod-35.westeurope.logic.azure.com:443/
         
        This will set all the details about invoking the Logic App.
         
    .EXAMPLE
        PS C:\> Set-D365LogicAppConfig -Email administrator@contoso.com -Subject "Work is done" -Url https://prod-35.westeurope.logic.azure.com:443/ -ConfigStorageLocation "System"
         
        This will set all the details about invoking the Logic App.
        The data will be stored in the system wide configuration storage, which makes it accessible from all users.
         
    .EXAMPLE
        PS C:\> Set-D365LogicAppConfig -Email administrator@contoso.com -Subject "Work is done" -Url https://prod-35.westeurope.logic.azure.com:443/ -Temporary
         
        This will set all the details about invoking the Logic App.
        The update will only last for the rest of this PowerShell console session.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Set-D365LogicAppConfig {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true )]
        [string] $Url,

        [Parameter(Mandatory = $false )]
        [string] $Email,

        [Parameter(Mandatory = $false )]
        [string] $Subject,

        [ValidateSet('User', 'System')]
        [string] $ConfigStorageLocation = "User",

        [switch] $Temporary
    )

    $configScope = Test-ConfigStorageLocation -ConfigStorageLocation $ConfigStorageLocation

    if (Test-PSFFunctionInterrupt) { return }

    $logicDetails = @{URL = $URL; Email = $Email;
        Subject = $Subject;
    }

    Set-PSFConfig -FullName "d365fo.tools.active.logic.app" -Value $logicDetails
    if (-not $Temporary) { Register-PSFConfig -FullName "d365fo.tools.active.logic.app" -Scope $configScope }

    $Script:LogicAppEmail = $logicDetails.Email
    $Script:LogicAppSubject = $logicDetails.Subject
    $Script:LogicAppUrl = $logicDetails.Url
}


<#
    .SYNOPSIS
        Sets the offline administrator e-mail
         
    .DESCRIPTION
        Sets the registered offline administrator in the "DynamicsDevConfig.xml" file located in the default Package Directory
         
    .PARAMETER Email
        The desired email address of the to be offline administrator
         
    .EXAMPLE
        PS C:\> Set-D365OfflineAuthenticationAdminEmail -Email "admin@contoso.com"
         
        Will update the Offline Administrator E-mail address in the DynamicsDevConfig.xml file with "admin@contoso.com"
         
    .NOTES
        This cmdlet is inspired by the work of "Sheikh Sohail Hussain" (twitter: @SSohailHussain)
         
        His blog can be found here:
        http://d365technext.blogspot.com
         
        The specific blog post that we based this cmdlet on can be found here:
        http://d365technext.blogspot.com/2018/07/offline-authentication-admin-email.html
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Set-D365OfflineAuthenticationAdminEmail {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Default', Position = 1 )]
        [string] $Email
    )

    if (-not ($script:IsAdminRuntime)) {
        Write-PSFMessage -Level Host -Message "The cmdlet needs <c='em'>administrator permission</c> (Run As Administrator) to be able to update the configuration. Please start an <c='em'>elevated</c> session and run the cmdlet again."
        Stop-PSFFunction -Message "Stopping because the function is not run elevated"
        return
    }

    $filePath = Join-Path (Join-Path $Script:PackageDirectory "bin") "DynamicsDevConfig.xml"

    if (-not (Test-PathExists -Path $filePath -Type Leaf)) {return}

    $namespace = @{ns="http://schemas.microsoft.com/dynamics/2012/03/development/configuration"}
    $xmlDoc = [xml] (Get-Content -Path $filePath)
    $OfflineAuthAdminEmail = Select-Xml -Xml $xmlDoc -XPath "/ns:DynamicsDevConfig/ns:OfflineAuthenticationAdminEmail"  -Namespace $namespace

    $oldValue = $OfflineAuthAdminEmail.Node.InnerText
    
    Write-PSFMessage -Level Verbose -Message "Old value found in the file was: $oldValue" -Target $oldValue

    $OfflineAuthAdminEmail.Node.InnerText = $Email
    $xmlDoc.Save($filePath)
}


<#
    .SYNOPSIS
        Set different RSAT configuration values
         
    .DESCRIPTION
        Update different RSAT configuration values while using the tool
         
    .PARAMETER LogGenerationEnabled
        Will set the LogGeneration property
         
        $true will make RSAT start generating logs
        $false will stop RSAT from generating logs
         
    .PARAMETER VerboseSnapshotsEnabled
        Will set the VerboseSnapshotsEnabled property
         
        $true will make RSAT start generating snapshots and store related details
        $false will stop RSAT from generating snapshots and store related details
         
    .PARAMETER AddOperatorFieldsToExcelValidationEnabled
        Will set the AddOperatorFieldsToExcelValidation property
         
        $true will make RSAT start adding the operation options in the excel parameter file
        $false will stop RSAT from adding the operation options in the excel parameter file
         
    .EXAMPLE
        PS C:\> Set-D365RsatConfiguration -LogGenerationEnabled $true
         
        This will enable the log generation logic of RSAT.
         
    .EXAMPLE
        PS C:\> Set-D365RsatConfiguration -VerboseSnapshotsEnabled $true
         
        This will enable the snapshot generation logic of RSAT.
         
    .EXAMPLE
        PS C:\> Set-D365RsatConfiguration -AddOperatorFieldsToExcelValidationEnabled $true
         
        This will enable the operator generation logic of RSAT.
         
    .NOTES
        Tags: RSAT, Testing, Regression Suite Automation Test, Regression, Test, Automation, Configuration
         
        Author: M�tz Jensen (@Splaxi)
#>


function Set-D365RsatConfiguration {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]

    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $false)]
        [bool] $LogGenerationEnabled,

        [Parameter(Mandatory = $false)]
        [bool] $VerboseSnapshotsEnabled,

        [Parameter(Mandatory = $false)]
        [bool] $AddOperatorFieldsToExcelValidationEnabled
    )

    $configPath = Join-Path $Script:RsatPath "Microsoft.Dynamics.RegressionSuite.WindowsApp.exe.config"

    if (-not (Test-PathExists -Path $configPath -Type Leaf)) {
        Write-PSFMessage -Level Critical -Message "The 'Microsoft.Dynamics.RegressionSuite.WindowsApp.exe.config' file could not be found on the system."
        Stop-PSFFunction -Message  "Stopping because the 'Microsoft.Dynamics.RegressionSuite.WindowsApp.exe.config' file could not be located."
        return
    }

    try {
        [xml]$xmlConfig = Get-Content $configPath

        if ($PSBoundParameters.Keys -contains "LogGenerationEnabled") {
            $logGenerationAttribute = $xmlConfig.SelectNodes('//appSettings//add[@key="LogGeneration"]')
            $logGenerationAttribute.SetAttribute('value', $LogGenerationEnabled.ToString().ToLower())
        }

        if ($PSBoundParameters.Keys -contains "VerboseSnapshotsEnabled") {
            $verboseSnapshotsAttribute = $xmlConfig.SelectNodes('//appSettings//add[@key="VerboseSnapshotsEnabled"]')
            $verboseSnapshotsAttribute.SetAttribute('value', $VerboseSnapshotsEnabled.ToString().ToLower())
        }

        if ($PSBoundParameters.Keys -contains "AddOperatorFieldsToExcelValidationEnabled") {
            $addOperatorFieldsToExcelValidationAttribute = $xmlConfig.SelectNodes('//appSettings//add[@key="AddOperatorFieldsToExcelValidation"]')
            $addOperatorFieldsToExcelValidationAttribute.SetAttribute('value', $AddOperatorFieldsToExcelValidationEnabled.ToString().ToLower())
        }

        $xmlConfig.Save($configPath)
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while updating the RSAT configuration file" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
}


<#
    .SYNOPSIS
        Set the needed configuration to work on Tier2+ environments
         
    .DESCRIPTION
        Set the needed registry settings for when you are running RSAT against a Tier2+ environment
         
    .EXAMPLE
        PS C:\> Set-D365RsatTier2Crypto
         
        This will configure the registry to support RSAT against a Tier2+ environment.
         
    .NOTES
        Tags: RSAT, Testing, Regression Suite Automation Test, Regression, Test, Automation, Configuration
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Set-D365RsatTier2Crypto {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]

    [CmdletBinding()]
    [OutputType()]
    param ()
    
    if ((Test-Path "HKLM:\SOFTWARE\Wow6432Node\Microsoft\.NETFramework\v4.0.30319")) {
        Set-ItemProperty "HKLM:\SOFTWARE\Wow6432Node\Microsoft\.NETFramework\v4.0.30319" -Name SchUseStrongCrypto -Value 1 -Type dword -Force -Confirm:$false
    }
}


<#
    .SYNOPSIS
        Set the cleanup retention period
         
    .DESCRIPTION
        Sets the configured retention period before updates are deleted
         
    .PARAMETER NumberOfDays
        Number of days that deployable software packages should remain on the server
         
    .EXAMPLE
        PS C:\> Set-D365SDPCleanUp -NumberOfDays 10
         
        This will set the retention period to 10 days inside the the registry
         
        The cmdlet REQUIRES elevated permissions to run, otherwise it will fail
         
    .NOTES
        This cmdlet is based on the findings from Alex Kwitny (@AlexOnDAX)
         
        See his blog for more info:
        http://www.alexondax.com/2018/04/msdyn365fo-how-to-adjust-your.html
         
        Author: M�tz Jensen (@Splaxi)
         
#>

function Set-D365SDPCleanUp {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [int] $NumberOfDays = 30
    )

    if (-not ($Script:IsAdminRuntime)) {
        Write-PSFMessage -Level Host -Message "It seems that you ran this cmdlet <c='em'>non-elevated</c>. Making changes to the registry requires you to run this cmdlet from an elevated console. Please exit the current console and start a new with `"Run As Administrator`""
        Stop-PSFFunction -Message "Stopping because of missing parameters"
        return
    }

    Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Dynamics\Deployment" -Name "CutoffDaysForCleanup" -Type STRING -Value "$NumberOfDays" -Force
}


<#
    .SYNOPSIS
        Set the path for SqlPackage.exe
         
    .DESCRIPTION
        Update the path where the module will be looking for the SqlPackage.exe executable
         
    .PARAMETER Path
        Path to the SqlPackage.exe
         
    .EXAMPLE
        PS C:\> Set-D365SqlPackagePath -Path "C:\Program Files\Microsoft SQL Server\150\DAC\bin\SqlPackage.exe"
         
        This will update the path for the SqlPackage.exe in the modules configuration
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
#>


function Set-D365SqlPackagePath {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    [OutputType()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $Path
    )

    if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }

    if (Test-PSFFunctionInterrupt) { return }

    Set-PSFConfig -FullName "d365fo.tools.path.sqlpackage" -Value $Path
    Register-PSFConfig -FullName "d365fo.tools.path.sqlpackage"

    Update-ModuleVariables
}


<#
    .SYNOPSIS
        Sets the start page in internet explorer
         
    .DESCRIPTION
        Function for setting the start page in internet explorer
         
    .PARAMETER Name
        Name of the D365 Instance
         
    .PARAMETER Url
        URL of the D365 for Finance & Operations instance that you want to have as your start page
         
    .EXAMPLE
        PS C:\> Set-D365StartPage -Name 'Demo1'
         
        This will update the start page for the current user to "https://Demo1.cloud.onebox.dynamics.com"
         
    .EXAMPLE
        PS C:\> Set-D365StartPage -URL "https://uat.sandbox.operations.dynamics.com"
         
        This will update the start page for the current user to "https://uat.sandbox.operations.dynamics.com"
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Set-D365StartPage() {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param(
        
        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = 'Default')]
        [String] $Name,

        [Parameter(Mandatory = $true, Position = 1, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Url')]
        [String] $Url
    )
   
    process {
        $path = 'HKCU:\Software\Microsoft\Internet Explorer\Main\'
        $propName = 'start page'
    
        if ($PSBoundParameters.ContainsKey("URL")) {
            $value = $Url
        }
        else {
            $value = "https://$Name.cloud.onebox.dynamics.com"
        }

        Set-Itemproperty -Path $path -Name $propName -Value $value
    }
}


<#
    .SYNOPSIS
        Set a user to sysadmin
         
    .DESCRIPTION
        Set a user to sysadmin inside the SQL Server
         
    .PARAMETER User
        The user that you want to make sysadmin
         
        Most be well formatted server\user or domain\user.
         
        Default value is: machinename\administrator
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .EXAMPLE
        PS C:\> Set-D365SysAdmin
         
        This will configure the local administrator on the machine as a SYSADMIN inside SQL Server
         
        For this to run you need to be running it from a elevated console
         
    .EXAMPLE
        PS C:\> Set-D365SysAdmin -SqlPwd Test123
         
        This will configure the local administrator on the machine as a SYSADMIN inside SQL Server.
        It will logon as the default SqlUser but use the provided SqlPwd.
         
        This can be run from a non-elevated console
         
    .NOTES
        Author: M�tz Jensen (@splaxi)
         
#>

function Set-D365SysAdmin {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (

        [Parameter(Mandatory = $false, Position = 1)]
        [string] $User = "$env:computername\administrator",

        [Parameter(Mandatory = $false, Position = 2)]
        [string] $DatabaseServer = $Script:DatabaseServer,

        [Parameter(Mandatory = $false, Position = 3)]
        [string] $DatabaseName = $Script:DatabaseName,

        [Parameter(Mandatory = $false, Position = 4)]
        [string] $SqlUser = $Script:DatabaseUserName,

        [Parameter(Mandatory = $false, Position = 5)]
        [string] $SqlPwd = $Script:DatabaseUserPassword
    )

    $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd
    }
    
    Write-PSFMessage -Level Debug -Message "Testing if running either elevated or with -SqlPwd set."
    if ((-not ($script:IsAdminRuntime)) -and (-not ($PSBoundParameters.ContainsKey("SqlPwd")))) {
        Write-PSFMessage -Level Host -Message "It seems that you ran this cmdlet <c='em'>non-elevated</c> and without the <c='em'>-SqlPwd parameter</c>. If you don't want to supply the -SqlPwd you must run the cmdlet elevated (Run As Administrator) otherwise simply use the -SqlPwd parameter"
        Stop-PSFFunction -Message "Stopping because of missing parameters"
        return
    }

    $commandText = (Get-Content "$script:ModuleRoot\internal\sql\set-sysadmin.sql") -join [Environment]::NewLine
    $commandText = $commandText.Replace('@USER', $User)

    $sqlCommand = Get-SqlCommand @SqlParams

    $sqlCommand.CommandText = $commandText

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()

        $null = $sqlCommand.ExecuteNonQuery()
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()
    }
}


<#
    .SYNOPSIS
        Save hashtable with parameters
         
    .DESCRIPTION
        Saves the hashtable as a json string into the configuration store
         
        This cmdlet is only intended to be used for New-D365Bacpac and Import-D365Bacpac for Tier2 environments
         
    .PARAMETER InputObject
        The hashtable containing all the parameters you want to store
         
    .PARAMETER ConfigStorageLocation
        Parameter used to instruct where to store the configuration objects
         
        The default value is "User" and this will store all configuration for the active user
         
        Valid options are:
        "User"
        "System"
         
        "System" will store the configuration so all users can access the configuration objects
         
    .PARAMETER Temporary
        Switch to instruct the cmdlet to only temporarily override the persisted settings in the configuration storage
         
    .EXAMPLE
        PS C:\> $params = @{ SqlUser = "sqladmin"
        PS C:\> SqlPwd = "pass@word1"
        PS C:\> }
        PS C:\> Set-D365Tier2Params -InputObject $params
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
#>


function Set-D365Tier2Params {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 1)]
        [HashTable] $InputObject,

        [ValidateSet('User', 'System')]
        [string] $ConfigStorageLocation = "User",
        
        [switch] $Temporary
    )

    if ($null -eq $($InputObject.Keys)) {
        Write-PSFMessage -Level Host -Message "The input object seems to be empty. Please ensure that the input object is a hashtable and it actually contains data."
        Stop-PSFFunction -Message "Stopping because the input object didn't contain data."
        return
    }
    
    $configScope = Test-ConfigStorageLocation -ConfigStorageLocation $ConfigStorageLocation

    $jsonString = ConvertTo-Json -InputObject $InputObject

    Write-PSFMessage -Level Verbose -Message "Converted hashtable to json string" -Target $jsonString

    Set-PSFConfig -FullName "d365fo.tools.tier2.bacpac.params" -Value $jsonString

    if (-not $Temporary) { Register-PSFConfig -FullName "d365fo.tools.tier2.bacpac.params"  -Scope $configScope }
}


<#
    .SYNOPSIS
        Configue a new maximum file size for the TraceParser
         
    .DESCRIPTION
        Change the maximum file size that the TraceParser generates
         
    .PARAMETER FileSizeInMB
        The maximum size that you want to allow the TraceParser file to grow to
         
        Original value inside the configuration is 1024 (MB)
         
    .PARAMETER Path
        The path to the TraceParser.config file that you want to edit
         
        The default path is: "\AosService\Webroot\Services\TraceParserService\TraceParserService.config"
         
    .EXAMPLE
        PS C:\> Set-D365TraceParserFileSize -FileSizeInMB 2048
         
        This will configure the maximum TraceParser file to 2048 MB.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Set-D365TraceParserFileSize {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $FileSizeInMB,
        
        [string] $Path = (Join-Path $Script:AOSPath "Services\TraceParserService\TraceParserService.config")
    )

    if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }

    $xmlDoc = [xml] (Get-Content -Path $Path)

    $fileSize = Select-Xml -Xml $xmlDoc -XPath "/Microsoft.Dynamics.AX.Services.Tracing.TraceParser.Properties.Settings/setting[@name='MaximumEtlFileSizeInMb']/value"
    
    $fileSize.Node."#text" = "$FileSizeInMB"

    $xmlDoc.Save($Path)
}


<#
    .SYNOPSIS
        Set the Workstation mode
         
    .DESCRIPTION
        Set the Workstation mode to enabled or not
         
        It is used to enable the tool to run on a personal machine and still be able to call Invoke-D365TableBrowser and Invoke-D365SysRunnerClass
         
    .PARAMETER Enabled
        $True enables the workstation mode while $false deactivated the workstation mode
         
    .EXAMPLE
        PS C:\> Set-D365WorkstationMode -Enabled $true
         
        This will enable the Workstation mode.
        You will have to restart the powershell session when you switch around.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
        You will have to run the Initialize-D365Config cmdlet first, before this will be capable of working.
         
#>

function Set-D365WorkstationMode {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [boolean] $Enabled
    )

    Set-PSFConfig -FullName "d365fo.tools.workstation.mode" -Value $Enabled
    Register-PSFConfig -FullName "d365fo.tools.workstation.mode"

    Write-PSFMessage -Level Host -Message "Please <c='em'>restart</c> the powershell session / console. This change affects core functionality that <c='em'>requires</c> the module to be <c='em'>reloaded</c>."
}


<#
    .SYNOPSIS
        Cmdlet to start the different services in a Dynamics 365 Finance & Operations environment
         
    .DESCRIPTION
        Can start all relevant services that is running in a D365FO environment
         
    .PARAMETER ComputerName
        An array of computers that you want to start services on.
         
    .PARAMETER All
        Set when you want to start all relevant services
         
        Includes:
        Aos
        Batch
        Financial Reporter
         
    .PARAMETER Aos
        Start the Aos (iis) service
         
    .PARAMETER Batch
        Start the batch service
         
    .PARAMETER FinancialReporter
        Start the financial reporter (Management Reporter 2012) service
         
    .PARAMETER DMF
        Start the Data Management Framework service
         
    .PARAMETER OnlyStartTypeAutomatic
        Instruct the cmdlet to filter out services that are set to manual start or disabled
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .EXAMPLE
        PS C:\> Start-D365Environment
         
        This will run the cmdlet with the default parameters.
        Default is "-All".
        This will start all D365FO services on the machine.
         
    .EXAMPLE
        PS C:\> Start-D365Environment -OnlyStartTypeAutomatic
         
        This will start all D365FO services on the machine that are configured for Automatic startup.
        It will exclude all services that are either manual or disabled in their startup configuration.
         
    .EXAMPLE
        PS C:\> Start-D365Environment -ShowOriginalProgress
         
        This will run the cmdlet with the default parameters.
        Default is "-All".
        This will start all D365FO services on the machine.
        The progress of starting the different services will be written to the console / host.
         
    .EXAMPLE
        PS C:\> Start-D365Environment -All
         
        This will start all D365FO services on the machine.
         
    .EXAMPLE
        PS C:\> Start-D365Environment -Aos -Batch
         
        This will start the Aos & Batch D365FO services on the machine.
         
    .EXAMPLE
        PS C:\> Start-D365Environment -FinancialReporter -DMF
         
        This will start the FinancialReporter and DMF services on the machine.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Start-D365Environment {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidDefaultValueSwitchParameter", "")]
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 1 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 1 )]
        [string[]] $ComputerName = @($env:computername),

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 2 )]
        [switch] $All = $true,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 2 )]
        [switch] $Aos,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 3 )]
        [switch] $Batch,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 4 )]
        [switch] $FinancialReporter,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 5 )]
        [switch] $DMF,

        [switch] $OnlyStartTypeAutomatic,

        [switch] $ShowOriginalProgress
    )

    if ($PSCmdlet.ParameterSetName -eq "Specific") {
        $All = $false
    }

    if ( (-not ($All)) -and (-not ($Aos)) -and (-not ($Batch)) -and (-not ($FinancialReporter)) -and (-not ($DMF))) {
        Write-PSFMessage -Level Host -Message "You have to use at least <c='em'>one switch</c> when running this cmdlet. Please run the cmdlet again."
        Stop-PSFFunction -Message "Stopping because of missing parameters"
        return
    }

    $warningActionValue = "SilentlyContinue"
    if ($ShowOriginalProgress) { $warningActionValue = "Continue" }

    $Params = Get-DeepClone $PSBoundParameters
    if ($Params.ContainsKey("ComputerName")) { $null = $Params.Remove("ComputerName") }
    if ($Params.ContainsKey("ShowOriginalProgress")) { $null = $Params.Remove("ShowOriginalProgress") }
    if ($Params.ContainsKey("OnlyStartTypeAutomatic")) { $null = $Params.Remove("OnlyStartTypeAutomatic") }

    $Services = Get-ServiceList @Params

    $Results = foreach ($server in $ComputerName) {
        Write-PSFMessage -Level Verbose -Message "Working against: $server - starting services"
        $temp = Get-Service -ComputerName $server -Name $Services -ErrorAction SilentlyContinue
        
        if ($OnlyStartTypeAutomatic) {
            $temp = $temp | Where-Object StartType -eq "Automatic"
        }

        $temp | Start-Service -ErrorAction SilentlyContinue -WarningAction $warningActionValue
    }

    $Results = foreach ($server in $ComputerName) {
        Write-PSFMessage -Level Verbose -Message "Working against: $server - listing services"
        $temp = Get-Service -ComputerName $server -Name $Services -ErrorAction SilentlyContinue
        
        if ($OnlyStartTypeAutomatic) {
            $temp = $temp | Where-Object StartType -eq "Automatic"
        }

        $temp | Select-Object @{Name = "Server"; Expression = { $Server } }, Name, Status, StartType, DisplayName
    }

    Write-PSFMessage -Level Verbose "Results are: $Results" -Target ($Results.Name -join ",")

    $Results | Select-PSFObject -TypeName "D365FO.TOOLS.Environment.Service" Server, DisplayName, Status, StartType, Name
}


<#
    .SYNOPSIS
        Start an Event Trace session
         
    .DESCRIPTION
        Start an Event Trace session with default values to help you getting started
         
    .PARAMETER ProviderName
        Name of the provider(s) you want to have part of your trace
         
        Accepts an array/list of provider names
         
    .PARAMETER OutputPath
        Path to the output folder where you want to store the ETL file that will be generated
         
        Default path is "C:\Temp\d365fo.tools\EventTrace"
         
    .PARAMETER SessionName
        Name that you want the tracing session to have while running the trace
         
        Default value is "d365fo.tools.trace"
         
    .PARAMETER FileName
        Name of the file that you want the trace to write its output to
         
        Default value is "d365fo.tools.trace.etl"
         
    .PARAMETER OutputFormat
        The desired output format of the ETL file being outputted from the tracing session
         
        Default value is "bincirc"
         
    .PARAMETER MinBuffer
        The minimum buffer size in MB that you want the tracing session to work with
         
        Default value is 10240
         
    .PARAMETER MaxBuffer
        The maximum buffer size in MB that you want the tracing session to work with
         
        Default value is 10240
         
    .PARAMETER BufferSizeKB
        The buffer size in KB that you want the tracing session to work with
         
        Default value is 1024
         
    .PARAMETER MaxLogFileSizeMB
        The maximum log file size in MB that you want the tracing session to work with
         
        Default value is 4096
         
    .EXAMPLE
        PS C:\> Start-D365EventTrace -ProviderName "Microsoft-Dynamics-AX-FormServer","Microsoft-Dynamics-AX-XppRuntime"
         
        This will start a new Event Tracing session with the binary circular output format.
        It uses "Microsoft-Dynamics-AX-FormServer","Microsoft-Dynamics-AX-XppRuntime" as the providernames.
        It uses the default output folder "C:\Temp\d365fo.tools\EventTrace".
         
        It will use the default values for the remaining parameters.
         
    .EXAMPLE
        PS C:\> Start-D365EventTrace -ProviderName "Microsoft-Dynamics-AX-FormServer","Microsoft-Dynamics-AX-XppRuntime" -OutputFormat CSV
         
        This will start a new Event Tracing session with the comma separated output format.
        It uses "Microsoft-Dynamics-AX-FormServer","Microsoft-Dynamics-AX-XppRuntime" as the providernames.
        It uses the default output folder "C:\Temp\d365fo.tools\EventTrace".
         
        It will use the default values for the remaining parameters.
         
    .NOTES
        Tags: ETL, EventTracing, EventTrace
         
        Author: M�tz Jensen (@Splaxi)
         
        This cmdlet/function was inspired by the work of Michael Stashwick (@D365Stuff)
         
        He blog is located here: https://www.d365stuff.co/
         
        and the blogpost that pointed us in the right direction is located here: https://www.d365stuff.co/trace-batch-jobs-and-more-via-cmd-logman/
#>


function Start-D365EventTrace {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true)]
        [string[]] $ProviderName,

        [string] $OutputPath = (Join-Path -Path $Script:DefaultTempPath -ChildPath "EventTrace"),

        [string] $SessionName = "d365fo.tools.trace",

        [string] $FileName = "d365fo.tools.trace.etl",

        [ValidateSet('bin', 'bincirc', 'csv', 'sql', 'tsv')]
        [string] $OutputFormat = "bincirc",

        [Int32] $MinBuffer = 10240,

        [Int32] $MaxBuffer = 10240,

        [Int32] $BufferSizeKB = 1024,

        [Int32] $MaxLogFileSizeMB = 4096
    )
    
    begin {
        $providers = New-Object System.Collections.Generic.List[string]

        if (-not (Test-PathExists -Path $OutputPath -Type Container -Create)) { return }

        Write-PSFMessage -Level Verbose -Message "Configuring the permissions on the folder to make sure the Start-Trace command can read the files." -Target $OutputPath
        
        $propagation = [system.security.accesscontrol.PropagationFlags]"None"
        $inherit = [system.security.accesscontrol.InheritanceFlags]"ContainerInherit, ObjectInherit"
        $accessRule = New-Object  system.security.accesscontrol.filesystemaccessrule("BUILTIN\Users", "FullControl", $inherit, $propagation, "Allow")
        $aclFolder = Get-Acl -Path $OutputPath
        $aclFolder.AddAccessRule($accessRule)
        Set-Acl -Path $OutputPath -AclObject $aclFolder
        
        $providerListPath = Join-Path -Path $OutputPath -ChildPath "ProviderList.txt"
    }
    
    process {
        foreach ($name in $ProviderName) {
            Write-PSFMessage -Level Verbose -Message "Adding the $name to the list of providers." -Target $name
            $providers.Add($name)
        }
    }
    
    end {
        Write-PSFMessage -Level Verbose -Message "Storing the providers in '$providerListPath' as a UTF8 (NON-BOM) file." -Target $providerListPath

        $Utf8NoBomEncoding = New-Object System.Text.UTF8Encoding $False
        [System.IO.File]::WriteAllLines($providerListPath, $($providers.ToArray() -join [System.Environment]::NewLine), $Utf8NoBomEncoding)

        $outputFile = Join-Path -Path $OutputPath -ChildPath $FileName

        Write-PSFMessage -Level Verbose -Message "Starting the trace now."
        Start-Trace -SessionName $SessionName -OutputFilePath $outputFile -ProviderFilePath $providerListPath -ETS -Format $OutputFormat -MinBuffers $MinBuffer -MaxBuffers $MaxBuffer -BufferSizeInKB $BufferSizeKB -MaxLogFileSizeInMB $MaxLogFileSizeMB
    }
}


<#
    .SYNOPSIS
        Cmdlet to stop the different services in a Dynamics 365 Finance & Operations environment
         
    .DESCRIPTION
        Can stop all relevant services that is running in a D365FO environment
         
    .PARAMETER ComputerName
        An array of computers that you want to stop services on.
         
    .PARAMETER All
        Set when you want to stop all relevant services
         
        Includes:
        Aos
        Batch
        Financial Reporter
         
    .PARAMETER Aos
        Stop the Aos (iis) service
         
    .PARAMETER Batch
        Stop the batch service
         
    .PARAMETER FinancialReporter
        Start the financial reporter (Management Reporter 2012) service
         
    .PARAMETER DMF
        Start the Data Management Framework service
         
    .PARAMETER ShowOriginalProgress
        Instruct the cmdlet to show the standard output in the console
         
        Default is $false which will silence the standard output
         
    .EXAMPLE
        PS C:\> Stop-D365Environment
         
        This will run the cmdlet with the default parameters.
        Default is "-All".
        This will stop all D365FO services on the machine.
         
    .EXAMPLE
        PS C:\> Stop-D365Environment -ShowOriginalProgress
         
        This will run the cmdlet with the default parameters.
        Default is "-All".
        This will Stop all D365FO services on the machine.
        The progress of Stopping the different services will be written to the console / host.
         
    .EXAMPLE
        PS C:\> Stop-D365Environment -All
         
        This will stop all D365FO services on the machine.
         
    .EXAMPLE
        PS C:\> Stop-D365Environment -Aos -Batch
         
        This will stop the Aos & Batch D365FO services on the machine.
         
    .EXAMPLE
        PS C:\> Stop-D365Environment -FinancialReporter -DMF
         
        This will stop the FinancialReporter and DMF services on the machine.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Stop-D365Environment {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidDefaultValueSwitchParameter", "")]
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 1 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 1 )]
        [string[]] $ComputerName = @($env:computername),

        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 2 )]
        [switch] $All = $true,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 2 )]
        [switch] $Aos,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 3 )]
        [switch] $Batch,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 4 )]
        [switch] $FinancialReporter,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 5 )]
        [switch] $DMF,

        [Parameter(Mandatory = $false, ParameterSetName = 'Specific', Position = 6 )]
        [Parameter(Mandatory = $false, ParameterSetName = 'Default', Position = 6 )]
        [switch] $ShowOriginalProgress
    )

    if ($PSCmdlet.ParameterSetName -eq "Specific") {
        $All = $false
    }

    if ((-not ($All)) -and (-not ($Aos)) -and (-not ($Batch)) -and (-not ($FinancialReporter)) -and (-not ($DMF))) {
        Write-PSFMessage -Level Host -Message "You have to use at least <c='em'>one switch</c> when running this cmdlet. Please run the cmdlet again."
        Stop-PSFFunction -Message "Stopping because of missing parameters"
        return
    }

    $warningActionValue = "SilentlyContinue"
    if ($ShowOriginalProgress) {$warningActionValue = "Continue"}

    $Params = Get-DeepClone $PSBoundParameters
    if ($Params.ContainsKey("ComputerName")) {$null = $Params.Remove("ComputerName")}
    if ($Params.ContainsKey("ShowOriginalProgress")) {$null = $Params.Remove("ShowOriginalProgress")}

    $Services = Get-ServiceList @Params
    
    $Results = foreach ($server in $ComputerName) {
        Write-PSFMessage -Level Verbose -Message "Working against: $server - stopping services"
        Get-Service -ComputerName $server -Name $Services -ErrorAction SilentlyContinue | Stop-Service -Force -ErrorAction SilentlyContinue -WarningAction $warningActionValue
    }

    $Results = foreach ($server in $ComputerName) {
        Write-PSFMessage -Level Verbose -Message "Working against: $server - listing services"
        Get-Service -ComputerName $server -Name $Services -ErrorAction SilentlyContinue | Select-Object @{Name = "Server"; Expression = {$Server}}, Name, Status, StartType, DisplayName
    }
    
    Write-PSFMessage -Level Verbose "Results are: $Results" -Target ($Results.Name -join ",")
    
    $Results | Select-PSFObject -TypeName "D365FO.TOOLS.Environment.Service" Server, DisplayName, Status, StartType, Name
}


<#
    .SYNOPSIS
        Stop an Event Trace session
         
    .DESCRIPTION
        Stop an Event Trace session that you have started earlier with the d365fo.tools
         
    .PARAMETER SessionName
        Name of the tracing session that you want to stop
         
        Default value is "d365fo.tools.trace"
         
    .EXAMPLE
        PS C:\> Stop-D365EventTrace
         
        This will stop an Event Trace session.
        It will use the "d365fo.tools.trace" as the SessionName parameter.
         
    .NOTES
        Tags: ETL, EventTracing, EventTrace
         
        Author: M�tz Jensen (@Splaxi)
         
        This cmdlet/function was inspired by the work of Michael Stashwick (@D365Stuff)
         
        He blog is located here: https://www.d365stuff.co/
         
        and the blogpost that pointed us in the right direction is located here: https://www.d365stuff.co/trace-batch-jobs-and-more-via-cmd-logman/
#>


function Stop-D365EventTrace {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [string] $SessionName = "d365fo.tools.trace"
    )

    end {
        Write-PSFMessage -Level Verbose -Message "Stopping the trace" -Target $SessionName
        
        Stop-Trace -SessionName $SessionName -ETS
    }
}


<#
    .SYNOPSIS
        Switches the 2 databases. The Old wil be renamed _original
         
    .DESCRIPTION
        Switches the 2 databases. The Old wil be renamed _original
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER SourceDatabaseName
        The database that takes the DatabaseName's place
         
    .PARAMETER DestinationSuffix
        The suffix that you want to append onto the database that is being switched out (DestinationDatabaseName / DatabaseName)
         
        The default value is "_original" to mimic the official guides from Microsoft
         
    .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:\> Switch-D365ActiveDatabase -SourceDatabaseName "GoldenConfig"
         
        This will switch the default database AXDB out and put "GoldenConfig" in its place instead.
        It will use the default value for DestinationSuffix which is "_original".
        The destination database "AXDB" will be renamed to "AXDB_original".
        The GoldenConfig database will be renamed to "AXDB".
         
    .EXAMPLE
        PS C:\> Switch-D365ActiveDatabase -SourceDatabaseName "AXDB_original" -DestinationSuffix "_reverted"
         
        This will switch the default database AXDB out and put "AXDB_original" in its place instead.
        It will use the "_reverted" value for DestinationSuffix parameter.
        The destination database "AXDB" will be renamed to "AXDB_reverted".
        The "AXDB_original" database will be renamed to "AXDB".
         
        This is used when you did a switch already and need to switch back to the original database.
         
        This example assumes that the used the first example to switch in the GoldenConfig database with default parameters.
         
    .NOTES
         
        Author: M�tz Jensen (@Splaxi)
         
        Author: Rasmus Andersen (@ITRasmus)
         
#>

function Switch-D365ActiveDatabase {
    [CmdletBinding()]
    param (
        [string] $DatabaseServer = $Script:DatabaseServer,

        [Alias('DestinationDatabaseName')]
        [string] $DatabaseName = $Script:DatabaseName,

        [string] $SqlUser = $Script:DatabaseUserName,

        [string] $SqlPwd = $Script:DatabaseUserPassword,
        
        [Parameter(Mandatory = $true)]
        [Alias('NewDatabaseName')]
        [string] $SourceDatabaseName,

        [string] $DestinationSuffix = "_original",

        [switch] $EnableException
    )

    $dbToBeName = "$DatabaseName$DestinationSuffix"

    $SqlParamsToBe = @{ DatabaseServer = $DatabaseServer; DatabaseName = "master";
        SqlUser = $SqlUser; SqlPwd = $SqlPwd
    }
    
    $dbName = Get-D365Database -Name "$dbToBeName" @SqlParamsToBe

    if (-not($null -eq $dbName)) {
        $messageString = "There <c='em'>already exists</c> a database named: <c='em'>`"$dbToBeName`"</c> on the server. You need to run the <c='em'>Remove-D365Database</c> cmdlet to remove the already existing database. Re-run this cmdlet once the other database has been removed."
        Write-PSFMessage -Level Host -Message $messageString -Target $dbToBeName
        Stop-PSFFunction -Message "Stopping because database already exists on the server." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
        return
    }

    $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

    $SqlParamsSource = @{ DatabaseServer = $DatabaseServer; DatabaseName = $SourceDatabaseName;
        SqlUser = $SqlUser; SqlPwd = $SqlPwd
    }

    $SqlCommand = Get-SqlCommand @SqlParamsSource -TrustedConnection $UseTrustedConnection

    $SqlCommand.CommandText = "SELECT COUNT(1) FROM dbo.USERINFO WHERE ID = 'Admin'"

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)
        Write-PSFMessage -Level Verbose -Message "Testing the new database for being a valid AXDB database." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()
        $null = $sqlCommand.ExecuteScalar()
    }
    catch {
        $messageString = "It seems that the new database either <c='em'>doesn't exists</c>, isn't a <c='em'>valid</c> AxDB database or your don't have enough <c='em'>permissions</c>."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }
    }
    
    $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = "master";
        SqlUser = $SqlUser; SqlPwd = $SqlPwd
    }

    $SqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection

    if ($DatabaseServer -like "*database.windows.net") {
        $commandText = (Get-Content "$script:ModuleRoot\internal\sql\switch-database-tier2.sql") -join [Environment]::NewLine
    }
    else {
        $commandText = (Get-Content "$script:ModuleRoot\internal\sql\switch-database-tier1.sql") -join [Environment]::NewLine
    }
    
    $sqlCommand.CommandText = $commandText

    $null = $sqlCommand.Parameters.AddWithValue("@DestinationName", $DatabaseName)
    $null = $sqlCommand.Parameters.AddWithValue("@SourceName", $SourceDatabaseName)
    $null = $sqlCommand.Parameters.AddWithValue("@ToBeName", $dbToBeName)

    try {
        Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)
        Write-PSFMessage -Level Verbose -Message "Switching out the $DatabaseName database with: $SourceDatabaseName." -Target (Get-SqlString $SqlCommand)

        $sqlCommand.Connection.Open()

        $null = $sqlCommand.ExecuteNonQuery()
    }
    catch {
        $messageString = "Something went wrong while <c='em'>switching</c> out the AXDB database."
        Write-PSFMessage -Level Host -Message $messageString -Exception $PSItem.Exception -Target (Get-SqlString $SqlCommand)
        Stop-PSFFunction -Message "Stopping because of errors." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', ''))) -ErrorRecord $_
        return
    }
    finally {
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }
        
        $sqlCommand.Dispose()
    }
    
    [PSCustomObject]@{
        OldDatabaseNewName = "$dbToBeName"
    }
}


<#
    .SYNOPSIS
        Validate or show parameter set details with colored output
         
    .DESCRIPTION
        Analyze a function and it's parameters
         
        The cmdlet / function is capable of validating a string input with function name and parameters
         
    .PARAMETER CommandText
        The string that you want to analyze
         
        If there is parameter value present, you have to use the opposite quote strategy to encapsulate the string correctly
         
        E.g. for double quotes
        -CommandText 'Import-D365Bacpac -ImportModeTier2 -SqlUser "sqladmin" -SqlPwd "XyzXyz" -BacpacFile2 "C:\temp\uat.bacpac"'
         
        E.g. for single quotes
        -CommandText "Import-D365Bacpac -ExportModeTier2 -SqlUser 'sqladmin' -SqlPwd 'XyzXyz' -BacpacFile2 'C:\temp\uat.bacpac'"
         
    .PARAMETER Mode
        The operation mode of the cmdlet / function
         
        Valid options are:
        - Validate
        - ShowParameters
         
    .PARAMETER SplatInput
        Pass in your hashtable that you use for your command execution and have it validated
         
    .PARAMETER ShowSplatStyleV1
        Include an hashtable splatting for all parameter sets in the output
         
        The example is built like this:
        PS C:\> $params = @{}
        PS C:\> $params.PropertyName = "SAMPLEVALUE"
        PS C:\> Test-FakeCommand @params
         
         
    .PARAMETER ShowSplatStyleV2
        Include an hashtable splatting for all parameter sets in the output
         
        The example is built like this:
        PS C:\> $params = @{
        PS C:\> PropertyName = "SAMPLEVALUE"
        PS C:\> }
        PS C:\> Test-FakeCommand @params
         
    .PARAMETER IncludeHelp
        Switch to instruct the cmdlet / function to output a simple guide with the colors in it
         
    .EXAMPLE
        PS C:\> Test-D365Command -CommandText 'Import-D365Bacpac -ImportModeTier2 -SqlUser "sqladmin" -SqlPwd "XyzXyz" -BacpacFile2 "C:\temp\uat.bacpac"' -Mode "Validate" -IncludeHelp
         
        This will validate all the parameters that have been passed to the Import-D365Bacpac cmdlet.
        All supplied parameters that matches a parameter will be marked with an asterisk.
        Will print the coloring help.
         
    .EXAMPLE
        PS C:\> Test-D365Command -CommandText 'Import-D365Bacpac' -Mode "ShowParameters" -IncludeHelp
         
        This will display all the parameter sets and their individual parameters.
        Will print the coloring help.
         
    .EXAMPLE
        PS C:\> $params = @{}
        PS C:\> $params.DatabaseName = "SAMPLEVALUE"
        PS C:\> Test-D365Command -CommandText 'Import-D365Bacpac -ImportModeTier2' -SplatInput $params -Mode "Validate"
         
        This builds a hashtable with a property names "DatabaseName".
        The hashtable is passed to the cmdlet to be part of the validation.
         
    .NOTES
        Author: M�tz Jensen (@Splaxi)
         
#>

function Test-D365Command {
    [CmdletBinding()]
    
    param (
        [Parameter(Mandatory = $true, Position = 1)]
        [string] $CommandText,

        [Parameter(Mandatory = $true, Position = 2)]
        [ValidateSet('Validate', 'ShowParameters')]
        [string] $Mode,

        [hashtable] $SplatInput,

        [switch] $ShowSplatStyleV1,

        [switch] $ShowSplatStyleV2,

        [switch] $IncludeHelp
    )

    $commonParameters = 'Verbose', 'Debug', 'ErrorAction', 'WarningAction', 'InformationAction', 'ErrorVariable', 'WarningVariable', 'InformationVariable', 'OutVariable', 'OutBuffer', 'PipelineVariable', 'Confirm', 'WhatIf'
    
    $colorParmsNotFound = "Red"
    $colorCommandName = "Green"
    $colorMandatoryParam = "Yellow"
    $colorNonMandatoryParam = "DarkGray"
    $colorFoundAsterisk = "Green"
    $colorNotFoundAsterisk = "Magenta"
    $colParmValue = "DarkCyan"
    $colorEqualSign = "DarkGray"
    $colorVariable = "Green"
    $colorProperty = "White"
    $colorCommandNameSplat = "Yellow"
    $colorComment = "DarkGreen"

    if(-not ($null -eq $SplatInput)) {
        $CommandText = "$CommandText "+ $(($SplatInput.Keys | ForEach-Object {"-$($_) `"$($SplatInput.Item($_))`""}) -Join " " )
    }

    #Match to find the command name: Non-Whitespace until the first whitespace
    $commandMatch = ($CommandText | Select-String '\S+\s*').Matches

    if ($null -eq $commandMatch) {
        Write-PSFMessage -Level Host -Message "The function was unable to extract a valid command name from the supplied command text. Please try again."
        Stop-PSFFunction -Message "Stopping because of missing command name."
        return
    }

    $commandName = $commandMatch.Value.Trim()

    $res = Get-Command $commandName -ErrorAction Ignore

    if ($null -eq $res) {
        Write-PSFMessage -Level Host -Message "The function was unable to get the help of the command. Make sure that the command name is valid and try again."
        Stop-PSFFunction -Message "Stopping because command name didn't return any help."
        return
    }

    $sbHelp = New-Object System.Text.StringBuilder
    $sbParmsNotFound = New-Object System.Text.StringBuilder
    $sbSplatStyleV1 = New-Object System.Text.StringBuilder
    $sbSplatStyleV2 = New-Object System.Text.StringBuilder

    switch ($Mode) {
        "Validate" {
            #Match to find the parameters: Whitespace Dash Non-Whitespace
            $inputParameterMatch = ($CommandText | Select-String '\s{1}[-]\S+' -AllMatches).Matches
                    
            if (-not ($null -eq $inputParameterMatch)) {
                $inputParameterNames = $inputParameterMatch.Value.Trim("-", " ")
                Write-PSFMessage -Level Verbose -Message "All input parameters - $($inputParameterNames -join ",")" -Target ($inputParameterNames -join ",")
            }
            else {
                Write-PSFMessage -Level Host -Message "The function was unable to extract any parameters from the supplied command text. Please try again."
                Stop-PSFFunction -Message "Stopping because of missing input parameters."
                return
            }

            $availableParameterNames = (Get-Command $commandName).Parameters.keys | Where-Object {$commonParameters -NotContains $_}
            Write-PSFMessage -Level Verbose -Message "Available parameters - $($availableParameterNames -join ",")" -Target ($availableParameterNames -join ",")

            $inputParameterNotFound = $inputParameterNames | Where-Object {$availableParameterNames -NotContains $_}

            if ($inputParameterNotFound.Length -gt 0) {
                $null = $sbParmsNotFound.AppendLine("Parameters that <c='em'>don't exists</c>")
                $inputParameterNotFound | ForEach-Object {
                    $null = $sbParmsNotFound.AppendLine("<c='$colorParmsNotFound'>$($_)</c>")
                }
            }

            foreach ($parmSet in (Get-Command $commandName).ParameterSets) {
                $null = $sb = New-Object System.Text.StringBuilder
                $null = $sb.AppendLine("ParameterSet Name: <c='em'>$($parmSet.Name)</c> - Validated List")
                $null = $sb.Append("<c='$colorCommandName'>$commandName </c>")

                $parmSetParameters = $parmSet.Parameters | Where-Object name -NotIn $commonParameters
        
                foreach ($parameter in $parmSetParameters) {
                    $parmFoundInCommandText = $parameter.Name -In $inputParameterNames
                            
                    $color = "$colorNonMandatoryParam"
        
                    if ($parameter.IsMandatory -eq $true) { $color = "$colorMandatoryParam" }
        
                    $null = $sb.Append("<c='$color'>-$($parameter.Name)</c>")
        
                    if ($parmFoundInCommandText) {
                        $null = $sb.Append("<c='$colorFoundAsterisk'>* </c>")
                    }
                    elseif ($parameter.IsMandatory -eq $true) {
                        $null = $sb.Append("<c='$colorNotFoundAsterisk'>* </c>")
                    }
                    else {
                        $null = $sb.Append(" ")
                    }
        
                    if (-not ($parameter.ParameterType -eq [System.Management.Automation.SwitchParameter])) {
                        $null = $sb.Append("<c='$colParmValue'>PARAMVALUE </c>")
                    }
                }
        
                $null = $sb.AppendLine("")
                Write-PSFHostColor -String "$($sb.ToString())"
            }

            $null = $sbHelp.AppendLine("")
            $null = $sbHelp.AppendLine("<c='$colorParmsNotFound'>$colorParmsNotFound</c> = Parameter not found")
            $null = $sbHelp.AppendLine("<c='$colorCommandName'>$colorCommandName</c> = Command Name")
            $null = $sbHelp.AppendLine("<c='$colorMandatoryParam'>$colorMandatoryParam</c> = Mandatory Parameter")
            $null = $sbHelp.AppendLine("<c='$colorNonMandatoryParam'>$colorNonMandatoryParam</c> = Optional Parameter")
            $null = $sbHelp.AppendLine("<c='$colParmValue'>$colParmValue</c> = Parameter value")
            $null = $sbHelp.AppendLine("<c='$colorFoundAsterisk'>*</c> = Parameter was filled")
            $null = $sbHelp.AppendLine("<c='$colorNotFoundAsterisk'>*</c> = Mandatory missing")
        }

        "ShowParameters" {
            foreach ($parmSet in (Get-Command $commandName).ParameterSets) {
                $sb = New-Object System.Text.StringBuilder
                $sbSplatStyleV1 = New-Object System.Text.StringBuilder
                $sbSplatStyleV2 = New-Object System.Text.StringBuilder

                $null = $sb.AppendLine("ParameterSet Name: <c='em'>$($parmSet.Name)</c> - Parameter List")
                $null = $sb.Append("<c='$colorCommandName'>$commandName </c>")
                
                $null = $sbSplatStyleV1.AppendLine("<c='$colorComment'>#Hashtable splatting style V1 - ParameterSet Name: </c><c='em'>$($parmSet.Name)</c>").AppendLine("<c='$colorVariable'>`$params</c> <c='$colorEqualSign'>=</c> <c='$colorProperty'>@{}</c>")
                $null = $sbSplatStyleV2.AppendLine("<c='$colorComment'>#Hashtable splatting style V2 - ParameterSet Name: </c><c='em'>$($parmSet.Name)</c>").AppendLine("<c='$colorVariable'>`$params</c> <c='$colorEqualSign'>=</c> <c='$colorProperty'>@{</c>")

                $parmSetParameters = $parmSet.Parameters | Where-Object name -NotIn $commonParameters
        
                foreach ($parameter in $parmSetParameters) {
                    $color = "$colorNonMandatoryParam"
                    $mandatoryComment = $null

                    if ($parameter.IsMandatory -eq $true) {
                        $color = "$colorMandatoryParam"

                        $mandatoryComment = " <c='$color'>#MANDATORY</c>"
                    }

                    $null = $sbSplatStyleV1.AppendLine("<c='$colorVariable'>`$params</c><c='$colorProperty'>.$($parameter.Name)</c> <c='$colorEqualSign'>=</c> <c='$colParmValue'>`"SAMPLEVALUE`"</c>$mandatoryComment")
                    $null = $sbSplatStyleV2.AppendLine("<c='$colorProperty'>$($parameter.Name)</c> <c='$colorEqualSign'>=</c> <c='$colParmValue'>`"SAMPLEVALUE`"</c>$mandatoryComment")
        
                    $null = $sb.Append("<c='$color'>-$($parameter.Name) </c>")
        
                    if (-not ($parameter.ParameterType -eq [System.Management.Automation.SwitchParameter])) {
                        $null = $sb.Append("<c='$colParmValue'>PARAMVALUE </c>")
                    }
                }
        
                $null = $sb.AppendLine("")
                $null = $sbSplatStyleV2.AppendLine("<c='$colorProperty'>}</c>")
                $null = $sbSplatStyleV1.AppendLine("<c='$colorCommandNameSplat'>$commandName</c> <c='$colorVariable'>@params</c>")
                $null = $sbSplatStyleV2.AppendLine("<c='$colorCommandNameSplat'>$commandName</c> <c='$colorVariable'>@params</c>")

                Write-PSFHostColor -String "$($sb.ToString())"
                
                if ($ShowSplatStyleV1) { Write-PSFHostColor -String "$($sbSplatStyleV1.ToString())" }
                if ($ShowSplatStyleV2) { Write-PSFHostColor -String "$($sbSplatStyleV2.ToString())" }
            }

            $null = $sbHelp.AppendLine("")
            $null = $sbHelp.AppendLine("<c='$colorCommandName'>$colorCommandName</c> = Command Name")
            $null = $sbHelp.AppendLine("<c='$colorMandatoryParam'>$colorMandatoryParam</c> = Mandatory Parameter")
            $null = $sbHelp.AppendLine("<c='$colorNonMandatoryParam'>$colorNonMandatoryParam</c> = Optional Parameter")
            $null = $sbHelp.AppendLine("<c='$colParmValue'>$colParmValue</c> = Parameter value")
        }
        Default {}
    }

    if ($sbParmsNotFound.ToString().Trim().Length -gt 0) {
        Write-PSFHostColor -String "$($sbParmsNotFound.ToString())"
    }
 
    if ($IncludeHelp) {
        Write-PSFHostColor -String "$($sbHelp.ToString())"
    }
}


<#
    .SYNOPSIS
        Test if the FlightingServiceCatalogID is present and filled out
         
    .DESCRIPTION
        Test if the FlightingServiceCatalogID element exists in the web.config file used by D365FO
         
    .PARAMETER AosServiceWebRootPath
        Path to the root folder where to locate the web.config file
         
    .EXAMPLE
        PS C:\> Test-D365FlightServiceCatalogId
         
        This will open the web.config and check if the FlightingServiceCatalogID element is present or not.
         
    .NOTES
        Tags: Flight, Flighting
         
        Author: M�tz Jensen (@Splaxi))
         
        The DataAccess.FlightingServiceCatalogID must already be set in the web.config file.
        https://docs.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/data-entities/data-entities-data-packages#features-flighted-in-data-management-and-enabling-flighted-features
#>


function Test-D365FlightServiceCatalogId {
    [CmdletBinding()]
    param (
        [string]$AosServiceWebRootPath = $Script:AOSPath
    )

    $res = @{}

    try {
        $WebConfigFile = Join-Path -Path $AosServiceWebRootPath -ChildPath $Script:WebConfig
        
        Write-PSFMessage -Level Verbose -Message "Retrieve the FlightingServiceCatalogID" -Target $WebConfigFile

        $FlightServiceNode = Select-Xml -XPath "/configuration/appSettings/add[@key='DataAccess.FlightingServiceCatalogID']/@value" -Path $WebConfigFile
        
        if($null -eq $FlightServiceNode){
            Write-PSFMessage -Level Host -Message "The <c='em'>DataAccess.FlightingServiceCatalogID</c> child element under the <c='em'>AppSettings</c> element is missing. See <c='em'>https://docs.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/data-entities/data-entities-data-packages#features-flighted-in-data-management-and-enabling-flighted-features</c> for details."
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }

        $res.FlightingServiceCatalogID = $FlightServiceNode.Node.Value
        
        Write-PSFMessage -Level Verbose -Message "FlightingServiceCatalogID: $FlightServiceId" -Target $WebConfigFile
    }
    catch {
        Write-PSFMessage -Level Host -Message "Something went wrong while reading from the web.config file" -Exception $PSItem.Exception
        Stop-PSFFunction -Message "Stopping because of errors"
        return
    }

    if(-not [System.String]::IsNullOrEmpty($res.FlightingServiceCatalogID)){
        [PsCustomObject]$res
    }
}


<#
    .SYNOPSIS
        Checks if a string is a valid 'Label Id' format
         
    .DESCRIPTION
        This function will validate if a string is a valid 'Label Id' format.
         
    .PARAMETER LabelId
        The LabelId string thay you want to validate
         
    .EXAMPLE
        PS C:> Test-D365LabelIdIsValid -LabelId "ABC123"
         
        This will test the if the LabelId is valid.
        It will use the "ABC123" as the LabelId parameter.
         
        The expected result is $true
         
    .EXAMPLE
        PS C:> Test-D365LabelIdIsValid -LabelId "@ABC123"
         
        This will test the if the LabelId is valid.
        It will use the "@ABC123" as the LabelId parameter.
         
        The expected result is $true
         
    .EXAMPLE
        PS C:> Test-D365LabelIdIsValid -LabelId "@ABC123_1"
         
        This will test the if the LabelId is valid.
        It will use the "@ABC123_1" as the LabelId parameter.
         
        The expected result is $false
         
    .EXAMPLE
        PS C:> Test-D365LabelIdIsValid -LabelId "ABC.123" #False
         
        This will test the if the LabelId is valid.
        It will use the "ABC.123" as the LabelId parameter.
         
        The expected result is $false
         
    .NOTES
        Author: Alex Kwitny (@AlexOnDAX)
         
        The intent of this function is to be used with other methods to create valid labels via scripting.
         
#>

function Test-D365LabelIdIsValid {
    [CmdletBinding()]
    [OutputType([bool])]
    param
    (
        [Parameter(Mandatory = $True)]
        [string] $LabelId
    )
    
    $RegexOptions = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Compiled -bor [System.Text.RegularExpressions.RegexOptions]::CultureInvariant

    $Matcher_New_LabelID = New-Object System.Text.RegularExpressions.Regex('(^[a-zA-Z_])([a-zA-Z\d_])*$', $RegexOptions)
    $Matcher_Legacy_LabelID = New-Object System.Text.RegularExpressions.Regex([System.String]::Format([System.IFormatProvider][System.Globalization.CultureInfo]::InvariantCulture, "^{0}{1}{2}$", [System.Object]'@', [System.Object]"[a-zA-Z]\w\w", [System.Object]"\d+"), $RegexOptions)
    $Matcher_New_Label_WithLabelFile = New-Object System.Text.RegularExpressions.Regex("(?<AtSign>\@)(?<LabelFileId>[a-zA-Z]\w*):(?<LabelId>[a-zA-Z]\w*)", $RegexOptions)

    if (!$LabelId) {
        $false
        return
    }

    if (!($Matcher_New_LabelID.IsMatch($LabelId)) -and !($Matcher_Legacy_LabelID.IsMatch($LabelId))) {
        $Matcher_New_Label_WithLabelFile.IsMatch($LabelId)
        return
    }

    $true
}


<#
    .SYNOPSIS
        Update the "model.xml" from the bacpac file to a single table
         
    .DESCRIPTION
        Update the "model.xml" file from inside the bacpac file to only handle a single table
         
        This can be used to restore a single table as fast as possible to a new data
         
        The table will be created like ordinary bacpac restore, expect it will only have the raw table definition and indexes, all other objects are dropped
         
        The output can be used directly with the Import-D365Bacpac cmdlet and its ModelFile parameter, see the example sections for more details
         
    .PARAMETER Path
        Path to the bacpac file that you want to work against
         
        It can also be a zip file
         
    .PARAMETER Table
        Name of the table that you want to be kept inside the model file when the update is done
         
    .PARAMETER Schema
        Schema where the table that you want to work against exists
         
        The default value is "dbo"
         
    .PARAMETER OutputPath
        Path to where you want the updated bacpac model file to be saved
         
        Default value is: "c:\temp\d365fo.tools"
         
    .PARAMETER Force
        Switch to instruct the cmdlet to overwrite the bacpac model file specified in the OutputPath
         
    .EXAMPLE
        PS C:\> Update-D365BacpacModelFileSingleTable -Path "c:\temp\d365fo.tools\bacpac.model.xml" -Table "SalesTable"
         
        This will create an updated bacpac.model.xml file with only the SalesTable to be imported.
        It will read the "c:\temp\d365fo.tools\bacpac.model.xml" file.
        It will use the default "dbo" as the Schema parameter.
        It will use the "SalesTable" as the Table parameter.
        It will use the "c:\temp\d365fo.tools\dbo.salestable.model.xml" as the default path for OutputPath parameter.
         
    .EXAMPLE
        PS C:\> Update-D365BacpacModelFileSingleTable -Path "c:\temp\d365fo.tools\bacpac.model.xml" -Table "CommissionSalesGroup" -Schema "AX"
         
        This will create an updated bacpac.model.xml file with only the "CommissionSalesGroup", from the "AX" schema, to be imported.
        It will read the "c:\temp\d365fo.tools\bacpac.model.xml" file.
        It will use the "AX" as the Schema for the table.
        It will use the "CommissionSalesGroup" as the Table parameter.
        It will use the "c:\temp\d365fo.tools\ax.CommissionSalesGroup.model.xml" as the default path for OutputPath parameter.
         
    .EXAMPLE
        PS C:\> Update-D365BacpacModelFileSingleTable -Path "c:\temp\d365fo.tools\bacpac.model.xml" -Table "SalesTable" -OutputPath "c:\temp\troubleshoot.xml"
         
        This will create an updated bacpac.model.xml file with only the SalesTable to be imported.
        It will read the "c:\temp\d365fo.tools\bacpac.model.xml" file.
        It will use the default "dbo" as the Schema parameter.
        It will use the "SalesTable" as the Table parameter.
        It will use the "c:\temp\troubleshoot.xml" as the path for OutputPath parameter.
         
    .EXAMPLE
        PS C:\> Export-D365BacpacModelFile -Path "c:\Temp\AxDB.bacpac" | Update-D365BacpacModelFileSingleTable -Table SalesTable
         
        This will create an updated bacpac.model.xml file with only the SalesTable to be imported.
        It will read the bacpac model file generated from the Export-D365BacpacModelFile cmdlet.
        It will use the default "dbo" as the Schema parameter.
        It will use the "SalesTable" as the Table parameter.
        It will use the "c:\temp\d365fo.tools\dbo.salestable.model.xml" as the default path for OutputPath parameter.
         
    .EXAMPLE
        PS C:\> Update-D365BacpacModelFileSingleTable -Path "c:\temp\d365fo.tools\bacpac.model.xml" -Table "SalesTable" -Force
         
        This will create an updated bacpac.model.xml file with only the SalesTable to be imported.
        It will read the "c:\temp\d365fo.tools\bacpac.model.xml" file.
        It will use the default "dbo" as the Schema parameter.
        It will use the "SalesTable" as the Table parameter.
        It will use the "c:\temp\d365fo.tools\dbo.salestable.model.xml" as the default path for OutputPath parameter.
         
        It will overwrite the "c:\temp\d365fo.tools\dbo.salestable.model.xml" if it already exists.
         
    .NOTES
        Tags: Bacpac, Servicing, Data, SqlPackage, Import, Table, Troubleshooting
         
        Author: M�tz Jensen (@Splaxi)
         
#>


function Update-D365BacpacModelFileSingleTable {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('ModelFile')]
        [Alias('File')]
        [string] $Path,

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

        [string] $Schema = "dbo",

        [string] $OutputPath = $Script:DefaultTempPath,

        [switch] $Force
    )
    
    begin {
        Invoke-TimeSignal -Start

        if ([System.IO.File]::GetAttributes($OutputPath).HasFlag([System.IO.FileAttributes]::Directory)) {
            $OutputPath = Join-Path -Path $OutputPath -ChildPath "$Schema.$Table.model.xml"
        }
        
        if (-not $Force) {
            if ((-not (Test-PathExists -Path $OutputPath -Type Leaf -ShouldNotExist -ErrorAction SilentlyContinue -WarningAction SilentlyContinue))) {
                Write-PSFMessage -Level Host -Message "The <c='em'>$OutputPath</c> already exists. Consider changing the <c='em'>OutputPath</c> path or set the <c='em'>Force</c> parameter to overwrite the file."
                return
            }
        }

        if (Test-PSFFunctionInterrupt) { return }

        if ($Schema -NotLike "[*]") {
            $Schema = "[$Schema]"
        }
        
        if ($Table -NotLike "[*]") {
            $Table = "[$Table]"
        }
    }

    process {
        if (-not (Test-PathExists -Path $Path -Type Leaf)) { return }

        if (Test-PSFFunctionInterrupt) { return }
        
        $xmlFile = [System.Xml.XmlReader]::Create($Path)

        $settings = [System.Xml.XmlWriterSettings]::new()
        $settings.Indent = $true
        $settings.Encoding = [System.Text.UTF8Encoding]::new($false)
        $outFile = [System.Xml.XmlWriter]::Create($OutputPath, $settings)

        while ($xmlFile.Read()) {

            if ($xmlFile.NodeType -in "XmlDeclaration", "ProcessingInstruction") {
                $outFile.WriteProcessingInstruction($xmlFile.Name, $xmlFile.Value)
            }
            elseif ($xmlFile.NodeType -eq "Element" -and $xmlFile.Name -eq "DataSchemaModel") {
                $outFile.WriteStartElement($xmlFile.name, "http://schemas.microsoft.com/sqlserver/dac/Serialization/2012/02")
                if ($xmlFile.HasAttributes) {
                    while ($xmlFile.MoveToNextAttribute()) {
                        if ($xmlFile.name -ne "xmlns") {
                            $outFile.WriteAttributeString($xmlFile.Name, $xmlFile.Value)
                        }
                    }
                }
            }
            elseif ($xmlFile.NodeType -eq "Element" -and $xmlFile.Name -eq "Model") {
                $outFile.WriteStartElement($xmlFile.name)
            }
            elseif ($xmlFile.NodeType -eq "Element" -and $xmlFile.Depth -eq 2) {
                $rawElement = $($xmlFile.ReadOuterXml() -replace 'xmlns=".*"', '')
                
                if (-not $($rawElement -match 'Type="(?<Type>.*?)".*?>')) {
                    continue
                }

                if ($Matches.Type -NotIn "SqlSchema", "SqlTable", "SqlDatabaseOptions", "SqlGenericDatabaseScopedConfigurationOptions", "SqlIndex", "SqlPrimaryKeyConstraint") {
                    continue
                }
        
                if ($Matches.Type -eq "SqlSchema") {
                    if (-not $($rawElement -match 'Name="(?<Name>.*?)".*?>')) {
                        continue
                    }
        
                    if ($Matches.Name -ne $Schema) {
                        continue
                    }

                    Write-PSFMessage -Level Verbose -Message "SqlSchema found" -Target $rawElement
                }

                if ($Matches.Type -eq "SqlTable") {
                    if (-not $($rawElement -match 'Name="(?<Name>.*?)".*?>')) {
                        continue
                    }
                    
                    if ($Matches.Name -ne "$Schema.$Table") {
                        continue
                    }
        
                    Write-PSFMessage -Level Verbose -Message "SqlTable found" -Target $rawElement

                    $rawElement = $rawElement -replace "\s*.*<AttachedAnnotation Disambiguator=`".*`".*/>", ""
                }
                
                if ($Matches.Type -eq "SqlIndex") {
                    if (-not $($rawElement -match 'Name="(?<Name>.*?)".*?>')) {
                        continue
                    }
                    
                    if (-not $Matches.Name.StartsWith("$Schema.$Table", [System.StringComparison]::InvariantCultureIgnoreCase)) {
                        continue
                    }

                    Write-PSFMessage -Level Verbose -Message "SqlIndex found" -Target $rawElement
                }

                if ($Matches.Type -eq "SqlPrimaryKeyConstraint") {
                    if (-not $(([System.Xml.XmlDocument]$rawElement).SelectSingleNode("//Relationship[@Name='DefiningTable']/Entry/References/@Name")."#text").Equals("$Schema.$Table", [System.StringComparison]::InvariantCultureIgnoreCase)) {
                        continue
                    }

                    Write-PSFMessage -Level Verbose -Message "SqlPrimaryKeyConstraint found" -Target $rawElement
                }
                
                $outFile.WriteRaw($rawElement)
            }
            else {
                if ($xmlFile.NodeType -eq "EndElement" -and $xmlFile.Name -in "Model", "DataSchemaModel") {
                    $outFile.WriteEndElement()
                }
            }
        }

        [PSCustomObject]@{
            File     = $OutputPath
            Filename = $(Split-Path -Path $OutputPath -Leaf)
        }
    }
    
    end {
        if ($outFile) {
            $outFile.Flush()
            $outFile.Close()
            $outFile.Dispose()
        }

        if ($xmlFile) {
            $xmlFile.Close()
            $xmlFile.Dispose()
        }

        Invoke-TimeSignal -End
    }
}


<#
    .SYNOPSIS
        Updates the user details in the database
         
    .DESCRIPTION
        Is capable of updating all the user details inside the UserInfo table to enable a user to sign in
         
    .PARAMETER DatabaseServer
        The name of the database server
         
        If on-premises or classic SQL Server, use either short name og Fully Qualified Domain Name (FQDN).
         
        If Azure use the full address to the database server, e.g. server.database.windows.net
         
    .PARAMETER DatabaseName
        The name of the database
         
    .PARAMETER SqlUser
        The login name for the SQL Server instance
         
    .PARAMETER SqlPwd
        The password for the SQL Server user
         
    .PARAMETER Email
        The search string to select which user(s) should be updated.
         
        The parameter supports wildcards. E.g. -Email "*@contoso.com*"
         
    .PARAMETER Company
        The company the user should start in.
         
    .EXAMPLE
        PS C:\> Update-D365User -Email "claire@contoso.com"
         
        This will search for the user with the e-mail address claire@contoso.com and update it with needed information based on the tenant owner of the environment
         
    .EXAMPLE
        PS C:\> Update-D365User -Email "*contoso.com"
         
        This will search for all users with an e-mail address containing 'contoso.com' and update them with needed information based on the tenant owner of the environment
         
    .NOTES
        Author: Rasmus Andersen (@ITRasmus)
        Author: M�tz Jensen (@Splaxi)
         
#>

function Update-D365User {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [string]$DatabaseServer = $Script:DatabaseServer,

        [string]$DatabaseName = $Script:DatabaseName,

        [string]$SqlUser = $Script:DatabaseUserName,

        [string]$SqlPwd = $Script:DatabaseUserPassword,

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

        [string]$Company

    )
    begin {
        Invoke-TimeSignal -Start
        
        $UseTrustedConnection = Test-TrustedConnection $PSBoundParameters

        $SqlParams = @{ DatabaseServer = $DatabaseServer; DatabaseName = $DatabaseName;
            SqlUser = $SqlUser; SqlPwd = $SqlPwd
        }

        $SqlCommand = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection
        
        $sqlCommand_Update = Get-SqlCommand @SqlParams -TrustedConnection $UseTrustedConnection

        try {
            $sqlCommand.Connection.Open()

            $sqlCommand_Update.Connection.Open()
        }
        catch {
            Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }
    }

    process {
        $sqlCommand.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\get-user.sql") -join [Environment]::NewLine

        $null = $sqlCommand.Parameters.Add("@Email", $Email.Replace("*", "%"))

        $sqlCommand_Update.CommandText = (Get-Content "$script:ModuleRoot\internal\sql\update-user.sql") -join [Environment]::NewLine

        try {
            Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $SqlCommand)
        
            $reader = $sqlCommand.ExecuteReader()

            while ($reader.Read() -eq $true) {
                Write-PSFMessage -Level Verbose -Message "Building the update statement with the needed details."

                $userId = "$($reader.GetString($($reader.GetOrdinal("ID"))))"
                $networkAlias = "$($reader.GetString($($reader.GetOrdinal("NETWORKALIAS"))))"

                $userAuth = Get-D365UserAuthenticationDetail $networkAlias

                $null = $sqlCommand_Update.Parameters.AddWithValue("@id", $userId)
                $null = $sqlCommand_Update.Parameters.AddWithValue("@networkDomain", $userAuth["NetworkDomain"])
                $null = $sqlCommand_Update.Parameters.AddWithValue("@sid", $userAuth["SID"])
                $null = $sqlCommand_Update.Parameters.AddWithValue("@identityProvider", $userAuth["IdentityProvider"])

                $null = $sqlCommand_Update.Parameters.AddWithValue("@Company", $Company)

                Write-PSFMessage -Level InternalComment -Message "Executing a script against the database." -Target (Get-SqlString $sqlCommand_Update)

                $null = $sqlCommand_Update.ExecuteNonQuery()

                $sqlCommand_Update.Parameters.Clear()
            }
        }
        catch {
            Write-PSFMessage -Level Host -Message "Something went wrong while working against the database" -Exception $PSItem.Exception
            Stop-PSFFunction -Message "Stopping because of errors"
            return
        }
        finally {
            $reader.close()
            $sqlCommand.Parameters.Clear()
        }
    }
    
    end {
        if ($sqlCommand_Update.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand_Update.Connection.Close()
        }

        $sqlCommand_Update.Dispose()
    
        if ($sqlCommand.Connection.State -ne [System.Data.ConnectionState]::Closed) {
            $sqlCommand.Connection.Close()
        }

        $sqlCommand.Dispose()

        Invoke-TimeSignal -End
    }
}