WinGetLookup.psm1

#Requires -Version 5.1

<#
.SYNOPSIS
    WinGetLookup PowerShell Module - Query WinGet package availability.
 
.DESCRIPTION
    This module provides functions to query the WinGet (Windows Package Manager) repository
    to determine if packages exist. It uses the winget.run API for package lookups.
     
    Features:
    - Smart matching algorithm to find the best package match
    - Publisher and PackageId filtering for precise matching
    - 64-bit version detection
    - Session-level caching to minimize API calls
     
    SUPPORTED PLATFORMS:
    - Windows 10 version 1709 (build 16299) or later
    - Windows 11 (all versions)
    - Windows Server 2019 (build 17763) or later
    - Windows Server 2022 (all versions)
    - PowerShell 5.1 (Windows PowerShell) and PowerShell 7+ (PowerShell Core)
     
    Note: The module's API-based functions work on any platform with internet access.
    The WinGet CLI-based functions (Test-WinGet64BitAvailable) require WinGet to be
    installed, which is only available on supported Windows versions.
 
.NOTES
    Module Name: WinGetLookup
    Author: Ringo
    Version: 1.7.0
    Date: December 27, 2025
     
    This module does not require WinGet to be installed locally as it queries
    the winget.run web API directly.
     
    API calls are cached for the duration of the PowerShell session to improve
    performance when querying multiple similar applications.
#>


#region Private Variables
$Script:WinGetApiBaseUrl = 'https://api.winget.run/v2'
$Script:DefaultTimeout = 30
$Script:DefaultTakeCount = 10  # Fetch multiple results for smart matching

# Session-level cache for API responses to minimize redundant calls
# Key: URL-encoded search term, Value: API response (packages array)
$Script:PackageCache = @{}
$Script:CacheHits = 0
$Script:CacheMisses = 0

# WinGet CLI path - cached at module load for performance
$Script:WinGetCliPath = $null
$Script:WinGetCliAvailable = $false
#endregion

#region Module Initialization
# Find and cache WinGet CLI path at module load (Improvement #1)
function Initialize-WinGetCliPath {
    <#
    .SYNOPSIS
        Finds and caches the WinGet CLI path once at module load.
    #>

    
    # Check standard PATH first
    $wingetCmd = Get-Command -Name 'winget.exe' -ErrorAction SilentlyContinue
    if ($wingetCmd) {
        $Script:WinGetCliPath = $wingetCmd.Source
        $Script:WinGetCliAvailable = $true
        return
    }
    
    # Check common locations
    $possiblePaths = @(
        "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe",
        "$env:ProgramFiles\WindowsApps\Microsoft.DesktopAppInstaller_*_x64__8wekyb3d8bbwe\winget.exe"
    )
    
    foreach ($pathPattern in $possiblePaths) {
        $resolved = Get-Item -Path $pathPattern -ErrorAction SilentlyContinue | 
            Sort-Object -Property LastWriteTime -Descending | 
            Select-Object -First 1
        if ($resolved) {
            $Script:WinGetCliPath = $resolved.FullName
            $Script:WinGetCliAvailable = $true
            return
        }
    }
    
    # Try user profiles for SYSTEM context
    $userProfiles = Get-ChildItem -Path 'C:\Users' -Directory -ErrorAction SilentlyContinue
    foreach ($profile in $userProfiles) {
        $userWinget = Join-Path -Path $profile.FullName -ChildPath 'AppData\Local\Microsoft\WindowsApps\winget.exe'
        if (Test-Path -Path $userWinget) {
            $Script:WinGetCliPath = $userWinget
            $Script:WinGetCliAvailable = $true
            return
        }
    }
    
    $Script:WinGetCliAvailable = $false
}

# Initialize WinGet CLI path at module load
Initialize-WinGetCliPath
#endregion

#region Private Functions

function Invoke-WinGetCliCommand {
    <#
    .SYNOPSIS
        Executes a WinGet CLI command with timeout and proper output handling.
    .DESCRIPTION
        Reusable helper for running WinGet commands with consistent error handling.
    #>

    param(
        [Parameter(Mandatory)]
        [string[]]$Arguments,
        
        [int]$TimeoutSeconds = 30
    )
    
    if (-not $Script:WinGetCliAvailable) {
        return @{
            Success  = $false
            ExitCode = -1
            StdOut   = ''
            StdErr   = 'WinGet CLI not available'
        }
    }
    
    $process = $null
    try {
        $psi = New-Object System.Diagnostics.ProcessStartInfo
        $psi.FileName = $Script:WinGetCliPath
        $psi.Arguments = $Arguments -join ' '
        $psi.UseShellExecute = $false
        $psi.RedirectStandardOutput = $true
        $psi.RedirectStandardError = $true
        $psi.CreateNoWindow = $true
        $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
        $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
        
        $process = New-Object System.Diagnostics.Process
        $process.StartInfo = $psi
        
        $process.Start() | Out-Null
        
        # Read output asynchronously
        $stdoutTask = $process.StandardOutput.ReadToEndAsync()
        $stderrTask = $process.StandardError.ReadToEndAsync()
        
        # Wait for process with timeout
        if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
            $process.Kill()
            return @{
                Success  = $false
                ExitCode = -1
                StdOut   = ''
                StdErr   = "Command timed out after $TimeoutSeconds seconds"
            }
        }
        
        return @{
            Success  = ($process.ExitCode -eq 0)
            ExitCode = $process.ExitCode
            StdOut   = $stdoutTask.Result
            StdErr   = $stderrTask.Result
        }
    }
    catch {
        return @{
            Success  = $false
            ExitCode = -1
            StdOut   = ''
            StdErr   = $_.Exception.Message
        }
    }
    finally {
        if ($process) {
            $process.Dispose()
        }
    }
}

