|

Nutanix Documentation Script v5.0: Visual Reports, Brand Templates, and Seven Embedded Diagrams

91 min read

One of the questions I get asked most often after a Nutanix engagement is: “Can you give us a report of what we have?” Up until recently, the honest answer was “yes, but it takes a while to make it look decent.” With v5.0 of the Nutanix Documentation Script, that excuse is gone. The script now generates a fully branded, visually rich Word or PDF report with 7 embedded diagrams, all without any third-party libraries, Excel, ActiveX, or post-processing.

If you haven’t seen the earlier versions, the script connects to Prism Central, collects your full infrastructure inventory via the REST API (v3 and v4), and writes everything to a structured Word document. v5.0 keeps all of that (parallel VM enrichment, exponential retry, compressed JSON export) and layers on top a complete Nutanix brand template and a drawing engine built entirely on System.Drawing.

What’s New in v5.0

The headline addition is the visual reporting layer. Every generated document now opens with a programmatically rendered cover page, carries a branded header and footer throughout, and contains seven PNG diagrams generated at runtime from the inventory data and embedded inline at full page width.

The cover page is no longer dependent on Word’s built-in cover page templates, which are frequently missing or broken in enterprise environments. It is drawn  System.Drawing every time: a 780×484 px image with a navy gradient header, the NUTANIX logotype, a cyan accent bar, an info panel showing the PC IP, organization name, date, and user, and a confidential footer. It simply works.

The document template itself is fully Nutanix-branded. Heading 1 uses Nutanix Blue (#024DA1) in Arial Bold 14pt, Heading 2 uses Nutanix Dark (#1B2631) at 12pt, and Heading 3 uses Nutanix Cyan (#00A3E0) at 11pt. Table headers carry a Nutanix Blue background with white bold text, data rows alternate between white and light blue (#EAF4FC), and the page header and footer carry a blue rule and the “Nutanix, Inc. | Confidential” branding throughout.

The Seven Diagrams

All diagrams are rendered at 150 DPI from the in-memory inventory object, so no additional API calls happen during document generation. Each drawing function is wrapped in a try/catch, meaning a failed diagram never aborts the document.

Figure 1 – Executive Summary Dashboard lands immediately after the cover page. Six metric tiles cover cluster count, host count, VM count, total storage, active alerts with critical count broken out, and project count. If container capacity data is unavailable, the script falls back to summing the physical disk capacities from the host API.

Figure 2 – Physical Infrastructure Topology goes at the end of the cluster chapter. Prism Central sits at the top as a full-width blue gradient node, and below it each cluster renders as a rounded card with a coloured title band. Each host inside a cluster shows hostname, CVM IP, and memory. Up to 20 hosts per cluster are shown; larger clusters get a “+N more nodes” label. Each cluster gets a distinct color from an 8-color brand palette, and those same colors carry through to the VM distribution chart, so the visual language stays consistent across the whole document.

Figure 3 – Logical Network Map groups subnets by cluster in color-coded cards showing the subnet name, VLAN ID or type, and CIDR block. VLAN subnets get a light green background; OVERLAY subnets get light blue. Up to 30 subnets are supported.

Figure 4 – Storage Container Capacity Chart is a horizontal stacked bar chart with used capacity in cyan and free capacity in light grey, bars scaled to the largest container, and GiB labels shown inside or beside each bar.

Figure 5 – VM Power State Donut sits at the start of the VM inventory chapter with Powered On (green), Powered Off (grey), and Other/Suspended (orange) slices, the total VM count in the center, and counts with percentages in the legend.

Figure 6 – VMs per Cluster follows immediately after, with each cluster color-coded to match Figure 2, making it immediately obvious which clusters are carrying the most workload.

Figure 7 – Alert Severity Donut closes the health chapter with Critical (red), Warning (orange), Info (blue), and Other (grey) slices. If there are zero alerts, the diagram is omitted entirely.

Document Structure

The full output is a 19-chapter report. Here is how the diagrams map across it:

Section Chapter Diagram
Cover and Navigation   Cover banner, ToC, List of Figures
Executive Summary   Figure 1 – Dashboard
Prism Central 1-2  
Cluster Configuration 3 Figure 2 – Physical Topology
Physical (hosts, disks) 4-6  
Networking 7 Figure 3 – Network Map
Flow Microsegmentation 8  
Storage 9-10 Figure 4 – Storage Capacity
VM Inventory 11 Figure 5 – Power State Donut + Figure 6 – VM Distribution
VM Disks, Images 12-13  
Data Protection 14-15  
Health and Alerts 16 Figure 7 – Alert Severity Donut
Hardware Alerts (Full mode) 17  
Identity and Access 18-19  

The Table of Contents and List of Figures are both auto-generated. Every entry in the List of Figures is a clickable hyperlink that jumps directly to the relevant diagram, using bookmarks added programmatically when each image is embedded.

Usage

The basic invocation hasn’t changed much. Use Get-Credential for your Prism Central account and point it at your PC IP:

$cred = Get-Credential
.\Nutanix_Documentation_Script_v5.0_PC_API.ps1 -PrismCentralIP 10.0.0.1 -Credential $cred

PDF output with your company name on the cover and in the header:

.\Nutanix_Documentation_Script_v5.0_PC_API.ps1 -PrismCentralIP 10.0.0.1 -Credential $cred `
    -PDF -CompanyName "Acme Corp"

Full run with the hardware alerts chapter and email delivery:

.\Nutanix_Documentation_Script_v5.0_PC_API.ps1 -PrismCentralIP 10.0.0.1 -Credential $cred `
    -Full -Folder C:\Reports -SmtpServer mail.acme.com -From noc@acme.com -To cto@acme.com

For large environments, -VmThrottle controls concurrent VM enrichment requests (default 20, max 50). If you just need the inventory data without per-VM disk and NIC detail, -SkipVmDetails cuts runtime significantly:

.\Nutanix_Documentation_Script_v5.0_PC_API.ps1 -PrismCentralIP 10.0.0.1 -Credential $cred `
    -VmThrottle 40 -SkipVmDetails

One thing worth noting: the -CoverPage parameter is still accepted for backwards compatibility but no longer does anything. The cover page is always the programmatic Nutanix-branded version now.

Prerequisites

Requirement Details
PowerShell 5.1 (Windows PowerShell)
System.Drawing Built into .NET Framework 4.x, no installation needed
Microsoft Word 2013, 2016, 2019, or 365 for .docx / .pdf output
Prism Central Any version exposing the v3 API; 2022.x+ for full v4 coverage
Network HTTPS access to Prism Central on port 9440

On PowerShell 7+ on Linux or macOS the System.Drawing calls are silently skipped. All tables and text still generate correctly, you just won’t get the diagrams or cover page.

Performance

The parallel VM enrichment from v4 is unchanged. Diagram rendering adds 5-8 seconds on top, regardless of environment size, since it is CPU-bound and runs from the in-memory inventory object rather than from additional API calls.

Environment v2/v3 (sequential) v5.0 (RunspacePool) Diagram + doc overhead
100 VMs ~100 s ~8 s +5-8 s
500 VMs ~500 s ~35 s +5-8 s
1,000 VMs ~1,000 s ~60 s +5-8 s

v4 API Support

The script automatically handles both v3 and v4 API responses. For clusters, it ConvertTo-NtnxCluster detects whether it is looking at a v4 response (has extId at the top level) or a v3 response (has metadata.uuid) and maps fields accordingly. Prism Central itself is excluded from cluster counts by checking config.clusterFunction for PRISM_CENTRAL. If the v4 cluster list comes back with empty nodeList arrays, node counts are back-filled from the collected host objects per cluster UUID.

Microseg v4 endpoints (policies, address groups, service groups) use page size 25 with a 400 ms inter-page sleep to avoid HTTP 429 responses, something that was not handled in v4.0.

Wrapping Up

v5.0 takes the script from a useful inventory tool to a report you can hand to a customer. The diagrams give people a visual anchor for the data, the brand template means it looks like something Nutanix produced, and the zero-dependency approach means it runs on any standard Windows machine with Word installed.

Said Script:

#Requires -Version 5.1
#This File is in Unicode format.  Do not edit in an ASCII editor.

<#
.SYNOPSIS
    Nutanix Infrastructure Documentation Script v5.0 — Visual Word / PDF / JSON output.

.DESCRIPTION
    Connects to Prism Central and collects comprehensive infrastructure data via
    REST API (v3 + v4).  Produces:
      • A richly illustrated 19-chapter Microsoft Word (.docx) or PDF report
        featuring embedded PNG diagrams generated with System.Drawing:
          - Executive Summary dashboard (6 key-metric tiles)
          - Physical Infrastructure Topology (cluster / host map)
          - Logical Network Map (subnet / VLAN cards by cluster)
          - Storage Capacity Chart (used vs free per container)
          - VM Power-State Donut chart
          - VM Distribution Bar Chart (VMs per cluster)
          - Alert Severity Donut chart
      • A deeply nested PSCustomObject written to the pipeline
      • An optional compressed JSON export (-ExportJson)

    Architectural improvements over v4:
      * All diagrams rendered via System.Drawing (no external dependencies)
      * Diagrams are PNG images embedded inline — no Excel, no ActiveX
      * Every drawing function is wrapped in try/catch; a failed diagram never
        aborts the document — it is silently skipped
      * All v4 improvements retained: parallel VM enrichment, v4?v3 fallback,
        exponential retry, SecureString credential handling, lookup tables

.PARAMETER PrismCentralIP
    IP address or FQDN of the Prism Central instance.

.PARAMETER Credential
    PSCredential for Prism Central authentication.  Create with: $cred = Get-Credential

.PARAMETER Port
    Prism Central HTTPS port (default: 9440).

.PARAMETER MSWord
    Save output as a DOCX file (default when no output format is selected).

.PARAMETER PDF
    Save output as a PDF file instead of DOCX.

.PARAMETER AddDateTime
    Append a yyyy-MM-dd_HHmm timestamp to the output filename.

.PARAMETER CompanyName
    Company name for the Word cover page.  Alias: CN.

.PARAMETER CoverPage
    Word cover page style (default: Sideline).  Alias: CP.

.PARAMETER UserName
    Author name for the Word cover page and footer.  Alias: UN.

.PARAMETER Folder
    Output folder for the Word / PDF file.

.PARAMETER Full
    Include full alert and event listings (adds Chapter 17).

.PARAMETER Log
    Write a transcript log file alongside the output document.

.PARAMETER Dev
    Capture all PowerShell errors to a text file for troubleshooting.

.PARAMETER ScriptInfo
    Write a script-info text file alongside the output document.

.PARAMETER SmtpServer
    SMTP server for emailing the finished report.

.PARAMETER SmtpPort
    SMTP port (default: 25).

.PARAMETER UseSSL
    Use SSL for the SMTP connection.

.PARAMETER From
    Sender email address (required when SmtpServer is set).

.PARAMETER To
    Recipient email address (required when SmtpServer is set).

.PARAMETER ExportJson
    Also export the inventory to a compressed JSON file.

.PARAMETER JsonPath
    Destination for the JSON export.  Defaults to .\NutanixInventory_<ts>.json.gz

.PARAMETER VmThrottle
    Max concurrent VM detail requests in the RunspacePool (default: 20).

.PARAMETER MaxRetries
    Max retry attempts on transient API failures (default: 3).

.PARAMETER SkipVmDetails
    Skip per-VM GET enrichment — faster but omits disk / NIC detail.

.EXAMPLE
    $cred = Get-Credential
    .\Nutanix_Documentation_Script_v5.0_PC_API.ps1 -PrismCentralIP 10.0.0.1 -Credential $cred

.EXAMPLE
    .\Nutanix_Documentation_Script_v5.0_PC_API.ps1 -PrismCentralIP 10.0.0.1 -Credential $cred `
        -PDF -CompanyName "Acme Corp" -ExportJson

.OUTPUTS
    [PSCustomObject] Normalised inventory object written to the pipeline.
    Word / PDF document written to disk.

.NOTES
    NAME   : Nutanix_Documentation_Script_v5.0_PC_API.ps1
    VERSION: 5.0
    AUTHOR : Kees Baggerman  (Word infrastructure by Carl Webster / Michael B. Smith)
    LASTEDIT: April 2026
#>

[CmdletBinding(SupportsShouldProcess = $False, ConfirmImpact = 'None',
               DefaultParameterSetName = 'Word')]
param(
    [Parameter(Mandatory, Position = 0)]
    [string]$PrismCentralIP,

    [Parameter(Mandatory, Position = 1)]
    [System.Management.Automation.PSCredential]
    [System.Management.Automation.Credential()]
    $Credential,

    [Parameter()]
    [ValidateRange(1, 65535)]
    [int]$Port = 9440,

    [parameter(ParameterSetName = 'Word', Mandatory = $False)]
    [Switch]$MSWord = $False,

    [parameter(ParameterSetName = 'PDF', Mandatory = $False)]
    [Switch]$PDF = $False,

    [parameter(Mandatory = $False)]
    [Switch]$AddDateTime = $False,

    [parameter(ParameterSetName = 'Word', Mandatory = $False)]
    [parameter(ParameterSetName = 'PDF',  Mandatory = $False)]
    [Alias('CN')]
    [ValidateNotNullOrEmpty()]
    [string]$CompanyName = '',

    [parameter(ParameterSetName = 'Word', Mandatory = $False)]
    [parameter(ParameterSetName = 'PDF',  Mandatory = $False)]
    [Alias('CP')]
    [ValidateNotNullOrEmpty()]
    [string]$CoverPage = 'Sideline',

    [parameter(ParameterSetName = 'Word', Mandatory = $False)]
    [parameter(ParameterSetName = 'PDF',  Mandatory = $False)]
    [Alias('UN')]
    [ValidateNotNullOrEmpty()]
    [string]$UserName = $env:username,

    [parameter(Mandatory = $False)]
    [string]$Folder = '',

    [parameter(Mandatory = $False)]
    [Switch]$Full = $False,

    [parameter(Mandatory = $False)]
    [Switch]$Log = $False,

    [parameter(Mandatory = $False)]
    [Switch]$Dev = $False,

    [parameter(Mandatory = $False)]
    [Alias('SI')]
    [Switch]$ScriptInfo = $False,

    [parameter(Mandatory = $False)]
    [string]$SmtpServer = '',

    [parameter(Mandatory = $False)]
    [int]$SmtpPort = 25,

    [parameter(Mandatory = $False)]
    [switch]$UseSSL = $False,

    [parameter(Mandatory = $False)]
    [string]$From = '',

    [parameter(Mandatory = $False)]
    [string]$To = '',

    [Parameter()]
    [switch]$ExportJson,

    [Parameter()]
    [string]$JsonPath = '',

    [Parameter()]
    [ValidateRange(1, 50)]
    [int]$VmThrottle = 20,

    [Parameter()]
    [ValidateRange(1, 5)]
    [int]$MaxRetries = 3,

    [Parameter()]
    [switch]$SkipVmDetails
)

Set-StrictMode -Version 2
$SaveEAPreference        = $ErrorActionPreference
$ErrorActionPreference   = 'SilentlyContinue'
$PSDefaultParameterValues = @{ '*:Verbose' = $True }

If ($Null -eq $MSWord) { $MSWord = if ($PDF) { $False } else { $True } }
If ($MSWord -eq $False -and $PDF -eq $False) { $MSWord = $True }

If ($Folder -ne '') {
    If (Test-Path $Folder -EA 0) {
        If (-not (Test-Path $Folder -PathType Container -EA 0)) {
            Write-Error "Folder $Folder is a file, not a folder.  Script cannot continue."; Exit
        }
    } Else {
        Write-Error "Folder $Folder does not exist.  Script cannot continue."; Exit
    }
}
$Script:pwdpath = If ($Folder -ne '') { $Folder } Else { $pwd.Path }
If ($Script:pwdpath.EndsWith('\')) { $Script:pwdpath = $Script:pwdpath.SubString(0, $Script:pwdpath.Length - 1) }

If ($Log) {
    $Script:LogPath = "$Script:pwdpath\NutanixInventoryScriptTranscript_$(Get-Date -f yyyy-MM-dd_HHmm).txt"
    try { Start-Transcript -Path $Script:LogPath -Force -Verbose:$false | Out-Null; $Script:StartLog = $true }
    catch { $Script:StartLog = $false }
}
If ($Dev) { $Error.Clear(); $Script:DevErrorFile = "$($pwd.Path)\NutanixInventoryScriptErrors_$(Get-Date -f yyyy-MM-dd_HHmm).txt" }

If (![String]::IsNullOrEmpty($SmtpServer) -and [String]::IsNullOrEmpty($From)) {
    Write-Error "SmtpServer specified but From address missing."; Exit }
If (![String]::IsNullOrEmpty($SmtpServer) -and [String]::IsNullOrEmpty($To)) {
    Write-Error "SmtpServer specified but To address missing."; Exit }

$Script:StartTime    = Get-Date
$Script:ApiBaseV3    = "https://${PrismCentralIP}:${Port}/api/nutanix/v3"
$Script:ApiBaseV4    = "https://${PrismCentralIP}:${Port}/api"
$Script:SkipCert     = ($PSEdition -eq 'Core' -or $PSVersionTable.PSVersion.Major -ge 6)
$Script:ActiveCred   = $Credential
$Script:MaxRetries   = $MaxRetries
$Script:ClusterMap   = @{}
$Script:HostMap      = @{}

If ($MSWord -or $PDF) {
    [int]$wdAlignPageNumberRight = 2
    [int]$wdColorGray15          = 14277081
    [int]$wdColorGray05          = 15987699
    [int]$wdMove                 = 0
    [int]$wdSeekMainDocument     = 0
    [int]$wdSeekPrimaryFooter    = 4
    [int]$wdStory                = 6
    [int]$wdColorRed             = 255
    [int]$wdColorBlack           = 0
    [int]$wdWord2007             = 12
    [int]$wdWord2010             = 14
    [int]$wdWord2013             = 15
    [int]$wdWord2016             = 16
    [int]$wdFormatDocumentDefault= 16
    [int]$wdFormatPDF            = 17
    [int]$wdAlignParagraphLeft   = 0
    [int]$wdAlignParagraphCenter = 1
    [int]$wdAlignParagraphRight  = 2
    [int]$wdCellAlignVerticalTop = 0
    [int]$wdAutoFitFixed         = 0
    [int]$wdAutoFitContent       = 1
    [int]$wdAutoFitWindow        = 2
    [int]$wdAdjustNone           = 0
    [int]$wdAdjustProportional   = 1
    [int]$PointsPerTabStop       = 36
    [int]$wdStyleHeading1        = -2
    [int]$wdStyleHeading2        = -3
    [int]$wdStyleHeading3        = -4
    [int]$wdStyleHeading4        = -5
    [int]$wdStyleNoSpacing       = -158
    [int]$wdTableGrid            = -155
    [int]$wdTableLightListAccent3= -206
    [int]$wdLineStyleNone        = 0
    [int]$wdLineStyleSingle      = 1
    [int]$wdHeadingFormatTrue    = -1
    [int]$wdHeadingFormatFalse   = 0
    [string]$Script:RunningOS    = (Get-WmiObject -class Win32_OperatingSystem -EA 0).Caption
}

# ???????????????????????????????????????????????????????????????????????????????
#  PART 0 ?? Visual Drawing Infrastructure  (System.Drawing; no external deps)
# ???????????????????????????????????????????????????????????????????????????????

Add-Type -AssemblyName System.Drawing -EA 0

# ?? Colour helpers — functions avoid ALL hashtable/property-access issues under StrictMode 2 ??

# Return a named brand colour.  Uses a switch so there is no hashtable or PSObject property
# access that could throw under Set-StrictMode -Version 2.
function _NC {
    param([string]$n)
    switch ($n) {
        'Blue'    { return [System.Drawing.Color]::FromArgb(  2,  77, 161) }
        'Cyan'    { return [System.Drawing.Color]::FromArgb(  0, 163, 224) }
        'Dark'    { return [System.Drawing.Color]::FromArgb( 27,  38,  49) }
        'Green'   { return [System.Drawing.Color]::FromArgb( 39, 174,  96) }
        'Red'     { return [System.Drawing.Color]::FromArgb(231,  76,  60) }
        'Orange'  { return [System.Drawing.Color]::FromArgb(230, 126,  34) }
        'Purple'  { return [System.Drawing.Color]::FromArgb(155,  89, 182) }
        'Teal'    { return [System.Drawing.Color]::FromArgb( 26, 188, 156) }
        'Gray'    { return [System.Drawing.Color]::FromArgb(127, 140, 141) }
        'LGray'   { return [System.Drawing.Color]::FromArgb(220, 228, 240) }
        'BgLight' { return [System.Drawing.Color]::FromArgb(246, 248, 252) }
        'White'   { return [System.Drawing.Color]::White }
        default   { return [System.Drawing.Color]::FromArgb(  2,  77, 161) }
    }
}

# Return a cluster palette colour by zero-based index (cycles every 8).
function _NCP {
    param([int]$i)
    $p = @(
        [System.Drawing.Color]::FromArgb(  2,  77, 161),
        [System.Drawing.Color]::FromArgb(  0, 163, 224),
        [System.Drawing.Color]::FromArgb( 39, 174,  96),
        [System.Drawing.Color]::FromArgb(155,  89, 182),
        [System.Drawing.Color]::FromArgb(230, 126,  34),
        [System.Drawing.Color]::FromArgb( 52, 152, 219),
        [System.Drawing.Color]::FromArgb( 26, 188, 156),
        [System.Drawing.Color]::FromArgb(231,  76,  60)
    )
    return $p[$i % 8]
}

# ?? Low-level helpers ??????????????????????????????????????????????????????????

function _RRect {
    param([float]$x,[float]$y,[float]$w,[float]$h,[float]$r=8.0)
    $p = [System.Drawing.Drawing2D.GraphicsPath]::new()
    $d = $r * 2
    $p.AddArc($x,         $y,         $d, $d, 180, 90)
    $p.AddArc($x+$w-$d,   $y,         $d, $d, 270, 90)
    $p.AddArc($x+$w-$d,   $y+$h-$d,   $d, $d,   0, 90)
    $p.AddArc($x,         $y+$h-$d,   $d, $d,  90, 90)
    $p.CloseFigure()
    return $p
}

function _CenterStr {
    param($G,[string]$Text,$Font,$Brush,
          [float]$x,[float]$y,[float]$w,[float]$h)
    $sf = [System.Drawing.StringFormat]::new()
    $sf.Alignment     = [System.Drawing.StringAlignment]::Center
    $sf.LineAlignment = [System.Drawing.StringAlignment]::Center
    $sf.Trimming      = [System.Drawing.StringTrimming]::EllipsisCharacter
    $G.DrawString($Text,$Font,$Brush,
        [System.Drawing.RectangleF]::new($x,$y,$w,$h),$sf)
    $sf.Dispose()
}

function _TitleBar {
    param($G,[string]$Text,[int]$W,[int]$H=30)
    $br = [System.Drawing.SolidBrush]::new((_NC 'Dark'))
    $G.FillRectangle($br,0,0,$W,$H); $br.Dispose()
    $f  = [System.Drawing.Font]::new('Segoe UI',9,[System.Drawing.FontStyle]::Bold)
    $wb = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::White)
    _CenterStr $G $Text $f $wb 0 0 $W $H
    $f.Dispose(); $wb.Dispose()
}

function _TempPng {
    param($Bitmap)
    $path = [System.IO.Path]::Combine(
        [System.IO.Path]::GetTempPath(),
        "NtnxV5_$([System.Guid]::NewGuid().ToString('N')).png")
    $Bitmap.Save($path,[System.Drawing.Imaging.ImageFormat]::Png)
    return $path
}

# Embed a PNG as an inline shape, optionally set a bookmark, and delete the temp file.
# The bookmark enables clickable cross-references from the List of Figures.
function Add-NtnxDiagram {
    param([string]$ImagePath,[float]$WidthPts=468.0,[string]$BookmarkName='')
    if (-not $ImagePath -or -not (Test-Path $ImagePath -EA 0)) { return }
    try {
        $shape = $Script:Selection.InlineShapes.AddPicture($ImagePath,$false,$true)
        if ($shape) {
            $ratio = $shape.Height / $shape.Width
            $shape.Width  = $WidthPts
            $shape.Height = [Math]::Round($WidthPts * $ratio, 0)
        }
        if ($BookmarkName) {
            try { $Script:Document.Bookmarks.Add($BookmarkName, $Script:Selection.Range) | Out-Null } catch {}
        }
        $Script:Selection.TypeParagraph()
    } catch {
        Write-Verbose "  [Diagram] Embed failed: $($_.Exception.Message)"
    } finally {
        try { Remove-Item $ImagePath -Force -EA 0 } catch {}
    }
}

# ?? Diagram 1: Executive Summary Dashboard ????????????????????????????????????
function New-NtnxSummaryDashboard {
    param([PSCustomObject]$Inv)
    try { Add-Type -AssemblyName System.Drawing -EA Stop } catch { return $null }
    try {
        $W = 780; $titleH = 30; $pad = 12; $gap = 8
        $tileW = [int](($W - 2*$pad - 2*$gap) / 3)
        $tileH = 80
        $H = $titleH + $pad + 2*$tileH + $gap + $pad

        $bmp = [System.Drawing.Bitmap]::new($W,$H)
        $bmp.SetResolution(150,150)
        $G = [System.Drawing.Graphics]::FromImage($bmp)
        $G.SmoothingMode    = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
        $G.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAlias
        $bgBr = [System.Drawing.SolidBrush]::new((_NC 'BgLight'))
        $G.FillRectangle($bgBr,0,0,$W,$H); $bgBr.Dispose()
        _TitleBar $G 'NUTANIX INFRASTRUCTURE — EXECUTIVE SUMMARY' $W $titleH

        # Calculate metrics — containers first, fall back to summing host physical disks
        $stoGiB = 0.0
        foreach ($ct in $Inv.Storage.Containers) {
            $v = $ct.PSObject.Properties['TotalGiB']; if ($v -and $v.Value) { $stoGiB += [double]$v.Value }
        }
        if ($stoGiB -eq 0.0 -and $Inv.Hosts) {
            foreach ($hh in $Inv.Hosts) {
                if ($hh.Disks) {
                    foreach ($dd in $hh.Disks) {
                        $dv = $dd.PSObject.Properties['SizeGiB']; if ($dv -and $dv.Value) { $stoGiB += [double]$dv.Value }
                    }
                }
            }
        }
        $stoStr  = if ($stoGiB -eq 0.0) { 'N/A' } elseif ($stoGiB -ge 1024) { "$([Math]::Round($stoGiB/1024,1)) TiB" } else { "$([int]$stoGiB) GiB" }
        $nAlerts = if ($Inv.Health.Alerts) { @($Inv.Health.Alerts).Count } else { 0 }
        $nCrit   = if ($Inv.Health.Alerts) { @($Inv.Health.Alerts | Where-Object { $_.Severity -eq 'CRITICAL' }).Count } else { 0 }
        $alertStr = if ($nCrit -gt 0) { "$nAlerts ($nCrit crit)" } else { [string]$nAlerts }

        $tiles = @(
            @{ Label='Clusters';          Value=[string]$Inv.Clusters.Count;          Clr=(_NC 'Blue')   }
            @{ Label='Hosts';             Value=[string]$Inv.Hosts.Count;             Clr=(_NC 'Cyan')   }
            @{ Label='Virtual Machines';  Value=[string]$Inv.VirtualMachines.Count;   Clr=(_NC 'Green')  }
            @{ Label='Total Storage';     Value=$stoStr;                              Clr=(_NC 'Purple') }
            @{ Label='Active Alerts';     Value=$alertStr; Clr=if($nCrit -gt 0){(_NC 'Red')}else{(_NC 'Orange')} }
            @{ Label='Projects';          Value=[string]$Inv.Projects.Count;          Clr=(_NC 'Teal')   }
        )

        $numFont = [System.Drawing.Font]::new('Segoe UI',20,[System.Drawing.FontStyle]::Bold)
        $lblFont = [System.Drawing.Font]::new('Segoe UI',7.5)
        $grayBr  = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(100,100,100))

        for ($i = 0; $i -lt 6; $i++) {
            $col  = $i % 3
            $row  = [int][Math]::Floor($i / 3)
            $tx   = $pad + $col * ($tileW + $gap)
            $ty   = $titleH + $pad + $row * ($tileH + $gap)
            $tile = $tiles[$i]

            # Card background
            $cp = _RRect $tx $ty $tileW $tileH 6
            $cb = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::White)
            $G.FillPath($cb,$cp); $cb.Dispose()
            $cpPen = [System.Drawing.Pen]::new([System.Drawing.Color]::FromArgb(210,220,235),1.0)
            $G.DrawPath($cpPen,$cp); $cpPen.Dispose(); $cp.Dispose()

            # Left accent stripe
            $ap = _RRect $tx $ty 5 $tileH 2
            $ab = [System.Drawing.SolidBrush]::new($tile.Clr)
            $G.FillPath($ab,$ap); $ab.Dispose(); $ap.Dispose()

            # Value (large)
            $vb = [System.Drawing.SolidBrush]::new($tile.Clr)
            _CenterStr $G $tile.Value $numFont $vb ($tx+6) $ty ($tileW-8) ($tileH-22)
            $vb.Dispose()

            # Label
            _CenterStr $G $tile.Label $lblFont $grayBr ($tx+6) ($ty+$tileH-22) ($tileW-8) 20
        }
        $numFont.Dispose(); $lblFont.Dispose(); $grayBr.Dispose()
        $G.Dispose()
        $path = _TempPng $bmp; $bmp.Dispose(); return $path
    } catch {
        Write-Verbose "  [SummaryDashboard] $($_.Exception.Message)"
        try { if ($G)  { $G.Dispose()   } } catch {}
        try { if ($bmp){ $bmp.Dispose() } } catch {}
        return $null
    }
}

