<?php

namespace App\Services;

use App\Support\Installation;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Validation\ValidationException;
use RuntimeException;
use Throwable;
use ZipArchive;

/**
 * Applies a release package uploaded from the admin panel.
 *
 * The package overlays code onto the installation: it never deletes files it
 * does not carry, and it never touches `.env`, `storage/` or the public symlink,
 * so configuration and uploaded plans survive an update. Every run keeps a
 * backup of the directories it is about to overwrite.
 */
class SystemUpdater
{
    /** Directories a release may replace. Everything else is left alone. */
    private const UPDATABLE = [
        'app', 'bootstrap/app.php', 'config', 'database', 'lang', 'public/build',
        'resources', 'routes', 'vendor', 'artisan', 'composer.json', 'composer.lock',
    ];

    /** Never overwritten: this is the site's own state. */
    private const PROTECTED_PATHS = ['.env', 'storage', 'public/storage', 'public/.htaccess'];

    /**
     * @return array{version: string, previous: string, backup: string, migrated: bool}
     */
    public function apply(UploadedFile $package): array
    {
        $workspace = storage_path('app/updates/'.uniqid('release_', true));
        File::ensureDirectoryExists($workspace);

        try {
            $archive = $workspace.'/package.zip';
            $package->move($workspace, 'package.zip');
            $root = $this->extract($archive, $workspace.'/files');
            $version = $this->readVersion($root);
            $previous = (string) config('app.version', '1.0.0');
            $backup = $this->backup();

            $this->overlay($root);
            $migrated = $this->migrate();
            $this->refresh();

            Installation::lock($version);

            return [
                'version' => $version,
                'previous' => $previous,
                'backup' => basename($backup),
                'migrated' => $migrated,
            ];
        } finally {
            File::deleteDirectory($workspace);
        }
    }

    /** Unpacks the archive, refusing any entry that escapes the target folder. */
    private function extract(string $archive, string $target): string
    {
        $zip = new ZipArchive;

        if ($zip->open($archive) !== true) {
            throw ValidationException::withMessages([
                'package' => 'El archivo no es un ZIP válido.',
            ]);
        }

        File::ensureDirectoryExists($target);

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

            if (str_contains($name, '..') || str_starts_with($name, '/')) {
                $zip->close();

                throw ValidationException::withMessages([
                    'package' => 'El paquete contiene rutas inseguras y fue rechazado.',
                ]);
            }
        }

        $zip->extractTo($target);
        $zip->close();

        return $this->locateRoot($target);
    }

    /** A package may be zipped with or without a wrapping folder. */
    private function locateRoot(string $target): string
    {
        if (File::exists($target.'/artisan')) {
            return $target;
        }

        foreach (File::directories($target) as $directory) {
            if (File::exists($directory.'/artisan')) {
                return $directory;
            }
        }

        throw ValidationException::withMessages([
            'package' => 'El ZIP no parece un paquete de Lotea: no contiene el archivo artisan.',
        ]);
    }

    private function readVersion(string $root): string
    {
        $manifest = $root.'/install/release.json';

        if (! File::exists($manifest)) {
            throw ValidationException::withMessages([
                'package' => 'El paquete no declara su versión en install/release.json.',
            ]);
        }

        $data = json_decode((string) File::get($manifest), true);
        $version = is_array($data) ? ($data['version'] ?? null) : null;

        if (! is_string($version) || $version === '') {
            throw ValidationException::withMessages([
                'package' => 'La versión declarada en el paquete no es válida.',
            ]);
        }

        return $version;
    }

    /** @return string the backup archive path */
    private function backup(): string
    {
        $directory = storage_path('app/backups');
        File::ensureDirectoryExists($directory);

        $path = $directory.'/respaldo-'.now()->format('Y-m-d-His').'.zip';
        $zip = new ZipArchive;

        if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            throw new RuntimeException('No se pudo crear el respaldo previo a la actualización.');
        }

        // vendor/ is reinstallable and huge; the rest is what makes this release.
        foreach (['app', 'config', 'database', 'resources', 'routes', 'artisan', 'composer.json'] as $item) {
            $this->addToArchive($zip, base_path($item), $item);
        }

        $zip->close();

        return $path;
    }

    private function addToArchive(ZipArchive $zip, string $path, string $localName): void
    {
        if (File::isFile($path)) {
            $zip->addFile($path, $localName);

            return;
        }

        if (! File::isDirectory($path)) {
            return;
        }

        foreach (File::allFiles($path) as $file) {
            $zip->addFile($file->getPathname(), $localName.'/'.$file->getRelativePathname());
        }
    }

    private function overlay(string $root): void
    {
        foreach (self::UPDATABLE as $item) {
            $source = $root.'/'.$item;

            if (! File::exists($source) || $this->isProtected($item)) {
                continue;
            }

            $destination = base_path($item);

            if (File::isFile($source)) {
                File::ensureDirectoryExists(dirname($destination));
                File::copy($source, $destination);

                continue;
            }

            File::ensureDirectoryExists($destination);
            File::copyDirectory($source, $destination);
        }
    }

    private function isProtected(string $item): bool
    {
        foreach (self::PROTECTED_PATHS as $protected) {
            if ($item === $protected || str_starts_with($item, $protected.'/')) {
                return true;
            }
        }

        return false;
    }

    private function migrate(): bool
    {
        try {
            Artisan::call('migrate', ['--force' => true]);

            return true;
        } catch (Throwable) {
            return false;
        }
    }

    private function refresh(): void
    {
        foreach (['config:clear', 'route:clear', 'view:clear', 'cache:clear'] as $command) {
            try {
                Artisan::call($command);
            } catch (Throwable) {
                // Clearing a cache that was never warmed is not a failure.
            }
        }
    }
}
