# Lokaler Mini-Webserver fuer 4ever Wild (Firefox + Chrome)
# Serviert das Spiel und schreibt levels.json direkt ins Projektverzeichnis.

param([int]$Port = 9847)

$Root = $PSScriptRoot
$LocalAudioRoot = Join-Path $Root "audio"
$LegacyAudioRoot = Join-Path (Split-Path $Root -Parent) "HTML-Game - Space"
$Prefix = "http://127.0.0.1:$Port/"

$AllowedHosts = @(
  "127.0.0.1:$Port",
  "localhost:$Port",
  "[::1]:$Port"
)

# Exakte API-Pfade (LocalPath, ohne Query)
$ApiPathRules = @{
  "/ping"         = @("GET", "OPTIONS")
  "/levels.json"  = @("GET", "POST", "OPTIONS")
  "/upload-asset" = @("POST", "OPTIONS")
}

# Erlaubte Dateiendungen fuer statische GET-Auslieferung (nur Dateien im Projektroot)
$AllowedFileExtensions = @(
  ".html", ".htm", ".js", ".json", ".css",
  ".png", ".jpg", ".jpeg", ".gif", ".mp3", ".ico"
)

# Gesperrte Dateinamen / Endungen (Server-Skripte etc.)
$BlockedFilePattern = '\.(ps1|bat|cmd|exe|dll|vbs|sh|msi)$'

$Mime = @{
  ".html" = "text/html; charset=utf-8"
  ".htm"  = "text/html; charset=utf-8"
  ".js"   = "text/javascript; charset=utf-8"
  ".json" = "application/json; charset=utf-8"
  ".css"  = "text/css; charset=utf-8"
  ".png"  = "image/png"
  ".jpg"  = "image/jpeg"
  ".jpeg" = "image/jpeg"
  ".gif"  = "image/gif"
  ".mp3"  = "audio/mpeg"
  ".ico"  = "image/x-icon"
}

function Test-ServerAlive([int]$port) {
  try {
    $req = [System.Net.WebRequest]::Create("http://127.0.0.1:$port/ping")
    $req.Method = "GET"
    $req.Timeout = 2500
    $res = $req.GetResponse()
    $ok = ($res.StatusCode -eq 200)
    $res.Close()
    return $ok
  } catch {
    return $false
  }
}

function Open-EditorPage([string]$prefix) {
  try { Start-Process ($prefix + "level-editor.html") } catch {}
}

if (Test-ServerAlive $Port) {
  Write-Host ""
  Write-Host "  Server laeuft bereits auf Port $Port"
  Write-Host "  Editor: ${Prefix}level-editor.html"
  Write-Host ""
  Write-Host "  Kein zweiter Start noetig - Fenster mit laufendem Server offen lassen."
  Write-Host ""
  Open-EditorPage $Prefix
  Read-Host "Enter zum Beenden"
  exit 0
}

$listener = New-Object System.Net.HttpListener
$listener.Prefixes.Add("http://127.0.0.1:$Port/")
$listener.Prefixes.Add("http://localhost:$Port/")

try {
  $listener.Start()
} catch {
  Write-Host ""
  Write-Host "FEHLER: Port $Port ist belegt."
  if (Test-ServerAlive $Port) {
    Write-Host "  Der 4ever-Wild-Server antwortet bereits."
    Write-Host "  Editor: ${Prefix}level-editor.html"
    Open-EditorPage $Prefix
  } else {
    Write-Host "  Port blockiert, aber Server antwortet nicht."
    Write-Host "  stop-local.bat ausfuehren, dann start-local.bat erneut starten."
  }
  Write-Host ""
  Read-Host "Enter zum Beenden"
  exit 1
}

$utf8 = New-Object System.Text.UTF8Encoding $false

function Add-Cors($request, $response) {
  $allowedOrigins = @(
    "http://127.0.0.1:$Port",
    "http://localhost:$Port",
    "http://[::1]:$Port"
  )
  $origin = $request.Headers["Origin"]
  if ([string]::IsNullOrWhiteSpace($origin) -or $origin -eq "null") {
    $response.Headers.Add("Access-Control-Allow-Origin", "*")
  } elseif ($allowedOrigins -contains $origin) {
    $response.Headers.Add("Access-Control-Allow-Origin", $origin)
  } else {
    $response.Headers.Add("Access-Control-Allow-Origin", "http://127.0.0.1:$Port")
  }
  $response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
  $response.Headers.Add("Access-Control-Allow-Headers", "Content-Type")
}

