Modules/businessdev.ALbuild.Feeds/Public/Resolve-BcExternalDependency.ps1

function Resolve-BcExternalDependency {
    <#
    .SYNOPSIS
        Stages dependency apps that are not resolved from a NuGet feed (Azure DevOps Universal feed
        packages and committed local .app files) into a folder, and returns their identities.
 
    .DESCRIPTION
        Complements the NuGet dependency resolver for sources it cannot query by app id:
          * UniversalPackages - named packages downloaded from an Azure DevOps Universal feed via
            Get-BcUniversalPackage (e.g. your own apps published to a 'Products' feed).
          * LocalPackage - repository folders of committed .app files, for ISVs without any public
            feed (e.g. CKL). Relative folders are resolved against -BaseFolder.
          * UrlPackage - .app files served from a static download URL (an ISV that publishes a
            direct link rather than a feed, e.g. bms GmbH's b-jira). Downloaded to -OutputFolder.
 
        All staged .app files land in -OutputFolder; the returned identities (read with
        Expand-BcAppFile) let the caller pin them as a satisfied baseline for NuGet resolution and
        install them into the container alongside the feed-resolved apps. Missing local folders are
        skipped with a warning; symbol-only files are kept (the caller decides installability).
 
    .PARAMETER UniversalPackages
        Universal feed entries, each an object/hashtable: { feed, name, version?, organization?,
        project? }. 'organization' falls back to -Organization; 'version' defaults to '*'.
 
    .PARAMETER LocalPackage
        Folders of committed .app files (relative paths resolved against -BaseFolder).
 
    .PARAMETER UrlPackage
        Static download URLs of .app files. Each entry is a string URL or an object
        { url, tokenEnv?, header? }: 'tokenEnv' names an environment variable whose value is sent as
        a Bearer token; 'header' is a raw 'Name: value' header. A download failure is non-fatal (a
        warning; other sources still stage).
 
    .PARAMETER OutputFolder
        Folder the packages are staged into (created if missing).
 
    .PARAMETER Organization
        Default Azure DevOps organization (URL or name) for Universal entries without their own.
 
    .PARAMETER Project
        Default project for project-scoped Universal feeds.
 
    .PARAMETER AccessToken
        PAT for the Universal download (exported as AZURE_DEVOPS_EXT_PAT by Get-BcUniversalPackage).
 
    .PARAMETER BaseFolder
        Root used to resolve relative -LocalPackage folders. Default: current location.
 
    .OUTPUTS
        PSCustomObject per staged app: Id, Name, Publisher, Version, File, Source ('local'|'url'|
        'universal') and NameBased ($true when the identity was read from the file name because the
        .app is an encrypted runtime package that cannot be read on the host; Id is then empty).
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '',
        Justification = 'Resolves the full set of external dependency packages; the plural is intentional.')]
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [object[]] $UniversalPackages = @(),
        [string[]] $LocalPackage = @(),
        [object[]] $UrlPackage = @(),
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $OutputFolder,
        [string] $Organization,
        [string] $Project,
        [string] $AccessToken,
        [string] $BaseFolder = (Get-Location).Path
    )

    if (-not (Test-Path -LiteralPath $OutputFolder)) { New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null }

    $field = {
        param($source, [string] $name)
        if ($null -eq $source) { return $null }
        $prop = $source.PSObject.Properties | Where-Object { $_.Name -ieq $name } | Select-Object -First 1
        if ($prop) { return $prop.Value }
        return $null
    }

    # Track which source staged each file, so the caller can apply source precedence (local/url beat
    # NuGet feeds; universal is feed-subordinate) and an unreadable local/url package can fall back to
    # a file-name identity. Universal packages nest arbitrarily -> tag them by a post-staging snapshot;
    # url/local land at known paths and are tagged inline.
    $norm = { param($p) try { [System.IO.Path]::GetFullPath("$p") } catch { "$p" } }
    $sourceByFile = @{}
    # Parse a '{Publisher}_{Name}_{Version}.app' file name: version is the last '_'-segment that looks
    # like a version, the first segment is the publisher, the rest is the name.
    $parseAppFileName = {
        param($fileName)
        # NB: cast the split result and the sub-range to [object[]] and use .Length - a file name with no
        # '_' (or one that does not match the convention) makes '-split' / an index range yield a scalar,
        # and '.Count' on a scalar throws under Set-StrictMode on Windows PowerShell 5.1. Without this the
        # whole function aborts on a single oddly-named local .app, dropping ALL local packages.
        $b = [System.IO.Path]::GetFileNameWithoutExtension($fileName)
        [object[]] $parts = @($b -split '_')
        $n = $parts.Length
        $ver = if ($n -ge 2 -and "$($parts[$n - 1])" -match '^\d+(\.\d+){0,3}$') { $parts[$n - 1] } else { '' }
        [object[]] $rest = if ($ver) { @($parts[0..($n - 2)]) } else { $parts }
        $rn = $rest.Length
        [PSCustomObject]@{
            Publisher = if ($rn -ge 1) { "$($rest[0])" } else { '' }
            Name      = if ($rn -ge 2) { ($rest[1..($rn - 1)] -join '_') } else { '' }
            Version   = $ver
        }
    }

    foreach ($entry in @($UniversalPackages)) {
        $feed = & $field $entry 'feed'
        $name = & $field $entry 'name'
        if (-not $feed -or -not $name) {
            Write-ALbuildLog -Level Warning "Skipping a universalPackages entry without 'feed'/'name'."
            continue
        }
        $org = & $field $entry 'organization'; if (-not $org) { $org = $Organization }
        if (-not $org) { throw "Universal package '$name' has no 'organization' and no default was provided." }
        $proj = & $field $entry 'project'; if (-not $proj) { $proj = $Project }
        $version = & $field $entry 'version'; if (-not $version) { $version = '*' }

        $packageArgs = @{ Organization = $org; Feed = $feed; Name = $name; Version = $version; OutputFolder = $OutputFolder }
        if ($proj) { $packageArgs['Project'] = $proj }
        if ($AccessToken) { $packageArgs['AccessToken'] = $AccessToken }
        # Non-fatal: if a universal package is missing or access is denied, fall through to the local
        # package(s) instead of failing the resolve. Local packages are the availability fallback.
        try {
            Get-BcUniversalPackage @packageArgs | Out-Null
        }
        catch {
            Write-ALbuildLog -Level Warning "Universal package '$name' (feed '$feed') unavailable: $($_.Exception.Message). Falling back to local/other sources."
        }
    }
    # Everything staged so far came from a universal feed (they nest in arbitrary subfolders).
    foreach ($f in (Get-ChildItem -LiteralPath $OutputFolder -Filter '*.app' -File -Recurse -ErrorAction SilentlyContinue)) {
        $sourceByFile[(& $norm $f.FullName)] = 'universal'
    }

    foreach ($entry in @($UrlPackage)) {
        $url = if ($entry -is [string]) { $entry } else { & $field $entry 'url' }
        if ([string]::IsNullOrWhiteSpace($url)) {
            Write-ALbuildLog -Level Warning "Skipping a urlPackages entry without 'url'."
            continue
        }

        # Name the download from the URL's last (URL-decoded) segment; fall back to a generated name.
        $leaf = try { [System.Uri]::UnescapeDataString(([System.Uri]$url).Segments[-1]) } catch { '' }
        if ([string]::IsNullOrWhiteSpace($leaf) -or ($leaf -notmatch '\.app$')) { $leaf = "urlpackage-$([guid]::NewGuid().ToString('N').Substring(0, 8)).app" }
        $dest = Join-Path $OutputFolder $leaf

        $headers = @{}
        $tokenEnv = if ($entry -is [string]) { $null } else { & $field $entry 'tokenEnv' }
        if ($tokenEnv) {
            $tokenValue = [System.Environment]::GetEnvironmentVariable($tokenEnv)
            if ($tokenValue) { $headers['Authorization'] = "Bearer $tokenValue" }
            else { Write-ALbuildLog -Level Warning "urlPackages tokenEnv '$tokenEnv' is not set; requesting '$url' without authorization." }
        }
        $rawHeader = if ($entry -is [string]) { $null } else { & $field $entry 'header' }
        if ($rawHeader) {
            $sep = ([string]$rawHeader).IndexOf(':')
            if ($sep -gt 0) { $headers[([string]$rawHeader).Substring(0, $sep).Trim()] = ([string]$rawHeader).Substring($sep + 1).Trim() }
        }

        # Non-fatal (like the universal source): a broken link falls through to local/other sources.
        try {
            Write-ALbuildLog "Downloading URL dependency '$leaf' from '$url'..."
            Invoke-WebRequest -Uri $url -OutFile $dest -Headers $headers -UseBasicParsing -ErrorAction Stop
        }
        catch {
            Write-ALbuildLog -Level Warning "URL dependency '$url' unavailable: $($_.Exception.Message). Falling back to local/other sources."
        }
        if (Test-Path -LiteralPath $dest) { $sourceByFile[(& $norm $dest)] = 'url' }
    }

    foreach ($folder in @($LocalPackage)) {
        if ([string]::IsNullOrWhiteSpace($folder)) { continue }
        # [IO.Path]::Combine / [IO.Directory]::Exists (not Join-Path / Test-Path) so a drive-qualified
        # path on a foreign OS - e.g. a Windows 'C:\...' folder evaluated on Linux - is combined and
        # reported missing instead of throwing a terminating DriveNotFoundException (both Join-Path and
        # Test-Path resolve the drive qualifier) under $ErrorActionPreference = 'Stop'.
        $resolved = if ([System.IO.Path]::IsPathRooted($folder)) { $folder } else { [System.IO.Path]::Combine($BaseFolder, $folder) }
        if (-not [System.IO.Directory]::Exists($resolved)) {
            Write-ALbuildLog "Local dependency folder '$resolved' not found; skipping."
            continue
        }
        $apps = @(Get-ChildItem -LiteralPath $resolved -Filter '*.app' -File -Recurse -ErrorAction SilentlyContinue)
        foreach ($app in $apps) {
            $destFile = Join-Path $OutputFolder $app.Name
            Copy-Item -LiteralPath $app.FullName -Destination $destFile -Force
            $sourceByFile[(& $norm $destFile)] = 'local'
        }
        Write-ALbuildLog "Staged $($apps.Count) local dependency package(s) from '$resolved'."
    }

    # Read every staged package, then keep only the newest version per app id - several sources may
    # serve the same app (e.g. universal + local). Files for superseded versions are removed from the
    # staging folder so only the chosen one is pinned and installed.
    $byId = @{}
    $loose = [System.Collections.Generic.List[object]]::new()   # packages pinned by file-name identity (no readable id)
    $rank = { param($s) if ("$s" -eq 'universal') { 1 } else { 0 } }   # local/url outrank universal
    # Recurse: a Universal-feed package nests its .app(s) in subfolders (e.g. app\ and test\), so a
    # top-level-only scan would miss them and stage nothing. Local packages are copied flat (above).
    foreach ($file in (Get-ChildItem -LiteralPath $OutputFolder -Filter '*.app' -File -Recurse -ErrorAction SilentlyContinue)) {
        $source = $sourceByFile[(& $norm $file.FullName)]; if (-not $source) { $source = 'universal' }
        $info = $null
        try { $info = Expand-BcAppFile -Path $file.FullName }
        catch {
            # No host-readable ZIP payload (an encrypted BC runtime app). For a local/url package, fall
            # back to the '{Publisher}_{Name}_{Version}.app' file-name identity so it can still be pinned
            # by name (never queried from a feed) and installed. A universal package without a readable
            # id has no reliable name to fall back on, so it is skipped.
            if ($source -eq 'universal') {
                Write-ALbuildLog "Skipping '$($file.Name)' for staging: no host-readable payload and no file-name identity."
                continue
            }
            $fn = & $parseAppFileName $file.Name
            Write-ALbuildLog "Package '$($file.Name)' is not host-readable (encrypted runtime app); using its file-name identity '$($fn.Publisher) / $($fn.Name) $($fn.Version)' for name-based pinning."
            $loose.Add([PSCustomObject]@{ Id = ''; Name = $fn.Name; Publisher = $fn.Publisher; Version = $fn.Version; File = $file.FullName; Source = $source; NameBased = $true })
            continue
        }
        finally { if ($info -and $info.ExtractedPath -and (Test-Path -LiteralPath $info.ExtractedPath)) { Remove-Item -LiteralPath $info.ExtractedPath -Recurse -Force -ErrorAction SilentlyContinue } }

        $entry = [PSCustomObject]@{ Id = $info.Id; Name = $info.Name; Publisher = $info.Publisher; Version = $info.Version; File = $file.FullName; Source = $source; NameBased = $false }
        if (-not $info.Id) { $loose.Add($entry); continue }

        $id = "$($info.Id)".ToLowerInvariant()
        if (-not $byId.ContainsKey($id)) { $byId[$id] = $entry; continue }

        # The same app id from several sources: prefer the higher-priority source (local/url over
        # universal); among equal sources keep the newest version.
        $kept = $byId[$id]
        $take = if ((& $rank $entry.Source) -ne (& $rank $kept.Source)) { (& $rank $entry.Source) -lt (& $rank $kept.Source) }
        else { (ConvertTo-BcVersion $entry.Version) -gt (ConvertTo-BcVersion $kept.Version) }
        if ($take) {
            Write-ALbuildLog "Using '$($entry.Name)' $($entry.Version) from $($entry.Source) (over $($kept.Version) from $($kept.Source))."
            Remove-Item -LiteralPath $kept.File -Force -ErrorAction SilentlyContinue
            $byId[$id] = $entry
        }
        else {
            if ($entry.File -ne $kept.File) { Remove-Item -LiteralPath $entry.File -Force -ErrorAction SilentlyContinue }
        }
    }

    # Flatten: a Universal package nests its .app in subfolders, but this cmdlet's contract is that every
    # staged .app lands directly in -OutputFolder (so consumers can install/copy from it without a
    # recursive scan). Move each kept file to the OutputFolder root and report its new path.
    $staged = @(@($byId.Values) + @($loose))
    foreach ($app in $staged) {
        $dest = Join-Path $OutputFolder ([System.IO.Path]::GetFileName($app.File))
        if ($app.File -ne $dest) {
            Move-Item -LiteralPath $app.File -Destination $dest -Force
            $app.File = $dest
        }
    }
    return $staged
}