Public/Invoke-AERReport.ps1

function Invoke-AerReport {
<#
.SYNOPSIS
    Generates a rich, fully-offline HTML "Azure Estate Report" (AER) for one or more Azure subscriptions.

.DESCRIPTION
    Invoke-AerReport connects to Azure using your current Az context (from Connect-AzAccount),
    discovers the subscriptions you can access, and collects a READ-ONLY inventory of your Azure
    estate through Azure Resource Graph and ARM REST APIs. It then renders a multi-page,
    self-contained HTML dashboard that you can open straight from disk (no web server and no
    internet connection required to view it).

    The report spans: resource inventory, cloud structure (management groups & subscriptions),
    Azure Advisor, Compute (VMs & scale sets), Databases, Applications, Network (VNets, peerings,
    load balancers), Observability (diagnostic settings coverage, AMA/DCRs, Log Analytics
    inventory), Azure Policy (compliance, assignments, exemptions, remediation) and Microsoft
    Defender for Cloud posture, plus Findings (cost waste and observability recommendations).

    The command is strictly READ-ONLY: it never creates, modifies, or deletes any Azure resource.
    Collection runs in parallel across many independent collectors for speed.

    Requirements: PowerShell 7+, the Az.Accounts and Az.ResourceGraph modules, and an
    authenticated Azure session (Connect-AzAccount). Minimum RBAC is Reader on the target
    scope; add Monitoring Reader for richer observability data.

.PARAMETER OutputPath
    Folder where the HTML report is written (created if it does not exist). Defaults to
    '.\aer-report'. The report entry point is <OutputPath>\index.html.

.PARAMETER SubscriptionId
    One or more subscription IDs (GUIDs) to include. When omitted, every subscription available
    in the current Az context is scanned.

.PARAMETER ExcludeSubscriptionName
    One or more subscription display names to exclude from the scan. Matching is exact and
    case-insensitive (wildcards are not supported).

.PARAMETER MaxParallelCollectors
    Number of data collectors to run concurrently (1-10). Higher values finish faster but issue
    more API calls in parallel. Defaults to 4.

.PARAMETER SampleData
    Generate the report with a rich built-in sample dataset instead of connecting to Azure.
    This is useful for validating the HTML layout, navigation and tables without requiring
    Azure authentication or tenant access.

.PARAMETER OpenReport
    Open the generated index.html in your default browser as soon as the report is ready.

.PARAMETER PassThru
    Emit a summary object (output paths, headline metrics and duration) to the pipeline instead
    of returning nothing.

.EXAMPLE
    Invoke-AerReport

    Scans every subscription in the current Az context and writes the report to .\aer-report.

.EXAMPLE
    Invoke-AerReport -OutputPath 'C:\Reports\Contoso' -OpenReport

    Writes the report to a custom folder and opens it in the browser when finished.

.EXAMPLE
    Invoke-AerReport -SubscriptionId '00000000-0000-0000-0000-000000000000'

    Scans only the specified subscription.

.EXAMPLE
    Invoke-AerReport -ExcludeSubscriptionName 'Sandbox','Visual Studio Enterprise' -MaxParallelCollectors 8

    Scans every subscription except the two named ones, using 8 parallel collectors.

.EXAMPLE
    Invoke-AerReport -SampleData -OutputPath .\aer-sample -OpenReport

    Generates the offline report using built-in fake data so you can validate the layout.

.EXAMPLE
    $r = Invoke-AerReport -PassThru
    $r.Resources
    $r.IndexHtml

    Captures the summary object so you can inspect headline metrics and the report path.

.INPUTS
    None. Invoke-AerReport does not accept pipeline input.

.OUTPUTS
    None by default. With -PassThru, a [pscustomobject] summary containing the output paths,
    headline counts and the run duration.

.NOTES
    READ-ONLY — no changes are ever made to your Azure environment.
    Requires PowerShell 7+, Az.Accounts and Az.ResourceGraph, and an authenticated Az session
    (Connect-AzAccount). Author: AER contributors. License: MIT.

.LINK
    https://github.com/TrimTechBr/azure-state-report
#>

    [CmdletBinding()]
    param(
        [string]   $OutputPath = '.\aer-report',
        [string[]] $SubscriptionId,
        [string[]] $ExcludeSubscriptionName = @(),
        [ValidateRange(1, 10)]
        [int]      $MaxParallelCollectors = 4,
        [switch]   $SampleData,
        [switch]   $OpenReport,
        [switch]   $PassThru
    )

    $startedAt = Get-Date
    $version   = try { (Get-Module Aer).Version.ToString() } catch { '' }
    if ([string]::IsNullOrWhiteSpace($version)) { $version = '0.1.0' }

    # ── Friendly console output helpers (icons + colour) ─────────────────────
    function Write-AerMsg([string]$Icon, [System.ConsoleColor]$Color, [string]$Msg, [string]$Detail) {
        Write-Host " $Icon " -ForegroundColor $Color -NoNewline
        if ($Detail) { Write-Host $Msg -NoNewline; Write-Host " $Detail" -ForegroundColor DarkGray }
        else { Write-Host $Msg }
    }
    function Format-AerNum($n) { '{0:N0}' -f [int64]([math]::Round([double]($n ?? 0))) }
    function Format-AerDuration([double]$Milliseconds) {
        $ts = [timespan]::FromMilliseconds([math]::Round($Milliseconds))
        if ($ts.TotalMinutes -ge 1) { return '{0}m {1}s' -f [int][math]::Floor($ts.TotalMinutes), $ts.Seconds }
        return '{0:N1}s' -f $ts.TotalSeconds
    }
    function Limit-AerConsoleText($Value, [int]$Width) {
        $text = "$($Value ?? '')" -replace '\s+', ' '
        $text = $text.Trim()
        if ($text.Length -le $Width) { return $text.PadRight($Width) }
        if ($Width -le 3) { return $text.Substring(0, $Width) }
        return ($text.Substring(0, $Width - 1) + '…')
    }
    function Get-AerConsoleCell($Row, [string]$Key) {
        if ($null -eq $Row) { return '' }
        $prop = $Row.PSObject.Properties[$Key]
        if ($prop) { return $prop.Value }
        return ''
    }
    function Write-AerConsoleTable([string]$Title, [object[]]$Rows, [object[]]$Columns, [string]$Icon = '▦') {
        $rowsSafe = @($Rows)
        $widths = @()
        foreach ($col in $Columns) {
            $maxWidth = if ($col.PSObject.Properties['Width']) { [int]$col.Width } else { 42 }
            $w = [math]::Min($maxWidth, [math]::Max(4, "$($col.Label)".Length))
            foreach ($row in $rowsSafe) {
                $cellLen = "$(Get-AerConsoleCell $row $col.Key)".Length
                if ($cellLen -gt $w) { $w = [math]::Min($maxWidth, $cellLen) }
            }
            $widths += $w
        }

        $top = ' ┌' + (($widths | ForEach-Object { '─' * ($_ + 2) }) -join '┬') + '┐'
        $mid = ' ├' + (($widths | ForEach-Object { '─' * ($_ + 2) }) -join '┼') + '┤'
        $bot = ' └' + (($widths | ForEach-Object { '─' * ($_ + 2) }) -join '┴') + '┘'

        Write-Host ''
        Write-Host " $Icon $Title" -ForegroundColor White
        Write-Host $top -ForegroundColor DarkGray
        $headerCells = for ($i = 0; $i -lt $Columns.Count; $i++) { Limit-AerConsoleText $Columns[$i].Label $widths[$i] }
        Write-Host (' │ ' + ($headerCells -join ' │ ') + ' │') -ForegroundColor Cyan
        Write-Host $mid -ForegroundColor DarkGray
        foreach ($row in $rowsSafe) {
            $cells = for ($i = 0; $i -lt $Columns.Count; $i++) {
                $value = Get-AerConsoleCell $row $Columns[$i].Key
                $text = Limit-AerConsoleText $value $widths[$i]
                if ($Columns[$i].PSObject.Properties['Align'] -and $Columns[$i].Align -eq 'Right') {
                    $text.Trim().PadLeft($widths[$i])
                } else {
                    $text
                }
            }
            Write-Host (' │ ' + ($cells -join ' │ ') + ' │') -ForegroundColor Gray
        }
        Write-Host $bot -ForegroundColor DarkGray
    }
    function Get-AerExportStats($ExportFiles) {
        $errors = @()
        $success = 0
        if ($ExportFiles) {
            if ($ExportFiles.PSObject.Properties['Errors']) {
                $errors = @($ExportFiles.Errors | Where-Object { $null -ne $_ })
            }
            if ($ExportFiles.PSObject.Properties['Xlsx'] -and $ExportFiles.Xlsx) { $success++ }
            if ($ExportFiles.PSObject.Properties['Pdf'] -and $ExportFiles.Pdf) { $success++ }
        }
        [pscustomobject]@{ Success = $success; Errors = $errors.Count; Total = 2; ErrorsList = $errors }
    }
    function Write-AerExportStatus($ExportFiles, [string]$OutputPath) {
        if (-not $ExportFiles) { return }
        if ($ExportFiles.Xlsx) {
            $xlsxPath = Join-Path $OutputPath ($ExportFiles.Xlsx -replace '/', [IO.Path]::DirectorySeparatorChar)
            Write-AerMsg '⇩' Cyan 'Excel export ready' (Resolve-Path $xlsxPath).Path
        }
        if ($ExportFiles.Pdf) {
            $pdfPath = Join-Path $OutputPath ($ExportFiles.Pdf -replace '/', [IO.Path]::DirectorySeparatorChar)
            Write-AerMsg '⇩' Cyan 'PDF export ready' (Resolve-Path $pdfPath).Path
        }
    }
    function Write-AerRunSummary {
        param(
            $Summary,
            [string] $Duration,
            [int] $CollectorSuccess,
            [int] $CollectorErrors,
            [int] $CollectorTotal,
            [string] $CollectorLabel = 'Collectors',
            $CollectionErrors = @(),
            $ExportFiles = $null
        )

        $signals = @{
            'Subscriptions' = 'Scope'; 'Resource groups' = 'Estate'; 'Resources' = 'Estate'
            'Virtual machines' = 'Compute'; 'Databases' = 'Data'; 'Advisor recommendations' = 'Optimization'
            'Policy assignments' = 'Governance'; 'Defender unhealthy' = 'Security'; 'Cost findings' = 'Savings'
            'General findings' = 'Risk'
        }
        $metricRows = foreach ($k in $Summary.Keys) {
            [pscustomobject]@{
                Metric = $k
                Count  = Format-AerNum $Summary[$k]
                Signal = $signals[$k] ?? 'Overview'
            }
        }
        Write-AerConsoleTable 'Executive summary' $metricRows @(
            [pscustomobject]@{ Key = 'Metric'; Label = 'Metric'; Width = 30 },
            [pscustomobject]@{ Key = 'Count';  Label = 'Count';  Width = 14; Align = 'Right' },
            [pscustomobject]@{ Key = 'Signal'; Label = 'Signal'; Width = 18 }
        ) '◈'

        $exportStats = Get-AerExportStats $ExportFiles
        $healthRows = @(
            [pscustomobject]@{
                Area = $CollectorLabel; Success = Format-AerNum $CollectorSuccess; Errors = Format-AerNum $CollectorErrors; Total = Format-AerNum $CollectorTotal
                Status = if ($CollectorErrors -eq 0) { '✓ Healthy' } else { '⚠ Partial' }
            },
            [pscustomobject]@{
                Area = 'Exports'; Success = Format-AerNum $exportStats.Success; Errors = Format-AerNum $exportStats.Errors; Total = Format-AerNum $exportStats.Total
                Status = if ($exportStats.Errors -eq 0) { '✓ Ready' } else { '⚠ Review' }
            },
            [pscustomobject]@{
                Area = 'Duration'; Success = ''; Errors = ''; Total = ''; Status = "⏱ $Duration"
            }
        )
        Write-AerConsoleTable 'Run health' $healthRows @(
            [pscustomobject]@{ Key = 'Area';    Label = 'Area';    Width = 18 },
            [pscustomobject]@{ Key = 'Success'; Label = 'Success'; Width = 12; Align = 'Right' },
            [pscustomobject]@{ Key = 'Errors';  Label = 'Errors';  Width = 10; Align = 'Right' },
            [pscustomobject]@{ Key = 'Total';   Label = 'Total';   Width = 10; Align = 'Right' },
            [pscustomobject]@{ Key = 'Status';  Label = 'Status';  Width = 18 }
        ) '☑'

        $errorRows = [System.Collections.Generic.List[object]]::new()
        $i = 1
        foreach ($e in @($CollectionErrors)) {
            $errorRows.Add([pscustomobject]@{ '# ' = $i; Type = 'Collector'; Name = "$($e.Collector)"; Message = "$($e.Message)" })
            $i++
        }
        foreach ($e in @($exportStats.ErrorsList)) {
            $errorRows.Add([pscustomobject]@{ '# ' = $i; Type = 'Export'; Name = "$($e.Export)"; Message = "$($e.Message)" })
            $i++
        }
        if ($errorRows.Count -eq 0) {
            $errorRows.Add([pscustomobject]@{ '# ' = '—'; Type = 'None'; Name = 'All clear'; Message = 'No collection or export errors detected.' })
        }
        Write-AerConsoleTable 'Error details' @($errorRows) @(
            [pscustomobject]@{ Key = '# ';      Label = '#';       Width = 4; Align = 'Right' },
            [pscustomobject]@{ Key = 'Type';    Label = 'Type';    Width = 12 },
            [pscustomobject]@{ Key = 'Name';    Label = 'Name';    Width = 22 },
            [pscustomobject]@{ Key = 'Message'; Label = 'Message'; Width = 72 }
        ) '🛡'
    }

    Write-Host ''
    Write-Host ' AER ' -ForegroundColor Cyan -NoNewline
    Write-Host '· Azure Estate Report ' -ForegroundColor White -NoNewline
    Write-Host "v$version" -ForegroundColor DarkGray
    Write-Host ' ──────────────────────────────────────────────' -ForegroundColor DarkGray

    if ($SampleData) {
        Write-AerMsg '▶' Cyan 'Generating sample dataset…' 'Azure connection skipped'
        $reportData = New-AerSampleReportData -ModuleVersion $version -GeneratedAt $startedAt
        Write-AerMsg '✓' Green 'Sample dataset ready' "$($reportData.inventory.TotalResources) resources"

        Write-AerMsg '▶' Cyan 'Rendering HTML report…'
        $indexPath = New-AerReportSite -ReportData $reportData -OutputPath $OutputPath
        $fullIndex = (Resolve-Path $indexPath).Path
        Write-AerMsg '✓' Green 'Report generated' $fullIndex
        Write-AerExportStatus -ExportFiles $reportData.metadata.ExportFiles -OutputPath $OutputPath

        $totalMs = [math]::Round(((Get-Date) - $startedAt).TotalMilliseconds)
        $durationStr = Format-AerDuration $totalMs

        $summary = [ordered]@{
            'Subscriptions'           = ($reportData.inventory.Subscriptions ?? 0)
            'Resource groups'         = ($reportData.inventory.TotalResourceGroups ?? 0)
            'Resources'               = ($reportData.inventory.TotalResources ?? 0)
            'Virtual machines'        = ($reportData.virtualMachines.TotalVMs ?? 0)
            'Databases'               = ($reportData.databases.TotalServices ?? 0)
            'Advisor recommendations' = ($reportData.advisor.TotalRecommendations ?? 0)
            'Policy assignments'      = ($reportData.policy.Assignments.Total ?? 0)
            'Defender unhealthy'      = ($reportData.defender.Summary.Unhealthy ?? 0)
            'Cost findings'           = ($reportData.cost.TotalWastedResources ?? 0)
            'General findings'        = ($reportData.security.TotalGaps ?? 0)
        }
        Write-AerRunSummary `
            -Summary $summary `
            -Duration $durationStr `
            -CollectorSuccess 1 `
            -CollectorErrors 0 `
            -CollectorTotal 1 `
            -CollectorLabel 'Sample dataset' `
            -CollectionErrors @() `
            -ExportFiles $reportData.metadata.ExportFiles
        Write-Host ''
        Write-AerMsg '✓' Green 'Done.' "open $fullIndex"
        Write-Host ''

        if ($OpenReport) {
            try { Invoke-Item $indexPath } catch { Write-AerMsg '⚠' Yellow 'Could not open the report automatically' $_.Exception.Message }
        }

        if ($PassThru) {
            return [pscustomobject]@{
                OutputPath             = (Resolve-Path $OutputPath).Path
                IndexHtml              = $fullIndex
                ManagementGroups       = $reportData.inventory.ManagementGroups ?? 0
                Subscriptions          = $reportData.inventory.Subscriptions ?? 0
                ResourceGroups         = $reportData.inventory.TotalResourceGroups ?? 0
                Resources              = $reportData.inventory.TotalResources ?? 0
                SecurityGaps           = $reportData.security.TotalGaps ?? 0
                WastedResources        = $reportData.cost.TotalWastedResources ?? 0
                AdvisorRecommendations = $reportData.advisor.TotalRecommendations ?? 0
                VirtualMachines        = $reportData.virtualMachines.TotalVMs ?? 0
                VmScaleSets            = $reportData.virtualMachineScaleSets.TotalVMSS ?? 0
                CollectionErrors       = 0
                Duration               = [timespan]::FromMilliseconds($totalMs)
                ExcelWorkbook          = if ($reportData.metadata.ExportFiles.Xlsx) { (Resolve-Path (Join-Path $OutputPath ($reportData.metadata.ExportFiles.Xlsx -replace '/', [IO.Path]::DirectorySeparatorChar))).Path } else { $null }
                PdfReport              = if ($reportData.metadata.ExportFiles.Pdf) { (Resolve-Path (Join-Path $OutputPath ($reportData.metadata.ExportFiles.Pdf -replace '/', [IO.Path]::DirectorySeparatorChar))).Path } else { $null }
                ExportErrors           = @($reportData.metadata.ExportFiles.Errors)
                SampleData             = $true
            }
        }

        return
    }

    # ── Phase 1 — Azure context ──────────────────────────────────────────────
    Write-AerMsg '▶' Cyan 'Resolving Azure context…'
    $ctx = Resolve-AerContext `
        -SubscriptionId          $SubscriptionId `
        -ExcludeSubscriptionName $ExcludeSubscriptionName
    Write-AerMsg '✓' Green "Signed in as $($ctx.Account)" "tenant: $($ctx.TenantDomain)"
    Write-AerMsg '✓' Green "$($ctx.Subscriptions.Count) subscription(s) in scope"

    $collectionErrors = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
    $subIds    = $ctx.SubscriptionIds
    $subMap    = $ctx.SubscriptionMap
    $modDir    = if ($m = Get-Module Aer -ErrorAction SilentlyContinue) {
        $m.ModuleBase
    } else {
        (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
    }
    # Pass the parent's module search paths so workers can find Az.ResourceGraph
    $psModPath = $env:PSModulePath

    $collectors = @('inventory', 'security', 'cost', 'advisor', 'structure', 'vm', 'vmss', 'database', 'relational', 'dataservices', 'application', 'appservices', 'network', 'vnets', 'loadbalancers', 'observability', 'diagnosticsettings', 'datacollection', 'obsinventory', 'policy', 'defender')

    # ── Phase 2 — Parallel data collection ───────────────────────────────────
    Write-AerMsg '▶' Cyan 'Collecting estate data…' "$($collectors.Count) collectors · parallelism $MaxParallelCollectors"
    $results    = $collectors | ForEach-Object -Parallel {
        $name       = $_
        $subIds     = $using:subIds
        $subMap     = $using:subMap
        $errBag     = $using:collectionErrors
        $dir        = $using:modDir
        $modulePath = $using:psModPath

        # Restore parent module paths and import Az.ResourceGraph so that
        # Search-AzGraph and Invoke-AzRestMethod are available with the saved Az context.
        $env:PSModulePath = $modulePath
        Import-Module Az.ResourceGraph -ErrorAction Stop

        . (Join-Path $dir 'Core\ResourceGraph.ps1')
        $collectorFile = switch ($name) {
            'inventory' { 'Collectors\Inventory.ps1' }
            'security'  { 'Collectors\Security.ps1'  }
            'cost'      { 'Collectors\Cost.ps1'       }
            'advisor'   { 'Collectors\Advisor.ps1'    }
            'structure' { 'Collectors\CloudStructure.ps1' }
            'vm'        { 'Collectors\VirtualMachines.ps1' }
            'vmss'      { 'Collectors\VmScaleSets.ps1' }
            'database'  { 'Collectors\Databases.ps1' }
            'relational'{ 'Collectors\RelationalDatabases.ps1' }
            'dataservices' { 'Collectors\DataServices.ps1' }
            'application' { 'Collectors\Applications.ps1' }
            'appservices' { 'Collectors\ApplicationServices.ps1' }
            'network'     { 'Collectors\Network.ps1' }
            'vnets'       { 'Collectors\Vnets.ps1' }
            'loadbalancers' { 'Collectors\LoadBalancers.ps1' }
            'observability' { 'Collectors\Observability.ps1' }
            'diagnosticsettings' { 'Collectors\DiagnosticSettings.ps1' }
            'datacollection' { 'Collectors\DataCollection.ps1' }
            'obsinventory' { 'Collectors\ObsInventory.ps1' }
            'policy'      { 'Collectors\Policy.ps1' }
            'defender'    { 'Collectors\Defender.ps1' }
        }
        . (Join-Path $dir $collectorFile)

        try {
            $data = switch ($name) {
                'inventory' { Get-AerInventory       -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'security'  { Get-AerSecurityGaps    -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'cost'      { Get-AerCostWaste        -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'advisor'   { Get-AerAdvisorSummary   -SubscriptionIds $subIds }
                'structure' { Get-AerCloudStructure   -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'vm'        { Get-AerVirtualMachines   -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'vmss'      { Get-AerVmScaleSets       -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'database'  { Get-AerDatabases         -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'relational'{ Get-AerRelationalDatabases -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'dataservices' { Get-AerDataServices    -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'application' { Get-AerApplications      -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'appservices' { Get-AerApplicationServices -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'network'     { Get-AerNetwork           -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'vnets'       { Get-AerVnets             -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'loadbalancers' { Get-AerLoadBalancers   -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'observability' { Get-AerObservability    -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'diagnosticsettings' { Get-AerDiagnosticSettings -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'datacollection' { Get-AerDataCollection   -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'obsinventory' { Get-AerObsInventory       -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'policy'      { Get-AerPolicy            -SubscriptionIds $subIds -SubscriptionMap $subMap }
                'defender'    { Get-AerDefender          -SubscriptionIds $subIds -SubscriptionMap $subMap }
            }
            [pscustomobject]@{ Name = $name; Data = $data; Error = $null }
        }
        catch {
            $errBag.Add([pscustomobject]@{
                Collector  = $name
                Message    = $_.Exception.Message
                StackTrace = $_.ScriptStackTrace
            })
            [pscustomobject]@{ Name = $name; Data = $null; Error = $_.Exception.Message }
        }
    } -ThrottleLimit $MaxParallelCollectors

    # Per-collector status (success roll-up + any failures, full traces under -Verbose)
    $okCount = @($results | Where-Object { -not $_.Error }).Count
    if ($okCount -eq $collectors.Count) { Write-AerMsg '✓' Green "Collected all $okCount data sets" }
    else { Write-AerMsg '⚠' Yellow "Collected $okCount of $($collectors.Count) data sets" }
    foreach ($r in ($results | Where-Object { $_.Error } | Sort-Object Name)) {
        Write-AerMsg '✗' Red "$($r.Name) collector failed" $r.Error
    }
    foreach ($e in $collectionErrors) { Write-Verbose "[$($e.Collector)] $($e.StackTrace)" }

    $resultMap = @{}
    foreach ($r in $results) { $resultMap[$r.Name] = $r.Data }

    # Assemble report data
    $durationMs = [math]::Round(((Get-Date) - $startedAt).TotalMilliseconds)
    $reportData = [pscustomobject]@{
        metadata = [pscustomobject]@{
            GeneratedAt   = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss') + ' UTC'
            TenantDomain  = $ctx.TenantDomain
            Account       = $ctx.Account
            DurationMs    = $durationMs
            ModuleVersion = $version
        }
        inventory        = $resultMap['inventory']
        security         = $resultMap['security']
        cost             = $resultMap['cost']
        advisor          = $resultMap['advisor']
        structure        = $resultMap['structure']
        virtualMachines  = $resultMap['vm']
        virtualMachineScaleSets = $resultMap['vmss']
        databases        = $resultMap['database']
        relationalDatabases = $resultMap['relational']
        dataServices     = $resultMap['dataservices']
        applications     = $resultMap['application']
        applicationServices = $resultMap['appservices']
        network          = $resultMap['network']
        vnets            = $resultMap['vnets']
        loadBalancers    = $resultMap['loadbalancers']
        observability    = $resultMap['observability']
        diagnosticSettings = $resultMap['diagnosticsettings']
        dataCollection   = $resultMap['datacollection']
        obsInventory     = $resultMap['obsinventory']
        policy           = $resultMap['policy']
        defender         = $resultMap['defender']
        collectionErrors = @($collectionErrors)
    }

    # ── Phase 3 — Render the HTML site ───────────────────────────────────────
    Write-AerMsg '▶' Cyan 'Rendering HTML report…'
    $indexPath = New-AerReportSite -ReportData $reportData -OutputPath $OutputPath
    $fullIndex = (Resolve-Path $indexPath).Path
    Write-AerMsg '✓' Green 'Report generated' $fullIndex
    Write-AerExportStatus -ExportFiles $reportData.metadata.ExportFiles -OutputPath $OutputPath

    # ── Summary table ────────────────────────────────────────────────────────
    $totalMs = [math]::Round(((Get-Date) - $startedAt).TotalMilliseconds)
    $durationStr = Format-AerDuration $totalMs

    $summary = [ordered]@{
        'Subscriptions'           = $ctx.Subscriptions.Count
        'Resource groups'         = ($resultMap['inventory']?.TotalResourceGroups ?? 0)
        'Resources'               = ($resultMap['inventory']?.TotalResources ?? 0)
        'Virtual machines'        = ($resultMap['vm']?.TotalVMs ?? 0)
        'Databases'               = ($resultMap['database']?.TotalServices ?? 0)
        'Advisor recommendations' = ($resultMap['advisor']?.TotalRecommendations ?? 0)
        'Policy assignments'      = ($resultMap['policy']?.Assignments.Total ?? 0)
        'Defender unhealthy'      = ($resultMap['defender']?.Summary.Unhealthy ?? 0)
        'Cost findings'           = ($resultMap['cost']?.TotalWastedResources ?? 0)
        'General findings'        = ($resultMap['security']?.TotalGaps ?? 0)
    }
    Write-AerRunSummary `
        -Summary $summary `
        -Duration $durationStr `
        -CollectorSuccess $okCount `
        -CollectorErrors $collectionErrors.Count `
        -CollectorTotal $collectors.Count `
        -CollectionErrors @($collectionErrors) `
        -ExportFiles $reportData.metadata.ExportFiles
    Write-Host ''
    Write-AerMsg '✓' Green 'Done.' "open $fullIndex"
    Write-Host ''

    if ($OpenReport) {
        try { Invoke-Item $indexPath } catch { Write-AerMsg '⚠' Yellow 'Could not open the report automatically' $_.Exception.Message }
    }

    if ($PassThru) {
        return [pscustomobject]@{
            OutputPath             = (Resolve-Path $OutputPath).Path
            IndexHtml              = $fullIndex
            ManagementGroups       = $resultMap['inventory']?.ManagementGroups ?? 0
            Subscriptions          = $resultMap['inventory']?.Subscriptions ?? 0
            ResourceGroups         = $resultMap['inventory']?.TotalResourceGroups ?? 0
            Resources              = $resultMap['inventory']?.TotalResources ?? 0
            SecurityGaps           = $resultMap['security']?.TotalGaps ?? 0
            WastedResources        = $resultMap['cost']?.TotalWastedResources ?? 0
            AdvisorRecommendations = $resultMap['advisor']?.TotalRecommendations ?? 0
            VirtualMachines        = $resultMap['vm']?.TotalVMs ?? 0
            VmScaleSets            = $resultMap['vmss']?.TotalVMSS ?? 0
            CollectionErrors       = $collectionErrors.Count
            Duration               = [timespan]::FromMilliseconds($totalMs)
            ExcelWorkbook          = if ($reportData.metadata.ExportFiles.Xlsx) { (Resolve-Path (Join-Path $OutputPath ($reportData.metadata.ExportFiles.Xlsx -replace '/', [IO.Path]::DirectorySeparatorChar))).Path } else { $null }
            PdfReport              = if ($reportData.metadata.ExportFiles.Pdf) { (Resolve-Path (Join-Path $OutputPath ($reportData.metadata.ExportFiles.Pdf -replace '/', [IO.Path]::DirectorySeparatorChar))).Path } else { $null }
            ExportErrors           = @($reportData.metadata.ExportFiles.Errors)
        }
    }
}