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
                    NameParam  = $null
                }

                # Extract the parameter name from the resource name expression
                # ARM JSON uses expressions like "[parameters('ip1')]" or
                # "[format('{0}-pip', parameters('ip1'))]" etc.
                if ($resource.ContainsKey('name') -and $resource.name) {
                    $nameExpr = "$($resource.name)"
                    if ($nameExpr -match "parameters\('([^']+)'\)") {
                        $info.NameParam = $Matches[1]
                    }
                }

                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 an ordered list of pricing API search terms from an ARM resource type.
.DESCRIPTION
    Returns terms from most specific to least specific. The caller should try
    each in order and use the first that returns results.

    Well-known mappings handle namespaces like Microsoft.Network where many
    unrelated services share the same namespace. For unknown resource types,
    the function falls back to splitting the resource name and namespace via
    camelCase boundaries.

    Examples:
        Microsoft.Network/virtualNetworks -> ["Virtual Network"]
        Microsoft.Network/publicIPAddresses -> ["IP Addresses"]
        Microsoft.KeyVault/vaults -> ["Key Vault"]
        Microsoft.Storage/storageAccounts -> ["Storage Accounts", "Storage"]
        Microsoft.Compute/virtualMachines -> ["Virtual Machines", "Compute"]
        Microsoft.ContainerRegistry/registries -> ["Container Registry"]
        Microsoft.OperationalInsights/workspaces -> ["Log Analytics"]
#>

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

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

    # Well-known overrides for resource types whose Azure service names
    # don't match the namespace/resource naming pattern
    $knownMappings = @{
        'Microsoft.Network/virtualNetworks'                = @('Virtual Network')
        'Microsoft.Network/publicIPAddresses'              = @('IP Addresses')
        'Microsoft.Network/publicIPPrefixes'               = @('IP Addresses')
        'Microsoft.Network/networkSecurityGroups'          = @('Network Watcher')
        'Microsoft.Network/loadBalancers'                  = @('Load Balancer')
        'Microsoft.Network/applicationGateways'            = @('Application Gateway')
        'Microsoft.Network/azureFirewalls'                 = @('Azure Firewall')
        'Microsoft.Network/bastionHosts'                   = @('Azure Bastion')
        'Microsoft.Network/virtualNetworkGateways'         = @('VPN Gateway', 'Virtual Network Gateway')
        'Microsoft.Network/privateDnsZones'                = @('Azure DNS')
        'Microsoft.Network/privateEndpoints'               = @('Private Link')
        'Microsoft.Network/networkInterfaces'              = @('Virtual Network')
        'Microsoft.Network/frontDoors'                     = @('Azure Front Door')
        'Microsoft.Network/natGateways'                    = @('NAT Gateway')
        'Microsoft.Network/routeTables'                    = @('Virtual Network')
        'Microsoft.Network/expressRouteCircuits'           = @('ExpressRoute')
        'Microsoft.Network/trafficManagerProfiles'         = @('Traffic Manager')
        'Microsoft.Compute/virtualMachines'                = @('Virtual Machines')
        'Microsoft.Compute/virtualMachineScaleSets'        = @('Virtual Machines')
        'Microsoft.Compute/disks'                          = @('Managed Disks')
        'Microsoft.Compute/snapshots'                      = @('Managed Disks')
        'Microsoft.Storage/storageAccounts'                = @('Storage Accounts', 'Storage')
        'Microsoft.KeyVault/vaults'                        = @('Key Vault')
        'Microsoft.ContainerRegistry/registries'           = @('Container Registry')
        'Microsoft.ContainerService/managedClusters'       = @('Azure Kubernetes Service')
        'Microsoft.Sql/servers'                            = @('SQL Database')
        'Microsoft.Sql/servers/databases'                  = @('SQL Database')
        'Microsoft.Web/sites'                              = @('Azure App Service')
        'Microsoft.Web/serverfarms'                        = @('Azure App Service')
        'Microsoft.Web/staticSites'                        = @('Azure Static Web Apps')
        'Microsoft.ManagedIdentity/userAssignedIdentities' = @('Managed Identity')
        'Microsoft.OperationalInsights/workspaces'         = @('Log Analytics')
        'Microsoft.Insights/components'                    = @('Application Insights')
        'Microsoft.Cdn/profiles'                           = @('Content Delivery Network')
        'Microsoft.Cache/redis'                            = @('Azure Cache for Redis')
        'Microsoft.DocumentDB/databaseAccounts'            = @('Azure Cosmos DB')
        'Microsoft.ServiceBus/namespaces'                  = @('Service Bus')
        'Microsoft.EventHub/namespaces'                    = @('Event Hubs')
        'Microsoft.CognitiveServices/accounts'             = @('Cognitive Services')
        'Microsoft.SignalRService/signalR'                 = @('Azure SignalR Service')
        'Microsoft.DBforPostgreSQL/flexibleServers'        = @('Azure Database for PostgreSQL')
        'Microsoft.DBforMySQL/flexibleServers'             = @('Azure Database for MySQL')
        'Microsoft.ApiManagement/service'                  = @('API Management')
    }

    # Check known mappings (exact match on full type)
    if ($knownMappings.ContainsKey($ArmResourceType)) {
        return @($knownMappings[$ArmResourceType])
    }

    # Check known mappings for parent type (e.g., Microsoft.Sql/servers/databases)
    $parentType = ($parts[0..1]) -join '/'
    if ($parts.Count -gt 2 -and $knownMappings.ContainsKey($parentType)) {
        return @($knownMappings[$parentType])
    }

    # Fallback: derive from resource name (specific) then namespace (broad)
    $namespacePart = $parts[0] -replace '^Microsoft\.', ''
    $resourcePart  = $parts[1]

    $namespaceWords = ($namespacePart -creplace '([a-z])([A-Z])', '$1 $2') -creplace '([A-Z]+)([A-Z][a-z])', '$1 $2'
    $resourceWords  = ($resourcePart  -creplace '([a-z])([A-Z])', '$1 $2') -creplace '([A-Z]+)([A-Z][a-z])', '$1 $2'
    $resourceWords  = (Get-Culture).TextInfo.ToTitleCase($resourceWords)

    $terms = [System.Collections.Generic.List[string]]::new()

    if ($resourceWords -ne $namespaceWords) {
        $terms.Add($resourceWords)
    }
    $terms.Add($namespaceWords)

    return @($terms)
}

