Private/ConvertTo-AACInventory.ps1

function ConvertTo-AACInventory {
    <#
    .SYNOPSIS
        Builds the tenant inventory tree - tenant > management groups >
        subscriptions > resource groups > resources - from Azure Resource
        Graph rows, with counts rolled up at every level.
    .DESCRIPTION
        Management groups are nested by their parent; a subscription goes
        under the management group it is directly in (the first of its
        managementGroupAncestorsChain); resource groups under their
        subscription; resources under their resource group. When the
        management groups can't be read (no permission on them), or a
        subscription's group isn't among those read, it goes straight under
        the tenant.
 
        Management groups with no subscription in the result are left out -
        unless they were asked for by name (-KeepManagementGroup), so an empty
        group asked for still shows.
 
        With -Security (Microsoft Defender for Cloud, from Resource Graph's
        securityresources), every node also has a security posture:
          - a resource: its secure score - the share of its assessed
            recommendations that are healthy - and its unhealthy findings by
            severity (High, Medium, Low), the worst of them (Severity) and
            the first few by name (TopFindings)
          - a resource group: the same, over its resources
          - a subscription: Defender's own secure score (current / max)
          - a management group and the tenant: their subscriptions' scores
            added up, as Defender does (sum of current / sum of max)
        Rating: Good (70% or more), Fair (40-69%), Poor (under 40%).
 
        With -Cost (Cost Management rows by ResourceId and SubscriptionId,
        monthly), every node also has its actual cost month to date and last
        month (CostMonthToDate, CostLastMonth) and its Currency:
          - a resource: its own cost; a cost row for a child resource (a
            database of a server, say) goes to the closest resource above it
          - costs of resources no longer in Azure, and charges not tied to a
            resource, go to a 'Deleted resources' line under their
            subscription (Level DeletedResources)
          - every level above: its children's costs added up - in their one
            currency; never converted, so a node whose children are billed
            in different currencies has Currency 'mixed' and no total
          - a subscription whose cost couldn't be read says why (CostStatus)
        With -ResourceGroupName in effect (FilterGroups), cost rows outside
        the resource groups read are left out.
 
        Returns a hashtable:
          Root the tenant node; every node is a hashtable with Level, Id,
                 Name, DisplayName, Detail, Children and the rolled-up counts
                 ManagementGroups, Subscriptions, ResourceGroups, Resources
          Items every node, flattened in tree order, as AAC.InventoryItem
                 objects (Level, Depth, Name, Path, Type, Location, ...)
          Stats the totals
          Controls, Recommendations (with -Security) Defender's security
                 controls per subscription, with the potential score increase
                 of fixing each, and every unhealthy recommendation with the
                 resource it is on
        TopSpend (with -Cost) the resources that cost the most this month
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [string] $TenantId,

        [string] $TenantName,

        # Rows: id, name, displayName, parentId.
        [AllowEmptyCollection()]
        [object[]] $ManagementGroup = @(),

        # Rows: subscriptionId, name, state, parentGroup (a management group name), tags.
        [AllowEmptyCollection()]
        [object[]] $Subscription = @(),

        # Rows: id, name, subscriptionId, location, state, managedBy, tags.
        [AllowEmptyCollection()]
        [object[]] $ResourceGroup = @(),

        # Rows: id, name, type, kind, location, resourceGroup, subscriptionId, sku, zones, tags.
        [AllowEmptyCollection()]
        [object[]] $Resource = @(),

        [string[]] $KeepManagementGroup = @(),

        # Defender for Cloud rows: Scores (subscriptionId, current, max),
        # Controls (subscriptionId, control, current, max, healthy,
        # unhealthy), Summary (resourceId, healthy, unhealthy, high, medium,
        # low) and Recommendations (resourceId, subscriptionId, name,
        # severity, impact, effort, categories, cause).
        [hashtable] $Security,

        # Cost Management: Rows (ResourceId, SubscriptionId, BillingMonth,
        # Cost, Currency), Status (subscription ID -> 'OK', 'No cost' or why
        # it couldn't be read), ThisMonth and LastMonth ('yyyy-MM'), and
        # FilterGroups (only rows in the resource groups read).
        [hashtable] $Cost
    )

    function Get-Value($Row, [string] $Key) {
        if ($Row -is [System.Collections.IDictionary]) {
            if ($Row.Contains($Key)) { return $Row[$Key] }
            foreach ($name in $Row.Keys) { if ($name -eq $Key) { return $Row[$name] } }
            return $null
        }
        Get-AACPropertyValue -InputObject $Row -Name $Key
    }
    $text = { param($Row, [string] $Key) $value = Get-Value $Row $Key; if ($null -eq $value) { '' } else { [string]$value } }
    $lower = { param([string] $Value) $Value.ToLowerInvariant() }
    $tagText = {
        param($Tags)
        if ($Tags -is [System.Collections.IDictionary]) { return (@($Tags.Keys | Sort-Object | ForEach-Object { "$_=$($Tags[$_])" }) -join '; ') }
        if ($Tags -and $Tags -isnot [string]) { return (@($Tags.PSObject.Properties | Sort-Object Name | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join '; ') }
        ''
    }
    $newNode = {
        param([string] $Level, [string] $Id, [string] $Name, [string] $DisplayName)
        @{
            Level = $Level; Id = $Id; Name = $Name; DisplayName = $(if ($DisplayName) { $DisplayName } else { $Name })
            Children = [System.Collections.Generic.List[object]]::new(); Parent = $null
            ManagementGroups = 0; Subscriptions = 0; ResourceGroups = 0; Resources = 0
            Location = ''; State = ''; Type = ''; Kind = ''; Sku = ''; Tags = ''; SubscriptionId = ''; SubscriptionName = ''
            ResourceGroup = ''; ManagementGroup = ''; Zones = ''; TypeCounts = @{}; Visible = $true
            # Security posture (Defender for Cloud).
            SecureScore = $null; ScoreCurrent = 0.0; ScoreMax = 0.0; OfficialScore = $false
            Healthy = 0; Unhealthy = 0; High = 0; Medium = 0; Low = 0; Severity = ''; Rating = ''; TopFindings = ''
            # Cost (Cost Management).
            CostMonthToDate = 0.0; CostLastMonth = 0.0; Currency = ''; Currencies = @{}; CostStatus = ''; DeletedCount = 0
        }
    }

    # --- The tenant and its management groups ------------------------------------------------------
    $root = & $newNode 'Tenant' "/tenants/$TenantId" $TenantId $(if ($TenantName) { $TenantName } else { $TenantId })
    $groups = @{}
    foreach ($row in $ManagementGroup) {
        $name = & $text $row 'name'
        if (-not $name) { continue }
        $groups[(& $lower $name)] = & $newNode 'ManagementGroup' (& $text $row 'id') $name (& $text $row 'displayName')
        $groups[(& $lower $name)].ParentName = (& $text $row 'parentId') -replace '^.*/', ''
    }
    foreach ($key in @($groups.Keys)) {
        $group = $groups[$key]
        $parentKey = & $lower ([string]$group.ParentName)
        # The tenant root group's parent is the tenant itself.
        $parent = if ($parentKey -and $groups.Contains($parentKey) -and $parentKey -ne $key) { $groups[$parentKey] } else { $root }
        $group.Parent = $parent
        $parent.Children.Add($group)
    }

    # --- Subscriptions, resource groups, resources ------------------------------------------------------
    $subscriptions = @{}
    foreach ($row in $Subscription) {
        $id = & $lower (& $text $row 'subscriptionId')
        if (-not $id -or $subscriptions.Contains($id)) { continue }
        $node = & $newNode 'Subscription' "/subscriptions/$id" (& $text $row 'name') ''
        $node.SubscriptionId = $id
        $node.SubscriptionName = $node.Name
        $node.State = & $text $row 'state'
        $node.Tags = & $tagText (Get-Value $row 'tags')
        $node.QuotaId = & $text $row 'quotaId'
        $parentKey = & $lower (& $text $row 'parentGroup')
        $parent = if ($parentKey -and $groups.Contains($parentKey)) { $groups[$parentKey] } else { $root }
        $node.ManagementGroup = if ($parent.Level -eq 'ManagementGroup') { $parent.DisplayName } else { '' }
        $node.Parent = $parent
        $parent.Children.Add($node)
        $subscriptions[$id] = $node
    }
    $ensureSubscription = {
        param([string] $Id)
        $key = & $lower $Id
        if (-not $subscriptions.Contains($key)) {
            # A subscription seen only through its resources.
            $node = & $newNode 'Subscription' "/subscriptions/$key" $key ''
            $node.SubscriptionId = $key; $node.SubscriptionName = $key; $node.Parent = $root
            $root.Children.Add($node)
            $subscriptions[$key] = $node
        }
        $subscriptions[$key]
    }
    $resourceGroups = @{}
    foreach ($row in $ResourceGroup) {
        $sub = & $ensureSubscription (& $text $row 'subscriptionId')
        $name = & $text $row 'name'
        $key = "$($sub.SubscriptionId)/$(& $lower $name)"
        if ($resourceGroups.Contains($key)) { continue }
        $node = & $newNode 'ResourceGroup' (& $text $row 'id') $name ''
        $node.Location = & $text $row 'location'
        $node.State = & $text $row 'state'
        $node.ManagedBy = & $text $row 'managedBy'
        $node.Tags = & $tagText (Get-Value $row 'tags')
        $node.SubscriptionId = $sub.SubscriptionId; $node.SubscriptionName = $sub.Name; $node.ResourceGroup = $name; $node.ManagementGroup = $sub.ManagementGroup
        $node.Parent = $sub
        $sub.Children.Add($node)
        $resourceGroups[$key] = $node
    }
    foreach ($row in $Resource) {
        $sub = & $ensureSubscription (& $text $row 'subscriptionId')
        $groupName = & $text $row 'resourceGroup'
        $key = "$($sub.SubscriptionId)/$(& $lower $groupName)"
        if (-not $resourceGroups.Contains($key)) {
            $group = & $newNode 'ResourceGroup' "/subscriptions/$($sub.SubscriptionId)/resourceGroups/$groupName" $groupName ''
            $group.SubscriptionId = $sub.SubscriptionId; $group.SubscriptionName = $sub.Name; $group.ResourceGroup = $groupName; $group.ManagementGroup = $sub.ManagementGroup
            $group.Parent = $sub; $sub.Children.Add($group)
            $resourceGroups[$key] = $group
        }
        $parent = $resourceGroups[$key]
        $node = & $newNode 'Resource' (& $text $row 'id') (& $text $row 'name') ''
        $node.Type = & $lower (& $text $row 'type')
        $node.Kind = & $text $row 'kind'
        $node.Location = & $text $row 'location'
        $node.Sku = & $text $row 'sku'
        $node.Zones = (@(Get-Value $row 'zones') | Where-Object { $_ }) -join ', '
        $node.Tags = & $tagText (Get-Value $row 'tags')
        $node.SubscriptionId = $sub.SubscriptionId; $node.SubscriptionName = $sub.Name; $node.ResourceGroup = $groupName; $node.ManagementGroup = $sub.ManagementGroup
        $node.Parent = $parent
        $parent.Children.Add($node)
    }

    # --- Security posture (Defender for Cloud) --------------------------------------------------------
    $hasSecurity = $null -ne $Security -and @(@($Security.Scores) + @($Security.Summary) | Where-Object { $_ }).Count -gt 0
    $byId = @{}
    $controls = @()
    $recommendations = @()
    if ($hasSecurity) {
        $walk = [System.Collections.Generic.Stack[object]]::new()
        $walk.Push($root)
        while ($walk.Count) {
            $node = $walk.Pop()
            if ($node.Id) { $byId[(& $lower $node.Id)] = $node }
            foreach ($child in $node.Children) { $walk.Push($child) }
        }
        foreach ($row in @($Security.Summary)) {
            $node = $byId[(& $lower (& $text $row 'resourceId'))]
            if (-not $node -or $node.Level -ne 'Resource') { continue }
            $node.Healthy = [int](Get-Value $row 'healthy'); $node.Unhealthy = [int](Get-Value $row 'unhealthy')
            $node.High = [int](Get-Value $row 'high'); $node.Medium = [int](Get-Value $row 'medium'); $node.Low = [int](Get-Value $row 'low')
        }
        foreach ($row in @($Security.Scores)) {
            $sub = $subscriptions[(& $lower (& $text $row 'subscriptionId'))]
            if (-not $sub) { continue }
            $sub.ScoreCurrent = [double](Get-Value $row 'current'); $sub.ScoreMax = [double](Get-Value $row 'max'); $sub.OfficialScore = $sub.ScoreMax -gt 0
        }
        # Controls and recommendations: the same objects Get-AACSecurityPosture
        # returns (ConvertTo-AACSecurityControl, ConvertTo-AACSecurityRecommendation),
        # kept to the subscriptions and resources in this inventory.
        $names = @{}
        $maxima = @{}
        foreach ($key in $subscriptions.Keys) { $names[$key] = $subscriptions[$key].Name; $maxima[$key] = $subscriptions[$key].ScoreMax }
        $controls = @(ConvertTo-AACSecurityControl -Row @($Security.Controls) -ScoreMax $maxima -SubscriptionName $names)
        $recommendations = @(foreach ($item in @(ConvertTo-AACSecurityRecommendation -Row @($Security.Recommendations) -SubscriptionName $names)) {
                $node = $byId[([string]$item.ResourceId).ToLowerInvariant()]
                if (-not $node -or $node.Level -notin 'Resource', 'Subscription', 'ResourceGroup') { continue }
                # As the tree names it.
                $item.Resource = $node.DisplayName; $item.Type = $node.Type; $item.ResourceGroup = $node.ResourceGroup
                $item.SubscriptionName = $node.SubscriptionName; $item.SubscriptionId = $node.SubscriptionId; $item.ResourceId = $node.Id
                $item
            })
        foreach ($group in @($recommendations | Where-Object { $_.Type } | Group-Object -Property { ([string]$_.ResourceId).ToLowerInvariant() })) {
            $node = $byId[$group.Name]
            if ($node) { $node.TopFindings = (@($group.Group | Select-Object -First 3 | ForEach-Object { $_.Recommendation })) -join '; ' }
        }
    }

    # --- Cost (Cost Management) -------------------------------------------------------------------------
    $hasCost = $null -ne $Cost
    $unplaced = 0
    if ($hasCost) {
        $invariant = [cultureinfo]::InvariantCulture
        $monthOf = {
            param($Value)
            if ($Value -is [datetime]) { return $Value.ToString('yyyy-MM', $invariant) }
            if ($Value -is [datetimeoffset]) { return $Value.UtcDateTime.ToString('yyyy-MM', $invariant) }
            $raw = ([string]$Value).Trim()
            if ($raw -match '^(\d{4})(\d{2})\d{2}$') { return "$($Matches[1])-$($Matches[2])" }
            if ($raw -match '^(\d{4})-(\d{2})') { return "$($Matches[1])-$($Matches[2])" }
            ''
        }
        $resourcesById = @{}
        $walk = [System.Collections.Generic.Stack[object]]::new()
        $walk.Push($root)
        while ($walk.Count) {
            $node = $walk.Pop()
            if ($node.Level -eq 'Resource' -and $node.Id) { $resourcesById[(& $lower $node.Id)] = $node }
            foreach ($child in $node.Children) { $walk.Push($child) }
        }
        $deleted = @{}
        $deletedIds = @{}
        $addCost = {
            param($Node, [string] $Month, [double] $Amount, [string] $Currency)
            if ($Month -eq $Cost.ThisMonth) { $Node.CostMonthToDate += $Amount }
            elseif ($Month -eq $Cost.LastMonth) { $Node.CostLastMonth += $Amount }
            else { return }
            if ($Currency) { $Node.Currencies[$Currency] = $true }
        }
        foreach ($row in @($Cost.Rows)) {
            $amount = [double](Get-Value $row 'Cost')
            $month = & $monthOf (Get-Value $row 'BillingMonth')
            if (-not $month) { $month = & $monthOf (Get-Value $row 'UsageDate') }
            if (-not $month) { $unplaced++; continue }
            $currency = & $text $row 'Currency'
            $resourceId = & $lower (& $text $row 'ResourceId')
            # The resource, or the closest one above it (a server for its database).
            $node = $null
            for ($id = $resourceId; $id -match '/providers/.+/.+/.+'; $id = $id -replace '/[^/]+/[^/]+$', '') {
                if ($resourcesById.Contains($id)) { $node = $resourcesById[$id]; break }
            }
            if ($node) { & $addCost $node $month $amount $currency; continue }
            # Not in the inventory: a deleted resource, or a charge not tied to one.
            $subscriptionId = & $lower (& $text $row 'SubscriptionId')
            if (-not $subscriptionId -and $resourceId -match '^/subscriptions/([^/]+)') { $subscriptionId = $Matches[1] }
            if (-not $subscriptions.Contains($subscriptionId)) { continue }
            if ($Cost['FilterGroups']) {
                if ($resourceId -notmatch '^/subscriptions/[^/]+/resourcegroups/([^/]+)') { continue }
                if (-not $resourceGroups.Contains("$subscriptionId/$($Matches[1])")) { continue }
            }
            if (-not $deleted.Contains($subscriptionId)) {
                $sub = $subscriptions[$subscriptionId]
                $node = & $newNode 'DeletedResources' "/subscriptions/$subscriptionId/deletedresources" 'Deleted resources' ''
                $node.SubscriptionId = $sub.SubscriptionId; $node.SubscriptionName = $sub.Name; $node.ManagementGroup = $sub.ManagementGroup
                $node.Parent = $sub
                $sub.Children.Add($node)
                $deleted[$subscriptionId] = $node
                $deletedIds[$subscriptionId] = [System.Collections.Generic.HashSet[string]]::new()
            }
            if ($resourceId) { [void]$deletedIds[$subscriptionId].Add($resourceId) }
            & $addCost $deleted[$subscriptionId] $month $amount $currency
        }
        foreach ($key in @($deleted.Keys)) {
            $node = $deleted[$key]
            $node.DeletedCount = $deletedIds[$key].Count
            # Nothing charged in the period: no line.
            if ($node.CostMonthToDate -eq 0 -and $node.CostLastMonth -eq 0) { [void]$node.Parent.Children.Remove($node) }
        }
        foreach ($key in @($subscriptions.Keys)) {
            $subscriptions[$key].CostStatus = if ($Cost['Status'] -and $Cost['Status'].Contains($key)) { [string]$Cost['Status'][$key] } else { 'Not read' }
        }
    }

    # --- Counts, rolled up; empty management groups left out -------------------------------------------
    $keep = @($KeepManagementGroup | ForEach-Object { & $lower $_ })
    function Measure-Node($Node) {
        $Node.TypeCounts = @{}
        $Node.ManagementGroups = 0; $Node.Subscriptions = 0; $Node.ResourceGroups = 0; $Node.Resources = 0
        $Node.Keep = $Node.Level -eq 'ManagementGroup' -and ($keep -contains (& $lower $Node.Name))
        if ($Node.Level -notin 'Resource', 'DeletedResources') {
            $Node.Healthy = 0; $Node.Unhealthy = 0; $Node.High = 0; $Node.Medium = 0; $Node.Low = 0
            if (-not $Node.OfficialScore) { $Node.ScoreCurrent = 0.0; $Node.ScoreMax = 0.0 }
            $Node.CostMonthToDate = 0.0; $Node.CostLastMonth = 0.0; $Node.Currencies = @{}
        }
        foreach ($child in @($Node.Children)) {
            Measure-Node $child
            switch ($child.Level) {
                'ManagementGroup' { $Node.ManagementGroups += 1 + $child.ManagementGroups }
                'Subscription' { $Node.Subscriptions += 1 }
                'ResourceGroup' { $Node.ResourceGroups += 1 }
                'Resource' { $Node.Resources += 1; $Node.TypeCounts[$child.Type] = 1 + [int]$Node.TypeCounts[$child.Type] }
            }
            if ($child.Level -eq 'ManagementGroup' -and $child.Keep) { $Node.Keep = $true }
            $Node.Healthy += $child.Healthy; $Node.Unhealthy += $child.Unhealthy
            $Node.CostMonthToDate += $child.CostMonthToDate; $Node.CostLastMonth += $child.CostLastMonth
            foreach ($currency in $child.Currencies.Keys) { $Node.Currencies[$currency] = $true }
            $Node.High += $child.High; $Node.Medium += $child.Medium; $Node.Low += $child.Low
            # A management group's and the tenant's score: their subscriptions' added up.
            if ($Node.Level -in 'Tenant', 'ManagementGroup' -and $child.Level -in 'Subscription', 'ManagementGroup') {
                $Node.ScoreCurrent += $child.ScoreCurrent; $Node.ScoreMax += $child.ScoreMax
            }
            if ($child.Level -ne 'Resource') {
                $Node.Subscriptions += $(if ($child.Level -eq 'Subscription') { 0 } else { $child.Subscriptions })
                $Node.ResourceGroups += $(if ($child.Level -eq 'ResourceGroup') { 0 } else { $child.ResourceGroups })
                $Node.Resources += $child.Resources
                foreach ($type in $child.TypeCounts.Keys) { $Node.TypeCounts[$type] = [int]$Node.TypeCounts[$type] + $child.TypeCounts[$type] }
            }
        }
        # A management group with no subscription below it isn't part of this
        # inventory, unless it was asked for.
        $Node.Children = [System.Collections.Generic.List[object]]@(@($Node.Children) | Where-Object {
                $_.Level -ne 'ManagementGroup' -or $_.Subscriptions -gt 0 -or $_.Keep
            })
        # Recount management groups after pruning.
        $Node.ManagementGroups = 0
        foreach ($child in $Node.Children) { if ($child.Level -eq 'ManagementGroup') { $Node.ManagementGroups += 1 + $child.ManagementGroups } }
    }
    Measure-Node $root

    # Order: management groups, then subscriptions, then groups and resources, by name.
    $order = @{ ManagementGroup = 0; Subscription = 1; ResourceGroup = 2; Resource = 3; DeletedResources = 4 }
    function Complete-Node($Node, [string] $Path, [int] $Depth, $Items) {
        $Node.Depth = $Depth
        $Node.Path = if ($Path) { "$Path / $($Node.DisplayName)" } else { $Node.DisplayName }
        $top = @($Node.TypeCounts.GetEnumerator() | Sort-Object -Property @{ Expression = { $_.Value }; Descending = $true }, Name | Select-Object -First 3 | ForEach-Object { "$(($_.Name -split '/')[-1]) $($_.Value)" })
        $Node.TopTypes = $top -join ', '
        if ($hasSecurity) {
            $assessed = $Node.Healthy + $Node.Unhealthy
            $Node.SecureScore = if ($Node.Level -in 'Tenant', 'ManagementGroup', 'Subscription' -and $Node.ScoreMax -gt 0) { [Math]::Round(100 * $Node.ScoreCurrent / $Node.ScoreMax) }
            elseif ($assessed -gt 0) { [Math]::Round(100 * $Node.Healthy / $assessed) }
            else { $null }
            $Node.Severity = if ($Node.High) { 'High' } elseif ($Node.Medium) { 'Medium' } elseif ($Node.Low) { 'Low' } elseif ($assessed) { 'Healthy' } else { '' }
            $Node.Rating = if ($null -eq $Node.SecureScore) { '' } elseif ($Node.SecureScore -ge 70) { 'Good' } elseif ($Node.SecureScore -ge 40) { 'Fair' } else { 'Poor' }
        }
        # One currency, or 'mixed' - never added across currencies.
        $Node.Currency = if ($Node.Currencies.Count -eq 1) { @($Node.Currencies.Keys)[0] } elseif ($Node.Currencies.Count -gt 1) { 'mixed' } else { '' }
        # Read, but nothing charged in the period: a state of its own, as Show-AACCost says it.
        if ($Node.Level -eq 'Subscription' -and $Node.CostStatus -eq 'OK' -and $Node.CostMonthToDate -eq 0 -and $Node.CostLastMonth -eq 0) { $Node.CostStatus = 'No cost' }
        $Node.Detail = switch ($Node.Level) {
            'Tenant' { $TenantId }
            'ManagementGroup' { $Node.Name }
            'Subscription' { (@($Node.SubscriptionId, $Node.State) | Where-Object { $_ }) -join ' · ' }
            'ResourceGroup' { (@($Node.Location, $(if ($Node.Resources -eq 0) { 'empty' })) | Where-Object { $_ }) -join ' · ' }
            'Resource' { (@(($Node.Type -replace '^microsoft\.', ''), $Node.Location, $Node.Sku) | Where-Object { $_ }) -join ' · ' }
            'DeletedResources' { "costs of resources no longer in Azure$(if ($Node.DeletedCount) { " ($($Node.DeletedCount))" }), and charges not tied to a resource" }
        }
        $Items.Add([pscustomobject][ordered]@{
                PSTypeName       = 'AAC.InventoryItem'
                Level            = $Node.Level
                Depth            = $Depth
                Name             = $Node.DisplayName
                Path             = $Node.Path
                ManagementGroup  = $Node.ManagementGroup
                SubscriptionName = $Node.SubscriptionName
                SubscriptionId   = $Node.SubscriptionId
                ResourceGroup    = $Node.ResourceGroup
                Type             = $Node.Type
                Kind             = $Node.Kind
                Location         = $Node.Location
                Sku              = $Node.Sku
                Zones            = $Node.Zones
                State            = $Node.State
                ManagementGroups = $Node.ManagementGroups
                Subscriptions    = $Node.Subscriptions
                ResourceGroups   = $Node.ResourceGroups
                Resources        = $Node.Resources
                TopTypes         = $Node.TopTypes
                SecureScore      = $Node.SecureScore
                ScorePoints      = $(if ($Node.Level -in 'Tenant', 'ManagementGroup', 'Subscription' -and $Node.ScoreMax -gt 0) { '{0:N1} / {1:N1}' -f $Node.ScoreCurrent, $Node.ScoreMax } else { '' })
                Rating           = $Node.Rating
                Severity         = $Node.Severity
                High             = $Node.High
                Medium           = $Node.Medium
                Low              = $Node.Low
                Findings         = $Node.Unhealthy
                TopFindings      = $Node.TopFindings
                CostMonthToDate  = $(if ($hasCost -and $Node.Currency -ne 'mixed') { [Math]::Round($Node.CostMonthToDate, 2) } else { $null })
                CostLastMonth    = $(if ($hasCost -and $Node.Currency -ne 'mixed') { [Math]::Round($Node.CostLastMonth, 2) } else { $null })
                Currency         = $Node.Currency
                CostStatus       = $Node.CostStatus
                Tags             = $Node.Tags
                Id               = $Node.Id
            })
        $sorted = @($Node.Children | Sort-Object -Property @{ Expression = { $order[$_.Level] } }, @{ Expression = { $_.DisplayName } })
        $Node.Children = [System.Collections.Generic.List[object]]@($sorted)
        foreach ($child in $sorted) { Complete-Node $child $Node.Path ($Depth + 1) $Items }
    }
    $items = [System.Collections.Generic.List[object]]::new()
    Complete-Node $root '' 0 $items

    $all = $items.ToArray()
    @{
        Root  = $root
        Items = $all
        Stats = @{
            ManagementGroups = $root.ManagementGroups
            Subscriptions    = @($all | Where-Object Level -EQ 'Subscription').Count
            ResourceGroups   = @($all | Where-Object Level -EQ 'ResourceGroup').Count
            EmptyGroups      = @($all | Where-Object { $_.Level -eq 'ResourceGroup' -and $_.Resources -eq 0 }).Count
            Resources        = $root.Resources
            Types            = $root.TypeCounts.Count
            Locations        = @($all | Where-Object { $_.Level -eq 'Resource' -and $_.Location } | Select-Object -ExpandProperty Location -Unique).Count
            HasSecurity      = $hasSecurity
            SecureScore      = $root.SecureScore
            Rating           = $root.Rating
            High             = $root.High
            Medium           = $root.Medium
            Low              = $root.Low
            Assessed         = @($all | Where-Object { $_.Level -eq 'Resource' -and $null -ne $_.SecureScore }).Count
            HasCost          = $hasCost
            # Totals per currency, largest first (never converted).
            Cost             = @(if ($hasCost) {
                    $all | Where-Object { $_.Level -in 'Resource', 'DeletedResources' -and $_.Currency } | Group-Object -Property Currency | ForEach-Object {
                        $monthToDate = 0.0; $lastMonth = 0.0
                        foreach ($item in $_.Group) { $monthToDate += [double]$item.CostMonthToDate; $lastMonth += [double]$item.CostLastMonth }
                        [pscustomobject]@{ Currency = $_.Name; MonthToDate = [Math]::Round($monthToDate, 2); LastMonth = [Math]::Round($lastMonth, 2) }
                    } | Sort-Object -Property MonthToDate -Descending
                })
            CostUnplaced     = $unplaced
        }
        Controls        = $controls
        Recommendations = $recommendations
        # Get-AACInventory -Insight adds it (ConvertTo-AACInventoryInsight).
        Insight         = $null
        TopSpend        = @(if ($hasCost) { $all | Where-Object { $_.Level -eq 'Resource' -and $_.CostMonthToDate -gt 0 } | Sort-Object -Property @{ Expression = 'CostMonthToDate'; Descending = $true }, Name | Select-Object -First 15 })
    }
}