function Get-CachedApiResponse {
    <#
    .SYNOPSIS
        Retrieves cached API response or makes a new API call and caches the result.
     
    .DESCRIPTION
        Implements session-level caching to reduce redundant API calls.
        Cache persists for the lifetime of the PowerShell session or until
        Clear-WinGetCache is called.
    #>

    param(
        [Parameter(Mandatory)]
        [string]$SearchTerm,
        
        [int]$TimeoutSeconds = $Script:DefaultTimeout
    )
    
    # Create cache key from search term
    $cacheKey = $SearchTerm.ToLowerInvariant().Trim()
    
    # Check if we have a cached response
    if ($Script:PackageCache.ContainsKey($cacheKey)) {
        $Script:CacheHits++
        Write-Verbose "Cache HIT for '$SearchTerm' (Total hits: $($Script:CacheHits))"
        return $Script:PackageCache[$cacheKey]
    }
    
    # Cache miss - make API call
    $Script:CacheMisses++
    Write-Verbose "Cache MISS for '$SearchTerm' - calling API (Total misses: $($Script:CacheMisses))"
    
    try {
        # URL encode the search term
        $encodedName = [System.Uri]::EscapeDataString($SearchTerm)
        
        # Build the API URL
        $takeCount = $Script:DefaultTakeCount
        $apiUrl = "$Script:WinGetApiBaseUrl/packages?name=$encodedName&ensureContains=true&take=$takeCount"
        
        Write-Verbose "API URL: $apiUrl"
        
        # Configure request parameters
        $requestParams = @{
            Uri             = $apiUrl
            Method          = 'Get'
            TimeoutSec      = $TimeoutSeconds
            ErrorAction     = 'Stop'
            UseBasicParsing = $true
        }
        
        # Make the API request
        $response = Invoke-RestMethod @requestParams
        
        # Parse response
        $packages = $null
        
        if ($response -is [string]) {
            $parsed = $response | ConvertFrom-Json -AsHashtable -ErrorAction SilentlyContinue
            if ($parsed -and $parsed.Packages) {
                $packages = $parsed.Packages
            }
        } elseif ($response.Packages) {
            $packages = $response.Packages
        }
        
        # Cache the result (even if empty - avoids repeated failed lookups)
        $Script:PackageCache[$cacheKey] = $packages
        
        return $packages
    }
    catch {
        Write-Verbose "API error for '$SearchTerm': $($_.Exception.Message)"
        # Cache the failure as empty result to avoid repeated failed calls
        $Script:PackageCache[$cacheKey] = @()
        return @()
    }
}

function Get-BestPackageMatch {
    <#
    .SYNOPSIS
        Scores and selects the best matching package from multiple API results.
    #>

    param(
        [array]$Packages,
        [string]$SearchTerm,
        [string]$Publisher,
        [string]$PackageId
    )
    
    if (-not $Packages -or $Packages.Count -eq 0) {
        return $null
    }
    
    # If PackageId is specified, find exact match
    if ($PackageId) {
        $exactMatch = $Packages | Where-Object { $_.Id -eq $PackageId } | Select-Object -First 1
        return $exactMatch
    }
    
    $scoredPackages = @()
    $searchTermLower = $SearchTerm.ToLower()
    $publisherLower = if ($Publisher) { $Publisher.ToLower() } else { $null }
    
    foreach ($pkg in $Packages) {
        $score = 0
        $pkgId = $pkg.Id
        $pkgName = if ($pkg.Latest) { $pkg.Latest.Name } else { $pkg.Name }
        $pkgPublisher = if ($pkg.Latest) { $pkg.Latest.Publisher } else { $pkg.Publisher }
        
        $pkgIdLower = if ($pkgId) { $pkgId.ToLower() } else { '' }
        $pkgNameLower = if ($pkgName) { $pkgName.ToLower() } else { '' }
        $pkgPublisherLower = if ($pkgPublisher) { $pkgPublisher.ToLower() } else { '' }
        
        # +100: Package ID = SearchTerm.SearchTerm pattern (e.g., "PuTTY.PuTTY", "7zip.7zip")
        $expectedId = "$searchTermLower.$searchTermLower"
        if ($pkgIdLower -eq $expectedId) {
            $score += 100
        }
        
        # +75: Publisher matches (when -Publisher specified)
        if ($publisherLower -and $pkgPublisherLower) {
            if ($pkgPublisherLower -eq $publisherLower) {
                $score += 75
            } elseif ($pkgPublisherLower -like "*$publisherLower*") {
                $score += 40
            }
        }
        
        # +50: Exact name match (case-insensitive)
        if ($pkgNameLower -eq $searchTermLower) {
            $score += 50
        }
        
        # +25: Name starts with search term
        if ($pkgNameLower.StartsWith($searchTermLower)) {
            $score += 25
        }
        
        # +15: Package ID starts with search term
        if ($pkgIdLower.StartsWith($searchTermLower)) {
            $score += 15
        }
        
        # +10: Package ID contains search term (but not just in suffix)
        if ($pkgIdLower -like "*.$searchTermLower" -or $pkgIdLower -like "$searchTermLower.*") {
            $score += 10
        }
        
        $scoredPackages += [PSCustomObject]@{
            Package = $pkg
            Score   = $score
            Id      = $pkgId
            Name    = $pkgName
        }
    }
    
    # Return highest scoring package
    $best = $scoredPackages | Sort-Object -Property Score -Descending | Select-Object -First 1
    return $best.Package
}

