content/src/scripts/Get-AzurePricing.ps1

# ---------------------------------------------------------------------------
# Get-AzurePricing.ps1
# Axeon Pricing Engine — Dynamic Azure Retail Prices API Integration
#
# Fetches live pricing from the public (no auth required) Azure Retail
# Prices REST API and caches results locally. Dynamically resolves ARM
# resource types to pricing API queries — no curated mapping needed.
#
# 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 -DeploymentFile ./deployments/hub-networking.yaml
# Update-AxeonPricing -Region uksouth
# ---------------------------------------------------------------------------

$ErrorActionPreference = "Stop"

# ============================================================================
# INTERNAL — ARM Resource Type Discovery
# ============================================================================

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

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

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

    $azCmd = Get-Command 'az' -ErrorAction SilentlyContinue
    if (-not $azCmd) {
        Write-Warning "Azure CLI ('az') not found. 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 = [System.Collections.Generic.List[PSCustomObject]]::new()

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

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

                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)"
                    }
                }

                if ($resource.ContainsKey('kind')) {
                    $info.Kind = $resource.kind
                }

                $resources.Add([PSCustomObject]$info)

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

                # Handle inline module deployments
                if ($resource.type -eq 'Microsoft.Resources/deployments' -and
                    $resource.ContainsKey('properties') -and
                    $resource.properties.ContainsKey('template') -and
                    $resource.properties.template.ContainsKey('resources')) {
                    Extract-Resources -ResourceArray $resource.properties.template.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 @()
    }
}

<#
.SYNOPSIS
    Extracts all ARM resource types from a parsed ARM template object.
.DESCRIPTION
    Recursively walks resources, nested resources, and inline module deployments.
    Returns a deduplicated list of resource type strings.
#>

function Extract-ResourceTypesFromArm {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $ArmTemplate
    )

    $types = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    function Recurse-Resources {
        param($Resources)
        if (-not $Resources) { return }
        foreach ($resource in $Resources) {
            if ($resource.type) {
                [void]$types.Add($resource.type)
            }
            if ($resource.resources) {
                Recurse-Resources -Resources $resource.resources
            }
            if ($resource.properties -and
                $resource.properties.template -and
                $resource.properties.template.resources) {
                Recurse-Resources -Resources $resource.properties.template.resources
            }
        }
    }

    if ($ArmTemplate.resources) {
        Recurse-Resources -Resources $ArmTemplate.resources
    }

    return @($types)
}

<#
.SYNOPSIS
    Discovers ARM resource types from a deployment YAML file by compiling its Bicep modules.
.DESCRIPTION
    Parses the deployment YAML, resolves each module's template path, compiles
    Bicep to ARM JSON, and returns all unique resource types found.
#>

function Get-ResourceTypesFromDeployment {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$DeploymentFile,

        [string]$BicepRoot
    )

    if (-not (Test-Path $DeploymentFile)) {
        Write-Warning "Deployment file not found: $DeploymentFile"
        return @()
    }

    $yamlModule = Get-Module -ListAvailable -Name 'powershell-yaml' -ErrorAction SilentlyContinue
    if (-not $yamlModule) {
        Write-Warning "Module 'powershell-yaml' not found. Install: Install-Module powershell-yaml"
        return @()
    }
    Import-Module powershell-yaml -ErrorAction SilentlyContinue

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

    if (-not $deploy.modules -or $deploy.modules.Count -eq 0) {
        Write-Warning "Deployment has no modules defined."
        return @()
    }

    if (-not $BicepRoot) {
        $BicepRoot = Join-Path $PSScriptRoot ".." "bicep"
    }

    $allTypes = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    foreach ($mod in $deploy.modules) {
        $templatePath = Join-Path $BicepRoot $mod.template
        if (-not (Test-Path $templatePath)) {
            Write-Warning " Template not found: $templatePath"
            continue
        }

        $resources = Get-BicepResourceTypes -TemplatePath $templatePath
        foreach ($r in $resources) {
            [void]$allTypes.Add($r.Type)
        }
    }

    return @($allTypes)
}


# ============================================================================
# INTERNAL — Dynamic ARM Type to Pricing API Query
# ============================================================================

<#
.SYNOPSIS
    Derives a pricing API search term from an ARM resource type.
.DESCRIPTION
    Algorithmically converts an ARM namespace to a human-readable service name
    by stripping the 'Microsoft.' prefix and splitting on camelCase boundaries.

    Examples:
        Microsoft.KeyVault/vaults -> "Key Vault"
        Microsoft.Network/virtualNetworks -> "Network"
        Microsoft.Compute/virtualMachines -> "Compute"
        Microsoft.Storage/storageAccounts -> "Storage"
        Microsoft.ContainerRegistry/registries -> "Container Registry"
        Microsoft.ContainerService/managedClusters -> "Container Service"
        Microsoft.OperationalInsights/workspaces -> "Operational Insights"
        Microsoft.ManagedIdentity/userAssignedIdentities -> "Managed Identity"

    This is used as a contains() filter on the pricing API, so approximate
    matches still return results.
