<?php

declare(strict_types=1);

namespace EasyChat\Library\Update;

use EasyChat\Library\Http\ExternalHttpClient;
use EasyChat\Library\Logger;
use EasyChat\Library\SafeRuntime;








final class UpdateOperationService
{
    private const MAX_AUTOMATIC_RETRIES = 3;

    private $registry;
    private UpdateOperationStore $store;
    private array $handlers;

    public function __construct($registry, ?UpdateOperationStore $store = null, array $handlers = [])
    {
        $this->registry = $registry;
        $this->store = $store ?: new UpdateOperationStore();
        $this->handlers = $handlers;
    }

     
    public function start(): array
    {
        try {
            return $this->store->synchronized(function (UpdateOperationStore $store): array {
                $current = $store->current();
                if ($current && $this->isActive((string)($current['state'] ?? ''))) {
                    return $this->result($current, ['reused' => true]);
                }
                $operation = $store->create($this->installedVersion());
                $this->audit($operation, 'operation_created');
                return $this->result($operation);
            });
        } catch (\Throwable $e) {
            return $this->failure($this->safeError($e->getMessage()));
        }
    }

     
    public function status(string $operationId = ''): array
    {
        $operation = $operationId !== '' ? $this->store->get($operationId) : $this->store->current();
        if (!$operation) {
            return ['success' => true, 'operation' => null];
        }
        return $this->result($operation);
    }

     
    public function retry(string $operationId): array
    {
        try {
            return $this->store->synchronized(function (UpdateOperationStore $store) use ($operationId): array {
                $operation = $this->resolveOperation($store, $operationId);
                if (!$operation) {
                    return $this->failure('update_operation_not_found');
                }
                if ((string)$operation['state'] !== 'retryable_failed') {
                    return $this->result($operation);
                }
                $retryState = (string)($operation['retry_state'] ?? '');
                if (!$this->isResumableState($retryState)) {
                    return $this->failOperation($store, $operation, 'update_operation_state_invalid', true);
                }
                $operation['state'] = $retryState;
                $operation['retry_state'] = '';
                $operation['retry_at'] = 0;
                $operation['attempt'] = 0;
                $operation['last_error'] = '';
                $this->touch($operation);
                $store->save($operation);
                $this->audit($operation, 'operation_retry_requested');
                return $this->result($operation);
            });
        } catch (\Throwable $e) {
            return $this->busyOrFailure($this->safeError($e->getMessage()));
        }
    }

     
    public function advance(string $operationId): array
    {
        try {
            return $this->store->synchronized(function (UpdateOperationStore $store) use ($operationId): array {
                $operation = $this->resolveOperation($store, $operationId);
                if (!$operation) {
                    return $this->failure('update_operation_not_found');
                }
                $state = (string)($operation['state'] ?? '');
                if ($this->isTerminal($state)) {
                    return $this->result($operation);
                }
                if ($state === 'retryable_failed') {
                    $retryAt = (int)($operation['retry_at'] ?? 0);
                    if ($retryAt <= 0 || $retryAt > time()) {
                        return $this->result($operation);
                    }
                    $retryState = (string)($operation['retry_state'] ?? '');
                    if (!$this->isResumableState($retryState)) {
                        return $this->failOperation($store, $operation, 'update_operation_state_invalid', true);
                    }
                    $operation['state'] = $retryState;
                    $operation['retry_state'] = '';
                    $operation['retry_at'] = 0;
                    $operation['last_error'] = '';
                    $this->touch($operation);
                    $store->save($operation);
                    return $this->result($operation);
                }
                if ($state === 'legacy_drain_pending' && (int)($operation['retry_at'] ?? 0) > time()) {
                    return $this->result($operation, [
                        'resume_token' => (string)($operation['resume_token'] ?? ''),
                    ]);
                }

                try {
                    if (in_array($state, ['created', 'checking_release'], true)) {
                        return $this->checkRelease($store, $operation);
                    }
                    if (in_array($state, ['download_pending', 'downloading'], true)) {
                        return $this->download($store, $operation);
                    }
                    if (in_array($state, ['downloaded', 'extracting'], true)) {
                        return $this->extract($store, $operation);
                    }
                    if (in_array($state, ['ready_to_publish', 'publishing', 'legacy_drain_pending'], true)) {
                        return $this->publish($store, $operation);
                    }
                    return $this->failOperation($store, $operation, 'update_operation_state_invalid', true);
                } catch (\Throwable $e) {
                    $error = $this->safeError($e->getMessage());
                    return $this->failOperation(
                        $store,
                        $operation,
                        $error,
                        $this->isTerminalError($error)
                    );
                }
            });
        } catch (\Throwable $e) {
            return $this->busyOrFailure($this->safeError($e->getMessage()));
        }
    }

    








