<?php

declare(strict_types=1);

namespace EasyChat\Library\Update;

use EasyChat\Library\SafeRuntime;









final class UpdateOperationStore
{
    private const FORMAT = 1;
    private const MAX_JSON_BYTES = 65536;
    private const RETENTION_SECONDS = 1209600;
    private const MAX_RETAINED_OPERATIONS = 20;

    private string $root;
    private string $operationsDirectory;
    private string $workDirectory;

    public function __construct(?string $root = null, ?string $workRoot = null)
    {
        $base = $root !== null
            ? rtrim($root, '/\\')
            : rtrim((string)DIR_LOGS, '/\\') . DIRECTORY_SEPARATOR . 'update';
        $this->root = $base;
        $this->operationsDirectory = $base . DIRECTORY_SEPARATOR . 'operations';
        $workBase = $workRoot !== null ? rtrim($workRoot, '/\\') : $base . DIRECTORY_SEPARATOR . 'work';

         
         
         
         
         
        $runtime = defined('SITE_PATH') ? (string)SITE_PATH : '';
        if ($workRoot === null && $runtime !== '' && $this->pathsOverlap($workBase, $runtime)) {
            $runtimeIdentity = $this->canonicalProspectivePath($runtime);
            if ($runtimeIdentity === '') {
                throw new \RuntimeException('update_work_directory_unavailable');
            }
            $workBase = rtrim(sys_get_temp_dir(), '/\\')
                . DIRECTORY_SEPARATOR
                . 'easychat-update-work-'
                . substr(hash('sha256', $runtimeIdentity), 0, 24);
            if ($this->pathsOverlap($workBase, $runtime)) {
                throw new \RuntimeException('update_work_directory_unavailable');
            }
        }

        $this->workDirectory = $workBase;
    }

    




    public function synchronized(callable $callback)
    {
        $this->ensureDirectories();
        $path = $this->root . DIRECTORY_SEPARATOR . 'operation.lock';
        $handle = $this->openPrivateLock($path);
        if (!is_resource($handle)) {
            throw new \RuntimeException('update_operation_lock_unavailable');
        }

        try {
            if (!SafeRuntime::call(static function () use ($handle): bool {
                return flock($handle, LOCK_EX | LOCK_NB);
            }, false)) {
                throw new \RuntimeException('update_operation_busy');
            }
            if (!$this->samePrivateRegularFile($path, $handle)) {
                throw new \RuntimeException('update_operation_lock_invalid');
            }
            return $callback($this);
        } finally {
            SafeRuntime::call(static function () use ($handle): bool {
                return flock($handle, LOCK_UN);
            }, false);
            fclose($handle);
        }
    }

     
    public function create(string $currentVersion): array
    {
        $this->ensureDirectories();
        $this->cleanup();
        try {
            $operationId = bin2hex(random_bytes(16));
        } catch (\Throwable $e) {
            throw new \RuntimeException('update_operation_id_unavailable');
        }
        $now = time();
        $operation = [
            'format' => self::FORMAT,
            'operation_id' => $operationId,
            'state' => 'created',
            'current_version' => $this->safeVersion($currentVersion),
            'target_version' => '',
            'artifact' => [],
            'retry_state' => '',
            'retry_at' => 0,
            'resume_token' => '',
            'attempt' => 0,
            'revision' => 1,
            'last_error' => '',
            'created_at' => $now,
            'updated_at' => $now,
            'completed_at' => 0,
        ];
        $this->save($operation);
        $this->writeJson($this->root . DIRECTORY_SEPARATOR . 'current.json', [
            'format' => self::FORMAT,
            'operation_id' => $operationId,
        ]);
        return $operation;
    }

     
    public function current(): array
    {
        $pointer = $this->readJson($this->root . DIRECTORY_SEPARATOR . 'current.json');
        $operationId = is_array($pointer) ? (string)($pointer['operation_id'] ?? '') : '';
        return $this->validOperationId($operationId) ? $this->get($operationId) : [];
    }

     
    public function get(string $operationId): array
    {
        if (!$this->validOperationId($operationId)) {
            return [];
        }
        $operation = $this->readJson($this->operationPath($operationId));
        if (!is_array($operation)
            || (int)($operation['format'] ?? 0) !== self::FORMAT
            || !hash_equals($operationId, (string)($operation['operation_id'] ?? ''))
        ) {
            return [];
        }
        return $operation;
    }

     
    public function save(array $operation): void
    {
        $operationId = (string)($operation['operation_id'] ?? '');
        if ((int)($operation['format'] ?? 0) !== self::FORMAT
            || !$this->validOperationId($operationId)
            || !$this->validState((string)($operation['state'] ?? ''))
        ) {
            throw new \RuntimeException('update_operation_state_invalid');
        }
        $operation['revision'] = max(1, (int)($operation['revision'] ?? 0));
        $operation['updated_at'] = max(1, (int)($operation['updated_at'] ?? time()));
        $this->writeJson($this->operationPath($operationId), $operation);
    }

