#Requires -Version 5.1

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

    [Parameter()]
    [string] $OutputDirectory
)

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

$manifestFileName = 'LegacyRuntimeBundle.manifest.json'
$archiveFileName = 'LegacyRuntimeBundle.zip'
$archiveHashFileName = 'LegacyRuntimeBundle.zip.sha256'
$expectedResAssetCount = 34

function Get-NormalizedFullPath {
    param(
        [Parameter(Mandatory = $true)]
        [string] $Path
    )

    return [IO.Path]::GetFullPath($Path)
}

function Test-PathIsWithin {
    param(
        [Parameter(Mandatory = $true)]
        [string] $Root,

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

    $normalizedRoot = (Get-NormalizedFullPath -Path $Root).TrimEnd(
        [IO.Path]::DirectorySeparatorChar,
        [IO.Path]::AltDirectorySeparatorChar)
    $normalizedCandidate = Get-NormalizedFullPath -Path $Candidate
    $prefix = $normalizedRoot + [IO.Path]::DirectorySeparatorChar
    return $normalizedCandidate.StartsWith(
        $prefix,
        [StringComparison]::OrdinalIgnoreCase)
}

function Assert-NoReparsePointInExistingAncestry {
    param(
        [Parameter(Mandatory = $true)]
        [string] $Path,

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

    $current = Get-NormalizedFullPath -Path $Path
    while (-not [string]::IsNullOrEmpty($current)) {
        if (Test-Path -LiteralPath $current) {
            $attributes = [IO.File]::GetAttributes($current)
            if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
                throw "$Description traverses a reparse point: $current"
            }
        }

        $trimmed = $current.TrimEnd(
            [IO.Path]::DirectorySeparatorChar,
            [IO.Path]::AltDirectorySeparatorChar)
        $parent = [IO.Path]::GetDirectoryName($trimmed)
        if ([string]::IsNullOrEmpty($parent) -or
            $parent.Equals($current, [StringComparison]::OrdinalIgnoreCase)) {
            break
        }

        $current = $parent
    }
}

function Assert-SafePayloadRelativePath {
    param(
        [Parameter(Mandatory = $true)]
        [string] $RelativePath
    )

    $portablePath = $RelativePath.Replace('\', '/')
    if (-not $portablePath.Equals(
            $portablePath.Normalize([Text.NormalizationForm]::FormC),
            [StringComparison]::Ordinal)) {
        throw "Runtime payload path must use Unicode NFC normalization: $RelativePath"
    }
    $segments = @($portablePath.Split('/'))
    $unsafeSegments = @($segments | Where-Object {
        [string]::IsNullOrWhiteSpace($_) -or $_ -eq '.' -or $_ -eq '..'
    })
    if ($segments.Count -lt 2 -or $unsafeSegments.Count -gt 0) {
        throw "Unsafe runtime payload path: $RelativePath"
    }

    $invalidFileNameCharacters = [IO.Path]::GetInvalidFileNameChars()
    foreach ($segment in $segments) {
        if ($segment.EndsWith(' ', [StringComparison]::Ordinal) -or
            $segment.EndsWith('.', [StringComparison]::Ordinal) -or
            $segment.IndexOfAny($invalidFileNameCharacters) -ge 0) {
            throw "Runtime payload path is not a canonical Windows path: $RelativePath"
        }
        $deviceStem = $segment.Split('.')[0]
        if ($deviceStem -match '(?i)^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$') {
            throw "Runtime payload uses a reserved Windows file name: $RelativePath"
        }
        if ($segment -match '(?i)^(backup|backups|database|databases|license|licenses|cert|certs|credential|credentials|secret|secrets|vendor)$') {
            throw "Forbidden runtime payload directory or file name: $RelativePath"
        }
    }

    $leafName = $segments[$segments.Count - 1]
    if (-not $leafName.Equals('MmoneyCoder.ico', [StringComparison]::OrdinalIgnoreCase) -and
        $leafName -match '(?i)^MmoneyCoder(?:$|[ ._-])') {
        throw "Credential-bearing or archived MmoneyCoder material is forbidden: $RelativePath"
    }

    if ($leafName.Equals('afiedt.buf.txt', [StringComparison]::OrdinalIgnoreCase) -or
        $leafName -match '(?i)(?:\.bak|\.backup|\.old|\.orig|\.save|\.tmp|\.temp|~)$' -or
        $leafName -match '(?i)(?:^|[ ._-])(backup|copy|\xBCF5\xC0AC\xBCF8)(?:[ ._-]|$)') {
        throw "Backup or temporary material is forbidden: $RelativePath"
    }

    $extension = [IO.Path]::GetExtension($leafName)
    $forbiddenExtensions = @(
        '.dll', '.ocx', '.tlb',
        '.lic', '.license',
        '.pfx', '.p12', '.snk', '.cer', '.crt', '.key', '.pem', '.p7b', '.p7c',
        '.db', '.sqlite', '.sqlite3', '.mdb', '.accdb', '.mdf', '.ldf', '.sql',
        '.zip', '.7z', '.rar',
        '.exe', '.com', '.msi'
    )
    if ($forbiddenExtensions -icontains $extension) {
        throw "Forbidden database, vendor, license, certificate, archive, or executable file: $RelativePath"
    }

    if ($leafName -match '(?i)^(database|credentials?|secrets?|connectionstrings?)(?:[._-]|$)') {
        throw "Credential or database configuration is forbidden: $RelativePath"
    }
}

function Get-LegacyPackagedResAssetNames {
    param(
        [Parameter(Mandatory = $true)]
        [string] $ProjectPath
    )

    if (-not (Test-Path -LiteralPath $ProjectPath -PathType Leaf)) {
        throw "Legacy parity project was not found: $ProjectPath"
    }

    [xml] $projectXml = Get-Content -LiteralPath $ProjectPath -Raw -Encoding UTF8
    $nodes = @($projectXml.SelectNodes(
        "/*[local-name()='Project']/*[local-name()='ItemGroup']/*[local-name()='LegacyPackagedResAsset']"))
    if ($nodes.Count -ne $expectedResAssetCount) {
        throw ("The project must declare exactly {0} LegacyPackagedResAsset items; found {1}." -f
            $expectedResAssetCount,
            $nodes.Count)
    }

    $prefix = '$(LegacyResSourceRoot)\'
    $names = New-Object 'System.Collections.Generic.List[string]'
    $seen = New-Object 'System.Collections.Generic.HashSet[string]' (
        [StringComparer]::OrdinalIgnoreCase)
    foreach ($node in $nodes) {
        $include = [string] $node.Include
        if (-not $include.StartsWith($prefix, [StringComparison]::Ordinal)) {
            throw "LegacyPackagedResAsset is outside the closed Res projection: $include"
        }

        $name = $include.Substring($prefix.Length)
        if ([string]::IsNullOrWhiteSpace($name) -or
            $name.IndexOfAny(@(
                [IO.Path]::DirectorySeparatorChar,
                [IO.Path]::AltDirectorySeparatorChar)) -ge 0 -or
            $name -eq '.' -or
            $name -eq '..' -or
            [IO.Path]::GetFileName($name) -ne $name) {
            throw "LegacyPackagedResAsset must be a single Res file name: $include"
        }

        Assert-SafePayloadRelativePath -RelativePath ('Res/' + $name)
        if (-not $seen.Add($name)) {
            throw "Duplicate LegacyPackagedResAsset declaration: $name"
        }

        $names.Add($name)
    }

    return @($names.ToArray() | Sort-Object)
}

function Get-SafeTreeInventory {
    param(
        [Parameter(Mandatory = $true)]
        [string] $Root
    )

    $rootPath = Get-NormalizedFullPath -Path $Root
    Assert-NoReparsePointInExistingAncestry -Path $rootPath -Description 'Runtime asset tree'
    $directories = New-Object 'System.Collections.Generic.List[string]'
    $files = New-Object 'System.Collections.Generic.List[string]'
    $pending = New-Object 'System.Collections.Generic.Queue[string]'
    $pending.Enqueue($rootPath)

    while ($pending.Count -gt 0) {
        $directory = $pending.Dequeue()
        $children = @(Get-ChildItem -LiteralPath $directory -Force | Sort-Object Name)
        foreach ($child in $children) {
            if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
                throw "Runtime asset tree contains a reparse point: $($child.FullName)"
            }

            if ($child.PSIsContainer) {
                $directories.Add($child.FullName)
                $pending.Enqueue($child.FullName)
            }
            else {
                $files.Add($child.FullName)
            }
        }
    }

    return [pscustomobject]@{
        Directories = @($directories.ToArray())
        Files = @($files.ToArray())
    }
}

function Get-RelativeChildPath {
    param(
        [Parameter(Mandatory = $true)]
        [string] $Root,

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

    $rootPath = (Get-NormalizedFullPath -Path $Root).TrimEnd(
        [IO.Path]::DirectorySeparatorChar,
        [IO.Path]::AltDirectorySeparatorChar)
    $childPath = Get-NormalizedFullPath -Path $Child
    if (-not (Test-PathIsWithin -Root $rootPath -Candidate $childPath)) {
        throw "Path is outside its expected root: $childPath"
    }

    return $childPath.Substring($rootPath.Length + 1)
}

function Get-GitRepositoryAncestor {
    param(
        [Parameter(Mandatory = $true)]
        [string] $Path
    )

    $current = Get-NormalizedFullPath -Path $Path
    while (-not [string]::IsNullOrEmpty($current)) {
        if (Test-Path -LiteralPath (Join-Path $current '.git')) {
            return $current
        }
        $trimmed = $current.TrimEnd(
            [IO.Path]::DirectorySeparatorChar,
            [IO.Path]::AltDirectorySeparatorChar)
        $parent = [IO.Path]::GetDirectoryName($trimmed)
        if ([string]::IsNullOrEmpty($parent) -or
            $parent.Equals($current, [StringComparison]::OrdinalIgnoreCase)) {
            break
        }
        $current = $parent
    }

    return $null
}

function Copy-VerifiedPayloadFile {
    param(
        [Parameter(Mandatory = $true)]
        [string] $Source,

        [Parameter(Mandatory = $true)]
        [string] $Destination,

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

    if (([IO.File]::GetAttributes($Source) -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
        throw "Runtime payload file is a reparse point: $Source"
    }

    $destinationParent = [IO.Path]::GetDirectoryName($Destination)
    if (-not (Test-Path -LiteralPath $destinationParent -PathType Container)) {
        [IO.Directory]::CreateDirectory($destinationParent) | Out-Null
    }

    [IO.File]::Copy($Source, $Destination, $false)
    $sourceHash = (Get-FileHash -LiteralPath $Source -Algorithm SHA256).Hash.ToUpperInvariant()
    $destinationHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash.ToUpperInvariant()
    if (-not $sourceHash.Equals($destinationHash, [StringComparison]::Ordinal)) {
        throw "Runtime payload changed while it was copied: $ManifestPath"
    }

    $destinationFile = Get-Item -LiteralPath $Destination -Force
    return [ordered]@{
        path = $ManifestPath.Replace('\', '/')
        length = [long] $destinationFile.Length
        sha256 = $destinationHash
    }
}

$repositoryRoot = Get-NormalizedFullPath -Path (Join-Path $PSScriptRoot '..')
$projectPath = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj'
$sourceRoot = Get-NormalizedFullPath -Path $LegacyRuntimeSourceRoot
$cutsRoot = Join-Path $sourceRoot 'Cuts'
$resRoot = Join-Path $sourceRoot 'Res'

if (-not (Test-Path -LiteralPath $sourceRoot -PathType Container)) {
    throw "Legacy runtime source root was not found: $sourceRoot"
}
if (-not (Test-Path -LiteralPath $cutsRoot -PathType Container)) {
    throw "Legacy Cuts source directory was not found: $cutsRoot"
}
if (-not (Test-Path -LiteralPath $resRoot -PathType Container)) {
    throw "Legacy Res source directory was not found: $resRoot"
}

Assert-NoReparsePointInExistingAncestry -Path $sourceRoot -Description 'Legacy runtime source root'
Assert-NoReparsePointInExistingAncestry -Path $cutsRoot -Description 'Legacy Cuts source directory'
Assert-NoReparsePointInExistingAncestry -Path $resRoot -Description 'Legacy Res source directory'

$resAssetNames = @(Get-LegacyPackagedResAssetNames -ProjectPath $projectPath)
$cutsInventory = Get-SafeTreeInventory -Root $cutsRoot
if (@($cutsInventory.Files).Count -eq 0) {
    throw "Legacy Cuts source directory contains no files: $cutsRoot"
}

if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
    $bundleLeaf = '{0}-{1}' -f
        (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ'),
        ([Guid]::NewGuid().ToString('N'))
    $outputRoot = Join-Path $repositoryRoot ('artifacts\legacy-runtime-bundles\' + $bundleLeaf)
}
else {
    $outputRoot = Get-NormalizedFullPath -Path $OutputDirectory
}
$outputRoot = Get-NormalizedFullPath -Path $outputRoot
$legacyRepositoryRoot = Get-GitRepositoryAncestor -Path $sourceRoot

if ($outputRoot.Equals(
        ([IO.Path]::GetPathRoot($outputRoot)).TrimEnd(
            [IO.Path]::AltDirectorySeparatorChar),
        [StringComparison]::OrdinalIgnoreCase)) {
    throw "The output directory cannot be a filesystem root: $outputRoot"
}
if ((Test-PathIsWithin -Root $sourceRoot -Candidate $outputRoot) -or
    (Test-PathIsWithin -Root $outputRoot -Candidate $sourceRoot) -or
    $outputRoot.Equals($sourceRoot, [StringComparison]::OrdinalIgnoreCase)) {
    throw 'The bundle output directory must not overlap the read-only runtime source root.'
}
if (-not [string]::IsNullOrWhiteSpace($legacyRepositoryRoot) -and
    ($outputRoot.Equals(
            $legacyRepositoryRoot,
            [StringComparison]::OrdinalIgnoreCase) -or
        (Test-PathIsWithin `
            -Root $legacyRepositoryRoot `
            -Candidate $outputRoot))) {
    throw 'The bundle output directory must not be inside the read-only legacy repository.'
}
if (Test-Path -LiteralPath $outputRoot) {
    throw "The bundle output directory already exists; choose a new directory: $outputRoot"
}

$outputParent = [IO.Path]::GetDirectoryName($outputRoot)
Assert-NoReparsePointInExistingAncestry -Path $outputParent -Description 'Bundle output parent'
[IO.Directory]::CreateDirectory($outputRoot) | Out-Null
Assert-NoReparsePointInExistingAncestry -Path $outputRoot -Description 'Bundle output directory'

$stagingRoot = Join-Path $outputRoot ('.staging-' + [Guid]::NewGuid().ToString('N'))
$stagingCreated = $false
$operationSucceeded = $false
try {
    [IO.Directory]::CreateDirectory($stagingRoot) | Out-Null
    $stagingCreated = $true
    $stagingCutsRoot = Join-Path $stagingRoot 'Cuts'
    $stagingResRoot = Join-Path $stagingRoot 'Res'
    [IO.Directory]::CreateDirectory($stagingCutsRoot) | Out-Null
    [IO.Directory]::CreateDirectory($stagingResRoot) | Out-Null

    foreach ($sourceDirectory in @($cutsInventory.Directories)) {
        $relativeDirectory = Get-RelativeChildPath -Root $cutsRoot -Child $sourceDirectory
        Assert-SafePayloadRelativePath -RelativePath ('Cuts/' + $relativeDirectory.Replace('\', '/'))
        [IO.Directory]::CreateDirectory(
            (Join-Path $stagingCutsRoot $relativeDirectory)) | Out-Null
    }

    $manifestFiles = New-Object 'System.Collections.Generic.List[object]'
    foreach ($sourceFile in @($cutsInventory.Files | Sort-Object)) {
        $relativeFile = Get-RelativeChildPath -Root $cutsRoot -Child $sourceFile
        $portableManifestPath = 'Cuts/' + $relativeFile.Replace('\', '/')
        Assert-SafePayloadRelativePath -RelativePath $portableManifestPath
        $destinationFile = Join-Path $stagingCutsRoot $relativeFile
        $manifestFiles.Add((Copy-VerifiedPayloadFile `
            -Source $sourceFile `
            -Destination $destinationFile `
            -ManifestPath $portableManifestPath))
    }

    foreach ($resAssetName in $resAssetNames) {
        $sourceFile = Join-Path $resRoot $resAssetName
        if (-not (Test-Path -LiteralPath $sourceFile -PathType Leaf)) {
            throw "Required packaged Res asset was not found: $sourceFile"
        }

        if (-not (Get-NormalizedFullPath -Path $sourceFile).StartsWith(
                ((Get-NormalizedFullPath -Path $resRoot).TrimEnd('\') + '\'),
                [StringComparison]::OrdinalIgnoreCase)) {
            throw "Packaged Res asset escaped the Res source directory: $resAssetName"
        }

        $portableManifestPath = 'Res/' + $resAssetName
        Assert-SafePayloadRelativePath -RelativePath $portableManifestPath
        $destinationFile = Join-Path $stagingResRoot $resAssetName
        $manifestFiles.Add((Copy-VerifiedPayloadFile `
            -Source $sourceFile `
            -Destination $destinationFile `
            -ManifestPath $portableManifestPath))
    }

    $sortedManifestFiles = @($manifestFiles.ToArray() | Sort-Object { $_.path })
    $cutsFileCount = @($sortedManifestFiles | Where-Object {
        ([string] $_.path).StartsWith('Cuts/', [StringComparison]::Ordinal)
    }).Count
    $resFileCount = @($sortedManifestFiles | Where-Object {
        ([string] $_.path).StartsWith('Res/', [StringComparison]::Ordinal)
    }).Count
    if ($cutsFileCount -ne @($cutsInventory.Files).Count -or
        $resFileCount -ne $expectedResAssetCount) {
        throw 'The staged runtime payload does not match the closed Cuts/Res projection.'
    }

    $manifest = [ordered]@{
        schemaVersion = 1
        bundleType = 'MBN_STOCK_WEBVIEW.LegacyRuntimeBundle'
        fileCount = [int] $sortedManifestFiles.Count
        cutsFileCount = [int] $cutsFileCount
        resFileCount = [int] $resFileCount
        files = $sortedManifestFiles
    }
    $stagedManifestPath = Join-Path $stagingRoot $manifestFileName
    $manifestJson = $manifest | ConvertTo-Json -Depth 6
    [IO.File]::WriteAllText(
        $stagedManifestPath,
        ($manifestJson + [Environment]::NewLine),
        (New-Object Text.UTF8Encoding($false)))

    Add-Type -AssemblyName System.IO.Compression
    Add-Type -AssemblyName System.IO.Compression.FileSystem
    $archivePath = Join-Path $outputRoot $archiveFileName
    $archiveStream = New-Object IO.FileStream(
        $archivePath,
        [IO.FileMode]::CreateNew,
        [IO.FileAccess]::Write,
        [IO.FileShare]::None)
    $archive = $null
    try {
        $archive = New-Object IO.Compression.ZipArchive(
            $archiveStream,
            [IO.Compression.ZipArchiveMode]::Create,
            $true)

        $stagedDirectories = @(Get-ChildItem `
            -LiteralPath $stagingRoot `
            -Directory `
            -Recurse `
            -Force |
            Sort-Object FullName)
        foreach ($stagedDirectory in $stagedDirectories) {
            $relativeDirectory = Get-RelativeChildPath `
                -Root $stagingRoot `
                -Child $stagedDirectory.FullName
            $entryName = $relativeDirectory.Replace('\', '/') + '/'
            $directoryEntry = $archive.CreateEntry($entryName)
            $directoryEntry.ExternalAttributes = [int] [IO.FileAttributes]::Directory
        }

        $stagedFiles = @(Get-ChildItem `
            -LiteralPath $stagingRoot `
            -File `
            -Recurse `
            -Force |
            Sort-Object FullName)
        foreach ($stagedFile in $stagedFiles) {
            $relativeFile = Get-RelativeChildPath `
                -Root $stagingRoot `
                -Child $stagedFile.FullName
            $entryName = $relativeFile.Replace('\', '/')
            $fileEntry = $archive.CreateEntry(
                $entryName,
                [IO.Compression.CompressionLevel]::Optimal)
            $sourceStream = [IO.File]::Open(
                $stagedFile.FullName,
                [IO.FileMode]::Open,
                [IO.FileAccess]::Read,
                [IO.FileShare]::Read)
            $entryStream = $fileEntry.Open()
            try {
                $sourceStream.CopyTo($entryStream)
            }
            finally {
                $entryStream.Dispose()
                $sourceStream.Dispose()
            }
        }
    }
    finally {
        if ($null -ne $archive) {
            $archive.Dispose()
        }
        $archiveStream.Dispose()
    }

    $externalManifestPath = Join-Path $outputRoot $manifestFileName
    [IO.File]::Copy($stagedManifestPath, $externalManifestPath, $false)
    $archiveHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToUpperInvariant()
    $archiveHashPath = Join-Path $outputRoot $archiveHashFileName
    [IO.File]::WriteAllText(
        $archiveHashPath,
        ($archiveHash + '  ' + $archiveFileName + [Environment]::NewLine),
        [Text.Encoding]::ASCII)

    $operationSucceeded = $true
}
finally {
    if ($stagingCreated -and (Test-Path -LiteralPath $stagingRoot)) {
        if (-not (Test-PathIsWithin -Root $outputRoot -Candidate $stagingRoot) -or
            -not ([IO.Path]::GetFileName($stagingRoot)).StartsWith(
                '.staging-',
                [StringComparison]::Ordinal)) {
            throw "Refusing to clean an unexpected staging path: $stagingRoot"
        }

        Get-SafeTreeInventory -Root $stagingRoot | Out-Null
        Remove-Item -LiteralPath $stagingRoot -Recurse -Force
    }

    if (-not $operationSucceeded -and (Test-Path -LiteralPath $outputRoot)) {
        $remainingItems = @(Get-ChildItem -LiteralPath $outputRoot -Force)
        if ($remainingItems.Count -eq 0) {
            Remove-Item -LiteralPath $outputRoot -Force
        }
    }
}

[pscustomobject]@{
    OutputDirectory = $outputRoot
    ArchivePath = $archivePath
    ArchiveSha256 = $archiveHash
    ArchiveHashPath = $archiveHashPath
    ManifestPath = $externalManifestPath
    FileCount = [int] $sortedManifestFiles.Count
    CutsFileCount = [int] $cutsFileCount
    ResFileCount = [int] $resFileCount
}
