Private/DevVm.Helpers.ps1

<#
Private helper functions for DevVm
#>


# Initialize configuration paths
function Initialize-DevVmConfigPath {
    $script:DevVmConfig.ConfigPaths = @{
        session = 'Memory'
        project = Resolve-DevVmProjectConfigPath -StartPath (Get-Location).Path
        global = (Get-ConfigValue 'globalDevVmPath')
    }
}

function Resolve-DevVmProjectConfigPath {
    param([string]$StartPath = (Get-Location).Path)

    if ([string]::IsNullOrWhiteSpace($StartPath)) {
        $StartPath = (Get-Location).Path
    }

    $startItem = Get-Item -LiteralPath $StartPath -ErrorAction SilentlyContinue
    if ($startItem) {
        if (-not $startItem.PSIsContainer) {
            $StartPath = $startItem.Directory.FullName
        }
        else {
            $StartPath = $startItem.FullName
        }
    }

    $projectPath = Find-NearestDevVmFile -StartPath $StartPath -FileName '.devvm'
    if ($projectPath) {
        return $projectPath
    }

    return Join-Path -Path $StartPath -ChildPath '.devvm'
}

# Get runtime info from definitions
function Get-DevVmRuntimeInfo {
    param([string]$Runtime)

    if (-not $script:DevVmConfig.RuntimeDefinitions) {
        return $null
    }

    if ($script:DevVmConfig.RuntimeDefinitions.runtimes.PSObject.Properties.Name -contains $Runtime) {
        return $script:DevVmConfig.RuntimeDefinitions.runtimes.$Runtime
    }

    return $null
}

# Test if runtime exists in configuration
function Test-DevVmRuntime {
    param([string]$Runtime)

    return $null -ne (Get-DevVmRuntimeInfo -Runtime $Runtime)
}

# Get currently activated version from PATH
function Get-DevVmActivatedVersion {
    param(
        [string]$Runtime,
        [string]$BasePath,
        [string]$RuntimeCommand
    )

    try {
        $cmdObj = Get-Command $RuntimeCommand -ErrorAction SilentlyContinue
        if ($cmdObj) {
            $cmdFullPath = [System.IO.Path]::GetFullPath($cmdObj.Source).ToLower()

            # Get all installed versions
            $versions = @(Get-ChildItem -Path $BasePath -Directory -ErrorAction SilentlyContinue)

            foreach ($v in $versions) {
                $versionPath = [System.IO.Path]::GetFullPath("$BasePath\$($v.Name)").ToLower()
                if ($cmdFullPath.StartsWith($versionPath)) {
                    return $v.Name
                }
            }
        }
    }
    catch {
        Write-Verbose "Failed to resolve activated version for ${Runtime}: $_"
    }

    return $null
}

# Convert versions section to a hashtable (supports PSCustomObject or hashtable)
function Convert-DevVmVersionsToHashtable {
    param([object]$Versions)

    $result = @{}
    if ($null -eq $Versions) {
        return $result
    }

    if ($Versions -is [hashtable]) {
        foreach ($key in $Versions.Keys) {
            $result[$key] = $Versions[$key]
        }
        return $result
    }

    if ($Versions -is [pscustomobject]) {
        foreach ($prop in $Versions.PSObject.Properties) {
            $result[$prop.Name] = $prop.Value
        }
    }

    return $result
}

