PwSh.Fw.GenericPackageEngine.psm1

#requires -version 7.0

$Script:ModuleRoot = $PSScriptRoot

# Format marker : every pkgdef file must carry this exact value at the root
# level. It identifies the file as a PwSh.Fw.GenericPackageEngine definition
# and pins the specification version (see schemas/pkgdef.schema.json).
$Script:PkgDefFormat = 'PwSh.Fw.GenericPackageEngine/v2'

# ═══════════════════════════════════════════════════════════════════════════════
# PHASE 1 : RESOLVE
# Resolve a product definition for one given target
# ═══════════════════════════════════════════════════════════════════════════════

<#
.SYNOPSIS
Resolve a product pkgdef for one target.
 
.DESCRIPTION
Loads a pkgdef v2 definition (from a file or a hashtable) and resolves it for
one target. The definition must carry the format marker
'pkgdef: PwSh.Fw.GenericPackageEngine/v2' (first key by convention) ;
definitions without a marker, or with an unsupported format version, are
rejected.
  - download : key-by-key merge of the global block and the target block
                (target values win) — the only merge of the schema ;
  - install / post : replacement (target block wins, no concatenation) ;
  - check : replacement (target block wins, no concatenation) ;
  - version : target value wins over the global value ;
                with -ResolveLatest, a 'latest' version is replaced by the
                concrete version found at the provider before returning
                (github/gitlab : latest release tag, webscrapper : highest
                (?<ver>) capture on the page, url : version-like token in
                the URL path, gitlab-artifact : the ref when it is a
                version). API-backed providers fail (Write-Fatal) when no
                version can be produced ; url / gitlab-artifact keep the
                literal token when nothing can be derived locally.
  - payload, upload : taken from the target when present.
 
The target is selected either explicitly (-TargetId) or by platform match
(-Os / -Distrib / -Arch). Platform match rules :
  - os : exact match ;
  - arch: exact match, or target.arch is 'all', or no arch requested ;
  - distrib: exact match (an empty requested distrib only matches targets
            without a distrib) ;
  - first match wins.
 
.EXAMPLE
$resolved = Resolve-GenericPackageDefinition -Path ./examples/firefox.yml -TargetId windows-x64
 
.EXAMPLE
$resolved = Resolve-GenericPackageDefinition -Path ./examples/libreoffice.yml -Os linux -Distrib debian -Arch x64
#>

