Private/Write-AACAksPdf.ps1

function Write-AACAksPdf {
    <#
    .SYNOPSIS
        Writes Invoke-AACAksAssessment's report as a landscape A4 PDF.
    .DESCRIPTION
        1. Summary: the scope, tiles, the clusters with their WAF scores,
           the pillars.
        2. Findings, most severe first.
        3. Azure Policy: by namespace, by policy, by cluster.
        4. A section per cluster (a bookmark each): its settings by area,
           node pools, versions, failed checks and PSRule failures.
        -Path must be a full path; see Save-AACPdfDocument.
    #>

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

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

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

        [System.Collections.IDictionary] $Detail,

        [ValidateRange(10, 5000)]
        [int] $RowLimit = 300
    )

    $stats = $Assessment.Stats
    $policy = $Assessment.Policy
    $pdf = New-AACPdfDocument -Title $Title -Subject "$($stats.Clusters) AKS clusters" -Landscape
    $section = $pdf.Section
    $colors = $pdf.Colors
    $pt = $pdf.Pt
    $right = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Right
    $tone = @{ High = $pdf.Tone.Bad.Solid; Medium = $pdf.Tone.Warn.Solid; Fail = $pdf.Tone.Bad.Solid; Pass = $pdf.Tone.Good.Solid; 'Out of support' = $pdf.Tone.Bad.Solid; 'Out of support (LTS only)' = $pdf.Tone.Bad.Solid; deny = $pdf.Tone.Bad.Solid }
    $table = {
        # Rows of objects, the properties to show (label = property), and the share of the width each gets.
        param([object[]] $Rows, [System.Collections.Specialized.OrderedDictionary] $Columns, [double[]] $Share)
        $labels = @($Columns.Keys)
        $total = ($Share | Measure-Object -Sum).Sum
        $t = & $pdf.NewTable @($Share | ForEach-Object { $pdf.PageWidth * $_ / $total })
        $numeric = @(for ($i = 0; $i -lt $labels.Count; $i++) { $sample = @($Rows | ForEach-Object { $_.($Columns[$labels[$i]]) } | Where-Object { $null -ne $_ -and "$_" -ne '' } | Select-Object -First 1); if ($sample.Count -and $sample[0] -is [ValueType] -and $sample[0] -isnot [bool]) { $i } })
        & $pdf.AddHeaderRow $t $labels $numeric
        foreach ($item in @($Rows) | Select-Object -First $RowLimit) {
            $row = & $pdf.AddBodyRow $t
            for ($i = 0; $i -lt $labels.Count; $i++) {
                $value = $item.($Columns[$labels[$i]])
                $text = if ($null -eq $value) { '' } elseif ($value -is [double]) { '{0:N1}' -f $value } else { [string]$value }
                if ($text.Length -gt 220) { $text = $text.Substring(0, 217) + '...' }
                $p = $row.Cells[$i].AddParagraph($text)
                $p.Format.Font.Size = 7
                if ($i -in $numeric) { $p.Format.Alignment = $right }
                if ($tone.Contains($text)) { $p.Format.Font.Color = $tone[$text]; $p.Format.Font.Bold = $true }
            }
        }
        if (@($Rows).Count -gt $RowLimit) { $more = $section.AddParagraph("The first $RowLimit of $(@($Rows).Count) rows - the HTML report and CSV files have them all."); $more.Format.Font.Size = 7; $more.Format.Font.Color = $colors.Muted }
    }
    $ordered = { param([string[]] $Pairs) $o = [ordered]@{}; for ($i = 0; $i -lt $Pairs.Count; $i += 2) { $o[$Pairs[$i]] = $Pairs[$i + 1] }; $o }

    # --- 1. Summary ------------------------------------------------------------------------------------------
    & $pdf.AddTitle "AKS cluster assessment · 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] } }
    foreach ($line in @($Assessment['Notices'])) { $facts['Note'] = $(if ($facts.Contains('Note')) { "$($facts['Note']) $line" } else { $line }) }
    $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 = @(
        @{ Value = '{0:N0}' -f $stats.Clusters; Label = 'clusters' }
        @{ Value = '{0:N0}' -f $stats.Nodes; Label = 'nodes' }
        @{ Value = $(if ($null -ne $stats.WafScore) { "$($stats.WafScore)%" } else { '-' }); Label = 'WAF checks passed' }
        @{ Value = '{0:N0}' -f $stats.High; Label = 'high findings'; Color = $(if ($stats.High) { $pdf.Tone.Bad.Solid }) }
        @{ Value = '{0:N0}' -f $stats.Medium; Label = 'medium findings'; Color = $(if ($stats.Medium) { $pdf.Tone.Warn.Solid }) }
        @{ Value = '{0:N0}' -f $stats.OutOfSupport; Label = 'out of support'; Color = $(if ($stats.OutOfSupport) { $pdf.Tone.Bad.Solid }) }
        @{ Value = $(if ($policy) { '{0:N0}' -f $policy.Stats.Violations } else { '-' }); Label = 'policy violations' }
    )
    $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([string]$tileData[$i].Value); $value.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Center; $value.Format.Font.Size = 16; $value.Format.Font.Name = 'Segoe UI Semibold'
        if ($tileData[$i].Contains('Color') -and $tileData[$i].Color) { $value.Format.Font.Color = $tileData[$i].Color }
        $caption = $cell.AddParagraph($tileData[$i].Label); $caption.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Center; $caption.Format.Font.Size = 8; $caption.Format.Font.Color = $colors.Muted
    }
    $section.AddParagraph('Clusters', 'Heading2') | Out-Null
    & $table $Assessment.Clusters (& $ordered 'Cluster', 'Name', 'Version', 'Version', 'Support', 'Support', 'Tier', 'Tier', 'Nodes', 'Nodes', 'Network', 'Network', 'API server', 'ApiServer', 'WAF %', 'WafScore', 'High', 'High', 'Medium', 'Medium', 'Policy', 'PolicyViolations') @(3, 1.4, 1.8, 1.1, 0.9, 2, 2, 1, 0.8, 0.8, 1)
    $section.AddParagraph('Well-Architected pillars', 'Heading2') | Out-Null
    & $table $Assessment.Pillars (& $ordered 'Pillar', 'Pillar', 'Score %', 'Score', 'Checks', 'Checks', 'Passed', 'Passed', 'Failed', 'Failed', 'PSRule failed', 'PSRuleFailed', 'Findings', 'Findings') @(3, 1, 1, 1, 1, 1, 1)

    # --- 2. Findings ---------------------------------------------------------------------------------------------
    if (@($Assessment.Findings).Count) {
        $section.AddPageBreak()
        $section.AddParagraph('Findings', 'Heading1') | Out-Null
        & $table $Assessment.Findings (& $ordered 'Severity', 'Severity', 'Pillar', 'Pillar', 'Source', 'Source', 'Cluster', 'Cluster', 'Finding', 'Check', 'What was found', 'Detail', 'What to do', 'Recommendation') @(1, 1.4, 1.2, 1.6, 2.6, 4, 3.4)
    }

    # --- 3. Azure Policy ---------------------------------------------------------------------------------------------
    if ($policy -and ($policy.Stats.Violations -or @($policy.ClusterStates).Count)) {
        $section.AddPageBreak()
        $section.AddParagraph('Azure Policy for Kubernetes', 'Heading1') | Out-Null
        if (@($policy.ByNamespace).Count) { $section.AddParagraph('By namespace', 'Heading2') | Out-Null; & $table $policy.ByNamespace (& $ordered 'Namespace', 'Namespace', 'Violations', 'Violations', 'Workloads', 'Workloads', 'Policies', 'Policies', 'Under Deny', 'Deny', 'Clusters', 'Clusters', 'Policies violated', 'PolicyNames') @(2, 1, 1, 1, 1, 2, 5) }
        if (@($policy.ByPolicy).Count) { $section.AddParagraph('By policy', 'Heading2') | Out-Null; & $table $policy.ByPolicy (& $ordered 'Policy', 'Policy', 'Effect', 'Effect', 'Violations', 'Violations', 'Clusters', 'Clusters', 'Namespaces', 'Namespaces', 'Workloads', 'Workloads', 'Assignment', 'Assignment') @(5, 1.4, 1, 1, 1, 1, 2.5) }
        if (@($policy.ByWorkload).Count) { $section.AddParagraph('By workload', 'Heading2') | Out-Null; & $table $policy.ByWorkload (& $ordered 'Cluster', 'Cluster', 'Namespace', 'Namespace', 'Kind', 'WorkloadKind', 'Workload', 'Workload', 'Violations', 'Violations', 'Policies', 'Policies', 'Policies violated', 'PolicyNames') @(1.6, 1.6, 1.2, 2, 1, 1, 5) }
        $nonCompliant = @($policy.ClusterStates | Where-Object State -EQ 'NonCompliant')
        if ($nonCompliant.Count) { $section.AddParagraph('Non-compliant policies on the clusters', 'Heading2') | Out-Null; & $table $nonCompliant (& $ordered 'Cluster', 'Cluster', 'Policy', 'Policy', 'Scope', 'Scope', 'Effect', 'Effect', 'Assignment', 'Assignment') @(2, 5, 1, 1.4, 2.5) }
    }

    # --- 4. Each cluster -------------------------------------------------------------------------------------------
    foreach ($cluster in $Assessment.Clusters) {
        $id = $cluster.ResourceId
        $section.AddPageBreak()
        $section.AddParagraph("$($cluster.Name) (Kubernetes $($cluster.Version), $($cluster.Support))", 'Heading1') | Out-Null
        $muted = $section.AddParagraph($id); $muted.Format.Font.Size = 7; $muted.Format.Font.Color = $colors.Muted
        $section.AddParagraph('Settings', 'Heading2') | Out-Null
        & $table @($Assessment.Settings | Where-Object ClusterId -EQ $id) (& $ordered 'Area', 'Area', 'Setting', 'Setting', 'Value', 'Value') @(2, 3, 6)
        $section.AddParagraph('Node pools', 'Heading2') | Out-Null
        & $table @($Assessment.NodePools | Where-Object ClusterId -EQ $id) (& $ordered 'Pool', 'Pool', 'Mode', 'Mode', 'Size', 'VmSize', 'OS', 'OsSku', 'Nodes', 'Nodes', 'Autoscale', 'Autoscale', 'Max', 'Max', 'Zones', 'Zones', 'Version', 'Version', 'Image age', 'NodeImageAge', 'OS disk', 'OsDiskType', 'Subnet', 'Subnet') @(1.4, 1, 2, 1.2, 0.8, 1, 0.7, 1, 1, 1, 1.1, 2.4)
        $section.AddParagraph('Versions and upgrades', 'Heading2') | Out-Null
        & $table @($Assessment.Upgrades | Where-Object ClusterId -EQ $id) (& $ordered 'Component', 'Component', 'Version', 'Current', 'Support', 'Support', 'Available', 'Available', 'Channel', 'Channel', 'Node image', 'NodeImage', 'Newest image', 'LatestNodeImage') @(2, 1.2, 1.6, 2, 1.4, 3, 3)
        $failed = @($Assessment.Checks | Where-Object { $_.ClusterId -eq $id -and $_.Status -eq 'Fail' })
        if ($failed.Count) { $section.AddParagraph('Failed Well-Architected checks', 'Heading2') | Out-Null; & $table $failed (& $ordered 'Pillar', 'Pillar', 'Check', 'Check', 'Severity', 'Severity', 'What was found', 'Detail', 'What to do', 'Recommendation') @(1.6, 2.6, 1, 4, 4) }
        $rules = @($Assessment.PSRule | Where-Object { $_.ResourceName -eq $cluster.Name -and $_.Outcome -ne 'Pass' })
        if ($rules.Count) { $section.AddParagraph('PSRule for Azure: failed rules', 'Heading2') | Out-Null; & $table $rules (& $ordered 'Rule', 'RuleName', 'Pillar', 'Pillar', 'Severity', 'Severity', 'Title', 'Title', 'Recommendation', 'Recommendation') @(2.6, 1.6, 1.2, 3.4, 4) }
    }
    Save-AACPdfDocument -Pdf $pdf -Path $Path
}