# Get merged configuration from all levels
function Get-DevVmMergedConfig {
    param([string]$StartPath = (Get-Location).Path)

    if ([string]::IsNullOrWhiteSpace($StartPath)) {
        $StartPath = (Get-Location).Path
    }

    Write-Verbose "Resolving project config from: $StartPath"

    $merged = @{}

    # Load global config
    $globalPath = $script:DevVmConfig.ConfigPaths.global
    if ($globalPath -and (Test-Path $globalPath)) {
        try {
            $globalRaw = Get-Content -Path $globalPath -Raw
            $global = ConvertFrom-DevVmJsonSafe -Raw $globalRaw
            if ($global) {
                $globalVersions = Convert-DevVmVersionsToHashtable -Versions $global.versions
                foreach ($key in $globalVersions.Keys) {
                    $merged[$key] = $globalVersions[$key]
                }
            }
        }
        catch {
            Write-Verbose "Failed to load global config: $_"
        }
    }

    # Load nearest project config (overrides global)
    $projectPath = Resolve-DevVmProjectConfigPath -StartPath $StartPath
    Write-Verbose "Resolved project config path: $projectPath"
    if ($projectPath -and (Test-Path $projectPath)) {
        try {
            $projectRaw = Get-Content -Path $projectPath -Raw
            $project = ConvertFrom-DevVmJsonSafe -Raw $projectRaw
            if ($project) {
                $projectVersions = Convert-DevVmVersionsToHashtable -Versions $project.versions
                foreach ($key in $projectVersions.Keys) {
                    $merged[$key] = $projectVersions[$key]
                }
            }
        }
        catch {
            Write-Verbose "Failed to load project config: $_"
        }
    }
    else {
        Write-Verbose "No project config found at resolved path."
    }

    # Load session overrides (highest priority)
    $sessionSelections = $script:DevVmConfig.SessionSelections
    if ($sessionSelections -and $sessionSelections.Count -gt 0) {
        foreach ($key in $sessionSelections.Keys) {
            $merged[$key] = $sessionSelections[$key]
        }
    }

    return $merged
}

# Find nearest .devvm file
function Find-NearestDevVmFile {
    param(
        [string]$StartPath,
        [string]$FileName = '.devvm'
    )

    if ([string]::IsNullOrWhiteSpace($StartPath)) {
        $StartPath = (Get-Location).Path
    }

    $startItem = Get-Item -LiteralPath $StartPath -ErrorAction SilentlyContinue
    if (-not $startItem) {
        return $null
    }

    $dirPath = if ($startItem.PSIsContainer) { $startItem.FullName } else { $startItem.Directory.FullName }

    while (-not [string]::IsNullOrWhiteSpace($dirPath)) {
        $candidate = Join-Path -Path $dirPath -ChildPath $FileName
        Write-Verbose "Checking for project config: $candidate"
        if (Test-Path -LiteralPath $candidate) {
            return $candidate
        }

        $parentPath = Split-Path -Parent $dirPath
        if ([string]::IsNullOrWhiteSpace($parentPath) -or $parentPath -eq $dirPath) {
            break
        }

        $dirPath = $parentPath
    }

    return $null
}

# Save version selection to config file
function Save-DevVmVersionSelection {
    param(
        [string]$Runtime,
        [string]$Version,
        [ValidateSet('project', 'global')]
        [string]$Target = 'project'
    )

    $configPath = $script:DevVmConfig.ConfigPaths[$Target]

    if ($Target -eq 'project') {
        $configPath = Find-NearestDevVmFile -StartPath (Get-Location).Path
        if (-not $configPath) {
            $configPath = Join-Path -Path (Get-Location).Path -ChildPath '.devvm'
        }
    }

    # Ensure directory exists
    $dir = Split-Path -Parent $configPath
    if (-not (Test-Path $dir)) {
        New-Item -ItemType Directory -Path $dir -Force -ErrorAction SilentlyContinue | Out-Null
    }

    # Load existing or create new
    $data = @{
        versions = @{}
        runtimes = @{}
    }

    if (Test-Path $configPath) {
        try {
            $rawContent = Get-Content -Path $configPath -Raw
            if ($rawContent -and $rawContent.Trim()) {
                $parsed = $rawContent | ConvertFrom-Json

                # Convert PSCustomObject to hashtable structure
                if ($parsed.versions) {
                    $data.versions = @{}
                    foreach ($property in $parsed.versions.PSObject.Properties) {
                        $data.versions[$property.Name] = $property.Value
                    }
                }

                if ($parsed.runtimes) {
                    $data.runtimes = @{}
                    foreach ($property in $parsed.runtimes.PSObject.Properties) {
                        $data.runtimes[$property.Name] = $property.Value
                    }
                }
            }
        }
        catch {
            Write-Verbose "Failed to load existing config, creating new: $_"
        }
    }

    # Update the version inside 'versions' section
    $data.versions[$Runtime] = $Version

    # Save
    $json = $data | ConvertTo-Json -Depth 5
    $json | Set-Content -Path $configPath -Encoding UTF8 -ErrorAction Stop

    Write-Verbose "Saved $Runtime=$Version to $Target config at $configPath"
}

