Airpower.psm1

Add-Type -AssemblyName System.Net.Http

function HttpRequest {
    param (
        [Parameter(Mandatory)]
        [string]$URL,
        [ValidateSet('GET', 'HEAD')]
        [string]$Method = 'GET',
        [string]$AuthToken,
        [string]$Accept,
        [string]$Range
    )
    $req = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), $URL)
    if ($AuthToken) {
        $req.Headers.Authorization = "Bearer $AuthToken"
    }
    if ($Accept) {
        $req.Headers.Accept.Add($Accept)
    }
    if ($Range) {
        $req.Headers.Range = $Range
    }
    return $req
}

function HttpSend {
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpRequestMessage]$Req,
        [switch]$NoRedirect
    )
    $ch = [Net.Http.HttpClientHandler]::new()
    if ($NoRedirect) {
        $ch.AllowAutoRedirect = $false
    }
    $ch.UseProxy = $false
    $cli = [Net.Http.HttpClient]::new($ch)
    try {
        return $cli.SendAsync($Req, [Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult()
    } catch {
        throw "An error occured while initiating an HTTP request; check your network connection and try again.`n+ Caused by: $_"
    }
}

function GetJsonResponse {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    if (($resp.Content.Headers.ContentType.MediaType -ne 'application/json') -and -not $resp.Content.Headers.ContentType.MediaType.EndsWith('+json')) {
        throw "want application/json, got $($resp.Content.Headers.ContentType.MediaType)"
    }
    return $Resp.Content.ReadAsStringAsync().GetAwaiter().GetResult() | ConvertFrom-Json
}
function ConvertTo-HashTable {
    param (
        [Parameter(ValueFromPipeline)]
        [PSCustomObject]$Object
    )
    if ($null -eq $Object) {
        return
    }
    $Table = @{}
    $Object.PSObject.Properties | ForEach-Object {
        $V = $_.Value
        if ($V -is [Array]) {
            $V = [System.Collections.ArrayList]$V
        } elseif ($V -is [PSCustomObject]) {
            $V = ($V | ConvertTo-HashTable)
        }
        $Table.($_.Name) = $V
    }
    return $Table
}

function GetAirpowerPath {
    if ($AirpowerPath) {
        $AirpowerPath
    } elseif ($env:AirpowerPath) {
        $env:AirpowerPath
    } else {
        "$env:LocalAppData\Airpower"
    }
}

function GetAirpowerPullPolicy {
    if ($AirpowerPullPolicy) {
        $AirpowerPullPolicy
    } elseif ($env:AirpowerPullPolicy) {
        $env:AirpowerPullPolicy
    } else {
        "IfNotPresent"
    }
}

function GetAirpowerAutoprune {
    if ($AirpowerAutoprune) {
        $AirpowerAutoprune
    } elseif ($env:AirpowerAutoprune) {
        $env:AirpowerAutoprune
    }
}

function GetAirpowerAutoupdate {
    if ($AirpowerAutoupdate) {
        $AirpowerAutoupdate
    } elseif ($env:AirpowerAutoupdate) {
        $env:AirpowerAutoupdate
    }
}

function GetPwrDBPath {
    "$(GetAirpowerPath)\cache"
}

function GetPwrTempPath {
    "$(GetAirpowerPath)\temp"
}

function GetPwrContentPath {
    "$(GetAirpowerPath)\content"
}

function ResolvePackagePath {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Digest
    )
    return "$(GetPwrContentPath)\$($digest.Substring('sha256:'.Length).Substring(0, 12))"
}

function MakeDirIfNotExist {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Path
    )
    New-Item -Path $Path -ItemType Directory -ErrorAction Ignore
}

function FindConfig {
    $path = (Get-Location).Path
    while ($path -ne '') {
        $cfg = "$path\Airpower.ps1"
        if (Test-Path $cfg -PathType Leaf) {
            return $cfg
        }
        $path = $path | Split-Path -Parent
    }
}
# U+2588 ? Full block
# U+258C ? Left half block

function GetUnicodeBlock {
    param (
        [Parameter(Mandatory)]
        [int]$Index
    )
    @{
        0 = " "
        1 = "$([char]0x258c)"
        2 = "$([char]0x2588)"
    }[$Index]
}

function GetProgress {
    param (
        [Parameter(Mandatory)]
        [long]$Current,
        [Parameter(Mandatory)]
        [long]$Total
    )
    $width = 30
    $esc = [char]27
    $p = $Current / $Total
    $inc = 1 / $width
    $full = [int][Math]::Floor($p / $inc)
    $left = [int][Math]::Floor((($p - ($inc * $full)) / $inc) * 2)
    $line = "$esc[94m$esc[47m" + ((GetUnicodeBlock 2) * $full)
    if ($full -lt $width) {
        $line += (GetUnicodeBlock $left) + (" " * ($width - $full - 1))
    }
    $stat = '{0,10} / {1,-10}' -f ($Current | AsByteString -FixDecimals), ($Total | AsByteString)
    $line += "$esc[0m $stat"
    return "$line$esc[0m"
}

function WritePeriodicConsole {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [scriptblock]$DeferLine
    )
    if (($null -eq $lastwrite) -or (((Get-Date) - $lastwrite).TotalMilliseconds -gt 125)) {
        $line = & $DeferLine
        WriteConsole $line
        $script:lastwrite = (Get-Date)
    }
}

function SetCursorVisible {
    param (
        [Parameter(Mandatory)]
        [bool]$Enable
    )
    try {
        [Console]::CursorVisible = $Enable
    } catch {
        Write-Error $_ -ErrorAction Ignore
    }
}

function WriteConsole {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Line
    )
    if ($ProgressPreference -eq 'Continue') {
        [Console]::Write("`r$Line")
    } elseif ($ProgressPreference -ne 'SilentlyContinue') {
        throw "cannot write progress for ProgressPreference=$ProgressPreference"
    }
}

function AsByteString {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [long]$Bytes,
        [switch]$FixDecimals
    )
    $n = [Math]::Abs($Bytes)
    $p = 0
    while ($n -gt 1024) {
        $n /= 1024
        $p += 3
    }
    $r = @{
        0 = ''
        3 = 'k'
        6 = 'M'
        9 = 'G'
    }
    return "{0:0.$(if ($FixDecimals) { '00' } else { '##' })} {1}B" -f $n, $r[[Math]::Min(9, $p)]
}

function FromOctalString {
    param (
        [Parameter(ValueFromPipeline)]
        [string]$ASCII
    )
    if (-not $ASCII) {
        return $null
    }
    return [Convert]::ToInt64($ASCII, 8)
}

