Invoke-NetStress.ps1

<#
.SYNOPSIS
    Invoke-NetStress: High-Performance Network Bandwidth & Bufferbloat Stress Suite.

.DESCRIPTION
    Generates heavy, multi-threaded, asynchronous HTTP/S traffic to completely saturate
    network links (WAN, LAN, SD-WAN, or VPN) while continuously sampling latency and jitter
    via hardware-level .NET ICMP pings. Measures bufferbloat severity, calculates queueing delay,
    assigns an industry-standard Bufferbloat Grade (A+ to F), renders a real-time ANSI terminal
    dashboard with sparklines, and exports comprehensive post-test reports (HTML, JSON, CSV).

    Supports both public global Edge CDNs (e.g. Cloudflare) and air-gapped private LAN / intranet
    endpoints without requiring third-party agents or listeners.

.PARAMETER Intensity
    Preconfigured traffic intensity profile:
    - 'Low' : 4 concurrent sockets
    - 'Medium' : 16 concurrent sockets
    - 'High' : 32 concurrent sockets (Default)
    - 'Extreme' : 64 concurrent sockets
    - 'Ultra' : 128 concurrent sockets

.PARAMETER Mode
    Traffic pattern:
    - 'Duplex' : Concurrent upload and download (maximum stress on bidirectional queues)
    - 'Download' : Heavy multi-stream download only
    - 'Upload' : Heavy multi-stream upload only

.PARAMETER Duration
    Duration of the stress test in seconds (default: 30 seconds).

.PARAMETER PingTarget
    Target host or IP address to ping continuously to observe latency increase under load (default: 1.1.1.1).

.PARAMETER CustomThreads
    Specify any custom number of parallel worker threads (overrides -Intensity).

.PARAMETER DownloadUri
    Target endpoint URL for download streams (default: Cloudflare Edge speed endpoint).
    Can be pointed to internal web servers (e.g. NGINX, Apache, IIS) for local LAN testing.

.PARAMETER UploadUri
    Target endpoint URL for upload streams (default: Cloudflare Edge speed endpoint).

.PARAMETER PayloadChunkMB
    Size of upload buffer chunks in megabytes (default: 2 MB).

.PARAMETER AcknowledgeAuthorization
    Required compliance flag confirming authorization to stress-test the network link.
    Aliases: -AcceptTerms, -Authorized.

.PARAMETER ReportPath
    Directory path to store exported reports (HTML, JSON, CSV). Default is 'reports'.

.PARAMETER NoReport
    Suppress generation of report files.

.PARAMETER SimpleDisplay
    Use traditional scrolling text display instead of the dynamic in-place ANSI terminal dashboard.

.PARAMETER PassThru
    Outputs the final test result object to the PowerShell pipeline for automated scripting.

.EXAMPLE
    .\Invoke-NetStress.ps1 -Duration 20 -Intensity Medium -AcknowledgeAuthorization
    Runs a 20-second duplex stress test with 16 parallel sockets and displays the live ANSI dashboard.

.EXAMPLE
    .\Invoke-NetStress.ps1 -Mode Download -Duration 45 -Intensity Extreme -PingTarget "8.8.8.8" -AcknowledgeAuthorization
    Runs a 45-second high-intensity download saturation test pinging Google DNS.

.EXAMPLE
    .\Invoke-NetStress.ps1 -DownloadUri "http://192.168.1.100:8080/large.bin" -UploadUri "http://192.168.1.100:8080/upload" -AcknowledgeAuthorization
    Runs a stress test against an internal enterprise server on an isolated LAN.

.NOTES
    Author: NetStress Engineering Team
    Repository: https://github.com/AlphaMvge/Invoke-NetStress
    License: MIT
#>


[CmdletBinding(DefaultParameterSetName = "Standard")]
param (
    [Parameter(Position = 0)]
    [ValidateSet("Low", "Medium", "High", "Extreme")]
    [string]$Intensity = "Medium",

    [Parameter(Position = 1)]
    [ValidateSet("Duplex", "Download", "Upload")]
    [string]$Mode = "Duplex",

    [Parameter(Position = 2)]
    [ValidateRange(5, 3600)]
    [int]$Duration = 30,

    [Parameter(Position = 3)]
    [string]$PingTarget = "1.1.1.1",

    [Parameter()]
    [ValidateRange(1, 512)]
    [int]$CustomThreads = 0,

    [Parameter()]
    [string]$DownloadUri = "https://speed.cloudflare.com/__down?bytes=50000000",

    [Parameter()]
    [string]$UploadUri = "https://speed.cloudflare.com/__up",

    [Parameter()]
    [ValidateRange(1, 32)]
    [int]$PayloadChunkMB = 2,

    [Parameter()]
    [Alias("AcceptTerms", "Authorized")]
    [switch]$AcknowledgeAuthorization,

    [Parameter()]
    [string]$ReportPath = "reports",

    [Parameter()]
    [switch]$NoReport,

    [Parameter()]
    [switch]$SimpleDisplay,

    [Parameter()]
    [switch]$PassThru
)

Set-StrictMode -Off
$ErrorActionPreference = "Stop"

