<?php
declare(strict_types=1);

/**
 * Updater – handles in-app updates for the Hochzeitswebseite app.
 *
 * Workflow: check → download → verify → backup → extract → migrate → finalize
 *
 * Paths that are NEVER touched during an update (user data):
 *   data/           uploads/        config/credentials.php      config/app.php
 *
 * All other app files (api/, assets/, backstage/, index.php, .htaccess) are safe
 * to overwrite.
 */
class Updater
{
    // URL of the update server's channel index (developer-hosted static file)
    private const UPDATE_INDEX_URL = 'https://updates.27pixel.de/howe/latest.json';

    // Paths that must never be overwritten – checked as prefixes relative to ROOT_DIR
    private const PROTECTED_PATHS = [
        'data/',
        'uploads/',
        'config/credentials.php',
        'config/app.php',
    ];

    private string $rootDir;
    private string $dataDir;
    private string $stagingDir;
    private string $backupDir;
    private string $versionFile;
    private string $progressFile;
    private string $logFile;

    public function __construct(string $dataDir, string $rootDir)
    {
        $this->rootDir     = rtrim($rootDir, '/\\');
        $this->dataDir     = rtrim($dataDir, '/\\');
        $this->stagingDir  = $this->dataDir . '/update-staging';
        $this->backupDir   = $this->dataDir . '/backup';
        $this->versionFile = $this->dataDir . '/version.json';
        $this->progressFile = $this->stagingDir . '/progress.json';
        $this->logFile     = $this->dataDir . '/update.log';
    }

    // ── Public API ────────────────────────────────────────────────────────────

    /**
     * Check for available updates.
     * Returns current version, available version, and whether an update is ready.
     */
    public function check(): array
    {
        $current  = $this->readVersionData();
        $index    = $this->fetchJson(self::UPDATE_INDEX_URL);
        $stable   = $index['stable'] ?? null;

        if (!$stable || empty($stable['version'])) {
            return [
                'current_version'   => $current['version'],
                'available_version' => null,
                'update_available'  => false,
                'rollback_available' => $current['rollback_available'] ?? false,
            ];
        }

        $updateAvailable = version_compare($stable['version'], $current['version'], '>');

        return [
            'current_version'    => $current['version'],
            'available_version'  => $stable['version'],
            'description'        => $stable['description'] ?? '',
            'update_available'   => $updateAvailable,
            'rollback_available' => $current['rollback_available'] ?? false,
            'previous_version'   => $current['previous_version'] ?? null,
        ];
    }

