<?php

declare(strict_types=1);

namespace EasyChat\Library\Update;

final class SafeUpdateArchive
{
    private const MAX_FILES = 12000;
    private const MAX_FILE_BYTES = 20 * 1024 * 1024;
    private const MAX_TOTAL_BYTES = 160 * 1024 * 1024;

    public function extractGeneric(string $archive, string $destination): string
    {
        if (!class_exists('ZipArchive') || !is_file($archive) || is_link($archive)) {
            throw new \RuntimeException('update_zip_unavailable');
        }
        if ((file_exists($destination) || is_link($destination))
            || !mkdir($destination, 0700, true)
            || !chmod($destination, 0700)
        ) {
            throw new \RuntimeException('update_extract_directory_unavailable');
        }

        $zip = new \ZipArchive();
        if ($zip->open($archive, \ZipArchive::RDONLY) !== true || $zip->numFiles <= 0 || $zip->numFiles > self::MAX_FILES) {
            throw new \RuntimeException('update_zip_invalid');
        }

        $total = 0;
        $seen = [];
        try {
            for ($index = 0; $index < $zip->numFiles; $index++) {
                $stat = $zip->statIndex($index, \ZipArchive::FL_UNCHANGED);
                $name = is_array($stat) ? (string)($stat['name'] ?? '') : '';
                $path = $this->normalizePath($name);
                if ($path === '' || isset($seen[$path]) || !$this->isAllowedGenericPath($path)) {
                    throw new \RuntimeException('update_zip_path_rejected');
                }
                $seen[$path] = true;
                $isDirectory = substr($name, -1) === '/';
                if ($this->isSymlink($zip, $index)) {
                    throw new \RuntimeException('update_zip_symlink_rejected');
                }
                $size = (int)($stat['size'] ?? 0);
                if ($size < 0 || $size > self::MAX_FILE_BYTES) {
                    throw new \RuntimeException('update_zip_entry_too_large');
                }
                $total += $size;
                if ($total > self::MAX_TOTAL_BYTES) {
                    throw new \RuntimeException('update_zip_too_large');
                }

                $target = $destination . '/' . rtrim($path, '/');
                if ($isDirectory) {
                    $this->ensureDirectory($target);
                    continue;
                }
                $this->ensureDirectory(dirname($target));
                $source = $zip->getStream($name);
                $output = fopen($target, 'x+b');
                if (!is_resource($source) || !is_resource($output)) {
                    if (is_resource($source)) {
                        fclose($source);
                    }
                    if (is_resource($output)) {
                        fclose($output);
                    }
                    throw new \RuntimeException('update_zip_extract_failed');
                }
                $copied = stream_copy_to_stream($source, $output, self::MAX_FILE_BYTES + 1);
                fflush($output);
                fclose($source);
                fclose($output);
                chmod($target, 0600);
                if ($copied !== $size || (int)filesize($target) !== $size) {
                    throw new \RuntimeException('update_zip_extract_mismatch');
                }
            }
        } finally {
            $zip->close();
        }

        $sourceRoot = $destination . '/easychat';
        if (!is_file($sourceRoot . '/version.php')
            || !is_file($sourceRoot . '/easychat-bundle-manifest.json')
            || !is_file($destination . '/easychat_bundle_expectation.json')
        ) {
            throw new \RuntimeException('update_bundle_metadata_missing');
        }

        return $sourceRoot;
    }

    private function normalizePath(string $path): string
    {
        $path = str_replace('\\', '/', $path);
        if ($path === '' || $path[0] === '/' || strpos($path, "\0") !== false || preg_match('/^[A-Za-z]:/', $path)) {
            return '';
        }
        $directory = substr($path, -1) === '/';
        $parts = explode('/', rtrim($path, '/'));
        foreach ($parts as $part) {
            if ($part === '' || $part === '.' || $part === '..') {
                return '';
            }
        }
        return implode('/', $parts) . ($directory ? '/' : '');
    }

    private function isAllowedGenericPath(string $path): bool
    {
        $plain = rtrim($path, '/');
        return $plain === 'easychat'
            || strpos($plain, 'easychat/') === 0
            || $plain === 'easychat_bundle_expectation.json'
            || preg_match('/^README(?:\.[A-Za-z0-9_-]+)?$/D', $plain) === 1;
    }

    private function isSymlink(\ZipArchive $zip, int $index): bool
    {
        $operatingSystem = 0;
        $attributes = 0;
        if (!$zip->getExternalAttributesIndex($index, $operatingSystem, $attributes)) {
            return false;
        }
        return (($attributes >> 16) & 0170000) === 0120000;
    }

    private function ensureDirectory(string $directory): void
    {
        if (is_link($directory)) {
            throw new \RuntimeException('update_extract_symlink_rejected');
        }
        if (!is_dir($directory) && !mkdir($directory, 0700, true)) {
            throw new \RuntimeException('update_extract_directory_failed');
        }
        if (!is_dir($directory) || is_link($directory)) {
            throw new \RuntimeException('update_extract_directory_invalid');
        }
        chmod($directory, 0700);
    }
}
