param(
    [string]$PackageRoot = 'C:\HyperTwist\packaged\desktop',
    [string]$DestinationZipPath = '',
    [string]$ReportPath = '',
    [Int64]$ExpectedSolverTableSizeBytes = 676207080,
    [string]$ExpectedSolverTableSha256 = 'dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034'
)

$ErrorActionPreference = 'Stop'

Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem

$ZipCreationRetryCount = 15
$ZipCreationRetryDelayMilliseconds = 1000

function Write-Utf8JsonFile {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,
        [Parameter(Mandatory = $true)]
        [object]$Value
    )

    $ParentPath = Split-Path -Parent $Path
    if (-not [string]::IsNullOrWhiteSpace($ParentPath))
    {
        New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null
    }

    $Json = $Value | ConvertTo-Json -Depth 10
    $Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
    [System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
}

function Test-IsTransientFileLockMessage {
    param(
        [string]$Message
    )

    if ([string]::IsNullOrWhiteSpace($Message))
    {
        return $false
    }

    return $Message -like '*being used by another process*' `
        -or $Message -like '*cannot access the file*'
}

function Remove-ItemWithRetry {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    for ($AttemptIndex = 1; $AttemptIndex -le $ZipCreationRetryCount; $AttemptIndex++)
    {
        try
        {
            if (Test-Path -LiteralPath $Path)
            {
                Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
            }

            return
        }
        catch
        {
            if ($AttemptIndex -ge $ZipCreationRetryCount `
                -or -not (Test-IsTransientFileLockMessage -Message $_.Exception.Message))
            {
                throw
            }

            Start-Sleep -Milliseconds $ZipCreationRetryDelayMilliseconds
        }
    }
}

function Resolve-PackagedExecutablePath {
    param(
        [Parameter(Mandatory = $true)]
        [string]$RootPath
    )

    $CandidateExecutablePaths = @(
        (Join-Path $RootPath 'Windows\UnrealHyperTwist.exe'),
        (Join-Path $RootPath 'WindowsNoEditor\UnrealHyperTwist.exe'),
        (Join-Path $RootPath 'UnrealHyperTwist.exe')
    )

    return $CandidateExecutablePaths | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
}

function Resolve-PackagedGameExecutablePath {
    param(
        [Parameter(Mandatory = $true)]
        [string]$PackagedProjectRoot
    )

    $GameExecutableDirectory = Join-Path $PackagedProjectRoot 'Binaries\Win64'
    $CandidateExecutablePaths = @(
        (Join-Path $GameExecutableDirectory 'UnrealHyperTwist.exe'),
        (Join-Path $GameExecutableDirectory 'UnrealHyperTwist-Win64-Shipping.exe'),
        (Join-Path $GameExecutableDirectory 'UnrealHyperTwist-Win64-Development.exe')
    )

    return $CandidateExecutablePaths |
        Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } |
        Select-Object -First 1
}

function Invoke-ZipCreationWithRetry {
    param(
        [Parameter(Mandatory = $true)]
        [string]$SourceDirectory,
        [Parameter(Mandatory = $true)]
        [string]$DestinationZipPath
    )

    $LastException = $null
    for ($AttemptIndex = 1; $AttemptIndex -le $ZipCreationRetryCount; $AttemptIndex++)
    {
        try
        {
            if (Test-Path -LiteralPath $DestinationZipPath)
            {
                Remove-ItemWithRetry -Path $DestinationZipPath
            }

            [System.IO.Compression.ZipFile]::CreateFromDirectory(
                $SourceDirectory,
                $DestinationZipPath,
                [System.IO.Compression.CompressionLevel]::Optimal,
                $false
            )
            return
        }
        catch
        {
            $LastException = $_.Exception
            if ($AttemptIndex -ge $ZipCreationRetryCount `
                -or -not (($_.Exception -is [System.IO.IOException]) `
                    -or (Test-IsTransientFileLockMessage -Message $_.Exception.Message)))
            {
                break
            }

            Start-Sleep -Milliseconds $ZipCreationRetryDelayMilliseconds
        }
    }

    if ($null -ne $LastException)
    {
        throw $LastException
    }

    throw "Packaged zip export failed for an unknown reason."
}