function Test-64BitIndicators {
    <#
    .SYNOPSIS
        Checks if package has 64-bit indicators in its ID, name, or tags.
    #>

    param(
        [string]$PackageId,
        [string]$PackageName,
        [array]$Tags
    )
    
    # Check package ID for 64-bit indicators
    if ($PackageId -match '64[-.]?bit|x64|win64|\.64|amd64') {
        return $true
    }
    
    # Check package name for 64-bit indicators
    if ($PackageName -match '64[-\s]?bit|x64|\(64\)|win64|amd64') {
        return $true
    }
    
    # Check tags for 64-bit/x64 indicators
    if ($Tags) {
        foreach ($tag in $Tags) {
            if ($tag -match '^(64-?bit|x64|amd64|win64)$') {
                return $true
            }
        }
    }
    
    # Many modern apps default to 64-bit, check for known 64-bit package patterns
    # These are well-known packages that provide 64-bit versions
    $known64BitPatterns = @(
        '7zip\.7zip',
        'Notepad\+\+\.Notepad\+\+',
        'VideoLAN\.VLC',
        'Mozilla\.Firefox',
        'Google\.Chrome',
        'Adobe\.Acrobat\.Reader\.64-bit',
        'Microsoft\.VisualStudioCode',
        'Git\.Git',
        'Python\.Python',
        'OpenJS\.NodeJS',
        'voidtools\.Everything',
        'WinSCP\.WinSCP',
        'PuTTY\.PuTTY',
        'Bitwarden\.Bitwarden',
        'KeePassXCTeam\.KeePassXC'
    )
    
    foreach ($pattern in $known64BitPatterns) {
        if ($PackageId -match $pattern) {
            return $true
        }
    }
    
    return $false
}

function Test-64BitPackageAvailable {
    <#
    .SYNOPSIS
        Parses API response string to check for 64-bit package availability.
    #>

    param(
        [string]$ResponseString
    )
    
    # Extract package ID from response
    $packageId = $null
    $packageName = $null
    $tags = @()
    
    if ($ResponseString -match '"Id"\s*:\s*"([^"]+)"') {
        $packageId = $Matches[1]
    }
    
    if ($ResponseString -match '"Name"\s*:\s*"([^"]+)"') {
        $packageName = $Matches[1]
    }
    
    # Extract tags array
    if ($ResponseString -match '"Tags"\s*:\s*\[([^\]]*)\]') {
        $tagsString = $Matches[1]
        $tags = [regex]::Matches($tagsString, '"([^"]+)"') | ForEach-Object { $_.Groups[1].Value }
    }
    
    return Test-64BitIndicators -PackageId $packageId -PackageName $packageName -Tags $tags
}
#endregion

#region Public Functions