# Update PATH for current session
function Update-DevVmSessionPath {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [string]$Runtime,
        [string]$Version,
        [string]$BasePath,
        [string]$BinPath
    )

    if (-not $PSCmdlet.ShouldProcess('PATH', "Update $Runtime to $Version")) {
        Write-Verbose "Skipped PATH update for $Runtime $Version"
        return
    }

    # Split current PATH into array and remove empty entries
    $pathArray = @($env:Path -split ';' | Where-Object { $_ -and $_.Trim() })

    # Remove all existing paths that belong to this runtime's base path
    # Convert to lowercase for case-insensitive comparison
    $baseLower = $BasePath.ToLower()
    $pathArray = @($pathArray | Where-Object {
        $_.ToLower() -notlike "$baseLower*"
    })

    # Remove duplicates to clean up accumulated PATH entries
    $pathArray = @($pathArray | Select-Object -Unique)

    # Construct new path for this runtime
    $versionPath = Join-Path -Path $BasePath -ChildPath $Version
    $newPath = if ($BinPath -and (Test-Path "$versionPath\bin")) {
        "$versionPath\bin"
    }
    else {
        $versionPath
    }

    # Only add if the path exists and isn't already in the array
    if ((Test-Path $newPath) -and $newPath.ToLower() -notin $pathArray.ToLower()) {
        $pathArray = @($newPath) + $pathArray
    }

    # Rebuild PATH
    $env:Path = $pathArray -join ';'

    Write-Verbose "Updated PATH for $Runtime $Version"
}

# Helper to get config value
function Get-ConfigValue {
    param([string]$Key)

    if ($Key -ne 'globalDevVmPath') {
        return $null
    }

    $globalPath = $env:DEVVM_GLOBAL_CONFIG
    if (-not $globalPath) {
        $globalPath = Join-Path $env:USERPROFILE 'AppData\Local\DevVm\.devvm'
    }

    $globalDir = Split-Path -Parent $globalPath
    if (-not (Test-Path $globalDir)) {
        New-Item -ItemType Directory -Path $globalDir -Force | Out-Null
    }

    if (-not (Test-Path $globalPath)) {
        $defaults = @{
            versions = @{
                node = ''
                java = ''
                maven = ''
                lein = ''
            }
            runtimes = @{}
        }
        $defaults | ConvertTo-Json -Depth 5 | Set-Content -Path $globalPath -Encoding UTF8
    }

    return $globalPath
}

# Get cache directory for DevVm
function Get-DevVmCacheDirectory {
    $cacheDir = Join-Path -Path $env:LOCALAPPDATA -ChildPath 'DevVm\Cache'
    if (-not (Test-Path $cacheDir)) {
        New-Item -ItemType Directory -Path $cacheDir -Force -ErrorAction SilentlyContinue | Out-Null
    }
    return $cacheDir
}

# Get install path for a runtime (configurable with precedence)
function Get-DevVmInstallPath {
    param(
        [string]$Runtime
    )

    # Precedence: env var > .devvm > global config > defaults
    $basePath = $null

    # 1. Check environment variable
    $envVar = "DEVVM_${Runtime.ToUpper()}_PATH"
    if (Test-Path env:$envVar) {
        $basePath = (Get-Item env:$envVar).Value
        Write-Verbose "Using basePath from env var $envVar : $basePath"
        return $basePath
    }

    # 2. Check project .devvm file
    $projectDevVm = Join-Path -Path (Get-Location) -ChildPath '.devvm'
    if (Test-Path $projectDevVm) {
        try {
            $configRaw = Get-Content -Path $projectDevVm -Raw
            $config = ConvertFrom-DevVmJsonSafe -Raw $configRaw
            if ($config -and $config.runtimes -and $config.runtimes.$Runtime -and $config.runtimes.$Runtime.basePath) {
                Write-Verbose "Using basePath from project .devvm: $($config.runtimes.$Runtime.basePath)"
                return $config.runtimes.$Runtime.basePath
            }
        }
        catch {
            Write-Verbose "Could not read project .devvm: $_"
        }
    }

    # 3. Check global config (~/.devvm)
    $globalDevVm = $script:DevVmConfig.ConfigPaths.global
    if ($globalDevVm -and (Test-Path $globalDevVm)) {
        try {
            $configRaw = Get-Content -Path $globalDevVm -Raw
            $config = ConvertFrom-DevVmJsonSafe -Raw $configRaw
            if ($config -and $config.runtimes -and $config.runtimes.$Runtime -and $config.runtimes.$Runtime.basePath) {
                Write-Verbose "Using basePath from global .devvm: $($config.runtimes.$Runtime.basePath)"
                return $config.runtimes.$Runtime.basePath
            }
        }
        catch {
            Write-Verbose "Could not read global .devvm: $_"
        }
    }

    # 4. Use defaults from runtime-definitions runtimes section
    if ($script:DevVmConfig.RuntimeDefinitions -and $script:DevVmConfig.RuntimeDefinitions.runtimes -and $script:DevVmConfig.RuntimeDefinitions.runtimes.$Runtime) {
        $runtimeDef = $script:DevVmConfig.RuntimeDefinitions.runtimes.$Runtime
        if ($runtimeDef.basePath) {
            Write-Verbose "Using basePath from runtime-definitions: $($runtimeDef.basePath)"
            return $runtimeDef.basePath
        }
    }

    # 5. Fallback to hardcoded default
    $defaultBase = "C:\$Runtime"
    Write-Verbose "Using fallback basePath: $defaultBase"
    return $defaultBase
}