# ?? Diagram 2: Physical Infrastructure Topology ???????????????????????????????
function New-NtnxTopologyDiagram {
    param([PSCustomObject]$Inv,[string]$PcIP)
    try { Add-Type -AssemblyName System.Drawing -EA Stop } catch { return $null }
    try {
        # Build cluster groups — fall back to host ClusterUUID when cluster objects lack UUIDs
        # (common when the v4 cluster API returns data the normaliser cannot map).
        $cGroups = [ordered]@{}
        foreach ($cl in ($Inv.Clusters | Sort-Object Name)) {
            $key = if ($cl.UUID) { $cl.UUID } elseif ($cl.Name) { "N:$($cl.Name)" } else { $null }
            if ($key -and -not $cGroups.Contains($key)) {
                $cGroups[$key] = [PSCustomObject]@{
                    Label = if ($cl.Name) { $cl.Name } else { 'Cluster' }
                    ExtIP = if ($cl.ExternalIP) { $cl.ExternalIP } else { '' }
                    Hosts = [System.Collections.Generic.List[object]]::new()
                }
            }
        }
        foreach ($h in $Inv.Hosts) {
            $cu = $h.ClusterUUID
            if ($cu -and $cGroups.Contains($cu)) {
                $cGroups[$cu].Hosts.Add($h)
            } else {
                # Host's ClusterUUID not in the groups yet — create a group from host data
                $clKey = if ($cu) { $cu } elseif ($h.ClusterName) { "N:$($h.ClusterName)" } else { 'unknown' }
                if (-not $cGroups.Contains($clKey)) {
                    $lbl = if ($h.ClusterName) { $h.ClusterName } elseif ($cu) { "Cluster …$($cu.Substring([Math]::Max(0,$cu.Length-8)))" } else { 'Unknown' }
                    $cGroups[$clKey] = [PSCustomObject]@{ Label=$lbl; ExtIP=''; Hosts=[System.Collections.Generic.List[object]]::new() }
                }
                $cGroups[$clKey].Hosts.Add($h)
            }
        }
        $cList = @($cGroups.Values)
        $nCl   = $cList.Count
        if ($nCl -eq 0) { return $null }

        $W=780; $titleH=30; $pcBoxH=34; $arrowH=22; $pad=12
        $clGap=12; $nodeW=88; $nodeH=54; $nGapX=6; $nGapY=6
        $nPRow=4;  $clPad=10; $clTH=24

        $colCount = if ($nCl -le 1){1} elseif ($nCl -le 4){2} else {3}
        $clW = [int](($W - 2*$pad - ($colCount-1)*$clGap) / $colCount)
        $rowCount = [int][Math]::Ceiling($nCl / $colCount)

        $clHts = @()
        foreach ($cg in $cList) {
            $nh    = [Math]::Min($cg.Hosts.Count, 20)
            $nrows = [Math]::Max(1,[int][Math]::Ceiling($nh / $nPRow))
            $clHts += $clTH + $clPad + $nrows*($nodeH+$nGapY) + $clPad
        }
        $rowHts = @()
        for ($r=0;$r -lt $rowCount;$r++) {
            $mx=0
            for ($c=0;$c -lt $colCount;$c++) {
                $idx=$r*$colCount+$c
                if ($idx -lt $clHts.Count) { $mx=[Math]::Max($mx,$clHts[$idx]) }
            }
            $rowHts += $mx
        }
        $totalClH = ($rowHts | Measure-Object -Sum).Sum + ($rowCount-1)*16
        $H = $titleH + 4 + $pcBoxH + $arrowH + $totalClH + $pad

        $bmp = [System.Drawing.Bitmap]::new($W,$H)
        $bmp.SetResolution(150,150)
        $G = [System.Drawing.Graphics]::FromImage($bmp)
        $G.SmoothingMode    = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
        $G.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAlias
        $bgBr = [System.Drawing.SolidBrush]::new((_NC 'BgLight'))
        $G.FillRectangle($bgBr,0,0,$W,$H); $bgBr.Dispose()
        _TitleBar $G 'PHYSICAL INFRASTRUCTURE TOPOLOGY' $W $titleH

        # Prism Central box
        $pcY  = $titleH + 4
        $pcX  = $pad
        $pcW  = $W - 2*$pad
        $pcPath = _RRect $pcX $pcY $pcW $pcBoxH 6
        $pcGrad = [System.Drawing.Drawing2D.LinearGradientBrush]::new(
            [System.Drawing.PointF]::new($pcX,$pcY),
            [System.Drawing.PointF]::new($pcX,$pcY+$pcBoxH),
            (_NC 'Blue'),
            [System.Drawing.Color]::FromArgb(0,55,130))
        $G.FillPath($pcGrad,$pcPath); $pcGrad.Dispose()
        $pcBdr = [System.Drawing.Pen]::new([System.Drawing.Color]::FromArgb(1,45,110),1.5)
        $G.DrawPath($pcBdr,$pcPath); $pcBdr.Dispose(); $pcPath.Dispose()
        $pcFont = [System.Drawing.Font]::new('Segoe UI',9,[System.Drawing.FontStyle]::Bold)
        $pcBr   = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::White)
        _CenterStr $G "Prism Central  •  $PcIP" $pcFont $pcBr $pcX $pcY $pcW $pcBoxH
        $pcFont.Dispose(); $pcBr.Dispose()

        # Connector line PC ? clusters
        $connX  = $W/2
        $connY1 = $pcY + $pcBoxH
        $connY2 = $connY1 + $arrowH - 4
        $dPen   = [System.Drawing.Pen]::new((_NC 'Gray'),1.5)
        $dPen.DashStyle = [System.Drawing.Drawing2D.DashStyle]::Dash
        $G.DrawLine($dPen,$connX,$connY1,$connX,$connY2); $dPen.Dispose()

        $clStartY = $pcY + $pcBoxH + $arrowH
        $cIdx = 0
        for ($row=0;$row -lt $rowCount;$row++) {
            $rowY = $clStartY
            for ($ri=0;$ri -lt $row;$ri++) { $rowY += $rowHts[$ri] + 16 }
            for ($col=0;$col -lt $colCount;$col++) {
                if ($cIdx -ge $cList.Count) { break }
                $cg    = $cList[$cIdx]
                $hosts = $cg.Hosts
                $clClr = _NCP $cIdx
                $clX   = $pad + $col*($clW+$clGap)
                $clH   = $rowHts[$row]

                # Cluster box
                $clPath = _RRect $clX $rowY $clW $clH 8
                $clBgBr = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(245,250,255))
                $G.FillPath($clBgBr,$clPath); $clBgBr.Dispose()
                $G.SetClip($clPath)
                $clTBr = [System.Drawing.SolidBrush]::new($clClr)
                $G.FillRectangle($clTBr,$clX,$rowY,$clW,$clTH); $clTBr.Dispose()
                $G.ResetClip()
                $clBdr = [System.Drawing.Pen]::new($clClr,2.0)
                $G.DrawPath($clBdr,$clPath); $clBdr.Dispose(); $clPath.Dispose()

                # Cluster title
                $ctf = [System.Drawing.Font]::new('Segoe UI',8,[System.Drawing.FontStyle]::Bold)
                $ctb = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::White)
                $cLabel = $cg.Label
                if ($cg.ExtIP) { $cLabel += "  ($($cg.ExtIP))" }
                _CenterStr $G $cLabel $ctf $ctb ($clX+4) $rowY ($clW-8) $clTH
                $ctf.Dispose(); $ctb.Dispose()

                # Host nodes
                $nShow   = [Math]::Min($hosts.Count,20)
                $hFont1  = [System.Drawing.Font]::new('Segoe UI',6.5)
                $hFont2  = [System.Drawing.Font]::new('Segoe UI',6)
                $hBrDark = [System.Drawing.SolidBrush]::new((_NC 'Dark'))
                $hBrGray = [System.Drawing.SolidBrush]::new((_NC 'Gray'))
                $nx0 = $clX + $clPad; $ny0 = $rowY + $clTH + 4

                for ($hi=0;$hi -lt $nShow;$hi++) {
                    $hr = [int][Math]::Floor($hi/$nPRow); $hc=$hi%$nPRow
                    $nx = $nx0 + $hc*($nodeW+$nGapX); $ny=$ny0+$hr*($nodeH+$nGapY)
                    $hNode = $hosts[$hi]   # avoid $host — read-only PowerShell automatic variable

                    $nPath = _RRect $nx $ny $nodeW $nodeH 4
                    $nBgBr = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(222,236,255))
                    $G.FillPath($nBgBr,$nPath); $nBgBr.Dispose()
                    $nPen  = [System.Drawing.Pen]::new([System.Drawing.Color]::FromArgb(180,205,230),1.0)
                    $G.DrawPath($nPen,$nPath); $nPen.Dispose(); $nPath.Dispose()
                    $G.FillRectangle([System.Drawing.SolidBrush]::new($clClr),$nx+3,$ny+3,$nodeW-6,7)
                    $hn = $hNode.Name; if ($hn -and $hn.Length -gt 14){$hn=$hn.Substring(0,12)+'…'}
                    $sf1=[System.Drawing.StringFormat]::new()
                    $sf1.Alignment=[System.Drawing.StringAlignment]::Center
                    $sf1.Trimming=[System.Drawing.StringTrimming]::EllipsisCharacter
                    $G.DrawString($hn,$hFont1,$hBrDark,[System.Drawing.RectangleF]::new($nx,$ny+12,$nodeW,14),$sf1)
                    $cv = if($hNode.CvmIP){$hNode.CvmIP}elseif($hNode.HypervisorIP){$hNode.HypervisorIP}else{'—'}
                    if ($cv.Length -gt 14){$cv=$cv.Substring(0,12)+'…'}
                    $G.DrawString($cv,$hFont2,$hBrGray,[System.Drawing.RectangleF]::new($nx,$ny+26,$nodeW,13),$sf1)
                    $mem = if($hNode.MemoryGiB){"$($hNode.MemoryGiB) GiB"}else{'—'}
                    $G.DrawString($mem,$hFont2,$hBrDark,[System.Drawing.RectangleF]::new($nx,$ny+$nodeH-14,$nodeW,12),$sf1)
                    $sf1.Dispose()
                }
                if ($hosts.Count -gt $nShow) {
                    $lastRow=[int][Math]::Floor(($nShow-1)/$nPRow)
                    $mY=$ny0+($lastRow+1)*($nodeH+$nGapY)
                    $mf=[System.Drawing.Font]::new('Segoe UI',7,[System.Drawing.FontStyle]::Italic)
                    $mb=[System.Drawing.SolidBrush]::new((_NC 'Gray'))
                    $G.DrawString("+ $($hosts.Count-$nShow) more nodes",$mf,$mb,
                        [System.Drawing.RectangleF]::new($clX+$clPad,$mY,$clW-2*$clPad,16))
                    $mf.Dispose();$mb.Dispose()
                }
                $hFont1.Dispose();$hFont2.Dispose();$hBrDark.Dispose();$hBrGray.Dispose()
                $cIdx++
            }
        }
        $G.Dispose(); $path=_TempPng $bmp; $bmp.Dispose(); return $path
    } catch {
        Write-Verbose "  [TopologyDiagram] $($_.Exception.Message)"
        try{if($G){$G.Dispose()}}catch{}; try{if($bmp){$bmp.Dispose()}}catch{}
        return $null
    }
}