function ParseTarHeader {
    param (
        [Parameter(Mandatory)]
        [byte[]]$Buffer
    )
    return @{
        Filename = [Text.Encoding]::ASCII.GetString($Buffer[0..99]).Trim(0)
        Mode = [Text.Encoding]::ASCII.GetString($Buffer[100..107]).Trim(0) | FromOctalString
        OwnerID = [Text.Encoding]::ASCII.GetString($Buffer[108..115]).Trim(0) | FromOctalString
        GroupID = [Text.Encoding]::ASCII.GetString($Buffer[116..123]).Trim(0) | FromOctalString
        Size = [Text.Encoding]::ASCII.GetString($Buffer[124..135]).Trim(0) | FromOctalString
        Modified = [Text.Encoding]::ASCII.GetString($Buffer[136..147]).Trim(0) | FromOctalString
        Checksum = [Text.Encoding]::ASCII.GetString($Buffer[148..155])
        Type = [Text.Encoding]::ASCII.GetString($Buffer[156..156]).Trim(0)
        Link = [Text.Encoding]::ASCII.GetString($Buffer[157..256]).Trim(0)
        UStar = [Text.Encoding]::ASCII.GetString($Buffer[257..262]).Trim(0)
        UStarVersion = [Text.Encoding]::ASCII.GetString($Buffer[263..264]).Trim(0)
        Owner = [Text.Encoding]::ASCII.GetString($Buffer[265..296]).Trim(0)
        Group = [Text.Encoding]::ASCII.GetString($Buffer[297..328]).Trim(0)
        DeviceMajor = [Text.Encoding]::ASCII.GetString($Buffer[329..336]).Trim(0)
        DeviceMinor = [Text.Encoding]::ASCII.GetString($Buffer[337..344]).Trim(0)
        FilenamePrefix = [Text.Encoding]::ASCII.GetString($Buffer[345..499]).Trim(0)
    }
}

function ParsePaxHeader {
    param (
        [Parameter(Mandatory)]
        [IO.Compression.GZipStream]$Source,
        [Parameter(Mandatory)]
        [Collections.Hashtable]$Header
    )
    $buf = New-Object byte[] $Header.Size
    [void]([Util]::GzipRead($Source, $buf, $Header.Size))
    $content = [Text.Encoding]::UTF8.GetString($buf)
    $xhdr = @{}
    foreach ($line in $content -split "`n") {
        if ($line -match '([0-9]+) ([^=]+)=(.+)') {
            $xhdr += @{
                "$($Matches[2])" = $Matches[3]
            }
        }
    }
    return $xhdr
}

function ExtractTarGz {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Path,
        [Parameter(Mandatory)]
        [string]$Digest
    )
    $tgz = $Path | Split-Path -Leaf
    $layer = $tgz.Replace('.tar.gz', '')
    if ($layer -ne (Get-FileHash $Path).Hash) {
        [IO.File]::Delete($Path)
        throw "removed $Path because it had corrupted data"
    }
    $fs = [IO.File]::OpenRead($Path)
    try {
        $gz = [IO.Compression.GZipStream]::new($fs, [IO.Compression.CompressionMode]::Decompress, $true)
        try {
            $gz | ExtractTar -Digest $Digest
        } finally {
            $gz.Dispose()
        }
    } finally {
        $fs.Dispose()
    }
    return $Path
}

class Util {
    static [int] GzipRead([IO.Compression.GZipStream]$Source, [byte[]]$Buffer, [int]$Size) {
        $read = 0
        while ($true) {
            $n = $Source.Read($buffer, $read, $Size - $read)
            $read += $n
            if ($n -eq 0) {
                break
            } elseif ($read -ge $size) {
                break
            }
        }
        return $read
    }
}

function ExtractTar {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [IO.Compression.GZipStream]$Source,
        [Parameter(Mandatory)]
        [string]$Digest
    )
    $root = ResolvePackagePath -Digest $Digest
    MakeDirIfNotExist -Path $root | Out-Null
    $buffer = New-Object byte[] 512
    try {
        while ($true) {
            { $layer.Substring(0, 12) + ': Extracting ' + (GetProgress -Current $Source.BaseStream.Position -Total $Source.BaseStream.Length) + ' ' } | WritePeriodicConsole
            if ([Util]::GzipRead($Source, $buffer, 512) -eq 0) {
                break
            }
            $hdr = ParseTarHeader $buffer
            $size = if ($xhdr.Size) { $xhdr.Size } else { $hdr.Size }
            $filename = if ($xhdr.Path) { $xhdr.Path } else { $hdr.Filename }
            $file = ($filename -split '/' | Select-Object -Skip 1) -join '\'
            if ($filename.Contains('\..')) {
                throw "suspicious tar filename '$($filename)'"
            }
            if ($hdr.Type -eq [char]53 -and $file -ne '') {
                New-Item -Path "\\?\$root\$file" -ItemType Directory -Force -ErrorAction Ignore | Out-Null
            }
            if ($hdr.Type -in [char]103, [char]120) {
                $xhdr = ParsePaxHeader -Source $Source -Header $hdr
            } elseif ($hdr.Type -in [char]0, [char]48, [char]55 -and $filename.StartsWith('Files')) {
                $buf = New-Object byte[] $size
                [void]([Util]::GzipRead($Source, $buf, $size))
                $fs = [IO.File]::Open("\\?\$root\$file", [IO.FileMode]::Create, [IO.FileAccess]::Write)
                try {
                    if ($write) {
                        $write.Wait()
                        if ($write.IsFaulted) {
                            throw $write.Exception
                        }
                        $writefs.Dispose()
                    }
                } catch {
                    $fs.Dispose()
                    throw
                }
                $writefs = $fs
                $write = $writefs.WriteAsync($buf, 0, $size)
                $xhdr = $null
            } else {
                if ($size -gt 0) {
                    [void]([Util]::GzipRead($Source, (New-Object byte[] $size), $size))
                }
                $xhdr = $null
            }
            $leftover = $size % 512
            if ($leftover -gt 0) {
                [void]([Util]::GzipRead($Source, $buffer, (512 - $leftover)))
            }
        }
        if ($write) {
            $write.Wait()
            if ($write.IsFaulted) {
                throw $write.Exception
            }
        }
    } finally {
        if ($writefs) {
            $writefs.Dispose()
        }
    }
    $layer.Substring(0, 12) + ': Extracting ' + (GetProgress -Current $Source.BaseStream.Length -Total $Source.BaseStream.Length) + ' ' | WriteConsole
}

function GetDockerRepo {
    return 'airpower/shipyard'
}

function GetAuthToken {
    $auth = "https://auth.docker.io/token?service=registry.docker.io&scope=repository:$(GetDockerRepo):pull"
    $resp = HttpRequest $auth | HttpSend | GetJsonResponse
    return $resp.Token
}

function GetTagsList {
    $api = "/v2/$(GetDockerRepo)/tags/list"
    $endpoint = "https://index.docker.io$api"
    return HttpRequest $endpoint -AuthToken (GetAuthToken) | HttpSend | GetJsonResponse
}

function GetManifest {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Ref
    )
    $api = "/v2/$(GetDockerRepo)/manifests/$Ref"
    $params = @{
        URL = "https://index.docker.io$api"
        AuthToken = (GetAuthToken)
        Accept = 'application/vnd.docker.distribution.manifest.v2+json'
    }
    return HttpRequest @params | HttpSend
}

function GetBlob {
    param (
        [Parameter(Mandatory)]
        [string]$Ref,
        [long]$StartByte
    )
    $api = "/v2/$(GetDockerRepo)/blobs/$Ref"
    $params = @{
        URL = "https://index.docker.io$api"
        AuthToken = (GetAuthToken)
        Accept = 'application/octet-stream'
        Range = "bytes=$StartByte-$($StartByte + 536870911)" # Request in 512 MB chunks
    }
    return HttpRequest @params | HttpSend
}

function GetDigestForRef {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Ref
    )
    $api = "/v2/$(GetDockerRepo)/manifests/$Ref"
    $params = @{
        URL = "https://index.docker.io$api"
        AuthToken = (GetAuthToken)
        Accept = 'application/vnd.docker.distribution.manifest.v2+json'
        Method = 'HEAD'
    }
    return HttpRequest @params | HttpSend | GetDigest
}