# Get temporary download path for a runtime
function Get-DevVmDownloadPath {
    param(
        [string]$Runtime
    )

    # Always use $env:TEMP/DevVm/<runtime>/<version>
    $downloadDir = Join-Path -Path $env:TEMP -ChildPath "DevVm\$Runtime"
    if (-not (Test-Path $downloadDir)) {
        New-Item -ItemType Directory -Path $downloadDir -Force -ErrorAction SilentlyContinue | Out-Null
    }
    return $downloadDir
}

# Get or download cached content
function Get-DevVmCachedContent {
    param(
        [string]$Uri,
        [int]$CacheDays = 30,
        [switch]$Update,
        [string]$Runtime = ''
    )

    $cacheDir = Get-DevVmCacheDirectory
    # Create SHA256 hash of URI for cache filename (compatible with PS 5.0)
    $uriBytes = [System.Text.Encoding]::UTF8.GetBytes($Uri)
    $sha256 = [System.Security.Cryptography.SHA256]::Create()
    $hashBytes = $sha256.ComputeHash($uriBytes)
    $cacheFileName = ($hashBytes | ForEach-Object { $_.ToString('x2') }) -join ''
    $cachePath = Join-Path -Path $cacheDir -ChildPath "$cacheFileName.cache"
    $metadataPath = Join-Path -Path $cacheDir -ChildPath "$cacheFileName.metadata"

    # Check if valid cache exists
    if (-not $Update -and (Test-Path $cachePath)) {
        $cacheAge = (Get-Date) - (Get-Item $cachePath).LastWriteTime
        if ($cacheAge.TotalDays -lt $CacheDays) {
            Write-Verbose "Cache hit: Using cached content for: $Uri (age: $([math]::Round($cacheAge.TotalHours, 1)) hours)"
            Write-Output "[CACHE]"
            return Get-Content -Path $cachePath -Raw -ErrorAction SilentlyContinue
        }
    }

    # Download content
    Write-Verbose "Cache miss or update requested: Downloading: $Uri"
    if ($Update) {
        Write-Output "[UPDATED]"
    } else {
        Write-Output "[DOWNLOADED]"
    }

    try {
        $response = Invoke-WebRequest -Uri $Uri -UseBasicParsing -ErrorAction Stop
        $content = $response.Content

        # Save to cache
        $content | Set-Content -Path $cachePath -Encoding UTF8 -ErrorAction SilentlyContinue

        # Save metadata with runtime and expiration info
        $metadata = @{
            Runtime = $Runtime
            Uri = $Uri
            DownloadDate = (Get-Date)
            ExpiryDate = (Get-Date).AddDays($CacheDays)
        }
        $metadata | ConvertTo-Json | Set-Content -Path $metadataPath -Encoding UTF8 -ErrorAction SilentlyContinue

        return $content
    }
    catch {
        # If download fails but cache exists, use expired cache
        if (Test-Path $cachePath) {
            Write-Verbose "Download failed, using expired cache: $_"
            Write-Output "[CACHE EXPIRED]"
            return Get-Content -Path $cachePath -Raw -ErrorAction SilentlyContinue
        }
        throw $_
    }
}

