KnowIT.AzureCosts.psm1


#region === Public functions ===

### Source file: 'Clear-KnowITAzCostsCache.ps1' ###
function Clear-KnowITAzCostsCache {
    <#
    .SYNOPSIS
        Borra el cache local de detalle de costos e inventario.

    .DESCRIPTION
        El cache está en ~/.knowit/KnowIT.AzureCosts, una carpeta por suscripcion. Sin
        parametros borra todo el cache. Borrar el detalle de un mes obliga a volver a
        consultar la Cost Details API, que Microsoft pide no usar mas de una vez al dia por
        suscripcion.

    .PARAMETER SubscriptionId
        Solo el cache de estas suscripciones.

    .PARAMETER Month
        Solo el detalle de costos de estos meses (yyyy-MM), de todas las metricas. El
        inventario no se toca. Alias: -Period.

    .EXAMPLE
        Clear-KnowITAzCostsCache -SubscriptionId $sub -Month 2026-09

    .EXAMPLE
        Clear-KnowITAzCostsCache -WhatIf
    #>


    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        [Parameter(Position = 0, ValueFromPipelineByPropertyName)]
        [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
        [Alias('Id')]
        [string[]]$SubscriptionId,

        [ValidatePattern('^\d{4}-(0[1-9]|1[0-2])$')]
        [Alias('Period')]
        [string[]]$Month
    )

    begin {
        $subscriptions = [System.Collections.Generic.List[string]]::new()
    }
    process {
        foreach($id in $SubscriptionId) { $subscriptions.Add($id) }
    }
    end {
        try {
            # Update-CallerPreference usa Set-Variable, que tambien obedece -WhatIf e imprimiria
            # un "What if" por cada preferencia.
            $whatIf = $WhatIfPreference
            $WhatIfPreference = $false
            Update-CallerPreference $PSCmdlet -Skip Confirm, WhatIf
            $WhatIfPreference = $whatIf

            if(!(Test-Path $script:CacheRoot)) {
                Write-Verbose "No hay cache en $script:CacheRoot"
                return
            }

            $folders = $subscriptions.Count ?
                @($subscriptions | ForEach-Object { GetCacheFolder $_ } | Where-Object { Test-Path $_ }) :
                @(Get-ChildItem $script:CacheRoot -Directory | ForEach-Object FullName)

            foreach($folder in $folders) {
                if($Month) {
                    $files = @(Get-ChildItem $folder -File | Where-Object {
                        $name = $_.Name
                        @($Month | Where-Object { $name -like "cost-*-$_.json" -or $name -like "cost-*-$_-part*.csv" }).Count
                    })
                    foreach($file in $files) {
                        if($PSCmdlet.ShouldProcess($file.FullName, 'Borrar del cache')) {
                            Remove-Item $file.FullName -Force
                        }
                    }
                }
                elseif($PSCmdlet.ShouldProcess($folder, 'Borrar cache de la suscripcion')) {
                    Remove-Item $folder -Recurse -Force
                }
            }
        }
        catch {
            $PSCmdlet.WriteError($_)
        }
    }
}

### Source file: 'Export-KnowITAzCostsRaw.ps1' ###
function Export-KnowITAzCostsRaw {
    <#
    .SYNOPSIS
        Exporta a CSV el detalle diario por recurso tal como lo entrega la Cost Details API,
        con los tags expandidos en columnas.

    .DESCRIPTION
        Escribe un solo CSV con todas las columnas originales del detalle de costos (una fila
        por recurso, por dia y por medidor) de las suscripciones y meses indicados, mas una
        columna por tag. Sin -TagColumn se agregan todos los tags que aparecen en las filas
        exportadas; al recurso que no tiene un tag se le deja vacio.

        Por default los tags son los que tenia el recurso el dia del cargo. Con
        -TagsFromInventory se usan los de hoy para los recursos que siguen existiendo.

        Usa el mismo cache local que Get-KnowITAzCostsDetail. Devuelve el archivo generado.

    .PARAMETER Path
        Ruta del CSV a generar. Si existe, se reemplaza.

    .PARAMETER Month
        Uno o varios meses en formato yyyy-MM. Alias: -Period.

    .PARAMETER StartDate
        Inicio de un rango de fechas. Con -EndDate, alternativa a -Month.

    .PARAMETER EndDate
        Fin del rango de fechas, inclusive.

    .PARAMETER SubscriptionId
        Una o varias suscripciones. Acepta la salida de Get-AzSubscription por pipeline.

    .PARAMETER ResourceGroup
        Solo las filas de estos resource groups. Acepta comodines.

    .PARAMETER Untagged
        Solo las filas sin ningun tag. Excluyente con -ResourceGroup y -Tag.

    .PARAMETER Tag
        Solo las filas cuyos tags cumplen todos los pares indicados. Los valores aceptan
        comodines.

    .PARAMETER TagColumn
        Limita las columnas de tags a estos. Si un tag se llama igual que una columna del
        detalle, la columna se llama tag_<nombre>.

    .PARAMETER TagsFromInventory
        Usa los tags de hoy (inventario de Resource Graph) en vez de los del dia del cargo.
        Los recursos que ya no existen conservan los del cargo.

    .PARAMETER Metric
        ActualCost (default) o AmortizedCost.

    .PARAMETER Tenant
        Tenant para pedir el token. Sin el se usa el de la sesion de Az.

    .PARAMETER Force
        Vuelve a descargar el detalle (y el inventario) aunque esten en cache.

    .EXAMPLE
        Export-KnowITAzCostsRaw -Month 2026-08 -SubscriptionId $sub -Path ./costos-2026-08.csv

    .EXAMPLE
        Export-KnowITAzCostsRaw -StartDate 2026-07-15 -EndDate 2026-08-14 -SubscriptionId $sub -TagColumn Environment, Owner -Path ./costos.csv
    #>


    [CmdletBinding(DefaultParameterSetName = 'Month')]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrWhiteSpace()]
        [string]$Path,

        [Parameter(Mandatory, Position = 0, ParameterSetName = 'Month')]
        [ValidatePattern('^\d{4}-(0[1-9]|1[0-2])$')]
        [Alias('Period')]
        [string[]]$Month,

        [Parameter(Mandatory, ParameterSetName = 'Range')]
        [datetime]$StartDate,

        [Parameter(Mandatory, ParameterSetName = 'Range')]
        [datetime]$EndDate,

        [Parameter(Mandatory, Position = 1, ValueFromPipelineByPropertyName)]
        [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
        [Alias('Id')]
        [string[]]$SubscriptionId,

        [SupportsWildcards()]
        [string[]]$ResourceGroup,

        [switch]$Untagged,

        [hashtable]$Tag,

        [string[]]$TagColumn,

        [switch]$TagsFromInventory,

        [ValidateSet('ActualCost', 'AmortizedCost')]
        [string]$Metric = 'ActualCost',

        [ValidateNotNullOrWhiteSpace()]
        [Alias('TenantId')]
        [string]$Tenant,

        [switch]$Force
    )

    begin {
        $subscriptions = [System.Collections.Generic.List[string]]::new()
    }
    process {
        foreach($id in $SubscriptionId) { $subscriptions.Add($id) }
    }
    end {
        try {
            Update-CallerPreference $PSCmdlet
            AssertFilterCombination $PSBoundParameters

            $periods = @(ResolvePeriod -Month $Month -StartDate ($PSBoundParameters.StartDate) -EndDate ($PSBoundParameters.EndDate))
            $unique = @($subscriptions | Sort-Object -Unique)
            $inventoryIndex = $TagsFromInventory ? (NewInventoryIndex @(GetInventory -SubscriptionId $unique -Tenant $Tenant -Force:$Force)) : @{}

            # Primera pasada: filtrar y resolver tags. Las columnas de tags dependen de todas
            # las filas elegidas, asi que no se puede escribir mientras se lee.
            $selected = [System.Collections.Generic.List[object]]::new()
            $headers = [System.Collections.Generic.List[string]]::new()
            $seenHeaders = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
            $tagsCache = [System.Collections.Generic.Dictionary[string, hashtable]]::new()

            foreach($subscription in $unique) {
                foreach($period in $periods) {
                    $files = @(GetCostDetailFiles -SubscriptionId $subscription -Month $period.Month -Metric $Metric -Tenant $Tenant -Force:$Force)
                    if(!$files) { continue }

                    Write-Verbose "Leyendo detalle de $($period.Month) de $subscription..."
                    $data = ReadCostRows $files
                    foreach($header in $data.Headers) {
                        if($seenHeaders.Add($header)) { $headers.Add($header) }
                    }
                    $map = NewColumnMap $data.Headers

                    foreach($row in $data.Rows) {
                        if($map.Date -and !(TestDateInPeriod (ConvertToUsageDate $row.($map.Date)) $period)) { continue }

                        $resourceId = $map.ResourceId ? "$($row.($map.ResourceId))".Trim() : ''
                        $resource = $resourceId ? $inventoryIndex[$resourceId.ToLowerInvariant()] : $null
                        if($resource) {
                            $tags = $resource.Tags
                        }
                        else {
                            $tagsText = $map.Tags ? "$($row.($map.Tags))" : ''
                            if(!$tagsCache.TryGetValue($tagsText, [ref]$null)) {
                                $tagsCache[$tagsText] = ConvertFromTagsText $tagsText
                            }
                            $tags = $tagsCache[$tagsText]
                        }

                        # No llamarla $resourceGroup: es la misma variable que el parametro -ResourceGroup.
                        $rowResourceGroup = $map.ResourceGroup ? "$($row.($map.ResourceGroup))".Trim() : ''
                        if(!$rowResourceGroup -and $resourceId) { $rowResourceGroup = GetResourceGroupFromId $resourceId }

                        if(TestItemFilter -ResourceGroup $rowResourceGroup -Tags $tags -ResourceGroupFilter $ResourceGroup -Untagged:$Untagged -TagFilter $Tag) {
                            $selected.Add(@{ Row = $row; Tags = $tags })
                        }
                    }
                }
            }

            $tagNames = $TagColumn ? $TagColumn : (GetTagNames @($selected | ForEach-Object Tags))
            $reserved = [System.Collections.Generic.HashSet[string]]::new([string[]]$headers, [StringComparer]::OrdinalIgnoreCase)
            $tagColumns = @(foreach($name in $tagNames) {
                @{ Tag = $name; Column = $reserved.Contains($name) ? "tag_$name" : $name }
            })

            $target = [IO.Path]::GetFullPath($Path, (Get-Location).Path)
            $null = New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force
            Write-Verbose "Escribiendo $($selected.Count) filas y $($tagColumns.Count) columnas de tags en $target..."
            WriteRawCsv -Path $target -Headers $headers -TagColumns $tagColumns -Items $selected
            Get-Item $target
        }
        catch {
            $PSCmdlet.WriteError($_)
        }
    }
}

