<?php

namespace App\Support;

use App\Models\UpdateCheck;
use App\Models\UpdateInstallation;
use App\Models\UpdateSetting;
use App\Models\User;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use Symfony\Component\Process\Process;
use Throwable;
use ZipArchive;

class UpdateInstaller
{
    private const ACTIVE_STATUSES = [
        'queued',
        'checking',
        'downloading',
        'extracting',
        'backing_up',
        'installing',
        'finalizing',
    ];

    public function __construct(
        private readonly UpdateCenterManager $updateCenterManager
    ) {
    }

    public function installLatest(?User $actor = null, ?UpdateSetting $settings = null): UpdateInstallation
    {
        $settings ??= $this->updateCenterManager->settings();
        $activeInstallation = $this->activeInstallation($settings);
        if ($activeInstallation) {
            return $activeInstallation;
        }

        $installation = $this->queueLatest($actor, $settings);

        return $this->processInstallation($installation);
    }

    public function queueLatest(?User $actor = null, ?UpdateSetting $settings = null): UpdateInstallation
    {
        if (! $this->updateCenterManager->storageIsReady()) {
            throw new RuntimeException('Update tables are not migrated. Run "php artisan migrate".');
        }

        $settings ??= $this->updateCenterManager->settings();
        $this->expireStaleInstallations($settings);
        $activeInstallation = $this->activeInstallation($settings);

        if ($activeInstallation) {
            return $activeInstallation;
        }

        $installation = UpdateInstallation::query()->create([
            'update_setting_id' => $settings->id,
            'actor_id' => $actor?->id,
            'status' => 'queued',
            'events' => [],
            'started_at' => now(),
        ]);

        $this->appendEvent($installation, 'queue', 'success', 'The update installation has been queued.');

        return $installation->fresh() ?? $installation;
    }

    public function startBackgroundProcessing(UpdateInstallation|int $installation): UpdateInstallation
    {
        if (is_int($installation)) {
            $installation = UpdateInstallation::query()->findOrFail($installation);
        } else {
            $installation = $installation->fresh() ?? $installation;
        }

        if (app()->environment('testing')) {
            $this->appendEvent($installation, 'queue', 'success', 'Background update worker is scheduled.');

            return $installation->fresh() ?? $installation;
        }

        $process = Process::fromShellCommandline($this->backgroundCommand((int) $installation->id), base_path());
        $process->setTimeout(15);
        $process->run();

        if (! $process->isSuccessful()) {
            $message = trim($process->getErrorOutput()) ?: trim($process->getOutput()) ?: 'Unable to start background update worker.';

            throw new RuntimeException($message);
        }

        $this->appendEvent($installation, 'queue', 'success', 'Background update worker has been started.');

        return $installation->fresh() ?? $installation;
    }

    public function processInstallation(UpdateInstallation|int $installation): UpdateInstallation
    {
        if (is_int($installation)) {
            $installation = UpdateInstallation::query()->findOrFail($installation);
        } else {
            $installation = $installation->fresh() ?? $installation;
        }

        if (! $this->updateCenterManager->storageIsReady()) {
            throw new RuntimeException('Update tables are not migrated. Run "php artisan migrate".');
        }

        $settings = $installation->setting()->first() ?? $this->updateCenterManager->settings();
        $this->transition($installation, 'checking', 'check', 'started', 'Checking the update source.');

        try {
            $check = $this->updateCenterManager->checkNow($settings);

            $installation->forceFill([
                'update_check_id' => $check->exists ? $check->id : null,
                'target_version' => $check->remote_version,
                'target_build' => $check->remote_build,
            ])->save();

            if ($check->status !== 'success') {
                return $this->finishFailure(
                    $installation,
                    (string) ($check->error_message ?: 'Failed to check the update source.'),
                    'check',
                    'failed'
                );
            }

            if (! $check->is_update_available) {
                return $this->finishNoUpdate($installation);
            }

            $release = $this->extractReleasePayload($check);
            $installation->forceFill([
                'target_version' => $release['version'],
                'target_build' => $release['build'],
                'package_url' => $release['download_url'],
                'package_name' => $release['file_name'],
                'checksum_sha256' => $release['checksum_sha256'],
            ])->save();

            $packageRelativePath = $this->downloadPackage($installation, $settings, $release);
            $installation->forceFill([
                'package_path' => $packageRelativePath,
            ])->save();

            $stagingPath = $this->extractPackage($installation, $packageRelativePath, $release['version']);
            $backupRelativePath = $this->createBackup($installation, $release['version']);
            if ($backupRelativePath !== null) {
                $installation->forceFill([
                    'backup_path' => $backupRelativePath,
                ])->save();
            }

            $this->transition($installation, 'installing', 'install', 'started', 'Applying the update package.');
            $this->syncStageToTarget($stagingPath);
            $this->transition($installation, 'finalizing', 'finalize', 'started', 'Running post-installation commands.');
            $this->runPostInstallCommands();

            $settings->forceFill([
                'current_version' => $release['version'],
                'last_remote_version' => $release['version'],
                'last_remote_build' => $release['build'] !== '' ? $release['build'] : null,
                'last_checked_at' => now(),
                'last_error' => null,
            ])->save();

            $this->appendEvent($installation, 'finalize', 'success', 'Post-installation commands have been completed.');
            $this->appendEvent($installation, 'install', 'success', 'The update package has been applied.');

            $installation->forceFill([
                'status' => 'installed',
                'message' => sprintf('Update %s has been installed.', $release['version']),
                'finished_at' => now(),
            ])->save();
        } catch (Throwable $exception) {
            return $this->finishFailure($installation, $exception->getMessage(), 'install', 'failed');
        }

        return $installation->fresh() ?? $installation;
    }