function Test-WinGetPackage {
    <#
    .SYNOPSIS
        Tests if an application package exists in the WinGet repository.
 
    .DESCRIPTION
        Queries the winget.run API to determine if a package with the specified
        display name exists in the Windows Package Manager (WinGet) repository.
         
        Uses smart matching to find the best package match when multiple results
        are returned. Supports filtering by publisher for precise matching.
         
        This function is useful for:
        - Validating if an application can be installed via WinGet
        - Checking package availability before automation scripts
        - Inventory assessment for application deployment planning
 
    .PARAMETER DisplayName
        The display name of the application to search for.
        This should be the common name of the application (e.g., "Notepad++", "7-Zip", "VLC").
        The search is case-insensitive and uses partial matching.
 
    .PARAMETER Publisher
        Optional publisher name to filter results for more precise matching.
        Useful when multiple packages share similar names.
        Example: -Publisher "Simon Tatham" to specifically match PuTTY.
 
    .PARAMETER PackageId
        Optional exact WinGet package ID for direct matching.
        When specified, DisplayName is still used for the search but only the
        package with this exact ID will be considered a match.
        Example: -PackageId "PuTTY.PuTTY"
 
    .PARAMETER Require64Bit
        When specified, returns 1 only if a 64-bit version of the package is available.
        The function checks if the package ID or name contains '64', 'x64', or 'win64' indicators,
        or if the package has x64 architecture tags.
 
    .PARAMETER TimeoutSeconds
        The timeout in seconds for the API request.
        Default is 30 seconds.
 
    .OUTPUTS
        System.Int32
        Returns 1 if the package is found in the WinGet repository (and meets all requirements).
        Returns 0 if the package is not found, doesn't match criteria, or an error occurs.
 
    .EXAMPLE
        Test-WinGetPackage -DisplayName "Visual Studio Code"
         
        Returns 1 if Visual Studio Code is available in WinGet, 0 otherwise.
 
    .EXAMPLE
        Test-WinGetPackage "Notepad++"
         
        Uses positional parameter to check if Notepad++ is available.
        Returns 1 if found.
 
    .EXAMPLE
        Test-WinGetPackage -DisplayName "PuTTY" -Publisher "Simon Tatham"
         
        Returns 1 only if PuTTY by Simon Tatham is found (not MTPuTTY or other variants).
 
    .EXAMPLE
        Test-WinGetPackage -DisplayName "PuTTY" -PackageId "PuTTY.PuTTY"
         
        Returns 1 only if the exact package ID "PuTTY.PuTTY" is found.
 
    .EXAMPLE
        $exists = Test-WinGetPackage "7-Zip"
        if ($exists -eq 1) {
            Write-Host "7-Zip is available for installation via WinGet"
        }
         
        Demonstrates using the return value in conditional logic.
 
    .EXAMPLE
        @("Notepad++", "7-Zip", "VLC", "FakeApp123") | ForEach-Object {
            [PSCustomObject]@{
                Application = $_
                Available = Test-WinGetPackage $_
            }
        }
         
        Checks multiple applications and creates a report of availability.
 
    .EXAMPLE
        Test-WinGetPackage -DisplayName "Adobe Acrobat Reader" -Require64Bit
         
        Returns 1 only if a 64-bit version of Adobe Acrobat Reader is available in WinGet.
 
    .EXAMPLE
        $has64Bit = Test-WinGetPackage "7-Zip" -Require64Bit
        if ($has64Bit -eq 1) {
            Write-Host "64-bit version is available"
        }
         
        Checks specifically for 64-bit availability.
 
    .NOTES
        Author: Ringo
        Version: 1.3.0
         
        This function uses the winget.run API which is a third-party service.
        It does not require WinGet to be installed locally.
         
        Caching: API responses are cached for the session to minimize redundant
        calls. Use Clear-WinGetCache to reset the cache if needed.
         
        Smart Matching: When multiple packages match, the function scores results
        based on name similarity, publisher match, and package ID patterns to
        return the most relevant match.
         
        API Rate Limiting: The winget.run API may have rate limits. For bulk
        operations, consider adding delays between requests.
 
    .LINK
        https://winget.run
 
    .LINK
        https://docs.microsoft.com/en-us/windows/package-manager/winget/
    #>


    [CmdletBinding()]
    [OutputType([System.Int32])]
    param(
        [Parameter(
            Mandatory = $true,
            Position = 0,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = "The display name of the application to search for."
        )]
        [ValidateNotNullOrEmpty()]
        [Alias('Name', 'ApplicationName', 'AppName')]
        [string]$DisplayName,

        [Parameter(
            Mandatory = $false,
            HelpMessage = "Publisher name to filter results for precise matching."
        )]
        [Alias('Vendor', 'Author')]
        [string]$Publisher,

        [Parameter(
            Mandatory = $false,
            HelpMessage = "Exact WinGet package ID for direct matching."
        )]
        [Alias('Id', 'WinGetId')]
        [string]$PackageId,

        [Parameter(
            Mandatory = $false,
            HelpMessage = "Only return 1 if a 64-bit version is available."
        )]
        [switch]$Require64Bit,

        [Parameter(
            Mandatory = $false,
            HelpMessage = "Timeout in seconds for the API request."
        )]
        [ValidateRange(5, 300)]
        [int]$TimeoutSeconds = $Script:DefaultTimeout
    )

    begin {
        Write-Verbose "Starting WinGet package lookup..."
        if ($Require64Bit) {
            Write-Verbose "64-bit version requirement enabled"
        }
        if ($Publisher) {
            Write-Verbose "Publisher filter: $Publisher"
        }
        if ($PackageId) {
            Write-Verbose "Package ID filter: $PackageId"
        }
    }

    process {
        Write-Verbose "Searching for package: $DisplayName"
        
        try {
            # Use cached API response or make new call
            $packages = Get-CachedApiResponse -SearchTerm $DisplayName -TimeoutSeconds $TimeoutSeconds
            
            if (-not $packages -or $packages.Count -eq 0) {
                Write-Verbose "Package not found: $DisplayName"
                return 0
            }
            
            # Use smart matching to find best package
            $bestMatch = Get-BestPackageMatch -Packages $packages -SearchTerm $DisplayName -Publisher $Publisher -PackageId $PackageId
            
            if (-not $bestMatch) {
                Write-Verbose "No matching package found for: $DisplayName"
                return 0
            }
            
            $matchedId = $bestMatch.Id
            $matchedName = if ($bestMatch.Latest) { $bestMatch.Latest.Name } else { $bestMatch.Name }
            Write-Verbose "Best match: $matchedName ($matchedId)"
            
            # If 64-bit is required, check for 64-bit indicators
            if ($Require64Bit) {
                $pkgTags = if ($bestMatch.Latest -and $bestMatch.Latest.Tags) { $bestMatch.Latest.Tags } else { @() }
                $has64Bit = Test-64BitIndicators -PackageId $matchedId -PackageName $matchedName -Tags $pkgTags
                
                if ($has64Bit) {
                    Write-Verbose "64-bit version confirmed for: $matchedName"
                    return 1
                } else {
                    Write-Verbose "No 64-bit version found for: $matchedName"
                    return 0
                }
            }
            
            return 1
        }
        catch [System.Net.WebException] {
            Write-Verbose "Network error while searching for $DisplayName : $($_.Exception.Message)"
            Write-Warning "Network error occurred while querying WinGet API for '$DisplayName'"
            return 0
        }
        catch {
            Write-Verbose "Error searching for $DisplayName : $($_.Exception.Message)"
            Write-Warning "Error occurred while querying WinGet API: $($_.Exception.Message)"
            return 0
        }
    }

    end {
        Write-Verbose "WinGet package lookup completed."
    }
}

