<?php

namespace App\Services;

use App\Support\GcmsVersion;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use ZipArchive;

class GcmsUpdateService
{
    public function isConfigured(): bool
    {
        if (! config('gcms-update.enabled')) {
            return false;
        }

        $url = trim((string) config('gcms-update.manifest_url'));

        return $url !== '' && filter_var($url, FILTER_VALIDATE_URL);
    }

    public function hasPublicKeyOrUnsignedAllowed(): bool
    {
        if ($this->manifestUnsignedAllowed()) {
            return true;
        }

        return $this->resolvePublicKeyPem() !== null;
    }

    /**
     * Accepter un manifeste sans signature vérifiée : uniquement si allow_unsigned
     * et (environnement local ou debug explicite).
     */
    protected function manifestUnsignedAllowed(): bool
    {
        if (! config('gcms-update.allow_unsigned')) {
            return false;
        }
        if (app()->environment('local')) {
            return true;
        }

        return (bool) config('app.debug');
    }

    /**
     * @return array<string, mixed>|null
     */
    public function getCachedStatusForBanner(): ?array
    {
        if (! $this->isConfigured()) {
            return null;
        }

        $cached = Cache::get(config('gcms-update.cache_key'));

        return is_array($cached) ? $cached : null;
    }

    /**
     * Télécharge le manifeste, vérifie la signature, met en cache le statut.
     *
     * @return array{ok: bool, error?: string, data?: array<string, mixed>}
     */
    public function refreshCheckResult(bool $forceNetwork = true): array
    {
        if (! $this->isConfigured()) {
            return ['ok' => false, 'error' => 'Mises à jour désactivées ou URL du manifeste manquante.'];
        }

        if (! $this->hasPublicKeyOrUnsignedAllowed()) {
            return ['ok' => false, 'error' => 'Clé publique de signature manquante (voir documentation GCMS_UPDATE_PUBLIC_KEY_PATH).'];
        }

        $ttl = max(1, (int) config('gcms-update.cache_ttl_minutes'));

        if (! $forceNetwork) {
            $hit = Cache::get(config('gcms-update.cache_key'));
            if (is_array($hit)) {
                return ['ok' => true, 'data' => $hit];
            }
        }

        try {
            $response = Http::timeout((int) config('gcms-update.timeout'))
                ->acceptJson()
                ->get(config('gcms-update.manifest_url'));

            if (! $response->successful()) {
                return ['ok' => false, 'error' => 'Manifeste inaccessible (HTTP '.$response->status().').'];
            }

            $manifest = $response->json();
            if (! is_array($manifest)) {
                return ['ok' => false, 'error' => 'Manifeste invalide (JSON attendu).'];
            }

            $validation = $this->validateManifestShape($manifest);
            if ($validation !== null) {
                return ['ok' => false, 'error' => $validation];
            }

            $signatureOk = $this->verifyManifestSignature($manifest);
            $allowUnsigned = $this->manifestUnsignedAllowed();

            if (! $signatureOk && ! $allowUnsigned) {
                return ['ok' => false, 'error' => 'Signature du manifeste invalide ou absente.'];
            }

            $current = GcmsVersion::current();
            $latest = $manifest['latest_version'];
            $updateAvailable = version_compare($current, $latest, '<');

            $phpOk = version_compare(PHP_VERSION, $manifest['min_php'], '>=');

            $payload = [
                'current_version' => $current,
                'latest_version' => $latest,
                'update_available' => $updateAvailable && $phpOk,
                'php_ok' => $phpOk,
                'min_php' => $manifest['min_php'],
                'requires_composer' => (bool) $manifest['requires_composer'],
                'package_url' => $manifest['package_url'],
                'sha256' => strtolower($manifest['sha256']),
                'changelog_url' => $manifest['changelog_url'] ?? null,
                'issued_at' => $manifest['issued_at'] ?? null,
                'signature_valid' => $signatureOk,
                'checked_at' => now()->toIso8601String(),
                'error' => null,
            ];

            if (! $phpOk) {
                $payload['update_available'] = false;
                $payload['error'] = sprintf(
                    'Version PHP insuffisante (minimum %s, actuellement %s).',
                    $manifest['min_php'],
                    PHP_VERSION
                );
            }

            Cache::put(config('gcms-update.cache_key'), $payload, now()->addMinutes($ttl));

            return ['ok' => true, 'data' => $payload];
        } catch (\Throwable $e) {
            Log::error('gcms.update.check_failed', ['message' => $e->getMessage()]);

            return ['ok' => false, 'error' => 'Erreur lors de la vérification : '.$e->getMessage()];
        }
    }

