<?php
declare(strict_types=1);

namespace Celena\Core\Plugin;

use Celena\Core\Database\Connection;
use Celena\Core\Database\MigrationRunner;
use Celena\Core\Logger;
use RuntimeException;
use ZipArchive;

/**
 * Установщик плагинов из zip-архива.
 *
 * Проверки:
 *  - zip распаковывается во временную папку
 *  - на верхнем уровне должен быть каталог, содержащий manifest.json
 *  - slug берётся из manifest.json и проверяется (латиница/цифры/-/_)
 *  - если указан pubkey и signature — проверяется Ed25519 (заглушка для маркетплейса)
 *  - после валидации каталог переезжает в /plugins/<slug>
 *  - миграции плагина применяются
 */
final class Installer
{
    public function __construct(
        private readonly string $pluginsPath,
        private readonly Connection $conn,
        private readonly Logger $logger,
    ) {
    }

    /**
     * Установить пакет из zip-файла.
     *
     * @param string      $zipPath     путь к архиву
     * @param string|null $targetBase  базовый каталог установки (по умолчанию pluginsPath).
     *                                 Для тем передайте путь к /templates.
     * @param string      $manifestFile имя файла манифеста: manifest.json (плагин) или theme.json (тема)
     * @return array{slug:string, manifest:array} — манифест установленного пакета
     */
    public function installFromZip(string $zipPath, ?string $targetBase = null, string $manifestFile = 'manifest.json'): array
    {
        $targetBase = $targetBase ?? $this->pluginsPath;
        if (!class_exists(ZipArchive::class)) {
            throw new RuntimeException('ext-zip не установлено.');
        }
        if (!is_file($zipPath)) {
            throw new RuntimeException('Архив не найден: ' . $zipPath);
        }
        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) {
            throw new RuntimeException('Не удалось открыть zip.');
        }

