Public/Get-WinEOL.ps1

function Get-WinEOL {
    <#
    .SYNOPSIS
        Retrieves product EOL information and lifecycle status.
 
    .DESCRIPTION
        The Get-WinEOL cmdlet fetches product lifecycle data from the endoflife.date API.
        It supports wildcard searching, session-level caching to reduce API load, and rich object output
        including calculated status (Active, NearEOL, EOL) and days remaining.
 
        It also includes smart fallback logic for complex products like 'windows-11' that are part of the 'windows' product availability.
 
    .PARAMETER ProductName
        The name of the product to query (e.g., 'windows-11', 'windows-server-2022').
        Supports wildcards (e.g., 'windows-*').
 
    .PARAMETER Release
        A specific release to query.
 
    .PARAMETER Latest
        Switch to return only the latest release.
 
    .PARAMETER Refresh
        Switch to bypass the session cache and force a fresh API call.
 
    .PARAMETER Pro
        Filter for 'Pro' edition (implies *-W suffix).
 
    .PARAMETER HomeEdition
        Filter for 'Home' edition (implies *-W suffix). Alias: Home.
 
    .PARAMETER Enterprise
        Filter for 'Enterprise' edition (implies *-E suffix).
 
    .PARAMETER Education
        Filter for 'Education' edition (implies *-E suffix).
 
    .PARAMETER IoT
        Filter for 'IoT' edition (implies *-E suffix).
 
    .PARAMETER Version
        Filter by version/feature release (e.g., '25H2', '24H2', '23H2'). Supports wildcards.
        Filters results where the cycle contains the specified version string.
 
    .PARAMETER Status
        Filter by lifecycle status. Options: 'All', 'Active', 'EOL', 'NearEOL'. Default is 'All'.
 
    .EXAMPLE
        Get-WinEOL -ProductName "windows-11"
        Retrieves all Windows 11 release information.
 
    .EXAMPLE
        Get-WinEOL -ProductName "windows-11" -Version "25H2"
        Retrieves Windows 11 25H2 release information.
 
    .EXAMPLE
        Get-WinEOL -ProductName "windows-server-*" -Status Active
        Retrieves all active Windows Server versions.
 
    .EXAMPLE
        Get-WinEOL -ProductName "windows-server-2022" -Latest
        Retrieves the latest Python release info.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param(
        [Parameter(Mandatory = $false, Position = 0, ValueFromPipeline = $true)]
        [string]$ProductName = 'windows-*',

        [Parameter()]
        [string]$Release,

        [Parameter()]
        [switch]$Latest,

        [Parameter()]
        [switch]$Refresh,

        [Parameter()]
        [string]$Version,

        # Edition Filters (Implied naming convention handling)
        [Parameter(ParameterSetName = 'Default')]
        [switch]$Pro,
        [Parameter(ParameterSetName = 'Default')]
        [Alias('Home')]
        [switch]$HomeEdition,
        [Parameter(ParameterSetName = 'Default')]
        [switch]$Workstation,
        [Parameter(ParameterSetName = 'Default')]
        [switch]$Enterprise,
        [Parameter(ParameterSetName = 'Default')]
        [switch]$Education,
        [Parameter(ParameterSetName = 'Default')]
        [switch]$IoT,

        # Status Filter
        [Parameter()]
        [ValidateSet('All', 'Active', 'EOL', 'NearEOL')]
        [string]$Status = 'All'
    )

    begin {
        # Initialize Cache
        if (-not (Get-Command Get-WinEOLCache -ErrorAction SilentlyContinue)) {
            try { . $PSScriptRoot\..\Private\WinEOL.Cache.ps1 } catch {}
        }
    }

    process {
        # Input Validation (Security & Ruggedness)
        # Allow alphanumeric, hyphens, and wildcards.
        if ($ProductName -notmatch '^[a-zA-Z0-9\-\*\.]+$') {
            Throw "Invalid ProductName '$ProductName'. Product names must only contain letters, numbers, hyphens, periods, or wildcards (*). This check prevents malformed requests."
        }

        # Handle implied product name suffix (-W / -E)
        $suffixFilter = $null
        if ($Pro -or $HomeEdition -or $Workstation) { $suffixFilter = "*W" }
        if ($Enterprise -or $Education -or $IoT) { $suffixFilter = "*E" }

        # 1. Wildcard Handling
        if ($ProductName -match '\*') {
            Write-Verbose "Wildcard detected in '$ProductName'. Fetching all products to search."
            
            $cacheKey = "ALL_PRODUCTS"
            $allProducts = $null
            if (-not $Refresh) { $allProducts = (Get-WinEOLCache)[$cacheKey] }
            
            if ($null -eq $allProducts) {
                try {
                    $response = (Invoke-RestMethod "https://endoflife.date/api/v1/products" -ErrorAction Stop)
                    # Extract product names from v1 API response
                    $allProducts = $response.result | Select-Object -ExpandProperty name
                    Set-WinEOLCache -Key $cacheKey -Value $allProducts
                }
                catch {
                    Write-Error "Failed to fetch product list: $_"
                    return
                }
            }

            $foundProducts = $allProducts | Where-Object { $_ -like $ProductName }
            
            # Note: Suffix filter on *Product Names* works if products are named "foo-w".
            # But for Windows 11, the suffix applies to *Releases*.
            # If the product list logic matches "windows-11", we recurse.
            # But "windows-11" isn't in product list.
            # So "windows-*" matches "windows", "windows-server".
            # Then we call Get-WinEOL "windows" ... filtering happens there?
            # Issue: "windows" contains *all* versions.
            # If user asks for "windows-*", they get "windows" product (all releases).
            # We might want to filter the *Output* of Get-WinEOL "windows" based on the Wildcard?
            # Complex. For now, basic wildcard matches Product Slugs.

            if (-not $foundProducts) {
                Write-Warning "No products found matching '$ProductName'."
                return
            }

            foreach ($m in $foundProducts) {
                Get-WinEOL -ProductName $m -Refresh:$Refresh -Status $Status -Latest:$Latest -Version $Version -Pro:$Pro -HomeEdition:$HomeEdition -Enterprise:$Enterprise -Education:$Education -IoT:$IoT -Workstation:$Workstation
            }
            return
        }

        # 2. Specific Product Handling
        $url = "https://endoflife.date/api/v1/products/$($ProductName)"
        if ($Release) { $url += "/releases/$Release" }
        elseif ($Latest) { $url += "/releases/latest" }

        $cacheKey = "PRODUCT_$ProductName"
        if ($Release) { $cacheKey += "_$Release" }
        if ($Latest) { $cacheKey += "_LATEST" }

        $data = $null
        if (-not $Refresh) { $data = (Get-WinEOLCache)[$cacheKey] }
        
        $fallbackMode = $false
        $fallbackFilter = $null
        $results = @()

        if ($null -eq $data) {
            try {
                $response = Invoke-RestMethod -Uri $url -Method Get -ErrorAction Stop
                $data = $response
                # Normalize API response (Handle 'result.releases' wrapper vs direct array)
                if ($data.result -and $data.result.releases) {
                    $data = $data.result.releases
                }

                if ($Latest) { $data = @($data) } 
                Set-WinEOLCache -Key $cacheKey -Value $data
            }
            catch {
                $err = $_
                if ($err.Exception.Response.StatusCode -eq 404) {
                    # Smart Fallback Logic
                    if ($ProductName -match '^windows-(\d+(\.\d+)?)$') {
                        Write-Verbose "Detected Windows version '$($matches[1])'. Redirecting to 'windows' product."
                        $fallbackProduct = 'windows'
                        $fallbackFilter = $matches[1] + "*"
                        $fallbackMode = $true
                    }
                    elseif ($ProductName -match '^windows-server-(.*)$') {
                        Write-Verbose "Detected Windows Server version '$($matches[1])'. Redirecting to 'windows-server' product."
                        $fallbackProduct = 'windows-server'
                        $fallbackFilter = "*" + $matches[1] + "*" 
                        # Note: Server versions are like "2019", "2012-r2". Regex capture needs match.
                        # windows-server-2019 -> match 1 = 2019. Filter *2019*.
                        $fallbackMode = $true
                    }
                     
                    if ($fallbackMode) {
                        # Recursive call with the base product, then we filter results
                        # BUT we can't easily recurse and filter inside.
                        # We will fetch the base product data manually here.
                        try {
                            $url = "https://endoflife.date/api/v1/products/$fallbackProduct"
                            $data = Invoke-RestMethod -Uri $url -ErrorAction Stop
                            
                            # Normalize Fallback Data
                            if ($data.result -and $data.result.releases) {
                                $data = $data.result.releases
                            }

                            Set-WinEOLCache -Key "PRODUCT_$fallbackProduct" -Value $data
                        }
                        catch {
                            Write-Error "Failed to fetch fallback product '$fallbackProduct': $_"
                            return
                        }
                    }
                    else {
                        Write-Warning "Product '$ProductName' not found."
                        # Fuzzy (simplified)
                        return
                    }
                }
                else {
                    Write-Error "API Error: $($err.Message)"
                    return
                }
            }
        }


        foreach ($item in $data) {
            # Normalize Cycle/Name
            $cycle = if ($item.cycle) { $item.cycle } else { $item.name }
             
            # Apply Fallback Filter (e.g. only show "11" cycle for "windows-11" request)
            if ($fallbackMode) {
                if ($cycle -notlike $fallbackFilter -and $item.name -notlike $fallbackFilter) { continue }
            }
             
            # Apply Suffix Filter (Pro/Home/etc)
            if ($suffixFilter) {
                if ($item.name -notlike $suffixFilter) { continue }
            }

            # Apply Version Filter
            if ($Version) {
                $versionPattern = "*$Version*"
                if ($cycle -notlike $versionPattern) { continue }
            }

            # Calculate Days Remaining
            $eolDate = $null
            $days = 0
            $statusStr = "Active"
            $isSupported = $true
             
            # Handle both API formats: v1 API uses 'eolFrom', direct API uses 'eol'
            $eolValue = if ($item.eolFrom) { $item.eolFrom } else { $item.eol }
            
            # Check if already marked as EOL (v1 API)
            if ($item.PSObject.Properties['isEol'] -and $item.isEol -eq $true) {
                $statusStr = "EOL"
                $isSupported = $false
                if ($eolValue -as [DateTime]) {
                    $eolDate = [DateTime]$eolValue
                    $days = ($eolDate - (Get-Date)).Days
                }
            }
            elseif ($eolValue -and $eolValue -ne $true -and $eolValue -ne $false) {
                if ($eolValue -as [DateTime]) {
                    $eolDate = [DateTime]$eolValue
                    $days = ($eolDate - (Get-Date)).Days
                    
                    if ($days -lt 0) { 
                        $statusStr = "EOL" 
                        $isSupported = $false
                    }
                    elseif ($days -le 60) { $statusStr = "NearEOL" }
                }
            }
            elseif ($eolValue -eq $true) {
                # Boolean true usually means EOL in the past or simple "Yes"
                $statusStr = "EOL"
                $isSupported = $false
            }

            # Add Properties by creating new object with all properties
            $objProps = [ordered]@{}
            
            # Copy existing properties except ones we'll override
            $excludeProps = @('Cycle', 'EOL', 'DaysRemaining', 'Status', 'IsSupported', 'Product')
            foreach ($prop in $item.PSObject.Properties) {
                if ($prop.Name -notin $excludeProps) {
                    $objProps[$prop.Name] = $prop.Value
                }
            }
            
            # Add calculated properties
            $objProps['Cycle'] = $cycle
            $objProps['EOL'] = $eolDate
            $objProps['DaysRemaining'] = $days
            $objProps['Status'] = $statusStr
            $objProps['IsSupported'] = $isSupported
            $objProps['Product'] = $ProductName
            
            $obj = [PSCustomObject]$objProps
             
            # Add TypeName
            $obj.PSTypeNames.Insert(0, "WinEOL.ProductInfo")
             
            # Status Filter
            if ($Status -ne 'All' -and $statusStr -ne $Status) { continue }

            $results += $obj
        }
        
        return $results
    }
}