    /**
     * @param  array<string, mixed>  $cached  Données cache (package_url, sha256, latest_version, requires_composer)
     * @return array{ok: bool, error?: string}
     */
    public function applyUpdate(array $cached): array
    {
        if (! $this->isConfigured()) {
            return ['ok' => false, 'error' => 'Mises à jour non configurées.'];
        }

        if (! empty($cached['requires_composer'])) {
            return ['ok' => false, 'error' => 'Cette version exécute composer sur le serveur : mise à jour automatique désactivée. Suivez la documentation manuelle.'];
        }

        $packageUrl = $cached['package_url'] ?? '';
        $expectedSha = strtolower((string) ($cached['sha256'] ?? ''));
        $targetVersion = (string) ($cached['latest_version'] ?? '');

        if ($packageUrl === '' || $expectedSha === '' || $targetVersion === '') {
            return ['ok' => false, 'error' => 'Données de mise à jour incomplètes. Vérifiez à nouveau.'];
        }

        if (! str_starts_with(strtolower($packageUrl), 'https://')) {
            return ['ok' => false, 'error' => 'URL du paquet invalide (HTTPS requis).'];
        }

        @set_time_limit((int) config('gcms-update.download_timeout') + 120);

        $workDir = storage_path('app/gcms-update');
        File::ensureDirectoryExists($workDir);
        $zipPath = $workDir.'/package-'.uniqid('', true).'.zip';
        $extractDir = $workDir.'/extract-'.uniqid('', true);

        try {
            $pending = Http::timeout((int) config('gcms-update.download_timeout'));
            $pending->sink($zipPath);
            $resp = $pending->get($packageUrl);
            if (! $resp->successful()) {
                return ['ok' => false, 'error' => 'Téléchargement du paquet échoué (HTTP '.$resp->status().').'];
            }

            if (! is_file($zipPath)) {
                return ['ok' => false, 'error' => 'Fichier téléchargé introuvable.'];
            }

            $size = filesize($zipPath);
            if ($size === false || $size > (int) config('gcms-update.max_zip_bytes')) {
                return ['ok' => false, 'error' => 'Paquet trop volumineux ou invalide.'];
            }

            $actualHash = hash_file('sha256', $zipPath);
            if (strtolower($actualHash) !== $expectedSha) {
                return ['ok' => false, 'error' => 'Empreinte SHA256 du paquet incorrecte. Mise à jour annulée pour sécurité.'];
            }

            if (! $this->safeExtractZip($zipPath, $extractDir)) {
                return ['ok' => false, 'error' => 'Extraction du ZIP impossible ou archive non conforme.'];
            }

            $root = $this->detectPackageRoot($extractDir);
            if ($root === null) {
                return ['ok' => false, 'error' => 'Structure du paquet non reconnue (artisan ou app/ manquant).'];
            }

            Artisan::call('down', ['--render' => 'errors::503']);

            try {
                $this->syncAllowedPaths($root);
            } catch (\Throwable $e) {
                Log::error('gcms.update.sync_failed', ['message' => $e->getMessage()]);
                Artisan::call('up');

                return ['ok' => false, 'error' => 'Copie des fichiers échouée : '.$e->getMessage()];
            }

            try {
                Artisan::call('migrate', ['--force' => true]);
            } catch (\Throwable $e) {
                Log::error('gcms.update.migrate_failed', ['message' => $e->getMessage()]);
            }

            try {
                Artisan::call('optimize:clear');
            } catch (\Throwable $e) {
                Log::warning('gcms.update.optimize_clear_failed', ['message' => $e->getMessage()]);
            }

            GcmsVersion::write($targetVersion);
            Artisan::call('up');

            Cache::forget(config('gcms-update.cache_key'));

            return ['ok' => true];
        } catch (\Throwable $e) {
            Log::error('gcms.update.apply_failed', ['message' => $e->getMessage()]);
            try {
                Artisan::call('up');
            } catch (\Throwable) {
            }

            return ['ok' => false, 'error' => 'Échec de la mise à jour : '.$e->getMessage()];
        } finally {
            if (is_file($zipPath)) {
                @unlink($zipPath);
            }
            if (is_dir($extractDir)) {
                File::deleteDirectory($extractDir);
            }
        }
    }

    protected function validateManifestShape(array $m): ?string
    {
        foreach (['latest_version', 'package_url', 'sha256', 'min_php'] as $key) {
            if (empty($m[$key]) || ! is_string($m[$key])) {
                return 'Champ manifeste manquant : '.$key;
            }
        }

        if (! preg_match('/^[a-f0-9]{64}$/i', $m['sha256'])) {
            return 'sha256 du manifeste invalide.';
        }

        if (! str_starts_with(strtolower($m['package_url']), 'https://')) {
            return 'URL du paquet doit être en HTTPS.';
        }

        if (! isset($m['requires_composer'])) {
            return 'Champ requires_composer manquant.';
        }

        return null;
    }