# Download and install a runtime version
function Invoke-DevVmDownloadAndInstall {
    param(
        [string]$Runtime,
        [string]$Version,
        [PSObject]$RuntimeInfo,
        [string]$InstallPath
    )

    # If InstallPath not provided, resolve with precedence
    if ([string]::IsNullOrWhiteSpace($InstallPath)) {
        $InstallPath = Get-DevVmInstallPath -Runtime $Runtime
    }

    # Special handling for Leiningen (download JAR + create wrapper)
    if ($Runtime -eq 'lein') {
        try {
            New-Item -ItemType Directory -Path $InstallPath -Force -ErrorAction SilentlyContinue | Out-Null

            # Download the JAR file
            $downloadUrl = $RuntimeInfo.downloadUrl -replace '{version}', $Version
            $jarPath = Join-Path -Path $InstallPath -ChildPath "leiningen-$Version-standalone.jar"

            Write-Output "Downloading Leiningen JAR from: $downloadUrl"
            Invoke-WebRequest -Uri $downloadUrl -OutFile $jarPath -ErrorAction Stop

            # Create wrapper batch script based on original lein.bat but with local JAR
            $wrapperPath = Join-Path -Path $InstallPath -ChildPath "lein.bat"
            $wrapperContent = @"
@echo off
 
setLocal EnableExtensions EnableDelayedExpansion
 
set LEIN_VERSION=$Version
set LEIN_JAR=%~dp0leiningen-$Version-standalone.jar
 
if "x!JAVA_CMD!" == "x" set JAVA_CMD=java
if "x!LEIN_JAVA_CMD!" == "x" set LEIN_JAVA_CMD=!JAVA_CMD!
 
if "x%JVM_OPTS%" == "x" set JVM_OPTS=%JAVA_OPTS%
 
if not exist "!LEIN_JAR!" (
    echo.
    echo Error: !LEIN_JAR! not found.
    echo.
    exit /b 1
)
 
set "TRAMPOLINE_FILE=%TEMP%\lein-trampoline-!RANDOM!.bat"
del "!TRAMPOLINE_FILE!" >nul 2>&1
 
set ERRORLEVEL=
set RC=0
 
"!LEIN_JAVA_CMD!" -client !LEIN_JVM_OPTS! ^
 -Dfile.encoding=UTF-8 ^
 -Dleiningen.original.pwd="%CD%" ^
 -cp "!LEIN_JAR!" clojure.main -m leiningen.core.main %*
 
SET RC=!ERRORLEVEL!
if not !RC! == 0 goto EXITRC
 
if not exist "!TRAMPOLINE_FILE!" goto EOF
call "!TRAMPOLINE_FILE!"
del "!TRAMPOLINE_FILE!" >nul 2>&1
goto EOF
 
:EXITRC
exit /B !RC!
 
:EOF
"@


            Set-Content -Path $wrapperPath -Value $wrapperContent -Encoding ASCII

            Write-Output "$Runtime version $Version installed successfully at $InstallPath"
            Write-Output " JAR: $jarPath"
            Write-Output " Wrapper: $wrapperPath"
            Write-Output ""
            Write-Output "Requirements: Java (OpenJDK) must be installed."
            Write-Output " Use: devvm use java <version>"
        }
        catch {
            Write-Error "Failed to install $Runtime $Version : $_"
            Remove-Item -Path $InstallPath -Recurse -Force -ErrorAction SilentlyContinue
        }
        return
    }

    # Standard handling for archive-based runtimes
    $tempZip = Join-Path -Path $env:TEMP -ChildPath "$Runtime-$Version-install.zip"

    try {
        # Download
        $downloadUrl = $RuntimeInfo.downloadUrl -replace '{version}', $Version
        Write-Output "Downloading from: $downloadUrl"
        Invoke-WebRequest -Uri $downloadUrl -OutFile $tempZip -ErrorAction Stop

        # Extract
        Write-Output "Extracting..."
        $tempExtract = Join-Path -Path $env:TEMP -ChildPath "$Runtime-$Version-extract"
        Expand-Archive -Path $tempZip -DestinationPath $tempExtract -Force -ErrorAction Stop

        # Move to final location
        New-Item -ItemType Directory -Path $InstallPath -Force -ErrorAction SilentlyContinue | Out-Null

        # Handle extraction pattern
        if ($RuntimeInfo.extractPattern) {
            $extractedName = $RuntimeInfo.extractPattern -replace '{version}', $Version
            $extractedPath = Join-Path -Path $tempExtract -ChildPath $extractedName

            if (Test-Path $extractedPath) {
                Copy-Item -Path "$extractedPath\*" -Destination $InstallPath -Recurse -Force
                Remove-Item -Path $extractedPath -Recurse -Force -ErrorAction SilentlyContinue
            }
        }
        else {
            Copy-Item -Path "$tempExtract\*" -Destination $InstallPath -Recurse -Force
        }

        Write-Output "$Runtime version $Version installed successfully at $InstallPath"
    }
    catch {
        Write-Error "Failed to install $Runtime $Version : $_"
        Remove-Item -Path $InstallPath -Recurse -Force -ErrorAction SilentlyContinue
    }
    finally {
        # Cleanup
        Remove-Item -Path $tempZip -ErrorAction SilentlyContinue
        Remove-Item -Path $tempExtract -Recurse -ErrorAction SilentlyContinue
    }
}