### Source file: 'Get-KnowITAzCostsDetail.ps1' ###
function Get-KnowITAzCostsDetail {
    <#
    .SYNOPSIS
        Costo por recurso y por mes de una o varias suscripciones de Azure.

    .DESCRIPTION
        Devuelve un objeto por recurso por mes con el costo acumulado del periodo, sus tags y
        los datos del recurso. Los cargos que no pertenecen a un recurso (Marketplace,
        soporte, reservas) salen como filas sin ResourceId.

        Trabaja sobre un cache local: la primera vez que se pide un mes de una suscripcion se
        descarga el detalle completo con la Cost Details API y los filtros se aplican
        despues, sin volver a consultar Azure. Microsoft pide no consultar esa API mas de una
        vez al dia por suscripcion. Los meses cerrados quedan en cache de forma permanente;
        el mes en curso se renueva cada 24 horas.

        Tags: si el recurso existe hoy se usan sus tags de hoy, aunque no tenga ninguno: son los
        que alguien va a corregir. Si ya no existe, los ultimos con valor que tuvo en el
        periodo. Los pipelines y triggers de Data Factory se suman a su factory.

    .PARAMETER Month
        Uno o varios meses en formato yyyy-MM. Alias: -Period.

    .PARAMETER StartDate
        Inicio de un rango de fechas. Con -EndDate, alternativa a -Month; los meses que el
        rango cubre a medias solo suman los dias dentro del rango.

    .PARAMETER EndDate
        Fin del rango de fechas, inclusive.

    .PARAMETER SubscriptionId
        Una o varias suscripciones. Acepta la salida de Get-AzSubscription por pipeline.

    .PARAMETER ResourceGroup
        Solo los recursos de estos resource groups. Acepta comodines.

    .PARAMETER Untagged
        Solo los recursos sin ningun tag. Excluyente con -ResourceGroup y -Tag.

    .PARAMETER Tag
        Solo los recursos cuyos tags cumplen todos los pares indicados. Los valores aceptan
        comodines: @{ Environment = 'Prod*'; Role = 'Web' }.

    .PARAMETER TagColumn
        Tags que ademas de ir en la propiedad Tags se agregan como propiedades propias, listas
        para Export-Csv. Si un tag se llama igual que una propiedad existente, la columna se
        llama tag_<nombre>.

    .PARAMETER Metric
        ActualCost (default) o AmortizedCost.

    .PARAMETER Tenant
        Tenant para pedir el token. Sin el se usa el de la sesion de Az.

    .PARAMETER SplitByTagChange
        Si un recurso cambio de tags dentro del mes, devuelve una fila por cada juego de tags
        que tuvo, con el costo de esos dias y las propiedades FirstDate y LastDate. Usa los tags
        historicos del detalle aunque el recurso exista hoy; los dias sin tags forman su propia
        fila. Los pipelines y triggers de Data Factory siguen los tags que su factory tuvo ese dia.

    .PARAMETER TagsAsJson
        Entrega la propiedad Tags como texto JSON compacto en vez de hashtable, lista para
        Export-Csv: {"Environment":"Production","Owner":"Equipo-FinOps"}. Sin tags queda {}.

    .PARAMETER Force
        Vuelve a descargar el detalle y el inventario aunque esten en cache.

    .EXAMPLE
        Get-KnowITAzCostsDetail -Month 2026-08 -SubscriptionId $sub -Untagged

    .EXAMPLE
        Get-KnowITAzCostsDetail -Period 2026-07, 2026-08 -SubscriptionId $sub -Tag @{ Environment = 'Production' } -TagColumn Role, Owner -TagsAsJson |
            Export-Csv costos.csv
    #>


    [CmdletBinding(DefaultParameterSetName = 'Month')]
    [OutputType('KnowIT.AzureCosts.CostDetail')]
    param(
        [Parameter(Mandatory, Position = 0, ParameterSetName = 'Month')]
        [ValidatePattern('^\d{4}-(0[1-9]|1[0-2])$')]
        [Alias('Period')]
        [string[]]$Month,

        [Parameter(Mandatory, ParameterSetName = 'Range')]
        [datetime]$StartDate,

        [Parameter(Mandatory, ParameterSetName = 'Range')]
        [datetime]$EndDate,

        [Parameter(Mandatory, Position = 1, ValueFromPipelineByPropertyName)]
        [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
        [Alias('Id')]
        [string[]]$SubscriptionId,

        [SupportsWildcards()]
        [string[]]$ResourceGroup,

        [switch]$Untagged,

        [hashtable]$Tag,

        [string[]]$TagColumn,

        [switch]$TagsAsJson,

        [switch]$SplitByTagChange,

        [ValidateSet('ActualCost', 'AmortizedCost')]
        [string]$Metric = 'ActualCost',

        [ValidateNotNullOrWhiteSpace()]
        [Alias('TenantId')]
        [string]$Tenant,

        [switch]$Force
    )

    begin {
        $subscriptions = [System.Collections.Generic.List[string]]::new()
    }
    process {
        foreach($id in $SubscriptionId) { $subscriptions.Add($id) }
    }
    end {
        try {
            Update-CallerPreference $PSCmdlet
            AssertFilterCombination $PSBoundParameters

            $periods = @(ResolvePeriod -Month $Month -StartDate ($PSBoundParameters.StartDate) -EndDate ($PSBoundParameters.EndDate))
            $unique = @($subscriptions | Sort-Object -Unique)
            $inventory = @(GetInventory -SubscriptionId $unique -Tenant $Tenant -Force:$Force)
            $inventoryIndex = NewInventoryIndex $inventory

            $results = [System.Collections.Generic.List[object]]::new()
            foreach($subscription in $unique) {
                foreach($period in $periods) {
                    $files = @(GetCostDetailFiles -SubscriptionId $subscription -Month $period.Month -Metric $Metric -Tenant $Tenant -Force:$Force)
                    if(!$files) { continue }

                    Write-Verbose "Leyendo detalle de $($period.Month) de $subscription..."
                    $data = ReadCostRows $files
                    $items = MergeResourceCost -SubscriptionId $subscription -Period $period -Rows $data.Rows -Headers $data.Headers -InventoryIndex $inventoryIndex -SplitByTagChange:$SplitByTagChange
                    foreach($item in $items) {
                        if(TestItemFilter -ResourceGroup $item.ResourceGroup -Tags $item.Tags -ResourceGroupFilter $ResourceGroup -Untagged:$Untagged -TagFilter $Tag) {
                            $results.Add($item)
                        }
                    }
                }
            }

            if($TagColumn -and $results.Count) {
                AddTagColumns $results $TagColumn $results[0].PSObject.Properties.Name
            }
            if($TagsAsJson) { ConvertTagsToJson $results }
            # Por suscripcion y mes, el recurso mas caro primero.
            $results | Sort-Object SubscriptionId, Month, @{ Expression = 'Cost'; Descending = $true } -Stable
        }
        catch {
            $PSCmdlet.WriteError($_)
        }
    }
}

### Source file: 'Get-KnowITAzCostsResourceInventory.ps1' ###
function Get-KnowITAzCostsResourceInventory {
    <#
    .SYNOPSIS
        Inventario de recursos de Azure con sus tags, desde Azure Resource Graph.

    .DESCRIPTION
        Devuelve un objeto por recurso ARM de las suscripciones indicadas, con sus tags de hoy.
        Complementa al detalle de costos: un NSG o un disco desasociado no generan cargo y no
        aparecen en el detalle, pero si en el inventario.

        El inventario se guarda en un cache local por dia; las siguientes llamadas del mismo
        dia no vuelven a consultar Azure salvo con -Force.

    .PARAMETER SubscriptionId
        Suscripciones a inventariar. Sin este parametro se usan todas las suscripciones
        habilitadas a las que tiene acceso la sesion, incluidas las delegadas por Lighthouse.

    .PARAMETER ResourceGroup
        Solo los recursos de estos resource groups. Acepta comodines. Excluyente con -Untagged.

    .PARAMETER Untagged
        Solo los recursos sin ningun tag (un tag con valor vacio no cuenta).

    .PARAMETER Tenant
        Tenant para pedir el token. Sin el se usa el de la sesion de Az.

    .PARAMETER TagsAsJson
        Entrega la propiedad Tags como texto JSON compacto en vez de hashtable, lista para
        Export-Csv: {"Environment":"Production","Owner":"Equipo-FinOps"}. Sin tags queda {}.

    .PARAMETER Force
        Vuelve a consultar Resource Graph aunque el inventario de hoy ya este en cache.

    .EXAMPLE
        Get-KnowITAzCostsResourceInventory -SubscriptionId $sub -Untagged

    .EXAMPLE
        Get-AzSubscription | Get-KnowITAzCostsResourceInventory -TagsAsJson | Export-Csv inventario.csv
    #>


    [CmdletBinding(DefaultParameterSetName = 'All')]
    [OutputType('KnowIT.AzureCosts.Resource')]
    param(
        [Parameter(Position = 0, ValueFromPipelineByPropertyName)]
        [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
        [Alias('Id')]
        [string[]]$SubscriptionId,

        [Parameter(Mandatory, ParameterSetName = 'ResourceGroup')]
        [SupportsWildcards()]
        [string[]]$ResourceGroup,

        [Parameter(Mandatory, ParameterSetName = 'Untagged')]
        [switch]$Untagged,

        [ValidateNotNullOrWhiteSpace()]
        [Alias('TenantId')]
        [string]$Tenant,

        [switch]$TagsAsJson,

        [switch]$Force
    )

    begin {
        $subscriptions = [System.Collections.Generic.List[string]]::new()
    }
    process {
        foreach($id in $SubscriptionId) { $subscriptions.Add($id) }
    }
    end {
        try {
            Update-CallerPreference $PSCmdlet

            if(!$subscriptions.Count) {
                Write-Verbose 'Sin -SubscriptionId: se usan todas las suscripciones accesibles.'
                foreach($id in (GetAccessibleSubscriptions $Tenant)) { $subscriptions.Add($id) }
            }
            $unique = @($subscriptions | Sort-Object -Unique)

            $inventory = @(GetInventory -SubscriptionId $unique -Tenant $Tenant -Force:$Force |
                Where-Object { TestItemFilter -ResourceGroup $_.ResourceGroup -Tags $_.Tags -ResourceGroupFilter $ResourceGroup -Untagged:$Untagged })
            if($TagsAsJson) { ConvertTagsToJson $inventory }
            SortOrdinal $inventory { "$($_.SubscriptionId)`0$($_.ResourceGroup)`0$($_.Name)".ToLowerInvariant() }
        }
        catch {
            $PSCmdlet.WriteError($_)
        }
    }
}

#endregion

#region === Private functions ===

### Source file: 'Aggregation.ps1' ###
function MergeResourceCost
{
    # Suma el detalle diario de un mes en una fila por recurso (o por recurso y juego de tags
    # con -SplitByTagChange). Devuelve los objetos sin filtrar; los filtros se aplican despues
    # sobre los tags efectivos.
    param(
        [string]$SubscriptionId,
        $Period,
        [System.Collections.Generic.List[object]]$Rows,
        [string[]]$Headers,
        [hashtable]$InventoryIndex,
        [switch]$SplitByTagChange
    )

    $map = NewColumnMap $Headers
    $column = { param($row, $field) $name = $map[$field]; $name ? "$($row.$name)".Trim() : '' }

    if(!$map.Cost) {
        Write-Warning "No se encontro ninguna columna de costo reconocible en el detalle de $($Period.Month). Candidatas: $($script:ColumnCandidates.Cost -join ', ')"
    }

    # Las llaves son ResourceId en minusculas; los sub-recursos de Data Factory van a la llave
    # de su padre. Por llave se guarda la historia de tags con valor, por fecha: los propios
    # del recurso y, aparte, los de sus hijos, que solo cuentan si el padre no trae ninguno.
    # $ownDayTags guarda ademas los dias sin tags: con split, los hijos siguen los tags que su
    # padre tuvo ese dia, vacios o no.
    $costByKey = [ordered]@{}
    $ownHistory = @{}
    $childHistory = @{}
    $ownDayTags = @{}
    $sampleByKey = @{}
    $sampleFromChild = [System.Collections.Generic.HashSet[string]]::new()
    $records = [System.Collections.Generic.List[object]]::new()
    # El mismo texto de tags se repite en miles de filas: parsearlo una vez.
    $tagsCache = [System.Collections.Generic.Dictionary[string, hashtable]]::new()
    $currency = ''

    foreach($row in $Rows) {
        $date = ConvertToUsageDate (& $column $row 'Date')
        if(!(TestDateInPeriod $date $Period)) { continue }

        $idNorm = (& $column $row 'ResourceId').ToLowerInvariant()
        $parent = $idNorm ? (GetParentResourceId $idNorm) : $null
        $cost = ConvertToCostNumber (& $column $row 'Cost')

        if(!$idNorm) {
            # Cargos sin ResourceId (soporte, algunos Marketplace) se agrupan por servicio y
            # tipo de cargo para no perderlos del total.
            $key = "`0$(& $column $row 'Service')`0$(& $column $row 'Charge')"
        }
        else {
            $key = $parent ?? $idNorm
        }

        $costByKey[$key] = ($costByKey[$key] ?? 0.0) + $cost
        # La muestra da nombre y servicio cuando el recurso no esta en el inventario: la del
        # propio padre gana sobre la de un hijo.
        if(!$sampleByKey.ContainsKey($key) -or (!$parent -and $sampleFromChild.Contains($key))) {
            $sampleByKey[$key] = $row
            if($parent) { $null = $sampleFromChild.Add($key) } else { $null = $sampleFromChild.Remove($key) }
        }

        $tagsText = & $column $row 'Tags'
        if(!$tagsCache.TryGetValue($tagsText, [ref]$null)) {
            $tagsCache[$tagsText] = ConvertFromTagsText $tagsText
        }
        $tags = $tagsCache[$tagsText]
        if(!(TestUntagged $tags)) {
            AddTagHistory ($parent ? $childHistory : $ownHistory) $key $date $tags
        }
        if($SplitByTagChange) {
            if(!$parent) { AddTagHistory $ownDayTags $key $date $tags }
            $records.Add(@{ Key = $key; Date = $date; Cost = $cost; Tags = $tags; IsChild = [bool]$parent })
        }
        if(!$currency) { $currency = & $column $row 'Currency' }
    }

    if(!$SplitByTagChange) {
        foreach($key in $costByKey.Keys) {
            $resource = GetInventoryResource $InventoryIndex $key
            # Si el recurso existe, sus tags de hoy, aunque no tenga ninguno: son los que alguien
            # va a corregir. Si ya no existe, los ultimos con valor que tuvo en el periodo (por
            # fecha, no por orden de fila: la API no entrega las filas ordenadas).
            $tags = $resource ? $resource.Tags :
                ((GetLatestTags $ownHistory[$key]) ?? (GetLatestTags $childHistory[$key]) ?? @{})
            NewCostDetailItem -Key $key -Sample $sampleByKey[$key] -Resource $resource -Cost $costByKey[$key] -Tags $tags `
                -Map $map -Month $Period.Month -SubscriptionId $SubscriptionId -Currency $currency -FromChild $sampleFromChild.Contains($key)
        }
        return
    }

    # Con split, cada fila lleva los tags que trae ese dia, tal cual: una fila sin tags es un
    # juego mas y genera su propio segmento. Los hijos de Data Factory siguen los tags que su
    # padre tuvo ese dia; solo si el padre no tiene filas ese dia se usan los del hijo.
    $segments = [ordered]@{}
    foreach($record in $records) {
        $key = $record.Key
        $tags = $record.Tags
        if($record.IsChild) {
            $parentTags = $null
            if($ownDayTags[$key]?.TryGetValue(($record.Date ?? [datetime]::MinValue), [ref]$parentTags)) { $tags = $parentTags }
        }
        $segmentKey = "$key`0$(GetTagSetKey $tags)"
        $segment = $segments[$segmentKey]
        if(!$segment) {
            $segment = @{ Key = $key; Tags = $tags; Cost = 0.0; FirstDate = $record.Date; LastDate = $record.Date }
            $segments[$segmentKey] = $segment
        }
        $segment.Cost += $record.Cost
        if($record.Date -and (!$segment.FirstDate -or $record.Date -lt $segment.FirstDate)) { $segment.FirstDate = $record.Date }
        if($record.Date -and (!$segment.LastDate -or $record.Date -gt $segment.LastDate)) { $segment.LastDate = $record.Date }
    }
    foreach($segment in $segments.Values) {
        $resource = GetInventoryResource $InventoryIndex $segment.Key
        $item = NewCostDetailItem -Key $segment.Key -Sample $sampleByKey[$segment.Key] -Resource $resource -Cost $segment.Cost -Tags $segment.Tags `
            -Map $map -Month $Period.Month -SubscriptionId $SubscriptionId -Currency $currency -FromChild $sampleFromChild.Contains($segment.Key)
        $item | Add-Member -NotePropertyName FirstDate -NotePropertyValue $segment.FirstDate
        $item | Add-Member -NotePropertyName LastDate -NotePropertyValue $segment.LastDate
        $item
    }
}

function GetInventoryResource ([hashtable]$InventoryIndex, [string]$Key)
{
    # Ordinal: con comparacion cultural .NET ignora el caracter nulo y todo "empieza" con el.
    if($Key.StartsWith("`0", [StringComparison]::Ordinal)) { return $null }
    $InventoryIndex[$Key]
}

function AddTagHistory ([hashtable]$History, [string]$Key, $Date, [hashtable]$Tags)
{
    # Un juego de tags por dia; si un dia trae dos distintos gana el ultimo leido (con valor,
    # si alguno lo tiene: una fila de ajuste vacia no borra los tags del dia).
    if(!$History.ContainsKey($Key)) {
        $History[$Key] = [System.Collections.Generic.SortedDictionary[datetime, hashtable]]::new()
    }
    $day = $Date ?? [datetime]::MinValue
    $previous = $null
    if($History[$Key].TryGetValue($day, [ref]$previous) -and (TestUntagged $Tags) -and !(TestUntagged $previous)) { return }
    $History[$Key][$day] = $Tags
}

function GetLatestTags ($History)
{
    if(!$History -or !$History.Count) { return $null }
    $latest = $null
    foreach($entry in $History.GetEnumerator()) { $latest = $entry.Value }
    $latest
}

function GetTagSetKey ([hashtable]$Tags)
{
    # Identifica un juego de tags sin importar el orden de las llaves ni sus mayusculas.
    $parts = foreach($name in (SortOrdinal @($Tags.Keys | ForEach-Object { $_.ToLowerInvariant() }))) {
        "$name=$($Tags[$name])"
    }
    $parts -join "`0"
}

function NewCostDetailItem
{
    param(
        [string]$Key,
        $Sample,
        $Resource,
        [double]$Cost,
        [hashtable]$Tags,
        [hashtable]$Map,
        [string]$Month,
        [string]$SubscriptionId,
        [string]$Currency,
        # La muestra salio de un sub-recurso: su nombre es el del hijo, no el del recurso.
        [bool]$FromChild
    )

    $isCharge = $Key.StartsWith("`0", [StringComparison]::Ordinal)
    $column = { param($row, $field) $name = $Map[$field]; $name ? "$($row.$name)".Trim() : '' }

    if($Resource) {
        $resourceId = $Resource.ResourceId
        $name = $Resource.Name
        $type = $Resource.Type
        $resourceGroup = $Resource.ResourceGroup
        $location = $Resource.Location
    }
    elseif($isCharge) {
        $resourceId = $null
        $name = $null
        $type = $null
        $resourceGroup = $null
        $location = & $column $Sample 'Location'
    }
    else {
        $sampleId = & $column $Sample 'ResourceId'
        $resourceId = $sampleId -and $sampleId.ToLowerInvariant() -eq $Key ? $sampleId : $Key
        $name = $FromChild ? '' : (& $column $Sample 'ResourceName')
        if(!$name) { $name = $Key.Substring($Key.LastIndexOf('/') + 1) }
        $type = & $column $Sample 'ResourceType'
        $resourceGroup = & $column $Sample 'ResourceGroup'
        if(!$resourceGroup) { $resourceGroup = GetResourceGroupFromId $Key }
        $location = & $column $Sample 'Location'
    }

    [PSCustomObject]@{
        PSTypeName        = 'KnowIT.AzureCosts.CostDetail'
        Month             = $Month
        SubscriptionId    = $SubscriptionId
        ResourceId        = $resourceId
        ResourceName      = $name
        ResourceType      = $type
        ResourceGroup     = $resourceGroup
        Location          = $location
        ServiceName       = & $column $Sample 'Service'
        ChargeType        = $isCharge ? (& $column $Sample 'Charge') : $null
        Cost              = $Cost
        Currency          = $Currency
        ExistsInInventory = [bool]$Resource
        Tags              = $Tags
    }
}

### Source file: 'AzManagement.ps1' ###
function InvokeAzManagement
{
    # Llamada a Azure Resource Manager. Devuelve el valor (o el objeto de respuesta completo
    # con -DetailResponse) y lanza excepcion si Azure responde con error: las funciones
    # publicas la atrapan y la emiten con $PSCmdlet.WriteError.
    param(
        # Ruta relativa a https://management.azure.com, o URL absoluta (p. ej. el
        # encabezado Location de una operacion asincrona o un nextLink).
        [Parameter(Mandatory, Position = 0)]
        [string]$Path,

        [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
        [string]$Method = 'GET',

        [object]$Body,

        [string]$Tenant,

        # Devuelve Success, StatusCode, Headers, Value y Content (el cuerpo sin desenvolver)
        # en vez de solo el valor, y no lanza excepcion por error. Hace falta para las
        # operaciones que responden 202 y para paginar con nextLink.
        [switch]$DetailResponse
    )

    $uri = $Path -match '^https://' ? $Path : "$script:ArmEndpoint/$($Path.TrimStart('/'))"
    $restParams = @{
        Method      = $Method
        Uri         = $uri
        Headers     = @{ Authorization = "Bearer $(GetArmToken $Tenant)" }
        Body        = $Body ? (ConvertTo-Json $Body -Depth 6) : $null
        ContentType = 'application/json'
        Verbose     = $false
    }
    Write-Verbose "Request ARM: $Method $uri"
    if($Body) { Write-Debug "Body: $($restParams.Body)" }

    $result = Invoke-RestMethod @restParams -SkipHttpErrorCheck -StatusCodeVariable statusCode -ResponseHeadersVariable headers

    if($statusCode -lt 400) {
        Write-Verbose "Response ARM: [$statusCode] $([Net.HttpStatusCode]$statusCode)"
        $response = [PSCustomObject]@{
            PSTypeName = 'KnowIT.AzureCosts.Response'
            Success    = $true
            StatusCode = $statusCode
            Headers    = $headers
            Value      = $result.value ?? $result
            Content    = $result
        }
    }
    else {
        Write-Debug 'Procesando error en la respuesta del API:'
        Write-Debug (ConvertTo-Json $result -Depth 8)
        $response = [PSCustomObject]@{
            PSTypeName = 'KnowIT.AzureCosts.Response'
            Success    = $false
            StatusCode = $statusCode
            Headers    = $headers
            Content    = $result
            Error      = [ordered]@{
                code    = $result.error.code ?? "$([Net.HttpStatusCode]$statusCode)"
                message = $result.error.message ?? "$result"
            }
        }
        Write-Verbose "Response ARM: [$statusCode] $($response.Error.code) - $($response.Error.message)"
    }

    if($DetailResponse) {
        return $response
    }
    if(!$response.Success) {
        throw (FormatArmError $response)
    }
    $response.Value
}

### Source file: 'Cache.ps1' ###
# Cache local por usuario en ~/.knowit/KnowIT.AzureCosts: sobrevive reinicios y usuario
# quien mantiene el suyo. Estructura:
# <CacheRoot>/<suscripcion>/inventory-<yyyy-MM-dd>.json un archivo por dia
# <CacheRoot>/<suscripcion>/cost-<metrica>-<yyyy-MM>.json manifiesto del mes
# <CacheRoot>/<suscripcion>/cost-<metrica>-<yyyy-MM>-partN.csv detalle tal cual de la API
# El manifiesto se escribe al final: un CSV sin manifiesto es una descarga interrumpida y
# no cuenta.

# Un mes se considera definitivo si se descargo al menos estos dias despues de cerrar: antes
# de eso Azure todavia consolida cargos del mes.
$script:CostFinalAfterDays = 5
# Mes en curso o recien cerrado: se vuelve a pedir pasado este tiempo. Microsoft pide no
# consultar la Cost Details API mas de una vez al dia por suscripcion.
$script:CostMaxAge = [timespan]::FromHours(24)

function GetCacheFolder ([string]$SubscriptionId)
{
    Join-Path $script:CacheRoot $SubscriptionId.ToLowerInvariant()
}

function GetCostCacheBaseName ([string]$Metric, [string]$Month)
{
    "cost-$($Metric.ToLowerInvariant())-$Month"
}

function ReadCostManifest ([string]$SubscriptionId, [string]$Metric, [string]$Month)
{
    $path = Join-Path (GetCacheFolder $SubscriptionId) "$(GetCostCacheBaseName $Metric $Month).json"
    if(!(Test-Path $path)) { return $null }
    try {
        Get-Content $path -Raw | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        # Un manifiesto corrupto equivale a no tener cache: se vuelve a descargar.
        Write-Verbose "Manifiesto ilegible, se ignora: $path"
        $null
    }
}

function TestCostCacheFresh ($Manifest, [string]$Month)
{
    if(!$Manifest) { return $false }

    $downloadedAt = ([datetime]$Manifest.downloadedAt).ToUniversalTime()
    $first = [datetime]::ParseExact("$Month-01", 'yyyy-MM-dd', [cultureinfo]::InvariantCulture)
    $finalFrom = $first.AddMonths(1).AddDays($script:CostFinalAfterDays)
    if($downloadedAt -ge $finalFrom) { return $true }

    return ([datetime]::UtcNow - $downloadedAt) -lt $script:CostMaxAge
}

function WriteCacheFile ([string]$Path, [string]$Content)
{
    # Escribir a un temporal y mover: si el proceso se interrumpe no queda un archivo a
    # medias con el nombre bueno.
    $folder = Split-Path $Path -Parent
    $null = New-Item -ItemType Directory -Path $folder -Force
    $temp = "$Path.tmp"
    Set-Content -Path $temp -Value $Content -Encoding utf8 -NoNewline
    Move-Item -Path $temp -Destination $Path -Force
}

### Source file: 'Columns.ps1' ###
# Orden = preferencia. FOCUS primero, esquema legacy (ActualCost/AmortizedCost) despues.
# La Cost Details API entrega el legacy con nombres en camelCase: la busqueda no distingue
# mayusculas.
$script:ColumnCandidates = [ordered]@{
    ResourceId    = 'ResourceId', 'InstanceId', 'InstanceName'
    ResourceName  = , 'ResourceName'
    ResourceType  = 'ResourceType', 'ConsumedService', 'MeterCategory'
    ResourceGroup = 'x_ResourceGroupName', 'ResourceGroup', 'ResourceGroupName'
    Service       = 'ServiceName', 'MeterCategory', 'ConsumedService'
    Charge        = 'ChargeCategory', 'ChargeType'
    Location      = 'RegionName', 'RegionId', 'ResourceLocation', 'MeterRegion'
    Tags          = , 'Tags'
    # EffectiveCost es el amortizado en FOCUS; para atribucion mensual es el correcto.
    Cost          = 'EffectiveCost', 'BilledCost', 'CostInBillingCurrency', 'Cost', 'PreTaxCost', 'CostInUSD'
    Currency      = 'BillingCurrency', 'BillingCurrencyCode', 'Currency'
    Date          = 'ChargePeriodStart', 'Date', 'UsageDate'
}

# La Cost Details API entrega MM/dd/yyyy; FOCUS, ISO 8601 con zona.
$script:DateFormats = [string[]]@('MM/dd/yyyy', 'M/d/yyyy', 'yyyy-MM-dd', 'yyyy-MM-ddTHH:mm:ssK', 'yyyy-MM-ddTHH:mm:ss')

function NewColumnMap ([string[]]$Headers)
{
    # Hashtable de PowerShell: llaves sin distinguir mayusculas.
    $index = @{}
    foreach($header in $Headers) {
        if(!$index.ContainsKey($header)) { $index[$header] = $header }
    }
    $map = @{}
    foreach($field in $script:ColumnCandidates.Keys) {
        $map[$field] = $null
        foreach($candidate in $script:ColumnCandidates[$field]) {
            if($index.ContainsKey($candidate)) {
                $map[$field] = $index[$candidate]
                break
            }
        }
    }
    $map
}

function ConvertFromTagsText ([string]$Text)
{
    # FOCUS entrega Tags como JSON: {"Owner":"Equipo-FinOps"}
    # El export legacy a veces lo entrega SIN llaves: "Owner": "Equipo-FinOps", "Env": "prod"
    # Esta diferencia es la causa clasica de reportar todo como sin etiquetar.
    $tags = @{}
    if([string]::IsNullOrWhiteSpace($Text)) { return $tags }

    $json = $Text.Trim()
    if(!$json.StartsWith('{')) { $json = "{$json}" }
    try {
        $data = ConvertFrom-Json $json -AsHashtable -ErrorAction Stop
    }
    catch {
        return $tags
    }
    if($data -isnot [System.Collections.IDictionary]) { return $tags }

    foreach($key in $data.Keys) {
        $tags[[string]$key] = "$($data[$key])"
    }
    return $tags
}

function ConvertToCostNumber ([string]$Text)
{
    # InvariantCulture: el CSV usa punto decimal y a veces notacion cientifica, sin
    # importar la configuracion regional de la maquina.
    $value = 0.0
    if([double]::TryParse($Text, [Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref]$value)) {
        return $value
    }
    return 0.0
}

function ConvertToUsageDate ([string]$Text)
{
    # AdjustToUniversal: sin el, '2026-08-01T00:00:00Z' en Mexico se vuelve 31 de julio.
    $styles = [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal
    $value = [datetime]::MinValue
    if([datetime]::TryParseExact($Text.Trim(), $script:DateFormats, [cultureinfo]::InvariantCulture, $styles, [ref]$value)) {
        return $value.Date
    }
    return $null
}

function ReadCostRows ([string[]]$Path)
{
    # Devuelve @{ Rows; Headers }. Los parts de un mismo reporte comparten esquema; entre
    # meses podria cambiar, asi que los encabezados son la union en orden de aparicion.
    $rows = [System.Collections.Generic.List[object]]::new()
    $headers = [System.Collections.Generic.List[string]]::new()
    $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    foreach($file in $Path) {
        $batch = Import-Csv -Path $file -Encoding utf8
        if($batch) {
            foreach($name in $batch[0].PSObject.Properties.Name) {
                if($seen.Add($name)) { $headers.Add($name) }
            }
        }
        foreach($row in $batch) { $rows.Add($row) }
    }
    @{ Rows = $rows; Headers = @($headers) }
}

### Source file: 'Completers.ps1' ###
# Autocompletado de -Tag y -TagColumn a partir de los tags reales de la suscripcion. Sale
# del inventario del dia: si no esta en cache se consulta Resource Graph, que es barato y no
# tiene el limite de la Cost Details API. Cualquier error se calla: un autocompletado que
# falla no debe ensuciar la consola.

function GetCompletionTags ([hashtable]$BoundParameters)
{
    $subscriptions = @($BoundParameters.SubscriptionId | Where-Object { $_ })
    if(!$subscriptions) { return @() }
    try {
        $params = @{ SubscriptionId = $subscriptions; ErrorAction = 'Stop' }
        if($BoundParameters.Tenant) { $params.Tenant = $BoundParameters.Tenant }
        @((GetInventory @params).Tags)
    }
    catch {
        @()
    }
}

function NewQuotedWord ([string]$Text)
{
    $Text -match '^[\w.-]+$' ? $Text : "'$($Text -replace "'", "''")'"
}

function CompleteTagName ([hashtable]$BoundParameters, [string]$WordToComplete)
{
    $word = $WordToComplete.Trim("'", '"')
    foreach($name in (GetTagNames (GetCompletionTags $BoundParameters))) {
        if($name -like "$word*") {
            [System.Management.Automation.CompletionResult]::new((NewQuotedWord $name), $name, 'ParameterValue', $name)
        }
    }
}

function CompleteTagFilter ([hashtable]$BoundParameters, [string]$WordToComplete)
{
    # -Tag es un hashtable: se ofrece cada par tag=valor existente ya escrito como hashtable.
    # Lo escrito se compara contra el nombre o el valor.
    $word = $WordToComplete.Trim('@', '{', "'", '"', ' ')
    $pairs = @{}
    foreach($tags in (GetCompletionTags $BoundParameters)) {
        foreach($name in $tags.Keys) {
            $value = $tags[$name]
            if([string]::IsNullOrWhiteSpace($value)) { continue }
            $pairs["$name`0$value"] = @{ Name = $name; Value = $value }
        }
    }
    foreach($key in (SortOrdinal @($pairs.Keys))) {
        $pair = $pairs[$key]
        if($pair.Name -like "$word*" -or $pair.Value -like "$word*") {
            $text = "@{ $(NewQuotedWord $pair.Name) = '$($pair.Value -replace "'", "''")' }"
            [System.Management.Automation.CompletionResult]::new($text, "$($pair.Name)=$($pair.Value)", 'ParameterValue', $text)
        }
    }
}

# Se registran al cargar el modulo y no con [ArgumentCompleter()] en el parametro: en el
# modulo compilado el scriptblock del atributo no queda ligado al modulo y no ve las
# funciones privadas. Este si, porque se crea aqui dentro.
$script:TagCompletionCommands = 'Get-KnowITAzCostsDetail', 'Export-KnowITAzCostsRaw'
Register-ArgumentCompleter -CommandName $script:TagCompletionCommands -ParameterName TagColumn -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $boundParameters)
    CompleteTagName $boundParameters $wordToComplete
}
Register-ArgumentCompleter -CommandName $script:TagCompletionCommands -ParameterName Tag -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $boundParameters)
    CompleteTagFilter $boundParameters $wordToComplete
}

### Source file: 'CostDetail.ps1' ###
function GetCostDetailFiles
{
    # Devuelve los CSV del mes desde el cache y los descarga si faltan o ya caducaron. Un mes
    # sin datos devuelve una lista vacia (tambien queda en cache para no volver a preguntar).
    param(
        [string]$SubscriptionId,
        [string]$Month,
        [string]$Metric,
        [string]$Tenant,
        [switch]$Force
    )

    $manifest = ReadCostManifest $SubscriptionId $Metric $Month
    if($Force -or !(TestCostCacheFresh $manifest $Month)) {
        $manifest = SaveCostDetailReport -SubscriptionId $SubscriptionId -Month $Month -Metric $Metric -Tenant $Tenant
    }
    else {
        Write-Verbose "Detalle de $Month ($Metric) de $SubscriptionId desde cache, descargado $($manifest.downloadedAt)"
    }

    if($manifest.status -eq 'NoDataFound') {
        Write-Warning "Azure no tiene datos de costo de $Month para la suscripcion $SubscriptionId."
        return
    }
    $folder = GetCacheFolder $SubscriptionId
    foreach($name in $manifest.files) { Join-Path $folder $name }
}

function SaveCostDetailReport
{
    # Genera el detalle de un mes con la Cost Details API y lo guarda en cache. No crea
    # recursos en Azure: Microsoft genera el CSV en su storage y devuelve URLs con SAS.
    # Limites: un mes por reporte, maximo 13 meses de antiguedad, solo ActualCost o
    # AmortizedCost (nunca FOCUS) y solo scope de suscripcion.
    param(
        [string]$SubscriptionId,
        [string]$Month,
        [string]$Metric,
        [string]$Tenant,
        [int]$TimeoutMinutes = 30
    )

    $first = [datetime]::ParseExact("$Month-01", 'yyyy-MM-dd', [cultureinfo]::InvariantCulture)
    $last = $first.AddMonths(1).AddDays(-1)
    # El mes en curso solo se puede pedir hasta hoy.
    $today = [datetime]::UtcNow.Date
    if($last -gt $today) { $last = $today }
    $apiParams = @{ DetailResponse = $true }
    if($Tenant) { $apiParams.Tenant = $Tenant }

    Write-Verbose "Solicitando detalle de $Month ($($first.ToString('yyyy-MM-dd')) a $($last.ToString('yyyy-MM-dd'))), metrica $Metric, suscripcion $SubscriptionId..."
    $response = InvokeAzManagement @apiParams -Method POST `
        -Path "subscriptions/$SubscriptionId/providers/Microsoft.CostManagement/generateCostDetailsReport?api-version=2024-08-01" `
        -Body @{
            metric     = $Metric
            timePeriod = @{ start = $first.ToString('yyyy-MM-dd'); end = $last.ToString('yyyy-MM-dd') }
        }
    if(!$response.Success) { throw (FormatArmError $response) }

    # 200 inmediato es posible con datasets chicos; 202 + Location es el camino normal.
    if($response.StatusCode -eq 202) {
        $location = "$($response.Headers.Location)"
        if(!$location) { throw 'Azure devolvio 202 sin encabezado Location.' }
        $retryAfter = "$($response.Headers.'Retry-After')"
        $wait = $retryAfter ? [int]$retryAfter : 20
        $deadline = (Get-Date).AddMinutes($TimeoutMinutes)
        $attempt = 0

        do {
            if((Get-Date) -gt $deadline) {
                throw "El reporte no termino de generarse en $TimeoutMinutes minutos."
            }
            $wait = [math]::Clamp($wait, 5, 60)
            $attempt++
            Write-Verbose " generando... (intento $attempt, siguiente consulta en ${wait}s)"
            Start-Sleep -Seconds $wait

            $response = InvokeAzManagement @apiParams -Path $location
            if(!$response.Success) { throw (FormatArmError $response) }
            if($response.Headers.'Retry-After') {
                $wait = [int]"$($response.Headers.'Retry-After')"
            }
        } while($response.StatusCode -ne 200)
    }

    $report = $response.Value
    $folder = GetCacheFolder $SubscriptionId
    $baseName = GetCostCacheBaseName $Metric $Month
    $manifest = [ordered]@{
        subscriptionId = $SubscriptionId
        month          = $Month
        metric         = $Metric
        status         = 'Completed'
        downloadedAt   = [datetime]::UtcNow.ToString('o')
        files          = @()
    }

    switch ($report.status) {
        'NoDataFound' {
            $manifest.status = 'NoDataFound'
        }
        { $_ -and $_ -ne 'Completed' } {
            throw "La generacion del reporte termino en estado '$_'."
        }
        default {
            $blobs = @($report.manifest.blobs)
            if(!$blobs) {
                throw "El manifiesto no trae blobs: $(ConvertTo-Json $report -Depth 4 -Compress)"
            }
            Write-Verbose ("Manifiesto listo: {0} archivo(s), {1:N0} KB" -f $blobs.Count, ($report.manifest.byteCount / 1KB))

            # Los parts viejos se borran antes: un mes que antes traia 3 parts y ahora 2 no
            # debe arrastrar el tercero.
            $null = New-Item -ItemType Directory -Path $folder -Force
            Get-ChildItem $folder -Filter "$baseName-part*.csv" | Remove-Item -Force
            for($i = 0; $i -lt $blobs.Count; $i++) {
                $name = "$baseName-part$i.csv"
                $target = Join-Path $folder $name
                Write-Verbose " descargando parte $($i + 1)/$($blobs.Count)..."
                # blobLink ya trae SAS: se descarga sin encabezado de autorizacion.
                Invoke-WebRequest -Uri $blobs[$i].blobLink -OutFile "$target.tmp" -Verbose:$false
                Move-Item "$target.tmp" $target -Force
                $manifest.files += $name
            }
        }
    }

    WriteCacheFile (Join-Path $folder "$baseName.json") (ConvertTo-Json $manifest)
    [PSCustomObject]$manifest
}

### Source file: 'Errors.ps1' ###
function FormatArmError ($Response)
{
    $detail = "[$($Response.StatusCode)] $($Response.Error.code): $($Response.Error.message)"
    switch ($Response.StatusCode) {
        403 {
            "Azure respondio 403. Posibles causas:`n" +
            " - La identidad no tiene Cost Management Reader (o Contributor) sobre la suscripcion`n" +
            " - En suscripciones CSP: el partner no tiene activada la politica de cost visibility`n" +
            $detail
        }
        429 {
            "Azure respondio 429 (rate limit). La Cost Details API esta pensada para consultas " +
            "poco frecuentes (Microsoft pide no mas de una al dia por suscripcion). Si ese mes ya " +
            "estaba en cache, vuelve a ejecutar el comando sin -Force.`n" + $detail
        }
        default { $detail }
    }
}

### Source file: 'Inventory.ps1' ###
function GetInventory
{
    # Inventario de Resource Graph de las suscripciones pedidas, desde el cache del dia. Las
    # que no estan en cache se consultan juntas en una sola llamada (con paginacion) y se
    # guardan por separado.
    param(
        [string[]]$SubscriptionId,
        [string]$Tenant,
        [switch]$Force
    )

    $today = [datetime]::Now.ToString('yyyy-MM-dd')
    $missing = [System.Collections.Generic.List[string]]::new()
    foreach($id in $SubscriptionId) {
        $path = Join-Path (GetCacheFolder $id) "inventory-$today.json"
        if($Force -or !(Test-Path $path)) { $missing.Add($id) }
    }

    if($missing.Count) {
        $bySubscription = @{}
        foreach($id in $missing) { $bySubscription[$id.ToLowerInvariant()] = [System.Collections.Generic.List[object]]::new() }

        foreach($item in (QueryResourceGraph -SubscriptionId $missing -Tenant $Tenant)) {
            $bySubscription["$($item.subscriptionId)".ToLowerInvariant()]?.Add($item)
        }
        foreach($id in $missing) {
            $folder = GetCacheFolder $id
            $items = $bySubscription[$id.ToLowerInvariant()]
            # Una suscripcion sin recursos tambien se guarda: el arreglo vacio es la respuesta.
            WriteCacheFile (Join-Path $folder "inventory-$today.json") (ConvertTo-Json -InputObject @($items) -Depth 6)
            # Solo sirve el inventario de hoy; los de dias anteriores se descartan.
            Get-ChildItem $folder -Filter 'inventory-*.json' |
                Where-Object Name -ne "inventory-$today.json" |
                Remove-Item -Force
            Write-Verbose "Inventario de $id guardado en cache: $($items.Count) recursos"
        }
    }

    foreach($id in $SubscriptionId) {
        $path = Join-Path (GetCacheFolder $id) "inventory-$today.json"
        if(!$missing.Contains($id)) { Write-Verbose "Inventario de $id desde cache ($today)" }
        foreach($item in (Get-Content $path -Raw | ConvertFrom-Json -NoEnumerate)) {
            NewResourceItem $item
        }
    }
}

function QueryResourceGraph
{
    param(
        [string[]]$SubscriptionId,
        [string]$Tenant
    )

    $apiParams = @{}
    if($Tenant) { $apiParams.Tenant = $Tenant }
    $skipToken = $null
    $total = 0

    do {
        $options = @{ resultFormat = 'objectArray'; '$top' = 1000 }
        if($skipToken) { $options.'$skipToken' = $skipToken }

        $response = InvokeAzManagement @apiParams -Method POST `
            -Path 'providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01' `
            -Body @{
                subscriptions = @($SubscriptionId)
                query         = 'resources | project id, name, type, resourceGroup, location, subscriptionId, tags'
                options       = $options
            }

        # ARG usa .data y no .value, asi que llega el cuerpo completo.
        foreach($item in $response.data) { $item }
        $total += @($response.data).Count
        $skipToken = $response.'$skipToken'
        Write-Verbose " Resource Graph: $total recursos acumulados"
    } while($skipToken)
}

function GetAccessibleSubscriptions ([string]$Tenant)
{
    # Incluye las suscripciones delegadas por Lighthouse: ARM las lista igual que las propias.
    $apiParams = @{ DetailResponse = $true }
    if($Tenant) { $apiParams.Tenant = $Tenant }
    $path = 'subscriptions?api-version=2022-12-01'
    while($path) {
        $response = InvokeAzManagement @apiParams -Path $path
        if(!$response.Success) { throw (FormatArmError $response) }
        foreach($subscription in @($response.Content.value)) {
            if($subscription.state -eq 'Enabled') { $subscription.subscriptionId }
        }
        $path = $response.Content.nextLink
    }
}

### Source file: 'KnowIT.ModuleHelpers.ps1' ###
function Update-CallerPreference {
    # https://devblogs.microsoft.com/scripting/weekend-scripter-access-powershell-preference-variables/
    param(
        [ValidateNotNull()]
        [PSTypeName('System.Management.Automation.PSScriptCmdlet')]$ScriptCmdlet = (Get-Variable PSCmdlet -Scope 1 -ValueOnly),

        [ValidateSet('ErrorAction', 'Warning', 'Verbose', 'Debug', 'Information', 'Progress', 'Confirm', 'WhatIf')]
        [string[]]$Skip
    )

    $commonParameters = 'ErrorAction', 'Warning', 'Verbose', 'Debug', 'Information', 'Progress', 'Confirm', 'WhatIf'

    $invocation = $ScriptCmdlet.MyInvocation
    $commandDebug = $invocation.BoundParameters.ContainsKey('Debug')

    Write-Debug "Updating [$($invocation.MyCommand)] Preference variables:" -Debug:$commandDebug
    foreach($p in $commonParameters) {
        if($invocation.BoundParameters.ContainsKey($p)) {
            continue
        }
        $var = "${p}Preference"

        if($p -eq 'ErrorAction') {
            $val = 'Stop'
            $scope = 'Forced'
        }
        elseif($p -in $Skip) {
            $val = Get-Variable -Scope Global -Name $var -ValueOnly
            $scope = 'Global'
        }
        else {
            $val = $ScriptCmdlet.GetVariableValue($var)
            $scope = 'Caller'
        }
        Write-Debug " (From $scope scope) $var = $val " -Debug:$commandDebug
        Set-Variable -Scope 1 -Name $var -Value $val
    }
}

function Map-Object {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '', Justification = 'Internal functions')]
    param([scriptblock]$ScriptBlock)