try {
    [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
    $OutputEncoding = [System.Text.Encoding]::UTF8
} catch {}

# ==============================================================================
# 1. INTERACTIVE TARGET & INTENSITY SELECTION & CONFIRMATION
# ==============================================================================
function Request-TestConfiguration {
    [CmdletBinding()]
    param(
        [ref]$TargetHost,
        [ref]$IntensityLevel
    )

    $defaultTarget = if ($TargetHost.Value) { $TargetHost.Value } else { "1.1.1.1" }

    Write-Host "`n╔══════════════════════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
    Write-Host "║ INVOKE-NETSTRESS: PRE-FLIGHT CONFIGURATION ║" -ForegroundColor Yellow
    Write-Host "╚══════════════════════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan

    # Step 1: Prompt for target IP
    Write-Host "`n[STEP 1/2] TARGET SPECIFICATION" -ForegroundColor Cyan
    $promptMsg = " Enter Target IP or Hostname to stress test [$defaultTarget]"
    $userEntry = Read-Host -Prompt $promptMsg

    if (-not [string]::IsNullOrWhiteSpace($userEntry)) {
        $TargetHost.Value = $userEntry.Trim()
    } else {
        $TargetHost.Value = $defaultTarget
    }

    $activeTarget = $TargetHost.Value

    # Step 2: Prompt for Intensity with Estimated Output
    Write-Host "`n[STEP 2/2] SELECT TRAFFIC INTENSITY (TARGET: $activeTarget)" -ForegroundColor Cyan
    Write-Host " ------------------------------------------------------------------------------" -ForegroundColor DarkGray
    Write-Host " [1] Low | 4 Sockets | Est. Output: ~150 - 500 Mbps (Light / Baseline Check)" -ForegroundColor Green
    Write-Host " [2] Medium | 16 Sockets | Est. Output: ~500 - 1,200 Mbps (Standard Gigabit Link)" -ForegroundColor Cyan
    Write-Host " [3] High | 32 Sockets | Est. Output: ~1.2 - 2.5 Gbps (Multi-Gig WAN / Fiber)" -ForegroundColor Yellow
    Write-Host " [4] Extreme | 64 Sockets | Est. Output: ~2.5 - 5.0+ Gbps (Max Saturation / 10GbE)" -ForegroundColor Red
    Write-Host " ------------------------------------------------------------------------------" -ForegroundColor DarkGray

    $curIntensity = if ($IntensityLevel.Value) { $IntensityLevel.Value } else { "Medium" }
    $defaultIndex = switch ($curIntensity) {
        "Low"     { "1" }
        "Medium"  { "2" }
        "High"    { "3" }
        "Extreme" { "4" }
        default   { "2" }
    }

    $intensityInput = Read-Host -Prompt " Select intensity profile [1-4] or name (Default: [$defaultIndex] $curIntensity)"

    if ([string]::IsNullOrWhiteSpace($intensityInput)) {
        $intensityInput = $defaultIndex
    }

    $selectedIntensity = switch -Regex ($intensityInput.Trim()) {
        "^1$|^low$"     { "Low" }
        "^2$|^medium$"  { "Medium" }
        "^3$|^high$"    { "High" }
        "^4$|^extreme$" { "Extreme" }
        default         { "Medium" }
    }

    $IntensityLevel.Value = $selectedIntensity

    $selectedEstimate = switch ($selectedIntensity) {
        "Low"     { "4 Sockets | Est. Output: ~150 - 500 Mbps" }
        "Medium"  { "16 Sockets | Est. Output: ~500 - 1,200 Mbps" }
        "High"    { "32 Sockets | Est. Output: ~1.2 - 2.5 Gbps" }
        "Extreme" { "64 Sockets | Est. Output: ~2.5 - 5.0+ Gbps" }
    }

    # Step 3: Explicit Verification & Confirmation
    Write-Host "`n╔══════════════════════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
    Write-Host "║ CONFIRM TEST PARAMETERS ║" -ForegroundColor Yellow
    Write-Host "╚══════════════════════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan
    Write-Host " • Target Host : " -NoNewline -ForegroundColor Gray
    Write-Host "$activeTarget" -ForegroundColor Green
    Write-Host " • Intensity Level : " -NoNewline -ForegroundColor Gray
    Write-Host "$selectedIntensity ($selectedEstimate)" -ForegroundColor Yellow
    Write-Host " ------------------------------------------------------------------------------" -ForegroundColor DarkGray

    $confirmResponse = Read-Host -Prompt " [?] Confirm these parameters and launch stress test? [Y/n]"

    if ([string]::IsNullOrWhiteSpace($confirmResponse) -or $confirmResponse -match "^[yY]([eE][sS])?$") {
        Write-Host " [✓] Parameters confirmed. Initializing engine...`n" -ForegroundColor Green
    } else {
        throw "Operation aborted by user: Target and intensity were not confirmed."
    }
}

Request-TestConfiguration -TargetHost ([ref]$PingTarget) -IntensityLevel ([ref]$Intensity)

# ==============================================================================
# 2. AUTHORIZATION & COMPLIANCE GUARDRAIL
# ==============================================================================
function Assert-AuthorizationConsent {
    [CmdletBinding()]
    param([switch]$Acknowledge)

    if ($Acknowledge) {
        return $true
    }

    $banner = @"
╔══════════════════════════════════════════════════════════════════════════════╗
║ NETWORK STRESS TEST AUTHORIZATION & COMPLIANCE ║
╚══════════════════════════════════════════════════════════════════════════════╝
 WARNING: This tool generates high-volume multi-threaded saturated network traffic
 designed to test link throughput and bufferbloat queue delays.

 Unauthorized network stress testing or volumetric traffic generation against
 targets or networks you do not own or have explicit written permission to test
 is strictly illegal and may violate national cybersecurity laws and ISP terms.

 Target Endpoints:
   • Ping Monitor : $PingTarget
   • Download URI : $DownloadUri
   • Upload URI : $UploadUri
   • Stress Mode : $Mode
════════════════════════════════════════════════════════════════════════════════
"@


    Write-Host $banner -ForegroundColor Yellow
    $response = Read-Host -Prompt "Do you confirm you have explicit authorization to stress-test this network? [y/N]"

    if ($response -match "^[yY]([eE][sS])?$") {
        Write-Host "[✓] Authorization confirmed by operator.`n" -ForegroundColor Green
        return $true
    } else {
        throw "Operation aborted by user: Authorization not confirmed."
    }
}

Assert-AuthorizationConsent -Acknowledge:$AcknowledgeAuthorization

# ==============================================================================
# 2. ATOMIC COUNTERS & .NET INITIALIZATION
# ==============================================================================
Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue

if (-not ([System.Management.Automation.PSTypeName]'NetStressAtomicCounters').Type) {
    Add-Type @"
    public class NetStressAtomicCounters {
        private long downloadBytes = 0;
        private long uploadBytes = 0;
        public void AddDownload(long amount) { System.Threading.Interlocked.Add(ref downloadBytes, amount); }
        public void AddUpload(long amount) { System.Threading.Interlocked.Add(ref uploadBytes, amount); }
        public long GetDownload() { return System.Threading.Interlocked.Read(ref downloadBytes); }
        public long GetUpload() { return System.Threading.Interlocked.Read(ref uploadBytes); }
        public long GetTotal() { return System.Threading.Interlocked.Read(ref downloadBytes) + System.Threading.Interlocked.Read(ref uploadBytes); }
    }
"@

}

# Network connection settings for maximum concurrency
try {
    [System.Net.ServicePointManager]::DefaultConnectionLimit = 1024
    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 -bor [System.Net.SecurityProtocolType]::Tls13
} catch {}

# Determine thread count
$threads = switch ($Intensity) {
    "Low"     { 4 }
    "Medium"  { 16 }
    "High"    { 32 }
    "Extreme" { 64 }
}

if ($CustomThreads -gt 0) {
    $threads = $CustomThreads
    $Intensity = "Custom ($threads threads)"
}

# ==============================================================================
# 3. ICMP PING ENGINE & BUFFERBLOAT GRADING
# ==============================================================================
function Get-FastPingSample {
    param(
        [string]$Target,
        [int]$TimeoutMs = 1000
    )
    $pinger = New-Object System.Net.NetworkInformation.Ping
    try {
        $buffer = [byte[]](1..32 | ForEach-Object { 65 })
        $options = New-Object System.Net.NetworkInformation.PingOptions(64, $true)
        $reply = $pinger.Send($Target, $TimeoutMs, $buffer, $options)
        if ($reply.Status -eq [System.Net.NetworkInformation.IPStatus]::Success) {
            return [double]$reply.RoundtripTime
        }
        return $null
    } catch {
        return $null
    } finally {
        $pinger.Dispose()
    }
}

function Get-BufferbloatAssessment {
    param([double]$DeltaMs, [double]$PacketLossRate)

    if ($PacketLossRate -gt 0.05 -or $DeltaMs -ge 120.0) {
        return @{
            Grade       = "F"
            Title       = "Poor / Severe Bufferbloat"
            Description = "Severe queueing delay or packet loss. Real-time apps (VoIP, gaming) will stutter heavily."
            Color       = "Red"
        }
    } elseif ($DeltaMs -ge 60.0) {
        return @{
            Grade       = "D"
            Title       = "Substandard Queueing"
            Description = "Noticeable latency spike under link saturation. Noticeable lag during heavy transfers."
            Color       = "Magenta"
        }
    } elseif ($DeltaMs -ge 30.0) {
        return @{
            Grade       = "C"
            Title       = "Fair Performance"
            Description = "Moderate queueing delay. Typical for unmanaged consumer ISP routers without SQM."
            Color       = "Yellow"
        }
    } elseif ($DeltaMs -ge 15.0) {
        return @{
            Grade       = "B"
            Title       = "Good Management"
            Description = "Minor latency increase. General web browsing and streaming remain smooth."
            Color       = "Green"
        }
    } elseif ($DeltaMs -ge 5.0) {
        return @{
            Grade       = "A"
            Title       = "Great Queue Discipline"
            Description = "Very low latency increase. Responsive link under heavy load."
            Color       = "Green"
        }
    } else {
        return @{
            Grade       = "A+"
            Title       = "Exceptional (Zero Bloat)"
            Description = "Virtually imperceptible buffer latency. State-of-the-art SQM (CAKE/fq_codel) active."
            Color       = "Cyan"
        }
    }
}

function Render-Sparkline {
    param(
        [double[]]$Values,
        [int]$MaxItems = 20
    )
    if (-not $Values -or $Values.Count -eq 0) { return "...................." }
    $recent = if ($Values.Count -gt $MaxItems) { $Values[($Values.Count - $MaxItems)..($Values.Count - 1)] } else { $Values }
    $min = ($recent | Measure-Object -Minimum).Minimum
    $max = ($recent | Measure-Object -Maximum).Maximum
    $range = $max - $min
    if ($range -le 0) { $range = 1 }

    $glyphs = @(' ', '▂', '▃', '▄', '▅', '▆', '▇', '█')
    $builder = New-Object System.Text.StringBuilder
    foreach ($val in $recent) {
        $normalized = ($val - $min) / $range
        $idx = [int][math]::Floor($normalized * ($glyphs.Count - 1))
        if ($idx -lt 0) { $idx = 0 }
        if ($idx -ge $glyphs.Count) { $idx = $glyphs.Count - 1 }
        [void]$builder.Append($glyphs[$idx])
    }
    return $builder.ToString()
}

function Render-MeterBar {
    param(
        [double]$Value,
        [double]$MaxExpected = 1000.0,
        [int]$Width = 18
    )
    if ($MaxExpected -le 0) { $MaxExpected = 100 }
    $ratio = [math]::Min(1.0, [math]::Max(0.0, $Value / $MaxExpected))
    $filled = [int][math]::Round($ratio * $Width)
    $empty = $Width - $filled
    $bar = ("█" * $filled) + ("░" * $empty)
    return $bar
}

# ==============================================================================
# 4. BASELINE IDLE LATENCY CALIBRATION
# ==============================================================================
Write-Host "`n[1/3] Measuring baseline idle latency to $PingTarget (5 samples)..." -ForegroundColor Cyan
$baselineSamples = @()
for ($i = 1; $i -le 5; $i++) {
    $sample = Get-FastPingSample -Target $PingTarget -TimeoutMs 1200
    if ($null -ne $sample) {
        $baselineSamples += $sample
        Write-Host " Sample ${i}: $sample ms" -ForegroundColor DarkGray
    } else {
        Write-Host " Sample ${i}: Timed out" -ForegroundColor Yellow
    }
    Start-Sleep -Milliseconds 250
}

if ($baselineSamples.Count -gt 0) {
    $baselinePing = [math]::Round(($baselineSamples | Measure-Object -Average).Average, 1)
    $baselineMin = [math]::Round(($baselineSamples | Measure-Object -Minimum).Minimum, 1)
    $baselineMax = [math]::Round(($baselineSamples | Measure-Object -Maximum).Maximum, 1)
    Write-Host "[✓] Baseline Idle Ping: $baselinePing ms (min: $baselineMin ms, max: $baselineMax ms)" -ForegroundColor Green
} else {
    $baselinePing = 20.0
    Write-Host "[!] Ping to $PingTarget timed out during baseline. Using fallback estimated baseline (20.0 ms)." -ForegroundColor Yellow
}

# ==============================================================================
# 5. ASYNC WORKER SETUP (RUNSPACE POOL)
# ==============================================================================
Write-Host "`n[2/3] Initializing $threads traffic worker threads..." -ForegroundColor Cyan

$cancellationTokenSource = New-Object System.Threading.CancellationTokenSource
$token = $cancellationTokenSource.Token
$counter = New-Object NetStressAtomicCounters

# Upload payload generation: in-memory chunk
$payloadBytesCount = $PayloadChunkMB * 1024 * 1024
$uploadPayload = New-Object byte[] $payloadBytesCount
$rng = New-Object System.Random
$rng.NextBytes($uploadPayload)

$runspacePool = [runspacefactory]::CreateRunspacePool(1, $threads + 2)
$runspacePool.Open()

# Asynchronous HTTP Stream Worker Script
$workerScript = {
    param(
        $Token,
        $WorkerMode,
        $WorkerId,
        $CounterObj,
        $UploadBytes,
        $DownUrl,
        $UpUrl
    )

    Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue

    $handler = New-Object System.Net.Http.HttpClientHandler
    $handler.MaxConnectionsPerServer = 512
    $client = New-Object System.Net.Http.HttpClient($handler)
    $client.Timeout = [TimeSpan]::FromSeconds(15)

    $readBuffer = New-Object byte[] 262144 # 256KB buffer

    while (-not $Token.IsCancellationRequested) {
        try {
            $isDownload = ($WorkerMode -eq "Download") -or ($WorkerMode -eq "Duplex" -and ($WorkerId % 2 -eq 0))
            if ($isDownload) {
                $response = $client.GetAsync($DownUrl, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead, $Token).GetAwaiter().GetResult()
                if ($response.IsSuccessStatusCode) {
                    $stream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult()
                    while (-not $Token.IsCancellationRequested) {
                        $bytesRead = $stream.Read($readBuffer, 0, $readBuffer.Length)
                        if ($bytesRead -le 0) { break }
                        $CounterObj.AddDownload([long]$bytesRead)
                    }
                    $stream.Dispose()
                } else {
                    Start-Sleep -Milliseconds 200
                }
                $response.Dispose()
            } else {
                $content = [System.Net.Http.ByteArrayContent]::new($UploadBytes)
                $response = $client.PostAsync($UpUrl, $content, $Token).GetAwaiter().GetResult()
                if ($response.IsSuccessStatusCode) {
                    $CounterObj.AddUpload([long]$UploadBytes.Length)
                } else {
                    Start-Sleep -Milliseconds 200
                }
                $content.Dispose()
                $response.Dispose()
            }
        } catch {
            Start-Sleep -Milliseconds 40
        }
    }
    $client.Dispose()
}

$asyncJobs = @()
for ($w = 0; $w -lt $threads; $w++) {
    $ps = [powershell]::Create()
    $ps.RunspacePool = $runspacePool
    [void]$ps.AddScript($workerScript)
    [void]$ps.AddArgument($token)
    [void]$ps.AddArgument($Mode)
    [void]$ps.AddArgument($w)
    [void]$ps.AddArgument($counter)
    [void]$ps.AddArgument($uploadPayload)
    [void]$ps.AddArgument($DownloadUri)
    [void]$ps.AddArgument($UploadUri)
    $asyncJobs += [PSCustomObject]@{
        PowerShell = $ps
        Handle     = $ps.BeginInvoke()
    }
}

# ==============================================================================
# 6. REAL-TIME MONITORING & ANSI DASHBOARD
# ==============================================================================
Write-Host "[3/3] Saturated traffic engine running! Monitoring link in real-time...`n" -ForegroundColor Green

$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$lastDown = 0L
$lastUp = 0L
$pingHistory = @()
$timelineData = @()
$maxLoadedPing = 0.0
$totalLostPackets = 0
$totalPingSamples = 0
$lastPingMs = $baselinePing
$maxObservedMbps = 100.0

# Prepare terminal capabilities
$isAnsiSupported = -not $SimpleDisplay -and [Environment]::UserInteractive -and -not [Console]::IsOutputRedirected

if ($isAnsiSupported) {
    [Console]::CursorVisible = $false
} else {
    Write-Host ("{0,-7} | {1,-11} | {2,-11} | {3,-11} | {4,-15} | {5,-8} | {6,-16}" -f `
        "Time", "Down(Mbps)", "Up(Mbps)", "Total(Mbps)", "Ping (ms)", "Jitter", "Status") -ForegroundColor Gray
    Write-Host ("-" * 85) -ForegroundColor DarkGray
}

$cleanTeardownDone = $false

try {
    $lastSampleSec = 0.0
    while ($stopwatch.Elapsed.TotalSeconds -lt $Duration) {
        $loopStart = [System.Diagnostics.Stopwatch]::GetTimestamp()
        Start-Sleep -Milliseconds 980

        $curTimeSec = $stopwatch.Elapsed.TotalSeconds
        $intervalSec = if ($lastSampleSec -gt 0) { [math]::Max(0.1, $curTimeSec - $lastSampleSec) } else { 1.0 }
        $lastSampleSec = $curTimeSec

        $elapsedSec = [math]::Round($curTimeSec, 1)
        $curDown = $counter.GetDownload()
        $curUp = $counter.GetUpload()

        $deltaDown = $curDown - $lastDown
        $deltaUp = $curUp - $lastUp
        $lastDown = $curDown
        $lastUp = $curUp

        $downMbps = [math]::Round(($deltaDown * 8) / ($intervalSec * 1000000.0), 2)
        $upMbps = [math]::Round(($deltaUp * 8) / ($intervalSec * 1000000.0), 2)
        $totalMbps = [math]::Round((($deltaDown + $deltaUp) * 8) / ($intervalSec * 1000000.0), 2)
        if ($totalMbps -gt $maxObservedMbps) { $maxObservedMbps = $totalMbps }

        $transferredMB = [math]::Round(($curDown + $curUp) / (1024 * 1024), 1)

        # Ping & Jitter measurement
        $totalPingSamples++
        $pingMs = Get-FastPingSample -Target $PingTarget -TimeoutMs 900
        $jitter = 0.0

        if ($null -ne $pingMs) {
            $pingHistory += $pingMs
            if ($pingMs -gt $maxLoadedPing) { $maxLoadedPing = $pingMs }
            $deltaPing = [math]::Round($pingMs - $baselinePing, 1)
            $jitter = [math]::Round([math]::Abs($pingMs - $lastPingMs), 1)
            $lastPingMs = $pingMs

            $assessment = Get-BufferbloatAssessment -DeltaMs $deltaPing -PacketLossRate ($totalLostPackets / [math]::Max(1, $totalPingSamples))
            $statusText = $assessment.Title
            $statusColor = $assessment.Color
            $pingFormatted = "$pingMs ms (+$deltaPing ms)"
        } else {
            $totalLostPackets++
            $deltaPing = $null
            $pingFormatted = "TIMED OUT"
            $statusText = "PACKET LOSS"
            $statusColor = "Red"
        }

        # Record timeline point
        $timelineData += [PSCustomObject]@{
            Timestamp    = (Get-Date).ToString("o")
            ElapsedSec   = $elapsedSec
            DownBytes    = $curDown
            UpBytes      = $curUp
            DownMbps     = $downMbps
            UpMbps       = $upMbps
            TotalMbps    = $totalMbps
            TotalMB      = $transferredMB
            PingMs       = $pingMs
            DeltaPingMs  = $deltaPing
            JitterMs     = $jitter
            Status       = $statusText
        }

        # Visual rendering
        if ($isAnsiSupported) {
            # Compute current stats
            $lossPct = [math]::Round(($totalLostPackets / [math]::Max(1, $totalPingSamples)) * 100, 1)
            $sparkline = Render-Sparkline -Values $pingHistory -MaxItems 24
            $downBar = Render-MeterBar -Value $downMbps -MaxExpected $maxObservedMbps -Width 16
            $upBar   = Render-MeterBar -Value $upMbps -MaxExpected $maxObservedMbps -Width 16
            $progressPct = [math]::Min(100, [int][math]::Round(($elapsedSec / $Duration) * 100))
            $progressBar = ("=" * [int]($progressPct / 5)) + (" " * (20 - [int]($progressPct / 5)))
            $currentGrade = if ($pingHistory.Count -gt 0) {
                $avgLoaded = ($pingHistory | Measure-Object -Average).Average
                (Get-BufferbloatAssessment -DeltaMs ($avgLoaded - $baselinePing) -PacketLossRate ($totalLostPackets / $totalPingSamples)).Grade
            } else { "N/A" }

            # ANSI Clear & Home
            $esc = [char]27
            $dashboard = @"
$esc[2J$esc[H
╔══════════════════════════════════════════════════════════════════════════════╗
║ INVOKE-NETSTRESS: REAL-TIME NETWORK SATURATION ENGINE ║
╠══════════════════════════════════════════════════════════════════════════════╣
║ Profile: $("{0,-9}" -f $Intensity) | Mode: $("{0,-8}" -f $Mode) | Sockets: $("{0,-3}" -f $threads) | Ping Target: $("{0,-15}" -f $PingTarget) ║
╠══════════════════════════════════════════════════════════════════════════════╣
║ PROGRESS : [$progressBar] $progressPct% (${elapsedSec}s / ${Duration}s)
║ DATA STATS : Total Transferred: $transferredMB MB | Peak Link Rate: $maxObservedMbps Mbps
╠══════════════════════════════════════════════════════════════════════════════╣
║ THROUGHPUT :
║ • Download : [$downBar] $("{0,8:N2}" -f $downMbps) Mbps
║ • Upload : [$upBar] $("{0,8:N2}" -f $upMbps) Mbps
║ • Combined : $("{0,8:N2}" -f $totalMbps) Mbps
╠══════════════════════════════════════════════════════════════════════════════╣
║ LATENCY & BUFFERBLOAT :
║ • Baseline Ping : $baselinePing ms
║ • Current Ping : $("{0,-18}" -f $pingFormatted) Jitter: ${jitter} ms
║ • Ping Sparkline: [$sparkline]
║ • Packet Loss : $totalLostPackets / $totalPingSamples ($lossPct%)
║ • Active Grade : [ GRADE: $currentGrade ] - $statusText
╚══════════════════════════════════════════════════════════════════════════════╝
  Press [Ctrl+C] to abort stress test early and generate final reports.
"@

            [Console]::Write($dashboard)
        } else {
            Write-Host ("{0,-7} | {1,11} | {2,11} | {3,11} | {4,-15} | {5,8} | " -f `
                "${elapsedSec}s", "$downMbps", "$upMbps", "$totalMbps", $pingFormatted, "${jitter}ms") -NoNewline
            Write-Host "$statusText" -ForegroundColor $statusColor
        }
    }
} finally {
    if (-not $cleanTeardownDone) {
        $cleanTeardownDone = $true
        if ($isAnsiSupported) {
            [Console]::CursorVisible = $true
            Write-Host "`n"
        }

        Write-Host "`nStopping stress traffic workers cleanly..." -ForegroundColor DarkGray
        $cancellationTokenSource.Cancel()
        Start-Sleep -Milliseconds 400

        foreach ($job in $asyncJobs) {
            try {
                $job.PowerShell.EndInvoke($job.Handle)
                $job.PowerShell.Dispose()
            } catch {}
        }
        $runspacePool.Dispose()
        $stopwatch.Stop()
    }
}

