Public/Export-AACTerraform.ps1

function Export-AACTerraform {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Exports existing Azure resources as Terraform - a folder ready for
        terraform init and plan, with the import blocks - through the Azure
        Export for Terraform API, and shows what couldn't be exported.
    .DESCRIPTION
        Asks Azure to write the Terraform configuration of what you choose -
        a resource group, resources by ID (or piped from any command that
        returns resources), or a Resource Graph query - and waits for it.
 
        The export is a long-running operation that Azure does on its side,
        and its time grows with the number of resources. So the command:
          - counts the resources first (Resource Graph), and says when an
            export is large and how to make it faster
          - with -BatchSize, splits it into exports of that many resources
            that run at the same time (-ThrottleLimit at once), and merges
            them into one folder
          - shows each export as it runs: its status, how long it has run,
            and how many batches are done - not a percentage Azure doesn't
            give
          - saves the operations in export.json as soon as Azure accepts
            them: after Ctrl+C or -TimeoutMinutes, -Resume collects the
            result without exporting again
 
        The folder (-Path) gets:
          terraform.tf the providers the configuration needs
          providers.tf the provider blocks, with the subscription ID
          main.tf the resources
          azapi.tf with -FallbackToAzApi: what azurerm couldn't
                         export, as azapi resources
          import.tf import blocks, so terraform plan brings the
                         existing resources under Terraform - no
                         terraform import commands to run
          export.json what was asked, the operations, what was skipped
                         and why
        Several subscriptions get a folder each, under -Path, exported at
        the same time.
 
        Several resource groups of one subscription are exported together,
        in one query (with their child resources), so they share one
        folder and their names can't clash. With -BatchSize, each batch
        gets names of its own (res-b1-0, res-b2-0...); a resource two
        batches both return is kept once. Resources in different batches
        refer to each other by Azure ID rather than by Terraform address.
 
        What couldn't be exported is listed, never silently dropped: a
        resource type the azurerm provider doesn't support yet is Skipped -
        -FallbackToAzApi exports those again with the azapi provider, which
        covers every Azure resource type. Sensitive values are masked
        unless -IncludeSensitive.
 
        -Plan proves the export: it runs terraform init, validate and plan
        in each folder and reads the plan (as Get-AACTerraformPlan does).
        A faithful export plans only imports; anything else - an attribute
        Terraform would change - is what the export didn't capture. It
        needs the terraform CLI, signed in its own way (az login, or
        ARM_CLIENT_ID, ARM_CLIENT_SECRET and ARM_TENANT_ID): Terraform
        doesn't use the Connect-AAC sign-in.
 
        The export reads Azure; it changes nothing there. It needs Reader
        on what is exported, and the Microsoft.AzureTerraform resource
        provider registered on the subscription - -RegisterProvider
        registers it (that is a change, so it asks first and honours
        -WhatIf; registering needs a role such as Contributor).
 
        The API is in preview (api-version 2026-09-01-preview); -ApiVersion
        picks another.
    .PARAMETER ResourceGroupName
        The resource groups to export. Found by name in the subscriptions
        you can see (or -SubscriptionId).
    .PARAMETER ResourceId
        The resources to export, by Azure resource ID - or piped: any object
        with an Id or ResourceId (Export-AACResource, Get-AACUnusedResource,
        Get-AACResourceHealth...), or the IDs as text.
    .PARAMETER Query
        A Resource Graph where-clause choosing the resources, such as
        'type =~ "microsoft.network/virtualnetworks" and location =~ "uksouth"'.
    .PARAMETER Table
        With -Query: the Resource Graph table (Resources if not given).
    .PARAMETER Resume
        Collect an export that was still running - after Ctrl+C or
        -TimeoutMinutes - from the operations saved in -Path\export.json (or
        in its subscription folders), without exporting again. -Plan and the
        report parameters still apply; the rest is read from export.json.
    .PARAMETER SubscriptionId
        With -ResourceGroupName: where to look for them. With -Query: the
        subscriptions to export from (every one you can see if not given).
    .PARAMETER Path
        The folder to write. Created if it doesn't exist; Terraform files
        already in it are not overwritten without -Force.
    .PARAMETER Provider
        azurerm (the default) or azapi.
    .PARAMETER Include
        Also export: ResourceGroup (the resource group itself, with
        -ResourceId or -Query), RoleAssignment, Lock, DiagnosticSetting (the
        extension resources on what is exported), ManagedResource (resources
        Azure manages for you, normally left out). Each makes the export
        larger, and slower.
    .PARAMETER Recurse
        With -ResourceId or -Query: the child resources too (subnets of a
        virtual network, databases of a server...).
    .PARAMETER Exclude
        Azure resource IDs to leave out - case-insensitive regular
        expressions, such as '/subscriptions/.+/resourceGroups/rg-app/providers/Microsoft.Insights/.*'.
    .PARAMETER ExcludeType
        Terraform resource types to leave out, such as azurerm_network_watcher.
    .PARAMETER NamePrefix
        The prefix of the Terraform resource names (res- if not given:
        res-0, res-1...).
    .PARAMETER Compact
        Leave out properties with empty values.
    .PARAMETER IncludeSensitive
        Don't mask sensitive values. They are then in the .tf files in clear
        text: keep the folder out of source control.
    .PARAMETER FallbackToAzApi
        Export what the azurerm provider couldn't again, with the azapi
        provider, into azapi.tf - so nothing is left out.
    .PARAMETER BatchSize
        Split an export of more resources than this into exports of this
        many, run at the same time and merged into one folder - the way to
        make a large export faster. 0 (the default): one export.
    .PARAMETER ThrottleLimit
        How many exports run at the same time (8 if not given) - batches,
        subscriptions and the azapi pass alike.
    .PARAMETER Plan
        Run terraform init, validate and plan in each folder, and show what
        Terraform would change beyond the imports.
    .PARAMETER TerraformPath
        The terraform executable, if it isn't on PATH.
    .PARAMETER RegisterProvider
        Register the Microsoft.AzureTerraform resource provider on the
        subscriptions where it isn't.
    .PARAMETER Force
        Overwrite Terraform files already in the folder.
    .PARAMETER TimeoutMinutes
        How long to wait for each export (30 if not given). One still
        running then is left running: -Resume collects it.
    .PARAMETER ApiVersion
        The Azure Export for Terraform API version.
    .PARAMETER CsvPath
        Write the resources - exported, skipped and errors - to this CSV file.
    .PARAMETER HtmlPath
        Write an interactive HTML report.
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return an object per folder.
    .PARAMETER NoDisplay
        Return an object per folder without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Export-AACTerraform -ResourceGroupName rg-app -Path .\terraform\rg-app
        The resource group as Terraform, with import blocks.
    .EXAMPLE
        Export-AACTerraform -ResourceGroupName rg-platform -BatchSize 50 -Path .\terraform\platform
        A large resource group, split into exports of 50 resources that run at the same time.
    .EXAMPLE
        Export-AACTerraform -Resume -Path .\terraform\platform
        Collects the export that was still running when the command was stopped.
    .EXAMPLE
        Export-AACTerraform -ResourceGroupName rg-hub, rg-spoke -FallbackToAzApi -Plan -Path .\terraform\network
        Two resource groups in one folder, azapi for what azurerm can't do, and terraform plan to prove it.
    .EXAMPLE
        Export-AACTerraform -Query 'type =~ "microsoft.keyvault/vaults"' -SubscriptionId $sub -Include RoleAssignment, Lock -Path .\terraform\vaults
        Every key vault of a subscription, with its role assignments and locks.
    .EXAMPLE
        Get-AACUnusedResource -NoDisplay | Export-AACTerraform -Path .\terraform\unused
        The unused resources as Terraform, piped by ID - a record of them before they are cleaned up.
    .EXAMPLE
        Export-AACTerraform -ResourceGroupName rg-app -Path .\tf -RegisterProvider -WhatIf
        Shows where the resource provider would be registered, without registering it.
    .OUTPUTS
        AAC.TerraformExport
    #>

    [CmdletBinding(DefaultParameterSetName = 'ResourceGroup', SupportsShouldProcess)]
    [OutputType('AAC.TerraformExport')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'ResourceGroup')]
        [ValidateNotNullOrEmpty()]
        [string[]] $ResourceGroupName,

        [Parameter(Mandatory, ParameterSetName = 'ResourceId', ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('Id')]
        [string[]] $ResourceId,

        [Parameter(Mandatory, ParameterSetName = 'Query')]
        [ValidateNotNullOrEmpty()]
        [string] $Query,

        [Parameter(ParameterSetName = 'Query')]
        [string] $Table,

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

        [Parameter(ParameterSetName = 'ResourceGroup')]
        [Parameter(ParameterSetName = 'Query')]
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

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

        [ValidateSet('azurerm', 'azapi')]
        [string] $Provider = 'azurerm',

        [ValidateSet('ResourceGroup', 'RoleAssignment', 'Lock', 'DiagnosticSetting', 'ManagedResource')]
        [string[]] $Include,

        [switch] $Recurse,

        [string[]] $Exclude,

        [string[]] $ExcludeType,

        [ValidatePattern('^[A-Za-z_][A-Za-z0-9_-]*$')]
        [string] $NamePrefix = 'res-',

        [switch] $Compact,

        [switch] $IncludeSensitive,

        [switch] $FallbackToAzApi,

        [ValidateRange(0, 1000)]
        [int] $BatchSize = 0,

        [ValidateRange(1, 20)]
        [int] $ThrottleLimit = 8,

        [switch] $Plan,

        [string] $TerraformPath,

        [switch] $RegisterProvider,

        [switch] $Force,

        [ValidateRange(1, 240)]
        [int] $TimeoutMinutes = 30,

        [ValidateNotNullOrEmpty()]
        [string] $ApiVersion = '2026-09-01-preview',

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Terraform export',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    begin {
        $pipedIds = [System.Collections.Generic.List[string]]::new()
    }

    process {
        foreach ($id in @($ResourceId | Where-Object { $_ })) { $pipedIds.Add([string]$id) }
    }

    end {
        trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

        $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
        $interactive = -not $NoDisplay -and -not $pipedOnward
        $resolve = { param([string] $Target) if ($Target) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Target) } }
        $root = & $resolve $Path
        $mode = $PSCmdlet.ParameterSetName
        $utf8 = [System.Text.UTF8Encoding]::new($false)
        $exportUri = { param([string] $Subscription, [string] $Version) "/subscriptions/$Subscription/providers/Microsoft.AzureTerraform/exportTerraform?api-version=$Version" }
        # An export this large takes minutes on Azure's side: say so, and how to make it faster.
        $largeExport = 100

        # -Plan needs the terraform CLI: found now, before anything is exported.
        $terraform = ''
        if ($Plan) {
            $terraform = if ($TerraformPath) { & $resolve $TerraformPath } else { [string](Get-Command -Name 'terraform' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 | ForEach-Object { $_.Source }) }
            if (-not $terraform -or -not (Test-Path -LiteralPath $terraform -PathType Leaf)) {
                throw 'Azure Admin Console found no terraform CLI for -Plan. Install it (https://developer.hashicorp.com/terraform/install) and put it on PATH, or give its path with -TerraformPath.'
            }
        }

        # The options - from the parameters, or (-Resume) from the export being collected.
        $options = [ordered]@{ provider = $Provider; fallbackToAzApi = [bool]$FallbackToAzApi; compact = [bool]$Compact; includeSensitive = [bool]$IncludeSensitive; namePrefix = $NamePrefix; batchSize = $BatchSize; apiVersion = $ApiVersion }

        # What every request shares.
        $common = [ordered]@{ targetProvider = $Provider; fullProperties = -not $Compact; maskSensitive = -not $IncludeSensitive; namePattern = $NamePrefix }
        if ($Exclude) { $common.excludeAzureResource = @($Exclude) }
        if ($ExcludeType) { $common.excludeTerraformResource = @($ExcludeType) }
        $extensionNames = @{ RoleAssignment = 'role-assignments'; Lock = 'locks'; DiagnosticSetting = 'diagnostic-settings' }
        $extensions = @($Include | Where-Object { $_ -and $extensionNames.ContainsKey($_) } | ForEach-Object { $extensionNames[$_] })
        if ($extensions.Count) { $common.includeExtensions = $extensions }
        if ($Include -contains 'ManagedResource') { $common.includeManagedResource = $true }
        $withCommon = { param([System.Collections.IDictionary] $Body) $copy = [ordered]@{}; foreach ($key in $Body.Keys) { $copy[$key] = $Body[$key] }; foreach ($key in $common.Keys) { if (-not $copy.Contains($key)) { $copy[$key] = $common[$key] } }; $copy }
        $quote = { param([string] $Value) "'$($Value -replace "'", "\'")'" }
        $clock = { param([timespan] $Span) if ($Span.TotalHours -ge 1) { '{0}:{1:mm\:ss}' -f [int][Math]::Floor($Span.TotalHours), $Span } else { '{0:m\:ss}' -f $Span } }

        # export.json: written when the export starts, updated as Azure accepts each operation, final when done.
        $saveManifest = {
            param([hashtable] $Job, [string] $Status)
            $record = [ordered]@{
                status = $Status; exportedBy = "Azure.Admin.Console $((Get-Module -Name 'Azure.Admin.Console').Version)"; startedAt = $Job.StartedAt.ToString('o')
                subscriptionId = $Job.SubscriptionId; subscription = $Job.Subscription; label = $Job.Label; resources = $Job.Planned; options = $options
                operations = @($Job.Requests | ForEach-Object { [ordered]@{ key = $_.Key; phase = $_.Phase; part = $_.Part; url = $_.OperationUrl; request = $_.Body } })
            }
            if ($Status -ne 'InProgress') { $record.finishedAt = [datetime]::UtcNow.ToString('o'); $record.skippedResources = @($Job.Skipped); $record.errors = @($Job.Errors); $record.duplicates = [int]$Job.Duplicates }
            $null = [System.IO.Directory]::CreateDirectory($Job.Path)
            [System.IO.File]::WriteAllText((Join-Path -Path $Job.Path -ChildPath 'export.json'), (ConvertTo-Json -InputObject $record -Depth 30), $utf8)
        }
        $newJob = {
            param([string] $Subscription, [string] $Label)
            @{ Index = 0; SubscriptionId = $Subscription.ToLowerInvariant(); Subscription = ''; Label = $Label; Path = ''; Body = $null; Failure = ''; Pending = $false; Planned = $null; Ids = @(); Groups = @()
                Requests = [System.Collections.Generic.List[hashtable]]::new(); Skipped = @(); Errors = @(); Duplicates = 0; Advice = @(); StartedAt = [datetime]::UtcNow; Resumed = $false; OperationId = ''; FallbackTried = $false }
        }

        $null = Get-AACAccessToken
        if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Terraform export' -Color 'mediumpurple' }
        $outcome = Invoke-AACProgress -ScriptBlock {
            $jobs = [System.Collections.Generic.List[hashtable]]::new()
            $scopeLabel = ''
            if ($mode -eq 'Resume') {
                # 0. -Resume: the exports saved in export.json, still in progress.
                $manifests = @(@(Join-Path -Path $root -ChildPath 'export.json') + @(Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue | ForEach-Object { Join-Path -Path $_.FullName -ChildPath 'export.json' }) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf })
                foreach ($file in $manifests) {
                    $saved = ConvertFrom-Json -InputObject ([System.IO.File]::ReadAllText($file)) -AsHashtable -Depth 50
                    if ([string]$saved['status'] -ne 'InProgress') { continue }
                    $job = & $newJob ([string]$saved['subscriptionId']) ([string]$saved['label'])
                    $job.Subscription = [string]$saved['subscription']
                    $job.Path = Split-Path -Path $file -Parent
                    $job.Planned = $saved['resources']
                    $job.Resumed = $true
                    $job.Index = $jobs.Count + 1
                    # ConvertFrom-Json reads an ISO date as a DateTime already.
                    $started = $saved['startedAt']
                    if ($started -is [datetime]) { $job.StartedAt = $started.ToUniversalTime() }
                    elseif ($started) { $job.StartedAt = [datetime]::Parse([string]$started, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind).ToUniversalTime() }
                    if ($saved['options'] -is [System.Collections.IDictionary]) { foreach ($key in @($saved['options'].Keys)) { $options[$key] = $saved['options'][$key] } }
                    foreach ($op in @($saved['operations'])) {
                        $job.Requests.Add(@{ Key = "$($job.Index)/$($op['part'])"; Phase = [string]$op['phase']; Part = [string]$op['part']; OperationUrl = [string]$op['url']; Body = $op['request']; Uri = (& $exportUri $job.SubscriptionId ([string]$options.apiVersion)) })
                    }
                    $jobs.Add($job)
                }
                if (-not $jobs.Count) { throw "Azure Admin Console found no export in progress in $root (an export.json with status InProgress). Start a new export without -Resume." }
                $scopeLabel = "$($jobs.Count) export(s) resumed"
                Update-AACProgress -Id 'scope' -Complete -Description "Resuming $(@($jobs | ForEach-Object { "$($_.Label) in $($_.Subscription)" }) -join '; ')"
            }
            else {
                # 1. What to export: one export per subscription.
                Update-AACProgress -Id 'scope' -Indeterminate -Description 'Working out what to export'
                switch ($mode) {
                    'ResourceGroup' {
                        $scope = Resolve-AACScope -SubscriptionId @($SubscriptionId | Where-Object { $_ })
                        $names = ($ResourceGroupName | ForEach-Object { & $quote $_ }) -join ', '
                        $found = Invoke-AACGraphBatch -Query ([ordered]@{ groups = "resourcecontainers | where type =~ 'microsoft.resources/subscriptions/resourcegroups' and name in~ ($names) | project name, subscriptionId = tolower(subscriptionId)" }) -SubscriptionId $scope.GraphScope
                        $groups = @($found.Rows['groups'] | Where-Object { $_ -and $ResourceGroupName -contains [string]$_['name'] })
                        foreach ($name in $ResourceGroupName) {
                            $hits = @($groups | Where-Object { [string]$_['name'] -ieq $name })
                            if (-not $hits.Count) { throw "Azure Admin Console found no resource group named '$name' in $($scope.Label). Check the name, or give -SubscriptionId." }
                            if (@($hits | ForEach-Object { $_['subscriptionId'] } | Select-Object -Unique).Count -gt 1) { throw "Azure Admin Console found a resource group named '$name' in more than one subscription. Give -SubscriptionId to choose." }
                        }
                        foreach ($bySubscription in @($groups | Group-Object -Property { [string]$_['subscriptionId'] })) {
                            $inSubscription = @($bySubscription.Group | ForEach-Object { [string]$_['name'] } | Select-Object -Unique)
                            $job = & $newJob $bySubscription.Name "resource group $($inSubscription -join ', ')"
                            $job.Groups = $inSubscription
                            $job.Body = if ($inSubscription.Count -eq 1) {
                                [ordered]@{ type = 'ExportResourceGroup'; resourceGroupName = $inSubscription[0] }
                            }
                            else {
                                # One query for them all: one folder, and no two resources named res-0.
                                [ordered]@{ type = 'ExportQuery'; query = "resourceGroup in~ ($(($inSubscription | ForEach-Object { & $quote $_ }) -join ', '))"; recursive = $true; includeResourceGroup = $true }
                            }
                            $jobs.Add($job)
                        }
                    }
                    'ResourceId' {
                        $bySubscription = [ordered]@{}
                        foreach ($id in $pipedIds) {
                            $m = [regex]::Match($id, '^/subscriptions/([0-9a-fA-F-]{36})(/|$)')
                            if (-not $m.Success) { throw "Azure Admin Console can't export '$id': it isn't an Azure resource ID (/subscriptions/<id>/resourceGroups/...)." }
                            $sub = $m.Groups[1].Value.ToLowerInvariant()
                            if (-not $bySubscription.Contains($sub)) { $bySubscription[$sub] = [System.Collections.Generic.List[string]]::new() }
                            if (-not $bySubscription[$sub].Contains($id)) { $bySubscription[$sub].Add($id) }
                        }
                        if (-not $bySubscription.Count) { throw 'Azure Admin Console got no resource IDs to export. Pipe resources in, or give -ResourceId.' }
                        $scope = Resolve-AACScope -SubscriptionId @($bySubscription.Keys)
                        foreach ($sub in $bySubscription.Keys) {
                            $job = & $newJob $sub "$($bySubscription[$sub].Count) resource(s)"
                            $job.Ids = @($bySubscription[$sub])
                            $job.Planned = $job.Ids.Count
                            $job.Body = [ordered]@{ type = 'ExportResource'; resourceIds = $job.Ids }
                            if ($Recurse) { $job.Body.recursive = $true }
                            if ($Include -contains 'ResourceGroup') { $job.Body.includeResourceGroup = $true }
                            $jobs.Add($job)
                        }
                    }
                    'Query' {
                        $scope = Resolve-AACScope -SubscriptionId @($SubscriptionId | Where-Object { $_ })
                        foreach ($sub in $scope.Ids) {
                            $job = & $newJob $sub "query $Query"
                            $job.Body = [ordered]@{ type = 'ExportQuery'; query = $Query }
                            if ($Table) { $job.Body.table = $Table }
                            if ($Recurse) { $job.Body.recursive = $true }
                            if ($Include -contains 'ResourceGroup') { $job.Body.includeResourceGroup = $true }
                            $jobs.Add($job)
                        }
                    }
                }
                $scopeLabel = [string]$scope.Label
                # A folder each when there are several subscriptions, named after them.
                $index = 0
                foreach ($job in $jobs) {
                    $job.Index = ++$index
                    $job.Subscription = [string]$scope.Names[$job.SubscriptionId]
                    if (-not $job.Subscription) { $job.Subscription = $job.SubscriptionId }
                    $job.Path = if ($jobs.Count -eq 1) { $root } else { Join-Path -Path $root -ChildPath (($job.Subscription -replace '[^\w.-]', '-').Trim('-')) }
                    if (-not $Force -and (Test-Path -LiteralPath $job.Path -PathType Container) -and @(Get-ChildItem -LiteralPath $job.Path -Filter '*.tf' -File).Count) {
                        throw "Azure Admin Console won't overwrite the Terraform files in $($job.Path). Choose an empty folder, or add -Force."
                    }
                }
                Update-AACProgress -Id 'scope' -Complete -Description "$($jobs.Count) export(s): $(@($jobs | ForEach-Object { "$($_.Label) in $($_.Subscription)" }) -join '; ')"

                # 2. The resource provider: registered, or registered now (-RegisterProvider), or a clear reason.
                foreach ($job in $jobs) {
                    $state = ''
                    try { $state = [string](Invoke-AACArmRequest -Uri "/subscriptions/$($job.SubscriptionId)/providers/Microsoft.AzureTerraform?api-version=2021-04-01")['registrationState'] }
                    catch { $state = '' }
                    # Unreadable (no right to read providers): let the export itself say.
                    if (-not $state -or $state -eq 'Registered') { continue }
                    if (-not $RegisterProvider) {
                        $job.Failure = "The Microsoft.AzureTerraform resource provider isn't registered on $($job.Subscription) ($state). Run again with -RegisterProvider, or register it in the portal (Subscription > Resource providers)."
                        continue
                    }
                    if (-not $PSCmdlet.ShouldProcess("subscription $($job.Subscription)", 'Register the Microsoft.AzureTerraform resource provider')) {
                        $job.Failure = "Not exported: the Microsoft.AzureTerraform resource provider isn't registered on $($job.Subscription), and -WhatIf left it so."
                        continue
                    }
                    Update-AACProgress -Id "register$($job.Index)" -Indeterminate -Description "Registering Microsoft.AzureTerraform on $($job.Subscription)"
                    $null = Invoke-AACArmWrite -Method Post -Uri "/subscriptions/$($job.SubscriptionId)/providers/Microsoft.AzureTerraform/register?api-version=2021-04-01"
                    $deadline = [datetime]::UtcNow.AddMinutes(5)
                    while ($state -ne 'Registered' -and [datetime]::UtcNow -lt $deadline) {
                        Start-Sleep -Seconds 5
                        $state = [string](Invoke-AACArmRequest -Uri "/subscriptions/$($job.SubscriptionId)/providers/Microsoft.AzureTerraform?api-version=2021-04-01")['registrationState']
                    }
                    if ($state -ne 'Registered') { $job.Failure = "Microsoft.AzureTerraform is still $state on $($job.Subscription) after 5 minutes. Try again in a few minutes." }
                    Update-AACProgress -Id "register$($job.Index)" -Complete -Description "Microsoft.AzureTerraform on $($job.Subscription): $state"
                }

                # 3. How big: the resources counted (Resource Graph), so a large export is seen coming - and can be split.
                $sizing = [ordered]@{}
                foreach ($job in @($jobs | Where-Object { -not $_.Failure -and -not $_.Ids.Count })) {
                    $sizing["j$($job.Index)"] = switch ($mode) {
                        'ResourceGroup' { "resources | where subscriptionId =~ '$($job.SubscriptionId)' and resourceGroup in~ ($(($job.Groups | ForEach-Object { & $quote $_ }) -join ', ')) | project id" }
                        'Query' { "$(if ($Table) { $Table } else { 'resources' }) | where subscriptionId =~ '$($job.SubscriptionId)' | where $Query | project id" }
                    }
                }
                if ($sizing.Count) {
                    Update-AACProgress -Id 'size' -Indeterminate -Description 'Counting the resources to export'
                    $sized = Invoke-AACGraphBatch -Query $sizing -SubscriptionId @($jobs | ForEach-Object { $_.SubscriptionId } | Select-Object -Unique) -AllowFailure @($sizing.Keys)
                    foreach ($job in @($jobs | Where-Object { $sizing.Contains("j$($_.Index)") })) {
                        if ($sized.Errors.Contains("j$($job.Index)")) { continue }
                        $job.Ids = @($sized.Rows["j$($job.Index)"] | Where-Object { $_ } | ForEach-Object { [string]$_['id'] })
                        $job.Planned = $job.Ids.Count
                    }
                    Update-AACProgress -Id 'size' -Complete -Description "To export: $(@($jobs | Where-Object { -not $_.Failure } | ForEach-Object { "$(if ($null -ne $_.Planned) { '{0:N0} resource(s)' -f $_.Planned } else { 'an unknown number of resources' }) in $($_.Subscription)" }) -join '; ')"
                }

                # Advice for a large export: batches, and the types most often left out.
                $noise = [ordered]@{ 'microsoft.network/networkwatchers' = 'azurerm_network_watcher'; 'microsoft.alertsmanagement/smartdetectoralertrules' = 'azurerm_monitor_smart_detector_alert_rule'; 'microsoft.insights/actiongroups' = 'azurerm_monitor_action_group' }
                foreach ($job in @($jobs | Where-Object { -not $_.Failure -and $null -ne $_.Planned -and $_.Planned -ge $largeExport })) {
                    if (-not $BatchSize) { $job.Advice += "$($job.Planned) resources: a large export takes several minutes on Azure's side. -BatchSize 50 splits it into exports that run at the same time." }
                    $types = @($job.Ids | ForEach-Object { [regex]::Match($_, '/providers/([^/]+/[^/]+)/[^/]+$').Groups[1].Value.ToLowerInvariant() } | Where-Object { $noise.Contains($_) } | Select-Object -Unique)
                    $leaveOut = @($types | ForEach-Object { $noise[$_] } | Where-Object { $ExcludeType -notcontains $_ })
                    if ($leaveOut.Count) { $job.Advice += "Not managed with Terraform? -ExcludeType $($leaveOut -join ', ') leaves those out." }
                }
                foreach ($job in $jobs) { foreach ($line in $job.Advice) { Update-AACProgress -Id "advice$($job.Index)$([guid]::NewGuid().ToString('n'))" -Complete -Description "$($job.Subscription): $line" } }

                # The requests: one export, or (-BatchSize) batches of resource IDs.
                foreach ($job in @($jobs | Where-Object { -not $_.Failure })) {
                    $uri = & $exportUri $job.SubscriptionId $ApiVersion
                    if ($BatchSize -and $job.Ids.Count -gt $BatchSize) {
                        $recursive = if ($mode -eq 'ResourceGroup') { $true } else { [bool]$Recurse }
                        $ids = @($job.Ids | Sort-Object -Unique)
                        if ($recursive) {
                            # A child whose parent is in the list comes with its parent: left out, so batches don't export it twice.
                            $listed = [System.Collections.Generic.HashSet[string]]::new([string[]]@($ids | ForEach-Object { $_.ToLowerInvariant() }), [System.StringComparer]::Ordinal)
                            $ids = @($ids | Where-Object { $lower = $_.ToLowerInvariant(); $parent = $false; for ($at = $lower.IndexOf('/', 1); $at -gt 0; $at = $lower.IndexOf('/', $at + 1)) { if ($listed.Contains($lower.Substring(0, $at))) { $parent = $true; break } }; -not $parent })
                        }
                        $part = 0
                        for ($at = 0; $at -lt $ids.Count; $at += $BatchSize) {
                            $part++
                            $body = [ordered]@{ type = 'ExportResource'; resourceIds = @($ids[$at..([Math]::Min($at + $BatchSize, $ids.Count) - 1)]); recursive = $recursive; namePattern = "${NamePrefix}b$part-" }
                            if ($mode -ne 'ResourceGroup' -and $Include -contains 'ResourceGroup') { $body.includeResourceGroup = $true }
                            $job.Requests.Add(@{ Key = "$($job.Index)/b$part"; Phase = 'primary'; Part = "b$part"; Uri = $uri; Body = (& $withCommon $body); OperationUrl = '' })
                        }
                        if ($mode -eq 'ResourceGroup') {
                            # The resource groups themselves, on their own: not recursive, or they would bring everything again.
                            $groupIds = @($job.Groups | ForEach-Object { "/subscriptions/$($job.SubscriptionId)/resourceGroups/$_" })
                            $job.Requests.Add(@{ Key = "$($job.Index)/rg"; Phase = 'primary'; Part = 'rg'; Uri = $uri; Body = (& $withCommon ([ordered]@{ type = 'ExportResource'; resourceIds = $groupIds; recursive = $false; namePattern = "${NamePrefix}rg-" })); OperationUrl = '' })
                        }
                    }
                    else {
                        $job.Requests.Add(@{ Key = "$($job.Index)/1"; Phase = 'primary'; Part = '1'; Uri = $uri; Body = (& $withCommon $job.Body); OperationUrl = '' })
                    }
                    & $saveManifest $job 'InProgress'
                }
            }

            # 4. Run the exports: all at once (-ThrottleLimit), each followed until Azure is done.
            $byKey = @{}
            $tracker = @{}
            foreach ($job in $jobs) { foreach ($request in $job.Requests) { $byKey[$request.Key] = @{ Job = $job; Request = $request } } }
            $describe = {
                param([hashtable] $Track)
                $size = if ($null -ne $Track.Job.Planned) { ' ({0:N0} resources{1})' -f $Track.Job.Planned, $(if ($Track.Total -gt 1) { " in $($Track.Total) exports" } else { '' }) } else { '' }
                $done = if ($Track.Total -gt 1) { " - $($Track.Done) of $($Track.Total) done" } else { '' }
                "$($Track.Verb) $($Track.Job.Label) from $($Track.Job.Subscription)$size - $($Track.Status), $(& $clock ([datetime]::UtcNow - $Track.Started))$done"
            }
            $run = {
                param([object[]] $Requests, [string] $Verb)
                if (-not $Requests.Count) { return [ordered]@{} }
                $tracker.Clear()
                foreach ($group in @($Requests | Group-Object -Property { [string]$byKey[$_.Key].Job.Index })) {
                    $job = $byKey[$group.Group[0].Key].Job
                    $track = @{ Id = "export$([guid]::NewGuid().ToString('n'))"; Job = $job; Total = $group.Count; Done = 0; Status = 'submitting'; Started = $(if ($job.Resumed -and $Verb -eq 'Exporting') { $job.StartedAt } else { [datetime]::UtcNow }); Verb = $Verb }
                    $tracker[[string]$job.Index] = $track
                    if ($track.Total -gt 1) { Update-AACProgress -Id $track.Id -Total $track.Total -Description (& $describe $track) }
                    else { Update-AACProgress -Id $track.Id -Indeterminate -Description (& $describe $track) }
                }
                $answers = Invoke-AACArmOperationBatch -Request $Requests -ThrottleLimit $ThrottleLimit -TimeoutMinutes $TimeoutMinutes -OnSubmitted {
                    param($Key, $Url)
                    $entry = $byKey[$Key]
                    $entry.Request.OperationUrl = $Url
                    & $saveManifest $entry.Job 'InProgress'
                    $track = $tracker[[string]$entry.Job.Index]; $track.Status = 'accepted'
                    Update-AACProgress -Id $track.Id -Description (& $describe $track)
                } -OnPoll {
                    param($Key, $Status, $Percent)
                    $track = $tracker[[string]$byKey[$Key].Job.Index]
                    $track.Status = $(if ($Status) { $Status } else { 'running' }) + $(if ($null -ne $Percent) { " $([int]$Percent)%" } else { '' })
                    Update-AACProgress -Id $track.Id -Description (& $describe $track)
                } -OnDone {
                    param($Key, $Succeeded, $ErrorText)
                    $track = $tracker[[string]$byKey[$Key].Job.Index]
                    $track.Done++
                    if ($track.Total -gt 1) { Update-AACProgress -Id $track.Id -Increment 1 -Description (& $describe $track) }
                }
                foreach ($track in $tracker.Values) {
                    $failed = @($track.Job.Requests | Where-Object { $answers.Contains($_.Key) -and -not $answers[$_.Key].Succeeded }).Count
                    $track.Status = if ($failed) { "$failed failed" } else { 'done' }
                    Update-AACProgress -Id $track.Id -Complete -Description (& $describe $track)
                }
                $answers
            }

            $answers = & $run @($jobs | Where-Object { -not $_.Failure } | ForEach-Object { $_.Requests } | Where-Object { $_.Phase -eq 'primary' -or $_.Phase -eq 'azapi' }) 'Exporting'
            $propertiesOf = { param($Answer) if ($Answer.Result -is [System.Collections.IDictionary] -and $Answer.Result['properties'] -is [System.Collections.IDictionary]) { $Answer.Result['properties'] } else { @{} } }

            # Each export's outcome: the batches that worked, what they skipped, what failed.
            foreach ($job in @($jobs | Where-Object { -not $_.Failure })) {
                $mine = @($job.Requests | Where-Object { $_.Phase -eq 'primary' })
                $results = @($mine | ForEach-Object { $answers[$_.Key] })
                if (@($results | Where-Object { $_.TimedOut }).Count) {
                    $job.Pending = $true
                    $job.Failure = "Still running after $TimeoutMinutes minute(s). Export-AACTerraform -Resume -Path '$root' collects it (export.json keeps the operations)."
                    continue
                }
                $worked = @($mine | Where-Object { $answers[$_.Key].Succeeded })
                if (-not $worked.Count) {
                    $first = [string]$results[0].Error
                    $job.Failure = if ($mine.Count -gt 1) { "All $($mine.Count) exports failed - the first: $first" } else { $first }
                    continue
                }
                $job.Sources = [System.Collections.Generic.List[hashtable]]::new()
                $skipped = [System.Collections.Generic.List[string]]::new()
                $errors = [System.Collections.Generic.List[object]]::new()
                $fileFor = if ($options.provider -eq 'azapi') { 'azapi.tf' } else { 'main.tf' }
                foreach ($request in $mine) {
                    $answer = $answers[$request.Key]
                    if (-not $answer.Succeeded) { $errors.Add(@{ code = 'BatchFailed'; message = "Export $($request.Part) failed: $($answer.Error)"; target = '' }); continue }
                    $properties = & $propertiesOf $answer
                    $job.Sources.Add(@{ Result = $properties; Provider = [string]$options.provider; File = $fileFor })
                    foreach ($id in @($properties['skippedResources'] | Where-Object { $_ })) { if (-not ($skipped | Where-Object { $_ -ieq $id })) { $skipped.Add([string]$id) } }
                    foreach ($e in @($properties['errors'] | Where-Object { $_ })) { $errors.Add($e) }
                    if (-not $job.OperationId) { $job.OperationId = [string]$answer.Result['name'] }
                }
                $job.Skipped = $skipped.ToArray()
                $job.Errors = $errors.ToArray()
                $job.FallbackTried = $false
                # A resumed export whose azapi pass was already submitted: collected with the rest.
                $azapi = @($job.Requests | Where-Object { $_.Phase -eq 'azapi' })
                if ($azapi.Count) { $job.FallbackDone = @($azapi | ForEach-Object { $answers[$_.Key] }) }
            }

            # 5. -FallbackToAzApi: what azurerm skipped, exported again with azapi - all at once, in batches if asked.
            $fallbackRequests = [System.Collections.Generic.List[hashtable]]::new()
            if ($options.fallbackToAzApi -and $options.provider -eq 'azurerm') {
                foreach ($job in @($jobs | Where-Object { -not $_.Failure -and $_.Skipped.Count -and -not $_.ContainsKey('FallbackDone') })) {
                    $size = if ([int]$options.batchSize) { [int]$options.batchSize } else { $job.Skipped.Count }
                    $part = 0
                    for ($at = 0; $at -lt $job.Skipped.Count; $at += $size) {
                        $part++
                        $prefix = if ($job.Skipped.Count -gt $size) { "$($options.namePrefix)azapi-b$part-" } else { "$($options.namePrefix)azapi-" }
                        $body = [ordered]@{ type = 'ExportResource'; resourceIds = @($job.Skipped[$at..([Math]::Min($at + $size, $job.Skipped.Count) - 1)]); targetProvider = 'azapi'; fullProperties = -not $options.compact; maskSensitive = -not $options.includeSensitive; namePattern = $prefix }
                        $request = @{ Key = "$($job.Index)/azapi$part"; Phase = 'azapi'; Part = "azapi$part"; Uri = (& $exportUri $job.SubscriptionId ([string]$options.apiVersion)); Body = $body; OperationUrl = '' }
                        $job.Requests.Add($request)
                        $byKey[$request.Key] = @{ Job = $job; Request = $request }
                        $fallbackRequests.Add($request)
                    }
                    & $saveManifest $job 'InProgress'
                }
            }
            $second = & $run $fallbackRequests.ToArray() 'Exporting with azapi'
            foreach ($job in @($jobs | Where-Object { -not $_.Failure -and $_.ContainsKey('Sources') })) {
                $azapiAnswers = @(if ($job.ContainsKey('FallbackDone')) { $job.FallbackDone } else { $job.Requests | Where-Object { $_.Phase -eq 'azapi' -and $second.Contains($_.Key) } | ForEach-Object { $second[$_.Key] } })
                if (-not $azapiAnswers.Count) { continue }
                $job.FallbackTried = $true
                $still = [System.Collections.Generic.List[string]]::new()
                $errors = [System.Collections.Generic.List[object]]::new(); foreach ($e in $job.Errors) { $errors.Add($e) }
                foreach ($answer in $azapiAnswers) {
                    if (-not $answer.Succeeded) { $errors.Add(@{ code = 'BatchFailed'; message = "The azapi export failed: $($answer.Error)"; target = '' }); continue }
                    $properties = & $propertiesOf $answer
                    $job.Sources.Add(@{ Result = $properties; Provider = 'azapi'; File = 'azapi.tf' })
                    foreach ($id in @($properties['skippedResources'] | Where-Object { $_ })) { $still.Add([string]$id) }
                    foreach ($e in @($properties['errors'] | Where-Object { $_ })) { $errors.Add($e) }
                }
                # What a failed azapi export was asked for stays skipped.
                if (@($azapiAnswers | Where-Object { -not $_.Succeeded }).Count) { foreach ($id in $job.Skipped) { if (-not ($still -contains $id)) { $still.Add($id) } } }
                $job.Skipped = @($still | Select-Object -Unique)
                $job.Errors = $errors.ToArray()
            }

            # 6. The folders: merged, written, export.json made final.
            $folders = [System.Collections.Generic.List[object]]::new()
            foreach ($job in $jobs) {
                $folder = [ordered]@{ Path = $job.Path; Label = $job.Label; SubscriptionId = $job.SubscriptionId; Subscription = $job.Subscription; Provider = [string]$options.provider; Imports = @(); Skipped = @(); FallbackTried = $false; Errors = @()
                    Files = @(); OperationId = ''; Duration = ([datetime]::UtcNow - $job.StartedAt); Plan = $null; Failure = $job.Failure; Pending = $job.Pending; Planned = $job.Planned
                    Batches = @($job.Requests | Where-Object { $_.Phase -eq 'primary' }).Count; Duplicates = 0 }
                if (-not $job.Failure -and $job.ContainsKey('Sources')) {
                    $layout = ConvertTo-AACTerraformFile -Source $job.Sources.ToArray() -SubscriptionId $job.SubscriptionId
                    $null = [System.IO.Directory]::CreateDirectory($job.Path)
                    foreach ($name in $layout.Files.Keys) { [System.IO.File]::WriteAllText((Join-Path -Path $job.Path -ChildPath $name), [string]$layout.Files[$name], $utf8) }
                    $job.Duplicates = $layout.Duplicates
                    & $saveManifest $job 'Succeeded'
                    $folder.Imports = @($layout.Imports)
                    $folder.Skipped = @($job.Skipped)
                    $folder.Errors = @($job.Errors)
                    $folder.FallbackTried = [bool]$job.FallbackTried
                    $folder.Files = @(@($layout.Files.Keys) + 'export.json')
                    $folder.OperationId = [string]$job['OperationId']
                    $folder.Duplicates = $layout.Duplicates
                }
                elseif ($job.Requests.Count -and -not $job.Pending) { & $saveManifest $job 'Failed' }
                $folders.Add($folder)
            }

            # 7. -Plan: terraform init, validate and plan in each folder; the plan read as Get-AACTerraformPlan reads it.
            if ($Plan) {
                foreach ($folder in @($folders | Where-Object { -not $_.Failure -and @($_.Imports).Count })) {
                    $progressId = "plan$([guid]::NewGuid().ToString('n'))"
                    $result = [ordered]@{ Ran = $false; Step = ''; Message = ''; Import = 0; Create = 0; Update = 0; Replace = 0; Delete = 0; Clean = $false; PlanPath = ''; Changes = @() }
                    $environment = @{ TF_IN_AUTOMATION = '1'; ARM_SUBSCRIPTION_ID = $folder.SubscriptionId }
                    $steps = [ordered]@{
                        init     = @('init', '-backend=false', '-input=false', '-no-color')
                        validate = @('validate', '-no-color')
                        plan     = @('plan', '-input=false', '-lock=false', '-no-color', '-out=tfplan')
                        show     = @('show', '-json', '-no-color', 'tfplan')
                    }
                    Update-AACProgress -Id $progressId -Total $steps.Count -Description "terraform plan in $($folder.Path)"
                    $shown = ''
                    foreach ($step in $steps.Keys) {
                        Update-AACProgress -Id $progressId -Description "terraform $step in $($folder.Path)"
                        $cli = Invoke-AACTerraformCli -FilePath $terraform -Argument $steps[$step] -WorkingDirectory $folder.Path -Environment $environment -TimeoutMinutes ([Math]::Min($TimeoutMinutes, 120))
                        Update-AACProgress -Id $progressId -Increment 1
                        if ($cli.ExitCode -ne 0) {
                            $said = @("$($cli.Error)`n$($cli.Output)" -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -and $_ -notmatch '^[│╷╵]*$' })
                            $result.Step = $step
                            $result.Message = "terraform $step failed: $(@($said | Select-Object -Last 6) -join ' ')"
                            break
                        }
                        if ($step -eq 'show') { $shown = [string]$cli.Output }
                    }
                    if ($shown) {
                        $planPath = Join-Path -Path $folder.Path -ChildPath 'plan.json'
                        [System.IO.File]::WriteAllText($planPath, $shown, $utf8)
                        $read = ConvertTo-AACTerraformPlan -Plan (ConvertFrom-Json -InputObject $shown -AsHashtable -Depth 200) -Path $planPath
                        $changes = @($read.Changes | Where-Object { $_.Mode -ne 'output' -and $_.Action -notin 'Import', 'NoOp', 'Read' })
                        $result.Ran = $true
                        $result.PlanPath = $planPath
                        foreach ($key in 'Import', 'Create', 'Update', 'Replace', 'Delete') { $result[$key] = [int]$read.Stats[$key] }
                        $result.Changes = $changes
                        $result.Clean = -not $changes.Count
                        $folder.Files = @($folder.Files) + 'plan.json'
                    }
                    Update-AACProgress -Id $progressId -Complete -Description $(if ($result.Ran) { "terraform plan in $($folder.Path): $($result.Import) to import, $(@($result.Changes).Count) to change" } else { $result.Message })
                    $folder.Plan = [pscustomobject]$result
                }
            }

            @{ Result = (ConvertTo-AACTerraformExport -Folder @($folders | ForEach-Object { [pscustomobject]$_ })); Label = $scopeLabel; Advice = @($jobs | ForEach-Object { $_.Advice }) }
        }

        $result = $outcome.Result
        $s = $result.Stats
        $rows = @($result.Rows)
        $folderObjects = @($result.Folders)
        $planned = @($folderObjects | Where-Object { $_.Plan -and $_.Plan.Ran })
        $planChanges = @($planned | ForEach-Object { $f = $_; @($f.Plan.Changes) | ForEach-Object { [pscustomobject]@{ Folder = $f.Path; Action = $_.Action; Address = $_.Address; Type = $_.Type; Importing = [bool]$_.Importing } } })
        $planImports = [int](@($planned | ForEach-Object { $_.Plan.Import }) | Measure-Object -Sum).Sum
        $longest = @($folderObjects | ForEach-Object { $_.Duration } | Sort-Object -Descending | Select-Object -First 1)
        $took = if ($longest.Count) { $longest[0] } else { [timespan]::Zero }
        $clockText = if ($took.TotalHours -ge 1) { '{0}:{1:mm\:ss}' -f [int][Math]::Floor($took.TotalHours), $took } else { '{0:m\:ss}' -f $took }
        $notices = [System.Collections.Generic.List[hashtable]]::new()
        foreach ($f in @($folderObjects | Where-Object { $_.Status -eq 'Running' })) { $notices.Add(@{ Status = 'Warning'; Text = "$($f.Scope) in $($f.Subscription): $($f.Message)" }) }
        foreach ($f in @($folderObjects | Where-Object { $_.Status -eq 'Failed' })) { $notices.Add(@{ Status = 'Failed'; Text = "$($f.Scope) in $($f.Subscription): $($f.Message)" }) }
        foreach ($line in @($outcome.Advice | Where-Object { $_ })) { $notices.Add(@{ Status = 'Info'; Text = $line }) }
        foreach ($f in @($folderObjects | Where-Object { $_.Duplicates })) { $notices.Add(@{ Status = 'Info'; Text = "$($f.Path): $($f.Duplicates) resource(s) more than one batch returned, kept once." }) }
        foreach ($f in @($folderObjects | Where-Object { $_.Plan -and -not $_.Plan.Ran })) { $notices.Add(@{ Status = 'Warning'; Text = "$($f.Path): $($f.Plan.Message) (Terraform signs in on its own: az login, or ARM_CLIENT_ID, ARM_CLIENT_SECRET and ARM_TENANT_ID.)" }) }
        if ($s.Skipped -and -not $FallbackToAzApi -and $Provider -eq 'azurerm' -and $mode -ne 'Resume') { $notices.Add(@{ Status = 'Info'; Text = "$($s.Skipped) resource(s) skipped: azurerm has no support for their type yet. -FallbackToAzApi exports them with the azapi provider." }) }
        foreach ($f in @($folderObjects | Where-Object { $_.Status -in 'Exported', 'Partial' })) {
            $next = if ($f.Plan -and $f.Plan.Ran) { "terraform apply brings $($f.Plan.Import) resource(s) under Terraform" } else { 'terraform init, then terraform plan' }
            $notices.Add(@{ Status = 'Info'; Text = "Next, in $($f.Path): $next." })
        }

        $planPart = if ($planned.Count) { "; terraform plan: $planImports to import, $($planChanges.Count) to change" } else { '' }
        $statusTones = @{ Exported = 'good'; Skipped = 'warn'; Error = 'bad'; Failed = 'bad'; Running = 'info' }
        $finished = $s.Folders - $s.Failed - $s.Running
        $report = @{
            Subtitle = 'Terraform export: existing Azure resources as Terraform, with import blocks'
            Facts    = [ordered]@{ Scope = [string]$outcome.Label; Provider = $(if ($FallbackToAzApi -and $Provider -eq 'azurerm') { 'azurerm, then azapi' } else { $Provider }); Folder = $root; 'Took' = $clockText; 'API version' = $ApiVersion }
            Status   = $(if ($s.Folders -and ($s.Failed + $s.Running) -eq $s.Folders) { $(if ($s.Running) { 'Warning' } else { 'Failed' }) } elseif ($s.Skipped -or $s.Errors -or $s.Failed -or $s.Running -or $planChanges.Count) { 'Warning' } else { 'Success' })
            Headline = "$($s.Exported) resource(s) exported to $finished folder(s) in $clockText$(if ($s.AzApi) { " ($($s.AzApi) with azapi)" }); $($s.Skipped) skipped, $($s.Errors) error(s)$(if ($s.Failed) { ", $($s.Failed) export(s) failed" })$(if ($s.Running) { ", $($s.Running) still running" })$planPart"
            Tiles    = @(
                @{ Value = '{0:N0}' -f $s.Exported; Label = 'exported'; Tone = 'good'; Table = 'resources'; Filters = @{ Status = 'Exported' } }
                @{ Value = '{0:N0}' -f $s.Skipped; Label = 'skipped'; Tone = $(if ($s.Skipped) { 'warn' } else { 'good' }); Table = 'resources'; Filters = @{ Status = 'Skipped' } }
                @{ Value = '{0:N0}' -f ($s.Errors + $s.Failed); Label = 'errors'; Tone = $(if ($s.Errors + $s.Failed) { 'bad' } else { 'good' }) }
                $(if ($planned.Count) { @{ Value = '{0:N0}' -f $planChanges.Count; Label = 'to change (plan)'; Tone = $(if ($planChanges.Count) { 'warn' } else { 'good' }) } } else { @{ Value = $clockText; Label = 'to export'; Tone = 'info' } })
            )
            Notices  = @($notices)
            Charts   = @(
                @{ Title = 'By Terraform type'; Items = @($rows | Where-Object { $_.Status -eq 'Exported' } | Group-Object -Property TerraformType | Sort-Object -Property Count -Descending | Select-Object -First 15 | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'resources'; Column = 'TerraformType'; Tone = 'info'; Console = $true }
                @{ Title = 'By status'; Kind = 'donut'; CenterLabel = 'resources'; Items = @(foreach ($status in 'Exported', 'Skipped', 'Error', 'Failed', 'Running') { $n = @($rows | Where-Object { $_.Status -eq $status }).Count; if ($n) { @{ Label = $status; Value = $n; Tone = $statusTones[$status]; Filter = $status } } }); Table = 'resources'; Column = 'Status' }
            )
            Tables   = @(@(
                    @{ Id = 'resources'; Title = 'Resources'; Section = 'Export'; Rows = $rows; Noun = 'resources'; GroupBy = @('Status', 'TerraformType'); ConsoleLimit = 30
                        Empty = 'Nothing was exported.'; EmptyStatus = 'Warning'
                        Columns = @(
                            @{ Key = 'Status'; Label = 'Status'; Type = 'badge'; Tones = $statusTones; Facet = $true; Console = $true; Pdf = $true }
                            @{ Key = 'Address'; Label = 'Terraform address'; Type = 'mono'; Console = $true; Pdf = $true }
                            @{ Key = 'ResourceType'; Label = 'Azure type'; Facet = $true; Console = $true }
                            @{ Key = 'Name'; Label = 'Name'; Console = $true; Pdf = $true }
                            @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true; Console = $true }
                            @{ Key = 'Note'; Label = 'Note'; Type = 'wide'; Console = $true; Pdf = $true }
                            @{ Key = 'TerraformType'; Label = 'Terraform type'; Facet = $true }
                            @{ Key = 'Provider'; Label = 'Provider'; Facet = $true }
                            @{ Key = 'File'; Label = 'File'; Facet = $true }
                            @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                            @{ Key = 'Folder'; Label = 'Folder' }
                            @{ Key = 'ResourceId'; Label = 'Resource ID'; Type = 'mono' }
                        ) }
                    $(if ($planned.Count) {
                            @{ Id = 'plan'; Title = 'What terraform plan would change'; Section = 'Plan'; Rows = $planChanges; Noun = 'changes'; GroupBy = @('Action'); ConsoleLimit = 20
                                Empty = 'terraform plan changes nothing beyond the imports: the export is faithful.'; EmptyStatus = 'Success'
                                Columns = @(
                                    @{ Key = 'Action'; Label = 'Action'; Type = 'badge'; Tones = @{ Update = 'warn'; Replace = 'bad'; Delete = 'bad'; Create = 'info' }; Facet = $true; Console = $true; Pdf = $true }
                                    @{ Key = 'Address'; Label = 'Terraform address'; Type = 'mono'; Console = $true; Pdf = $true }
                                    @{ Key = 'Type'; Label = 'Type'; Facet = $true; Console = $true }
                                    @{ Key = 'Importing'; Label = 'Imported'; Console = $true }
                                    @{ Key = 'Folder'; Label = 'Folder' }
                                ) }
                        })
                ) | Where-Object { $null -ne $_ })
            Hint     = '-BatchSize splits a large export into exports that run at the same time; -Resume collects one still running; -FallbackToAzApi exports what azurerm can''t; -Plan runs terraform plan to prove it; -HtmlPath or -CsvPath for a report.'
        }
        Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $rows -Noun 'resource' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
            -ShowView:$interactive -NoPaging:$NoPaging -Object $folderObjects -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
    }
}