function GetDigest {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    return $resp.Headers.GetValues('docker-content-digest')
}

function DebugRateLimit {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    if ($resp.Headers.Contains('ratelimit-limit')) {
        Write-Debug "DockerHub RateLimit = $($resp.Headers.GetValues('ratelimit-limit'))"
    }
    if ($resp.Headers.Contains('ratelimit-remaining')) {
        Write-Debug "DockerHub Remaining = $($resp.Headers.GetValues('ratelimit-remaining'))"
    }
}

function GetPackageLayers {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    $layers = ($Resp | GetJsonResponse).layers
    $packageLayers = [System.Collections.Generic.List[PSObject]]::new()
    for ($i = 0; $i -lt $layers.Length; $i++) {
        if ($layers[$i].mediaType -eq 'application/vnd.docker.image.rootfs.diff.tar.gzip' -and ($i -gt 0 -or $layer.length -eq 1)) {
            $packageLayers.Add($layers[$i])
        }
    }
    return $packageLayers
}

function GetSize {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    $layers = $Resp | GetPackageLayers
    $size = 0
    foreach ($layer in $layers) {
        $size += $layer.size
    }
    return $size
}

function SaveBlob {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Digest
    )
    $sha256 = $Digest.Substring('sha256:'.Length)
    $path = "$(GetPwrTempPath)\$sha256.tar.gz"
    if ((Test-Path $path) -and (Get-FileHash $path).Hash -eq $sha256) {
        return $path
    }
    MakeDirIfNotExist (GetPwrTempPath) | Out-Null
    $fs = [IO.File]::Open($path, [IO.FileMode]::OpenOrCreate)
    $fs.Seek(0, [IO.SeekOrigin]::End) | Out-Null
    try {
        do {
            $resp = GetBlob -Ref $Digest -StartByte $fs.Length
            if (-not $resp.IsSuccessStatusCode) {
                throw "cannot download blob $($Digest): $($resp.ReasonPhrase)"
            }
            $size = if ($resp.Content.Headers.ContentRange.HasLength) { $resp.Content.Headers.ContentRange.Length } else { $resp.Content.Headers.ContentLength + $fs.Length }
            $task = $resp.Content.CopyToAsync($fs)
            while (-not $task.IsCompleted) {
                $sha256.Substring(0, 12) + ': Downloading ' + (GetProgress -Current $fs.Length -Total $size) + ' ' | WriteConsole
                Start-Sleep -Milliseconds 125
            }
        } while ($fs.Length -lt $size)
        $sha256.Substring(0, 12) + ': Downloading ' + (GetProgress -Current $fs.Length -Total $size) + ' ' | WriteConsole
    } finally {
        $fs.Close()
    }
    return $path
}
function WriteHost {
    param (
        [string]$Line
    )
    Write-Information $Line -InformationAction Continue
}

class FileLock {
    hidden [IO.FileStream]$File
    hidden [IO.MemoryStream]$Buffer
    hidden [string]$Path
    hidden [IO.FileAccess]$Access
    hidden [bool]$Delete
    [string[]]$Key

    FileLock([string]$Path, [IO.FileAccess]$Access, [string[]]$Key) {
        $this.Key = $Key
        $this.Access = $Access
        $this.Path = $Path
        $this.File = [IO.FileStream]::new($Path, [IO.FileMode]::OpenOrCreate, $Access, [IO.FileShare]::Read)
        if ($Access -eq [IO.FileAccess]::ReadWrite) {
            $this.Buffer = [IO.MemoryStream]::new()
        }
    }

    static [FileLock] RLock([string]$Path, [string[]]$Key) {
        return [FileLock]::new($Path, [IO.FileAccess]::Read, $Key)
    }

    static [FileLock] Lock([string]$Path, [string[]]$Key) {
        return [FileLock]::new($Path, [IO.FileAccess]::ReadWrite, $Key)
    }

    Unlock() {
        if ($this.Buffer -and $this.Buffer.Length -gt 0) {
            $this.File.SetLength(0)
            $this.Buffer.WriteTo($this.File)
        }
        $this.File.Dispose()
        if ($this.Delete) {
            [IO.File]::Delete($this.Path)
        }
    }

    Revert() {
        $this.File.Dispose()
    }

    Remove() {
        $this.Delete = $this.Access -eq [IO.FileAccess]::ReadWrite
    }

    [object] Get() {
        $b = [byte[]]::new($this.File.Length)
        $this.File.Read($b, 0, $this.File.Length)
        return [Db]::Decode([Text.Encoding]::UTF8.GetString($b))
    }

    Put([object]$Value) {
        $content = [Db]::Encode($Value)
        $this.Buffer.Write([Text.Encoding]::UTF8.GetBytes($content), 0, [Text.Encoding]::UTF8.GetByteCount($content))
    }
}

class Db {
    static [string]$Dir = (GetPwrDBPath)

    static Db() {
        [Db]::Init()
    }

    static Init() {
        MakeDirIfNotExist ([Db]::Dir)
    }

    static Remove([string[]]$key) {
        [IO.File]::Delete("$([Db]::Dir)\$([Db]::Key($key))")
    }

    static [object] Get([string[]]$key) {
         return [Db]::Decode([IO.File]::ReadAllText("$([Db]::Dir)\$([Db]::Key($key))"))
    }

    static [object[]] TryGet([string[]]$key) {
        try {
            return [Db]::Get($key), $null
        } catch {
            return $null, $_
        }
    }

    static Put([string[]]$key, [object]$value) {
        [IO.File]::WriteAllText("$([Db]::Dir)\$([Db]::Key($key))", [Db]::Encode($value))
    }

    static [string] Encode([object]$value) {
        $json = $value | ConvertTo-Json -Compress -Depth 10
        if ($null -eq $json) {
            $json = 'null' # PS 5.1 does not handle null
        }
        return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json))
    }

    static [object] Decode([string]$value) {
        return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($value)) | ConvertFrom-Json
    }

    static [FileLock] Lock([string[]]$key) {
        return [FileLock]::Lock("$([Db]::Dir)\$([Db]::Key($key))", $key)
    }

    static [object[]] TryLock([string[]]$key) {
        try {
            return [Db]::Lock($key), $null
        } catch {
            return $null, $_
        }
    }

    static [FileLock] RLock([string[]]$key) {
        return [FileLock]::RLock("$([Db]::Dir)\$([Db]::Key($key))", $key)
    }

    static [object[]] TryRLock([string[]]$key) {
        try {
            return [Db]::RLock($key), $null
        } catch {
            return $null, $_
        }
    }

    static [string] Key([string[]]$key) {
        $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($key -join "`n"))
        return $b64.Replace('/', '_').Replace('+', '-')
    }

    static [string[]] DecodeKey([string]$b64) {
        return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b64.Replace('_', '/').Replace('-', '+'))) -split "`n"
    }

    static [bool] HasPrefix([string]$b64, [string[]]$key) {
        $s = [Db]::DecodeKey($b64)
        for($i=0; $i -lt $key.Length; $i+=1) {
            if ($key[$i] -ne $s[$i]) {
                return $false
            }
        }
        return $true
    }

    static [bool] ContainsKey([string[]]$key) {
        return [IO.File]::Exists("$([Db]::Dir)\$([Db]::Key($key))")
    }

    static [Entry[]] GetAll([string[]]$key) {
        $entries = @()
        foreach ($f in [IO.Directory]::GetFiles([Db]::Dir)) {
            $k = $f.Substring([Db]::Dir.Length+1)
            if ([Db]::HasPrefix($k, $key)) {
                $decodedKey = [Db]::DecodeKey($k)
                $v, $err = [Db]::TryGet($decodedKey)
                if (-not $err) {
                    $entries += @{
                        Key = $decodedKey
                        Value = $v
                    }
                }
            }
        }
        return $entries
    }

    static [object[]] TryLockAll([string[]]$key) {
        $locks = @()
        foreach ($f in [IO.Directory]::GetFiles([Db]::Dir)) {
            $k = $f.Substring([Db]::Dir.Length+1)
            if ([Db]::HasPrefix($k, $key)) {
                $decodedKey = [Db]::DecodeKey($k)
                try {
                    $locks += [Db]::Lock($decodedKey)
                } catch {
                    if ($locks) {
                        $locks.Revert()
                    }
                    return $null, "a package $($decodedKey) is being used by another airpower process"
                }
            }
        }
        return $locks, $null
    }
}

