# Initialize variables if they aren't already defined.
# These may be defined as parameters of the importing script, or set after importing this script.

# CI mode - set to true on CI server for PR validation build or official build.
[bool]$ci = if (Test-Path variable:ci) { $ci } else { $false }

# Build configuration. Common values include 'Debug' and 'Release', but the repository may use other names.
[string]$configuration = if (Test-Path variable:configuration) { $configuration } else { 'Debug' }

# Set to true to opt out of outputting binary log while running in CI
[bool]$excludeCIBinarylog = if (Test-Path variable:excludeCIBinarylog) { $excludeCIBinarylog } else { $false }

# Set to true to output binary log from msbuild. Note that emitting binary log slows down the build.
[bool]$binaryLog = if (Test-Path variable:binaryLog) { $binaryLog } else { $ci -and !$excludeCIBinarylog }

# Set to true to use the pipelines logger which will enable Azure logging output.
# https://github.com/Microsoft/azure-pipelines-tasks/blob/master/docs/authoring/commands.md
# This flag is meant as a temporary opt-in for the feature while validating it across
# our consumers. It will be deleted in the future.
[bool]$pipelinesLog = if (Test-Path variable:pipelinesLog) { $pipelinesLog } else { $ci }

# Turns on machine preparation/clean up code that changes the machine state (e.g. kills build processes).
[bool]$prepareMachine = if (Test-Path variable:prepareMachine) { $prepareMachine } else { $false }

# True to restore toolsets and dependencies.
[bool]$restore = if (Test-Path variable:restore) { $restore } else { $true }

# Adjusts msbuild verbosity level.
[string]$verbosity = if (Test-Path variable:verbosity) { $verbosity } else { 'minimal' }

# Set to true to reuse msbuild nodes. Recommended to not reuse on CI.
[bool]$nodeReuse = if (Test-Path variable:nodeReuse) { $nodeReuse } else { !$ci }

# Configures warning treatment in msbuild.
[bool]$warnAsError = if (Test-Path variable:warnAsError) { $warnAsError } else { $true }

# Specifies semi-colon delimited list of warning codes that should not be treated as errors.
[string]$warnNotAsError = if (Test-Path variable:warnNotAsError) { $warnNotAsError } else { '' }

# Specifies which msbuild engine to use for build: 'vs', 'dotnet' or unspecified (determined based on presence of tools.vs in global.json).
[string]$msbuildEngine = if (Test-Path variable:msbuildEngine) { $msbuildEngine } else { $null }

# True to attempt using .NET Core already that meets requirements specified in global.json
# installed on the machine instead of downloading one.
[bool]$useInstalledDotNetCli = if (Test-Path variable:useInstalledDotNetCli) { $useInstalledDotNetCli } else { $true }

# Enable repos to use a particular version of the on-line dotnet-install scripts.
#    default URL: https://builds.dotnet.microsoft.com/dotnet/scripts/v1/dotnet-install.ps1
[string]$dotnetInstallScriptVersion = if (Test-Path variable:dotnetInstallScriptVersion) { $dotnetInstallScriptVersion } else { 'v1' }

# True to use global NuGet cache instead of restoring packages to repository-local directory.
[bool]$useGlobalNuGetCache = if (Test-Path variable:useGlobalNuGetCache) { $useGlobalNuGetCache } else { !$ci }

# True to exclude prerelease versions Visual Studio during build
[bool]$excludePrereleaseVS = if (Test-Path variable:excludePrereleaseVS) { $excludePrereleaseVS } else { $false }

# An array of names of processes to stop on script exit if prepareMachine is true.
$processesToStopOnExit = if (Test-Path variable:processesToStopOnExit) { $processesToStopOnExit } else { @('msbuild', 'dotnet', 'vbcscompiler') }

$disableConfigureToolsetImport = if (Test-Path variable:disableConfigureToolsetImport) { $disableConfigureToolsetImport } else { $null }

set-strictmode -version 2.0
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# If specifies, provides an alternate path for getting .NET Core SDKs and Runtimes. This script will still try public sources first.
[string]$runtimeSourceFeed = if (Test-Path variable:runtimeSourceFeed) { $runtimeSourceFeed } else { $null }
# Base-64 encoded SAS token that has permission to storage container described by $runtimeSourceFeed
[string]$runtimeSourceFeedKey = if (Test-Path variable:runtimeSourceFeedKey) { $runtimeSourceFeedKey } else { $null }

# True when the build is running within the VMR.
[bool]$fromVMR = if (Test-Path variable:fromVMR) { $fromVMR } else { $false }

[bool]$disablePipelineSetResult = if (Test-Path variable:disablePipelineSetResult) { $disablePipelineSetResult } else { $false }

function Create-Directory ([string[]] $path) {
    New-Item -Path $path -Force -ItemType 'Directory' | Out-Null
}

function Unzip([string]$zipfile, [string]$outpath) {
  Add-Type -AssemblyName System.IO.Compression.FileSystem
  [System.IO.Compression.ZipFile]::ExtractToDirectory($zipfile, $outpath)
}