begin {
    $code = "& { process { $ScriptBlock } }"
    $pipeline = [scriptblock]::Create($code).GetSteppablePipeline()
    $pipeline.Begin($true)
}
process {
    $pipeline.Process($_)
}
end {
    $pipeline.End()
}
}

### Source file: 'Periods.ps1' ###
function ResolvePeriod
{
    # Convierte -Month o -StartDate/-EndDate en la lista de meses que hay que tener en
    # cache. El cache siempre guarda meses completos; Start/End recortan despues las filas
    # cuando el rango no cubre el mes entero.
    param(
        [string[]]$Month,
        [Nullable[datetime]]$StartDate,
        [Nullable[datetime]]$EndDate
    )

    $today = [datetime]::UtcNow.Date
    $periods = [System.Collections.Generic.List[object]]::new()

    if($Month) {
        foreach($value in $Month | Select-Object -Unique) {
            $first = [datetime]::ParseExact("$value-01", 'yyyy-MM-dd', [cultureinfo]::InvariantCulture)
            $periods.Add([PSCustomObject]@{ Month = $value; First = $first; Start = $null; End = $null })
        }
    }
    else {
        $start = $StartDate.Date
        $end = $EndDate.Date
        if($start -gt $end) {
            throw "-StartDate ($($start.ToString('yyyy-MM-dd'))) es posterior a -EndDate ($($end.ToString('yyyy-MM-dd')))."
        }
        $first = [datetime]::new($start.Year, $start.Month, 1)
        while($first -le $end) {
            $last = $first.AddMonths(1).AddDays(-1)
            $periods.Add([PSCustomObject]@{
                Month = $first.ToString('yyyy-MM')
                First = $first
                # Solo se recorta si el rango no cubre el mes completo.
                Start = $start -gt $first ? $start : $null
                End   = $end -lt $last ? $end : $null
            })
            $first = $first.AddMonths(1)
        }
    }

    foreach($period in $periods) {
        if($period.First -gt $today) {
            throw "El mes $($period.Month) todavia no empieza: no hay costos que consultar."
        }
    }
    $periods
}