# Fetch available versions from remote source
function Get-DevVmAvailableVersion {
    param(
        [string]$Runtime,
        [PSObject]$RuntimeInfo,
        [bool]$ShowAll = $false,
        [switch]$Update
    )

    try {
        if ($Runtime -eq 'node') {
            Get-DevVmNodeVersion -RuntimeInfo $RuntimeInfo -ShowAll $ShowAll -Update:$Update
        }
        elseif ($Runtime -eq 'java') {
            Get-DevVmJavaVersion -RuntimeInfo $RuntimeInfo -ShowAll $ShowAll -Update:$Update
        }
        elseif ($Runtime -eq 'maven') {
            Get-DevVmMavenVersion -RuntimeInfo $RuntimeInfo -ShowAll $ShowAll -Update:$Update
        }
        elseif ($Runtime -eq 'lein') {
            Get-DevVmLeinVersion -RuntimeInfo $RuntimeInfo -ShowAll $ShowAll -Update:$Update
        }
        else {
            Write-Output "Version fetching not implemented for $Runtime"
        }
    }
    catch {
        Write-Error "Failed to fetch available versions: $_"
    }
}

function Get-DevVmNodeVersion {
    param(
        [PSObject]$RuntimeInfo,
        [bool]$ShowAll,
        [switch]$Update
    )

    $content = Get-DevVmCachedContent -Uri $RuntimeInfo.indexUrl -Update:$Update -Runtime 'node' -ErrorAction Stop
    $versions = $content | ConvertFrom-Json

    # Filter v10+
    $filtered = $versions | Where-Object { [int]($_.version.TrimStart('v').Split('.')[0]) -ge 10 }

    if ($ShowAll) {
        Write-Output "Available Node.js versions (v10+):"
        $filtered | ForEach-Object { Write-Output " $($_.version)" }
    }
    else {
        Write-Output "Latest Node.js versions (one per major version):"
        $filtered | Group-Object { $_.version.Split('.')[0] } | ForEach-Object {
            $latest = $_.Group | Sort-Object -Property version -Descending | Select-Object -First 1
            Write-Output " $($latest.version)"
        }
    }
}