# This will exec a process using the console and return it's exit code.
# This will not throw when the process fails.
# Returns process exit code.
function Exec-Process([string]$command, [string]$commandArgs) {
  $startInfo = New-Object System.Diagnostics.ProcessStartInfo
  $startInfo.FileName = $command
  $startInfo.Arguments = $commandArgs
  $startInfo.UseShellExecute = $false
  $startInfo.WorkingDirectory = Get-Location

  $process = New-Object System.Diagnostics.Process
  $process.StartInfo = $startInfo
  $process.Start() | Out-Null

  $finished = $false
  try {
    while (-not $process.WaitForExit(100)) {
      # Non-blocking loop done to allow ctr-c interrupts
    }

    $finished = $true
    return $global:LASTEXITCODE = $process.ExitCode
  }
  finally {
    # If we didn't finish then an error occurred or the user hit ctrl-c.  Either
    # way kill the process
    if (-not $finished) {
      $process.Kill()
    }
  }
}

# Take the given block, print it, print what the block probably references from the current set of
# variables using low-effort string matching, then run the block.
#
# This is intended to replace the pattern of manually copy-pasting a command, wrapping it in quotes,
# and printing it using "Write-Host". The copy-paste method is more readable in build logs, but less
# maintainable and less reliable. It is easy to make a mistake and modify the command without
# properly updating the "Write-Host" line, resulting in misleading build logs. The probability of
# this mistake makes the pattern hard to trust when it shows up in build logs. Finding the bug in
# existing source code can also be difficult, because the strings are not aligned to each other and
# the line may be 300+ columns long.
#
# By removing the need to maintain two copies of the command, Exec-BlockVerbosely avoids the issues.
#
# In Bash (or any posix-like shell), "set -x" prints usable verbose output automatically.
# "Set-PSDebug" appears to be similar at first glance, but unfortunately, it isn't very useful: it
# doesn't print any info about the variables being used by the command, which is normally the
# interesting part to diagnose.
function Exec-BlockVerbosely([scriptblock] $block) {
  Write-Host "--- Running script block:"
  $blockString = $block.ToString().Trim()
  Write-Host $blockString

  Write-Host "--- List of variables that might be used:"
  # For each variable x in the environment, check the block for a reference to x via simple "$x" or
  # "@x" syntax. This doesn't detect other ways to reference variables ("${x}" nor "$variable:x",
  # among others). It only catches what this function was originally written for: simple
  # command-line commands.
  $variableTable = Get-Variable |
    Where-Object {
      $blockString.Contains("`$$($_.Name)") -or $blockString.Contains("@$($_.Name)")
    } |
    Format-Table -AutoSize -HideTableHeaders -Wrap |
    Out-String
  Write-Host $variableTable.Trim()

  Write-Host "--- Executing:"
  & $block
  Write-Host "--- Done running script block!"
}

# createSdkLocationFile parameter enables a file being generated under the toolset directory
# which writes the sdk's location into. This is only necessary for cmd --> powershell invocations
# as dot sourcing isn't possible.
function InitializeDotNetCli([bool]$install, [bool]$createSdkLocationFile) {
  if (Test-Path variable:global:_DotNetInstallDir) {
    return $global:_DotNetInstallDir
  }

  # Disable first run since we do not need all ASP.NET packages restored.
  $env:DOTNET_NOLOGO=1

  # Disable telemetry on CI.
  if ($ci) {
    $env:DOTNET_CLI_TELEMETRY_OPTOUT=1
  }

  # Find the first path on %PATH% that contains the dotnet.exe
  if ($useInstalledDotNetCli -and (-not $globalJsonHasRuntimes) -and ($env:DOTNET_INSTALL_DIR -eq $null)) {
    $dotnetExecutable = GetExecutableFileName 'dotnet'
    $dotnetCmd = Get-Command $dotnetExecutable -ErrorAction SilentlyContinue

    if ($dotnetCmd -ne $null) {
      $env:DOTNET_INSTALL_DIR = Split-Path $dotnetCmd.Path -Parent
    }
  }

  $dotnetSdkVersion = $GlobalJson.tools.dotnet

  # Use dotnet installation specified in DOTNET_INSTALL_DIR if it contains the required SDK version,
  # otherwise install the dotnet CLI and SDK to repo local .dotnet directory to avoid potential permission issues.
  if ((-not $globalJsonHasRuntimes) -and (-not [string]::IsNullOrEmpty($env:DOTNET_INSTALL_DIR)) -and (Test-Path(Join-Path $env:DOTNET_INSTALL_DIR "sdk\$dotnetSdkVersion"))) {
    $dotnetRoot = $env:DOTNET_INSTALL_DIR
  } else {
    if (-not [string]::IsNullOrEmpty($env:DOTNET_GLOBAL_INSTALL_DIR)) {
      $dotnetRoot = $env:DOTNET_GLOBAL_INSTALL_DIR
    } else {
      $dotnetRoot = Join-Path $RepoRoot '.dotnet'
    }

    if (-not (Test-Path(Join-Path $dotnetRoot "sdk\$dotnetSdkVersion"))) {
      if ($install) {
        InstallDotNetSdk $dotnetRoot $dotnetSdkVersion
      } else {
        Write-PipelineTelemetryError -Category 'InitializeToolset' -Message "Unable to find dotnet with SDK version '$dotnetSdkVersion'"
        ExitWithExitCode 1
      }
    }

    $env:DOTNET_INSTALL_DIR = $dotnetRoot
  }

  # Creates a temporary file under the toolset dir.
  # The following code block is protecting against concurrent access so that this function can
  # be called in parallel.
  if ($createSdkLocationFile) {
    do {
      $sdkCacheFileTemp = Join-Path $ToolsetDir $([System.IO.Path]::GetRandomFileName())
    }
    until (!(Test-Path $sdkCacheFileTemp))
    Set-Content -Path $sdkCacheFileTemp -Value $dotnetRoot

    try {
      Move-Item -Force $sdkCacheFileTemp (Join-Path $ToolsetDir 'sdk.txt')
    } catch {
      # Somebody beat us
      Remove-Item -Path $sdkCacheFileTemp
    }
  }

  # Add dotnet to PATH. This prevents any bare invocation of dotnet in custom
  # build steps from using anything other than what we've downloaded.
  # It also ensures that VS msbuild will use the downloaded sdk targets.
  $env:PATH = "$dotnetRoot;$env:PATH"

  # Make Sure that our bootstrapped dotnet cli is available in future steps of the Azure Pipelines build
  Write-PipelinePrependPath -Path $dotnetRoot

  Write-PipelineSetVariable -Name 'DOTNET_NOLOGO' -Value '1'

  return $global:_DotNetInstallDir = $dotnetRoot
}

