content/src/scripts/Get-AzurePricing.ps1

# ---------------------------------------------------------------------------
# Get-AzurePricing.ps1
# Axeon Pricing Engine — Fetches live pricing from Azure Retail Prices API
#
# Uses the public (no auth required) Azure Retail Prices REST API to fetch
# current pricing data and caches it locally in a JSON file.
#
# API docs: https://learn.microsoft.com/en-us/rest/api/cost-management/retail-prices/azure-retail-prices
#
# Usage:
# . ./Get-AzurePricing.ps1
# Update-AxeonPricing -PlatformSpecPath ./platform-spec.json
# Update-AxeonPricing -Region uksouth -OutputPath ./azure-pricing.json
# Update-AxeonPricing -PlatformSpecPath ./platform-spec.json -DeploymentFile ./deployments/hub-networking.yaml
# ---------------------------------------------------------------------------

$ErrorActionPreference = "Stop"

# ============================================================================
# INTERNAL HELPERS
# ============================================================================

<#
.SYNOPSIS
    Loads the ARM-to-pricing mapping file shipped with Axeon.
#>

function Get-ArmPricingMap {
    [CmdletBinding()]
    param(
        [string]$MapPath
    )

    if (-not $MapPath) {
        # Try to find next to this script (scaffolded layout)
        $MapPath = Join-Path $PSScriptRoot ".." "schemas" "arm-pricing-map.json"
    }

    if (-not (Test-Path $MapPath)) {
        throw "ARM pricing map not found at '$MapPath'. Ensure Axeon is properly scaffolded."
    }

    $raw = Get-Content $MapPath -Raw | ConvertFrom-Json -AsHashtable
    return $raw.resources
}

<#
.SYNOPSIS
    Fetches pricing data from Azure Retail Prices API for a given service name and region.
.DESCRIPTION
    Handles OData pagination automatically. Returns an array of price items.
#>

function Get-RetailPrices {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$ServiceName,

        [Parameter(Mandatory)]
        [string]$Region,

        [string]$CurrencyCode = 'USD'
    )

    $baseUrl = "https://prices.azure.com/api/retail/prices"
    $filter = "armRegionName eq '$Region' and serviceName eq '$ServiceName'"
    $url = "${baseUrl}?currencyCode=${CurrencyCode}&`$filter=${filter}"

    $allItems = @()
    $pageCount = 0
    $maxPages = 50  # Safety limit

    while ($url -and $pageCount -lt $maxPages) {
        $pageCount++
        Write-Verbose " Fetching page $pageCount for '$ServiceName'..."

        try {
            $response = Invoke-RestMethod -Uri $url -Method Get -TimeoutSec 30
        }
        catch {
            Write-Warning "Failed to fetch pricing for '$ServiceName': $($_.Exception.Message)"
            return $allItems
        }

        if ($response.Items) {
            $allItems += $response.Items
        }

        $url = $response.NextPageLink
    }

    return $allItems
}

<#
.SYNOPSIS
    Compiles a Bicep file to ARM JSON and extracts resource types and SKUs.
.DESCRIPTION
    Requires the Azure CLI with Bicep extension installed.
    Returns an array of objects with Type and Sku properties.
#>

function Get-BicepResourceTypes {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$TemplatePath
    )

    if (-not (Test-Path $TemplatePath)) {
        Write-Warning "Template not found: $TemplatePath"
        return @()
    }

    # Check for az CLI
    $azCmd = Get-Command 'az' -ErrorAction SilentlyContinue
    if (-not $azCmd) {
        Write-Warning "Azure CLI ('az') not found. Cannot compile Bicep templates for resource discovery. Install from https://aka.ms/installazurecli"
        return @()
    }

    try {
        $armJson = az bicep build --file $TemplatePath --stdout 2>$null
        if ($LASTEXITCODE -ne 0) {
            Write-Warning "Bicep compilation failed for '$TemplatePath'. Ensure 'az bicep' is installed."
            return @()
        }

        $arm = $armJson | ConvertFrom-Json -AsHashtable

        $resources = @()

        # Recursively extract resources (handles nested resources)
        function Extract-Resources {
            param([array]$ResourceArray)

            foreach ($resource in $ResourceArray) {
                $info = @{
                    Type    = $resource.type
                    ApiVersion = $resource.apiVersion
                    Sku     = $null
                    Tier    = $null
                    Kind    = $null
                }

                # Extract SKU if present
                if ($resource.ContainsKey('sku')) {
                    if ($resource.sku -is [hashtable]) {
                        $info.Sku = $resource.sku.name ?? $resource.sku.Name ?? ($resource.sku | ConvertTo-Json -Compress)
                        $info.Tier = $resource.sku.tier ?? $resource.sku.Tier
                    }
                    else {
                        $info.Sku = "$($resource.sku)"
                    }
                }

                # Extract kind if present (e.g., StorageV2, FunctionApp)
                if ($resource.ContainsKey('kind')) {
                    $info.Kind = $resource.kind
                }

                $resources += [PSCustomObject]$info

                # Handle nested child resources
                if ($resource.ContainsKey('resources') -and $resource.resources) {
                    Extract-Resources -ResourceArray $resource.resources
                }
            }
        }

        if ($arm.ContainsKey('resources') -and $arm.resources) {
            Extract-Resources -ResourceArray $arm.resources
        }

        return $resources
    }
    catch {
        Write-Warning "Error processing Bicep template '$TemplatePath': $($_.Exception.Message)"
        return @()
    }
}