function Get-DevVmJavaVersion {
    param(
        [PSObject]$RuntimeInfo,
        [bool]$ShowAll,
        [switch]$Update
    )

    try {
        # Fetch Microsoft OpenJDK versions from learn.microsoft.com
        $indexUrl = $RuntimeInfo.indexUrl

        Write-Verbose "Fetching Microsoft OpenJDK versions from: $indexUrl"
        $content = Get-DevVmCachedContent -Uri $indexUrl -Update:$Update -Runtime 'java' -ErrorAction Stop

        # Parse version URLs from the page
        # Supports versions like: 21.0.2, 21.0, 21
        $pattern = 'https://aka\.ms/download-jdk/microsoft-jdk-([0-9]+(?:\.[0-9]+){0,2})-windows-x64\.zip'
        $regexMatches = [regex]::Matches($content, $pattern)

        if ($regexMatches.Count -eq 0) {
            throw "No Microsoft OpenJDK versions found on the page"
        }

        # Extract unique versions and sort them
        $versions = @()
        $uniqueVersions = @{}

        foreach ($match in $regexMatches) {
            $version = $match.Groups[1].Value
            if (-not $uniqueVersions.ContainsKey($version)) {
                $uniqueVersions[$version] = $true
                $versions += $version
            }
        }

        # Normalize version strings for sorting (handles major-only entries like "25")
        $normalizeVersion = {
            param([string]$raw)
            if ($raw -match '^\d+$') {
                return "$raw.0.0"
            }
            if ($raw -match '^\d+\.\d+$') {
                return "$raw.0"
            }
            return $raw
        }

        # Sort versions (highest first)
        $versions = $versions | Sort-Object -Property { [version](& $normalizeVersion $_) } -Descending

        if ($versions.Count -eq 0) {
            throw "Failed to extract valid versions from Microsoft page"
        }

        # Categorize versions as LTS or Feature releases
        # LTS versions: 8, 11, 17, 21 (every 3 years)
        # Feature releases: others
        $categorized = @()
        foreach ($ver in $versions) {
            $majorVersion = [int]($ver.Split('.')[0])
            $isLTS = @(8, 11, 17, 21) -contains $majorVersion

            $categorized += @{
                Version = $ver
                LTS = $isLTS
                Major = $majorVersion
                SortKey = & $normalizeVersion $ver
            }
        }

        if ($ShowAll) {
            Write-Output "Available Microsoft OpenJDK versions (from learn.microsoft.com):"
            Write-Output ""

            $ltsVersions = $categorized | Where-Object { $_.LTS } | Sort-Object -Property Major -Descending
            $featureVersions = $categorized | Where-Object { -not $_.LTS } | Sort-Object -Property { [version]$_.SortKey } -Descending

            if ($ltsVersions.Count -gt 0) {
                Write-Output "LTS (Long Term Support) Versions:"
                $ltsVersions | ForEach-Object {
                    Write-Output " $($_.Version) [LTS - Java $($_.Major)]"
                }
            }

            if ($featureVersions.Count -gt 0) {
                Write-Output ""
                Write-Output "Latest Feature Releases:"
                $featureVersions | ForEach-Object {
                    Write-Output " $($_.Version)"
                }
            }
        }
        else {
            Write-Output "Latest Microsoft OpenJDK versions (LTS recommended):"
            Write-Output ""

            # Get latest version of each major release
            $grouped = $categorized | Group-Object -Property Major | Sort-Object -Property Name -Descending

            $grouped | ForEach-Object {
                $major = $_.Name
                $latest = $_.Group | Sort-Object -Property { [version]$_.SortKey } -Descending | Select-Object -First 1
                $ltsLabel = if ($latest.LTS) { " [LTS]" } else { "" }
                Write-Output " Java ${major}: $($latest.Version)$ltsLabel"
            }
        }
    }
    catch {
        Write-Verbose "Failed to fetch Microsoft OpenJDK versions from web: $_"
        Write-Error "Failed to fetch Microsoft OpenJDK versions: $_"
        Write-Output ""
        Write-Output "Commonly available Microsoft OpenJDK versions you can try:"
        Write-Output " LTS versions: 21, 17, 11"
        Write-Output " Latest: 20"
        Write-Output ""
        Write-Output "Get latest versions from: https://learn.microsoft.com/en-us/java/openjdk/download-major-urls"
    }
}

function Get-DevVmMavenVersion {
    param(
        [PSObject]$RuntimeInfo,
        [bool]$ShowAll,
        [switch]$Update
    )

    try {
        $content = Get-DevVmCachedContent -Uri $RuntimeInfo.indexUrl -Update:$Update -Runtime 'maven' -ErrorAction Stop
        $releases = $content | ConvertFrom-Json

        # Filter Maven releases (tag format: maven-3.9.0, maven-3.8.8, etc.)
        $mavenReleases = $releases | Where-Object {
            $_.tag_name -match '^maven-\d+\.\d+\.\d+$'
        } | ForEach-Object {
            [PSCustomObject]@{
                Tag = $_.tag_name
                Version = $_.tag_name -replace '^maven-', ''
                Published = $_.published_at
            }
        } | Sort-Object -Property @{Expression={[version]$_.Version}} -Descending

        if ($ShowAll) {
            Write-Output "Available Maven versions:"
            $mavenReleases | ForEach-Object {
                Write-Output " $($_.Version) (published: $($_.Published.Substring(0,10)))"
            }
        }
        else {
            # Group by major.minor and show latest patch version for each
            Write-Output "Latest Maven versions (one per minor version):"
            $mavenReleases | Group-Object {
                $parts = $_.Version.Split('.')
                "$($parts[0]).$($parts[1])"
            } | ForEach-Object {
                $latest = $_.Group | Select-Object -First 1
                Write-Output " $($latest.Version) (published: $($latest.Published.Substring(0,10)))"
            }
        }
    }
    catch {
        Write-Error "Failed to fetch Maven versions from GitHub API: $_"
        Write-Output ""
        Write-Output "Common Maven versions you can try:"
        Write-Output " 3.9.6"
        Write-Output " 3.9.5"
        Write-Output " 3.9.4"
        Write-Output " 3.8.8"
        Write-Output " 3.8.7"
        Write-Output " 3.8.6"
    }
}