class Entry {
    [string[]]$Key
    [object]$Value
}

function AsRemotePackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$RegistryTag
    )
    if ($RegistryTag -match '(.*)-([0-9].+)') {
        return @{
            Package = $Matches[1]
            Tag = $Matches[2] | AsTagHashtable
        }
    }
    throw "failed to parse registry tag: $RegistryTag"
}

function AsTagHashtable {
    param (
        [Parameter(ValueFromPipeline)]
        [string]$Tag
    )
    if ($Tag -in 'latest', '', $null) {
        return @{ Latest = $true }
    }
    if ($Tag -match '^([0-9]+)(?:\.([0-9]+))?(?:\.([0-9]+))?(?:(?:\+|_)([0-9]+))?$') {
        return @{
            Major = $Matches[1]
            Minor = $Matches[2]
            Patch = $Matches[3]
            Build = $Matches[4]
        }
    }
    throw "failed to parse tag: $Tag"
}

function AsTagString {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [collections.Hashtable]$Tag
    )
    if ($true -eq $Tag.Latest) {
        "latest"
    } else {
        $s = "$($Tag.Major)"
        if ($Tag.Minor) {
            $s += ".$($Tag.Minor)"
        }
        if ($Tag.Patch) {
            $s += ".$($Tag.Patch)"
        }
        if ($Tag.Build) {
            $s += "+$($Tag.Build)"
        }
        $s
    }
}

function GetRemotePackages {
    $remote = @{}
    foreach ($tag in (GetTagsList).Tags) {
        $pkg = $tag | AsRemotePackage
        $remote.$($pkg.Package) = $remote.$($pkg.Package) + @($pkg.Tag)
    }
    $remote
}

function GetRemoteTags {
    $remote = GetRemotePackages
    $o = New-Object PSObject
    foreach ($k in $remote.keys | Sort-Object) {
        $arr = @()
        foreach ($t in $remote.$k) {
            $arr += [Tag]::new(($t | AsTagString))
        }
        $o | Add-Member -MemberType NoteProperty -Name $k -Value ($arr | Sort-Object -Descending)
    }
    $o
}

function AsPackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Pkg
    )
    if ($Pkg -match '^([^:]+)(?::([^:]+))?(?:::?([^:]+))?$') {
        return @{
            Package = $Matches[1]
            Tag = $Matches[2] | AsTagHashtable
            Config = if ($Matches[3]) { $Matches[3] } else { 'default' }
        }
    }
    throw "failed to parse package: $Pkg"
}

function TryEachPackage {
    param (
        [Parameter(Mandatory, Position = 0)]
        [string[]]$Packages,
        [Parameter(Mandatory, Position = 1)]
        [scriptblock]$ScriptBlock,
        [string]$ActionDescription = 'process'
    )
    $results = @()
    $failures = @()
    foreach ($p in $Packages) {
        try {
            $results += $p | &$ScriptBlock
        } catch {
            Write-Error $_
            $failures += $p
        }
    }
    if ($failures.Count -gt 0) {
        throw "Failed to $ActionDescription packages: $($failures -join ', ')"
    }
    return $results
}

function ResolvePackageRefPath {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    return "$(GetAirpowerPath)\ref\$($Pkg.Package)$(if (-not $Pkg.Tag.Latest) { "-$($Pkg.Tag | AsTagString)" })"
}

function ResolveRemoteRef {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $remote = GetRemoteTags
    if (-not $remote.$($Pkg.Package)) {
        throw "no such package: $($Pkg.Package)"
    }
    $want = $Pkg.Tag
    foreach ($got in $remote.$($Pkg.Package)) {
        $eq = $true
        if ($null -ne $want.Major) {
            $eq = $eq -and $want.Major -eq $got.Major
        }
        if ($null -ne $want.Minor) {
            $eq = $eq -and $want.Minor -eq $got.Minor
        }
        if ($null -ne $want.Patch) {
            $eq = $eq -and $want.Patch -eq $got.Patch
        }
        if ($null -ne $want.Build) {
            $eq = $eq -and $want.Build -eq $got.Build
        }
        if ($eq) {
            $Pkg.Version = $got.ToString()
            return "$($Pkg.Package)-$($Pkg.Version.Replace('+', '_'))"
        }
    }
    throw "no such $($Pkg.Package) tag: $($Pkg.Tag)"
}

function GetLocalPackages {
    $pkgs = @()
    $locks, $err = [Db]::TryLockAll('pkgdb')
    if ($err) {
        throw $err
    }
    try {
        foreach ($lock in $locks) {
            $tag = $lock.Key[2]
            $t = [Tag]::new($tag)
            $digest = if ($t.None) { $tag } else { $lock.Get() }
            $m = [Db]::Get(('metadatadb', $digest))
            $pkgs += [LocalPackage]@{
                Package = $lock.Key[1]
                Tag = $t
                Version = $m.Version
                Digest = $digest | AsDigest
                Size = $m.size | AsSize
                Updated = if ($m.updated) { [datetime]::Parse($m.updated) } else { }
                Orphaned = if ($m.orphaned) { [datetime]::Parse($m.orphaned) }
            }
            $lock.Unlock()
        }
    } finally {
        if ($locks) {
            $locks.Revert()
        }
    }
    if (-not $pkgs) {
        $pkgs = ,[LocalPackage]@{}
    }
    return $pkgs
}

function ResolvePackageDigest {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    if ($pkg.digest) {
        return $pkg.digest
    }
    $k = 'pkgdb', $Pkg.Package, ($Pkg.Tag | AsTagString)
    if ([Db]::ContainsKey($k)) {
        return [Db]::Get($k)
    }
}

