src/report/renderers/Export-Word.ps1

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

<#
.SYNOPSIS
    Auto-assemble a Word (.docx) assessment report from scored findings via the
    OpenXML SDK (DocumentFormat.OpenXml) — no Word install required.

.DESCRIPTION
    Renders a print/share-friendly Word document directly from the scored
    Findings object (the output of Get-Score: GeneratedOn/Frameworks/Areas/
    Gaps/Manual/Errors/Findings) plus the raw Collect object (only its optional
    _meta.scope / _meta.managementGroupId are read, mirroring Export-Pptx).
    Every part of the .docx OPC package (document body, section properties,
    tables) is constructed programmatically with the OpenXML SDK
    (DocumentFormat.OpenXml.Wordprocessing) — the same accepted design used by
    Export-Pptx.ps1 for the executive deck (AB#5044).

    Section inventory:
      1. Cover — title, subtitle, generated date/scope/mgmt-group
      2. Executive Summary — framework score table + rollup counts
      3. Findings by Area — one heading + findings table per assessed area
      4. Prioritized Gaps — Severity/Area/Gap table, sorted worst-first,
                             capped at top $Script:ScoutDocxMaxGaps rows
      5. Manual Review — outstanding manual-review worklist, capped at
                             $Script:ScoutDocxMaxManual rows

    ASSEMBLY ACQUISITION: reuses the exact acquire-once-and-cache pattern
    Export-Pptx.ps1 established (Import-ScoutOpenXmlAssembly) — a throwaway
    dotnet-build csproj that pulls DocumentFormat.OpenXml from NuGet on first
    use and caches the DLLs under output/.tools/openxml/<version>. This file
    defines its own copy (Import-ScoutDocxOpenXmlAssembly) rather than calling
    Export-Pptx.ps1's function directly, so this renderer stays fully
    self-contained and loadable on its own (every existing renderer test
    harness in tests/ dot-sources only the one renderer .ps1 file it needs) —
    but it deliberately points at the SAME cache directory and version pin, so
    a prior Export-Pptx run (or Pptx Pester run) that already populated the
    cache means this renderer's first use costs nothing extra: Wordprocessing
    types live in the very same DocumentFormat.OpenXml.dll the deck renderer
    already downloaded.

.NOTES
    Tracks ADO Story AB#333.

    OpenXML SDK GOTCHA (also documented in Export-Pptx.ps1): every
    OpenXmlElement implements IEnumerable<OpenXmlElement> over its own
    children, and PowerShell's function-output pipeline auto-enumerates any
    IEnumerable it sees. 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. The same is true of a plain
    System.Collections.Generic.List[object] (also IEnumerable), so
    New-ScoutDocxList follows the same pattern. Do not remove the commas.

    Non-fatal on failure: the whole render is wrapped in try/catch. If
    anything throws (a broken OpenXML acquire, a malformed Findings object,
    etc.) this writes a self-contained "assessment_word_fallback.html" next
    to where the .docx would have gone (mirroring Export-Pdf.ps1's
    Export-ScoutPdfHtmlFallback pattern) instead of throwing and aborting the
    whole multi-renderer Export-Report loop in Invoke-ScoutAssessment.ps1.
#>


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

# Pinned so every run resolves the exact same OpenXML SDK build; bump deliberately.
# Deliberately the same version Export-Pptx.ps1 pins — see this file's header.
$Script:ScoutDocxOpenXmlVersion = '3.0.2'

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

    # Idempotent within a process — a prior Export-Pptx (or Export-Word) call
    # 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:ScoutDocxOpenXmlVersion
    $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-Word: DocumentFormat.OpenXml $($Script:ScoutDocxOpenXmlVersion) 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:ScoutDocxOpenXmlVersion)" />
  </ItemGroup>