function Get-DevVmLeinVersion {
    param(
        [PSObject]$RuntimeInfo,
        [bool]$ShowAll,
        [switch]$Update
    )

    try {
        $content = Get-DevVmCachedContent -Uri $RuntimeInfo.indexUrl -Update:$Update -Runtime 'lein' -ErrorAction Stop
        $releases = $content | ConvertFrom-Json

        # Filter Leiningen releases (tags like: 2.10.0, 2.9.10, etc.)
        $leinReleases = $releases | Where-Object {
            $_.tag_name -match '^\d+\.\d+\.\d+$'
        } | ForEach-Object {
            [PSCustomObject]@{
                Version = $_.tag_name
                Published = $_.published_at
            }
        } | Sort-Object -Property @{Expression={[version]$_.Version}} -Descending

        if ($ShowAll) {
            Write-Output "Available Leiningen versions:"
            $leinReleases | ForEach-Object {
                Write-Output " $($_.Version) (published: $($_.Published.Substring(0,10)))"
            }
        }
        else {
            # Group by major.minor and show latest patch version for each
            Write-Output "Latest Leiningen versions (one per minor version):"
            $leinReleases | Group-Object {
                $parts = $_.Version.Split('.')
                "$($parts[0]).$($parts[1])"
            } | ForEach-Object {
                $latest = $_.Group | Select-Object -First 1
                Write-Output " $($latest.Version) (published: $($latest.Published.Substring(0,10)))"
            }
        }
    }
    catch {
        Write-Error "Failed to fetch Leiningen versions from GitHub API: $_"
        Write-Output ""
        Write-Output "Common Leiningen versions you can try:"
        Write-Output " 2.10.0"
        Write-Output " 2.9.10"
        Write-Output " 2.9.9"
        Write-Output " 2.9.8"
        Write-Output " 2.9.7"
    }
}

# Safe JSON parser for .devvm (handles BOM and trailing commas)
function ConvertFrom-DevVmJsonSafe {
    param([string]$Raw)

    if ([string]::IsNullOrWhiteSpace($Raw)) {
        return $null
    }

    $normalized = $Raw.TrimStart([char]0xFEFF)
    $normalized = [regex]::Replace($normalized, ',\s*([}\]])', '$1')

    try {
        return $normalized | ConvertFrom-Json
    }
    catch {
        Write-Verbose "Failed to parse .devvm JSON: $_"
        return $null
    }
}

# Verify runtime command executes after activation
function Test-DevVmRuntimeCommand {
    param(
        [string]$Runtime,
        [string]$Command,
        [string]$VersionFlag
    )

    if ([string]::IsNullOrWhiteSpace($Command)) {
        Write-Verbose "No command configured for runtime ${Runtime}"
        return $null
    }

    $cmd = Get-Command $Command -ErrorAction SilentlyContinue
    if (-not $cmd) {
        Write-Warning "Could not find command for ${Runtime} after activation: $Command"
        return $null
    }

    $versionArgs = @()
    if (-not [string]::IsNullOrWhiteSpace($VersionFlag)) {
        $versionArgs += $VersionFlag
    }

    try {
        # Capture the first line of output from the command
        # Commands like 'java -version' write to stderr, so we redirect with 2>&1
        # We use a script block with &() to properly handle the stderr output
        $ErrorActionPreference = 'Continue'
        $output = & {
            $result = & $Command @versionArgs 2>&1
            return $result
        } | Select-Object -First 1 | ForEach-Object { "$_" }

        if ($output -and "$output".Trim() -ne "") {
            # Format: [RUNTIME_NAME] version_output
            Write-Information " [OK] ${Runtime}: $output" -InformationAction Continue
            return $output
        }

        Write-Verbose "No output from $Runtime command"
        return $null
    }
    catch {
        Write-Warning "Failed to execute $Runtime command: $_"
        return $null
    }
}