# ============================================================================
# PUBLIC FUNCTION
# ============================================================================

<#
.SYNOPSIS
    Fetches live Azure pricing data and caches it locally.
.DESCRIPTION
    Queries the Azure Retail Prices API (free, no auth required) for all
    resource types known to Axeon's ARM pricing map. Optionally, when
    -DeploymentFile is specified, also compiles the deployment's Bicep
    templates and discovers additional resource types not in the curated map.

    Results are written to a local JSON cache file that other Axeon commands
    (like Get-AxeonCostEstimate) read for cost estimation.
.PARAMETER PlatformSpecPath
    Path to platform-spec.json. Used to read the target region.
.PARAMETER Region
    Azure region for pricing lookup (e.g., 'uksouth'). Overrides
    the region from platform-spec.json if both are provided.
.PARAMETER CurrencyCode
    ISO currency code (default: USD).
.PARAMETER OutputPath
    Path where the pricing cache JSON is written (default: ./azure-pricing.json).
.PARAMETER DeploymentFile
    Optional. Path to a deployment YAML file. When specified, its Bicep
    templates are compiled and any discovered resource types not in the
    curated map are also fetched from the pricing API.
.PARAMETER BicepRoot
    Root path for resolving Bicep template paths from deployment YAML.
    Defaults to src/bicep relative to the script directory.
.PARAMETER MapPath
    Path to the ARM pricing map JSON. Defaults to the one shipped with Axeon.
.EXAMPLE
    Update-AxeonPricing -PlatformSpecPath ./platform-spec.json
.EXAMPLE
    Update-AxeonPricing -Region uksouth -OutputPath ./pricing-cache.json
.EXAMPLE
    Update-AxeonPricing -PlatformSpecPath ./platform-spec.json -DeploymentFile ./deployments/hub-networking.yaml
#>