    /**
     * @param  array<string, mixed>  $manifest
     */
    protected function verifyManifestSignature(array $manifest): bool
    {
        $sig = $manifest['signature'] ?? '';
        if (! is_string($sig) || $sig === '') {
            return false;
        }

        $pem = $this->resolvePublicKeyPem();
        if ($pem === null) {
            return false;
        }

        $payload = $this->signingPayload($manifest);
        $binary = base64_decode($sig, true);
        if ($binary === false) {
            return false;
        }

        $pub = openssl_pkey_get_public($pem);
        if ($pub === false) {
            return false;
        }

        $ok = openssl_verify($payload, $binary, $pub, OPENSSL_ALGO_SHA256);

        return $ok === 1;
    }

    /**
     * @param  array<string, mixed>  $manifest
     */
    protected function signingPayload(array $manifest): string
    {
        $req = ! empty($manifest['requires_composer']) ? '1' : '0';

        return $manifest['latest_version']."\n".$manifest['package_url']."\n".$manifest['sha256']."\n".$manifest['min_php']."\n".$req;
    }

    protected function resolvePublicKeyPem(): ?string
    {
        $path = trim((string) config('gcms-update.public_key_path'));
        if ($path !== '' && is_readable($path)) {
            $c = file_get_contents($path);
            if (is_string($c) && str_contains($c, 'BEGIN PUBLIC KEY')) {
                return $c;
            }
        }

        $inline = trim((string) config('gcms-update.public_key_pem'));
        if ($inline !== '') {
            $decoded = str_replace('\\n', "\n", $inline);
            if (str_contains($decoded, 'BEGIN PUBLIC KEY')) {
                return $decoded;
            }
        }

        $docs = base_path('docs/gcms-update-public.pem');
        if (is_readable($docs)) {
            $c = file_get_contents($docs);
            if (is_string($c) && str_contains($c, 'BEGIN PUBLIC KEY')) {
                return $c;
            }
        }

        $default = base_path('gcms-update-public.pem');
        if (is_readable($default)) {
            $c = file_get_contents($default);
            if (is_string($c) && str_contains($c, 'BEGIN PUBLIC KEY')) {
                return $c;
            }
        }

        return null;
    }

    protected function safeExtractZip(string $zipPath, string $extractDir): bool
    {
        File::ensureDirectoryExists($extractDir);
        $zip = new ZipArchive;
        if ($zip->open($zipPath) !== true) {
            return false;
        }

        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = $zip->getNameIndex($i);
            if ($name === false || str_contains($name, '..') || str_starts_with($name, '/') || preg_match('#^\w+:#', $name)) {
                $zip->close();

                return false;
            }
        }

        $ok = $zip->extractTo($extractDir);
        $zip->close();

        return $ok;
    }

    protected function detectPackageRoot(string $extractDir): ?string
    {
        if (File::exists($extractDir.'/artisan') && File::isDirectory($extractDir.'/app')) {
            return $extractDir;
        }

        $dirs = File::directories($extractDir);
        if (count($dirs) === 1) {
            $sub = $dirs[0];
            if (File::exists($sub.'/artisan') && File::isDirectory($sub.'/app')) {
                return $sub;
            }
        }

        return null;
    }

    protected function syncAllowedPaths(string $sourceRoot): void
    {
        $never = array_flip(config('gcms-update.never_copy_from_package', []));
        $allowed = config('gcms-update.allowed_paths', []);
        $skipSub = config('gcms-update.skip_subpaths', []);

        foreach ($allowed as $rel) {
            if (str_contains($rel, '..')) {
                continue;
            }

            $src = $sourceRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
            if (! File::exists($src)) {
                continue;
            }

            $dst = base_path($rel);

            if (isset($never[$rel])) {
                continue;
            }

            if (File::isDirectory($src)) {
                $this->copyDirectoryMerged($src, $dst, $skipSub, $rel);
            } else {
                File::ensureDirectoryExists(dirname($dst));
                File::copy($src, $dst);
            }
        }
    }

    /**
     * @param  array<int, string>  $skipSubpaths
     */
    protected function copyDirectoryMerged(string $src, string $dst, array $skipSubpaths, string $relPrefix): void
    {
        $srcReal = realpath($src);
        if ($srcReal === false) {
            return;
        }

        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($srcReal, \RecursiveDirectoryIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST
        );

        foreach ($iterator as $item) {
            $full = $item->getRealPath();
            if ($full === false) {
                continue;
            }

            $sub = ltrim(str_replace($srcReal, '', $full), DIRECTORY_SEPARATOR);
            $unixSub = str_replace(DIRECTORY_SEPARATOR, '/', $sub);
            $fullRel = $relPrefix.'/'.$unixSub;

            foreach ($skipSubpaths as $skip) {
                if ($fullRel === $skip || str_starts_with($fullRel, $skip.'/')) {
                    continue 2;
                }
            }

            if ($unixSub === 'installed.lock' && $relPrefix === 'config') {
                continue;
            }

            $target = $dst.DIRECTORY_SEPARATOR.$sub;
            if ($item->isDir()) {
                File::ensureDirectoryExists($target);
            } else {
                File::ensureDirectoryExists(dirname($target));
                File::copy($full, $target);
            }
        }
    }
}
