# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

param(
    # Skips a check that prevents building PowerShell on unsupported Linux distributions.
    [parameter(Mandatory = $false)][switch]$SkipLinuxDistroCheck = $false
)

. "$PSScriptRoot\tools\buildCommon\startNativeExecution.ps1"

# CI runs with PowerShell 5.0, so don't use features like ?: && ||
Set-StrictMode -Version 3.0

# On Unix paths is separated by colon.
# On Windows paths is separated by semicolon
$script:TestModulePathSeparator = [System.IO.Path]::PathSeparator
$script:Options = $null

$dotnetMetadata = Get-Content $PSScriptRoot/DotnetRuntimeMetadata.json | ConvertFrom-Json
$dotnetCLIChannel = $dotnetMetadata.Sdk.Channel
$dotnetCLIQuality = $dotnetMetadata.Sdk.Quality
# __DOTNET_RUNTIME_FEED and __DOTNET_RUNTIME_FEED_KEY are private variables used in release builds
$dotnetAzureFeed = if ([string]::IsNullOrWhiteSpace($env:__DOTNET_RUNTIME_FEED)) { $dotnetMetadata.Sdk.azureFeed } else { $env:__DOTNET_RUNTIME_FEED }
$dotnetAzureFeedSecret = $env:__DOTNET_RUNTIME_FEED_KEY
$dotnetSDKVersionOveride = $dotnetMetadata.Sdk.sdkImageOverride
$dotnetCLIRequiredVersion = $(Get-Content $PSScriptRoot/global.json | ConvertFrom-Json).Sdk.Version

# Track if tags have been sync'ed
$tagsUpToDate = $false

# Sync Tags
# When not using a branch in PowerShell/PowerShell, tags will not be fetched automatically
# Since code that uses Get-PSCommitID and Get-PSLatestTag assume that tags are fetched,
# This function can ensure that tags have been fetched.
# This function is used during the setup phase in tools/ci.psm1
function Sync-PSTags
{
    <#
    .SYNOPSIS
        Syncs git tags from the PowerShell/PowerShell upstream remote.
    .DESCRIPTION
        Ensures that tags from the PowerShell/PowerShell upstream remote have been fetched.
        Functions like Get-PSCommitId and Get-PSLatestTag require tags to be current.
        This is called during the setup phase in tools/ci.psm1.
    .PARAMETER AddRemoteIfMissing
        If specified, adds the upstream remote automatically when it is not present.
    #>
    param(
        [Switch]
        $AddRemoteIfMissing
    )

    $powerShellRemoteUrls = @(
        'https://github.com/PowerShell/PowerShell'
        'git@github.com:PowerShell/PowerShell'
    )
    $defaultRemoteUrl = "$($powerShellRemoteUrls[0]).git"

    $upstreamRemoteDefaultName = 'upstream'
    $remotes = Start-NativeExecution {git --git-dir="$PSScriptRoot/.git" remote}
    $upstreamRemote = $null
    foreach($remote in $remotes)
    {
        $url = Start-NativeExecution {git --git-dir="$PSScriptRoot/.git" remote get-url $remote}
        if ($url.EndsWith('.git')) { $url = $url.Substring(0, $url.Length - 4) }

        if($url -in $powerShellRemoteUrls)
        {
            $upstreamRemote = $remote
            break
        }
    }

    if(!$upstreamRemote -and $AddRemoteIfMissing.IsPresent -and $remotes -notcontains $upstreamRemoteDefaultName)
    {
        $null = Start-NativeExecution {git --git-dir="$PSScriptRoot/.git" remote add $upstreamRemoteDefaultName $defaultRemoteUrl}
        $upstreamRemote = $upstreamRemoteDefaultName
    }
    elseif(!$upstreamRemote)
    {
        Write-Error "Please add a remote to PowerShell\PowerShell.  Example:  git remote add $upstreamRemoteDefaultName $defaultRemoteUrl" -ErrorAction Stop
    }

    $null = Start-NativeExecution {git --git-dir="$PSScriptRoot/.git" fetch --tags --quiet $upstreamRemote}
    $script:tagsUpToDate=$true
}

# Gets the latest tag for the current branch
function Get-PSLatestTag
{
    <#
    .SYNOPSIS
        Gets the latest git tag reachable from the current HEAD.
    .DESCRIPTION
        Returns the most recent annotated git tag. Run Sync-PSTags first to ensure tags
        are up to date; otherwise a warning is emitted.
    .OUTPUTS
        System.String. The latest tag string, e.g. 'v7.5.0'.
    #>
    [CmdletBinding()]
    param()
    # This function won't always return the correct value unless tags have been sync'ed
    # So, Write a warning to run Sync-PSTags
    if(!$tagsUpToDate)
    {
        Write-Warning "Run Sync-PSTags to update tags"
    }

    return (Start-NativeExecution {git --git-dir="$PSScriptRoot/.git" describe --abbrev=0})
}

function Get-PSVersion
{
    <#
    .SYNOPSIS
        Returns the PowerShell version string for the current commit.
    .DESCRIPTION
        Derives the version from the latest git tag, optionally omitting the commit-ID suffix.
    .PARAMETER OmitCommitId
        When specified, returns only the bare version (e.g. '7.5.0') from the latest tag,
        without the commit-count and hash suffix appended by git describe.
    .OUTPUTS
        System.String. A version string such as '7.5.0' or '7.5.0-15-gabcdef1234'.
    #>
    [CmdletBinding()]
    param(
        [switch]
        $OmitCommitId
    )
    if($OmitCommitId.IsPresent)
    {
        return (Get-PSLatestTag) -replace '^v'
    }
    else
    {
        return (Get-PSCommitId) -replace '^v'
    }
}

function Get-PSCommitId
{
    <#
    .SYNOPSIS
        Returns the PowerShell commit-ID string produced by git describe.
    .DESCRIPTION
        Returns the full git describe string including the tag, number of commits since
        the tag, and the abbreviated commit hash (e.g. 'v7.5.0-15-gabcdef1234567890').
        Run Sync-PSTags first; otherwise a warning is emitted.
    .OUTPUTS
        System.String. A git describe string such as 'v7.5.0-15-gabcdef1234567890'.
    #>
    [CmdletBinding()]
    param()
    # This function won't always return the correct value unless tags have been sync'ed
    # So, Write a warning to run Sync-PSTags
    if(!$tagsUpToDate)
    {
        Write-Warning "Run Sync-PSTags to update tags"
    }

    return (Start-NativeExecution {git --git-dir="$PSScriptRoot/.git" describe --dirty --abbrev=60})
}

