param(
  [ValidateSet('auto','nvidia','amd','intel','cpu')]
  [string]$Gpu = 'auto',
  [switch]$InstallModel,
  [switch]$EnableAutostart,
  [switch]$StartNow,
  [switch]$Force,
  [switch]$UpdateOnly,
  [switch]$NonInteractive
)

$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$VendooDir = Split-Path -Parent $ScriptDir
$LocalAiDir = Join-Path $VendooDir 'local-ai'
$InstallRoot = Join-Path $LocalAiDir 'comfyui'
$PortableRoot = Join-Path $InstallRoot 'ComfyUI_windows_portable'
$ComfyDir = Join-Path $PortableRoot 'ComfyUI'
$EmbeddedPython = Join-Path $PortableRoot 'python_embeded\python.exe'
$ModelDir = Join-Path $ComfyDir 'models\checkpoints'
$ModelPath = Join-Path $ModelDir 'flux1-schnell-fp8.safetensors'
$WorkflowDir = Join-Path $LocalAiDir 'workflows'
$WorkflowPath = Join-Path $WorkflowDir 'vendoo_flux_schnell_api.json'
$LogDir = Join-Path $VendooDir 'logs'
$LogFile = Join-Path $LogDir 'local-flux-install.log'
$StateFile = Join-Path $LocalAiDir 'install-state.json'
$EnvFile = Join-Path $VendooDir '.env'
$DownloadDir = Join-Path $LocalAiDir 'downloads'
$StartScript = Join-Path $LocalAiDir 'start-local-flux.ps1'
$StopScript = Join-Path $LocalAiDir 'stop-local-flux.ps1'
$TaskName = 'Vendoo Local FLUX'
$ComfyPort = 8188
$ComfyUrl = "http://127.0.0.1:$ComfyPort"
$GitHubApi = 'https://api.github.com/repos/Comfy-Org/ComfyUI/releases/latest'
$ModelUrl = 'https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell-fp8.safetensors?download=true'

$InstallStartedAt = Get-Date
$LastConsoleProgressAt = [DateTime]::MinValue

New-Item -ItemType Directory -Force -Path $LocalAiDir,$InstallRoot,$WorkflowDir,$LogDir,$DownloadDir | Out-Null

function Write-Utf8NoBom([string]$Path, [string]$Content) {
  $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
  [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom)
}


function Write-Utf8Bom([string]$Path, [string]$Content) {
  $utf8Bom = New-Object System.Text.UTF8Encoding($true)
  [System.IO.File]::WriteAllText($Path, $Content, $utf8Bom)
}

function Write-Log([string]$Message, [string]$Level = 'INFO') {
  $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message"
  Add-Content -Path $LogFile -Value $line -Encoding utf8
  if (-not $NonInteractive) {
    $color = 'Gray'
    if ($Level -eq 'OK') { $color = 'Green' }
    elseif ($Level -eq 'WARN') { $color = 'Yellow' }
    elseif ($Level -eq 'ERROR') { $color = 'Red' }
    Write-Host $line -ForegroundColor $color
  }
}

function Save-State([string]$Status, [string]$Phase, [int]$Progress, [string]$Message, [hashtable]$Extra) {
  $previous = $null
  if (Test-Path -LiteralPath $StateFile) {
    try { $previous = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json } catch {}
  }
  $startedAt = if ($Phase -eq 'preflight' -and $Status -eq 'running') { $InstallStartedAt.ToString('o') } elseif ($previous -and $previous.started_at) { [string]$previous.started_at } else { $InstallStartedAt.ToString('o') }
  $state = [ordered]@{
    status = $Status
    phase = $Phase
    progress = [math]::Max(0, [math]::Min(100, $Progress))
    message = $Message
    started_at = $startedAt
    updated_at = (Get-Date).ToString('o')
    heartbeat_at = (Get-Date).ToString('o')
    process_id = $PID
    elapsed_seconds = [math]::Round(((Get-Date) - ([DateTime]::Parse($startedAt))).TotalSeconds)
    install_root = $InstallRoot
    comfy_url = $ComfyUrl
    model_path = $ModelPath
    workflow_path = $WorkflowPath
    log_file = $LogFile
    requested_install_model = [bool]$InstallModel
    requested_enable_autostart = [bool]$EnableAutostart
    requested_start_now = [bool]$StartNow
  }
  if ($Extra) {
    foreach ($key in $Extra.Keys) { $state[$key] = $Extra[$key] }
  }
  $tempState = "$StateFile.tmp"
  Write-Utf8NoBom -Path $tempState -Content ($state | ConvertTo-Json -Depth 6)
  Move-Item -LiteralPath $tempState -Destination $StateFile -Force
}

function Set-EnvVar([string]$Key, [string]$Value) {
  $content = if (Test-Path $EnvFile) { Get-Content $EnvFile -Raw } else { '' }
  $line = "$Key=$Value"
  if ($content -match "(?m)^$([regex]::Escape($Key))=") {
    $content = [regex]::Replace($content, "(?m)^$([regex]::Escape($Key))=.*$", $line)
  } else {
    $content = $content.TrimEnd() + "`r`n" + $line + "`r`n"
  }
  Write-Utf8NoBom -Path $EnvFile -Content $content
}

function Get-EnvValue([string]$Key) {
  if (-not (Test-Path $EnvFile)) { return '' }
  $content = Get-Content $EnvFile -Raw
  $match = [regex]::Match($content, "(?m)^$([regex]::Escape($Key))=(.*)$")
  if ($match.Success) { return $match.Groups[1].Value.Trim() }
  return ''
}

