Private/Helpers.ps1

function Invoke-LissDataSource {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][scriptblock]$ScriptBlock
    )

    try {
        $data = @(& $ScriptBlock)
        [pscustomobject]@{
            Status = 'Available'
            Error  = $null
            Data   = $data
        }
    }
    catch {
        [pscustomobject]@{
            Status = 'Unavailable'
            Error  = $_.Exception.Message
            Data   = @()
        }
    }
}

function Get-LissTimeContext {
    [CmdletBinding()]
    param()

    $now = Get-Date
    [pscustomobject]@{
        Timestamp       = $now
        TimestampUtc    = $now.ToUniversalTime()
        TimeZoneId      = [TimeZoneInfo]::Local.Id
        UtcOffsetMinutes = [int][TimeZoneInfo]::Local.GetUtcOffset($now).TotalMinutes
    }
}

function Invoke-LissPingSample {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Target,
        [Parameter(Mandatory)][int]$TimeoutMilliseconds
    )

    $ping = New-Object System.Net.NetworkInformation.Ping
    try {
        $reply = $ping.Send($Target, $TimeoutMilliseconds)
        [pscustomobject]@{
            Target           = $Target
            Timestamp        = Get-Date
            Success          = $reply.Status -eq [Net.NetworkInformation.IPStatus]::Success
            Status           = [string]$reply.Status
            LatencyMilliseconds = if ($reply.Status -eq [Net.NetworkInformation.IPStatus]::Success) { [double]$reply.RoundtripTime } else { $null }
            Address          = if ($reply.Address) { $reply.Address.IPAddressToString } else { $null }
            Error            = $null
        }
    }
    catch {
        [pscustomobject]@{
            Target           = $Target
            Timestamp        = Get-Date
            Success          = $false
            Status           = 'Error'
            LatencyMilliseconds = $null
            Address          = $null
            Error            = $_.Exception.Message
        }
    }
    finally {
        $ping.Dispose()
    }
}

function Get-LissLatencySummary {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][object[]]$Sample,
        [string]$Target
    )

    $successful = @($Sample | Where-Object { $_.Success -and $null -ne $_.LatencyMilliseconds })
    $latencies = @($successful | ForEach-Object { [double]$_.LatencyMilliseconds } | Sort-Object)
    $sent = @($Sample).Count
    $received = $latencies.Count
    $p95 = $null
    if ($received -gt 0) {
        $p95Index = [Math]::Max(0, [Math]::Ceiling($received * 0.95) - 1)
        $p95 = $latencies[$p95Index]
    }

    [pscustomobject]@{
        PSTypeName              = 'LISSTech.WindowsNetworkDiagnostics.LatencySummary'
        Target                  = $Target
        Sent                    = $sent
        Received                = $received
        LossPercentage          = if ($sent -gt 0) { [Math]::Round((($sent - $received) / [double]$sent) * 100, 2) } else { 0.0 }
        MinimumMilliseconds     = if ($received -gt 0) { $latencies[0] } else { $null }
        AverageMilliseconds     = if ($received -gt 0) { [Math]::Round(($latencies | Measure-Object -Average).Average, 2) } else { $null }
        Percentile95Milliseconds = $p95
        MaximumMilliseconds     = if ($received -gt 0) { $latencies[-1] } else { $null }
    }
}

function Invoke-LissLatencyWindow {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Target,
        [Parameter(Mandatory)][int]$DurationSeconds,
        [Parameter(Mandatory)][int]$IntervalMilliseconds,
        [Parameter(Mandatory)][int]$TimeoutMilliseconds,
        [Diagnostics.Process]$WhileProcessRuns
    )

    $samples = New-Object 'Collections.Generic.List[object]'
    $deadline = [DateTime]::UtcNow.AddSeconds($DurationSeconds)
    do {
        if ($WhileProcessRuns -and $WhileProcessRuns.HasExited) {
            break
        }
        $samples.Add((Invoke-LissPingSample -Target $Target -TimeoutMilliseconds $TimeoutMilliseconds))
        $remaining = ($deadline - [DateTime]::UtcNow).TotalMilliseconds
        if ($remaining -gt 0) {
            Start-Sleep -Milliseconds ([int][Math]::Min($IntervalMilliseconds, $remaining))
        }
    } while ([DateTime]::UtcNow -lt $deadline)
    $samples.ToArray()
}

