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
    )
    $ch = [Net.Http.HttpClientHandler]::new()
    $ch.UseProxy = $false
    $cli = [Net.Http.HttpClient]::new($ch)
    $resp = $cli.SendAsync($Req, [Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult()
    return $resp
}

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 GetStringResponse {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    return $Resp.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
function ConvertTo-HashTable {
    param (
        [Parameter(ValueFromPipeline)]
        [PSCustomObject]$Object
    )
    $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 GetPwrPullPolicy {
    if ($PwrPullPolicy) {
        $PwrPullPolicy
    } elseif ($env:PwrPullPolicy) {
        $env:PwrPullPolicy
    } else {
        "IfNotPresent"
    }
}

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

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
    )
    if (-not (Test-Path $Path -PathType Container)) {
        New-Item -Path $Path -ItemType Directory
    }
}

function OutPwrDB {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$PwrDB
    )
    MakeDirIfNotExist (GetAirpowerPath)
    $PwrDB |
        ConvertTo-Json -Compress -Depth 10 |
        Out-File -FilePath (GetPwrDBPath) -Encoding 'UTF8' -Force
}

function GetPwrDB {
    $db = GetPwrDBPath
    if (Test-Path $db -PathType Leaf) {
        Get-Content $db -Raw | ConvertFrom-Json | ConvertTo-HashTable
    } else {
        @{
            'pkgdb' = @{}
            'metadatadb' = @{}
        }
    }
}

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
        [Console]::Write("`r$line")
        $script:lastwrite = (Get-Date)
    }
}

function WriteConsole {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Line
    )
    [Console]::Write("`r$Line")
}

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)]
}

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))
}

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
    [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 -lt $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
                [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) {
                    [Util]::GzipRead($Source, (New-Object byte[] $size), $size)
                }
                $xhdr = $null
            }
            $leftover = $size % 512
            if ($leftover -gt 0) {
                [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 GetSize {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    $manifest = $Resp | GetJsonResponse
    $size = 0
    foreach ($layer in $manifest.layers) {
        if ($layer.mediaType -eq 'application/vnd.docker.image.rootfs.diff.tar.gzip') {
            $size += $layer.size
        }
    }
    return $size
}

function SaveBlob {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Digest
    )
    $sha256 = $Digest.Substring('sha256:'.Length)
    $path = "$(GetPwrTempPath)\$sha256.tar.gz"
    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
            $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
}

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 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) {
            return "$($Pkg.Package)-$(($got.ToString()).Replace('+', '_'))"
        }
    }
    throw "no such $($Pkg.Package) tag: $($Pkg.Tag)"
}

function GetLocalPackages {
    $db = GetPwrDB
    $pkgs = @()
    foreach ($pkg in $db.pkgdb.keys) {
        foreach ($tag in $db.pkgdb.$pkg.keys) {
            $t = [Tag]::new($tag)
            $digest = if ($t.None) { $tag } else { $db.pkgdb.$pkg.$tag }
            $pkgs += [PSCustomObject]@{
                Package = $pkg
                Tag = $t
                Digest = $digest | AsDigest
                Size = $db.metadatadb.$digest.size | AsSize
                # Signers
            }
        }
    }
    if (-not $pkgs) {
        $pkgs = ,[PSCustomObject]@{
            Package = $null
            Tag = $null
            Digest = $null
            Size = $null
        }
    }
    return $pkgs
}

function ResolvePackageDigest {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $db = GetPwrDB
    return $db.pkgdb.$($Pkg.Package).$($Pkg.Tag | AsTagString)
}

