modules/WinGet-Restore.psm1

#Requires -Modules TableUI
Set-StrictMode -Version 3
Import-Module "$PSScriptRoot\WinGet-Utils.psm1"

[string]$PackageDatabase = "$PSScriptRoot\winget.packages.json"
[string]$PackageDatabaseSchema = "$PSScriptRoot\schema\packages.schema.json"
[string]$CheckpointFilePath = "$PSScriptRoot\winget.{HOSTNAME}.checkpoint"

<#
.DESCRIPTION
    Restore a collection of packages based on the provided list of tags.
 
.EXAMPLE
    PS> Restore-WinGetSoftware -All -UseUI
 
.EXAMPLE
    PS> Restore-WinGetSoftware -Tag Dev,Essential
#>

function Restore-WinGetSoftware
{
    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Filter', ConfirmImpact = 'High')]
    param (
        # The specified tags to filter and determine which software to install.
        # A matching package is one that contains all the specified tags.
        # See $MatchAny to change the filtering behavior for this parameter.
        [Parameter(Mandatory = $true, ParameterSetName = 'Filter')]
        [string[]]$Tag,

        # When set, the specified list of $Tag will no longer require a package
        # to contain all the specified tags for it to be considered a match,
        # as long as one of the tags is associated with the package it will be
        # considered a match. The default behavior is to "Match All" tags.
        # This switch does not affect $ExcludeTag behavior.
        [Parameter(ParameterSetName = 'Filter')]
        [switch]$MatchAny,

        # An optional list of tags which will filter a package from the
        # install list if it contains ANY of tags specified in this list.
        [Parameter()]
        [string[]]$ExcludeTag = @(),

        # When set, all packages in "winget.packages.json" will be selected.
        [Parameter(Mandatory, ParameterSetName = 'NoFilter')]
        [switch]$All,

        # When set, packages listed in the "checkpoint" file (generated via
        # Checkpoint-WinGetSoftware) will be filtered from the list. Thus
        # supplying a list of packages that are not installed on the system.
        [Parameter()]
        [switch]$NotInstalled,

        # When set, a CLI based UI will be presented to allow for more refined
        # selection of packages to install.
        [Parameter()]
        [switch]$UseUI,

        # When set, indicates that interactive installation should be used,
        # requires user to navigate install wizards.
        [Parameter()]
        [switch]$Interactive,

        # Acts as a inverse of -Confirm. Provided for convenience.
        [Parameter()]
        [switch]$Force,

        # Launches and runs the invoked command in an administrator instance of PowerShell.
        [Parameter()]
        [switch]$Administrator
    )

    function Write-ProgressHelper
    {
        param (
            [PSObject[]]$Packages,
            [int]$PackageIndex
        )

        $i = $PackageIndex + 1
        Write-Output "`r▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬"
        Write-Output "[ $i / $($Packages.Count) ] Installing '$($Packages[$PackageIndex].PackageIdentifier)'"
        Write-Output "▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬`n"
    }

    if ($Administrator -and -not(Test-Administrator)) {
        $boundParamsString = $PSBoundParameters.Keys | ForEach-Object {
            if ($PSBoundParameters[$_] -is [switch]) {
                if ($PSBoundParameters[$_]) {
                    "-$($_)"
                }
            } else {
                "-$($_) $($PSBoundParameters[$_])"
            }
        }
        $cmdArgs = "-NoLogo -NoExit -Command Restore-WinGetSoftware $($boundParamsString -join ' ')"
        Start-Process -Verb RunAs -FilePath "pwsh" -ArgumentList $cmdArgs
        return
    }

    if (-not(Test-Administrator) -and -not($Force)) {
        Write-Warning ('Some programs will not install correctly if WinGet is used without administrator rights. ' +
            'This is particularly true for zip-based installs which involve the creation of symbolic links to export the utility the WinGet "Links" path.')
        Write-Host 'Press any key to continue ...'
        $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
    }

    if ($Force -and -not $Confirm){
        $ConfirmPreference = 'None'
    }

    Initialize-WinGetRestore | Out-Null
    if (-not(Test-Path $PackageDatabase)) {
        Write-Error ("`"$PackageDatabase`" does not exist. Please create this file and populate it with tagged winget package identifiers. " +
            "Then use Initialize-WinGetRestore to setup a symlink to this file.")
        return
    }

    $MatchAnyScriptBlock = {
        param([string[]]$p1, [string[]]$p2)

        $tags = $p1
        $packageTags = $p2
        $matchedAny = $false
        $tags | ForEach-Object {
            if ($packageTags -contains $_) {
                $matchedAny = $true
                return
            }
        }

        $matchedAny
    }

    $MatchAllScriptBlock = {
        param([string[]]$p1, [string[]]$p2)

        $tags = $p1
        $packageTags = $p2
        $matchedAll = $true
        $tags | ForEach-Object {
            if ($packageTags -notcontains $_) {
                $matchedAll = $false
                return
            }
        }

        $matchedAll
    }

    $MatchNoneScriptBlock = {
        param([string[]]$p1, [string[]]$p2)

        $tags = $p1
        $packageTags = $p2
        $matchedNone = $true
        $tags | ForEach-Object {
            if ($packageTags -contains $_) {
                $matchedNone = $false
                return
            }
        }

        $matchedNone
    }

    if ($MatchAny) {
        $isMatch = $MatchAnyScriptBlock
    } else {
        $isMatch = $MatchAllScriptBlock
    }

    if (-not(Test-Json -Json ([string](Get-Content $PackageDatabase)) -SchemaFile $PackageDatabaseSchema)) {
        Write-Error "Schema validation failed for: '$PackageDatabase'. Please fix and try again. The file can be validated against '$PackageDatabaseSchema'."
        return
    }

    $installPackages = Get-Content $PackageDatabase | ConvertFrom-Json

    $checkpointFile = $CheckpointFilePath.Replace('{HOSTNAME}', $(hostname).ToLower())
    if ($NotInstalled) {
        if (Test-Path $checkpointFile) {
            # Check across all sources for packages, not just winget.
            $checkpointPackageIds = (Get-Content $checkpointFile | ConvertFrom-Json).Sources.Packages.PackageIdentifier

            $installPackages = $installPackages | Where-Object {
                $checkpointPackageIds -notcontains $_.PackageIdentifier
            }
        } else {
            Write-Error "No checkpoint file found. 'Checkpoint-WinGetSoftware' should be run before using -NotInstalled."
            return
        }
    }

    if (-not($All)) {
        $installPackages  = $installPackages | Where-Object {
            &$isMatch -p1 $Tag -p2 $_.Tags
        }
    }

    if ($ExcludeTag.Count -gt 0) {
        $installPackages  = $installPackages | Where-Object {
            &$MatchNoneScriptBlock -p1 $ExcludeTag -p2 $_.Tags
        }
    }

    if ($installPackages.Count -eq 0) {
        Write-Output "No packages to install."
        return
    }

    $installPackages = $installPackages | Sort-Object -Property PackageIdentifier

    if ($UseUI) {
        $selections = [bool[]]@()

        $ShowPackageDetailsScriptBlock = {
            param($currentSelections, $selectedIndex)
            $command = "winget show $($installPackages[$selectedIndex].PackageIdentifier)"
            Clear-Host
            Invoke-Expression $command
            Write-Output "`n[Press ENTER to return.]"
            [Console]::CursorVisible = $false
            $cursorPos = $host.UI.RawUI.CursorPosition
            while ($host.ui.RawUI.ReadKey().VirtualKeyCode -ne [ConsoleKey]::Enter) {
                $host.UI.RawUI.CursorPosition = $cursorPos
                [Console]::CursorVisible = $false
            }
        }

        $TableUIArgs = @{
            Table = $installPackages
            Title = 'Select Software to Install'
            EnterKeyDescription = "Press ENTER to show selection details. "
            EnterKeyScript = $ShowPackageDetailsScriptBlock
            DefaultMemberToShow = "PackageIdentifier"
            SelectedItemMembersToShow = @("PackageIdentifier","Tags")
            Selections = ([ref]$selections)
        }

        Show-TableUI @TableUIArgs

        if ($null -eq $selections) {
            $selectedPackages = @();
        } else {
            $selectedPackages = @($installPackages | Where-Object { $selections[$installPackages.indexOf($_)] })
        }
    } else {
        $selectedPackages = $installPackages
    }

    if ($selectedPackages.Count -eq 0) {
        Write-Output "No packages selected."
        return
    }

    $packageIndex = 0
    $errorCount = 0

    foreach ($installPackage in $selectedPackages)
    {
        Write-ProgressHelper -Packages $selectedPackages -PackageIndex $packageIndex

        if ($PSCmdlet.ShouldProcess($installPackage.PackageIdentifier)) {
            Install-WinGetSoftware -Package $installPackage -ErrorCount ([ref]$errorCount)
        } else {
            Write-Output "Skipped."
        }

        $packageIndex++
    }

    if ($errorCount -gt 0) {
        throw "Done (Errors = $errorCount)."
    } else {
        Write-Output "Done."
    }
}