function Get-LissAdapterStatisticsSnapshot {
    [CmdletBinding()]
    param()

    if (-not (Get-Command Get-NetAdapterStatistics -ErrorAction SilentlyContinue)) {
        throw 'Get-NetAdapterStatistics is unavailable.'
    }
    @(Get-NetAdapterStatistics -ErrorAction Stop | Select-Object Name, InterfaceDescription, ReceivedBytes, SentBytes, ReceivedUnicastPackets, SentUnicastPackets, ReceivedPacketErrors, OutboundPacketErrors, ReceivedDiscardedPackets, OutboundDiscardedPackets)
}

function Test-LissTcpPort {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Target,
        [Parameter(Mandatory)][int]$Port,
        [Parameter(Mandatory)][int]$TimeoutMilliseconds
    )

    $client = New-Object Net.Sockets.TcpClient
    $stopwatch = [Diagnostics.Stopwatch]::StartNew()
    try {
        $async = $client.BeginConnect($Target, $Port, $null, $null)
        if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMilliseconds, $false)) {
            throw "TCP connection timed out after $TimeoutMilliseconds ms."
        }
        $client.EndConnect($async)
        [pscustomobject]@{
            Target              = $Target
            Port                = $Port
            Success             = $true
            ElapsedMilliseconds = $stopwatch.ElapsedMilliseconds
            Error               = $null
        }
    }
    catch {
        [pscustomobject]@{
            Target              = $Target
            Port                = $Port
            Success             = $false
            ElapsedMilliseconds = $stopwatch.ElapsedMilliseconds
            Error               = $_.Exception.Message
        }
    }
    finally {
        $stopwatch.Stop()
        $client.Close()
    }
}

function Invoke-LissPingExecutable {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Target,
        [Parameter(Mandatory)][int]$PayloadBytes,
        [Parameter(Mandatory)][int]$TimeoutMilliseconds,
        [switch]$DontFragment
    )

    $arguments = @('-4', '-n', '1', '-w', [string]$TimeoutMilliseconds)
    if ($DontFragment) {
        $arguments += '-f'
    }
    if ($PayloadBytes -ge 0) {
        $arguments += @('-l', [string]$PayloadBytes)
    }
    $arguments += $Target
    $output = @(& "$env:SystemRoot\System32\PING.EXE" @arguments 2>&1 | ForEach-Object { [string]$_ })
    [pscustomobject]@{
        Success  = $LASTEXITCODE -eq 0
        ExitCode = $LASTEXITCODE
        Output   = $output
    }
}

function Resolve-LissIperfExecutable {
    [CmdletBinding()]
    param([string]$Path)

    if ($Path) {
        if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
            throw "iperf3 executable '$Path' was not found. The module does not install iperf3."
        }
        return (Get-Item -LiteralPath $Path).FullName
    }
    $bundledPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'Bin\x64\iperf3.exe'
    if (Test-Path -LiteralPath $bundledPath -PathType Leaf) {
        return $bundledPath
    }
    $command = Get-Command iperf3.exe -CommandType Application -ErrorAction SilentlyContinue
    if (-not $command) {
        $command = Get-Command iperf3 -CommandType Application -ErrorAction SilentlyContinue
    }
    if (-not $command) {
        throw 'iperf3 is unavailable. Install it separately through an approved process and provide -IperfPath if it is not on PATH.'
    }
    $command.Source
}