function InstallPackage { # $db, $status
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $db = GetPwrDB
    $digest = $Pkg.Digest
    $name = $Pkg.Package
    $tag = $Pkg.Tag | AsTagString
    if ($null -ne $db.pkgdb.$name.$tag -and $digest -ne $db.pkgdb.$name.$tag) {
        $status = 'newer'
        $old = $db.pkgdb.$name.$tag
        $db.pkgdb.$name.Remove($tag)
        $db.pkgdb.$name.$old = $null
        if ($db.metadatadb.$old.refcount -gt 0) {
            $db.metadatadb.$old.refcount -= 1
        }
    }
    if ($null -eq $db.pkgdb.$name.$tag) {
        if ($db.metadatadb.$digest) {
            if ($db.pkgdb.$name.ContainsKey($digest)) {
                $db.pkgdb.$name.Remove($digest)
            }
            $status = 'tag'
            $db.metadatadb.$digest.refcount += 1
        } else {
            $status = 'new'
            $db.metadatadb.$digest = @{
                RefCount = 1
                Size = $Pkg.Size
            }
        }
        $db.pkgdb.$name += @{
            "$tag" = $digest
        }
    } else {
        $status = 'uptodate'
    }
    return $db, $status
}

function PullPackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $manifest = $Pkg | ResolveRemoteRef | GetManifest
    $Pkg.Digest = $manifest | GetDigest
    $Pkg.Size = $manifest | GetSize
    WriteHost "$($pkg.Tag | AsTagString): Pulling $($Pkg.Package)"
    WriteHost "Digest: $($Pkg.Digest)"
    $db, $status = $Pkg | InstallPackage
    $ref = "$($Pkg.Package):$($Pkg.Tag | AsTagString)"
    if ($status -eq 'uptodate') {
        WriteHost "Status: Package is up to date for $ref"
    } else {
        if ($status -eq 'new') {
            $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"
        $db | OutPwrDB
    }
}

function SavePackage {
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Net.Http.HttpResponseMessage]$Resp
    )
    [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
    [Console]::CursorVisible = $false
    try {
        $manifest = $Resp | GetJsonResponse
        $digest = $Resp | GetDigest
        $temp = @()
        foreach ($layer in $manifest.layers) {
            if ($layer.mediaType -eq 'application/vnd.docker.image.rootfs.diff.tar.gzip') {
                try {
                    $temp += $layer.Digest | SaveBlob | ExtractTarGz -Digest $digest
                    "$($layer.Digest.Substring('sha256:'.Length).Substring(0, 12)): Pull complete" + ' ' * 60 | WriteConsole
                } finally {
                    [Console]::WriteLine()
                }
            }
        }
        foreach ($tmp in $temp) {
            [IO.File]::Delete($tmp)
        }
    } finally {
        [Console]::CursorVisible = $true
    }
}

function UninstallPackage { # $db, $digest, $err
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Collections.Hashtable]$Pkg
    )
    $db = GetPwrDB
    $name = $Pkg.Package
    $key = $Pkg.Tag | AsTagString
    $table = $db.pkgdb.$name
    if (-not $db.pkgdb.ContainsKey($name) -or -not $table.ContainsKey($key)) {
        return $null, $null, "package not installed: ${name}:$key"
    }
    $digest = $table.$key
    $table.Remove($key)
    if ($table.Count -eq 0) {
        $db.pkgdb.Remove($name)
    }
    if ($db.metadatadb.$digest.refcount -gt 0) {
        $db.metadatadb.$digest.refcount -= 1
    }
    if (0 -eq $db.metadatadb.$digest.refcount) {
        $db.metadatadb.Remove($digest)
    } else {
        $digest = $null
    }
    return $db, $digest, $null
}

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

function UninstallOrhpanedPackages {
    $db = GetPwrDB
    $rm = @()
    foreach ($digest in $db.metadatadb.keys) {
        $tbl = $db.metadatadb.$digest
        if ($tbl.refcount -eq 0) {
            $tbl.digest = $digest
            $rm += ,$tbl
        }
    }
    $empty = @()
    foreach ($i in $rm) {
        $db.metadatadb.Remove($i.digest)
        foreach ($pkg in $db.pkgdb.keys) {
            if ($db.pkgdb.$pkg.ContainsKey($i.digest)) {
                $db.pkgdb.$pkg.Remove($i.digest)
                if ($db.pkgdb.$pkg.Count -eq 0) {
                    $empty += $pkg
                }
            }
        }
    }
    foreach ($name in $empty) {
        $db.pkgdb.Remove($name)
    }
    return $db, $rm
}