#>

function ConvertTo-PricingSearchTerm {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$ArmResourceType
    )

    $parts = $ArmResourceType -split '/'
    if ($parts.Count -lt 2) { return $null }

    $namespacePart = $parts[0] -replace '^Microsoft\.', ''

    # Split PascalCase into words: "KeyVault" -> "Key Vault"
    $serviceName = ($namespacePart -creplace '([a-z])([A-Z])', '$1 $2') -creplace '([A-Z]+)([A-Z][a-z])', '$1 $2'

    return $serviceName
}

<#
.SYNOPSIS
    Queries the Azure Retail Prices API using a contains() filter on serviceName.
.DESCRIPTION
    Uses the free, no-auth-required endpoint. Handles pagination.
    Returns an array of meter objects.
#>

function Invoke-PricingApiQuery {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Region,

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

        [string]$CurrencyCode = 'USD',

        [int]$MaxPages = 5
    )

    $allMeters = [System.Collections.Generic.List[object]]::new()

    $filter = "armRegionName eq '$Region' and contains(serviceName, '$ServiceSearchTerm')"
    $encodedFilter = [System.Uri]::EscapeDataString($filter)
    $url = "https://prices.azure.com/api/retail/prices?currencyCode=${CurrencyCode}&`$filter=${encodedFilter}"

    $pageCount = 0

    try {
        while ($url -and $pageCount -lt $MaxPages) {
            $pageCount++
            Write-Verbose " Fetching page $pageCount for '$ServiceSearchTerm'..."

            $response = Invoke-RestMethod -Uri $url -Method Get -TimeoutSec 30

            if ($response.Items) {
                foreach ($item in $response.Items) {
                    $allMeters.Add($item)
                }
            }

            $url = $response.NextPageLink
        }
    }
    catch {
        Write-Warning "Failed to query pricing for '$ServiceSearchTerm': $($_.Exception.Message)"
    }

    return $allMeters
}


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

<#
.SYNOPSIS
    Fetches live Azure pricing data and caches it locally.
.DESCRIPTION
    Dynamically discovers ARM resource types — either from a deployment file's
    Bicep templates or from the resource providers registered in your Azure
    subscription — then queries the Azure Retail Prices API for each.

    No curated mapping file is needed. The ARM resource type namespace is
    algorithmically converted to a pricing API search term.

    Results are written to a local JSON cache file used by
    Get-AxeonCostEstimate and the -FreeOnly bootstrap flag.
.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
    Path to a deployment YAML file. Its Bicep templates are compiled and
    resource types are discovered automatically. This is the recommended
    approach — only fetches pricing for resources you actually deploy.
.PARAMETER BicepRoot
    Root path for resolving Bicep template paths from deployment YAML.
    Defaults to src/bicep relative to the script directory.
.EXAMPLE
    Update-AxeonPricing -PlatformSpecPath ./platform-spec.json -DeploymentFile ./deployments/hub-networking.yaml
.EXAMPLE
    Update-AxeonPricing -Region uksouth -DeploymentFile ./deployments/hub-networking.yaml
.EXAMPLE
    Update-AxeonPricing -PlatformSpecPath ./platform-spec.json
