src/report/renderers/Export-Pptx.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Auto-assemble an executive PowerPoint deck from scored findings via the
    OpenXML SDK (DocumentFormat.OpenXml) — no Python, no PowerPoint install.
 
.DESCRIPTION
    Renders the Tier-3 executive deck directly from the scored Findings object
    (the output of Get-Score: GeneratedOn/Frameworks/Areas/Gaps/Manual/Errors/
    Findings). Every element of the .pptx OPC package (theme, slide master,
    slide layouts, slides, tables) is constructed programmatically with the
    OpenXML SDK — this is the accepted design in
    docs/design/decisions/pptx-renderer.md (AB#5044).
 
    Slide inventory:
      1. Title — deck branding, generated date, scope/mgmt-group if known
      2. Executive Summary — CAF/WAF score cards + rollup counts
      3..N Area Breakdown — Framework/Area/Score/Pass/Partial/Fail/Manual table,
                             paginated at 10 rows/slide
      N+1.. Prioritized Gaps — Severity/Area/Gap table, sorted worst-first,
                             paginated at 10 rows/slide, capped at top 15
      .. Manual Review — outstanding manual-review worklist (capped at 12 rows)
      .. Next Steps — closing recommendations
 
    TEMPLATE-AS-CODE EXTENSION POINT: the theme/master/layout/chrome built by
    New-ScoutDeckShell and Add-ScoutSlideChrome below is the programmatic
    stand-in for a designer-authored deck.pptx.template (see the decision
    record's amendment). A future iteration can replace this function pair
    with code that opens a designer-authored .pptx template and clones its
    slide master/layout parts instead of building them from scratch here —
    every other function in this file (the slide-content builders) is
    unaffected by that swap, since they only depend on the shell exposing a
    content SlideLayoutPart + title SlideLayoutPart relationship ids.
 
.NOTES
    Tracks ADO Story AB#5048, Epic AB#5044. Carries forward the AB#5089
    defensive severity guard from the retired build_deck.py (null/unknown
    severity sorts LAST and never throws — see Get-ScoutSeverityRank/-Label).
#>


#region Assembly acquisition (first-use NuGet acquire + cache, no committed binaries)

# Pinned so every run resolves the exact same OpenXML SDK build; bump deliberately.
$Script:ScoutOpenXmlVersion = '3.0.2'

function Import-ScoutOpenXmlAssembly {
    [CmdletBinding()]
    param()

    # Idempotent within a process — a prior Export-Pptx call (or Pester run)
    # in the same pwsh session already loaded these.
    $loaded = [System.AppDomain]::CurrentDomain.GetAssemblies() |
        Where-Object { $_.GetName().Name -eq 'DocumentFormat.OpenXml' }
    if ($loaded) { return }

    $repoRoot = Split-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -Parent
    $cacheDir = Join-Path $repoRoot 'output' '.tools' 'openxml' $Script:ScoutOpenXmlVersion
    $requiredDlls = @('DocumentFormat.OpenXml.Framework.dll', 'System.IO.Packaging.dll', 'DocumentFormat.OpenXml.dll')

    $haveAll = -not ($requiredDlls | Where-Object { -not (Test-Path (Join-Path $cacheDir $_)) })
    if (-not $haveAll) {
        if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
            throw "Export-Pptx: DocumentFormat.OpenXml $($Script:ScoutOpenXmlVersion) is not cached at '$cacheDir' and the 'dotnet' SDK is not on PATH to acquire it. Install the .NET SDK (or pre-seed the cache folder with the three DLLs above) and retry."
        }

        New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
        $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "AzScoutOpenXmlAcquire_$([guid]::NewGuid().ToString('N'))"
        New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
        $tempProj = Join-Path $tempDir 'acquire.csproj'
        @"
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>disable</Nullable>
    <ImplicitUsings>disable</ImplicitUsings>
    <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
    <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
    <EnableDefaultCompileItems>false</EnableDefaultCompileItems>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="DocumentFormat.OpenXml" Version="$($Script:ScoutOpenXmlVersion)" />
  </ItemGroup>
</Project>
"@
 | Out-File -FilePath $tempProj -Encoding utf8

        Write-Host "[Export-Pptx] Acquiring DocumentFormat.OpenXml $($Script:ScoutOpenXmlVersion) via dotnet/NuGet (first use — cached under $cacheDir for subsequent runs)..." -ForegroundColor Cyan
        $buildOutput = & dotnet build $tempProj -c Release -o $cacheDir --nologo 2>&1
        $exitCode = $LASTEXITCODE
        Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
        Get-ChildItem $cacheDir -Filter 'acquire.*' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue

        if ($exitCode -ne 0 -or -not (Test-Path (Join-Path $cacheDir 'DocumentFormat.OpenXml.dll'))) {
            throw "Export-Pptx: could not acquire DocumentFormat.OpenXml $($Script:ScoutOpenXmlVersion) (offline, and nothing cached at '$cacheDir'?). dotnet build exit code $exitCode.`n$($buildOutput -join "`n")"
        }
        Write-Host "[Export-Pptx] DocumentFormat.OpenXml $($Script:ScoutOpenXmlVersion) cached at $cacheDir" -ForegroundColor Green
    }

    foreach ($dll in $requiredDlls) {
        Add-Type -Path (Join-Path $cacheDir $dll) -ErrorAction Stop
    }
}

#endregion

#region Low-level OpenXML element/part helpers
#
# IMPORTANT: every OpenXmlElement in this SDK implements IEnumerable<OpenXmlElement>
# over its own children. PowerShell's function-output pipeline auto-enumerates any
# IEnumerable it sees, so a bare "return $element" silently flattens the element
# into its (often zero) children instead of returning the element itself. Every
# helper below returns via the unary comma operator (`return ,$x`) to suppress
# that unrolling — do not remove the commas.
#
# For the same reason, never build an array of OpenXmlElement objects with `@(...)`
# or a bare comma list — use New-ScoutList + .Add(), which is a plain .NET method
# call and is not subject to pipeline enumeration.

$Script:PresNs = 'DocumentFormat.OpenXml.Presentation'
$Script:DrawNs = 'DocumentFormat.OpenXml.Drawing'

function New-ScoutEl {
    param([Parameter(Mandatory)][string]$TypeName)
    $o = New-Object -TypeName $TypeName
    return , $o
}

function New-ScoutList {
    return , ([System.Collections.Generic.List[object]]::new())
}

function New-ScoutPart {
    # Wraps the generic OpenXmlPartContainer.AddNewPart<T>(string id) method,
    # which PowerShell cannot call with normal method-call syntax.
    param($Parent, [Parameter(Mandatory)][type]$PartType, [Parameter(Mandatory)][string]$Id)
    $mi = $Parent.GetType().GetMethods() | Where-Object {
        $_.Name -eq 'AddNewPart' -and $_.IsGenericMethodDefinition -and
        $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType -eq [string]
    } | Select-Object -First 1
    if (-not $mi) { throw "Export-Pptx: could not locate generic AddNewPart<T> on $($Parent.GetType().FullName)." }
    $gmi = $mi.MakeGenericMethod($PartType)
    $part = $gmi.Invoke($Parent, @($Id))
    return , $part
}

function ScoutEmu {
    # EMU (English Metric Units), 914400 per inch. Int64 — used for a:off/a:ext,
    # table row heights and grid column widths (ST_PositiveCoordinate).
    param([double]$Inches)
    return [int64][math]::Round($Inches * 914400)
}

function ScoutEmu32 {
    # Int32 EMU — required specifically by p:sldSz / p:notesSz (ST_PositiveCoordinate32).
    param([double]$Inches)
    return [int32][math]::Round($Inches * 914400)
}

#endregion

#region Fill / text / table-cell helpers

function New-ScoutFill {
    param([Parameter(Mandatory)][string]$Hex)
    $fill = New-ScoutEl "$Script:DrawNs.SolidFill"
    $rgb = New-ScoutEl "$Script:DrawNs.RgbColorModelHex"
    $rgb.Val = $Hex
    $fill.Append($rgb)
    return , $fill
}

function New-ScoutPhColor {
    # Placeholder scheme-color reference used inside a theme's FormatScheme
    # boilerplate (fill/line/effect/background style lists).
    $sc = New-ScoutEl "$Script:DrawNs.SchemeColor"
    $sc.Val = [DocumentFormat.OpenXml.Drawing.SchemeColorValues]::PhColor
    return , $sc
}

function New-ScoutRun {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [double]$SizePt = 12,
        [string]$Hex = '1A1A1A',
        [bool]$Bold = $false,
        [string]$Font = 'Segoe UI'
    )
    $run = New-ScoutEl "$Script:DrawNs.Run"
    $rPr = New-ScoutEl "$Script:DrawNs.RunProperties"
    $rPr.Language = 'en-US'
    $rPr.FontSize = [int]([math]::Round($SizePt * 100))
    $rPr.Bold = $Bold
    $rPr.Append((New-ScoutFill $Hex))
    $latin = New-ScoutEl "$Script:DrawNs.LatinFont"
    $latin.Typeface = $Font
    $rPr.Append($latin)
    $run.Append($rPr)
    $t = New-ScoutEl "$Script:DrawNs.Text"
    $t.Text = $Text
    $run.Append($t)
    return , $run
}

function New-ScoutPara {
    # $Runs: a List[object] of Drawing.Run elements (built with New-ScoutList/.Add()).
    param($Runs, [string]$Align = $null, [double]$SpaceBeforePt = 0)
    $p = New-ScoutEl "$Script:DrawNs.Paragraph"
    if ($Align -or $SpaceBeforePt -gt 0) {
        $pPr = New-ScoutEl "$Script:DrawNs.ParagraphProperties"
        if ($Align) {
            $pPr.Alignment = switch ($Align) {
                'ctr' { [DocumentFormat.OpenXml.Drawing.TextAlignmentTypeValues]::Center }
                'r' { [DocumentFormat.OpenXml.Drawing.TextAlignmentTypeValues]::Right }
                default { [DocumentFormat.OpenXml.Drawing.TextAlignmentTypeValues]::Left }
            }
        }
        if ($SpaceBeforePt -gt 0) {
            $spcBef = New-ScoutEl "$Script:DrawNs.SpaceBefore"
            $spcPts = New-ScoutEl "$Script:DrawNs.SpacingPoints"
            $spcPts.Val = [int]([math]::Round($SpaceBeforePt * 100))
            $spcBef.Append($spcPts)
            $pPr.Append($spcBef)
        }
        $p.Append($pPr)
    }
    foreach ($r in $Runs) { $p.Append($r) }
    return , $p
}

function New-ScoutShapeTextBody {
    # $Paragraphs: a List[object] of Drawing.Paragraph — wrapped for a p:sp (shape).
    param($Paragraphs, [string]$Anchor = 't', [bool]$Wrap = $true)
    $tb = New-ScoutEl "$Script:PresNs.TextBody"
    $bp = New-ScoutEl "$Script:DrawNs.BodyProperties"
    $bp.Wrap = if ($Wrap) { [DocumentFormat.OpenXml.Drawing.TextWrappingValues]::Square } else { [DocumentFormat.OpenXml.Drawing.TextWrappingValues]::None }
    $bp.Anchor = switch ($Anchor) {
        'ctr' { [DocumentFormat.OpenXml.Drawing.TextAnchoringTypeValues]::Center }
        'b' { [DocumentFormat.OpenXml.Drawing.TextAnchoringTypeValues]::Bottom }
        default { [DocumentFormat.OpenXml.Drawing.TextAnchoringTypeValues]::Top }
    }
    $tb.Append($bp)
    $tb.Append((New-ScoutEl "$Script:DrawNs.ListStyle"))
    foreach ($p in $Paragraphs) { $tb.Append($p) }
    return , $tb
}

function New-ScoutCellTextBody {
    # $Paragraphs: a List[object] of Drawing.Paragraph — wrapped for an a:tc (table cell).
    param($Paragraphs)
    $tb = New-ScoutEl "$Script:DrawNs.TextBody"
    $tb.Append((New-ScoutEl "$Script:DrawNs.BodyProperties"))
    $tb.Append((New-ScoutEl "$Script:DrawNs.ListStyle"))
    foreach ($p in $Paragraphs) { $tb.Append($p) }
    return , $tb
}

#endregion

#region Shape / table builders

$Script:ScoutShapeId = 10

function New-ScoutShape {
    # A rectangle or rounded rectangle with optional fill and text content —
    # the one shape primitive every slide/card/label in this deck is built from.
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][double]$X, [Parameter(Mandatory)][double]$Y,
        [Parameter(Mandatory)][double]$Cx, [Parameter(Mandatory)][double]$Cy,
        [string]$FillHex = $null,
        $Paragraphs,
        [string]$Anchor = 't',
        [double]$RadiusPct = 0
    )
    $Script:ScoutShapeId++
    $sh = New-ScoutEl "$Script:PresNs.Shape"

    $nvSp = New-ScoutEl "$Script:PresNs.NonVisualShapeProperties"
    $cnv = New-ScoutEl "$Script:PresNs.NonVisualDrawingProperties"
    $cnv.Id = [uint32]$Script:ScoutShapeId
    $cnv.Name = $Name
    $nvSp.Append($cnv)
    $nvSp.Append((New-ScoutEl "$Script:PresNs.NonVisualShapeDrawingProperties"))
    $nvSp.Append((New-ScoutEl "$Script:PresNs.ApplicationNonVisualDrawingProperties"))
    $sh.Append($nvSp)

    $spPr = New-ScoutEl "$Script:PresNs.ShapeProperties"
    $xfrm = New-ScoutEl "$Script:DrawNs.Transform2D"
    $off = New-ScoutEl "$Script:DrawNs.Offset"; $off.X = (ScoutEmu $X); $off.Y = (ScoutEmu $Y)
    $ext = New-ScoutEl "$Script:DrawNs.Extents"; $ext.Cx = (ScoutEmu $Cx); $ext.Cy = (ScoutEmu $Cy)
    $xfrm.Append($off); $xfrm.Append($ext)
    $spPr.Append($xfrm)

    $geom = New-ScoutEl "$Script:DrawNs.PresetGeometry"
    $geom.Preset = if ($RadiusPct -gt 0) {
        [DocumentFormat.OpenXml.Drawing.ShapeTypeValues]::RoundRectangle
    } else {
        [DocumentFormat.OpenXml.Drawing.ShapeTypeValues]::Rectangle
    }
    $avLst = New-ScoutEl "$Script:DrawNs.AdjustValueList"
    if ($RadiusPct -gt 0) {
        $av = New-ScoutEl "$Script:DrawNs.ShapeGuide"
        $av.Name = 'adj'
        $av.Formula = "val $([int]($RadiusPct * 1000))"
        $avLst.Append($av)
    }
    $geom.Append($avLst)
    $spPr.Append($geom)

    if ($FillHex) { $spPr.Append((New-ScoutFill $FillHex)) }
    else { $spPr.Append((New-ScoutEl "$Script:DrawNs.NoFill")) }
    $noLineOutline = New-ScoutEl "$Script:DrawNs.Outline"
    $noLineOutline.Append((New-ScoutEl "$Script:DrawNs.NoFill"))
    $spPr.Append($noLineOutline)
    $sh.Append($spPr)

    if ($Paragraphs) { $sh.Append((New-ScoutShapeTextBody -Paragraphs $Paragraphs -Anchor $Anchor)) }
    return , $sh
}

function New-ScoutTableCell {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [double]$SizePt = 11,
        [string]$Hex = '1A1A1A',
        [bool]$Bold = $false,
        [string]$FillHex = 'FFFFFF',
        [string]$Align = $null
    )
    $tc = New-ScoutEl "$Script:DrawNs.TableCell"
    $runs = New-ScoutList
    $runs.Add((New-ScoutRun -Text $Text -SizePt $SizePt -Hex $Hex -Bold $Bold))
    $paras = New-ScoutList
    $paras.Add((New-ScoutPara -Runs $runs -Align $Align))
    $tc.Append((New-ScoutCellTextBody $paras))

    $tcPr = New-ScoutEl "$Script:DrawNs.TableCellProperties"
    $tcPr.Anchor = [DocumentFormat.OpenXml.Drawing.TextAnchoringTypeValues]::Center
    $tcPr.LeftMargin = [int32](ScoutEmu 0.08)
    $tcPr.RightMargin = [int32](ScoutEmu 0.08)
    $tcPr.TopMargin = [int32](ScoutEmu 0.02)
    $tcPr.BottomMargin = [int32](ScoutEmu 0.02)
    if ($FillHex) { $tcPr.Append((New-ScoutFill $FillHex)) }
    $tc.Append($tcPr)
    return , $tc
}

function New-ScoutTableRow {
    param([double]$HeightIn, $Cells)
    $row = New-ScoutEl "$Script:DrawNs.TableRow"
    $row.Height = (ScoutEmu $HeightIn)
    foreach ($c in $Cells) { $row.Append($c) }
    return , $row
}

function New-ScoutTable {
    # $ColWidthsIn: double[] of column widths in inches.
    # $Rows: List[object] of Drawing.TableRow (header row typically at index 0).
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][double]$X, [Parameter(Mandatory)][double]$Y,
        [Parameter(Mandatory)][double[]]$ColWidthsIn,
        [Parameter(Mandatory)]$Rows
    )
    $Script:ScoutShapeId++
    $gf = New-ScoutEl "$Script:PresNs.GraphicFrame"

    $nvGfp = New-ScoutEl "$Script:PresNs.NonVisualGraphicFrameProperties"
    $cnv = New-ScoutEl "$Script:PresNs.NonVisualDrawingProperties"
    $cnv.Id = [uint32]$Script:ScoutShapeId
    $cnv.Name = $Name
    $nvGfp.Append($cnv)
    $locks = New-ScoutEl "$Script:DrawNs.GraphicFrameLocks"; $locks.NoGrouping = $true
    $nvGfdp = New-ScoutEl "$Script:PresNs.NonVisualGraphicFrameDrawingProperties"
    $nvGfdp.Append($locks)
    $nvGfp.Append($nvGfdp)
    $nvGfp.Append((New-ScoutEl "$Script:PresNs.ApplicationNonVisualDrawingProperties"))
    $gf.Append($nvGfp)

    $totalWidth = ($ColWidthsIn | Measure-Object -Sum).Sum
    $totalHeight = 0.0
    foreach ($r in $Rows) { $totalHeight += ([double]$r.Height.Value / 914400.0) }

    $xfrm = New-ScoutEl "$Script:PresNs.Transform"
    $off = New-ScoutEl "$Script:DrawNs.Offset"; $off.X = (ScoutEmu $X); $off.Y = (ScoutEmu $Y)
    $ext = New-ScoutEl "$Script:DrawNs.Extents"; $ext.Cx = (ScoutEmu $totalWidth); $ext.Cy = (ScoutEmu $totalHeight)
    $xfrm.Append($off); $xfrm.Append($ext)
    $gf.Append($xfrm)

    $graphic = New-ScoutEl "$Script:DrawNs.Graphic"
    $graphicData = New-ScoutEl "$Script:DrawNs.GraphicData"
    $graphicData.Uri = 'http://schemas.openxmlformats.org/drawingml/2006/table'

    $table = New-ScoutEl "$Script:DrawNs.Table"
    $tblPr = New-ScoutEl "$Script:DrawNs.TableProperties"
    $tblPr.FirstRow = $true
    $tblPr.BandRow = $true
    $table.Append($tblPr)

    $grid = New-ScoutEl "$Script:DrawNs.TableGrid"
    foreach ($w in $ColWidthsIn) {
        $gc = New-ScoutEl "$Script:DrawNs.GridColumn"
        $gc.Width = (ScoutEmu $w)
        $grid.Append($gc)
    }
    $table.Append($grid)
    foreach ($r in $Rows) { $table.Append($r) }

    $graphicData.Append($table)
    $graphic.Append($graphicData)
    $gf.Append($graphic)

    return , $gf
}

#endregion

#region Data helpers (safe property access, score/severity bands, pagination)

function Get-ScoutProp {
    param($Obj, [Parameter(Mandatory)][string]$Name, $Default = $null)
    if ($null -eq $Obj) { return $Default }
    $p = $Obj.PSObject.Properties[$Name]
    if ($p) { return $p.Value } else { return $Default }
}

function Get-ScoutScoreColor {
    param($Score)
    if ($null -eq $Score) { return '595959' }
    if ($Score -ge 80) { return '2E7D32' }
    if ($Score -ge 50) { return 'B8860B' }
    return 'B00020'
}

$Script:ScoutSeverityRank = @{ high = 0; medium = 1; low = 2 }

function Get-ScoutSeverityRank {
    # AB#5089: null/missing/unrecognized severity sorts LAST, never throws.
    param($Severity)
    if ($Severity) {
        $key = $Severity.ToString().Trim().ToLowerInvariant()
        if ($Script:ScoutSeverityRank.ContainsKey($key)) { return $Script:ScoutSeverityRank[$key] }
    }
    return 99
}

function Get-ScoutSeverityLabel {
    # AB#5089 companion: always renders a label, never crashes on null/blank.
    param($Severity)
    if ($Severity -and "$Severity".Trim()) { return "$Severity".ToUpperInvariant() }
    return 'UNKNOWN'
}

function Get-ScoutSeverityColor {
    param($Severity)
    if (-not $Severity) { return '595959' }
    switch ($Severity.ToString().Trim().ToLowerInvariant()) {
        'high' { return 'B00020' }
        'medium' { return 'B8860B' }
        'low' { return '2E75B6' }
        default { return '595959' }
    }
}

function Split-ScoutChunks {
    param([array]$Items, [int]$Size)
    $chunks = [System.Collections.Generic.List[object]]::new()
    if (-not $Items -or $Items.Count -eq 0) { return $chunks }
    for ($i = 0; $i -lt $Items.Count; $i += $Size) {
        $endIdx = [Math]::Min($i + $Size, $Items.Count) - 1
        $chunks.Add(@($Items[$i..$endIdx]))
    }
    return $chunks
}

#endregion

#region Palette

$Script:Navy = '1F4E78'
$Script:Steel = '2E75B6'
$Script:Green = '2E7D32'
$Script:Gold = 'B8860B'
$Script:Red = 'B00020'
$Script:Ink = '1A1A1A'
$Script:Paper = 'FFFFFF'
$Script:Mist = 'F6F9FD'
$Script:Line = 'E2E2E2'
$Script:Gray = '595959'

$Script:SlideWIn = 13.333
$Script:SlideHIn = 7.5

#endregion

#region Deck shell — theme, slide master, two slide layouts (title-as-code template)

function New-ScoutDeckShell {
    param([Parameter(Mandatory)][string]$OutFile)

    if (Test-Path $OutFile) { Remove-Item $OutFile -Force }
    $doc = [DocumentFormat.OpenXml.Packaging.PresentationDocument]::Create($OutFile, [DocumentFormat.OpenXml.PresentationDocumentType]::Presentation)
    $presPart = $doc.AddPresentationPart()
    $presPart.Presentation = New-ScoutEl "$Script:PresNs.Presentation"

    function New-ScoutEmptyShapeTree {
        $tree = New-ScoutEl "$Script:PresNs.ShapeTree"
        $nvGrp = New-ScoutEl "$Script:PresNs.NonVisualGroupShapeProperties"
        $cnv = New-ScoutEl "$Script:PresNs.NonVisualDrawingProperties"
        $cnv.Id = [uint32]1
        $cnv.Name = ''
        $nvGrp.Append($cnv)
        $nvGrp.Append((New-ScoutEl "$Script:PresNs.NonVisualGroupShapeDrawingProperties"))
        $nvGrp.Append((New-ScoutEl "$Script:PresNs.ApplicationNonVisualDrawingProperties"))
        $tree.Append($nvGrp)
        $tree.Append((New-ScoutEl "$Script:PresNs.GroupShapeProperties"))
        return , $tree
    }

    # ---- Slide master ----
    $masterPart = New-ScoutPart $presPart ([DocumentFormat.OpenXml.Packaging.SlideMasterPart]) 'rIdMaster1'
    $master = New-ScoutEl "$Script:PresNs.SlideMaster"
    $mcsd = New-ScoutEl "$Script:PresNs.CommonSlideData"
    $mcsd.Append((New-ScoutEmptyShapeTree))
    $master.Append($mcsd)

    $cm = New-ScoutEl "$Script:PresNs.ColorMap"
    $cm.Background1 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Light1
    $cm.Text1 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Dark1
    $cm.Background2 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Light2
    $cm.Text2 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Dark2
    $cm.Accent1 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Accent1
    $cm.Accent2 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Accent2
    $cm.Accent3 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Accent3
    $cm.Accent4 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Accent4
    $cm.Accent5 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Accent5
    $cm.Accent6 = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Accent6
    $cm.Hyperlink = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::Hyperlink
    $cm.FollowedHyperlink = [DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues]::FollowedHyperlink
    $master.Append($cm)

    $sliList = New-ScoutEl "$Script:PresNs.SlideLayoutIdList"
    $sliTitle = New-ScoutEl "$Script:PresNs.SlideLayoutId"; $sliTitle.Id = [uint32]2147483649; $sliTitle.RelationshipId = 'rIdLayoutTitle'
    $sliContent = New-ScoutEl "$Script:PresNs.SlideLayoutId"; $sliContent.Id = [uint32]2147483650; $sliContent.RelationshipId = 'rIdLayoutContent'
    $sliList.Append($sliTitle); $sliList.Append($sliContent)
    $master.Append($sliList)

    $ts = New-ScoutEl "$Script:PresNs.TextStyles"
    $ts.Append((New-ScoutEl "$Script:PresNs.TitleStyle"))
    $ts.Append((New-ScoutEl "$Script:PresNs.BodyStyle"))
    $ts.Append((New-ScoutEl "$Script:PresNs.OtherStyle"))
    $master.Append($ts)
    $masterPart.SlideMaster = $master

    # ---- Slide layouts (title + content) ----
    $layoutTitlePart = New-ScoutPart $masterPart ([DocumentFormat.OpenXml.Packaging.SlideLayoutPart]) 'rIdLayoutTitle'
    $layoutTitle = New-ScoutEl "$Script:PresNs.SlideLayout"
    $layoutTitle.Type = [DocumentFormat.OpenXml.Presentation.SlideLayoutValues]::Title
    $ltCsd = New-ScoutEl "$Script:PresNs.CommonSlideData"
    $ltCsd.Name = 'Azure Scout Title'
    $ltCsd.Append((New-ScoutEmptyShapeTree))
    $layoutTitle.Append($ltCsd)
    $ltCmo = New-ScoutEl "$Script:PresNs.ColorMapOverride"; $ltCmo.Append((New-ScoutEl "$Script:DrawNs.MasterColorMapping"))
    $layoutTitle.Append($ltCmo)
    $layoutTitlePart.SlideLayout = $layoutTitle

    $layoutContentPart = New-ScoutPart $masterPart ([DocumentFormat.OpenXml.Packaging.SlideLayoutPart]) 'rIdLayoutContent'
    $layoutContent = New-ScoutEl "$Script:PresNs.SlideLayout"
    $layoutContent.Type = [DocumentFormat.OpenXml.Presentation.SlideLayoutValues]::Text
    $lcCsd = New-ScoutEl "$Script:PresNs.CommonSlideData"
    $lcCsd.Name = 'Azure Scout Content'
    $lcCsd.Append((New-ScoutEmptyShapeTree))
    $layoutContent.Append($lcCsd)
    $lcCmo = New-ScoutEl "$Script:PresNs.ColorMapOverride"; $lcCmo.Append((New-ScoutEl "$Script:DrawNs.MasterColorMapping"))
    $layoutContent.Append($lcCmo)
    $layoutContentPart.SlideLayout = $layoutContent

    # ---- Theme (navy/steel/gold corporate palette, matches report.html.template) ----
    $themePart = New-ScoutPart $masterPart ([DocumentFormat.OpenXml.Packaging.ThemePart]) 'rIdTheme1'
    $theme = New-ScoutEl "$Script:DrawNs.Theme"
    $theme.Name = 'Azure Scout'

    $colorScheme = New-ScoutEl "$Script:DrawNs.ColorScheme"
    $colorScheme.Name = 'Azure Scout'
    $dk1 = New-ScoutEl "$Script:DrawNs.Dark1Color"
    $sysDk = New-ScoutEl "$Script:DrawNs.SystemColor"; $sysDk.Val = [DocumentFormat.OpenXml.Drawing.SystemColorValues]::WindowText; $sysDk.LastColor = $Script:Ink
    $dk1.Append($sysDk)
    $lt1 = New-ScoutEl "$Script:DrawNs.Light1Color"
    $sysLt = New-ScoutEl "$Script:DrawNs.SystemColor"; $sysLt.Val = [DocumentFormat.OpenXml.Drawing.SystemColorValues]::Window; $sysLt.LastColor = $Script:Paper
    $lt1.Append($sysLt)
    $dk2 = New-ScoutEl "$Script:DrawNs.Dark2Color"; $dk2.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex"))
    $dk2.RgbColorModelHex.Val = $Script:Navy
    $lt2 = New-ScoutEl "$Script:DrawNs.Light2Color"; $lt2.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex"))
    $lt2.RgbColorModelHex.Val = $Script:Mist
    $a1 = New-ScoutEl "$Script:DrawNs.Accent1Color"; $a1.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $a1.RgbColorModelHex.Val = $Script:Navy
    $a2 = New-ScoutEl "$Script:DrawNs.Accent2Color"; $a2.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $a2.RgbColorModelHex.Val = $Script:Steel
    $a3 = New-ScoutEl "$Script:DrawNs.Accent3Color"; $a3.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $a3.RgbColorModelHex.Val = $Script:Green
    $a4 = New-ScoutEl "$Script:DrawNs.Accent4Color"; $a4.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $a4.RgbColorModelHex.Val = $Script:Gold
    $a5 = New-ScoutEl "$Script:DrawNs.Accent5Color"; $a5.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $a5.RgbColorModelHex.Val = $Script:Red
    $a6 = New-ScoutEl "$Script:DrawNs.Accent6Color"; $a6.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $a6.RgbColorModelHex.Val = $Script:Gray
    $hl = New-ScoutEl "$Script:DrawNs.Hyperlink"; $hl.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $hl.RgbColorModelHex.Val = $Script:Steel
    $fhl = New-ScoutEl "$Script:DrawNs.FollowedHyperlinkColor"; $fhl.Append((New-ScoutEl "$Script:DrawNs.RgbColorModelHex")); $fhl.RgbColorModelHex.Val = $Script:Navy
    $colorScheme.Append($dk1, $lt1, $dk2, $lt2, $a1, $a2, $a3, $a4, $a5, $a6, $hl, $fhl)

    $fontScheme = New-ScoutEl "$Script:DrawNs.FontScheme"
    $fontScheme.Name = 'Azure Scout'
    $majorFont = New-ScoutEl "$Script:DrawNs.MajorFont"
    $majorLatin = New-ScoutEl "$Script:DrawNs.LatinFont"; $majorLatin.Typeface = 'Segoe UI Semibold'
    $majorFont.Append($majorLatin); $majorFont.Append((New-ScoutEl "$Script:DrawNs.EastAsianFont")); $majorFont.Append((New-ScoutEl "$Script:DrawNs.ComplexScriptFont"))
    $minorFont = New-ScoutEl "$Script:DrawNs.MinorFont"
    $minorLatin = New-ScoutEl "$Script:DrawNs.LatinFont"; $minorLatin.Typeface = 'Segoe UI'
    $minorFont.Append($minorLatin); $minorFont.Append((New-ScoutEl "$Script:DrawNs.EastAsianFont")); $minorFont.Append((New-ScoutEl "$Script:DrawNs.ComplexScriptFont"))
    $fontScheme.Append($majorFont, $minorFont)

    $fmtScheme = New-ScoutEl "$Script:DrawNs.FormatScheme"
    $fmtScheme.Name = 'Azure Scout'
    $fillList = New-ScoutEl "$Script:DrawNs.FillStyleList"
    1..3 | ForEach-Object { $s = New-ScoutEl "$Script:DrawNs.SolidFill"; $s.Append((New-ScoutPhColor)); $fillList.Append($s) }
    $lineList = New-ScoutEl "$Script:DrawNs.LineStyleList"
    1..3 | ForEach-Object {
        $ol = New-ScoutEl "$Script:DrawNs.Outline"; $ol.Width = 9525
        $olFill = New-ScoutEl "$Script:DrawNs.SolidFill"; $olFill.Append((New-ScoutPhColor))
        $ol.Append($olFill)
        $lineList.Append($ol)
    }
    $effList = New-ScoutEl "$Script:DrawNs.EffectStyleList"
    1..3 | ForEach-Object { $es = New-ScoutEl "$Script:DrawNs.EffectStyle"; $es.Append((New-ScoutEl "$Script:DrawNs.EffectList")); $effList.Append($es) }
    $bgList = New-ScoutEl "$Script:DrawNs.BackgroundFillStyleList"
    1..3 | ForEach-Object { $s = New-ScoutEl "$Script:DrawNs.SolidFill"; $s.Append((New-ScoutPhColor)); $bgList.Append($s) }
    $fmtScheme.Append($fillList, $lineList, $effList, $bgList)

    $themeElements = New-ScoutEl "$Script:DrawNs.ThemeElements"
    $themeElements.Append($colorScheme, $fontScheme, $fmtScheme)
    $theme.Append($themeElements)
    $theme.Append((New-ScoutEl "$Script:DrawNs.ObjectDefaults"))
    $theme.Append((New-ScoutEl "$Script:DrawNs.ExtraColorSchemeList"))
    $themePart.Theme = $theme

    return [pscustomobject]@{
        Doc                = $doc
        PresPart           = $presPart
        MasterPart         = $masterPart
        LayoutTitlePart    = $layoutTitlePart
        LayoutContentPart  = $layoutContentPart
        NextSlideRelIdNum  = 1
        NextSlideIdNum     = 256
    }
}

#endregion

#region Slide chrome + slide assembly

function Add-ScoutSlideChrome {
    # Appends the shared navy-bar / title / gold-underline / footer shapes
    # that make every content slide read as one consistent template.
    param($Tree, [string]$Title, [int]$PageNum, [int]$TotalPages)

    $bar = New-ScoutShape -Name 'TopBar' -X 0 -Y 0 -Cx $Script:SlideWIn -Cy 0.09 -FillHex $Script:Navy
    $Tree.Append($bar)

    $titleParas = New-ScoutList
    $titleRuns = New-ScoutList
    $titleRuns.Add((New-ScoutRun -Text $Title -SizePt 26 -Hex $Script:Navy -Bold $true -Font 'Segoe UI Semibold'))
    $titleParas.Add((New-ScoutPara -Runs $titleRuns))
    $titleShape = New-ScoutShape -Name 'SlideTitle' -X 0.55 -Y 0.26 -Cx 12.2 -Cy 0.62 -Paragraphs $titleParas
    $Tree.Append($titleShape)

    $underline = New-ScoutShape -Name 'TitleUnderline' -X 0.55 -Y 0.92 -Cx 1.1 -Cy 0.045 -FillHex $Script:Gold
    $Tree.Append($underline)

    $divider = New-ScoutShape -Name 'FooterDivider' -X 0.55 -Y 7.02 -Cx 12.23 -Cy 0.012 -FillHex $Script:Line
    $Tree.Append($divider)

    $wmParas = New-ScoutList
    $wmRuns = New-ScoutList
    $wmRuns.Add((New-ScoutRun -Text 'AZURE SCOUT' -SizePt 9 -Hex $Script:Gray -Bold $true))
    $wmParas.Add((New-ScoutPara -Runs $wmRuns))
    $wordmark = New-ScoutShape -Name 'FooterWordmark' -X 0.55 -Y 7.09 -Cx 4 -Cy 0.3 -Paragraphs $wmParas
    $Tree.Append($wordmark)

    if ($PageNum -gt 0) {
        $pgParas = New-ScoutList
        $pgRuns = New-ScoutList
        $pgRuns.Add((New-ScoutRun -Text "$PageNum / $TotalPages" -SizePt 9 -Hex $Script:Gray))
        $pgParas.Add((New-ScoutPara -Runs $pgRuns -Align 'r'))
        $pgShape = New-ScoutShape -Name 'FooterPage' -X 10.78 -Y 7.09 -Cx 2 -Cy 0.3 -Paragraphs $pgParas
        $Tree.Append($pgShape)
    }
}

function New-ScoutEmptyShapeTreeStandalone {
    # Same structure as the shell's private helper, exposed here for slide bodies.
    $tree = New-ScoutEl "$Script:PresNs.ShapeTree"
    $nvGrp = New-ScoutEl "$Script:PresNs.NonVisualGroupShapeProperties"
    $cnv = New-ScoutEl "$Script:PresNs.NonVisualDrawingProperties"
    $cnv.Id = [uint32]1
    $cnv.Name = ''
    $nvGrp.Append($cnv)
    $nvGrp.Append((New-ScoutEl "$Script:PresNs.NonVisualGroupShapeDrawingProperties"))
    $nvGrp.Append((New-ScoutEl "$Script:PresNs.ApplicationNonVisualDrawingProperties"))
    $tree.Append($nvGrp)
    $tree.Append((New-ScoutEl "$Script:PresNs.GroupShapeProperties"))
    return , $tree
}

function New-ScoutSlideElement {
    param($Tree)
    $slide = New-ScoutEl "$Script:PresNs.Slide"
    $scsd = New-ScoutEl "$Script:PresNs.CommonSlideData"
    $scsd.Append($Tree)
    $slide.Append($scsd)
    $cmo = New-ScoutEl "$Script:PresNs.ColorMapOverride"
    $cmo.Append((New-ScoutEl "$Script:DrawNs.MasterColorMapping"))
    $slide.Append($cmo)
    return , $slide
}

function Add-ScoutSlideToDeck {
    # Creates the SlidePart for a fully-built Slide element, wires it to the
    # given layout part, and registers it in the presentation's SlideIdList.
    param($Shell, $SlideElement, $LayoutPart)
    $relId = "rIdSlide$($Shell.NextSlideRelIdNum)"
    $slidePart = New-ScoutPart $Shell.PresPart ([DocumentFormat.OpenXml.Packaging.SlidePart]) $relId
    $slidePart.Slide = $SlideElement
    $null = $slidePart.AddPart($LayoutPart)

    if (-not $Shell.PSObject.Properties['SlideIdList']) {
        $Shell | Add-Member -NotePropertyName SlideIdList -NotePropertyValue (New-ScoutEl "$Script:PresNs.SlideIdList")
    }
    $sid = New-ScoutEl "$Script:PresNs.SlideId"
    $sid.Id = [uint32]$Shell.NextSlideIdNum
    $sid.RelationshipId = $Shell.PresPart.GetIdOfPart($slidePart)
    $Shell.SlideIdList.Append($sid)

    $Shell.NextSlideRelIdNum++
    $Shell.NextSlideIdNum++
}

#endregion

#region Slide content builders

function New-ScoutTitleSlide {
    param([Parameter(Mandatory)]$Shell, [string]$Title, [string]$Subtitle, [string]$MetaLine)

    $tree = New-ScoutEmptyShapeTreeStandalone
    $bg = New-ScoutShape -Name 'TitleBackground' -X 0 -Y 0 -Cx $Script:SlideWIn -Cy $Script:SlideHIn -FillHex $Script:Navy
    $tree.Append($bg)

    $wmParas = New-ScoutList; $wmRuns = New-ScoutList
    $wmRuns.Add((New-ScoutRun -Text 'AZURE SCOUT' -SizePt 14 -Hex $Script:Paper -Bold $true))
    $wmParas.Add((New-ScoutPara -Runs $wmRuns))
    $tree.Append((New-ScoutShape -Name 'Wordmark' -X 0.6 -Y 0.45 -Cx 5 -Cy 0.4 -Paragraphs $wmParas))

    $accent = New-ScoutShape -Name 'TitleAccent' -X 5.67 -Y 2.45 -Cx 2 -Cy 0.05 -FillHex $Script:Gold
    $tree.Append($accent)

    $titleParas = New-ScoutList; $titleRuns = New-ScoutList
    $titleRuns.Add((New-ScoutRun -Text $Title -SizePt 40 -Hex $Script:Paper -Bold $true -Font 'Segoe UI Semibold'))
    $titleParas.Add((New-ScoutPara -Runs $titleRuns -Align 'ctr'))
    $tree.Append((New-ScoutShape -Name 'Title' -X 1 -Y 2.65 -Cx 11.33 -Cy 1.1 -Paragraphs $titleParas -Anchor 'ctr'))

    if ($Subtitle) {
        $subParas = New-ScoutList; $subRuns = New-ScoutList
        $subRuns.Add((New-ScoutRun -Text $Subtitle -SizePt 18 -Hex 'D9E4EF'))
        $subParas.Add((New-ScoutPara -Runs $subRuns -Align 'ctr'))
        $tree.Append((New-ScoutShape -Name 'Subtitle' -X 1 -Y 3.75 -Cx 11.33 -Cy 0.55 -Paragraphs $subParas -Anchor 'ctr'))
    }

    if ($MetaLine) {
        $metaParas = New-ScoutList; $metaRuns = New-ScoutList
        $metaRuns.Add((New-ScoutRun -Text $MetaLine -SizePt 13 -Hex 'AFC3D9'))
        $metaParas.Add((New-ScoutPara -Runs $metaRuns -Align 'ctr'))
        $tree.Append((New-ScoutShape -Name 'MetaLine' -X 1 -Y 4.35 -Cx 11.33 -Cy 0.5 -Paragraphs $metaParas -Anchor 'ctr'))
    }

    $footParas = New-ScoutList; $footRuns = New-ScoutList
    $footRuns.Add((New-ScoutRun -Text 'Confidential — prepared by Azure Scout' -SizePt 10 -Hex 'AFC3D9'))
    $footParas.Add((New-ScoutPara -Runs $footRuns -Align 'ctr'))
    $tree.Append((New-ScoutShape -Name 'TitleFooter' -X 1 -Y 6.85 -Cx 11.33 -Cy 0.4 -Paragraphs $footParas -Anchor 'ctr'))

    $slide = New-ScoutSlideElement -Tree $tree
    Add-ScoutSlideToDeck -Shell $Shell -SlideElement $slide -LayoutPart $Shell.LayoutTitlePart
}

function New-ScoutContentSlide {
    # $BodyShapeBuilder: scriptblock that appends body shapes onto $tree (receives $tree as arg).
    param([Parameter(Mandatory)]$Shell, [string]$Title, [int]$PageNum, [int]$TotalPages, [scriptblock]$BodyShapeBuilder)
    $tree = New-ScoutEmptyShapeTreeStandalone
    Add-ScoutSlideChrome -Tree $tree -Title $Title -PageNum $PageNum -TotalPages $TotalPages
    & $BodyShapeBuilder $tree
    $slide = New-ScoutSlideElement -Tree $tree
    Add-ScoutSlideToDeck -Shell $Shell -SlideElement $slide -LayoutPart $Shell.LayoutContentPart
}

function Add-ScoutBulletList {
    param($Tree, [double]$X, [double]$Y, [double]$Cx, [string[]]$Lines, [double]$SizePt = 15, [double]$LineGapIn = 0.5)
    $paras = New-ScoutList
    foreach ($line in $Lines) {
        $runs = New-ScoutList
        $runs.Add((New-ScoutRun -Text "• $line" -SizePt $SizePt -Hex $Script:Ink))
        $paras.Add((New-ScoutPara -Runs $runs -SpaceBeforePt 6))
    }
    $height = [math]::Max($LineGapIn * $Lines.Count, 0.4)
    $Tree.Append((New-ScoutShape -Name 'BulletList' -X $X -Y $Y -Cx $Cx -Cy $height -Paragraphs $paras))
}

function Add-ScoutScoreCard {
    param($Tree, [double]$X, [double]$Y, [double]$Cx, [double]$Cy, [string]$Label, $Score)
    $color = Get-ScoutScoreColor $Score
    $scoreText = if ($null -eq $Score) { '—' } else { "$Score" }
    $paras = New-ScoutList
    $bigRuns = New-ScoutList
    $bigRuns.Add((New-ScoutRun -Text $scoreText -SizePt 44 -Hex $Script:Paper -Bold $true -Font 'Segoe UI Semibold'))
    $paras.Add((New-ScoutPara -Runs $bigRuns -Align 'ctr'))
    $lblRuns = New-ScoutList
    $lblRuns.Add((New-ScoutRun -Text $Label -SizePt 13 -Hex $Script:Paper))
    $paras.Add((New-ScoutPara -Runs $lblRuns -Align 'ctr'))
    $Tree.Append((New-ScoutShape -Name "Card_$Label" -X $X -Y $Y -Cx $Cx -Cy $Cy -FillHex $color -Anchor 'ctr' -RadiusPct 6 -Paragraphs $paras))
}

function New-ScoutExecSummarySlide {
    param($Shell, $Frameworks, $Areas, $Gaps, $Manual, $Errors, [int]$PageNum, [int]$TotalPages)

    New-ScoutContentSlide -Shell $Shell -Title 'Executive Summary' -PageNum $PageNum -TotalPages $TotalPages -BodyShapeBuilder {
        param($tree)

        $cardW = 2.7
        $gap = 0.25
        $startX = 0.55
        $i = 0
        if (@($Frameworks).Count -eq 0) {
            Add-ScoutScoreCard -Tree $tree -X $startX -Y 1.25 -Cx $cardW -Cy 1.7 -Label 'No framework scored' -Score $null
        } else {
            foreach ($fw in @($Frameworks)) {
                $x = $startX + ($i * ($cardW + $gap))
                $label = "$(Get-ScoutProp $fw 'Framework') Alignment Score"
                Add-ScoutScoreCard -Tree $tree -X $x -Y 1.25 -Cx $cardW -Cy 1.7 -Label $label -Score (Get-ScoutProp $fw 'Score')
                $i++
            }
        }

        $areaArr = @($Areas)
        $passSum = ($areaArr | ForEach-Object { Get-ScoutProp $_ 'Pass' 0 } | Measure-Object -Sum).Sum
        $partialSum = ($areaArr | ForEach-Object { Get-ScoutProp $_ 'Partial' 0 } | Measure-Object -Sum).Sum
        $failSum = ($areaArr | ForEach-Object { Get-ScoutProp $_ 'Fail' 0 } | Measure-Object -Sum).Sum
        $manualCount = @($Manual).Count
        $errorCount = @($Errors).Count
        $highGaps = (@($Gaps) | Where-Object { (Get-ScoutSeverityLabel (Get-ScoutProp $_ 'Severity')) -eq 'HIGH' } | Measure-Object).Count

        $lines = @(
            "Areas assessed: $($areaArr.Count)"
            "Rules evaluated — Pass: $passSum, Partial: $partialSum, Fail: $failSum"
            "Critical (High severity) gaps: $highGaps"
            "Manual review items pending: $manualCount"
            "Unknown/Error findings (check collector permissions): $errorCount"
        )
        Add-ScoutBulletList -Tree $tree -X 0.55 -Y 3.5 -Cx 12.2 -Lines $lines -SizePt 15 -LineGapIn 0.5
    }
}

function New-ScoutAreaTableSlides {
    param($Shell, $Areas, [ref]$PageCounter, [int]$TotalPages)

    # @(...) wraps the WHOLE pipeline, not just $Areas -- a Sort-Object over zero
    # input collapses the bare assignment to $null, and $null.Count throws
    # PropertyNotFoundException under Set-StrictMode -Version Latest. Same
    # load-bearing pattern Get-Score.ps1 documents for its own Pass/Fail counters.
    $rows = @(@($Areas) | Sort-Object Framework, Area)
    if ($rows.Count -eq 0) { return }
    $chunks = Split-ScoutChunks -Items $rows -Size 10
    $pageOfPages = $chunks.Count

    $chunkIdx = 0
    foreach ($chunk in $chunks) {
        $chunkIdx++
        $title = if ($pageOfPages -gt 1) { "Area Score Breakdown ($chunkIdx/$pageOfPages)" } else { 'Area Score Breakdown' }
        $capturedChunk = $chunk
        New-ScoutContentSlide -Shell $Shell -Title $title -PageNum $PageCounter.Value -TotalPages $TotalPages -BodyShapeBuilder {
            param($tree)
            $colW = @(1.6, 3.5, 1.0, 0.9, 0.9, 0.9, 1.0)
            $rowsList = New-ScoutList
            $headerCells = New-ScoutList
            foreach ($h in 'Framework', 'Area', 'Score', 'Pass', 'Partial', 'Fail', 'Manual') {
                $headerCells.Add((New-ScoutTableCell -Text $h -SizePt 12 -Hex $Script:Paper -Bold $true -FillHex $Script:Navy))
            }
            $rowsList.Add((New-ScoutTableRow -HeightIn 0.38 -Cells $headerCells))

            $r = 0
            foreach ($area in $capturedChunk) {
                $r++
                $bg = if ($r % 2 -eq 0) { $Script:Mist } else { $Script:Paper }
                $score = Get-ScoutProp $area 'Score'
                $scoreColor = Get-ScoutScoreColor $score
                $scoreText = if ($null -eq $score) { '—' } else { "$score" }
                $cells = New-ScoutList
                $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $area 'Framework')" -Hex $Script:Ink -FillHex $bg))
                $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $area 'Area')" -Hex $Script:Ink -FillHex $bg -Align 'l'))
                $cells.Add((New-ScoutTableCell -Text $scoreText -Bold $true -Hex $scoreColor -FillHex $bg))
                $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $area 'Pass' 0)" -Hex $Script:Ink -FillHex $bg))
                $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $area 'Partial' 0)" -Hex $Script:Ink -FillHex $bg))
                $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $area 'Fail' 0)" -Hex $Script:Ink -FillHex $bg))
                $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $area 'Manual' 0)" -Hex $Script:Ink -FillHex $bg))
                $rowsList.Add((New-ScoutTableRow -HeightIn 0.32 -Cells $cells))
            }

            $tree.Append((New-ScoutTable -Name 'AreaTable' -X 0.55 -Y 1.25 -ColWidthsIn $colW -Rows $rowsList))
        }
        $PageCounter.Value++
    }
}

function New-ScoutGapsSlides {
    param($Shell, $Gaps, [ref]$PageCounter, [int]$TotalPages, [int]$MaxGaps = 15)

    # AB#5089: defensive re-sort at render time too — null/unrecognized severity
    # sorts LAST — even if the caller passes gaps that were never run through
    # Get-Score's own sort. Both @(...) wraps are load-bearing: a Sort-Object/
    # Select-Object over zero input collapses the bare assignment to $null, and
    # $null.Count throws PropertyNotFoundException under Set-StrictMode -Version Latest.
    $sorted = @(@($Gaps) | Sort-Object @{ Expression = { Get-ScoutSeverityRank (Get-ScoutProp $_ 'Severity') } }, Area)
    $top = @($sorted | Select-Object -First $MaxGaps)

    if ($top.Count -eq 0) {
        New-ScoutContentSlide -Shell $Shell -Title 'Prioritized Gaps' -PageNum $PageCounter.Value -TotalPages $TotalPages -BodyShapeBuilder {
            param($tree)
            Add-ScoutBulletList -Tree $tree -X 0.55 -Y 1.6 -Cx 12.2 -Lines @('No prioritized gaps — all assessed rules are passing.') -SizePt 16
        }
        $PageCounter.Value++
        return
    }

    $chunks = Split-ScoutChunks -Items $top -Size 10
    $pageOfPages = $chunks.Count
    $chunkIdx = 0
    foreach ($chunk in $chunks) {
        $chunkIdx++
        $title = if ($pageOfPages -gt 1) { "Prioritized Gaps (Top $($top.Count)) — $chunkIdx/$pageOfPages" } else { "Prioritized Gaps (Top $($top.Count))" }
        $capturedChunk = $chunk
        New-ScoutContentSlide -Shell $Shell -Title $title -PageNum $PageCounter.Value -TotalPages $TotalPages -BodyShapeBuilder {
            param($tree)
            $colW = @(1.3, 2.6, 7.0)
            $rowsList = New-ScoutList
            $headerCells = New-ScoutList
            foreach ($h in 'Severity', 'Area', 'Gap') {
                $headerCells.Add((New-ScoutTableCell -Text $h -SizePt 12 -Hex $Script:Paper -Bold $true -FillHex $Script:Navy))
            }
            $rowsList.Add((New-ScoutTableRow -HeightIn 0.38 -Cells $headerCells))

            $r = 0
            foreach ($gap in $capturedChunk) {
                $r++
                $bg = if ($r % 2 -eq 0) { $Script:Mist } else { $Script:Paper }
                $sevLabel = Get-ScoutSeverityLabel (Get-ScoutProp $gap 'Severity')
                $sevColor = Get-ScoutSeverityColor (Get-ScoutProp $gap 'Severity')
                $cells = New-ScoutList
                $cells.Add((New-ScoutTableCell -Text $sevLabel -Bold $true -Hex $Script:Paper -FillHex $sevColor))
                $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $gap 'Area')" -Hex $Script:Ink -FillHex $bg -Align 'l'))
                $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $gap 'Title')" -Hex $Script:Ink -FillHex $bg -Align 'l'))
                $rowsList.Add((New-ScoutTableRow -HeightIn 0.4 -Cells $cells))
            }

            $tree.Append((New-ScoutTable -Name 'GapsTable' -X 0.55 -Y 1.25 -ColWidthsIn $colW -Rows $rowsList))
        }
        $PageCounter.Value++
    }
}

function New-ScoutManualSlide {
    param($Shell, $Manual, [ref]$PageCounter, [int]$TotalPages, [int]$MaxRows = 12)

    $items = @($Manual)
    if ($items.Count -eq 0) {
        New-ScoutContentSlide -Shell $Shell -Title 'Manual Review Worklist' -PageNum $PageCounter.Value -TotalPages $TotalPages -BodyShapeBuilder {
            param($tree)
            Add-ScoutBulletList -Tree $tree -X 0.55 -Y 1.6 -Cx 12.2 -Lines @('No manual review items — full automated coverage for the selected assessment(s).') -SizePt 16
        }
        $PageCounter.Value++
        return
    }

    # @() keeps $shown a real array regardless of how many items Select-Object
    # -First actually returns -- Select-Object over exactly one input otherwise
    # collapses the bare assignment to a scalar, which is the same fragile
    # single-item-collapse shape the zero-item $null collapse guarded elsewhere
    # in this file comes from ($items.Count -eq 0 is already ruled out above).
    $shown = @($items | Select-Object -First $MaxRows)
    $truncated = $items.Count - $shown.Count
    New-ScoutContentSlide -Shell $Shell -Title 'Manual Review Worklist' -PageNum $PageCounter.Value -TotalPages $TotalPages -BodyShapeBuilder {
        param($tree)
        $colW = @(3.5, 8.5)
        $rowsList = New-ScoutList
        $headerCells = New-ScoutList
        foreach ($h in 'Area', 'Item') {
            $headerCells.Add((New-ScoutTableCell -Text $h -SizePt 12 -Hex $Script:Paper -Bold $true -FillHex $Script:Navy))
        }
        $rowsList.Add((New-ScoutTableRow -HeightIn 0.38 -Cells $headerCells))

        $r = 0
        foreach ($m in $shown) {
            $r++
            $bg = if ($r % 2 -eq 0) { $Script:Mist } else { $Script:Paper }
            $cells = New-ScoutList
            $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $m 'Area')" -Hex $Script:Ink -FillHex $bg -Align 'l'))
            $cells.Add((New-ScoutTableCell -Text "$(Get-ScoutProp $m 'Title')" -Hex $Script:Ink -FillHex $bg -Align 'l'))
            $rowsList.Add((New-ScoutTableRow -HeightIn 0.36 -Cells $cells))
        }
        $tree.Append((New-ScoutTable -Name 'ManualTable' -X 0.55 -Y 1.25 -ColWidthsIn $colW -Rows $rowsList))

        if ($truncated -gt 0) {
            Add-ScoutBulletList -Tree $tree -X 0.55 -Y 6.55 -Cx 12.2 -Lines @("+$truncated more not shown — see the evidence pack (Excel tier) for the full list.") -SizePt 11 -LineGapIn 0.3
        }
    }
    $PageCounter.Value++
}

function New-ScoutNextStepsSlide {
    param($Shell, [ref]$PageCounter, [int]$TotalPages)

    New-ScoutContentSlide -Shell $Shell -Title 'Recommended Next Steps' -PageNum $PageCounter.Value -TotalPages $TotalPages -BodyShapeBuilder {
        param($tree)
        $lines = @(
            'Prioritize remediation of High severity gaps identified in this report.'
            'Assign owners and target dates for each Fail finding using the evidence pack (Excel tier).'
            'Complete outstanding Manual review items with the platform/security team.'
            'Re-run Azure Scout after remediation to track score improvement over time.'
            'Revisit Unknown/Error findings — these usually indicate a collector permission or query issue, not a compliance failure.'
        )
        Add-ScoutBulletList -Tree $tree -X 0.55 -Y 1.5 -Cx 12.2 -Lines $lines -SizePt 16 -LineGapIn 0.7
    }
    $PageCounter.Value++
}

#endregion

function Export-Pptx {
    <#
    .SYNOPSIS
        Renders the executive PowerPoint deck for a scored Findings object.
 
    .PARAMETER Findings
        The scored object returned by Get-Score (GeneratedOn/Frameworks/Areas/
        Gaps/Manual/Errors/Findings).
 
    .PARAMETER Collect
        Optional — the raw collect object (same one Export-Html/-Excel/-PowerBi
        already receive from Export-Report.ps1). Used only to surface scope /
        management-group context on the title slide when present; the deck
        renders fine without it (Collect carries no tenant field today — see
        src/collect/Invoke-Collect.ps1 — so the title slide degrades gracefully
        to date + branding only when Collect is absent or _meta is empty).
 
    .PARAMETER OutputPath
        Directory the rendered assessment_deck.pptx is written into.
    #>

    param($Findings, $Collect, [string] $OutputPath)

    Import-ScoutOpenXmlAssembly

    $outFile = Join-Path $OutputPath 'assessment_deck.pptx'
    New-Item -ItemType Directory -Path $OutputPath -Force -ErrorAction SilentlyContinue | Out-Null

    $frameworks = @(Get-ScoutProp $Findings 'Frameworks')
    $areas = @(Get-ScoutProp $Findings 'Areas')
    $gaps = @(Get-ScoutProp $Findings 'Gaps')
    $manual = @(Get-ScoutProp $Findings 'Manual')
    $errors = @(Get-ScoutProp $Findings 'Errors')
    $generatedOn = Get-ScoutProp $Findings 'GeneratedOn'
    $generatedText = if ($generatedOn) {
        try { ([datetime]$generatedOn).ToString('yyyy-MM-dd') } catch { "$generatedOn" }
    } else { (Get-Date).ToString('yyyy-MM-dd') }

    $scope = Get-ScoutProp (Get-ScoutProp $Collect '_meta') 'scope'
    $mgId = Get-ScoutProp (Get-ScoutProp $Collect '_meta') 'managementGroupId'
    $metaParts = New-Object System.Collections.Generic.List[string]
    $metaParts.Add("Generated $generatedText")
    if ($scope) { $metaParts.Add("Scope: $scope") }
    if ($mgId) { $metaParts.Add("Management Group: $mgId") }
    $metaLine = [string]::Join(' · ', $metaParts)

    # Slide count plan (used for the "n / total" footer):
    # 1 title + 1 summary + area-table pages + gap pages (>=1) + 1 manual + 1 next-steps
    $areaPages = if (@($areas).Count -gt 0) { [Math]::Ceiling(@($areas).Count / 10.0) } else { 0 }
    # @() wraps the WHOLE pipeline -- Select-Object -First over zero input
    # collapses the bare assignment to $null, and $null.Count throws under
    # Set-StrictMode -Version Latest.
    $gapCandidates = @(@($gaps) | Select-Object -First 15)
    $gapPages = if ($gapCandidates.Count -gt 0) { [Math]::Ceiling($gapCandidates.Count / 10.0) } else { 1 }
    $totalPages = 1 + 1 + $areaPages + $gapPages + 1 + 1

    $shell = New-ScoutDeckShell -OutFile $outFile

    New-ScoutTitleSlide -Shell $shell -Title 'Azure Landing Zone Assessment' `
        -Subtitle 'Executive Assessment — CAF & WAF Alignment' -MetaLine $metaLine

    $page = 2
    New-ScoutExecSummarySlide -Shell $shell -Frameworks $frameworks -Areas $areas -Gaps $gaps -Manual $manual -Errors $errors -PageNum $page -TotalPages $totalPages
    $page++

    $pageRef = [ref]$page
    New-ScoutAreaTableSlides -Shell $shell -Areas $areas -PageCounter $pageRef -TotalPages $totalPages
    New-ScoutGapsSlides -Shell $shell -Gaps $gaps -PageCounter $pageRef -TotalPages $totalPages
    New-ScoutManualSlide -Shell $shell -Manual $manual -PageCounter $pageRef -TotalPages $totalPages
    New-ScoutNextStepsSlide -Shell $shell -PageCounter $pageRef -TotalPages $totalPages

    # ---- wire the presentation-level lists and save ----
    $slideMasterIdList = New-ScoutEl "$Script:PresNs.SlideMasterIdList"
    $smId = New-ScoutEl "$Script:PresNs.SlideMasterId"
    $smId.Id = [uint32]2147483648
    $smId.RelationshipId = $shell.PresPart.GetIdOfPart($shell.MasterPart)
    $slideMasterIdList.Append($smId)

    $slideSize = New-ScoutEl "$Script:PresNs.SlideSize"
    $slideSize.Cx = [int32](ScoutEmu32 $Script:SlideWIn)
    $slideSize.Cy = [int32](ScoutEmu32 $Script:SlideHIn)
    $slideSize.Type = [DocumentFormat.OpenXml.Presentation.SlideSizeValues]::Screen16x9

    $notesSize = New-ScoutEl "$Script:PresNs.NotesSize"
    $notesSize.Cx = [int32](ScoutEmu32 7.5)
    $notesSize.Cy = [int32](ScoutEmu32 10)

    $shell.PresPart.Presentation.Append($slideMasterIdList, $shell.SlideIdList, $slideSize, $notesSize, (New-ScoutEl "$Script:PresNs.DefaultTextStyle"))
    $shell.PresPart.Presentation.Save()
    $shell.Doc.Dispose()

    return $outFile
}