<?php

namespace App\Services\CmsUpdate;

use App\Core\App;
use App\Core\Database;
use App\Models\Setting;
use App\Services\ActivityLogger;
use App\Services\BackupService;
use App\Services\Marketplace\DependencyChecker;
use App\Services\Marketplace\Exceptions\SignatureException;
use App\Services\Marketplace\RegistryPolicy;
use App\Services\Marketplace\SignatureVerifier;

/**
 * Orchestrates the 9-step CMS Core Update install flow.
 *
 * Steps:
 *  0. Guards (already checked in controller – CSRF, role, maintenance)
 *  1. Pre-Backup  (BackupService::createBackup with optional uploads scope)
 *  2. Re-Check    (CmsUpdateChecker::run() fresh to prevent race conditions)
 *  3. Download    (HTTP stream to storage/temp/)
 *  4. Verify      (Ed25519 + SHA256 via SignatureVerifier)
 *  5. Dep-Check   (DependencyChecker::checkCmsVersion – min_cms_version hard-gate)
 *  6. Validate    (BackupService::validateUpdate + preflightUpdate)
 *  7. Apply       (BackupService::applyUpdate – maintenance, files, migrations, OPcache)
 *  8. Post-Verify (version matches, DB ping)
 *  9. Rollback    (on step 7/8 failure: restore pre-backup + notify admin)
 */
class CmsUpdateService
{
    private const MAX_DOWNLOAD_BYTES = 150 * 1024 * 1024; // 150 MB limit for core ZIPs

    private string   $basePath;
    private Database $db;
    private string   $progressFile;

    public function __construct(string $basePath, Database $db)
    {
        $this->basePath     = rtrim($basePath, '/\\');
        $this->db           = $db;
        $this->progressFile = $this->basePath . '/storage/temp/cms_update_progress.json';
    }

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