    public function runLegacyInstall(): array
    {
        $started = $this->start();
        $operation = is_array($started['operation'] ?? null) ? $started['operation'] : [];
        $operationId = (string)($operation['operation_id'] ?? '');
        if ($operationId === '') {
            return $started;
        }

        $last = $started;
        for ($step = 0; $step < 8; $step++) {
            $last = $this->advance($operationId);
            $operation = is_array($last['operation'] ?? null) ? $last['operation'] : [];
            $state = (string)($operation['state'] ?? '');
            if ($state === 'completed') {
                return [
                    'success' => true,
                    'version' => (string)($operation['target_version'] ?? ''),
                    'reload_url' => 'index.php?route=update',
                ];
            }
            if ($state === 'legacy_drain_pending') {
                return [
                    'success' => false,
                    'error' => 'legacy_drain_pending',
                    'retry_after' => max(1, (int)($operation['retry_after'] ?? 1)),
                    'resume_token' => (string)($last['resume_token'] ?? ''),
                ];
            }
            if (in_array($state, ['retryable_failed', 'failed', 'rolled_back'], true)) {
                return [
                    'success' => false,
                    'error' => (string)($operation['last_error'] ?? 'update_install_failed'),
                    'retry_after' => (int)($operation['retry_after'] ?? 0),
                ];
            }
            if (!empty($last['busy'])) {
                return ['success' => false, 'error' => 'update_already_running'];
            }
        }
        return ['success' => false, 'error' => 'update_operation_incomplete'];
    }

     
    private function checkRelease(UpdateOperationStore $store, array $operation): array
    {
        $this->transition($store, $operation, 'checking_release');
        $snapshot = isset($this->handlers['snapshot'])
            ? ($this->handlers['snapshot'])(true)
            : (new UpdateCenterService($this->registry))->snapshot(true);
        if (!is_array($snapshot) || empty($snapshot['success'])) {
            throw new \RuntimeException((string)($snapshot['error'] ?? 'update_catalog_unavailable'));
        }
        if (empty($snapshot['has_update'])) {
            throw new \RuntimeException('update_not_available');
        }
        if (empty($snapshot['ready'])) {
            throw new \RuntimeException('update_not_ready');
        }
        if (($snapshot['platform']['management_mode'] ?? '') !== 'managed_runtime'
            || !is_array($snapshot['artifact'] ?? null)
            || (string)($snapshot['artifact']['key'] ?? '') !== 'generic'
        ) {
            throw new \RuntimeException('update_requires_native_cms');
        }
        $artifact = $snapshot['artifact'];
        $operation['target_version'] = (string)$snapshot['latest_version'];
        $operation['artifact'] = [
            'key' => 'generic',
            'filename' => (string)($artifact['filename'] ?? ''),
            'size_bytes' => (int)($artifact['size_bytes'] ?? 0),
            'sha256' => strtolower((string)($artifact['sha256'] ?? '')),
            'download_url' => (string)($artifact['download_url'] ?? ''),
        ];
        $operation['attempt'] = 0;
        $operation['last_error'] = '';
        $this->transition($store, $operation, 'download_pending');
        $this->audit($operation, 'release_verified');
        return $this->result($operation);
    }

     
    private function download(UpdateOperationStore $store, array $operation): array
    {
        $this->transition($store, $operation, 'downloading');
        $work = $store->operationWorkDirectory((string)$operation['operation_id']);
        $package = $work . DIRECTORY_SEPARATOR . 'package.zip';
        if (!$this->validPackage($package, (array)$operation['artifact'])) {
            SafeRuntime::unlink($package);
            $downloaded = isset($this->handlers['download'])
                ? ($this->handlers['download'])((array)$operation['artifact'], $work)
                : (new VerifiedArtifactDownloader(new ExternalHttpClient($this->registry)))->download(
                    (array)$operation['artifact'],
                    $work
                );
            if (!is_string($downloaded) || !$this->validPackage($downloaded, (array)$operation['artifact'])) {
                throw new \RuntimeException('update_download_verification_failed');
            }
        }
        $operation['attempt'] = 0;
        $this->transition($store, $operation, 'downloaded');
        $this->audit($operation, 'artifact_downloaded');
        return $this->result($operation);
    }

     
    private function extract(UpdateOperationStore $store, array $operation): array
    {
        $this->transition($store, $operation, 'extracting');
        $work = $store->operationWorkDirectory((string)$operation['operation_id']);
        $package = $work . DIRECTORY_SEPARATOR . 'package.zip';
        if (!$this->validPackage($package, (array)$operation['artifact'])) {
            throw new \RuntimeException('update_download_verification_failed');
        }
        $destination = $work . DIRECTORY_SEPARATOR . 'extracted';
        $this->removeTree($destination, $work);
        $source = isset($this->handlers['extract'])
            ? ($this->handlers['extract'])($package, $destination)
            : (new SafeUpdateArchive())->extractGeneric($package, $destination);
        $expected = $destination . DIRECTORY_SEPARATOR . 'easychat';
        if (!is_string($source)
            || !hash_equals(str_replace('\\', '/', $expected), str_replace('\\', '/', $source))
            || !is_file($source . DIRECTORY_SEPARATOR . 'version.php')
            || !is_file($source . DIRECTORY_SEPARATOR . 'easychat-bundle-manifest.json')
        ) {
            throw new \RuntimeException('update_extract_contract_invalid');
        }
        $operation['attempt'] = 0;
        $this->transition($store, $operation, 'ready_to_publish');
        $this->audit($operation, 'artifact_prepared');
        return $this->result($operation);
    }

     
    private function publish(UpdateOperationStore $store, array $operation): array
    {
        $target = (string)($operation['target_version'] ?? '');
        $installed = $this->installedVersion();
        if ($target !== '' && version_compare($installed, $target, '>=')) {
            if (!hash_equals($target, $installed)) {
                $this->audit($operation, 'operation_superseded_by_installed_version');
            }
            return $this->complete($store, $operation);
        }

        $work = $store->operationWorkDirectory((string)$operation['operation_id']);
        $source = $work . DIRECTORY_SEPARATOR . 'extracted' . DIRECTORY_SEPARATOR . 'easychat';
        if (!is_file($source . DIRECTORY_SEPARATOR . 'version.php')
            || !is_file($source . DIRECTORY_SEPARATOR . 'easychat-bundle-manifest.json')
        ) {
             
             
             
             
             
            $this->cleanupOperationWork($store, (string)$operation['operation_id']);
            $operation['attempt'] = 0;
            $operation['last_error'] = '';
            $this->transition($store, $operation, 'download_pending');
            $this->audit($operation, 'prepared_source_rebuild_requested', 'update_prepared_source_missing');
            return $this->result($operation, ['recovered' => true]);
        }
        $this->transition($store, $operation, 'publishing');
        $publish = isset($this->handlers['publish'])
            ? ($this->handlers['publish'])($source, SITE_PATH, $target)
            : $this->publishRuntime($source, $target);
        if (!is_array($publish) || empty($publish['success'])) {
            $error = (string)($publish['error'] ?? 'update_publish_failed');
            if ($error === 'legacy_drain_pending') {
                $resumeToken = (string)($publish['resume_token'] ?? '');
                if (preg_match('/^[a-f0-9]{64}$/D', $resumeToken) !== 1) {
                    throw new \RuntimeException('legacy_drain_gate_failed');
                }
                $operation['retry_at'] = time() + max(1, (int)($publish['retry_after'] ?? 1));
                $operation['resume_token'] = $resumeToken;
                $this->transition($store, $operation, 'legacy_drain_pending');
                $this->audit($operation, 'legacy_drain_pending', $error);
                return $this->result($operation, [
                    'resume_token' => (string)$operation['resume_token'],
                ]);
            }
            throw new \RuntimeException($error);
        }
        if (!hash_equals($target, $this->installedVersion())) {
            throw new \RuntimeException('update_publish_version_mismatch');
        }
        return $this->complete($store, $operation);
    }

     
    private function publishRuntime(string $source, string $target): array
    {
        if (!class_exists('EasyChatOpenCartRuntimePublisher')) {
            require_once SITE_PATH . '/library/update/runtime_publisher.php';
        }
        if (function_exists('easychat_runtime_reader_lock_release')
            && !easychat_runtime_reader_lock_release()
        ) {
            return ['success' => false, 'error' => 'update_reader_release_failed'];
        }
        $publisher = new \EasyChatOpenCartRuntimePublisher(5000);
        if (!$publisher->publish($source, SITE_PATH, $target)) {
            $error = (string)$publisher->lastError();
            $retryAfter = $error === 'legacy_drain_pending'
                ? max(1, (int)$publisher->legacyDrainReadyAt() - time())
                : 0;
            return [
                'success' => false,
                'error' => $error !== '' ? $error : 'update_publish_failed',
                'retry_after' => $retryAfter,
                'resume_token' => $error === 'legacy_drain_pending'
                    ? (string)$publisher->legacyDrainResumeToken()
                    : '',
            ];
        }
        return ['success' => true];
    }

     
    private function complete(UpdateOperationStore $store, array $operation): array
    {
        $operation['completed_at'] = time();
        $operation['last_error'] = '';
        $operation['retry_at'] = 0;
        $operation['retry_state'] = '';
        $operation['resume_token'] = '';
        $this->transition($store, $operation, 'completed');
        $this->audit($operation, 'operation_completed');
        $this->cleanupOperationWork($store, (string)$operation['operation_id']);
        return $this->result($operation);
    }