function Test-ZipEntryMatchesExecutablePath {
    param(
        [Parameter(Mandatory = $true)]
        [string]$EntryFullName
    )

    $NormalizedEntryPath = $EntryFullName.Replace('\', '/')
    return $NormalizedEntryPath.EndsWith('UnrealHyperTwist.exe', [System.StringComparison]::OrdinalIgnoreCase)
}

function Get-NormalizedRelativePath {
    param(
        [Parameter(Mandatory = $true)]
        [string]$RootPath,
        [Parameter(Mandatory = $true)]
        [string]$FilePath
    )

    $RootWithSeparator = $RootPath.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
    $FullFilePath = [System.IO.Path]::GetFullPath($FilePath)
    if (-not $FullFilePath.StartsWith($RootWithSeparator, [System.StringComparison]::OrdinalIgnoreCase))
    {
        throw "Required package file '$FullFilePath' is not beneath package root '$RootPath'."
    }

    return $FullFilePath.Substring($RootWithSeparator.Length).Replace('\', '/')
}

function Get-RequiredPackageFileEvidence {
    param(
        [Parameter(Mandatory = $true)]
        [string]$RootPath,
        [Parameter(Mandatory = $true)]
        [string]$Path,
        [Parameter(Mandatory = $true)]
        [string]$Kind,
        [Int64]$ExpectedSizeBytes = -1,
        [string]$ExpectedSha256 = ''
    )

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf))
    {
        throw "Required packaged $Kind file was not found at '$Path'."
    }

    $Item = Get-Item -LiteralPath $Path
    if ($Item.Length -le 0)
    {
        throw "Required packaged $Kind file '$Path' was empty."
    }

    if ($ExpectedSizeBytes -ge 0 -and $Item.Length -ne $ExpectedSizeBytes)
    {
        throw "Required packaged $Kind file '$Path' was $($Item.Length) bytes; expected exactly $ExpectedSizeBytes bytes."
    }

    $ActualSha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
    if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256) `
        -and $ActualSha256 -ne $ExpectedSha256.Trim().ToLowerInvariant())
    {
        throw "Required packaged $Kind file '$Path' did not match the expected SHA-256."
    }

    return [pscustomobject][ordered]@{
        kind = $Kind
        relativePath = Get-NormalizedRelativePath -RootPath $RootPath -FilePath $Item.FullName
        sizeBytes = [Int64]$Item.Length
        sha256 = $ActualSha256
    }
}

function Get-ZipEntrySha256 {
    param(
        [Parameter(Mandatory = $true)]
        [System.IO.Compression.ZipArchiveEntry]$Entry
    )

    $Sha256 = [System.Security.Cryptography.SHA256]::Create()
    $EntryStream = $Entry.Open()
    try
    {
        $Digest = $Sha256.ComputeHash($EntryStream)
        return ([System.BitConverter]::ToString($Digest)).Replace('-', '').ToLowerInvariant()
    }
    finally
    {
        $EntryStream.Dispose()
        $Sha256.Dispose()
    }
}

if (-not (Test-Path -LiteralPath $PackageRoot))
{
    throw "Package root '$PackageRoot' was not found."
}

$ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path
$PackagedExecutablePath = Resolve-PackagedExecutablePath -RootPath $ResolvedPackageRoot
if ($null -eq $PackagedExecutablePath)
{
    throw "No packaged UnrealHyperTwist executable was found beneath '$ResolvedPackageRoot'."
}

$PackagedExecutableDirectory = Split-Path -Parent $PackagedExecutablePath
$PackagedProjectRoot = Join-Path $PackagedExecutableDirectory 'UnrealHyperTwist'
$PackagedGameExecutablePath = Resolve-PackagedGameExecutablePath `
    -PackagedProjectRoot $PackagedProjectRoot