# ?? Diagram 3: Logical Network Map ????????????????????????????????????????????
function New-NtnxNetworkDiagram {
    param([PSCustomObject]$Inv,[int]$MaxSubnets=30)
    try { Add-Type -AssemblyName System.Drawing -EA Stop } catch { return $null }
    try {
        $subnets = @($Inv.Networks.Subnets | Select-Object -First $MaxSubnets)
        if ($subnets.Count -eq 0) { return $null }

        $grps=[ordered]@{}
        foreach ($s in $subnets) {
            $k=if($s.ClusterName){$s.ClusterName}else{'Unassigned'}
            if (-not $grps.Contains($k)){$grps[$k]=[System.Collections.Generic.List[object]]::new()}
            $grps[$k].Add($s)
        }
        $clNames=@($grps.Keys|Sort-Object)
        $nC=$clNames.Count

        $W=780; $titleH=30; $pad=12; $clGap=14
        $cardW=148; $cardH=54; $cGapX=8; $cGapY=8; $clPad=10; $clTH=24
        $colCount=if($nC -le 1){1}elseif($nC -le 4){2}else{3}
        $clW=[int](($W-2*$pad-($colCount-1)*$clGap)/$colCount)

        $clHts=@{}
        foreach ($cl in $clNames) {
            $n=$grps[$cl].Count
            $rows=[int][Math]::Ceiling($n/[Math]::Max(1,[int](($clW-2*$clPad)/($cardW+$cGapX))))
            $clHts[$cl]=$clTH+$clPad+$rows*($cardH+$cGapY)+$clPad
        }
        $rowCount=[int][Math]::Ceiling($nC/$colCount)
        $rowHts=@()
        for ($r=0;$r -lt $rowCount;$r++) {
            $mx=0
            for ($c=0;$c -lt $colCount;$c++) {
                $idx=$r*$colCount+$c
                if ($idx -lt $clNames.Count){$mx=[Math]::Max($mx,$clHts[$clNames[$idx]])}
            }
            $rowHts+=$mx
        }
        $H=$titleH+$pad+($rowHts|Measure-Object -Sum).Sum+($rowCount-1)*$clGap+$pad

        $bmp=[System.Drawing.Bitmap]::new($W,$H); $bmp.SetResolution(150,150)
        $G=[System.Drawing.Graphics]::FromImage($bmp)
        $G.SmoothingMode=[System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
        $G.TextRenderingHint=[System.Drawing.Text.TextRenderingHint]::AntiAlias
        $bgBr=[System.Drawing.SolidBrush]::new((_NC 'BgLight'))
        $G.FillRectangle($bgBr,0,0,$W,$H); $bgBr.Dispose()
        _TitleBar $G 'LOGICAL NETWORK MAP — SUBNETS AND VLANs' $W $titleH

        $cIdx=0
        for ($row=0;$row -lt $rowCount;$row++) {
            $rowY=$titleH+$pad
            for ($ri=0;$ri -lt $row;$ri++){$rowY+=$rowHts[$ri]+$clGap}
            for ($col=0;$col -lt $colCount;$col++) {
                if ($cIdx -ge $clNames.Count){break}
                $clName=$clNames[$cIdx]
                $subs=@($grps[$clName])
                $clClr=_NCP $cIdx
                $clX=$pad+$col*($clW+$clGap)
                $clH=$rowHts[$row]

                $clPath=_RRect $clX $rowY $clW $clH 8
                $clBgBr=[System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(245,250,255))
                $G.FillPath($clBgBr,$clPath); $clBgBr.Dispose()
                $G.SetClip($clPath)
                $G.FillRectangle([System.Drawing.SolidBrush]::new($clClr),$clX,$rowY,$clW,$clTH)
                $G.ResetClip()
                $G.DrawPath([System.Drawing.Pen]::new($clClr,2.0),$clPath); $clPath.Dispose()

                $ctf=[System.Drawing.Font]::new('Segoe UI',8,[System.Drawing.FontStyle]::Bold)
                _CenterStr $G $clName $ctf ([System.Drawing.SolidBrush]::new([System.Drawing.Color]::White)) ($clX+4) $rowY ($clW-8) $clTH
                $ctf.Dispose()

                $cardsPerRow=[Math]::Max(1,[int](($clW-2*$clPad)/($cardW+$cGapX)))
                $cx0=$clX+$clPad; $cy0=$rowY+$clTH+$cGapY
                $sf=[System.Drawing.StringFormat]::new()
                $sf.Trimming=[System.Drawing.StringTrimming]::EllipsisCharacter
                $sf.LineAlignment=[System.Drawing.StringAlignment]::Center
                $cBoldF=[System.Drawing.Font]::new('Segoe UI',7.5,[System.Drawing.FontStyle]::Bold)
                $cSmF  =[System.Drawing.Font]::new('Segoe UI',7)
                $cDkBr =[System.Drawing.SolidBrush]::new((_NC 'Dark'))
                $cGyBr =[System.Drawing.SolidBrush]::new((_NC 'Gray'))

                for ($si=0;$si -lt $subs.Count;$si++) {
                    $sr=[int][Math]::Floor($si/$cardsPerRow); $sc=$si%$cardsPerRow
                    $cx=$cx0+$sc*($cardW+$cGapX); $cy=$cy0+$sr*($cardH+$cGapY)
                    $sub=$subs[$si]

                    $cPath=_RRect $cx $cy $cardW $cardH 5
                    $cardBg=switch($sub.SubnetType){'OVERLAY'{[System.Drawing.Color]::FromArgb(228,240,255)}'VLAN'{[System.Drawing.Color]::FromArgb(232,255,240)}default{[System.Drawing.Color]::White}}
                    $G.FillPath([System.Drawing.SolidBrush]::new($cardBg),$cPath)
                    $G.DrawPath([System.Drawing.Pen]::new($clClr,1.0),$cPath); $cPath.Dispose()
                    $G.FillRectangle([System.Drawing.SolidBrush]::new($clClr),$cx,$cy,4,$cardH)
                    $sn=$sub.Name; if($sn -and $sn.Length -gt 19){$sn=$sn.Substring(0,17)+'…'}
                    $G.DrawString($sn,$cBoldF,$cDkBr,[System.Drawing.RectangleF]::new($cx+8,$cy+4,$cardW-12,15),$sf)
                    $vStr=if($null -ne $sub.VlanId){"VLAN $($sub.VlanId)"}else{if($sub.SubnetType){$sub.SubnetType}else{'—'}}
                    $G.DrawString($vStr,$cSmF,$cGyBr,[System.Drawing.RectangleF]::new($cx+8,$cy+20,$cardW-12,13),$sf)
                    $G.DrawString($(if($sub.Cidr){$sub.Cidr}else{'—'}),$cSmF,$cDkBr,
                        [System.Drawing.RectangleF]::new($cx+8,$cy+34,$cardW-12,14),$sf)
                }
                $sf.Dispose();$cBoldF.Dispose();$cSmF.Dispose();$cDkBr.Dispose();$cGyBr.Dispose()
                $cIdx++
            }
        }
        $G.Dispose(); $path=_TempPng $bmp; $bmp.Dispose(); return $path
    } catch {
        Write-Verbose "  [NetworkDiagram] $($_.Exception.Message)"
        try{if($G){$G.Dispose()}}catch{}; try{if($bmp){$bmp.Dispose()}}catch{}
        return $null
    }
}

# ?? Diagram 4: Horizontal Bar Chart ??????????????????????????????????????????
# $Items = @( @{Label='...'; Value=123; FormatStr='123 GiB'; Color=[Drawing.Color]} )
function New-NtnxBarChart {
    param([hashtable[]]$Items,[string]$Title='',[int]$BarH=30,[int]$W=780)
    if (-not $Items -or $Items.Count -eq 0) { return $null }
    try { Add-Type -AssemblyName System.Drawing -EA Stop } catch { return $null }
    try {
        $tH=30; $mL=185; $mR=90; $mT=$tH+8; $mB=12; $bGap=6
        $H=$mT+$Items.Count*($BarH+$bGap)+$bGap+$mB
        $bmp=[System.Drawing.Bitmap]::new($W,$H); $bmp.SetResolution(150,150)
        $G=[System.Drawing.Graphics]::FromImage($bmp)
        $G.SmoothingMode=[System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
        $G.TextRenderingHint=[System.Drawing.Text.TextRenderingHint]::AntiAlias
        $G.Clear((_NC 'BgLight')); _TitleBar $G $Title $W $tH

        $maxV=($Items|ForEach-Object{[double]$_.Value}|Measure-Object -Maximum).Maximum
        if ($maxV -le 0){$maxV=1}
        $bAreaW=$W-$mL-$mR
        $lblF=[System.Drawing.Font]::new('Segoe UI',8)
        $valF=[System.Drawing.Font]::new('Segoe UI',7.5,[System.Drawing.FontStyle]::Bold)
        $dkBr=[System.Drawing.SolidBrush]::new((_NC 'Dark'))
        $whBr=[System.Drawing.SolidBrush]::new([System.Drawing.Color]::White)
        $sfR=[System.Drawing.StringFormat]::new()
        $sfR.Alignment=[System.Drawing.StringAlignment]::Far
        $sfR.LineAlignment=[System.Drawing.StringAlignment]::Center
        $sfL=[System.Drawing.StringFormat]::new()
        $sfL.Alignment=[System.Drawing.StringAlignment]::Near
        $sfL.LineAlignment=[System.Drawing.StringAlignment]::Center
        $sfL.Trimming=[System.Drawing.StringTrimming]::EllipsisCharacter

        for ($i=0;$i -lt $Items.Count;$i++) {
            $it=$Items[$i]
            $bY=$mT+$bGap+$i*($BarH+$bGap)
            $bW=[int][Math]::Max(4,([double]$it.Value/$maxV)*$bAreaW)
            $clr=if($it.ContainsKey('Color')){$it.Color}else{(_NC 'Blue')}
            # Track
            $G.FillRectangle([System.Drawing.SolidBrush]::new((_NC 'LGray')),$mL,$bY,$bAreaW,$BarH)
            # Bar gradient
            $bg=[System.Drawing.Drawing2D.LinearGradientBrush]::new(
                [System.Drawing.PointF]::new($mL,$bY),
                [System.Drawing.PointF]::new($mL+$bW,$bY+$BarH),
                $clr,
                [System.Drawing.Color]::FromArgb([Math]::Max(0,$clr.R-40),[Math]::Max(0,$clr.G-30),[Math]::Min(255,$clr.B+30)))
            $G.FillRectangle($bg,$mL,$bY,$bW,$BarH); $bg.Dispose()
            # Label
            $G.DrawString($it.Label,$lblF,$dkBr,[System.Drawing.RectangleF]::new(0,$bY,$mL-8,$BarH),$sfR)
            # Value
            $vs=if($it.ContainsKey('FormatStr')){$it.FormatStr}else{[string]$it.Value}
            if ($bW -gt 55) {
                $G.DrawString($vs,$valF,$whBr,[System.Drawing.RectangleF]::new($mL+6,$bY,$bW-8,$BarH),$sfL)
            } else {
                $G.DrawString($vs,$valF,$dkBr,[System.Drawing.RectangleF]::new($mL+$bW+4,$bY,$mR-4,$BarH),$sfL)
            }
        }
        $lblF.Dispose();$valF.Dispose();$dkBr.Dispose();$whBr.Dispose();$sfR.Dispose();$sfL.Dispose()
        $G.Dispose(); $path=_TempPng $bmp; $bmp.Dispose(); return $path
    } catch {
        Write-Verbose "  [BarChart] $($_.Exception.Message)"
        try{if($G){$G.Dispose()}}catch{}; try{if($bmp){$bmp.Dispose()}}catch{}
        return $null
    }
}

# ?? Diagram 5: Donut / Pie Chart ??????????????????????????????????????????????
# $Slices = @( @{Label='...'; Value=123; Color=[Drawing.Color]} )
function New-NtnxDonutChart {
    param([hashtable[]]$Slices,[string]$Title='',[string]$CenterLabel='',[int]$W=780,[int]$H=220)
    if (-not $Slices -or $Slices.Count -eq 0) { return $null }
    $total=($Slices|ForEach-Object{[double]$_.Value}|Measure-Object -Sum).Sum
    if ($total -le 0) { return $null }
    try { Add-Type -AssemblyName System.Drawing -EA Stop } catch { return $null }
    try {
        $bmp=[System.Drawing.Bitmap]::new($W,$H); $bmp.SetResolution(150,150)
        $G=[System.Drawing.Graphics]::FromImage($bmp)
        $G.SmoothingMode=[System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
        $G.TextRenderingHint=[System.Drawing.Text.TextRenderingHint]::AntiAlias
        $tH=30
        $G.Clear((_NC 'BgLight')); _TitleBar $G $Title $W $tH

        $diam=[Math]::Min($H-$tH-16,160); $pieX=40; $pieY=$tH+([int](($H-$tH-$diam)/2))
        $start=-90.0
        foreach ($sl in $Slices) {
            $sweep=[float](($sl.Value/$total)*360.0)
            $G.FillPie([System.Drawing.SolidBrush]::new($sl.Color),$pieX,$pieY,$diam,$diam,$start,$sweep)
            $start+=$sweep
        }
        $ho=[int]($diam*0.34)
        $G.FillEllipse([System.Drawing.SolidBrush]::new((_NC 'BgLight')),$pieX+$ho,$pieY+$ho,$diam-2*$ho,$diam-2*$ho)
        $cf=[System.Drawing.Font]::new('Segoe UI',13,[System.Drawing.FontStyle]::Bold)
        $cf2=[System.Drawing.Font]::new('Segoe UI',7)
        $cBr=[System.Drawing.SolidBrush]::new((_NC 'Dark'))
        $cGy=[System.Drawing.SolidBrush]::new((_NC 'Gray'))
        _CenterStr $G $CenterLabel $cf $cBr ($pieX+$ho) ($pieY+$ho) ($diam-2*$ho) (($diam-2*$ho)/2)
        _CenterStr $G 'total' $cf2 $cGy ($pieX+$ho) ($pieY+$ho+($diam-2*$ho)/2) ($diam-2*$ho) (($diam-2*$ho)/2)
        $cf.Dispose();$cf2.Dispose();$cBr.Dispose();$cGy.Dispose()

        $legX=$pieX+$diam+32; $legY=$pieY+($diam/2)-($Slices.Count*26/2)
        $lf1=[System.Drawing.Font]::new('Segoe UI',8.5)
        $lf2=[System.Drawing.Font]::new('Segoe UI',7.5)
        $dkBr=[System.Drawing.SolidBrush]::new((_NC 'Dark')); $gyBr=[System.Drawing.SolidBrush]::new((_NC 'Gray'))
        for ($i=0;$i -lt $Slices.Count;$i++) {
            $sl=$Slices[$i]; $lY=$legY+$i*26
            $sp=_RRect $legX $lY 14 14 3
            $G.FillPath([System.Drawing.SolidBrush]::new($sl.Color),$sp); $sp.Dispose()
            $pct=[Math]::Round($sl.Value/$total*100,1)
            $G.DrawString($sl.Label,$lf1,$dkBr,[System.Drawing.PointF]::new($legX+20,$lY))
            $G.DrawString("$($sl.Value)  ($pct%)",$lf2,$gyBr,[System.Drawing.PointF]::new($legX+20,$lY+12))
        }
        $lf1.Dispose();$lf2.Dispose();$dkBr.Dispose();$gyBr.Dispose()
        $G.Dispose(); $path=_TempPng $bmp; $bmp.Dispose(); return $path
    } catch {
        Write-Verbose "  [DonutChart] $($_.Exception.Message)"
        try{if($G){$G.Dispose()}}catch{}; try{if($bmp){$bmp.Dispose()}}catch{}
        return $null
    }
}

# ?? Diagram 6: Stacked Storage Bar (Used + Free per container) ????????????????
function New-NtnxStorageChart {
    param([PSCustomObject[]]$Containers,[int]$W=780)
    if (-not $Containers -or $Containers.Count -eq 0) { return $null }
    $data=@($Containers | Where-Object { $_.TotalGiB -and [double]$_.TotalGiB -gt 0 })
    if ($data.Count -eq 0) { return $null }
    try { Add-Type -AssemblyName System.Drawing -EA Stop } catch { return $null }
    try {
        $tH=30; $legH=24; $mL=185; $mR=70; $mT=$tH+$legH+8; $mB=12; $bH=28; $bGap=6
        $H=$mT+$data.Count*($bH+$bGap)+$bGap+$mB
        $bmp=[System.Drawing.Bitmap]::new($W,$H); $bmp.SetResolution(150,150)
        $G=[System.Drawing.Graphics]::FromImage($bmp)
        $G.SmoothingMode=[System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
        $G.TextRenderingHint=[System.Drawing.Text.TextRenderingHint]::AntiAlias
        $G.Clear((_NC 'BgLight')); _TitleBar $G 'STORAGE CONTAINER CAPACITY (GiB)' $W $tH

        $lfont=[System.Drawing.Font]::new('Segoe UI',7.5)
        $lItems=@(@{T='Used';C=(_NC 'Cyan')},@{T='Free';C=(_NC 'LGray')})
        $lx=$mL
        foreach ($li in $lItems) {
            $G.FillRectangle([System.Drawing.SolidBrush]::new($li.C),$lx,$tH+5,14,14)
            $G.DrawString($li.T,$lfont,[System.Drawing.SolidBrush]::new((_NC 'Dark')),[System.Drawing.PointF]::new($lx+18,$tH+5))
            $lx+=65
        }
        $lfont.Dispose()

        $maxV=($data|ForEach-Object{[double]$_.TotalGiB}|Measure-Object -Maximum).Maximum
        if ($maxV -le 0){$maxV=1}
        $bAreaW=$W-$mL-$mR
        $lblF=[System.Drawing.Font]::new('Segoe UI',7.5); $valF=[System.Drawing.Font]::new('Segoe UI',6.5,[System.Drawing.FontStyle]::Bold)
        $dkBr=[System.Drawing.SolidBrush]::new((_NC 'Dark')); $whBr=[System.Drawing.SolidBrush]::new([System.Drawing.Color]::White)
        $sfR=[System.Drawing.StringFormat]::new(); $sfR.Alignment=[System.Drawing.StringAlignment]::Far; $sfR.LineAlignment=[System.Drawing.StringAlignment]::Center
        $sfL=[System.Drawing.StringFormat]::new(); $sfL.Alignment=[System.Drawing.StringAlignment]::Near; $sfL.LineAlignment=[System.Drawing.StringAlignment]::Center

        for ($i=0;$i -lt $data.Count;$i++) {
            $ct=$data[$i]; $bY=$mT+$bGap+$i*($bH+$bGap)
            $tot=[double]$ct.TotalGiB; $used=if($ct.UsedGiB){[double]$ct.UsedGiB}else{0}
            $free=$tot-$used; if($free -lt 0){$free=0}
            $usedW=[int][Math]::Max(0,($used/$maxV)*$bAreaW)
            $freeW=[int][Math]::Max(0,($free/$maxV)*$bAreaW)
            $G.FillRectangle([System.Drawing.SolidBrush]::new((_NC 'LGray')),$mL,$bY,$bAreaW,$bH)
            if ($usedW -gt 0){$G.FillRectangle([System.Drawing.SolidBrush]::new((_NC 'Cyan')),$mL,$bY,$usedW,$bH)}
            if ($freeW -gt 0){$G.FillRectangle([System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(200,215,235)),$mL+$usedW,$bY,$freeW,$bH)}
            # Label
            $n=$ct.Name; if($n -and $n.Length -gt 24){$n=$n.Substring(0,22)+'…'}
            $G.DrawString($n,$lblF,$dkBr,[System.Drawing.RectangleF]::new(0,$bY,$mL-8,$bH),$sfR)
            # Value inside used bar
            $vs="$([int]$used) / $([int]$tot) GiB"
            if ($usedW -gt 80){$G.DrawString($vs,$valF,$whBr,[System.Drawing.RectangleF]::new($mL+4,$bY,$usedW-6,$bH),$sfL)}
            else{$G.DrawString($vs,$valF,$dkBr,[System.Drawing.RectangleF]::new($mL+$usedW+4,$bY,$mR-4,$bH),$sfL)}
        }
        $lblF.Dispose();$valF.Dispose();$dkBr.Dispose();$whBr.Dispose();$sfR.Dispose();$sfL.Dispose()
        $G.Dispose(); $path=_TempPng $bmp; $bmp.Dispose(); return $path
    } catch {
        Write-Verbose "  [StorageChart] $($_.Exception.Message)"
        try{if($G){$G.Dispose()}}catch{}; try{if($bmp){$bmp.Dispose()}}catch{}
        return $null
    }
}

# ?????????????????????????????????????????????????????????????????????????????
#  Nutanix Branded Cover Banner  (System.Drawing)
# ?????????????????????????????????????????????????????????????????????????????
function New-NtnxCoverBanner {
    param([string]$PcIP='',[string]$CoName='',[string]$CollectedAt='')
    try { Add-Type -AssemblyName System.Drawing -EA Stop } catch { return $null }
    try {
        [int]$W    = 780
        [int]$topH = 260   # dark navy header
        [int]$midH = 180   # white info section
        [int]$botH = 44    # brand-blue footer bar
        [int]$H    = $topH + $midH + $botH

        $bmp = [System.Drawing.Bitmap]::new($W, $H)
        $bmp.SetResolution(150, 150)
        $G   = [System.Drawing.Graphics]::FromImage($bmp)
        $G.SmoothingMode    = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
        $G.TextRenderingHint= [System.Drawing.Text.TextRenderingHint]::AntiAlias

        # ?? Top section: navy ? deep-blue gradient ????????????????????????????
        $topBrush = [System.Drawing.Drawing2D.LinearGradientBrush]::new(
            [System.Drawing.Point]::new(0,0),
            [System.Drawing.Point]::new($W,0),
            [System.Drawing.Color]::FromArgb(27,38,49),
            [System.Drawing.Color]::FromArgb(4,62,128))
        $G.FillRectangle($topBrush, 0, 0, $W, $topH)
        $topBrush.Dispose()

        # ?? "NUTANIX" logotype ????????????????????????????????????????????????
        $logoF  = [System.Drawing.Font]::new('Segoe UI', 52, [System.Drawing.FontStyle]::Bold)
        $whitBr = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::White)
        $cyanBr = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(0,163,224))
        $ltBlBr = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(176,205,235))
        $sfC    = [System.Drawing.StringFormat]::new()
        $sfC.Alignment = [System.Drawing.StringAlignment]::Center
        $sfC.LineAlignment = [System.Drawing.StringAlignment]::Center

        $G.DrawString('NUTANIX', $logoF,  $whitBr, [System.Drawing.RectangleF]::new(0,18,$W,90), $sfC)
        $logoF.Dispose()

        # ?? Cyan accent bar ???????????????????????????????????????????????????
        $G.FillRectangle($cyanBr, 40, 118, $W-80, 3)

        # ?? Subtitle lines ????????????????????????????????????????????????????
        $subF1 = [System.Drawing.Font]::new('Segoe UI', 13, [System.Drawing.FontStyle]::Bold)
        $subF2 = [System.Drawing.Font]::new('Segoe UI', 9)
        $G.DrawString('Infrastructure Documentation Report', $subF1, $whitBr,
            [System.Drawing.RectangleF]::new(0,130,$W,34), $sfC)
        $G.DrawString('Generated by Nutanix Documentation Script v5.0', $subF2, $ltBlBr,
            [System.Drawing.RectangleF]::new(0,172,$W,28), $sfC)
        $subF1.Dispose(); $subF2.Dispose()

        # ?? Stylised "N" mark (left side) ????????????????????????????????????
        # Draw a minimal geometric chevron referencing the Nutanix mark
        $nPath = [System.Drawing.Drawing2D.GraphicsPath]::new()
        [int]$nx = 38; [int]$ny = 22; [int]$ns = 70
        $nPath.AddPolygon([System.Drawing.Point[]]@(
            [System.Drawing.Point]::new($nx,         $ny+$ns),
            [System.Drawing.Point]::new($nx,         $ny),
            [System.Drawing.Point]::new($nx+$ns/3,   $ny),
            [System.Drawing.Point]::new($nx+2*$ns/3, $ny+$ns*2/3),
            [System.Drawing.Point]::new($nx+2*$ns/3, $ny),
            [System.Drawing.Point]::new($nx+$ns,     $ny),
            [System.Drawing.Point]::new($nx+$ns,     $ny+$ns),
            [System.Drawing.Point]::new($nx+2*$ns/3, $ny+$ns),
            [System.Drawing.Point]::new($nx+$ns/3,   $ny+$ns/3),
            [System.Drawing.Point]::new($nx+$ns/3,   $ny+$ns)
        ))
        $nBr = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(60,255,255,255))
        $G.FillPath($nBr, $nPath)
        $nBr.Dispose(); $nPath.Dispose()

        # ?? Middle section: off-white info area ???????????????????????????????
        $midBr  = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(246,248,252))
        $G.FillRectangle($midBr, 0, $topH, $W, $midH); $midBr.Dispose()
        # Cyan left accent stripe
        $G.FillRectangle($cyanBr, 0, $topH, 5, $midH)
        # Subtle bottom border on mid section
        $borderBr = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(200,210,230))
        $G.FillRectangle($borderBr, 0, $topH+$midH-1, $W, 1); $borderBr.Dispose()

        $lblF   = [System.Drawing.Font]::new('Segoe UI', 10, [System.Drawing.FontStyle]::Bold)
        $valF   = [System.Drawing.Font]::new('Segoe UI', 10)
        $bluBr  = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(2,77,161))
        $drkBr  = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(27,38,49))
        $sfL    = [System.Drawing.StringFormat]::new()
        $sfL.LineAlignment = [System.Drawing.StringAlignment]::Center

        [int]$lx = 28; [int]$ry = $topH + 18
        $iPcIP   = if ($PcIP)        { $PcIP }        else { '—' }
        $iCo     = if ($CoName)      { $CoName }      else { '—' }
        $iDate   = if ($CollectedAt) { $CollectedAt } else { Get-Date -f 'yyyy-MM-dd HH:mm' }
        $iUser   = if ($env:USERNAME){ $env:USERNAME } else { '—' }
        @(
            @('Prism Central:',    $iPcIP),
            @('Organization:',     $iCo),
            @('Report Generated:', $iDate),
            @('Prepared by:',      $iUser)
        ) | ForEach-Object {
            $G.DrawString($_[0], $lblF, $bluBr, [System.Drawing.RectangleF]::new($lx,    $ry, 200, 28), $sfL)
            $G.DrawString($_[1], $valF, $drkBr, [System.Drawing.RectangleF]::new($lx+210,$ry, $W-$lx-220, 28), $sfL)
            $ry += 34
        }
        $lblF.Dispose(); $valF.Dispose(); $bluBr.Dispose(); $drkBr.Dispose(); $sfL.Dispose()

        # ?? Bottom bar: brand blue ?????????????????????????????????????????????
        $botBr = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(2,77,161))
        $G.FillRectangle($botBr, 0, $topH+$midH, $W, $botH); $botBr.Dispose()
        $confF  = [System.Drawing.Font]::new('Segoe UI', 8)
        $G.DrawString('NUTANIX, INC.  —  CONFIDENTIAL', $confF, $ltBlBr,
            [System.Drawing.RectangleF]::new(0,$topH+$midH,$W,$botH), $sfC)
        $confF.Dispose()

        $sfC.Dispose(); $whitBr.Dispose(); $cyanBr.Dispose(); $ltBlBr.Dispose()
        $G.Dispose()
        $path = _TempPng $bmp; $bmp.Dispose()
        return $path
    } catch {
        Write-Verbose "  [CoverBanner] $($_.Exception.Message)"
        try { if ($G)   { $G.Dispose()   } } catch {}
        try { if ($bmp) { $bmp.Dispose() } } catch {}
        return $null
    }
}

# ???????????????????????????????????????????????????????????????????????????????
#  PART 1 ?? Word Infrastructure Functions  (from v3; unchanged)
# ???????????????????????????????????????????????????????????????????????????????

Function SetWordHashTable {
    Param([string]$CultureCode)
    [string]$toc = $(Switch ($CultureCode) {
        'ca-' { 'Taula automática 2'; Break }
        'da-' { 'Automatisk tabel 2'; Break }
        'de-' { 'Automatische Tabelle 2'; Break }
        'en-' { 'Automatic Table 2'; Break }
        'es-' { 'Tabla automática 2'; Break }
        'fi-' { 'Automaattinen taulukko 2'; Break }
        'fr-' { 'Table automatique 2'; Break }
        'nb-' { 'Automatisk tabell 2'; Break }
        'nl-' { 'Automatische inhoudsopgave 2'; Break }
        'pt-' { 'Sumário Automático 2'; Break }
        'sv-' { 'Automatisk innehållsförteckning2'; Break }
        'zh-' { '???? 2'; Break }
    })
    $Script:myHash                      = @{}
    $Script:myHash.Word_TableOfContents = $toc
    $Script:myHash.Word_NoSpacing       = $wdStyleNoSpacing
    $Script:myHash.Word_Heading1        = $wdStyleHeading1
    $Script:myHash.Word_Heading2        = $wdStyleHeading2
    $Script:myHash.Word_Heading3        = $wdStyleHeading3
    $Script:myHash.Word_Heading4        = $wdStyleHeading4
    $Script:myHash.Word_TableGrid       = $wdTableGrid
}

Function GetCulture {
    Param([int]$WordValue)
    $CatalanArray    = 1027
    $ChineseArray    = 2052,3076,5124,4100
    $DanishArray     = 1030
    $DutchArray      = 2067,1043
    $EnglishArray    = 3081,10249,4105,9225,6153,8201,5129,13321,7177,11273,2057,1033,12297
    $FinnishArray    = 1035
    $FrenchArray     = 2060,1036,11276,3084,12300,5132,13324,6156,8204,10252,7180,9228,4108
    $GermanArray     = 1031,3079,5127,4103,2055
    $NorwegianArray  = 1044,2068
    $PortugueseArray = 1046,2070
    $SpanishArray    = 1034,11274,16394,13322,9226,5130,7178,12298,17418,4106,18442,19466,6154,15370,10250,20490,3082,14346,8202
    $SwedishArray    = 1053,2077
    Switch ($WordValue) {
        {$CatalanArray    -contains $_} { $CultureCode = 'ca-' }
        {$ChineseArray    -contains $_} { $CultureCode = 'zh-' }
        {$DanishArray     -contains $_} { $CultureCode = 'da-' }
        {$DutchArray      -contains $_} { $CultureCode = 'nl-' }
        {$EnglishArray    -contains $_} { $CultureCode = 'en-' }
        {$FinnishArray    -contains $_} { $CultureCode = 'fi-' }
        {$FrenchArray     -contains $_} { $CultureCode = 'fr-' }
        {$GermanArray     -contains $_} { $CultureCode = 'de-' }
        {$NorwegianArray  -contains $_} { $CultureCode = 'nb-' }
        {$PortugueseArray -contains $_} { $CultureCode = 'pt-' }
        {$SpanishArray    -contains $_} { $CultureCode = 'es-' }
        {$SwedishArray    -contains $_} { $CultureCode = 'sv-' }
        Default                         { $CultureCode = 'en-' }
    }
    Return $CultureCode
}

Function ValidateCoverPage {
    Param([int]$xWordVersion, [string]$xCP, [string]$CultureCode)
    $xArray = ''
    Switch ($CultureCode) {
        'en-' {
            If ($xWordVersion -eq $wdWord2013 -or $xWordVersion -eq $wdWord2016) {
                $xArray = ('Austin','Banded','Facet','Filigree','Grid','Integral',
                           'Ion (Dark)','Ion (Light)','Motion','Retrospect','Semaphore',
                           'Sideline','Slice (Dark)','Slice (Light)','ViewMaster','Whisp')
            } ElseIf ($xWordVersion -eq $wdWord2010) {
                $xArray = ('Alphabet','Annual','Austere','Austin','Conservative','Contrast',
                           'Cubicles','Exposure','Grid','Mod','Motion','Newsprint','Perspective',
                           'Pinstripes','Puzzle','Sideline','Stacks','Tiles','Transcend')
            }
        }
        Default {
            If ($xWordVersion -eq $wdWord2013 -or $xWordVersion -eq $wdWord2016) {
                $xArray = ('Austin','Banded','Facet','Filigree','Grid','Integral',
                           'Ion (Dark)','Ion (Light)','Motion','Retrospect','Semaphore',
                           'Sideline','Slice (Dark)','Slice (Light)','ViewMaster','Whisp')
            } ElseIf ($xWordVersion -eq $wdWord2010) {
                $xArray = ('Alphabet','Annual','Austere','Austin','Conservative','Contrast',
                           'Cubicles','Exposure','Grid','Mod','Motion','Newsprint','Perspective',
                           'Pinstripes','Puzzle','Sideline','Stacks','Tiles','Transcend')
            }
        }
    }
    If ($xArray -contains $xCP) { $xArray = $Null; Return $True }
    Else                         { $xArray = $Null; Return $False }
}

Function CheckWordPrereq {
    If ((Test-Path REGISTRY::HKEY_CLASSES_ROOT\Word.Application) -eq $False) {
        $ErrorActionPreference = $SaveEAPreference
        Write-Host "`n`n`t`tThis script directly outputs to Microsoft Word, please install Microsoft Word`n`n"
        Exit
    }
    $SessionID = (Get-Process -PID $PID).SessionId
    [bool]$wordrunning = ((Get-Process 'WinWord' -ea 0) | Where-Object { $_.SessionId -eq $SessionID }) -ne $Null
    If ($wordrunning) {
        $ErrorActionPreference = $SaveEAPreference
        Write-Host "`n`n`tPlease close all instances of Microsoft Word before running this report.`n`n"
        Exit
    }
}