# ==============================================================================
# 7. SUMMARY STATISTICS COMPUTATION
# ==============================================================================
$testDurationSec = [math]::Max(1.0, [math]::Round($stopwatch.Elapsed.TotalSeconds, 2))
$totalBytesDown = $counter.GetDownload()
$totalBytesUp   = $counter.GetUpload()
$totalBytesAll  = $totalBytesDown + $totalBytesUp

$totalMBTransferred = [math]::Round($totalBytesAll / (1024 * 1024), 2)
$avgDownMbps = [math]::Round(($totalBytesDown * 8) / ($testDurationSec * 1000000.0), 2)
$avgUpMbps   = [math]::Round(($totalBytesUp * 8) / ($testDurationSec * 1000000.0), 2)
$avgTotalMbps = [math]::Round(($totalBytesAll * 8) / ($testDurationSec * 1000000.0), 2)

$packetLossRate = if ($totalPingSamples -gt 0) { $totalLostPackets / $totalPingSamples } else { 0.0 }
$packetLossPct  = [math]::Round($packetLossRate * 100, 2)

if ($pingHistory.Count -gt 0) {
    $sortedPings = $pingHistory | Sort-Object
    $avgLoadedPing = [math]::Round(($pingHistory | Measure-Object -Average).Average, 1)
    $minLoadedPing = [math]::Round(($pingHistory | Measure-Object -Minimum).Minimum, 1)
    $maxLoadedPing = [math]::Round(($pingHistory | Measure-Object -Maximum).Maximum, 1)
    $deltaAvgPing  = [math]::Round($avgLoadedPing - $baselinePing, 1)

    # Percentiles
    $p50Index = [int][math]::Floor($sortedPings.Count * 0.50)
    $p95Index = [int][math]::Floor($sortedPings.Count * 0.95)
    $p99Index = [int][math]::Floor($sortedPings.Count * 0.99)
    $p50Ping  = $sortedPings[[math]::Min($p50Index, $sortedPings.Count - 1)]
    $p95Ping  = $sortedPings[[math]::Min($p95Index, $sortedPings.Count - 1)]
    $p99Ping  = $sortedPings[[math]::Min($p99Index, $sortedPings.Count - 1)]

    # Jitter calculation (Mean absolute difference)
    $jitterList = @()
    for ($k = 1; $k -lt $pingHistory.Count; $k++) {
        $jitterList += [math]::Abs($pingHistory[$k] - $pingHistory[$k-1])
    }
    $avgJitter = if ($jitterList.Count -gt 0) { [math]::Round(($jitterList | Measure-Object -Average).Average, 1) } else { 0.0 }
} else {
    $avgLoadedPing = 0.0
    $minLoadedPing = 0.0
    $maxLoadedPing = 0.0
    $deltaAvgPing  = 0.0
    $p50Ping = 0.0
    $p95Ping = 0.0
    $p99Ping = 0.0
    $avgJitter = 0.0
}