function Get-LissPktmonCapability {
    [CmdletBinding()]
    param()

    $missing = New-Object 'Collections.Generic.List[string]'
    $checks = @(
        [pscustomobject]@{
            Name      = 'BaseCommands'
            Arguments = @('help')
            Pattern   = '(?is)\bfilter\b.*\bstart\b.*\bstop\b.*\bstatus\b.*\betl2pcap\b'
        }
        [pscustomobject]@{
            Name      = 'FilterByIpProtocolAndPort'
            Arguments = @('filter', 'add', 'help')
            Pattern   = '(?is)(-i|--ip).*?(-t|--transport).*?(-p|--port)'
        }
        [pscustomobject]@{
            Name      = 'BoundedCircularCapture'
            Arguments = @('start', 'help')
            Pattern   = '(?is)--capture.*?--pkt-size.*?--file-name.*?--file-size.*?--log-mode.*?circular'
        }
        [pscustomobject]@{
            Name      = 'PcapngConversion'
            Arguments = @('etl2pcap', 'help')
            Pattern   = '(?is)etl2pcap.*?--out'
        }
    )
    $details = foreach ($check in $checks) {
        $result = Invoke-LissPktmonCommand -ArgumentList $check.Arguments
        $text = $result.Output -join "`n"
        $supported = $result.ExitCode -eq 0 -and $text -match $check.Pattern
        if (-not $supported) {
            $missing.Add($check.Name)
        }
        [pscustomobject]@{
            Capability = $check.Name
            Supported  = $supported
            ExitCode   = $result.ExitCode
        }
    }
    [pscustomobject]@{
        PSTypeName          = 'LISSTech.WindowsNetworkDiagnostics.PktmonCapability'
        Supported           = $missing.Count -eq 0
        MissingCapabilities = $missing.ToArray()
        Details             = @($details)
    }
}

function ConvertTo-LissProcessArgumentString {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string[]]$ArgumentList)

    (($ArgumentList | ForEach-Object {
            if ($_ -match '[\s"]') {
                '"' + ($_ -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1') + '"'
            }
            else {
                $_
            }
        }) -join ' ')
}

function Start-LissRedirectedProcess {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$FilePath,
        [Parameter(Mandatory)][string[]]$ArgumentList
    )

    $workingDirectory = Join-Path ([IO.Path]::GetTempPath()) ("LISSTech.WindowsNetworkDiagnostics\" + [guid]::NewGuid().ToString('N'))
    [IO.Directory]::CreateDirectory($workingDirectory) | Out-Null
    $stdoutPath = Join-Path $workingDirectory 'stdout.txt'
    $stderrPath = Join-Path $workingDirectory 'stderr.txt'
    $startInfo = New-Object Diagnostics.ProcessStartInfo
    $startInfo.FileName = $FilePath
    $startInfo.Arguments = ConvertTo-LissProcessArgumentString -ArgumentList $ArgumentList
    $startInfo.UseShellExecute = $false
    $startInfo.CreateNoWindow = $true
    $startInfo.RedirectStandardOutput = $true
    $startInfo.RedirectStandardError = $true
    $process = New-Object Diagnostics.Process
    $process.StartInfo = $startInfo
    if (-not $process.Start()) {
        [IO.Directory]::Delete($workingDirectory, $true)
        throw "Failed to start '$FilePath'."
    }
    $stdoutTask = $process.StandardOutput.ReadToEndAsync()
    $stderrTask = $process.StandardError.ReadToEndAsync()
    [pscustomobject]@{
        Process          = $process
        StandardOutputTask = $stdoutTask
        StandardErrorTask  = $stderrTask
        WorkingDirectory = $workingDirectory
        StandardOutputPath = $stdoutPath
        StandardErrorPath  = $stderrPath
    }
}

function Stop-LissProcessContext {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$Context)

    if ($Context.Process) {
        try {
            if (-not $Context.Process.HasExited) {
                $Context.Process.Kill()
                $Context.Process.WaitForExit(5000) | Out-Null
            }
        }
        catch {
            Write-Warning "Failed to stop the child process: $($_.Exception.Message)"
        }
    }
}

function Complete-LissRedirectedProcess {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Context,
        [Parameter(Mandatory)][int]$TimeoutMilliseconds
    )

    try {
        if (-not $Context.Process.WaitForExit($TimeoutMilliseconds)) {
            Stop-LissProcessContext -Context $Context
            throw "The child process exceeded the $TimeoutMilliseconds ms execution limit."
        }
        $stdout = $Context.StandardOutputTask.GetAwaiter().GetResult()
        $stderr = $Context.StandardErrorTask.GetAwaiter().GetResult()
        [pscustomobject]@{
            ExitCode       = $Context.Process.ExitCode
            StandardOutput = $stdout
            StandardError  = $stderr
        }
    }
    finally {
        $Context.Process.Dispose()
        if ([IO.Directory]::Exists($Context.WorkingDirectory)) {
            [IO.Directory]::Delete($Context.WorkingDirectory, $true)
        }
    }
}