Function ValidateCompanyName {
    [bool]$xResult = Test-RegistryValue 'HKCU:\Software\Microsoft\Office\Common\UserInfo' 'CompanyName'
    If ($xResult) { Return Get-RegistryValue 'HKCU:\Software\Microsoft\Office\Common\UserInfo' 'CompanyName' }
    Else {
        $xResult = Test-RegistryValue 'HKCU:\Software\Microsoft\Office\Common\UserInfo' 'Company'
        If ($xResult) { Return Get-RegistryValue 'HKCU:\Software\Microsoft\Office\Common\UserInfo' 'Company' }
        Else { Return '' }
    }
}

Function Test-RegistryValue($path, $name) {
    $key = Get-Item -LiteralPath $path -EA 0
    $key -and $Null -ne $key.GetValue($name, $Null)
}

Function Get-RegistryValue($path, $name) {
    $key = Get-Item -LiteralPath $path -EA 0
    If ($key) { $key.GetValue($name, $Null) } Else { $Null }
}

Function WriteWordLine {
    Param([int]$style = 0, [int]$tabs = 0, [string]$name = '', [string]$value = '',
          [string]$fontName = $Null, [int]$fontSize = 0,
          [bool]$italics = $False, [bool]$boldface = $False, [Switch]$nonewline)
    [string]$output = ''
    Switch ($style) {
        0       { $Script:Selection.Style = $myHash.Word_NoSpacing }
        1       { $Script:Selection.Style = $myHash.Word_Heading1  }
        2       { $Script:Selection.Style = $myHash.Word_Heading2  }
        3       { $Script:Selection.Style = $myHash.Word_Heading3  }
        4       { $Script:Selection.Style = $myHash.Word_Heading4  }
        Default { $Script:Selection.Style = $myHash.Word_NoSpacing }
    }
    While ($tabs -gt 0) { $output += "`t"; $tabs-- }
    If (![String]::IsNullOrEmpty($fontName)) { $Script:Selection.Font.name  = $fontName }
    If ($fontSize -ne 0)                     { $Script:Selection.Font.size  = $fontSize }
    If ($italics -eq $True)                  { $Script:Selection.Font.Italic = $True }
    If ($boldface -eq $True)                 { $Script:Selection.Font.Bold  = $True }
    $output += $name + $value
    $Script:Selection.TypeText($output)
    If (-not $nonewline) { $Script:Selection.TypeParagraph() }
}

Function _SetDocumentProperty {
    Param([object]$Properties,[string]$Name,[string]$Value)
    $Prop = $Properties | Where-Object { $_.Name -eq $Name }
    If ($Null -eq $Prop) {
        $Properties.Add($Name,$Value) | Out-Null
    } Else {
        $Prop.Value = $Value
    }
}

Function AddWordTable {
    Param([System.Collections.Hashtable[]]$Hashtable,[string[]]$Columns,
          [int]$Format=$wdTableGrid,[int]$AutoFit=$wdAutoFitContent,
          [bool]$AutoFitMaxWidth=$False)
    If ($Null -eq $Hashtable -or $Hashtable.Count -eq 0) { Return $Null }
    $Headers = $Columns
    [int]$Rows    = $Hashtable.Count + 1
    [int]$Cols    = $Headers.Count
    $Table = $Script:Document.Tables.Add($Script:Selection.Range,$Rows,$Cols)
    $Table.Style = $Format
    $Table.ApplyStyleHeadingRows  = $False   # we apply colours manually below
    $Table.ApplyStyleFirstColumn  = $False
    $Table.ApplyStyleLastRow      = $False
    $Table.ApplyStyleLastColumn   = $False
    $Table.ApplyStyleRowBands     = $False   # disable theme row-banding (prevents pink/red rows)
    $Table.AutoFitBehavior($AutoFit)

    # Header row: Nutanix Blue (#024DA1 BGR=10571010), white bold text
    [int]$xRow = 1
    For ($i=0;$i -lt $Cols;$i++) {
        $hdrCell = $Table.Cell($xRow,$i+1)
        $hdrCell.Range.Text       = $Headers[$i]
        $hdrCell.Range.Bold       = $true
        $hdrCell.Range.Font.Color = 16777215   # White
        try { $hdrCell.Shading.BackgroundPatternColor = 10571010 } catch {}   # Nutanix Blue
    }
    $xRow++

    # Data rows — alternate White / very light blue (#EAF4FC BGR=16577770)
    [int]$dataIdx = 0
    ForEach ($HashRow in $Hashtable) {
        [int]$rowClr = if ($dataIdx % 2 -eq 1) { 16577770 } else { 16777215 }   # alt light-blue / white
        For ($i=0;$i -lt $Cols;$i++) {
            $Key  = $Headers[$i]
            $Val  = $HashRow[$Key]
            $cell = $Table.Cell($xRow,$i+1)
            $cell.Range.Text = If ($Null -eq $Val) { '' } Else { [string]$Val }
            try { $cell.Shading.BackgroundPatternColor = $rowClr } catch {}
        }
        $xRow++; $dataIdx++
    }
    $Script:Selection.MoveDown() | Out-Null
    Return $Table
}

Function SetWordCellFormat {
    Param([object]$Cell,[bool]$Bold=$False,[bool]$Italic=$False,
          [int]$BackgroundColor=-1,[bool]$DisableWrap=$False)
    If ($Bold)            { $Cell.Range.Bold = $True }
    If ($Italic)          { $Cell.Range.Italic = $True }
    If ($BackgroundColor -ge 0) { $Cell.Shading.BackgroundPatternColor = $BackgroundColor }
    If ($DisableWrap)     { $Cell.WordWrap = $False }
}

Function validStateProp {
    Param([object]$Object,[string]$TopLevel,[string]$SecondLevel)
    If ($Object.PSObject.Properties[$TopLevel]) {
        If ($Object.$TopLevel.PSObject.Properties[$SecondLevel]) { Return $True }
    }
    Return $False
}

Function AbortScript {
    $Script:Word.quit()
    Write-Verbose "$(Get-Date): System Cleanup"
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($Script:Word) | Out-Null
    If (Test-Path variable:global:word) { Remove-Variable -Name word -Scope Global }
    $ErrorActionPreference = $SaveEAPreference
    Exit
}

Function FindWordDocumentEnd {
    $Script:Selection.EndKey($wdStory,$wdMove) | Out-Null
}

Function SetupWord {
    Write-Verbose "$(Get-Date): Setting up Word"
    If (Test-Path REGISTRY::HKEY_CLASSES_ROOT\Word.Application) {
        $Script:Word = New-Object -comobject "Word.Application" -EA 0
    }
    If ($Null -eq $Script:Word) {
        Write-Error "An instance of Microsoft Word could not be created."
        AbortScript
    }
    [int]$Script:WordVersion = [int]$Script:Word.Version
    SetWordHashTable $(GetCulture $Script:Word.Language)
    $Script:Word.Visible = $False
    $Script:Document     = $Script:Word.Documents.Add()
    $Script:Selection    = $Script:Word.Selection
    If ($Script:WordVersion -ne $wdWord2007) {
        $Script:Word.ActiveDocument.ChangeTheme('Office Theme') 2>$Null
    }
    # Cover page, TOC, header/footer and brand styles are applied by Write-NtnxWordReport
    FindWordDocumentEnd
}

Function UpdateDocumentProperties {
    Param([string]$AbstractTitle,[string]$SubjectTitle)
    [string]$xTitle    = $Script:Title
    [string]$xAuthor   = $UserName
    [string]$xSubject  = $SubjectTitle
    [string]$xAbstract = $AbstractTitle
    [string]$xCompany  = $Script:CoName
    $Props = $Script:Word.ActiveDocument.BuiltInDocumentProperties
    _SetDocumentProperty $Props 'Title'    $xTitle
    _SetDocumentProperty $Props 'Author'   $xAuthor
    _SetDocumentProperty $Props 'Subject'  $xSubject
    _SetDocumentProperty $Props 'Company'  $xCompany
}

Function SaveandCloseDocumentandShutdownWord {
    Write-Verbose "$(Get-Date): Saving and closing document"
    If ($PDF) {
        $Script:Word.ActiveDocument.ExportAsFixedFormat($Script:FileName2,$wdFormatPDF,$False,$False,$False,$False,$False)
    }
    $Script:Word.ActiveDocument.SaveAs2($Script:FileName1,$wdFormatDocumentDefault)
    $Script:Word.quit()
    Write-Verbose "$(Get-Date): System Cleanup"
    [System.Runtime.Interopservices.Marshal]::ReleaseComObject($Script:Word) | Out-Null
    If (Test-Path variable:global:word) { Remove-Variable -Name word -Scope Global }
}

Function SetFileName1andFileName2 {
    Param([string]$OutputFileName)
    $Script:FileName1 = ''
    $Script:FileName2 = ''
    If ($AddDateTime) { $OutputFileName += "_$(Get-Date -f yyyy-MM-dd_HHmm)" }
    If ($PDF) {
        $Script:FileName1 = "$Script:pwdpath\$($OutputFileName).docx"
        $Script:FileName2 = "$Script:pwdpath\$($OutputFileName).pdf"
    } Else {
        $Script:FileName1 = "$Script:pwdpath\$($OutputFileName).docx"
    }
    Write-Verbose "$(Get-Date): FileName1 = $Script:FileName1"
    If ($PDF) { Write-Verbose "$(Get-Date): FileName2 = $Script:FileName2" }
}

Function SendEmail {
    Param([string]$Attachments)
    $emailAttachment = $Attachments
    $emailFrom       = $From
    $emailTo         = $To
    $emailBody       = 'Attached is the Nutanix Documentation Report'
    $emailSubject    = 'Nutanix Documentation Report'
    $emailSmtp       = $SmtpServer
    $emailPort       = $SmtpPort
    $msg = New-Object System.Net.Mail.MailMessage
    $msg.From       = $emailFrom
    $msg.ReplyTo    = $emailFrom
    $msg.To.Add($emailTo)
    $msg.Body       = $emailBody
    $msg.Subject    = $emailSubject
    $msg.IsBodyHtml = $False
    $msg.Attachments.Add($emailAttachment)
    $smtp = New-Object System.Net.Mail.SmtpClient($emailSmtp,$emailPort)
    $smtp.EnableSsl = $UseSSL
    $smtp.Send($msg)
}

# ???????????????????????????????????????????????????????????????????????????????
#  PART 2 ?? TLS / Certificate Setup
# ???????????????????????????????????????????????????????????????????????????????

[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12

If ($PSVersionTable.PSVersion.Major -lt 6) {
    if (-not ([System.Management.Automation.PSTypeName]'NtnxTrustAll').Type) {
        Add-Type -TypeDefinition @"
using System.Net; using System.Security.Cryptography.X509Certificates;
public class NtnxTrustAll : ICertificatePolicy {
    public bool CheckValidationResult(ServicePoint sp, X509Certificate cert,
        WebRequest req, int problem) { return true; }
}
"@
    }
    [System.Net.ServicePointManager]::CertificatePolicy = New-Object NtnxTrustAll
}

# ???????????????????????????????????????????????????????????????????????????????
#  PART 3 ?? Security Helper
# ???????????????????????????????????????????????????????????????????????????????

function New-NtnxAuthHeader {
    param([System.Management.Automation.PSCredential]$Cred)
    $bstr  = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Cred.Password)
    try {
        $plain = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
        $b64   = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("$($Cred.UserName):$plain"))
        return @{ Authorization = "Basic $b64" }
    } finally {
        [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
    }
}

# ???????????????????????????????????????????????????????????????????????????????
#  PART 4 ?? Core API Engine
# ???????????????????????????????????????????????????????????????????????????????

function Invoke-NtnxApi {
    param([string]$Uri,[string]$Method='GET',[object]$Body=$null,[int]$Attempt=0)
    $headers = New-NtnxAuthHeader -Cred $Script:ActiveCred
    $headers['Content-Type'] = 'application/json'
    $p = @{ Uri=$Uri; Method=$Method; Headers=$headers; TimeoutSec=120; ErrorAction='Stop' }
    if ($Script:SkipCert)    { $p.SkipCertificateCheck = $true }
    if ($null -ne $Body)     { $p.Body = ($Body | ConvertTo-Json -Depth 10 -Compress) }
    try {
        return Invoke-RestMethod @p -Verbose:$false
    } catch {
        $msg = $_.Exception.Message
        if ($msg -match '(5[0-9][0-9]|429|connection)' -and $Attempt -lt $Script:MaxRetries) {
            $wait = [Math]::Pow(2,$Attempt+1)
            Write-Verbose "  Retry $($Attempt+1) for $Uri in ${wait}s — $msg"
            Start-Sleep -Seconds $wait
            return Invoke-NtnxApi -Uri $Uri -Method $Method -Body $Body -Attempt ($Attempt+1)
        }
        if ($msg -match '(404|400|401|403|422)') {
            Write-Verbose "$(Get-Date -f 'HH:mm:ss'): $Method [$Uri]: $msg — skipping."
        } else {
            Write-Warning "$(Get-Date -f 'HH:mm:ss'): $Method [$Uri] failed: $msg"
        }
        return $null
    }
}

function _ResolveEntities {
    param([object]$Response)
    if ($null -eq $Response) { return ,([object[]]@()) }
    if ($Response -is [System.Array]) { return ,([object[]]$Response) }
    foreach ($__field in @('entities','data','results','items','alert_list','alert_summaries')) {
        $__p = $Response.PSObject.Properties[$__field]
        if ($__p -and $null -ne $__p.Value) {
            $__v = $__p.Value
            if ($__v -is [System.Array]) { return ,([object[]]$__v) }
            else                         { return ,([object[]]@($__v)) }
        }
    }
    return ,([object[]]@())
}

function Get-NtnxV3List {
    [OutputType([System.Collections.Generic.List[object]])]
    param([string]$Endpoint,[string]$Kind,[int]$PageSize=500,[hashtable]$Filter=@{})
    $all=[System.Collections.Generic.List[object]]::new()
    $offset=0; $total=[int]::MaxValue
    while ($all.Count -lt $total) {
        $body=@{kind=$Kind;length=$PageSize;offset=$offset}+$Filter
        $r=Invoke-NtnxApi -Uri "$Script:ApiBaseV3/$Endpoint" -Method 'POST' -Body $body
        if (-not $r) { break }
        $batch=_ResolveEntities $r
        if ($batch.Count -gt 0) { $all.AddRange([object[]]$batch) }
        $mp=$r.PSObject.Properties['metadata']
        if ($mp -and $mp.Value) {
            $tmp=$mp.Value.PSObject.Properties['total_matches']
            if ($tmp -and $null -ne $tmp.Value){$total=[int]$tmp.Value}else{$total=$all.Count}
        } else { $total=$all.Count }
        $offset+=$batch.Count
        if ($batch.Count -lt $PageSize) { break }
    }
    return $all
}

function Get-NtnxV4List {
    [OutputType([System.Collections.Generic.List[object]])]
    param([string]$Namespace,[string]$Resource,[string]$Version='v4.0',[int]$PageSize=50,
          [string]$Expand='',[int]$InterPageSleepMs=0)
    $all=[System.Collections.Generic.List[object]]::new()
    $page=0; $total=[int]::MaxValue
    while ($all.Count -lt $total) {
        if ($page -gt 0 -and $InterPageSleepMs -gt 0) { Start-Sleep -Milliseconds $InterPageSleepMs }
        $uri="$Script:ApiBaseV4/$Namespace/$Version/$Resource`?`$page=$page&`$limit=$PageSize"
        if ($Expand) { $uri+="&`$expand=$Expand" }
        $r=Invoke-NtnxApi -Uri $uri -Method 'GET'
        if (-not $r) { break }
        $batch=_ResolveEntities $r
        if ($batch.Count -gt 0) { $all.AddRange([object[]]$batch) }
        if ($page -eq 0) {
            $mp=$r.PSObject.Properties['metadata']
            if ($mp -and $mp.Value) {
                $tp=$mp.Value.PSObject.Properties['totalAvailableResults']
                if ($tp -and $null -ne $tp.Value){$total=[int]$tp.Value}
            }
        }
        $page++
        if ($batch.Count -lt $PageSize) { break }
    }
    return $all
}