function TestDateInPeriod ([Nullable[datetime]]$Date, $Period)
{
    if(!$Period.Start -and !$Period.End) { return $true }
    # Sin fecha no se puede ubicar la fila: dentro de un rango recortado se descarta.
    if(!$Date) { return $false }
    if($Period.Start -and $Date -lt $Period.Start) { return $false }
    if($Period.End -and $Date -gt $Period.End) { return $false }
    return $true
}

### Source file: 'RawCsv.ps1' ###
function WriteRawCsv
{
    # Mismo formato que Export-Csv de PowerShell 7 (todos los campos entre comillas, UTF-8 sin
    # BOM), pero escribiendo directo: un mes de una suscripcion grande son decenas de miles de
    # filas con ~60 columnas, y crear un PSCustomObject por fila lo hace varias veces mas lento.
    param(
        [string]$Path,
        [string[]]$Headers,
        [object[]]$TagColumns,
        [System.Collections.Generic.List[object]]$Items
    )

    # foreach y no pipeline: $null | ForEach-Object corre una vez y agregaria una columna nula
    # cuando no hay tags que exportar.
    $tagNames = @(foreach($column in $TagColumns) { $column.Tag })
    $fields = [string[]]::new($Headers.Count + $tagNames.Count)

    $writer = [IO.StreamWriter]::new($Path, $false, [Text.UTF8Encoding]::new($false))
    try {
        $columnNames = @($Headers) + @(foreach($column in $TagColumns) { $column.Column })
        $writer.WriteLine((ConvertToCsvLine $columnNames))

        foreach($item in $Items) {
            $row = $item.Row
            $tags = $item.Tags
            $i = 0
            foreach($header in $Headers) { $fields[$i++] = "$($row.$header)" }
            foreach($tag in $tagNames) { $fields[$i++] = "$($tags[$tag])" }
            $writer.WriteLine((ConvertToCsvLine $fields))
        }
    }
    finally {
        $writer.Dispose()
    }
}

