roe.Misc.psm1

function Build-PowerShellModule {

    <#
        .SYNOPSIS
        Builds PowerShell module from functions found in given path

        .DESCRIPTION
        Takes function from seperate files in FunctionsPath and builds a PowerShell module to ModuleDir.
        Existing module files in ModuleDir will be overwritten.

        FunctionsPath must include one separate file for each function to include.
        The full content of the file will be included as a function. If a Function declaration is not found as the first line, it will be automatically created.

        Build version for module is incrementeted by 1 on each build, relative to version found in ManifestFile.
        

        .PARAMETER FunctionsPath
        Path to individual functions files

        .PARAMETER ModuleDir
        Directory to export finished module to

        .PARAMETER ModuleName
        Name of module. If omitted name will be taken from Manifest. If no manifest can be found, name will be autogenerated as Module-yyyyMMdd

        .PARAMETER ManifestFile
        Path to .psd1 file to use as template. If omitted the first .psd1 file found in ModuleDir will be used. If no file can be found a default will be created

        .PARAMETER Description
        Description of module

        .PARAMETER RunScriptAtImport
        Script to run when module is imported. Will be added to .psm1 as-is

    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateScript({Test-Path -path $_})]
        [String]$FunctionsPath,
        [Parameter(Mandatory = $true)]
        [string]$ModuleDir,
        [Parameter(Mandatory = $false)]
        [string]$ModuleName,
        [Parameter(Mandatory = $false)]
        [String]$ManifestFile,
        [Parameter(Mandatory = $false)]
        [String]$Description,
        [Parameter(Mandatory = $false)]
        [String]$RunScriptAtImport,
        [Parameter(Mandatory = $false)]
        [switch]$RetainVersion
    )

    $ErrorActionPreference = 'Stop'

    switch ($ModuleDir) {
        {-not (Test-Path -Path $_ -ErrorAction SilentlyContinue)}                       {Write-Verbose "Creating $_" ; $null = New-Item -Path $_ -ItemType Directory ; break}
        {-not (Get-Item -Path $ModuleDir -ErrorAction SilentlyContinue).PSIsContainer}  {Throw "$ModuleDir is not a valid directory path"}
        default                                                                         {}
    }

    # Make sure we have a valid Manifest file
    if ([string]::isnullorwhitespace($ManifestFile)) {
        Write-Verbose "Getting first manifest file from $ModuleDir"
        $ManifestFile = (Get-Item -Path (Join-Path -Path $ModuleDir -ChildPath "*.psd1") | Select-Object -First 1).FullName
    }

    if ([string]::isnullorwhitespace($ManifestFile)) {
        if ([string]::IsNullOrWhiteSpace($ModuleName)) {
            Write-Verbose "Generating Modulename"
            $ModuleName = "Module-$(Get-Date -format yyyyMMdd)"
        }
        Write-Verbose "Creating default manifestfile"
        $ManifestFile = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psd1"
        New-ModuleManifest -Path $ManifestFile -ModuleVersion 0.0.0
    }
    
    # Get content of manifest
    $ManifestHash = [ScriptBlock]::Create((Get-Content -Path $ManifestFile -Raw)).InvokeReturnAsIs()

    # Make sure destination files will end up in the ModuleDir
    if ([string]::isnullorwhitespace($ModuleName)) {
        Write-Verbose "Naming module after $Manifestfile"
        $ModuleName = split-path -path $ManifestFile -LeafBase    
    }

    $ManifestFile   = Join-Path -path $moduleDir -childPath "$ModuleName.psd1" 
    $moduleFile     = Join-Path -Path $ModuleDir -ChildPath "$ModuleName.psm1"

    $ManifestHash["RootModule"] = "$ModuleName.psm1"
    if (-not [String]::isnullorwhitespace($Description)) {
        Write-Verbose "Adding description to manifest"
        $ManifestHash["Description"] = $Description
    }

    [Version]$Version = $manifestHash["ModuleVersion"]
    if (-not $RetainVersion) {
        # Increment version number
        
        if ($Version.Minor -eq 9 -and $Version.Build -eq 9) {
            Write-Verbose "Incrementing major version"
            [version]$Version = "$($version.Major + 1).0.0"
        }
        elseif ($Version.Build -lt 9) {
            Write-Verbose "Incrementing build version"
            [version]$Version = "$($Version.Major).$($Version.Minor).$($Version.Build + 1)"
        }
        elseif ($Version.Minor -lt 9 -and $version.build -eq 9){
            Write-Verbose "Incrementing minor version"
            [Version]$Version = "$($Version.Major).$($Version.Minor + 1).0"
        }
        else {
            Write-Warning "WTF?"
        }
    }
    else {
        Write-Verbose "Retaining version: $($Version)"
    }
    # Get content of PS1 files in FunctionsPath
    $moduleContent = @(
        Get-Item -ErrorAction SilentlyContinue -Path (Join-Path -Path $FunctionsPath -childPath "*.ps1") | Sort-Object -Property BaseName | ForEach-Object -Process {
            Write-Verbose "Processing functionfile: $_"
            $FunctionsToExport += @($_.BaseName)
            $FunctionContent = Get-Content -Path $_ | Where-Object {-not [String]::isnullorwhitespace($_)}
            if ($FunctionContent[0].trim() -match "^Function") {
                Write-Verbose "Detected function"
                Get-Content -Path $_ -Raw
            }
            else {
                Write-Verbose "Adding function declaration"
                $Return = @()
                $Return += "Function $($_.BaseName) {"
                $Return += Get-Content -Path $_ -Raw
                $Return += "}"
                $Return
            }
        }
    )

  
    if (-not [String]::IsNullOrWhiteSpace($RunScriptAtImport)) {
        Write-Verbose "Adding RunScriptAtImport"
        $moduleContent += $RunScriptAtImport
    }
    
    Write-Verbose "Writing $Modulefile"
    Set-Content -Path $moduleFile -Value $moduleContent


    # Update manifest
    $ManifestHash["Path"]   = $manifestFile
    $ManifestHash["ModuleVersion"]        = $version
    
    $ManifestHash['FunctionsToExport']    = $functionsToExport 
    Write-Verbose "Updating manifest"
    Update-ModuleManifest @ManifestHash

    $props = [ordered]@{"ModuleName" = $ModuleName
                        "Version" = $Version
                        "Manifest" = (Get-Item -Path $manifestFile).FullName
                        "Module" = (Get-Item -Path $moduleFile).FullName
                        }
    return New-Object -TypeName PSObject -Property $props 

}
Function Clear-UserVariables {
    #Clear any variable not defined in the $SysVars variable
    if (Get-Variable -Name SysVars -ErrorAction SilentlyContinue) {
        $UserVars = get-childitem variable: | Where-Object {$SysVars -notcontains $_.Name} 
        ForEach ($var in $UserVars) {
            Write-Host ("Clearing $" + $var.name)
            Remove-Variable $var.name -Scope 'Global'
        }    
    }
    else {
        Write-Warning "SysVars variable not set"
        break
    }
}
Function Connect-EXOPartner {
    param(
        [parameter(Mandatory = $false)]
        [System.Management.Automation.CredentialAttribute()] 
        $Credential, 
        [parameter(Mandatory = $false)]
        [string] 
        $TenantDomain 
    )
    if (-not $TenantDomain) {
        $TenantDomain = Read-Host -Prompt "Input tenant domain, e.g. hosters.com"
    }
    if (-not $Credential) {
        $Credential = Get-Credential -Message "Credentials for CSP delegated admin, e.g. ""bm@klestrup.dk""/""password"""
    }
    $ExSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$TenantDomain" -Credential $Credential -Authentication Basic -AllowRedirection
    if ($ExSession) {Import-PSSession $ExSession}
}

Function ConvertTo-HashTable {
    <#
        .SYNOPSIS
        Converts PS Object, or JSON string, to Hashtable
    #>

    # https://stackoverflow.com/questions/3740128/pscustomobject-to-hashtable
    param (
        [Parameter(ValueFromPipeline)]
        $PSObject
    )
  
    process {
        if ($null -eq $PSObject -and $null -eq $JSON ) { 
            return $null 
        }
          
        if ($PSObject -is [string]) {
            try {
                $PSObject = $PSObject | ConvertFrom-Json
            }
            catch {
                [string]$PSObject = $PSObject #Uncast string parameter is both type [psobject] and [string]
            }
        }
  
        if ($PSObject -is [Hashtable] -or $PSObject -is [System.Collections.Specialized.OrderedDictionary]) {
            return $PSObject
        }

        if ($PSObject -is [System.Collections.IEnumerable] -and $PSObject -isnot [string]) {
            $collection = @(
                foreach ($object in $PSObject) { ConvertTo-HashTable $object }
            )
            Write-Output -NoEnumerate $collection
        }
        elseif ($PSObject -is [psobject]) {
            $hash = [ordered]@{}
            foreach ($property in $PSObject.PSObject.Properties) {
                $hash[$property.Name] = ConvertTo-HashTable $property.Value
            }
            return $hash
        }
        else {
            return $PSObject
        }
    }
} 

