#Requires -Version 5.1

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string] $SpecificationPath,

    [Parameter(Mandatory = $true)]
    [string] $OutputPath
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$utf8NoBom = [Text.UTF8Encoding]::new($false, $true)
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$orchestratorPath = Join-Path $PSScriptRoot 'Invoke-LegacyPgmPrepareGate.ps1'
$nodeHelperPath = Join-Path $PSScriptRoot 'Test-LegacyPgmPrepareGate.mjs'
$userProfilePath = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)
$approvedNodePath = [IO.Path]::GetFullPath((Join-Path $userProfilePath '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe'))
$expectedRemote = 'https://gitea.comtropy.synology.me/Wickedness/MBN_STOCK_WEBVIEW.git'
$expectedUrl = 'https://legacy-parity.mbn.local/index.html'
$approvedSceneDirectory = 'C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts'
$approvedCutPath = Join-Path $approvedSceneDirectory '5001.t2s'
$approvedCutSha256 = '99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7'
$approvedMsixPath = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\AppPackages\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Test\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64.msix'
$approvedMsixSha256 = '0E44F87FDD31852B7DDFCD6CF3A6325DA674F1F5A23AD39CB262A725AEB84367'
$approvedDatabaseConfigPath = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'MBN_STOCK_WEBVIEW\Config\database.local.json'
$expectedMarketText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7L2U7Iqk7ZS8'))
$expectedStockName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7IK87ISx7KCE7J6Q'))
$expectedCutLabel = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('MeyXtO2MkOq4sOuzuF/tmITsnqzqsIA='))
$expectedGraphicType = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('MeyXtO2MkOq4sOuzuA=='))
$expectedSubtype = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7ZiE7J6s6rCA'))

function Fail([string] $Message) {
    throw [InvalidOperationException]::new($Message)
}

function FullPath([string] $Path, [string] $Name) {
    if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) {
        Fail "$Name must be an absolute path."
    }
    return [IO.Path]::GetFullPath($Path)
}