function Resolve-GenericPackageDefinition {
    [CmdletBinding()]
    [OutputType([hashtable])]
    Param (
        # Path to the pkgdef file
        [Parameter(Mandatory = $false)][string]$Path,
        # Package definition (hashtable, e.g. loaded yaml)
        [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Definition,
        # Explicit target id (e.g. windows-x64)
        [Parameter(Mandatory = $false)][string]$TargetId,
        # Target OS: 'windows', 'linux', 'macos'
        [Parameter(Mandatory = $false)][ValidateSet('windows', 'linux', 'macos')][string]$Os,
        # Target distribution (linux)
        [Parameter(Mandatory = $false)][string]$Distrib = '',
        # Target architecture
        [Parameter(Mandatory = $false)][ValidateSet('x86', 'x64', 'arm32', 'arm64', 'all')][string]$Arch = '',
        # Replace a 'latest' version with the provider's current version (network call for github/gitlab/webscrapper)
        [Parameter(Mandatory = $false)][switch]$ResolveLatest
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (-not $Path -and -not $Definition) {
            Write-Fatal "Either -Path or -Definition must be provided"
        }
        if ($Path -and $Definition) {
            Write-Fatal "Provide either -Path or -Definition, not both"
        }
        if ($TargetId -and $Os) {
            Write-Fatal "Provide either -TargetId or -Os, not both"
        }
        if (-not $TargetId -and -not $Os) {
            Write-Fatal "A target must be selected : -TargetId or -Os (plus -Distrib/-Arch)"
        }

        # ─── Load raw definition ──────────────────────────────────────────────
        if ($Path) {
            if (-not (Test-FileExist $Path)) { Write-Fatal "File not found : $Path" }
            $raw = Get-Content -Path $Path -Raw | ConvertFrom-Yaml | ConvertTo-PlainHashtable
        } else {
            $raw = ConvertTo-PlainHashtable -InputObject $Definition
        }
        # Format marker : identity check before anything else
        if (-not $raw -or -not $raw.pkgdef) {
            Write-Fatal "Missing format marker : expected 'pkgdef: $Script:PkgDefFormat' as the first key (this file is not a PwSh.Fw.GenericPackageEngine definition)"
        }
        if ($raw.pkgdef -ne $Script:PkgDefFormat) {
            Write-Fatal "Unsupported pkgdef format : '$($raw.pkgdef)' (this engine supports '$Script:PkgDefFormat')"
        }
        if (-not $raw.name) {
            Write-Fatal "Invalid pkgdef : 'name' is missing"
        }

        # NB : @($raw.targets) wraps a missing key into @($null) — filter nulls
        $targets = @(@($raw.targets) | Where-Object { $null -ne $_ })
        if ($targets.Count -eq 0) {
            Write-Fatal "No targets defined in pkgdef '$($raw.name)'"
        }

        # ─── Select target ────────────────────────────────────────────────────
        if ($TargetId) {
            $target = @($targets | Where-Object { $_.id -eq $TargetId }) | Select-Object -First 1
            if (-not $target) {
                Write-Fatal "Target '$TargetId' not found in pkgdef '$($raw.name)' (available : $(($targets | ForEach-Object { $_.id }) -join ', '))"
            }
        } else {
            $target = @($targets | Where-Object {
                $_.os -eq $Os.ToLower() `
                -and ($_.arch -eq $Arch -or $_.arch -eq 'all' -or [string]::IsNullOrEmpty($Arch)) `
                -and (([string]::IsNullOrEmpty($Distrib) -and [string]::IsNullOrEmpty($_.distrib)) -or ($_.distrib -eq $Distrib.ToLower()))
            }) | Select-Object -First 1
            if (-not $target) {
                Write-Fatal "No target matching os=$Os distrib=$Distrib arch=$Arch in pkgdef '$($raw.name)'"
            }
        }

        # ─── Build resolved definition ────────────────────────────────────────
        $resolved = @{
            pkgdef   = $raw.pkgdef
            name     = $raw.name
            codename = $raw.codename ?? (($raw.name.ToLower() -replace '\s+', '-'))
            type     = 'product'
            version  = ($target.version ?? $raw.version) ?? 'latest'
            depends  = @($raw.depends)
            tags     = @($raw.tags)
            download = Merge-GenericPackageDownload -Global $raw.download -Override $target.download
            install  = @($target.install ?? $raw.install)
            post     = @($target.post ?? $raw.post)
            check    = $target.check ?? $raw.check
            target   = @{
                id     = $target.id
                os     = $target.os
                distrib = $target.distrib ?? ''
                arch   = $target.arch
            }
        }
        if ($target.payload) { $resolved.payload = $target.payload }
        if ($target.upload) { $resolved.upload = $target.upload }

        # ─── Optional : resolve 'latest' to the provider's current version ───
        if ($ResolveLatest -and "$($resolved.version)" -ieq 'latest') {
            $mvSeed = Get-GenericPackageMustacheValues -pkgdef $resolved
            $concrete = Get-GenericPackageLatestVersion -download $resolved.download -mustacheValues $mvSeed
            if ($concrete) {
                Write-Verbose "Resolved 'latest' to '$concrete' (provider '$($resolved.download.provider)')"
                $resolved.version = $concrete
            }
            # $null : no external source of truth for this provider (url with
            # a literal 'latest', gitlab-artifact branch ref) — token stays
        }

        Write-Debug "Resolved '$($raw.name)' for target '$($target.id)' (os=$($target.os) distrib=$($target.distrib ?? '') arch=$($target.arch) version=$($resolved.version))"
        return $resolved
    }

    End {
        Write-LeaveFunction
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# EXPAND : one resolved file per target (CI matrix)
# ═══════════════════════════════════════════════════════════════════════════════

<#
.SYNOPSIS
Expand a pkgdef into one resolved definition file per target.
 
.DESCRIPTION
Resolves every target of the product and writes one YAML file per target,
named '<codename>-<target.id>.yml', into -OutputDir. Returns the list of
written file paths. Useful to feed a CI matrix.
 
.EXAMPLE
$files = Expand-GenericPackageTargets -Path ./examples/firefox.yml -OutputDir ./resolved
#>

function Expand-GenericPackageTargets {
    [CmdletBinding()]
    [OutputType([array])]
    Param (
        # Path to the pkgdef file
        [Parameter(Mandatory = $false)][string]$Path,
        # Package definition (hashtable)
        [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Definition,
        # Output directory for the resolved files
        [Parameter(Mandatory = $true)][string]$OutputDir,
        # Overwrite existing files
        [switch]$Force
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (-not $Path -and -not $Definition) { Write-Fatal "Either -Path or -Definition must be provided" }
        if ($Path -and $Definition) { Write-Fatal "Provide either -Path or -Definition, not both" }

        if ($Path) {
            if (-not (Test-FileExist $Path)) { Write-Fatal "File not found : $Path" }
            $raw = Get-Content -Path $Path -Raw | ConvertFrom-Yaml | ConvertTo-PlainHashtable
        } else {
            $raw = ConvertTo-PlainHashtable -InputObject $Definition
        }
        if (-not (Test-DirExist $OutputDir)) {
            $null = New-Item -Path $OutputDir -ItemType Directory -Force
        }

        $files = @()
        foreach ($target in @($raw.targets)) {
            $resolved = Resolve-GenericPackageDefinition -Definition $raw -TargetId $target.id
            $fileName = "$($resolved.codename)-$($target.id).yml"
            $filePath = Join-Path $OutputDir $fileName
            # NB : parenthesize the command call — without them PowerShell
            # binds '-and' as a parameter of Test-FileExist
            if ((Test-FileExist $filePath) -and (-not $Force)) {
                Write-Warning "File exists, skipped (use -Force to overwrite) : $filePath"
                continue
            }
            $resolved | ConvertTo-Yaml -Options WithIndentedSequences | Set-Content -Path $filePath -Encoding UTF8
            Write-Verbose "Wrote $filePath"
            $files += $filePath
        }
        return $files
    }

    End {
        Write-LeaveFunction
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# VALIDATE : semantic checks on top of the JSON Schema
# ═══════════════════════════════════════════════════════════════════════════════

<#
.SYNOPSIS
Semantic validation of a pkgdef.
 
.DESCRIPTION
Complements the static JSON Schema validation with semantic checks :
  - presence and version of the pkgdef format marker ;
  - uniqueness of target ids ;
  - validity of asset / signature-file regex patterns ;
  - provider coherence (owner/repo, url, artifact) ;
  - declared payload vs asset pattern extension mismatch (warning) ;
  - check expression structure (one key per node, and/or arity, not child,
    file-exists leaf fields) ;
  - check relying on {{ ASSET }} (warning : only available after download) ;
  - upload coherence (nexus requires repository).
 
Returns a results object : [PSCustomObject]@{ Valid ; Errors ; Warnings }.
 
.EXAMPLE
$result = Test-GenericPackageDefinition -Path ./examples/firefox.yml
if (-not $result.Valid) { $result.Errors }
#>

function Test-GenericPackageDefinition {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    Param (
        # Path to the pkgdef file
        [Parameter(Mandatory = $false)][string]$Path,
        # Package definition (hashtable)
        [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Definition
    )
    Begin {
        Write-EnterFunction
        $errors = @()
        $warnings = @()
    }

    Process {
        if (-not $Path -and -not $Definition) { Write-Fatal "Either -Path or -Definition must be provided" }
        if ($Path -and $Definition) { Write-Fatal "Provide either -Path or -Definition, not both" }

        if ($Path) {
            if (-not (Test-FileExist $Path)) {
                return [PSCustomObject]@{ Valid = $false; Errors = @("File not found : $Path"); Warnings = @() }
            }
            $raw = Get-Content -Path $Path -Raw | ConvertFrom-Yaml | ConvertTo-PlainHashtable
        } else {
            $raw = ConvertTo-PlainHashtable -InputObject $Definition
        }
        # Format marker : identity check before anything else
        if (-not $raw -or -not $raw.pkgdef) {
            return [PSCustomObject]@{ Valid = $false; Errors = @("missing format marker : expected 'pkgdef: $Script:PkgDefFormat'"); Warnings = @() }
        }
        if ($raw.pkgdef -ne $Script:PkgDefFormat) {
            return [PSCustomObject]@{ Valid = $false; Errors = @("unsupported pkgdef format : '$($raw.pkgdef)'"); Warnings = @() }
        }
        if (-not $raw -or -not $raw.name) {
            return [PSCustomObject]@{ Valid = $false; Errors = @("'name' is missing") ; Warnings = @() }
        }

        # NB : @($raw.targets) wraps a missing key into @($null) — filter nulls
        $targets = @(@($raw.targets) | Where-Object { $null -ne $_ })
        if ($targets.Count -eq 0) { $errors += "product requires at least one target" }

        # ─── target id uniqueness ─────────────────────────────────────────────
        $ids = @($targets | ForEach-Object { $_.id })
        foreach ($dup in ($ids | Group-Object | Where-Object { $_.Count -gt 1 })) {
            $errors += "duplicate target id : $($dup.Name)"
        }

        # ─── per-target checks ────────────────────────────────────────────────
        foreach ($target in $targets) {
            # merged download view (same rule as Resolve)
            $download = @{}
            if ($raw.download) { foreach ($k in $raw.download.Keys) { $download[$k] = $raw.download[$k] } }
            if ($target.download) { foreach ($k in $target.download.Keys) { $download[$k] = $target.download[$k] } }

            # provider coherence
            $provider = $download.provider
            if ($provider) {
                switch ($provider) {
                    { $_ -in @('github', 'gitlab', 'gitlab-artifact') } {
                        if (-not $download.owner) { $errors += "[$($target.id)] provider $provider requires 'owner'" }
                        if (-not $download.repo) { $errors += "[$($target.id)] provider $provider requires 'repo'" }
                        # NB : 'gitlab-artifact' is matched by the condition
                        # above — a dedicated switch case would be unreachable
                        if ($provider -eq 'gitlab-artifact') {
                            if (-not $download.artifact) { $errors += "[$($target.id)] provider gitlab-artifact requires 'artifact'" }
                            elseif (-not $download.artifact.ref -or -not $download.artifact.job) {
                                $errors += "[$($target.id)] artifact requires 'ref' and 'job'"
                            }
                        }
                    }
                    { $_ -in @('url', 'webscrapper') } {
                        if (-not $download.url) { $errors += "[$($target.id)] provider $provider requires 'url'" }
                    }
                }
            }

            # regex validity + payload/pattern heuristic
            foreach ($asset in @($download.assets)) {
                foreach ($pattern in @($asset.pattern, $asset.'signature-file'.pattern)) {
                    if (-not $pattern) { continue }
                    $clean = $pattern -replace '\{\{.*?\}\}', ''
                    try {
                        $null = [regex]::new($clean)
                    } catch {
                        $errors += "[$($target.id)] invalid regex pattern : $pattern"
                    }
                }
                if ($target.payload -and $asset.pattern) {
                    $suffix = switch ($target.payload) {
                        'tar.gz' { '\.tar\.gz' }
                        default { "\.$($target.payload)" }
                    }
                    $literal = $asset.pattern -replace '\{\{.*?\}\}', ''
                    $extMatch = [regex]::Match($literal, '\\\.(?<ext>[a-zA-Z0-9.]+)$')
                    if ($extMatch.Success) {
                        $declaredExt = $extMatch.Groups['ext'].Value.ToLower()
                        $expectedExt = ($suffix -replace '^\\\.','')
                        if ($declaredExt -ne $expectedExt -and $declaredExt -ne $expectedExt.Split('.')[0]) {
                            $warnings += "[$($target.id)] payload '$($target.payload)' does not look like the pattern extension '$declaredExt'"
                        }
                    }
                }
            }

            # check expression coherence (merged view : target wins over global)
            $check = $target.check ?? $raw.check
            if ($check) {
                $errors += @(Get-GenericPackageCheckIssues -Expression $check -Prefix "[$($target.id)] ")
                # {{ ASSET }} only exists after the download phase
                if (($check | ConvertTo-Json -Depth 10 -Compress) -match '\{\{\s*ASSET\s*\}\}') {
                    $warnings += "[$($target.id)] check uses '{{ ASSET }}' which is only available after the download phase (the pre-check cannot rely on it)"
                }
            }

            # upload coherence
            if ($target.upload) {
                if ($target.upload.type -eq 'nexus' -and -not $target.upload.repository) {
                    $errors += "[$($target.id)] upload type 'nexus' requires 'repository'"
                }
                if (-not $target.upload.url) {
                    $errors += "[$($target.id)] upload requires 'url'"
                }
            }
        }

        $valid = -not $errors.Count
        foreach ($w in $warnings) { Write-Warning $w }
        return [PSCustomObject]@{ Valid = $valid; Errors = $errors; Warnings = $warnings }
    }

    End {
        Write-LeaveFunction
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# DISCOVER : find pkgdef files on disk
# ═══════════════════════════════════════════════════════════════════════════════

<#
.SYNOPSIS
Find pkgdef files on disk.
 
.DESCRIPTION
Scans a directory (or a single file) for PwSh.Fw.GenericPackageEngine
package definitions. A file qualifies when it parses as YAML and carries
the required format marker 'pkgdef: PwSh.Fw.GenericPackageEngine/v2' ;
foreign YAML files (Ansible, GitLab CI, ...) are ignored. With -Validate,
each candidate is additionally checked with Test-GenericPackageDefinition
and only valid definitions are returned (invalid ones are reported as
warnings).
 
Returns the full paths of the found definitions.
 
.EXAMPLE
$files = Find-GenericPackageDefinition -Path /opt/pkgdefs -Recurse
foreach ($file in $files) {
    Install-GenericPackage -Path $file -TargetId 'linux-x64' -DestinationPath /mnt/chroot
}
 
.EXAMPLE
# Keep only the definitions that pass the semantic validation
Find-GenericPackageDefinition -Path ./definitions -Recurse -Validate
#>

function Find-GenericPackageDefinition {
    [CmdletBinding()]
    [OutputType([string[]])]
    Param (
        # Path to a pkgdef file or a directory to scan
        [Parameter(Mandatory = $true)][string]$Path,
        # Scan subdirectories (directory input only)
        [switch]$Recurse,
        # Additionally run Test-GenericPackageDefinition and keep only valid definitions
        [switch]$Validate
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (-not (Test-Path $Path)) { Write-Fatal "Path not found : $Path" }

        $candidates = @()
        if (Test-Path $Path -PathType Container) {
            $candidates = @(Get-ChildItem -Path $Path -File -Recurse:$Recurse |
                Where-Object { $_.Extension -in @('.yml', '.yaml') })
        } else {
            $candidates = @(Get-Item $Path)
        }

        $found = @()
        foreach ($file in $candidates) {
            $rawText = Get-Content $file.FullName -Raw -ErrorAction SilentlyContinue
            if ([string]::IsNullOrEmpty($rawText)) { continue }
            # cheap pre-filter : the marker line must appear in the raw text
            # (constant value, dots escaped for regex)
            if ($rawText -notmatch '(?m)^\s*pkgdef:\s*PwSh\.Fw\.GenericPackageEngine/v2\s*(?:#.*)?$') {
                Write-Verbose "Not a pkgdef (no format marker) : $($file.FullName)"
                continue
            }
            # definitive check : parse and verify the key
            try {
                $def = $rawText | ConvertFrom-Yaml | ConvertTo-PlainHashtable
            } catch {
                Write-Warning "Invalid YAML, skipped : $($file.FullName)"
                continue
            }
            if ($def.pkgdef -ne $Script:PkgDefFormat) {
                Write-Verbose "Unsupported format version, skipped : $($file.FullName)"
                continue
            }
            if ($Validate) {
                $result = Test-GenericPackageDefinition -Definition $def
                if (-not $result.Valid) {
                    Write-Warning "Invalid pkgdef, skipped : $($file.Name) ($(($result.Errors) -join '; '))"
                    continue
                }
            }
            $found += $file.FullName
            Write-Verbose "Found pkgdef : $($file.FullName)"
        }
        return $found
    }

    End {
        Write-LeaveFunction
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# PHASE 2 : DOWNLOAD
# Download the asset of a resolved definition
# ═══════════════════════════════════════════════════════════════════════════════

<#
.SYNOPSIS
Download the asset of a resolved package definition.
 
.DESCRIPTION
Dispatches to the provider implementation (github, gitlab, gitlab-artifact,
url, webscrapper) using the resolved download block. The API token is read
from the environment variable named by 'token_env' (never stored in the
definition). The asset is cached : if the file already exists in the cache
directory, the download is skipped. When the matched asset declares a
signature-file, the companion checksum file is downloaded from the same
source and the asset is verified before being returned.
 
Returns the local path of the downloaded asset.
 
.EXAMPLE
$asset = Download-GenericPackageAsset -pkgdef $resolved -CachePath '/tmp/cache'
#>

function Download-GenericPackageAsset {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", "", Justification="Download is the intended verb for this function.")]
    [CmdletBinding()]
    [OutputType([String])]
    Param (
        # Resolved package definition (output of Resolve-GenericPackageDefinition)
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][object]$pkgdef,
        # Path to the cache directory to download assets to
        [Parameter(Mandatory = $false)][string]$CachePath,
        # Template values (uppercase keys, mustache compatible)
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (-not $pkgdef.download) {
            Write-Warning "No download configuration found"
            return $null
        }

        # Resolve CachePath: parameter > pkgdef.download.destination > temp path
        if ([string]::IsNullOrEmpty($CachePath)) {
            if ($pkgdef.download.destination) {
                $CachePath = $pkgdef.download.destination
            } else {
                $CachePath = [io.path]::GetTempPath()
            }
        }
        if (!(Test-Path -Path $CachePath)) {
            $null = New-Item -Path $CachePath -ItemType Directory -Force
        }

        $token = Get-GenericPackageToken -pkgdef $pkgdef

        # Seed mustache values from the resolved definition
        # (caller-provided values win over the derived ones)
        $mv = Get-GenericPackageMustacheValues -pkgdef $pkgdef -overrides $mustacheValues

        Write-Verbose "Downloading asset using provider '$($pkgdef.download.provider)'"
        Write-Devel -InputObject $pkgdef.download

        switch ($pkgdef.download.provider) {
            'github' {
                $asset = Download-AssetFromGithub -pkgdef $pkgdef -DestinationPath $CachePath -MustacheValues $mv
            }
            'gitlab' {
                $asset = Download-AssetFromGitlab -pkgdef $pkgdef -DestinationPath $CachePath -MustacheValues $mv -GitLabBaseUrl ($pkgdef.download.gitlab_url ?? "https://gitlab.com") -GitLabToken $token
            }
            'gitlab-artifact' {
                $asset = Download-ArtifactFromGitlab -pkgdef $pkgdef -DestinationPath $CachePath -MustacheValues $mv -GitLabBaseUrl ($pkgdef.download.gitlab_url ?? "https://gitlab.com") -GitLabToken $token
            }
            'url' {
                $asset = Download-AssetFromUrl -pkgdef $pkgdef -DestinationPath $CachePath -MustacheValues $mv
            }
            'webscrapper' {
                $asset = Download-AssetFromWebScrapper -pkgdef $pkgdef -DestinationPath $CachePath -MustacheValues $mv
            }
            default {
                Write-Fatal "Unknown download provider: '$($pkgdef.download.provider)'"
                return $null
            }
        }

        if ($asset -and (Test-FileExist $asset)) {
            return $asset
        } else {
            Write-Fatal "Failed to download asset"
            return $null
        }
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Get the asset information of a resolved package definition without downloading.
 
.DESCRIPTION
Inspects the download configuration and resolves which asset would be
downloaded, returning metadata instead of performing actual downloads :
  - name : asset file name
  - download_url : URL to download from
  - local_path : expected local file path (cache / file name)
  - digest : hash digest when available (github)
 
.EXAMPLE
$assets = Get-GenericPackageAssetInfo -pkgdef $resolved -CachePath '/tmp/cache'
#>

function Get-GenericPackageAssetInfo {
    [CmdletBinding()]
    [OutputType([array])]
    Param (
        # Resolved package definition
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][object]$pkgdef,
        # Cache directory (used to compute local_path)
        [Parameter(Mandatory = $false)][string]$CachePath,
        # Template values (uppercase keys, mustache compatible)
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (-not $pkgdef.download) {
            Write-Warning "No download configuration found"
            return @()
        }
        if ([string]::IsNullOrEmpty($CachePath)) {
            $CachePath = $pkgdef.download.destination ?? [io.path]::GetTempPath()
        }

        $token = Get-GenericPackageToken -pkgdef $pkgdef

        # Seed mustache values from the resolved definition
        # (caller-provided values win over the derived ones)
        $mv = Get-GenericPackageMustacheValues -pkgdef $pkgdef -overrides $mustacheValues

        switch ($pkgdef.download.provider) {
            'github' {
                $assets = Get-AssetInfoFromGithub -pkgdef $pkgdef -CachePath $CachePath -MustacheValues $mv
            }
            'gitlab' {
                $assets = Get-AssetInfoFromGitlab -pkgdef $pkgdef -CachePath $CachePath -MustacheValues $mv -GitLabBaseUrl ($pkgdef.download.gitlab_url ?? "https://gitlab.com") -GitLabToken $token
            }
            'gitlab-artifact' {
                $assets = Get-AssetInfoFromGitlabArtifact -pkgdef $pkgdef -CachePath $CachePath -MustacheValues $mv -GitLabBaseUrl ($pkgdef.download.gitlab_url ?? "https://gitlab.com")
            }
            'url' {
                $assets = Get-AssetInfoFromUrl -pkgdef $pkgdef -CachePath $CachePath -MustacheValues $mv
            }
            'webscrapper' {
                $assets = Get-AssetInfoFromWebScrapper -pkgdef $pkgdef -CachePath $CachePath -MustacheValues $mv
            }
            default {
                Write-Fatal "Unknown download provider: '$($pkgdef.download.provider)'"
                return @()
            }
        }

        # Normalize : always return one array object. The unary comma keeps
        # the array intact on the output pipeline (otherwise a single-element
        # result would be unwrapped into a bare hashtable by the caller)
        if (-not $assets) {
            return , @()
        }
        return , @($assets)
    }

    End {
        Write-LeaveFunction
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# PHASE 3 / 4 : INSTALL + POST
# Execute the install steps, then the post-install steps
# ═══════════════════════════════════════════════════════════════════════════════

<#
.SYNOPSIS
Execute a list of installation steps.
 
.DESCRIPTION
Runs each step in order through Execute-GenericPackageStep, skipping null
and empty entries. A failing step aborts the loop and returns $false.
#>

function Execute-GenericPackageSteps {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", "", Justification="Execute is the intended verb for this function.")]
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    Param (
        # Label of the phase (install / post) — for logging only
        [Parameter(Mandatory = $true)][string]$Phase,
        # Steps to execute (may be empty or contain null entries : both are
        # skipped. NB : cannot be Mandatory [object[]] — PowerShell refuses
        # to bind empty or null-containing arrays to mandatory array params)
        [Parameter(Mandatory = $false)][object[]]$Steps,
        # Destination path (chroot / mount / install root)
        [Parameter(Mandatory = $false)][string]$DestinationPath,
        # Template values (uppercase keys, mustache compatible)
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (-not $Steps -or $Steps.Count -eq 0) {
            Write-Verbose "No $Phase steps defined"
            return $true
        }

        foreach ($step in $Steps) {
            if ($null -eq $step) { continue }
            if ($step -eq @{}) { continue }
            Write-Verbose "Executing $Phase step '$($step.action)'"
            Write-Devel -InputObject $step
            $rc = Execute-GenericPackageStep -step $step -DestinationPath $DestinationPath -mustacheValues $mustacheValues
            if (-not $rc) {
                Write-Warning "$Phase step '$($step.action)' failed"
                return $false
            }
        }
        return $true
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Resolve a pkgdef path against a base directory.
 
.DESCRIPTION
Universal path rule of the engine : an absolute (rooted) path is used as-is ;
a relative path is joined to the base directory (-DestinationPath for most
step fields, the pkgdef directory for file-from-template 'source'). Mustache
rendering is done by the caller before calling this helper.
#>

function Resolve-GenericPackagePath {
    [CmdletBinding()]
    [OutputType([string])]
    Param (
        # Path to resolve (already mustache-rendered)
        [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path,
        # Base directory for relative paths
        [Parameter(Mandatory = $false)][AllowEmptyString()][string]$Base = ''
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if ([System.IO.Path]::IsPathRooted($Path)) { return $Path }
        if ($Base) { return "$Base/$($Path.Trim('/'))" }
        return $Path
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Execute one installation step.
 
.DESCRIPTION
Internal step executor shared by the install and post phases. Supported
actions : apt, copy, delete, exec, exec-chroot, exec-scriptblock,
file-content, file-from-template, mkdir, rename, unzip ('file' is accepted
as a deprecated alias of 'file-content'). Path resolution : an absolute path
is used as-is ; a relative path is joined to -DestinationPath, except
file-from-template 'source' which is joined to the pkgdef directory.
Mustache templating is applied on text fields.
#>

function Execute-GenericPackageStep {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", "", Justification="Execute is an intuitive verb for this function. We could use Invoke but the meaning is not that.")]
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    Param (
        # Step definition from yaml
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][hashtable]$step,
        # Destination path to install : typically a LinuxPE chroot or a WinPE mount path
        [Parameter(Mandatory = $false)][string]$DestinationPath,
        # Template values : single level, uppercased keys hashtable suitable for mustache {{ VALUE }}
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        Write-Devel -InputObject $mustacheValues
        $rc = $false
        # 'file' is a deprecated alias of 'file-content' (kept for migration)
        $action = $step.action
        if ($action -eq 'file') {
            # native warning stream (the framework override is display-only)
            # so the notice stays machine-readable and CI-greppable
            & Microsoft.PowerShell.Utility\Write-Warning "Step action 'file' is deprecated : use 'file-content' instead"
            $action = 'file-content'
        }
        switch ($action) {
            'apt' {
                $script = @"
EOF
export DEBIAN_FRONTEND=noninteractive
apt-get update
[ `$? -eq 0 ] || exit `$?
 
apt-get install --no-install-recommends -y $($step.packages -join ' ')
[ `$? -eq 0 ] || exit `$?
 
EOF
"@

                $rc = Execute-Command -exe "/bin/bash" "-c `"LANG=C.UTF-8 chroot $DestinationPath <<- $script`"" -AsBool
            }
            'copy' {
                $source = Render-MustacheField -value $step.source -mustacheValues $mustacheValues
                $destination = Render-MustacheField -value $step.destination -mustacheValues $mustacheValues
                $source = Resolve-GenericPackagePath -Path $source -Base $DestinationPath
                $destFile = Resolve-GenericPackagePath -Path $destination -Base $DestinationPath
                $parent = Split-Path $destFile -Parent
                if ($parent -and -not (Test-DirExist $parent)) {
                    $null = New-Item -Path $parent -ItemType Directory -Force
                }
                $null = Copy-Item -Path $source -Destination $destFile -Force
                $rc = Test-Path $destFile -PathType Leaf
            }
            'delete' {
                $path = Render-MustacheField -value $step.path -mustacheValues $mustacheValues
                $target = Resolve-GenericPackagePath -Path $path -Base $DestinationPath
                if (Test-Path $target) {
                    Remove-Item -Path $target -Recurse -Force
                }
                $rc = -not (Test-Path $target)
            }
            'exec' {
                # 2 cases :
                # - user provides cmd: attribute with whole command
                # - user provides exe: and args: as separate attributes
                if ($step.cmd) {
                    $command = Render-MustacheField -value $step.cmd -mustacheValues $mustacheValues
                    $workingDir = if ($step.workingDir) { Resolve-GenericPackagePath -Path (Render-MustacheField -value $step.workingDir -mustacheValues $mustacheValues) -Base $DestinationPath } else { $DestinationPath }
                    $rc = Execute-Command $command -WorkingDir $workingDir -AsBool
                } else {
                    $exe = Render-MustacheField -value $step.exe -mustacheValues $mustacheValues
                    $parameters = Render-MustacheField -value $step.args -mustacheValues $mustacheValues
                    $workingDir = if ($step.workingDir) { Resolve-GenericPackagePath -Path (Render-MustacheField -value $step.workingDir -mustacheValues $mustacheValues) -Base $DestinationPath } else { $DestinationPath }
                    $rc = Execute-Command -exe $exe -args $parameters -WorkingDir $workingDir -AsBool
                }
            }
            'exec-chroot' {
                $script = Render-MustacheField -value $step.script -mustacheValues $mustacheValues
                $script = @"
EOF
$script
EOF
"@

                $rc = Execute-Command -exe "/bin/bash" "-c `"LANG=C.UTF-8 chroot $DestinationPath <<- $script`"" -AsBool
            }
            'exec-scriptblock' {
                $scriptblock = Render-MustacheField -value $step.scriptblock -mustacheValues $mustacheValues
                $output = Invoke-Command -ScriptBlock ([scriptblock]::Create($scriptblock))
                # the variable name is normalized to uppercase : the mustache
                # values table convention ({{ KEY }} uppercased keys)
                if ($step.variable) { $mustacheValues[$($step.variable.ToUpper())] = $output.ToString() }
                $rc = $true
            }
            'file-content' {
                $filepath = Render-MustacheField -value $step.filepath -mustacheValues $mustacheValues
                $content = Render-MustacheField -value $step.content -mustacheValues $mustacheValues
                $full = Resolve-GenericPackagePath -Path $filepath -Base $DestinationPath
                $parent = Split-Path $full -Parent
                if ($parent -and -not (Test-DirExist $parent)) {
                    $null = New-Item -Path $parent -ItemType Directory -Force
                }
                $null = Set-Content -Path $full -Value $content
                $rc = Test-Path $full -PathType Leaf
            }
            'file-from-template' {
                $source = Render-MustacheField -value $step.source -mustacheValues $mustacheValues
                # a relative source is resolved against the pkgdef directory
                # (PKGDEFDIR is set by Install from -Path)
                if (-not [System.IO.Path]::IsPathRooted($source)) {
                    if (-not $mustacheValues['PKGDEFDIR']) {
                        Write-Fatal "file-from-template : relative 'source' requires PKGDEFDIR : run Install-GenericPackage with -Path, or provide PKGDEFDIR via -mustacheValues"
                    }
                    $source = "$($mustacheValues['PKGDEFDIR'])/$source"
                }
                if (-not (Test-FileExist $source)) {
                    Write-Fatal "file-from-template : template not found : $source"
                }
                $content = Render-MustacheField -value (Get-Content -Path $source -Raw) -mustacheValues $mustacheValues
                $destination = Render-MustacheField -value $step.destination -mustacheValues $mustacheValues
                $destFile = Resolve-GenericPackagePath -Path $destination -Base $DestinationPath
                $parent = Split-Path $destFile -Parent
                if ($parent -and -not (Test-DirExist $parent)) {
                    $null = New-Item -Path $parent -ItemType Directory -Force
                }
                $null = Set-Content -Path $destFile -Value $content
                $rc = Test-Path $destFile -PathType Leaf
            }
            'mkdir' {
                $path = Resolve-GenericPackagePath -Path (Render-MustacheField -value $step.path -mustacheValues $mustacheValues) -Base $DestinationPath
                $null = New-Item -Path $path -ItemType Directory -ErrorAction SilentlyContinue
                $rc = Test-Path $path -PathType Container
            }
            'rename' {
                $source = Render-MustacheField -value $step.source -mustacheValues $mustacheValues
                $newName = Render-MustacheField -value $step.newname -mustacheValues $mustacheValues
                $srcFile = Resolve-GenericPackagePath -Path $source -Base $DestinationPath
                $parentDir = Split-Path $srcFile -Parent
                $null = Rename-Item -Path $srcFile -NewName $newName
                $rc = Test-Path "$parentDir/$newName"
            }
            'unzip' {
                $source = Resolve-GenericPackagePath -Path (Render-MustacheField -value $step.source -mustacheValues $mustacheValues) -Base $DestinationPath
                $destination = Resolve-GenericPackagePath -Path (Render-MustacheField -value $step.destination -mustacheValues $mustacheValues) -Base $DestinationPath
                $null = New-Item -Path $destination -ItemType Directory -ErrorAction SilentlyContinue
                $null = Expand-Archive -Path $source -DestinationPath $destination
                $rc = Test-Path $destination -PathType Container
            }
            default {
                Write-Fatal "Unknown step action : '$($step.action)'"
            }
        }
        return $rc
    }

    End {
        Write-LeaveFunction
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# PHASE 5 : UPLOAD
# Publish the downloaded asset to a server
# ═══════════════════════════════════════════════════════════════════════════════

<#
.SYNOPSIS
Publish a local file to the server declared by an upload block.
 
.DESCRIPTION
Backends : dufs (WebDAV PUT), nexus (raw repository PUT), ftp (FtpClient)
and smb (Copy-Item to a UNC path with the session credentials). The
password is read from the environment variable named by password_env.
#>

function Invoke-GenericPackageUpload {
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    Param (
        # Upload block (target.upload of the resolved definition)
        [Parameter(Mandatory = $true)][hashtable]$upload,
        # Local file to upload
        [Parameter(Mandatory = $true)][string]$Source,
        # Template values (uppercase keys, mustache compatible)
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        $url = Render-MustacheField -value $upload.url -mustacheValues $mustacheValues
        $filename = $null
        if ($upload.filename) {
            $filename = Render-MustacheField -value $upload.filename -mustacheValues $mustacheValues
        }

        $username = $upload.username
        $password = ''
        if ($upload.password_env) {
            $password = [Environment]::GetEnvironmentVariable($upload.password_env)
            if ([string]::IsNullOrEmpty($password)) {
                Write-Fatal "Environment variable '$($upload.password_env)' is empty or not set"
            }
        }
        $headers = @{}
        if ($username) {
            $auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${username}:${password}"))
            $headers['Authorization'] = "Basic $auth"
        }

        switch ($upload.type) {
            'dufs' {
                $targetUrl = $url
                if ($filename) { $targetUrl = "$($url.TrimEnd('/'))/$filename" }
                Write-Verbose "Uploading $Source to $targetUrl (dufs/WebDAV)"
                $null = Invoke-WebRequest -Uri $targetUrl -Method Put -InFile $Source -Headers $headers -UseBasicParsing
                $rc = $?
            }
            'nexus' {
                if (-not $upload.repository) { Write-Fatal "upload.repository is required for type 'nexus'" }
                $leaf = $filename ?? [IO.Path]::GetFileName($Source)
                $targetUrl = "$($url.TrimEnd('/'))/service/rest/v1/raw/$($upload.repository)/$leaf"
                Write-Verbose "Uploading $Source to $targetUrl (nexus)"
                $null = Invoke-WebRequest -Uri $targetUrl -Method Put -InFile $Source -Headers $headers -UseBasicParsing
                $rc = $?
            }
            'smb' {
                $leaf = $filename ?? [IO.Path]::GetFileName($Source)
                $dest = "$url/$leaf"
                Write-Verbose "Copying $Source to $dest (smb)"
                # Note : SMB authentication relies on the current session credentials
                $null = Copy-Item -Path $Source -Destination $dest -Force
                $rc = $?
            }
            'ftp' {
                $uri = [uri]$url
                $leaf = $filename ?? [IO.Path]::GetFileName($Source)
                $remotePath = "$($uri.AbsolutePath.TrimEnd('/'))/$leaf"
                Write-Verbose "Uploading $Source to ${uri.Host}$remotePath (ftp)"
                $client = New-Object System.Net.FtpClient
                $client.Host = $uri.Host
                if ($uri.Port) { $client.Port = $uri.Port }
                if ($uri.Scheme -eq 'ftps') { $client.EnableSsl = $true }
                if ($username) {
                    $client.Credentials = New-Object System.Net.NetworkCredential($username, $password)
                }
                try {
                    $null = $client.UploadFile($Source, $remotePath)
                    $rc = $?
                } finally {
                    $client.Dispose()
                }
            }
            default {
                Write-Fatal "Unknown upload type : '$($upload.type)'"
                $rc = $false
            }
        }
        return $rc
    }

    End {
        Write-LeaveFunction
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# WRAPPER : Complete workflow
# resolve → check → download → verification → install → post → upload → check
# ═══════════════════════════════════════════════════════════════════════════════

<#
.SYNOPSIS
Install a package from a pkgdef v2 definition.
 
.DESCRIPTION
Main entry point orchestrating the full pipeline for one target :
  1. Resolve : resolve the definition for the selected target ('latest' is
                 resolved to the provider's current version via -ResolveLatest,
                 so {{ VERSION }} is concrete from the pre-check on)
  2. Check : pre-install check — when the expression is true the package
                 is considered already installed and the pipeline is skipped
                 (Status=Success, Skipped=$true) ; -Force continues anyway
  3. Download : download the asset (+ digest / signature verification)
  4. Install : execute the install steps
  5. Post : execute the post-install steps
  6. Upload : publish the asset when the target declares an upload block
  7. Check : post-install check — must be true, otherwise the target
                 fails (Phase='Check') ; -NoCheck skips both check phases
 
Returns a result hashtable : Status (Success/Failed), Phase, Error, Skipped,
Name, Codename, Version, Target, Asset.
 
.EXAMPLE
$result = Install-GenericPackage -Path ./examples/aria2c.yml -TargetId windows-x64 -DestinationPath 'C:\'
 
.EXAMPLE
$result = Install-GenericPackage -Path ./examples/libreoffice.yml -Os linux -Distrib debian -Arch x64 -DestinationPath /mnt/chroot
#>

function Install-GenericPackage {
    [CmdletBinding()]
    [OutputType([hashtable])]
    Param (
        # Path to the pkgdef file
        [Parameter(Mandatory = $false)][string]$Path,
        # Package definition (hashtable)
        [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Definition,
        # Explicit target id
        [Parameter(Mandatory = $false)][string]$TargetId,
        # Target OS (alternative to -TargetId)
        [Parameter(Mandatory = $false)][ValidateSet('windows', 'linux', 'macos')][string]$Os,
        # Target distribution (linux)
        [Parameter(Mandatory = $false)][string]$Distrib = '',
        # Target architecture
        [Parameter(Mandatory = $false)][ValidateSet('x86', 'x64', 'arm32', 'arm64', 'all')][string]$Arch = '',
        # Destination path to install (chroot / mount / install root)
        [Parameter(Mandatory = $false)][string]$DestinationPath,
        # Path to the cache directory to download assets to
        [Parameter(Mandatory = $false)][string]$CachePath,
        # Template values (uppercase keys, mustache compatible)
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{},
        # Continue the pipeline even when the pre-check reports the package as already installed
        [Parameter(Mandatory = $false)][switch]$Force,
        # Skip both check phases (pre and post)
        [Parameter(Mandatory = $false)][switch]$NoCheck
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (-not $Path -and -not $Definition) { Write-Fatal "Either -Path or -Definition must be provided" }
        if ($Path -and $Definition) { Write-Fatal "Provide either -Path or -Definition, not both" }

        # ─── Load raw definition ──────────────────────────────────────────────
        if ($Path) {
            if (-not (Test-FileExist $Path)) { Write-Fatal "File not found : $Path" }
            $raw = Get-Content -Path $Path -Raw | ConvertFrom-Yaml | ConvertTo-PlainHashtable
        } else {
            $raw = ConvertTo-PlainHashtable -InputObject $Definition
        }

        $result = @{
            Status   = ''
            Phase    = ''
            Error    = ''
            Skipped  = $false
            Name     = $raw.name
            Codename = $raw.codename ?? (($raw.name.ToLower() -replace '\s+', '-'))
            Version  = ''
            Target   = $TargetId ?? "$Os/$Distrib/$Arch"
            Asset    = ''
        }

        try {
            # ─── Phase 1 : Resolve ────────────────────────────────────────────
            Write-Verbose "Phase 1 : resolving definition"
            # target selection splat : -Os/-Distrib/-Arch are only valid when
            # -TargetId is not used (empty values would fail ValidateSet)
            $targetParams = @{}
            if ($TargetId) {
                $targetParams.TargetId = $TargetId
            } else {
                $targetParams.Os = $Os
                $targetParams.Distrib = $Distrib
                $targetParams.Arch = $Arch
            }
            try {
                # -ResolveLatest : an install is online by nature, and a
                # 'latest' version must be concrete for the pre-check (and
                # the result) to make sense
                $resolved = Resolve-GenericPackageDefinition -Definition $raw -ResolveLatest @targetParams
            } catch {
                $resolved = $null
                Write-Warning $_.Exception.Message
            }
            if (-not $resolved) {
                $result.Status = 'Failed'; $result.Phase = 'Resolve'; $result.Error = 'definition could not be resolved'
                return $result
            }
            $result.Target = $resolved.target.id
            $result.Version = $resolved.version

            # Seed mustache values from the resolved definition
            # (caller-provided values win over the derived ones)
            $mv = Get-GenericPackageMustacheValues -pkgdef $resolved -overrides $mustacheValues
            $mv.DEST = $DestinationPath
            # directory of the pkgdef file : relative file-from-template
            # sources resolve against it ; an explicit caller value wins
            if ($Path -and -not $mustacheValues['PKGDEFDIR']) { $mv.PKGDEFDIR = Split-Path $Path -Parent }
            Write-Devel -InputObject $mv

            # Resolve CachePath: parameter > pkgdef.download.destination > temp path
            if ([string]::IsNullOrEmpty($CachePath)) {
                if ($resolved.download.destination) {
                    $CachePath = $resolved.download.destination
                } else {
                    $CachePath = [io.path]::GetTempPath()
                }
            }

            # ─── Phase 2 : Check (pre) ────────────────────────────────────────
            if (-not $NoCheck -and $resolved.check) {
                Write-Verbose "Phase 2 : evaluating pre-install check"
                if (Test-GenericPackageCheck -Expression $resolved.check -DestinationPath $DestinationPath -mustacheValues $mv) {
                    if ($Force) {
                        Write-Verbose "Pre-check passed but -Force given : continuing the pipeline"
                    } else {
                        Write-Verbose "Pre-check passed : package already installed, skipping the pipeline"
                        $result.Status = 'Success'; $result.Phase = 'Check'; $result.Skipped = $true
                        return $result
                    }
                }
            }

            # ─── Phase 3 : Download (+ verification) ──────────────────────────
            Write-Verbose "Phase 3 : downloading asset"
            try {
                $asset = Download-GenericPackageAsset -pkgdef $resolved -CachePath $CachePath -mustacheValues $mv
            } catch {
                $asset = $null
                Write-Warning $_.Exception.Message
            }
            if (-not $asset) {
                $result.Status = 'Failed'; $result.Phase = 'Download'; $result.Error = 'asset download failed'
                return $result
            }
            $result.Asset = $asset
            $mv.ASSET = $asset
            # a provider may have refined the version during download
            # (webscrapper ?<ver> capture) : reflect it in the result
            $result.Version = $mv.VERSION
            Write-Verbose "Downloaded asset : $asset"

            # ─── Phase 4 : Install ────────────────────────────────────────────
            Write-Verbose "Phase 4 : executing install steps"
            $rc = Execute-GenericPackageSteps -Phase 'install' -Steps @($resolved.install) -DestinationPath $DestinationPath -mustacheValues $mv
            if (-not $rc) {
                $result.Status = 'Failed'; $result.Phase = 'Install'; $result.Error = 'install step execution failed'
                return $result
            }

            # ─── Phase 5 : Post ───────────────────────────────────────────────
            Write-Verbose "Phase 5 : executing post-install steps"
            $rc = Execute-GenericPackageSteps -Phase 'post' -Steps @($resolved.post) -DestinationPath $DestinationPath -mustacheValues $mv
            if (-not $rc) {
                $result.Status = 'Failed'; $result.Phase = 'Post'; $result.Error = 'post-install step execution failed'
                return $result
            }

            # ─── Phase 6 : Upload ─────────────────────────────────────────────
            if ($resolved.upload) {
                Write-Verbose "Phase 6 : uploading asset"
                $rc = Invoke-GenericPackageUpload -upload $resolved.upload -Source $asset -mustacheValues $mv
                if (-not $rc) {
                    $result.Status = 'Failed'; $result.Phase = 'Upload'; $result.Error = 'asset upload failed'
                    return $result
                }
            }

            # ─── Phase 7 : Check (post) ───────────────────────────────────────
            if (-not $NoCheck -and $resolved.check) {
                Write-Verbose "Phase 7 : evaluating post-install check"
                if (-not (Test-GenericPackageCheck -Expression $resolved.check -DestinationPath $DestinationPath -mustacheValues $mv)) {
                    $result.Status = 'Failed'; $result.Phase = 'Check'; $result.Error = 'post-install check failed'
                    return $result
                }
            }

            $result.Status = 'Success'
            return $result
        } catch {
            Write-Warning "Unexpected error: $_"
            $result.Status = 'Failed'
            $result.Phase = 'Unknown'
            $result.Error = $_.Exception.Message
            return $result
        }
    }

    End {
        Write-LeaveFunction
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# PRIVATE HELPERS
# ═══════════════════════════════════════════════════════════════════════════════

<#
.SYNOPSIS
Recursively convert a definition into plain hashtables / object arrays.
 
.DESCRIPTION
powershell-yaml (and PwSh.Fw.Core ConvertTo-Hashtable) produce PSCustomObject
and OrderedDictionary instances. Plain hashtables are safer downstream :
they bind to [hashtable] parameters and behave identically everywhere.
#>

function ConvertTo-PlainHashtable {
    [CmdletBinding()]
    [OutputType([object])]
    Param (
        [AllowNull()][Parameter(Mandatory = $true, ValueFromPipeline = $true)][object]$InputObject
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if ($null -eq $InputObject) { return $null }
        if ($InputObject -is [System.Collections.IDictionary]) {
            $h = @{}
            foreach ($k in $InputObject.Keys) { $h[$k] = ConvertTo-PlainHashtable -InputObject $InputObject[$k] }
            return $h
        }
        if ($InputObject -is [System.Management.Automation.PSCustomObject]) {
            $h = @{}
            foreach ($prop in $InputObject.PSObject.Properties) { $h[$prop.Name] = ConvertTo-PlainHashtable -InputObject $prop.Value }
            return $h
        }
        if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
            $arr = @()
            foreach ($item in $InputObject) { $arr += ConvertTo-PlainHashtable -InputObject $item }
            return , $arr
        }
        return $InputObject
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Key-by-key merge of two download blocks (target keys win).
#>

function Merge-GenericPackageDownload {
    [CmdletBinding()]
    [OutputType([hashtable])]
    Param (
        [Parameter(Mandatory = $false)][object]$Global,
        [Parameter(Mandatory = $false)][object]$Override
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        # NB : -is [hashtable] would miss OrderedDictionary (what
        # ConvertTo-Hashtable returns for yaml input), hence IDictionary
        $merged = @{}
        if ($Global -is [System.Collections.IDictionary]) { foreach ($k in $Global.Keys) { $merged[$k] = $Global[$k] } }
        if ($Override -is [System.Collections.IDictionary]) { foreach ($k in $Override.Keys) { $merged[$k] = $Override[$k] } }
        return $merged
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Read the API token from the environment variable named by download.token_env.
Tokens are never stored in the definition.
#>

function Get-GenericPackageToken {
    [CmdletBinding()]
    [OutputType([string])]
    Param (
        [Parameter(Mandatory = $true)][object]$pkgdef
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        $token = ''
        if ($pkgdef.download.token_env) {
            $token = [Environment]::GetEnvironmentVariable($pkgdef.download.token_env)
            if ([string]::IsNullOrEmpty($token)) {
                Write-Warning "token_env '$($pkgdef.download.token_env)' is empty or not set"
            }
        }
        return $token
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Build the mustache values table for a resolved definition.
 
.DESCRIPTION
Seeds the standard keys (NAME, CODENAME, VERSION, OS, DISTRIB, ARCH) from
the resolved definition, then applies the caller-provided values on top :
explicit values win over the derived ones, like the key-by-key download
merge. TAGNAME is added later by the providers once the release is
resolved ; DEST and ASSET are added by Install-GenericPackage.
#>

function Get-GenericPackageMustacheValues {
    [CmdletBinding()]
    [OutputType([hashtable])]
    Param (
        [Parameter(Mandatory = $true)][object]$pkgdef,
        [Parameter(Mandatory = $false)][hashtable]$overrides = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        $mv = @{}
        if ($pkgdef.name) { $mv.NAME = $pkgdef.name }
        if ($pkgdef.codename) { $mv.CODENAME = $pkgdef.codename }
        if ($pkgdef.version) { $mv.VERSION = $pkgdef.version }
        if ($pkgdef.target) {
            if ($pkgdef.target.id) { $mv.TARGETID = $pkgdef.target.id }
            if ($pkgdef.target.os) { $mv.OS = $pkgdef.target.os }
            if ($pkgdef.target.distrib) { $mv.DISTRIB = $pkgdef.target.distrib }
            if ($pkgdef.target.arch) { $mv.ARCH = $pkgdef.target.arch }
        }
        foreach ($k in $overrides.Keys) { $mv[$k] = $overrides[$k] }
        return $mv
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Render a mustache template field when it contains placeholders.
#>

function Render-MustacheField {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", "", Justification="Render is the intended verb for this function.")]
    [CmdletBinding()]
    [OutputType([string])]
    Param (
        [AllowNull()][AllowEmptyString()][string]$value,
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if ([string]::IsNullOrEmpty($value)) { return $value }
        if (Test-MustacheTemplate $value) {
            return ConvertFrom-MustacheTemplate -Template $value -values $mustacheValues
        }
        return $value
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Select the first asset matching the ordered pattern list.
 
.DESCRIPTION
Shared pattern-matching loop for the providers. -Names are the candidate
names (release asset names or page link URLs), -Assets the corresponding
objects, -AssetSpecs the ordered list of download.assets entries
({ pattern ; signature-file }). Returns @{ Index ; Spec } or $null.
#>

function Select-GenericPackageAsset {
    [CmdletBinding()]
    [OutputType([hashtable])]
    Param (
        [Parameter(Mandatory = $true)][string[]]$Names,
        [Parameter(Mandatory = $true)][object[]]$Assets,
        # may be absent : a definition without assets[] cannot match anything
        [Parameter(Mandatory = $false)][object[]]$AssetSpecs,
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{},
        # when the matched pattern defines a named group 'ver', the candidate
        # holding the highest captured version wins instead of the first match
        [switch]$PickHighestVersion
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        # filter null entries : -match against a null pattern would match anything
        $specs = @($AssetSpecs | Where-Object { $null -ne $_ })
        if (-not $specs.Count) { return $null }
        foreach ($spec in $specs) {
            $pattern = $spec.pattern
            if (Test-MustacheTemplate $pattern) {
                $pattern = ConvertFrom-MustacheTemplate -Template $pattern -values $mustacheValues
            }
            Write-Devel "Looking for asset matching pattern '$pattern'"
            $candidates = @()
            for ($i = 0; $i -lt $Names.Count; $i++) {
                if ($Names[$i] -match $pattern) {
                    if (-not $PickHighestVersion) {
                        return @{ Index = $i; Spec = $spec; CapturedVersion = $null }
                    }
                    $candidate = @{ Index = $i; Captured = $Matches['ver'] }
                    try { $candidate.V = [version]$Matches['ver'] } catch { $candidate.V = $null }
                    $candidates += $candidate
                }
            }
            if ($candidates.Count) {
                # highest parseable captured version wins, else first match
                $withVersion = @($candidates | Where-Object { $null -ne $_.V } | Sort-Object -Property V -Descending)
                if ($withVersion.Count) {
                    return @{ Index = $withVersion[0].Index; Spec = $spec; CapturedVersion = $withVersion[0].Captured }
                }
                return @{ Index = $candidates[0].Index; Spec = $spec; CapturedVersion = $null }
            }
        }
        return $null
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Verify a downloaded asset against a signature (checksum) file.
 
.DESCRIPTION
The signature file must contain the asset's hex hash : the first line
starting with a hex string is used (bare '<hash>', or sha256sum style
'<hash> <filename>'). Binary formats (e.g. GPG detached signatures) are
not supported.
#>

function Test-GenericPackageSignature {
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    Param (
        [Parameter(Mandatory = $true)][string]$AssetPath,
        [Parameter(Mandatory = $true)][string]$SignaturePath,
        [Parameter(Mandatory = $true)][ValidateSet('md5', 'sha1', 'sha256', 'sha512')][string]$Algorithm
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (-not (Test-FileExist $SignaturePath)) {
            Write-Error "Signature file not found : $SignaturePath"
            return $false
        }
        $expected = $null
        foreach ($line in (Get-Content $SignaturePath)) {
            if ($line -match '^\s*(?<hash>[0-9a-fA-F]{8,})\b') {
                $expected = $Matches.hash.ToUpper()
                break
            }
        }
        if (-not $expected) {
            Write-Error "Could not extract a hash from signature file : $SignaturePath"
            return $false
        }
        $actual = (Get-FileHash -Algorithm $Algorithm.ToUpper() -Path $AssetPath).Hash.ToUpper()
        if ($actual -eq $expected) {
            Write-Success "Signature ($Algorithm) verified : $([IO.Path]::GetFileName($AssetPath))"
            return $true
        }
        Write-Error "Signature mismatch ($Algorithm) for $([IO.Path]::GetFileName($AssetPath)) : expected $expected, got $actual"
        return $false
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Test whether a string contains mustache placeholders.
#>

function Test-MustacheTemplate {
    [CmdletBinding()]
    [OutputType([boolean])]
    Param (
        [AllowEmptyString()][AllowNull()]
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][string]$string
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        return ($string -match '{{ [a-zA-Z0-9_.-]+ }}')
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Evaluate a pkgdef check expression against a destination path.
 
.DESCRIPTION
Recursive boolean evaluator for the 'check' block of a resolved definition :
  - and : true when every child expression is true
  - or : true when at least one child expression is true
  - not : true when the child expression is false
  - file-exists : leaf, tests a path relative to -DestinationPath (mustache
                  rendered ; an absolute path is used as-is) ; type file (default) | dir | any
 
Structural problems (unknown node, wrong arity, missing path) are reported
as warnings and evaluate to $false — fail-closed : a broken expression never
reports "installed".
#>

function Test-GenericPackageCheck {
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    Param (
        # Check expression node (hashtable from a resolved definition)
        [Parameter(Mandatory = $true)][object]$Expression,
        # Destination path the relative paths are resolved against
        [Parameter(Mandatory = $false)][string]$DestinationPath = '',
        # Template values (uppercase keys, mustache compatible)
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        # normalize : plain hashtables everywhere (the engine's own conversion)
        $Expression = ConvertTo-PlainHashtable -InputObject $Expression
        if (-not $Expression -or $Expression -isnot [System.Collections.IDictionary]) {
            Write-Warning "Invalid check node : expected a mapping, got '$Expression'"
            return $false
        }
        if ($Expression.Count -ne 1) {
            Write-Warning "Invalid check node : expected exactly one key"
            return $false
        }
        $key = @($Expression.Keys)[0]
        switch ($key) {
            'and' {
                $children = @(@($Expression.and) | Where-Object { $null -ne $_ })
                if ($children.Count -lt 2) {
                    Write-Warning "Invalid check node 'and' : at least 2 children required"
                    return $false
                }
                foreach ($child in $children) {
                    if (-not (Test-GenericPackageCheck -Expression $child -DestinationPath $DestinationPath -mustacheValues $mustacheValues)) {
                        return $false
                    }
                }
                return $true
            }
            'or' {
                $children = @(@($Expression.or) | Where-Object { $null -ne $_ })
                if ($children.Count -lt 2) {
                    Write-Warning "Invalid check node 'or' : at least 2 children required"
                    return $false
                }
                foreach ($child in $children) {
                    if (Test-GenericPackageCheck -Expression $child -DestinationPath $DestinationPath -mustacheValues $mustacheValues) {
                        return $true
                    }
                }
                return $false
            }
            'not' {
                if (-not $Expression.not) {
                    Write-Warning "Invalid check node 'not' : a child expression is required"
                    return $false
                }
                return -not (Test-GenericPackageCheck -Expression $Expression.not -DestinationPath $DestinationPath -mustacheValues $mustacheValues)
            }
            'file-exists' {
                $leaf = $Expression.'file-exists'
                if (-not $leaf -or -not $leaf.path) {
                    Write-Warning "Invalid check node 'file-exists' : 'path' is required"
                    return $false
                }
                $path = Render-MustacheField -value $leaf.path -mustacheValues $mustacheValues
                $full = Resolve-GenericPackagePath -Path $path -Base $DestinationPath
                $type = $leaf.type ?? 'file'
                $exists = switch ($type) {
                    'file' { Test-Path $full -PathType Leaf }
                    'dir'  { Test-Path $full -PathType Container }
                    default { Test-Path $full }
                }
                # expected (config literal + mustache-resolved full path) vs found
                Write-Debug "check file-exists : expected '$($leaf.path)' → resolved '$full' (type '$type') ; found : $exists"
                return $exists
            }
            default {
                Write-Warning "Unknown check node : '$key'"
                return $false
            }
        }
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Collect structural issues of a pkgdef check expression.
 
.DESCRIPTION
Semantic validation counterpart of Test-GenericPackageCheck : walks the
expression tree and returns one message per problem (node without exactly
one key, and/or with fewer than 2 children, not without a child,
file-exists without a path or with an unknown type). Used by
Test-GenericPackageDefinition ; an empty list means the expression is
structurally sound.
#>

function Get-GenericPackageCheckIssues {
    [CmdletBinding()]
    [OutputType([string[]])]
    Param (
        # Check expression node (hashtable from a raw definition)
        [Parameter(Mandatory = $true)][object]$Expression,
        # Location prefix for the messages (e.g. '[windows-x64] and[0]. ')
        [Parameter(Mandatory = $false)][string]$Prefix = ''
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        # normalize : plain hashtables everywhere (the engine's own conversion)
        $Expression = ConvertTo-PlainHashtable -InputObject $Expression
        if (-not $Expression -or $Expression -isnot [System.Collections.IDictionary]) {
            return @("$Prefixinvalid check node : expected a mapping, got '$Expression'")
        }
        if ($Expression.Count -ne 1) {
            return @("$Prefixinvalid check node : expected exactly one key")
        }
        $key = @($Expression.Keys)[0]
        switch ($key) {
            { $_ -in @('and', 'or') } {
                $children = @(@($Expression.$key) | Where-Object { $null -ne $_ })
                if ($children.Count -lt 2) {
                    return @("$Prefix'$key' requires at least 2 children")
                }
                $issues = @()
                for ($i = 0; $i -lt $children.Count; $i++) {
                    $issues += @(Get-GenericPackageCheckIssues -Expression $children[$i] -Prefix "$Prefix$key[$i]. ")
                }
                return $issues
            }
            'not' {
                if (-not $Expression.not) {
                    return @("$Prefix'not' requires a child expression")
                }
                return @(Get-GenericPackageCheckIssues -Expression $Expression.not -Prefix "$Prefixnot. ")
            }
            'file-exists' {
                $leaf = $Expression.'file-exists'
                if (-not $leaf -or -not $leaf.path) {
                    return @("$Prefix'file-exists' requires 'path'")
                }
                if ($leaf.type -and $leaf.type -notin @('file', 'dir', 'any')) {
                    return @("$Prefix'file-exists' invalid type '$($leaf.type)' (expected file, dir or any)")
                }
                return @()
            }
            default {
                return @("$Prefixunknown check node : '$key'")
            }
        }
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Normalize a provider version string.
 
.DESCRIPTION
Strips a single leading 'v'/'V' prefix and trims whitespace. Everything else
is kept as-is : distro build suffixes (1.2.3-deb-1, 1.2.3~deb12u1) are part
of the real package version and must survive.
#>

function Normalize-GenericPackageVersion {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", "", Justification="Normalize is the intended verb for this function.")]
    [CmdletBinding()]
    [OutputType([string])]
    Param (
        [AllowNull()][AllowEmptyString()][Parameter(Mandatory = $true)][string]$Version
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if ([string]::IsNullOrWhiteSpace($Version)) { return '' }
        return ($Version.Trim() -replace '^[vV]', '')
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Resolve the 'latest' version of a product from its download provider.
 
.DESCRIPTION
Provider implementations :
  - github : latest release tag (API call)
  - gitlab : latest release tag (API call)
  - webscrapper : highest (?<ver>) capture among the page links matching
                      the asset patterns (page scrape, no asset download)
  - url : local best-effort, last version-like token in the URL
                      path (1.2.3, 1.2.3-deb-1) ; $null when the URL carries
                      the literal 'latest'
  - gitlab-artifact : local, the artifact ref when it looks like a version ;
                      $null otherwise
 
Returns the normalized version string, or $null only for the local fallback
cases above. Providers backed by an API (github / gitlab / webscrapper)
Write-Fatal when they cannot produce a version : without network there would
be nothing to download anyway.
#>

function Get-GenericPackageLatestVersion {
    [CmdletBinding()]
    [OutputType([string])]
    Param (
        # Merged download block of the resolved definition
        [Parameter(Mandatory = $true)][hashtable]$download,
        # Template values used to render the url (VERSION is still 'latest' here)
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        switch ($download.provider) {
            'github' {
                try {
                    $release = Get-GithubRelease -Owner $download.owner -RepositoryName $download.repo -Latest
                } catch {
                    Write-Fatal "Cannot resolve 'latest' from github $($download.owner)/$($download.repo) : $($_.Exception.Message)"
                }
                if (-not $release -or -not $release.tag_name) {
                    Write-Fatal "Cannot resolve 'latest' : no release found on github $($download.owner)/$($download.repo)"
                }
                return (Normalize-GenericPackageVersion -Version $release.tag_name)
            }
            'gitlab' {
                # Get-ReleaseFromGitlab expects the pkgdef shape (download.* + version)
                $synthetic = @{ download = $download; version = 'latest' }
                $token = Get-GenericPackageToken -pkgdef $synthetic
                try {
                    $release = Get-ReleaseFromGitlab -pkgdef $synthetic -GitLabBaseUrl ($download.gitlab_url ?? 'https://gitlab.com') -GitLabToken $token
                } catch {
                    Write-Fatal "Cannot resolve 'latest' from gitlab $($download.owner)/$($download.repo) : $($_.Exception.Message)"
                }
                if (-not $release -or -not $release.tag_name) {
                    Write-Fatal "Cannot resolve 'latest' : no release found on gitlab $($download.owner)/$($download.repo)"
                }
                return (Normalize-GenericPackageVersion -Version $release.tag_name)
            }
            'webscrapper' {
                try {
                    $pageUrl = Render-MustacheField -value $download.url -mustacheValues $mustacheValues
                    $links = Get-LinksFromWebPage -Url $pageUrl
                } catch {
                    Write-Fatal "Cannot resolve 'latest' : cannot scrape page '$($download.url)' : $($_.Exception.Message)"
                }
                if (-not $links.Count) {
                    Write-Fatal "Cannot resolve 'latest' : no http(s) link found on page '$($download.url)'"
                }
                $match = Select-GenericPackageAsset -Names $links -Assets $links -AssetSpecs @($download.assets) -mustacheValues $mustacheValues -PickHighestVersion
                if (-not $match) {
                    Write-Fatal "Cannot resolve 'latest' : no link of page '$($download.url)' matches the declared patterns"
                }
                if (-not $match.CapturedVersion) {
                    Write-Fatal "Cannot resolve 'latest' : no asset pattern defines a '(?<ver>)' capture group"
                }
                return (Normalize-GenericPackageVersion -Version $match.CapturedVersion)
            }
            'url' {
                # local derivation : last version-like token in the URL path
                # (at least two dotted components, optional distro suffix)
                try {
                    $pageUrl = Render-MustacheField -value $download.url -mustacheValues $mustacheValues
                    $pathOnly = ([uri]$pageUrl).AbsolutePath
                    $versionTokens = [regex]::Matches($pathOnly, '\d+\.\d+(?:\.\d+)*(?:[-~][0-9A-Za-z]+(?:[-][0-9A-Za-z]+)*)?')
                    if ($versionTokens.Count) {
                        return (Normalize-GenericPackageVersion -Version $versionTokens[$versionTokens.Count - 1].Value)
                    }
                } catch {
                    Write-Verbose "Cannot extract a version from URL '$($download.url)' : $($_.Exception.Message)"
                }
                return $null
            }
            'gitlab-artifact' {
                # local derivation : the ref is the version when it looks like one
                $ref = Normalize-GenericPackageVersion -Version "$($download.artifact.ref)"
                if ($ref -match '^\d+(\.\d+)') {
                    return $ref
                }
                return $null
            }
            default {
                return $null
            }
        }
    }

    End {
        Write-LeaveFunction
    }
}

# ─── Load provider implementations ────────────────────────────────────────────
# NB : providers are .ps1 files, NOT .psm1 : dot-sourcing a .psm1 file makes
# PowerShell import it as a separate module instead of executing it in module
# scope, so the provider functions would not be visible to this module.
foreach ($include in (Get-ChildItem -Path "$PSScriptRoot/Includes" -Filter '*.ps1' -ErrorAction SilentlyContinue)) {
    . $include.FullName
}

# ─── Export public API ────────────────────────────────────────────────────────
Export-ModuleMember -Function @(
    'Resolve-GenericPackageDefinition',
    'Expand-GenericPackageTargets',
    'Test-GenericPackageDefinition',
    'Find-GenericPackageDefinition',
    'Download-GenericPackageAsset',
    'Get-GenericPackageAssetInfo',
    'Install-GenericPackage'
)