if ($null -eq $PackagedGameExecutablePath)
{
    throw "No packaged inner UnrealHyperTwist game executable was found beneath '$PackagedProjectRoot'."
}
$RequiredPackageFiles = @(
    Get-RequiredPackageFileEvidence `
        -RootPath $ResolvedPackageRoot `
        -Path $PackagedExecutablePath `
        -Kind 'launcher-executable'
    Get-RequiredPackageFileEvidence `
        -RootPath $ResolvedPackageRoot `
        -Path $PackagedGameExecutablePath `
        -Kind 'game-executable'
    Get-RequiredPackageFileEvidence `
        -RootPath $ResolvedPackageRoot `
        -Path (Join-Path $PackagedProjectRoot 'Binaries\Win64\tbbmalloc.dll') `
        -Kind 'runtime-dependency-tbbmalloc'
    Get-RequiredPackageFileEvidence `
        -RootPath $ResolvedPackageRoot `
        -Path (Join-Path $PackagedProjectRoot 'Content\Paks\UnrealHyperTwist-Windows.pak') `
        -Kind 'cooked-pak'
    Get-RequiredPackageFileEvidence `
        -RootPath $ResolvedPackageRoot `
        -Path (Join-Path $PackagedProjectRoot 'Content\Paks\UnrealHyperTwist-Windows.utoc') `
        -Kind 'cooked-utoc'
    Get-RequiredPackageFileEvidence `
        -RootPath $ResolvedPackageRoot `
        -Path (Join-Path $PackagedProjectRoot 'Content\Paks\UnrealHyperTwist-Windows.ucas') `
        -Kind 'cooked-ucas'
    Get-RequiredPackageFileEvidence `
        -RootPath $ResolvedPackageRoot `
        -Path (Join-Path $PackagedProjectRoot 'Saved\twophase-ht.tbl') `
        -Kind 'solver-table' `
        -ExpectedSizeBytes $ExpectedSolverTableSizeBytes `
        -ExpectedSha256 $ExpectedSolverTableSha256
    Get-RequiredPackageFileEvidence `
        -RootPath $ResolvedPackageRoot `
        -Path (Join-Path $PackagedExecutableDirectory 'Content\Browser\index.html') `
        -Kind 'browser-shell-index'
    Get-RequiredPackageFileEvidence `
        -RootPath $ResolvedPackageRoot `
        -Path (Join-Path $PackagedExecutableDirectory 'Content\Browser\src\browser-runtime-bootstrap.js') `
        -Kind 'browser-shell-bootstrap'
    Get-RequiredPackageFileEvidence `
        -RootPath $ResolvedPackageRoot `
        -Path (Join-Path $PackagedExecutableDirectory 'Content\Browser\src\browser-spatial-runtime-fallback.js') `
        -Kind 'browser-shell-fallback'
    Get-RequiredPackageFileEvidence `
        -RootPath $ResolvedPackageRoot `
        -Path (Join-Path $PackagedExecutableDirectory 'Content\Browser\dist\browser-spatial-runtime.js') `
        -Kind 'browser-shell-runtime'
)

if ([string]::IsNullOrWhiteSpace($DestinationZipPath))
{
    $PackageLeafName = Split-Path -Leaf $ResolvedPackageRoot
    $DestinationZipPath = Join-Path (
        Split-Path -Parent $ResolvedPackageRoot
    ) ("{0}-{1}.zip" -f $PackageLeafName, [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ'))
}

if ([string]::IsNullOrWhiteSpace($ReportPath))
{
    $ReportPath = Join-Path $ResolvedPackageRoot 'validation\zip-export-report.json'
}

$ResolvedDestinationZipPath = [System.IO.Path]::GetFullPath($DestinationZipPath)
$ResolvedPackageRootWithSeparator = $ResolvedPackageRoot.TrimEnd('\') + '\'
if ($ResolvedDestinationZipPath.StartsWith($ResolvedPackageRootWithSeparator, [System.StringComparison]::OrdinalIgnoreCase))
{
    throw "Destination zip path '$ResolvedDestinationZipPath' must live outside the package root '$ResolvedPackageRoot'."
}

New-Item -ItemType Directory -Force -Path (Split-Path -Parent $ResolvedDestinationZipPath) | Out-Null

if (Test-Path -LiteralPath $ResolvedDestinationZipPath)
{
    Remove-ItemWithRetry -Path $ResolvedDestinationZipPath
}

$ZipReport = [ordered]@{
    reportVersion = 'ht-packaged-build-zip-export/v2'
    generatedAtUtc = [DateTime]::UtcNow.ToString('o')
    packageRoot = $ResolvedPackageRoot
    packagedExecutablePath = $PackagedExecutablePath
    destinationZipPath = $ResolvedDestinationZipPath
    result = 'failed'
    entryCount = 0
    packagedExecutableEntry = $null
    requiredEntries = @($RequiredPackageFiles)
    archivedRequiredEntries = @()
    zipSizeBytes = 0
    zipSha256 = $null
    error = $null
}

try
{
    Invoke-ZipCreationWithRetry `
        -SourceDirectory $ResolvedPackageRoot `
        -DestinationZipPath $ResolvedDestinationZipPath

    $ZipArchive = [System.IO.Compression.ZipFile]::OpenRead($ResolvedDestinationZipPath)
    try
    {
        $Entries = @($ZipArchive.Entries)
        $ZipReport.entryCount = $Entries.Count

        $ExecutableEntry = $Entries | Where-Object {
            Test-ZipEntryMatchesExecutablePath -EntryFullName $_.FullName
        } | Select-Object -First 1
        if ($null -eq $ExecutableEntry)
        {
            throw "The exported zip '$ResolvedDestinationZipPath' did not contain a packaged UnrealHyperTwist executable entry."
        }

        $ZipReport.packagedExecutableEntry = $ExecutableEntry.FullName
        if ($ZipReport.entryCount -le 0)
        {
            throw "The exported zip '$ResolvedDestinationZipPath' contained zero entries."
        }

        foreach ($RequiredFile in $RequiredPackageFiles)
        {
            $MatchingRequiredEntries = @($Entries | Where-Object {
                $_.FullName.Replace('\', '/').Equals(
                    $RequiredFile.relativePath,
                    [System.StringComparison]::OrdinalIgnoreCase)
            })
            if ($MatchingRequiredEntries.Count -ne 1)
            {
                throw (
                    "The exported zip '$ResolvedDestinationZipPath' contained " +
                    "$($MatchingRequiredEntries.Count) copies of required $($RequiredFile.kind) " +
                    "entry '$($RequiredFile.relativePath)'; expected exactly one."
                )
            }

            $RequiredEntry = $MatchingRequiredEntries[0]
            if ([Int64]$RequiredEntry.Length -ne [Int64]$RequiredFile.sizeBytes)
            {
                throw "Required zip entry '$($RequiredFile.relativePath)' had an unexpected uncompressed size."
            }

            $ArchivedSha256 = Get-ZipEntrySha256 -Entry $RequiredEntry
            if ($ArchivedSha256 -ne $RequiredFile.sha256)
            {
                throw (
                    "Required zip entry '$($RequiredFile.relativePath)' did not preserve " +
                    "the source SHA-256 '$($RequiredFile.sha256)'."
                )
            }

            $ZipReport.archivedRequiredEntries += @(
                [pscustomobject][ordered]@{
                    kind = $RequiredFile.kind
                    relativePath = $RequiredFile.relativePath
                    sizeBytes = [Int64]$RequiredEntry.Length
                    sha256 = $ArchivedSha256
                }
            )
        }
    }
    finally
    {
        if ($null -ne $ZipArchive)
        {
            $ZipArchive.Dispose()
        }
    }

    $ZipItem = Get-Item -LiteralPath $ResolvedDestinationZipPath
    $ZipReport.zipSizeBytes = $ZipItem.Length
    $ZipReport.zipSha256 = (Get-FileHash -LiteralPath $ResolvedDestinationZipPath -Algorithm SHA256).Hash.ToLowerInvariant()
    $ZipReport.result = 'passed'
}
catch
{
    $ZipReport.error = $_.Exception.Message
    Write-Utf8JsonFile -Path $ReportPath -Value $ZipReport
    throw
}

Write-Utf8JsonFile -Path $ReportPath -Value $ZipReport
Write-Host "Exported verified packaged zip to '$ResolvedDestinationZipPath'."