function InstallPackage { # $locks, $status
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $digest = $Pkg.Digest
    $name = $Pkg.Package
    $tag = $Pkg.Tag | AsTagString
    $locks = @()
    $mLock, $err = [Db]::TryLock(('metadatadb', $digest))
    if ($err) {
        throw "package '$digest' is in use by another airpower process"
    }
    $locks += $mLock
    $pLock, $err = [Db]::TryLock(('pkgdb', $name, $tag))
    if ($err) {
        $locks.Revert()
        throw "package '${name}:$tag' is in use by another airpower process"
    }
    $locks += $pLock
    $p = $pLock.Get()
    $m = $mLock.Get() | ConvertTo-HashTable
    $status = if ($null -eq $p) {
        if ($null -eq $m) {
            'new'
        } else {
            'tag'
        }
    } elseif ($digest -ne $p) {
        if ($null -eq $m) {
            'newer'
        } else {
            'ref'
        }
    } else {
        'uptodate'
    }
    $pLock.Put($digest)
    switch ($status) {
        {$_ -in 'new', 'newer'} {
            $mLock.Put(@{
                RefCount = 1
                Version = $Pkg.Version
                Size = $Pkg.Size
                Updated = [datetime]::UtcNow.ToString()
            })
        }
        {$_ -in 'newer', 'ref'} {
            $moLock, $err = [Db]::TryLock(('metadatadb', $p))
            if ($err) {
                $locks.Revert()
                throw "package '$p' is in use by another airpower process"
            }
            $locks += $moLock
            $mo = $moLock.Get() | ConvertTo-HashTable
            $mo.RefCount -= 1
            if ($mo.RefCount -eq 0) {
                $poLock, $err = [Db]::TryLock(('pkgdb', $name, $p))
                if ($err) {
                    $locks.Revert()
                    throw "package '$p' is in use by another airpower process"
                }
                $locks += $poLock
                $poLock.Put($null)
                $mo.Orphaned = [datetime]::UtcNow.ToString('u')
            }
            $moLock.Put($mo)
        }
        {$_ -in 'tag', 'ref'} {
            if ([Db]::ContainsKey(('pkgdb', $name, $digest))) {
                $dLock, $err = [Db]::TryLock(('pkgdb', $name, $digest))
                if ($err) {
                    $locks.Revert()
                    throw "package '$digest' is in use by another airpower process"
                }
                $locks += $dLock
                $dLock.Remove()
            }
            if ($m.RefCount -eq 0 -and $m.Orphaned) {
                $m.Remove('Orphaned')
            }
            $m.RefCount += 1
            $m.Updated = [datetime]::UtcNow.ToString()
            $mLock.Put($m)
        }
        'uptodate' {
            $m.Updated = [datetime]::UtcNow.ToString()
            $mLock.Put($m)
        }
    }
    return $locks, $status
}

function PullPackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $ref = $Pkg | ResolveRemoteRef
    $digest = $ref | GetDigestForRef
    WriteHost "Pulling $($Pkg.Package):$($pkg.Tag | AsTagString)"
    WriteHost "Digest: $($digest)"
    $k = 'metadatadb', $digest
    if ([Db]::ContainsKey($k) -and ($m = [Db]::Get($k)) -and $m.Size) {
        $size = $m.Size
    } else {
        $manifest = $ref | GetManifest
        $manifest | DebugRateLimit
        $size = $manifest | GetSize
    }
    $Pkg.Digest = $digest
    $Pkg.Size = $size
    $locks, $status = $Pkg | InstallPackage
    try {
        $ref = "$($Pkg.Package):$($Pkg.Tag | AsTagString)"
        if ($status -eq 'uptodate') {
            WriteHost "Status: Package is up to date for $ref"
        } else {
            if ($status -in 'new', 'newer') {
                $manifest | SavePackage
            }
            $refpath = $Pkg | ResolvePackageRefPath
            MakeDirIfNotExist (Split-Path $refpath) | Out-Null
            if (Test-Path -Path $refpath -PathType Container) {
                [IO.Directory]::Delete($refpath)
            }
            New-Item $refpath -ItemType Junction -Target ($Pkg.Digest | ResolvePackagePath) | Out-Null
            WriteHost "Status: Downloaded newer package for $ref"
        }
        $locks.Unlock()
    } finally {
        if ($locks) {
            $locks.Revert()
        }
    }
    return $status
}

function SavePackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
    SetCursorVisible $false
    try {
        $layers = $Resp | GetPackageLayers
        $digest = $Resp | GetDigest
        $temp = @()
        foreach ($layer in $layers) {
            try {
                $temp += $layer.Digest | SaveBlob | ExtractTarGz -Digest $digest
                "$($layer.Digest.Substring('sha256:'.Length).Substring(0, 12)): Pull complete" + ' ' * 60 | WriteConsole
            } finally {
                WriteConsole "`n"
            }
        }
        foreach ($tmp in $temp) {
            [IO.File]::Delete($tmp)
        }
    } finally {
        SetCursorVisible $true
    }
}

function UninstallPackage { # $locks, $digest, $err
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $name = $Pkg.Package
    $tag = $Pkg.Tag | AsTagString
    $k = 'pkgdb', $name, $tag
    $locks = @()
    if (-not [Db]::ContainsKey($k)) {
        return $null, $null, "package '${name}:$tag' not installed"
    }
    $pLock, $err = [Db]::TryLock($k)
    if ($err) {
        return $null, $null, "package '${name}:$tag' is in use by another airpower process"
    }
    $locks += $pLock
    $p = $pLock.Get()
    $pLock.Remove()
    $mLock, $err = [Db]::TryLock(('metadatadb', $p))
    if ($err) {
        $locks.Revert()
        $null, $null, "package '$p' is in use by another airpower process"
    }
    $locks += $mLock
    $m = $mLock.Get()
    if ($m.refcount -gt 0) {
        $m.refcount -= 1
    }
    if ($m.refcount -eq 0) {
        $mLock.Remove()
        $digest = $p
    } else {
        $mLock.Put($m)
        $digest = $null
    }
    return $locks, $digest, $null
}

function DeleteDirectory {
    param (
        [string]$Dir
    )
    $name = [IO.Path]::GetRandomFileName()
    $tempDir = "$(GetPwrTempPath)\$name"
    [IO.Directory]::CreateDirectory($tempDir) | Out-Null
    try {
        Robocopy.exe $tempDir $Dir /MIR /PURGE | Out-Null
        [IO.Directory]::Delete($Dir)
    } finally {
        [IO.Directory]::Delete($tempDir)
    }
}

function RemovePackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $locks, $digest, $err = $Pkg | UninstallPackage
    if ($null -ne $err) {
        throw $err
    }
    try {
        WriteHost "Untagged: $($Pkg.Package):$($pkg.Tag | AsTagString)"
        if ($null -ne $digest) {
            $content = $digest | ResolvePackagePath
            if (Test-Path $content -PathType Container) {
                DeleteDirectory $content
            }
            WriteHost "Deleted: $digest"
        }
        $refpath = $Pkg | ResolvePackageRefPath
        if (Test-Path -Path $refpath -PathType Container) {
            [IO.Directory]::Delete($refpath)
        }
        $locks.Unlock()
    } finally {
        if ($locks) {
            $locks.Revert()
        }
    }
}

function UninstallOrphanedPackages {
    param (
        [timespan]$Span
    )
    $now = [datetime]::UtcNow
    $locks = @()
    $metadata = @()
    $ls, $err = [Db]::TryLockAll('metadatadb')
    if ($err) {
        throw $err
    }
    foreach ($lock in $ls) {
        $m = $lock.Get() | ConvertTo-HashTable
        $orphaned = if ($m.orphaned) { $now - [datetime]::Parse($m.orphaned) }
        if ($m.refcount -eq 0 -and $orphaned -ge $Span) {
            $locks += $lock
            $m.digest = $lock.Key[1]
            $metadata += $m
            $lock.Remove()
        } else {
            $lock.Unlock()
        }
    }
    $ls, $err = [Db]::TryLockAll('pkgdb')
    if ($err) {
        if ($locks) {
            $locks.Revert()
        }
        throw $err
    }
    foreach ($lock in $ls) {
        if ($lock.Key[2].StartsWith('sha256:') -and $lock.Key[2] -in $metadata.digest) {
            $locks += $lock
            $lock.Remove()
        } else {
            $lock.Unlock()
        }
    }
    return $locks, $metadata
}