function Retry($downloadBlock, $maxRetries = 5) {
  $retries = 1

  while($true) {
    try {
      & $downloadBlock
      break
    }
    catch {
      Write-PipelineTelemetryError -Category 'InitializeToolset' -Message $_
    }

    if (++$retries -le $maxRetries) {
      $delayInSeconds = [math]::Pow(2, $retries) - 1 # Exponential backoff
      Write-Host "Retrying. Waiting for $delayInSeconds seconds before next attempt ($retries of $maxRetries)."
      Start-Sleep -Seconds $delayInSeconds
    }
    else {
      Write-PipelineTelemetryError -Category 'InitializeToolset' -Message "Unable to download file in $maxRetries attempts."
      break
    }
  }
}

function GetDotNetInstallScript([string] $dotnetRoot) {
  $installScript = Join-Path $dotnetRoot 'dotnet-install.ps1'
  $shouldDownload = $false
  
  if (!(Test-Path $installScript)) {
    $shouldDownload = $true
  } else {
    # Check if the script is older than 30 days
    $fileAge = (Get-Date) - (Get-Item $installScript).LastWriteTime
    if ($fileAge.Days -gt 30) {
      Write-Host "Existing install script is too old, re-downloading..."
      $shouldDownload = $true
    }
  }
  
  if ($shouldDownload) {
    Create-Directory $dotnetRoot
    $ProgressPreference = 'SilentlyContinue' # Don't display the console progress UI - it's a huge perf hit
    $uri = "https://builds.dotnet.microsoft.com/dotnet/scripts/$dotnetInstallScriptVersion/dotnet-install.ps1"

    Retry({
      Write-Host "GET $uri"
      Invoke-WebRequest $uri -UseBasicParsing -OutFile $installScript
    })
  }

  return $installScript
}

function InstallDotNetSdk([string] $dotnetRoot, [string] $version, [string] $architecture = '', [switch] $noPath) {
  InstallDotNet $dotnetRoot $version $architecture '' $false $runtimeSourceFeed $runtimeSourceFeedKey -noPath:$noPath
}

function InstallDotNet([string] $dotnetRoot,
  [string] $version,
  [string] $architecture = '',
  [string] $runtime = '',
  [bool] $skipNonVersionedFiles = $false,
  [string] $runtimeSourceFeed = '',
  [string] $runtimeSourceFeedKey = '',
  [switch] $noPath) {

  $dotnetVersionLabel = "'sdk v$version'"

  # For performance this check is duplicated in src/Microsoft.DotNet.Arcade.Sdk/src/InstallDotNetCore.cs
  # if you are making changes here, consider if you need to make changes there as well.
  if ($runtime -ne '' -and $runtime -ne 'sdk') {
    $runtimePath = $dotnetRoot
    $runtimePath = $runtimePath + "\shared"
    if ($runtime -eq "dotnet") { $runtimePath = $runtimePath + "\Microsoft.NETCore.App" }
    if ($runtime -eq "aspnetcore") { $runtimePath = $runtimePath + "\Microsoft.AspNetCore.App" }
    if ($runtime -eq "windowsdesktop") { $runtimePath = $runtimePath + "\Microsoft.WindowsDesktop.App" }
    $runtimePath = $runtimePath + "\" + $version
  
    $dotnetVersionLabel = "runtime toolset '$runtime/$architecture v$version'"

    if (Test-Path $runtimePath) {
      Write-Host "  Runtime toolset '$runtime/$architecture v$version' already installed."
      $installSuccess = $true
      Exit
    }
  }

  $installScript = GetDotNetInstallScript $dotnetRoot
  $installParameters = @{
    Version = $version
    InstallDir = $dotnetRoot
  }

  if ($architecture) { $installParameters.Architecture = $architecture }
  if ($runtime) { $installParameters.Runtime = $runtime }
  if ($skipNonVersionedFiles) { $installParameters.SkipNonVersionedFiles = $skipNonVersionedFiles }
  if ($noPath) { $installParameters.NoPath = $True }

  $variations = @()
  $variations += @($installParameters)

  $dotnetBuilds = $installParameters.Clone()
  $dotnetbuilds.AzureFeed = "https://ci.dot.net/public"
  $variations += @($dotnetBuilds)

  if ($runtimeSourceFeed) {
    $runtimeSource = $installParameters.Clone()
    $runtimeSource.AzureFeed = $runtimeSourceFeed
    if ($runtimeSourceFeedKey) {
      $decodedBytes = [System.Convert]::FromBase64String($runtimeSourceFeedKey)
      $decodedString = [System.Text.Encoding]::UTF8.GetString($decodedBytes)
      $runtimeSource.FeedCredential = $decodedString
    }
    $variations += @($runtimeSource)
  }

  $installSuccess = $false
  foreach ($variation in $variations) {
    if ($variation | Get-Member AzureFeed) {
      $location = $variation.AzureFeed
    } else {
      $location = "public location";
    }
    Write-Host "  Attempting to install $dotnetVersionLabel from $location."
    try {
      & $installScript @variation
      $installSuccess = $true
      break
    }
    catch {
      Write-Host "  Failed to install $dotnetVersionLabel from $location."
    }
  }
  if (-not $installSuccess) {
    Write-PipelineTelemetryError -Category 'InitializeToolset' -Message "Failed to install $dotnetVersionLabel from any of the specified locations."
    ExitWithExitCode 1
  }
}