function Get-WinGetPackageInfo {
    <#
    .SYNOPSIS
        Retrieves detailed information about a WinGet package.
 
    .DESCRIPTION
        Queries the winget.run API to retrieve detailed information about a package
        including its WinGet ID, publisher, description, available versions, and more.
         
        Uses smart matching to find the best package match when multiple results
        are returned. Supports filtering by publisher for precise matching.
 
    .PARAMETER DisplayName
        The display name of the application to search for.
 
    .PARAMETER Publisher
        Optional publisher name to filter results for more precise matching.
        Useful when multiple packages share similar names.
 
    .PARAMETER PackageId
        Optional exact WinGet package ID for direct matching.
        When specified, only the package with this exact ID will be returned.
 
    .PARAMETER TimeoutSeconds
        The timeout in seconds for the API request.
        Default is 30 seconds.
 
    .OUTPUTS
        PSCustomObject
        Returns an object with package details if found, or $null if not found.
 
    .EXAMPLE
        Get-WinGetPackageInfo -DisplayName "Notepad++"
         
        Returns detailed information about the Notepad++ package.
 
    .EXAMPLE
        Get-WinGetPackageInfo "7-Zip" | Select-Object Id, Name, Publisher
         
        Gets 7-Zip info and displays specific properties.
 
    .EXAMPLE
        Get-WinGetPackageInfo -DisplayName "PuTTY" -Publisher "Simon Tatham"
         
        Returns info for PuTTY by Simon Tatham specifically.
 
    .EXAMPLE
        Get-WinGetPackageInfo -DisplayName "PuTTY" -PackageId "PuTTY.PuTTY"
         
        Returns info for the exact package ID "PuTTY.PuTTY".
 
    .NOTES
        Author: Ringo
        Version: 1.3.0
 
    .LINK
        https://winget.run
    #>


    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(
            Mandatory = $true,
            Position = 0,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = "The display name of the application to search for."
        )]
        [ValidateNotNullOrEmpty()]
        [Alias('Name', 'ApplicationName', 'AppName')]
        [string]$DisplayName,

        [Parameter(
            Mandatory = $false,
            HelpMessage = "Publisher name to filter results for precise matching."
        )]
        [Alias('Vendor', 'Author')]
        [string]$Publisher,

        [Parameter(
            Mandatory = $false,
            HelpMessage = "Exact WinGet package ID for direct matching."
        )]
        [Alias('Id', 'WinGetId')]
        [string]$PackageId,

        [Parameter(
            Mandatory = $false,
            HelpMessage = "Timeout in seconds for the API request."
        )]
        [ValidateRange(5, 300)]
        [int]$TimeoutSeconds = $Script:DefaultTimeout
    )

    begin {
        Write-Verbose "Starting WinGet package info lookup..."
        if ($Publisher) {
            Write-Verbose "Publisher filter: $Publisher"
        }
        if ($PackageId) {
            Write-Verbose "Package ID filter: $PackageId"
        }
    }

    process {
        Write-Verbose "Searching for package info: $DisplayName"
        
        try {
            # Use cached API response or make new call
            $packages = Get-CachedApiResponse -SearchTerm $DisplayName -TimeoutSeconds $TimeoutSeconds
            
            if (-not $packages -or $packages.Count -eq 0) {
                Write-Verbose "Package not found: $DisplayName"
                return [PSCustomObject]@{
                    Id                = $null
                    Name              = $DisplayName
                    Publisher         = $null
                    Description       = $null
                    Homepage          = $null
                    License           = $null
                    Tags              = $null
                    Versions          = $null
                    Has64BitIndicator = $false
                    Found             = $false
                }
            }
            
            # Use smart matching to find best package
            $bestMatch = Get-BestPackageMatch -Packages $packages -SearchTerm $DisplayName -Publisher $Publisher -PackageId $PackageId
            
            if (-not $bestMatch) {
                Write-Verbose "No matching package found for: $DisplayName"
                return [PSCustomObject]@{
                    Id                = $null
                    Name              = $DisplayName
                    Publisher         = $null
                    Description       = $null
                    Homepage          = $null
                    License           = $null
                    Tags              = $null
                    Versions          = $null
                    Has64BitIndicator = $false
                    Found             = $false
                }
            }
            
            $latest = $bestMatch.Latest
            
            # Check for 64-bit indicators in package metadata
            $has64Bit = Test-64BitIndicators -PackageId $bestMatch.Id -PackageName $latest.Name -Tags $latest.Tags
            
            $result = [PSCustomObject]@{
                Id                = $bestMatch.Id
                Name              = $latest.Name
                Publisher         = $latest.Publisher
                Description       = $latest.Description
                Homepage          = $latest.Homepage
                License           = $latest.License
                Tags              = if ($latest.Tags) { $latest.Tags -join ', ' } else { $null }
                Versions          = if ($bestMatch.Versions) { $bestMatch.Versions -join ', ' } else { $null }
                Has64BitIndicator = $has64Bit
                Found             = $true
            }
            
            Write-Verbose "Package info retrieved: $($result.Name) ($($result.Id))"
            return $result
        }
        catch {
            Write-Verbose "Error searching for $DisplayName : $($_.Exception.Message)"
            Write-Warning "Error occurred while querying WinGet API: $($_.Exception.Message)"
            return $null
        }
    }

    end {
        Write-Verbose "WinGet package info lookup completed."
    }
}