function Install-WinGetSoftware
{
    param(
        [object]$Package,
        [ref]$ErrorCount
    )

    $postInstallQuestion = "Run post-install command(s)?"
    $postInstallChoices = @(
        [System.Management.Automation.Host.ChoiceDescription]::new("&Yes", "Do run post-install command")
        [System.Management.Automation.Host.ChoiceDescription]::new("&No", "Do not run post-install command")
    )

    $runPostInstall = ($Package.PSobject.Properties.Name -contains "PostInstall")

    if ($Interactive) {
        winget install --id $Package.PackageIdentifier --interactive
    } else {
        winget install --id $Package.PackageIdentifier
    }

    $installOk = $?

    if (-not($runPostInstall)) { continue }

    if ($installOk) {
        if ($Package.PostInstall.Run -eq "Prompt") {
            $decision = $Host.UI.PromptForChoice($null, $postInstallQuestion, $postInstallChoices, 0)
            $runPostInstall = $runPostInstall -and ($decision -eq 0)
        } else {
            $runPostInstall = $runPostInstall -and (
                ($Package.PostInstall.Run -eq "Always") -or
                ($Package.PostInstall.Run -eq "OnSuccess"))
        }
    } else {
        if (($Package.PostInstall.Run -eq "Prompt") -or
            ($Package.PostInstall.Run -eq "PromptOnError")) {
            $decision = $Host.UI.PromptForChoice($null, $postInstallQuestion, $postInstallChoices, 1)
            $runPostInstall = $runPostInstall -and ($decision -eq 0)
        } else {
            $runPostInstall = $runPostInstall -and ($Package.PostInstall.Run -eq "Always")
            $ErrorCount.Value++
        }
    }

    if (-not($runPostInstall)) { continue }

    foreach ($cmd in $Package.PostInstall.Commands) {
        $runCommand = $true
        while ($runCommand) {
            $runCommand = $false
            Write-Output "Executing: '$cmd'"
            $errorReult = $false
            try {
                $global:LASTEXITCODE = 0
                Invoke-Expression $cmd -ErrorVariable errorOutput
                $errorReult = ($LASTEXITCODE -ne 0) -or -not($?) -or -not([string]::IsNullOrEmpty($errorOutput))
            } catch {
                Write-Output "Last command encountered an error: $_"
                $errorReult = $true
            }

            if ($errorReult) {
                $ErrorCount.Value++
                if ($Package.PostInstall.OnError -eq "Skip") {
                    break
                } elseif ($Package.PostInstall.OnError -eq "Prompt") {
                    $title = "An error occurred during the last post-install command"
                    $question = "What action should be performed?"
                    $choices = @(
                        [System.Management.Automation.Host.ChoiceDescription]::new("&Continue", "Continue with the next command")
                        [System.Management.Automation.Host.ChoiceDescription]::new("&Re-Run", "Re-run the last command")
                        [System.Management.Automation.Host.ChoiceDescription]::new("&Skip", "Skip the remaining commands for the current package")
                    )

                    $decision = $Host.UI.PromptForChoice($title, $question, $choices, 2)
                    $runCommand = $decision -eq 1
                    if ($decision -eq 2) { return }
                } else {
                    # "Continue", do nothing.
                }
            }
        }
    }
}

$RestoreTagScriptBlock = {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

    if (Test-Path $PackageDatabase) {
        $packages = Get-Content $PackageDatabase | ConvertFrom-Json
        if ($null -ne $packages) {
            $packages.Tags | Sort-Object -Unique | Where-Object { $_ -like "$wordToComplete*" }
        }
    }
}

Register-ArgumentCompleter -CommandName Restore-WingetSoftware -ParameterName Tag -ScriptBlock $RestoreTagScriptBlock
Register-ArgumentCompleter -CommandName Restore-WingetSoftware -ParameterName ExcludeTag -ScriptBlock $RestoreTagScriptBlock