function Find-NugetPackage {
    <#
        .SYNOPSIS
        Finds URL for NuGet package in PSGallery

        .DESCRIPTION
        Finds, and optionally downloads, URL for NuGet packages in PSGallery.
        Can be used as substitute for Install-Module in circumstances where it is not possible to install the required PackageProviders.

        .PARAMETER Name
        Name of module to find

        .Parameter All
        Get all versions of module

        .PARAMETER Version
        Get specific version of module

        .Parameter IncludeDependencies
        Get info for all dependecies for module (doesn't work with -All)

        .Parameter KeepDuplicateDependencies
        Don't clean up output, if several versions of the same module is named as dependency. If omitted only the newest version of depedency modules will be returned.

        .Parameter DownloadTo
        Download found modules to path
    #>

    # Moddified from https://www.powershellgallery.com/packages/BuildHelpers/2.0.16/Content/Public%5CFind-NugetPackage.ps1
    
    [CMDLetbinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [String]$Name,
        [Parameter(Mandatory = $false)]
        [switch]$All,
        [Parameter(Mandatory = $false)]
        [string]$Version,
        [Parameter(Mandatory = $false)]
        [switch]$IncludeDependencies,
        [Parameter(Mandatory = $false)]
        [switch]$KeepDuplicateDependencies,
        [Parameter(Mandatory = $false)]
        [String]$DownloadTo
    )
    # Return stuff in this
    $Result = @()

    $PackageSourceUrl = "https://www.powershellgallery.com/api/v2/"

    #Figure out which version to find
    if ($PSBoundParameters.ContainsKey("Version")) {
        Write-Verbose "Searching for version [$version] of [$name]"
        $URI = "${PackageSourceUrl}Packages?`$filter=Id eq '$name' and Version eq '$Version'"
    }
    elseif ($PSBoundParameters.ContainsKey("All")) {
        Write-Verbose "Searching for all versions of [$name] module"
        $URI = "${PackageSourceUrl}Packages?`$filter=Id eq '$name'"
    }
    else {
        Write-Verbose "Searching for latest [$name] module"
        $URI = "${PackageSourceUrl}Packages?`$filter=Id eq '$name' and IsLatestVersion"
    }



    $NUPKG = @(Invoke-RestMethod $URI -Verbose:$false) 
    if ($Null -eq $NUPKG) {
        Write-Warning "No result for module $Name"
    }
    foreach ($pkg in $NUPKG) {
        $PkgDependencies = $pkg.properties.Dependencies.split("|")
        $Dependencies = @()
        foreach ($Dependency in ($PkgDependencies | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) {
            # Dependency will be formatted similar to (the lonely [ is not a typo):
            # Az.Accounts:[2.7.1, ):
            try {
                $DepName = $Dependency.split(":")[0]
                $DepVersion = (($Dependency.split(":")[1].split(",")[0] | Select-String -Pattern "\d|\." -AllMatches).Matches | ForEach-Object { $_.Value }) -join ""
                $Dependencies += New-Object -TypeName PSObject -property ([ordered]@{"Module" = $DepName; "Version" = $DepVersion })
            }
            catch {
                Write-Warning $_.Exception.Message
                throw $_ 
            }
        }

        # Not sure in which cases NormalizedVersion and Version will differ, but the original author made a distinction so so do I
        # And in case of -preview version numbers or similar, move the -preview part to the module name, so we can treat the rest of the version as a [version]. Not perfect but best we can do.
        [string]$PkgVersion =   if ($pkg.properties.NormalizedVersion) {
                                    $pkg.properties.NormalizedVersion
                                }
                                else {
                                    $pkg.properties.Version
                                }
        if ($pkgVersion -match "-") {
            [version]$PkgVersion = ($pkg.properties.NormalizedVersion | Select-String -AllMatches -Pattern "\d|\." | ForEach-Object { $_.Matches.value }) -join ""
            $pkgName = "$($pkg.title.('#text'))-$($pkg.properties.NormalizedVersion.split("-",2)[-1])"
        }
        else {
            [version]$PkgVersion = $PkgVersion
            $pkgName = $pkg.title.('#text')
        }


        $Props = [ordered]@{"Name"          = $PkgName
                            "Author"        = $pkg.author.name
                            "Version"       = $pkgVersion
                            "URI"           = $pkg.content.src
                            "Description"   = $pkg.properties.Description
                            "Properties"    = $pkg.properties
                            "NuGet"         = $pkg
                            "Dependencies"  = $Dependencies
                        }
        Write-Verbose "Finished collecting for $($pkgName) $($pkgVersion)"
        $Result += New-Object -TypeName PSObject -Property $Props

    }

    if ($IncludeDependencies) {
        # When function is called by itself, get a list of already retrieved dependencies, so we don't waste time getting info for the same module multiple times unless there is a requirement for a newer version
        $CurrentDependencies = [ordered]@{}
        foreach ($dep in $Dependencies ) {
            $CurrentDependencies.add($dep.Module, $dep.Version)
        }

        try {
            # If we can retrieve $KnownDependencies from parent scope this is most likely a nested run. Maybe use $Script scope... need testing
            try {
                $KnownDependencies = Get-Variable -Name KnownDependencies -ValueOnly -Scope 1
            }
            catch {
            }
            $GetDependencies = @{}
            foreach ($key in $CurrentDependencies.Keys) {
                if ($KnownDependencies[$key] -lt $CurrentDependencies[$key]) {
                    $KnownDependencies[$key]    = $CurrentDependencies[$key]
                    $GetDependencies[$key]      = $CurrentDependencies[$key]
                }
            }
            # Now we have updated $KnownDependencies with any new versions, so return it to parent for the next modules dependency check
            Set-Variable -Name KnownDependencies -Value $KnownDependencies -Scope 1
        }
        catch {
            # If we cant retrieve $KnownDependencies from parent scope, this is most likely first iteration of function
            # Save a list of dependencymodules we already know we need to get.
            $KnownDependencies  = $CurrentDependencies
            $GetDependencies    = $KnownDependencies
        }

        # For some reason a "Collection was modified" error is thrown if referencing the keys directly.
        $GetModules = $GetDependencies.keys | ForEach-Object {$_.ToString()}
        foreach ($module in $GetModules) {
            if ($Module -eq "Az.Accounts") {
            Write-Verbose "Finding dependency for $($pkgName) $($Pkgversion): $($module) $($GetDependencies[$module])"
            }

            $Result += Find-Nugetpackage -Name $module -Version $GetDependencies[$module] -IncludeDependencies -KeepDuplicateDependencies:$PSBoundParameters.ContainsKey("KeepDuplicateDependencies")
        }
    }
    if (-not $KeepDuplicateDependencies) {
        # Only keep latest version of each dependecy
        $DuplicateModules = $Result | Group-Object -Property Name | Where-Object {$_.Count -gt 1} | Select-object -ExpandProperty Name 
        Write-Verbose "$($DuplicateModules.count) duplicate dependency-module versions found"
        $RemoveVersions = @()
        foreach ($Module in $DuplicateModules) {
            $RemoveVersions += $Result | Where-Object {$_.Name -eq $Module} | Sort-Object -Property Version -Descending | Select-object -Skip 1
            Write-Verbose "Removing duplicates of $Module"
        }
        $Result = $Result | Where-Object {$_ -notin $RemoveVersions}
    }

    if (-not [string]::IsNullOrWhiteSpace($DownloadTo)) {
        if (-not (Test-Path -Path $DownloadTo -ErrorAction SilentlyContinue)) {
            $null = New-Item -Path $DownloadTo -Force -ItemType Directory
        }
        $DownloadCounter = 1
        foreach ($Module in $Result) {
            $ZipFile = Join-Path $DownloadTo -ChildPath $($Module.Name) -AdditionalChildPath "$($Module.version).zip"
            $ExtractDir = $ZipFile.replace(".zip", "")
            if (-not (Test-Path -Path $ExtractDir -ErrorAction SilentlyContinue)) {
                $null = New-Item -Path $ExtractDir -ItemType Directory -Force
            }
            Write-Verbose "$($DownloadCounter)/$($Result.count) : Downloading $($Module.Name) $($Module.Version) to $ZipFile"
            Invoke-WebRequest -Uri $Module.Uri -OutFile $ZipFile
            Expand-Archive -Path $ZipFile -DestinationPath $ExtractDir -Force
            Remove-Item -Path $ZipFile
            $DownloadCounter++
        }
    }
    return $Result
}

Function Get-COMObjects {
    if (-not $IsLinux) {
        $Objects = Get-ChildItem HKLM:\Software\Classes -ErrorAction SilentlyContinue | Where-Object {$_.PSChildName -match '^\w+\.\w+$' -and (Test-Path -Path "$($_.PSPath)\CLSID")}
        $Objects | Select-Object -ExpandProperty PSChildName
    }
}

Function Get-MatchingString {
    param(
        [string]$Text,
        [string]$RegEx,
        [switch]$CaseSensitive
    )
    $Text | Select-String -Pattern $RegEx -AllMatches -CaseSensitive:$CaseSensitive | ForEach-Object {$_.Matches.Value}
}
Function Get-NetIPAdapters {
    Param(
    [Parameter(Mandatory = $false)]
        [String[]]$ComputerName
    )
    if ($ComputerName.length -lt 1 -or $computername.Count -lt 1) {
        $computername = @($env:COMPUTERNAME)
    }
    $OutPut = @()
    #Vis netkort med tilhørende IP adr.
    foreach ($pc in $Computername) {
        $OutPut += Get-NetAdapter -CimSession $pc | Select-Object Name,InterfaceDescription,IfIndex,Status,MacAddress,LinkSpeed,@{N="IPv4";E={(Get-NetIpaddress -CimSession $pc -InterfaceIndex $_.ifindex -AddressFamily IPv4 ).IPAddress}},@{N="IPv6";E={(Get-NetIpaddress -CimSession $pc -InterfaceIndex $_.ifindex -AddressFamily IPv6 ).IPAddress}},@{N="Computer";E={$pc}} | Sort-Object -Property Name
    }
    $OutPut
}

Function Get-NthDayOfMonth {
    <#
    .SYNOPSIS
    Returns the Nth weekday of a specified month
    

    .DESCRIPTION
    Returns the Nth weekday of a specified month.
    If no weekday is specified an array is returned containing dates for the 7 weekdays throughout the specified month.
    If no Month or Year is specified current month will be used.

    .PARAMETER Month
    The month to process, in numeric format. If no month is specified current month is used.

    .PARAMETER Weekday
    The weekday to lookup. If no weekday is specified, all weekdays are returned.

    .PARAMETER Number
    The week number in the specified month. If no number is specified, all weekdays are returned.

    .PARAMETER Year
    The year to process. If no year is specified, current year is used.

    .INPUTS
    None. You cannot pipe objects to this function

    .OUTPUTS
    PSCustomObject

    .EXAMPLE
    PS> #Get the 3rd tuesday of march 1962
    PS> Get-NthDayOfMonth -Month 3 -Year 1962 -Weekday Tuesday -Number 3
           
    20. marts 1962 00:00:00
#>

[cmdletbinding()]
param (
    [validateset(1,2,3,4,5,6,7,8,9,10,11,12)]
    [int]$Month,
    [ValidatePattern("^[0-9]{4}$")]
    [int]$Year,
    [validateset("Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday")]
    [string]$Weekday,
    [validateset(-5,-4,-3,-2,-1,0,1,2,3,4,5)]
    $Number
)

if ($number) {
    [int]$number = $number #If parameter is cast as [int] in param section it will default to 0. If no type is defined null value is allowed, but any value will be string by default
}


#Find last date of current month. Workarounds to avoid any cultural differences (/,- or whatever as date seperator as well as mm/dd/yyyy, yyyy/dd/mm or whatever syntax)
$BaseDate = Get-Date
if ($Month) {
    $CurMonth = [int](Get-Date -Format MM)
    if ($CurMonth -ge $Month) {
        $BaseDate = (Get-Date $BaseDate).AddMonths(-($CurMonth - $Month))
    }
    else {
        $BaseDate = (Get-Date $BaseDate).AddMonths(($Month - $CurMonth))
    }
}

if ($Year) {
    $CurYear = [int](Get-Date -Format yyyy)
    if ($CurYear -ge $Year) {
        $BaseDate = (Get-Date $BaseDate).AddYears(-($CurYear - $Year))
    }
    else {
        $BaseDate = (Get-Date $BaseDate).AddYears(($Year - $CurYear))
    }
    
}

$CurDate = (Get-Date $BaseDate).Day
if ($CurDate -gt 1) {
    $FirstDate = (Get-Date $BaseDate).AddDays(-($CurDate -1)).Date
}
else {
    $FirstDate = (Get-Date $BaseDate).date
}
$NextMonth = (Get-Date $FirstDate).AddMonths(1)
$LastDate = (Get-Date $NextMonth).AddDays(-1)

# Build the object to get dates for each weekday
$Props = [ordered]@{"Monday" = @()
                    "Tuesday" = @()
                    "Wednesday" = @()
                    "Thursday" = @()
                    "Friday" = @()
                    "Saturday" = @()
                    "Sunday" = @()
                    }
$DaysOfMonth = New-Object -TypeName PSObject -Property $Props

#We start on day one and add the numeric values to parse through the dates
$DaysToProcess = @(0..($LastDate.Day - 1))
Foreach ($Day in $DaysToProcess) {
    $Date = (Get-Date $FirstDate).AddDays($Day)
    #Get dates corresponding the 7 weekdays
    $CurDayValue = $Date.DayOfWeek.value__
    if ($CurDayValue -eq 0) {
        $DaysOfMonth.Sunday += $Date
    }
    if ($CurDayValue -eq 1) {
        $DaysOfMonth.Monday += $Date
    }
    if ($CurDayValue -eq 2) {
        $DaysOfMonth.Tuesday += $Date
    }
    if ($CurDayValue -eq 3) {
        $DaysOfMonth.Wednesday += $Date
    }
    if ($CurDayValue -eq 4) {
        $DaysOfMonth.Thursday += $Date
    }
    if ($CurDayValue -eq 5) {
        $DaysOfMonth.Friday += $Date
    }
    if ($CurDayValue -eq 6) {
        $DaysOfMonth.Saturday += $Date
    }
}

$NumberWithinRange = ($number -ge -$DaysOfMonth.$Weekday.count -and $number -le ($DaysOfMonth.$Weekday.Count -1) -and -not [string]::IsNullOrWhiteSpace($number) )

if ($Weekday -and $NumberWithinRange) {
    if ($number -lt 0) {
        $number = $number 
    }
    else {
        $number = $number - 1
    }
    Return $DaysOfMonth.$Weekday[($number)]
}

if ($Weekday -and -not $NumberWithinRange -and -not [string]::IsNullOrWhiteSpace($number)) {
    Write-Warning "No $Weekday number $number in selected month" 
    break   
}

if ($Weekday) {
    Return $DaysOfMonth.$Weekday
}
if ($Number) {
    $Days = $DaysOfMonth | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name | Sort-Object
    foreach ($Day in $days) {
        #Recycle the $props from earlier with weekdays in correct order
        $props.$Day = $DaysOfMonth.$Day[($number -1)]
    }
    $Result = New-Object -TypeName PSObject -Property $props
    Return $Result
}

Return $DaysOfMonth
}
Function Get-StringASCIIValues {
[CMDLetbinding()]
    param (
        [string]$String
   )

   return $String.ToCharArray() | ForEach-Object {$_ + " : " + [int][Char]$_}
}

Function Get-StringHash {
    #https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/get-filehash?view=powershell-7.1 (ex. 4)
    [CMDLetBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string[]]$String,
        [Parameter(Mandatory = $false)]
        [ValidateSet("SHA1","SHA256","SHA384","SHA512","MACTripleDES","MD5","RIPEMD160")]
        [string]$Algorithm = "SHA256",
        [Parameter(Mandatory = $false)]
        [int]$GroupCount = 2,
        [Parameter(Mandatory = $false)]
        [String]$Seperator = "-"
    )

    $Result = @()
    Write-Verbose "Received $($String.count) string(s)"
    Write-Verbose "Using algorithm: $Algorithm"
    $stringAsStream = [System.IO.MemoryStream]::new()
    $writer = [System.IO.StreamWriter]::new($stringAsStream)
    foreach ($t in $String) {
        $writer.write($t)
        $writer.Flush()
        $stringAsStream.Position = 0
        $HashString = Get-FileHash -InputStream $stringAsStream -Algorithm $Algorithm | Select-Object -ExpandProperty Hash
        Write-Verbose "$($HashString.length) characters in hash"
    }

    Write-Verbose "Dividing string to groups of $GroupCount characters, seperated by $Seperator"
    for ($x = 0 ; $x -lt $HashString.Length ; $x = $x + $GroupCount) {
        $Result += $HashString[$x..($x + ($GroupCount -1))] -join ""
    }
    Write-Verbose "$($Result.count) groups"
    $Result = $Result -join $Seperator
    Write-Verbose "Returning $($Result.length) character string"

    return $Result
}

Function Get-Unicode {
    param(
        [string]$Word
    )
    $word.ToCharArray() | ForEach-Object {
        $_ + ": " + [int][char]$_ 
    }
}

Function Get-UserVariables {
    #Get, and display, any variable not defined in the $SysVars variable
    get-childitem variable: | Where-Object {$SysVars -notcontains $_.Name}
}

Function Get-WebRequestError {
<#
        .SYNOPSIS
        Read more detailed error from failed Invoke-Webrequest and Invoke-RestMethod
        https://stackoverflow.com/questions/35986647/how-do-i-get-the-body-of-a-web-request-that-returned-400-bad-request-from-invoke

        .Parameter ErrorObject
        $_ from a Catch block

    #>


    [CMDLetbinding()]
    param (
        [object]$ErrorObject
    )

    $streamReader = [System.IO.StreamReader]::new($ErrorObject.Exception.Response.GetResponseStream())
    $ErrResp = $streamReader.ReadToEnd() | ConvertFrom-Json
    $streamReader.Close()
    return $ErrResp
}

Function New-CSVExportable {
    param($Object
    )
    # Gennemgår properties for alle elementer, og tilføjer manglende så alle elementer har samme properties til CSV eksport eller lignende
    $AllMembers = @()
    foreach ($Item in $Object) {
        $ItemMembers = ($item | ConvertTo-Csv -NoTypeInformation -Delimiter ";")[0] -split ";" -replace '"','' #For at sikre vi får alle properties i den korrekte rækkefølge (Get-Member kan være lidt tilfældig i rækkefølgen)
        foreach ($itemmember in $ItemMembers) {
            if ($ItemMember -notin $AllMembers) {
                $AllMembers += $ItemMember
            }
        }
        
    }
    #$AllMembers = $AllMembers | Select-Object -Unique

    for ($x = 0 ; $x -lt $Object.Count ; $x++) {
        $CurMembers = $Object[$x] | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name
        for ($y = 0 ; $y -lt $AllMembers.count ; $y++) {
            if ($AllMembers[$y] -notin $CurMembers) {
                $Object[$x] | Add-Member -MemberType NoteProperty -Name $AllMembers[$y] -Value "N/A"
            }
        }
    }
    return $Object

}

Function New-YAMLTemplate {
 
    <#
    .SYNOPSIS
    Generates Azure Pipelines based on the comment based help, and parameter definitions, in the input script

    .Description
    Generates Azure Pipelines based on the comment based help in the input script.
    For help on comment based help see https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_comment_based_help?view=powershell-7.2
    Script parameters are parsed, and YAML template, pipeline and variable files are generated with default values, and validateset values prepopulated.

    Default variables that will be defined in pipeline yaml:
        deployment: DeploymentDisplayName converted to lower-case, spaces replaced with underscores, and non-alphanumeric characters removed.
        deploymentDisplayName: Value of DeploymentDisplayName parameter

    For each supplied environment, default variables will be created:
        <ENV>_ServiceConnectionAD: '<ServiceConnection parameter value>'
        <ENV>_ServiceConnectionResources: '<ServiceConnection parameter value>'
        <ENV>_EnvironmentName: '<ENV>'

    Unless otherwise specified with the -Agent parameter, the template will use the first of the valid options from the validateset.

    The script will output the following files:
        if -WikiOnly is not specified:
            <scriptname>.yml - the template for the script itself.
            deploy-<scriptname>.yml.sample - a sample pipeline yaml.
            deploy-<scriptname>-<environment>-vars.yml - a variable file for the <environment> stage of the pipeline file. One file for each environment specified in -Environment parameter
        if -WikiOnly, or -Wiki, is specified:
            <scriptname>.md - A Wiki file based on the comment based help.

    Outputfiles will be placed in the same directory as the source script, unless the -OutDir parameter is specified.
    The template for the script will have the extension .yml and the sample files will have the extension .yml.sample so the yaml selection list in Azure DevOps isn't crowded.


    .Parameter ScriptPath
    Path to script to generate YAML templates for

    .Parameter DeploymentDisplayName
    Display Name of deployment when pipeline is run. Will default to "Deployment of <scriptfile>"

    .Parameter Environment
    Name of environment(s) to deploy to. Will default to "Dev"

    .Parameter Overwrite
    Overwrite existing YAML files

    .Parameter ServiceConnection
    Name of serviceconnection. Will default to "IteraCCoEMG"

    .Parameter Wiki
    Generate Wiki file as well as template files.

    .Parameter WikiOnly
    Determines if only a Wiki should be generated.

    .Parameter OutDir
    Directory to output files to. If omitted file will be written to script location.

    .Parameter Agent
    Agent type to use when running pipeline. If none specified, default will be first in ValidateSet.

    .Example
    PS> $ScriptPath = "C:\Scripts\AwesomeScript.ps1"
    PS> New-YAMLTemplate -ScriptPath $ScriptPath -Environment "DEV","TEST" -Overwrite

    This will generate a template file, a pipeline file and two variable files for deployment of C:\Scripts\AwesomeScript.ps1 to DEV and TEST environments.
    Existing files will be overwritten, and files placed in C:\Scripts
    No Wiki file will be created.


    .Example
    PS> $ScriptPath = "C:\Scripts\AwesomeScript.ps1"
    PS> New-YAMLTemplate -ScriptPath $ScriptPath -Wiki -Environment Prod

    This will generate a template file, a pipeline file, a single varibles files for deployment of C:\Scripts\AwesomeScript.ps1 to Prod environment, as well as a Wiki file.
    If files already exist the script will return a message stating no files are generated.

    .Example
    PS> $ScriptPath = "C:\Scripts\AwesomeScript.ps1"
    PS> New-YAMLTemplate -ScriptPath $ScriptPath -WikiOnly -OutDir C:\Wikis -OverWrite

    This will generate a Wiki file only as C:\Wikis\AwesomeScript.md
    If the file already exist, it will be overwritten.

    #>


    [CMDLetbinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateScript({ Get-ChildItem -File -Path $_ })]
        [String]$ScriptPath,
        [Parameter(Mandatory = $false)]
        [String]$DeploymentDisplayName = "Deployment of $(Split-Path -Path $ScriptPath -Leaf)",
        [Parameter(Mandatory = $false)]
        [String[]]$Environment = "Test",
        [Parameter(Mandatory = $false)]
        [Switch]$Overwrite,
        [Parameter(Mandatory = $false)]
        [String]$ServiceConnection = "IteraCCoEMG",
        [Parameter(Mandatory = $false)]
        [switch]$WikiOnly,
        [Parameter(Mandatory = $false)]
        [switch]$Wiki,
        [Parameter(Mandatory = $false)]
        [ValidateScript({ Test-Path -Path $_ })]
        [String]$OutDir,
        [Parameter(Mandatory = $false)]
        [ValidateSet("Mastercard Payment Services", "vmImage: windows-latest", "vmImage: ubuntu-latest")]
        [String]$Agent 
    )

    # Pipeline PowerShell task: https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/utility/powershell?view=azure-devops

    $ScriptName         = Split-Path -Path $ScriptPath -Leaf
    $ScriptDirectory    = Split-Path -Path $ScriptPath -Parent
    # This weird way of getting the file is necessary to get filesystem name casing. Otherwise whatever casing is passed in the parameter is used.
    # Should be obsolete after lower-case standards have been decided, except for producing a warning.
    $ScriptFile         = Get-Item -Path "$ScriptDirectory\*.*" | Where-Object { $_.Name -eq $ScriptName }

    # Retrieve info from comment-based help and parameter definitions.
    $ScriptHelp                 = Get-Help -Name $ScriptPath -full 
    $ScriptCommand              = (Get-Command -Name $ScriptPath)
    $ScriptCommandParameters    = $ScriptCommand.Parameters
    $ScriptHelpParameters       = $ScriptHelp.parameters
    $ScriptBaseName             = $ScriptFile.BaseName
    $VariablePrefix             = $ScriptBaseName.replace("-", "_").ToLower()
    $ScriptExamples             = $ScriptHelp.Examples
    $ScriptSynopsis             = ($ScriptHelp.Synopsis | foreach-object { $_ | Out-String } ) -split "`r`n|`n" 
    $ScriptNotes                = if ($ScriptHelp.AlertSet.Alert.count -gt 0) { ($ScriptHelp.alertset.alert[0] | foreach-object { $_ | Out-String } ) -split "`r`n|`n" | ForEach-Object { if ( -not [string]::isnullorwhitespace($_)) { $_ } } }
    $ScriptDescription          = ($ScriptHelp.description | foreach-object { $_ | Out-String } ) -split ("`r`n") 
        
    # Header with a few reminderes, for the variables and pipeline files. soon-to-be obsolete when we all have become pipeline-gurus!
    $PipelineVariablesHeader = @()
    $PipelineVariablesHeader += '# If using double quotes, remember to escape special characters'
    $PipelineVariablesHeader += '# Booleans, and Numbers, must be passed in ${{variables.<variablename>}} to template to retain data type when received by template.'
    $PipelineVariablesHeader += '# Booleans still need to be prefixed with $ when passed to script, because Pipelines sucks (https://www.codewrecks.com/post/azdo/pipeline/powershell-boolean/)'
    $PipelineVariablesHeader += '# Split long strings to multiple lines by using >- , indenting value-lines ONE level and NO single-quotes surrounding entire value (https://yaml-multiline.info/ - (folded + strip))'


    # Get path of script relative to repo if possible.
    Push-Location -Path (Split-Path -Path $ScriptPath -Parent) -StackName RelativePath
    try {
        $RepoScriptPath = & git ls-files --full-name $ScriptPath
        if ([string]::IsNullOrWhiteSpace($RepoScriptPath)) {
            $RepoScriptPath = & git ls-files --full-name $ScriptPath --others
        }
            
        if ([string]::IsNullOrWhiteSpace($RepoScriptPath)) {
            # If we can't find the script as part of the repo, fallback to the path relative to current location
            $RepoScriptPath = (Resolve-Path -Path $ScriptPath -Relative).replace("\", "/")
            Write-Warning "Couldn't find path relative to repository. Is file in a repo? Relative references will fall back to $RepoScriptPath"
        }
        else {
            # We found the script as part of repo, so lets guess the project, repository and direct URL for the Wiki as well
            $RemoteGit          = & git remote -v # Returns something similar to 'origin https://<organization>@dev.azure.com/<organization>/<project>/_git/<repository> (push)'
            $RemoteURL          = "https://" + $RemoteGit.split(" ")[0].split("@")[-1] + "?path=/$RepoScriptPath"
            $RemotePath         = $RemoteGit[0].split("/")
            $DevOpsProject      = $RemotePath[4]
            $DevOpsRepository   = $RemotePath[6] -replace "\(fetch\)|\(push\)", ""
        }
    }
    catch {
        Write-Warning "Unable to run git commands"
        Write-Warning $_.Exception.Message 
    }
    Pop-Location -StackName RelativePath
        
    if ($RepoScriptPath -cmatch "[A-Z]") {
        # File names should be in lower case in accordance with https://dev.azure.com/itera-dk/Mastercard.PaymentsOnboarding/_git/infrastructure?path=/readme.md
        Write-Warning "Scriptpath not in all lowercase: $RepoScriptPath"
    }

    if ([string]::isnullorwhitespace($OutDir)) {
        [string]$OutDir = $ScriptFile.DirectoryName.ToString()
    }
    $FSTemplateFilePath     = Join-Path -Path $OutDir -ChildPath $ScriptFile.Name.Replace(".ps1", ".yml")
    $FSPipelineFilePath     = Join-Path -Path (Split-Path -Path $FSTemplateFilePath -Parent) -ChildPath ("deploy-$(Split-Path -Path $FSTemplateFilePath -Leaf)")
    $FSVariablesFilePath    = @{}
    foreach ($env in $environment.tolower()) {
        $FSVariablesFilePath[$env] = $FSPipelineFilePath.Replace(".yml", "-$($env)-vars.yml") #Template for variable files. #ENV# is replace with corresponding environment name.
    }
    $FSWikiFilePath = $FSTemplateFilePath.replace(".yml", ".md") 
    # Wiki uses dash'es for spaces, and hex values for dash'es, so replace those in the filename.
    $FSWikiFilePath = Join-Path (Split-Path -Path $FSWikiFilePath) -ChildPath ((Split-Path -Path $FSWikiFilePath -Leaf).Replace("-", "%2D").Replace(" ", "-"))

    # Get path to yaml template file, relative to script location
    $RepoTemplateFilePath   = $RepoScriptPath.replace(".ps1", ".yml")
    if ([string]::IsNullOrWhiteSpace((Split-Path -Path $RepoTemplateFilePath))) {
        # If script is located in root of repo, we cant split-path it
        $RepoPipelineFilePath  = "/deploy-$(Split-Path -Path $RepoTemplateFilePath -Leaf)"
    }
    else {
        $RepoPipelineFilePath   = "/" + (Join-Path -Path (Split-Path -Path $RepoTemplateFilePath) -ChildPath ("deploy-$(Split-Path -Path $RepoTemplateFilePath -Leaf)")).replace("\","/")
    }

    # Save variable filenames in hashtable for easy references
    $RepoVariablesFilePath  = @{}
    foreach ($env in $environment.tolower()) {
        $RepoVariablesFilePath[$env] = $RepoPipelineFilePath.Replace(".yml", "-$($env)-vars.yml") #Template for variable files. #ENV# is replace with corresponding environment name.
    }

    #$RepoWikiFilePath = $RepoTemplateFilePath.replace(".yml", ".md") # Maybe we'll need this one day.... maybe not


    # Parse the parameters and get necessary values for YAML generation
    $ScriptParameters = @()
    foreach ($param in $ScriptHelpParameters.parameter) {
        $Command    = $ScriptCommandParameters[$param.name]
        $Props      = [ordered]@{   "Description"                 = $param.description 
                                    "Name"                        = $param.name
                                    "HelpMessage"                 = ($Command.Attributes | Where-Object { $_.GetType().Name -eq "ParameterAttribute" }).HelpMessage
                                    "Type"                        = $param.type
                                    "Required"                    = $param.required
                                    "DefaultValue"                = $param.defaultValue
                                    "ValidateSet"                 = ($Command.Attributes | Where-Object { $_.GetType().Name -eq "ValidateSetAttribute" }).ValidValues
                                    "ValidateScript"              = ($Command.Attributes | Where-Object { $_.GetType().Name -eq "ValidateScriptAttribute" }).scriptblock
                                }

        # Build a description text to add to variables, and parameters, in YAML files
        $YAMLHelp = ""
        if ($props.Description.length -gt 0) {
            $YAMLHelp += "$((($props.Description | foreach-object {$_.Text}) -join " ") -replace ("`r`n|`n|`r", " "))"
        }
        
        if ($Props.HelpMessage.Length -gt 0) {
            $YAMLHelp += " Help: $($Props.HelpMessage)"
        }

        $YAMLHelp += " Required: $($param.required)"

        if ($Props.ValidateSet.Count -gt 0) {
            $YAMLHelp += " ValidateSet: ($(($Props.ValidateSet | ForEach-Object {"'$_'"}) -join ","))"
        }

        if ($Props.ValidateScript.Length -gt 0) {
            $YAMLHelp += " ValidateScript: {$($Props.ValidateScript)}"
        }

        if ($YAMLHelp.Length -gt 0) {
            $Props.add("YAMLHelp", $YAMLHelp.Trim())
        }
        
        $ScriptParameters += New-Object -TypeName PSObject -Property $Props
    }

    if ($ScriptParameters.count -eq 0) {
        Write-Warning "No parameters found for $ScriptPath. Make sure comment based help is correctly entered: https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_comment_based_help?view=powershell-7.2"
    }

    # Build the YAMLParameters object containing more YAML specific information (could be done in previous loop... to do someday)
    $YAMLParameters     = @()
    $ScriptArguments    = ""
    foreach ($param in $ScriptParameters) {
        $ParamType = $ParamDefaultValue = $null 
        # There are really only 3 parameter types we can use when running Powershell in a pipeline
        switch ($Param.Type.Name ) {
            "SwitchParameter"                                   { $ParamType = "boolean" }
            { $_ -match "Int|Int32|long|byte|double|single" }   { $ParamType = "number" }
            default                                             { $ParamType = "string" } # Undeclared parameters will be of type Object and treated as string
        }

        # Not a proper switch, but this is where we figure out the correct default value
        switch ($Param.DefaultValue) {
            { $_ -match "\$" }                                                                          { $ParamDefaultValue = "'' # Scriptet default: $($Param.DefaultValue)" ; break } # If default value contains $ it most likely references another parameter.
            { (-not ([string]::IsNullOrWhiteSpace($Param.DefaultValue)) -and $ParamType -eq "String") } { $ParamDefaultValue = "'$($param.defaultValue)'" ; break } # Add single quotes around string values
            { $ParamType -eq "number" }                                                                 { $ParamDefaultValue = "$($param.defaultValue)" ; break } # No quotes around numbers as that would make it a string
            { $ParamType -eq "boolean" }                                                                { if ($param.defaultvalue -eq $true) { $ParamDefaultValue = "true" } else { $ParamDefaultValue = "false" } ; break } # Set a default value for booleans as well
            { $Param.ValidateSet.count -gt 0 }                                                          { if ($Param.ValidateSet -contains " ") { $ParamDefaultValue = "' '" } else { $ParamDefaultValue = "'$($Param.ValidateSet[0])'" } }
            default                                                                                     { $ParamDefaultValue = "''" } # If all else fails, set the default value to empty string
        }

        $YAMLParameterProps = @{"Name"          = $Param.name
                                "YAMLHelp"      = $Param.YAMLHelp
                                "Type"          = $ParamType
                                "Default"       = $ParamDefaultValue
                                "ValidateSet"   = $param.validateSet 
                                "VariableName"  = "$($VariablePrefix)_$($param.name)" # Property to use as variable name in YAML. #ENV# will be replaced with the different environments to deploy to
                                }
        $YAMLParameters += New-Object -TypeName PSObject -Property $YAMLParameterProps

        # Define the scriptarguments to pass to the script. The name of the variable will correspond with the name of the parameter
        if ($ParamType -eq "boolean") {
            $ScriptArguments += ("-$($Param.Name):`$`${{parameters.$($Param.name)}} ") # Add additional $ to turn "false" into "$false"
        }
        elseif ($param.type.name -eq "String") {
            $ScriptArguments += ("-$($Param.Name) '`${{parameters.$($Param.name)}}' ") # Make sure string values has single quotes around them so spaces and special characters survive
        } 
        else {
            #integer type
            $ScriptArguments += ("-$($Param.Name) `${{parameters.$($Param.name)}} ") # Numbers as-is
        } 
        if ($YAMLParameters[-1].VariableName.Length -gt $MaxParameterNameLength) {
            $MaxParameterNameLength = $YAMLParameters[-1].VariableName.Length # Used for padding in pipeline and variables file, to make them less messy.
        }
    } 
    $MaxParameterNameLength++ # To account for the colon in the YAML

    # Initialize PipelineVariables and set the corresponding template as comment
    # $PipelineVariables contains the content of the variable files for each environment
    $PipelineVariables = @()
    $PipelineVariables += " # $(Split-Path -Path $RepoTemplateFilePath -leaf)"

    # Default template parameters independent of script parameters
    $TemplateParameters = @()
    $TemplateParameters += " - name: serviceConnection # The name of the service Connection to use"
    $TemplateParameters += " type: string"
    $TemplateParameters += " default: false"

    # Build the template parameters
    foreach ($param in $YAMLParameters) {
        $TemplateParameters += ""
        $TemplateParameters += " - name: $($param.Name) # $($Param.YAMLHelp)"
        $TemplateParameters += " type: $($Param.type)"
        $TemplateParameters += " default: $($Param.Default)"
        if ($param.validateset) {
            $TemplateParameters += " values:"
            foreach ($value in $param.validateset) {
                if ($param.Type -eq "number") {
                    $TemplateParameters += " - $value"
                }
                else {
                    $TemplateParameters += " - '$value'"
                }
            }
        }
        $PipelineVariables += " $("$($Param.VariableName):".PadRight($MaxParameterNameLength)) $($param.Default) # $($Param.YAMLHelp)"
    }

    #region BuildTemplate
    $Template = @()
    $Template += "# Template to deploy $($ScriptFile.Name):"
    $Template += ""

    # Add script synopsis to template file if available - could propably be done with out-string or similar waaaay simpler
    
    if ($ScriptSynopsis.length -gt 0) {
        $Template += "# Synopsis:"
        $Template += $ScriptSynopsis | foreach-object {"#`t $_"}
    }
    $Template += ""

    # Add script description to template file if available
    if ($ScriptDescription.length -gt 0) {
        $Template += "# Description:"
        $Template += $ScriptDescription | ForEach-Object {"#`t $_"}
    }
    $Template += ""

    # Add script notes to template file if available
    if ($ScriptNotes.length -gt 0) {
        $Template += "# Notes:"
        $Template += $ScriptNotes | foreach-object {"#`t $_"}
    }
    $Template += ""

    $Template += "parameters:"
    $Template += $TemplateParameters
    $Template += ""
    $Template += "steps:"
    $Template += " - task: AzurePowerShell@5"
    $Template += " displayName: ""PS: $($ScriptFile.Name)"""
    $Template += " inputs:"
    $Template += ' azureSubscription: "${{parameters.serviceConnection}}"'
    $Template += ' scriptType: "FilePath"'
    $Template += " scriptPath: ""$RepoScriptPath"" # Relative to repo root"
    $Template += " azurePowerShellVersion: latestVersion"
    $Template += " scriptArguments: $ScriptArguments"
    $Template += " pwsh: true # Run in PowerShell Core"
    #endregion #BuildTemplate

    # Make variables nicely alligned
    $MaxEnvLength = $Environment | Group-Object -Property Length | Sort-Object -Property Name -Descending | Select-Object -First 1 -ExpandProperty Name
    $PadTo = "_ServiceConnectionResources".Length + $MaxEnvLength + " ".Length
    
    # Get agent options from ValidateSet for Agent parameter of this script/function
    $Command = Get-Command -Name $MyInvocation.MyCommand
    $ValidAgents = ($Command.Parameters["Agent"].Attributes | Where-Object { $_.GetType().Name -eq "ValidateSetAttribute" }).ValidValues

    if ($Agent) {
        $SelectedAgent = $Agent 
    }
    else {
        $SelectedAgent = $ValidAgents[0]
    }
    $AgentOptions = $ValidAgents | Where-Object {$_ -ne $SelectedAgent}

    #region BuildPipeline
    $Pipeline = @()
    $Pipeline += "# https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/?view=azure-pipelines"
    $Pipeline += ""
    $Pipeline += "trigger: none"
    $Pipeline += ""
    $Pipeline += $PipelineVariablesHeader
    $Pipeline += "variables:"
    $Pipeline += " # Pipeline variables"
    $Pipeline += " $("deployment:".PadRight($PadTo)) '$((($DeploymentDisplayName.ToCharArray() | Where-Object {$_ -match '[\w| ]'}) -join '').replace(" ","_").tolower())' `t`t# Name of deployment"
    $Pipeline += " $("deploymentDisplayName:".PadRight($PadTo)) '$DeploymentDisplayName' `t# Name of deployment"
    foreach ($env in $Environment) {
        $Pipeline += ""
        $Pipeline += " $("$($env)_ServiceConnectionAD:".PadRight($PadTo)) '$($ServiceConnection)' `t`t# Name of connection to use for AD deployments in $env environment"
        $Pipeline += " $("$($env)_ServiceConnectionResources:".PadRight($PadTo)) '$($ServiceConnection)' `t`t# Name of connection to use for Azure resource deployments in $env environment"
        $Pipeline += " $("$($env)_EnvironmentName:".PadRight($PadTo)) '$env'"
    }


    $Pipeline += ""
    $Pipeline += "# Comment/remove incorrect agents"
    $Pipeline += "pool:" 
    $Pipeline += " '$SelectedAgent'"
    foreach ($agnt in $AgentOptions) {
        $Pipeline += " #'$agnt'"
    }

    $Pipeline += ""
    $Pipeline += "stages:"
    foreach ($env in $Environment) {
        $Pipeline += "# $env"
        $Pipeline += " - stage: '`${{variables.$($env)_EnvironmentName}}'"
        $Pipeline += " displayName: '`${{variables.$($env)_EnvironmentName}}'"

        $Pipeline += " variables:"
        $Pipeline += " - template: '$($RepoVariablesFilePath[$env])'"

        $Pipeline += " jobs:"
        $Pipeline += " - deployment: '`${{variables.deployment}}'"
        $Pipeline += " displayName: '`${{variables.deploymentDisplayName}}'"
        $Pipeline += " environment: '`${{variables.$($env)_EnvironmentName}}'"
        $Pipeline += " strategy:"
        $Pipeline += " runOnce:"
        $Pipeline += " deploy:"
        $Pipeline += " steps:"
        $Pipeline += " - checkout: self"
        if ($ScriptSynopsis.Length -gt 0) {
            $Pipeline += " " + ($ScriptSynopsis | ForEach-Object {"# $_"})
        }
        $Pipeline += " - template: '/$RepoTemplateFilePath' #Template paths should be relative to this file. For absolute path use /path/to/template"
        $Pipeline += " parameters:"
        $Pipeline += " $("#serviceConnection:".PadRight($MaxParameterNameLength)) `$`{{variables.$($env)_ServiceConnectionAD}} # Comment/remove the incorrect connection!" 
        $Pipeline += " $("serviceConnection:".PadRight($MaxParameterNameLength)) `$`{{variables.$($env)_ServiceConnectionResources}}"
        foreach ($param in $YAMLParameters) {
            $ParamValue = "`${{variables.$($param.VariableName)}}"
            $Pipeline += " $("$($param.Name):".PadRight($MaxParameterNameLength)) $ParamValue"
        }
        $Pipeline += ""
    }
    #endregion BuildPipeline

    #Finally output the files
    try {
        if ($Wiki -or $WikiOnly) {
            $ScriptWiki = @("<b>Name:</b> $(Split-Path $ScriptPath -Leaf)"
                ""
                "<b>Project:</b> $DevOpsProject"
                ""
                "<b>Repository:</b> $DevOpsRepository"
                ""
                "<b>Path:</b> <a href=""$RemoteURL""> $RepoScriptPath</a>"
                ""
                "<b>Synopsis:</b>"
                $ScriptSynopsis
                ""
                "<b>Description:</b>"
                $ScriptDescription
                ""
                if ($ScriptNotes.Length -gt 0) {
                    "<b>Notes:</b>"
                    $ScriptNotes
                    ""
                }
                "<b>Examples</b>"
                ($ScriptExamples | Out-String).trim()
                ""
                "<b>Parameters</b>"
                ($ScriptHelpParameters | Out-String) -split "`r`n|`n" | ForEach-Object {$_.trim()}
            ) -join "`r`n"
            $ScriptWiki | Out-File -FilePath $FSWikiFilePath -Encoding utf8 -NoClobber:(-not $Overwrite) -Force:$Overwrite
        }
        if (-not $WikiOnly) {
            $PipelineVariablesHeader += "variables:"
            $PipelineVariables = $PipelineVariablesHeader + $PipelineVariables
            foreach ($env in $Environment) {
                $PipelineVariables | Out-File -FilePath "$($FSVariablesFilePath[$Env]).sample" -Encoding utf8 -NoClobber:(-not $Overwrite) -Force:$Overwrite
            }
            $Pipeline   | Out-File -FilePath "$FSPipelineFilePath.sample" -Encoding utf8 -NoClobber:(-not $Overwrite) -Force:$Overwrite
            $Template   | Out-File -FilePath $FSTemplateFilePath -Encoding utf8 -NoClobber:(-not $Overwrite) -Force:$Overwrite
        }
    }
    catch {
        Write-Error "Unable to write files"
        Write-Error $_.Exception.Message 
    }
}
Function Prompt {
    (get-date -Format HH:mm:ss).ToString().Replace(".",":") + " PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) ";
    # .Link
    # https://go.microsoft.com/fwlink/?LinkID=225750
    # .ExternalHelp System.Management.Automation.dll-help.xml
}

Function Remove-OldModules {
[ CMDLetbinding()]
  Param ()

  $Modules = Get-Module -ListAvailable | Where-Object {$_.ModuleBase -notmatch "\.vscode"} #Theres probably a reason for vs code specific modules
  $DuplicateModules = $Modules | Group-Object -Property Name | Where-Object {$_.Count -gt 1} | Select-Object  -ExpandProperty Group
  foreach ($Module in $DuplicateModules) {
    $RemoveModules = $DuplicateModules | Where-Object {$_.Name -eq $Module.Name} | Sort-Object -Property Version -Descending | Select-Object -Skip 1
    foreach ($mod in $RemoveModules) {
      Write-Host "$($Module.Name) : $($Module.ModuleBase)"
      Remove-Module -Name $Module.Name -Force -ErrorAction SilentlyContinue
      Remove-Item -Path $Module.ModuleBase -Recurse -Force -Confirm:$false 
    }
  }
}

Function Set-AzTestSetup {

    [CMDLetbinding()]
    param(
        [Parameter(Mandatory =$true)]
        [String[]]$ResourceGroupName,
        [string]$Prefix,
        [int]$NumWinVMs,
        [int]$NumLinuxVMs,
        [string]$VMAutoshutdownTime,
        [string]$WorkspaceName,
        [string]$AutomationAccountName,
        [String]$Location,
        [string]$KeyvaultName,
        [switch]$PowerOff,
        [switch]$Force,
        [switch]$Remove

    )
    foreach ($RG in $ResourceGroupName) {
        if (-not $PSBoundParameters.ContainsKey("Prefix")) {[string]$Prefix = $RG}
        if (-not $PSBoundParameters.ContainsKey("NumWinVMs")) {[int]$NumWinVMs = 2}
        if (-not $PSBoundParameters.ContainsKey("NumLinuxVMs")) {[int]$NumLinuxVMs = 0}
        if (-not $PSBoundParameters.ContainsKey("VMAutoshutdownTime")) {[string]$VMAutoshutdownTime = "2300"}
        if (-not $PSBoundParameters.ContainsKey("WorkspaceName")) {[string]$WorkspaceName = ($Prefix + "-workspace")}
        if (-not $PSBoundParameters.ContainsKey("AutomationAccountName")) {[string]$AutomationAccountName = ($Prefix + "-automation")}
        if (-not $PSBoundParameters.ContainsKey("Location")) {[String]$Location = "westeurope"}
        if (-not $PSBoundParameters.ContainsKey("KeyvaultName")) {[string]$KeyvaultName = ($Prefix + "-keyvault")}

        try {
            if (Get-AzResourceGroup -Name $RG -ErrorAction SilentlyContinue) {
                Write-Host "$RG exist"
                if ($Force -or $Remove) {
                    Write-Host "`tWill be deleted"
                    $WorkSpace = Get-AzOperationalInsightsWorkspace -ResourceGroupName $RG -Name $WorkspaceName -ErrorAction SilentlyContinue
                    $Keyvault = Get-AzKeyVault -VaultName $KeyvaultName -ResourceGroupName $RG -ErrorAction SilentlyContinue
                    if ($null -eq $Keyvault) {
                        $keyvault = Get-AzKeyVault -VaultName $KeyvaultName -InRemovedState -Location $location 
                        if ($null -ne $Keyvault) {
                            Write-Host "`tDeleting $KeyvaultName"
                            $null = Remove-AzKeyVault -VaultName $KeyvaultName -InRemovedState -Force -Confirm:$false -Location $Location
                        }
                    }
                    else {
                        Write-Host "`tDeleting $KeyvaultName"
                        Remove-AzKeyVault -VaultName $KeyvaultName -Force -Confirm:$false -Location $location
                        Start-Sleep -Seconds 1 
                        Remove-AzKeyVault -VaultName $KeyvaultName -InRemovedState -Force -Confirm:$false -Location $location
                    }
                    if ($WorkSpace) {
                        Write-Host "`tDeleting Workspace"
                        $workspace | Remove-AzOperationalInsightsWorkspace -ForceDelete -Force -Confirm:$false 
                    }
                    Write-Host "`tDeleting Resourcegroup and contained resources"
                    Remove-AzResourceGroup -Name $RG -Force -Confirm:$false
                }
                else {
                    Write-Host "Nothing to do"
                    continue
                }
            }
            if ($Remove) {
                Write-Host "Remove specified. Exiting"
                continue 
            }

            Write-Host "Creating $RG"
            New-AzResourceGroup -Name $RG -Location $Location 
            Write-Host "Creating $AutomationAccountName"
            New-AzAutomationAccount -ResourceGroupName $RG -Name $AutomationAccountName -Location $Location -Plan Basic -AssignSystemIdentity 
            Write-Host "Creating $KeyvaultName"
            New-AzKeyVault -Name $KeyvaultName -ResourceGroupName $RG -Location $Location -EnabledForDeployment -EnabledForTemplateDeployment -EnabledForDiskEncryption -SoftDeleteRetentionInDays 7 -Sku Standard 
            Set-AzKeyVaultAccessPolicy -VaultName $KeyvaultName -ResourceGroupName $RG -UserPrincipalName "robert.eriksen_itera.com#EXT#@roedomlan.onmicrosoft.com" -PermissionsToKeys all -PermissionsToSecrets all -PermissionsToCertificates all -PermissionsToStorage all -Confirm:$false 
            Set-AzKeyVaultAccessPolicy -VaultName $KeyvaultName -ResourceGroupName $RG -ObjectId (Get-AzAutomationAccount -ResourceGroupName $RG -Name $AutomationAccountName).Identity.PrincipalId -PermissionsToKeys all -PermissionsToSecrets all -PermissionsToCertificates all -PermissionsToStorage all -Confirm:$false 
            Set-AzKeyVaultAccessPolicy -VaultName $KeyvaultName -ResourceGroupName $RG -ServicePrincipalName 04e7eb7d-da63-4c13-b5ba-04331145fdff -PermissionsToKeys all -PermissionsToSecrets all -PermissionsToCertificates all -PermissionsToStorage all -Confirm:$false 
            Write-Host "Creating $WorkspaceName"
            New-azOperationalInsightsWorkspace -ResourceGroupName $RG -Name $WorkspaceName -Location $location -Sku pergb2018
            $VMCredentials = [pscredential]::new("roe",("Pokemon1234!" | ConvertTo-SecureString -AsPlainText -Force))
            # https://www.powershellgallery.com/packages/HannelsToolBox/1.4.0/Content/Functions%5CEnable-AzureVMAutoShutdown.ps1
            $ShutdownPolicy = @{}
            $ShutdownPolicy.Add('status', 'Enabled')
            $ShutdownPolicy.Add('taskType', 'ComputeVmShutdownTask')
            $ShutdownPolicy.Add('dailyRecurrence', @{'time'= "$VMAutoshutdownTime"})
            $ShutdownPolicy.Add('timeZoneId', "Romance Standard Time")
            $ShutdownPolicy.Add('notificationSettings', @{status='enabled'; timeInMinutes=30; emailRecipient="robert.eriksen@itera.com" })
            $VMPrefix = "$($RG[0])$($RG[-1])"
            if ($NumWinVMs -gt 0) {
                (1..$NumWinVMs) | ForEach-Object {
                    $VMName = ([string]"$($VMPrefix)-Win-$( $_)")
                    Write-Host "Deploying $VMName"
                    $null = New-AzVm -ResourceGroupName $RG -Name $VMName -Location $Location -Credential $VMCredentials -VirtualNetworkName "$($RG)-vnet" -SubnetName "$($RG)-Subnet" -SecurityGroupName "$($RG)-nsg" -PublicIpAddressName "$($VMName)-Public-ip" -OpenPorts 80,3389 -Size "Standard_B2s" -Image Win2019Datacenter 
                    $vm = Get-AzVM -ResourceGroupName $RG -Name $VMName 
                    $vm | Stop-AzVM -Confirm:$false -Force 
                    $Disk = Get-AzDisk | Where-Object {$_.ManagedBy -eq $vm.id}
                    $Disk.Sku = [Microsoft.Azure.Management.Compute.Models.DiskSku]::new("Standard_LRS")
                    $disk | Update-AzDisk
                    $rgName = $vm.ResourceGroupName
                    $vmName = $vm.Name
                    $location = $vm.Location
                    $VMResourceId = $VM.Id
                    $SubscriptionId = ($vm.Id).Split('/')[2]
                    $ScheduledShutdownResourceId = "/subscriptions/$SubscriptionId/resourceGroups/$rgName/providers/microsoft.devtestlab/schedules/shutdown-computevm-$vmName"
                    if ($VMAutoshutdownTime -ne "Off") {
                        Write-Host "Setting autoshutdown: $VMAutoshutdownTime"
                        $ShutdownPolicy['targetResourceId'] = $VMResourceId
                        $null = New-azResource -Location $location -ResourceId $ScheduledShutdownResourceId -Properties $ShutdownPolicy -Force  
                    }
                    if (-not $PowerOff) {
                        Write-Host "Starting VM"
                        $vm | Start-AzVM
                    }
                }
            }
            if ($NumLinuxVMs -gt 0) {
                (1..$NumLinuxVMs) | ForEach-Object {
                    $VMName = ([string]"$($VMPrefix)-Lin-$($_)")
                    Write-Host "Deploying $VMName"
                    $null = New-AzVm -ResourceGroupName $RG -Name $VMName -Location $Location -Credential $VMCredentials -VirtualNetworkName "$($RG)-vnet" -SubnetName "$($RG)-Subnet" -SecurityGroupName "$($RG)-nsg" -PublicIpAddressName "$($VMName)-Public-ip" -OpenPorts 80,3389 -Size "Standard_B2s" -Image UbuntuLTS
                    $vm = Get-AzVM -ResourceGroupName $RG -Name $VMName 
                    $vm | Stop-AzVM -Confirm:$false -Force 
                    $Disk = Get-AzDisk | Where-Object {$_.ManagedBy -eq $vm.id}
                    $Disk.Sku = [Microsoft.Azure.Management.Compute.Models.DiskSku]::new("Standard_LRS")
                    $disk | Update-AzDisk
                    $rgName = $vm.ResourceGroupName
                    $vmName = $vm.Name
                    $location = $vm.Location
                    $VMResourceId = $VM.Id
                    $SubscriptionId = ($vm.Id).Split('/')[2]
                    $ScheduledShutdownResourceId = "/subscriptions/$SubscriptionId/resourceGroups/$rgName/providers/microsoft.devtestlab/schedules/shutdown-computevm-$vmName"
                    if ($VMAutoshutdownTime -ne "Off") {
                        Write-Host "Setting autoshutdown: $VMAutoshutdownTime"
                        $ShutdownPolicy['targetResourceId'] = $VMResourceId
                        $null = New-azResource -Location $location -ResourceId $ScheduledShutdownResourceId -Properties $ShutdownPolicy -Force  
                    }
                    if (-not $PowerOff) {
                        Write-Host "Starting VM"
                        $vm | Start-AzVM
                    }
                }
            }
        }
        catch {
            throw $_ 
        }
    }
}

Function Set-SystemVariables {
    #Collect all variables and store them, so userdefined variables can be easily cleared without restarting the PowerShell session
    New-Variable -Name 'SysVars' -Scope 'Global' -Force
    $global:SysVars = Get-Variable | Select-Object -ExpandProperty Name
    $global:SysVars += 'SysVars'
}

# Add default functionality to user profile
$ProfilePath    = $profile.CurrentUserAllHosts
$ProfileContent = @(Get-Content -Path $ProfilePath -ErrorAction SilentlyContinue)
$AddToProfile   = @('# Added by module: roe.Misc ')

if (-not ($ProfileContent-match "^(\s+)?Import-Module -Name roe.Misc *")) {
    Write-Host "Module roe.Misc : First import - Adding Import-Module roe.Misc to $profilepath"
    $AddToProfile += @(
        "Import-Module -Name roe.Misc -Verbose"
    ) 
}

if (-not ($ProfileContent-match "^(\s+)?Prompt*")) {
    Write-Host "Module roe.Misc : First import - Adding Prompt to $profilepath"
    $AddToProfile += @(
        "Prompt"
    )
}

if (-not ($ProfileContent -match "^(\s+)?Set-SystemVariables*")) {
    Write-Host "Module roe.Misc : First import - Adding Set-SystemVariables to $profilepath"
    $AddToProfile += @(
        "Set-SystemVariables"
    ) 
}

if ($AddToProfile.count -gt 1) {
    if (-not (Test-Path -Path $ProfilePath -ErrorAction SilentlyContinue)) {
        $null = New-Item -Path $ProfilePath -Force -ItemType File
    }
    $ProfileContent += $AddToProfile
    $ProfileContent | Set-Content -Path $ProfilePath -Force
}