    public function operationWorkDirectory(string $operationId): string
    {
        if (!$this->validOperationId($operationId)) {
            throw new \RuntimeException('update_operation_id_invalid');
        }
        $this->ensureDirectories();
        $path = $this->workDirectory . DIRECTORY_SEPARATOR . $operationId;
        if (!is_dir($path)) {
            $previousUmask = umask(0077);
            try {
                $created = SafeRuntime::mkdir($path, 0700, false);
            } finally {
                umask($previousUmask);
            }
            if (!$created) {
                throw new \RuntimeException('update_work_directory_unavailable');
            }
        }
        if (!$this->privateDirectory($path)) {
            throw new \RuntimeException('update_work_directory_invalid');
        }
        return $path;
    }

    









    public function operationWorkDirectories(string $operationId): array
    {
        if (!$this->validOperationId($operationId)) {
            throw new \RuntimeException('update_operation_id_invalid');
        }

        $current = $this->workDirectory . DIRECTORY_SEPARATOR . $operationId;
        $paths = [$current];
        $legacyParent = $this->root . DIRECTORY_SEPARATOR . 'work';
        $legacy = $legacyParent . DIRECTORY_SEPARATOR . $operationId;
         
         
         
        if (is_dir($legacyParent)
            && $this->privateDirectory($legacyParent)
            && !$this->sameProspectivePath($current, $legacy)
        ) {
            $paths[] = $legacy;
        }
        return $paths;
    }

    private function ensureDirectories(): void
    {
        foreach ([$this->root, $this->operationsDirectory, $this->workDirectory] as $directory) {
            if (!is_dir($directory)) {
                $previousUmask = umask(0077);
                try {
                    $created = SafeRuntime::mkdir($directory, 0700, true);
                } finally {
                    umask($previousUmask);
                }
                if (!$created) {
                    throw new \RuntimeException('update_operation_store_unavailable');
                }
            }
            if (!SafeRuntime::chmod($directory, 0700) || !$this->privateDirectory($directory)) {
                throw new \RuntimeException('update_operation_store_invalid');
            }
        }
    }

    private function privateDirectory(string $path): bool
    {
        clearstatcache(true, $path);
        $stat = SafeRuntime::call(static function () use ($path) {
            return lstat($path);
        }, false);
        return is_array($stat)
            && (($stat['mode'] ?? 0) & 0170000) === 0040000
            && (($stat['mode'] ?? 0) & 0077) === 0
            && !is_link($path);
    }

    


    private function openPrivateLock(string $path)
    {
        if (is_link($path) || is_dir($path)) {
            return false;
        }
        $handle = false;
        if (!file_exists($path)) {
            $handle = SafeRuntime::call(static function () use ($path) {
                $previousUmask = umask(0077);
                try {
                    return fopen($path, 'x+b');
                } finally {
                    umask($previousUmask);
                }
            }, false);
        }
        if (!is_resource($handle) && is_file($path) && !is_link($path)) {
            $handle = SafeRuntime::call(static function () use ($path) {
                return fopen($path, 'r+b');
            }, false);
        }
        if (!is_resource($handle)) {
            return false;
        }
        SafeRuntime::chmod($path, 0600);
        if (!$this->samePrivateRegularFile($path, $handle)) {
            fclose($handle);
            return false;
        }
        return $handle;
    }

    


    private function samePrivateRegularFile(string $path, $handle): bool
    {
        clearstatcache(true, $path);
        $pathStat = SafeRuntime::call(static function () use ($path) {
            return lstat($path);
        }, false);
        $handleStat = SafeRuntime::call(static function () use ($handle) {
            return fstat($handle);
        }, false);
        return is_array($pathStat)
            && is_array($handleStat)
            && (($pathStat['mode'] ?? 0) & 0170000) === 0100000
            && (($handleStat['mode'] ?? 0) & 0170000) === 0100000
            && (($pathStat['mode'] ?? 0) & 0077) === 0
            && (($handleStat['mode'] ?? 0) & 0077) === 0
            && (int)($pathStat['nlink'] ?? 0) === 1
            && (int)($handleStat['nlink'] ?? 0) === 1
            && ($pathStat['dev'] ?? null) === ($handleStat['dev'] ?? null)
            && ($pathStat['ino'] ?? null) === ($handleStat['ino'] ?? null);
    }

     
    private function readJson(string $path): ?array
    {
        if (!file_exists($path) && !is_link($path)) {
            return null;
        }
        if (is_link($path) || is_dir($path)) {
            return null;
        }
        $handle = SafeRuntime::call(static function () use ($path) {
            return fopen($path, 'rb');
        }, false);
        if (!is_resource($handle) || !$this->samePrivateRegularFile($path, $handle)) {
            if (is_resource($handle)) {
                fclose($handle);
            }
            return null;
        }
        $raw = SafeRuntime::call(static function () use ($handle) {
            return stream_get_contents($handle, self::MAX_JSON_BYTES + 1);
        }, false);
        $same = $this->samePrivateRegularFile($path, $handle);
        fclose($handle);
        if (!is_string($raw) || strlen($raw) > self::MAX_JSON_BYTES || !$same) {
            return null;
        }
        $decoded = json_decode($raw, true);
        return is_array($decoded) ? $decoded : null;
    }

     
    private function writeJson(string $path, array $payload): void
    {
        if ((file_exists($path) || is_link($path))
            && (is_link($path) || is_dir($path) || !$this->privateRegularPath($path))
        ) {
            throw new \RuntimeException('update_operation_store_invalid');
        }
        $json = json_encode($payload, JSON_UNESCAPED_SLASHES);
        if (!is_string($json)
            || strlen($json) > self::MAX_JSON_BYTES
            || !SafeRuntime::writeFileAtomic($path, $json, 0600)
        ) {
            throw new \RuntimeException('update_operation_write_failed');
        }
    }