</Project>
"@
 | Out-File -FilePath $tempProj -Encoding utf8

        Write-Host "[Export-Word] Acquiring DocumentFormat.OpenXml $($Script:ScoutDocxOpenXmlVersion) 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-Word: could not acquire DocumentFormat.OpenXml $($Script:ScoutDocxOpenXmlVersion) (offline, and nothing cached at '$cacheDir'?). dotnet build exit code $exitCode.`n$($buildOutput -join "`n")"
        }
        Write-Host "[Export-Word] DocumentFormat.OpenXml $($Script:ScoutDocxOpenXmlVersion) cached at $cacheDir" -ForegroundColor Green
    }

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

#endregion

#region Low-level OpenXML element helpers

$Script:ScoutDocxWNs = 'DocumentFormat.OpenXml.Wordprocessing'

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

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

function ScoutDocxDxa {
    # Twentieths of a point ("dxa") — the unit w:tblGrid/w:tcW/w:pgSz/w:pgMar all use.
    # 1440 dxa per inch.
    param([double]$Inches)
    return [int64][math]::Round($Inches * 1440)
}

#endregion

#region Palette (mirrors Export-Pptx.ps1's navy/steel/gold corporate palette)

$Script:ScoutDocxNavy = '1F4E78'
$Script:ScoutDocxSteel = '2E75B6'
$Script:ScoutDocxGreen = '2E7D32'
$Script:ScoutDocxGold = 'B8860B'
$Script:ScoutDocxRed = 'B00020'
$Script:ScoutDocxInk = '1A1A1A'
$Script:ScoutDocxPaper = 'FFFFFF'
$Script:ScoutDocxMist = 'F6F9FD'
$Script:ScoutDocxLine = 'D9D9D9'
$Script:ScoutDocxGray = '595959'

# Bounds on the doc's longer, unbounded-in-theory lists — a document naturally
# paginates (unlike a slide deck), so these are generous, but still finite so a
# pathological Findings object (thousands of gaps) can't produce a runaway render.
$Script:ScoutDocxMaxGaps = 50
$Script:ScoutDocxMaxManual = 100

#endregion

#region Paragraph / run helpers

function New-ScoutDocxRun {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [double]$SizePt = 11,
        [string]$Hex = $Script:ScoutDocxInk,
        [bool]$Bold = $false,
        [bool]$Italic = $false,
        [string]$Font = 'Segoe UI'
    )
    $run = New-ScoutDocxEl "$Script:ScoutDocxWNs.Run"
    $rPr = New-ScoutDocxEl "$Script:ScoutDocxWNs.RunProperties"
    $rFonts = New-ScoutDocxEl "$Script:ScoutDocxWNs.RunFonts"
    $rFonts.Ascii = $Font
    $rFonts.HighAnsi = $Font
    $rPr.Append($rFonts)
    if ($Bold) { $rPr.Append((New-ScoutDocxEl "$Script:ScoutDocxWNs.Bold")) }
    if ($Italic) { $rPr.Append((New-ScoutDocxEl "$Script:ScoutDocxWNs.Italic")) }
    # CT_RPr child order (ECMA-376 §17.3.2.30) requires color BEFORE sz/szCs —
    # putting FontSize first (as an earlier draft of this file did) validates
    # as a schema error ("unexpected child element w:color") even though Word
    # itself tolerates it; keep this order to stay strictly schema-valid.
    $color = New-ScoutDocxEl "$Script:ScoutDocxWNs.Color"
    $color.Val = $Hex
    $rPr.Append($color)
    $sz = New-ScoutDocxEl "$Script:ScoutDocxWNs.FontSize"
    $sz.Val = "$([int][math]::Round($SizePt * 2))"
    $rPr.Append($sz)
    $run.Append($rPr)
    $t = New-ScoutDocxEl "$Script:ScoutDocxWNs.Text"
    $t.Text = $Text
    $t.Space = [DocumentFormat.OpenXml.SpaceProcessingModeValues]::Preserve
    $run.Append($t)
    return , $run
}

function New-ScoutDocxBreakRun {
    # A page-break run — appended as its own run inside the last paragraph of a
    # section so the next content starts on a fresh page.
    $run = New-ScoutDocxEl "$Script:ScoutDocxWNs.Run"
    $br = New-ScoutDocxEl "$Script:ScoutDocxWNs.Break"
    $br.Type = [DocumentFormat.OpenXml.Wordprocessing.BreakValues]::Page
    $run.Append($br)
    return , $run
}

function New-ScoutDocxPara {
    param(
        $Runs,
        [string]$Align = $null,
        [double]$SpaceBeforePt = 0,
        [double]$SpaceAfterPt = 6,
        [bool]$KeepNext = $false
    )
    $p = New-ScoutDocxEl "$Script:ScoutDocxWNs.Paragraph"
    $pPr = New-ScoutDocxEl "$Script:ScoutDocxWNs.ParagraphProperties"
    $any = $false
    # CT_PPrBase child order (ECMA-376 §17.3.1.26) is keepNext, then spacing,
    # then jc — appending Justification first (as an earlier draft did)
    # validates as a schema error ("unexpected child element w:keepNext"/
    # "w:spacing" showing up after w:jc). Keep this order.
    if ($KeepNext) {
        $pPr.Append((New-ScoutDocxEl "$Script:ScoutDocxWNs.KeepNext"))
        $any = $true
    }
    if ($SpaceBeforePt -gt 0 -or $SpaceAfterPt -gt 0) {
        $spacing = New-ScoutDocxEl "$Script:ScoutDocxWNs.SpacingBetweenLines"
        if ($SpaceBeforePt -gt 0) { $spacing.Before = "$([int][math]::Round($SpaceBeforePt * 20))" }
        if ($SpaceAfterPt -gt 0) { $spacing.After = "$([int][math]::Round($SpaceAfterPt * 20))" }
        $pPr.Append($spacing)
        $any = $true
    }
    if ($Align) {
        $jc = New-ScoutDocxEl "$Script:ScoutDocxWNs.Justification"
        $jc.Val = switch ($Align) {
            'center' { [DocumentFormat.OpenXml.Wordprocessing.JustificationValues]::Center }
            'right' { [DocumentFormat.OpenXml.Wordprocessing.JustificationValues]::Right }
            default { [DocumentFormat.OpenXml.Wordprocessing.JustificationValues]::Left }
        }
        $pPr.Append($jc)
        $any = $true
    }
    if ($any) { $p.Append($pPr) }
    foreach ($r in $Runs) { $p.Append($r) }
    return , $p
}

function Add-ScoutDocxHeading {
    param($Body, [Parameter(Mandatory)][string]$Text, [int]$Level = 1)
    $sizePt = switch ($Level) { 1 { 22 } 2 { 16 } default { 13 } }
    $runs = New-ScoutDocxList
    $runs.Add((New-ScoutDocxRun -Text $Text -SizePt $sizePt -Hex $Script:ScoutDocxNavy -Bold $true -Font 'Segoe UI Semibold'))
    $spaceBefore = if ($Level -eq 1) { 12 } else { 8 }
    $Body.Append((New-ScoutDocxPara -Runs $runs -SpaceBeforePt $spaceBefore -SpaceAfterPt 6 -KeepNext $true))
}

function Add-ScoutDocxParagraph {
    param($Body, [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [double]$SizePt = 11, [string]$Hex = $Script:ScoutDocxInk, [bool]$Italic = $false, [string]$Align = $null)
    $runs = New-ScoutDocxList
    $runs.Add((New-ScoutDocxRun -Text $Text -SizePt $SizePt -Hex $Hex -Italic $Italic))
    $Body.Append((New-ScoutDocxPara -Runs $runs -Align $Align))
}

function Add-ScoutDocxPageBreak {
    param($Body)
    $runs = New-ScoutDocxList
    $runs.Add((New-ScoutDocxBreakRun))
    $p = New-ScoutDocxEl "$Script:ScoutDocxWNs.Paragraph"
    foreach ($r in $runs) { $p.Append($r) }
    $Body.Append($p)
}

#endregion

#region Table helpers

function New-ScoutDocxShading {
    param([Parameter(Mandatory)][string]$Hex)
    $sh = New-ScoutDocxEl "$Script:ScoutDocxWNs.Shading"
    $sh.Val = [DocumentFormat.OpenXml.Wordprocessing.ShadingPatternValues]::Clear
    $sh.Color = 'auto'
    $sh.Fill = $Hex
    return , $sh
}

function New-ScoutDocxCell {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [Parameter(Mandatory)][double]$WidthIn,
        [double]$SizePt = 10,
        [string]$Hex = $Script:ScoutDocxInk,
        [bool]$Bold = $false,
        [string]$FillHex = $null,
        [string]$Align = $null
    )
    $tc = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableCell"
    $tcPr = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableCellProperties"
    $tcW = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableCellWidth"
    $tcW.Width = "$(ScoutDocxDxa $WidthIn)"
    $tcW.Type = [DocumentFormat.OpenXml.Wordprocessing.TableWidthUnitValues]::Dxa
    $tcPr.Append($tcW)
    if ($FillHex) { $tcPr.Append((New-ScoutDocxShading -Hex $FillHex)) }
    $vAlign = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableCellVerticalAlignment"
    $vAlign.Val = [DocumentFormat.OpenXml.Wordprocessing.TableVerticalAlignmentValues]::Center
    $tcPr.Append($vAlign)
    $tc.Append($tcPr)
    $runs = New-ScoutDocxList
    $runs.Add((New-ScoutDocxRun -Text $Text -SizePt $SizePt -Hex $Hex -Bold $Bold))
    $tc.Append((New-ScoutDocxPara -Runs $runs -Align $Align -SpaceAfterPt 0))
    return , $tc
}

function New-ScoutDocxRow {
    param($Cells, [bool]$Header = $false)
    $tr = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableRow"
    if ($Header) {
        $trPr = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableRowProperties"
        # AB#394-style header repeat (the same idea Export-Pdf.ps1 hand-rolls for
        # its findings table): w:tblHeader marks this row to repeat on every page
        # a long table spills onto, without duplicating it in the source content.
        $trPr.Append((New-ScoutDocxEl "$Script:ScoutDocxWNs.TableHeader"))
        $tr.Append($trPr)
    }
    foreach ($c in $Cells) { $tr.Append($c) }
    return , $tr
}

function New-ScoutDocxTable {
    param([Parameter(Mandatory)][double[]]$ColWidthsIn, [Parameter(Mandatory)]$Rows)
    $tbl = New-ScoutDocxEl "$Script:ScoutDocxWNs.Table"
    $tblPr = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableProperties"

    $tblW = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableWidth"
    $tblW.Width = '5000'
    $tblW.Type = [DocumentFormat.OpenXml.Wordprocessing.TableWidthUnitValues]::Pct
    $tblPr.Append($tblW)

    $borders = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableBorders"
    # CT_TblBorders child order (ECMA-376 §17.4.38) is top, left, bottom,
    # right, insideH, insideV — NOT top/bottom/left/right.
    foreach ($side in 'TopBorder', 'LeftBorder', 'BottomBorder', 'RightBorder', 'InsideHorizontalBorder', 'InsideVerticalBorder') {
        $b = New-ScoutDocxEl "$Script:ScoutDocxWNs.$side"
        $b.Val = [DocumentFormat.OpenXml.Wordprocessing.BorderValues]::Single
        $b.Size = [uint32]4
        $b.Color = $Script:ScoutDocxLine
        $borders.Append($b)
    }
    $tblPr.Append($borders)

    $tblLook = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableLook"
    $tblLook.FirstRow = $true
    $tblLook.LastRow = $false
    $tblLook.FirstColumn = $false
    $tblLook.LastColumn = $false
    $tblLook.NoHorizontalBand = $false
    $tblLook.NoVerticalBand = $true
    $tblPr.Append($tblLook)

    $tbl.Append($tblPr)

    $grid = New-ScoutDocxEl "$Script:ScoutDocxWNs.TableGrid"
    foreach ($w in $ColWidthsIn) {
        $gc = New-ScoutDocxEl "$Script:ScoutDocxWNs.GridColumn"
        $gc.Width = "$(ScoutDocxDxa $w)"
        $grid.Append($gc)
    }
    $tbl.Append($grid)

    foreach ($r in $Rows) { $tbl.Append($r) }
    return , $tbl
}

#endregion

#region Data helpers (safe property access, score/severity bands — mirrors Export-Pptx.ps1)

function Get-ScoutDocxProp {
    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-ScoutDocxScoreColor {
    param($Score)
    if ($null -eq $Score) { return $Script:ScoutDocxGray }
    if ($Score -ge 80) { return $Script:ScoutDocxGreen }
    if ($Score -ge 50) { return $Script:ScoutDocxGold }
    return $Script:ScoutDocxRed
}

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

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

function Get-ScoutDocxSeverityLabel {
    param($Severity)
    if ($Severity -and "$Severity".Trim()) { return "$Severity".ToUpperInvariant() }
    return 'UNKNOWN'
}

function Get-ScoutDocxSeverityColor {
    param($Severity)
    if (-not $Severity) { return $Script:ScoutDocxGray }
    switch ($Severity.ToString().Trim().ToLowerInvariant()) {
        'high' { return $Script:ScoutDocxRed }
        'medium' { return $Script:ScoutDocxGold }
        'low' { return $Script:ScoutDocxSteel }
        default { return $Script:ScoutDocxGray }
    }
}

#endregion

#region HTML fallback (non-fatal-on-failure companion, mirrors Export-Pdf.ps1's pattern)

function Export-ScoutDocxHtmlFallback {
    param($Findings, $Collect, [string] $OutputPath, [string] $Reason)
    if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null }
    $json = ($Findings | ConvertTo-Json -Depth 100) -replace '</', '<\/'
    $safeReason = $Reason -replace '&', '&amp;' -replace '<', '&lt;' -replace '>', '&gt;'
    $generatedOn = Get-ScoutDocxProp $Findings 'GeneratedOn' '(unknown)'
    $html = @"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Azure Scout Assessment Report (fallback)</title>
<style>
  body { font-family: 'Segoe UI', Arial, sans-serif; color:#1a1a1a; margin: 2rem; }
  .banner { background:#B00020; color:#fff; padding: 0.75rem 1rem; margin-bottom:1rem; }
</style>
</head>
<body>
<div class="banner">
  Word (.docx) generation failed for this run -- this is a plain HTML fallback, not a renamed non-docx file.
  Reason: $safeReason
</div>
<h1>Azure Scout Assessment Report</h1>
<p>Generated: $generatedOn</p>
<script>window.__FINDINGS__ = $json;</script>
</body>
</html>
"@

    $path = Join-Path $OutputPath 'assessment_word_fallback.html'
    $html | Out-File -FilePath $path -Encoding utf8
    return $path
}

#endregion

#region Section builders

function New-ScoutDocxCoverParagraphs {
    param($Body, [string]$Title, [string]$Subtitle, [string]$MetaLine)

    $wmRuns = New-ScoutDocxList
    $wmRuns.Add((New-ScoutDocxRun -Text 'AZURE SCOUT' -SizePt 12 -Hex $Script:ScoutDocxSteel -Bold $true))
    $Body.Append((New-ScoutDocxPara -Runs $wmRuns -Align 'center' -SpaceBeforePt 60 -SpaceAfterPt 12))

    $titleRuns = New-ScoutDocxList
    $titleRuns.Add((New-ScoutDocxRun -Text $Title -SizePt 32 -Hex $Script:ScoutDocxNavy -Bold $true -Font 'Segoe UI Semibold'))
    $Body.Append((New-ScoutDocxPara -Runs $titleRuns -Align 'center' -SpaceAfterPt 8))

    if ($Subtitle) {
        $subRuns = New-ScoutDocxList
        $subRuns.Add((New-ScoutDocxRun -Text $Subtitle -SizePt 15 -Hex $Script:ScoutDocxGray))
        $Body.Append((New-ScoutDocxPara -Runs $subRuns -Align 'center' -SpaceAfterPt 24))
    }

    if ($MetaLine) {
        $metaRuns = New-ScoutDocxList
        $metaRuns.Add((New-ScoutDocxRun -Text $MetaLine -SizePt 11 -Hex $Script:ScoutDocxGray -Italic $true))
        $Body.Append((New-ScoutDocxPara -Runs $metaRuns -Align 'center' -SpaceAfterPt 6))
    }
}

function New-ScoutDocxExecSummary {
    param($Body, $Frameworks, $Areas, $Gaps, $Manual, $Errors)

    Add-ScoutDocxHeading -Body $Body -Text 'Executive Summary' -Level 1

    if (@($Frameworks).Count -eq 0) {
        Add-ScoutDocxParagraph -Body $Body -Text 'No framework scored for this run.' -Hex $Script:ScoutDocxGray
    }
    else {
        $rows = New-ScoutDocxList
        $header = New-ScoutDocxList
        $header.Add((New-ScoutDocxCell -Text 'Framework' -WidthIn 3.0 -Hex $Script:ScoutDocxPaper -Bold $true -FillHex $Script:ScoutDocxNavy))
        $header.Add((New-ScoutDocxCell -Text 'Alignment Score' -WidthIn 3.0 -Hex $Script:ScoutDocxPaper -Bold $true -FillHex $Script:ScoutDocxNavy -Align 'center'))
        $rows.Add((New-ScoutDocxRow -Cells $header -Header $true))

        $r = 0
        foreach ($fw in @($Frameworks)) {
            $r++
            $bg = if ($r % 2 -eq 0) { $Script:ScoutDocxMist } else { $Script:ScoutDocxPaper }
            $score = Get-ScoutDocxProp $fw 'Score'
            $scoreText = if ($null -eq $score) { 'Not scored' } else { "$score / 100" }
            $cells = New-ScoutDocxList
            $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $fw 'Framework')" -WidthIn 3.0 -FillHex $bg))
            $cells.Add((New-ScoutDocxCell -Text $scoreText -WidthIn 3.0 -Bold $true -Hex (Get-ScoutDocxScoreColor $score) -FillHex $bg -Align 'center'))
            $rows.Add((New-ScoutDocxRow -Cells $cells))
        }
        $Body.Append((New-ScoutDocxTable -ColWidthsIn @(3.0, 3.0) -Rows $rows))
    }

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

    Add-ScoutDocxParagraph -Body $Body -Text ' ' -SizePt 4
    foreach ($line in @(
            "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-ScoutDocxParagraph -Body $Body -Text "• $line" -SizePt 12
    }
}

function New-ScoutDocxAreaFindingsSection {
    param($Body, $Areas, $AllFindings)

    Add-ScoutDocxHeading -Body $Body -Text 'Findings by Area' -Level 1

    # @(...) wraps the WHOLE pipeline, not just $Areas -- Sort-Object over zero
    # input emits nothing, which collapses a bare assignment to $null (and
    # $null.Count throws under Set-StrictMode -Version Latest). Same
    # load-bearing pattern Get-Score.ps1 documents for its own Pass/Fail counters.
    $sortedAreas = @(@($Areas) | Sort-Object Framework, Area)
    if ($sortedAreas.Count -eq 0) {
        Add-ScoutDocxParagraph -Body $Body -Text 'No areas were assessed for this run.' -Hex $Script:ScoutDocxGray
        return
    }

    foreach ($area in $sortedAreas) {
        $framework = Get-ScoutDocxProp $area 'Framework'
        $areaName = Get-ScoutDocxProp $area 'Area'
        $score = Get-ScoutDocxProp $area 'Score'
        $scoreText = if ($null -eq $score) { 'not scored' } else { "$score / 100" }
        Add-ScoutDocxHeading -Body $Body -Text "$framework — $areaName (Score: $scoreText)" -Level 2

        $areaFindings = @($AllFindings | Where-Object {
                (Get-ScoutDocxProp $_ 'Framework') -eq $framework -and (Get-ScoutDocxProp $_ 'Area') -eq $areaName
            })

        if ($areaFindings.Count -eq 0) {
            Add-ScoutDocxParagraph -Body $Body -Text 'No individual findings recorded for this area.' -Hex $Script:ScoutDocxGray -Italic $true
            continue
        }

        $rows = New-ScoutDocxList
        $header = New-ScoutDocxList
        foreach ($h in 'Id', 'Severity', 'Status', 'Title') {
            $w = switch ($h) { 'Id' { 1.3 } 'Severity' { 1.0 } 'Status' { 1.0 } default { 3.2 } }
            $header.Add((New-ScoutDocxCell -Text $h -WidthIn $w -Hex $Script:ScoutDocxPaper -Bold $true -FillHex $Script:ScoutDocxNavy))
        }
        $rows.Add((New-ScoutDocxRow -Cells $header -Header $true))

        $r = 0
        foreach ($f in ($areaFindings | Sort-Object @{ Expression = { Get-ScoutDocxSeverityRank (Get-ScoutDocxProp $_ 'Severity') } }, Id)) {
            $r++
            $bg = if ($r % 2 -eq 0) { $Script:ScoutDocxMist } else { $Script:ScoutDocxPaper }
            $sevLabel = Get-ScoutDocxSeverityLabel (Get-ScoutDocxProp $f 'Severity')
            $sevColor = Get-ScoutDocxSeverityColor (Get-ScoutDocxProp $f 'Severity')
            $cells = New-ScoutDocxList
            $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $f 'Id')" -WidthIn 1.3 -FillHex $bg))
            $cells.Add((New-ScoutDocxCell -Text $sevLabel -WidthIn 1.0 -Bold $true -Hex $Script:ScoutDocxPaper -FillHex $sevColor -Align 'center'))
            $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $f 'Status')" -WidthIn 1.0 -FillHex $bg -Align 'center'))
            $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $f 'Title')" -WidthIn 3.2 -FillHex $bg))
            $rows.Add((New-ScoutDocxRow -Cells $cells))
        }
        $Body.Append((New-ScoutDocxTable -ColWidthsIn @(1.3, 1.0, 1.0, 3.2) -Rows $rows))
        Add-ScoutDocxParagraph -Body $Body -Text ' ' -SizePt 4
    }
}

function New-ScoutDocxGapsSection {
    param($Body, $Gaps)

    Add-ScoutDocxHeading -Body $Body -Text 'Prioritized Gaps' -Level 1

    # @() wraps the whole pipeline for the same reason New-ScoutDocxAreaFindingsSection
    # wraps its Sort-Object above -- zero-input Sort-Object collapses to $null otherwise.
    $sorted = @(@($Gaps) | Sort-Object @{ Expression = { Get-ScoutDocxSeverityRank (Get-ScoutDocxProp $_ 'Severity') } }, Area)
    $top = @($sorted | Select-Object -First $Script:ScoutDocxMaxGaps)

    if ($top.Count -eq 0) {
        Add-ScoutDocxParagraph -Body $Body -Text 'No prioritized gaps — all assessed rules are passing.' -Hex $Script:ScoutDocxGreen
        return
    }

    $rows = New-ScoutDocxList
    $header = New-ScoutDocxList
    foreach ($h in 'Severity', 'Area', 'Gap') {
        $w = switch ($h) { 'Severity' { 1.1 } 'Area' { 1.7 } default { 3.7 } }
        $header.Add((New-ScoutDocxCell -Text $h -WidthIn $w -Hex $Script:ScoutDocxPaper -Bold $true -FillHex $Script:ScoutDocxNavy))
    }
    $rows.Add((New-ScoutDocxRow -Cells $header -Header $true))

    $r = 0
    foreach ($gap in $top) {
        $r++
        $bg = if ($r % 2 -eq 0) { $Script:ScoutDocxMist } else { $Script:ScoutDocxPaper }
        $sevLabel = Get-ScoutDocxSeverityLabel (Get-ScoutDocxProp $gap 'Severity')
        $sevColor = Get-ScoutDocxSeverityColor (Get-ScoutDocxProp $gap 'Severity')
        $cells = New-ScoutDocxList
        $cells.Add((New-ScoutDocxCell -Text $sevLabel -WidthIn 1.1 -Bold $true -Hex $Script:ScoutDocxPaper -FillHex $sevColor -Align 'center'))
        $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $gap 'Area')" -WidthIn 1.7 -FillHex $bg))
        $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $gap 'Title')" -WidthIn 3.7 -FillHex $bg))
        $rows.Add((New-ScoutDocxRow -Cells $cells))
    }
    $Body.Append((New-ScoutDocxTable -ColWidthsIn @(1.1, 1.7, 3.7) -Rows $rows))

    $truncated = @($sorted).Count - $top.Count
    if ($truncated -gt 0) {
        Add-ScoutDocxParagraph -Body $Body -Text "+$truncated more not shown — see the evidence pack (Excel tier) for the full list." -SizePt 9 -Hex $Script:ScoutDocxGray -Italic $true
    }
}

function New-ScoutDocxManualSection {
    param($Body, $Manual)

    Add-ScoutDocxHeading -Body $Body -Text 'Manual Review Worklist' -Level 1

    $items = @($Manual)
    if ($items.Count -eq 0) {
        Add-ScoutDocxParagraph -Body $Body -Text 'No manual review items — full automated coverage for the selected assessment(s).' -Hex $Script:ScoutDocxGreen
        return
    }

    $shown = @($items | Select-Object -First $Script:ScoutDocxMaxManual)
    $rows = New-ScoutDocxList
    $header = New-ScoutDocxList
    $header.Add((New-ScoutDocxCell -Text 'Area' -WidthIn 2.0 -Hex $Script:ScoutDocxPaper -Bold $true -FillHex $Script:ScoutDocxNavy))
    $header.Add((New-ScoutDocxCell -Text 'Item' -WidthIn 4.5 -Hex $Script:ScoutDocxPaper -Bold $true -FillHex $Script:ScoutDocxNavy))
    $rows.Add((New-ScoutDocxRow -Cells $header -Header $true))

    $r = 0
    foreach ($m in $shown) {
        $r++
        $bg = if ($r % 2 -eq 0) { $Script:ScoutDocxMist } else { $Script:ScoutDocxPaper }
        $cells = New-ScoutDocxList
        $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $m 'Area')" -WidthIn 2.0 -FillHex $bg))
        $cells.Add((New-ScoutDocxCell -Text "$(Get-ScoutDocxProp $m 'Title')" -WidthIn 4.5 -FillHex $bg))
        $rows.Add((New-ScoutDocxRow -Cells $cells))
    }
    $Body.Append((New-ScoutDocxTable -ColWidthsIn @(2.0, 4.5) -Rows $rows))

    $truncated = $items.Count - $shown.Count
    if ($truncated -gt 0) {
        Add-ScoutDocxParagraph -Body $Body -Text "+$truncated more not shown — see the evidence pack (Excel tier) for the full list." -SizePt 9 -Hex $Script:ScoutDocxGray -Italic $true
    }
}

function New-ScoutDocxSectionProperties {
    # US Letter, portrait, 1" top/bottom, 0.75" left/right — twips (dxa) throughout.
    $sectPr = New-ScoutDocxEl "$Script:ScoutDocxWNs.SectionProperties"
    $pgSz = New-ScoutDocxEl "$Script:ScoutDocxWNs.PageSize"
    $pgSz.Width = [uint32](ScoutDocxDxa 8.5)
    $pgSz.Height = [uint32](ScoutDocxDxa 11)
    $sectPr.Append($pgSz)
    $pgMar = New-ScoutDocxEl "$Script:ScoutDocxWNs.PageMargin"
    $pgMar.Top = [int32](ScoutDocxDxa 1.0)
    $pgMar.Bottom = [int32](ScoutDocxDxa 1.0)
    $pgMar.Left = [uint32](ScoutDocxDxa 0.75)
    $pgMar.Right = [uint32](ScoutDocxDxa 0.75)
    $pgMar.Header = [uint32](ScoutDocxDxa 0.5)
    $pgMar.Footer = [uint32](ScoutDocxDxa 0.5)
    $pgMar.Gutter = [uint32]0
    $sectPr.Append($pgMar)
    return , $sectPr
}

#endregion

function Export-Word {
    <#
    .SYNOPSIS
        Renders the Word (.docx) assessment report 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/-Pptx
        already receive from Export-Report.ps1). Used only to surface scope /
        management-group context on the cover page when present.

    .PARAMETER OutputPath
        Directory the rendered assessment_report.docx is written into.
    #>

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

    try {
        Import-ScoutDocxOpenXmlAssembly

        if (-not (Test-Path $OutputPath)) {
            New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
        }
        $outFile = Join-Path $OutputPath 'assessment_report.docx'
        if (Test-Path $outFile) { Remove-Item $outFile -Force }

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

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

        $doc = [DocumentFormat.OpenXml.Packaging.WordprocessingDocument]::Create($outFile, [DocumentFormat.OpenXml.WordprocessingDocumentType]::Document)
        $mainPart = $doc.AddMainDocumentPart()
        $mainPart.Document = New-ScoutDocxEl "$Script:ScoutDocxWNs.Document"
        $body = New-ScoutDocxEl "$Script:ScoutDocxWNs.Body"
        $mainPart.Document.Append($body)

        # ---- Cover ----
        New-ScoutDocxCoverParagraphs -Body $body -Title 'Azure Landing Zone Assessment' `
            -Subtitle 'Executive Assessment — CAF & WAF Alignment' -MetaLine $metaLine
        Add-ScoutDocxPageBreak -Body $body

        # ---- Executive Summary ----
        New-ScoutDocxExecSummary -Body $body -Frameworks $frameworks -Areas $areas -Gaps $gaps -Manual $manual -Errors $errors
        Add-ScoutDocxPageBreak -Body $body

        # ---- Findings by Area ----
        New-ScoutDocxAreaFindingsSection -Body $body -Areas $areas -AllFindings $allFindings
        Add-ScoutDocxPageBreak -Body $body

        # ---- Prioritized Gaps ----
        New-ScoutDocxGapsSection -Body $body -Gaps $gaps
        Add-ScoutDocxPageBreak -Body $body

        # ---- Manual Review Worklist ----
        New-ScoutDocxManualSection -Body $body -Manual $manual

        # ---- Section properties (must be the last child of w:body) ----
        $body.Append((New-ScoutDocxSectionProperties))

        $mainPart.Document.Save()
        $doc.Dispose()

        return $outFile
    }
    catch {
        Write-Warning "Export-Word: .docx generation failed ($_) -- writing an HTML fallback instead."
        return (Export-ScoutDocxHtmlFallback -Findings $Findings -Collect $Collect -OutputPath $OutputPath -Reason $_.Exception.Message)
    }
}