function PrunePackages {
    param (
        [switch]$Auto
    )
    $autoprune = (GetAirpowerAutoprune)
    if ($Auto -and -not $autoprune) {
        return
    }
    $span = if ($Auto) { [timespan]::Parse($autoprune) } else { [timespan]::Zero }
    $locks, $pruned = UninstallOrphanedPackages $span
    try {
        $bytes = 0
        foreach ($i in $pruned) {
            $content = $i.Digest | ResolvePackagePath
            WriteHost "Deleted: $($i.Digest)"
            $stats = Get-ChildItem $content -Recurse | Measure-Object -Sum Length
            $bytes += $stats.Sum
            if (Test-Path $content -PathType Container) {
                DeleteDirectory $content
            }
        }
        if ($pruned) {
            WriteHost "Total reclaimed space: $($bytes | AsByteString)"
            $locks.Unlock()
        }
    } finally {
        if ($locks) {
            $locks.Revert()
        }
    }
}

function GetOutofdatePackages {
    param (
        [timespan]$Span
    )
    $now = [datetime]::UtcNow
    $locks, $err = [Db]::TryLockAll('pkgdb')
    if ($err) {
        throw $err
    }
    $pkgs = @()
    try {
        foreach ($lock in $locks) {
            $tag = $lock.Key[2]
            if (-not $tag.StartsWith('sha256:')) {
                $mlock, $err = [Db]::TryLock(('metadatadb', $lock.Get()))
                if ($err) {
                    throw $err
                }
                $m = $mlock.Get() | ConvertTo-HashTable
                $since = if ($m.updated) { $now - [datetime]::Parse($m.updated) } else { [timespan]::MaxValue }
                if ($since -ge $Span) {
                    $pkgs += "$($lock.Key[1]):$($lock.Key[2])"
                }
                $mlock.Revert()
            }
            $lock.Revert()
        }
    } finally {
        if ($locks) {
            $locks.Revert()
        }
    }
    return $pkgs
}

function UpdatePackages {
    param (
        [switch]$Auto,
        [string[]]$Packages
    )
    $autoupdate = (GetAirpowerAutoupdate)
    if ($Auto -and -not $autoupdate) {
        return
    }
    $span = if ($Auto) { [timespan]::Parse($autoupdate) } else { [timespan]::MinValue }
    $pkgs = GetOutofdatePackages $span
    if ($Auto -and -not $pkgs) {
        return
    }
    $updated = 0
    $skipped = 0
    $formal_pkgs = if ($Packages) { $Packages | AsPackage | ForEach-Object { "$($_.Package):$($_.Tag | AsTagString)" } }
    foreach ($pkg in $pkgs) {
        if ($Auto -and $pkg -notin $formal_pkgs) {
            ++$skipped
            continue
        }
        try {
            $status = $pkg | AsPackage | PullPackage
            if ($status -ne 'uptodate') {
                ++$updated
            }
        } catch {
            if (-not $err) {
                $err = $_
            }
        }
    }
    if ($err) {
        throw $err
    }
    WriteHost "Updated $updated package$(if ($updated -ne 1) { 's' })$(if ($skipped -ne 0) { " (Run update command to check $skipped skipped package$(if ($skipped -ne 1) { 's' })" }))"
}

class Digest {
    [string]$Sha256

    Digest([string]$sha256) {
        $this.Sha256 = $sha256
    }

    [string] ToString() {
        return "$($this.Sha256.Substring('sha256:'.Length).Substring(0, 12))"
    }
}

function AsDigest {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Digest
    )
    return [Digest]::new($Digest)
}

class Tag : IComparable {
    [object]$Major
    [object]$Minor
    [object]$Patch
    [object]$Build
    hidden [bool]$None
    hidden [bool]$Latest

    Tag([string]$tag) {
        if ($tag -eq '<none>' -or $tag.StartsWith('sha256:')) {
            $this.None = $true
            return
        }
        if ($tag -in 'latest', '') {
            $this.Latest = $true
            return
        }
        if ($tag -match '^([0-9]+)(?:\.([0-9]+))?(?:\.([0-9]+))?(?:(?:\+|_)([0-9]+))?$') {
            $this.Major = $Matches[1]
            $this.Minor = $Matches[2]
            $this.Patch = $Matches[3]
            $this.Build = $Matches[4]
            return
        }
        throw "failed to parse tag: $tag"
    }

    [int] CompareTo([object]$Obj) {
        if ($Obj -isnot $this.GetType()) {
            throw "cannot compare types $($Obj.GetType()) and $($this.GetType())"
        }
        if ($this.Latest -or $Obj.Latest) {
            return $this.Latest - $Obj.Latest
        }
        if ($this.None -or $Obj.None) {
            return $Obj.None - $this.None
        }
        if ($this.Major -ne $Obj.Major) {
            return $this.Major - $Obj.Major
        } elseif ($this.Minor -ne $Obj.Minor) {
            return $this.Minor - $Obj.Minor
        } elseif ($this.Patch -ne $Obj.Patch) {
            return $this.Patch - $Obj.Patch
        } else {
            return $this.Build - $Obj.Build
        }
    }

    [string] ToString() {
        if ($this.None) {
            return ''
        }
        if ($null -eq $this.Major) {
            return 'latest'
        }
        $s = "$($this.Major)"
        if ($this.Minor) {
            $s += ".$($this.Minor)"
        }
        if ($this.Patch) {
            $s += ".$($this.Patch)"
        }
        if ($this.Build) {
            $s += "+$($this.Build)"
        }
        return $s
    }
}

function ResolvePackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Ref
    )
    if ($Ref.StartsWith('file:///')) {
        $i = $ref.IndexOf('<')
        $cfg = if ($i -eq -1 -and $ref.Length -gt $i + 1) { 'default' } else { $ref.Substring($i+1).Trim() }
        return @{
            Digest = $Ref
            Tag = @{}
            Config = $cfg
        }
    }
    $pkg = $Ref | AsPackage
    $digest = $pkg | ResolvePackageDigest
    $pullpolicy = (GetAirpowerPullPolicy)
    switch ($pullpolicy) {
        'IfNotPresent' {
            if (-not $digest) {
                $pkg | PullPackage | Out-Null
                $pkg.digest = $pkg | ResolvePackageDigest
            }
        }
        'Never' {
            if (-not $digest) {
                throw "cannot find package $($pkg.Package):$($pkg.Tag | AsTagString)"
            }
        }
        'Always' {
            $pkg | PullPackage | Out-Null
            $pkg.digest = $pkg | ResolvePackageDigest
        }
        default {
            throw "AirpowerPullPolicy '$pullpolicy' is not valid"
        }
    }
    return $pkg
}

class Size : IComparable {
    [long]$Bytes
    hidden [string]$ByteString

    Size([long]$Bytes, [string]$ByteString) {
        $this.Bytes = $Bytes
        $this.ByteString = $ByteString
    }

    [int] CompareTo([object]$Obj) {
        return $this.Bytes.CompareTo($Obj.Bytes)
    }

    [string] ToString() {
        return $this.ByteString
    }
}

function AsSize {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [long]$Bytes
    )
    return [Size]::new($Bytes, ($Bytes | AsByteString))
}