    private function cleanupOperationWork(UpdateOperationStore $store, string $operationId): void
    {
        foreach ($store->operationWorkDirectories($operationId) as $work) {
            $this->removeTree($work, dirname($work));
        }
    }

     
    private function failOperation(
        UpdateOperationStore $store,
        array $operation,
        string $error,
        bool $terminal
    ): array {
        $retryState = $this->retryState((string)($operation['state'] ?? ''));
        $attempt = (int)($operation['attempt'] ?? 0) + 1;
        $operation['attempt'] = $attempt;
        $operation['last_error'] = $error;
        if ($terminal || $retryState === '') {
            $operation['retry_state'] = '';
            $operation['retry_at'] = 0;
            $this->transition($store, $operation, 'failed');
        } else {
            $operation['retry_state'] = $retryState;
            $operation['retry_at'] = $attempt < self::MAX_AUTOMATIC_RETRIES
                ? time() + min(30, (int)pow(2, $attempt))
                : 0;
            $this->transition($store, $operation, 'retryable_failed');
        }
        $this->audit($operation, 'operation_failed', $error);
        return $this->result($operation);
    }

     
    private function transition(UpdateOperationStore $store, array &$operation, string $state): void
    {
        $operation['state'] = $state;
        $operation['revision'] = (int)($operation['revision'] ?? 0) + 1;
        $this->touch($operation);
        $store->save($operation);
    }

     
    private function touch(array &$operation): void
    {
        $operation['updated_at'] = time();
    }

     
    private function resolveOperation(UpdateOperationStore $store, string $operationId): array
    {
        return $operationId !== '' ? $store->get($operationId) : $store->current();
    }

     
    private function result(array $operation, array $extra = []): array
    {
        return ['success' => true, 'operation' => $this->publicOperation($operation)] + $extra;
    }

     
    private function publicOperation(array $operation): array
    {
        $retryAt = max(0, (int)($operation['retry_at'] ?? 0));
        return [
            'operation_id' => (string)($operation['operation_id'] ?? ''),
            'state' => (string)($operation['state'] ?? ''),
            'current_version' => (string)($operation['current_version'] ?? ''),
            'target_version' => (string)($operation['target_version'] ?? ''),
            'last_error' => (string)($operation['last_error'] ?? ''),
            'attempt' => (int)($operation['attempt'] ?? 0),
            'revision' => (int)($operation['revision'] ?? 0),
            'created_at' => (int)($operation['created_at'] ?? 0),
            'updated_at' => (int)($operation['updated_at'] ?? 0),
            'completed_at' => (int)($operation['completed_at'] ?? 0),
            'retry_at' => $retryAt,
            'retry_after' => $retryAt > time() ? $retryAt - time() : 0,
            'can_retry' => (string)($operation['state'] ?? '') === 'retryable_failed'
                && $retryAt <= 0,
        ];
    }

     
    private function busyOrFailure(string $error): array
    {
        if (in_array($error, ['update_operation_busy', 'update_operation_lock_unavailable'], true)) {
            $current = $this->store->current();
            return $current
                ? $this->result($current, ['busy' => true])
                : $this->failure($error);
        }
        return $this->failure($error);
    }

     
    private function failure(string $error): array
    {
        return ['success' => false, 'error' => $error];
    }

