<?php

declare(strict_types=1);

namespace PpPhp\Core\Projects;

/** Validates untrusted project packages and keeps inspected uploads in owned staging. */
final class ProjectPackageInspector
{
    public function __construct(private ?string $storageRoot = null)
    {
        $this->storageRoot ??= (defined('BASE_PATH') ? constant('BASE_PATH') : getcwd()) . '/storage/project-imports';
    }

    public function stageUpload(array $upload, string $ownerKey): array
    {
        $originalName = trim((string)($upload['name'] ?? ''));
        if ($originalName === '' || strtolower(pathinfo($originalName, PATHINFO_EXTENSION)) !== ProjectPackageFormat::EXTENSION) {
            throw new \RuntimeException('Choose a PPphp project package with the .ppphp extension.');
        }
        $error = (int)($upload['error'] ?? UPLOAD_ERR_NO_FILE);
        if ($error !== UPLOAD_ERR_OK) throw new \RuntimeException($this->uploadError($error));
        $size = (int)($upload['size'] ?? 0);
        if ($size < 1) throw new \RuntimeException('The selected package is empty.');
        if ($size > ProjectPackageFormat::MAX_ARCHIVE_BYTES) {
            throw new \RuntimeException('The package exceeds the ' . ProjectPackageFormat::formatBytes(ProjectPackageFormat::MAX_ARCHIVE_BYTES) . ' upload limit.');
        }
        $temporary = (string)($upload['tmp_name'] ?? '');
        if (!is_file($temporary)) throw new \RuntimeException('The uploaded package could not be read.');

        $this->cleanupExpired();
        $token = bin2hex(random_bytes(24));
        $stageDir = $this->stageDir($token);
        if (!mkdir($stageDir, 0700, true) && !is_dir($stageDir)) throw new \RuntimeException('Could not prepare secure import staging.');
        $archive = $stageDir . '/package.' . ProjectPackageFormat::EXTENSION;
        if (!@move_uploaded_file($temporary, $archive) && !@copy($temporary, $archive)) {
            $this->removeDirectory($stageDir);
            throw new \RuntimeException('Could not move the uploaded package into secure staging.');
        }
        @chmod($archive, 0600);

        try {
            $inspection = $this->inspectArchive($archive);
            $metadata = ['owner' => hash('sha256', $ownerKey), 'created_at' => time(), 'inspection' => $inspection];
            if (file_put_contents($stageDir . '/stage.json', json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) === false) {
                throw new \RuntimeException('Could not save import staging metadata.');
            }
            return ['token' => $token] + $inspection;
        } catch (\Throwable $e) {
            $this->removeDirectory($stageDir);
            throw $e;
        }
    }

    public function inspectArchive(string $archive): array
    {
        if (!class_exists(\ZipArchive::class)) throw new \RuntimeException('The PHP ZIP extension is required for project import.');
        if (!is_file($archive)) throw new \RuntimeException('The staged package no longer exists.');
        if ((int)filesize($archive) > ProjectPackageFormat::MAX_ARCHIVE_BYTES) throw new \RuntimeException('The package archive is too large.');

        $zip = new \ZipArchive();
        $opened = $zip->open($archive, \ZipArchive::RDONLY);
        if ($opened !== true) throw new \RuntimeException('This file is not a readable ZIP-based .ppphp package.');
        try {
            if ($zip->numFiles < 2 || $zip->numFiles > ProjectPackageFormat::MAX_ENTRIES) {
                throw new \RuntimeException('The package contains an invalid number of entries.');
            }
            $entries = [];
            $expandedBytes = 0;
            $executable = [];
            for ($index = 0; $index < $zip->numFiles; $index++) {
                $stat = $zip->statIndex($index);
                if (!is_array($stat)) throw new \RuntimeException('A package entry could not be inspected.');
                $rawName = (string)($stat['name'] ?? '');
                if (str_ends_with($rawName, '/')) continue;
                $path = ProjectPackageFormat::normalizeEntryPath($rawName);
                if (!ProjectPackageFormat::isAllowedEntry($path)) throw new \RuntimeException('The package contains an unsupported path: ' . $path);
                if (isset($entries[$path])) throw new \RuntimeException('The package contains a duplicate path: ' . $path);
                if ($this->isSymlink($zip, $index)) throw new \RuntimeException('Symbolic links are not allowed in project packages.');
                $size = (int)($stat['size'] ?? 0);
                $compressed = (int)($stat['comp_size'] ?? 0);
                if ($size < 0 || $compressed < 0) throw new \RuntimeException('The package contains invalid entry sizes.');
                if ($size > 0 && $compressed > 0 && ($size / $compressed) > ProjectPackageFormat::MAX_COMPRESSION_RATIO) {
                    throw new \RuntimeException('The package contains a suspiciously compressed entry: ' . $path);
                }
                $expandedBytes += $size;
                if ($expandedBytes > ProjectPackageFormat::MAX_EXPANDED_BYTES) throw new \RuntimeException('The expanded package exceeds the safety limit.');
                $entries[$path] = ['index' => $index, 'size' => $size, 'compressed_size' => $compressed];
                if (ProjectPackageFormat::isExecutableEntry($path)) $executable[] = $path;
            }

            if (!isset($entries['manifest.json'])) throw new \RuntimeException('The package manifest is missing.');
            if ((int)$entries['manifest.json']['size'] > 1024 * 1024) throw new \RuntimeException('The package manifest exceeds the 1 MB safety limit.');
            $manifestJson = $zip->getFromIndex($entries['manifest.json']['index']);
            $manifest = is_string($manifestJson) ? json_decode($manifestJson, true) : null;
            if (!is_array($manifest)) throw new \RuntimeException('The package manifest is not valid JSON.');
            if (($manifest['format'] ?? '') !== ProjectPackageFormat::ID) throw new \RuntimeException('This archive is not a PPphp editable-project package.');
            if ((int)($manifest['format_version'] ?? 0) !== ProjectPackageFormat::VERSION) throw new \RuntimeException('This package format version is not supported by this PPphp installation.');
            if (!is_array($manifest['checksums'] ?? null)) throw new \RuntimeException('The package checksum catalog is missing.');

            $declared = $manifest['checksums'];
            unset($entries['manifest.json']);
            if (count($declared) !== count($entries)) throw new \RuntimeException('The package file catalog does not match its contents.');
            foreach ($entries as $path => $entry) {
                $expected = strtolower((string)($declared[$path] ?? ''));
                if (!preg_match('/^[a-f0-9]{64}$/', $expected)) throw new \RuntimeException('A checksum is missing or invalid for ' . $path . '.');
                $actual = $this->hashEntry($zip, $entry['index']);
                if (!hash_equals($expected, $actual)) {
                    throw new \RuntimeException('Checksum verification failed for ' . $path . '.');
                }
            }

            $project = is_array($manifest['project'] ?? null) ? $manifest['project'] : [];
            $name = trim((string)($project['name'] ?? 'Imported project')) ?: 'Imported project';
            $suggestedSlug = strtolower(trim((string)($project['slug'] ?? 'imported-project')));
            $suggestedSlug = preg_replace('/[^a-z0-9]+/', '-', $suggestedSlug) ?? 'imported-project';
            $suggestedSlug = trim($suggestedSlug, '-') ?: 'imported-project';

            return [
                'manifest' => $manifest,
                'summary' => [
                    'name' => $name,
                    'suggested_slug' => $suggestedSlug,
                    'description' => (string)($project['description'] ?? ''),
                    'database' => (array)($manifest['database'] ?? []),
                    'content' => (array)($manifest['content'] ?? []),
                    'warnings' => array_values((array)($manifest['warnings'] ?? [])),
                    'entry_count' => count($entries),
                    'expanded_bytes' => $expandedBytes,
                    'executable_files' => $executable,
                    'requires_code_confirmation' => $executable !== [],
                ],
            ];
        } finally {
            $zip->close();
        }
    }