function Update-AxeonPricing {
    [CmdletBinding()]
    param(
        [string]$PlatformSpecPath,

        [string]$Region,

        [string]$CurrencyCode = 'USD',

        [string]$OutputPath = './azure-pricing.json',

        [string]$DeploymentFile,

        [string]$BicepRoot,

        [string]$MapPath
    )

    Write-Host ""
    Write-Host " Axeon Pricing Update" -ForegroundColor Cyan
    Write-Host " ─────────────────────────────────────────" -ForegroundColor DarkCyan
    Write-Host ""

    # Resolve region
    if (-not $Region -and $PlatformSpecPath) {
        if (Test-Path $PlatformSpecPath) {
            $spec = Get-Content $PlatformSpecPath -Raw | ConvertFrom-Json -AsHashtable
            $Region = $spec.location
        }
    }

    if (-not $Region) {
        throw "Region is required. Provide -Region or -PlatformSpecPath with a valid platform-spec.json."
    }

    Write-Host " Region : $Region" -ForegroundColor White
    Write-Host " Currency : $CurrencyCode" -ForegroundColor White
    Write-Host " Output : $OutputPath" -ForegroundColor White
    Write-Host ""

    # Load ARM pricing map
    $armMap = Get-ArmPricingMap -MapPath $MapPath

    # Collect all service names to query
    $serviceNames = @{}
    foreach ($armType in $armMap.Keys) {
        $entry = $armMap[$armType]
        $svcName = $entry.serviceName
        if ($svcName -and -not $serviceNames.ContainsKey($svcName)) {
            $serviceNames[$svcName] = $true
        }
    }

    # If deployment file provided, also discover resource types from Bicep
    $discoveredTypes = @{}
    if ($DeploymentFile) {
        Write-Host " Scanning deployment for additional resource types..." -ForegroundColor Gray

        if (-not (Test-Path $DeploymentFile)) {
            Write-Warning "Deployment file not found: $DeploymentFile"
        }
        else {
            if (-not $BicepRoot) {
                $BicepRoot = Join-Path $PSScriptRoot ".." "bicep"
            }

            # Need powershell-yaml for parsing
            $yamlModule = Get-Module -ListAvailable -Name 'powershell-yaml' -ErrorAction SilentlyContinue
            if (-not $yamlModule) {
                Write-Warning "Module 'powershell-yaml' not found. Cannot parse deployment YAML. Install: Install-Module powershell-yaml"
            }
            else {
                Import-Module powershell-yaml -ErrorAction SilentlyContinue

                $raw = Get-Content $DeploymentFile -Raw
                $deploy = $raw | ConvertFrom-Yaml

                if ($deploy.modules) {
                    foreach ($mod in $deploy.modules) {
                        $templatePath = Join-Path $BicepRoot $mod.template
                        Write-Host " Compiling: $($mod.template)" -ForegroundColor DarkGray

                        $resources = Get-BicepResourceTypes -TemplatePath $templatePath
                        foreach ($r in $resources) {
                            $discoveredTypes[$r.Type] = $r

                            # If not in curated map, try to determine service name
                            if (-not $armMap.ContainsKey($r.Type)) {
                                # Derive service name from the resource provider namespace
                                $provider = ($r.Type -split '/')[0]
                                $guessedService = switch -Wildcard ($provider) {
                                    'Microsoft.Network'           { 'Virtual Network' }
                                    'Microsoft.Storage'           { 'Storage' }
                                    'Microsoft.Compute'           { 'Virtual Machines' }
                                    'Microsoft.KeyVault'          { 'Key Vault' }
                                    'Microsoft.Web'               { 'Azure App Service' }
                                    'Microsoft.Sql'               { 'SQL Database' }
                                    'Microsoft.ContainerService'  { 'Azure Kubernetes Service' }
                                    'Microsoft.ContainerRegistry' { 'Container Registry' }
                                    default                       { $null }
                                }

                                if ($guessedService -and -not $serviceNames.ContainsKey($guessedService)) {
                                    $serviceNames[$guessedService] = $true
                                    Write-Host " Discovered: $($r.Type) → $guessedService" -ForegroundColor DarkYellow
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    # Fetch pricing for each service
    $pricingData = @{}
    $totalServices = $serviceNames.Count
    $currentService = 0

    foreach ($svcName in $serviceNames.Keys) {
        $currentService++
        Write-Host " [$currentService/$totalServices] Fetching: $svcName" -ForegroundColor Gray

        $prices = Get-RetailPrices -ServiceName $svcName -Region $Region -CurrencyCode $CurrencyCode

        if ($prices.Count -gt 0) {
            Write-Host " → $($prices.Count) price items" -ForegroundColor DarkGray
        }
        else {
            Write-Host " → No pricing data for region '$Region'" -ForegroundColor DarkYellow
        }

        $pricingData[$svcName] = $prices
    }

    # Build the cache structure: map each ARM resource type to pricing info
    $resourcePricing = @{}

    foreach ($armType in $armMap.Keys) {
        $entry = $armMap[$armType]
        $svcName = $entry.serviceName
        $isFree = [bool]$entry.defaultFree

        $meters = @()
        if ($pricingData.ContainsKey($svcName)) {
            $meters = @($pricingData[$svcName] | ForEach-Object {
                @{
                    meterName     = $_.meterName
                    skuName       = $_.skuName
                    retailPrice   = $_.retailPrice
                    unitPrice     = $_.unitPrice
                    unitOfMeasure = $_.unitOfMeasure
                    productName   = $_.productName
                    type          = $_.type           # Consumption vs Reservation
                    isPrimaryMeterRegion = $_.isPrimaryMeterRegion
                }
            })
        }

        # Determine if truly free: defaultFree or all consumption meters are $0
        $consumptionMeters = @($meters | Where-Object { $_.type -eq 'Consumption' })
        $allZero = ($consumptionMeters.Count -eq 0) -or
                   ($consumptionMeters | Where-Object { $_.retailPrice -gt 0 }).Count -eq 0

        $resourcePricing[$armType] = @{
            serviceName     = $svcName
            defaultFree     = $isFree
            isFree          = $isFree -or $allZero
            notes           = $entry.notes
            meterCount      = $meters.Count
            consumptionMeters = @($consumptionMeters | Select-Object -First 20)  # Keep cache manageable
        }
    }

    # Add any discovered types not in the curated map
    foreach ($armType in $discoveredTypes.Keys) {
        if (-not $resourcePricing.ContainsKey($armType)) {
            $resourcePricing[$armType] = @{
                serviceName     = 'Unknown'
                defaultFree     = $false
                isFree          = $false
                notes           = "Discovered from Bicep template. No curated pricing data — run Update-AxeonPricing to refresh."
                meterCount      = 0
                consumptionMeters = @()
            }
        }
    }

    # Write cache file
    $cache = @{
        '$schema'   = 'https://json-schema.org/draft-07/schema#'
        lastUpdated = (Get-Date -Format 'o')
        region      = $Region
        currency    = $CurrencyCode
        resources   = $resourcePricing
    }

    $cacheJson = $cache | ConvertTo-Json -Depth 10
    Set-Content -Path $OutputPath -Value $cacheJson -Encoding UTF8

    # Summary
    $freeCount = ($resourcePricing.Values | Where-Object { $_.isFree }).Count
    $paidCount = ($resourcePricing.Values | Where-Object { -not $_.isFree }).Count

    Write-Host ""
    Write-Host " Pricing cache updated:" -ForegroundColor Green
    Write-Host " File : $OutputPath" -ForegroundColor White
    Write-Host " Region : $Region" -ForegroundColor White
    Write-Host " Resource types: $($resourcePricing.Count)" -ForegroundColor White
    Write-Host " Free : $freeCount" -ForegroundColor Green
    Write-Host " Paid : $paidCount" -ForegroundColor Yellow
    Write-Host " Last updated : $($cache.lastUpdated)" -ForegroundColor DarkGray
    Write-Host ""

    return $cache
}