function New-LocalToken {
  $bytes = New-Object byte[] 32
  [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
  return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+','-').Replace('/','_')
}

function Test-Command([string]$Name) {
  return $null -ne (Get-Command $Name -ErrorAction SilentlyContinue)
}

function Get-GpuType {
  if ($Gpu -ne 'auto') { return $Gpu }
  try {
    $names = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | ForEach-Object { $_.Name })
    $joined = ($names -join ' ').ToLowerInvariant()
    if ($joined -match 'nvidia|geforce|quadro|rtx|gtx') { return 'nvidia' }
    if ($joined -match 'amd|radeon') { return 'amd' }
    if ($joined -match 'intel') { return 'intel' }
  } catch {}
  return 'cpu'
}

function Get-FreeSpaceGb([string]$Path) {
  try {
    $resolved = (Resolve-Path -LiteralPath $Path).Path
    $root = [System.IO.Path]::GetPathRoot($resolved)
    $drive = New-Object System.IO.DriveInfo -ArgumentList $root
    return [math]::Round($drive.AvailableFreeSpace / 1GB, 1)
  } catch {
    Write-Log "Freier Speicher konnte nicht ermittelt werden: $($_.Exception.Message)" 'WARN'
    return 0
  }
}

function Get-7ZipCandidates {
  $items = New-Object System.Collections.Generic.List[string]
  try {
    $command = Get-Command 7z.exe -ErrorAction SilentlyContinue
    if ($command -and $command.Source) { [void]$items.Add([string]$command.Source) }
  } catch {}

  $known = New-Object System.Collections.Generic.List[string]
  if ($env:ProgramFiles) { [void]$known.Add((Join-Path $env:ProgramFiles '7-Zip\7z.exe')) }
  if (${env:ProgramFiles(x86)}) { [void]$known.Add((Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe')) }
  if ($env:LOCALAPPDATA) { [void]$known.Add((Join-Path $env:LOCALAPPDATA 'Programs\7-Zip\7z.exe')) }
  foreach ($path in $known) {
    if ($path -and (Test-Path -LiteralPath $path)) { [void]$items.Add([string]$path) }
  }
  return @($items | Select-Object -Unique)
}

function Refresh-ProcessPath {
  $machine = [System.Environment]::GetEnvironmentVariable('Path', 'Machine')
  $user = [System.Environment]::GetEnvironmentVariable('Path', 'User')
  $env:Path = "$machine;$user"
}

function Ensure-7Zip {
  Save-State 'running' 'dependency-7zip' 24 '7-Zip wird geprüft.' @{}
  $candidates = @(Get-7ZipCandidates)
  if ($candidates.Count -gt 0) {
    $path = [string]$candidates[0]
    Write-Log "7-Zip gefunden: $path" 'OK'
    return $path
  }

  if (Test-Command 'winget.exe') {
    Write-Log '7-Zip fehlt. Installation über winget wird gestartet.' 'WARN'
    Save-State 'running' 'install-7zip' 25 '7-Zip wird automatisch installiert.' @{}
    & winget.exe install --id 7zip.7zip -e --silent --accept-source-agreements --accept-package-agreements | Out-Null
    $wingetExit = $LASTEXITCODE
    Refresh-ProcessPath
    Start-Sleep -Seconds 2
    $candidates = @(Get-7ZipCandidates)
    if ($candidates.Count -gt 0) {
      $path = [string]$candidates[0]
      Write-Log "7-Zip installiert und gefunden: $path" 'OK'
      return $path
    }
    Write-Log "winget beendete die 7-Zip-Installation mit Exit-Code $wingetExit, aber 7z.exe wurde nicht gefunden." 'WARN'
  }

  throw '7-Zip ist erforderlich und konnte nicht automatisch installiert oder gefunden werden. Installiere 7-Zip und starte die FLUX-Installation danach erneut.'
}

function Get-DirectoryMetrics([string]$Path) {
  if (-not (Test-Path -LiteralPath $Path)) {
    return @{ file_count = 0; bytes = [long]0; megabytes = 0 }
  }
  try {
    $files = @(Get-ChildItem -LiteralPath $Path -Recurse -File -Force -ErrorAction SilentlyContinue)
    $bytes = [long](($files | Measure-Object -Property Length -Sum).Sum)
    return @{
      file_count = $files.Count
      bytes = $bytes
      megabytes = [math]::Round($bytes / 1MB, 1)
    }
  } catch {
    return @{ file_count = 0; bytes = [long]0; megabytes = 0 }
  }
}

function Stop-Stale7ZipForArchive([string]$ArchivePath, [string]$Destination) {
  try {
    $archiveEscaped = [regex]::Escape($ArchivePath)
    $destinationEscaped = [regex]::Escape($Destination)
    $items = @(Get-CimInstance Win32_Process -Filter "Name='7z.exe' OR Name='7zG.exe'" -ErrorAction SilentlyContinue |
      Where-Object { $_.CommandLine -and ($_.CommandLine -match $archiveEscaped -or $_.CommandLine -match $destinationEscaped) })
    foreach ($item in $items) {
      Write-Log "Beende veralteten 7-Zip-Prozess PID $($item.ProcessId) für diese Installation." 'WARN'
      Stop-Process -Id $item.ProcessId -Force -ErrorAction SilentlyContinue
    }
  } catch {
    Write-Log "Veraltete 7-Zip-Prozesse konnten nicht geprüft werden: $($_.Exception.Message)" 'WARN'
  }
}

function Invoke-7ZipExtract([string]$SevenZipPath, [string]$ArchivePath, [string]$Destination) {
  if (-not (Test-Path -LiteralPath $SevenZipPath)) { throw "7-Zip wurde nicht gefunden: $SevenZipPath" }
  if (-not (Test-Path -LiteralPath $ArchivePath)) { throw "ComfyUI-Archiv wurde nicht gefunden: $ArchivePath" }

  Stop-Stale7ZipForArchive -ArchivePath $ArchivePath -Destination $Destination
  $stdoutFile = Join-Path $LogDir 'local-flux-7zip-output.log'
  $stderrFile = Join-Path $LogDir 'local-flux-7zip-error.log'
  Remove-Item -LiteralPath $stdoutFile,$stderrFile -Force -ErrorAction SilentlyContinue

  $arguments = "x `"$ArchivePath`" -o`"$Destination`" -y -bsp1 -bso1 -bse1"
  Write-Log "Starte 7-Zip-Entpackung: `"$SevenZipPath`" x `"$ArchivePath`" -o`"$Destination`"" 'INFO'
  Save-State 'running' 'extract-comfyui' 33 '7-Zip wurde gestartet. ComfyUI wird entpackt.' @{ seven_zip = $SevenZipPath; archive = $ArchivePath; extract_root = $Destination; extract_files = 0; extract_mb = 0 }

  $process = Start-Process -FilePath $SevenZipPath -ArgumentList $arguments -WorkingDirectory $DownloadDir -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile
  if (-not $process) { throw '7-Zip-Prozess konnte nicht gestartet werden.' }

  $started = Get-Date
  $lastReportedFiles = -1
  $lastActivity = Get-Date
  $timeoutMinutes = 45

  while (-not $process.HasExited) {
    Start-Sleep -Seconds 5
    $process.Refresh()
    $metrics = Get-DirectoryMetrics $Destination
    if ($metrics.file_count -ne $lastReportedFiles) {
      $lastReportedFiles = $metrics.file_count
      $lastActivity = Get-Date
    }
    $elapsed = [math]::Round(((Get-Date) - $started).TotalSeconds)
    $progress = 34 + [math]::Min(16, [math]::Floor($elapsed / 20))
    $message = "ComfyUI wird entpackt: $($metrics.file_count) Dateien · $($metrics.megabytes) MB · ${elapsed}s"
    Save-State 'running' 'extract-comfyui' $progress $message @{
      seven_zip = $SevenZipPath
      archive = $ArchivePath
      extract_root = $Destination
      extract_files = $metrics.file_count
      extract_bytes = [long]$metrics.bytes
      extract_mb = $metrics.megabytes
      extract_elapsed_seconds = $elapsed
      extract_process_id = $process.Id
      extract_stdout = $stdoutFile
      extract_stderr = $stderrFile
    }
    if (-not $NonInteractive -and ((Get-Date) - $script:LastConsoleProgressAt).TotalSeconds -ge 10) {
      Write-Host "  [..] $message" -ForegroundColor Cyan
      $script:LastConsoleProgressAt = Get-Date
    }
    if (((Get-Date) - $started).TotalMinutes -gt $timeoutMinutes) {
      try { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } catch {}
      throw "7-Zip-Entpackung wurde nach $timeoutMinutes Minuten wegen Zeitüberschreitung abgebrochen."
    }
    if (((Get-Date) - $lastActivity).TotalMinutes -gt 3 -and $metrics.file_count -eq 0) {
      try { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } catch {}
      throw '7-Zip zeigt seit 3 Minuten keine Extraktionsaktivität. Prüfe Archiv, Virenscanner und freien Speicher.'
    }
  }

  $process.WaitForExit()
  $process.Refresh()

  $rawExitCode = $null
  try {
    $rawExitCode = $process.ExitCode
  } catch {
    Write-Log "7-Zip Exit-Code konnte über das Process-Objekt nicht direkt gelesen werden: $($_.Exception.Message)" 'WARN'
  }

  $stdoutTail = if (Test-Path -LiteralPath $stdoutFile) { (Get-Content -LiteralPath $stdoutFile -Tail 20 -ErrorAction SilentlyContinue | Out-String).Trim() } else { '' }
  $stderrTail = if (Test-Path -LiteralPath $stderrFile) { (Get-Content -LiteralPath $stderrFile -Tail 20 -ErrorAction SilentlyContinue | Out-String).Trim() } else { '' }
  $finalMetrics = Get-DirectoryMetrics $Destination

  $exitCode = $rawExitCode
  if ($null -eq $exitCode) {
    $hasSuccessMarker = $stdoutTail -match '(?im)^Everything is Ok\s*$'
    $hasPlausibleResult = ($finalMetrics.file_count -ge 20 -and $finalMetrics.bytes -ge 50MB)
    $hasErrorOutput = -not [string]::IsNullOrWhiteSpace($stderrTail)

    if ($hasSuccessMarker -and $hasPlausibleResult -and -not $hasErrorOutput) {
      $exitCode = 0
      Write-Log "7-Zip Exit-Code war unter Windows PowerShell 5.1 leer. Erfolg wurde sicher über 'Everything is Ok', leeres Fehlerprotokoll und $($finalMetrics.file_count) Dateien / $($finalMetrics.megabytes) MB bestätigt." 'WARN'
    } else {
      $details = (($stderrTail, $stdoutTail | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ' | ')
      throw "7-Zip-Prozess wurde beendet, aber der Exit-Code konnte nicht ermittelt werden und die Erfolgsprüfung war nicht eindeutig. $details"
    }
  }

  if ([int]$exitCode -ne 0) {
    $details = (($stderrTail, $stdoutTail | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ' | ')
    throw "ComfyUI-Archiv konnte nicht entpackt werden (7-Zip Exit-Code $exitCode). $details"
  }
  if ($finalMetrics.file_count -lt 20 -or $finalMetrics.bytes -lt 50MB) {
    throw "7-Zip meldete Erfolg, aber das Entpackungsergebnis ist unvollständig ($($finalMetrics.file_count) Dateien, $($finalMetrics.megabytes) MB)."
  }

  Save-State 'running' 'extract-comfyui' 51 "ComfyUI wurde entpackt: $($finalMetrics.file_count) Dateien · $($finalMetrics.megabytes) MB." @{
    seven_zip = $SevenZipPath
    archive = $ArchivePath
    extract_root = $Destination
    extract_files = $finalMetrics.file_count
    extract_bytes = [long]$finalMetrics.bytes
    extract_mb = $finalMetrics.megabytes
    extract_exit_code = $exitCode
  }
  Write-Log "7-Zip-Entpackung abgeschlossen: $($finalMetrics.file_count) Dateien, $($finalMetrics.megabytes) MB." 'OK'
}

function Get-LatestPortableAsset([string]$GpuType) {
  Write-Log 'Frage aktuelle ComfyUI-Portable-Version über GitHub API ab.'
  $release = Invoke-RestMethod -Uri $GitHubApi -Headers @{ 'User-Agent' = 'Vendoo-Local-Flux-Installer' }
  $assets = @($release.assets)
  if ($assets.Count -eq 0) { throw 'Die aktuelle ComfyUI-Version enthält keine Download-Assets.' }

  $patterns = @()
  switch ($GpuType) {
    'nvidia' { $patterns = @('windows_portable.*nvidia.*\.7z$','windows_portable.*cu13.*\.7z$','windows_portable.*\.7z$') }
    'amd'    { $patterns = @('windows_portable.*amd.*\.7z$','windows_portable.*\.7z$') }
    'intel'  { $patterns = @('windows_portable.*intel.*\.7z$','windows_portable.*\.7z$') }
    default  { $patterns = @('windows_portable.*\.7z$') }
  }

  foreach ($pattern in $patterns) {
    $asset = $assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1
    if ($asset) { return $asset }
  }
  throw "Kein passendes ComfyUI-Windows-Portable-Paket für GPU-Typ '$GpuType' gefunden."
}

function Format-DownloadDuration([double]$Seconds) {
  if ($Seconds -lt 0 -or [double]::IsInfinity($Seconds) -or [double]::IsNaN($Seconds)) { return 'unbekannt' }
  $span = [TimeSpan]::FromSeconds([math]::Round($Seconds))
  if ($span.TotalHours -ge 1) { return ('{0}h {1}m' -f [math]::Floor($span.TotalHours), $span.Minutes) }
  if ($span.TotalMinutes -ge 1) { return ('{0}m {1}s' -f [math]::Floor($span.TotalMinutes), $span.Seconds) }
  return ('{0}s' -f [math]::Max(0, $span.Seconds))
}

function Remove-StaleVendooBitsJobs {
  try {
    Import-Module BitsTransfer -ErrorAction SilentlyContinue
    $jobs = @(Get-BitsTransfer -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like 'Vendoo *' })
    foreach ($job in $jobs) {
      try { Remove-BitsTransfer -BitsJob $job -Confirm:$false -ErrorAction SilentlyContinue } catch {}
    }
    if ($jobs.Count -gt 0) {
      Write-Log "$($jobs.Count) veraltete(r) Vendoo-BITS-Auftrag/Aufträge wurden entfernt." 'WARN'
    }
  } catch {}
}

function Invoke-ResumableHttpDownload(
  [string]$Url,
  [string]$PartialPath,
  [string]$Label,
  [string]$Phase,
  [int]$ProgressStart,
  [int]$ProgressEnd
) {
  Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue

  $maxAttempts = 5
  for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
    $handler = $null
    $client = $null
    $request = $null
    $response = $null
    $networkStream = $null
    $fileStream = $null

    try {
      $resumeFrom = 0L
      if (Test-Path -LiteralPath $PartialPath) {
        $resumeFrom = [long](Get-Item -LiteralPath $PartialPath).Length
      }

      $handler = [System.Net.Http.HttpClientHandler]::new()
      $handler.AllowAutoRedirect = $true
      $handler.AutomaticDecompression = [System.Net.DecompressionMethods]::None
      $client = [System.Net.Http.HttpClient]::new($handler)
      $client.Timeout = [TimeSpan]::FromHours(24)

      $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $Url)
      [void]$request.Headers.TryAddWithoutValidation('User-Agent', 'Vendoo-Local-Flux-Installer/1.14.5')
      if ($resumeFrom -gt 0) {
        $request.Headers.Range = [System.Net.Http.Headers.RangeHeaderValue]::Parse("bytes=$resumeFrom-")
        Write-Log "$Label wird ab $([math]::Round($resumeFrom / 1MB, 1)) MB fortgesetzt." 'OK'
      }

      $response = $client.SendAsync($request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult()
      $statusCode = [int]$response.StatusCode

      if (-not $response.IsSuccessStatusCode) {
        throw "HTTP-Fehler $statusCode ($($response.ReasonPhrase))."
      }

      $append = ($resumeFrom -gt 0 -and $statusCode -eq 206)
      if ($resumeFrom -gt 0 -and -not $append) {
        Write-Log 'Der Downloadserver unterstützt an dieser Stelle keine Fortsetzung. Die Teildatei wird kontrolliert neu begonnen.' 'WARN'
        Remove-Item -LiteralPath $PartialPath -Force -ErrorAction SilentlyContinue
        $resumeFrom = 0L
      }

      $remainingLength = 0L
      if ($null -ne $response.Content.Headers.ContentLength) {
        $remainingLength = [long]$response.Content.Headers.ContentLength
      }
      $totalBytes = if ($remainingLength -gt 0) { $resumeFrom + $remainingLength } else { 0L }

      $networkStream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult()
      $mode = if ($append) { [System.IO.FileMode]::Append } else { [System.IO.FileMode]::Create }
      $fileStream = [System.IO.File]::Open($PartialPath, $mode, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read)

      $buffer = New-Object byte[] (1MB)
      $downloaded = $resumeFrom
      $lastSampleBytes = $downloaded
      $lastSampleAt = Get-Date
      $lastStateAt = [DateTime]::MinValue
      $lastConsoleAt = [DateTime]::MinValue
      $smoothedBytesPerSecond = 0.0

      while (($read = $networkStream.Read($buffer, 0, $buffer.Length)) -gt 0) {
        $fileStream.Write($buffer, 0, $read)
        $downloaded += $read
        $now = Get-Date

        if (($now - $lastSampleAt).TotalSeconds -ge 2) {
          $sampleSeconds = [math]::Max(0.1, ($now - $lastSampleAt).TotalSeconds)
          $sampleSpeed = ($downloaded - $lastSampleBytes) / $sampleSeconds
          if ($smoothedBytesPerSecond -le 0) { $smoothedBytesPerSecond = $sampleSpeed }
          else { $smoothedBytesPerSecond = ($smoothedBytesPerSecond * 0.65) + ($sampleSpeed * 0.35) }
          $lastSampleBytes = $downloaded
          $lastSampleAt = $now
        }

        if (($now - $lastStateAt).TotalSeconds -ge 2) {
          $percent = 0
          if ($totalBytes -gt 0) {
            $percent = [math]::Min(100, [math]::Round(($downloaded / $totalBytes) * 100, 1))
          }
          $overall = $ProgressStart
          if ($totalBytes -gt 0) {
            $overall = $ProgressStart + [math]::Round((($ProgressEnd - $ProgressStart) * $percent) / 100)
          }
          $downloadedMb = [math]::Round($downloaded / 1MB, 1)
          $totalMb = if ($totalBytes -gt 0) { [math]::Round($totalBytes / 1MB, 1) } else { 0 }
          $speedMbps = [math]::Round($smoothedBytesPerSecond / 1MB, 1)
          $etaSeconds = -1
          if ($totalBytes -gt 0 -and $smoothedBytesPerSecond -gt 0) {
            $etaSeconds = [math]::Max(0, ($totalBytes - $downloaded) / $smoothedBytesPerSecond)
          }
          $etaText = Format-DownloadDuration $etaSeconds
          $message = if ($totalBytes -gt 0) {
            "${Label}: $percent% · $downloadedMb / $totalMb MB · $speedMbps MB/s · Rest ca. $etaText"
          } else {
            "${Label}: $downloadedMb MB · $speedMbps MB/s · Gesamtgröße wird ermittelt"
          }

          Save-State 'running' $Phase $overall $message @{
            download_percent = $percent
            bytes_transferred = [long]$downloaded
            bytes_total = [long]$totalBytes
            download_speed_mbps = $speedMbps
            eta_seconds = [math]::Round($etaSeconds)
            download_transport = 'http-stream-resumable'
            download_attempt = $attempt
            partial_path = $PartialPath
          }

          if (-not $NonInteractive) {
            $progressPercent = if ($totalBytes -gt 0) { [int][math]::Min(100, [math]::Round($percent)) } else { 0 }
            Write-Progress -Activity "$Label wird heruntergeladen" -Status $message -PercentComplete $progressPercent
            if (($now - $lastConsoleAt).TotalSeconds -ge 10) {
              Write-Host "  [DOWNLOAD] $message" -ForegroundColor Cyan
              $lastConsoleAt = $now
            }
          }
          $lastStateAt = $now
        }
      }

      $fileStream.Flush()
      if (-not $NonInteractive) {
        Write-Progress -Activity "$Label wird heruntergeladen" -Completed
      }
      return
    } catch {
      $errorText = $_.Exception.Message
      if ($attempt -ge $maxAttempts) {
        throw "$Label konnte nach $maxAttempts Versuchen nicht vollständig geladen werden. Letzter Fehler: $errorText"
      }

      $currentBytes = 0L
      if (Test-Path -LiteralPath $PartialPath) {
        try { $currentBytes = [long](Get-Item -LiteralPath $PartialPath).Length } catch {}
      }
      Write-Log "$Label-Download wurde unterbrochen: $errorText. Automatische Fortsetzung in 5 Sekunden (bereits $([math]::Round($currentBytes / 1MB, 1)) MB)." 'WARN'
      Save-State 'running' $Phase $ProgressStart "$Label wird nach einer Netzwerkunterbrechung automatisch fortgesetzt." @{
        bytes_transferred = $currentBytes
        download_transport = 'http-stream-resumable'
        download_attempt = $attempt
        retry_in_seconds = 5
      }
      Start-Sleep -Seconds 5
    } finally {
      if ($fileStream) { try { $fileStream.Dispose() } catch {} }
      if ($networkStream) { try { $networkStream.Dispose() } catch {} }
      if ($response) { try { $response.Dispose() } catch {} }
      if ($request) { try { $request.Dispose() } catch {} }
      if ($client) { try { $client.Dispose() } catch {} }
      if ($handler) { try { $handler.Dispose() } catch {} }
    }
  }
}

function Download-File([string]$Url, [string]$Destination, [string]$Label, [long]$MinimumBytes = 1) {
  $partial = "$Destination.part"

  if (Test-Path -LiteralPath $Destination) {
    $size = [long](Get-Item -LiteralPath $Destination).Length
    if ($size -ge $MinimumBytes) {
      Write-Log "$Label bereits vollständig vorhanden; Download wird übersprungen." 'OK'
      return
    }

    Write-Log "$Label ist unvollständig. Bereits vorhandene Daten werden für die Fortsetzung übernommen." 'WARN'
    if (-not (Test-Path -LiteralPath $partial) -or (Get-Item -LiteralPath $partial).Length -lt $size) {
      Move-Item -LiteralPath $Destination -Destination $partial -Force
    } else {
      Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue
    }
  }

  Remove-StaleVendooBitsJobs

  $existingBytes = 0L
  if (Test-Path -LiteralPath $partial) {
    $existingBytes = [long](Get-Item -LiteralPath $partial).Length
  }

  $phase = if ($Label -like 'FLUX*') { 'download-model' } else { 'download-comfyui' }
  $progressStart = if ($phase -eq 'download-model') { 65 } else { 10 }
  $progressEnd = if ($phase -eq 'download-model') { 85 } else { 23 }

  if ($existingBytes -gt 0) {
    Write-Log "$Label-Teildownload gefunden: $([math]::Round($existingBytes / 1MB, 1)) MB. Download wird fortgesetzt." 'OK'
  } else {
    Write-Log "$Label wird mit sichtbarem Fortschritt heruntergeladen."
  }

  Save-State 'running' $phase $progressStart "$Label-Download wird vorbereitet." @{
    download_percent = 0
    bytes_transferred = $existingBytes
    download_transport = 'http-stream-resumable'
    partial_path = $partial
  }

  Invoke-ResumableHttpDownload -Url $Url -PartialPath $partial -Label $Label -Phase $phase -ProgressStart $progressStart -ProgressEnd $progressEnd

  if (-not (Test-Path -LiteralPath $partial)) { throw "$Label wurde nicht heruntergeladen." }
  $downloadedSize = [long](Get-Item -LiteralPath $partial).Length
  if ($downloadedSize -lt $MinimumBytes) {
    throw "$Label ist mit $downloadedSize Bytes unerwartet klein und wahrscheinlich unvollständig. Die Teildatei bleibt für eine Fortsetzung erhalten."
  }

  Move-Item -LiteralPath $partial -Destination $Destination -Force
  Save-State 'running' $phase $progressEnd "$Label vollständig heruntergeladen." @{
    download_percent = 100
    bytes_transferred = $downloadedSize
    bytes_total = $downloadedSize
    download_transport = 'http-stream-resumable'
  }
  Write-Log "$Label heruntergeladen ($([math]::Round($downloadedSize / 1MB, 1)) MB)." 'OK'
}

function Write-Workflow {
  $workflow = @'
{
  "3": { "inputs": { "ckpt_name": "flux1-schnell-fp8.safetensors" }, "class_type": "CheckpointLoaderSimple" },
  "4": { "inputs": { "text": "__PROMPT__", "clip": ["3", 1] }, "class_type": "CLIPTextEncode" },
  "5": { "inputs": { "text": "__NEGATIVE_PROMPT__", "clip": ["3", 1] }, "class_type": "CLIPTextEncode" },
  "6": { "inputs": { "width": 1024, "height": 1024, "batch_size": 1 }, "class_type": "EmptyLatentImage" },
  "7": { "inputs": { "seed": "__SEED__", "steps": 4, "cfg": 1, "sampler_name": "euler", "scheduler": "simple", "denoise": 1, "model": ["3", 0], "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0] }, "class_type": "KSampler" },
  "8": { "inputs": { "samples": ["7", 0], "vae": ["3", 2] }, "class_type": "VAEDecode" },
  "9": { "inputs": { "filename_prefix": "Vendoo/flux_schnell", "images": ["8", 0] }, "class_type": "SaveImage" }
}
'@
  Write-Utf8NoBom -Path $WorkflowPath -Content $workflow
  Write-Log 'FLUX-Prompt-Workflow wurde erstellt.' 'OK'
}

function Write-StartStopScripts {
  $runtimeStartTemplate = Join-Path $ScriptDir 'runtime-start-local-flux.ps1'
  $runtimeStopTemplate = Join-Path $ScriptDir 'runtime-stop-local-flux.ps1'
  if (-not (Test-Path -LiteralPath $runtimeStartTemplate)) {
    throw "FLUX-Runtime-Startvorlage fehlt: $runtimeStartTemplate"
  }
  if (-not (Test-Path -LiteralPath $runtimeStopTemplate)) {
    throw "FLUX-Runtime-Stoppvorlage fehlt: $runtimeStopTemplate"
  }
  Copy-Item -LiteralPath $runtimeStartTemplate -Destination $StartScript -Force
  Copy-Item -LiteralPath $runtimeStopTemplate -Destination $StopScript -Force
  Write-Log 'Gehärtete FLUX-Runtime-Skripte wurden installiert.' 'OK'
}

function Register-AutostartTask {
  if (-not $EnableAutostart) { return }
  $taskCommand = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$StartScript`""
  & schtasks.exe /Create /SC ONLOGON /TN $TaskName /TR $taskCommand /F | Out-Null
  if ($LASTEXITCODE -ne 0) { throw 'Autostart-Aufgabe konnte nicht erstellt werden.' }
  Write-Log 'Autostart-Aufgabe für lokalen FLUX-Server erstellt.' 'OK'
}

function Test-ComfyReachable {
  try {
    $response = Invoke-WebRequest -UseBasicParsing -Uri "$ComfyUrl/system_stats" -TimeoutSec 5
    return $response.StatusCode -ge 200 -and $response.StatusCode -lt 300
  } catch { return $false }
}

try {
  Save-State 'running' 'preflight' 2 'Vorprüfung läuft.' @{}
  $gpuType = Get-GpuType
  $freeGb = Get-FreeSpaceGb $LocalAiDir
  Write-Log "Erkannte GPU-Klasse: $gpuType"
  Write-Log "Freier Speicher: $freeGb GB"
  if ($InstallModel -and $freeGb -lt 28 -and -not $Force) {
    throw 'Für ComfyUI plus FLUX.1-schnell FP8 werden mindestens 28 GB freier Speicher empfohlen.'
  }

  $alreadyInstalled = (Test-Path $EmbeddedPython) -and (Test-Path (Join-Path $ComfyDir 'main.py'))
  if ($alreadyInstalled -and -not $Force -and -not $UpdateOnly) {
    Write-Log 'ComfyUI ist bereits installiert. Neuinstallation wird übersprungen.' 'OK'
  } elseif ($UpdateOnly -and $alreadyInstalled) {
    Save-State 'running' 'update' 15 'ComfyUI wird aktualisiert.' @{ gpu = $gpuType }
    $updateScript = Join-Path $PortableRoot 'update\update_comfyui.bat'
    if (Test-Path $updateScript) {
      & cmd.exe /d /c "`"$updateScript`"" | Out-Null
      Write-Log 'ComfyUI-Update ausgeführt.' 'OK'
    } else {
      Write-Log 'Kein portables Update-Skript gefunden; aktuelle Installation bleibt unverändert.' 'WARN'
    }
  } else {
    Save-State 'running' 'download-comfyui' 10 'ComfyUI Portable wird geladen.' @{ gpu = $gpuType }
    $asset = Get-LatestPortableAsset $gpuType
    $archive = Join-Path $DownloadDir $asset.name
    Download-File $asset.browser_download_url $archive 'ComfyUI Portable' 100MB

    Save-State 'running' 'extract-comfyui' 30 'ComfyUI Portable wird für die Entpackung vorbereitet.' @{ asset = $asset.name; gpu = $gpuType }
    $sevenZip = Ensure-7Zip
    $extractTemp = Join-Path $LocalAiDir 'extract-temp'
    $reuseExtractedPortable = $false

    if (Test-Path -LiteralPath $extractTemp) {
      $resumePortable = Get-ChildItem -LiteralPath $extractTemp -Directory -Recurse -ErrorAction SilentlyContinue |
        Where-Object {
          $_.Name -match '^ComfyUI_windows_portable' -and
          (Test-Path -LiteralPath (Join-Path $_.FullName 'ComfyUI\main.py')) -and
          (Test-Path -LiteralPath (Join-Path $_.FullName 'python_embeded\python.exe'))
        } |
        Select-Object -First 1

      if ($resumePortable) {
        $resumeMetrics = Get-DirectoryMetrics $extractTemp
        if ($resumeMetrics.file_count -ge 20 -and $resumeMetrics.bytes -ge 50MB) {
          $reuseExtractedPortable = $true
          Write-Log "Vollständiger temporärer ComfyUI-Entpackstand wird wiederverwendet: $($resumeMetrics.file_count) Dateien, $($resumeMetrics.megabytes) MB." 'OK'
          Save-State 'running' 'extract-comfyui' 51 "Vorhandener vollständiger Entpackstand wird fortgesetzt: $($resumeMetrics.file_count) Dateien · $($resumeMetrics.megabytes) MB." @{
            seven_zip = $sevenZip
            archive = $archive
            extract_root = $extractTemp
            extract_files = $resumeMetrics.file_count
            extract_bytes = [long]$resumeMetrics.bytes
            extract_mb = $resumeMetrics.megabytes
            extract_reused = $true
            extract_exit_code = 0
          }
        }
      }

      if (-not $reuseExtractedPortable) {
        Write-Log 'Vorhandenes temporäres Entpackungsverzeichnis ist nicht vollständig und wird bereinigt.' 'WARN'
        Remove-Item -LiteralPath $extractTemp -Recurse -Force -ErrorAction Stop
      }
    }

    if (-not $reuseExtractedPortable) {
      New-Item -ItemType Directory -Force -Path $extractTemp | Out-Null
      Invoke-7ZipExtract -SevenZipPath $sevenZip -ArchivePath $archive -Destination $extractTemp
    }

    Save-State 'running' 'install-comfyui' 52 'Entpackte ComfyUI-Struktur wird geprüft.' @{ asset = $asset.name; gpu = $gpuType; extract_reused = $reuseExtractedPortable }
    $sourcePortable = Get-ChildItem -LiteralPath $extractTemp -Directory -Recurse -ErrorAction SilentlyContinue |
      Where-Object { $_.Name -match '^ComfyUI_windows_portable' -and (Test-Path -LiteralPath (Join-Path $_.FullName 'ComfyUI\main.py')) } |
      Select-Object -First 1
    if (-not $sourcePortable) {
      $mainFile = Get-ChildItem -LiteralPath $extractTemp -Filter 'main.py' -File -Recurse -ErrorAction SilentlyContinue |
        Where-Object { $_.FullName -match '[\\/]ComfyUI[\\/]main\.py$' } |
        Select-Object -First 1
      if ($mainFile) {
        $comfyFolder = Split-Path -Parent $mainFile.FullName
        $sourcePortablePath = Split-Path -Parent $comfyFolder
        $sourcePortable = Get-Item -LiteralPath $sourcePortablePath
      }
    }
    if (-not $sourcePortable) { throw 'Entpacktes ComfyUI-Portable-Verzeichnis wurde nicht gefunden. Prüfe local-ai\extract-temp und das 7-Zip-Protokoll.' }
    if (Test-Path -LiteralPath $PortableRoot) { Remove-Item -LiteralPath $PortableRoot -Recurse -Force }
    Move-Item -LiteralPath $sourcePortable.FullName -Destination $PortableRoot
    if (-not (Test-Path -LiteralPath $EmbeddedPython) -or -not (Test-Path -LiteralPath (Join-Path $ComfyDir 'main.py'))) {
      throw 'ComfyUI wurde verschoben, aber Python oder main.py fehlen. Die Archivstruktur ist unerwartet.'
    }
    Remove-Item -LiteralPath $extractTemp -Recurse -Force -ErrorAction SilentlyContinue
    Save-State 'running' 'install-comfyui' 54 'ComfyUI Portable ist vollständig installiert.' @{ asset = $asset.name; gpu = $gpuType; comfy_installed = $true }
    Write-Log 'ComfyUI Portable installiert.' 'OK'
  }

  Save-State 'running' 'configure' 55 'Sichere localhost-Konfiguration wird erstellt.' @{ gpu = $gpuType }
  Write-Workflow
  Write-StartStopScripts
  $token = Get-EnvValue 'LOCAL_IMAGE_TOKEN'
  if ([string]::IsNullOrWhiteSpace($token)) { $token = New-LocalToken }
  Set-EnvVar 'LOCAL_IMAGE_ENABLED' 'true'
  Set-EnvVar 'LOCAL_IMAGE_URL' $ComfyUrl
  Set-EnvVar 'LOCAL_IMAGE_TOKEN' $token
  Set-EnvVar 'LOCAL_IMAGE_WORKFLOW_PATH' 'local-ai/workflows/vendoo_flux_schnell_api.json'
  Set-EnvVar 'LOCAL_IMAGE_INSTALL_PATH' 'local-ai/comfyui/ComfyUI_windows_portable'
  Set-EnvVar 'LOCAL_IMAGE_REQUIRE_TOKEN' 'true'

  if ($InstallModel) {
    Save-State 'running' 'download-model' 65 'FLUX.1-schnell FP8 wird geladen.' @{ gpu = $gpuType }
    New-Item -ItemType Directory -Force -Path $ModelDir | Out-Null
    Download-File $ModelUrl $ModelPath 'FLUX.1-schnell FP8' 1GB
    if ((Get-Item $ModelPath).Length -lt 1GB) { throw 'Die heruntergeladene FLUX-Datei ist unerwartet klein und wahrscheinlich unvollständig.' }
    Write-Log 'FLUX.1-schnell FP8 installiert.' 'OK'
  } else {
    Write-Log 'Modell-Download wurde nicht ausgewählt.' 'WARN'
  }

  Save-State 'running' 'autostart' 88 'Autostart wird eingerichtet.' @{ gpu = $gpuType }
  Register-AutostartTask

  if ($StartNow) {
    Save-State 'running' 'start' 94 'Lokaler FLUX-Server wird gestartet.' @{ gpu = $gpuType }
    & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $StartScript
    Start-Sleep -Seconds 8
  }

  $reachable = Test-ComfyReachable
  $modelInstalled = Test-Path $ModelPath
  $message = 'Lokales FLUX / ComfyUI ist installiert.'
  if (-not $modelInstalled) { $message += ' Das FLUX-Modell muss noch installiert werden.' }
  if ($StartNow -and -not $reachable) { $message += ' Der Server wurde gestartet, ist aber noch nicht erreichbar; prüfe das Runtime-Log.' }
  Save-State 'completed' 'done' 100 $message @{ gpu = $gpuType; comfy_installed = $true; model_installed = $modelInstalled; reachable = $reachable; autostart = [bool]$EnableAutostart }
  Write-Log $message 'OK'
  exit 0
} catch {
  $message = $_.Exception.Message
  $position = $_.InvocationInfo.PositionMessage
  $failedProgress = 0
  try {
    if (Test-Path -LiteralPath $StateFile) {
      $previousState = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json
      $failedProgress = [int]$previousState.progress
    }
  } catch {}
  Write-Log $message 'ERROR'
  if ($position) { Write-Log $position 'ERROR' }
  Save-State 'failed' 'error' $failedProgress $message @{ error_position = $position; can_resume = $true; comfy_installed = ((Test-Path $EmbeddedPython) -and (Test-Path (Join-Path $ComfyDir 'main.py'))); model_installed = (Test-Path $ModelPath) }
  exit 1
}