function ConvertToCsvLine ([string[]]$Fields)
{
    '"' + [string]::Join('","', [string[]]$Fields.ForEach({ $_.Replace('"', '""') })) + '"'
}

### Source file: 'Resources.ps1' ###
function NewResourceItem ($Item)
{
    # $Item: fila de Resource Graph (id, name, type, resourceGroup, location, subscriptionId,
    # tags), recien consultada o leida del cache.
    $tags = @{}
    if($Item.tags) {
        foreach($property in $Item.tags.PSObject.Properties) {
            $tags[[string]$property.Name] = [string]$property.Value
        }
    }
    [PSCustomObject]@{
        PSTypeName     = 'KnowIT.AzureCosts.Resource'
        ResourceId     = $Item.id
        Name           = $Item.name
        Type           = $Item.type
        ResourceGroup  = $Item.resourceGroup
        Location       = $Item.location
        SubscriptionId = $Item.subscriptionId
        Tags           = $tags
    }
}

function NewInventoryIndex ([object[]]$Inventory)
{
    # El casing del ResourceId difiere entre el detalle de costos y ARG (ARG devuelve
    # /resourcegroups/ en minusculas). Indexar en minusculas es lo que hace posible el cruce.
    $index = @{}
    foreach($resource in $Inventory) {
        $index[$resource.ResourceId.ToLowerInvariant()] = $resource
    }
    $index
}