function Get-NtnxEntityList {
    [OutputType([System.Collections.Generic.List[object]])]
    param([string]$V4Namespace='',[string]$V4Resource='',[string]$V4Version='v4.0',
          [string]$V4Expand='',[string]$V3Endpoint='',[string]$V3Kind='',[int]$PageSize=200)
    if ($V4Namespace -and $V4Resource) {
        $v4r=Get-NtnxV4List -Namespace $V4Namespace -Resource $V4Resource -Version $V4Version `
                            -Expand $V4Expand -PageSize ([Math]::Min($PageSize,100))
        if ($v4r -and $v4r.Count -gt 0) { return $v4r }
        Write-Verbose "$(Get-Date -f 'HH:mm:ss'): v4 [$V4Namespace/$V4Resource] returned 0 — falling back to v3."
    }
    if ($V3Endpoint -and $V3Kind) {
        return Get-NtnxV3List -Endpoint $V3Endpoint -Kind $V3Kind -PageSize ([Math]::Min($PageSize,500))
    }
    return [System.Collections.Generic.List[object]]::new()
}

# ???????????????????????????????????????????????????????????????????????????????
#  PART 5 ?? Sanitization / Conversion Utilities
# ???????????????????????????????????????????????????????????????????????????????

function _Sanitize {
    param([object]$Value,[object]$Default=$null,[string]$Unit='',[int]$Decimals=1)
    if ($null -eq $Value) { return $Default }
    $s=([string]$Value).Trim()
    if ([string]::IsNullOrWhiteSpace($s) -or $s -eq 'null') { return $Default }
    if ($Unit) {
        $n=0.0
        if (-not [double]::TryParse($s,[ref]$n)) { return $Default }
        switch ($Unit) {
            'bytes?GiB' { return [Math]::Round($n/1073741824,$Decimals) }
            'bytes?GB'  { return [Math]::Round($n/1000000000,$Decimals) }
            'mib?GiB'   { return [Math]::Round($n/1024,      $Decimals) }
            'kib?GiB'   { return [Math]::Round($n/1048576,   $Decimals) }
        }
    }
    return ($s -replace '[\t\r\n]+', ' ')
}

function _Get {
    param([object]$Obj,[string[]]$Path)
    $cur=$Obj
    foreach ($key in $Path) {
        if ($null -eq $cur) { return $null }
        if ($cur -is [hashtable]) {
            if (-not $cur.ContainsKey($key)) { return $null }
            $cur=$cur[$key]; continue
        }
        $prop=$cur.PSObject.Properties[$key]
        if (-not $prop) { return $null }
        $cur=$prop.Value
    }
    return $cur
}

function Get-SafeVal {
    param([object]$Val)
    if ($null -eq $Val -or [string]$Val -eq '' -or [string]$Val -eq 'null') { return '—' }
    return ([string]$Val -replace '[\t\r\n]+', ' ').Trim()
}

# ???????????????????????????????????????????????????????????????????????????????
#  PART 6 ?? Parallel VM Detail Enrichment (RunspacePool)
# ???????????????????????????????????????????????????????????????????????????????

function Invoke-ParallelVmDetail {
    param([System.Collections.Generic.List[object]]$Summaries,
          [int]$Throttle=20,
          [System.Management.Automation.PSCredential]$Cred=$Script:ActiveCred)
    if (-not $Summaries -or $Summaries.Count -eq 0) { return @{} }
    $t0=([datetime]::UtcNow); $headers=New-NtnxAuthHeader -Cred $Cred
    $baseUrl=$Script:ApiBaseV3; $skip=$Script:SkipCert
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): Parallel VM detail — $($Summaries.Count) VMs, throttle=$Throttle"
    $fetchBlock = {
        param([string]$Uuid,[string]$Uri,[hashtable]$Headers,[bool]$SkipCert,[int]$MaxAtt=3)
        $attempt = 0
        while ($true) {
            try {
                $p=@{Uri=$Uri;Method='GET';Headers=$Headers;TimeoutSec=90;ErrorAction='Stop'}
                if ($SkipCert){$p.SkipCertificateCheck=$true}
                $data=Invoke-RestMethod @p -Verbose:$false
                return [PSCustomObject]@{Uuid=$Uuid;Data=$data;Err=$null}
            } catch {
                $msg=$_.Exception.Message
                if ($msg -match '(5[0-9][0-9]|429)' -and $attempt -lt $MaxAtt) {
                    $wait=[Math]::Pow(2,$attempt+1)   # 2s, 4s, 8s
                    Start-Sleep -Seconds $wait
                    $attempt++
                } else {
                    return [PSCustomObject]@{Uuid=$Uuid;Data=$null;Err=$msg}
                }
            }
        }
    }
    $pool=[System.Management.Automation.Runspaces.RunspacePool]::CreateRunspacePool(1,$Throttle)
    $pool.Open()
    try {
        $handles=[System.Collections.Generic.List[PSCustomObject]]::new()
        $batchIdx = 0
        foreach ($vm in $Summaries) {
            $uuid=_Get $vm @('metadata','uuid'); if (-not $uuid){continue}
            $ps=[System.Management.Automation.PowerShell]::Create()
            $ps.RunspacePool=$pool
            [void]$ps.AddScript($fetchBlock)
            [void]$ps.AddParameter('Uuid',$uuid)
            [void]$ps.AddParameter('Uri',"$baseUrl/vms/$uuid")
            [void]$ps.AddParameter('Headers',$headers)
            [void]$ps.AddParameter('SkipCert',$skip)
            [void]$ps.AddParameter('MaxAtt',$Script:MaxRetries)
            $handles.Add([PSCustomObject]@{PS=$ps;Handle=$ps.BeginInvoke();Uuid=$uuid})
            # Stagger launches: brief pause every $Throttle requests to avoid burst 429/503
            $batchIdx++
            if ($batchIdx % $Throttle -eq 0) { Start-Sleep -Milliseconds 500 }
        }
        $detailMap=@{}; $errCount=0
        foreach ($h in $handles) {
            try {
                $res=$h.PS.EndInvoke($h.Handle)
                if ($res -and $res.Data){$detailMap[$res.Uuid]=$res.Data}
                if ($res -and $res.Err) {$errCount++;Write-Verbose "  VM $($res.Uuid): $($res.Err)"}
            } catch {$errCount++;Write-Verbose "  Runspace error [$($h.Uuid)]: $($_.Exception.Message)"}
            finally {$h.PS.Dispose()}
        }
        $elapsed=[Math]::Round(([datetime]::UtcNow-$t0).TotalSeconds,1)
        Write-Verbose "$(Get-Date -f 'HH:mm:ss'): VM detail complete — $($detailMap.Count) enriched, $errCount errors, ${elapsed}s"
        return $detailMap
    } finally { $pool.Close(); $pool.Dispose() }
}

# ???????????????????????????????????????????????????????????????????????????????
#  PART 7 ?? Normalization Functions
# ???????????????????????????????????????????????????????????????????????????????

function ConvertTo-NtnxCluster {
    param([object]$Raw)

    # Detect API version: v4 clustermgmt has 'extId' at top level; v3 has 'metadata.uuid'
    $extId = _Get $Raw @('extId')

    if ($extId) {
        # ?? v4 clustermgmt/v4.0/config/clusters format ??????????????????????
        $cfg   = _Get $Raw @('config')
        $net   = _Get $Raw @('network')
        $build = _Get $cfg @('buildInfo')

        # External IP — v4 uses nested address object {ipv4:{value:"..."}}
        $extIpV4  = _Get $net @('externalAddress','ipv4','value')
        if (-not $extIpV4) { $extIpV4 = _Get $net @('externalAddress','value') }

        # Subnet strings (best-effort from complex v4 address objects)
        $extSubObj = _Get $net @('externalSubnet')
        $extSub = if ($extSubObj) {
            $ip = _Get $extSubObj @('ipv4','ip','value')
            if (-not $ip) { $ip = _Get $extSubObj @('ipv4','value') }
            $pfx = _Get $extSubObj @('ipv4','prefixLength')
            if ($ip -and $pfx) { "$ip/$pfx" } elseif ($ip) { $ip } else { $null }
        } else { $null }

        $intSubObj = _Get $net @('internalSubnet')
        $intSub = if ($intSubObj) {
            $ip = _Get $intSubObj @('ipv4','ip','value')
            if (-not $ip) { $ip = _Get $intSubObj @('ipv4','value') }
            $pfx = _Get $intSubObj @('ipv4','prefixLength')
            if ($ip -and $pfx) { "$ip/$pfx" } elseif ($ip) { $ip } else { $null }
        } else { $null }

        # Name/DNS servers — each entry is {ipv4:{value:"..."}} or {ipv6:{value:"..."}}
        $nsRaw  = _Get $net @('nameServerIpList')
        $ntpRaw = _Get $net @('ntpServerIpList')

        $nameServers = @(if ($nsRaw) {
            @($nsRaw) | ForEach-Object {
                $v = _Get $_ @('ipv4','value')
                if (-not $v) { $v = _Get $_ @('ipv6','value') }
                if (-not $v) { $v = _Get $_ @('value') }
                if ($v) { $v }
            }
        })
        $ntpServers = @(if ($ntpRaw) {
            @($ntpRaw) | ForEach-Object {
                $v = _Get $_ @('ipv4','value')
                if (-not $v) { $v = _Get $_ @('ipv6','value') }
                if (-not $v) { $v = _Get $_ @('fqdn','value') }
                if (-not $v) { $v = _Get $_ @('value') }
                if ($v) { $v }
            }
        })

        # Nodes — list may be absent in some API versions; fall back to hosts-derived count later
        $nodeList  = _Get $Raw @('nodes','nodeList')
        $nodeArr   = if ($nodeList) { @($nodeList) } else { @() }
        $nodeCount = $nodeArr.Count
        $hvTypes   = (@($nodeArr | ForEach-Object {
            $ht = _Get $_ @('hypervisorType')
            if (-not $ht) { $ht = _Get $_ @('hypervisor_type') }
            $ht
        } | Where-Object { $_ } | Select-Object -Unique)) -join ', '
        if (-not $hvTypes) { $hvTypes = 'AHV' }  # default for Nutanix clusters

        return [PSCustomObject]@{
            UUID             = _Sanitize $extId
            Name             = _Sanitize(_Get $Raw @('name'))
            ExternalIP       = _Sanitize $extIpV4
            AOSVersion       = _Sanitize(_Get $build @('version'))
            RedundancyFactor = _Sanitize(_Get $cfg @('redundancyFactor'))
            OperationMode    = _Sanitize(_Get $cfg @('operationMode'))
            Timezone         = _Sanitize(_Get $cfg @('timezone'))
            ExternalSubnet   = _Sanitize $extSub
            InternalSubnet   = _Sanitize $intSub
            NameServers      = $nameServers
            NtpServers       = $ntpServers
            HypervisorTypes  = $hvTypes
            NodeCount        = $nodeCount
        }
    } else {
        # ?? v3 format (metadata/spec/status) ????????????????????????????????
        $meta=_Get $Raw @('metadata'); $spec=_Get $Raw @('spec'); $status=_Get $Raw @('status')
        $res=_Get $status @('resources'); $cfg=_Get $res @('config'); $net=_Get $res @('network')
        $nodes=_Get $res @('nodes'); $build=_Get $cfg @('build')
        $hvSrv=_Get $nodes @('hypervisor_server_list')
        $nodeCount=if ($hvSrv){@($hvSrv).Count}else{0}
        $hvTypes=if ($hvSrv){(@($hvSrv)|ForEach-Object{_Get $_ @('type')}|Where-Object{$_}|Select-Object -Unique)-join ', '}else{$null}
        $cname=_Get $spec @('name'); if (-not $cname){$cname=_Get $status @('name')}
        $dns=_Get $net @('name_server_ip_list'); $ntp=_Get $net @('ntp_server_ip_list')
        return [PSCustomObject]@{
            UUID=_Sanitize(_Get $meta @('uuid')); Name=_Sanitize $cname
            ExternalIP=_Sanitize(_Get $net @('external_ip')); AOSVersion=_Sanitize(_Get $build @('version'))
            RedundancyFactor=_Sanitize(_Get $cfg @('redundancy_factor')); OperationMode=_Sanitize(_Get $cfg @('operation_mode'))
            Timezone=_Sanitize(_Get $cfg @('timezone')); ExternalSubnet=_Sanitize(_Get $net @('external_subnet'))
            InternalSubnet=_Sanitize(_Get $net @('internal_subnet'))
            NameServers=if ($dns){@($dns)}else{@()}; NtpServers=if ($ntp){@($ntp)}else{@()}
            HypervisorTypes=$hvTypes; NodeCount=$nodeCount
        }
    }
}

function ConvertTo-NtnxHost {
    param([object]$Raw)
    $meta=_Get $Raw @('metadata'); $status=_Get $Raw @('status'); $res=_Get $status @('resources')
    $cvmIP=_Get $res @('controller_vm','ip'); $hvIP=_Get $res @('hypervisor','ip')
    $ipmiIP=_Get $res @('ipmi','ip'); $blkModel=_Get $res @('block','block_model')
    $blkSerial=_Get $res @('block','block_serial_number'); $cpuMod=_Get $res @('cpu_model')
    $cpuSock=_Get $res @('num_cpu_sockets'); $cpuCores=_Get $res @('num_cpu_cores')
    $memMiB=_Get $res @('memory_capacity_mib'); $hvFull=_Get $res @('hypervisor','hypervisor_full_name')
    if (-not $hvFull){$hvFull=_Get $res @('hypervisor_type')}
    $aosVer=_Get $res @('controller_vm','software_version'); if (-not $aosVer){$aosVer=_Get $status @('version')}
    $rawDisks=_Get $res @('disk_list')
    $disks=if ($rawDisks){@($rawDisks)|ForEach-Object{
        $szB=_Get $_ @('disk_size_bytes')
        [PSCustomObject]@{UUID=_Sanitize(_Get $_ @('uuid'));Tier=_Sanitize(_Get $_ @('storage_tier_name'))
            SizeGiB=if ($szB){_Sanitize $szB -Unit 'bytes?GiB' -Decimals 1}else{$null}
            MountPath=_Sanitize(_Get $_ @('mount_path'));Serial=_Sanitize(_Get $_ @('disk_hardware_config','serial_number'))
            Status=_Sanitize(_Get $_ @('disk_status'))}
    }}else{@()}
    $clRef=_Get $status @('cluster_reference'); $clUuid=_Sanitize(_Get $clRef @('uuid'))
    $clName=if ($clUuid -and $Script:ClusterMap[$clUuid]){$Script:ClusterMap[$clUuid].Name}else{_Sanitize(_Get $clRef @('name'))}
    [PSCustomObject]@{
        UUID=_Sanitize(_Get $meta @('uuid'));Name=_Sanitize(_Get $status @('name'))
        ClusterUUID=$clUuid;ClusterName=$clName;CvmIP=_Sanitize $cvmIP
        HypervisorIP=_Sanitize $hvIP;IpmiIP=_Sanitize $ipmiIP
        BlockModel=_Sanitize $blkModel;BlockSerial=_Sanitize $blkSerial;CpuModel=_Sanitize $cpuMod
        CpuSockets=if ($cpuSock){[int]$cpuSock}else{0}
        CpuTotalCores=if ($cpuCores){[int]$cpuCores}else{0}
        MemoryGiB=if ($memMiB){_Sanitize $memMiB -Unit 'mib?GiB' -Decimals 0}else{$null}
        HypervisorType=_Sanitize $hvFull;AosVersion=_Sanitize $aosVer;Disks=$disks
    }
}

function ConvertTo-NtnxVM {
    param([object]$Summary,[object]$Detail=$null)
    $src=if ($Detail){$Detail}else{$Summary}
    $meta=_Get $src @('metadata'); $spec=_Get $src @('spec')
    $status=_Get $src @('status'); $specRes=_Get $spec @('resources'); $statRes=_Get $status @('resources')
    $sockRaw=_Get $specRes @('num_sockets'); $vcpuPSRaw=_Get $specRes @('num_vcpus_per_socket')
    $sockets=if ($sockRaw){[int]$sockRaw}else{0}; $vcpuPS=if ($vcpuPSRaw){[int]$vcpuPSRaw}else{0}
    $vCPUs=$sockets*$vcpuPS
    $memMiB=_Get $specRes @('memory_size_mib')
    $memGiB=if ($memMiB){_Sanitize $memMiB -Unit 'mib?GiB' -Decimals 1}else{0.0}
    $pwr=_Get $statRes @('power_state'); if (-not $pwr){$pwr=_Get $status @('state')}
    $hostRef=_Get $statRes @('host_reference'); $hostUuid=_Sanitize(_Get $hostRef @('uuid'))
    $hostName=if ($hostUuid -and $Script:HostMap[$hostUuid]){$Script:HostMap[$hostUuid].Name}else{_Sanitize(_Get $hostRef @('name'))}
    $diskList=_Get $statRes @('disk_list'); if (-not $diskList){$diskList=_Get $specRes @('disk_list')}
    $container=$null
    if ($diskList -and @($diskList).Count -gt 0) {
        $scRef=_Get (@($diskList)[0]) @('storage_config','storage_container_reference')
        $container=_Sanitize(_Get $scRef @('name')); if (-not $container){$container=_Sanitize(_Get $scRef @('uuid'))}
    }
    $nicList=_Get $statRes @('nic_list'); $nicCount=0; $ipAddrs=@()
    if ($nicList) {
        $nicCount=@($nicList).Count
        $ipAddrs=@($nicList)|ForEach-Object{$eps=_Get $_ @('ip_endpoint_list');if ($eps){@($eps)|ForEach-Object{_Get $_ @('ip')}|Where-Object{$_}}}|Where-Object{$_}
    }
    $polRef=_Get $statRes @('protection_policy_state','policy_reference'); $protN=_Sanitize(_Get $polRef @('name'))
    $ngt=_Get $statRes @('guest_tools','nutanix_guest_tools')
    $ngtEn=_Get $ngt @('is_enabled'); $ngtVer=_Get $ngt @('version')
    $ngtStat=if ($null -ne $ngtEn){if ($ngtEn){if ($ngtVer){"Enabled v$ngtVer"}else{'Enabled'}}else{'Disabled'}}else{$null}
    $catMap=_Get $meta @('categories_mapping')
    $cats=if ($catMap){@($catMap.PSObject.Properties|ForEach-Object{"$($_.Name):$($_.Value)"})}else{@()}
    $gpuList=_Get $statRes @('gpu_list'); $gpuCnt=if ($gpuList){@($gpuList).Count}else{0}
    $diskRows=@(); $provGiB=0.0
    if ($diskList) {
        $diskRows=@($diskList)|ForEach-Object{
            $sc=_Get $_ @('storage_config'); $scRef=_Get $sc @('storage_container_reference')
            $szB=_Get $_ @('disk_size_bytes'); $szG=if ($szB){_Sanitize $szB -Unit 'bytes?GiB' -Decimals 1}else{0.0}
            $provGiB+=[double]$szG
            [PSCustomObject]@{ContainerName=_Sanitize(_Get $scRef @('name'));SizeGiB=$szG
                DeviceType=_Sanitize(_Get $_ @('device_properties','device_type'))
                AdapterType=_Sanitize(_Get $_ @('device_properties','disk_address','adapter_type'))
                DedupEnabled=(_Get $sc @('deduplication_enabled')) -eq $true}
        }
    }
    [PSCustomObject]@{
        UUID=_Sanitize(_Get $meta @('uuid'));Name=_Sanitize(_Get $spec @('name'))
        HostName=$hostName;Container=$container;PowerState=_Sanitize $pwr
        vCPUs=$vCPUs;MemoryGiB=$memGiB
        DiskCount=if ($diskList){@($diskList).Count}else{0}
        ProvisionedGiB=[Math]::Round($provGiB,1);NicCount=$nicCount
        IPAddresses=@($ipAddrs);ProtectionPolicy=$protN;NgtStatus=$ngtStat
        Categories=$cats;GPUs=$gpuCnt;Disks=$diskRows
    }
}

function ConvertTo-NtnxSubnet {
    param([object]$Raw)
    $spec=_Get $Raw @('spec'); $status=_Get $Raw @('status'); $res=_Get $status @('resources')
    if (-not $res){$res=_Get $spec @('resources')}; $ipConf=_Get $res @('ip_config')
    $vlanProp=if ($res){$res.PSObject.Properties['vlan_id']}else{$null}
    $vlanId=if ($vlanProp -and $null -ne $vlanProp.Value){[int]$vlanProp.Value}else{$null}
    $subIp=_Get $ipConf @('subnet_ip'); $pfxL=_Get $ipConf @('prefix_length')
    $cidr=if ($subIp){"$subIp/$pfxL"}else{$null}
    $dhcpA=_Get $ipConf @('dhcp_server_address'); $pools=_Get $ipConf @('pool_list')
    $poolRows=if ($pools){@($pools)|ForEach-Object{[PSCustomObject]@{Start=_Sanitize(_Get $_ @('range_start'));End=_Sanitize(_Get $_ @('range_end'))}}}else{@()}
    $clRef=_Get $status @('cluster_reference'); $clUuid=_Sanitize(_Get $clRef @('uuid'))
    $clName=if ($clUuid -and $Script:ClusterMap[$clUuid]){$Script:ClusterMap[$clUuid].Name}else{_Sanitize(_Get $clRef @('name'))}
    [PSCustomObject]@{
        Name=_Sanitize(_Get $spec @('name'));ClusterName=$clName;VlanId=$vlanId
        SubnetType=_Sanitize(_Get $res @('subnet_type'));Cidr=$cidr
        Gateway=_Sanitize(_Get $ipConf @('default_gateway_ip'));DhcpServer=_Sanitize(_Get $dhcpA @('ip'))
        DhcpPools=$poolRows
    }
}

function ConvertTo-NtnxVolumeGroup {
    param([object]$Raw)
    $meta=_Get $Raw @('metadata'); $spec=_Get $Raw @('spec'); $status=_Get $Raw @('status')
    $specRes=_Get $spec @('resources'); $statRes=_Get $status @('resources')
    $diskDat=if ($statRes -and (_Get $statRes @('disk_list'))){@(_Get $statRes @('disk_list'))}elseif ($specRes -and (_Get $specRes @('disk_list'))){@(_Get $specRes @('disk_list'))}else{@()}
    $totalMiB=0; foreach ($d in $diskDat){$mib=_Get $d @('disk_size_mib'); if ($mib){$totalMiB+=[long]$mib}}
    $capGiB=if ($totalMiB -gt 0){[Math]::Round($totalMiB/1024,1)}else{0.0}
    [PSCustomObject]@{
        UUID=_Sanitize(_Get $meta @('uuid'));Name=_Sanitize(_Get $spec @('name'))
        CapacityGiB=$capGiB;DiskCount=$diskDat.Count
        Shared=(_Get $statRes @('is_shared')) -eq $true
        FlashMode=_Sanitize(_Get $specRes @('flash_mode'));IscsiTarget=_Sanitize(_Get $statRes @('iscsi_target_name'))
    }
}

function ConvertTo-NtnxContainer {
    param([object]$Raw)
    # Detect v4 clustermgmt format (extId at top) vs v3 (metadata.uuid)
    $extId = _Get $Raw @('extId')
    if ($extId) {
        # v4 format: clustermgmt/v4.0/config/storage-containers
        $clRef  = _Get $Raw @('clusterReference')
        if (-not $clRef) { $clRef = _Get $Raw @('cluster') }
        $clUuid = _Sanitize(_Get $clRef @('extId'))
        if (-not $clUuid) { $clUuid = _Sanitize(_Get $clRef @('uuid')) }
        $clName = if ($clUuid -and $Script:ClusterMap[$clUuid]) { $Script:ClusterMap[$clUuid].Name } else { _Sanitize(_Get $clRef @('name')) }
        $totB = _Get $Raw @('logicalCapacityBytes')
        if (-not $totB) { $totB = _Get $Raw @('totalCapacityBytes') }
        $useB = _Get $Raw @('logicalUsageBytes')
        if (-not $useB) { $useB = _Get $Raw @('usedCapacityBytes') }
        $frB  = if ($totB -and $useB) { [double]$totB - [double]$useB } else { $null }
        $cfg  = _Get $Raw @('storagePolicy')
        return [PSCustomObject]@{
            Name              = _Sanitize(_Get $Raw @('name'))
            ClusterName       = $clName
            TotalGiB          = if ($totB){ _Sanitize $totB -Unit 'bytes?GiB' -Decimals 0 } else { $null }
            UsedGiB           = if ($useB){ _Sanitize $useB -Unit 'bytes?GiB' -Decimals 0 } else { $null }
            FreeGiB           = if ($frB -and $frB -gt 0){ _Sanitize $frB -Unit 'bytes?GiB' -Decimals 0 } else { $null }
            DedupEnabled      = (_Get $cfg @('compressionEnabled')) -eq $true
            CompressionEnabled= (_Get $cfg @('compressionEnabled')) -eq $true
            ErasureCoding     = (_Get $cfg @('erasureCodeEnabled'))  -eq $true
            ReplicationFactor = _Sanitize(_Get $cfg @('replicationFactor'))
        }
    } else {
        # v3 format
        $spec=_Get $Raw @('spec'); $status=_Get $Raw @('status'); $meta=_Get $Raw @('metadata')
        $scRes=_Get $spec @('resources'); $scStat=_Get $status @('resources')
        $clRef=_Get $meta @('cluster_reference'); $clUuid=_Sanitize(_Get $clRef @('uuid'))
        $clName=if ($clUuid -and $Script:ClusterMap[$clUuid]){$Script:ClusterMap[$clUuid].Name}else{_Sanitize(_Get $clRef @('name'))}
        $totB=_Get $scStat @('storage_pool_capacity_bytes'); $useB=_Get $scStat @('used_storage_bytes'); $frB=_Get $scStat @('free_storage_bytes')
        return [PSCustomObject]@{
            Name=_Sanitize(_Get $spec @('name'));ClusterName=$clName
            TotalGiB=if ($totB){_Sanitize $totB -Unit 'bytes?GiB' -Decimals 0}else{$null}
            UsedGiB=if ($useB){_Sanitize $useB -Unit 'bytes?GiB' -Decimals 0}else{$null}
            FreeGiB=if ($frB){_Sanitize $frB -Unit 'bytes?GiB' -Decimals 0}else{$null}
            DedupEnabled=(_Get $scRes @('is_dedup_enabled')) -eq $true
            CompressionEnabled=(_Get $scRes @('is_compression_enabled')) -eq $true
            ErasureCoding=(_Get $scRes @('is_erasure_code_enabled')) -eq $true
            ReplicationFactor=_Sanitize(_Get $scRes @('replication_factor'))
        }
    }
}

function ConvertTo-NtnxImage {
    param([object]$Raw)
    $spec=_Get $Raw @('spec'); $meta=_Get $Raw @('metadata'); $status=_Get $Raw @('status')
    $imgRes=_Get $spec @('resources'); $stRes=_Get $status @('resources')
    $szB=_Get $stRes @('size_bytes'); if (-not $szB){$szB=_Get $imgRes @('size_bytes')}
    $szGiB=if ($szB -and [double]$szB -gt 0){_Sanitize $szB -Unit 'bytes?GiB' -Decimals 1}else{$null}
    [PSCustomObject]@{
        UUID=_Sanitize(_Get $meta @('uuid'));Name=_Sanitize(_Get $spec @('name'))
        Type=_Sanitize(_Get $imgRes @('image_type'));SizeGiB=$szGiB
        State=_Sanitize(_Get $status @('state'));Description=_Sanitize(_Get $spec @('description'))
    }
}

function ConvertTo-NtnxAlert {
    param([object]$Raw)
    $meta=_Get $Raw @('metadata'); $res=_Get $Raw @('status','resources')
    $msg=_Get $res @('default_message'); if (-not $msg){$msg=_Get $res @('title')}
    [PSCustomObject]@{Severity=_Sanitize(_Get $res @('severity'));Title=_Sanitize(_Get $res @('title'));Message=_Sanitize $msg;Timestamp=_Sanitize(_Get $meta @('creation_time'))}
}

function ConvertTo-NtnxUser {
    param([object]$Raw)
    $meta=_Get $Raw @('metadata'); $res=_Get $Raw @('spec','resources')
    [PSCustomObject]@{UUID=_Sanitize(_Get $meta @('uuid'));Username=_Sanitize(_Get $res @('upn_name'));DisplayName=_Sanitize(_Get $res @('display_name'));UserType=_Sanitize(_Get $res @('user_type'));Enabled=(_Get $res @('is_enabled')) -eq $true}
}

function ConvertTo-NtnxRole {
    param([object]$Raw)
    $rSpec=_Get $Raw @('spec'); $rMeta=_Get $Raw @('metadata')
    $permList=_Get $rSpec @('resources','permission_reference_list')
    $permCount=if ($permList){@($permList).Count}else{0}
    [PSCustomObject]@{UUID=_Sanitize(_Get $rMeta @('uuid'));Name=_Sanitize(_Get $rSpec @('name'));Description=_Sanitize(_Get $rSpec @('description'));PermissionCount=$permCount}
}

function ConvertTo-NtnxACP {
    param([object]$Raw)
    $res=_Get $Raw @('spec','resources'); $roleRef=_Get $res @('role_reference')
    $users=_Get $res @('user_reference_list'); $groups=_Get $res @('user_group_reference_list')
    [PSCustomObject]@{
        UUID=_Sanitize(_Get $Raw @('metadata','uuid'));Name=_Sanitize(_Get $Raw @('spec','name'))
        RoleName=_Sanitize(_Get $roleRef @('name'))
        Users=if ($users){@($users|ForEach-Object{_Sanitize(_Get $_ @('name'))}|Where-Object{$_})}else{@()}
        Groups=if ($groups){@($groups|ForEach-Object{_Sanitize(_Get $_ @('name'))}|Where-Object{$_})}else{@()}
    }
}

# ???????????????????????????????????????????????????????????????????????????????
#  PART 8 ?? Lookup Table Builders
# ???????????????????????????????????????????????????????????????????????????????

function Build-ClusterLookup {
    param([System.Collections.Generic.List[object]]$RawClusters)
    $Script:ClusterMap=@{}
    foreach ($c in $RawClusters) {
        # Skip Prism Central (clusterFunction=PRISM_CENTRAL without AOS)
        $fn = _Get $c @('config','clusterFunction')
        if ($fn) {
            $fnArr = @($fn)
            if (($fnArr -contains 'PRISM_CENTRAL') -and ($fnArr -notcontains 'AOS')) { continue }
        }
        $norm=ConvertTo-NtnxCluster -Raw $c
        if ($norm.UUID) { $Script:ClusterMap[$norm.UUID]=$norm }
    }
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): ClusterMap built — $($Script:ClusterMap.Count) entries"
}

function Build-HostLookup {
    param([System.Collections.Generic.List[object]]$RawHosts)
    $Script:HostMap=@{}
    foreach ($h in $RawHosts) {
        $uuid=_Sanitize(_Get $h @('metadata','uuid')); if (-not $uuid){continue}
        $Script:HostMap[$uuid]=[PSCustomObject]@{Name=_Sanitize(_Get $h @('status','name'));CvmIP=_Sanitize(_Get $h @('status','resources','controller_vm','ip'))}
    }
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): HostMap built — $($Script:HostMap.Count) entries"
}

# ???????????????????????????????????????????????????????????????????????????????
#  PART 9 ?? Main Orchestrator (data collection + normalization)
# ???????????????????????????????????????????????????????????????????????????????

function Get-NutanixInventory {
    [CmdletBinding()]
    param([string]$PcIP,[System.Management.Automation.PSCredential]$Cred,[int]$Throttle,[bool]$SkipDetail)
    $t0=[datetime]::UtcNow
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): ?? Nutanix Inventory Collector v5.0 ??"

    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): [1/14] Clusters..."
    $rawClusters=Get-NtnxEntityList -V4Namespace 'clustermgmt' -V4Resource 'config/clusters' -V3Endpoint 'clusters/list' -V3Kind 'cluster' -PageSize 100
    Build-ClusterLookup -RawClusters $rawClusters

    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): [2/14] Hosts..."
    $rawHosts=Get-NtnxV3List -Endpoint 'hosts/list' -Kind 'host' -PageSize 500
    Build-HostLookup -RawHosts $rawHosts

    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): [3/14] VMs (summaries)..."
    $rawAllVMs=Get-NtnxV3List -Endpoint 'vms/list' -Kind 'vm' -PageSize 500
    $userVMList=[System.Collections.Generic.List[object]]::new()
    foreach ($v in $rawAllVMs) {
        $n=_Get $v @('spec','name'); $isCtrl=(_Get $v @('status','resources','is_controller_vm')) -eq $true
        if (-not $isCtrl -and ($null -eq $n -or $n -notmatch '^NTNX-')){$userVMList.Add($v)}
    }
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'):   $($userVMList.Count) user VMs (of $($rawAllVMs.Count) total)"

    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): [4/14] Volume Groups..."
    $rawVGs=Get-NtnxV3List -Endpoint 'volume_groups/list' -Kind 'volume_group' -PageSize 500
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): [5/14] Subnets..."
    $rawSubnets=Get-NtnxV3List -Endpoint 'subnets/list' -Kind 'subnet' -PageSize 500
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): [6/14] Storage Containers..."
    $rawContainers=Get-NtnxEntityList -V4Namespace 'clustermgmt' -V4Resource 'config/storage-containers' -V3Endpoint 'storage_containers/list' -V3Kind 'storage_container' -PageSize 200
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): [7/14] Images..."
    $rawImages=Get-NtnxV3List -Endpoint 'images/list' -Kind 'image' -PageSize 500
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): [8/14] Protection Rules..."
    $rawProtRules=Get-NtnxV3List -Endpoint 'protection_rules/list' -Kind 'protection_rule'
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): [9/14] Recovery Plans + Availability Zones..."
    $rawRecovPlans=Get-NtnxV3List -Endpoint 'recovery_plans/list' -Kind 'recovery_plan'
    $rawAvailZones=Get-NtnxV3List -Endpoint 'availability_zones/list' -Kind 'availability_zone'
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): [10/14] Alerts..."
    $rawAlerts=Get-NtnxV3List -Endpoint 'alerts/list' -Kind 'alert' -PageSize 500
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): [11/14] IAM..."
    $rawUsers  =Get-NtnxV3List -Endpoint 'users/list'                   -Kind 'user'                   -PageSize 500
    $rawGroups =Get-NtnxV3List -Endpoint 'user_groups/list'             -Kind 'user_group'             -PageSize 500
    $rawRoles  =Get-NtnxV3List -Endpoint 'roles/list'                   -Kind 'role'                   -PageSize 500
    $rawACPs   =Get-NtnxV3List -Endpoint 'access_control_policies/list' -Kind 'access_control_policy'  -PageSize 500
    $rawDirSvcs=Get-NtnxV3List -Endpoint 'directory_services/list'      -Kind 'directory_service'
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): [12/14] Projects..."
    $rawProjects=Get-NtnxV3List -Endpoint 'projects/list' -Kind 'project'
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): [13/14] Prism Central info..."
    $rawPCInfo=Invoke-NtnxApi -Uri "$Script:ApiBaseV3/prism_central" -Method 'GET'
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): [14/14] Networking v4 (VPCs / Flow)..."
    $rawVPCs      =Get-NtnxV4List -Namespace 'networking' -Resource 'config/vpcs'          -PageSize 50
    $rawFlowPols  =Get-NtnxV4List -Namespace 'microseg' -Resource 'config/policies'       -PageSize 25 -InterPageSleepMs 400
    $rawAddrGroups=Get-NtnxV4List -Namespace 'microseg' -Resource 'config/address-groups' -PageSize 25 -InterPageSleepMs 400
    $rawSvcGroups =Get-NtnxV4List -Namespace 'microseg' -Resource 'config/service-groups' -PageSize 25 -InterPageSleepMs 400

    # Parallel VM enrichment
    $vmDetailMap=@{}
    if (-not $SkipDetail -and $userVMList.Count -gt 0) {
        $vmDetailMap=Invoke-ParallelVmDetail -Summaries $userVMList -Throttle $Throttle -Cred $Cred
    } else { Write-Verbose "$(Get-Date -f 'HH:mm:ss'): VM detail enrichment skipped." }

    # Normalization
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): Normalizing data..."
    # Filter out Prism Central itself — the v4 API includes PC as a cluster with
    # clusterFunction=["PRISM_CENTRAL"].  Real AOS clusters have clusterFunction=["AOS"].
    $normClusters=@($rawClusters | ForEach-Object {
        $fn = _Get $_ @('config','clusterFunction')
        if ($fn) {
            $fnArr = @($fn)
            # Skip if the only function is PRISM_CENTRAL (no AOS role)
            if (($fnArr -contains 'PRISM_CENTRAL') -and ($fnArr -notcontains 'AOS')) { return }
        }
        ConvertTo-NtnxCluster -Raw $_
    })
    $normHosts   =@($rawHosts|ForEach-Object{ConvertTo-NtnxHost -Raw $_}|Where-Object{
        $bm=$_.BlockModel; $bs=$_.BlockSerial
        ($bm -and $bm -notin @('','null')) -or ($bs -and $bs -notin @('','null'))})

    # Back-fill NodeCount on clusters from actual host data (v4 list API often omits nodes.nodeList)
    if ($normClusters.Count -gt 0 -and $normHosts.Count -gt 0) {
        $hostCountByCluster = @{}
        foreach ($h in $normHosts) {
            $cu = $h.ClusterUUID
            if ($cu) {
                if (-not $hostCountByCluster.ContainsKey($cu)) { $hostCountByCluster[$cu] = 0 }
                $hostCountByCluster[$cu]++
            }
        }
        foreach ($cl in $normClusters) {
            if ($cl.NodeCount -eq 0 -and $cl.UUID -and $hostCountByCluster.ContainsKey($cl.UUID)) {
                $cl.NodeCount = $hostCountByCluster[$cl.UUID]
            }
        }
    }
    $normVMs     =@($userVMList|ForEach-Object{
        $uuid=_Get $_ @('metadata','uuid'); $detail=if ($uuid){$vmDetailMap[$uuid]}else{$null}
        ConvertTo-NtnxVM -Summary $_ -Detail $detail})
    $normSubnets =@($rawSubnets   |ForEach-Object{ConvertTo-NtnxSubnet      -Raw $_})
    $normVGs     =@($rawVGs       |ForEach-Object{ConvertTo-NtnxVolumeGroup -Raw $_})
    $normConts   =@($rawContainers|ForEach-Object{ConvertTo-NtnxContainer   -Raw $_})
    $normImages  =@($rawImages    |ForEach-Object{ConvertTo-NtnxImage       -Raw $_})
    $normAlerts  =@($rawAlerts    |ForEach-Object{ConvertTo-NtnxAlert       -Raw $_})
    $normUsers   =@($rawUsers     |ForEach-Object{ConvertTo-NtnxUser        -Raw $_})
    $normRoles   =@($rawRoles     |ForEach-Object{ConvertTo-NtnxRole        -Raw $_})
    $normACPs    =@($rawACPs      |ForEach-Object{ConvertTo-NtnxACP         -Raw $_})
    $normGroups  =@($rawGroups|ForEach-Object{
        $res=_Get $_ @('spec','resources')
        [PSCustomObject]@{UUID=_Sanitize(_Get $_ @('metadata','uuid'));Name=_Sanitize(_Get $_ @('status','resources','display_name'));DistinguishedName=_Sanitize(_Get $res @('distinguished_name'));GroupType=_Sanitize(_Get $res @('group_type'))}})
    $normDirSvcs =@($rawDirSvcs|ForEach-Object{
        $res=_Get $_ @('spec','resources')
        [PSCustomObject]@{UUID=_Sanitize(_Get $_ @('metadata','uuid'));Name=_Sanitize(_Get $_ @('spec','name'));Url=_Sanitize(_Get $res @('url'));DirectoryType=_Sanitize(_Get $res @('directory_type'));Domain=_Sanitize(_Get $res @('domain_name'))}})
    $normProtRules=@($rawProtRules|ForEach-Object{
        $res=_Get $_ @('spec','resources'); $azRaw=_Get $res @('ordered_availability_zone_list')
        [PSCustomObject]@{UUID=_Sanitize(_Get $_ @('metadata','uuid'));Name=_Sanitize(_Get $_ @('spec','name'));Description=_Sanitize(_Get $_ @('spec','description'))
            State=_Sanitize(_Get $_ @('status','state'))
            AvailabilityZones=if ($azRaw){@($azRaw|ForEach-Object{_Sanitize(_Get $_ @('availability_zone_url'))}|Where-Object{$_})}else{@()}}})
    $normRecovPlans=@($rawRecovPlans|ForEach-Object{
        $spec=_Get $_ @('spec'); $res=_Get $spec @('resources'); $primList=_Get $res @('primary_location_list'); $recList=_Get $res @('recovery_location_list')
        $stageCount=if (_Get $res @('stage_list')){@(_Get $res @('stage_list')).Count}else{0}
        $primName=if ($primList){(@($primList)|ForEach-Object{_Get $_ @('name')}|Where-Object{$_})-join ', '}else{'—'}
        $recName=if ($recList){(@($recList)|ForEach-Object{_Get $_ @('name')}|Where-Object{$_})-join ', '}else{'—'}
        [PSCustomObject]@{UUID=_Sanitize(_Get $_ @('metadata','uuid'));Name=_Sanitize(_Get $spec @('name'));Description=_Sanitize(_Get $spec @('description'));State=_Sanitize(_Get $_ @('status','state'));Stages=$stageCount;PrimarySite=$primName;RecoverySite=$recName}})
    $normAvailZones=@($rawAvailZones|ForEach-Object{
        $res=_Get $_ @('spec','resources')
        [PSCustomObject]@{UUID=_Sanitize(_Get $_ @('metadata','uuid'));Name=_Sanitize(_Get $_ @('spec','name'));URL=_Sanitize(_Get $res @('url'));Type=_Sanitize(_Get $res @('resource_type'))}})
    $normProjects=@($rawProjects|ForEach-Object{
        $res=_Get $_ @('spec','resources'); $pjClRefs=_Get $res @('cluster_reference_list'); $pjSubRef=_Get $res @('default_subnet_reference')
        $pjUsers=_Get $res @('user_reference_list'); $pjGroups=_Get $res @('user_group_reference_list')
        $clNames=if ($pjClRefs){(@($pjClRefs)|ForEach-Object{_Get $_ @('name')}|Where-Object{$_})-join ', '}else{'—'}
        [PSCustomObject]@{UUID=_Sanitize(_Get $_ @('metadata','uuid'));Name=_Sanitize(_Get $_ @('spec','name'));Description=_Sanitize(_Get $_ @('spec','description'));DefaultCluster=$clNames;DefaultSubnet=_Sanitize(_Get $pjSubRef @('name'));UserCount=if ($pjUsers){@($pjUsers).Count}else{0};GroupCount=if ($pjGroups){@($pjGroups).Count}else{0}}})
    $normVPCs=@($rawVPCs|ForEach-Object{
        $extId=$null; foreach ($f in @('extId','ext_id','id','uuid')){$p=$_.PSObject.Properties[$f]; if ($p -and $p.Value){$extId=$p.Value; break}}
        $extSubs=_Get $_ @('externalSubnets')
        [PSCustomObject]@{ExtId=_Sanitize $extId;Name=_Sanitize(_Get $_ @('name'));Description=_Sanitize(_Get $_ @('description'));ExternalSubnets=if ($extSubs){@($extSubs|ForEach-Object{_Sanitize(_Get $_ @('name'))}|Where-Object{$_})}else{@()}}})
    $normFlowPols=@($rawFlowPols|ForEach-Object{
        $extId=$null; foreach ($f in @('extId','ext_id','id','uuid')){$p=$_.PSObject.Properties[$f]; if ($p -and $p.Value){$extId=$p.Value; break}}
        [PSCustomObject]@{ExtId=_Sanitize $extId;Name=_Sanitize(_Get $_ @('name'));Type=_Sanitize(_Get $_ @('type'));State=_Sanitize(_Get $_ @('state'));HitLogEnabled=(_Get $_ @('hitlogEnabled')) -eq $true;Description=_Sanitize(_Get $_ @('description'))}})
    $normAddrGroups=@($rawAddrGroups|ForEach-Object{
        $extId=$null; foreach ($f in @('extId','ext_id','id','uuid')){$p=$_.PSObject.Properties[$f]; if ($p -and $p.Value){$extId=$p.Value; break}}
        $pfxList=_Get $_ @('ipv4Prefixes')
        [PSCustomObject]@{ExtId=_Sanitize $extId;Name=_Sanitize(_Get $_ @('name'));Description=_Sanitize(_Get $_ @('description'));IpPrefixes=if ($pfxList){@($pfxList|ForEach-Object{_Get $_ @('value')}|Where-Object{$_})}else{@()}}})
    $normSvcGroups=@($rawSvcGroups|ForEach-Object{
        $extId=$null; foreach ($f in @('extId','ext_id','id','uuid')){$p=$_.PSObject.Properties[$f]; if ($p -and $p.Value){$extId=$p.Value; break}}
        $hasTcp=$null -ne (_Get $_ @('tcpServices')); $hasUdp=$null -ne (_Get $_ @('udpServices')); $hasIcmp=$null -ne (_Get $_ @('icmpServices'))
        $protos=@(if ($hasTcp){'TCP'}; if ($hasUdp){'UDP'}; if ($hasIcmp){'ICMP'})
        [PSCustomObject]@{ExtId=_Sanitize $extId;Name=_Sanitize(_Get $_ @('name'));Description=_Sanitize(_Get $_ @('description'));Protocols=$protos}})

    $pcVer=_Get $rawPCInfo @('status','resources','version')
    $pcVMs=_Get $rawPCInfo @('status','resources','pc_vm_info_list')
    $nccVer=$null; if ($pcVMs){$nccVer=_Get (@($pcVMs)[0]) @('software_map','NCC','version')}
    $pulseOn=(_Get $rawPCInfo @('status','resources','pulse_status','is_enabled')) -eq $true
    $pcInfo=[PSCustomObject]@{Version=_Sanitize $pcVer;NccVersion=_Sanitize $nccVer;PulseEnabled=$pulseOn;AuthProviders=@($normDirSvcs|ForEach-Object{$_.Name}|Where-Object{$_})}

    $elapsed=([datetime]::UtcNow-$t0).ToString('hh\:mm\:ss\.f')
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): ?? Collection complete — $elapsed ??"

    return [PSCustomObject]@{
        Metadata=[PSCustomObject]@{ScriptVersion='5.0';CollectedAt=$t0.ToString('yyyy-MM-dd HH:mm:ss')+' UTC';CollectionDuration=$elapsed;PrismCentralIP=$PcIP;TotalClusters=$normClusters.Count;TotalHosts=$normHosts.Count;TotalUserVMs=$normVMs.Count;VmDetailsEnriched=$vmDetailMap.Count}
        PrismCentral=$pcInfo
        Licensing=[PSCustomObject]@{Clusters=@($normClusters|Select-Object Name,UUID,@{n='Note';e={'License managed at Prism Central level'}});Note='Navigate to Prism Central > Settings > Licensing for full details.'}
        Clusters=$normClusters;Hosts=$normHosts
        Networks=[PSCustomObject]@{Subnets=$normSubnets;VPCs=$normVPCs}
        FlowSecurity=[PSCustomObject]@{Policies=$normFlowPols;AddressGroups=$normAddrGroups;ServiceGroups=$normSvcGroups}
        Storage=[PSCustomObject]@{VolumeGroups=$normVGs;Containers=$normConts}
        VirtualMachines=$normVMs;Images=$normImages
        DataProtection=[PSCustomObject]@{ProtectionRules=$normProtRules;RecoveryPlans=$normRecovPlans;AvailabilityZones=$normAvailZones}
        Health=[PSCustomObject]@{Alerts=$normAlerts}
        IdentityAccess=[PSCustomObject]@{Users=$normUsers;UserGroups=$normGroups;Roles=$normRoles;AccessControlPolicies=$normACPs;DirectoryServices=$normDirSvcs}
        Projects=$normProjects
        _Raw=[PSCustomObject]@{AllVMs=$rawAllVMs}
    }
}

# ?????????????????????????????????????????????????????????????????????????????
#  Nutanix Brand Styles — apply to the active Word document
# ?????????????????????????????????????????????????????????????????????????????
function Set-NtnxBrandStyles {
    try {
        # Nutanix brand colors in Word's OLE_COLOR (BGR) int format
        # #024DA1  R:2   G:77  B:161  ? (161×65536)+(77×256)+2   = 10 571 010
        # #1B2631  R:27  G:38  B:49   ? (49×65536)+(38×256)+27   =  3 221 019
        # #00A3E0  R:0   G:163 B:224  ? (224×65536)+(163×256)+0  = 14 721 792
        [int]$nBlue = 10571010
        [int]$nDark =  3221019
        [int]$nCyan = 14721792

        $styles = $Script:Document.Styles

        # Heading 1: Nutanix Blue, Arial Bold 14 pt
        $h1 = $styles.Item(-2)   # wdStyleHeading1
        $h1.Font.Name  = 'Arial'; $h1.Font.Size  = 14
        $h1.Font.Bold  = $true;   $h1.Font.Color = $nBlue
        $h1.ParagraphFormat.SpaceBefore = 12; $h1.ParagraphFormat.SpaceAfter = 4

        # Heading 2: Nutanix Dark, Arial Bold 12 pt
        $h2 = $styles.Item(-3)   # wdStyleHeading2
        $h2.Font.Name  = 'Arial'; $h2.Font.Size  = 12
        $h2.Font.Bold  = $true;   $h2.Font.Color = $nDark
        $h2.ParagraphFormat.SpaceBefore = 8; $h2.ParagraphFormat.SpaceAfter = 3

        # Heading 3: Nutanix Cyan, Arial Bold 11 pt
        $h3 = $styles.Item(-4)   # wdStyleHeading3
        $h3.Font.Name  = 'Arial'; $h3.Font.Size  = 11
        $h3.Font.Bold  = $true;   $h3.Font.Color = $nCyan
        $h3.ParagraphFormat.SpaceBefore = 6; $h3.ParagraphFormat.SpaceAfter = 2

        # Normal: Calibri 10 pt dark
        $nrm = $styles.Item('Normal')
        $nrm.Font.Name = 'Calibri'; $nrm.Font.Size = 10; $nrm.Font.Color = $nDark

        Write-Verbose "$(Get-Date -f 'HH:mm:ss'): Brand styles applied."
    } catch {
        Write-Verbose "  [BrandStyles] $($_.Exception.Message)"
    }
}

# ?????????????????????????????????????????????????????????????????????????????
#  Nutanix Branded Header / Footer
# ?????????????????????????????????????????????????????????????????????????????
function Set-NtnxBrandHeaderFooter {
    param([string]$ReportTitle='Nutanix Infrastructure Report',[string]$CoName='')
    # Word OLE_COLOR BGR integers
    [int]$nBlue = 10571010   # #024DA1
    [int]$nDark =  3221019   # #1B2631
    # WdBorderType paragraph border values (negative in Word COM)
    [int]$wdBorderBottom = -3
    [int]$wdBorderTop    = -1
    # WdAlignTab: Right = 2
    [int]$wdAlignTabRight = 2

    $doc = $Script:Word.ActiveDocument
    $sec = $doc.Sections.Item(1)

    # ?? Primary header ????????????????????????????????????????????????????????
    try {
        $hdr = $sec.Headers.Item(1)   # wdHeaderFooterPrimary = 1
        $hdr.Range.Text = ''
        $leftText = if ($CoName) { "$ReportTitle  |  $CoName" } else { $ReportTitle }
        $hdr.Range.InsertAfter($leftText)
        $hdr.Range.Font.Name  = 'Arial'
        $hdr.Range.Font.Size  = 9
        $hdr.Range.Font.Bold  = $false
        $hdr.Range.Font.Color = $nBlue
    } catch { Write-Verbose "  [BrandHeader-text] $($_.Exception.Message)" }

    # Bottom border on header — optional, skip silently if Word rejects it
    try {
        $bdr = $sec.Headers.Item(1).Range.Paragraphs.Item(1).Borders.Item($wdBorderBottom)
        $bdr.LineStyle = 1; $bdr.LineWidth = 8; $bdr.Color = $nBlue
    } catch {}

    # ?? Primary footer ????????????????????????????????????????????????????????
    try {
        $ftr = $sec.Footers.Item(1)
        $ftr.Range.Text = ''
        $ftr.Range.Font.Name  = 'Arial'
        $ftr.Range.Font.Size  = 8
        $ftr.Range.Font.Color = $nDark
        $ftr.Range.InsertAfter("Nutanix, Inc.  |  Confidential`t")
        # Right-aligned page number after a tab
        $ftr.Range.Paragraphs.Item(1).TabStops.Add(468, $wdAlignTabRight) | Out-Null
        $ftrEnd = $ftr.Range; $ftrEnd.Collapse(2)   # wdCollapseEnd
        $ftrEnd.Fields.Add($ftrEnd, 33) | Out-Null  # wdFieldPage = 33
    } catch { Write-Verbose "  [BrandFooter-text] $($_.Exception.Message)" }

    # Top border on footer — optional
    try {
        $fbdr = $sec.Footers.Item(1).Range.Paragraphs.Item(1).Borders.Item($wdBorderTop)
        $fbdr.LineStyle = 1; $fbdr.LineWidth = 8; $fbdr.Color = $nBlue
    } catch {}

    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): Brand header/footer applied."
}