#>

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

        [string]$Region,

        [string]$CurrencyCode = 'USD',

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

        [string]$DeploymentFile,

        [string]$BicepRoot
    )

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

    # Resolve region from platform spec if not provided
    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 ""

    # ── Step 1: Discover resource types ──────────────────────────────────

    $resourceTypes = @()

    if ($DeploymentFile) {
        Write-Host " Discovering resource types from deployment..." -ForegroundColor Gray

        if (-not $BicepRoot) {
            $BicepRoot = Join-Path $PSScriptRoot ".." "bicep"
        }

        $resourceTypes = Get-ResourceTypesFromDeployment `
            -DeploymentFile $DeploymentFile `
            -BicepRoot $BicepRoot

        if ($resourceTypes.Count -gt 0) {
            Write-Host " Found $($resourceTypes.Count) resource type(s):" -ForegroundColor Green
            foreach ($rt in $resourceTypes) {
                Write-Host " - $rt" -ForegroundColor DarkGray
            }
        }
        else {
            Write-Host " No resource types discovered from deployment." -ForegroundColor DarkYellow
        }
    }

    # If no deployment file or no resource types found, try subscription providers
    if ($resourceTypes.Count -eq 0 -and -not $DeploymentFile) {
        Write-Host " No deployment file specified." -ForegroundColor DarkYellow
        Write-Host " Trying to discover registered resource providers from Azure subscription..." -ForegroundColor Gray

        try {
            $providersJson = az provider list --query "[?registrationState=='Registered'].namespace" -o json 2>$null
            if ($LASTEXITCODE -eq 0 -and $providersJson) {
                $namespaces = $providersJson | ConvertFrom-Json
                # Use namespaces directly — we query by derived service name
                $resourceTypes = @($namespaces | ForEach-Object { "$_/all" })
                Write-Host " Found $($namespaces.Count) registered resource providers." -ForegroundColor Green
            }
        }
        catch {
            Write-Verbose " Could not query Azure providers: $($_.Exception.Message)"
        }
    }

    # Final fallback: common resource types
    if ($resourceTypes.Count -eq 0) {
        Write-Host " Using common resource types as fallback." -ForegroundColor DarkYellow
        $resourceTypes = @(
            'Microsoft.Compute/virtualMachines'
            'Microsoft.Network/virtualNetworks'
            'Microsoft.Network/publicIPAddresses'
            'Microsoft.Network/loadBalancers'
            'Microsoft.Network/applicationGateways'
            'Microsoft.Network/bastionHosts'
            'Microsoft.Network/azureFirewalls'
            'Microsoft.Network/natGateways'
            'Microsoft.Network/privateEndpoints'
            'Microsoft.Network/privateDnsZones'
            'Microsoft.Storage/storageAccounts'
            'Microsoft.KeyVault/vaults'
            'Microsoft.ContainerRegistry/registries'
            'Microsoft.ContainerService/managedClusters'
            'Microsoft.Sql/servers'
            'Microsoft.Web/sites'
            'Microsoft.Web/serverfarms'
            'Microsoft.OperationalInsights/workspaces'
            'Microsoft.Insights/components'
            'Microsoft.ManagedIdentity/userAssignedIdentities'
            'Microsoft.Cdn/profiles'
        )
    }

    Write-Host ""

    # ── Step 2: Derive unique service search terms ───────────────────────

    $serviceQueries = @{}  # searchTerm -> list of armTypes

    foreach ($rt in $resourceTypes) {
        $searchTerm = ConvertTo-PricingSearchTerm -ArmResourceType $rt
        if (-not $searchTerm) { continue }

        if (-not $serviceQueries.ContainsKey($searchTerm)) {
            $serviceQueries[$searchTerm] = [System.Collections.Generic.List[string]]::new()
        }
        if ($rt -notin $serviceQueries[$searchTerm]) {
            $serviceQueries[$searchTerm].Add($rt)
        }
    }

    Write-Host " Fetching pricing for $($serviceQueries.Count) service(s)..." -ForegroundColor Gray
    Write-Host ""

    # ── Step 3: Query pricing API per service term ───────────────────────

    $allServiceMeters = @{}  # searchTerm -> meters
    $totalQueries = $serviceQueries.Count
    $currentQuery = 0

    foreach ($entry in $serviceQueries.GetEnumerator()) {
        $currentQuery++
        $searchTerm = $entry.Key

        Write-Host " [$currentQuery/$totalQueries] $searchTerm" -ForegroundColor Gray

        $meters = Invoke-PricingApiQuery `
            -Region $Region `
            -ServiceSearchTerm $searchTerm `
            -CurrencyCode $CurrencyCode

        $meterCount = @($meters).Count
        if ($meterCount -gt 0) {
            Write-Host " -> $meterCount meter(s)" -ForegroundColor DarkGray
        }
        else {
            Write-Host " -> No pricing data" -ForegroundColor DarkYellow
        }

        $allServiceMeters[$searchTerm] = $meters
    }

    # ── Step 4: Build per-resource-type pricing data ─────────────────────

    $resourcePricing = @{}

    foreach ($entry in $serviceQueries.GetEnumerator()) {
        $searchTerm = $entry.Key
        $armTypes   = $entry.Value
        $meters     = $allServiceMeters[$searchTerm]

        foreach ($armType in $armTypes) {
            # Filter: only Consumption type (not Reservation, DevTestConsumption etc.)
            $consumptionMeters = @($meters | Where-Object { $_.type -eq 'Consumption' })

            # Determine if free: no consumption meters OR all have $0 price
            $paidConsumption = @($consumptionMeters | Where-Object { $_.retailPrice -gt 0 })
            $isFree = ($consumptionMeters.Count -eq 0) -or ($paidConsumption.Count -eq 0)

            # Store a compact subset of meters in the cache
            $cachedMeters = @($consumptionMeters |
                Sort-Object retailPrice |
                Select-Object -First 30 |
                ForEach-Object {
                    @{
                        meterName     = $_.meterName
                        skuName       = $_.skuName
                        armSkuName    = $_.armSkuName
                        retailPrice   = $_.retailPrice
                        unitOfMeasure = $_.unitOfMeasure
                        productName   = $_.productName
                    }
                })

            $resourcePricing[$armType] = @{
                searchTerm        = $searchTerm
                isFree            = $isFree
                meterCount        = $consumptionMeters.Count
                consumptionMeters = $cachedMeters
            }
        }
    }

    # ── Step 5: Write cache ──────────────────────────────────────────────

    $cache = @{
        lastUpdated = (Get-Date -Format 'o')
        region      = $Region
        currency    = $CurrencyCode
        resources   = $resourcePricing
    }

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

    # Summary
    $freeCount = @($resourcePricing.GetEnumerator() | Where-Object { $_.Value.isFree }).Count
    $paidCount = @($resourcePricing.GetEnumerator() | Where-Object { -not $_.Value.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
}