function SamePath([string] $Left, [string] $Right) {
    return [string]::Equals(
        [IO.Path]::GetFullPath($Left).TrimEnd('\'),
        [IO.Path]::GetFullPath($Right).TrimEnd('\'),
        [StringComparison]::OrdinalIgnoreCase)
}

function Assert-NoReparse([string] $Path, [string] $Name) {
    $current = [IO.Path]::GetFullPath($Path)
    while (-not [string]::IsNullOrWhiteSpace($current)) {
        if ([IO.File]::Exists($current) -or [IO.Directory]::Exists($current)) {
            if (([IO.File]::GetAttributes($current) -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
                Fail "$Name contains a reparse point."
            }
        }
        $parent = [IO.Directory]::GetParent($current)
        if ($null -eq $parent) { break }
        $current = $parent.FullName
    }
}

function Read-Json([string] $Path, [string] $Name) {
    $full = FullPath $Path $Name
    if (-not [IO.File]::Exists($full)) { Fail "$Name does not exist." }
    Assert-NoReparse $full $Name
    $bytes = [IO.File]::ReadAllBytes($full)
    try {
        if ($bytes.Length -eq 0 -or $bytes.Length -gt 1MB) { Fail "$Name has an unsafe size." }
        if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
            Fail "$Name must be UTF-8 without BOM."
        }
        try { $text = $utf8NoBom.GetString($bytes) }
        catch { Fail "$Name is not strict UTF-8." }
        try { return $text | ConvertFrom-Json -ErrorAction Stop }
        catch { Fail "$Name is not valid JSON." }
    }
    finally { [Array]::Clear($bytes, 0, $bytes.Length) }
}

function Require([object] $Object, [string] $Name, [string] $Context) {
    if ($null -eq $Object -or -not ($Object.PSObject.Properties.Name -ccontains $Name)) {
        Fail "$Context.$Name is required."
    }
    return $Object.$Name
}

function Assert-ExactProperties([object] $Object, [string[]] $Expected, [string] $Context) {
    if ($null -eq $Object) { Fail "$Context is required." }
    $actual = @($Object.PSObject.Properties.Name)
    if ($actual.Count -ne $Expected.Count) { Fail "$Context property count is not exact." }
    foreach ($name in $Expected) {
        if (@($actual | Where-Object { $_ -ceq $name }).Count -ne 1) {
            Fail "$Context property set is not exact: $name."
        }
    }
}

function Assert-JsonInt([object] $Value, [int] $Expected, [string] $Name) {
    if ($Value -isnot [int] -or [int]$Value -ne $Expected) { Fail "$Name must be JSON integer $Expected." }
}

function Assert-JsonBool([object] $Value, [bool] $Expected, [string] $Name) {
    if ($Value -isnot [bool] -or [bool]$Value -ne $Expected) { Fail "$Name must be JSON boolean $Expected." }
}

function Assert-JsonString([object] $Value, [string] $Expected, [string] $Name) {
    if ($Value -isnot [string] -or [string]$Value -cne $Expected) { Fail "$Name must be the exact JSON string." }
}

function New-FilePin([string] $Path, [string] $Name) {
    $full = FullPath $Path $Name
    if (-not [IO.File]::Exists($full)) { Fail "$Name does not exist." }
    Assert-NoReparse $full $Name
    $item = Get-Item -LiteralPath $full -Force
    return [ordered]@{
        path = $full
        length = [int64]$item.Length
        sha256 = (Get-FileHash -LiteralPath $full -Algorithm SHA256).Hash
    }
}

function Get-RuntimeTreePin([string] $Root) {
    $fullRoot = FullPath $Root 'package.installLocation'
    if (-not [IO.Directory]::Exists($fullRoot)) { Fail 'package.installLocation does not exist.' }
    Assert-NoReparse $fullRoot 'package.installLocation'
    foreach ($directory in [IO.Directory]::GetDirectories($fullRoot, '*', [IO.SearchOption]::AllDirectories)) {
        Assert-NoReparse $directory 'package runtime directory'
    }
    $files = @([IO.Directory]::GetFiles($fullRoot, '*', [IO.SearchOption]::AllDirectories))
    if ($files.Count -eq 0 -or $files.Count -gt 4096) { Fail 'The package runtime tree count is unsafe.' }
    $relative = New-Object 'Collections.Generic.List[string]'
    $rootPrefix = $fullRoot.TrimEnd('\') + '\'
    foreach ($file in $files) {
        Assert-NoReparse $file 'package runtime file'
        if (-not $file.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) {
            Fail 'A package runtime file escaped the install root.'
        }
        $name = $file.Substring($rootPrefix.Length).Replace('\', '/')
        if ($name.Contains('|') -or $name.Contains("`r") -or $name.Contains("`n")) {
            Fail 'A package runtime relative path cannot be represented safely.'
        }
        [void]$relative.Add($name)
    }
    $names = $relative.ToArray()
    [Array]::Sort($names, [StringComparer]::Ordinal)
    $builder = [Text.StringBuilder]::new()
    foreach ($name in $names) {
        $file = Join-Path $fullRoot $name.Replace('/', '\')
        $item = Get-Item -LiteralPath $file -Force
        $hash = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash
        [void]$builder.Append($name).Append('|').Append(([int64]$item.Length).ToString([Globalization.CultureInfo]::InvariantCulture)).Append('|').Append($hash).Append("`n")
    }
    $manifestBytes = $utf8NoBom.GetBytes($builder.ToString())
    try {
        $algorithm = [Security.Cryptography.SHA256]::Create()
        try { $digest = ([BitConverter]::ToString($algorithm.ComputeHash($manifestBytes))).Replace('-', '') }
        finally { $algorithm.Dispose() }
    }
    finally { [Array]::Clear($manifestBytes, 0, $manifestBytes.Length) }
    return [ordered]@{ fileCount = $names.Length; manifestSha256 = $digest }
}

function Assert-GateConfig([string] $Path, [string] $SceneDirectory) {
    $config = Read-Json $Path 'configuration.gate'
    $names = @(
        'mode','host','port','tcpMode','clientPort','outputChannel','layoutIndex',
        'legacySceneFadeDuration','legacySceneBackgroundKind',
        'legacySceneBackgroundAssetPath','legacySceneBackgroundVideoLoopCount',
        'legacySceneBackgroundVideoLoopInfinite','legacyBackgroundDirectory',
        'sceneDirectory','testProcessWindowTitlePattern','testSceneAllowlist',
        'trustedLiveOutputEnabled','queueCapacity','connectTimeoutMilliseconds',
        'operationTimeoutMilliseconds','disconnectTimeoutMilliseconds',
        'processPollIntervalMilliseconds','reconnectDelayMilliseconds',
        'maximumReconnectAttempts','reconnectEnabled',
        'maximumAutomaticRefreshesPerTakeIn')
    Assert-ExactProperties $config $names 'configuration.gate JSON'
    if ($config.testSceneAllowlist -isnot [Array]) {
        Fail 'configuration.gate.testSceneAllowlist must be a JSON array.'
    }
    $allowlist = @(Require $config 'testSceneAllowlist' 'configuration.gate JSON')
    Assert-JsonString $config.mode 'DryRun' 'configuration.gate.mode'
    Assert-JsonString $config.host '127.0.0.1' 'configuration.gate.host'
    Assert-JsonInt $config.port 30001 'configuration.gate.port'
    Assert-JsonInt $config.tcpMode 1 'configuration.gate.tcpMode'
    Assert-JsonInt $config.clientPort 0 'configuration.gate.clientPort'
    Assert-JsonInt $config.layoutIndex 10 'configuration.gate.layoutIndex'
    Assert-JsonInt $config.legacySceneFadeDuration 6 'configuration.gate.legacySceneFadeDuration'
    Assert-JsonString $config.legacySceneBackgroundKind 'None' 'configuration.gate.legacySceneBackgroundKind'
    Assert-JsonInt $config.legacySceneBackgroundVideoLoopCount 2004 'configuration.gate.legacySceneBackgroundVideoLoopCount'
    Assert-JsonBool $config.legacySceneBackgroundVideoLoopInfinite $true 'configuration.gate.legacySceneBackgroundVideoLoopInfinite'
    Assert-JsonBool $config.trustedLiveOutputEnabled $true 'configuration.gate.trustedLiveOutputEnabled'
    Assert-JsonInt $config.queueCapacity 64 'configuration.gate.queueCapacity'
    Assert-JsonInt $config.connectTimeoutMilliseconds 5000 'configuration.gate.connectTimeoutMilliseconds'
    Assert-JsonInt $config.operationTimeoutMilliseconds 5000 'configuration.gate.operationTimeoutMilliseconds'
    Assert-JsonInt $config.disconnectTimeoutMilliseconds 3000 'configuration.gate.disconnectTimeoutMilliseconds'
    Assert-JsonInt $config.processPollIntervalMilliseconds 1000 'configuration.gate.processPollIntervalMilliseconds'
    Assert-JsonInt $config.reconnectDelayMilliseconds 1000 'configuration.gate.reconnectDelayMilliseconds'
    Assert-JsonInt $config.maximumReconnectAttempts 0 'configuration.gate.maximumReconnectAttempts'
    Assert-JsonBool $config.reconnectEnabled $false 'configuration.gate.reconnectEnabled'
    Assert-JsonInt $config.maximumAutomaticRefreshesPerTakeIn 0 'configuration.gate.maximumAutomaticRefreshesPerTakeIn'
    if (
        $null -ne (Require $config 'outputChannel' 'configuration.gate JSON') -or
        $null -ne (Require $config 'legacySceneBackgroundAssetPath' 'configuration.gate JSON') -or
        $null -ne (Require $config 'legacyBackgroundDirectory' 'configuration.gate JSON') -or
        -not (SamePath ([string](Require $config 'sceneDirectory' 'configuration.gate JSON')) $SceneDirectory) -or
        $null -ne (Require $config 'testProcessWindowTitlePattern' 'configuration.gate JSON') -or
        $allowlist.Count -ne 1 -or $allowlist[0] -isnot [string] -or [string]$allowlist[0] -cne '5001') {
        Fail 'configuration.gate JSON is not the closed 5001-only persistent-DryRun configuration.'
    }
}

$specificationPath = FullPath $SpecificationPath 'SpecificationPath'
$outputPath = FullPath $OutputPath 'OutputPath'
if ($outputPath.StartsWith($repositoryRoot.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) {
    Fail 'OutputPath must remain outside the Git repository so the execution checkout can be clean.'
}
if ([IO.File]::Exists($outputPath) -or [IO.Directory]::Exists($outputPath)) {
    Fail 'OutputPath already exists; a frozen plan is never overwritten.'
}
if (SamePath $specificationPath $outputPath) { Fail 'SpecificationPath and OutputPath must differ.' }
if (-not [IO.File]::Exists($orchestratorPath) -or -not [IO.File]::Exists($nodeHelperPath)) {
    Fail 'The committed Gate orchestrator and Node helper must both exist before sealing a plan.'
}

$spec = Read-Json $specificationPath 'SpecificationPath'
Assert-ExactProperties $spec @(
    'schemaVersion','roundId','repository','package','automation','configuration',
    'cut','k3d','pgm','webView') 'specification'
Assert-JsonInt (Require $spec 'schemaVersion' 'specification') 1 'specification.schemaVersion'
$roundId = [string](Require $spec 'roundId' 'specification')
if ($roundId -cnotmatch '^MBNWEB-[0-9]{8}-[A-Z0-9][A-Z0-9-]{0,31}$') { Fail 'roundId is invalid.' }

$repo = Require $spec 'repository' 'specification'
Assert-ExactProperties $repo @('path','branch','commit','upstream','remoteUrl') 'repository'
if (-not (SamePath ([string]$repo.path) $repositoryRoot) -or
    [string]$repo.branch -cne 'main' -or [string]$repo.upstream -cne 'origin/main' -or
    [string]$repo.remoteUrl -cne $expectedRemote -or
    [string]$repo.commit -cnotmatch '^[0-9a-f]{40}$') { Fail 'repository pin is not exact.' }

$package = Require $spec 'package' 'specification'
Assert-ExactProperties $package @(
    'name','familyName','fullName','publisher','version','architecture',
    'applicationId','installLocation','executablePath','msixPath') 'package'
if ([string]$package.name -cne 'Wickedness.MBNStockWebView.LegacyParity' -or
    [string]$package.familyName -cne 'Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0' -or
    [string]$package.fullName -cne 'Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0' -or
    [string]$package.publisher -cne 'CN=Comtrophy' -or [string]$package.version -cne '0.1.0.0' -or
    [string]$package.architecture -cne 'X64' -or [string]$package.applicationId -cne 'LegacyParityApp') {
    Fail 'package identity is not the exact LegacyParity Release x64 package.'
}
$installLocation = FullPath ([string]$package.installLocation) 'package.installLocation'
$expectedInstallLocation = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64'
if (-not (SamePath $installLocation $expectedInstallLocation)) {
    Fail 'package.installLocation is not the exact Release x64 output.'
}
$executablePath = FullPath ([string]$package.executablePath) 'package.executablePath'
if (-not (SamePath $executablePath (Join-Path $installLocation 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe'))) {
    Fail 'package executable is outside the exact install root.'
}
$msixPath = FullPath ([string]$package.msixPath) 'package.msixPath'
if (-not (SamePath $msixPath $approvedMsixPath)) {
    Fail 'package.msixPath is not the exact Release x64 AppPackages MSIX.'
}
if ((Get-FileHash -LiteralPath $msixPath -Algorithm SHA256).Hash -cne $approvedMsixSha256) {
    Fail 'The approved Release x64 MSIX SHA-256 changed.'
}

$automation = Require $spec 'automation' 'specification'
Assert-ExactProperties $automation @('nodePath') 'automation'
if (-not (SamePath ([string]$automation.nodePath) $approvedNodePath)) {
    Fail 'automation.nodePath is not the approved bundled Node runtime.'
}
$configuration = Require $spec 'configuration' 'specification'
Assert-ExactProperties $configuration @('originalPath','gatePath','sceneDirectory') 'configuration'
$originalConfigPath = FullPath ([string]$configuration.originalPath) 'configuration.originalPath'
$gateConfigPath = FullPath ([string]$configuration.gatePath) 'configuration.gatePath'
$sceneDirectory = FullPath ([string]$configuration.sceneDirectory) 'configuration.sceneDirectory'
if (-not (SamePath $sceneDirectory $approvedSceneDirectory)) {
    Fail 'configuration.sceneDirectory is not the approved read-only original Cuts root.'
}
if (SamePath $originalConfigPath $gateConfigPath) { Fail 'Original and Gate config paths must differ.' }
Assert-GateConfig $gateConfigPath $sceneDirectory

$cut = Require $spec 'cut' 'specification'
Assert-ExactProperties $cut @('path') 'cut'
$cutPath = FullPath ([string]$cut.path) 'cut.path'
if (-not (SamePath $cutPath $approvedCutPath)) { Fail 'cut.path is not the approved read-only original 5001.t2s.' }
if ((Get-FileHash -LiteralPath $cutPath -Algorithm SHA256).Hash -cne $approvedCutSha256) {
    Fail 'The approved original 5001.t2s SHA-256 changed.'
}

$k3d = Require $spec 'k3d' 'specification'
Assert-ExactProperties $k3d @('nativePath','interopPath') 'k3d'
$pgm = Require $spec 'pgm' 'specification'
Assert-ExactProperties $pgm @(
    'processId','startTimeUtc','processName','executablePath','windowTitle',
    'listenerAddress','host','port') 'pgm'
if ($pgm.processId -isnot [int] -or [int]$pgm.processId -le 0 -or $pgm.port -isnot [int] -or [string]$pgm.startTimeUtc -cnotmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z$' -or
    [string]$pgm.windowTitle -cne 'PGM' -or [string]$pgm.listenerAddress -cne '0.0.0.0' -or
    [string]$pgm.host -cne '127.0.0.1' -or [int]$pgm.port -ne 30001) { Fail 'PGM pin is not the exact current PGM listener contract.' }

$webView = Require $spec 'webView' 'specification'
Assert-ExactProperties $webView @('address','port','url') 'webView'
if ($webView.port -isnot [int] -or [string]$webView.address -cne '127.0.0.1' -or [int]$webView.port -lt 1024 -or [int]$webView.port -gt 65535 -or
    [int]$webView.port -eq 30001 -or [string]$webView.url -cne $expectedUrl) { Fail 'WebView pin is invalid.' }

$plan = [ordered]@{
    schemaVersion = 1
    kind = 'LegacyPgmPrepareGatePlan'
    roundId = $roundId
    createdAtUtc = [DateTime]::UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", [Globalization.CultureInfo]::InvariantCulture)
    repository = [ordered]@{
        path = $repositoryRoot; branch = 'main'; commit = [string]$repo.commit
        upstream = 'origin/main'; remoteUrl = $expectedRemote
    }
    package = [ordered]@{
        name = [string]$package.name; familyName = [string]$package.familyName
        fullName = [string]$package.fullName; publisher = [string]$package.publisher
        version = [string]$package.version; architecture = 'X64'
        applicationId = [string]$package.applicationId; installLocation = $installLocation
        executable = New-FilePin $executablePath 'package executable'
        msix = New-FilePin $msixPath 'package MSIX'
        runtimeTree = Get-RuntimeTreePin $installLocation
    }
    automation = [ordered]@{
        orchestrator = New-FilePin $orchestratorPath 'Gate orchestrator'
        node = New-FilePin ([string]$automation.nodePath) 'Node runtime'
        helper = New-FilePin $nodeHelperPath 'Node Gate helper'
    }
    configuration = [ordered]@{
        original = New-FilePin $originalConfigPath 'original DryRun config'
        gate = New-FilePin $gateConfigPath 'Gate DryRun config'
        database = New-FilePin $approvedDatabaseConfigPath 'database config'
        sceneDirectory = $sceneDirectory
    }
    cut = [ordered]@{
        sceneCode = '5001'; pageNumberOneBased = 1
        file = New-FilePin $cutPath '5001 cut'
    }
    selector = [ordered]@{
        marketText = $expectedMarketText; groupCode = 'KOSPI'; stockName = $expectedStockName
        stockCode = '005930'; cutLabel = $expectedCutLabel
        graphicType = $expectedGraphicType; subtype = $expectedSubtype
        normalizedSubtype = 'CURRENT'; sceneAlias = '5001'
    }
    k3d = [ordered]@{
        native = New-FilePin ([string]$k3d.nativePath) 'K3D native binary'
        interop = New-FilePin ([string]$k3d.interopPath) 'K3D interop assembly'
    }
    pgm = [ordered]@{
        processId = [int]$pgm.processId; startTimeUtc = [string]$pgm.startTimeUtc
        processName = [string]$pgm.processName
        executable = New-FilePin ([string]$pgm.executablePath) 'PGM executable'
        windowTitle = 'PGM'; listenerAddress = '0.0.0.0'; host = '127.0.0.1'; port = 30001
    }
    webView = [ordered]@{ address = '127.0.0.1'; port = [int]$webView.port; url = $expectedUrl }
    approvalPolicy = [ordered]@{
        requiresSeparateAuthorization = $true; maximumAgeSeconds = 900; maximumClockSkewSeconds = 5
    }
    budgets = [ordered]@{
        connect = 1; prepare = 1; takeIn = 0; next = 0; pageNext = 0
        takeOut = 0; databaseWrite = 0; appClose = 0; retry = 0
    }
    safety = [ordered]@{
        automaticConnectOnly = $true; nodeHelperInvocationMaximum = 1
        nodeHelperFailStopRequired = $true
        packageWrapperFailStopRequired = $true
        persistentGateConfigModeDryRun = $true; restoreOriginalBeforePrepare = $true
        nativeOneShotPrepareGateRequired = $true
        leaveApplicationAndSessionUntouched = $true; forceCloseForbidden = $true
        automaticSessionCleanupForbiddenAfterLaunch = $true; retryForbidden = $true
    }
}

$parent = [IO.Path]::GetDirectoryName($outputPath)
if ([string]::IsNullOrWhiteSpace($parent)) { Fail 'OutputPath parent is invalid.' }
[void][IO.Directory]::CreateDirectory($parent)
Assert-NoReparse $parent 'OutputPath parent'
$json = ($plan | ConvertTo-Json -Depth 12) + [Environment]::NewLine
$bytes = $utf8NoBom.GetBytes($json)
try {
    $stream = [IO.File]::Open($outputPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read)
    try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) }
    finally { $stream.Dispose() }
}
finally { [Array]::Clear($bytes, 0, $bytes.Length) }

[pscustomobject][ordered]@{
    result = 'PLAN_CREATED_COMMAND_FREE'
    roundId = $roundId
    planPath = $outputPath
    planLength = (Get-Item -LiteralPath $outputPath).Length
    planSha256 = (Get-FileHash -LiteralPath $outputPath -Algorithm SHA256).Hash
    authorizationCreated = $false
    appCommandsIssued = 0
    vendorCommandsIssued = 0
}