function Clear-WinGetCache {
    <#
    .SYNOPSIS
        Clears the session-level WinGet API cache.
 
    .DESCRIPTION
        Clears all cached API responses and resets cache statistics.
        Use this if you need fresh data from the API or to free memory.
 
    .EXAMPLE
        Clear-WinGetCache
         
        Clears all cached WinGet package data.
 
    .EXAMPLE
        Clear-WinGetCache -Verbose
         
        Clears cache and shows how many entries were removed.
 
    .NOTES
        Author: Ringo
        Version: 1.3.0
    #>

    [CmdletBinding()]
    param()
    
    $entryCount = $Script:PackageCache.Count
    $Script:PackageCache.Clear()
    $Script:CacheHits = 0
    $Script:CacheMisses = 0
    
    Write-Verbose "Cleared $entryCount cached entries and reset statistics"
}

function Get-WinGetCacheStatistics {
    <#
    .SYNOPSIS
        Returns cache statistics for the current session.
 
    .DESCRIPTION
        Returns information about cache hits, misses, and efficiency
        to help understand API call reduction.
 
    .OUTPUTS
        PSCustomObject with cache statistics.
 
    .EXAMPLE
        Get-WinGetCacheStatistics
         
        Returns cache hit/miss counts and efficiency percentage.
 
    .EXAMPLE
        # After running multiple queries
        Get-WinGetCacheStatistics | Format-List
         
        Shows detailed cache performance.
 
    .NOTES
        Author: Ringo
        Version: 1.3.0
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param()
    
    $totalRequests = $Script:CacheHits + $Script:CacheMisses
    $efficiency = if ($totalRequests -gt 0) { 
        [math]::Round(($Script:CacheHits / $totalRequests) * 100, 2) 
    } else { 
        0 
    }
    
    [PSCustomObject]@{
        CachedEntries    = $Script:PackageCache.Count
        CacheHits        = $Script:CacheHits
        CacheMisses      = $Script:CacheMisses
        TotalRequests    = $totalRequests
        EfficiencyPct    = $efficiency
        ApiCallsSaved    = $Script:CacheHits
    }
}