    public function loadStage(string $token, string $ownerKey): array
    {
        if (!preg_match('/^[a-f0-9]{48}$/', $token)) throw new \InvalidArgumentException('Invalid import token.');
        $dir = $this->stageDir($token);
        $metadata = is_file($dir . '/stage.json') ? json_decode((string)file_get_contents($dir . '/stage.json'), true) : null;
        if (!is_array($metadata) || !hash_equals((string)($metadata['owner'] ?? ''), hash('sha256', $ownerKey))) {
            throw new \RuntimeException('The import preview expired or does not belong to this account.', 404);
        }
        if ((int)($metadata['created_at'] ?? 0) < time() - 3600) {
            $this->removeDirectory($dir);
            throw new \RuntimeException('The import preview expired. Upload the package again.', 410);
        }
        return ['archive' => $dir . '/package.' . ProjectPackageFormat::EXTENSION, 'directory' => $dir, 'inspection' => (array)$metadata['inspection']];
    }

    public function consume(string $token, string $ownerKey): void
    {
        $stage = $this->loadStage($token, $ownerKey);
        $this->removeDirectory($stage['directory']);
    }

    private function stageDir(string $token): string { return rtrim((string)$this->storageRoot, '/') . '/' . $token; }

    private function cleanupExpired(): void
    {
        if (!is_dir((string)$this->storageRoot)) {
            @mkdir((string)$this->storageRoot, 0700, true);
            return;
        }
        foreach (glob(rtrim((string)$this->storageRoot, '/') . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
            if ((int)@filemtime($dir) < time() - 7200) $this->removeDirectory($dir);
        }
    }

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

    private function hashEntry(\ZipArchive $zip, int $index): string
    {
        $name = $zip->getNameIndex($index);
        $stream = is_string($name) ? $zip->getStream($name) : false;
        if (!is_resource($stream)) throw new \RuntimeException('A package entry could not be read for checksum verification.');
        $context = hash_init('sha256');
        try {
            while (!feof($stream)) {
                $chunk = fread($stream, 1024 * 1024);
                if ($chunk === false) throw new \RuntimeException('A package entry could not be read for checksum verification.');
                hash_update($context, $chunk);
            }
        } finally {
            fclose($stream);
        }
        return hash_final($context);
    }

    private function uploadError(int $error): string
    {
        return match ($error) {
            UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The package exceeds the server upload limit.',
            UPLOAD_ERR_PARTIAL => 'The package upload was interrupted. Try again.',
            UPLOAD_ERR_NO_FILE => 'Choose a .ppphp package to import.',
            default => 'The package upload failed (code ' . $error . ').',
        };
    }

    private function removeDirectory(string $dir): void
    {
        if (!is_dir($dir)) return;
        $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
        foreach ($iterator as $item) $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
        @rmdir($dir);
    }
}
