param(
    [string]$Feed = $(if ($env:STARDEW_MODSYNC_FEED) { $env:STARDEW_MODSYNC_FEED } else { "https://stardew.thetorg.org/" }),
    [string]$GamePath = $PSScriptRoot,
    [switch]$DryRun,
    [switch]$NoLaunch
)

$ErrorActionPreference = "Stop"
$GamePath = [IO.Path]::GetFullPath($GamePath)
# Windows PowerShell 5.1 can otherwise negotiate an obsolete TLS version on
# older machines, which makes the HTTPS feed fail before any file is read.
try {
    [Net.ServicePointManager]::SecurityProtocol =
        [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
} catch {
    # PowerShell 7 uses its own HTTP stack and doesn't need this setting.
}
$UpdateControlPath = Join-Path $GamePath "thors-fjord-update.txt"
$UpdateMode = ""
if (Test-Path -LiteralPath $UpdateControlPath -PathType Leaf) {
    $UpdateMode = ([string](Get-Content -Raw -LiteralPath $UpdateControlPath)).Trim() -replace '\s+', ' '
    $UpdateMode = $UpdateMode.ToLowerInvariant()
}
if ($UpdateMode -eq "disable remote update") {
    Write-Host "Remote mod update disabled by $UpdateControlPath; using the installed mod set."
    if (-not $DryRun -and -not $NoLaunch) {
        & (Join-Path $GamePath "StardewModdingAPI.exe")
        exit $LASTEXITCODE
    }
    return
}

$ModsPath = Join-Path $GamePath "Mods"
$StatePath = Join-Path $GamePath ".stardew-modsync-manifest.json"
if (-not (Test-Path -LiteralPath $ModsPath -PathType Container)) { throw "Missing Mods directory: $ModsPath" }

function Get-FeedFile([string]$RelativePath, [string]$Destination) {
    if (Test-Path -LiteralPath $Feed -PathType Container) {
        Copy-Item -LiteralPath (Join-Path $Feed ($RelativePath -replace "/", "\")) -Destination $Destination
    } else {
        # Encode every path segment even though staged ZIP names are restricted
        # to ASCII letters and numbers, so this remains safe for future paths.
        $EncodedPath = (($RelativePath -split "/") | ForEach-Object { [Uri]::EscapeDataString($_) }) -join "/"
        Invoke-WebRequest -UseBasicParsing -Uri ($Feed.TrimEnd("/") + "/" + $EncodedPath) -OutFile $Destination
    }
}

function Assert-SafeName([string]$Value, [string]$Label) {
    if (-not $Value -or $Value.StartsWith(".") -or $Value.Contains("/") -or $Value.Contains("\") -or [IO.Path]::GetFileName($Value) -ne $Value) {
        throw "Unsafe ${Label}: '$Value'"
    }
}

function Copy-PreservedPath([string]$Source, [string]$Destination) {
    if (-not (Test-Path -LiteralPath $Source)) { return }
    $Parent = Split-Path -Parent $Destination
    New-Item -ItemType Directory -Force -Path $Parent | Out-Null
    if (Test-Path -LiteralPath $Source -PathType Container) {
        Copy-Item -Recurse -Force -LiteralPath $Source -Destination $Destination
    } else {
        Copy-Item -Force -LiteralPath $Source -Destination $Destination
    }
}

$DownloadRoot = Join-Path ([IO.Path]::GetTempPath()) ("stardew-modsync-" + [Guid]::NewGuid().ToString("N"))
# Keep extraction and rollback paths short. Putting this beneath Mods can push
# otherwise valid mod assets over Windows' legacy MAX_PATH limit.
$Transaction = Join-Path ([IO.Path]::GetTempPath()) ("sdm-" + [Guid]::NewGuid().ToString("N"))
$NewRoot = Join-Path $Transaction "new"
$OldRoot = Join-Path $Transaction "old"
$MovedOld = New-Object System.Collections.Generic.List[string]
$InstalledNew = New-Object System.Collections.Generic.List[string]
New-Item -ItemType Directory -Path $DownloadRoot | Out-Null
New-Item -ItemType Directory -Path $Transaction | Out-Null

try {
    $ManifestPath = Join-Path $DownloadRoot "manifest.json"
    Get-FeedFile "manifest.json" $ManifestPath
    $Manifest = Get-Content -Raw -LiteralPath $ManifestPath | ConvertFrom-Json
    if ($Manifest.schemaVersion -ne 1 -or $Manifest.hashAlgorithm -ne "sha256") { throw "Unsupported manifest" }

    $Remote = @{}
    foreach ($Entry in $Manifest.mods) {
        $Name = [string]$Entry.sourceDirectory
        $ZipName = [string]$Entry.zip
        Assert-SafeName $Name "mod directory"
        Assert-SafeName $ZipName "zip name"
        if ($ZipName -notmatch '^[A-Za-z0-9]+\.zip$' -or [string]$Entry.path -ne "Mods/$ZipName" -or $Remote.ContainsKey($Name)) {
            throw "Inconsistent manifest entry for '$Name'"
        }
        $Remote[$Name] = $Entry
    }

    $SaveDataMods = @{}
    foreach ($RawName in @($Manifest.preservation.saveDataModDirectories)) {
        $SaveDataName = [string]$RawName
        Assert-SafeName $SaveDataName "save-data mod directory"
        if (-not $Remote.ContainsKey($SaveDataName)) {
            throw "Unknown save-data mod directory: '$SaveDataName'"
        }
        $SaveDataMods[$SaveDataName] = $true
    }

    $PriorHashes = @{}
    if (Test-Path -LiteralPath $StatePath -PathType Leaf) {
        try {
            $Prior = Get-Content -Raw -LiteralPath $StatePath | ConvertFrom-Json
            foreach ($Property in $Prior.mods.PSObject.Properties) { $PriorHashes[$Property.Name] = [string]$Property.Value }
        } catch { $PriorHashes = @{} }
    }

    $LocalNames = @(Get-ChildItem -LiteralPath $ModsPath -Directory | Where-Object { -not $_.Name.StartsWith(".") } | ForEach-Object Name)
    $Removed = @($LocalNames | Where-Object { -not $Remote.ContainsKey($_) } | Sort-Object)
    $Changed = @($Remote.Keys | Where-Object {
        -not $PriorHashes.ContainsKey($_) -or
        $PriorHashes[$_] -ne [string]$Remote[$_].sha256 -or
        -not (Test-Path -LiteralPath (Join-Path $ModsPath $_) -PathType Container)
    } | Sort-Object)

    Write-Host "Mod sync: $($Changed.Count) update(s), $($Removed.Count) removal(s), $($Remote.Count - $Changed.Count) current."
    foreach ($Name in $Removed) { Write-Host "  REMOVE $Name" }
    foreach ($Name in $Changed) { Write-Host "  UPDATE $Name" }
    if ($DryRun) { return }

    Add-Type -AssemblyName System.IO.Compression.FileSystem
    $Archives = @{}
    foreach ($Name in $Changed) {
        $Entry = $Remote[$Name]
        $ArchivePath = Join-Path $DownloadRoot ([string]$Entry.zip)
        Get-FeedFile ([string]$Entry.path) $ArchivePath
        $Hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $ArchivePath).Hash.ToLowerInvariant()
        $Length = (Get-Item -LiteralPath $ArchivePath).Length
        if ($Hash -ne [string]$Entry.sha256 -or $Length -ne [long]$Entry.bytes) { throw "Download verification failed: $Name" }
        $Archive = [IO.Compression.ZipFile]::OpenRead($ArchivePath)
        try {
            if ($Archive.Entries.Count -eq 0) { throw "Empty archive: $Name" }
            foreach ($ZipEntry in $Archive.Entries) {
                $Parts = @($ZipEntry.FullName.Replace("\", "/").Split("/") | Where-Object { $_ })
                $UnixType = (($ZipEntry.ExternalAttributes -shr 16) -band 0xF000)
                if ($Parts.Count -eq 0 -or $Parts[0] -ne $Name -or $Parts -contains ".." -or $UnixType -eq 0xA000) {
                    throw "Unsafe archive entry for ${Name}: $($ZipEntry.FullName)"
                }
            }
        } finally { $Archive.Dispose() }
        $Archives[$Name] = $ArchivePath
    }

    New-Item -ItemType Directory -Path $NewRoot | Out-Null
    New-Item -ItemType Directory -Path $OldRoot | Out-Null
    foreach ($Name in $Changed) {
        [IO.Compression.ZipFile]::ExtractToDirectory($Archives[$Name], $NewRoot)
        $StagedMod = Join-Path $NewRoot $Name
        if (-not (Test-Path -LiteralPath $StagedMod -PathType Container)) { throw "Archive did not create '$Name'" }
        $LiveMod = Join-Path $ModsPath $Name

        foreach ($RawPath in @($Manifest.preservation.paths)) {
            $Normalized = ([string]$RawPath).Replace("\", "/")
            $Prefix = "$Name/"
            if ($Normalized.StartsWith($Prefix, [StringComparison]::Ordinal)) {
                $Relative = $Normalized.Substring($Prefix.Length)
                Copy-PreservedPath (Join-Path $LiveMod ($Relative -replace "/", "\")) (Join-Path $StagedMod ($Relative -replace "/", "\"))
            }
        }
        $Suffix = [string]$Manifest.preservation.saveDataSuffix
        if ($SaveDataMods.ContainsKey($Name) -and (Test-Path -LiteralPath $LiveMod -PathType Container)) {
            # A malformed legacy mod tree must not prevent its own replacement.
            # Only declared save-data mods are traversed, and unreadable legacy
            # paths are skipped instead of aborting the whole transaction.
            foreach ($SaveFile in Get-ChildItem -LiteralPath $LiveMod -Recurse -File -Filter "*$Suffix" -ErrorAction SilentlyContinue) {
                $Relative = $SaveFile.FullName.Substring($LiveMod.Length).TrimStart("\", "/")
                Copy-PreservedPath $SaveFile.FullName (Join-Path $StagedMod $Relative)
                if ($Manifest.preservation.preservePairedJson) {
                    $BaseName = $SaveFile.Name.Substring(0, $SaveFile.Name.Length - $Suffix.Length)
                    $Paired = Join-Path $SaveFile.DirectoryName "$BaseName.json"
                    if (Test-Path -LiteralPath $Paired -PathType Leaf) {
                        $PairedRelative = $Paired.Substring($LiveMod.Length).TrimStart("\", "/")
                        Copy-PreservedPath $Paired (Join-Path $StagedMod $PairedRelative)
                    }
                }
            }
        }
    }

    foreach ($Name in @($Removed) + @($Changed)) {
        $LiveMod = Join-Path $ModsPath $Name
        if (Test-Path -LiteralPath $LiveMod) {
            Move-Item -LiteralPath $LiveMod -Destination (Join-Path $OldRoot $Name)
            $MovedOld.Add($Name)
        }
    }
    foreach ($Name in $Changed) {
        Move-Item -LiteralPath (Join-Path $NewRoot $Name) -Destination (Join-Path $ModsPath $Name)
        $InstalledNew.Add($Name)
    }

    $NewHashes = [ordered]@{}
    foreach ($Name in ($Remote.Keys | Sort-Object)) { $NewHashes[$Name] = [string]$Remote[$Name].sha256 }
    $State = [ordered]@{ schemaVersion = 1; manifestGeneratedAt = $Manifest.generatedAt; mods = $NewHashes }
    $PendingState = "$StatePath.$([Guid]::NewGuid().ToString('N')).tmp"
    $State | ConvertTo-Json -Depth 5 | Set-Content -Encoding UTF8 -LiteralPath $PendingState
    Move-Item -Force -LiteralPath $PendingState -Destination $StatePath
    Write-Host "Mod sync complete."
} catch {
    foreach ($Name in $InstalledNew) { Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $ModsPath $Name) }
    $RollbackNames = @($MovedOld)
    [array]::Reverse($RollbackNames)
    foreach ($Name in $RollbackNames) {
        $Old = Join-Path $OldRoot $Name
        if (Test-Path -LiteralPath $Old) { Move-Item -LiteralPath $Old -Destination (Join-Path $ModsPath $Name) }
    }
    throw
} finally {
    Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $Transaction, $DownloadRoot
}

if (-not $NoLaunch) {
    & (Join-Path $GamePath "StardewModdingAPI.exe")
    exit $LASTEXITCODE
}