<#
.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 search terms per resource type ────────────────────

    $resourceSearchTerms = @{}  # armType -> ordered list of search terms

    foreach ($rt in $resourceTypes) {
        $terms = ConvertTo-PricingSearchTerm -ArmResourceType $rt
        if ($terms.Count -gt 0) {
            $resourceSearchTerms[$rt] = $terms
        }
    }

    Write-Host " Fetching pricing for $($resourceSearchTerms.Count) resource type(s)..." -ForegroundColor Gray
    Write-Host ""

    # ── Step 3: Query pricing API per resource type ───────────────────────
    # Uses a query cache so identical search terms are only fetched once.

    $queryCache = @{}  # searchTerm -> meters (avoids duplicate API calls)
    $resourcePricing = @{}
    $totalResources = $resourceSearchTerms.Count
    $currentResource = 0

    foreach ($entry in $resourceSearchTerms.GetEnumerator()) {
        $currentResource++
        $armType     = $entry.Key
        $searchTerms = $entry.Value

        Write-Host " [$currentResource/$totalResources] $armType" -ForegroundColor Gray

        $meters = @()
        $usedTerm = $searchTerms[0]

        # Try each search term in order (most specific first)
        foreach ($term in $searchTerms) {
            if ($queryCache.ContainsKey($term)) {
                $meters = $queryCache[$term]
                Write-Host " -> cached: '$term' ($(@($meters).Count) meter(s))" -ForegroundColor DarkGray
            }
            else {
                Write-Host " -> querying: '$term'" -ForegroundColor DarkGray
                $meters = @(Invoke-PricingApiQuery `
                    -Region $Region `
                    -ServiceSearchTerm $term `
                    -CurrencyCode $CurrencyCode)
                $queryCache[$term] = $meters
                Write-Host " -> $(@($meters).Count) meter(s)" -ForegroundColor DarkGray
            }

            $usedTerm = $term

            if (@($meters).Count -gt 0) {
                break  # got results with this term, no need to try broader ones
            }
        }

        # 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 {
                $unit = $_.unitOfMeasure ?? ''
                $price = $_.retailPrice

                # Calculate daily, monthly, and annual prices based on unit of measure
                $daily = switch -Wildcard ($unit) {
                    '*Hour*'   { [math]::Round($price * 24, 4) }        # 24 hours/day
                    '*Day*'    { [math]::Round($price, 4) }             # already daily
                    '*Month*'  { [math]::Round($price / 30, 4) }        # monthly to daily
                    '*Year*'   { [math]::Round($price / 365, 4) }       # yearly to daily
                    default    { [math]::Round($price, 4) }             # usage-dependent — per-unit
                }

                $monthly = switch -Wildcard ($unit) {
                    '*Hour*'   { [math]::Round($price * 730, 4) }       # ~730 hours/month
                    '*Day*'    { [math]::Round($price * 30, 4) }        # ~30 days/month
                    '*Month*'  { [math]::Round($price, 4) }             # already monthly
                    '*Year*'   { [math]::Round($price / 12, 4) }        # yearly to monthly
                    default    { [math]::Round($price, 4) }             # usage-dependent — per-unit
                }

                $annual = switch -Wildcard ($unit) {
                    '*Hour*'   { [math]::Round($price * 8760, 4) }      # 8760 hours/year
                    '*Day*'    { [math]::Round($price * 365, 4) }       # 365 days/year
                    '*Month*'  { [math]::Round($price * 12, 4) }        # 12 months/year
                    '*Year*'   { [math]::Round($price, 4) }             # already annual
                    default    { [math]::Round($price, 4) }             # usage-dependent — per-unit
                }

                @{
                    meterName     = $_.meterName
                    skuName       = $_.skuName
                    armSkuName    = $_.armSkuName
                    retailPrice   = $price
                    dailyPrice    = $daily
                    monthlyPrice  = $monthly
                    annualPrice   = $annual
                    unitOfMeasure = $unit
                    productName   = $_.productName
                }
            })

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

    # ── Step 4: 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
}