    /**
     * Start the update process. Meant to be called after the HTTP response is flushed
     * (connection-close pattern) so the frontend gets an immediate answer.
     */
    public function start(): void
    {
        ignore_user_abort(true);
        set_time_limit(300);

        $this->writeProgress('starting', 0, 'Update wird gestartet…');
        $log = [];

        try {
            // ── 1. Fetch manifest ─────────────────────────────────────────────
            $this->writeProgress('check', 5, 'Updateinformationen werden abgerufen…');
            $index    = $this->fetchJson(self::UPDATE_INDEX_URL);
            $stable   = $index['stable'] ?? null;
            if (!$stable || empty($stable['version'])) {
                throw new RuntimeException('Kein Update verfügbar.');
            }

            $newVersion  = $stable['version'];
            $manifestUrl = $stable['manifest_url'];
            $packageUrl  = $stable['package_url'];
            $indexSha256 = $stable['sha256'];

            $manifest = $this->fetchJson($manifestUrl);
            $this->log("UPDATE GESTARTET: {$this->readVersionData()['version']} → {$newVersion}", $log);

            // ── 2. Prepare staging directory ──────────────────────────────────
            $this->ensureDir($this->stagingDir);
            // Block web access to staging
            $htaccessStaging = $this->stagingDir . '/.htaccess';
            if (!file_exists($htaccessStaging)) {
                file_put_contents($htaccessStaging, "Require all denied\n");
            }

            // ── 3. Download ───────────────────────────────────────────────────
            $this->writeProgress('download', 10, 'Update wird heruntergeladen…');
            $zipFile = $this->stagingDir . '/howe-' . $newVersion . '.zip';
            $this->downloadFile($packageUrl, $zipFile);
            $this->log('Download abgeschlossen: ' . basename($zipFile) . ' (' . round(filesize($zipFile) / 1024) . ' KB)', $log);

            // ── 4. Verify checksum ────────────────────────────────────────────
            $this->writeProgress('verify', 30, 'Integrität wird geprüft…');
            $actual = hash_file('sha256', $zipFile);
            if (!hash_equals($indexSha256, $actual)) {
                throw new RuntimeException('Checksumme stimmt nicht überein – Download möglicherweise beschädigt.');
            }
            $this->log('Checksumme OK', $log);

            // ── 5. Backup current app code ────────────────────────────────────
            $this->writeProgress('backup', 40, 'Aktuelle Version wird gesichert…');
            $currentVersion = $this->readVersionData()['version'];
            $backupFile     = $this->doBackup($currentVersion);
            $this->log('Backup erstellt: ' . basename($backupFile), $log);

            // ── 6. Extract ────────────────────────────────────────────────────
            $this->writeProgress('extracting', 55, 'Update wird entpackt…');
            [$extracted, $skipped] = $this->extract($zipFile, $manifest);
            $this->log("Entpackt: {$extracted} Dateien ({$skipped} geschützte übersprungen)", $log);

            // ── 7. Migrations ─────────────────────────────────────────────────
            $this->writeProgress('migrations', 85, 'Datenmigration wird durchgeführt…');
            $versionData  = $this->readVersionData();
            $migrations   = $manifest['migrations'] ?? [];
            $this->runMigrations($migrations, $versionData, $log);

            // ── 8. Finalize ───────────────────────────────────────────────────
            $this->writeProgress('finalizing', 95, 'Abschließende Schritte…');
            $versionData = $this->readVersionData(); // re-read after migrations updated it
            $newData = [
                'version'           => $newVersion,
                'updated_at'        => date('c'),
                'previous_version'  => $currentVersion,
                'rollback_available' => true,
                'migrations_run'    => $versionData['migrations_run'] ?? [],
            ];
            $this->writeJson($this->versionFile, $newData);

            // Cleanup staging zip (keep progress + .htaccess)
            if (file_exists($zipFile)) unlink($zipFile);

            // Prune old backups (keep max 2)
            $this->pruneBackups(2);

            $this->log("UPDATE ABGESCHLOSSEN: jetzt auf {$newVersion}", $log);
            $this->writeProgress('done', 100, "Update auf {$newVersion} erfolgreich!", null);

        } catch (Throwable $e) {
            $this->log('FEHLER: ' . $e->getMessage(), $log);
            $this->writeProgress('error', 0, 'Update fehlgeschlagen.', $e->getMessage());
        } finally {
            // Flush accumulated log entries
            foreach ($log as $line) {
                $this->appendLog($line);
            }
        }
    }

    /**
     * Return current progress (for polling from the frontend).
     */
    public function getProgress(): array
    {
        if (!file_exists($this->progressFile)) {
            return ['phase' => 'idle', 'percent' => 0, 'message' => '', 'error' => null];
        }
        return json_decode(file_get_contents($this->progressFile), true) ?? ['phase' => 'idle', 'percent' => 0, 'message' => '', 'error' => null];
    }

    /**
     * Rollback to the previous version using the stored backup.
     */
    public function rollback(): array
    {
        $versionData = $this->readVersionData();
        if (empty($versionData['rollback_available']) || empty($versionData['previous_version'])) {
            throw new RuntimeException('Kein Rollback verfügbar.');
        }

        $prevVersion = $versionData['previous_version'];
        $backupFile  = $this->findBackup($prevVersion);
        if (!$backupFile) {
            throw new RuntimeException('Backup-Datei nicht gefunden für Version ' . $prevVersion . '.');
        }

        $log = [];
        $this->log("ROLLBACK GESTARTET: {$versionData['version']} → {$prevVersion}", $log);

        $zip = new ZipArchive();
        if ($zip->open($backupFile) !== true) {
            throw new RuntimeException('Backup-Zip konnte nicht geöffnet werden.');
        }

        // Extract backup – same protected-path logic as forward update
        $extracted = 0;
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name    = $zip->getNameIndex($i);
            $relPath = ltrim(preg_replace('#^app/#', '', $name), '/');
            if (!$relPath || str_ends_with($relPath, '/')) continue;
            if ($this->isProtected($relPath)) continue;

            $dest = $this->rootDir . '/' . $relPath;
            $this->ensureDir(dirname($dest));
            $content = $zip->getFromIndex($i);
            if ($content === false) continue;
            $tmp = $dest . '.new';
            file_put_contents($tmp, $content, LOCK_EX);
            rename($tmp, $dest);
            $extracted++;
        }
        $zip->close();

        // Remove the last migration entry (best-effort; migrations are additive so no harm either way)
        $migrationsRun = $versionData['migrations_run'] ?? [];
        array_pop($migrationsRun);

        $newData = [
            'version'            => $prevVersion,
            'updated_at'         => date('c'),
            'previous_version'   => null,
            'rollback_available' => false,
            'migrations_run'     => $migrationsRun,
        ];
        $this->writeJson($this->versionFile, $newData);