# ???????????????????????????????????????????????????????????????????????????????
#  PART 10 ?? Word Report Writer  (19 chapters + 6 embedded diagrams)
# ???????????????????????????????????????????????????????????????????????????????

function Write-NtnxWordReport {
    param([PSCustomObject]$Inv)
    $Chapter=0

    # ?? Apply Nutanix brand styles to heading/normal styles ??????????????????
    Set-NtnxBrandStyles

    # ?? Nutanix branded cover page ????????????????????????????????????????????
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): Building branded cover page..."
    $coverDate  = Get-Date -f 'yyyy-MM-dd HH:mm'
    $coverPcIP  = if ($PrismCentralIP)    { $PrismCentralIP }    elseif ($Inv.Metadata.PrismCentralIP) { $Inv.Metadata.PrismCentralIP } else { '' }
    $coverCo    = if ($Script:CoName)     { $Script:CoName }     else { '' }
    $coverPath  = New-NtnxCoverBanner -PcIP $coverPcIP -CoName $coverCo -CollectedAt $coverDate
    if ($coverPath) {
        # Embed banner at full text width (468 pt = 6.5 in at standard margins)
        $coverShape = $Script:Selection.InlineShapes.AddPicture($coverPath,$false,$true)
        if ($coverShape -and $coverShape.Width -gt 0) {
            [float]$origRatio = $coverShape.Height / $coverShape.Width
            $coverShape.Width  = 468.0
            $coverShape.Height = [Math]::Round(468.0 * $origRatio, 0)
        }
        try { Remove-Item $coverPath -Force -EA 0 } catch {}
        $Script:Selection.TypeParagraph()
    }

    # ?? Cover page text block ?????????????????????????????????????????????????
    $Script:Selection.Style        = $Script:Document.Styles.Item('Normal')
    $Script:Selection.Font.Size    = 11
    $Script:Selection.Font.Name    = 'Arial'
    $Script:Selection.Font.Bold    = $false
    $Script:Selection.Font.Color   = 3221019   # nDark #1B2631
    WriteWordLine 0 0 " "

    $Script:Selection.InsertNewPage()

    # ?? Table of Contents ?????????????????????????????????????????????????????
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): Inserting Table of Contents..."
    WriteWordLine 1 0 "Table of Contents"
    try {
        # Pass only Range — all other parameters are optional and have safe defaults.
        # Supplying the extra boolean/string mix that the original SetupWord used can
        # cause DISP_E_TYPEMISMATCH on some Word versions.
        $Script:Word.ActiveDocument.TablesOfContents.Add(
            $Script:Selection.Range) | Out-Null
    } catch {
        Write-Verbose "  [TOC] $($_.Exception.Message)"
    }
    $Script:Selection.InsertNewPage()

    # ?? Branded header / footer ???????????????????????????????????????????????
    Set-NtnxBrandHeaderFooter -ReportTitle 'Nutanix Infrastructure Report' -CoName $coverCo
    FindWordDocumentEnd

    # ?? List of Figures — clickable hyperlinks via Word bookmarks ????????????
    Write-Verbose "$(Get-Date): Inserting List of Figures..."
    WriteWordLine 1 0 "List of Figures"
    WriteWordLine 0 0 "The following diagrams are embedded in this document. Each is generated from live inventory data. Click a title to jump directly to the diagram."
    WriteWordLine 0 0 " "

    # Figure metadata — bookmark names must match those passed to Add-NtnxDiagram below
    $Script:FigMeta = @(
        @{ BM='NtnxFig1'; Num='Figure 1'; Title='Executive Summary Dashboard';           Desc='Six key-metric tiles: Clusters, Hosts, VMs, Total Storage, Active Alerts, Projects';    Chap='Before Chapter 1' }
        @{ BM='NtnxFig2'; Num='Figure 2'; Title='Physical Infrastructure Topology';      Desc='Prism Central node with all registered clusters and their host nodes (CVM IP, memory)'; Chap='Chapter 3' }
        @{ BM='NtnxFig3'; Num='Figure 3'; Title='Logical Network Map — Subnets & VLANs'; Desc='Subnet cards grouped by cluster, colour-coded by type (VLAN / OVERLAY) with VLAN IDs';  Chap='Chapter 7' }
        @{ BM='NtnxFig4'; Num='Figure 4'; Title='Storage Container Capacity Chart';      Desc='Stacked horizontal bar — Used (cyan) and Free (grey) storage per container in GiB';     Chap='Chapter 10' }
        @{ BM='NtnxFig5'; Num='Figure 5'; Title='VM Power State Distribution';           Desc='Donut chart: Powered On (green), Powered Off (grey), Other/Suspended (orange)';         Chap='Chapter 11' }
        @{ BM='NtnxFig6'; Num='Figure 6'; Title='Virtual Machines per Cluster';          Desc='Horizontal bar chart: VM distribution across clusters, colour-coded per cluster';        Chap='Chapter 11' }
        @{ BM='NtnxFig7'; Num='Figure 7'; Title='Alert Severity Distribution';           Desc='Donut chart: Critical (red), Warning (orange), Info (blue) — omitted if no alerts';     Chap='Chapter 16' }
    )

    try {
        # Build a native Word table so we can embed real hyperlinks in the Title column
        $Script:Selection.Style = $Script:Document.Styles.Item('No Spacing')
        $tblRange  = $Script:Selection.Range
        $figTable  = $Script:Document.Tables.Add($tblRange, $Script:FigMeta.Count + 1, 4)
        $figTable.Style = $wdTableGrid   # plain grid; header colour applied manually below
        $figTable.AutoFitBehavior($wdAutoFitWindow)

        # Header row — Nutanix Blue background, white text
        $hdrCells  = @('Figure','Title (click to navigate)','Description','Chapter')
        for ($c = 1; $c -le 4; $c++) {
            $cell = $figTable.Cell(1, $c)
            $cell.Range.Text  = $hdrCells[$c-1]
            $cell.Range.Bold  = $true
            $cell.Range.Font.Color = 16777215   # White
            $cell.Shading.BackgroundPatternColor = 10571010   # Nutanix Blue #024DA1
        }

        # Data rows with hyperlinks in the Title column
        for ($r = 0; $r -lt $Script:FigMeta.Count; $r++) {
            $fm   = $Script:FigMeta[$r]
            $row  = $r + 2
            $figTable.Cell($row,1).Range.Text = $fm['Num']
            $figTable.Cell($row,3).Range.Text = $fm['Desc']
            $figTable.Cell($row,4).Range.Text = $fm['Chap']

            # Title cell — collapse to an insertion point at cell start, then insert hyperlink
            $titleCell  = $figTable.Cell($row,2)
            $cellStart  = $titleCell.Range
            $cellStart.Collapse(1)   # wdCollapseStart = 1: zero-length range at start of cell
            try {
                # Address="" + SubAddress=bookmark ? internal document hyperlink
                $Script:Document.Hyperlinks.Add(
                    $cellStart,
                    '',            # Address (empty = internal)
                    $fm['BM'],     # SubAddress = bookmark name
                    $fm['Title'],  # ScreenTip
                    $fm['Title']   # TextToDisplay
                ) | Out-Null
            } catch {
                $titleCell.Range.Text = $fm['Title']
            }
        }
        FindWordDocumentEnd
    } catch {
        Write-Verbose "  [ListOfFigures] $($_.Exception.Message) — falling back to plain table"
        [System.Collections.Hashtable[]]$FigList = @()
        foreach ($fm in $Script:FigMeta) {
            $FigList += @{ "Figure"=$fm['Num']; "Title"=$fm['Title']; "Description"=$fm['Desc']; "Chapter"=$fm['Chap'] }
        }
        $Table = AddWordTable -Hashtable $FigList -Columns "Figure","Title","Description","Chapter" -Format $wdTableGrid -AutoFit $wdAutoFitWindow
        FindWordDocumentEnd
    }

    WriteWordLine 0 0 " "
    $Script:Selection.InsertNewPage()

    # ?? Executive Summary Dashboard (before Chapter 1) ??????????????????????
    Write-Verbose "$(Get-Date): Generating Executive Summary Dashboard..."
    $diagPath = New-NtnxSummaryDashboard -Inv $Inv
    if ($diagPath) { Add-NtnxDiagram -ImagePath $diagPath -WidthPts 468 -BookmarkName 'NtnxFig1' }
    WriteWordLine 0 0 " "

    # ?? SECTION 1 – Prism Central Overview ??????????????????????????????????
    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Prism Central Overview"
    WriteWordLine 1 0 "Prism Central / API Info"
    WriteWordLine 3 0 "Prism Central Version and Configuration"
    $pc=$Inv.PrismCentral
    [System.Collections.Hashtable[]]$t=@(@{
        "PC Name"="$(Get-SafeVal $Inv.Metadata.PrismCentralIP)";"AOS / PC Version"=Get-SafeVal $pc.Version
        "NCC Version"=Get-SafeVal $pc.NccVersion
        "Pulse (Remote Support)"=if ($null -ne $pc.PulseEnabled){if ($pc.PulseEnabled){'Enabled'}else{'Disabled'}}else{'—'}
        "REST API"='v3 (primary) + v4 where available'
    })
    $Table=AddWordTable -Hashtable $t -Columns "PC Name","AOS / PC Version","NCC Version","Pulse (Remote Support)","REST API" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd
    WriteWordLine 0 0 " "
    WriteWordLine 3 0 "Authentication Providers (Directory Services)"
    [System.Collections.Hashtable[]]$DirRows=@()
    foreach ($ds in $Inv.IdentityAccess.DirectoryServices) {
        $DirRows+=@{"Name"=Get-SafeVal $ds.Name;"Type"=Get-SafeVal $ds.DirectoryType;"URL"=Get-SafeVal $ds.Url;"Domain"=Get-SafeVal $ds.Domain}
    }
    if ($DirRows.Count -eq 0){$DirRows=@(@{"Name"='No directory services configured';"Type"='—';"URL"='—';"Domain"='—'})}
    $Table=AddWordTable -Hashtable $DirRows -Columns "Name","Type","URL","Domain" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd
    WriteWordLine 0 0 " "

    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Licensing Information"
    WriteWordLine 1 0 "Licensing Information"
    WriteWordLine 3 0 "Prism Central Licensing"
    [System.Collections.Hashtable[]]$LicRows=@()
    foreach ($c in $Inv.Licensing.Clusters){$LicRows+=@{"Cluster Name"=Get-SafeVal $c.Name;"Cluster UUID"=Get-SafeVal $c.UUID;"Note"=Get-SafeVal $c.Note}}
    if ($LicRows.Count -eq 0){$LicRows=@(@{"Cluster Name"='N/A';"Cluster UUID"='N/A';"Note"='N/A'})}
    $Table=AddWordTable -Hashtable $LicRows -Columns "Cluster Name","Cluster UUID","Note" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd
    WriteWordLine 0 0 "License details are managed at Prism Central level. Navigate to Prism Central > Settings > Licensing."
    WriteWordLine 0 0 " "

    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Cluster Configuration"
    WriteWordLine 1 0 "Prism Central - Cluster Information"
    WriteWordLine 3 0 "Cluster Configuration (registered clusters)"
    [System.Collections.Hashtable[]]$ClRows=@()
    foreach ($c in $Inv.Clusters) {
        $ClRows+=@{
            "UUID"=Get-SafeVal $c.UUID;"Name"=Get-SafeVal $c.Name;"External IP"=Get-SafeVal $c.ExternalIP
            "Timezone"=Get-SafeVal $c.Timezone;"AOS Version"=Get-SafeVal $c.AOSVersion
            "Redundancy Factor"=Get-SafeVal $c.RedundancyFactor;"Operation Mode"=Get-SafeVal $c.OperationMode
            "External Subnet"=Get-SafeVal $c.ExternalSubnet;"Internal Subnet"=Get-SafeVal $c.InternalSubnet
            "Name Servers"=Get-SafeVal(if ($c.NameServers){$c.NameServers -join ', '}else{$null})
            "NTP Servers"=Get-SafeVal(if ($c.NtpServers){$c.NtpServers -join ', '}else{$null})
            "Hypervisors"=Get-SafeVal $c.HypervisorTypes;"Number of Nodes"=$c.NodeCount
        }
    }
    if ($ClRows.Count -eq 0){$ClRows=@(@{"UUID"='N/A';"Name"='No clusters found';"External IP"='N/A';"Timezone"='N/A';"AOS Version"='N/A';"Redundancy Factor"='N/A';"Operation Mode"='N/A';"External Subnet"='N/A';"Internal Subnet"='N/A';"Name Servers"='N/A';"NTP Servers"='N/A';"Hypervisors"='N/A';"Number of Nodes"=0})}
    $Table=AddWordTable -Hashtable $ClRows -Columns "UUID","Name","External IP","Timezone","AOS Version","Redundancy Factor" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd
    WriteWordLine 0 0 " "
    $Table=AddWordTable -Hashtable $ClRows -Columns "UUID","External Subnet","Internal Subnet","Operation Mode","Name Servers","NTP Servers","Hypervisors","Number of Nodes" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd
    WriteWordLine 0 0 " "

    # Physical Topology Diagram
    Write-Verbose "$(Get-Date): Generating Physical Topology Diagram..."
    $diagPath=New-NtnxTopologyDiagram -Inv $Inv -PcIP $Inv.Metadata.PrismCentralIP
    if ($diagPath){Add-NtnxDiagram -ImagePath $diagPath -WidthPts 468 -BookmarkName 'NtnxFig2'}
    WriteWordLine 0 0 " "

    If ($Full) {
        WriteWordLine 3 0 "Prism Central Alerts"
        [System.Collections.Hashtable[]]$ARows=@(); foreach ($a in $Inv.Health.Alerts){$ARows+=@{"Severity"=Get-SafeVal $a.Severity;"Title"=Get-SafeVal $a.Title;"Message"=Get-SafeVal $a.Message}}
        if ($ARows.Count -eq 0){$ARows=@(@{"Severity"='N/A';"Title"='No alerts';"Message"='—'})}
        $Table=AddWordTable -Hashtable $ARows -Columns "Severity","Title","Message" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd
        WriteWordLine 0 0 "Note: If this chapter is empty there are no critical alerts"; WriteWordLine 0 0 " "
    }

    # ?? SECTION 2 – Physical Infrastructure ?????????????????????????????????
    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Host Hardware"
    WriteWordLine 2 0 "Nutanix System Information"
    WriteWordLine 3 0 "Rackable Unit / Host hardware"
    [System.Collections.Hashtable[]]$RU=@()
    foreach ($h in $Inv.Hosts){$RU+=@{"ID"=Get-SafeVal $h.UUID;"Model"=Get-SafeVal $h.BlockModel;"Location"='—';"Serial"=Get-SafeVal $h.BlockSerial;"Positions"='—'}}
    if ($RU.Count -eq 0){$RU=@(@{"ID"='N/A';"Model"='N/A';"Location"='N/A';"Serial"='N/A';"Positions"='N/A'})}
    $Table=AddWordTable -Hashtable $RU -Columns "ID","Model","Location","Serial","Positions" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd
    WriteWordLine 0 0 " "
    WriteWordLine 3 0 "Remote Support / SMTP / SNMP"
    WriteWordLine 0 0 "SMTP and SNMP are configured per cluster in Prism Element. Use Prism Central > Managed Clusters to access cluster settings."
    $Table=AddWordTable -Hashtable @(@{"Enabled"='—';"Details"='See cluster settings in Prism Element'}) -Columns "Enabled","Details" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd
    WriteWordLine 0 0 " "

    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Host Configuration"
    WriteWordLine 3 0 "Nutanix IP Configuration"
    [System.Collections.Hashtable[]]$HC=@()
    foreach ($h in $Inv.Hosts) {
        $HC+=@{"Name"=Get-SafeVal $h.Name;"Model"=Get-SafeVal $h.BlockModel;"CVM"=Get-SafeVal $h.CvmIP;"Hypervisor IP"=Get-SafeVal $h.HypervisorIP;"IPMI Address"=Get-SafeVal $h.IpmiIP;"State"='—';
               "Serial"=Get-SafeVal $h.BlockSerial;"Block Serial"=Get-SafeVal $h.BlockSerial;"MetaDataStatus"='—';"CPU Model"=Get-SafeVal $h.CpuModel;
               "Number of CPU Cores"=$h.CpuTotalCores;"Number of CPU Sockets"=$h.CpuSockets;"CPU in Hz"='—';
               "Memory In GB"=if ($h.MemoryGiB){[int]$h.MemoryGiB}else{0};"Hypervisor"=Get-SafeVal $h.HypervisorType}
    }
    if ($HC.Count -eq 0){$HC=@(@{"Name"='N/A';"Model"='N/A';"CVM"='N/A';"Hypervisor IP"='N/A';"IPMI Address"='N/A';"State"='N/A';"Serial"='N/A';"Block Serial"='N/A';"MetaDataStatus"='—';"CPU Model"='N/A';"Number of CPU Cores"=0;"Number of CPU Sockets"=0;"CPU in Hz"=0;"Memory In GB"=0;"Hypervisor"='N/A'})}
    $Table=AddWordTable -Hashtable $HC -Columns "Name","Model","CVM","Hypervisor IP","IPMI Address","State" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "
    WriteWordLine 3 0 "Nutanix Serial Configuration"
    $Table=AddWordTable -Hashtable $HC -Columns "Name","Serial","Block Serial","MetaDataStatus" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "
    WriteWordLine 3 0 "Nutanix CPU Configuration"
    $Table=AddWordTable -Hashtable $HC -Columns "Name","CPU Model","Number of CPU Cores","Number of CPU Sockets","CPU in Hz" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "
    WriteWordLine 3 0 "Nutanix Memory Configuration"
    $Table=AddWordTable -Hashtable $HC -Columns "Name","Memory In GB" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "
    If ($Full) {
        WriteWordLine 3 0 "Nutanix Alerts (Prism Central)"
        [System.Collections.Hashtable[]]$HA=@(); foreach ($a in $Inv.Health.Alerts){$HA+=@{"Severity"=Get-SafeVal $a.Severity;"Message"=Get-SafeVal $a.Message}}
        if ($HA.Count -eq 0){$HA=@(@{"Severity"='N/A';"Message"='No alerts'})}
        $Table=AddWordTable -Hashtable $HA -Columns "Severity","Message" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "
    }

    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Physical Disk Configuration"
    WriteWordLine 1 0 "Nutanix Disk Information"
    WriteWordLine 3 0 "Physical disk configuration (from host data)"
    WriteWordLine 0 0 " "
    [System.Collections.Hashtable[]]$DC=@()
    foreach ($h in $Inv.Hosts) {
        if ($h.Disks -and $h.Disks.Count -gt 0) {
            foreach ($d in $h.Disks) {
                $DC+=@{"Hostname"=Get-SafeVal $h.Name;"CVM IP"=Get-SafeVal $h.CvmIP;"UUID"=Get-SafeVal $d.UUID;"Tier"=Get-SafeVal $d.Tier;"Disk Size (GB)"=if ($d.SizeGiB){$d.SizeGiB}else{'—'};"Mount Path"=Get-SafeVal $d.MountPath;"Serial"=Get-SafeVal $d.Serial;"Status"=Get-SafeVal $d.Status}
            }
        } else {
            $DC+=@{"Hostname"=Get-SafeVal $h.Name;"CVM IP"=Get-SafeVal $h.CvmIP;"UUID"='—';"Tier"='—';"Disk Size (GB)"='—';"Mount Path"='—';"Serial"='—';"Status"='—'}
        }
    }
    if ($DC.Count -eq 0){$DC=@(@{"Hostname"='—';"CVM IP"='—';"UUID"='—';"Tier"='—';"Disk Size (GB)"='—';"Mount Path"='—';"Serial"='—';"Status"='—'})}
    $Table=AddWordTable -Hashtable $DC -Columns "Hostname","CVM IP","UUID","Tier","Disk Size (GB)","Mount Path","Serial","Status" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    # ?? SECTION 3 – Networking ???????????????????????????????????????????????
    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Network and Subnet Overview"
    WriteWordLine 1 0 "Nutanix Network / Subnet Overview"
    WriteWordLine 3 0 "Subnets (Prism Central v3 API)"
    [System.Collections.Hashtable[]]$SR=@()
    foreach ($s in $Inv.Networks.Subnets) {
        $SR+=@{"Name"=Get-SafeVal $s.Name;"Cluster"=Get-SafeVal $s.ClusterName;"VLAN ID"=if ($null -ne $s.VlanId){$s.VlanId}else{'—'};"Type"=Get-SafeVal $s.SubnetType;"Subnet/Prefix"=Get-SafeVal $s.Cidr;"Gateway"=Get-SafeVal $s.Gateway;"DHCP Server"=Get-SafeVal $s.DhcpServer}
    }
    if ($SR.Count -eq 0){$SR=@(@{"Name"='No subnets found';"Cluster"='—';"VLAN ID"='—';"Type"='—';"Subnet/Prefix"='—';"Gateway"='—';"DHCP Server"='—'})}
    $Table=AddWordTable -Hashtable $SR -Columns "Name","Cluster","VLAN ID","Type","Subnet/Prefix","Gateway","DHCP Server" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    WriteWordLine 3 0 "DHCP IP Pools (per subnet)"
    [System.Collections.Hashtable[]]$PR=@()
    foreach ($s in $Inv.Networks.Subnets){foreach ($p in $s.DhcpPools){$PR+=@{"Subnet"=Get-SafeVal $s.Name;"Pool Start"=Get-SafeVal $p.Start;"Pool End"=Get-SafeVal $p.End}}}
    if ($PR.Count -eq 0){$PR=@(@{"Subnet"='No DHCP pools defined';"Pool Start"='—';"Pool End"='—'})}
    $Table=AddWordTable -Hashtable $PR -Columns "Subnet","Pool Start","Pool End" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    WriteWordLine 3 0 "VPCs (Virtual Private Clouds)"
    WriteWordLine 0 0 " "
    [System.Collections.Hashtable[]]$VR=@()
    foreach ($v in $Inv.Networks.VPCs){$VR+=@{"Name"=Get-SafeVal $v.Name;"ExtId"=Get-SafeVal $v.ExtId;"External Subnets"=Get-SafeVal(if ($v.ExternalSubnets){$v.ExternalSubnets -join ', '}else{$null});"Description"=Get-SafeVal $v.Description}}
    if ($VR.Count -eq 0){$VR=@(@{"Name"='No VPCs defined';"ExtId"='—';"External Subnets"='—';"Description"='—'})}
    $Table=AddWordTable -Hashtable $VR -Columns "Name","ExtId","External Subnets","Description" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    # Logical Network Diagram
    Write-Verbose "$(Get-Date): Generating Logical Network Diagram..."
    $diagPath=New-NtnxNetworkDiagram -Inv $Inv
    if ($diagPath){Add-NtnxDiagram -ImagePath $diagPath -WidthPts 468 -BookmarkName 'NtnxFig3'}
    WriteWordLine 0 0 " "

    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Network Security (Flow Microsegmentation)"
    WriteWordLine 1 0 "Network Security (Flow Microsegmentation)"
    WriteWordLine 0 0 " "
    WriteWordLine 3 0 "Security Policies"
    [System.Collections.Hashtable[]]$SP=@()
    foreach ($sp in $Inv.FlowSecurity.Policies){$SP+=@{"Name"=Get-SafeVal $sp.Name;"Type"=Get-SafeVal $sp.Type;"State"=Get-SafeVal $sp.State;"Hit Log"=if ($sp.HitLogEnabled){'Enabled'}else{'Disabled'};"Description"=Get-SafeVal $sp.Description;"ExtId"=Get-SafeVal $sp.ExtId}}
    if ($SP.Count -eq 0){$SP=@(@{"Name"='No Flow security policies found';"Type"='—';"State"='—';"Hit Log"='—';"Description"='—';"ExtId"='—'})}
    $Table=AddWordTable -Hashtable $SP -Columns "Name","Type","State","Hit Log","Description","ExtId" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    WriteWordLine 3 0 "Address Groups"
    [System.Collections.Hashtable[]]$AG=@()
    foreach ($ag in $Inv.FlowSecurity.AddressGroups){$AG+=@{"Name"=Get-SafeVal $ag.Name;"IP Prefixes"=Get-SafeVal(if ($ag.IpPrefixes){$ag.IpPrefixes -join ', '}else{$null});"Description"=Get-SafeVal $ag.Description;"ExtId"=Get-SafeVal $ag.ExtId}}
    if ($AG.Count -eq 0){$AG=@(@{"Name"='No address groups defined';"IP Prefixes"='—';"Description"='—';"ExtId"='—'})}
    $Table=AddWordTable -Hashtable $AG -Columns "Name","IP Prefixes","Description","ExtId" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    WriteWordLine 3 0 "Service Groups"
    [System.Collections.Hashtable[]]$SG=@()
    foreach ($sg in $Inv.FlowSecurity.ServiceGroups){$SG+=@{"Name"=Get-SafeVal $sg.Name;"Services"=Get-SafeVal(if ($sg.Protocols){$sg.Protocols -join ' | '}else{$null});"Description"=Get-SafeVal $sg.Description;"ExtId"=Get-SafeVal $sg.ExtId}}
    if ($SG.Count -eq 0){$SG=@(@{"Name"='No service groups defined';"Services"='—';"Description"='—';"ExtId"='—'})}
    $Table=AddWordTable -Hashtable $SG -Columns "Name","Services","Description","ExtId" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    # ?? SECTION 4 – Storage ??????????????????????????????????????????????????
    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Storage Information (Volume Groups)"
    WriteWordLine 1 0 "Nutanix Storage Information"
    WriteWordLine 3 0 "Volume Groups"
    WriteWordLine 0 0 " "
    [System.Collections.Hashtable[]]$VG=@()
    foreach ($vg in $Inv.Storage.VolumeGroups){$VG+=@{"ID"=Get-SafeVal $vg.UUID;"Name"=Get-SafeVal $vg.Name;"Capacity in GB"=if ($null -ne $vg.CapacityGiB){$vg.CapacityGiB}else{'—'};"Disk Count"=$vg.DiskCount;"Shared"=if ($vg.Shared){'Yes'}else{'No'}}}
    if ($VG.Count -eq 0){$VG=@(@{"ID"='—';"Name"='No volume groups defined';"Capacity in GB"='—';"Disk Count"=0;"Shared"='—'})}
    $Table=AddWordTable -Hashtable $VG -Columns "ID","Name","Capacity in GB","Disk Count","Shared" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Volume Group Detail and Storage Containers"
    WriteWordLine 1 0 "Container / Storage Information"
    WriteWordLine 3 0 "Volume Groups – detail"
    WriteWordLine 0 0 " "
    [System.Collections.Hashtable[]]$VGD=@()
    foreach ($vg in $Inv.Storage.VolumeGroups){$VGD+=@{"Name"=Get-SafeVal $vg.Name;"Pool ID"=Get-SafeVal $vg.UUID;"Max Capacity in GB"=if ($null -ne $vg.CapacityGiB){$vg.CapacityGiB}else{'—'};"Disk Count"=$vg.DiskCount;"Shared"=if ($vg.Shared){'Yes'}else{'No'};"Flash Mode"=Get-SafeVal $vg.FlashMode;"iSCSI Target"=Get-SafeVal $vg.IscsiTarget;"Compression enabled"='—'}}
    if ($VGD.Count -eq 0){$VGD=@(@{"Name"='—';"Pool ID"='—';"Max Capacity in GB"='—';"Disk Count"=0;"Shared"='—';"Flash Mode"='—';"iSCSI Target"='—';"Compression enabled"='—'})}
    $Table=AddWordTable -Hashtable $VGD -Columns "Name","Pool ID","Max Capacity in GB","Disk Count","Shared","Flash Mode" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "
    $Table=AddWordTable -Hashtable $VGD -Columns "Name","iSCSI Target","Compression enabled" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    WriteWordLine 3 0 "Storage Containers"
    WriteWordLine 0 0 " "
    [System.Collections.Hashtable[]]$SC=@()
    foreach ($sc in $Inv.Storage.Containers) {
        $SC+=@{"Name"=Get-SafeVal $sc.Name;"Cluster"=Get-SafeVal $sc.ClusterName;"Total (GB)"=if ($null -ne $sc.TotalGiB){$sc.TotalGiB}else{'—'};"Used (GB)"=if ($null -ne $sc.UsedGiB){$sc.UsedGiB}else{'—'};"Free (GB)"=if ($null -ne $sc.FreeGiB){$sc.FreeGiB}else{'—'};"Dedup"=if ($sc.DedupEnabled){'Yes'}else{'No'};"Compression"=if ($sc.CompressionEnabled){'Yes'}else{'No'};"Erasure Coding"=if ($sc.ErasureCoding){'Yes'}else{'No'};"Replication Factor"=Get-SafeVal $sc.ReplicationFactor}
    }
    if ($SC.Count -eq 0){$SC=@(@{"Name"='No storage containers found';"Cluster"='—';"Total (GB)"='—';"Used (GB)"='—';"Free (GB)"='—';"Dedup"='—';"Compression"='—';"Erasure Coding"='—';"Replication Factor"='—'})}
    $Table=AddWordTable -Hashtable $SC -Columns "Name","Cluster","Total (GB)","Used (GB)","Free (GB)","Dedup","Compression","Erasure Coding","Replication Factor" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    # Storage Capacity Chart
    Write-Verbose "$(Get-Date): Generating Storage Capacity Chart..."
    $diagPath=New-NtnxStorageChart -Containers $Inv.Storage.Containers
    if ($diagPath){Add-NtnxDiagram -ImagePath $diagPath -WidthPts 468 -BookmarkName 'NtnxFig4'}
    WriteWordLine 0 0 " "

    # ?? SECTION 5 – Virtual Compute ??????????????????????????????????????????
    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - VM Inventory"
    WriteWordLine 1 0 "Nutanix VM Inventory"

    # VM Power State Donut Chart
    Write-Verbose "$(Get-Date): Generating VM Power State Chart..."
    $pwrOn  = @($Inv.VirtualMachines | Where-Object { $_.PowerState -match 'ON|POWERED_ON|on'  }).Count
    $pwrOff = @($Inv.VirtualMachines | Where-Object { $_.PowerState -match 'OFF|POWERED_OFF|off'}).Count
    $pwrOth = $Inv.VirtualMachines.Count - $pwrOn - $pwrOff
    $pwrSlices=@(
        @{Label='Powered On'; Value=$pwrOn;  Color=(_NC 'Green')}
        @{Label='Powered Off';Value=$pwrOff; Color=(_NC 'Gray') }
    )
    if ($pwrOth -gt 0){$pwrSlices+=@{Label='Other/Suspended';Value=$pwrOth;Color=(_NC 'Orange')}}
    $diagPath=New-NtnxDonutChart -Slices $pwrSlices -Title 'VM POWER STATE DISTRIBUTION' -CenterLabel [string]$Inv.VirtualMachines.Count
    if ($diagPath){Add-NtnxDiagram -ImagePath $diagPath -WidthPts 468 -BookmarkName 'NtnxFig5'}
    WriteWordLine 0 0 " "

    # VM Distribution Bar Chart (VMs per cluster)
    Write-Verbose "$(Get-Date): Generating VM Distribution Chart..."
    $clVmCounts=@{}
    foreach ($vm in $Inv.VirtualMachines) {
        $hostUuid=if($vm.PSObject.Properties['HostName'] -and $vm.HostName){$vm.HostName}else{'Unknown'}
        $clName='Unknown'
        foreach ($cl in $Inv.Clusters){
            foreach ($h in ($Inv.Hosts|Where-Object{$_.ClusterUUID -eq $cl.UUID})){
                if ($h.Name -eq $hostUuid){$clName=$cl.Name; break}
            }
            if ($clName -ne 'Unknown'){break}
        }
        if (-not $clVmCounts.ContainsKey($clName)){$clVmCounts[$clName]=0}
        $clVmCounts[$clName]++
    }
    $vmDistItems=@()
    $ci=0
    foreach ($kv in ($clVmCounts.GetEnumerator()|Sort-Object Value -Descending)) {
        $vmDistItems+=@{Label=$kv.Key;Value=$kv.Value;FormatStr="$($kv.Value) VMs";Color=(_NCP $ci)}
        $ci++
    }
    if ($vmDistItems.Count -gt 0){
        $diagPath=New-NtnxBarChart -Items $vmDistItems -Title 'VIRTUAL MACHINES PER CLUSTER'
        if ($diagPath){Add-NtnxDiagram -ImagePath $diagPath -WidthPts 468 -BookmarkName 'NtnxFig6'}
        WriteWordLine 0 0 " "
    }

    [System.Collections.Hashtable[]]$VMs=@()
    foreach ($vm in $Inv.VirtualMachines) {
        $ipStr=if ($vm.IPAddresses -and $vm.IPAddresses.Count -gt 0){($vm.IPAddresses|Where-Object{$_})-join ', '}else{'—'}
        $catStr=if ($vm.Categories -and $vm.Categories.Count -gt 0){$vm.Categories -join ', '}else{'—'}
        $VMs+=@{
            "VM Name"=Get-SafeVal $vm.Name;"Container"=Get-SafeVal $vm.Container;"Protection Domain"=Get-SafeVal $vm.ProtectionPolicy;"Host Placement"=Get-SafeVal $vm.HostName;"Power State"=Get-SafeVal $vm.PowerState;"Network adapters"=$vm.NicCount
            "IP Address(es)"=Get-SafeVal $ipStr;"vCPUs"=$vm.vCPUs;"vRAM (GB)"=if ($null -ne $vm.MemoryGiB){[int]$vm.MemoryGiB}else{0};"Disk Count"=$vm.DiskCount;"Provisioned Space (GB)"=$vm.ProvisionedGiB;"Used Space (GB)"='—'
            "NGT Status"=Get-SafeVal $vm.NgtStatus;"Categories"=Get-SafeVal $catStr;"GPUs"=$vm.GPUs
        }
    }
    if ($VMs.Count -eq 0){$VMs=@(@{"VM Name"='—';"Container"='—';"Protection Domain"='—';"Host Placement"='—';"Power State"='—';"Network adapters"=0;"IP Address(es)"='—';"vCPUs"=0;"vRAM (GB)"=0;"Disk Count"=0;"Provisioned Space (GB)"=0;"Used Space (GB)"='—';"NGT Status"='—';"Categories"='—';"GPUs"=0})}
    $Table=AddWordTable -Hashtable $VMs -Columns "VM Name","Container","Protection Domain","Host Placement","Power State","Network adapters","IP Address(es)","vCPUs","vRAM (GB)","Disk Count","Provisioned Space (GB)","Used Space (GB)" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "
    $Table=AddWordTable -Hashtable $VMs -Columns "VM Name","NGT Status","Categories","GPUs" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - VM Disk Information"
    WriteWordLine 1 0 "Nutanix vDisk / VM Disks"
    WriteWordLine 3 0 "VM disk references"
    [System.Collections.Hashtable[]]$vDisk=@()
    foreach ($vm in $Inv.VirtualMachines) {
        if ($vm.Disks -and $vm.Disks.Count -gt 0) {
            foreach ($d in $vm.Disks) {
                $vDisk+=@{"Container Name"=Get-SafeVal $d.ContainerName;"Pool Name"=Get-SafeVal $d.ContainerName;"VM Name"=Get-SafeVal $vm.Name;"Size (GB)"=if ($d.SizeGiB){$d.SizeGiB}else{'—'};"Device Type"=Get-SafeVal $d.DeviceType;"Adapter Type"=Get-SafeVal $d.AdapterType;"Dedup"=if ($d.DedupEnabled){'Yes'}else{'No'};"NFS File Name"='—'}
            }
        }
    }
    if ($vDisk.Count -eq 0){$vDisk=@(@{"Container Name"='N/A';"Pool Name"='N/A';"VM Name"='N/A';"Size (GB)"='—';"Device Type"='—';"Adapter Type"='—';"Dedup"='—';"NFS File Name"='—'})}
    $Table=AddWordTable -Hashtable $vDisk -Columns "Container Name","Pool Name","VM Name","Size (GB)","Device Type","Adapter Type","Dedup","NFS File Name" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Images Catalog"
    WriteWordLine 1 0 "Nutanix Images Catalog"
    WriteWordLine 3 0 "Disk and ISO Images"
    WriteWordLine 0 0 " "
    [System.Collections.Hashtable[]]$Imgs=@()
    foreach ($img in $Inv.Images){$Imgs+=@{"Name"=Get-SafeVal $img.Name;"Type"=Get-SafeVal $img.Type;"Size (GB)"=if ($img.SizeGiB){$img.SizeGiB}else{'—'};"State"=Get-SafeVal $img.State;"Description"=Get-SafeVal $img.Description;"UUID"=Get-SafeVal $img.UUID}}
    if ($Imgs.Count -eq 0){$Imgs=@(@{"Name"='No images found';"Type"='—';"Size (GB)"='—';"State"='—';"Description"='—';"UUID"='—'})}
    $Table=AddWordTable -Hashtable $Imgs -Columns "Name","Type","Size (GB)","State","Description","UUID" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    # ?? SECTION 6 – Data Protection ??????????????????????????????????????????
    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Protection Rules"
    WriteWordLine 1 0 "Nutanix Protection Rules (Prism Central)"
    WriteWordLine 3 0 "Protection Rules"
    [System.Collections.Hashtable[]]$PD=@()
    foreach ($pr in $Inv.DataProtection.ProtectionRules){$PD+=@{"Name"=Get-SafeVal $pr.Name;"Active"=Get-SafeVal $pr.State;"Pending Replications"='—';"Current Replication"='—';"Removal"='—';"Written Bytes"='—'}}
    if ($PD.Count -eq 0){$PD=@(@{"Name"='—';"Active"='—';"Pending Replications"='—';"Current Replication"='—';"Removal"='—';"Written Bytes"='—'})}
    $Table=AddWordTable -Hashtable $PD -Columns "Name","Active","Pending Replications","Current Replication","Removal","Written Bytes" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    WriteWordLine 3 0 "Unprotected VMs"
    [System.Collections.Hashtable[]]$UV=@()
    foreach ($vm in $Inv.VirtualMachines) {
        if (-not $vm.ProtectionPolicy) {
            $UV+=@{"Name"=Get-SafeVal $vm.Name;"OS"='—';"Memory in GB"=if ($vm.MemoryGiB){[int]$vm.MemoryGiB}else{0};"Res. Memory in GB"='—';"vCPUs"=$vm.vCPUs;"Res. Hz"='—';"NICs"=$vm.NicCount;"Disk Capacity in GB"=$vm.ProvisionedGiB}
        }
    }
    if ($UV.Count -eq 0){$UV=@(@{"Name"='None — all VMs are protected';"OS"='—';"Memory in GB"='—';"Res. Memory in GB"='—';"vCPUs"='—';"Res. Hz"='—';"NICs"='—';"Disk Capacity in GB"='—'})}
    $Table=AddWordTable -Hashtable $UV -Columns "Name","OS","Memory in GB","Res. Memory in GB","vCPUs","Res. Hz","NICs","Disk Capacity in GB" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Data Protection and Disaster Recovery"
    WriteWordLine 1 0 "Nutanix Data Protection"
    WriteWordLine 3 0 "Recovery Plans"
    WriteWordLine 0 0 " "
    [System.Collections.Hashtable[]]$RP=@()
    foreach ($rp in $Inv.DataProtection.RecoveryPlans){$RP+=@{"Name"=Get-SafeVal $rp.Name;"UUID"=Get-SafeVal $rp.UUID;"Primary Site"=Get-SafeVal $rp.PrimarySite;"Recovery Site"=Get-SafeVal $rp.RecoverySite;"Stages"=$rp.Stages;"State"=Get-SafeVal $rp.State}}
    if ($RP.Count -eq 0){$RP=@(@{"Name"='No recovery plans defined';"UUID"='—';"Primary Site"='—';"Recovery Site"='—';"Stages"=0;"State"='—'})}
    $Table=AddWordTable -Hashtable $RP -Columns "Name","UUID","Primary Site","Recovery Site","Stages","State" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    WriteWordLine 3 0 "Availability Zones"
    WriteWordLine 0 0 " "
    [System.Collections.Hashtable[]]$AZ=@()
    foreach ($az in $Inv.DataProtection.AvailabilityZones){$AZ+=@{"Name"=Get-SafeVal $az.Name;"UUID"=Get-SafeVal $az.UUID;"Management URL"=Get-SafeVal $az.URL;"Zone Type"=Get-SafeVal $az.Type;"Connection State"='—'}}
    if ($AZ.Count -eq 0){$AZ=@(@{"Name"='No remote availability zones paired';"UUID"='—';"Management URL"='—';"Zone Type"='LOCAL';"Connection State"='—'})}
    $Table=AddWordTable -Hashtable $AZ -Columns "Name","UUID","Management URL","Zone Type","Connection State" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    # ?? SECTION 7 – Health and Operations ????????????????????????????????????
    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Health Summary and Alerts"
    WriteWordLine 1 0 "Nutanix Health"
    WriteWordLine 3 0 "Health (alerts summary)"
    [System.Collections.Hashtable[]]$HC2=@()
    foreach ($a in $Inv.Health.Alerts){$HC2+=@{"ID"=Get-SafeVal $a.Timestamp;"Name"=Get-SafeVal $a.Title;"Description"=Get-SafeVal $a.Message;"Causes"=Get-SafeVal $a.Severity}}
    if ($HC2.Count -eq 0){$HC2=@(@{"ID"='—';"Name"='No issues';"Description"='No critical alerts';"Causes"='—'})}
    $Table=AddWordTable -Hashtable $HC2 -Columns "ID","Name","Description","Causes" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    # Alert Severity Donut Chart
    Write-Verbose "$(Get-Date): Generating Alert Severity Chart..."
    $nCrit2  = @($Inv.Health.Alerts | Where-Object { $_.Severity -match 'CRITICAL|critical'}).Count
    $nWarn   = @($Inv.Health.Alerts | Where-Object { $_.Severity -match 'WARNING|warning'  }).Count
    $nInfo   = @($Inv.Health.Alerts | Where-Object { $_.Severity -match 'INFO|info'        }).Count
    $nOther2 = $Inv.Health.Alerts.Count - $nCrit2 - $nWarn - $nInfo
    $alertSlices=@()
    if ($nCrit2 -gt 0) { $alertSlices+=@{Label='Critical'; Value=$nCrit2; Color=(_NC 'Red')   } }
    if ($nWarn  -gt 0) { $alertSlices+=@{Label='Warning';  Value=$nWarn;  Color=(_NC 'Orange')} }
    if ($nInfo  -gt 0) { $alertSlices+=@{Label='Info';     Value=$nInfo;  Color=(_NC 'Blue')  } }
    if ($nOther2-gt 0) { $alertSlices+=@{Label='Other';    Value=$nOther2;Color=(_NC 'Gray')  } }
    if ($alertSlices.Count -gt 0){
        $diagPath=New-NtnxDonutChart -Slices $alertSlices -Title 'ALERT SEVERITY DISTRIBUTION' -CenterLabel [string]$Inv.Health.Alerts.Count
        if ($diagPath){Add-NtnxDiagram -ImagePath $diagPath -WidthPts 468 -BookmarkName 'NtnxFig7'}
        WriteWordLine 0 0 " "
    }

    If ($Full) {
        $Chapter++
        Write-Verbose "$(Get-Date): Chapter $Chapter - Hardware Alerts"
        WriteWordLine 1 0 "Nutanix Host Alerts"
        WriteWordLine 3 0 "Hardware / Critical Alerts"
        [System.Collections.Hashtable[]]$HWA=@()
        foreach ($a in $Inv.Health.Alerts){$HWA+=@{"Severity"=Get-SafeVal $a.Severity;"Message"=Get-SafeVal $a.Message}}
        if ($HWA.Count -eq 0){$HWA=@(@{"Severity"='N/A';"Message"='No critical alerts'})}
        $Table=AddWordTable -Hashtable $HWA -Columns "Severity","Message" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd
        WriteWordLine 0 0 "Note: If this chapter is empty there are no critical alerts"; WriteWordLine 0 0 " "
    }

    # ?? SECTION 8 – Identity and Access ??????????????????????????????????????
    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Identity and Access Management"
    WriteWordLine 1 0 "Identity and Access Management"

    WriteWordLine 3 0 "Local and Directory User Accounts"
    [System.Collections.Hashtable[]]$UR=@()
    foreach ($u in $Inv.IdentityAccess.Users){$UR+=@{"Username / UPN"=Get-SafeVal $u.Username;"Display Name"=Get-SafeVal $u.DisplayName;"Type"=Get-SafeVal $u.UserType;"Directory"='—';"State"=if ($u.Enabled){'Enabled'}else{'Disabled'};"UUID"=Get-SafeVal $u.UUID}}
    if ($UR.Count -eq 0){$UR=@(@{"Username / UPN"='No users found';"Display Name"='—';"Type"='—';"Directory"='—';"State"='—';"UUID"='—'})}
    $Table=AddWordTable -Hashtable $UR -Columns "Username / UPN","Display Name","Type","Directory","State","UUID" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    WriteWordLine 3 0 "User Groups (LDAP / AD Group Mappings)"
    [System.Collections.Hashtable[]]$UGR=@()
    foreach ($ug in $Inv.IdentityAccess.UserGroups){$UGR+=@{"Distinguished Name"=Get-SafeVal $ug.DistinguishedName;"SAM Account Name"='—';"Type"=Get-SafeVal $ug.GroupType;"Directory"='—';"UUID"=Get-SafeVal $ug.UUID}}
    if ($UGR.Count -eq 0){$UGR=@(@{"Distinguished Name"='No user groups mapped';"SAM Account Name"='—';"Type"='—';"Directory"='—';"UUID"='—'})}
    $Table=AddWordTable -Hashtable $UGR -Columns "Distinguished Name","SAM Account Name","Type","Directory","UUID" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    WriteWordLine 3 0 "RBAC Roles"
    [System.Collections.Hashtable[]]$RR=@()
    foreach ($r in $Inv.IdentityAccess.Roles){$RR+=@{"Role Name"=Get-SafeVal $r.Name;"Description"=Get-SafeVal $r.Description;"Permission Count"=$r.PermissionCount;"UUID"=Get-SafeVal $r.UUID}}
    if ($RR.Count -eq 0){$RR=@(@{"Role Name"='No custom roles defined';"Description"='—';"Permission Count"=0;"UUID"='—'})}
    $Table=AddWordTable -Hashtable $RR -Columns "Role Name","Description","Permission Count","UUID" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    WriteWordLine 3 0 "Access Control Policies (Role Assignments)"
    [System.Collections.Hashtable[]]$ACPR=@()
    foreach ($acp in $Inv.IdentityAccess.AccessControlPolicies){$ACPR+=@{"ACP Name"=Get-SafeVal $acp.Name;"Role"=Get-SafeVal $acp.RoleName;"Users"=Get-SafeVal(if ($acp.Users){$acp.Users -join ', '}else{$null});"Groups"=Get-SafeVal(if ($acp.Groups){$acp.Groups -join ', '}else{$null});"UUID"=Get-SafeVal $acp.UUID}}
    if ($ACPR.Count -eq 0){$ACPR=@(@{"ACP Name"='No access control policies found';"Role"='—';"Users"='—';"Groups"='—';"UUID"='—'})}
    $Table=AddWordTable -Hashtable $ACPR -Columns "ACP Name","Role","Users","Groups","UUID" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    $Chapter++
    Write-Verbose "$(Get-Date): Chapter $Chapter - Projects"
    WriteWordLine 1 0 "Nutanix Projects"
    WriteWordLine 3 0 "Projects (Multi-Tenancy / Self-Service)"
    WriteWordLine 0 0 " "
    [System.Collections.Hashtable[]]$ProjR=@()
    foreach ($proj in $Inv.Projects){$ProjR+=@{"Project Name"=Get-SafeVal $proj.Name;"Default Cluster"=Get-SafeVal $proj.DefaultCluster;"Default Subnet"=Get-SafeVal $proj.DefaultSubnet;"User Count"=$proj.UserCount;"Group Count"=$proj.GroupCount;"UUID"=Get-SafeVal $proj.UUID}}
    if ($ProjR.Count -eq 0){$ProjR=@(@{"Project Name"='No projects defined';"Default Cluster"='—';"Default Subnet"='—';"User Count"=0;"Group Count"=0;"UUID"='—'})}
    $Table=AddWordTable -Hashtable $ProjR -Columns "Project Name","Default Cluster","Default Subnet","User Count","Group Count","UUID" -Format $wdTableGrid -AutoFit $wdAutoFitWindow; FindWordDocumentEnd; WriteWordLine 0 0 " "

    # ?? Update TOC so it reflects all headings just written ???????????????????
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): Updating Table of Contents..."
    try {
        $tocColl = $Script:Word.ActiveDocument.TablesOfContents
        if ($tocColl -and $tocColl.Count -gt 0) {
            $tocColl.Item(1).Update() | Out-Null
        }
    } catch { Write-Verbose "  [TOC-Update] $($_.Exception.Message)" }

    Write-Verbose "$(Get-Date): All $Chapter chapters written (including visual diagrams)."
}