function Test-WinGet64BitAvailable {
    <#
    .SYNOPSIS
        Tests if a WinGet package has a 64-bit installer available using the local WinGet CLI.
 
    .DESCRIPTION
        Uses the local WinGet command-line tool to definitively check if a package
        has an x64 architecture installer available. This is more accurate than
        heuristic-based detection as it queries the actual package manifest.
         
        Note: This function requires WinGet to be installed locally.
 
    .PARAMETER PackageId
        The WinGet package ID to check (e.g., "RARLab.WinRAR", "7zip.7zip").
 
    .PARAMETER TimeoutSeconds
        Maximum time to wait for the WinGet command to complete.
        Default is 30 seconds.
 
    .OUTPUTS
        System.Boolean
        Returns $true if the package has an x64 installer available, $false otherwise.
 
    .EXAMPLE
        Test-WinGet64BitAvailable -PackageId "RARLab.WinRAR"
         
        Returns $true because WinRAR has an x64 installer.
 
    .EXAMPLE
        Test-WinGet64BitAvailable -PackageId "Adobe.Acrobat.Reader.32-bit"
         
        Returns $false because this package only has x86 installers.
 
    .EXAMPLE
        # Use with Get-WinGetPackageInfo for complete package analysis
        $pkg = Get-WinGetPackageInfo -DisplayName "WinRAR"
        if ($pkg.Found) {
            $has64 = Test-WinGet64BitAvailable -PackageId $pkg.Id
            Write-Host "$($pkg.Name): 64-bit available = $has64"
        }
 
    .NOTES
        Author: Ringo
        Version: 1.5.0
         
        This function requires WinGet (winget.exe) to be installed and accessible.
        Unlike other functions in this module that use the winget.run API,
        this function queries the local WinGet installation directly for
        accurate architecture information.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(
            Mandatory = $true,
            Position = 0,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = "The WinGet package ID to check for 64-bit availability."
        )]
        [ValidateNotNullOrEmpty()]
        [Alias('Id', 'WinGetId')]
        [string]$PackageId,

        [Parameter(
            Mandatory = $false,
            HelpMessage = "Timeout in seconds for the WinGet command."
        )]
        [ValidateRange(5, 300)]
        [int]$TimeoutSeconds = 30
    )

    begin {
        Write-Verbose "Checking 64-bit availability via WinGet CLI..."
        
        if (-not $Script:WinGetCliAvailable) {
            Write-Warning "WinGet (winget.exe) not found. Cannot verify 64-bit availability via CLI."
        } else {
            Write-Verbose "Using cached WinGet path: $($Script:WinGetCliPath)"
        }
    }

    process {
        if (-not $Script:WinGetCliAvailable) {
            Write-Verbose "WinGet CLI not available, returning false for: $PackageId"
            return $false
        }

        Write-Verbose "Checking x64 availability for: $PackageId"
        
        # Build arguments for winget show with x64 architecture
        $arguments = @(
            'show',
            '--id', $PackageId,
            '--architecture', 'x64',
            '--accept-source-agreements',
            '--disable-interactivity'
        )
        
        $result = Invoke-WinGetCliCommand -Arguments $arguments -TimeoutSeconds $TimeoutSeconds
        
        if (-not $result.Success -and $result.ExitCode -eq -1) {
            Write-Warning "WinGet command failed for $PackageId : $($result.StdErr)"
            return $false
        }
        
        $output = $result.StdOut
        $exitCode = $result.ExitCode
        
        # Check if package was found and has a valid installer
        # Exit code 0 with "Found" indicates package exists
        # Must also have installer info but NOT "No applicable installer found"
        if ($exitCode -eq 0 -and $output -match 'Found.*\[') {
            # Check for "No applicable installer found" which means no x64 installer exists
            if ($output -match 'No applicable installer found') {
                Write-Verbose "Package found but no x64 installer available for: $PackageId"
                return $false
            }
            
            # Check for valid installer information (Installer URL or Type)
            if ($output -match 'Installer Url:|Installer Type:') {
                Write-Verbose "64-bit version available for: $PackageId"
                return $true
            }
        }
        
        Write-Verbose "No 64-bit version found for: $PackageId (Exit code: $exitCode)"
        return $false
    }

    end {
        Write-Verbose "64-bit availability check completed."
    }
}

function Get-WinGet64BitPackageId {
    <#
    .SYNOPSIS
        Gets the 64-bit package ID for an application, handling both same-package and separate-package scenarios.
 
    .DESCRIPTION
        Some applications have both 32-bit and 64-bit installers in the same WinGet package (e.g., 7-Zip, WinRAR).
        Other applications have separate packages for each architecture (e.g., Adobe.Acrobat.Reader.32-bit vs .64-bit).
         
        This function handles both scenarios:
        1. If the package has an x64 installer, returns the same package ID
        2. If the package ID contains ".32-bit" or "-32-bit", checks for a ".64-bit" variant
        3. Returns the appropriate 64-bit package ID, or $null if none available
 
    .PARAMETER PackageId
        The WinGet package ID to check (e.g., "Adobe.Acrobat.Reader.32-bit", "7zip.7zip").
 
    .PARAMETER TimeoutSeconds
        Maximum time to wait for WinGet commands.
        Default is 30 seconds.
 
    .OUTPUTS
        System.String
        Returns the package ID that provides 64-bit installation, or $null if none available.
 
    .EXAMPLE
        Get-WinGet64BitPackageId -PackageId "Adobe.Acrobat.Reader.32-bit"
         
        Returns "Adobe.Acrobat.Reader.64-bit" because a separate 64-bit package exists.
 
    .EXAMPLE
        Get-WinGet64BitPackageId -PackageId "7zip.7zip"
         
        Returns "7zip.7zip" because the same package has an x64 installer.
 
    .EXAMPLE
        Get-WinGet64BitPackageId -PackageId "SomeApp.OnlyX86"
         
        Returns $null if no 64-bit version is available.
 
    .NOTES
        Author: Ringo
        Version: 1.5.0
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(
            Mandatory = $true,
            Position = 0,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = "The WinGet package ID to check."
        )]
        [ValidateNotNullOrEmpty()]
        [Alias('Id', 'WinGetId')]
        [string]$PackageId,

        [Parameter(
            Mandatory = $false,
            HelpMessage = "Timeout in seconds for WinGet commands."
        )]
        [ValidateRange(5, 300)]
        [int]$TimeoutSeconds = 30
    )

    process {
        Write-Verbose "Checking for 64-bit package variant of: $PackageId"
        
        # First, check if the current package has an x64 installer
        if (Test-WinGet64BitAvailable -PackageId $PackageId -TimeoutSeconds $TimeoutSeconds) {
            Write-Verbose "Package $PackageId has x64 installer available"
            return $PackageId
        }
        
        # Check if this is a 32-bit specific package that might have a 64-bit variant
        $potential64BitId = $null
        
        if ($PackageId -match '\.32-bit$') {
            $potential64BitId = $PackageId -replace '\.32-bit$', '.64-bit'
        }
        elseif ($PackageId -match '-32-bit$') {
            $potential64BitId = $PackageId -replace '-32-bit$', '-64-bit'
        }
        elseif ($PackageId -match '\.x86$') {
            $potential64BitId = $PackageId -replace '\.x86$', '.x64'
        }
        elseif ($PackageId -match '-x86$') {
            $potential64BitId = $PackageId -replace '-x86$', '-x64'
        }
        elseif ($PackageId -match '32$') {
            # Handle cases like "SomeApp32" -> "SomeApp64"
            $potential64BitId = $PackageId -replace '32$', '64'
        }
        
        if ($potential64BitId) {
            Write-Verbose "Checking for 64-bit variant package: $potential64BitId"
            
            # Verify the 64-bit variant package exists and has an x64 installer
            if (Test-WinGet64BitAvailable -PackageId $potential64BitId -TimeoutSeconds $TimeoutSeconds) {
                Write-Verbose "Found 64-bit variant package: $potential64BitId"
                return $potential64BitId
            }
        }
        
        Write-Verbose "No 64-bit package available for: $PackageId"
        return $null
    }
}