        // ZIP-Slip: проверяем все имена файлов до распаковки.
        $entries = [];
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = (string) $zip->getNameIndex($i);
            if ($name === '' || self::isUnsafeZipEntry($name)) {
                $zip->close();
                throw new RuntimeException('Архив содержит небезопасный путь: ' . $name);
            }
            // Запрещаем .phar — отдельный обработчик PHP, который часто остаётся включённым.
            if (preg_match('/\.(phar)$/i', $name)) {
                $zip->close();
                throw new RuntimeException('Архив содержит .phar-файл: ' . $name);
            }
            $entries[] = $name;
        }

        // Временная папка для распаковки
        $tmp = sys_get_temp_dir() . '/celena-plugin-' . bin2hex(random_bytes(8));
        if (!mkdir($tmp, 0755, true) && !is_dir($tmp)) {
            $zip->close();
            throw new RuntimeException('Не удалось создать временную папку.');
        }
        if (!$zip->extractTo($tmp, $entries)) {
            $zip->close();
            $this->rmdirRecursive($tmp);
            throw new RuntimeException('Не удалось распаковать архив.');
        }
        $zip->close();

        try {
            // Доп. проверка: каждый распакованный файл реально лежит внутри $tmp.
            $tmpReal = realpath($tmp) ?: $tmp;
            $it = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($tmp, \RecursiveDirectoryIterator::SKIP_DOTS)
            );
            foreach ($it as $file) {
                $real = realpath($file->getPathname());
                if ($real === false || !str_starts_with($real, $tmpReal)) {
                    throw new RuntimeException('Архив попытался выйти за пределы временной папки.');
                }
            }

            // Найти манифест (может быть в корне либо в одном подкаталоге)
            $manifestPath = $this->findManifest($tmp, $manifestFile);
            if ($manifestPath === null) {
                throw new RuntimeException($manifestFile . ' не найден в архиве.');
            }
            $manifest = json_decode((string) file_get_contents($manifestPath), true);
            if (!is_array($manifest) || empty($manifest['slug'])) {
                throw new RuntimeException('Невалидный manifest.json.');
            }
            $slug = (string) $manifest['slug'];
            if (!preg_match('/^[a-z0-9][a-z0-9_\-]*$/', $slug)) {
                throw new RuntimeException('Некорректный slug плагина: ' . $slug);
            }

            // Опциональная проверка подписи Ed25519. Если в манифесте есть `pubkey` и `signature` —
            // проверяем; иначе только логируем предупреждение об отсутствии supply-chain контроля.
            $this->verifySignature($manifest, dirname($manifestPath), $slug);

            $source = dirname($manifestPath);
            $target = $targetBase . '/' . $slug;
            if (is_dir($target)) {
                // Обновление: сохраним /storage плагина (если был)
                $this->rmdirRecursive($target);
            }
            $this->copyDir($source, $target);

            // Миграции плагина (необязательно)
            $migrationsDir = $target . '/migrations';
            if (is_dir($migrationsDir)) {
                $runner = new MigrationRunner($this->conn, $this->logger);
                $runner->run($migrationsDir);
            }

            $this->logger->info('Plugin installed: ' . $slug . ' v' . ($manifest['version'] ?? '?'));
            return ['slug' => $slug, 'manifest' => $manifest];
        } finally {
            $this->rmdirRecursive($tmp);
        }
    }

    /**
     * Имя из ZIP считается небезопасным, если содержит `..`, начинается с `/` или `\`,
     * или указывает на абсолютный путь Windows (`C:\...`).
     */
    private static function isUnsafeZipEntry(string $name): bool
    {
        $n = str_replace('\\', '/', $name);
        if (str_starts_with($n, '/')) {
            return true;
        }
        if (preg_match('#^[A-Za-z]:/#', $n)) {
            return true;
        }
        foreach (explode('/', $n) as $segment) {
            if ($segment === '..') {
                return true;
            }
        }
        return false;
    }

    /**
     * Проверка Ed25519-подписи плагина.
     * manifest.signature — base64 от подписи `manifest.json` без поля `signature`,
     * подписанного приватным ключом издателя; manifest.pubkey — base64 публичного ключа.
     */
    private function verifySignature(array $manifest, string $sourceDir, string $slug): void
    {
        $pubkey = (string) ($manifest['pubkey'] ?? '');
        $signature = (string) ($manifest['signature'] ?? '');
        if ($pubkey === '' && $signature === '') {
            $this->logger->warning('Plugin "' . $slug . '" установлен без подписи — supply-chain не верифицируется.');
            return;
        }
        if ($pubkey === '' || $signature === '') {
            throw new RuntimeException('Подпись неполная: нужны и pubkey, и signature.');
        }
        if (!function_exists('sodium_crypto_sign_verify_detached')) {
            throw new RuntimeException('libsodium недоступен — невозможно проверить подпись.');
        }
        $pubBin = base64_decode($pubkey, true);
        $sigBin = base64_decode($signature, true);
        if ($pubBin === false || strlen($pubBin) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
            throw new RuntimeException('Невалидный pubkey.');
        }
        if ($sigBin === false || strlen($sigBin) !== SODIUM_CRYPTO_SIGN_BYTES) {
            throw new RuntimeException('Невалидная signature.');
        }
        $signed = $manifest;
        unset($signed['signature']);
        $payload = (string) json_encode($signed, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if (!sodium_crypto_sign_verify_detached($sigBin, $payload, $pubBin)) {
            throw new RuntimeException('Подпись плагина не прошла проверку.');
        }
        $this->logger->info('Plugin "' . $slug . '" signature verified.');
    }

    private function findManifest(string $dir, string $manifestFile = 'manifest.json'): ?string
    {
        if (is_file($dir . '/' . $manifestFile)) {
            return $dir . '/' . $manifestFile;
        }
        $subs = glob($dir . '/*', GLOB_ONLYDIR) ?: [];
        if (count($subs) === 1 && is_file($subs[0] . '/' . $manifestFile)) {
            return $subs[0] . '/' . $manifestFile;
        }
        return null;
    }

    private function copyDir(string $src, string $dst): void
    {
        if (!is_dir($dst)) {
            mkdir($dst, 0755, true);
        }
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($src, \RecursiveDirectoryIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST
        );
        foreach ($it as $item) {
            $target = $dst . DIRECTORY_SEPARATOR . $it->getSubPathname();
            if ($item->isDir()) {
                if (!is_dir($target)) mkdir($target, 0755, true);
            } else {
                copy($item->getPathname(), $target);
            }
        }
    }

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