# ???????????????????????????????????????????????????????????????????????????????
#  PART 11 ?? Entry Point
# ???????????????????????????????????????????????????????????????????????????????

If ($MSWord -or $PDF) {
    $Script:CoName = $CompanyName
    Write-Verbose "$(Get-Date): CoName is $($Script:CoName)"
    SetFileName1andFileName2 'Nutanix Documentation Script'
    [string]$Script:Title = "Nutanix Documentation Script $Script:CoName"
    CheckWordPrereq
    SetupWord
    FindWordDocumentEnd
}

$inventory = Get-NutanixInventory `
    -PcIP      $PrismCentralIP `
    -Cred      $Script:ActiveCred `
    -Throttle  $VmThrottle `
    -SkipDetail $SkipVmDetails.IsPresent

# Only write the inventory object to the pipeline when NOT generating a Word/PDF file,
# so it doesn't flood the console with raw property output during normal report runs.
If (-not ($MSWord -or $PDF)) {
    $inventory | Select-Object -ExcludeProperty _Raw
}

If ($MSWord -or $PDF) {
    Write-Verbose "$(Get-Date): Writing Word document..."
    Write-NtnxWordReport -Inv $inventory
    Write-Verbose "$(Get-Date): Finishing up document"
    UpdateDocumentProperties 'Nutanix Documentation Report' 'Nutanix Documentation Report'
    SaveandCloseDocumentandShutdownWord

    $GotFile=$False
    If ($PDF) {
        If (Test-Path $Script:FileName2){Write-Verbose "$(Get-Date): $($Script:FileName2) is ready for use"; $GotFile=$True}
        Else{Write-Warning "Unable to save output file: $($Script:FileName2)"}
    } Else {
        If (Test-Path $Script:FileName1){Write-Verbose "$(Get-Date): $($Script:FileName1) is ready for use"; $GotFile=$True}
        Else{Write-Warning "Unable to save output file: $($Script:FileName1)"}
    }
    If ($GotFile -and ![System.String]::IsNullOrEmpty($SmtpServer)) {
        $emailAttachment=If ($PDF){$Script:FileName2}Else{$Script:FileName1}
        SendEmail $emailAttachment
    }
}