# Sub-recursos que generan cargo con su propio ResourceId pero que Resource Graph no
# inventaria. Sin esto aparecen como recursos inexistentes aunque su padre exista. Lista
# explicita y no una regla generica de "subir al padre": una base SQL que no esta en ARG
# es una base borrada, y sumarla a su servidor la esconderia.
$script:ChildToParentTypes = [System.Collections.Generic.HashSet[string]]::new(
    [string[]]@(
        'microsoft.datafactory/factories/pipelines'
        'microsoft.datafactory/factories/triggers'
    ),
    [StringComparer]::OrdinalIgnoreCase
)

function GetParentResourceId ([string]$ResourceIdNorm)
{
    # .../providers/microsoft.datafactory/factories/adf/pipelines/p1
    # -> .../providers/microsoft.datafactory/factories/adf
    $i = $ResourceIdNorm.LastIndexOf('/providers/')
    if($i -lt 0) { return $null }

    $parts = $ResourceIdNorm.Substring($i + '/providers/'.Length).Split('/')
    if($parts.Count -lt 5) { return $null }
    if(!$script:ChildToParentTypes.Contains("$($parts[0])/$($parts[1])/$($parts[3])")) { return $null }

    return $ResourceIdNorm.Substring(0, $i) + "/providers/$($parts[0])/$($parts[1])/$($parts[2])"
}