#
# Locates Visual Studio MSBuild installation.
# The preference order for MSBuild to use is as follows:
#
#   1. MSBuild from an active VS command prompt
#   2. MSBuild from a compatible VS installation
#
# Returns full path to msbuild.exe.
# Throws on failure.
#
function InitializeVisualStudioMSBuild([object]$vsRequirements = $null) {
  if (-not (IsWindowsPlatform)) {
    throw "Cannot initialize Visual Studio on non-Windows"
  }

  if (Test-Path variable:global:_MSBuildExe) {
    return $global:_MSBuildExe
  }

  # Minimum VS version to require.
  $vsMinVersionReqdStr = '18.0'

  if (!$vsRequirements) {
    if (Get-Member -InputObject $GlobalJson.tools -Name 'vs') {
      $vsRequirements = $GlobalJson.tools.vs
    }
    else {
      $vsRequirements = New-Object PSObject -Property @{ version = $vsMinVersionReqdStr }
    }
  }
  $vsMinVersionStr = if ($vsRequirements.version) { $vsRequirements.version } else { $vsMinVersionReqdStr }
  $vsMinVersion = [Version]::new($vsMinVersionStr)

  # Try msbuild command available in the environment.
  if ($env:VSINSTALLDIR -ne $null) {
    $msbuildCmd = Get-Command 'msbuild.exe' -ErrorAction SilentlyContinue
    if ($msbuildCmd -ne $null) {
      # Workaround for https://github.com/dotnet/roslyn/issues/35793
      # Due to this issue $msbuildCmd.Version returns 0.0.0.0 for msbuild.exe 16.2+
      $msbuildVersion = [Version]::new((Get-Item $msbuildCmd.Path).VersionInfo.ProductVersion.Split([char[]]@('-', '+'))[0])

      if ($msbuildVersion -ge $vsMinVersion) {
        return $global:_MSBuildExe = $msbuildCmd.Path
      }

      # Report error - the developer environment is initialized with incompatible VS version.
      throw "Developer Command Prompt for VS $($env:VisualStudioVersion) is not recent enough. Please upgrade to $vsMinVersionStr or build from a plain CMD window"
    }
  }

  # Locate Visual Studio installation.
  $vsInfo = LocateVisualStudio $vsRequirements
  if ($vsInfo -ne $null) {
    # Ensure vsInstallDir has a trailing slash
    $vsInstallDir = Join-Path $vsInfo.installationPath "\"
    $vsMajorVersion = $vsInfo.installationVersion.Split('.')[0]

    InitializeVisualStudioEnvironmentVariables $vsInstallDir $vsMajorVersion
  } else {
    throw 'Unable to find Visual Studio that has required version and components installed'
  }

  $msbuildVersionDir = if ([int]$vsMajorVersion -lt 16) { "$vsMajorVersion.0" } else { "Current" }

  $local:BinFolder = Join-Path $vsInstallDir "MSBuild\$msbuildVersionDir\Bin"

  # Use the MSBuild matching the host's process architecture (e.g. amd64 or arm64),
  # falling back to the 32-bit MSBuild in the root Bin folder when no matching subfolder exists.

  # Determine the architecture of the current process, accounting for a 32-bit process
  # running on a 64-bit OS (PROCESSOR_ARCHITEW6432 holds the real machine architecture).
  $local:ProcessArchitecture = $env:PROCESSOR_ARCHITECTURE
  if (($local:ProcessArchitecture -eq 'x86') -and ($env:PROCESSOR_ARCHITEW6432)) {
    $local:ProcessArchitecture = $env:PROCESSOR_ARCHITEW6432
  }

  # Map the architecture to the corresponding MSBuild subfolder. The 32-bit MSBuild lives in the
  # root Bin folder, so x86 maps to an empty subfolder.
  $local:MSBuildArchSubFolder = switch ($local:ProcessArchitecture) {
    'AMD64' { 'amd64' }
    'ARM64' { 'arm64' }
    default { '' }
  }

  $global:_MSBuildExe = Join-Path $local:BinFolder "msbuild.exe"
  if ($local:MSBuildArchSubFolder) {
    $local:ArchMSBuildExe = Join-Path $local:BinFolder (Join-Path $local:MSBuildArchSubFolder "msbuild.exe")
    if (Test-Path $local:ArchMSBuildExe) {
      $global:_MSBuildExe = $local:ArchMSBuildExe
    }
  }

  return $global:_MSBuildExe
}