$finalAssessment = Get-BufferbloatAssessment -DeltaMs $deltaAvgPing -PacketLossRate $packetLossRate

# Display Final Summary Banner
Write-Host "╔═══════════════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
Write-Host "║ NETSTRESS FINAL TEST AUDIT ║" -ForegroundColor Yellow
Write-Host "╠═══════════════════════════════════════════════════════════════════════╣" -ForegroundColor Cyan
Write-Host "║ BUFFERBLOAT GRADE : [ $($finalAssessment.Grade) ] - $($finalAssessment.Title)" -ForegroundColor $finalAssessment.Color
Write-Host "║ Data Transferred : $totalMBTransferred MB ($([math]::Round($totalBytesDown/(1024*1024),1)) MB Down / $([math]::Round($totalBytesUp/(1024*1024),1)) MB Up)" -ForegroundColor White
Write-Host "║ Avg Saturated Speed : $avgTotalMbps Mbps (Down: $avgDownMbps Mbps | Up: $avgUpMbps Mbps)" -ForegroundColor Yellow
Write-Host "║ Baseline Idle Latency : $baselinePing ms" -ForegroundColor White
Write-Host "║ Loaded Avg Latency : $avgLoadedPing ms (+$deltaAvgPing ms under saturation)" -ForegroundColor White
Write-Host "║ Loaded Latency Spread : Min: $minLoadedPing ms | Max Peak: $maxLoadedPing ms" -ForegroundColor White
Write-Host "║ Latency Percentiles : p50: $p50Ping ms | p95: $p95Ping ms | p99: $p99Ping ms" -ForegroundColor White
Write-Host "║ Average Loaded Jitter : $avgJitter ms" -ForegroundColor White
Write-Host "║ Packet Loss : $totalLostPackets / $totalPingSamples ($packetLossPct%)" -ForegroundColor $(if ($packetLossPct -gt 0) { "Red" } else { "Green" })
Write-Host "║ Assessment : $($finalAssessment.Description)" -ForegroundColor DarkGray
Write-Host "╚═══════════════════════════════════════════════════════════════════════╝`n" -ForegroundColor Cyan