        $this->log("ROLLBACK ABGESCHLOSSEN: zurück auf {$prevVersion}, {$extracted} Dateien wiederhergestellt", $log);
        foreach ($log as $line) $this->appendLog($line);

        return ['rolled_back_to' => $prevVersion, 'files_restored' => $extracted];
    }

    // ── Internal helpers ──────────────────────────────────────────────────────

    private function readVersionData(): array
    {
        if (!file_exists($this->versionFile)) {
            return ['version' => '0.0.0', 'migrations_run' => [], 'rollback_available' => false, 'previous_version' => null];
        }
        return json_decode(file_get_contents($this->versionFile), true) ?? ['version' => '0.0.0', 'migrations_run' => []];
    }

    private function fetchJson(string $url): array
    {
        $context = stream_context_create([
            'http' => [
                'timeout'       => 15,
                'user_agent'    => 'HoweUpdater/1.0',
                'ignore_errors' => true,
            ],
            'ssl' => [
                'verify_peer'      => true,
                'verify_peer_name' => true,
            ],
        ]);
        $raw = @file_get_contents($url, false, $context);
        if ($raw === false) {
            throw new RuntimeException('Verbindung zum Update-Server fehlgeschlagen: ' . $url);
        }
        $data = json_decode($raw, true);
        if (!is_array($data)) {
            throw new RuntimeException('Ungültige Antwort vom Update-Server.');
        }
        return $data;
    }

    private function downloadFile(string $url, string $dest): void
    {
        $context = stream_context_create([
            'http' => [
                'timeout'    => 120,
                'user_agent' => 'HoweUpdater/1.0',
            ],
            'ssl' => [
                'verify_peer'      => true,
                'verify_peer_name' => true,
            ],
        ]);
        $in = @fopen($url, 'rb', false, $context);
        if (!$in) throw new RuntimeException('Download fehlgeschlagen: ' . $url);

        $tmp = $dest . '.download';
        $out = fopen($tmp, 'wb');
        if (!$out) { fclose($in); throw new RuntimeException('Kann Datei nicht schreiben: ' . $tmp); }

        while (!feof($in)) {
            fwrite($out, fread($in, 65536));
        }
        fclose($in);
        fclose($out);
        rename($tmp, $dest);
    }

    private function doBackup(string $currentVersion): string
    {
        $this->ensureDir($this->backupDir);

        // Block web access to backup dir
        $htaccess = $this->backupDir . '/.htaccess';
        if (!file_exists($htaccess)) {
            file_put_contents($htaccess, "Require all denied\n");
        }

        $timestamp  = date('Ymd-His');
        $backupFile = $this->backupDir . '/backup-' . $currentVersion . '-' . $timestamp . '.zip';

        $zip = new ZipArchive();
        if ($zip->open($backupFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            throw new RuntimeException('Kann Backup-Zip nicht erstellen: ' . $backupFile);
        }

        // Directories to back up (mirrors update package app/ structure)
        $dirs = ['api', 'assets', 'backstage'];
        foreach ($dirs as $dir) {
            $fullDir = $this->rootDir . '/' . $dir;
            if (!is_dir($fullDir)) continue;
            $this->addDirToZip($zip, $fullDir, 'app/' . $dir);
        }

        // Root-level files
        foreach (['index.php', '.htaccess'] as $file) {
            $fullPath = $this->rootDir . '/' . $file;
            if (file_exists($fullPath)) {
                $zip->addFile($fullPath, 'app/' . $file);
            }
        }

        $zip->close();
        return $backupFile;
    }

    private function addDirToZip(ZipArchive $zip, string $dir, string $zipBase): void
    {
        $files = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::LEAVES_ONLY
        );
        foreach ($files as $file) {
            if (!$file->isFile()) continue;
            $filePath = str_replace('\\', '/', $file->getRealPath());
            $relPath  = $zipBase . '/' . ltrim(str_replace(str_replace('\\', '/', $dir), '', $filePath), '/');
            $zip->addFile($filePath, $relPath);
        }
    }

    /**
     * Extract the update zip into the webroot, skipping protected paths.
     * Uses .new temp files + rename for near-atomic replacement.
     *
     * @return array{0: int, 1: int} [extracted count, skipped count]
     */
    private function extract(string $zipFile, array $manifest): array
    {
        $zip = new ZipArchive();
        if ($zip->open($zipFile) !== true) {
            throw new RuntimeException('Kann Update-Zip nicht öffnen.');
        }

        $fileChecksums = $manifest['files'] ?? [];
        $tmpFiles      = [];
        $extracted     = 0;
        $skipped       = 0;

        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = $zip->getNameIndex($i);

            // Only process files under app/
            if (!str_starts_with($name, 'app/')) continue;

            // Strip leading app/ to get path relative to webroot
            $relPath = substr($name, 4);
            if (!$relPath || str_ends_with($relPath, '/')) continue;

            // Verify per-file checksum if manifest provides it
            if ($fileChecksums && isset($fileChecksums[$name])) {
                $content = $zip->getFromIndex($i);
                if ($content === false) continue;
                if (!hash_equals($fileChecksums[$name], hash('sha256', $content))) {
                    throw new RuntimeException("Checksumme ungültig für Datei: {$name}");
                }
            } else {
                $content = $zip->getFromIndex($i);
                if ($content === false) continue;
            }

            // Skip protected paths
            if ($this->isProtected($relPath)) {
                $skipped++;
                continue;
            }

            $dest = $this->rootDir . '/' . $relPath;
            $this->ensureDir(dirname($dest));

            $tmp = $dest . '.new';
            file_put_contents($tmp, $content, LOCK_EX);
            $tmpFiles[] = [$tmp, $dest];
            $extracted++;
        }

        $zip->close();

        // Rename all at once after successful extraction
        foreach ($tmpFiles as [$tmp, $dest]) {
            rename($tmp, $dest);
        }

        return [$extracted, $skipped];
    }

    /**
     * Run migration scripts that are listed in the manifest and haven't been run yet.
     * Each migration file must define a function named migrate_{version_underscored}(string $dataDir, array &$log).
     */
    private function runMigrations(array $migrations, array &$versionData, array &$log): void
    {
        $alreadyRun = $versionData['migrations_run'] ?? [];

        foreach ($migrations as $migVersion) {
            if (in_array($migVersion, $alreadyRun, true)) {
                $log[] = "Migration {$migVersion}: übersprungen (bereits ausgeführt)";
                continue;
            }

            // Migration scripts are extracted from the zip into staging (under migrations/)
            $migFile = $this->stagingDir . '/migrations/' . $migVersion . '.php';
            if (!file_exists($migFile)) {
                $log[] = "Migration {$migVersion}: Datei nicht gefunden, übersprungen";
                continue;
            }

            require_once $migFile;
            $fn = 'migrate_' . str_replace(['.', '-'], '_', $migVersion);
            if (!function_exists($fn)) {
                $log[] = "Migration {$migVersion}: Funktion {$fn} nicht gefunden";
                continue;
            }

            $migLog = [];
            $fn($this->dataDir, $migLog);
            foreach ($migLog as $entry) $log[] = "Migration {$migVersion}: " . $entry;

            $alreadyRun[] = $migVersion;
        }

        // Persist migrations_run immediately after each batch
        $versionData['migrations_run'] = $alreadyRun;
        if (file_exists($this->versionFile)) {
            $current = json_decode(file_get_contents($this->versionFile), true) ?? [];
            $current['migrations_run'] = $alreadyRun;
            $this->writeJson($this->versionFile, $current);
        }
    }

    private function isProtected(string $relPath): bool
    {
        $relPath = ltrim(str_replace('\\', '/', $relPath), '/');
        foreach (self::PROTECTED_PATHS as $protected) {
            if (str_starts_with($relPath, $protected) || $relPath === rtrim($protected, '/')) {
                return true;
            }
        }
        return false;
    }

    private function ensureDir(string $dir): void
    {
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }
    }

    private function pruneBackups(int $keep): void
    {
        $files = glob($this->backupDir . '/backup-*.zip') ?: [];
        if (count($files) <= $keep) return;
        usort($files, fn($a, $b) => filemtime($a) <=> filemtime($b));
        foreach (array_slice($files, 0, count($files) - $keep) as $old) {
            unlink($old);
        }
    }

    private function findBackup(string $version): ?string
    {
        $files = glob($this->backupDir . '/backup-' . $version . '-*.zip') ?: [];
        if (empty($files)) return null;
        usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a));
        return $files[0];
    }

    private function writeProgress(string $phase, int $percent, string $message, ?string $error = null): void
    {
        $this->ensureDir($this->stagingDir);
        $data = compact('phase', 'percent', 'message', 'error');
        $this->writeJson($this->progressFile, $data);
    }

    private function writeJson(string $file, array $data): void
    {
        $tmp = $file . '.tmp';
        file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), LOCK_EX);
        rename($tmp, $file);
    }

    private function log(string $message, array &$log): void
    {
        $log[] = '[' . date('Y-m-d H:i:s') . '] ' . $message;
    }

    private function appendLog(string $line): void
    {
        file_put_contents($this->logFile, $line . "\n", FILE_APPEND | LOCK_EX);
    }
}
