Private/Write-AACInventoryPdf.ps1

function Write-AACInventoryPdf {
    <#
    .SYNOPSIS
        Writes the tenant inventory (ConvertTo-AACInventory) as a landscape A4
        PDF report.
    .DESCRIPTION
        1. Summary: title, scope, tiles (management groups, subscriptions,
           resource groups, resources, types, locations).
        2. Hierarchy: the tree down to resource groups, indented, with each
           level's counts; empty resource groups in amber.
        3. Security (with Defender for Cloud data): each subscription's secure
           score, the controls with the most to gain, and the unhealthy
           recommendations, most severe first - scores coloured Good (70%+)
           green, Fair (40-69%) amber, Poor red; severities High red, Medium
           amber, Low blue.
        4. Subscriptions, resource groups, and resources by type.
        5. Every resource (name, type, resource group, subscription, location,
           SKU) - up to -MaxResources; the HTML report and CSV have them all.
 
        -Path must be a full path; see Save-AACPdfDocument.
    #>

    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Inventory,

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

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

        [System.Collections.IDictionary] $Detail,

        [int] $MaxResources = 5000
    )

    $items = @($Inventory.Items)
    $stats = $Inventory.Stats
    $pdf = New-AACPdfDocument -Title $Title -Subject "$($stats.Resources) resources in $($stats.Subscriptions) subscriptions" -Landscape
    $section = $pdf.Section
    $colors = $pdf.Colors
    $pt = $pdf.Pt
    $right = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Right
    $number = { param($Cell, $Value) $p = $Cell.AddParagraph(('{0:N0}' -f [double]$Value)); $p.Format.Alignment = $right }
    $security = [bool]$stats.HasSecurity
    $blue = [MigraDoc.DocumentObjectModel.Color]::Parse('#0369A1')
    $scoreColor = { param($Score) if ($null -eq $Score) { $colors.Muted } elseif ($Score -ge 70) { $pdf.Tone.Good.Solid } elseif ($Score -ge 40) { $pdf.Tone.Warn.Solid } else { $pdf.Tone.Bad.Solid } }
    $severityColor = @{ High = $pdf.Tone.Bad.Solid; Medium = $pdf.Tone.Warn.Solid; Low = $blue; Healthy = $pdf.Tone.Good.Solid; Moderate = $pdf.Tone.Warn.Solid }
    # A score as a bold coloured percentage; findings as coloured H / M / L counts.
    $addScore = {
        param($Cell, $Score)
        if ($null -eq $Score) { return }
        $p = $Cell.AddParagraph("$Score%")
        $p.Format.Alignment = $right
        $p.Format.Font.Name = 'Segoe UI Semibold'
        $p.Format.Font.Color = & $scoreColor $Score
    }
    $addFindings = {
        param($Cell, $Item)
        $p = $Cell.AddParagraph()
        $p.Format.Alignment = $right
        foreach ($level in @(@('High', 'H'), @('Medium', 'M'), @('Low', 'L'))) {
            $count = [int]$Item.($level[0])
            if (-not $count) { continue }
            $text = $p.AddFormattedText("$($level[1])$count ")
            $text.Color = $severityColor[$level[0]]
            $text.Bold = $true
        }
    }

    # --- 1. Summary -----------------------------------------------------------------------------
    & $pdf.AddTitle "Tenant inventory · generated $($pdf.Generated.ToString('dddd d MMMM yyyy, HH:mm'))"
    $facts = [ordered]@{}
    if ($script:AACSession) {
        $facts['Azure account'] = [string]$script:AACSession.Account
        $facts['Tenant'] = [string]$script:AACSession.TenantId
    }
    if ($Detail) { foreach ($key in $Detail.Keys) { $facts[[string]$key] = [string]$Detail[$key] } }
    $factTable = & $pdf.NewTable @(4.0, ($pdf.PageWidth - 4.0))
    foreach ($key in $facts.Keys) {
        $row = & $pdf.AddBodyRow $factTable
        $row.Cells[0].AddParagraph($key).Format.Font.Color = $colors.Muted
        $row.Cells[1].AddParagraph($facts[$key]) | Out-Null
    }
    $section.AddParagraph().Format.SpaceAfter = & $pt 6
    $tileData = @(
        if ($security -and $null -ne $stats.SecureScore) { @{ Value = "$($stats.SecureScore)%"; Label = "secure score ($($stats.Rating.ToLowerInvariant()))"; Color = (& $scoreColor $stats.SecureScore) } }
        if ($security) { @{ Value = $stats.High; Label = 'high-severity findings'; Color = $(if ($stats.High) { $pdf.Tone.Bad.Solid } else { $pdf.Tone.Good.Solid }) } }
        @{ Value = $stats.ManagementGroups; Label = 'management groups' }
        @{ Value = $stats.Subscriptions; Label = 'subscriptions' }
        @{ Value = $stats.ResourceGroups; Label = 'resource groups' }
        @{ Value = $stats.Resources; Label = 'resources' }
        if (-not $security) { @{ Value = $stats.Types; Label = 'resource types' } }
        if (-not $security) { @{ Value = $stats.Locations; Label = 'locations' } }
    )
    $tiles = & $pdf.NewTable @(1..$tileData.Count | ForEach-Object { $pdf.PageWidth / $tileData.Count })
    $tiles.TopPadding = & $pt 8
    $tiles.BottomPadding = & $pt 8
    $tileRow = $tiles.AddRow()
    for ($i = 0; $i -lt $tileData.Count; $i++) {
        $cell = $tileRow.Cells[$i]
        $cell.Shading.Color = $colors.Panel
        $cell.Borders.Left.Width = $(if ($i -gt 0) { 2 } else { 0 })
        $cell.Borders.Left.Color = $colors.White
        $value = $cell.AddParagraph($(if ($tileData[$i].Value -is [string]) { $tileData[$i].Value } else { '{0:N0}' -f $tileData[$i].Value }))
        if ($tileData[$i].Contains('Color')) { $value.Format.Font.Color = $tileData[$i].Color }
        $value.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Center
        $value.Format.Font.Size = 18
        $value.Format.Font.Name = 'Segoe UI Semibold'
        $caption = $cell.AddParagraph($tileData[$i].Label)
        $caption.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Center
        $caption.Format.Font.Size = 8
        $caption.Format.Font.Color = $colors.Muted
    }
    if ($stats.EmptyGroups) {
        $note = $section.AddParagraph("$($stats.EmptyGroups) resource group(s) have no resources - shown in amber below.")
        $note.Format.Font.Color = $colors.Amber
        $note.Format.SpaceBefore = & $pt 6
    }

    # --- 2. The hierarchy, down to resource groups ----------------------------------------------------
    $section.AddParagraph('Hierarchy', 'Heading2') | Out-Null
    $levelLabel = @{ Tenant = 'Tenant'; ManagementGroup = 'Management group'; Subscription = 'Subscription'; ResourceGroup = 'Resource group' }
    $tree = if ($security) { & $pdf.NewTable @(7.4, 2.9, 5.2, 2.2, 2.4, 2.0, 1.8, 2.2) } else { & $pdf.NewTable @(9.0, 3.2, 6.2, 2.4, 2.6, 2.3) }
    & $pdf.AddHeaderRow $tree @(@('Name', 'Level', 'Detail', 'Subscriptions', 'Resource groups', 'Resources') + $(if ($security) { @('Score', 'Findings') } else { @() })) @(3, 4, 5, 6, 7)
    foreach ($item in @($items | Where-Object Level -NE 'Resource')) {
        $row = & $pdf.AddBodyRow $tree
        $name = $row.Cells[0].AddParagraph($item.Name)
        $name.Format.LeftIndent = [MigraDoc.DocumentObjectModel.Unit]::FromCentimeter([Math]::Min(6, 0.45 * $item.Depth))
        if ($item.Level -ne 'ResourceGroup') { $name.Format.Font.Name = 'Segoe UI Semibold' }
        $row.Cells[1].AddParagraph($levelLabel[$item.Level]).Format.Font.Color = $colors.Muted
        $what = switch ($item.Level) {
            'Subscription' { (@($item.SubscriptionId, $item.State) | Where-Object { $_ }) -join ' · ' }
            'ResourceGroup' { (@($item.Location, $item.TopTypes) | Where-Object { $_ }) -join ' · ' }
            default { '' }
        }
        $detailText = $row.Cells[2].AddParagraph($what)
        $detailText.Format.Font.Size = 7.5
        $detailText.Format.Font.Color = $colors.Muted
        if ($item.Level -in 'Tenant', 'ManagementGroup') { & $number $row.Cells[3] $item.Subscriptions }
        if ($item.Level -ne 'ResourceGroup') { & $number $row.Cells[4] $item.ResourceGroups }
        & $number $row.Cells[5] $item.Resources
        if ($security) {
            & $addScore $row.Cells[6] $item.SecureScore
            & $addFindings $row.Cells[7] $item
        }
        if ($item.Level -eq 'ResourceGroup' -and $item.Resources -eq 0) {
            foreach ($cell in $row.Cells) { foreach ($paragraph in $cell.Elements) { $paragraph.Format.Font.Color = $colors.Amber } }
        }
    }

    # --- 3. Security (Defender for Cloud) --------------------------------------------------------------
    if ($security) {
        $section.AddPageBreak()
        $section.AddParagraph('Security posture', 'Heading2') | Out-Null
        $note = $section.AddParagraph('Microsoft Defender for Cloud. Secure score: Defender''s own for subscriptions (added up for management groups and the tenant); for resource groups and resources, the share of their assessed recommendations that are healthy. Good 70% or more, Fair 40-69%, Poor under 40%.')
        $note.Format.Font.Size = 8
        $note.Format.Font.Color = $colors.Muted
        $note.Format.SpaceAfter = & $pt 6
        $table = & $pdf.NewTable @(8.0, 7.0, 2.6, 3.0, 1.8, 1.8, 1.9)
        & $pdf.AddHeaderRow $table @('Subscription', 'Management group', 'Secure score', 'Points', 'High', 'Medium', 'Low') @(2, 3, 4, 5, 6)
        foreach ($item in @($items | Where-Object Level -EQ 'Subscription' | Sort-Object -Property @{ Expression = { if ($null -eq $_.SecureScore) { 999 } else { $_.SecureScore } } }, Name)) {
            $row = & $pdf.AddBodyRow $table
            $row.Cells[0].AddParagraph($item.Name) | Out-Null
            $row.Cells[1].AddParagraph($item.ManagementGroup) | Out-Null
            if ($null -ne $item.SecureScore) { & $addScore $row.Cells[2] $item.SecureScore } else { $row.Cells[2].AddParagraph('no data').Format.Font.Color = $colors.Muted }
            if ($item.ScorePoints) {
                $pointsText = $row.Cells[3].AddParagraph($item.ScorePoints)
                $pointsText.Format.Alignment = $right
                $pointsText.Format.Font.Color = $colors.Muted
            }
            foreach ($level in @(@(4, 'High'), @(5, 'Medium'), @(6, 'Low'))) {
                $count = [int]$item.($level[1])
                $p = $row.Cells[$level[0]].AddParagraph("$count")
                $p.Format.Alignment = $right
                if ($count) { $p.Format.Font.Color = $severityColor[$level[1]]; $p.Format.Font.Name = 'Segoe UI Semibold' } else { $p.Format.Font.Color = $colors.Muted }
            }
        }

        $gain = @($Inventory.Controls | Where-Object { $_.PotentialIncrease -gt 0 } | Sort-Object -Property @{ Expression = 'PotentialIncrease'; Descending = $true }, Control)
        if ($gain.Count) {
            $section.AddParagraph('Security controls with the most to gain', 'Heading3') | Out-Null
            $table = & $pdf.NewTable @(10.0, 6.4, 2.4, 3.3, 4.0)
            & $pdf.AddHeaderRow $table @('Control', 'Subscription', 'Score', 'Unhealthy resources', 'Potential increase') @(2, 3, 4)
            foreach ($control in $gain) {
                $row = & $pdf.AddBodyRow $table
                $row.Cells[0].AddParagraph($control.Control) | Out-Null
                $row.Cells[1].AddParagraph($control.SubscriptionName) | Out-Null
                & $addScore $row.Cells[2] $control.Score
                & $number $row.Cells[3] $control.UnhealthyResources
                $p = $row.Cells[4].AddParagraph("+$($control.PotentialIncrease)%")
                $p.Format.Alignment = $right
                $p.Format.Font.Name = 'Segoe UI Semibold'
                $p.Format.Font.Color = $(if ($control.PotentialIncrease -ge 5) { $pdf.Tone.Bad.Solid } elseif ($control.PotentialIncrease -ge 2) { $pdf.Tone.Warn.Solid } else { $blue })
            }
        }

        $findings = @($Inventory.Recommendations | Select-Object -First 2000)
        if ($findings.Count) {
            $section.AddParagraph("Unhealthy recommendations$(if ($findings.Count -lt $Inventory.Recommendations.Count) { " (the first $($findings.Count) of $($Inventory.Recommendations.Count))" })", 'Heading3') | Out-Null
            $table = & $pdf.NewTable @(1.9, 9.6, 4.8, 3.6, 3.8, 2.4)
            & $pdf.AddHeaderRow $table @('Severity', 'Recommendation', 'Resource', 'Resource group', 'Subscription', 'User impact')
            foreach ($finding in $findings) {
                $row = & $pdf.AddBodyRow $table
                $severity = $row.Cells[0].AddParagraph($finding.Severity)
                $severity.Format.Font.Name = 'Segoe UI Semibold'
                if ($severityColor.Contains($finding.Severity)) { $severity.Format.Font.Color = $severityColor[$finding.Severity] }
                $row.Cells[1].AddParagraph($finding.Recommendation) | Out-Null
                $row.Cells[2].AddParagraph($finding.Resource) | Out-Null
                $row.Cells[3].AddParagraph($finding.ResourceGroup) | Out-Null
                $row.Cells[4].AddParagraph($finding.SubscriptionName) | Out-Null
                $impact = $row.Cells[5].AddParagraph($finding.Impact)
                if ($severityColor.Contains($finding.Impact)) { $impact.Format.Font.Color = $severityColor[$finding.Impact] }
            }
        }
    }

    # --- 4. Subscriptions, resource groups, resource types ---------------------------------------------
    $subscriptions = @($items | Where-Object Level -EQ 'Subscription')
    if ($subscriptions.Count) {
        $section.AddPageBreak()
        $section.AddParagraph('Subscriptions', 'Heading2') | Out-Null
        $table = & $pdf.NewTable @(6.0, 6.8, 2.2, 5.0, 2.6, 2.4)
        & $pdf.AddHeaderRow $table @('Subscription', 'Subscription ID', 'State', 'Management group', 'Resource groups', 'Resources') @(4, 5)
        foreach ($item in $subscriptions) {
            $row = & $pdf.AddBodyRow $table
            $row.Cells[0].AddParagraph($item.Name) | Out-Null
            $row.Cells[1].AddParagraph($item.SubscriptionId).Format.Font.Size = 7.5
            $row.Cells[2].AddParagraph($item.State) | Out-Null
            $row.Cells[3].AddParagraph($item.ManagementGroup) | Out-Null
            & $number $row.Cells[4] $item.ResourceGroups
            & $number $row.Cells[5] $item.Resources
        }
    }
    $groups = @($items | Where-Object Level -EQ 'ResourceGroup' | Sort-Object -Property SubscriptionName, Name)
    if ($groups.Count) {
        $section.AddParagraph('Resource groups', 'Heading2') | Out-Null
        $table = & $pdf.NewTable @(6.0, 5.0, 3.0, 2.2, 8.8)
        & $pdf.AddHeaderRow $table @('Resource group', 'Subscription', 'Location', 'Resources', 'Most common types') @(3)
        foreach ($item in $groups) {
            $row = & $pdf.AddBodyRow $table
            $row.Cells[0].AddParagraph($item.Name) | Out-Null
            $row.Cells[1].AddParagraph($item.SubscriptionName) | Out-Null
            $row.Cells[2].AddParagraph($item.Location) | Out-Null
            & $number $row.Cells[3] $item.Resources
            $types = $row.Cells[4].AddParagraph($(if ($item.Resources -eq 0) { 'empty' } else { $item.TopTypes }))
            $types.Format.Font.Size = 7.5
            $types.Format.Font.Color = $(if ($item.Resources -eq 0) { $colors.Amber } else { $colors.Muted })
        }
    }
    $resources = @($items | Where-Object Level -EQ 'Resource')
    if ($resources.Count) {
        $section.AddPageBreak()
        $section.AddParagraph('Resources by type', 'Heading2') | Out-Null
        $table = & $pdf.NewTable @(12.0, 3.0, 3.0)
        & $pdf.AddHeaderRow $table @('Type', 'Resources', 'Share') @(1, 2)
        foreach ($type in @($resources | Group-Object -Property Type | Sort-Object -Property @{ Expression = 'Count'; Descending = $true }, Name)) {
            $row = & $pdf.AddBodyRow $table
            $row.Cells[0].AddParagraph($type.Name) | Out-Null
            & $number $row.Cells[1] $type.Count
            $share = $row.Cells[2].AddParagraph(('{0:N1}%' -f (100 * $type.Count / $resources.Count)))
            $share.Format.Alignment = $right
            $share.Format.Font.Color = $colors.Muted
        }

        # --- 5. Every resource -----------------------------------------------------------------------
        $section.AddPageBreak()
        $shown = @($resources | Sort-Object -Property SubscriptionName, ResourceGroup, Type, Name | Select-Object -First $MaxResources)
        $section.AddParagraph("Resources$(if ($shown.Count -lt $resources.Count) { " (the first $('{0:N0}' -f $shown.Count) of $('{0:N0}' -f $resources.Count); the HTML report and CSV have them all)" })", 'Heading2') | Out-Null
        $table = & $pdf.NewTable @(6.2, 6.4, 4.6, 4.2, 2.4, 2.6)
        & $pdf.AddHeaderRow $table @('Resource', 'Type', 'Resource group', 'Subscription', 'Location', 'SKU')
        foreach ($item in $shown) {
            $row = & $pdf.AddBodyRow $table
            $row.Cells[0].AddParagraph($item.Name) | Out-Null
            $row.Cells[1].AddParagraph($item.Type).Format.Font.Size = 7.5
            $row.Cells[2].AddParagraph($item.ResourceGroup) | Out-Null
            $row.Cells[3].AddParagraph($item.SubscriptionName) | Out-Null
            $row.Cells[4].AddParagraph($item.Location) | Out-Null
            $row.Cells[5].AddParagraph($item.Sku) | Out-Null
        }
    }

    Save-AACPdfDocument -Pdf $pdf -Path $Path
}