    /**
     * Execute the full install flow.
     *
     * @param  array $latest      Registry entry from CmsUpdateChecker (the 'data' key)
     * @param  array $backupScope ['uploads' => bool] – App+DB always included
     * @return array{success: bool, version: string, error: ?string, rolled_back: bool}
     */
    public function install(array $latest, array $backupScope = []): array
    {
        $includeUploads = (bool) ($backupScope['uploads'] ?? false);
        $targetVersion  = $latest['version'] ?? '';
        $preBackupZip   = null;
        $downloadZip    = null;

        try {
            // Step 1 – Pre-Backup
            $this->progress(1, 'Backup erstellen', 'running', 11);
            $preBackupZip = $this->createScopedBackup($includeUploads, $targetVersion);
            Setting::set('cms_update_last_pre_backup', $preBackupZip, 'system_update');
            $this->progress(1, 'Backup erstellen', 'done', 20);

            // Step 2 – Re-Check (race condition prevention)
            $this->progress(2, 'Update-Verfügbarkeit prüfen', 'running', 22);
            $checker = new CmsUpdateChecker($this->basePath);
            $fresh   = $checker->run();
            if (!$fresh['available'] || $fresh['version'] !== $targetVersion) {
                throw new \RuntimeException('Update ' . $targetVersion . ' ist nicht mehr verfügbar (Race Condition oder bereits installiert).');
            }
            $latest = $fresh['data']; // use freshest data
            $this->progress(2, 'Update-Verfügbarkeit prüfen', 'done', 30);

            // Step 3 – Download
            $this->progress(3, 'ZIP herunterladen', 'running', 32);
            $downloadZip = $this->download($latest['download_url'] ?? '', $targetVersion);
            $this->progress(3, 'ZIP herunterladen', 'done', 44);

            // Step 4 – Verify (Ed25519 + SHA256)
            $this->progress(4, 'Signatur prüfen', 'running', 46);
            $this->verify($downloadZip, $latest);
            $this->progress(4, 'Signatur prüfen', 'done', 54);

            // Step 5 – Dependency check (min_cms_version hard-gate)
            $this->progress(5, 'Kompatibilität prüfen', 'running', 56);
            $manifest = json_decode($latest['manifest_json'] ?? '{}', true) ?: [];
            (new DependencyChecker())->checkCmsVersion($manifest);
            $this->progress(5, 'Kompatibilität prüfen', 'done', 62);

            // Step 6 – Validate + Preflight
            $this->progress(6, 'Update validieren', 'running', 64);
            $validation = BackupService::validateUpdate($downloadZip, function_exists('cms_version') ? cms_version() : '');
            if (!$validation['valid']) {
                throw new \RuntimeException('Update-Validierung fehlgeschlagen: ' . ($validation['error'] ?? ''));
            }
            $preflight = BackupService::preflightUpdate($downloadZip, $this->basePath);
            if (!empty($preflight['errors'])) {
                throw new \RuntimeException('Preflight fehlgeschlagen: ' . implode('; ', $preflight['errors']));
            }
            $this->progress(6, 'Update validieren', 'done', 72);

            // Step 7 – Apply (point of no return)
            $this->progress(7, 'Update einspielen', 'running', 74);
            BackupService::applyUpdate($downloadZip, $this->basePath, $this->db);
            $this->progress(7, 'Update einspielen', 'done', 88);

            // Step 8 – Post-Verify
            $this->progress(8, 'Installation prüfen', 'running', 90);
            $this->postVerify($targetVersion);
            $this->progress(8, 'Installation prüfen', 'done', 98);

        } catch (\Throwable $e) {
            // Rollback if we're past step 6 (i.e., pre-backup exists and apply may have run)
            $rolledBack = false;
            if ($preBackupZip !== null && is_file($preBackupZip)) {
                try {
                    $this->progress(9, 'Rollback durchführen', 'running', 50);
                    BackupService::restore($preBackupZip, $this->db, $this->basePath);
                    $rolledBack = true;
                    $this->progress(9, 'Rollback abgeschlossen', 'done', 100);
                    ActivityLogger::log('rollback', 'cms', null, 'cms_core=' . $targetVersion);
                } catch (\Throwable $re) {
                    $this->notifyRollbackFailure($targetVersion, $e->getMessage(), $re->getMessage());
                    $this->progress(9, 'Rollback FEHLGESCHLAGEN – manueller Eingriff nötig', 'error', 100);
                    ActivityLogger::log('rollback_failed', 'cms', null, 'cms_core=' . $targetVersion);
                }
            }

            $this->cleanupDownload($downloadZip);

            $log = ['version' => $targetVersion, 'error' => $e->getMessage(), 'at' => date('c'), 'rolled_back' => $rolledBack];
            Setting::set('cms_update_last_install_log', json_encode($log), 'system_update');

            return ['success' => false, 'version' => $targetVersion, 'error' => $e->getMessage(), 'rolled_back' => $rolledBack];
        }

        $this->cleanupDownload($downloadZip);

        $log = ['version' => $targetVersion, 'success' => true, 'at' => date('c')];
        Setting::set('cms_update_last_install_at', date('c'), 'system_update');
        Setting::set('cms_update_last_install_log', json_encode($log), 'system_update');

        ActivityLogger::log('install', 'cms', null, 'cms_core=' . $targetVersion);

        // Invalidate update cache – no update available after successful install
        $cacheFile = $this->basePath . '/storage/cache/cms_core_update.json';
        @file_put_contents($cacheFile, json_encode(['available' => false, 'version' => '', 'data' => null, 'checked_at' => date('c')]));

        $this->progress(8, 'Update erfolgreich installiert', 'done', 100);

        return ['success' => true, 'version' => $targetVersion, 'error' => null, 'rolled_back' => false];
    }

    // ─────────────────────────────────────────────────────────
    // Progress
    // ─────────────────────────────────────────────────────────

    /**
     * Read the current progress JSON (for JS polling).
     */
    public function getProgress(): array
    {
        if (!is_file($this->progressFile)) {
            return ['step' => 0, 'label' => '', 'status' => 'idle', 'percent' => 0];
        }
        return json_decode((string) file_get_contents($this->progressFile), true) ?: [];
    }

    // ─────────────────────────────────────────────────────────
    // Private helpers
    // ─────────────────────────────────────────────────────────

    private function progress(int $step, string $label, string $status, int $percent): void
    {
        $data = ['step' => $step, 'label' => $label, 'status' => $status, 'percent' => $percent, 'updated_at' => date('c')];
        $dir  = dirname($this->progressFile);
        if (!is_dir($dir)) {
            @mkdir($dir, 0755, true);
        }
        @file_put_contents($this->progressFile, json_encode($data));
    }