function New-LissIperfArgumentList {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Server,
        [Parameter(Mandatory)][int]$Port,
        [Parameter(Mandatory)][int]$DurationSeconds,
        [Parameter(Mandatory)][ValidateSet('Upload', 'Download')][string]$Direction
    )

    $arguments = @('-c', $Server, '-p', [string]$Port, '-t', [string]$DurationSeconds, '-J')
    if ($Direction -eq 'Download') {
        $arguments += '-R'
    }
    $arguments
}

function Invoke-LissIperfProcess {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Executable,
        [Parameter(Mandatory)][string]$Server,
        [Parameter(Mandatory)][int]$Port,
        [Parameter(Mandatory)][int]$DurationSeconds,
        [Parameter(Mandatory)][ValidateSet('Upload', 'Download')][string]$Direction
    )

    $arguments = New-LissIperfArgumentList -Server $Server -Port $Port -DurationSeconds $DurationSeconds -Direction $Direction
    $context = Start-LissRedirectedProcess -FilePath $Executable -ArgumentList $arguments
    $result = Complete-LissRedirectedProcess -Context $context -TimeoutMilliseconds (($DurationSeconds + 20) * 1000)
    if ($result.ExitCode -ne 0) {
        throw "iperf3 failed with exit code $($result.ExitCode): $($result.StandardError.Trim())"
    }
    try {
        $parsed = $result.StandardOutput | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        throw "iperf3 returned invalid JSON: $($_.Exception.Message)"
    }
    [pscustomobject]@{
        Arguments = $arguments
        Result    = $parsed
        ErrorText = $result.StandardError
    }
}

function Start-LissIperfLoad {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Executable,
        [Parameter(Mandatory)][string]$Server,
        [Parameter(Mandatory)][int]$Port,
        [Parameter(Mandatory)][int]$DurationSeconds,
        [Parameter(Mandatory)][ValidateSet('Upload', 'Download')][string]$Direction
    )

    $arguments = New-LissIperfArgumentList -Server $Server -Port $Port -DurationSeconds $DurationSeconds -Direction $Direction
    $context = Start-LissRedirectedProcess -FilePath $Executable -ArgumentList $arguments
    $context | Add-Member -NotePropertyName Arguments -NotePropertyValue $arguments
    $context
}

function Complete-LissIperfLoad {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Context,
        [Parameter(Mandatory)][int]$DurationSeconds
    )

    $result = Complete-LissRedirectedProcess -Context $Context -TimeoutMilliseconds (($DurationSeconds + 20) * 1000)
    if ($result.ExitCode -ne 0) {
        throw "iperf3 failed with exit code $($result.ExitCode): $($result.StandardError.Trim())"
    }
    try {
        $result.StandardOutput | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        throw "iperf3 returned invalid JSON: $($_.Exception.Message)"
    }
}

function Test-LissAdministrator {
    [CmdletBinding()]
    param()

    $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = New-Object Security.Principal.WindowsPrincipal($identity)
    $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}

function Invoke-LissPktmonCommand {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string[]]$ArgumentList)

    $output = @(& pktmon.exe @ArgumentList 2>&1 | ForEach-Object { [string]$_ })
    [pscustomobject]@{
        ExitCode = $LASTEXITCODE
        Output   = $output
    }
}

function Get-LissPktmonState {
    [CmdletBinding()]
    param()

    $status = Invoke-LissPktmonCommand -ArgumentList @('status')
    if ($status.ExitCode -ne 0) {
        throw "Unable to query pktmon status: $($status.Output -join ' ')"
    }
    $filters = Invoke-LissPktmonCommand -ArgumentList @('filter', 'list')
    if ($filters.ExitCode -ne 0) {
        throw "Unable to query pktmon filters: $($filters.Output -join ' ')"
    }
    $statusText = $status.Output -join "`n"
    $filterText = $filters.Output -join "`n"
    [pscustomobject]@{
        IsCaptureActive = $statusText -match '(?im)^\s*(Running|Logger is running|Capture is running)\b'
        HasFilters      = -not ([string]::IsNullOrWhiteSpace($filterText) -or $filterText -match '(?im)\b(no filters|filter list is empty|none)\b')
        StatusText      = $statusText
        FilterText      = $filterText
    }
}