    private function installedVersion(): string
    {
        if (isset($this->handlers['installed_version'])) {
            return (string)($this->handlers['installed_version'])();
        }
        $path = SITE_PATH . DIRECTORY_SEPARATOR . 'version.php';
        $source = SafeRuntime::readFile($path);
        if (is_string($source)
            && preg_match("/define\\s*\\(\\s*['\"]EASYCHAT_VERSION['\"]\\s*,\\s*['\"]([^'\"]+)['\"]\\s*\\)/", $source, $matches) === 1
        ) {
            return $this->safeVersion((string)$matches[1]);
        }
        return defined('EASYCHAT_VERSION') ? $this->safeVersion((string)EASYCHAT_VERSION) : '0.0.0';
    }

     
    private function validPackage(string $path, array $artifact): bool
    {
        $expectedSize = (int)($artifact['size_bytes'] ?? 0);
        $expectedSha = strtolower((string)($artifact['sha256'] ?? ''));
        return $expectedSize > 0
            && preg_match('/^[a-f0-9]{64}$/D', $expectedSha) === 1
            && is_file($path)
            && !is_link($path)
            && (int)filesize($path) === $expectedSize
            && hash_equals($expectedSha, (string)hash_file('sha256', $path));
    }

    private function retryState(string $state): string
    {
        if (in_array($state, ['created', 'checking_release'], true)) {
            return 'created';
        }
        if (in_array($state, ['download_pending', 'downloading'], true)) {
            return 'download_pending';
        }
        if (in_array($state, ['downloaded', 'extracting'], true)) {
            return 'downloaded';
        }
        if (in_array($state, ['ready_to_publish', 'publishing', 'legacy_drain_pending'], true)) {
            return 'ready_to_publish';
        }
        return '';
    }