    private function privateRegularPath(string $path): bool
    {
        clearstatcache(true, $path);
        $stat = SafeRuntime::call(static function () use ($path) {
            return lstat($path);
        }, false);
        return is_array($stat)
            && (($stat['mode'] ?? 0) & 0170000) === 0100000
            && (($stat['mode'] ?? 0) & 0077) === 0
            && (int)($stat['nlink'] ?? 0) === 1;
    }

    private function operationPath(string $operationId): string
    {
        return $this->operationsDirectory . DIRECTORY_SEPARATOR . $operationId . '.json';
    }

    private function validOperationId(string $operationId): bool
    {
        return preg_match('/^[a-f0-9]{32}$/D', $operationId) === 1;
    }

    private function validState(string $state): bool
    {
        return in_array($state, [
            'created',
            'checking_release',
            'download_pending',
            'downloading',
            'downloaded',
            'extracting',
            'ready_to_publish',
            'publishing',
            'legacy_drain_pending',
            'retryable_failed',
            'failed',
            'completed',
            'rolled_back',
        ], 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 cleanup(): void
    {
        $files = glob($this->operationsDirectory . DIRECTORY_SEPARATOR . '*.json') ?: [];
        usort($files, static function (string $left, string $right): int {
            $rightTime = SafeRuntime::call(static function () use ($right) {
                return filemtime($right);
            }, 0);
            $leftTime = SafeRuntime::call(static function () use ($left) {
                return filemtime($left);
            }, 0);
            return ((int)$rightTime) <=> ((int)$leftTime);
        });
        $now = time();
        foreach ($files as $index => $file) {
            $operation = $this->readJson($file);
            $terminal = is_array($operation)
                && in_array((string)($operation['state'] ?? ''), ['completed', 'failed', 'rolled_back'], true);
            $modified = SafeRuntime::call(static function () use ($file) {
                return filemtime($file);
            }, 0);
            $expired = (int)$modified > 0 && ($now - (int)$modified) > self::RETENTION_SECONDS;
            if ($terminal && ($expired || $index >= self::MAX_RETAINED_OPERATIONS)) {
                SafeRuntime::unlink($file);
            }
        }
    }

    private function pathsOverlap(string $source, string $runtime): bool
    {
        $sourcePath = $this->canonicalProspectivePath($source);
        $runtimePath = $this->canonicalProspectivePath($runtime);
        if ($sourcePath === '' || $runtimePath === '') {
            return true;
        }
        if (DIRECTORY_SEPARATOR === '\\') {
            $sourcePath = strtolower($sourcePath);
            $runtimePath = strtolower($runtimePath);
        }
        return $sourcePath === $runtimePath
            || strpos($sourcePath . '/', $runtimePath . '/') === 0
            || strpos($runtimePath . '/', $sourcePath . '/') === 0;
    }

    private function sameProspectivePath(string $left, string $right): bool
    {
        $leftPath = $this->canonicalProspectivePath($left);
        $rightPath = $this->canonicalProspectivePath($right);
        if ($leftPath === '' || $rightPath === '') {
            return false;
        }
        if (DIRECTORY_SEPARATOR === '\\') {
            $leftPath = strtolower($leftPath);
            $rightPath = strtolower($rightPath);
        }
        return hash_equals($leftPath, $rightPath);
    }

    private function canonicalProspectivePath(string $path): string
    {
        $path = rtrim($path, '/\\');
        if ($path === '') {
            return '';
        }
        $tail = [];
        $cursor = $path;
        while (!file_exists($cursor) && !is_link($cursor)) {
            $parent = dirname($cursor);
            if ($parent === $cursor) {
                return '';
            }
            array_unshift($tail, basename($cursor));
            $cursor = $parent;
        }
        $base = realpath($cursor);
        if ($base === false) {
            return '';
        }
        $canonical = rtrim(str_replace('\\', '/', $base), '/');
        foreach ($tail as $part) {
            if ($part === '' || $part === '.' || $part === '..') {
                return '';
            }
            $canonical .= '/' . $part;
        }
        return $canonical;
    }
}