function GetResourceGroupFromId ([string]$ResourceId)
{
    # /subscriptions/x/resourceGroups/MI-RG/providers/... -> MI-RG
    $parts = $ResourceId.Split('/', [StringSplitOptions]::RemoveEmptyEntries)
    for($i = 0; $i -lt $parts.Count - 1; $i++) {
        if($parts[$i] -eq 'resourcegroups') { return $parts[$i + 1] }
    }
    return ''
}

function TestUntagged ([hashtable]$Tags)
{
    # Un tag presente pero vacio no cuenta como etiquetado.
    foreach($value in $Tags.Values) {
        if(![string]::IsNullOrWhiteSpace($value)) { return $false }
    }
    return $true
}

function TestItemFilter
{
    # Filtros comunes a todos los comandos. -like: acepta comodines y no distingue
    # mayusculas, igual que Azure con nombres de resource group y de tag.
    param(
        [string]$ResourceGroup,
        [hashtable]$Tags,
        [string[]]$ResourceGroupFilter,
        [switch]$Untagged,
        [hashtable]$TagFilter
    )

    if($ResourceGroupFilter) {
        $match = $false
        foreach($pattern in $ResourceGroupFilter) {
            if($ResourceGroup -like $pattern) { $match = $true; break }
        }
        if(!$match) { return $false }
    }
    if($Untagged -and !(TestUntagged $Tags)) { return $false }
    if($TagFilter) {
        foreach($name in $TagFilter.Keys) {
            if(!$Tags.ContainsKey($name) -or $Tags[$name] -notlike $TagFilter[$name]) { return $false }
        }
    }
    return $true
}