    public function activeInstallation(UpdateSetting $settings): ?UpdateInstallation
    {
        $this->expireStaleInstallations($settings);

        return $settings->installations()
            ->whereIn('status', self::ACTIVE_STATUSES)
            ->latest('id')
            ->first();
    }

    /**
     * @return array{version:string,build:string,download_url:string,file_name:string,checksum_sha256:string}
     */
    private function extractReleasePayload(UpdateCheck $check): array
    {
        $payload = is_array($check->payload) ? $check->payload : [];
        $downloadUrl = trim((string) ($payload['download_url'] ?? ''));
        $version = trim((string) ($check->remote_version ?: ($payload['version'] ?? $payload['latest_version'] ?? '')));
        $build = trim((string) ($check->remote_build ?: ($payload['build'] ?? $payload['latest_build'] ?? '')));
        $fileName = trim((string) ($payload['file_name'] ?? ''));
        $checksum = trim((string) ($payload['checksum_sha256'] ?? ''));

        if ($downloadUrl === '') {
            throw new RuntimeException('The update source did not return a package download URL.');
        }

        if ($version === '') {
            throw new RuntimeException('The update source did not return a target version.');
        }

        if ($fileName === '') {
            $path = parse_url($downloadUrl, PHP_URL_PATH);
            $fileName = basename(is_string($path) ? $path : '') ?: sprintf('crm25-%s.zip', $version);
        }

        return [
            'version' => $version,
            'build' => $build,
            'download_url' => $downloadUrl,
            'file_name' => $fileName,
            'checksum_sha256' => $checksum,
        ];
    }

    private function downloadPackage(UpdateInstallation $installation, UpdateSetting $settings, array $release): string
    {
        $this->transition($installation, 'downloading', 'download', 'started', 'Downloading the update package.');

        $disk = Storage::disk((string) config('updates.storage_disk', 'local'));
        $relativePath = trim((string) config('updates.storage_root', 'updates'), '/').'/packages/'.now()->format('YmdHis').'-'.$this->slug($release['version']).'-'.$release['file_name'];
        $disk->makeDirectory(dirname($relativePath));
        $absolutePath = $disk->path($relativePath);

        $response = Http::timeout(max(2, min(300, (int) ($settings->request_timeout_seconds ?: 8) * 15)))
            ->withOptions(['verify' => (bool) $settings->verify_tls])
            ->get($release['download_url']);

        if (! $response->successful()) {
            throw new RuntimeException(sprintf('Package download failed with HTTP %d.', $response->status()));
        }

        File::put($absolutePath, $response->body());

        if (! File::exists($absolutePath) || File::size($absolutePath) === 0) {
            throw new RuntimeException('The downloaded update package is empty.');
        }

        $expectedChecksum = trim((string) ($release['checksum_sha256'] ?? ''));
        if ($expectedChecksum !== '') {
            $actualChecksum = hash_file('sha256', $absolutePath);
            if (! hash_equals(strtolower($expectedChecksum), strtolower($actualChecksum))) {
                throw new RuntimeException('The downloaded package checksum does not match the update source.');
            }
        }

        $this->appendEvent($installation, 'download', 'success', 'The update package has been downloaded.');

        return $relativePath;
    }