function Test-AllowedHost($request) {
  $hostHeader = $request.Headers["Host"]
  if ([string]::IsNullOrWhiteSpace($hostHeader)) { return $false }
  $hostHeader = $hostHeader.Trim().ToLowerInvariant()
  foreach ($allowed in $AllowedHosts) {
    if ($hostHeader -eq $allowed.ToLowerInvariant()) { return $true }
  }
  return $false
}

function Get-NormalizedLocalPath([string]$localPath) {
  if ([string]::IsNullOrWhiteSpace($localPath)) { return "/" }
  $p = $localPath.Trim()
  if (-not $p.StartsWith("/")) { $p = "/" + $p }
  $q = $p.IndexOf("?")
  if ($q -ge 0) { $p = $p.Substring(0, $q) }
  $h = $p.IndexOf("#")
  if ($h -ge 0) { $p = $p.Substring(0, $h) }
  if ($p.Length -gt 1 -and $p.EndsWith("/")) { $p = $p.TrimEnd("/") }
  return $p
}

function Test-ApiPathAllowed([string]$path, [string]$method) {
  if (-not $ApiPathRules.ContainsKey($path)) { return $false }
  return $ApiPathRules[$path] -contains $method.ToUpperInvariant()
}

function Test-StaticPathWhitelisted([string]$path) {
  if ($path -eq "/" -or [string]::IsNullOrWhiteSpace($path)) {
    return @{ type = "root"; file = "4ever-wild.html" }
  }

  $rel = [Uri]::UnescapeDataString($path.TrimStart("/"))
  if ([string]::IsNullOrWhiteSpace($rel)) {
    return @{ type = "root"; file = "4ever-wild.html" }
  }

  if ($rel -match '\.\.') { return $null }
  if ($rel.StartsWith(".")) { return $null }

  # MP3 aus Nachbarordner: /audio/start.mp3
  if ($rel -match '^audio/([^/\\]+\.mp3)$') {
    $name = $Matches[1]
    if ($name -match '[\\/]|\.\.') { return $null }
    return @{ type = "audio"; file = $name }
  }

  # Nur Dateien direkt im Projektroot (keine Unterordner)
  if ($rel -match '[\\/]') { return $null }
  if ($rel -match $BlockedFilePattern) { return $null }

  $ext = [IO.Path]::GetExtension($rel).ToLowerInvariant()
  if ($AllowedFileExtensions -notcontains $ext) { return $null }

  return @{ type = "root"; file = $rel }
}

function Resolve-StaticFile([string]$relativeFile) {
  $full = [IO.Path]::GetFullPath((Join-Path $Root $relativeFile))
  $rootFull = [IO.Path]::GetFullPath($Root)
  if (-not $full.StartsWith($rootFull, [StringComparison]::OrdinalIgnoreCase)) { return $null }
  if (-not (Test-Path $full -PathType Leaf)) { return $null }
  return $full
}

function Resolve-AudioFile([string]$fileName) {
  if ($fileName -match '[\\/]|\.\.') { return $null }
  foreach ($audioRoot in @($LocalAudioRoot, $LegacyAudioRoot)) {
    if (-not (Test-Path $audioRoot -PathType Container)) { continue }
    $full = [IO.Path]::GetFullPath((Join-Path $audioRoot $fileName))
    $audioFull = [IO.Path]::GetFullPath($audioRoot)
    if (-not $full.StartsWith($audioFull, [StringComparison]::OrdinalIgnoreCase)) { continue }
    if (Test-Path $full -PathType Leaf) { return $full }
  }
  return $null
}

function Resolve-StaticRequest([string]$path) {
  $entry = Test-StaticPathWhitelisted $path
  if (-not $entry) { return $null }
  if ($entry.type -eq "audio") { return Resolve-AudioFile $entry.file }
  return Resolve-StaticFile $entry.file
}