function AssertFilterCombination ([hashtable]$BoundParameters)
{
    # -Untagged es "sin ningun tag": combinarlo con -Tag no puede devolver nada, y con
    # -ResourceGroup se decidio mantenerlos excluyentes.
    if($BoundParameters.ContainsKey('Untagged') -and $BoundParameters.Untagged) {
        foreach($name in 'ResourceGroup', 'Tag') {
            if($BoundParameters.ContainsKey($name)) {
                throw "-Untagged no se puede combinar con -$name."
            }
        }
    }
}

function AddTagColumns ([object[]]$Items, [string[]]$TagNames, [string[]]$ReservedNames, [string]$TagsProperty = 'Tags')
{
    # Una propiedad por tag, con el nombre del tag tal cual. Si choca con una propiedad
    # existente (p. ej. un tag 'Location') se le antepone tag_ solo a ese.
    $reserved = [System.Collections.Generic.HashSet[string]]::new([string[]]$ReservedNames, [StringComparer]::OrdinalIgnoreCase)
    $columns = @(foreach($tag in $TagNames) {
        @{ Tag = $tag; Column = $reserved.Contains($tag) ? "tag_$tag" : $tag }
    })
    foreach($item in $Items) {
        $tags = $item.$TagsProperty
        foreach($column in $columns) {
            $item | Add-Member -NotePropertyName $column.Column -NotePropertyValue ($tags[$column.Tag] ?? '') -Force
        }
    }
}

function GetTagNames ([object[]]$TagSets)
{
    # Nombres de tag sin repetir y sin distinguir mayusculas, en orden ordinal para que las
    # columnas salgan igual en cualquier maquina.
    $names = @{}
    foreach($tags in $TagSets) {
        foreach($key in $tags.Keys) {
            if(!$names.ContainsKey($key)) { $names[$key] = $key }
        }
    }
    @(SortOrdinal @($names.Values))
}

function ConvertTagsToJson ([object[]]$Items)
{
    # Export-Csv escribe una hashtable como 'System.Collections.Hashtable'. En JSON compacto
    # el CSV conserva los tags; las llaves en orden ordinal para que el texto sea el mismo en
    # cualquier maquina. Va al final: los filtros y -TagColumn necesitan la hashtable.
    foreach($item in $Items) {
        $ordered = [ordered]@{}
        foreach($key in (SortOrdinal @($item.Tags.Keys))) { $ordered[$key] = $item.Tags[$key] }
        $item.Tags = ConvertTo-Json -InputObject $ordered -Compress
    }
}

### Source file: 'Sorting.ps1' ###
function SortOrdinal
{
    # Orden estable por comparacion ordinal. Sort-Object usa reglas de la cultura y ordena
    # distinto ('_' antes que los digitos), lo que cambiaria el orden de filas entre
    # maquinas con distinta configuracion regional.
    param([object[]]$Items, [scriptblock]$Key = { $_ })

    $pairs = [Tuple[string, int][]]::new($Items.Count)
    for($i = 0; $i -lt $Items.Count; $i++) {
        $_ = $Items[$i]
        $pairs[$i] = [Tuple]::Create([string](& $Key), $i)
    }
    [Array]::Sort($pairs, [Comparison[Tuple[string, int]]] {
        param($a, $b)
        $result = [string]::CompareOrdinal($a.Item1, $b.Item1)
        $result ? $result : $a.Item2.CompareTo($b.Item2)
    })
    foreach($pair in $pairs) { $Items[$pair.Item2] }
}

### Source file: 'Tokens.ps1' ###

function GetArmToken ([string]$Tenant)
{
    # Sin -Tenant se usa el del contexto de Az. Con Azure Lighthouse eso es lo correcto:
    # el token del tenant administrador sirve para las suscripciones delegadas.
    $params = @{
        ResourceUrl    = $script:ArmEndpoint
        AsSecureString = $true
        Debug          = $false
    }
    if($Tenant) { $params.Tenant = $Tenant }

    if(!(Get-AzContext)) {
        throw 'No hay sesion de Azure. Ejecuta Connect-AzAccount (o Connect-AzAccount -Identity con una managed identity).'
    }
    $token = Get-AzAccessToken @params
    ConvertFrom-SecureString $token.Token -AsPlainText
}

#endregion

#region === Source .psm1 file ===

#region === Module initialization ===

$script:ArmEndpoint = 'https://management.azure.com'

# En el perfil del usuario, en carpeta .knowit persistiendo reinicios
# donde cada modulo KnowIT tiene su propia subcarpeta.
$script:CacheRoot = Join-Path $HOME '.knowit' 'KnowIT.AzureCosts'

#endregion

#endregion