    private function extractPackage(UpdateInstallation $installation, string $packageRelativePath, string $version): string
    {
        $this->transition($installation, 'extracting', 'extract', 'started', 'Extracting the update package.');

        $disk = Storage::disk((string) config('updates.storage_disk', 'local'));
        $absolutePackagePath = $disk->path($packageRelativePath);
        $relativeStagingPath = trim((string) config('updates.storage_root', 'updates'), '/').'/staging/'.now()->format('YmdHis').'-'.$this->slug($version);
        $absoluteStagingPath = $disk->path($relativeStagingPath);

        File::deleteDirectory($absoluteStagingPath);
        File::ensureDirectoryExists($absoluteStagingPath);

        $zip = new ZipArchive();
        if ($zip->open($absolutePackagePath) !== true) {
            throw new RuntimeException('Unable to open the downloaded update package.');
        }

        if (! $zip->extractTo($absoluteStagingPath)) {
            $zip->close();
            throw new RuntimeException('Unable to extract the update package.');
        }

        $zip->close();

        if (! File::exists($absoluteStagingPath.'/artisan') || ! File::exists($absoluteStagingPath.'/config/app.php')) {
            throw new RuntimeException('The update package does not contain a valid CRM application structure.');
        }

        $this->appendEvent($installation, 'extract', 'success', 'The update package has been extracted.');

        return $absoluteStagingPath;
    }

