Private/Write-AACAdvisorRecommendationPdf.ps1

function Write-AACAdvisorRecommendationPdf {
    <#
    .SYNOPSIS
        Writes AAC.AdvisorRecommendation objects (from
        Get-AACAdvisorRecommendation) as a landscape A4 PDF report.
    .DESCRIPTION
        Layout:
          1. Summary: title, where the recommendations came from, tiles with
             the totals (recommendations, high / medium / low impact,
             resources affected, estimated monthly savings), a category by
             impact table and one row per subscription.
          2. Recommendations by type: every distinct recommendation once,
             with its category, impact, how many resources and subscriptions
             it affects and its estimated savings - the consolidated view.
          3. One part per category (each starting on a new page): each
             recommendation as a heading with its impact (High in red, Medium
             in amber, Low in grey) and solution, then a table of the
             resources it affects, with savings or retirement date.
 
        -Path must be a full path; see Save-AACPdfDocument.
    #>

    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]] $Recommendation,

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

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

        [System.Collections.IDictionary] $Detail
    )

    $categories = @('Cost', 'Security', 'Reliability', 'OperationalExcellence', 'Performance')
    $categoryLabel = @{ Cost = 'Cost'; Security = 'Security'; Reliability = 'Reliability'; OperationalExcellence = 'Operational excellence'; Performance = 'Performance' }
    $impacts = @('High', 'Medium', 'Low')

    $pdf = New-AACPdfDocument -Title $Title -Subject "$($Recommendation.Count) Azure Advisor recommendations" -Landscape
    $section = $pdf.Section
    $colors = $pdf.Colors
    $pt = $pdf.Pt
    $right = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Right
    $impactTone = @{ High = $pdf.Tone.Bad; Medium = $pdf.Tone.Warn; Low = $pdf.Tone.Neutral }
    $toneOf = { param([string] $Impact) $t = $impactTone[$Impact]; if ($t) { $t } else { $pdf.Tone.Neutral } }

    # "USD 1,234.50" per currency present, e.g. "USD 1,234.50 + EUR 20.00".
    # Returns '' when none of the items has a savings estimate.
    $savings = {
        param($Items, [string] $Property = 'MonthlySavings')
        (@($Items | Where-Object { $null -ne $_.$Property }) |
            Group-Object -Property SavingsCurrency |
            Sort-Object -Property Name |
            ForEach-Object { ('{0} {1:N2}' -f $(if ($_.Name) { $_.Name } else { '?' }), ($_.Group | Measure-Object -Property $Property -Sum).Sum).Trim() }) -join ' + '
    }
    $distinctResources = { param($Items) @($Items | ForEach-Object { if ($_.ResourceId) { $_.ResourceId.ToLowerInvariant() } else { $_.ResourceName } } | Select-Object -Unique).Count }
    $subscriptionLabel = { param($Item) if ($Item.SubscriptionName) { $Item.SubscriptionName } else { $Item.SubscriptionId } }
    $addNumber = {
        param($Cell, $Value)
        $number = $Cell.AddParagraph(('{0:N0}' -f $Value))
        $number.Format.Alignment = $right
        if ($Value -eq 0) { $number.Format.Font.Color = $colors.Muted }
    }
    # A paragraph with the text, or a grey '-' when there is none.
    $addText = {
        param($Cell, [string] $Text)
        $paragraph = $Cell.AddParagraph($(if ($Text) { $Text } else { '-' }))
        if (-not $Text) { $paragraph.Format.Font.Color = $colors.Muted }
        $paragraph
    }
    $addImpactBadge = {
        param($Paragraph, [string] $Impact)
        $badge = $Paragraph.AddFormattedText(" $($Impact.ToUpperInvariant()) ")
        $badge.Size = 7.5
        $badge.Bold = $true
        $badge.Color = (& $toneOf $Impact).Text
        $badge.Font.Name = 'Segoe UI'
    }

    # --- 1. Summary ---------------------------------------------------------------
    $subscriptionCount = @($Recommendation | Select-Object -ExpandProperty SubscriptionId -Unique).Count
    $resourceCount = & $distinctResources $Recommendation
    & $pdf.AddTitle "$('{0:N0}' -f $Recommendation.Count) recommendations for $('{0:N0}' -f $resourceCount) resources in $subscriptionCount subscription$(if ($subscriptionCount -ne 1) { 's' }) · generated $($pdf.Generated.ToString('dddd d MMMM yyyy, HH:mm'))"

    $facts = [ordered]@{}
    $session = $script:AACSession
    if ($session) {
        $facts['Azure account'] = [string]$session.Account
        $facts['Tenant'] = [string]$session.TenantId
    }
    if ($Detail) {
        foreach ($key in $Detail.Keys) { $facts[[string]$key] = [string]$Detail[$key] }
    }
    $monthly = & $savings $Recommendation 'MonthlySavings'
    $annual = & $savings $Recommendation 'AnnualSavings'
    if ($monthly -or $annual) {
        $facts['Estimated savings'] = "$(if ($monthly) { "$monthly per month" })$(if ($monthly -and $annual) { ' · ' })$(if ($annual) { "$annual per year" }). Advisor's own estimates; recommendations can overlap (e.g. a reservation and a right-size for the same VM), so totals are an upper bound."
    }
    $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
    $firstCurrency = @($Recommendation | Where-Object { $null -ne $_.MonthlySavings } | Group-Object SavingsCurrency | Sort-Object Count -Descending | Select-Object -First 1)
    $tileData = @(
        @{ Value = ('{0:N0}' -f $Recommendation.Count); Label = 'recommendations' }
        @{ Value = ('{0:N0}' -f @($Recommendation | Where-Object Impact -eq 'High').Count); Label = 'high impact'; Tone = $pdf.Tone.Bad }
        @{ Value = ('{0:N0}' -f @($Recommendation | Where-Object Impact -eq 'Medium').Count); Label = 'medium impact'; Tone = $pdf.Tone.Warn }
        @{ Value = ('{0:N0}' -f @($Recommendation | Where-Object Impact -eq 'Low').Count); Label = 'low impact' }
        @{ Value = ('{0:N0}' -f $resourceCount); Label = 'resources affected' }
        @{
            Value = if ($firstCurrency) { '{0:N0}' -f ($firstCurrency[0].Group | Measure-Object -Property MonthlySavings -Sum).Sum } else { '-' }
            Label = if ($firstCurrency) { "$($firstCurrency[0].Name) / month est. savings".Trim() } else { 'no savings estimates' }
            Tone  = $pdf.Tone.Good
        }
    )
    $tileWidth = $pdf.PageWidth / $tileData.Count
    $tiles = & $pdf.NewTable @(1..$tileData.Count | ForEach-Object { $tileWidth })
    $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
        $number = $cell.AddParagraph($tileData[$i].Value)
        $number.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Center
        $number.Format.Font.Size = 18
        $number.Format.Font.Name = 'Segoe UI Semibold'
        if ($tileData[$i]['Tone'] -and $tileData[$i].Value -notin '0', '-') { $number.Format.Font.Color = $tileData[$i]['Tone'].Text }
        $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 ($Recommendation.Count -eq 0) {
        $none = $section.AddParagraph('No Azure Advisor recommendations were found.')
        $none.Format.Font.Color = $colors.Muted
        $none.Format.SpaceBefore = & $pt 12
        return Save-AACPdfDocument -Pdf $pdf -Path $Path
    }

    $section.AddParagraph('By category', 'Heading2') | Out-Null
    $categoryTable = & $pdf.NewTable @(6.1, 2.4, 2.4, 2.4, 2.4, 3.2, 7.2)
    & $pdf.AddHeaderRow $categoryTable @('Category', 'High', 'Medium', 'Low', 'Total', 'Resources', 'Estimated savings / month') @(1, 2, 3, 4, 5, 6)
    foreach ($categoryName in $categories) {
        $items = @($Recommendation | Where-Object Category -eq $categoryName)
        $row = & $pdf.AddBodyRow $categoryTable
        $row.Cells[0].AddParagraph($categoryLabel[$categoryName]).Format.Font.Name = 'Segoe UI Semibold'
        foreach ($i in 0..2) { & $addNumber $row.Cells[$i + 1] @($items | Where-Object Impact -eq $impacts[$i]).Count }
        & $addNumber $row.Cells[4] $items.Count
        & $addNumber $row.Cells[5] (& $distinctResources $items)
        $saving = & $savings $items
        (& $addText $row.Cells[6] $saving).Format.Alignment = $right
    }

    $section.AddParagraph('By subscription', 'Heading2') | Out-Null
    $subscriptionTable = & $pdf.NewTable @(6.4, 2.0, 2.0, 2.1, 2.5, 2.4, 1.6, 1.7, 5.4)
    & $pdf.AddHeaderRow $subscriptionTable @('Subscription', 'Cost', 'Security', 'Reliability', 'Op. excellence', 'Performance', 'High', 'Total', 'Est. savings / month') @(1, 2, 3, 4, 5, 6, 7, 8)
    foreach ($subscription in @($Recommendation | Group-Object -Property SubscriptionId | Sort-Object { & $subscriptionLabel $_.Group[0] })) {
        $row = & $pdf.AddBodyRow $subscriptionTable
        $name = $row.Cells[0].AddParagraph((& $subscriptionLabel $subscription.Group[0]))
        $name.Format.Font.Name = 'Segoe UI Semibold'
        for ($i = 0; $i -lt $categories.Count; $i++) {
            & $addNumber $row.Cells[$i + 1] @($subscription.Group | Where-Object Category -eq $categories[$i]).Count
        }
        & $addNumber $row.Cells[6] @($subscription.Group | Where-Object Impact -eq 'High').Count
        & $addNumber $row.Cells[7] $subscription.Count
        $saving = & $savings $subscription.Group
        (& $addText $row.Cells[8] $saving).Format.Alignment = $right
    }

    # One group per distinct recommendation (same type, impact and wording),
    # in category then impact order - the objects arrive sorted that way.
    $typeKey = { "$($_.Category)|$($_.Impact)|$(if ($_.RecommendationTypeId) { $_.RecommendationTypeId } else { $_.Problem })|$($_.Problem)" }
    $types = @($Recommendation | Group-Object -Property $typeKey | Sort-Object -Property @(
            @{ Expression = { $categories.IndexOf($_.Group[0].Category) } }
            @{ Expression = { $impacts.IndexOf($_.Group[0].Impact) } }
            @{ Expression = { $_.Count }; Descending = $true }
            @{ Expression = { $_.Group[0].Problem } }
        ))

    # --- 2. Recommendations by type -----------------------------------------------------
    $section.AddPageBreak()
    $section.AddParagraph('Recommendations by type', 'Heading1') | Out-Null
    $intro = $section.AddParagraph("$($types.Count) distinct recommendation$(if ($types.Count -ne 1) { 's' }), each listed once with the number of resources it applies to. Resource-level detail follows, category by category.")
    $intro.Format.Font.Color = $colors.Muted
    $intro.Format.SpaceAfter = & $pt 6
    $typeTable = & $pdf.NewTable @(11.4, 4.1, 1.9, 2.0, 2.2, 4.5)
    & $pdf.AddHeaderRow $typeTable @('Recommendation', 'Category', 'Impact', 'Resources', 'Subscriptions', 'Est. savings / month') @(3, 4, 5)
    foreach ($type in $types) {
        $first = $type.Group[0]
        $row = & $pdf.AddBodyRow $typeTable
        $row.Cells[0].Borders.Left.Width = 2.5
        $row.Cells[0].Borders.Left.Color = (& $toneOf $first.Impact).Solid
        $row.Cells[0].AddParagraph($(if ($first.Problem) { $first.Problem } else { '(no description)' })) | Out-Null
        $row.Cells[1].AddParagraph($categoryLabel[$first.Category] ?? $first.Category) | Out-Null
        & $addImpactBadge $row.Cells[2].AddParagraph() $first.Impact
        & $addNumber $row.Cells[3] (& $distinctResources $type.Group)
        & $addNumber $row.Cells[4] @($type.Group | Select-Object -ExpandProperty SubscriptionId -Unique).Count
        $saving = & $savings $type.Group
        (& $addText $row.Cells[5] $saving).Format.Alignment = $right
    }

    # --- 3. Every affected resource, category by category ------------------------------
    foreach ($categoryName in $categories) {
        $categoryTypes = @($types | Where-Object { $_.Group[0].Category -eq $categoryName })
        if ($categoryTypes.Count -eq 0) { continue }
        $items = @($categoryTypes | ForEach-Object { $_.Group })

        $section.AddPageBreak()
        $section.AddParagraph($categoryLabel[$categoryName], 'Heading1') | Out-Null
        $about = $section.AddParagraph(@(
                "$($items.Count) recommendation(s) for $(& $distinctResources $items) resource(s)"
                "$(@($items | Where-Object Impact -eq 'High').Count) high · $(@($items | Where-Object Impact -eq 'Medium').Count) medium · $(@($items | Where-Object Impact -eq 'Low').Count) low impact"
                $(if (($saving = & $savings $items)) { "est. $saving per month" })
            ) -join ' · ')
        $about.Format.Font.Color = $colors.Muted
        $about.Format.SpaceAfter = & $pt 4

        foreach ($type in $categoryTypes) {
            $first = $type.Group[0]
            $heading = $section.AddParagraph('', 'Heading3')
            $heading.AddText($(if ($first.Problem) { $first.Problem } else { '(no description)' })) | Out-Null
            $heading.AddText(' ') | Out-Null
            & $addImpactBadge $heading $first.Impact
            if ($first.Solution -and $first.Solution -ne $first.Problem) {
                $solution = $section.AddParagraph($first.Solution)
                $solution.Format.Font.Color = $colors.Muted
                $solution.Format.SpaceAfter = & $pt 3
                $solution.Format.KeepWithNext = $true
            }

            $table = & $pdf.NewTable @(6.4, 5.4, 7.0, 4.6, 2.7)
            & $pdf.AddHeaderRow $table @('Resource', 'Type', 'Subscription · resource group', 'Savings / retirement', 'Updated')
            foreach ($item in $type.Group) {
                $row = & $pdf.AddBodyRow $table
                # A coloured bar on the left edge carries the impact.
                $row.Cells[0].Borders.Left.Width = 2.5
                $row.Cells[0].Borders.Left.Color = (& $toneOf $item.Impact).Solid
                $name = & $addText $row.Cells[0] $item.ResourceName
                $name.Format.Font.Name = 'Segoe UI Semibold'
                if ($item.Status -ne 'Active') {
                    $status = $row.Cells[0].AddParagraph("$($item.Status)$(if ($item.SuppressionExpires) { " until $($item.SuppressionExpires.ToString('d MMM yyyy'))" })")
                    $status.Format.Font.Size = 7
                    $status.Format.Font.Color = $colors.Amber
                }
                (& $addText $row.Cells[1] $item.ResourceType).Format.Font.Size = 8
                $row.Cells[2].AddParagraph("$(& $subscriptionLabel $item)$(if ($item.ResourceGroup) { " · $($item.ResourceGroup)" })") | Out-Null

                $notes = [System.Collections.Generic.List[string]]::new()
                if ($null -ne $item.MonthlySavings) { $notes.Add(('{0} {1:N2} / month' -f $item.SavingsCurrency, $item.MonthlySavings).Trim()) }
                elseif ($null -ne $item.AnnualSavings) { $notes.Add(('{0} {1:N2} / year' -f $item.SavingsCurrency, $item.AnnualSavings).Trim()) }
                if ($item.RetirementDate) { $notes.Add("Retires $($item.RetirementDate.ToString('d MMM yyyy'))") }
                if ($item.RetiringFeature) { $notes.Add($item.RetiringFeature) }
                & $addText $row.Cells[3] ($notes -join ' · ') | Out-Null
                & $addText $row.Cells[4] $(if ($item.LastUpdated) { $item.LastUpdated.ToString('d MMM yyyy') }) | Out-Null
            }
        }
    }

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