class LocalPackage {
    [object]$Package
    [Tag]$Tag
    [string]$Version
    [Digest]$Digest
    [Size]$Size
    [object]$Updated
    [object]$Orphaned
    # Signers
}

function GetSessionState {
    return @{
        Vars = (Get-Variable -Scope Global | ForEach-Object { [psvariable]::new($_.Name, $_.Value) })
        Env = (Get-Item Env:)
    }
}

function SaveSessionState {
    param (
        [Parameter(Mandatory)]
        [string]$GUID
    )
    Set-Variable -Name "AirpowerSaveState_$GUID" -Value (GetSessionState) -Scope Global
}

function ClearSessionState {
    param (
        [Parameter(Mandatory)]
        [string]$GUID
    )
    $default = "AirpowerSaveState_$GUID", '__LastHistoryId', '__VSCodeOriginalPrompt', '__VSCodeOriginalPSConsoleHostReadLine', '?', '^', '$', 'args', 'ConfirmPreference', 'DebugPreference', 'EnabledExperimentalFeatures', 'Error', 'ErrorActionPreference', 'ErrorView', 'ExecutionContext', 'false', 'FormatEnumerationLimit', 'HOME', 'Host', 'InformationPreference', 'input', 'IsCoreCLR', 'IsLinux', 'IsMacOS', 'IsWindows', 'MaximumHistoryCount', 'MyInvocation', 'NestedPromptLevel', 'null', 'OutputEncoding', 'PID', 'PROFILE', 'ProgressPreference', 'PSBoundParameters', 'PSCommandPath', 'PSCulture', 'PSDefaultParameterValues', 'PSEdition', 'PSEmailServer', 'PSHOME', 'PSScriptRoot', 'PSSessionApplicationName', 'PSSessionConfigurationName', 'PSSessionOption', 'PSStyle', 'PSUICulture', 'PSVersionTable', 'PWD', 'ShellId', 'StackTrace', 'true', 'VerbosePreference', 'WarningPreference', 'WhatIfPreference', 'ConsoleFileName', 'MaximumAliasCount', 'MaximumDriveCount', 'MaximumErrorCount', 'MaximumFunctionCount', 'MaximumVariableCount'
    foreach ($v in (Get-Variable -Scope Global)) {
        if ($v.name -notin $default) {
            Remove-Variable -Name $v.name -Scope Global -Force -ErrorAction SilentlyContinue
        }
    }
    foreach ($k in [Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::User).keys) {
        if ($k -notin 'temp', 'tmp', 'AirpowerPath') {
            Remove-Item "env:$k" -Force -ErrorAction SilentlyContinue
        }
    }
    Remove-Item 'env:AirpowerLoadedPackages' -Force -ErrorAction SilentlyContinue
}

function RestoreSessionState {
    param (
        [Parameter(Mandatory)]
        [string]$GUID
    )
    $state = (Get-Variable "AirpowerSaveState_$GUID").value
    foreach ($v in $state.vars) {
        Set-Variable -Name $v.name -Value $v.value -Scope Global -Force -ErrorAction SilentlyContinue
    }
    foreach ($e in $state.env) {
        Set-Item -Path "env:$($e.name)" -Value $e.value -Force -ErrorAction SilentlyContinue
    }
    Remove-Variable "AirpowerSaveState_$GUID" -Force -Scope Global -ErrorAction SilentlyContinue
}

function GetPackageDefinition {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Digest
    )
    if (-not $Digest) {
        return $null
    }
    if ($digest.StartsWith('file:///')) {
        $root = $digest.Substring(8)
        $i = $root.IndexOf('<')
        if ($i -ne -1) {
            $root = $root.Substring(0, $i).Trim()
        }
    } else {
        $root = ResolvePackagePath -Digest $Digest
    }
    return (Get-Content -Raw "$root\.pwr").Replace('${.}', $root.Replace('\', '\\')) | ConvertFrom-Json | ConvertTo-HashTable
}

function ConfigurePackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg,
        [switch]$AppendPath
    )
    $defn = $Pkg.Digest | GetPackageDefinition
    $cfg = if ($Pkg.Config -eq 'default') { $defn } else { $defn.$($Pkg.Config) }
    if (-not $cfg) {
        throw "configuration '$($Pkg.Config)' not found for $($Pkg.Package):$($Pkg.Tag | AsTagString)"
    }
    foreach ($k in $cfg.env.keys) {
        if ($k -eq 'Path') {
            if ($AppendPath) {
                $pre = "$env:Path$(if ($env:Path -and -not $env:Path.EndsWith(';')) { ';' })"
            } else {
                $post = "$(if ($env:Path) { ';' })$env:Path"
            }
        } else {
            $pre = $post = ''
        }
        Set-Item "env:$k" "$pre$($cfg.env.$k)$post"
    }
}

function LoadPackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $digest = $Pkg | ResolvePackageDigest
    $ref = "$($Pkg.Package):$($Pkg.Tag | AsTagString)"
    if (-not $digest) {
        throw "no such package $ref"
    }
    $Pkg.Digest = $digest
    WriteHost "Digest: $digest"
    if ($digest -notin ($env:AirpowerLoadedPackages -split ';')) {
        $Pkg | ConfigurePackage
        $env:AirpowerLoadedPackages += "$(if ($env:AirpowerLoadedPackages) { ';' })$digest"
        WriteHost "Status: Session configured for $ref"
    } else {
        WriteHost "Status: Session is up to date for $ref"
    }
}

function ExecuteScript {
    param (
        [Parameter(Mandatory)]
        [scriptblock]$ScriptBlock,
        [Parameter(Mandatory)]
        [Collections.Hashtable[]]$Pkgs
    )
    $GUID = New-Guid
    SaveSessionState $GUID
    try {
        ClearSessionState $GUID
        $env:Path = ''
        foreach ($pkg in $Pkgs) {
            $pkg.digest = $pkg | ResolvePackageDigest
            $ref = "$($Pkg.Package):$($Pkg.Tag | AsTagString)"
            if (-not $pkg.digest) {
                throw "no such package $ref"
            }
            $pkg | ConfigurePackage -AppendPath
        }
        $env:Path = "$(if ($env:Path) { "$env:Path;" })$env:SYSTEMROOT;$env:SYSTEMROOT\System32;$PSHOME"
        & $ScriptBlock
    } finally {
        RestoreSessionState $GUID
    }
}

<#
.SYNOPSIS
A package manager and environment to provide consistent tooling for software teams.
 
.DESCRIPTION
Airpower manages software packages using container technology and allows users to configure local PowerShell sessions to their need. Airpower seamlessly integrates common packages with a standardized project script to enable common build commands kept in source control for consistency.
 
.LINK
For detailed documentation and examples, visit https://github.com/airpwr/airpwr.
#>