    private function createBackup(UpdateInstallation $installation, string $version): ?string
    {
        if (! (bool) config('updates.backup_enabled', true)) {
            return null;
        }

        $this->transition($installation, 'backing_up', 'backup', 'started', 'Creating a backup before deployment.');

        $disk = Storage::disk((string) config('updates.storage_disk', 'local'));
        $relativePath = trim((string) config('updates.storage_root', 'updates'), '/').'/backups/'.now()->format('YmdHis').'-'.$this->slug($version).'-backup.zip';
        $absolutePath = $disk->path($relativePath);

        File::ensureDirectoryExists(dirname($absolutePath));

        $zip = new ZipArchive();
        if ($zip->open($absolutePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            throw new RuntimeException('Unable to create an update backup archive.');
        }

        $targetPath = $this->targetPath();
        $excludePatterns = $this->excludePatterns();

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

        foreach ($iterator as $item) {
            $absoluteItemPath = $item->getPathname();
            $relativeItemPath = ltrim(str_replace('\\', '/', substr($absoluteItemPath, strlen($targetPath))), '/');
            if ($relativeItemPath === '' || $this->isExcluded($relativeItemPath, $excludePatterns)) {
                continue;
            }

            if ($item->isDir()) {
                $zip->addEmptyDir($relativeItemPath);
                continue;
            }

            $zip->addFile($absoluteItemPath, $relativeItemPath);
        }

        $zip->close();
        $this->appendEvent($installation, 'backup', 'success', 'Backup archive has been created.');

        return $relativePath;
    }

    private function syncStageToTarget(string $stagingPath): void
    {
        $targetPath = $this->targetPath();
        if (! File::isDirectory($targetPath)) {
            throw new RuntimeException(sprintf('Update target path "%s" was not found.', $targetPath));
        }

        if (! is_writable($targetPath)) {
            throw new RuntimeException(sprintf('Update target path "%s" is not writable by the application.', $targetPath));
        }

        $command = [
            'rsync',
            '-rl',
            '--delete',
            '--omit-dir-times',
            '--no-perms',
            '--no-owner',
            '--no-group',
        ];
        foreach ($this->excludePatterns() as $pattern) {
            $command[] = '--exclude='.$pattern;
        }

        $command[] = rtrim($stagingPath, '/').'/';
        $command[] = rtrim($targetPath, '/').'/';

        $process = new Process($command, $targetPath);
        $process->setTimeout(600);
        $process->run();

        if (! $process->isSuccessful()) {
            throw new RuntimeException(trim($process->getErrorOutput()) ?: 'The update package could not be synchronized with the application files.');
        }
    }

    private function runPostInstallCommands(): void
    {
        if (! (bool) config('updates.run_post_install_commands', true)) {
            return;
        }

        $targetPath = $this->targetPath();
        foreach ([['artisan', 'migrate', '--force'], ['artisan', 'optimize:clear']] as $arguments) {
            $process = new Process(array_merge([$this->phpCliBinary()], $arguments), $targetPath);
            $process->setTimeout(600);
            $process->run();

            if (! $process->isSuccessful()) {
                throw new RuntimeException(trim($process->getErrorOutput()) ?: trim($process->getOutput()) ?: 'A post-install command failed.');
            }
        }
    }

    private function finishFailure(UpdateInstallation $installation, string $message, string $step, string $status): UpdateInstallation
    {
        $this->appendEvent($installation, $step, $status, $message);

        $installation->forceFill([
            'status' => 'failed',
            'message' => $message,
            'finished_at' => now(),
        ])->save();

        return $installation->fresh() ?? $installation;
    }

    private function finishNoUpdate(UpdateInstallation $installation): UpdateInstallation
    {
        $message = 'No new updates are available.';
        $this->appendEvent($installation, 'check', 'success', $message);

        $installation->forceFill([
            'status' => 'no_update',
            'message' => $message,
            'finished_at' => now(),
        ])->save();

        return $installation->fresh() ?? $installation;
    }

    private function appendEvent(UpdateInstallation $installation, string $step, string $status, string $message): void
    {
        $events = is_array($installation->events) ? $installation->events : [];
        $events[] = [
            'step' => $step,
            'status' => $status,
            'message' => $message,
            'timestamp' => now()->toIso8601String(),
        ];

        $installation->forceFill([
            'events' => $events,
        ])->save();
    }

    private function transition(UpdateInstallation $installation, string $status, string $step, string $eventStatus, string $message): void
    {
        $installation->forceFill([
            'status' => $status,
        ])->save();

        $this->appendEvent($installation, $step, $eventStatus, $message);
    }

    private function expireStaleInstallations(UpdateSetting $settings): void
    {
        $timeoutMinutes = max(5, (int) config('updates.stale_timeout_minutes', 30));
        $staleBefore = now()->subMinutes($timeoutMinutes);

        $staleInstallations = $settings->installations()
            ->whereIn('status', self::ACTIVE_STATUSES)
            ->where('updated_at', '<', $staleBefore)
            ->get();

        foreach ($staleInstallations as $installation) {
            $this->appendEvent($installation, 'monitor', 'failed', 'The update process timed out and was reset.');

            $installation->forceFill([
                'status' => 'failed',
                'message' => 'The update process timed out and was reset.',
                'finished_at' => now(),
            ])->save();
        }
    }

    /**
     * @return list<string>
     */
    private function excludePatterns(): array
    {
        $patterns = config('updates.exclude_paths', []);

        return is_array($patterns)
            ? array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $patterns)))
            : [];
    }

    private function isExcluded(string $relativePath, array $patterns): bool
    {
        foreach ($patterns as $pattern) {
            if (Str::is($pattern, $relativePath)) {
                return true;
            }

            if (! str_contains($pattern, '*') && str_starts_with($relativePath, rtrim($pattern, '/').'/')) {
                return true;
            }
        }

        return false;
    }

    private function targetPath(): string
    {
        return rtrim((string) config('updates.target_path', base_path()), '/');
    }

    private function backgroundCommand(int $installationId): string
    {
        $phpBinary = escapeshellarg($this->phpCliBinary());
        $artisanPath = escapeshellarg(base_path('artisan'));
        $logPath = escapeshellarg((string) config('updates.background_log_path', storage_path('logs/update-worker.log')));
        $command = sprintf('%s %s updates:process-installation %d', $phpBinary, $artisanPath, $installationId);

        if (DIRECTORY_SEPARATOR === '\\') {
            return sprintf('start /B "" %s >> %s 2>&1', $command, $logPath);
        }

        return sprintf('nohup %s >> %s 2>&1 &', $command, $logPath);
    }

    private function phpCliBinary(): string
    {
        $binary = PHP_BINARY;
        $binaryName = strtolower(basename($binary));

        if (str_contains($binaryName, 'fpm')) {
            $cliBinary = rtrim(PHP_BINDIR, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'php';

            if (is_file($cliBinary) || is_executable($cliBinary)) {
                return $cliBinary;
            }

            return 'php';
        }

        return $binary;
    }

    private function slug(string $value): string
    {
        $slug = strtolower(trim($value));
        $slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?: 'release';

        return trim($slug, '-') ?: 'release';
    }
}