function PrunePackages {
    $db, $pruned = UninstallOrhpanedPackages
    $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) {
            [IO.Directory]::Delete("\\?\$((Resolve-Path $content).Path)", $true)
        }
    }
    WriteHost "Total reclaimed space: $($bytes | AsByteString)"
    $db | OutPwrDB
}

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
    )
    $pkg = $Ref | AsPackage
    $digest = $pkg | ResolvePackageDigest
    switch (GetPwrPullPolicy) {
        '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 "invalid PwrPullPolicy '$(GetPwrPullPolicy)'"
        }
    }
    return $pkg
}

function GetSessionState {
    return @{
        Vars = (Get-Variable -Scope Global | ForEach-Object { ConvertTo-HashTable $_ } )
        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
    }
    $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) {
                $post = "$(if ($env:Path -and -not $env:Path.StartsWith(';')) { ';' })$env:Path"
            } else {
                $pre = "$env:Path$(if ($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
    }
}

function Migrate { # TODO REMOVE
    if (Test-Path "$(GetAirpowerPath)\pwrdb" -PathType Leaf) {
        Rename-Item -Path "$(GetAirpowerPath)\pwrdb" -NewName (GetPwrDBPath) -Force *>$null
    }
    $env = [Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::User)
    if ($env.PwrHome -and -not $env.AirpowerPath) {
        [Environment]::SetEnvironmentVariable('AirpowerPath', $env.PwrHome, [EnvironmentVariableTarget]::User)
    }
    if ($env:PwrHome) {
        $env:AirpowerPath = $env:PwrHome
    }
} # TODO REMOVE

function Invoke-Airpower {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('version', 'v', 'remote', 'list', 'load', 'pull', 'exec', 'run', 'remove', 'rm', 'prune', 'help', 'h')]
        [string]$Command,
        [Parameter(ValueFromRemainingArguments)]
        [object[]]$ArgumentList
    )
    Migrate # TODO REMOVE
    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
            }
            {$_ -in 'remove', 'rm'} {
                if ($PSVersionTable.PSVersion.Major -le 5) {
                    Invoke-AirpowerRemove @ArgumentList
                } else {
                    Invoke-AirpowerRemove $ArgumentList
                }
            }
            'exec' {
                $params, $remaining = ResolveParameters 'Invoke-AirpowerExec' $ArgumentList
                Invoke-AirpowerExec @params @remaining
            }
            'run' {
                Invoke-AirpowerRun @ArgumentList
            }
            {$_ -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'
    }
    foreach ($p in $Packages) {
        $p | ResolvePackage | LoadPackage
    }
}

function Invoke-AirpowerRemove {
    [CmdletBinding()]
    param (
        [string[]]$Packages
    )
    foreach ($p in $Packages) {
        $p | AsPackage | RemovePackage
    }
}

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"
    }
    foreach ($p in $Packages) {
        $p | AsPackage | PullPackage
    }
}

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"
    }
    $resolved = @()
    foreach ($p in $Packages) {
        $resolved += $p | ResolvePackage
    }
    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
 
A package manager and environment to provide consistent tooling for software teams
 
Commands:
    version, v Outputs the version of the module
    list Outputs a list of installed packages
    remote list Outputs an object of remote packages and versions
    pull Downlaods packages
    load Loads packages into the PowerShell session
    exec Runs a user-defined scriptblock in a managed PowerShell session state
    run Runs a user-defined scriptblock provided in a project file
    prune Deletes unreferenced packages
    remove, rm Untags and deletes packages
"@

}

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