function Invoke-Airpower {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('version', 'v', 'remote', 'list', 'load', 'pull', 'exec', 'run', 'remove', 'rm', 'prune', 'update', 'help', 'h')]
        [string]$Command,
        [Parameter(ValueFromRemainingArguments)]
        [object[]]$ArgumentList
    )
    try {
        switch ($Command) {
            {$_ -in 'v', 'version'} {
                Invoke-AirpowerVersion
            }
            'remote' {
                Invoke-AirpowerRemote @ArgumentList
            }
            'list' {
                Invoke-AirpowerList
            }
            'load' {
                if ($PSVersionTable.PSVersion.Major -le 5) {
                    Invoke-AirpowerLoad @ArgumentList
                } else {
                    Invoke-AirpowerLoad $ArgumentList
                }
            }
            'pull' {
                if ($PSVersionTable.PSVersion.Major -le 5) {
                    Invoke-AirpowerPull @ArgumentList
                } else {
                    Invoke-AirpowerPull $ArgumentList
                }
            }
            'prune' {
                Invoke-AirpowerPrune
            }
            'update' {
                Invoke-AirpowerUpdate
            }
            {$_ -in 'remove', 'rm'} {
                if ($PSVersionTable.PSVersion.Major -le 5) {
                    Invoke-AirpowerRemove @ArgumentList
                } else {
                    Invoke-AirpowerRemove $ArgumentList
                }
            }
            'exec' {
                $params, $remaining = ResolveParameters 'Invoke-AirpowerExec' $ArgumentList
                if ((-not $params.ScriptBlock) -and ($null -ne $remaining) -and ($remaining[-1] -isnot [scriptblock])) {
                    $params.Packages += $remaining | ForEach-Object { $_ }
                    $remaining = @()
                }
                Invoke-AirpowerExec @params @remaining
            }
            'run' {
                if ($PSVersionTable.PSVersion.Major -le 5) {
                    Invoke-AirpowerRun @ArgumentList
                } else {
                    Invoke-AirpowerRun -FnName $ArgumentList[0] -ArgumentList $ArgumentList[1..$ArgumentList.Count]
                }
            }
            {$_ -in 'help', 'h'} {
                Invoke-AirpowerHelp
            }
        }
    } catch {
        Write-Error $_
    }
}

function GetConfigPackages {
    $cfg = FindConfig
    if ($cfg) {
        . $cfg
    }
    [string[]]$AirpowerPackages
}

function ResolveParameters {
    param (
        [Parameter(Mandatory)]
        [string]$FnName,
        [object[]]$ArgumentList
    )
    $fn = Get-Item "function:$FnName"
    $params = @{}
    $remaining = [Collections.ArrayList]@()
    for ($i = 0; $i -lt $ArgumentList.Count; $i++) {
        if ($fn.parameters.keys -and ($ArgumentList[$i] -match '^-([^:]+)(?::(.*))?$') -and ($Matches[1] -in $fn.parameters.keys)) {
            $name = $Matches[1]
            $value = $Matches[2]
            if ($value) {
                $params.$name = $value
            } else {
                if ($fn.parameters.$name.SwitchParameter -and $null -eq $value) {
                    $params.$name = $true
                } else {
                    $params.$name = $ArgumentList[$i+1]
                    $i++
                }
            }
        } else {
            [void]$remaining.Add($ArgumentList[$i])
        }
    }
    return $params, $remaining
}

function Invoke-AirpowerVersion {
    [CmdletBinding()]
    param ()
    (Get-Module -Name Airpower).Version
}

function Invoke-AirpowerList {
    [CmdletBinding()]
    param ()
    GetLocalPackages
}

function Invoke-AirpowerLoad {
    [CmdletBinding()]
    param (
        [string[]]$Packages
    )
    if (-not $Packages) {
        $Packages = GetConfigPackages
    }
    if (-not $Packages) {
        Write-Error 'no packages provided'
    }
    UpdatePackages -Auto $Packages
    TryEachPackage $Packages { $Input | ResolvePackage | LoadPackage } -ActionDescription 'load'
}

function Invoke-AirpowerRemove {
    [CmdletBinding()]
    param (
        [string[]]$Packages
    )
    TryEachPackage $Packages { $Input | AsPackage | RemovePackage } -ActionDescription 'remove'
}

function Invoke-AirpowerUpdate {
    [CmdletBinding()]
    param ()
    UpdatePackages
}

function Invoke-AirpowerPrune {
    [CmdletBinding()]
    param ()
    PrunePackages
}

function Invoke-AirpowerPull {
    [CmdletBinding()]
    param (
        [string[]]$Packages
    )
    if (-not $Packages) {
        $Packages = GetConfigPackages
    }
    if (-not $Packages) {
        Write-Error "no packages provided"
    }
    TryEachPackage $Packages { $Input | AsPackage | PullPackage | Out-Null } -ActionDescription 'pull'
}

function Invoke-AirpowerRun {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$FnName,
        [Parameter(ValueFromRemainingArguments)]
        [object[]]$ArgumentList
    )
    $cfg = FindConfig
    if ($cfg) {
        . $cfg
    }
    $fn = Get-Item "function:Airpower$FnName"
    if ($fn) {
        $params, $remaining = ResolveParameters "Airpower$FnName" $ArgumentList
        $script = { & $fn @params @remaining }
        if ($AirpowerPackages) {
            Invoke-AirpowerExec -Packages $AirpowerPackages -ScriptBlock $script
        } else {
            & $script
        }
    }
}

function Invoke-AirpowerExec {
    [CmdletBinding()]
    param (
        [string[]]$Packages,
        [scriptblock]$ScriptBlock = { $Host.EnterNestedPrompt() }
    )
    if (-not $Packages) {
        $Packages = GetConfigPackages
    }
    if (-not $Packages) {
        Write-Error "no packages provided"
    }
    UpdatePackages -Auto $Packages
    $resolved = TryEachPackage $Packages { $Input | ResolvePackage } -ActionDescription 'resolve'
    ExecuteScript -ScriptBlock $ScriptBlock -Pkgs $resolved
}

function Invoke-AirpowerRemote {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('list')]
        [string]$Command
    )
    switch ($Command) {
        'list' {
            GetRemoteTags
        }
    }
}

function Invoke-AirpowerHelp {
@"
 
Usage: airpower COMMAND
 
Commands:
  version Outputs the version of the module
  list Outputs a list of installed packages
  remote list Outputs an object of remote packages and versions
  pull Downloads packages
  load Loads packages into the PowerShell session
  exec Runs a user-defined scriptblock in a managed PowerShell session
  run Runs a user-defined scriptblock provided in a project file
  update Updates all tagged packages
  prune Deletes unreferenced packages
  remove Untags and deletes packages
  help Outputs usage for this command
 
For detailed documentation and examples, visit https://github.com/airpwr/airpwr.
 
"@

}

function CheckForUpdates {
    try {
        $params = @{
            URL = "https://www.powershellgallery.com/packages/airpower"
            Method = 'HEAD'
        }
        $resp = HttpRequest @params | HttpSend -NoRedirect
        if ($resp.Headers.Location) {
            $remote = [Version]::new($resp.Headers.Location.OriginalString.Substring('/packages/airpower/'.Length))
            $local = [Version]::new((Import-PowerShellDataFile -Path "$PSScriptRoot\Airpower.psd1").ModuleVersion)
            if ($remote -gt $local) {
                WriteHost "$([char]27)[92mA new version of Airpower is available! [v$remote]$([char]27)[0m"
                WriteHost "$([char]27)[92mUse command ``Update-Module Airpower`` for the latest version$([char]27)[0m"
            }
        }
    } catch {
        Write-Debug "failed to check for updates: $_"
    }
}

Set-Alias -Name 'airpower' -Value 'Invoke-Airpower' -Scope Global
Set-Alias -Name 'air' -Value 'Invoke-Airpower' -Scope Global
Set-Alias -Name 'pwr' -Value 'Invoke-Airpower' -Scope Global

& {
    if ('Airpower.psm1' -eq (Split-Path $MyInvocation.ScriptName -Leaf)) {
        # Invoked as a module
        CheckForUpdates
        PrunePackages -Auto
    }
}