function Initialize-WinGetPackageCache {
    <#
    .SYNOPSIS
        Pre-fetches package information for multiple applications to warm the cache.
 
    .DESCRIPTION
        Improvement #3: Batch API lookups
        Makes API calls for all provided search terms to pre-populate the cache.
        Subsequent calls to Get-WinGetPackageInfo or Test-WinGetPackage will use cached data.
         
        This is more efficient than making individual calls when processing many applications.
 
    .PARAMETER SearchTerms
        Array of application names to pre-fetch from the WinGet API.
 
    .PARAMETER TimeoutSeconds
        Timeout in seconds for each API request. Default is 30.
 
    .PARAMETER ThrottleDelayMs
        Milliseconds to wait between API calls to avoid rate limiting. Default is 100.
 
    .EXAMPLE
        $apps = @("7-Zip", "Notepad++", "VLC", "WinRAR")
        Initialize-WinGetPackageCache -SearchTerms $apps
         
        Pre-fetches all packages, then subsequent lookups are instant.
 
    .EXAMPLE
        $installed = Get-Installed32BitApplications
        Initialize-WinGetPackageCache -SearchTerms ($installed.DisplayName)
         
        Pre-warms cache with all installed application names.
 
    .NOTES
        Author: Ringo
        Version: 1.7.0
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        [string[]]$SearchTerms,

        [Parameter()]
        [ValidateRange(5, 300)]
        [int]$TimeoutSeconds = 30,

        [Parameter()]
        [ValidateRange(0, 5000)]
        [int]$ThrottleDelayMs = 100
    )

    begin {
        $allTerms = @()
    }

    process {
        $allTerms += $SearchTerms
    }

    end {
        # Deduplicate and normalize search terms
        $uniqueTerms = $allTerms | 
            ForEach-Object { $_.ToLowerInvariant().Trim() } | 
            Select-Object -Unique |
            Where-Object { -not $Script:PackageCache.ContainsKey($_) }  # Skip already cached

        if ($uniqueTerms.Count -eq 0) {
            Write-Verbose "All search terms already cached, nothing to prefetch"
            return
        }

        Write-Verbose "Pre-fetching $($uniqueTerms.Count) packages to cache..."
        $fetched = 0
        $total = $uniqueTerms.Count

        foreach ($term in $uniqueTerms) {
            $fetched++
            Write-Progress -Activity "Pre-fetching WinGet packages" -Status "$fetched of $total - $term" -PercentComplete (($fetched / $total) * 100)
            
            # This will cache the result
            $null = Get-CachedApiResponse -SearchTerm $term -TimeoutSeconds $TimeoutSeconds
            
            # Throttle to avoid rate limiting
            if ($ThrottleDelayMs -gt 0 -and $fetched -lt $total) {
                Start-Sleep -Milliseconds $ThrottleDelayMs
            }
        }

        Write-Progress -Activity "Pre-fetching WinGet packages" -Completed
        Write-Verbose "Pre-fetch complete. Cache now contains $($Script:PackageCache.Count) entries."
    }
}

#endregion

#region Module Exports
Export-ModuleMember -Function @(
    'Test-WinGetPackage',
    'Get-WinGetPackageInfo',
    'Clear-WinGetCache',
    'Get-WinGetCacheStatistics',
    'Test-WinGet64BitAvailable',
    'Get-WinGet64BitPackageId',
    'Initialize-WinGetPackageCache'
)
#endregion