function Get-EnvironmentInformation
{
    <#
    .SYNOPSIS
        Collects information about the current operating environment.
    .DESCRIPTION
        Returns a PSCustomObject containing OS-identity flags, architecture, admin status,
        NuGet package root paths, and Linux distribution details. The object is used
        throughout the build module to make platform-conditional decisions.
    .OUTPUTS
        System.Management.Automation.PSCustomObject. An object with properties such as
        IsWindows, IsLinux, IsMacOS, IsAdmin, OSArchitecture, and distribution-specific flags
        (IsUbuntu, IsDebian, IsRedHatFamily, etc.).
    #>
    param()
    $environment = @{'IsWindows' = [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT}
    # PowerShell will likely not be built on pre-1709 nanoserver
    if ('System.Management.Automation.Platform' -as [type]) {
        $environment += @{'IsCoreCLR' = [System.Management.Automation.Platform]::IsCoreCLR}
        $environment += @{'IsLinux' = [System.Management.Automation.Platform]::IsLinux}
        $environment += @{'IsMacOS' = [System.Management.Automation.Platform]::IsMacOS}
    } else {
        $environment += @{'IsCoreCLR' = $false}
        $environment += @{'IsLinux' = $false}
        $environment += @{'IsMacOS' = $false}
    }

    if ($environment.IsWindows)
    {
        $environment += @{'IsAdmin' = (New-Object Security.Principal.WindowsPrincipal ([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)}
        $environment += @{'nugetPackagesRoot' = "${env:USERPROFILE}\.nuget\packages", "${env:NUGET_PACKAGES}"}
        $environment += @{ 'OSArchitecture' = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture }
    }
    else
    {
        $environment += @{'nugetPackagesRoot' = "${env:HOME}/.nuget/packages"}
    }

    if ($environment.IsMacOS) {
        $environment += @{'UsingHomebrew' = [bool](Get-Command brew -ErrorAction ignore)}
        $environment += @{'UsingMacports' = [bool](Get-Command port -ErrorAction ignore)}

        $environment += @{
            'OSArchitecture' = if ((uname -v) -match 'ARM64') { 'arm64' } else { 'x64' }
        }

        if (-not($environment.UsingHomebrew -or $environment.UsingMacports)) {
            throw "Neither Homebrew nor MacPorts is installed on this system, visit https://brew.sh/ or https://www.macports.org/ to continue"
        }
    }

    if ($environment.IsLinux) {
        $environment += @{ 'OSArchitecture' = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture }
        $LinuxInfo = Get-Content /etc/os-release -Raw | ConvertFrom-StringData
        $lsb_release = Get-Command lsb_release -Type Application -ErrorAction Ignore | Select-Object -First 1
        if ($lsb_release) {
            $LinuxID = & $lsb_release -is
        }
        else {
            $LinuxID = ""
        }

        $environment += @{'LinuxInfo' = $LinuxInfo}
        $environment += @{'IsDebian' = $LinuxInfo.ID -match 'debian' -or $LinuxInfo.ID -match 'kali'}
        $environment += @{'IsDebian9' = $environment.IsDebian -and $LinuxInfo.VERSION_ID -match '9'}
        $environment += @{'IsDebian10' = $environment.IsDebian -and $LinuxInfo.VERSION_ID -match '10'}
        $environment += @{'IsDebian11' = $environment.IsDebian -and $LinuxInfo.PRETTY_NAME -match 'bullseye'}
        $environment += @{'IsUbuntu' = $LinuxInfo.ID -match 'ubuntu' -or $LinuxID -match 'Ubuntu'}
        $environment += @{'IsUbuntu16' = $environment.IsUbuntu -and $LinuxInfo.VERSION_ID -match '16.04'}
        $environment += @{'IsUbuntu18' = $environment.IsUbuntu -and $LinuxInfo.VERSION_ID -match '18.04'}
        $environment += @{'IsUbuntu20' = $environment.IsUbuntu -and $LinuxInfo.VERSION_ID -match '20.04'}
        $environment += @{'IsUbuntu22' = $environment.IsUbuntu -and $LinuxInfo.VERSION_ID -match '22.04'}
        $environment += @{'IsUbuntu24' = $environment.IsUbuntu -and $LinuxInfo.VERSION_ID -match '24.04'}
        $environment += @{'IsCentOS' = $LinuxInfo.ID -match 'centos' -and $LinuxInfo.VERSION_ID -match '7'}
        $environment += @{'IsFedora' = $LinuxInfo.ID -match 'fedora' -and $LinuxInfo.VERSION_ID -ge 24}
        $environment += @{'IsOpenSUSE' = $LinuxInfo.ID -match 'opensuse'}
        $environment += @{'IsSLES' = $LinuxInfo.ID -match 'sles'}
        $environment += @{'IsRedHat' = $LinuxInfo.ID -match 'rhel'}
        $environment += @{'IsRedHat7' = $environment.IsRedHat -and $LinuxInfo.VERSION_ID -match '7' }
        $environment += @{'IsOpenSUSE13' = $environment.IsOpenSUSE -and $LinuxInfo.VERSION_ID  -match '13'}
        $environment += @{'IsOpenSUSE42.1' = $environment.IsOpenSUSE -and $LinuxInfo.VERSION_ID  -match '42.1'}
        $environment += @{'IsDebianFamily' = $environment.IsDebian -or $environment.IsUbuntu}
        $environment += @{'IsRedHatFamily' = $environment.IsCentOS -or $environment.IsFedora -or $environment.IsRedHat}
        $environment += @{'IsSUSEFamily' = $environment.IsSLES -or $environment.IsOpenSUSE}
        $environment += @{'IsAlpine' = $LinuxInfo.ID -match 'alpine'}
        $environment += @{'IsMariner' = $LinuxInfo.ID -match 'mariner' -or $LinuxInfo.ID -match 'azurelinux'}

        # Workaround for temporary LD_LIBRARY_PATH hack for Fedora 24
        # https://github.com/PowerShell/PowerShell/issues/2511
        if ($environment.IsFedora -and (Test-Path ENV:\LD_LIBRARY_PATH)) {
            Remove-Item -Force ENV:\LD_LIBRARY_PATH
            Get-ChildItem ENV:
        }

        if( -not(
            $environment.IsDebian -or
            $environment.IsUbuntu -or
            $environment.IsRedHatFamily -or
            $environment.IsSUSEFamily -or
            $environment.IsAlpine -or
            $environment.IsMariner)
        ) {
            if ($SkipLinuxDistroCheck) {
                Write-Warning "The current OS : $($LinuxInfo.ID) is not supported for building PowerShell."
            } else {
                throw "The current OS : $($LinuxInfo.ID) is not supported for building PowerShell. Import this module with '-ArgumentList `$true' to bypass this check."
            }
        }
    }

    return [PSCustomObject] $environment
}

$environment = Get-EnvironmentInformation

# Autoload (in current session) temporary modules used in our tests
$TestModulePath = Join-Path $PSScriptRoot "test/tools/Modules"
if ( -not $env:PSModulePath.Contains($TestModulePath) ) {
    $env:PSModulePath = $TestModulePath+$TestModulePathSeparator+$($env:PSModulePath)
}

<#
    .Synopsis
        Tests if a version is preview
    .EXAMPLE
        Test-IsPreview -version '6.1.0-sometthing' # returns true
        Test-IsPreview -version '6.1.0' # returns false
#>
function Test-IsPreview
{
    param(
        [parameter(Mandatory)]
        [string]
        $Version,

        [switch]$IsLTS
    )

    if ($IsLTS.IsPresent) {
        ## If we are building a LTS package, then never consider it preview.
        return $false
    }

    return $Version -like '*-*'
}

<#
    .Synopsis
        Tests if a version is a Release Candidate
    .EXAMPLE
        Test-IsReleaseCandidate -version '6.1.0-sometthing' # returns false
        Test-IsReleaseCandidate -version '6.1.0-rc.1' # returns true
        Test-IsReleaseCandidate -version '6.1.0' # returns false
#>
function Test-IsReleaseCandidate
{
    param(
        [parameter(Mandatory)]
        [string]
        $Version
    )

    if ($Version -like '*-rc.*')
    {
        return $true
    }

    return $false
}

$optimizedFddRegex = 'fxdependent-(linux|win|win7|osx)-(x64|x86|arm64|arm)'

function Start-PSBuild {
    <#
    .SYNOPSIS
        Builds PowerShell from source using dotnet publish.
    .DESCRIPTION
        Compiles the PowerShell source tree for the specified runtime and configuration.
        Optionally restores NuGet packages, regenerates resources, generates the type catalog,
        and restores Gallery modules. Saves build options so subsequent commands can reuse them.
    .PARAMETER StopDevPowerShell
        Stops any running dev pwsh process before building to prevent file-in-use errors.
    .PARAMETER Restore
        Forces NuGet package restore even when packages already exist.
    .PARAMETER Output
        Path to the output directory. Defaults to the standard build location.
    .PARAMETER ResGen
        Regenerates C# bindings for resx resource files before building.
    .PARAMETER TypeGen
        Regenerates the CorePsTypeCatalog.cs type-catalog file before building.
    .PARAMETER Clean
        Runs 'git clean -fdX' to remove untracked and ignored files before building.
    .PARAMETER PSModuleRestore
        Restores PowerShell Gallery modules to the build output directory (legacy parameter set).
    .PARAMETER NoPSModuleRestore
        Skips restoring PowerShell Gallery modules to the build output directory.
    .PARAMETER CI
        Indicates a CI build; restores the Pester module to the output directory.
    .PARAMETER ForMinimalSize
        Produces a build optimized for minimal binary size (linux-x64, win7-x64, or osx-x64 only).
    .PARAMETER ForceProduceLocalizedResources
        Forces the build to produce satellite assemblies for all cultures, even when not required.
    .PARAMETER SkipExperimentalFeatureGeneration
        Skips the step that runs the built pwsh to produce the experimental-features list.
    .PARAMETER SMAOnly
        Rebuilds only System.Management.Automation.dll for rapid engine iteration.
    .PARAMETER UseNuGetOrg
        Uses nuget.org instead of the private PowerShell feed for package restore.
    .PARAMETER Runtime
        The .NET runtime identifier (RID) to target, e.g. 'win7-x64' or 'linux-x64'.
    .PARAMETER Configuration
        The build configuration: Debug, Release, CodeCoverage, or StaticAnalysis.
    .PARAMETER ReleaseTag
        A git tag in 'vX.Y.Z[-preview.N|-rc.N]' format to embed as the release version.
    .PARAMETER Detailed
        Passes '--verbosity d' to dotnet for detailed build output.
    .PARAMETER InteractiveAuth
        Passes '--interactive' to dotnet restore for interactive feed authentication.
    .PARAMETER SkipRoslynAnalyzers
        Skips Roslyn analyzer execution during the build.
    .PARAMETER PSOptionsPath
        When supplied, saves the resolved build options to this JSON file path.
    #>
    [CmdletBinding(DefaultParameterSetName="Default")]
    param(
        # When specified this switch will stops running dev powershell
        # to help avoid compilation error, because file are in use.
        [switch]$StopDevPowerShell,

        [switch]$Restore,
        # Accept a path to the output directory
        # When specified, --output <path> will be passed to dotnet
        [string]$Output,
        [switch]$ResGen,
        [switch]$TypeGen,
        [switch]$Clean,
        [Parameter(ParameterSetName="Legacy")]
        [switch]$PSModuleRestore,
        [Parameter(ParameterSetName="Default")]
        [switch]$NoPSModuleRestore,
        [switch]$CI,
        [switch]$ForMinimalSize,
        [switch]$ForceProduceLocalizedResources,

        # Skips the step where the pwsh that's been built is used to create a configuration
        # Useful when changing parsing/compilation, since bugs there can mean we can't get past this step
        [switch]$SkipExperimentalFeatureGeneration,

        # this switch will re-build only System.Management.Automation.dll
        # it's useful for development, to do a quick changes in the engine
        [switch]$SMAOnly,

        # Use nuget.org instead of the PowerShell specific feed
        [switch]$UseNuGetOrg,

        # These runtimes must match those in project.json
        # We do not use ValidateScript since we want tab completion
        # If this parameter is not provided it will get determined automatically.
        [ValidateSet("linux-musl-x64",
                     "fxdependent",
                     "fxdependent-noopt-linux-musl-x64",
                     "fxdependent-linux-x64",
                     "fxdependent-linux-arm64",
                     "fxdependent-win-desktop",
                     "linux-arm",
                     "linux-arm64",
                     "linux-x64",
                     "osx-arm64",
                     "osx-x64",
                     "win-arm",
                     "win-arm64",
                     "win7-x64",
                     "win7-x86")]
        [string]$Runtime,

        [ValidateSet('Debug', 'Release', 'CodeCoverage', 'StaticAnalysis', '')] # We might need "Checked" as well
        [string]$Configuration,

        [ValidatePattern("^v\d+\.\d+\.\d+(-\w+(\.\d{1,2})?)?$")]
        [ValidateNotNullOrEmpty()]
        [string]$ReleaseTag,
        [switch]$Detailed,
        [switch]$InteractiveAuth,
        [switch]$SkipRoslynAnalyzers,
        [string]$PSOptionsPath
    )

    if ($ReleaseTag -and $ReleaseTag -notmatch "^v\d+\.\d+\.\d+(-(preview|rc)(\.\d{1,2})?)?$") {
        Write-Warning "Only preview or rc are supported for releasing pre-release version of PowerShell"
    }

    if ($PSCmdlet.ParameterSetName -eq "Default" -and !$NoPSModuleRestore)
    {
        $PSModuleRestore = $true
    }

    if ($Runtime -eq "linux-arm" -and $environment.IsLinux -and -not $environment.IsUbuntu -and -not $environment.IsMariner) {
        throw "Cross compiling for linux-arm is only supported on AzureLinux/Ubuntu environment"
    }

    if ("win-arm","win-arm64" -contains $Runtime -and -not $environment.IsWindows) {
        throw "Cross compiling for win-arm or win-arm64 is only supported on Windows environment"
    }

    if ($ForMinimalSize) {
        if ($Runtime -and "linux-x64", "linux-arm64", "win7-x64", "win-arm64", "osx-x64", "osx-arm64" -notcontains $Runtime) {
            throw "Build for the minimal size is enabled only for following runtimes: 'linux-x64', 'linux-arm64', 'win7-x64', 'win-arm64', 'osx-x64', 'osx-arm64'"
        }
    }

    if ($UseNuGetOrg) {
        Switch-PSNugetConfig -Source Public
    } else {
        Write-Verbose -Message "Using default feeds which are Microsoft, use `-UseNuGetOrg` to switch to Public feeds" -Verbose
    }

    function Stop-DevPowerShell {
        Get-Process pwsh* |
            Where-Object {
                $_.Modules |
                Where-Object {
                    $_.FileName -eq (Resolve-Path $script:Options.Output).Path
                }
            } |
        Stop-Process -Verbose
    }

    if ($Clean) {
        Write-LogGroupStart -Title "Cleaning your working directory"
        Push-Location $PSScriptRoot
        try {
            # Excluded sqlite3 folder is due to this Roslyn issue: https://github.com/dotnet/roslyn/issues/23060
            # Excluded src/Modules/nuget.config as this is required for release build.
            # Excluded nuget.config as this is required for release build.
            git clean -fdX --exclude .vs/PowerShell/v16/Server/sqlite3 --exclude src/Modules/nuget.config  --exclude nuget.config
        } finally {
            Write-LogGroupEnd -Title "Cleaning your working directory"
            Pop-Location
        }
    }

    # Add .NET CLI tools to PATH
    Find-Dotnet

    # Verify we have git in place to do the build, and abort if the precheck failed
    $precheck = precheck 'git' "Build dependency 'git' not found in PATH. See <URL: https://docs.github.com/en/github/getting-started-with-github/set-up-git#setting-up-git >"
    if (-not $precheck) {
        return
    }

    # Verify we have .NET SDK in place to do the build, and abort if the precheck failed
    $precheck = precheck 'dotnet' "Build dependency 'dotnet' not found in PATH. Run Start-PSBootstrap. Also see <URL: https://dotnet.github.io/getting-started/ >"
    if (-not $precheck) {
        return
    }

    # Verify if the dotnet in-use is the required version
    $dotnetCLIInstalledVersion = Find-RequiredSDK $dotnetCLIRequiredVersion

    If ($dotnetCLIInstalledVersion -ne $dotnetCLIRequiredVersion) {
        Write-Warning @"
The currently installed .NET Command Line Tools is not the required version.

Installed version: $dotnetCLIInstalledVersion
Required version: $dotnetCLIRequiredVersion

Fix steps:

1. Remove the installed version from:
    - on windows '`$env:LOCALAPPDATA\Microsoft\dotnet'
    - on macOS and linux '`$env:HOME/.dotnet'
2. Run Start-PSBootstrap or Install-Dotnet
3. Start-PSBuild -Clean
`n
"@
        return
    }

    # set output options
    $OptionsArguments = @{
        Output=$Output
        Runtime=$Runtime
        Configuration=$Configuration
        Verbose=$true
        SMAOnly=[bool]$SMAOnly
        PSModuleRestore=$PSModuleRestore
        ForMinimalSize=$ForMinimalSize
    }

    $script:Options = New-PSOptions @OptionsArguments

    if ($StopDevPowerShell) {
        Stop-DevPowerShell
    }

    # Setup arguments
    # Added ErrorOnDuplicatePublishOutputFiles=false due to .NET SDK issue: https://github.com/dotnet/sdk/issues/15748
    # Removed the option "--no-restore" due to .NET SDK issue: https://github.com/dotnet/sdk/issues/18999
    $Arguments = @("publish", "/property:GenerateFullPaths=true", "/property:ErrorOnDuplicatePublishOutputFiles=false")

    # Today, we only support localization for MSIX packages. For Linux and macOS packages, fxdependent and min-size packages,
    # as well as .zip packages, we only support the default en-US culture.
    # Therefore, we only produce satellite assemblies for win7-x64, win7-x86, and win-arm64 by default (excluding min-size build),
    # unless the caller wants to force produce localized resources.
    if (!$ForceProduceLocalizedResources -and ($Options.Runtime -notmatch '^(win7-x64|win7-x86|win-arm64)$' -or $ForMinimalSize)) {
        # Disable satellite assemblies for other cultures.
        $Arguments += "/property:SatelliteResourceLanguages=en"
    }

    if ($Output -or $SMAOnly) {
        $Arguments += "--output", (Split-Path $Options.Output)
    }

    # Add --self-contained due to "warning NETSDK1179: One of '--self-contained' or '--no-self-contained' options are required when '--runtime' is used."
    if ($Options.Runtime -like 'fxdependent*') {
        $Arguments += "--no-self-contained"
        # The UseAppHost = true property creates ".exe" for the fxdependent packages.
        # We need this in the package as Start-Job needs it.
        $Arguments += "/property:UseAppHost=true"
    }
    else {
        $Arguments += "--self-contained"
    }

    if ($Options.Runtime -like 'win*') {
        # Starting in .NET 8, the .NET SDK won't recognize version-specific RIDs by default, such as win7-x64,
        # see https://learn.microsoft.com/dotnet/core/compatibility/sdk/8.0/rid-graph for details.
        # It will cause huge amount of changes in our build infrastructure because our building and packaging
        # scripts have the 'win7-xx' assumption regarding the target runtime.
        #
        # As a workaround, we use the old full RID graph during the build so that we can continue to use the
        # 'win7-x64' and 'win7-x86' RIDs.
        $Arguments += "/property:UseRidGraph=true"
    }

    if ($Options.Runtime -like 'win*' -or ($Options.Runtime -like 'fxdependent*' -and $environment.IsWindows)) {
        $Arguments += "/property:IsWindows=true"
        if(!$environment.IsWindows) {
            $Arguments += "/property:EnableWindowsTargeting=true"
        }
    }
    else {
        $Arguments += "/property:IsWindows=false"
    }

    # We pass in the AppDeployment property to indicate which type of deployment we are doing.
    # This allows the PowerShell.Common.props to set the correct properties for the build.
    $AppDeployment = if($Options.Runtime -like 'fxdependent*' -and $Options.Runtime -notmatch $optimizedFddRegex) {
        # Global and zip files
        "FxDependent"
    }
    elseif($Options.Runtime -like 'fxdependent*' -and $Options.Runtime -match $optimizedFddRegex) {
        # These are Optimized and must come from the correct version of the runtime.
        # Global
        "FxDependentDeployment"
    }
    else {
        # The majority of our packages
        # AppLocal
        "SelfContained"
    }

    $Arguments += "/property:AppDeployment=$AppDeployment"

    if ($ForMinimalSize) {
        # Skip Ready-to-Run compilation in 'PowerShell.Common.props' to keep binary size smaller
        $Arguments += "/property:ForMinimalSize=True"
    }

    $Arguments += "--configuration", $Options.Configuration
    $Arguments += "--framework", $Options.Framework

    if ($Detailed.IsPresent)
    {
        $Arguments += '--verbosity', 'd'
    }

    if (-not $SMAOnly -and $Options.Runtime -notlike 'fxdependent*') {
        # libraries should not have runtime
        $Arguments += "--runtime", $Options.Runtime
    } elseif ($Options.Runtime -match $optimizedFddRegex) {
        $runtime = $Options.Runtime -replace 'fxdependent-', ''
        $Arguments += "--runtime", $runtime
    }

    if ($ReleaseTag) {
        $ReleaseTagToUse = $ReleaseTag -Replace '^v'
        $Arguments += "/property:ReleaseTag=$ReleaseTagToUse"
    }

    if ($SkipRoslynAnalyzers) {
        $Arguments += "/property:RunAnalyzersDuringBuild=false"
    }

    # handle Restore
    Write-LogGroupStart -Title "Restore NuGet Packages"
    Restore-PSPackage -Options $Options -Force:$Restore -InteractiveAuth:$InteractiveAuth
    Write-LogGroupEnd -Title "Restore NuGet Packages"

    # handle ResGen
    # Heuristic to run ResGen on the fresh machine
    if ($ResGen -or -not (Test-Path "$PSScriptRoot/src/Microsoft.PowerShell.ConsoleHost/gen")) {
        Write-Log -message "Run ResGen (generating C# bindings for resx files)"
        Start-ResGen
    }

    # Handle TypeGen
    # .inc file name must be different for Windows and Linux to allow build on Windows and WSL.
    $runtime = $Options.Runtime
    if ($Options.Runtime -match $optimizedFddRegex) {
        $runtime = $Options.Runtime -replace 'fxdependent-', ''
    }

    $incFileName = "powershell_$runtime.inc"
    if ($TypeGen -or -not (Test-Path "$PSScriptRoot/src/TypeCatalogGen/$incFileName")) {
        Write-Log -message "Run TypeGen (generating CorePsTypeCatalog.cs)"
        Start-TypeGen -IncFileName $incFileName
    }

    # Get the folder path where pwsh.exe is located.
    if ((Split-Path $Options.Output -Leaf) -like "pwsh*") {
        $publishPath = Split-Path $Options.Output -Parent
    }
    else {
        $publishPath = $Options.Output
    }

    Write-LogGroupStart -Title "Build PowerShell"
    try {
        # Relative paths do not work well if cwd is not changed to project
        Push-Location $Options.Top

        if ($Options.Runtime -notlike 'fxdependent*' -or $Options.Runtime -match $optimizedFddRegex) {
            Write-Verbose "Building without shim" -Verbose
            $sdkToUse = 'Microsoft.NET.Sdk'
            if (($Options.Runtime -like 'win7-*' -or $Options.Runtime -eq 'win-arm64') -and !$ForMinimalSize) {
                ## WPF/WinForm and the PowerShell GraphicalHost assemblies are included
                ## when 'Microsoft.NET.Sdk.WindowsDesktop' is used.
                $sdkToUse = 'Microsoft.NET.Sdk.WindowsDesktop'
            }

            $Arguments += "/property:SDKToUse=$sdkToUse"

            Write-Log -message "Run dotnet $Arguments from $PWD"
            Start-NativeExecution { dotnet $Arguments }
            Write-Log -message "PowerShell output: $($Options.Output)"
        } else {
            Write-Verbose "Building with shim" -Verbose
            $globalToolSrcFolder = Resolve-Path (Join-Path $Options.Top "../Microsoft.PowerShell.GlobalTool.Shim") | Select-Object -ExpandProperty Path

            if ($Options.Runtime -eq 'fxdependent' -or $Options.Runtime -eq 'fxdependent-noopt-linux-musl-x64') {
                $Arguments += "/property:SDKToUse=Microsoft.NET.Sdk"
            } elseif ($Options.Runtime -eq 'fxdependent-win-desktop') {
                $Arguments += "/property:SDKToUse=Microsoft.NET.Sdk.WindowsDesktop"
            }

            Write-Log -message "Run dotnet $Arguments from $PWD"
            Start-NativeExecution { dotnet $Arguments }
            Write-Log -message "PowerShell output: $($Options.Output)"

            try {
                Push-Location $globalToolSrcFolder

                if ($Options.Runtime -like 'fxdependent*') {
                    if ($Arguments -contains '/property:UseAppHost=true') {
                        $Arguments = @($Arguments | Where-Object { $_ -notlike '/property:UseAppHost=true' })
                    }
                }

                if ($Arguments -notcontains '--output') {
                    $Arguments += "--output", $publishPath
                }
                Write-Log -message "Run dotnet $Arguments from $PWD to build global tool entry point"
                Start-NativeExecution { dotnet $Arguments }
            }
            finally {
                Pop-Location
            }
        }
    } finally {
        Pop-Location
    }
    Write-LogGroupEnd -Title "Build PowerShell"

    # No extra post-building task will run if '-SMAOnly' is specified, because its purpose is for a quick update of S.M.A.dll after full build.
    if ($SMAOnly) {
        return
    }

    # publish reference assemblies
    Write-LogGroupStart -Title "Publish Reference Assemblies"
    try {
        Push-Location "$PSScriptRoot/src/TypeCatalogGen"
        $refAssemblies = Get-Content -Path $incFileName | Where-Object { $_ -like "*microsoft.netcore.app*" } | ForEach-Object { $_.TrimEnd(';') }
        $refDestFolder = Join-Path -Path $publishPath -ChildPath "ref"

        if (Test-Path $refDestFolder -PathType Container) {
            Remove-Item $refDestFolder -Force -Recurse -ErrorAction Stop
        }
        New-Item -Path $refDestFolder -ItemType Directory -Force -ErrorAction Stop > $null
        Copy-Item -Path $refAssemblies -Destination $refDestFolder -Force -ErrorAction Stop
    } finally {
        Pop-Location
    }
    Write-LogGroupEnd -Title "Publish Reference Assemblies"

    if ($ReleaseTag) {
        $psVersion = $ReleaseTag
    }
    else {
        $psVersion = git --git-dir="$PSScriptRoot/.git" describe
    }

    if ($environment.IsLinux) {
        if ($environment.IsRedHatFamily -or $environment.IsDebian) {
            # Symbolic links added here do NOT affect packaging as we do not build on Debian.
            # add two symbolic links to system shared libraries that libmi.so is dependent on to handle
            # platform specific changes. This is the only set of platforms needed for this currently
            # as Ubuntu has these specific library files in the platform and macOS builds for itself
            # against the correct versions.

            if ($environment.IsDebian10 -or $environment.IsDebian11){
                $sslTarget = "/usr/lib/x86_64-linux-gnu/libssl.so.1.1"
                $cryptoTarget = "/usr/lib/x86_64-linux-gnu/libcrypto.so.1.1"
            }
            elseif ($environment.IsDebian9){
                # NOTE: Debian 8 doesn't need these symlinks
                $sslTarget = "/usr/lib/x86_64-linux-gnu/libssl.so.1.0.2"
                $cryptoTarget = "/usr/lib/x86_64-linux-gnu/libcrypto.so.1.0.2"
            }
            else { #IsRedHatFamily
                $sslTarget = "/lib64/libssl.so.10"
                $cryptoTarget = "/lib64/libcrypto.so.10"
            }

            if ( ! (Test-Path "$publishPath/libssl.so.1.0.0")) {
                $null = New-Item -Force -ItemType SymbolicLink -Target $sslTarget -Path "$publishPath/libssl.so.1.0.0" -ErrorAction Stop
            }
            if ( ! (Test-Path "$publishPath/libcrypto.so.1.0.0")) {
                $null = New-Item -Force -ItemType SymbolicLink -Target $cryptoTarget -Path "$publishPath/libcrypto.so.1.0.0" -ErrorAction Stop
            }
        }
    }

    # download modules from powershell gallery.
    #   - PowerShellGet, PackageManagement, Microsoft.PowerShell.Archive
    if ($PSModuleRestore) {
        Write-LogGroupStart -Title "Restore PowerShell Modules"
        Restore-PSModuleToBuild -PublishPath $publishPath
        Write-LogGroupEnd -Title "Restore PowerShell Modules"
    }

    # publish powershell.config.json
    Write-LogGroupStart -Title "Generate PowerShell Configuration"
    $config = [ordered]@{}

    if ($Options.Runtime -like "*win*") {
        # Execution Policy and WinCompat feature are only supported on Windows.
        $config.Add("Microsoft.PowerShell:ExecutionPolicy", "RemoteSigned")
        $config.Add("WindowsPowerShellCompatibilityModuleDenyList", @("PSScheduledJob", "BestPractices", "UpdateServices"))
    }

    if (-not $SkipExperimentalFeatureGeneration -and
        (Test-IsPreview $psVersion) -and
        -not (Test-IsReleaseCandidate $psVersion)
    ) {
        if ((Test-ShouldGenerateExperimentalFeatures -Runtime $Options.Runtime)) {
            Write-Verbose "Build experimental feature list by running 'Get-ExperimentalFeature'" -Verbose
            $json = & $publishPath\pwsh -noprofile -command {
                $expFeatures = Get-ExperimentalFeature | ForEach-Object -MemberName Name
                ConvertTo-Json $expFeatures
            }
        } else {
            Write-Verbose "Build experimental feature list by using the pre-generated JSON files" -Verbose
            $ExperimentalFeatureJsonFilePath = if ($Options.Runtime -like "*win*") {
                "$PSScriptRoot/experimental-feature-windows.json"
            } else {
                "$PSScriptRoot/experimental-feature-linux.json"
            }

            if (-not (Test-Path $ExperimentalFeatureJsonFilePath)) {
                throw "ExperimentalFeatureJsonFilePath: $ExperimentalFeatureJsonFilePath does not exist"
            }

            $json = Get-Content -Raw $ExperimentalFeatureJsonFilePath
        }

        $config.Add('ExperimentalFeatures', [string[]]($json | ConvertFrom-Json));

    } else {
        Write-Warning -Message "Experimental features are not enabled in powershell.config.json file"
    }

    if ($config.Count -gt 0) {
        $configPublishPath = Join-Path -Path $publishPath -ChildPath "powershell.config.json"
        Set-Content -Path $configPublishPath -Value ($config | ConvertTo-Json) -Force -ErrorAction Stop
    } else {
        Write-Warning "No powershell.config.json generated for $publishPath"
    }
    Write-LogGroupEnd -Title "Generate PowerShell Configuration"

    # Restore the Pester module
    if ($CI) {
        Write-LogGroupStart -Title "Restore Pester Module"
        Restore-PSPester -Destination (Join-Path $publishPath "Modules")
        Write-LogGroupEnd -Title "Restore Pester Module"
    }

    Clear-NativeDependencies -PublishFolder $publishPath

    if ($PSOptionsPath) {
        $resolvedPSOptionsPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($PSOptionsPath)
        $parent = Split-Path -Path $resolvedPSOptionsPath
        if (!(Test-Path $parent)) {
            $null = New-Item -ItemType Directory -Path $parent
        }
        Save-PSOptions -PSOptionsPath $PSOptionsPath -Options $Options
    }
}

function Switch-PSNugetConfig {
    <#
    .SYNOPSIS
        Switches the NuGet configuration between public, private, and NuGet.org-only sources.
    .DESCRIPTION
        Regenerates nuget.config files in the repository root, src/Modules, and test/tools/Modules
        to point to the specified feed source. Optionally stores authenticated credentials.
    .PARAMETER Source
        The feed set to activate: 'Public' (nuget.org + dotnet feed), 'Private' (PowerShell ADO
        feed), or 'NuGetOnly' (nuget.org only).
    .PARAMETER UserName
        Username for authenticated private feed access.
    .PARAMETER ClearTextPAT
        Personal access token in clear text for authenticated private feed access.
    #>
    param(
        [Parameter(Mandatory = $true, ParameterSetName = 'user')]
        [Parameter(Mandatory = $true, ParameterSetName = 'nouser')]
        [ValidateSet('Public', 'Private', 'NuGetOnly', 'EarlyAccess')]
        [string] $Source,

        [Parameter(Mandatory = $true, ParameterSetName = 'user')]
        [string] $UserName,

        [Parameter(Mandatory = $true, ParameterSetName = 'user')]
        [string] $ClearTextPAT
    )

    Clear-PipelineNugetAuthentication

    $extraParams = @()
    if ($UserName) {
        $extraParams = @{
            UserName     = $UserName
            ClearTextPAT = $ClearTextPAT
        }
    }

    $dotnetSdk = [NugetPackageSource] @{Url = 'https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v2'; Name = 'dotnet' }
    $gallery = [NugetPackageSource] @{Url = 'https://www.powershellgallery.com/api/v2/'; Name = 'psgallery' }
    $nugetorg = [NugetPackageSource] @{Url = 'https://api.nuget.org/v3/index.json'; Name = 'nuget.org' }
    if ( $Source -eq 'Public') {
        New-NugetConfigFile -NugetPackageSource $nugetorg, $dotnetSdk   -Destination "$PSScriptRoot/" @extraParams
        New-NugetConfigFile -NugetPackageSource $gallery                -Destination "$PSScriptRoot/src/Modules/" @extraParams
        New-NugetConfigFile -NugetPackageSource $gallery                -Destination "$PSScriptRoot/test/tools/Modules/" @extraParams
    } elseif ( $Source -eq 'NuGetOnly') {
        New-NugetConfigFile -NugetPackageSource $nugetorg   -Destination "$PSScriptRoot/" @extraParams
        New-NugetConfigFile -NugetPackageSource $gallery                -Destination "$PSScriptRoot/src/Modules/" @extraParams
        New-NugetConfigFile -NugetPackageSource $gallery                -Destination "$PSScriptRoot/test/tools/Modules/" @extraParams
    } elseif ( $Source -eq 'Private') {
        $powerShellPackages = [NugetPackageSource] @{Url = 'https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/PowerShell/nuget/v3/index.json'; Name = 'powershell' }

        New-NugetConfigFile -NugetPackageSource $powerShellPackages -Destination "$PSScriptRoot/" @extraParams
        New-NugetConfigFile -NugetPackageSource $powerShellPackages -Destination "$PSScriptRoot/src/Modules/" @extraParams
        New-NugetConfigFile -NugetPackageSource $powerShellPackages -Destination "$PSScriptRoot/test/tools/Modules/" @extraParams
    } elseif ($Source -eq 'EarlyAccess') {
        $earlyAccess = $env:EARLY_ACCESS_FEED

        $earlyAccessFeedUrl = switch ($earlyAccess) {
            'net8' { 'https://pkgs.dev.azure.com/powershell-rel/PowerShell/_packaging/powershell-net-8-early-access/nuget/v3/index.json' }
            'net9' { 'https://pkgs.dev.azure.com/powershell-rel/PowerShell/_packaging/powershell-net-9-early-access/nuget/v3/index.json' }
            'net10' { 'https://pkgs.dev.azure.com/powershell-rel/PowerShell/_packaging/powershell-net-10-early-access/nuget/v3/index.json' }
            default { throw "Unknown early access feed URL: $earlyAccess" }
        }

        Set-PipelineVariable -Name 'EARLY_ACCESS_FEED_URL' -Value $earlyAccessFeedUrl

        $earlyAccessFeed = [NugetPackageSource] @{Url = $earlyAccessFeedUrl; Name = 'earlyaccess' }
        $powerShellPackages = [NugetPackageSource] @{Url = 'https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/PowerShell/nuget/v3/index.json'; Name = 'powershell' }

        New-NugetConfigFile -NugetPackageSource $earlyAccessFeed -Destination "$PSScriptRoot/"
        New-NugetConfigFile -NugetPackageSource $powerShellPackages -Destination "$PSScriptRoot/src/Modules/" @extraParams
        New-NugetConfigFile -NugetPackageSource $powerShellPackages -Destination "$PSScriptRoot/test/tools/Modules/" @extraParams
    } else {
        throw "Unknown source: $Source"
    }

    if ($UserName -or $ClearTextPAT) {
        Set-PipelineNugetAuthentication
    }
}

function Test-ShouldGenerateExperimentalFeatures
{
    <#
    .SYNOPSIS
        Determines whether experimental-feature JSON files should be generated on this host.
    .DESCRIPTION
        Returns $true only when the current runtime identifier matches the host OS and
        architecture, the build is not a release build (PS_RELEASE_BUILD not set), and the
        runtime is not fxdependent.
    .PARAMETER Runtime
        The .NET runtime identifier (RID) being targeted by the build.
    .OUTPUTS
        System.Boolean. $true if the experimental-feature list should be generated.
    #>
    param(
        [Parameter(Mandatory)]
        $Runtime
    )

    if ($env:PS_RELEASE_BUILD) {
        return $false
    }

    if ($Runtime -like 'fxdependent*') {
        return $false
    }

    $runtimePattern = 'unknown-'
    if ($environment.IsWindows) {
        $runtimePattern = '^win.*-'
    }

    if ($environment.IsMacOS) {
        $runtimePattern = '^osx.*-'
    }

    if ($environment.IsLinux) {
        $runtimePattern = '^linux.*-'
    }

    $runtimePattern += $environment.OSArchitecture.ToString()
    Write-Verbose "runtime pattern check: $Runtime -match $runtimePattern" -Verbose
    if ($Runtime -match $runtimePattern) {
        Write-Verbose "Generating experimental feature list" -Verbose
        return $true
    }

    Write-Verbose "Skipping generating experimental feature list" -Verbose
    return $false
}

function Restore-PSPackage
{
    <#
    .SYNOPSIS
        Restores NuGet packages for the PowerShell project directories.
    .DESCRIPTION
        Runs 'dotnet restore' on the main PowerShell project directories with up to five
        retries on transient failures. Honors the target runtime identifier and build verbosity.
    .PARAMETER ProjectDirs
        Explicit list of project directories to restore. Defaults to the standard PS project set.
    .PARAMETER Options
        PSOptions object specifying runtime and configuration. Defaults to Get-PSOptions.
    .PARAMETER Force
        Forces restore even when project.assets.json already exists.
    .PARAMETER InteractiveAuth
        Passes '--interactive' to dotnet restore for interactive feed authentication.
    .PARAMETER PSModule
        Restores in PSModule mode, omitting the runtime argument.
    #>
    [CmdletBinding()]
    param(
        [ValidateNotNullOrEmpty()]
        [Parameter()]
        [string[]] $ProjectDirs,

        [ValidateNotNullOrEmpty()]
        [Parameter()]
        $Options = (Get-PSOptions -DefaultToNew),

        [switch] $Force,

        [switch] $InteractiveAuth,

        [switch] $PSModule
    )

    if (-not $ProjectDirs)
    {
        $ProjectDirs = @($Options.Top, "$PSScriptRoot/src/TypeCatalogGen", "$PSScriptRoot/src/ResGen", "$PSScriptRoot/src/Modules", "$PSScriptRoot/tools/wix")

        if ($Options.Runtime -like 'fxdependent*') {
            $ProjectDirs += "$PSScriptRoot/src/Microsoft.PowerShell.GlobalTool.Shim"
        }
    }

    if ($Force -or (-not (Test-Path "$($Options.Top)/obj/project.assets.json"))) {

        if ($Options.Runtime -eq 'fxdependent-win-desktop') {
            $sdkToUse = 'Microsoft.NET.Sdk.WindowsDesktop'
        }
        else {
            $sdkToUse = 'Microsoft.NET.Sdk'
            if (($Options.Runtime -like 'win7-*' -or $Options.Runtime -eq 'win-arm64') -and !$Options.ForMinimalSize) {
                $sdkToUse = 'Microsoft.NET.Sdk.WindowsDesktop'
            }
        }

        if ($PSModule.IsPresent) {
            $RestoreArguments = @("--verbosity")
        }
        elseif ($Options.Runtime -notlike 'fxdependent*') {
            $RestoreArguments = @("--runtime", $Options.Runtime, "/property:SDKToUse=$sdkToUse", "--verbosity")
        } else {
            $RestoreArguments = @("/property:SDKToUse=$sdkToUse", "--verbosity")
        }

        if ($VerbosePreference -eq 'Continue') {
            $RestoreArguments += "detailed"
        } else {
            $RestoreArguments += "quiet"
        }

        if ($Options.Runtime -like 'win*') {
            $RestoreArguments += "/property:EnableWindowsTargeting=True"
            $RestoreArguments += "/property:UseRidGraph=True"
        }

        if ($InteractiveAuth) {
            $RestoreArguments += "--interactive"
        }

        if ($env:ENABLE_MSBUILD_BINLOGS -eq 'true') {
            $RestoreArguments += '-bl'
        }

        $ProjectDirs | ForEach-Object {
            $project = $_
            Write-Log -message "Run dotnet restore $project $RestoreArguments"
            $retryCount = 0
            $maxTries = 5
            while($retryCount -lt $maxTries)
            {
                try
                {
                    Start-NativeExecution { dotnet restore $project $RestoreArguments }
                }
                catch
                {
                    Write-Log -message "Failed to restore $project, retrying..."
                    $retryCount++
                    if($retryCount -ge $maxTries)
                    {
                        if ($env:ENABLE_MSBUILD_BINLOGS -eq 'true') {
                            if ( Test-Path ./msbuild.binlog ) {
                                if (!(Test-Path $env:OB_OUTPUTDIRECTORY -PathType Container)) {
                                    $null = New-Item -path $env:OB_OUTPUTDIRECTORY -ItemType Directory -Force -Verbose
                                }

                                $projectName = Split-Path -Leaf -Path $project
                                $binlogFileName = "${projectName}.msbuild.binlog"
                                if ($IsMacOS) {
                                    $resolvedPath = (Resolve-Path -Path ./msbuild.binlog).ProviderPath
                                    Write-Host "##vso[artifact.upload containerfolder=$binLogFileName;artifactname=$binLogFileName]$resolvedPath"
                                } else {
                                    Copy-Item -Path ./msbuild.binlog -Destination "$env:OB_OUTPUTDIRECTORY/${projectName}.msbuild.binlog" -Verbose
                                }
                            }
                        }

                        throw
                    }
                    continue
                }

                Write-Log -message "Done restoring $project"
                break
            }
        }
    }
}

function Restore-PSModuleToBuild
{
    <#
    .SYNOPSIS
        Copies PowerShell Gallery modules from the NuGet cache into the build output Modules folder.
    .DESCRIPTION
        Resolves Gallery module packages referenced in PSGalleryModules.csproj and copies
        them to the Modules subdirectory of the specified publish path. Also removes
        .nupkg.metadata files left behind by the restore.
    .PARAMETER PublishPath
        The PowerShell build output directory whose Modules sub-folder receives the modules.
    #>
    param(
        [Parameter(Mandatory)]
        [string]
        $PublishPath
    )

    Write-Log -message "Restore PowerShell modules to $publishPath"
    $modulesDir = Join-Path -Path $publishPath -ChildPath "Modules"
    Copy-PSGalleryModules -Destination $modulesDir -CsProjPath "$PSScriptRoot\src\Modules\PSGalleryModules.csproj"

    # Remove .nupkg.metadata files
    Get-ChildItem $PublishPath -Filter '.nupkg.metadata' -Recurse | ForEach-Object { Remove-Item $_.FullName -ErrorAction SilentlyContinue -Force }
}

function Restore-PSPester
{
    <#
    .SYNOPSIS
        Downloads and saves the Pester module (v4.x) from the PowerShell Gallery.
    .DESCRIPTION
        Uses Save-Module to install Pester up to version 4.99 into the target directory.
    .PARAMETER Destination
        Directory to save Pester into. Defaults to the Modules folder of the current build output.
    #>
    param(
        [ValidateNotNullOrEmpty()]
        [string] $Destination = ([IO.Path]::Combine((Split-Path (Get-PSOptions -DefaultToNew).Output), "Modules"))
    )
    Save-Module -Name Pester -Path $Destination -Repository PSGallery -MaximumVersion 4.99
}

function Compress-TestContent {
    <#
    .SYNOPSIS
        Compresses the test directory into a zip archive for distribution.
    .DESCRIPTION
        Publishes PSTestTools and then zips the entire test/ directory to the given
        destination path using System.IO.Compression.ZipFile.
    .PARAMETER Destination
        The path of the output zip file to create.
    #>
    [CmdletBinding()]
    param(
        $Destination
    )

    $null = Publish-PSTestTools
    $powerShellTestRoot =  Join-Path $PSScriptRoot 'test'
    Add-Type -AssemblyName System.IO.Compression.FileSystem

    $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Destination)
    [System.IO.Compression.ZipFile]::CreateFromDirectory($powerShellTestRoot, $resolvedPath)
}

function New-PSOptions {
    <#
    .SYNOPSIS
        Creates a new PSOptions hashtable describing a PowerShell build configuration.
    .DESCRIPTION
        Computes the output path, project directory, and framework for a PowerShell build
        based on the supplied runtime and configuration. The resulting hashtable is consumed
        by Start-PSBuild, Restore-PSPackage, and related functions.
    .PARAMETER Configuration
        The build configuration: Debug (default), Release, CodeCoverage, or StaticAnalysis.
    .PARAMETER Framework
        The target .NET framework moniker. Defaults to 'net11.0'.
    .PARAMETER Runtime
        The .NET runtime identifier (RID). Detected automatically via 'dotnet --info' if omitted.
    .PARAMETER Output
        Optional path to the output directory. The executable name is appended automatically.
    .PARAMETER SMAOnly
        Targets only the System.Management.Automation project rather than the full host.
    .PARAMETER PSModuleRestore
        Indicates whether Start-PSBuild should restore PowerShell Gallery modules.
    .PARAMETER ForMinimalSize
        Produces a build targeting minimal binary size.
    .OUTPUTS
        System.Collections.Hashtable. A hashtable with build option properties.
    #>
    [CmdletBinding()]
    param(
        [ValidateSet('Debug', 'Release', 'CodeCoverage', 'StaticAnalysis', '')]
        [string]$Configuration,

        [ValidateSet("net10.0")]
        [string]$Framework = "net10.0",

        # These are duplicated from Start-PSBuild
        # We do not use ValidateScript since we want tab completion
        [ValidateSet("",
                     "linux-musl-x64",
                     "fxdependent",
                     "fxdependent-noopt-linux-musl-x64",
                     "fxdependent-linux-x64",
                     "fxdependent-linux-arm64",
                     "fxdependent-win-desktop",
                     "linux-arm",
                     "linux-arm64",
                     "linux-x64",
                     "osx-arm64",
                     "osx-x64",
                     "win-arm",
                     "win-arm64",
                     "win7-x64",
                     "win7-x86")]
        [string]$Runtime,

        # Accept a path to the output directory
        # If not null or empty, name of the executable will be appended to
        # this path, otherwise, to the default path, and then the full path
        # of the output executable will be assigned to the Output property
        [string]$Output,

        [switch]$SMAOnly,

        [switch]$PSModuleRestore,

        [switch]$ForMinimalSize
    )

    # Add .NET CLI tools to PATH
    Find-Dotnet

    if (-not $Configuration) {
        $Configuration = 'Debug'
    }

    Write-Verbose "Using configuration '$Configuration'"
    Write-Verbose "Using framework '$Framework'"

    if (-not $Runtime) {
        $Platform, $Architecture = dotnet --info |
            Select-String '^\s*OS Platform:\s+(\w+)$', '^\s*Architecture:\s+(\w+)$' |
            Select-Object -First 2 |
            ForEach-Object { $_.Matches.Groups[1].Value }

        switch ($Platform) {
            'Windows' {
                # For x86 and x64 architectures, we use win7-x64 and win7-x86 RIDs.
                # For arm and arm64 architectures, we use win-arm and win-arm64 RIDs.
                $Platform = if ($Architecture[0] -eq 'x') { 'win7' } else { 'win' }
                $Runtime = "${Platform}-${Architecture}"
            }

            'Linux' {
                $Runtime = "linux-${Architecture}"
            }

            'Darwin' {
                $Runtime = "osx-${Architecture}"
            }
        }

        if (-not $Runtime) {
            Throw "Could not determine Runtime Identifier, please update dotnet"
        } else {
            Write-Verbose "Using runtime '$Runtime'"
        }
    }

    $PowerShellDir = if ($Runtime -like 'win*' -or ($Runtime -like 'fxdependent*' -and $environment.IsWindows)) {
        "powershell-win-core"
    } else {
        "powershell-unix"
    }

    $Top = [IO.Path]::Combine($PSScriptRoot, "src", $PowerShellDir)
    Write-Verbose "Top project directory is $Top"

    $Executable = if ($Runtime -like 'fxdependent*') {
        "pwsh.dll"
    } elseif ($environment.IsLinux -or $environment.IsMacOS) {
        "pwsh"
    } elseif ($environment.IsWindows) {
        "pwsh.exe"
    }

    # Build the Output path
    if (!$Output) {
        if ($Runtime -like 'fxdependent*' -and ($Runtime -like 'fxdependent*linux*' -or $Runtime -like 'fxdependent*alpine*')) {
            $outputRuntime = $Runtime -replace 'fxdependent-', ''
            $Output = [IO.Path]::Combine($Top, "bin", $Configuration, $Framework, $outputRuntime, "publish", $Executable)
        }
        elseif ($Runtime -like 'fxdependent*') {
            $Output = [IO.Path]::Combine($Top, "bin", $Configuration, $Framework, "publish", $Executable)
        }
        else {
            $Output = [IO.Path]::Combine($Top, "bin", $Configuration, $Framework, $Runtime, "publish", $Executable)
        }
    } else {
        $Output = [IO.Path]::Combine($Output, $Executable)
    }

    if ($SMAOnly)
    {
        $Top = [IO.Path]::Combine($PSScriptRoot, "src", "System.Management.Automation")
    }

    $RootInfo = @{RepoPath = $PSScriptRoot}

    # the valid root is the root of the filesystem and the folder PowerShell
    $RootInfo['ValidPath'] = Join-Path -Path ([system.io.path]::GetPathRoot($RootInfo.RepoPath)) -ChildPath 'PowerShell'

    if($RootInfo.RepoPath -ne $RootInfo.ValidPath)
    {
        $RootInfo['Warning'] = "Please ensure your repo is at the root of the file system and named 'PowerShell' (example: '$($RootInfo.ValidPath)'), when building and packaging for release!"
        $RootInfo['IsValid'] = $false
    }
    else
    {
        $RootInfo['IsValid'] = $true
    }

    return New-PSOptionsObject `
                -RootInfo ([PSCustomObject]$RootInfo) `
                -Top $Top `
                -Runtime $Runtime `
                -Configuration $Configuration `
                -PSModuleRestore $PSModuleRestore.IsPresent `
                -Framework $Framework `
                -Output $Output `
                -ForMinimalSize $ForMinimalSize
}

# Get the Options of the last build
function Get-PSOptions {
    <#
    .SYNOPSIS
        Returns the PSOptions from the most recent Start-PSBuild call.
    .DESCRIPTION
        Retrieves the script-level $script:Options object. If no build has been run and
        -DefaultToNew is specified, returns a fresh object from New-PSOptions.
    .PARAMETER DefaultToNew
        When specified, returns default options from New-PSOptions if no build has occurred.
    .OUTPUTS
        System.Collections.Hashtable. The current PSOptions hashtable, or $null.
    #>
    param(
        [Parameter(HelpMessage='Defaults to New-PSOption if a build has not occurred.')]
        [switch]
        $DefaultToNew
    )

    if (!$script:Options -and $DefaultToNew.IsPresent)
    {
        return New-PSOptions
    }

    return $script:Options
}

function Set-PSOptions {
    <#
    .SYNOPSIS
        Stores the supplied PSOptions as the active build options.
    .DESCRIPTION
        Writes the options hashtable to the script-scoped $script:Options variable,
        making it available to subsequent Get-PSOptions calls.
    .PARAMETER Options
        The PSOptions hashtable to store.
    #>
    param(
        [PSObject]
        $Options
    )

    $script:Options = $Options
}

function Get-PSOutput {
    <#
    .SYNOPSIS
        Returns the path to the PowerShell executable produced by the build.
    .DESCRIPTION
        Looks up the Output path from the supplied options hashtable, the cached
        script-level options, or a fresh New-PSOptions call, in that order of precedence.
    .PARAMETER Options
        An explicit options hashtable. If omitted, the most recent build options are used.
    .OUTPUTS
        System.String. The full path to the built pwsh or pwsh.exe executable.
    #>
    [CmdletBinding()]param(
        [hashtable]$Options
    )
    if ($Options) {
        return $Options.Output
    } elseif ($script:Options) {
        return $script:Options.Output
    } else {
        return (New-PSOptions).Output
    }
}

function Get-PesterTag {
    <#
    .SYNOPSIS
        Scans the Pester test tree and returns a summary of all tags in use.
    .DESCRIPTION
        Parses every *.tests.ps1 file under the specified base directory using the
        PowerShell AST, validates that each Describe block has exactly one priority tag
        (CI, Feature, or Scenario), and returns a summary object with tag counts and
        any validation warnings.
    .PARAMETER testbase
        Root directory to search for test files.
        Defaults to '$PSScriptRoot/test/powershell'.
    .OUTPUTS
        PSCustomObject (DescribeTagsInUse). Properties are tag names mapped to usage
        counts, plus 'Result' (Pass/Fail) and 'Warnings' (string[]).
    #>
    param ( [Parameter(Position=0)][string]$testbase = "$PSScriptRoot/test/powershell" )
    $alltags = @{}
    $warnings = @()

    Get-ChildItem -Recurse $testbase -File | Where-Object {$_.name -match "tests.ps1"}| ForEach-Object {
        $fullname = $_.fullname
        $tok = $err = $null
        $ast = [System.Management.Automation.Language.Parser]::ParseFile($FullName, [ref]$tok,[ref]$err)
        $des = $ast.FindAll({
            $args[0] -is [System.Management.Automation.Language.CommandAst] `
                -and $args[0].CommandElements.GetType() -in @(
                    [System.Management.Automation.Language.StringConstantExpressionAst],
                    [System.Management.Automation.Language.ExpandableStringExpressionAst]
                ) `
                -and $args[0].CommandElements[0].Value -eq "Describe"
        }, $true)
        foreach( $describe in $des) {
            $elements = $describe.CommandElements
            $lineno = $elements[0].Extent.StartLineNumber
            $foundPriorityTags = @()
            for ( $i = 0; $i -lt $elements.Count; $i++) {
                if ( $elements[$i].extent.text -match "^-t" ) {
                    $vAst = $elements[$i+1]
                    if ( $vAst.FindAll({$args[0] -is "System.Management.Automation.Language.VariableExpressionAst"},$true) ) {
                        $warnings += "TAGS must be static strings, error in ${fullname}, line $lineno"
                    }
                    $values = $vAst.FindAll({$args[0] -is "System.Management.Automation.Language.StringConstantExpressionAst"},$true).Value
                    $values | ForEach-Object {
                        if (@('REQUIREADMINONWINDOWS', 'REQUIRESUDOONUNIX', 'SLOW') -contains $_) {
                            # These are valid tags also, but they are not the priority tags
                        }
                        elseif (@('CI', 'FEATURE', 'SCENARIO') -contains $_) {
                            $foundPriorityTags += $_
                        }
                        else {
                            $warnings += "${fullname} includes improper tag '$_', line '$lineno'"
                        }

                        $alltags[$_]++
                    }
                }
            }
            if ( $foundPriorityTags.Count -eq 0 ) {
                $warnings += "${fullname}:$lineno does not include -Tag in Describe"
            }
            elseif ( $foundPriorityTags.Count -gt 1 ) {
                $warnings += "${fullname}:$lineno includes more then one scope -Tag: $foundPriorityTags"
            }
        }
    }
    if ( $Warnings.Count -gt 0 ) {
        $alltags['Result'] = "Fail"
    }
    else {
        $alltags['Result'] = "Pass"
    }
    $alltags['Warnings'] = $warnings
    $o = [pscustomobject]$alltags
    $o.psobject.TypeNames.Add("DescribeTagsInUse")
    $o
}

# Function to build and publish the Microsoft.PowerShell.NamedPipeConnection module for
# testing PowerShell remote custom connections.
function Publish-CustomConnectionTestModule
{
    <#
    .SYNOPSIS
        Builds and publishes the Microsoft.PowerShell.NamedPipeConnection test module.
    .DESCRIPTION
        Invokes the module's own build.ps1 script, copies the output to
        test/tools/Modules, and then runs a clean build to remove intermediate artifacts.
    #>
    Write-LogGroupStart -Title "Publish-CustomConnectionTestModule"
    $sourcePath = "${PSScriptRoot}/test/tools/NamedPipeConnection"
    $outPath = "${PSScriptRoot}/test/tools/NamedPipeConnection/out/Microsoft.PowerShell.NamedPipeConnection"
    $publishPath = "${PSScriptRoot}/test/tools/Modules"

    Find-DotNet

    Push-Location -Path $sourcePath
    try {
        # Build the Microsoft.PowerShell.NamedPipeConnect module
        ./build.ps1 -Clean -Build

        if (! (Test-Path -Path $outPath)) {
            throw "Publish-CustomConnectionTestModule: Build failed. Output path does not exist: $outPath"
        }

        # Publish the Microsoft.PowerShell.NamedPipeConnection module
        Copy-Item -Path $outPath -Destination $publishPath -Recurse -Force

        # Clean up build artifacts
        ./build.ps1 -Clean
    }
    finally {
        Pop-Location
    }

    Write-LogGroupEnd -Title "Publish-CustomConnectionTestModule"
}

function Publish-PSTestTools {
    <#
    .SYNOPSIS
        Builds and publishes all test tool projects to their bin directories.
    .DESCRIPTION
        Runs 'dotnet publish' for each test tool project (TestAlc, TestExe, UnixSocket,
        WebListener, and on Windows TestService), copies Gallery test modules, and
        publishes the NamedPipeConnection module. The tool bin directories are added to PATH
        so that tests can locate the executables.
    .PARAMETER runtime
        The .NET runtime identifier (RID) used when publishing executables.
        Defaults to the runtime from the current build options.
    #>
    [CmdletBinding()]
    param(
        [string]
        $runtime
    )

    Write-LogGroupStart -Title "Publish-PSTestTools"
    Find-Dotnet

    $tools = @(
        @{ Path="${PSScriptRoot}/test/tools/TestAlc";     Output="library" }
        @{ Path="${PSScriptRoot}/test/tools/TestExe";     Output="exe" }
        @{ Path="${PSScriptRoot}/test/tools/UnixSocket";  Output="exe" }
        @{ Path="${PSScriptRoot}/test/tools/WebListener"; Output="exe" }
    )

    # This is a windows service, so it only works on windows
    if ($environment.IsWindows) {
        $tools += @{ Path = "${PSScriptRoot}/test/tools/TestService"; Output = "exe" }
    }

    $Options = Get-PSOptions -DefaultToNew

    # Publish tools so it can be run by tests
    foreach ($tool in $tools)
    {
        Push-Location $tool.Path
        try {
            $toolPath = Join-Path -Path $tool.Path -ChildPath "bin"
            $objPath = Join-Path -Path $tool.Path -ChildPath "obj"

            if (Test-Path $toolPath) {
                Remove-Item -Path $toolPath -Recurse -Force
            }

            if (Test-Path $objPath) {
                Remove-Item -Path $objPath -Recurse -Force
            }

            if ($tool.Output -eq 'library') {
                ## Handle building and publishing assemblies.
                dotnet publish --configuration $Options.Configuration --framework $Options.Framework
                continue
            }

            ## Handle building and publishing executables.
            if (-not $runtime) {
                $runtime = $Options.Runtime
            }

            # We are using non-version/distro specific RIDs for test tools, so we need to fix the runtime
            # value here if it starts with 'win7'.
            $runtime = $runtime -replace '^win7-', 'win-'

            Write-Verbose -Verbose -Message "Starting dotnet publish for $toolPath with runtime $runtime"

            dotnet publish --output bin --configuration $Options.Configuration --framework $Options.Framework --runtime $runtime --self-contained | Out-String | Write-Verbose -Verbose

            $dll = $null
            $dll = Get-ChildItem -Path bin -Recurse -Filter "*.dll"

            if (-not $dll) {
                throw "Failed to find exe in $toolPath"
            }

            if ( -not $env:PATH.Contains($toolPath) ) {
                $env:PATH = $toolPath+$TestModulePathSeparator+$($env:PATH)
            }
        } finally {
            Pop-Location
        }
    }

    # `dotnet restore` on test project is not called if product projects have been restored unless -Force is specified.
    Copy-PSGalleryModules -Destination "${PSScriptRoot}/test/tools/Modules" -CsProjPath "$PSScriptRoot/test/tools/Modules/PSGalleryTestModules.csproj" -Force

    # Publish the Microsoft.PowerShell.NamedPipeConnection module
    Publish-CustomConnectionTestModule
    Write-LogGroupEnd -Title "Publish-PSTestTools"
}

function Get-ExperimentalFeatureTests {
    <#
    .SYNOPSIS
        Returns a mapping of experimental feature names to their associated test files.
    .DESCRIPTION
        Reads test/tools/TestMetadata.json and extracts the ExperimentalFeatures section,
        returning a hashtable where keys are feature names and values are arrays of test paths.
    .OUTPUTS
        System.Collections.Hashtable. Keys are experimental feature names; values are
        arrays of test file paths.
    #>
    $testMetadataFile = Join-Path $PSScriptRoot "test/tools/TestMetadata.json"
    $metadata = Get-Content -Path $testMetadataFile -Raw | ConvertFrom-Json | ForEach-Object -MemberName ExperimentalFeatures
    $features = $metadata | Get-Member -MemberType NoteProperty | ForEach-Object -MemberName Name

    $featureTests = @{}
    foreach ($featureName in $features) {
        $featureTests[$featureName] = $metadata.$featureName
    }
    $featureTests
}

function Start-PSPester {
    <#
    .SYNOPSIS
        Runs the Pester test suite against the built PowerShell.
    .DESCRIPTION
        Launches the built pwsh process with the Pester module and runs the specified
        test paths. Automatically adjusts tag exclusions based on the current elevation
        level, and emits NUnit XML results that are optionally published to Azure DevOps
        or GitHub Actions.
    .PARAMETER Path
        One or more test file or directory paths to run. Defaults to test/powershell.
    .PARAMETER OutputFormat
        The Pester output format. Defaults to 'NUnitXml'.
    .PARAMETER OutputFile
        Path for the XML results file. Defaults to 'pester-tests.xml'.
    .PARAMETER ExcludeTag
        Tags to exclude from the run. Defaults to 'Slow'; adjusted for elevation level.
    .PARAMETER Tag
        Tags to include in the run. Defaults to 'CI' and 'Feature'.
    .PARAMETER ThrowOnFailure
        Throws an exception after the run if any tests failed.
    .PARAMETER BinDir
        Directory containing the built pwsh executable. Defaults to the current build output.
    .PARAMETER powershell
        Full path to the pwsh executable used for running tests.
    .PARAMETER Pester
        Path to the Pester module directory.
    .PARAMETER Unelevate
        Runs tests in an unelevated child process on Windows.
    .PARAMETER Quiet
        Suppresses most Pester output.
    .PARAMETER Terse
        Shows compact pass/fail indicators instead of full output lines.
    .PARAMETER PassThru
        Returns the Pester result object to the caller.
    .PARAMETER Sudo
        Runs tests under sudo on Unix (PassThru parameter set).
    .PARAMETER IncludeFailingTest
        Includes tests from tools/failingTests.
    .PARAMETER IncludeCommonTests
        Includes tests from test/common.
    .PARAMETER ExperimentalFeatureName
        Enables the named experimental feature for this test run via a temporary config file.
    .PARAMETER Title
        Title for the published test results. Defaults to 'PowerShell 7 Tests'.
    .PARAMETER Wait
        Waits for a debugger to attach before starting Pester (Debug builds only).
    .PARAMETER SkipTestToolBuild
        Skips rebuilding test tool executables before running tests.
    .PARAMETER UseNuGetOrg
        Switches NuGet config to public feeds before running tests.
    #>
    [CmdletBinding(DefaultParameterSetName='default')]
    param(
        [Parameter(Position=0)]
        [ArgumentCompleter({param($c,$p,$word) Get-ChildItem -Recurse -File -LiteralPath $PSScriptRoot/Test/PowerShell -filter *.tests.ps1 | Where-Object FullName -like "*$word*" })]
        [string[]]$Path = @("$PSScriptRoot/test/powershell"),
        [string]$OutputFormat = "NUnitXml",
        [string]$OutputFile = "pester-tests.xml",
        [string[]]$ExcludeTag = 'Slow',
        [string[]]$Tag = @("CI","Feature"),
        [switch]$ThrowOnFailure,
        [string]$BinDir = (Split-Path (Get-PSOptions -DefaultToNew).Output),
        [string]$powershell = (Join-Path $BinDir 'pwsh'),
        [string]$Pester = ([IO.Path]::Combine($BinDir, "Modules", "Pester")),
        [Parameter(ParameterSetName='Unelevate',Mandatory=$true)]
        [switch]$Unelevate,
        [switch]$Quiet,
        [switch]$Terse,
        [Parameter(ParameterSetName='PassThru',Mandatory=$true)]
        [switch]$PassThru,
        [Parameter(ParameterSetName='PassThru',HelpMessage='Run commands on Linux with sudo.')]
        [switch]$Sudo,
        [switch]$IncludeFailingTest,
        [switch]$IncludeCommonTests,
        [string]$ExperimentalFeatureName,
        [Parameter(HelpMessage='Title to publish the results as.')]
        [string]$Title = 'PowerShell 7 Tests',
        [Parameter(ParameterSetName='Wait', Mandatory=$true,
            HelpMessage='Wait for the debugger to attach to PowerShell before Pester starts.  Debug builds only!')]
        [switch]$Wait,
        [switch]$SkipTestToolBuild,
        [switch]$UseNuGetOrg
    )

    if ($UseNuGetOrg) {
        Switch-PSNugetConfig -Source Public
    }

    if (-not (Get-Module -ListAvailable -Name $Pester -ErrorAction SilentlyContinue | Where-Object { $_.Version -ge "4.2" } ))
    {
        Restore-PSPester
    }

    if ($IncludeFailingTest.IsPresent)
    {
        $Path += "$PSScriptRoot/tools/failingTests"
    }

    if($IncludeCommonTests.IsPresent)
    {
        $path += "$PSScriptRoot/test/common"
    }

    # we need to do few checks and if user didn't provide $ExcludeTag explicitly, we should alternate the default
    if ($Unelevate)
    {
        if (-not $environment.IsWindows)
        {
            throw '-Unelevate is currently not supported on non-Windows platforms'
        }

        if (-not $environment.IsAdmin)
        {
            throw '-Unelevate cannot be applied because the current user is not Administrator'
        }

        if (-not $PSBoundParameters.ContainsKey('ExcludeTag'))
        {
            $ExcludeTag += 'RequireAdminOnWindows'
        }
    }
    elseif ($environment.IsWindows -and (-not $environment.IsAdmin))
    {
        if (-not $PSBoundParameters.ContainsKey('ExcludeTag'))
        {
            $ExcludeTag += 'RequireAdminOnWindows'
        }
    }
    elseif (-not $environment.IsWindows -and (-not $Sudo.IsPresent))
    {
        if (-not $PSBoundParameters.ContainsKey('ExcludeTag'))
        {
            $ExcludeTag += 'RequireSudoOnUnix'
        }
    }
    elseif (-not $environment.IsWindows -and $Sudo.IsPresent)
    {
        if (-not $PSBoundParameters.ContainsKey('Tag'))
        {
            $Tag = 'RequireSudoOnUnix'
        }
    }

    Write-Verbose "Running pester tests at '$path' with tag '$($Tag -join ''', ''')' and ExcludeTag '$($ExcludeTag -join ''', ''')'" -Verbose
    if(!$SkipTestToolBuild.IsPresent)
    {
        $publishArgs = @{ }
        # if we are building for Alpine, we must include the runtime as linux-x64
        # will not build runnable test tools
        if ( $environment.IsLinux -and $environment.IsAlpine ) {
            $publishArgs['runtime'] = 'linux-musl-x64'
        }
        Publish-PSTestTools @publishArgs | ForEach-Object {Write-Host $_}

        # Publish the Microsoft.PowerShell.NamedPipeConnection module for testing custom remote connections.
        Publish-CustomConnectionTestModule | ForEach-Object { Write-Host $_ }
    }

    # All concatenated commands/arguments are suffixed with the delimiter (space)

    # Disable telemetry for all startups of pwsh in tests
    $command = "`$env:POWERSHELL_TELEMETRY_OPTOUT = 'yes';"
    if ($Terse)
    {
        $command += "`$ProgressPreference = 'silentlyContinue'; "
    }

    # Autoload (in subprocess) temporary modules used in our tests
    $newPathFragment = $TestModulePath + $TestModulePathSeparator
    $command += '$env:PSModulePath = '+"'$newPathFragment'" + '+$env:PSModulePath;'

    # Windows needs the execution policy adjusted
    if ($environment.IsWindows) {
        $command += "Set-ExecutionPolicy -Scope Process Unrestricted; "
    }

    $command += "Import-Module '$Pester'; "

    if ($Unelevate)
    {
        if ($environment.IsWindows) {
            $outputBufferFilePath = [System.IO.Path]::GetTempFileName()
        }
        else {
            # Azure DevOps agents do not have Temp folder setup on Ubuntu 20.04, hence using HOME directory
            $outputBufferFilePath = (Join-Path $env:HOME $([System.IO.Path]::GetRandomFileName()))
        }
    }

    $command += "Invoke-Pester "

    $command += "-OutputFormat ${OutputFormat} -OutputFile ${OutputFile} "
    if ($ExcludeTag -and ($ExcludeTag -ne "")) {
        $command += "-ExcludeTag @('" + (${ExcludeTag} -join "','") + "') "
    }
    if ($Tag) {
        $command += "-Tag @('" + (${Tag} -join "','") + "') "
    }
    # sometimes we need to eliminate Pester output, especially when we're
    # doing a daily build as the log file is too large
    if ( $Quiet ) {
        $command += "-Quiet "
    }
    if ( $PassThru ) {
        $command += "-PassThru "
    }

    $command += "'" + ($Path -join "','") + "'"
    if ($Unelevate)
    {
        $command += " *> $outputBufferFilePath; '__UNELEVATED_TESTS_THE_END__' >> $outputBufferFilePath"
    }

    Write-Verbose $command -Verbose

    $script:nonewline = $true
    $script:inerror = $false
    function Write-Terse([string] $line)
    {
        $trimmedline = $line.Trim()
        if ($trimmedline.StartsWith("[+]")) {
            Write-Host "+" -NoNewline -ForegroundColor Green
            $script:nonewline = $true
            $script:inerror = $false
        }
        elseif ($trimmedline.StartsWith("[?]")) {
            Write-Host "?" -NoNewline -ForegroundColor Cyan
            $script:nonewline = $true
            $script:inerror = $false
        }
        elseif ($trimmedline.StartsWith("[!]")) {
            Write-Host "!" -NoNewline -ForegroundColor Gray
            $script:nonewline = $true
            $script:inerror = $false
        }
        elseif ($trimmedline.StartsWith("Executing script ")) {
            # Skip lines where Pester reports that is executing a test script
            return
        }
        elseif ($trimmedline -match "^\d+(\.\d+)?m?s$") {
            # Skip the time elapse like '12ms', '1ms', '1.2s' and '12.53s'
            return
        }
        else {
            if ($script:nonewline) {
                Write-Host "`n" -NoNewline
            }
            if ($trimmedline.StartsWith("[-]") -or $script:inerror) {
                Write-Host $line -ForegroundColor Red
                $script:inerror = $true
            }
            elseif ($trimmedline.StartsWith("VERBOSE:")) {
                Write-Host $line -ForegroundColor Yellow
                $script:inerror = $false
            }
            elseif ($trimmedline.StartsWith("Describing") -or $trimmedline.StartsWith("Context")) {
                Write-Host $line -ForegroundColor Magenta
                $script:inerror = $false
            }
            else {
                Write-Host $line -ForegroundColor Gray
            }
            $script:nonewline = $false
        }
    }

    $PSFlags = @("-noprofile")
    if (-not [string]::IsNullOrEmpty($ExperimentalFeatureName)) {

        if ($environment.IsWindows) {
            $configFile = [System.IO.Path]::GetTempFileName()
        }
        else {
            $configFile = (Join-Path $env:HOME $([System.IO.Path]::GetRandomFileName()))
        }

        $configFile = [System.IO.Path]::ChangeExtension($configFile, ".json")

        ## Create the config.json file to enable the given experimental feature.
        ## On Windows, we need to have 'RemoteSigned' declared for ExecutionPolicy because the ExecutionPolicy is 'Restricted' by default.
        ## On Unix, ExecutionPolicy is not supported, so we don't need to declare it.
        if ($environment.IsWindows) {
            $content = @"
{
    "Microsoft.PowerShell:ExecutionPolicy":"RemoteSigned",
    "ExperimentalFeatures": [
        "$ExperimentalFeatureName"
    ]
}
"@
        } else {
            $content = @"
{
    "ExperimentalFeatures": [
        "$ExperimentalFeatureName"
    ]
}
"@
        }

        Set-Content -Path $configFile -Value $content -Encoding Ascii -Force
        $PSFlags = @("-settings", $configFile, "-noprofile")
    }

	# -Wait is only available on Debug builds
	# It is used to allow the debugger to attach before PowerShell
	# runs pester in this case
    if($Wait.IsPresent){
        $PSFlags += '-wait'
    }

    # To ensure proper testing, the module path must not be inherited by the spawned process
    try {
        $originalModulePath = $env:PSModulePath
        $originalTelemetry = $env:POWERSHELL_TELEMETRY_OPTOUT
        $env:POWERSHELL_TELEMETRY_OPTOUT = 'yes'
        if ($Unelevate)
        {
            Start-UnelevatedProcess -process $powershell -arguments ($PSFlags + "-c $Command")
            $currentLines = 0
            while ($true)
            {
                $lines = Get-Content $outputBufferFilePath | Select-Object -Skip $currentLines
                if ($Terse)
                {
                    foreach ($line in $lines)
                    {
                        Write-Terse -line $line
                    }
                }
                else
                {
                    $lines | Write-Host
                }
                if ($lines | Where-Object { $_ -eq '__UNELEVATED_TESTS_THE_END__'})
                {
                    break
                }

                $count = ($lines | Measure-Object).Count
                if ($count -eq 0)
                {
                    Start-Sleep -Seconds 1
                }
                else
                {
                    $currentLines += $count
                }
            }
        }
        else
        {
            if ($PassThru.IsPresent)
            {
                if ($environment.IsWindows) {
                    $passThruFile = [System.IO.Path]::GetTempFileName()
                }
                else {
                    $passThruFile = Join-Path $env:HOME $([System.IO.Path]::GetRandomFileName())
                }

                try
                {
                    $command += "| Export-Clixml -Path '$passThruFile' -Force"

                    $passThruCommand = { & $powershell $PSFlags -c $command }
                    if ($Sudo.IsPresent) {
                        # -E says to preserve the environment
                        $passThruCommand =  { & sudo -E $powershell $PSFlags -c $command }
                    }

                    $writeCommand = { Write-Host $_ }
                    if ($Terse)
                    {
                        $writeCommand = { Write-Terse $_ }
                    }

                    Start-NativeExecution -sb $passThruCommand | ForEach-Object $writeCommand
                    Import-Clixml -Path $passThruFile | Where-Object {$_.TotalCount -is [Int32]}
                }
                finally
                {
                    Remove-Item $passThruFile -ErrorAction SilentlyContinue -Force
                }
            }
            else
            {
                if ($Terse)
                {
                    Start-NativeExecution -sb {& $powershell $PSFlags -c $command} | ForEach-Object { Write-Terse -line $_ }
                }
                else
                {
                    Start-NativeExecution -sb {& $powershell $PSFlags -c $command}
                }
            }
        }
    } finally {
        $env:PSModulePath = $originalModulePath
        $env:POWERSHELL_TELEMETRY_OPTOUT = $originalTelemetry
        if ($Unelevate)
        {
            Remove-Item $outputBufferFilePath
        }
    }

    Publish-TestResults -Path $OutputFile -Title $Title

    if($ThrowOnFailure)
    {
        Test-PSPesterResults -TestResultsFile $OutputFile
    }
}

function Publish-TestResults
{
    <#
    .SYNOPSIS
        Publishes test result files to Azure DevOps or GitHub Actions.
    .DESCRIPTION
        In an Azure DevOps build (TF_BUILD), uploads the result file via a ##vso command
        and attaches it as a build artifact. In GitHub Actions, copies the file to the
        testResults directory under $env:RUNNER_WORKSPACE. Does nothing outside of CI environments.
    .PARAMETER Title
        The run title shown in the CI testing tab.
    .PARAMETER Path
        Path to the NUnit or XUnit result file to publish.
    .PARAMETER Type
        The result file format: 'NUnit' (default) or 'XUnit'.
    #>
    param(
        [Parameter(Mandatory)]
        [string]
        $Title,

        [Parameter(Mandatory)]
        [ValidateScript({Test-Path -Path $_})]
        [string]
        $Path,

        [ValidateSet('NUnit','XUnit')]
        [string]
        $Type='NUnit'
    )

    # In VSTS publish Test Results
    if($env:TF_BUILD)
    {
        $fileName = Split-Path -Leaf -Path $Path
        $tempPath = $env:BUILD_ARTIFACTSTAGINGDIRECTORY
        if (! $tempPath)
        {
            $tempPath = [system.io.path]::GetTempPath()
        }
        $tempFilePath = Join-Path -Path $tempPath -ChildPath $fileName

        # NUnit allowed values are: Passed, Failed, Inconclusive or Ignored (the spec says Skipped but it doesn' work with Azure DevOps)
        # https://github.com/nunit/docs/wiki/Test-Result-XML-Format
        # Azure DevOps Reporting is so messed up for NUnit V2 and doesn't follow their own spec
        # https://learn.microsoft.com/azure/devops/pipelines/tasks/test/publish-test-results?view=azure-devops&tabs=yaml
        # So, we will map skipped to the actual value in the NUnit spec and they will ignore all results for tests which were not executed
        Get-Content $Path | ForEach-Object {
            $_ -replace 'result="Ignored"', 'result="Skipped"'
        } | Out-File -FilePath $tempFilePath -Encoding ascii -Force

        # If we attempt to upload a result file which has no test cases in it, then vsts will produce a warning
        # so check to be sure we actually have a result file that contains test cases to upload.
        # If the "test-case" count is greater than 0, then we have results.
        # Regardless, we want to upload this as an artifact, so this logic doesn't pertain to that.
        if ( @(([xml](Get-Content $Path)).SelectNodes(".//test-case")).Count -gt 0 -or $Type -eq 'XUnit' ) {
            Write-Host "##vso[results.publish type=$Type;mergeResults=true;runTitle=$Title;publishRunAttachments=true;resultFiles=$tempFilePath;failTaskOnFailedTests=true]"
        }

        $resolvedPath = (Resolve-Path -Path $Path).ProviderPath
        Write-Host "##vso[artifact.upload containerfolder=testResults;artifactname=testResults]$resolvedPath"
    } elseif ($env:GITHUB_WORKFLOW -and $env:RUNNER_WORKSPACE) {
        # In GitHub Actions
        $destinationPath = Join-Path -Path $env:RUNNER_WORKSPACE -ChildPath 'testResults'

        # Create the folder if it does not exist
        if (!(Test-Path -Path $destinationPath)) {
            $null = New-Item -ItemType Directory -Path $destinationPath -Force
        }

        Copy-Item -Path $Path -Destination $destinationPath -Force -Verbose
    }
}

function script:Start-UnelevatedProcess
{
    <#
    .SYNOPSIS
        Starts a process at an unelevated trust level on Windows.
    .DESCRIPTION
        Uses runas.exe /trustlevel:0x20000 to launch a process without elevation.
        Only supported on Windows and non-arm64 architectures.
    .PARAMETER process
        The path to the executable to start.
    .PARAMETER arguments
        Arguments to pass to the executable.
    #>
    param(
        [string]$process,
        [string[]]$arguments
    )

    if (-not $environment.IsWindows)
    {
        throw "Start-UnelevatedProcess is currently not supported on non-Windows platforms"
    }

    if ($environment.OSArchitecture -eq 'arm64')
    {
        throw "Start-UnelevatedProcess is currently not supported on arm64 platforms"
    }

    runas.exe /trustlevel:0x20000 "$process $arguments"
}

function Show-PSPesterError
{
    <#
    .SYNOPSIS
        Outputs a formatted error block for a single Pester test failure.
    .DESCRIPTION
        Accepts either an XmlElement from a NUnit result file or a PSCustomObject from
        a Pester PassThru result, and writes a structured description/name/message/stack-trace
        block to the log output.
    .PARAMETER testFailure
        An XML test-case element from a Pester NUnit result file (xml parameter set).
    .PARAMETER testFailureObject
        A Pester test-result PSCustomObject from a PassThru run (object parameter set).
    #>
    [CmdletBinding(DefaultParameterSetName='xml')]
    param (
        [Parameter(ParameterSetName='xml',Mandatory)]
        [Xml.XmlElement]$testFailure,
        [Parameter(ParameterSetName='object',Mandatory)]
        [PSCustomObject]$testFailureObject
        )

    if ($PSCmdlet.ParameterSetName -eq 'xml')
    {
        $description = $testFailure.description
        $name = $testFailure.name
        $message = $testFailure.failure.message
        $stack_trace = $testFailure.failure."stack-trace"
    }
    elseif ($PSCmdlet.ParameterSetName -eq 'object')
    {
        $description = $testFailureObject.Describe + '/' + $testFailureObject.Context
        $name = $testFailureObject.Name
        $message = $testFailureObject.FailureMessage
        $stack_trace = $testFailureObject.StackTrace
    }
    else
    {
        throw 'Unknown Show-PSPester parameter set'
    }

    # Empty line at the end is intentional formatting
    Write-Log -isError -message @"
Description: $description
Name:        $name
message:
$message
stack-trace:
$stack_trace

"@

}

function Get-PesterFailureFileInfo
{
    <#
    .SYNOPSIS
        Parses a Pester stack-trace string and returns the source file path and line number.
    .DESCRIPTION
        Tries several common stack-trace formats produced by Pester 4 and Pester 5 (on
        both Windows and Unix) and returns a hashtable with File and Line keys.
        Returns $null values for both keys when no pattern matches.
    .PARAMETER StackTraceString
        The raw stack trace text from a Pester test failure.
    .OUTPUTS
        System.Collections.Hashtable. A hashtable with 'File' (string) and 'Line' (string).
    #>
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$StackTraceString
    )

    # Parse stack trace to extract file path and line number
    # Common patterns:
    # "at line: 123 in C:\path\to\file.ps1" (Pester 4)
    # "at C:\path\to\file.ps1:123"
    # "at <ScriptBlock>, C:\path\to\file.ps1: line 123"
    # "at 1 | Should -Be 2, /path/to/file.ps1:123" (Pester 5)
    # "at 1 | Should -Be 2, C:\path\to\file.ps1:123" (Pester 5 Windows)

    $result = @{
        File = $null
        Line = $null
    }

    if ([string]::IsNullOrWhiteSpace($StackTraceString)) {
        return $result
    }

    # Try pattern: "at line: 123 in <path>" (Pester 4)
    if ($StackTraceString -match 'at line:\s*(\d+)\s+in\s+(.+?)(?:\r|\n|$)') {
        $result.Line = $matches[1]
        $result.File = $matches[2].Trim()
        return $result
    }

    # Try pattern: ", <path>:123" (Pester 5 format)
    # This handles both Unix paths (/path/file.ps1:123) and Windows paths (C:\path\file.ps1:123)
    if ($StackTraceString -match ',\s*((?:[A-Za-z]:)?[\/\\].+?\.ps[m]?1):(\d+)') {
        $result.File = $matches[1].Trim()
        $result.Line = $matches[2]
        return $result
    }

    # Try pattern: "at <path>:123" (without comma)
    # Handle both absolute Unix and Windows paths
    if ($StackTraceString -match 'at\s+((?:[A-Za-z]:)?[\/\\][^,]+?\.ps[m]?1):(\d+)(?:\r|\n|$)') {
        $result.File = $matches[1].Trim()
        $result.Line = $matches[2]
        return $result
    }

    # Try pattern: "<path>: line 123"
    if ($StackTraceString -match '((?:[A-Za-z]:)?[\/\\][^,]+?\.ps[m]?1):\s*line\s+(\d+)(?:\r|\n|$)') {
        $result.File = $matches[1].Trim()
        $result.Line = $matches[2]
        return $result
    }

    # Try to extract just the file path if no line number found
    if ($StackTraceString -match '(?:at\s+|in\s+)?((?:[A-Za-z]:)?[\/\\].+?\.ps[m]?1)') {
        $result.File = $matches[1].Trim()
    }

    return $result
}

function Test-XUnitTestResults
{
    <#
    .SYNOPSIS
        Validates an xUnit XML result file and throws if any tests failed.
    .DESCRIPTION
        Parses the specified xUnit result file, logs description, name, message, and
        stack trace for each failed test, then throws an exception summarizing the count.
    .PARAMETER TestResultsFile
        Path to the xUnit XML result file to validate.
    #>
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $TestResultsFile
    )

    if(-not (Test-Path $TestResultsFile))
    {
        throw "File not found $TestResultsFile"
    }

    try
    {
        $results = [xml] (Get-Content $TestResultsFile)
    }
    catch
    {
        throw "Cannot convert $TestResultsFile to xml : $($_.message)"
    }

    $failedTests = $results.assemblies.assembly.collection.test | Where-Object result -eq "fail"

    if(-not $failedTests)
    {
        return $true
    }

    foreach($failure in $failedTests)
    {
        $description = $failure.type
        $name = $failure.method
        $message = $failure.failure.message
        $stack_trace = $failure.failure.'stack-trace'

        # Empty line at the end is intentional formatting
        Write-Log -isError -message         @"
        Description: $description
        Name:        $name
        message:
        $message
        stack-trace:
        $stack_trace

"@

    }

    throw "$($results.assemblies.assembly.failed) tests failed"
}

#
# Read the test result file and
# Throw if a test failed
function Test-PSPesterResults
{
    <#
    .SYNOPSIS
        Validates Pester test results and throws if any tests failed.
    .DESCRIPTION
        In file mode, reads a NUnit XML result file and logs each failure before throwing.
        In object mode, inspects a Pester PassThru result object. Optionally permits
        empty result sets.
    .PARAMETER TestResultsFile
        Path to the NUnit XML result file. Defaults to 'pester-tests.xml'.
    .PARAMETER TestArea
        Label for the test area, used in error messages. Defaults to 'test/powershell'.
    .PARAMETER ResultObject
        A Pester PassThru result object to inspect instead of parsing a file.
    .PARAMETER CanHaveNoResult
        When specified with ResultObject, suppresses the 'NO TESTS RUN' exception for
        zero-count results.
    #>
    [CmdletBinding(DefaultParameterSetName='file')]
    param(
        [Parameter(ParameterSetName='file')]
        [string] $TestResultsFile = "pester-tests.xml",

        [Parameter(ParameterSetName='file')]
        [string] $TestArea = 'test/powershell',

        [Parameter(ParameterSetName='PesterPassThruObject', Mandatory)]
        [pscustomobject] $ResultObject,

        [Parameter(ParameterSetName='PesterPassThruObject')]
        [switch] $CanHaveNoResult
    )

    if($PSCmdlet.ParameterSetName -eq 'file')
    {
        if(!(Test-Path $TestResultsFile))
        {
            throw "Test result file '$testResultsFile' not found for $TestArea."
        }

        $x = [xml](Get-Content -Raw $testResultsFile)
        if ([int]$x.'test-results'.failures -gt 0)
        {
            Write-LogGroupStart -Title 'TEST FAILURES'

            # switch between methods, SelectNode is not available on dotnet core
            if ( "System.Xml.XmlDocumentXPathExtensions" -as [Type] )
            {
                $failures = [System.Xml.XmlDocumentXPathExtensions]::SelectNodes($x."test-results",'.//test-case[@result = "Failure"]')
            }
            else
            {
                $failures = $x.SelectNodes('.//test-case[@result = "Failure"]')
            }
            foreach ( $testfail in $failures )
            {
                Show-PSPesterError -testFailure $testfail
            }

            Write-LogGroupEnd -Title 'TEST FAILURES'
            throw "$($x.'test-results'.failures) tests in $TestArea failed"
        }
    }
    elseif ($PSCmdlet.ParameterSetName -eq 'PesterPassThruObject')
    {
        if (-not $CanHaveNoResult)
        {
            $noTotalCountMember = if ($null -eq (Get-Member -InputObject $ResultObject -Name 'TotalCount')) { $true } else { $false }
            if ($noTotalCountMember)
            {
                Write-Verbose -Verbose -Message "`$ResultObject has no 'TotalCount' property"
                Write-Verbose -Verbose "$($ResultObject | Out-String)"
            }
            if ($noTotalCountMember -or $ResultObject.TotalCount -le 0)
            {
                throw 'NO TESTS RUN'
            }
        }
        elseif ($ResultObject.FailedCount -gt 0)
        {
            Write-LogGroupStart -Title 'TEST FAILURES'

            $ResultObject.TestResult | Where-Object {$_.Passed -eq $false} | ForEach-Object {
                Show-PSPesterError -testFailureObject $_
            }
            Write-LogGroupEnd -Title 'TEST FAILURES'

            throw "$($ResultObject.FailedCount) tests in $TestArea failed"
        }
    }
}

function Start-PSxUnit {
    <#
    .SYNOPSIS
        Runs the xUnit tests for the PowerShell engine.
    .DESCRIPTION
        Executes 'dotnet test' in the test/xUnit directory against the built PowerShell
        binaries. On Unix, copies native libraries and required dependencies into the test
        output directory. Publishes results to CI when not in debug-logging mode.
    .PARAMETER xUnitTestResultsFile
        Path for the xUnit XML result file. Defaults to 'xUnitResults.xml'.
    .PARAMETER DebugLogging
        Enables detailed console test output instead of writing an XML result file.
    .PARAMETER Filter
        An xUnit filter expression to restrict which tests are run.
    #>
    [CmdletBinding()]param(
        [string] $xUnitTestResultsFile = "xUnitResults.xml",
        [switch] $DebugLogging,
        [string] $Filter
    )

    # Add .NET CLI tools to PATH
    Find-Dotnet

    $Content = Split-Path -Parent (Get-PSOutput)
    if (-not (Test-Path $Content)) {
        throw "PowerShell must be built before running tests!"
    }

    $originalDOTNET_ROOT = $env:DOTNET_ROOT

    try {
        Push-Location $PSScriptRoot/test/xUnit

        # Add workaround to unblock xUnit testing see issue: https://github.com/dotnet/sdk/issues/26462
        $dotnetPath = if ($environment.IsWindows) { "$env:LocalAppData\Microsoft\dotnet" } else { "$env:HOME/.dotnet" }
        $env:DOTNET_ROOT = $dotnetPath

        # Path manipulation to obtain test project output directory

        if(-not $environment.IsWindows)
        {
            if($environment.IsMacOS)
            {
                $nativeLib = "$Content/libpsl-native.dylib"
            }
            else
            {
                $nativeLib = "$Content/libpsl-native.so"
            }

            $requiredDependencies = @(
                $nativeLib,
                "$Content/Microsoft.Management.Infrastructure.dll",
                "$Content/System.Text.Encoding.CodePages.dll"
            )

            if((Test-Path $requiredDependencies) -notcontains $false)
            {
                $options = Get-PSOptions -DefaultToNew
                $Destination = "bin/$($options.configuration)/$($options.framework)"
                New-Item $Destination -ItemType Directory -Force > $null
                Copy-Item -Path $requiredDependencies -Destination $Destination -Force
            }
            else
            {
                throw "Dependencies $requiredDependencies not met."
            }
        }

        if (Test-Path $xUnitTestResultsFile) {
            Remove-Item $xUnitTestResultsFile -Force -ErrorAction SilentlyContinue
        }

        # We run the xUnit tests sequentially to avoid race conditions caused by manipulating the config.json file.
        # xUnit tests run in parallel by default. To make them run sequentially, we need to define the 'xunit.runner.json' file.
        $extraParams = @()
        if($Filter) {
            $extraParams += @(
                '--filter'
                $Filter
            )
        }

        if($DebugLogging) {
            $extraParams += @(
                "--logger:console;verbosity=detailed"
            )
        } else {
            $extraParams += @(
                "--logger:xunit;LogFilePath=$xUnitTestResultsFile"
            )
        }
        dotnet test @extraParams --configuration $Options.configuration --test-adapter-path:.

        if(!$DebugLogging){
            Publish-TestResults -Path $xUnitTestResultsFile -Type 'XUnit' -Title 'Xunit Sequential'
        }
    }
    finally {
        $env:DOTNET_ROOT = $originalDOTNET_ROOT
        Pop-Location
    }
}

function Get-DotnetEarlyAccess {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$DestinationPath,

        [Parameter(Mandatory)]
        [ValidateSet('win-x64', 'win-x86', 'win-arm64', 'linux-x64', 'linux-arm64', 'linux-alpine-x64', 'linux-alpine-arm64', 'osx-x64', 'osx-arm64', 'all')]
        [string]$Architecture,

        [Parameter(Mandatory)]
        [string]$DOTNET_PRIVATE_SAS
    )

    $baseUrl = $env:DOTNET_PRIVATE_BLOB_BASE_URL
    $DOTNET_RUNTIME_VERSION = $env:DOTNET_RUNTIME_VERSION
    $DOTNET_SDK_VERSION = $env:DOTNET_SDK_VERSION

    $fileName = switch ($Architecture) {
        'win-x64' { "dotnet-sdk-$DOTNET_SDK_VERSION-win-x64.zip" }
        'win-x86' { "dotnet-sdk-$DOTNET_SDK_VERSION-win-x86.zip" }
        'win-arm64' { "dotnet-sdk-$DOTNET_SDK_VERSION-win-arm64.zip" }
        'linux-x64' { "dotnet-sdk-$DOTNET_SDK_VERSION-linux-x64.tar.gz" }
        'linux-arm64' { "dotnet-sdk-$DOTNET_SDK_VERSION-linux-arm64.tar.gz" }
        'linux-alpine-x64' { "dotnet-sdk-$DOTNET_SDK_VERSION-linux-musl-x64.tar.gz" }
        'linux-alpine-arm64' { "dotnet-sdk-$DOTNET_SDK_VERSION-linux-musl-arm64.tar.gz" }
        'osx-x64' { "dotnet-sdk-$DOTNET_SDK_VERSION-osx-x64.tar.gz" }
        'osx-arm64' { "dotnet-sdk-$DOTNET_SDK_VERSION-osx-arm64.tar.gz" }
        'all' { @(
            "dotnet-sdk-$DOTNET_SDK_VERSION-win-x64.zip"
            "dotnet-sdk-$DOTNET_SDK_VERSION-win-x86.zip"
            "dotnet-sdk-$DOTNET_SDK_VERSION-win-arm64.zip"
            "dotnet-sdk-$DOTNET_SDK_VERSION-linux-x64.tar.gz"
            "dotnet-sdk-$DOTNET_SDK_VERSION-linux-arm64.tar.gz"
            "dotnet-sdk-$DOTNET_SDK_VERSION-linux-musl-x64.tar.gz"
            "dotnet-sdk-$DOTNET_SDK_VERSION-linux-musl-arm64.tar.gz"
            "dotnet-sdk-$DOTNET_SDK_VERSION-osx-x64.tar.gz"
            "dotnet-sdk-$DOTNET_SDK_VERSION-osx-arm64.tar.gz"
        ) }
        default { throw "Unsupported architecture: $Architecture" }
    }

    $fileName | ForEach-Object {
        $destFile = "$DestinationPath/$_"
        Write-Verbose -Verbose "Downloading $_ from $baseUrl/$DOTNET_RUNTIME_VERSION to $destFile"

        try {
            Invoke-WebRequest -Uri "$baseUrl/$DOTNET_RUNTIME_VERSION/$_$DOTNET_PRIVATE_SAS" -OutFile $destFile -RetryIntervalSec 5 -MaximumRetryCount 3
        }
        catch {
            Write-Error "Failed to download $_ from $baseUrl/$DOTNET_RUNTIME_VERSION"
            throw
        }
    }
}

function Install-Dotnet {
    <#
    .SYNOPSIS
        Installs the .NET SDK using the official install script.
    .DESCRIPTION
        Downloads and runs dotnet-install.sh (Linux/macOS) or dotnet-install.ps1 (Windows)
        to install the specified SDK version into the user-local dotnet installation directory.
    .PARAMETER Channel
        The release channel to install from when no explicit version is given.
    .PARAMETER Version
        The exact SDK version to install. Defaults to the version required by this repository.
    .PARAMETER Quality
        The quality level (e.g. 'GA', 'preview') used when installing by channel.
    .PARAMETER RemovePreviousVersion
        Attempts to uninstall previously installed dotnet packages before installing.
    .PARAMETER NoSudo
        Omits sudo from install commands, useful inside containers running as root.
    .PARAMETER InstallDir
        Custom installation directory for the .NET SDK.
    .PARAMETER AzureFeed
        Override URL for the Azure CDN feed used to download the SDK.
    .PARAMETER FeedCredential
        Credential token for accessing a private Azure feed.
    #>
    [CmdletBinding()]
    param(
        [string]$Channel = $dotnetCLIChannel,
        [string]$Version = $dotnetCLIRequiredVersion,
        [string]$Quality = $dotnetCLIQuality,
        [switch]$RemovePreviousVersion,
        [switch]$NoSudo,
        [string]$InstallDir,
        [string]$AzureFeed,
        [string]$FeedCredential
    )

    Write-LogGroupStart -Title "Install .NET SDK $Version"
    Write-Verbose -Verbose "In install-dotnet"

    # This allows sudo install to be optional; needed when running in containers / as root
    # Note that when it is null, Invoke-Expression (but not &) must be used to interpolate properly
    $sudo = if (!$NoSudo) { "sudo" }

    $installObtainUrl = "https://builds.dotnet.microsoft.com/dotnet/scripts/v1"
    #$installObtainUrl = "https://dotnet.microsoft.com/download/dotnet/scripts/v1"
    $uninstallObtainUrl = "https://raw.githubusercontent.com/dotnet/cli/master/scripts/obtain"

    # Install for Linux and OS X
    if ($environment.IsLinux -or $environment.IsMacOS) {
        $wget = Get-Command -Name wget -CommandType Application -TotalCount 1 -ErrorAction Stop

        # Attempt to uninstall previous dotnet packages if requested
        if ($RemovePreviousVersion) {
            $uninstallScript = if ($environment.IsLinux -and $environment.IsUbuntu) {
                "dotnet-uninstall-debian-packages.sh"
            } elseif ($environment.IsMacOS) {
                "dotnet-uninstall-pkgs.sh"
            }

            if ($uninstallScript) {
                Start-NativeExecution {
                    & $wget $uninstallObtainUrl/uninstall/$uninstallScript
                    Invoke-Expression "$sudo bash ./$uninstallScript"
                }
            } else {
                Write-Warning "This script only removes prior versions of dotnet for Ubuntu and OS X"
            }
        }

        Write-Verbose -Verbose "Invoking install script"

        # Install new dotnet 1.1.0 preview packages
        $installScript = "dotnet-install.sh"

        Write-Verbose -Message "downloading install script from $installObtainUrl/$installScript ..." -Verbose
            & $wget $installObtainUrl/$installScript

            if ((Get-ChildItem "./$installScript").Length -eq 0) {
                throw "./$installScript was 0 length"
            }

            if ($Version) {
                $bashArgs = @("./$installScript", '-v', $Version)
            }
            elseif ($Channel) {
                $bashArgs = @("./$installScript", '-c', $Channel, '-q', $Quality)
            }

            if ($InstallDir) {
                $bashArgs += @('-i', $InstallDir)
            }

            if ($AzureFeed) {
                $bashArgs += @('-AzureFeed', $AzureFeed)
            }

            if ($FeedCredential) {
                $bashArgs += @('-FeedCredential', $FeedCredential)
            }

            $bashArgs += @('-skipnonversionedfiles')

            $bashArgs -join ' ' | Write-Verbose -Verbose

        Start-NativeExecution {
            bash @bashArgs
        }
    } elseif ($environment.IsWindows) {
        Remove-Item -ErrorAction SilentlyContinue -Recurse -Force ~\AppData\Local\Microsoft\dotnet
        $installScript = "dotnet-install.ps1"
        Invoke-WebRequest -Uri $installObtainUrl/$installScript -OutFile $installScript
        if (-not $environment.IsCoreCLR) {
            $installArgs = @{}
            if ($Version) {
                $installArgs += @{ Version = $Version }
            } elseif ($Channel) {
                $installArgs += @{ Quality = $Quality }
                $installArgs += @{ Channel = $Channel }
            }

            if ($InstallDir) {
                $installArgs += @{ InstallDir = $InstallDir }
            }

            if ($AzureFeed) {
                $installArgs += @{AzureFeed = $AzureFeed}
            }

            if ($FeedCredential) {
                $installArgs += @{FeedCredential = $FeedCredential}
            }

            $installArgs += @{ SkipNonVersionedFiles = $true }

            $installArgs | Out-String | Write-Verbose -Verbose
            & ./$installScript @installArgs
        }
        else {
            # dotnet-install.ps1 uses APIs that are not supported in .NET Core, so we run it with Windows PowerShell
            $fullPSPath = Join-Path -Path $env:windir -ChildPath "System32\WindowsPowerShell\v1.0\powershell.exe"
            $fullDotnetInstallPath = Join-Path -Path (Convert-Path -Path $PWD.Path) -ChildPath $installScript

            if ($Version) {
                $psArgs = @('-NoLogo', '-NoProfile', '-File', $fullDotnetInstallPath, '-Version', $Version)
            }
            elseif ($Channel) {
                $psArgs = @('-NoLogo', '-NoProfile', '-File', $fullDotnetInstallPath, '-Channel', $Channel, '-Quality', $Quality)
            }

            if ($InstallDir) {
                $psArgs += @('-InstallDir', $InstallDir)
            }

            if ($AzureFeed) {
                $psArgs += @('-AzureFeed', $AzureFeed)
            }

            if ($FeedCredential) {
                $psArgs += @('-FeedCredential', $FeedCredential)
            }

            $psArgs += @('-SkipNonVersionedFiles')

            # Removing the verbose message to not expose the secret
            # $psArgs -join ' ' | Write-Verbose -Verbose

            Start-NativeExecution {
                & $fullPSPath @psArgs
            }
        }
    }
    Write-LogGroupEnd -Title "Install .NET SDK $Version"
}

function Get-RedHatPackageManager {
    <#
    .SYNOPSIS
        Returns the install command prefix for the available Red Hat-family package manager.
    .DESCRIPTION
        Detects whether yum, dnf, or tdnf is installed and returns the corresponding
        install command string for use in bootstrapping scripts.
    .OUTPUTS
        System.String. A package-manager install command such as 'dnf install -y -q'.
    #>
    if ($environment.IsCentOS -or (Get-Command -Name yum -CommandType Application -ErrorAction SilentlyContinue)) {
        "yum install -y -q"
    } elseif ($environment.IsFedora -or (Get-Command -Name dnf -CommandType Application -ErrorAction SilentlyContinue)) {
        "dnf install -y -q"
    } elseif ($environment.IsMariner -or (Get-Command -Name Test-DscConfiguration -CommandType Application -ErrorAction SilentlyContinue)) {
        "tdnf install -y -q"
    } else {
        throw "Error determining package manager for this distribution."
    }
}

function Start-PSBootstrap {
    <#
    .SYNOPSIS
        Installs build dependencies for PowerShell.
    .DESCRIPTION
        Depending on the selected scenario, installs native OS packages, the required
        .NET SDK, Windows packaging tools (WiX), and/or .NET global tools (dotnet-format).
        Supports Linux, macOS, and Windows.
    .PARAMETER Channel
        The .NET SDK release channel to use when installing by channel.
    .PARAMETER Version
        The exact .NET SDK version to install. Defaults to the required version.
    .PARAMETER NoSudo
        Omits sudo from native-package install commands, useful inside containers.
    .PARAMETER BuildLinuxArm
        Installs Linux ARM cross-compilation dependencies (Ubuntu/AzureLinux only).
    .PARAMETER Force
        Forces .NET SDK reinstallation even if the correct version is already present.
    .PARAMETER Scenario
        What to install: 'Package' (packaging tools), 'DotNet' (.NET SDK),
        'Both' (Package + DotNet), 'Tools' (.NET global tools), or 'All' (everything).
    #>
    [CmdletBinding()]
    param(
        [string]$Channel = $dotnetCLIChannel,
        # we currently pin dotnet-cli version, and will
        # update it when more stable version comes out.
        [string]$Version = $dotnetCLIRequiredVersion,
        [switch]$NoSudo,
        [switch]$BuildLinuxArm,
        [switch]$Force,
        [Parameter(Mandatory = $true)]
        # Package: Install dependencies for packaging tools (rpmbuild, dpkg-deb, pkgbuild, WiX)
        # DotNet: Install the .NET SDK
        # Both: Package and DotNet scenarios
        # Tools: Install .NET global tools (e.g., dotnet-format)
        # All: Install all dependencies (packaging, .NET SDK, and tools)
        [ValidateSet("Package", "DotNet", "Both", "Tools", "All")]
        [string]$Scenario = "Package"
    )

    Write-Log -message "Installing PowerShell build dependencies"

    Push-Location $PSScriptRoot/tools

    if ($dotnetSDKVersionOveride) {
        $Version = $dotnetSDKVersionOveride
    }

    try {
        if ($environment.IsLinux -or $environment.IsMacOS) {
            Write-LogGroupStart -Title "Install Native Dependencies"
            # This allows sudo install to be optional; needed when running in containers / as root
            # Note that when it is null, Invoke-Expression (but not &) must be used to interpolate properly
            $sudo = if (!$NoSudo) { "sudo" }

            if ($BuildLinuxArm -and $environment.IsLinux -and -not $environment.IsUbuntu -and -not $environment.IsMariner) {
                Write-Error "Cross compiling for linux-arm is only supported on AzureLinux/Ubuntu environment"
                Write-LogGroupEnd -Title "Install Native Dependencies"
                return
            }

            # Install ours and .NET's dependencies
            $Deps = @()
            if ($environment.IsLinux -and $environment.IsUbuntu) {
                # Build tools
                $Deps += "curl", "wget"

                # .NET Core required runtime libraries
                if ($environment.IsUbuntu16) { $Deps += "libicu55" }
                elseif ($environment.IsUbuntu18) { $Deps += "libicu60"}

                # Packaging tools
                # Note: ruby-dev, libffi-dev, g++, and make are no longer needed for DEB packaging
                # DEB packages now use native dpkg-deb (pre-installed)
                if ($Scenario -eq 'Both' -or $Scenario -eq 'Package') { $Deps += "groff", "rpm" }

                # Install dependencies
                # change the fontend from apt-get to noninteractive
                $originalDebianFrontEnd=$env:DEBIAN_FRONTEND
                $env:DEBIAN_FRONTEND='noninteractive'
                try {
                    Start-NativeExecution {
                        Invoke-Expression "$sudo apt-get update -qq"
                        Invoke-Expression "$sudo apt-get install -y -qq $Deps"
                    }
                }
                finally {
                    # change the apt frontend back to the original
                    $env:DEBIAN_FRONTEND=$originalDebianFrontEnd
                }
            } elseif ($environment.IsLinux -and ($environment.IsRedHatFamily -or $environment.IsMariner)) {
                # Build tools
                $Deps += "which", "curl", "wget"

                # .NET Core required runtime libraries
                $Deps += "libicu", "openssl-libs"

                # Packaging tools
                # Note: ruby-devel and libffi-devel are no longer needed
                # RPM packages use rpmbuild, DEB packages use dpkg-deb
                if ($Scenario -eq 'Both' -or $Scenario -eq 'Package') { $Deps += "rpm-build", "groff" }

                $PackageManager = Get-RedHatPackageManager

                $baseCommand = "$sudo $PackageManager"

                # On OpenSUSE 13.2 container, sudo does not exist, so don't use it if not needed
                if($NoSudo)
                {
                    $baseCommand = $PackageManager
                }

                # Install dependencies
                Start-NativeExecution {
                    Invoke-Expression "$baseCommand $Deps"
                }
            } elseif ($environment.IsLinux -and $environment.IsSUSEFamily) {
                # Build tools
                $Deps += "wget"

                # Packaging tools
                # Note: ruby-devel and libffi-devel are no longer needed for packaging
                if ($Scenario -eq 'Both' -or $Scenario -eq 'Package') { $Deps += "rpmbuild", "groff" }

                $PackageManager = "zypper --non-interactive install"
                $baseCommand = "$sudo $PackageManager"

                # On OpenSUSE 13.2 container, sudo does not exist, so don't use it if not needed
                if($NoSudo)
                {
                    $baseCommand = $PackageManager
                }

                # Install dependencies
                Start-NativeExecution {
                    Invoke-Expression "$baseCommand $Deps"
                }
            } elseif ($environment.IsMacOS) {
                if ($environment.UsingHomebrew) {
                    $baseCommand = "brew install --quiet"
                } elseif ($environment.UsingMacports) {
                    $baseCommand = "$sudo port -q install"
                }

                # wget for downloading dotnet
                $Deps += "wget"

                # .NET Core required runtime libraries
                $Deps += "openssl"

                # Install dependencies
                # ignore exitcode, because they may be already installed
                Start-NativeExecution ([ScriptBlock]::Create("$baseCommand $Deps")) -IgnoreExitcode
            } elseif ($environment.IsLinux -and $environment.IsAlpine) {
                $Deps += 'libunwind', 'libcurl', 'bash', 'build-base', 'git', 'curl', 'wget'

                Start-NativeExecution {
                    Invoke-Expression "apk add $Deps"
                }
            }

            if ($Scenario -in 'All', 'Both', 'Package') {
                # For RPM-based systems, ensure rpmbuild is available
                if ($environment.IsLinux -and ($environment.IsRedHatFamily -or $environment.IsSUSEFamily -or $environment.IsMariner)) {
                    Write-Verbose -Verbose "Checking for rpmbuild..."
                    if (!(Get-Command rpmbuild -ErrorAction SilentlyContinue)) {
                        Write-Warning "rpmbuild not found. Installing rpm-build package..."
                        Start-NativeExecution -sb ([ScriptBlock]::Create("$sudo $PackageManager install -y rpm-build")) -IgnoreExitcode
                    }
                }

                # For Debian-based systems and Mariner, ensure dpkg-deb is available
                if ($environment.IsLinux -and ($environment.IsDebianFamily -or $environment.IsMariner)) {
                    Write-Verbose -Verbose "Checking for dpkg-deb..."
                    if (!(Get-Command dpkg-deb -ErrorAction SilentlyContinue)) {
                        Write-Warning "dpkg-deb not found. Installing dpkg package..."
                        if ($environment.IsMariner) {
                            # For Mariner (Azure Linux), install the extended repo first to access dpkg.
                            Write-Verbose -verbose "BEGIN: /etc/os-release content:"
                            Get-Content /etc/os-release | Write-Verbose -verbose
                            Write-Verbose -verbose "END: /etc/os-release content"

                            Write-Verbose -Verbose "Installing azurelinux-repos-extended for Mariner..."

                            Start-NativeExecution -sb ([ScriptBlock]::Create("$sudo $PackageManager azurelinux-repos-extended")) -IgnoreExitcode -Verbose
                            Start-NativeExecution -sb ([ScriptBlock]::Create("$sudo $PackageManager dpkg")) -IgnoreExitcode -Verbose
                        } else {
                            Start-NativeExecution -sb ([ScriptBlock]::Create("$sudo apt-get install -y dpkg")) -IgnoreExitcode
                        }
                    }
                }
            }
            Write-LogGroupEnd -Title "Install Native Dependencies"
        }

        if ($Scenario -eq 'DotNet' -or $Scenario -eq 'Both') {
            Write-LogGroupStart -Title "Install .NET SDK"

            Write-Verbose -Verbose "Calling Find-Dotnet from Start-PSBootstrap"

            # Try to locate dotnet-SDK before installing it
            Find-Dotnet

            Write-Verbose -Verbose "Back from calling Find-Dotnet from Start-PSBootstrap"

            # Install dotnet-SDK
            $dotNetExists = precheck 'dotnet' $null
            $dotNetVersion = [string]::Empty
            if($dotNetExists) {
                $dotNetVersion = Find-RequiredSDK $dotnetCLIRequiredVersion
            }

            if(!$dotNetExists -or $dotNetVersion -ne $dotnetCLIRequiredVersion -or $Force.IsPresent) {
                if($Force.IsPresent) {
                    Write-Log -message "Installing dotnet due to -Force."
                }
                elseif(!$dotNetExists) {
                    Write-Log -message "dotnet not present.  Installing dotnet."
                }
                else {
                    Write-Log -message "dotnet out of date ($dotNetVersion).  Updating dotnet."
                }

                $DotnetArguments = @{ Channel=$Channel; Version=$Version; NoSudo=$NoSudo }

                if ($dotnetAzureFeed) {
                    $null = $DotnetArguments.Add("AzureFeed", $dotnetAzureFeed)
                    $null = $DotnetArguments.Add("FeedCredential", $dotnetAzureFeedSecret)
                }

                Install-Dotnet @DotnetArguments
            }
            else {
                Write-Log -message "dotnet is already installed.  Skipping installation."
            }
            Write-LogGroupEnd -Title "Install .NET SDK"
        }

        # Install Windows dependencies if `-Package` or `-BuildWindowsNative` is specified
        if ($environment.IsWindows) {
            Write-LogGroupStart -Title "Install Windows Dependencies"
            ## The VSCode build task requires 'pwsh.exe' to be found in Path
            if (-not (Get-Command -Name pwsh.exe -CommandType Application -ErrorAction Ignore))
            {
                Write-Log -message "pwsh.exe not found. Install latest PowerShell release and add it to Path"
                $psInstallFile = [System.IO.Path]::Combine($PSScriptRoot, "tools", "install-powershell.ps1")
                & $psInstallFile -AddToPath
            }
            if ($Scenario -eq 'Both' -or $Scenario -eq 'Package') {
                Import-Module "$PSScriptRoot\tools\wix\wix.psm1"
                $isArm64 = "$env:RUNTIME" -eq 'arm64'
                Install-Wix -arm64:$isArm64
            }
            Write-LogGroupEnd -Title "Install Windows Dependencies"
        }

        # Ensure dotnet is available
        Find-Dotnet

        if (-not $env:TF_BUILD) {
            if ($Scenario -eq 'DotNet' -or $Scenario -eq 'Both') {
                Write-LogGroupStart -Title "Install .NET SDK"
                Write-Log -message "Installing .NET global tools"

                # Install dotnet-format
                Write-Verbose -Verbose "Installing dotnet-format global tool"
                Start-NativeExecution {
                    dotnet tool install --global dotnet-format
                }
                Write-LogGroupEnd -Title "Install .NET Global Tools"
            }
        }

        if ($env:TF_BUILD) {
            Write-LogGroupStart -Title "Capture NuGet Sources"
            Write-Verbose -Verbose "--- Start - Capturing nuget sources"
            dotnet nuget list source --format detailed
            Write-Verbose -Verbose "--- End   - Capturing nuget sources"
            Write-LogGroupEnd -Title "Capture NuGet Sources"
        }
    } finally {
        Pop-Location
    }
}

## If the required SDK version is found, return it.
## Otherwise, return the latest installed SDK version that can be found.
function Find-RequiredSDK {
    <#
    .SYNOPSIS
        Returns the installed .NET SDK version that best satisfies the required version.
    .DESCRIPTION
        Lists installed SDKs with 'dotnet --list-sdks'. Returns the required version
        string if it is installed; otherwise returns the newest installed SDK version.
    .PARAMETER requiredSdkVersion
        The exact .NET SDK version string to search for.
    .OUTPUTS
        System.String. The matched or newest installed SDK version string.
    #>
    param(
        [Parameter(Mandatory, Position = 0)]
        [string] $requiredSdkVersion
    )

    $output = Start-NativeExecution -sb { dotnet --list-sdks } -IgnoreExitcode 2> $null

    $installedSdkVersions = $output | ForEach-Object {
        # this splits strings like
        # '6.0.202 [C:\Program Files\dotnet\sdk]'
        # '7.0.100-preview.2.22153.17 [C:\Users\johndoe\AppData\Local\Microsoft\dotnet\sdk]'
        # into version and path parts.
        ($_ -split '\s',2)[0]
    }

    if ($installedSdkVersions -contains $requiredSdkVersion) {
        $requiredSdkVersion
    }
    else {
        $installedSdkVersions | Sort-Object -Descending | Select-Object -First 1
    }
}

function Start-DevPowerShell {
    <#
    .SYNOPSIS
        Launches a PowerShell session using the locally built pwsh.
    .DESCRIPTION
        Starts a new pwsh process from the build output directory, optionally setting
        the DEVPATH environment variable, redirecting PSModulePath to the built Modules
        directory, and loading or suppressing the user profile.
    .PARAMETER ArgumentList
        Additional arguments passed to the pwsh process.
    .PARAMETER LoadProfile
        When specified, the user profile is loaded (by default -noprofile is prepended).
    .PARAMETER Configuration
        Build configuration whose output directory to use (ConfigurationParamSet).
    .PARAMETER BinDir
        Explicit path to the directory containing the pwsh binary (BinDirParamSet).
    .PARAMETER NoNewWindow
        Runs pwsh in the current console window instead of a new one.
    .PARAMETER Command
        A command string passed to pwsh via -command.
    .PARAMETER KeepPSModulePath
        Preserves the existing PSModulePath instead of redirecting it to the build output.
    #>
    [CmdletBinding(DefaultParameterSetName='ConfigurationParamSet')]
    param(
        [string[]]$ArgumentList = @(),
        [switch]$LoadProfile,
        [Parameter(ParameterSetName='ConfigurationParamSet')]
        [ValidateSet('Debug', 'Release', 'CodeCoverage', 'StaticAnalysis', '')] # should match New-PSOptions -Configuration values
        [string]$Configuration,
        [Parameter(ParameterSetName='BinDirParamSet')]
        [string]$BinDir,
        [switch]$NoNewWindow,
        [string]$Command,
        [switch]$KeepPSModulePath
    )

    try {
        if (-not $BinDir) {
            $BinDir = Split-Path (New-PSOptions -Configuration $Configuration).Output
        }

        if ((-not $NoNewWindow) -and ($environment.IsCoreCLR)) {
            Write-Warning "Start-DevPowerShell -NoNewWindow is currently implied in PowerShellCore edition https://github.com/PowerShell/PowerShell/issues/1543"
            $NoNewWindow = $true
        }

        if (-not $LoadProfile) {
            $ArgumentList = @('-noprofile') + $ArgumentList
        }

        if (-not $KeepPSModulePath) {
            if (-not $Command) {
                $ArgumentList = @('-NoExit') + $ArgumentList
            }
            $Command = '$env:PSModulePath = Join-Path $env:DEVPATH Modules; ' + $Command
        }

        if ($Command) {
            $ArgumentList = $ArgumentList + @("-command $Command")
        }

        $env:DEVPATH = $BinDir


        # splatting for the win
        $startProcessArgs = @{
            FilePath = Join-Path $BinDir 'pwsh'
        }

        if ($ArgumentList) {
            $startProcessArgs.ArgumentList = $ArgumentList
        }

        if ($NoNewWindow) {
            $startProcessArgs.NoNewWindow = $true
            $startProcessArgs.Wait = $true
        }

        Start-Process @startProcessArgs
    } finally {
        if($env:DevPath)
        {
            Remove-Item env:DEVPATH
        }
    }
}

function Start-TypeGen
{
    <#
    .SYNOPSIS
        Generates the CorePsTypeCatalog type-catalog file.
    .DESCRIPTION
        Invokes the TypeCatalogGen .NET tool to produce CorePsTypeCatalog.cs, which maps
        .NET types to their containing assemblies. The output .inc file name varies by
        runtime to allow simultaneous builds on Windows and WSL.
    .PARAMETER IncFileName
        Name of the .inc file listing dependent assemblies. Defaults to 'powershell.inc'.
    #>
    [CmdletBinding()]
    param
    (
        [ValidateNotNullOrEmpty()]
        $IncFileName = 'powershell.inc'
    )

    # Add .NET CLI tools to PATH
    Find-Dotnet

    Push-Location "$PSScriptRoot/src/Microsoft.PowerShell.SDK"
    try {
        $ps_inc_file = "$PSScriptRoot/src/TypeCatalogGen/$IncFileName"
        Start-NativeExecution { dotnet msbuild .\Microsoft.PowerShell.SDK.csproj /t:_GetDependencies "/property:DesignTimeBuild=true;_DependencyFile=$ps_inc_file" /nologo }
    } finally {
        Pop-Location
    }

    Push-Location "$PSScriptRoot/src/TypeCatalogGen"
    try {
        Start-NativeExecution { dotnet run -- ../System.Management.Automation/CoreCLR/CorePsTypeCatalog.cs $IncFileName }
    } finally {
        Pop-Location
    }
}

function Start-ResGen
{
    <#
    .SYNOPSIS
        Regenerates C# resource bindings from resx files.
    .DESCRIPTION
        Runs the ResGen .NET tool in src/ResGen to produce strongly-typed resource classes
        for all resx files in the PowerShell project.
    #>
    [CmdletBinding()]
    param()

    # Add .NET CLI tools to PATH
    Find-Dotnet

    Push-Location "$PSScriptRoot/src/ResGen"
    try {
        Start-NativeExecution { dotnet run } | Write-Verbose
    } finally {
        Pop-Location
    }
}

function Find-Dotnet {
    <#
    .SYNOPSIS
        Ensures the required .NET SDK is available on PATH.
    .DESCRIPTION
        Checks whether the dotnet currently on PATH can locate the required SDK version.
        If not, prepends the user-local dotnet installation directory to PATH.
        Optionally sets DOTNET_ROOT and adds the global tools directory to PATH.
    .PARAMETER SetDotnetRoot
        When specified, sets the DOTNET_ROOT environment variable and adds the
        .NET global tools path to PATH.
    #>
    param (
        [switch] $SetDotnetRoot
    )

    Write-Verbose "In Find-DotNet"

    $originalPath = $env:PATH
    $dotnetPath = if ($environment.IsWindows) { "$env:LocalAppData\Microsoft\dotnet" } else { "$env:HOME/.dotnet" }

    $chosenDotNetVersion = if($dotnetSDKVersionOveride) {
        $dotnetSDKVersionOveride
    }
    else {
        $dotnetCLIRequiredVersion
    }

    # If there dotnet is already in the PATH, check to see if that version of dotnet can find the required SDK
    # This is "typically" the globally installed dotnet
    if (precheck dotnet) {
        # Must run from within repo to ensure global.json can specify the required SDK version
        Push-Location $PSScriptRoot
        $dotnetCLIInstalledVersion = Find-RequiredSDK $chosenDotNetVersion
        Pop-Location

        Write-Verbose -Message "Find-DotNet: dotnetCLIInstalledVersion = $dotnetCLIInstalledVersion; chosenDotNetVersion = $chosenDotNetVersion"

        if ($dotnetCLIInstalledVersion -ne $chosenDotNetVersion) {
            Write-Warning "The 'dotnet' in the current path can't find SDK version ${dotnetCLIRequiredVersion}, prepending $dotnetPath to PATH."
            # Globally installed dotnet doesn't have the required SDK version, prepend the user local dotnet location
            $env:PATH = $dotnetPath + [IO.Path]::PathSeparator + $env:PATH

            if ($SetDotnetRoot) {
                Write-Verbose -Verbose "Setting DOTNET_ROOT to $dotnetPath"
                $env:DOTNET_ROOT = $dotnetPath
            }
        } elseif ($SetDotnetRoot) {
            Write-Verbose -Verbose "Expected dotnet version found, setting DOTNET_ROOT to $dotnetPath"
            $env:DOTNET_ROOT = $dotnetPath
        }
    }
    else {
        Write-Warning "Could not find 'dotnet', appending $dotnetPath to PATH."
        $env:PATH += [IO.Path]::PathSeparator + $dotnetPath
    }

    if (-not (precheck 'dotnet' "Still could not find 'dotnet', restoring PATH.")) {
        $env:PATH = $originalPath
    }
}

<#
    This is one-time conversion. We use it for to turn GetEventResources.txt into GetEventResources.resx

    .EXAMPLE Convert-TxtResourceToXml -Path Microsoft.PowerShell.Commands.Diagnostics\resources
#>
function Convert-TxtResourceToXml
{
    param(
        [string[]]$Path
    )

    process {
        $Path | ForEach-Object {
            Get-ChildItem $_ -Filter "*.txt" | ForEach-Object {
                $txtFile = $_.FullName
                $resxFile = Join-Path (Split-Path $txtFile) "$($_.BaseName).resx"
                $resourceHashtable = ConvertFrom-StringData (Get-Content -Raw $txtFile)
                $resxContent = $resourceHashtable.GetEnumerator() | ForEach-Object {
@'
  <data name="{0}" xml:space="preserve">
    <value>{1}</value>
  </data>
'@ -f $_.Key, $_.Value
                } | Out-String
                Set-Content -Path $resxFile -Value ($script:RESX_TEMPLATE -f $resxContent)
            }
        }
    }
}

function script:Use-MSBuild {
    <#
    .SYNOPSIS
        Ensures that the msbuild command is available in the current scope.
    .DESCRIPTION
        If msbuild is not found in PATH, creates a script-scoped alias pointing to the
        .NET Framework 4 MSBuild at its standard Windows location. Throws if neither
        location provides a usable msbuild.
    #>
    # TODO: we probably should require a particular version of msbuild, if we are taking this dependency
    # msbuild v14 and msbuild v4 behaviors are different for XAML generation
    $frameworkMsBuildLocation = "${env:SystemRoot}\Microsoft.Net\Framework\v4.0.30319\msbuild"

    $msbuild = Get-Command msbuild -ErrorAction Ignore
    if ($msbuild) {
        # all good, nothing to do
        return
    }

    if (-not (Test-Path $frameworkMsBuildLocation)) {
        throw "msbuild not found in '$frameworkMsBuildLocation'. Install Visual Studio 2015."
    }

    Set-Alias msbuild $frameworkMsBuildLocation -Scope Script
}

function script:Write-Log
{
    <#
    .SYNOPSIS
        Writes a colored message to the host, with optional error annotation.
    .DESCRIPTION
        In GitHub Actions, error messages are emitted as workflow error annotations
        using the '::error::' command. Normal messages are written in green; errors
        in red. Console colors are reset after each call.
    .PARAMETER message
        The text to write.
    .PARAMETER isError
        When specified, writes the message as an error (red / GitHub Actions annotation).
    #>
    param
    (
        [Parameter(Position=0, Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $message,

        [switch] $isError
    )
    if ($isError)
    {
        if ($env:GITHUB_WORKFLOW) {
            # https://github.com/actions/toolkit/issues/193#issuecomment-605394935
            $escapedMessage = $message -replace "`n", "%0A" -replace "`r"
            Write-Host "::error::${escapedMessage}"
        } else {
            Write-Host -Foreground Red $message
        }
    }
    else
    {
        Write-Host -Foreground Green $message
    }
    #reset colors for older package to at return to default after error message on a compilation error
    [console]::ResetColor()
}

function script:Write-LogGroup {
    <#
    .SYNOPSIS
        Emits a titled group of log messages wrapped in log-group markers.
    .DESCRIPTION
        Calls Write-LogGroupStart, writes each message line via Write-Log, then calls
        Write-LogGroupEnd. In GitHub Actions this creates a collapsible group; on other
        hosts it adds BEGIN/END banners.
    .PARAMETER Message
        One or more message lines to write inside the group.
    .PARAMETER Title
        The title displayed for the log group.
    #>
    param
    (
        [Parameter(Position = 0, Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string[]] $Message,
        [Parameter(Mandatory)]
        [string] $Title
    )


    Write-LogGroupStart -Title $Title

    foreach ($line in $Message) {
        Write-Log -Message $line
    }

    Write-LogGroupEnd -Title $Title
}

$script:logGroupColor = [System.ConsoleColor]::Cyan

function script:Write-LogGroupStart {
    <#
    .SYNOPSIS
        Opens a collapsible log group section.
    .DESCRIPTION
        In GitHub Actions emits '::group::<Title>'. On other hosts writes a colored
        begin banner using the script-level log group color.
    .PARAMETER Title
        The label for the group.
    #>
    param
    (
        [Parameter(Mandatory)]
        [string] $Title
    )

    if ($env:GITHUB_WORKFLOW) {
        Write-Host "::group::${Title}"
    }
    else {
        Write-Host -ForegroundColor $script:logGroupColor "=== BEGIN: $Title ==="
    }
}

function script:Write-LogGroupEnd {
    <#
    .SYNOPSIS
        Closes a collapsible log group section.
    .DESCRIPTION
        In GitHub Actions emits '::endgroup::'. On other hosts writes a colored
        end banner using the script-level log group color.
    .PARAMETER Title
        The group label (used only in non-GitHub-Actions output).
    #>
    param
    (
        [Parameter(Mandatory)]
        [string] $Title
    )

    if ($env:GITHUB_WORKFLOW) {
        Write-Host "::endgroup::"
    }
    else {
        Write-Host -ForegroundColor $script:logGroupColor "==== END: $Title ===="
    }
}

function script:precheck([string]$command, [string]$missedMessage) {
    <#
    .SYNOPSIS
        Tests whether a command exists on PATH and optionally emits a warning if missing.
    .DESCRIPTION
        Uses Get-Command to locate the specified command. Returns $true if found,
        $false otherwise. If the command is absent and a message is provided,
        Write-Warning is called with that message.
    .PARAMETER command
        The command name to look for.
    .PARAMETER missedMessage
        Warning text to emit when the command is not found. Pass $null to suppress it.
    .OUTPUTS
        System.Boolean. $true when the command is found; $false otherwise.
    #>
    $c = Get-Command $command -ErrorAction Ignore
    if (-not $c) {
        if (-not [string]::IsNullOrEmpty($missedMessage))
        {
            Write-Warning $missedMessage
        }
        return $false
    } else {
        return $true
    }
}

# Cleans the PowerShell repo - everything but the root folder
function Clear-PSRepo
{
    <#
    .SYNOPSIS
        Cleans all subdirectories of the PowerShell repository using 'git clean -fdX'.
    .DESCRIPTION
        Iterates over every top-level directory under the repository root and removes all
        files that are not tracked by git, including ignored files.
    #>
    [CmdletBinding()]
    param()

    Get-ChildItem $PSScriptRoot\* -Directory | ForEach-Object {
        Write-Verbose "Cleaning $_ ..."
        git clean -fdX $_
    }
}

# Install PowerShell modules such as PackageManagement, PowerShellGet
function Copy-PSGalleryModules
{
    <#
    .SYNOPSIS
        Copies PowerShell Gallery modules from the NuGet cache to a Modules directory.
    .DESCRIPTION
        Reads the PackageReference items in the specified csproj file, resolves each
        package from the NuGet global cache, and copies it to the destination directory.
        Package nupkg and metadata files are excluded from the copy.
    .PARAMETER CsProjPath
        Path to the csproj file whose PackageReference items describe Gallery modules.
    .PARAMETER Destination
        Destination Modules directory. Must end with 'Modules'.
    .PARAMETER Force
        Forces NuGet package restore even if packages are already present.
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]$CsProjPath,

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

        [Parameter()]
        [switch]$Force
    )

    if (!$Destination.EndsWith("Modules")) {
        throw "Installing to an unexpected location"
    }

    Find-DotNet

    Restore-PSPackage -ProjectDirs (Split-Path $CsProjPath) -Force:$Force.IsPresent -PSModule

    $cache = dotnet nuget locals global-packages -l
    if ($cache -match "global-packages: (.*)") {
        $nugetCache = $Matches[1]
    }
    else {
        throw "Can't find nuget global cache"
    }

    $psGalleryProj = [xml](Get-Content -Raw $CsProjPath)

    foreach ($m in $psGalleryProj.Project.ItemGroup.PackageReference) {
        $name = $m.Include
        $version = $m.Version
        Write-Log -message "Name='$Name', Version='$version', Destination='$Destination'"

        # Remove the build revision from the src (nuget drops it).
        $srcVer = if ($version -match "(\d+.\d+.\d+).0") {
            $Matches[1]
        } elseif ($version -match "^\d+.\d+$") {
            # Two digit versions are stored as three digit versions
            "$version.0"
        } else {
            $version
        }

        # Nuget seems to always use lowercase in the cache
        $src = "$nugetCache/$($name.ToLower())/$srcVer"
        $dest = "$Destination/$name"

        Remove-Item -Force -ErrorAction Ignore -Recurse "$Destination/$name"
        New-Item -Path $dest -ItemType Directory -Force -ErrorAction Stop > $null
        # Exclude files/folders that are not needed. The fullclr folder is coming from the PackageManagement module
        $dontCopy = '*.nupkg', '*.nupkg.metadata', '*.nupkg.sha512', '*.nuspec', 'System.Runtime.InteropServices.RuntimeInformation.dll', 'fullclr'
        Copy-Item -Exclude $dontCopy -Recurse $src/* $dest -ErrorAction Stop
    }
}

function Merge-TestLogs
{
    <#
    .SYNOPSIS
        Merges xUnit and NUnit test log files into a single xUnit XML file.
    .DESCRIPTION
        Converts NUnit Pester logs to xUnit assembly format and appends them, along with
        any additional xUnit logs, to the primary xUnit log. The merged result is saved
        to the specified output path.
    .PARAMETER XUnitLogPath
        Path to the primary xUnit XML log file.
    .PARAMETER NUnitLogPath
        One or more NUnit (Pester) XML log file paths to merge in.
    .PARAMETER AdditionalXUnitLogPath
        Optional additional xUnit XML log files to append.
    .PARAMETER OutputLogPath
        Path for the merged xUnit output file.
    #>
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateScript({Test-Path $_})]
        [string]$XUnitLogPath,

        [Parameter(Mandatory = $true)]
        [ValidateScript({Test-Path $_})]
        [string[]]$NUnitLogPath,

        [Parameter()]
        [ValidateScript({Test-Path $_})]
        [string[]]$AdditionalXUnitLogPath,

        [Parameter()]
        [string]$OutputLogPath
    )

    # Convert all the NUnit logs into single object
    $convertedNUnit = ConvertFrom-PesterLog -logFile $NUnitLogPath

    $xunit = [xml] (Get-Content $XUnitLogPath -ReadCount 0 -Raw)

    $strBld = [System.Text.StringBuilder]::new($xunit.assemblies.InnerXml)

    foreach($assembly in $convertedNUnit.assembly)
    {
        $strBld.Append($assembly.ToString()) | Out-Null
    }

    foreach($path in $AdditionalXUnitLogPath)
    {
        $addXunit = [xml] (Get-Content $path -ReadCount 0 -Raw)
        $strBld.Append($addXunit.assemblies.InnerXml) | Out-Null
    }

    $xunit.assemblies.InnerXml = $strBld.ToString()
    $xunit.Save($OutputLogPath)
}

function ConvertFrom-PesterLog {
    <#
    .SYNOPSIS
        Converts Pester NUnit XML log files to xUnit assembly format.
    .DESCRIPTION
        Accepts one or more NUnit log files produced by Pester, or existing xUnit logs,
        and converts them to an in-memory xUnit assembly object model. If multiple logs
        are provided and -MultipleLog is not set, they are combined into a single
        assemblies object.
    .PARAMETER Logfile
        Path(s) to the NUnit or xUnit log file(s) to convert. Accepts pipeline input.
    .PARAMETER IncludeEmpty
        When specified, includes test assemblies that contain zero test cases.
    .PARAMETER MultipleLog
        When specified, returns one assemblies object per log file instead of combining.
    .OUTPUTS
        assemblies. One or more xUnit assemblies objects containing converted test data.
    #>
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true, Mandatory = $true, Position = 0)]
        [string[]]$Logfile,
        [Parameter()][switch]$IncludeEmpty,
        [Parameter()][switch]$MultipleLog
    )
    BEGIN {
        # CLASSES
        class assemblies {
            # attributes
            [datetime]$timestamp
            # child elements
            [System.Collections.Generic.List[testAssembly]]$assembly
            assemblies() {
                $this.timestamp = [datetime]::now
                $this.assembly = [System.Collections.Generic.List[testAssembly]]::new()
            }
            static [assemblies] op_Addition([assemblies]$ls, [assemblies]$rs) {
                $newAssembly = [assemblies]::new()
                $newAssembly.assembly.AddRange($ls.assembly)
                $newAssembly.assembly.AddRange($rs.assembly)
                return $newAssembly
            }
            [string]ToString() {
                $sb = [text.stringbuilder]::new()
                $sb.AppendLine('<assemblies timestamp="{0:MM}/{0:dd}/{0:yyyy} {0:HH}:{0:mm}:{0:ss}">' -f $this.timestamp)
                foreach ( $a in $this.assembly  ) {
                    $sb.Append("$a")
                }
                $sb.AppendLine("</assemblies>");
                return $sb.ToString()
            }
            # use Write-Output to emit these into the pipeline
            [array]GetTests() {
                return $this.Assembly.collection.test
            }
        }

        class testAssembly {
            # attributes
            [string]$name # path to pester file
            [string]${config-file}
            [string]${test-framework} # Pester
            [string]$environment
            [string]${run-date}
            [string]${run-time}
            [decimal]$time
            [int]$total
            [int]$passed
            [int]$failed
            [int]$skipped
            [int]$errors
            testAssembly ( ) {
                $this."config-file" = "no config"
                $this."test-framework" = "Pester"
                $this.environment = $script:environment
                $this."run-date" = $script:rundate
                $this."run-time" = $script:runtime
                $this.collection = [System.Collections.Generic.List[collection]]::new()
            }
            # child elements
            [error[]]$error
            [System.Collections.Generic.List[collection]]$collection
            [string]ToString() {
                $sb = [System.Text.StringBuilder]::new()
                $sb.AppendFormat('  <assembly name="{0}" ', $this.name)
                $sb.AppendFormat('environment="{0}" ', [security.securityelement]::escape($this.environment))
                $sb.AppendFormat('test-framework="{0}" ', $this."test-framework")
                $sb.AppendFormat('run-date="{0}" ', $this."run-date")
                $sb.AppendFormat('run-time="{0}" ', $this."run-time")
                $sb.AppendFormat('total="{0}" ', $this.total)
                $sb.AppendFormat('passed="{0}" ', $this.passed)
                $sb.AppendFormat('failed="{0}" ', $this.failed)
                $sb.AppendFormat('skipped="{0}" ', $this.skipped)
                $sb.AppendFormat('time="{0}" ', $this.time)
                $sb.AppendFormat('errors="{0}" ', $this.errors)
                $sb.AppendLine(">")
                if ( $this.error ) {
                    $sb.AppendLine("    <errors>")
                    foreach ( $e in $this.error ) {
                        $sb.AppendLine($e.ToString())
                    }
                    $sb.AppendLine("    </errors>")
                } else {
                    $sb.AppendLine("    <errors />")
                }
                foreach ( $col in $this.collection ) {
                    $sb.AppendLine($col.ToString())
                }
                $sb.AppendLine("  </assembly>")
                return $sb.ToString()
            }
        }

        class collection {
            # attributes
            [string]$name
            [decimal]$time
            [int]$total
            [int]$passed
            [int]$failed
            [int]$skipped
            # child element
            [System.Collections.Generic.List[test]]$test
            # constructor
            collection () {
                $this.test = [System.Collections.Generic.List[test]]::new()
            }
            [string]ToString() {
                $sb = [Text.StringBuilder]::new()
                if ( $this.test.count -eq 0 ) {
                    $sb.AppendLine("    <collection />")
                } else {
                    $sb.AppendFormat('    <collection total="{0}" passed="{1}" failed="{2}" skipped="{3}" name="{4}" time="{5}">' + "`n",
                        $this.total, $this.passed, $this.failed, $this.skipped, [security.securityelement]::escape($this.name), $this.time)
                    foreach ( $t in $this.test ) {
                        $sb.AppendLine("    " + $t.ToString());
                    }
                    $sb.Append("    </collection>")
                }
                return $sb.ToString()
            }
        }

        class errors {
            [error[]]$error
        }
        class error {
            # attributes
            [string]$type
            [string]$name
            # child elements
            [failure]$failure
            [string]ToString() {
                $sb = [system.text.stringbuilder]::new()
                $sb.AppendLine('<error type="{0}" name="{1}" >' -f $this.type, [security.securityelement]::escape($this.Name))
                $sb.AppendLine($this.failure -as [string])
                $sb.AppendLine("</error>")
                return $sb.ToString()
            }
        }

        class cdata {
            [string]$text
            cdata ( [string]$s ) { $this.text = $s }
            [string]ToString() {
                return '<![CDATA[' + [security.securityelement]::escape($this.text) + ']]>'
            }
        }

        class failure {
            [string]${exception-type}
            [cdata]$message
            [cdata]${stack-trace}
            failure ( [string]$message, [string]$stack ) {
                $this."exception-type" = "Pester"
                $this.Message = [cdata]::new($message)
                $this."stack-trace" = [cdata]::new($stack)
            }
            [string]ToString() {
                $sb = [text.stringbuilder]::new()
                $sb.AppendLine("        <failure>")
                $sb.AppendLine("          <message>" + ($this.message -as [string]) + "</message>")
                $sb.AppendLine("          <stack-trace>" + ($this."stack-trace" -as [string]) + "</stack-trace>")
                $sb.Append("        </failure>")
                return $sb.ToString()
            }
        }

        enum resultenum {
            Pass
            Fail
            Skip
        }

        class trait {
            # attributes
            [string]$name
            [string]$value
        }
        class traits {
            [trait[]]$trait
        }
        class test {
            # attributes
            [string]$name
            [string]$type
            [string]$method
            [decimal]$time
            [resultenum]$result
            # child elements
            [trait[]]$traits
            [failure]$failure
            [cdata]$reason # skip reason
            [string]ToString() {
                $sb = [text.stringbuilder]::new()
                $sb.appendformat('  <test name="{0}" type="{1}" method="{2}" time="{3}" result="{4}"',
                    [security.securityelement]::escape($this.name), [security.securityelement]::escape($this.type),
                    [security.securityelement]::escape($this.method), $this.time, $this.result)
                if ( $this.failure ) {
                    $sb.AppendLine(">")
                    $sb.AppendLine($this.failure -as [string])
                    $sb.append('      </test>')
                } else {
                    $sb.Append("/>")
                }
                return $sb.ToString()
            }
        }

        function convert-pesterlog ( [xml]$x, $logpath, [switch]$includeEmpty ) {
            <#$resultMap = @{
                Success = "Pass"
                Ignored = "Skip"
                Failure = "Fail"
            }#>

            $resultMap = @{
                Success = "Pass"
                Ignored = "Skip"
                Failure = "Fail"
                Inconclusive = "Skip"
            }

            $configfile = $logpath
            $runtime = $x."test-results".time
            $environment = $x."test-results".environment.platform + "-" + $x."test-results".environment."os-version"
            $rundate = $x."test-results".date
            $suites = $x."test-results"."test-suite".results."test-suite"
            $assemblies = [assemblies]::new()
            foreach ( $suite in $suites ) {
                $tCases = $suite.SelectNodes(".//test-case")
                # only create an assembly group if we have tests
                if ( $tCases.count -eq 0 -and ! $includeEmpty ) { continue }
                $tGroup = $tCases | Group-Object result
                $asm = [testassembly]::new()
                $asm.environment = $environment
                $asm."run-date" = $rundate
                $asm."run-time" = $runtime
                $asm.Name = $suite.name
                $asm."config-file" = $configfile
                $asm.time = $suite.time
                $asm.total = $suite.SelectNodes(".//test-case").Count
                $asm.Passed = $tGroup| Where-Object -FilterScript {$_.Name -eq "Success"} | ForEach-Object -Process {$_.Count}
                $asm.Failed = $tGroup| Where-Object -FilterScript {$_.Name -eq "Failure"} | ForEach-Object -Process {$_.Count}
                $asm.Skipped = $tGroup| Where-Object -FilterScript { $_.Name -eq "Ignored" } | ForEach-Object -Process {$_.Count}
                $asm.Skipped += $tGroup| Where-Object -FilterScript { $_.Name -eq "Inconclusive" } | ForEach-Object -Process {$_.Count}
                $c = [collection]::new()
                $c.passed = $asm.Passed
                $c.failed = $asm.failed
                $c.skipped = $asm.skipped
                $c.total = $asm.total
                $c.time = $asm.time
                $c.name = $asm.name
                foreach ( $tc in $suite.SelectNodes(".//test-case")) {
                    if ( $tc.result -match "Success|Ignored|Failure" ) {
                        $t = [test]::new()
                        $t.name = $tc.Name
                        $t.time = $tc.time
                        $t.method = $tc.description # the pester actually puts the name of the "it" as description
                        $t.type = $suite.results."test-suite".description | Select-Object -First 1
                        $t.result = $resultMap[$tc.result]
                        if ( $tc.failure ) {
                            $t.failure = [failure]::new($tc.failure.message, $tc.failure."stack-trace")
                        }
                        $null = $c.test.Add($t)
                    }
                }
                $null = $asm.collection.add($c)
                $assemblies.assembly.Add($asm)
            }
            $assemblies
        }

        # convert it to our object model
        # a simple conversion
        function convert-xunitlog {
            param ( $x, $logpath )
            $asms = [assemblies]::new()
            $asms.timestamp = $x.assemblies.timestamp
            foreach ( $assembly in $x.assemblies.assembly ) {
                $asm = [testAssembly]::new()
                $asm.environment = $assembly.environment
                $asm."test-framework" = $assembly."test-framework"
                $asm."run-date" = $assembly."run-date"
                $asm."run-time" = $assembly."run-time"
                $asm.total = $assembly.total
                $asm.passed = $assembly.passed
                $asm.failed = $assembly.failed
                $asm.skipped = $assembly.skipped
                $asm.time = $assembly.time
                $asm.name = $assembly.name
                foreach ( $coll in $assembly.collection ) {
                    $c = [collection]::new()
                    $c.name = $coll.name
                    $c.total = $coll.total
                    $c.passed = $coll.passed
                    $c.failed = $coll.failed
                    $c.skipped = $coll.skipped
                    $c.time = $coll.time
                    foreach ( $t in $coll.test ) {
                        $test = [test]::new()
                        $test.name = $t.name
                        $test.type = $t.type
                        $test.method = $t.method
                        $test.time = $t.time
                        $test.result = $t.result
                        $c.test.Add($test)
                    }
                    $null = $asm.collection.add($c)
                }
                $null = $asms.assembly.add($asm)
            }
            $asms
        }
        $Logs = @()
    }

    PROCESS {
        #### MAIN ####
        foreach ( $log in $Logfile ) {
            foreach ( $logpath in (Resolve-Path $log).path ) {
                Write-Progress "converting file $logpath"
                if ( ! $logpath) { throw "Cannot resolve $Logfile" }
                $x = [xml](Get-Content -Raw -ReadCount 0 $logpath)

                if ( $x.psobject.properties['test-results'] ) {
                    $Logs += convert-pesterlog $x $logpath -includeempty:$includeempty
                } elseif ( $x.psobject.properties['assemblies'] ) {
                    $Logs += convert-xunitlog $x $logpath -includeEmpty:$includeEmpty
                } else {
                    Write-Error "Cannot determine log type"
                }
            }
        }
    }

    END {
        if ( $MultipleLog ) {
            $Logs
        } else {
            $combinedLog = $Logs[0]
            for ( $i = 1; $i -lt $logs.count; $i++ ) {
                $combinedLog += $Logs[$i]
            }
            $combinedLog
        }
    }
}

# Save PSOptions to be restored by Restore-PSOptions
function Save-PSOptions {
    <#
    .SYNOPSIS
        Persists the current PSOptions to a JSON file.
    .DESCRIPTION
        Serializes the current build options (or the supplied Options object) to JSON
        and writes them to the specified path. Defaults to psoptions.json in the repo root.
    .PARAMETER PSOptionsPath
        Path to the JSON file to write. Defaults to '$PSScriptRoot/psoptions.json'.
    .PARAMETER Options
        PSOptions object to save. Defaults to the current build options.
    #>
    param(
        [ValidateScript({$parent = Split-Path $_;if($parent){Test-Path $parent}else{return $true}})]
        [ValidateNotNullOrEmpty()]
        [string]
        $PSOptionsPath = (Join-Path -Path $PSScriptRoot -ChildPath 'psoptions.json'),

        [ValidateNotNullOrEmpty()]
        [object]
        $Options = (Get-PSOptions -DefaultToNew)
    )

    $Options | ConvertTo-Json -Depth 3 | Out-File -Encoding utf8 -FilePath $PSOptionsPath
}

# Restore PSOptions
# Optionally remove the PSOptions file
function Restore-PSOptions {
    <#
    .SYNOPSIS
        Loads saved PSOptions from a JSON file and makes them the active build options.
    .DESCRIPTION
        Reads the JSON file produced by Save-PSOptions, reconstructs a PSOptions
        hashtable, and stores it via Set-PSOptions. Optionally deletes the file afterward.
    .PARAMETER PSOptionsPath
        Path to the JSON file to read. Defaults to '$PSScriptRoot/psoptions.json'.
    .PARAMETER Remove
        When specified, deletes the JSON file after loading.
    #>
    param(
        [ValidateScript({Test-Path $_})]
        [string]
        $PSOptionsPath = (Join-Path -Path $PSScriptRoot -ChildPath 'psoptions.json'),
        [switch]
        $Remove
    )

    $options = Get-Content -Path $PSOptionsPath | ConvertFrom-Json

    if($Remove)
    {
        # Remove PSOptions.
        # The file is only used to set the PSOptions.
        Remove-Item -Path $psOptionsPath -Force
    }

    $newOptions = New-PSOptionsObject `
                    -RootInfo $options.RootInfo `
                    -Top $options.Top `
                    -Runtime $options.Runtime `
                    -Configuration $options.Configuration `
                    -PSModuleRestore $options.PSModuleRestore `
                    -Framework $options.Framework `
                    -Output $options.Output `
                    -ForMinimalSize $options.ForMinimalSize

    Set-PSOptions -Options $newOptions
}

function New-PSOptionsObject
{
    <#
    .SYNOPSIS
        Constructs the PSOptions hashtable from individual build-option components.
    .DESCRIPTION
        Assembles the hashtable consumed by Start-PSBuild, Restore-PSPackage, and related
        commands. Prefer New-PSOptions, which auto-computes fields such as the output path.
    .PARAMETER RootInfo
        PSCustomObject with repo root path validation metadata.
    .PARAMETER Top
        Path to the top-level project directory (pwsh source directory).
    .PARAMETER Runtime
        The .NET runtime identifier (RID) for the build.
    .PARAMETER Configuration
        The build configuration: Debug, Release, CodeCoverage, or StaticAnalysis.
    .PARAMETER PSModuleRestore
        Whether Gallery modules should be restored to the build output.
    .PARAMETER Framework
        The target .NET framework moniker, e.g. 'net11.0'.
    .PARAMETER Output
        Full path to the output pwsh executable.
    .PARAMETER ForMinimalSize
        Whether this is a minimal-size build.
    .OUTPUTS
        System.Collections.Hashtable. A PSOptions hashtable.
    #>
    param(
        [PSCustomObject]
        $RootInfo,

        [Parameter(Mandatory)]
        [String]
        $Top,

        [Parameter(Mandatory)]
        [String]
        $Runtime,

        [Parameter(Mandatory)]
        [String]
        $Configuration,

        [Parameter(Mandatory)]
        [Bool]
        $PSModuleRestore,

        [Parameter(Mandatory)]
        [String]
        $Framework,

        [Parameter(Mandatory)]
        [String]
        $Output,

        [Parameter(Mandatory)]
        [Bool]
        $ForMinimalSize
    )

    return @{
        RootInfo = $RootInfo
        Top = $Top
        Configuration = $Configuration
        Framework = $Framework
        Runtime = $Runtime
        Output = $Output
        PSModuleRestore = $PSModuleRestore
        ForMinimalSize = $ForMinimalSize
    }
}

$script:RESX_TEMPLATE = @'
<?xml version="1.0" encoding="utf-8"?>
<root>
  <!--
    Microsoft ResX Schema

    Version 2.0

    The primary goals of this format is to allow a simple XML format
    that is mostly human readable. The generation and parsing of the
    various data types are done through the TypeConverter classes
    associated with the data types.

    Example:

    ... ado.net/XML headers & schema ...
    <resheader name="resmimetype">text/microsoft-resx</resheader>
    <resheader name="version">2.0</resheader>
    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
        <value>[base64 mime encoded serialized .NET Framework object]</value>
    </data>
    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
        <comment>This is a comment</comment>
    </data>

    There are any number of "resheader" rows that contain simple
    name/value pairs.

    Each data row contains a name, and value. The row also contains a
    type or mimetype. Type corresponds to a .NET class that support
    text/value conversion through the TypeConverter architecture.
    Classes that don't support this are serialized and stored with the
    mimetype set.

    The mimetype is used for serialized objects, and tells the
    ResXResourceReader how to depersist the object. This is currently not
    extensible. For a given mimetype the value must be set accordingly:

    Note - application/x-microsoft.net.object.binary.base64 is the format
    that the ResXResourceWriter will generate, however the reader can
    read any of the formats listed below.

    mimetype: application/x-microsoft.net.object.binary.base64
    value   : The object must be serialized with
            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
            : and then encoded with base64 encoding.

    mimetype: application/x-microsoft.net.object.soap.base64
    value   : The object must be serialized with
            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
            : and then encoded with base64 encoding.

    mimetype: application/x-microsoft.net.object.bytearray.base64
    value   : The object must be serialized into a byte array
            : using a System.ComponentModel.TypeConverter
            : and then encoded with base64 encoding.
    -->
  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
    <xsd:element name="root" msdata:IsDataSet="true">
      <xsd:complexType>
        <xsd:choice maxOccurs="unbounded">
          <xsd:element name="metadata">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" />
              </xsd:sequence>
              <xsd:attribute name="name" use="required" type="xsd:string" />
              <xsd:attribute name="type" type="xsd:string" />
              <xsd:attribute name="mimetype" type="xsd:string" />
              <xsd:attribute ref="xml:space" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="assembly">
            <xsd:complexType>
              <xsd:attribute name="alias" type="xsd:string" />
              <xsd:attribute name="name" type="xsd:string" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="data">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
              </xsd:sequence>
              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
              <xsd:attribute ref="xml:space" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="resheader">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
              </xsd:sequence>
              <xsd:attribute name="name" type="xsd:string" use="required" />
            </xsd:complexType>
          </xsd:element>
        </xsd:choice>
      </xsd:complexType>
    </xsd:element>
  </xsd:schema>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <resheader name="version">
    <value>2.0</value>
  </resheader>
  <resheader name="reader">
    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
  <resheader name="writer">
    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
{0}
</root>
'@

function Get-UniquePackageFolderName {
    <#
    .SYNOPSIS
        Returns a unique temporary folder path for a test package under the specified root.
    .DESCRIPTION
        Tries the path '<Root>/TestPackage' first, then appends a random numeric suffix
        until an unused path is found. Throws if a unique name cannot be found in 10 tries.
    .PARAMETER Root
        The parent directory under which the unique folder name is generated.
    .OUTPUTS
        System.String. A path under Root that does not yet exist.
    #>
    param(
        [Parameter(Mandatory)] $Root
    )

    $packagePath = Join-Path $Root 'TestPackage'

    $triesLeft = 10

    while(Test-Path $packagePath) {
        $suffix = Get-Random

        # Not using Guid to avoid maxpath problems as in example below.
        # Example: 'TestPackage-ba0ae1db-8512-46c5-8b6c-1862d33a2d63\test\powershell\Modules\Microsoft.PowerShell.Security\TestData\CatalogTestData\UserConfigProv\DSCResources\UserConfigProviderModVersion1\UserConfigProviderModVersion1.schema.mof'
        $packagePath = Join-Path $Root "TestPackage_$suffix"
        $triesLeft--

        if ($triesLeft -le 0) {
            throw "Could find unique folder name for package path"
        }
    }

    $packagePath
}

function New-TestPackage
{
    <#
    .SYNOPSIS
        Creates a zip archive containing all test content and test tools.
    .DESCRIPTION
        Builds and publishes test tools, copies the test directory, assets directory,
        and resx resource directories into a temporary staging folder, then zips the
        staging folder to TestPackage.zip in the specified destination directory.
    .PARAMETER Destination
        Directory where the TestPackage.zip file is created.
    .PARAMETER Runtime
        The .NET runtime identifier (RID) used when publishing test tool executables.
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string] $Destination,
        [string] $Runtime
    )

    if (Test-Path $Destination -PathType Leaf)
    {
        throw "Destination: '$Destination' is not a directory or does not exist."
    }
    else
    {
        $null = New-Item -Path $Destination -ItemType Directory -Force
        Write-Verbose -Message "Creating destination folder: $Destination"
    }

    $rootFolder = $env:TEMP

    # In some build agents, typically macOS on AzDevOps, $env:TEMP might not be set.
    if (-not $rootFolder -and $env:TF_BUILD) {
        $rootFolder = $env:AGENT_WORKFOLDER
    }

    Write-Verbose -Message "RootFolder: $rootFolder" -Verbose
    $packageRoot = Get-UniquePackageFolderName -Root $rootFolder

    $null = New-Item -ItemType Directory -Path $packageRoot -Force
    $packagePath = Join-Path $Destination "TestPackage.zip"
    Write-Verbose -Message "PackagePath: $packagePath" -Verbose

    # Build test tools so they are placed in appropriate folders under 'test' then copy to package root.
    $null = Publish-PSTestTools -runtime $Runtime
    $powerShellTestRoot =  Join-Path $PSScriptRoot 'test'
    Copy-Item $powerShellTestRoot -Recurse -Destination $packageRoot -Force
    Write-Verbose -Message "Copied test directory"

    # Copy assests folder to package root for wix related tests.
    $assetsPath = Join-Path $PSScriptRoot 'assets'
    Copy-Item $assetsPath -Recurse -Destination $packageRoot -Force
    Write-Verbose -Message "Copied assests directory"

    # Create expected folder structure for resx files in package root.
    $srcRootForResx = New-Item -Path "$packageRoot/src" -Force -ItemType Directory

    $resourceDirectories = Get-ChildItem -Recurse "$PSScriptRoot/src" -Directory -Filter 'resources'

    $resourceDirectories | ForEach-Object {
        $directoryFullName = $_.FullName

        $partToRemove = Join-Path $PSScriptRoot "src"

        $assemblyPart = $directoryFullName.Replace($partToRemove, '')
        $assemblyPart = $assemblyPart.TrimStart([io.path]::DirectorySeparatorChar)
        $resxDestPath = Join-Path $srcRootForResx $assemblyPart
        $null = New-Item -Path $resxDestPath -Force -ItemType Directory
        Write-Verbose -Message "Created resx directory : $resxDestPath"
        Copy-Item -Path "$directoryFullName\*" -Recurse $resxDestPath -Force
    }

    Add-Type -AssemblyName System.IO.Compression.FileSystem

    if(Test-Path $packagePath)
    {
        Remove-Item -Path $packagePath -Force
    }

    [System.IO.Compression.ZipFile]::CreateFromDirectory($packageRoot, $packagePath)
}

class NugetPackageSource {
    [string] $Url
    [string] $Name
}

function New-NugetPackageSource {
    <#
    .SYNOPSIS
        Creates a NugetPackageSource object with the given URL and name.
    .PARAMETER Url
        The NuGet feed URL.
    .PARAMETER Name
        The feed name used as the key in nuget.config.
    .OUTPUTS
        NugetPackageSource. An object with Url and Name properties.
    #>
    param(
        [Parameter(Mandatory = $true)] [string]$Url,
        [Parameter(Mandatory = $true)] [string] $Name
    )

    return [NugetPackageSource] @{Url = $Url; Name = $Name }
}

$script:NuGetEndpointCredentials = [System.Collections.Generic.Dictionary[String,System.Object]]::new()
function New-NugetConfigFile {
    <#
    .SYNOPSIS
        Generates a nuget.config file at the specified destination.
    .DESCRIPTION
        Creates a nuget.config XML file with the supplied package sources and optional
        credentials. The generated file is marked as skip-worktree in git to prevent
        accidental commits of feed credentials.
    .PARAMETER NugetPackageSource
        One or more NugetPackageSource objects defining the feeds to include.
    .PARAMETER Destination
        Directory where nuget.config is written.
    .PARAMETER UserName
        Username for authenticated feed access.
    .PARAMETER ClearTextPAT
        Personal access token in clear text for authenticated feed access.
    #>
    param(
        [Parameter(Mandatory = $true, ParameterSetName ='user')]
        [Parameter(Mandatory = $true, ParameterSetName ='nouser')]
        [NugetPackageSource[]] $NugetPackageSource,

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

        [Parameter(Mandatory = $true, ParameterSetName = 'user')]
        [string] $UserName,

        [Parameter(Mandatory = $true, ParameterSetName = 'user')]
        [string] $ClearTextPAT
    )

    $nugetConfigHeaderTemplate = @'
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <clear />
'@

    $nugetPackageSourceTemplate = @'
    <add key="[FEEDNAME]" value="[FEED]" />
'@
    $nugetPackageSourceFooterTemplate = @'
  </packageSources>
  <disabledPackageSources>
    <clear />
  </disabledPackageSources>
'@
    $nugetCredentialsTemplate = @'
  <packageSourceCredentials>
    <[FEEDNAME]>
      <add key="Username" value="[USERNAME]" />
      <add key="ClearTextPassword" value="[PASSWORD]" />
    </[FEEDNAME]>
  </packageSourceCredentials>
'@
    $nugetConfigFooterTemplate = @'
</configuration>
'@
    $content = $nugetConfigHeaderTemplate
    $feedNamePostfix = ''
    if ($UserName) {
        $feedNamePostfix += '-' + $UserName.Replace('@', '-').Replace('.', '-')
    }

    [NugetPackageSource]$source = $null
    $newLine = [Environment]::NewLine
    foreach ($source in $NugetPackageSource) {
        $content += $newLine + $nugetPackageSourceTemplate.Replace('[FEED]', $source.Url).Replace('[FEEDNAME]', $source.Name + $feedNamePostfix)
    }

    $content += $newLine + $nugetPackageSourceFooterTemplate

    if ($UserName -or $ClearTextPAT) {
        foreach ($source in $NugetPackageSource) {
            if (!$script:NuGetEndpointCredentials.ContainsKey($source.Url)) {
                $script:NuGetEndpointCredentials.Add($source.Url, @{
                        endpoint = $source.Url
                        username = $UserName
                        password = $ClearTextPAT
                    })
            }
        }
    }

    $content += $newLine + $nugetConfigFooterTemplate

    Set-Content -Path (Join-Path $Destination 'nuget.config') -Value $content -Force

    # Set the nuget.config file to be skipped by git
    push-location $Destination
    try {
        git update-index --skip-worktree (Join-Path $Destination 'nuget.config')
    } finally {
        pop-location
    }
}

function Clear-PipelineNugetAuthentication {
    <#
    .SYNOPSIS
        Clears cached NuGet feed credentials used by the pipeline.
    .DESCRIPTION
        Removes all entries from the script-scoped NuGetEndpointCredentials dictionary.
    #>
    $script:NuGetEndpointCredentials.Clear()
}

function Set-PipelineNugetAuthentication {
    <#
    .SYNOPSIS
        Publishes cached NuGet feed credentials to the Azure DevOps pipeline.
    .DESCRIPTION
        Serializes the script-scoped NuGetEndpointCredentials dictionary to JSON and sets
        the VSS_NUGET_EXTERNAL_FEED_ENDPOINTS pipeline variable so that subsequent NuGet
        operations authenticate automatically.
    #>
    $endpointcredentials = @()

    foreach ($key in $script:NuGetEndpointCredentials.Keys) {
        $endpointcredentials += $script:NuGetEndpointCredentials[$key]
    }

    $json = @{
        endpointCredentials = $endpointcredentials
    } | convertto-json -Compress
    Set-PipelineVariable -Name 'VSS_NUGET_EXTERNAL_FEED_ENDPOINTS' -Value $json
}

function Set-CorrectLocale
{
    <#
    .SYNOPSIS
        Configures the Linux locale to en_US.UTF-8 for consistent build behavior.
    .DESCRIPTION
        On Ubuntu 20+ systems, generates the en_US.UTF-8 locale and sets LC_ALL and LANG
        environment variables. Skips execution on non-Linux platforms and Ubuntu versions
        earlier than 20.
    #>
    Write-LogGroupStart -Title "Set-CorrectLocale"

    if (-not $IsLinux)
    {
        Write-LogGroupEnd -Title "Set-CorrectLocale"
        return
    }

    $environment = Get-EnvironmentInformation
    if ($environment.IsUbuntu16 -or $environment.IsUbuntu18) {
        Write-Verbose -Message "Don't set locale before Ubuntu 20" -Verbose
        Write-LogGroupEnd -Title "Set-CorrectLocale"
        Write-Locale
        return
    }

    if ($environment.IsUbuntu) {
        Write-Log -Message "Setting locale to en_US.UTF-8"
        $env:LC_ALL = 'en_US.UTF-8'
        $env:LANG = 'en_US.UTF-8'
        sudo locale-gen $env:LANG
        if ($environment.IsUbuntu20) {
            Write-Log -Message "Updating locale for Ubuntu 20"
            sudo update-locale
        } else {
            Write-Log -Message "Updating locale for Ubuntu 22 and newer"
            sudo update-locale LANG=$env:LANG LC_ALL=$env:LC_ALL
        }
    }

    Write-LogGroupEnd -Title "Set-CorrectLocale"
    Write-Locale

}

function Write-Locale {
    <#
    .SYNOPSIS
        Writes the current system locale settings to the log output.
    .DESCRIPTION
        Runs the 'locale' command on Linux or macOS and writes the output inside a
        collapsible log group. Does nothing on Windows.
    #>
    if (-not $IsLinux -and -not $IsMacOS) {
        Write-Verbose -Message "only supported on Linux and macOS" -Verbose
        return
    }

    # Output the locale to log it
    $localOutput = & locale
    Write-LogGroup -Title "Capture Locale" -Message $localOutput
}

function Install-AzCopy {
    <#
    .SYNOPSIS
        Downloads and installs AzCopy v10 on Windows.
    .DESCRIPTION
        Downloads the AzCopy v10 zip archive from the official Microsoft URL and extracts
        it to the Agent tools directory. Skips installation if AzCopy is already present.
    #>
    $testPath = "C:\Program Files (x86)\Microsoft SDKs\Azure\AzCopy\AzCopy.exe"
    if (Test-Path $testPath) {
        Write-Verbose "AzCopy already installed" -Verbose
        return
    }

    $destination = "$env:TEMP\azcopy10.zip"
    $downloadLocation = (Invoke-WebRequest -Uri https://aka.ms/downloadazcopy-v10-windows -MaximumRedirection 0 -ErrorAction SilentlyContinue -SkipHttpErrorCheck).headers.location | Select-Object -First 1

    Invoke-WebRequest -Uri $downloadLocation -OutFile $destination -Verbose
    Expand-archive -Path $destination -Destinationpath '$(Agent.ToolsDirectory)\azcopy10'
}

function Find-AzCopy {
    <#
    .SYNOPSIS
        Locates the AzCopy executable on the system.
    .DESCRIPTION
        Searches several well-known installation paths for AzCopy.exe and falls back to
        Get-Command if none of the paths contain the executable.
    .OUTPUTS
        System.String. The full path to the AzCopy executable.
    #>
    $searchPaths = @('$(Agent.ToolsDirectory)\azcopy10\AzCopy.exe', "C:\Program Files (x86)\Microsoft SDKs\Azure\AzCopy\AzCopy.exe", "C:\azcopy10\AzCopy.exe")

    foreach ($filter in $searchPaths) {
        $azCopy = Get-ChildItem -Path $filter -Recurse -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName -First 1
        if ($azCopy) {
            return $azCopy
        }
    }

    $azCopy = Get-Command -Name azCopy -ErrorAction Stop | Select-Object -First 1
    return $azCopy.Path
}

function Clear-NativeDependencies
{
    <#
    .SYNOPSIS
        Removes unnecessary native dependency files from the publish output.
    .DESCRIPTION
        Strips architecture-specific DiaSym reader DLLs that are not needed for the
        target runtime from both the publish folder and the pwsh.deps.json manifest.
        Skips fxdependent runtimes where no cleanup is needed.
    .PARAMETER PublishFolder
        Path to the publish output directory containing pwsh.deps.json.
    #>
    param(
        [Parameter(Mandatory=$true)] [string] $PublishFolder
    )

    $diasymFileNamePattern = 'microsoft.diasymreader.native.{0}.dll'

    switch -regex ($($script:Options.Runtime)) {
        '.*-x64' {
            $diasymFileName = $diasymFileNamePattern -f 'amd64'
        }
        '.*-x86' {
            $diasymFileName = $diasymFileNamePattern -f 'x86'
        }
        '.*-arm' {
            $diasymFileName = $diasymFileNamePattern -f 'arm'
        }
        '.*-arm64' {
            $diasymFileName = $diasymFileNamePattern -f 'arm64'
        }
        'fxdependent.*' {
            Write-Verbose -Message "$($script:Options.Runtime) is a fxdependent runtime, no cleanup needed in pwsh.deps.json" -Verbose
            return
        }
        Default {
            throw "Unknown runtime $($script:Options.Runtime)"
        }
    }

    $filesToDeleteCore = @($diasymFileName)

    ## Currently we do not need to remove any files from WinDesktop runtime.
    $filesToDeleteWinDesktop = @()

    $deps = Get-Content "$PublishFolder/pwsh.deps.json" -Raw | ConvertFrom-Json -Depth 20
    $targetRuntime = ".NETCoreApp,Version=v10.0/$($script:Options.Runtime)"

    $runtimePackNetCore = $deps.targets.${targetRuntime}.PSObject.Properties.Name -like 'runtimepack.Microsoft.NETCore.App.Runtime*'
    $runtimePackWinDesktop = $deps.targets.${targetRuntime}.PSObject.Properties.Name -like 'runtimepack.Microsoft.WindowsDesktop.App.Runtime*'

    if ($runtimePackNetCore)
    {
        $filesToDeleteCore | ForEach-Object {
            Write-Verbose "Removing $_ from pwsh.deps.json" -Verbose
            $deps.targets.${targetRuntime}.${runtimePackNetCore}.native.PSObject.Properties.Remove($_)
            if (Test-Path $PublishFolder/$_) {
                Remove-Item -Path $PublishFolder/$_ -Force -Verbose
            }
        }
    }

    if ($runtimePackWinDesktop)
    {
        $filesToDeleteWinDesktop | ForEach-Object {
            Write-Verbose "Removing $_ from pwsh.deps.json" -Verbose
            $deps.targets.${targetRuntime}.${runtimePackWinDesktop}.native.PSObject.Properties.Remove($_)
            if (Test-Path $PublishFolder/$_) {
                Remove-Item -Path $PublishFolder/$_ -Force -Verbose
            }
        }
    }

    $deps | ConvertTo-Json -Depth 20 | Set-Content "$PublishFolder/pwsh.deps.json" -Force
}


function Update-DotNetSdkVersion {
<#
    .SYNOPSIS
        Updates the .NET SDK version in global.json and DotnetRuntimeMetadata.json.
    .DESCRIPTION
        Queries the official .NET SDK feed for the latest version in the current channel
        and writes the new version to global.json and DotnetRuntimeMetadata.json.
    #>
    param()
    $globalJsonPath = "$PSScriptRoot/global.json"
    $globalJson = get-content $globalJsonPath | convertfrom-json
    $oldVersion = $globalJson.sdk.version
    $versionParts = $oldVersion -split '\.'
    $channel = $versionParts[0], $versionParts[1] -join '.'
    Write-Verbose "channel: $channel" -Verbose
    $azure_feed = 'https://builds.dotnet.microsoft.com/dotnet'
    $version_file_url = "$azure_feed/Sdk/$channel/latest.version"
    $version = Invoke-RestMethod $version_file_url
    Write-Verbose "updating from: $oldVersion to: $version" -Verbose
    $globalJson.sdk.version = $version
    $globalJson | convertto-json | out-file $globalJsonPath
    $dotnetRuntimeMetaPath = "$psscriptroot\DotnetRuntimeMetadata.json"
    $dotnetRuntimeMeta = get-content $dotnetRuntimeMetaPath | convertfrom-json
    $dotnetRuntimeMeta.sdk.sdkImageVersion = $version
    $dotnetRuntimeMeta | ConvertTo-Json | Out-File $dotnetRuntimeMetaPath
}

function Set-PipelineVariable {
    <#
    .SYNOPSIS
        Sets an Azure DevOps pipeline variable and the corresponding environment variable.
    .DESCRIPTION
        Emits a ##vso[task.setvariable] logging command so that subsequent pipeline steps
        can access the variable, and also sets it in the current process environment.
    .PARAMETER Name
        The pipeline variable name.
    .PARAMETER Value
        The value to assign.
    #>
    param(
        [parameter(Mandatory)]
        [string] $Name,
        [parameter(Mandatory)]
        [string] $Value
    )

    $vstsCommandString = "vso[task.setvariable variable=$Name]$Value"
    Write-Verbose -Verbose -Message ("sending " + $vstsCommandString)
    Write-Host "##$vstsCommandString"

    # also set in the current session
    Set-Item -Path "env:$Name" -Value $Value
}