    private function createScopedBackup(bool $includeUploads, string $targetVersion): string
    {
        if ($includeUploads) {
            return BackupService::createBackup($this->db, $this->basePath);
        }

        // Without uploads: create backup, then rebuild ZIP excluding public/uploads
        $fullZip = BackupService::createBackup($this->db, $this->basePath);

        // Rebuild ZIP without uploads entries
        $slimZip = preg_replace('/\.zip$/', '_noupload.zip', $fullZip);
        $zipIn   = new \ZipArchive();
        $zipOut  = new \ZipArchive();

        if ($zipIn->open($fullZip) !== true || $zipOut->open($slimZip, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
            // Fallback: just use the full backup
            return $fullZip;
        }

        for ($i = 0; $i < $zipIn->numFiles; $i++) {
            $name = $zipIn->getNameIndex($i);
            // Skip uploads/ entries
            if (strpos($name, 'uploads/') === 0) {
                continue;
            }
            $zipOut->addFromString($name, $zipIn->getFromIndex($i));
        }

        $zipIn->close();
        $zipOut->close();
        @unlink($fullZip);

        return $slimZip;
    }

    private function download(string $url, string $version): string
    {
        if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
            throw new \RuntimeException('Ungültige Download-URL für Version ' . $version);
        }

        if (strpos($url, 'https://') !== 0) {
            throw new \RuntimeException('Download-URL muss HTTPS verwenden (HTTPS-Pflicht).');
        }

        $tempFile = $this->basePath . '/storage/temp/cms_update_' . $version . '_' . time() . '.zip';
        $dir      = dirname($tempFile);
        if (!is_dir($dir)) {
            @mkdir($dir, 0755, true);
        }

        $ctx = stream_context_create([
            'http' => [
                'method'          => 'GET',
                'timeout'         => 120,
                'follow_location' => 1,
                'user_agent'      => 'ITSWEBER-CMS/' . (function_exists('cms_version') ? cms_version() : ''),
            ],
            'ssl' => [
                'verify_peer'      => true,
                'verify_peer_name' => true,
            ],
        ]);

        // F7 – Content-Length Pre-Check: ablehnen bevor wir Stream aufbauen
        // und Platte füllen. Stream-Check bleibt aktiv für Server ohne Header.
        $headers = @get_headers($url, 1, $ctx);
        if (is_array($headers) && isset($headers['Content-Length'])) {
            $cl = is_array($headers['Content-Length']) ? (int) end($headers['Content-Length']) : (int) $headers['Content-Length'];
            if ($cl > self::MAX_DOWNLOAD_BYTES) {
                throw new \RuntimeException(
                    'Paket zu groß laut Server-Angabe: ' . round($cl / 1024 / 1024, 1)
                    . ' MB (Limit ' . (self::MAX_DOWNLOAD_BYTES / 1024 / 1024) . ' MB).'
                );
            }
        }

        $in  = @fopen($url, 'r', false, $ctx);
        $out = @fopen($tempFile, 'w');

        if (!$in || !$out) {
            throw new \RuntimeException('Download fehlgeschlagen: URL nicht erreichbar oder Schreibzugriff verweigert.');
        }

        $bytes = 0;
        while (!feof($in)) {
            $chunk  = fread($in, 65536);
            $bytes += strlen($chunk);
            if ($bytes > self::MAX_DOWNLOAD_BYTES) {
                fclose($in);
                fclose($out);
                @unlink($tempFile);
                throw new \RuntimeException('Download überschreitet Größenlimit (' . (self::MAX_DOWNLOAD_BYTES / 1024 / 1024) . ' MB).');
            }
            fwrite($out, $chunk);
        }

        fclose($in);
        fclose($out);

        if ($bytes < 1024) {
            @unlink($tempFile);
            throw new \RuntimeException('Heruntergeladene Datei ist zu klein (< 1 KB) – möglicher Fehler auf dem Server.');
        }

        return $tempFile;
    }