If ($ExportJson) {
    $compress=$true
    If ([string]::IsNullOrEmpty($JsonPath)) {
        $ts=[datetime]::UtcNow.ToString('yyyyMMdd_HHmmss')
        $JsonPath=Join-Path (Get-Location) "NutanixInventory_${ts}.json.gz"
    }
    If ($JsonPath -notmatch '\.gz$'){$compress=$false}
    Write-Verbose "$(Get-Date -f 'HH:mm:ss'): Exporting inventory ? $JsonPath (compressed: $compress)"
    $json=$inventory|Select-Object -ExcludeProperty _Raw|ConvertTo-Json -Depth 20 -Compress
    $bytes=[System.Text.Encoding]::UTF8.GetBytes($json)
    If ($compress) {
        $outFs=[System.IO.File]::Create($JsonPath)
        $gzip=[System.IO.Compression.GZipStream]::new($outFs,[System.IO.Compression.CompressionMode]::Compress)
        try{$gzip.Write($bytes,0,$bytes.Length)}finally{$gzip.Close();$outFs.Close()}
        $kib=[Math]::Round((Get-Item $JsonPath).Length/1KB,1)
        Write-Host "Inventory exported (compressed): $JsonPath  ($kib KB)" -ForegroundColor Green
    } Else {
        [System.IO.File]::WriteAllBytes($JsonPath,$bytes)
        Write-Host "Inventory exported: $JsonPath  ($([Math]::Round($bytes.Length/1KB,1)) KB)" -ForegroundColor Green
    }
}

Write-Verbose "$(Get-Date): Script started: $($Script:StartTime)"
Write-Verbose "$(Get-Date): Script ended: $(Get-Date)"
$runtime=$(Get-Date)-$Script:StartTime
Write-Verbose ("$(Get-Date): Elapsed time: {0} days, {1} hours, {2} minutes, {3}.{4} seconds" -f $runtime.Days,$runtime.Hours,$runtime.Minutes,$runtime.Seconds,$runtime.Milliseconds)

If ($Dev){Out-File -FilePath $Script:DevErrorFile -InputObject $error 4>$Null}

If ($ScriptInfo) {
    $SIFile="$Script:pwdpath\NutanixInventoryScriptInfo_$(Get-Date -f yyyy-MM-dd_HHmm).txt"
    @(
        "Script Version : 5.0"
        "Add DateTime   : $AddDateTime"
        "Company Name   : $Script:CoName"
        "Cover Page     : $CoverPage"
        "Filename1      : $Script:FileName1"
        "PDF            : $PDF"
        "MSWord         : $MSWord"
        "Full           : $Full"
        "VmThrottle     : $VmThrottle"
        "MaxRetries     : $MaxRetries"
        "PrismCentralIP : $PrismCentralIP"
        "Script start   : $Script:StartTime"
    ) | Out-File -FilePath $SIFile 4>$Null
}

$ErrorActionPreference = $SaveEAPreference

 

Script author: Kees Baggerman. Word infrastructure by Carl Webster and Michael B. Smith.

The following two tabs change content below.

Kees Baggerman

Kees Baggerman is Senior Technical Director — Performance & Solutions Engineering R&D at Nutanix, where he leads a global team responsible for defining how enterprise applications are delivered on the Nutanix platform. A former Citrix Technology Professional and NVIDIA Enterprise Platform Advisor, he has spent 15+ years driving EUC strategy and technical direction across architecture, product, and customer success. He has been writing here since 2011 — sharing what he learns at the intersection of platform engineering and enterprise IT.
Kees Baggerman

Kees Baggerman

Senior Technical Director at Nutanix - Former Citrix CTP - NVIDIA Enterprise Platform Advisor - 15+ years in EUC

Kees Baggerman is Senior Technical Director — Performance & Solutions Engineering R&D at Nutanix, where he leads a global team responsible for defining how enterprise applications are delivered on the Nutanix platform. A former Citrix Technology Professional and NVIDIA Enterprise Platform Advisor, he has spent 15+ years driving EUC strategy and technical direction across architecture, product, and customer success. He has been writing here since 2011 — sharing what he learns at the intersection of platform engineering and enterprise IT.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.