    private function isTerminalError(string $error): bool
    {
        return in_array($error, [
            'update_not_available',
            'update_not_ready',
            'update_requires_native_cms',
            'update_signature_invalid',
            'update_manifest_verification_failed',
            'update_payload_invalid',
            'update_download_contract_invalid',
            'update_operation_state_invalid',
            'update_extract_contract_invalid',
            'source_runtime_overlap',
        ], true);
    }

    private function isActive(string $state): bool
    {
        return $state !== '' && !$this->isTerminal($state);
    }

    private function isTerminal(string $state): bool
    {
        return in_array($state, ['completed', 'failed', 'rolled_back'], true);
    }

    private function isResumableState(string $state): bool
    {
        return in_array($state, ['created', 'download_pending', 'downloaded', 'ready_to_publish'], true);
    }

    private function safeVersion(string $version): string
    {
        return preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][a-zA-Z0-9.-]+)?$/D', $version) === 1
            ? $version
            : '0.0.0';
    }

    private function safeError(string $error): string
    {
        return preg_match('/^[a-z0-9_]{3,80}$/D', $error) === 1 ? $error : 'update_operation_failed';
    }

     
    private function audit(array $operation, string $event, string $error = ''): void
    {
        $record = [
            'event' => $event,
            'operation_id' => (string)($operation['operation_id'] ?? ''),
            'state' => (string)($operation['state'] ?? ''),
            'target_version' => (string)($operation['target_version'] ?? ''),
            'revision' => (int)($operation['revision'] ?? 0),
        ];
        if ($error !== '') {
            $record['error'] = $this->safeError($error);
        }
        try {
            Logger::info(json_encode($record, JSON_UNESCAPED_SLASHES), 'update.log');
        } catch (\Throwable $ignored) {
             
        }
    }

    private function removeTree(string $path, string $allowedParent): void
    {
        $path = rtrim($path, '/\\');
        $allowedParent = rtrim($allowedParent, '/\\');
        if ($path === ''
            || $allowedParent === ''
            || strpos($path, $allowedParent . DIRECTORY_SEPARATOR) !== 0
            || (!file_exists($path) && !is_link($path))
        ) {
            return;
        }
        if (is_link($path) || !is_dir($path)) {
            SafeRuntime::unlink($path);
            return;
        }
        $items = scandir($path);
        if (is_array($items)) {
            foreach ($items as $item) {
                if ($item !== '.' && $item !== '..') {
                    $this->removeTree($path . DIRECTORY_SEPARATOR . $item, $allowedParent);
                }
            }
        }
        SafeRuntime::rmdir($path);
    }
}