    private function verify(string $zipPath, array $registryEntry): void
    {
        // Determine trusted public key for the CMS-update registry.
        // The CMS-update setting holds the ROOT URL (e.g. "https://itsweber.de"),
        // while RegistryPolicy::OFFICIAL_REGISTRY holds the API-base
        // ("https://itsweber.de/api/v1/registry"). We therefore compare by host
        // to recognise the official registry, then fall back to allowlist + legacy setting.
        $registryUrl  = rtrim(Setting::get('cms_update_registry_url', 'https://itsweber.de'), '/');
        $publicKeyB64 = '';

        $officialHost = parse_url(RegistryPolicy::OFFICIAL_REGISTRY, PHP_URL_HOST);
        $currentHost  = parse_url($registryUrl, PHP_URL_HOST);

        if ($officialHost !== null && $currentHost !== null && strcasecmp($officialHost, $currentHost) === 0) {
            // Official ITSWEBER registry – use built-in trusted key.
            $publicKeyB64 = RegistryPolicy::OFFICIAL_PUBLIC_KEY_B64;
        } else {
            // Custom registry – try allowlist lookup via RegistryPolicy.
            try {
                $policy      = new RegistryPolicy($this->db);
                $trustedKeys = $policy->getTrustedPublicKeys($registryUrl);
                $entryKeyId  = $registryEntry['key_id'] ?? '';
                foreach ($trustedKeys as $k) {
                    if ($entryKeyId !== '' && ($k['key_id'] ?? '') === $entryKeyId) {
                        $publicKeyB64 = (string) ($k['public_key_b64'] ?? '');
                        break;
                    }
                }
                if ($publicKeyB64 === '' && !empty($trustedKeys)) {
                    $publicKeyB64 = (string) ($trustedKeys[0]['public_key_b64'] ?? '');
                }
            } catch (\Throwable $ignored) {
                // fall through – try legacy setting below
            }
        }

        // RegistryPolicy ist single source of truth für Trust-Anchor
        // (siehe C46 Lessons Learned). Kein Fallback auf Settings, um
        // Admin-Session-Takeover + rogue Registry nicht zu untergraben.
        if ($publicKeyB64 === '') {
            throw new SignatureException(
                'Kein vertrauenswürdiger Signatur-Schlüssel für Registry konfiguriert: ' . $registryUrl
            );
        }

        $verifier = new SignatureVerifier();
        $verifier->verifyPackage($zipPath, $registryEntry, $publicKeyB64);
    }

    private function postVerify(string $targetVersion): void
    {
        // (a) Reload version.php to get fresh version
        $versionFile = $this->basePath . '/app/Config/version.php';
        if (is_file($versionFile)) {
            // Clear opcache for version file if possible
            if (function_exists('opcache_invalidate')) {
                @opcache_invalidate($versionFile, true);
            }
        }

        // (b) Check version via fresh read.
        // cms_version() reads version.php via include – OPcache was invalidated
        // above, so this returns the fresh value.
        $actual = function_exists('cms_version') ? cms_version() : '';

        if ($actual !== $targetVersion) {
            throw new \RuntimeException(
                'Post-Verify fehlgeschlagen: Erwartete Version ' . $targetVersion . ', gefunden: ' . $actual . '. Rollback wird ausgeführt.'
            );
        }

        // (c) DB connectivity ping
        try {
            $this->db->fetch("SELECT 1");
        } catch (\Throwable $e) {
            throw new \RuntimeException('Post-Verify: Datenbank nicht erreichbar nach Update. Rollback wird ausgeführt.');
        }
    }

    private function cleanupDownload(?string $path): void
    {
        if ($path !== null && is_file($path)) {
            @unlink($path);
        }
    }

    private function notifyRollbackFailure(string $targetVersion, string $originalError, string $rollbackError): void
    {
        $adminEmail = Setting::get('admin_email', '');
        if ($adminEmail === '') {
            return;
        }

        try {
            $app = App::getInstance();
            if (!$app || !$app->hasService('mailer')) {
                return;
            }

            $siteName = Setting::get('site_name', 'ITSWEBER CMS');
            $subject  = '[' . $siteName . '] KRITISCH: CMS-Update Rollback fehlgeschlagen';
            $body     = "Das CMS-Core-Update auf Version {$targetVersion} ist fehlgeschlagen UND der Rollback war ebenfalls nicht erfolgreich.\n\n"
                      . "Manueller Eingriff erforderlich!\n\n"
                      . "Update-Fehler: " . $originalError . "\n"
                      . "Rollback-Fehler: " . $rollbackError . "\n\n"
                      . "Admin-Panel: " . Setting::get('site_url', '') . "/admin\n";

            $mailer = $app->service('mailer');
            $mailer->send($adminEmail, $subject, nl2br(e($body)), strip_tags($body));
        } catch (\Throwable $ignored) {
            // Mail failure must not mask the original error
        }
    }
}