function Send-Plain($response, [int]$statusCode, [string]$body, [string]$contentType) {
  $response.StatusCode = $statusCode
  if ($contentType) { $response.ContentType = $contentType }
  if ($body) {
    $buf = $utf8.GetBytes($body)
    $response.OutputStream.Write($buf, 0, $buf.Length)
  }
}

function Write-PackFiles([string]$jsonText) {
  $levelsPath = Join-Path $Root "levels.json"
  $dataPath = Join-Path $Root "levels-data.js"
  $tmpJson = $levelsPath + ".tmp"
  $tmpJs = $dataPath + ".tmp"
  try {
    [System.IO.File]::WriteAllText($tmpJson, $jsonText, $utf8)
    [System.IO.File]::Copy($tmpJson, $levelsPath, $true)
    if (Test-Path $tmpJson) { [System.IO.File]::Delete($tmpJson) }
    $jsHeader = '/* auto-generated - sync with levels.json */' + [Environment]::NewLine
    $js = $jsHeader + 'window.__4EVER_WILD_PACK__ = ' + $jsonText + ';' + [Environment]::NewLine
    [System.IO.File]::WriteAllText($tmpJs, $js, $utf8)
    [System.IO.File]::Copy($tmpJs, $dataPath, $true)
    if (Test-Path $tmpJs) { [System.IO.File]::Delete($tmpJs) }
    return $true
  } catch {
    if (Test-Path $tmpJson) { try { [System.IO.File]::Delete($tmpJson) } catch {} }
    if (Test-Path $tmpJs) { try { [System.IO.File]::Delete($tmpJs) } catch {} }
    throw
  }
}

function Test-UploadFileName([string]$name) {
  if ([string]::IsNullOrWhiteSpace($name)) { return $false }
  if ($name -match '[\\/]|\.\.') { return $false }
  if ($name -match $BlockedFilePattern) { return $false }
  $ext = [IO.Path]::GetExtension($name).ToLowerInvariant()
  return @(".png", ".jpg", ".jpeg", ".mp3") -contains $ext
}

function Save-UploadedAsset([string]$fileName, [byte[]]$bytes) {
  if (-not (Test-UploadFileName $fileName)) { return $false }
  if ($bytes.Length -gt 52428800) { return $false }
  $ext = [IO.Path]::GetExtension($fileName).ToLowerInvariant()
  if ($ext -eq ".mp3") {
    if (-not (Test-Path $LocalAudioRoot -PathType Container)) {
      New-Item -ItemType Directory -Path $LocalAudioRoot -Force | Out-Null
    }
    $dest = [IO.Path]::GetFullPath((Join-Path $LocalAudioRoot $fileName))
    $audioFull = [IO.Path]::GetFullPath($LocalAudioRoot)
    if (-not $dest.StartsWith($audioFull, [StringComparison]::OrdinalIgnoreCase)) { return $false }
    [IO.File]::WriteAllBytes($dest, $bytes)
    return $true
  }
  $dest = [IO.Path]::GetFullPath((Join-Path $Root $fileName))
  $rootFull = [IO.Path]::GetFullPath($Root)
  if (-not $dest.StartsWith($rootFull, [StringComparison]::OrdinalIgnoreCase)) { return $false }
  [IO.File]::WriteAllBytes($dest, $bytes)
  return $true
}

Write-Host ""
Write-Host "  4ever Wild - lokaler Server laeuft"
Write-Host "  $Prefix"
Write-Host "  Ordner: $Root"
Write-Host "  Sicherheit: Host-Check + Pfad-Whitelist aktiv"
Write-Host ""
Write-Host "  Spiel:   ${Prefix}4ever-wild.html"
Write-Host "  Editor:  ${Prefix}level-editor.html"
Write-Host ""
Write-Host "  Fenster offen lassen. Speichern im Editor schreibt levels.json hierher."
Write-Host ""

try {
  Start-Process "${Prefix}level-editor.html"
} catch {}