# ==============================================================================
# 8. MULTI-FORMAT REPORT GENERATION (JSON, CSV, HTML)
# ==============================================================================
$reportData = [PSCustomObject]@{
    Metadata = [PSCustomObject]@{
        SuiteName         = "Invoke-NetStress"
        Version           = "1.0.0"
        TimestampUtc      = (Get-Date).ToUniversalTime().ToString("o")
        Operator          = $env:USERNAME
        ComputerName      = $env:COMPUTERNAME
        OperatingSystem   = [System.Environment]::OSVersion.ToString()
        PowerShellVersion = $PSVersionTable.PSVersion.ToString()
        Authorized        = $true
    }
    Parameters = [PSCustomObject]@{
        Intensity     = $Intensity
        Threads       = $threads
        Mode          = $Mode
        DurationSec   = $Duration
        PingTarget    = $PingTarget
        DownloadUri   = $DownloadUri
        UploadUri     = $UploadUri
    }
    Summary = [PSCustomObject]@{
        BufferbloatGrade     = $finalAssessment.Grade
        BufferbloatTitle     = $finalAssessment.Title
        BufferbloatSummary   = $finalAssessment.Description
        BaselinePingMs       = $baselinePing
        AvgLoadedPingMs      = $avgLoadedPing
        DeltaPingMs          = $deltaAvgPing
        MinLoadedPingMs      = $minLoadedPing
        MaxLoadedPingMs      = $maxLoadedPing
        Percentile50PingMs   = $p50Ping
        Percentile95PingMs   = $p95Ping
        Percentile99PingMs   = $p99Ping
        AvgJitterMs          = $avgJitter
        TotalLostPackets     = $totalLostPackets
        TotalPingSamples     = $totalPingSamples
        PacketLossPct        = $packetLossPct
        AvgDownloadMbps      = $avgDownMbps
        AvgUploadMbps        = $avgUpMbps
        AvgTotalMbps         = $avgTotalMbps
        PeakObservedMbps     = $maxObservedMbps
        TotalTransferredMB   = $totalMBTransferred
        ActualDurationSec    = $testDurationSec
    }
    Timeline = $timelineData
}