function InitializeVisualStudioEnvironmentVariables([string] $vsInstallDir, [string] $vsMajorVersion) {
  $env:VSINSTALLDIR = $vsInstallDir
  Set-Item "env:VS$($vsMajorVersion)0COMNTOOLS" (Join-Path $vsInstallDir "Common7\Tools\")

  $vsSdkInstallDir = Join-Path $vsInstallDir "VSSDK\"
  if (Test-Path $vsSdkInstallDir) {
    Set-Item "env:VSSDK$($vsMajorVersion)0Install" $vsSdkInstallDir
    $env:VSSDKInstall = $vsSdkInstallDir
  }
}

#
# Locates Visual Studio instance that meets the minimal requirements specified by tools.vs object in global.json.
#
# The following properties of tools.vs are recognized:
#   "version": "{major}.{minor}"
#       Two part minimal VS version, e.g. "15.9", "16.0", etc.
#   "components": ["componentId1", "componentId2", ...]
#       Array of ids of workload components that must be available in the VS instance.
#       See e.g. https://docs.microsoft.com/en-us/visualstudio/install/workload-component-id-vs-enterprise?view=vs-2017
#
# Returns JSON describing the located VS instance (same format as returned by vswhere),
# or $null if no instance meeting the requirements is found on the machine.
#
function LocateVisualStudio([object]$vsRequirements = $null){
  if (-not (IsWindowsPlatform)) {
    throw "Cannot run vswhere on non-Windows platforms."
  }

  if (Get-Member -InputObject $GlobalJson.tools -Name 'vswhere') {
    $vswhereVersion = $GlobalJson.tools.vswhere
  } else {
    $vswhereVersion = '3.1.7'
  }

  $vsWhereDir = Join-Path $ToolsDir "vswhere\$vswhereVersion"
  $vsWhereExe = Join-Path $vsWhereDir 'vswhere.exe'

  if (!(Test-Path $vsWhereExe)) {
    Create-Directory $vsWhereDir
    Write-Host "Downloading vswhere $vswhereVersion"
    $ProgressPreference = 'SilentlyContinue' # Don't display the console progress UI - it's a huge perf hit
    Retry({
      Invoke-WebRequest "https://netcorenativeassets.blob.core.windows.net/resource-packages/external/windows/vswhere/$vswhereVersion/vswhere.exe" -UseBasicParsing -OutFile $vswhereExe
    })
  }

  if (!$vsRequirements) {
    if (Get-Member -InputObject $GlobalJson.tools -Name 'vs' -ErrorAction SilentlyContinue) {
      $vsRequirements = $GlobalJson.tools.vs
    } else {
      $vsRequirements = $null
    }
  }

  $args = @('-latest', '-format', 'json', '-requires', 'Microsoft.Component.MSBuild', '-products', '*')

  if (!$excludePrereleaseVS) {
    $args += '-prerelease'
  }

  if ($vsRequirements -and (Get-Member -InputObject $vsRequirements -Name 'version' -ErrorAction SilentlyContinue)) {
    $args += '-version'
    $args += $vsRequirements.version
  }

  if ($vsRequirements -and (Get-Member -InputObject $vsRequirements -Name 'components' -ErrorAction SilentlyContinue)) {
    foreach ($component in $vsRequirements.components) {
      $args += '-requires'
      $args += $component
    }
  }

  $vsInfo =& $vsWhereExe $args | ConvertFrom-Json

  if ($lastExitCode -ne 0) {
    return $null
  }

  if ($null -eq $vsInfo -or $vsInfo.Count -eq 0) {
    throw "No instance of Visual Studio meeting the requirements specified was found. Requirements: $($args -join ' ')"
    return $null
  }

  # use first matching instance
  return $vsInfo[0]
}

function InitializeBuildTool() {
  # Allow a caller (e.g. a bootstrap script running out-of-proc) to inject the build tool via
  # environment variables instead of the in-proc $global:_BuildTool variable. Only Path and
  # Command are consumed by the MSBuild function below, so those are all that's needed.
  if ($env:_BuildToolPath) {
    return $global:_BuildTool = @{
      Path    = $env:_BuildToolPath
      Command = $env:_BuildToolCommand
    }
  }

  if (Test-Path variable:global:_BuildTool) {
    # If the requested msbuild parameters do not match, clear the cached variables.
    if($global:_BuildTool.Contains('ExcludePrereleaseVS') -and $global:_BuildTool.ExcludePrereleaseVS -ne $excludePrereleaseVS) {
      Remove-Item variable:global:_BuildTool
      Remove-Item variable:global:_MSBuildExe
    } else {
      return $global:_BuildTool
    }
  }

  if (-not $msbuildEngine) {
    $msbuildEngine = GetDefaultMSBuildEngine
  }

  # Initialize dotnet cli if listed in 'tools'
  $dotnetRoot = $null
  if (Get-Member -InputObject $GlobalJson.tools -Name 'dotnet') {
    $dotnetRoot = InitializeDotNetCli -install:$restore
  }

  if ($msbuildEngine -eq 'dotnet') {
    if (!$dotnetRoot) {
      Write-PipelineTelemetryError -Category 'InitializeToolset' -Message "/global.json must specify 'tools.dotnet'."
      ExitWithExitCode 1
    }
    $dotnetPath = Join-Path $dotnetRoot (GetExecutableFileName 'dotnet')

    $buildTool = @{ Path = $dotnetPath; Command = 'msbuild' }
  } elseif ($msbuildEngine -eq "vs") {
    try {
      $msbuildPath = InitializeVisualStudioMSBuild
    } catch {
      Write-PipelineTelemetryError -Category 'InitializeToolset' -Message $_
      ExitWithExitCode 1
    }

    $buildTool = @{ Path = $msbuildPath; Command = ""; ExcludePrereleaseVS = $excludePrereleaseVS }
  } else {
    Write-PipelineTelemetryError -Category 'InitializeToolset' -Message "Unexpected value of -msbuildEngine: '$msbuildEngine'."
    ExitWithExitCode 1
  }

  return $global:_BuildTool = $buildTool
}

function GetDefaultMSBuildEngine() {
  # Presence of tools.vs indicates the repo needs to build using VS msbuild on Windows.
  if (Get-Member -InputObject $GlobalJson.tools -Name 'vs') {
    return 'vs'
  }

  if (Get-Member -InputObject $GlobalJson.tools -Name 'dotnet') {
    return 'dotnet'
  }

  Write-PipelineTelemetryError -Category 'InitializeToolset' -Message "-msbuildEngine must be specified, or /global.json must specify 'tools.dotnet' or 'tools.vs'."
  ExitWithExitCode 1
}

function InitializeNuGetPackageCachePath() {
  if ($env:NUGET_PACKAGES -eq $null) {
    # Use local cache on CI to ensure deterministic build.
    # use global cache in dev builds to avoid cost of downloading packages.
    # For directory normalization, see also: https://github.com/NuGet/Home/issues/7968
    if ($useGlobalNuGetCache) {
      $userProfile = if (IsWindowsPlatform) { $env:UserProfile } else { $env:HOME }
      $env:NUGET_PACKAGES = [IO.Path]::Combine($userProfile, '.nuget', 'packages') + [IO.Path]::DirectorySeparatorChar
    } else {
      $env:NUGET_PACKAGES = [IO.Path]::Combine($RepoRoot, '.packages') + [IO.Path]::DirectorySeparatorChar
    }
  }

  return $env:NUGET_PACKAGES
}

# Returns a full path to an Arcade SDK task project file.
function GetSdkTaskProject([string]$taskName) {
  $toolsetDir = Split-Path (InitializeToolset) -Parent
  $proj = Join-Path $toolsetDir "$taskName.proj"
  if (Test-Path $proj) {
    return $proj
  }

  throw "Unable to find $taskName.proj in toolset at: $toolsetDir"
}

function InitializeNativeTools() {
  if (-Not (Test-Path variable:DisableNativeToolsetInstalls) -And (Get-Member -InputObject $GlobalJson -Name "native-tools")) {
    $nativeArgs= @{}
    if ($ci) {
      $nativeArgs = @{
        InstallDirectory = "$ToolsDir"
      }
    }
    if ($env:NativeToolsOnMachine) {
      Write-Host "Variable NativeToolsOnMachine detected, enabling native tool path promotion..."
      $nativeArgs += @{ PathPromotion = $true }
    }
    & "$PSScriptRoot/init-tools-native.ps1" @nativeArgs
  }
}

function Read-ArcadeSdkVersion() {
  return $GlobalJson.'msbuild-sdks'.'Microsoft.DotNet.Arcade.Sdk'
}

function InitializeToolset() {
  # For Unified Build/Source-build support, check whether the environment variable is
  # set. If it is, then use this as the toolset build project.
  if ($env:_InitializeToolset -ne $null) {
    return $global:_InitializeToolset = $env:_InitializeToolset
  }

  if (Test-Path variable:global:_InitializeToolset) {
    return $global:_InitializeToolset
  }

  $toolsetVersion = Read-ArcadeSdkVersion
  $toolsetToolsDir = Join-Path $ToolsetDir $toolsetVersion

  # Check if the toolset has already been extracted
  $toolsetBuildProj = $null
  $buildProjPath = Join-Path $toolsetToolsDir 'Build.proj'

  if (Test-Path $buildProjPath) {
    $toolsetBuildProj = $buildProjPath
  }

  if ($toolsetBuildProj -ne $null) {
    return $global:_InitializeToolset = $toolsetBuildProj
  }

  if (-not $restore) {
    Write-PipelineTelemetryError -Category 'InitializeToolset' -Message "Toolset version $toolsetVersion has not been restored."
    ExitWithExitCode 1
  }

  $downloadArgs = @("package", "download", "Microsoft.DotNet.Arcade.Sdk@$toolsetVersion", "--verbosity", "minimal", "--prerelease", "--output", "$nugetPackageCachePath")
  $nugetConfig = $env:NUGET_CONFIG
  if (-not $nugetConfig) {
    # Search for any variation of nuget.config in the RepoRoot
    $configFile = Get-ChildItem -Path $RepoRoot -File | Where-Object { $_.Name -ieq "nuget.config" } | Select-Object -First 1

    if ($configFile) {
        $nugetConfig = $configFile.FullName
    }
  }

  if ($nugetConfig) {
    $downloadArgs += "--configfile"
    $downloadArgs += $nugetConfig
  }

  # 'dotnet package download' fails outright if any source in the repo's NuGet.config is
  # unavailable (for example a transport feed that was decommissioned after a release). The
  # Arcade SDK is always published to the public dotnet-eng feed, so if the config-driven
  # download fails, retry once against that feed directly (which ignores the other sources)
  # before giving up, so a single dead source doesn't block the build.
  $downloadExitCode = DotNet -ignoreFailure @downloadArgs
  if ($downloadExitCode) {
    Write-Host "Restoring the Arcade SDK from the configured sources failed; retrying from the public dotnet-eng feed."
    DotNet @downloadArgs --source "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json"
  }

  $packageDir = Join-Path $nugetPackageCachePath (Join-Path 'microsoft.dotnet.arcade.sdk' $toolsetVersion)
  $packageToolsetDir = Join-Path $packageDir 'toolset'

  if (!(Test-Path $packageToolsetDir)) {
    Write-PipelineTelemetryError -Category 'InitializeToolset' -Message "Arcade SDK package does not contain a toolset or tools folder: $packageDir"
    ExitWithExitCode 3
  }

  New-Item -ItemType Directory -Path $toolsetToolsDir -Force | Out-Null
  Copy-Item -Path "$packageToolsetDir\*" -Destination $toolsetToolsDir -Recurse -Force

  if (Test-Path $buildProjPath) {
    $toolsetBuildProj = $buildProjPath
  } else {
    throw "Unable to find Build.proj in toolset at: $toolsetToolsDir"
  }

  return $global:_InitializeToolset = $toolsetBuildProj
}

function ExitWithExitCode([int] $exitCode) {
  if ($prepareMachine) {
    Stop-Processes
  }
  exit $exitCode
}

# Check if $LASTEXITCODE is a nonzero exit code (NZEC). If so, print a Azure Pipeline error for
# diagnostics, then exit the script with the $LASTEXITCODE.
function Exit-IfNZEC([string] $category = "General") {
  Write-Host "Exit code $LASTEXITCODE"
  if ($LASTEXITCODE -ne 0) {
    $message = "Last command failed with exit code $LASTEXITCODE."
    Write-PipelineTelemetryError -Force -Category $category -Message $message
    ExitWithExitCode $LASTEXITCODE
  }
}

function Stop-Processes() {
  Write-Host 'Killing running build processes...'
  foreach ($processName in $processesToStopOnExit) {
    Get-Process -Name $processName -ErrorAction SilentlyContinue | Stop-Process
  }
}

#
# Executes msbuild (or 'dotnet msbuild') with arguments passed to the function.
# The arguments are automatically quoted.
# Terminates the script if the build fails.
#
function MSBuild() {
  if ($ci) {
    if (!$binaryLog -and !$excludeCIBinarylog) {
      Write-PipelineTelemetryError -Category 'Build' -Message 'Binary log must be enabled in CI build, or explicitly opted-out from with the -excludeCIBinarylog switch.'
      ExitWithExitCode 1
    }
  }

  $buildTool = InitializeBuildTool

  if ($pipelinesLog) {
    $toolsetBuildProject = InitializeToolset
    $basePath = Split-Path -parent $toolsetBuildProject
    $selectedPath = Join-Path $basePath (Join-Path 'net' 'Microsoft.DotNet.ArcadeLogging.dll')

    # Only inject the logger when it's present. A last-known-good Arcade used to bootstrap
    # the build may not ship the logger yet, so its absence must not be a hard error.
    # Specify the logger type explicitly so loading is deterministic.
    if (Test-Path $selectedPath) {
      $args += "/logger:Microsoft.DotNet.ArcadeLogging.PipelinesLogger,$selectedPath"
    }
  }

  $cmdArgs = "$($buildTool.Command) /m /nologo /clp:Summary /v:$verbosity /nr:$nodeReuse /p:ContinuousIntegrationBuild=$ci"

  # Add -mt flag for MSBuild multithreaded mode if enabled via environment variable
  if ($env:MSBUILD_MT_ENABLED -eq "1") {
    $cmdArgs += ' -mt'
  }

  if ($warnAsError) {
    $cmdArgs += ' /warnaserror /p:TreatWarningsAsErrors=true'
  }
  else {
    $cmdArgs += ' /p:TreatWarningsAsErrors=false'
  }

  if ($warnAsError -and $warnNotAsError) {
    $escapedWarnNotAsError = $warnNotAsError -replace ';', '%3B'
    $cmdArgs += " /warnnotaserror:$warnNotAsError /p:AdditionalWarningsNotAsErrors=$escapedWarnNotAsError"
  }

  foreach ($arg in $args) {
    if ($null -ne $arg -and $arg.Trim() -ne "") {
      if ($arg.EndsWith('\')) {
        $arg = $arg + "\"
      }
      $cmdArgs += " `"$arg`""
    }
  }

  # Be sure quote the path in case there are spaces in the dotnet installation location.
  $env:ARCADE_BUILD_TOOL_COMMAND = "`"$($buildTool.Path)`" $cmdArgs"

  $exitCode = Exec-Process $buildTool.Path $cmdArgs

  if ($exitCode -ne 0) {
    # We should not Write-PipelineTaskError here because that message shows up in the build summary
    # The build already logged an error, that's the reason it failed. Producing an error here only adds noise.
    Write-Host "Build failed with exit code $exitCode. Check errors above." -ForegroundColor Red

    # When running on Azure Pipelines, override the returned exit code to avoid double logging.
    # Skip this when the build is a child of the VMR build, or when -disablePipelineSetResult is set so the real exit code propagates.
    if ($ci -and $env:SYSTEM_TEAMPROJECT -ne $null -and !$fromVMR -and !$disablePipelineSetResult) {
      Write-PipelineSetResult -Result "Failed" -Message "msbuild execution failed."
      # Exiting with an exit code causes the azure pipelines task to log yet another "noise" error
      # The above Write-PipelineSetResult will cause the task to be marked as failure without adding yet another error
      ExitWithExitCode 0
    } else {
      ExitWithExitCode $exitCode
    }
  }
}

#
# Executes a dotnet command with arguments passed to the function.
# Terminates the script if the command fails.
#
function DotNet([switch]$ignoreFailure) {
  $dotnetRoot = InitializeDotNetCli -install:$restore
  $dotnetPath = Join-Path $dotnetRoot (GetExecutableFileName 'dotnet')

  $cmdArgs = ""
  foreach ($arg in $args) {
    if ($null -ne $arg -and $arg.Trim() -ne "") {
      if ($arg.EndsWith('\')) {
        $arg = $arg + "\"
      }
      $cmdArgs += " `"$arg`""
    }
  }

  $env:ARCADE_BUILD_TOOL_COMMAND = "`"$dotnetPath`" $cmdArgs"

  $exitCode = Exec-Process $dotnetPath $cmdArgs

  if ($exitCode -ne 0) {
    # When -ignoreFailure is set, return the exit code to the caller so it can implement
    # its own fallback logic instead of terminating the script.
    if ($ignoreFailure) {
      return $exitCode
    }

    Write-Host "dotnet command failed with exit code $exitCode. Check errors above." -ForegroundColor Red

    if ($ci -and $env:SYSTEM_TEAMPROJECT -ne $null -and !$fromVMR -and !$disablePipelineSetResult) {
      Write-PipelineSetResult -Result "Failed" -Message "dotnet command execution failed."
      ExitWithExitCode 0
    } else {
      ExitWithExitCode $exitCode
    }
  }
}

function GetExecutableFileName($baseName) {
  if (IsWindowsPlatform) {
    return "$baseName.exe"
  }
  else {
    return $baseName
  }
}

function IsWindowsPlatform() {
  return [environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
}

function Get-Darc($version) {
  $darcPath  = "$TempDir\darc\$([guid]::NewGuid())"
  if ($version -ne $null) {
    & $PSScriptRoot\darc-init.ps1 -toolpath $darcPath -darcVersion $version | Out-Host
  } else {
    & $PSScriptRoot\darc-init.ps1 -toolpath $darcPath | Out-Host
  }
  return "$darcPath\darc.exe"
}

. $PSScriptRoot\pipeline-logging-functions.ps1

$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..\')
$EngRoot = Resolve-Path (Join-Path $PSScriptRoot '..')
$ArtifactsDir = Join-Path $RepoRoot 'artifacts'
$ToolsetDir = Join-Path $ArtifactsDir 'toolset'
$ToolsDir = Join-Path $RepoRoot '.tools'
$LogDir = Join-Path (Join-Path $ArtifactsDir 'log') $configuration
$TempDir = Join-Path (Join-Path $ArtifactsDir 'tmp') $configuration
$GlobalJson = Get-Content -Raw -Path (Join-Path $RepoRoot 'global.json') | ConvertFrom-Json
# true if global.json contains a "runtimes" section
$globalJsonHasRuntimes = if ($GlobalJson.tools.PSObject.Properties.Name -Match 'runtimes') { $true } else { $false }

Create-Directory $ToolsetDir
Create-Directory $TempDir
Create-Directory $LogDir

# Direct MSBuild crash diagnostics (MSB4166 failure.txt files) to a known location
# under artifacts/log so they are captured as build artifacts in CI.
if (-not $env:MSBUILDDEBUGPATH) {
  $env:MSBUILDDEBUGPATH = Join-Path $LogDir 'MsbuildDebugLogs'
}

Write-PipelineSetVariable -Name 'Artifacts' -Value $ArtifactsDir
Write-PipelineSetVariable -Name 'Artifacts.Toolset' -Value $ToolsetDir
Write-PipelineSetVariable -Name 'Artifacts.Log' -Value $LogDir
Write-PipelineSetVariable -Name 'TEMP' -Value $TempDir
Write-PipelineSetVariable -Name 'TMP' -Value $TempDir

# Import custom tools configuration, if present in the repo.
# Note: Import in global scope so that the script set top-level variables without qualification.
if (!$disableConfigureToolsetImport) {
  $configureToolsetScript = Join-Path $EngRoot 'configure-toolset.ps1'
  if (Test-Path $configureToolsetScript) {
    . $configureToolsetScript
    if ((Test-Path variable:failOnConfigureToolsetError) -And $failOnConfigureToolsetError) {
      if ((Test-Path variable:LastExitCode) -And ($LastExitCode -ne 0)) {
        Write-PipelineTelemetryError -Category 'Build' -Message 'configure-toolset.ps1 returned a non-zero exit code'
        ExitWithExitCode $LastExitCode
      }
    }
  }
}

# Initialize the nuget package cache vars
$nugetPackageCachePath = InitializeNuGetPackageCachePath