while ($listener.IsListening) {
  $ctx = $listener.GetContext()
  $req = $ctx.Request
  $res = $ctx.Response
  Add-Cors $req $res

  try {
    if (-not (Test-AllowedHost $req)) {
      $badHost = $req.Headers["Host"]
      Write-Host "[$(Get-Date -Format 'HH:mm:ss')] ABGELEHNT - ungueltiger Host: $badHost"
      Send-Plain $res 403 '{"error":"forbidden host"}' "application/json"
      $res.Close()
      continue
    }

    $path = Get-NormalizedLocalPath $req.Url.LocalPath
    $method = $req.HttpMethod.ToUpperInvariant()

    if ($method -eq "OPTIONS") {
      if (Test-ApiPathAllowed $path "OPTIONS") {
        $res.StatusCode = 204
      } else {
        $res.StatusCode = 204
      }
    }
    elseif ($path -eq "/ping" -and $method -eq "GET") {
      if (-not (Test-ApiPathAllowed $path $method)) {
        Send-Plain $res 403 '{"error":"forbidden"}' "application/json"
      } else {
        Send-Plain $res 200 '{"ok":true}' "application/json"
      }
    }
    elseif ($path -eq "/levels.json" -and $method -eq "POST") {
      if (-not (Test-ApiPathAllowed $path $method)) {
        Send-Plain $res 403 '{"error":"forbidden"}' "application/json"
      } else {
        try {
          $ms = New-Object System.IO.MemoryStream
          $req.InputStream.CopyTo($ms)
          $body = $utf8.GetString($ms.ToArray())
          $ms.Close()
          Write-PackFiles $body
          Send-Plain $res 200 '{"ok":true}' "application/json"
          Write-Host "[$(Get-Date -Format 'HH:mm:ss')] levels.json gespeichert"
        } catch {
          Write-Host "[$(Get-Date -Format 'HH:mm:ss')] Speichern fehlgeschlagen: $($_.Exception.Message)"
          Send-Plain $res 500 ('{"error":"' + ($_.Exception.Message -replace '"', "'") + '"}') "application/json"
        }
      }
    }
    elseif ($path -eq "/upload-asset" -and $method -eq "POST") {
      if (-not (Test-ApiPathAllowed $path $method)) {
        Send-Plain $res 403 '{"error":"forbidden"}' "application/json"
      } else {
        $rawName = $req.QueryString["name"]
        $fileName = [IO.Path]::GetFileName([Uri]::UnescapeDataString($rawName))
        $ms = New-Object System.IO.MemoryStream
        $req.InputStream.CopyTo($ms)
        $bytes = $ms.ToArray()
        $ms.Close()
        if (Save-UploadedAsset $fileName $bytes) {
          Send-Plain $res 200 ('{"ok":true,"file":"' + $fileName + '"}') "application/json"
          Write-Host "[$(Get-Date -Format 'HH:mm:ss')] Asset gespeichert: $fileName"
        } else {
          Send-Plain $res 400 '{"error":"invalid upload"}' "application/json"
        }
      }
    }
    elseif ($method -eq "GET") {
      if ($path -eq "/levels.json") {
        if (-not (Test-ApiPathAllowed $path $method)) {
          Send-Plain $res 403 '{"error":"forbidden"}' "application/json"
        } else {
          $file = Resolve-StaticFile "levels.json"
          if ($file) {
            $res.StatusCode = 200
            $res.ContentType = "application/json; charset=utf-8"
            $bytes = [IO.File]::ReadAllBytes($file)
            $res.OutputStream.Write($bytes, 0, $bytes.Length)
          } else {
            $res.StatusCode = 404
          }
        }
      } else {
        $file = Resolve-StaticRequest $path
        if ($file) {
          $ext = [IO.Path]::GetExtension($file).ToLowerInvariant()
          $res.ContentType = $Mime[$ext]
          if (-not $res.ContentType) { $res.ContentType = "application/octet-stream" }
          $res.StatusCode = 200
          $bytes = [IO.File]::ReadAllBytes($file)
          $res.OutputStream.Write($bytes, 0, $bytes.Length)
        } else {
          if (Test-StaticPathWhitelisted $path) {
            $res.StatusCode = 404
          } else {
            Write-Host "[$(Get-Date -Format 'HH:mm:ss')] ABGELEHNT - Pfad nicht erlaubt: $path"
            $res.StatusCode = 404
          }
        }
      }
    }
    else {
      $res.StatusCode = 405
    }
  } catch {
    Write-Host "Fehler: $($_.Exception.Message)"
    $res.StatusCode = 500
  }

  $res.Close()
}