if (-not $NoReport) {
    if (-not (Test-Path -Path $ReportPath)) {
        New-Item -ItemType Directory -Path $ReportPath -Force | Out-Null
    }

    $timeTag = (Get-Date).ToString("yyyyMMdd_HHmmss")
    $baseName = "NetStress_Report_$timeTag"

    # 1. JSON Export
    $jsonPath = Join-Path $ReportPath "$baseName.json"
    $reportData | ConvertTo-Json -Depth 6 | Out-File -FilePath $jsonPath -Encoding utf8
    Write-Host "[✓] Exported JSON report: $jsonPath" -ForegroundColor Green

    # 2. CSV Export (Timeline + Summary + Direct Folder Copy)
    $csvPath = Join-Path $ReportPath "$baseName.csv"
    $enhancedTimeline = $timelineData | ForEach-Object {
        [PSCustomObject]@{
            Target           = $PingTarget
            Intensity        = $Intensity
            DurationSec      = $Duration
            ElapsedSec       = $_.ElapsedSec
            DownMbps         = $_.DownMbps
            UpMbps           = $_.UpMbps
            TotalMbps        = $_.TotalMbps
            TotalMB          = $_.TotalMB
            PingMs           = $_.PingMs
            DeltaPingMs      = $_.DeltaPingMs
            JitterMs         = $_.JitterMs
            Status           = $_.Status
            BufferbloatGrade = $finalAssessment.Grade
            Timestamp        = $_.Timestamp
        }
    }
    $enhancedTimeline | Export-Csv -Path $csvPath -NoTypeInformation -Encoding utf8

    # Also export dedicated Summary CSV
    $summaryCsvPath = Join-Path $ReportPath "NetStress_Summary_$timeTag.csv"
    [PSCustomObject]@{
        Timestamp          = (Get-Date).ToString("o")
        TargetHost         = $PingTarget
        Intensity          = $Intensity
        Sockets            = $threads
        DurationSec        = $testDurationSec
        BufferbloatGrade   = $finalAssessment.Grade
        BufferbloatTitle   = $finalAssessment.Title
        AvgTotalMbps       = $avgTotalMbps
        AvgDownMbps        = $avgDownMbps
        AvgUpMbps          = $avgUpMbps
        PeakObservedMbps   = $maxObservedMbps
        TotalMBTransferred = $totalMBTransferred
        BaselinePingMs     = $baselinePing
        AvgLoadedPingMs    = $avgLoadedPing
        DeltaPingMs        = $deltaAvgPing
        MaxLoadedPingMs    = $maxLoadedPing
        AvgJitterMs        = $avgJitter
        PacketLossPct      = $packetLossPct
        TotalPacketsLost   = $totalLostPackets
        TotalPingSamples   = $totalPingSamples
    } | Export-Csv -Path $summaryCsvPath -NoTypeInformation -Encoding utf8

    # Save direct CSV copy into the working directory
    $rootCsvPath = "NetStress_Latest_Report.csv"
    $enhancedTimeline | Export-Csv -Path $rootCsvPath -NoTypeInformation -Encoding utf8

    Write-Host "[✓] Exported CSV Timeline Report : $csvPath" -ForegroundColor Green
    Write-Host "[✓] Exported CSV Summary Report : $summaryCsvPath" -ForegroundColor Green
    Write-Host "[✓] Saved CSV in Working Folder : $rootCsvPath" -ForegroundColor Green

    # 3. HTML Export (Self-contained, dark-mode with SVG charts)
    $htmlPath = Join-Path $ReportPath "$baseName.html"
    
    # Generate SVG Path for Throughput
    $svgW = 760
    $svgH = 200
    $ptsCount = $timelineData.Count

    $throughSvgPoints = ""
    $pingSvgPoints = ""
    if ($ptsCount -gt 1) {
        $maxChartMbps = [math]::Max(10.0, $maxObservedMbps * 1.1)
        $maxChartPing = [math]::Max(50.0, $maxLoadedPing * 1.2)

        for ($idx = 0; $idx -lt $ptsCount; $idx++) {
            $pt = $timelineData[$idx]
            $x = [math]::Round(($idx / ($ptsCount - 1)) * $svgW, 1)
            $yMbps = [math]::Round($svgH - (($pt.TotalMbps / $maxChartMbps) * $svgH), 1)
            $throughSvgPoints += "$x,$yMbps "

            $pVal = if ($null -ne $pt.PingMs) { $pt.PingMs } else { $maxChartPing }
            $yPing = [math]::Round($svgH - (($pVal / $maxChartPing) * $svgH), 1)
            $pingSvgPoints += "$x,$yPing "
        }
    }

    $gradeColorHex = switch ($finalAssessment.Grade) {
        "A+" { "#00f0ff" }
        "A"  { "#10b981" }
        "B"  { "#34d399" }
        "C"  { "#f59e0b" }
        "D"  { "#ec4899" }
        default { "#ef4444" }
    }

    $tableRowsHtml = ""
    foreach ($row in $timelineData) {
        $pMs = if ($null -ne $row.PingMs) { "$($row.PingMs) ms" } else { '<span style="color:#ef4444">LOSS</span>' }
        $dMs = if ($null -ne $row.DeltaPingMs) { "+$($row.DeltaPingMs) ms" } else { "-" }
        $tableRowsHtml += "<tr><td>$($row.ElapsedSec)s</td><td>$($row.DownMbps)</td><td>$($row.UpMbps)</td><td>$($row.TotalMbps)</td><td>$($row.TotalMB) MB</td><td>$pMs</td><td>$dMs</td><td>$($row.JitterMs) ms</td><td>$($row.Status)</td></tr>"
    }

    $htmlContent = @"
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>NetStress Report - $($reportData.Metadata.TimestampUtc)</title>
  <style>
    :root {
      --bg: #0b0f19;
      --card: #151d30;
      --border: #232f48;
      --text: #f1f5f9;
      --muted: #94a3b8;
      --accent: #38bdf8;
      --grade: $gradeColorHex;
    }
    * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; }
    body { background: var(--bg); color: var(--text); padding: 30px 20px; line-height: 1.5; }
    .container { max-width: 1080px; margin: 0 auto; }
    .header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); padding-bottom: 20px; margin-bottom: 25px; }
    .header h1 { font-size: 24px; font-weight: 700; color: #fff; letter-spacing: -0.5px; }
    .badge { background: rgba(56, 189, 248, 0.15); color: var(--accent); padding: 4px 12px; border-radius: 9999px; font-size: 12px; font-weight: 600; }
    .grade-hero { display: flex; align-items: center; gap: 24px; background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; margin-bottom: 25px; box-shadow: 0 4px 20px rgba(0,0,0,0.4); }
    .grade-box { width: 90px; height: 90px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 44px; font-weight: 900; background: rgba(255,255,255,0.04); border: 2px solid var(--grade); color: var(--grade); }
    .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 25px; }
    .card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 18px; }
    .card-title { font-size: 12px; font-weight: 600; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; }
    .card-value { font-size: 22px; font-weight: 700; color: #fff; }
    .card-sub { font-size: 12px; color: var(--muted); margin-top: 4px; }
    .chart-box { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 20px; margin-bottom: 25px; }
    .chart-box h3 { font-size: 15px; margin-bottom: 15px; color: #fff; display: flex; justify-content: space-between; }
    svg { width: 100%; height: auto; display: block; }
    table { width: 100%; border-collapse: collapse; font-size: 13px; text-align: left; }
    th { background: rgba(255,255,255,0.03); color: var(--muted); padding: 10px 12px; border-bottom: 1px solid var(--border); }
    td { padding: 8px 12px; border-bottom: 1px solid rgba(255,255,255,0.05); color: var(--text); }
    tr:hover { background: rgba(255,255,255,0.02); }
    .footer { text-align: center; color: var(--muted); font-size: 12px; margin-top: 40px; border-top: 1px solid var(--border); padding-top: 20px; }
  </style>
</head>
<body>
  <div class="container">
    <div class="header">
      <div>
        <h1>⚡ Invoke-NetStress Benchmark Report</h1>
        <p style="font-size:13px; color:var(--muted); margin-top:4px;">Target: <strong>$PingTarget</strong> | Operator: <strong>$($reportData.Metadata.Operator)</strong> | Host: <strong>$($reportData.Metadata.ComputerName)</strong></p>
      </div>
      <div>
        <span class="badge">$Intensity Profile ($threads Workers)</span>
      </div>
    </div>

    <div class="grade-hero">
      <div class="grade-box">$($finalAssessment.Grade)</div>
      <div>
        <h2 style="color:var(--grade); font-size:22px; margin-bottom:4px;">$($finalAssessment.Title)</h2>
        <p style="color:var(--text); font-size:14px; max-width:700px;">$($finalAssessment.Description)</p>
        <p style="color:var(--muted); font-size:12px; margin-top:6px;">Baseline Idle: <strong>$baselinePing ms</strong> ➔ Loaded Average: <strong>$avgLoadedPing ms</strong> (Delta: <strong>+$deltaAvgPing ms</strong>)</p>
      </div>
    </div>

    <div class="grid">
      <div class="card">
        <div class="card-title">Avg Saturated Throughput</div>
        <div class="card-value">$avgTotalMbps <span style="font-size:14px; font-weight:normal;">Mbps</span></div>
        <div class="card-sub">Down: $avgDownMbps Mbps | Up: $avgUpMbps Mbps</div>
      </div>
      <div class="card">
        <div class="card-title">Peak Bandwidth Burst</div>
        <div class="card-value">$maxObservedMbps <span style="font-size:14px; font-weight:normal;">Mbps</span></div>
        <div class="card-sub">Total Transferred: $totalMBTransferred MB</div>
      </div>
      <div class="card">
        <div class="card-title">Bufferbloat Latency Spike</div>
        <div class="card-value">+$deltaAvgPing <span style="font-size:14px; font-weight:normal;">ms</span></div>
        <div class="card-sub">Peak Ping: $maxLoadedPing ms (p95: $p95Ping ms)</div>
      </div>
      <div class="card">
        <div class="card-title">Packet Reliability</div>
        <div class="card-value">$(100 - $packetLossPct)%</div>
        <div class="card-sub">Loss: $packetLossPct% ($totalLostPackets lost) | Jitter: ${avgJitter}ms</div>
      </div>
    </div>

    <div class="chart-box">
      <h3><span>Throughput Saturation Over Time (Mbps)</span> <span style="font-size:12px; color:var(--muted)">Peak: $maxObservedMbps Mbps</span></h3>
      <svg viewBox="0 0 $svgW $svgH">
        <line x1="0" y1="0" x2="$svgW" y2="0" stroke="rgba(255,255,255,0.08)" />
        <line x1="0" y1="100" x2="$svgW" y2="100" stroke="rgba(255,255,255,0.08)" />
        <line x1="0" y1="$svgH" x2="$svgW" y2="$svgH" stroke="rgba(255,255,255,0.15)" />
        <polyline fill="none" stroke="#38bdf8" stroke-width="2.5" points="$throughSvgPoints" />
      </svg>
    </div>

    <div class="chart-box">
      <h3><span>Latency Response Under Saturation (ms)</span> <span style="font-size:12px; color:var(--muted)">Baseline: $baselinePing ms | Peak: $maxLoadedPing ms</span></h3>
      <svg viewBox="0 0 $svgW $svgH">
        <line x1="0" y1="0" x2="$svgW" y2="0" stroke="rgba(255,255,255,0.08)" />
        <line x1="0" y1="100" x2="$svgW" y2="100" stroke="rgba(255,255,255,0.08)" />
        <line x1="0" y1="$svgH" x2="$svgW" y2="$svgH" stroke="rgba(255,255,255,0.15)" />
        <polyline fill="none" stroke="#f59e0b" stroke-width="2.5" points="$pingSvgPoints" />
      </svg>
    </div>

    <div class="chart-box">
      <h3 style="margin-bottom:12px;">Timeline Telemetry Log</h3>
      <div style="max-height: 380px; overflow-y: auto;">
        <table>
          <thead>
            <tr>
              <th>Time</th>
              <th>Down Mbps</th>
              <th>Up Mbps</th>
              <th>Total Mbps</th>
              <th>Transferred</th>
              <th>Ping</th>
              <th>Delta</th>
              <th>Jitter</th>
              <th>Status</th>
            </tr>
          </thead>
          <tbody>
            $tableRowsHtml
          </tbody>
        </table>
      </div>
    </div>

    <div class="footer">
      Generated automatically by <strong>Invoke-NetStress</strong> v1.0.0 &bull; Network Stress & Bufferbloat Suite
    </div>
  </div>
</body>
</html>
"@


    [System.IO.File]::WriteAllText($htmlPath, $htmlContent, [System.Text.Encoding]::UTF8)
    Write-Host "[✓] Exported Interactive HTML report: $htmlPath`n" -ForegroundColor Green
}

if ($PassThru) {
    return $reportData
}