<?php
declare(strict_types=1);

namespace Celena\Core\Install;

use Celena\Core\Database\Connection;
use Celena\Core\Database\MigrationRunner;
use Celena\Core\Logger;
use Celena\Core\Security\Password;
use PDO;
use PDOException;
use RuntimeException;

/**
 * Логика установки CMS: проверка окружения, подключение к БД, генерация .env,
 * прогон миграций, создание администратора и базовых настроек.
 * Используется и веб-установщиком (install.php), и CLI (bin/celena install).
 */
final class SetupService
{
    public const MIN_PHP = '8.3.0';

    public function __construct(private readonly string $root)
    {
    }

    // ---------------------------------------------------------------
    // Шаг 1. Проверка окружения
    // ---------------------------------------------------------------

    /**
     * @return array<int, array{label: string, ok: bool, required: bool, details: string}>
     */
    public function checkEnvironment(): array
    {
        $checks = [];

        $checks[] = [
            'label' => 'PHP ' . self::MIN_PHP . '+',
            'ok' => version_compare(PHP_VERSION, self::MIN_PHP, '>='),
            'required' => true,
            'details' => 'текущая: ' . PHP_VERSION,
        ];

        foreach (['pdo' => true, 'mbstring' => true, 'json' => true, 'openssl' => true, 'zip' => true] as $ext => $required) {
            $checks[] = [
                'label' => "Расширение $ext",
                'ok' => extension_loaded($ext),
                'required' => $required,
                'details' => extension_loaded($ext) ? 'есть' : 'отсутствует',
            ];
        }

        $pgsql = extension_loaded('pdo_pgsql');
        $mysql = extension_loaded('pdo_mysql');
        $checks[] = [
            'label' => 'Драйвер БД (pdo_pgsql или pdo_mysql)',
            'ok' => $pgsql || $mysql,
            'required' => true,
            'details' => trim(($pgsql ? 'pdo_pgsql ' : '') . ($mysql ? 'pdo_mysql' : '')) ?: 'нет ни одного',
        ];

        $gd = extension_loaded('gd');
        $imagick = extension_loaded('imagick');
        $checks[] = [
            'label' => 'Обработка изображений (gd или imagick)',
            'ok' => $gd || $imagick,
            'required' => true,
            'details' => trim(($gd ? 'gd ' : '') . ($imagick ? 'imagick' : '')) ?: 'нет ни одного',
        ];

        $checks[] = [
            'label' => 'Расширение sodium (подписи пакетов маркетплейса)',
            'ok' => function_exists('sodium_crypto_sign_verify_detached'),
            'required' => false,
            'details' => function_exists('sodium_crypto_sign_verify_detached') ? 'есть' : 'нет — маркетплейс плагинов будет недоступен',
        ];

        $checks[] = [
            'label' => 'Расширение intl (локализация дат)',
            'ok' => extension_loaded('intl'),
            'required' => false,
            'details' => extension_loaded('intl') ? 'есть' : 'нет — используется упрощённое форматирование',
        ];

        // Права на запись
        $writable = [
            ['путь' => $this->root, 'label' => 'Корень сайта (для .env и install.lock)'],
            ['путь' => $this->root . '/storage', 'label' => 'storage/'],
            ['путь' => $this->root . '/public/uploads', 'label' => 'public/uploads/'],
        ];
        foreach ($writable as $w) {
            $dir = $w['путь'];
            if (!is_dir($dir)) {
                @mkdir($dir, 0775, true);
            }
            $checks[] = [
                'label' => 'Запись: ' . $w['label'],
                'ok' => is_dir($dir) && is_writable($dir),
                'required' => true,
                'details' => is_dir($dir) ? (is_writable($dir) ? 'доступна' : 'нет прав на запись') : 'каталог не создаётся',
            ];
        }

        return $checks;
    }

    public function environmentOk(): bool
    {
        foreach ($this->checkEnvironment() as $c) {
            if ($c['required'] && !$c['ok']) {
                return false;
            }
        }
        return true;
    }

    // ---------------------------------------------------------------
    // Шаг 2. База данных
    // ---------------------------------------------------------------

    /**
     * Нормализует конфиг БД: дефолтные порт/charset по драйверу.
     * @param array<string, mixed> $db
     * @return array<string, string>
     */
    public function normalizeDb(array $db): array
    {
        $driver = strtolower(trim((string) ($db['driver'] ?? 'pgsql')));
        if (!in_array($driver, ['pgsql', 'mysql'], true)) {
            throw new RuntimeException('Неизвестный драйвер БД: допустимы pgsql и mysql.');
        }
        $prefix = trim((string) ($db['prefix'] ?? 'cl_'));
        if ($prefix !== '' && !preg_match('/^[A-Za-z0-9_]{1,16}$/', $prefix)) {
            throw new RuntimeException('Префикс таблиц: только латиница, цифры и «_», до 16 символов.');
        }
        $port = trim((string) ($db['port'] ?? ''));
        if ($port === '') {
            $port = $driver === 'pgsql' ? '5432' : '3306';
        }
        if (!preg_match('/^\d{1,5}$/', $port)) {
            throw new RuntimeException('Порт БД должен быть числом.');
        }
        $name = trim((string) ($db['name'] ?? ''));
        if ($name === '' || !preg_match('/^[A-Za-z0-9_.-]{1,64}$/', $name)) {
            throw new RuntimeException('Имя БД: латиница, цифры, «_», «.», «-», до 64 символов.');
        }
        return [
            'driver' => $driver,
            'host' => trim((string) ($db['host'] ?? '127.0.0.1')) ?: '127.0.0.1',
            'port' => $port,
            'name' => $name,
            'user' => trim((string) ($db['user'] ?? '')),
            'pass' => (string) ($db['pass'] ?? ''),
            'prefix' => $prefix,
            'charset' => $driver === 'mysql' ? 'utf8mb4' : 'utf8',
        ];
    }

    /**
     * Проверяет подключение; если БД не существует — пытается создать её.
     * @param array<string, string> $db нормализованный конфиг
     * @return string 'connected' | 'created' (БД была создана)
     */
    public function ensureDatabase(array $db): string
    {
        try {
            $this->connect($db)->query('SELECT 1');
            return 'connected';
        } catch (PDOException $e) {
            if (!$this->isUnknownDatabase($db['driver'], $e)) {
                throw new RuntimeException($this->friendlyDbError($e), 0, $e);
            }
        }

        // БД отсутствует — пробуем создать через служебное подключение.
        try {
            $server = $this->connectServer($db);
            if ($db['driver'] === 'mysql') {
                $server->exec('CREATE DATABASE IF NOT EXISTS `' . str_replace('`', '', $db['name']) . '` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
            } else {
                $stmt = $server->prepare('SELECT 1 FROM pg_database WHERE datname = ?');
                $stmt->execute([$db['name']]);
                if ($stmt->fetchColumn() === false) {
                    $server->exec('CREATE DATABASE "' . str_replace('"', '', $db['name']) . '" ENCODING \'UTF8\'');
                }
            }
        } catch (PDOException $e) {
            throw new RuntimeException(
                'База «' . $db['name'] . '» не существует, создать её не удалось: ' . $this->friendlyDbError($e)
                . ' Создайте базу вручную и повторите.',
                0,
                $e,
            );
        }

        // Финальная проверка.
        try {
            $this->connect($db)->query('SELECT 1');
        } catch (PDOException $e) {
            throw new RuntimeException($this->friendlyDbError($e), 0, $e);
        }
        return 'created';
    }

    /** @param array<string, string> $db */
    private function connect(array $db): PDO
    {
        $dsn = $db['driver'] === 'mysql'
            ? "mysql:host={$db['host']};port={$db['port']};dbname={$db['name']};charset={$db['charset']}"
            : "pgsql:host={$db['host']};port={$db['port']};dbname={$db['name']}";
        return new PDO($dsn, $db['user'], $db['pass'], [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_TIMEOUT => 8,
        ]);
    }

    /** Подключение на уровень сервера (без целевой БД) — для CREATE DATABASE. */
    private function connectServer(array $db): PDO
    {
        $dsn = $db['driver'] === 'mysql'
            ? "mysql:host={$db['host']};port={$db['port']};charset={$db['charset']}"
            : "pgsql:host={$db['host']};port={$db['port']};dbname=postgres";
        return new PDO($dsn, $db['user'], $db['pass'], [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_TIMEOUT => 8,
        ]);
    }

    private function isUnknownDatabase(string $driver, PDOException $e): bool
    {
        if ($driver === 'mysql') {
            return ($e->errorInfo[1] ?? 0) === 1049 || str_contains($e->getMessage(), 'Unknown database');
        }
        return ($e->errorInfo[0] ?? '') === '3D000' || str_contains($e->getMessage(), 'does not exist');
    }

    private function friendlyDbError(PDOException $e): string
    {
        $msg = $e->getMessage();
        if (str_contains($msg, 'Access denied') || str_contains($msg, 'authentication failed') || str_contains($msg, 'password authentication')) {
            return 'Неверный логин или пароль БД.';
        }
        if (str_contains($msg, 'Connection refused') || str_contains($msg, 'No connection could be made')
            || str_contains($msg, 'конечный компьютер отверг') || str_contains($msg, 'getaddrinfo')
            || str_contains($msg, 'Name or service not known') || str_contains($msg, 'timed out')) {
            return 'Сервер БД недоступен — проверьте хост и порт.';
        }
        return $msg;
    }

    // ---------------------------------------------------------------
    // Шаг 3. Установка
    // ---------------------------------------------------------------

    /**
     * Полная установка. Валидирует вход, пишет .env, гоняет миграции,
     * создаёт администратора, базовые настройки и install.lock.
     *
     * @param array<string, string> $db     нормализованный конфиг БД (см. normalizeDb)
     * @param array{url: string, name: string} $app
     * @param array{email: string, password: string, name: string} $admin
     * @return string[] лог выполненных шагов
     */
    public function install(array $db, array $app, array $admin): array
    {
        if (is_file($this->root . '/install.lock')) {
            throw new RuntimeException('CMS уже установлена (найден install.lock).');
        }

        // Все текстовые поля должны быть валидным UTF-8 — иначе PostgreSQL/MySQL(utf8mb4)
        // отвергнут вставку с невнятной ошибкой драйвера. Ловим это заранее.
        foreach (['site_name' => $app['name'] ?? '', 'admin_name' => $admin['name'] ?? '', 'admin_email' => $admin['email'] ?? '', 'app_url' => $app['url'] ?? ''] as $label => $val) {
            if ($val !== '' && !mb_check_encoding((string) $val, 'UTF-8')) {
                throw new RuntimeException("Поле «$label» содержит символы не в кодировке UTF-8. Введите текст в UTF-8.");
            }
        }

        $email = strtolower(trim($admin['email']));
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new RuntimeException('Некорректный e-mail администратора.');
        }
        $password = (string) $admin['password'];
        if (mb_strlen($password) < 8) {
            throw new RuntimeException('Пароль администратора — минимум 8 символов.');
        }
        $adminName = trim($admin['name']) !== '' ? trim($admin['name']) : 'Администратор';

        $siteName = trim($app['name']) !== '' ? trim($app['name']) : 'Celena CMS';
        $appUrl = rtrim(trim($app['url']), '/');
        if ($appUrl !== '' && !preg_match('~^https?://[^\s"\']+$~i', $appUrl)) {
            throw new RuntimeException('APP_URL должен начинаться с http:// или https://');
        }

        $log = [];

        // 1. Каталоги
        foreach (['/storage/cache/tpl', '/storage/cache/data', '/storage/cache/http', '/storage/logs', '/storage/sessions', '/public/uploads'] as $d) {
            if (!is_dir($this->root . $d) && !@mkdir($this->root . $d, 0775, true)) {
                throw new RuntimeException('Не удалось создать каталог ' . $d);
            }
        }
        $log[] = 'Каталоги storage/ и public/uploads готовы';

        // 2. Подключение (или создание БД)
        $state = $this->ensureDatabase($db);
        $log[] = $state === 'created' ? 'База данных создана' : 'Подключение к БД успешно';

        // 3. .env
        $this->writeEnv($db, $siteName, $appUrl);
        $log[] = 'Файл .env записан';

        // 4. Миграции ядра и плагинов
        $conn = new Connection([
            'driver' => $db['driver'],
            'host' => $db['host'],
            'port' => $db['port'],
            'name' => $db['name'],
            'user' => $db['user'],
            'pass' => $db['pass'],
            'prefix' => $db['prefix'],
            'charset' => $db['charset'],
        ]);
        $logger = new Logger($this->root . '/storage/logs', 'warning');
        $runner = new MigrationRunner($conn, $logger);
        $ran = $runner->run($this->root . '/core/Database/migrations');
        $log[] = 'Миграции ядра: ' . count($ran) . ' шт.';

        foreach (glob($this->root . '/plugins/*/migrations', GLOB_ONLYDIR) ?: [] as $dir) {
            $slug = basename(dirname($dir));
            $pluginRan = (new MigrationRunner($conn, $logger))->run($dir);
            if ($pluginRan !== []) {
                $log[] = "Миграции плагина $slug: " . count($pluginRan) . ' шт.';
            }
        }

        // 5. Администратор
        $exists = $conn->builder('celena_users')->where('email', '=', $email)->first();
        $now = date('Y-m-d H:i:s');
        if ($exists === null) {
            $conn->builder('celena_users')->insert([
                'email' => $email,
                'password_hash' => Password::hash($password),
                'name' => $adminName,
                'role' => 'admin',
                'is_active' => true,
                'locale' => 'ru',
                'created_at' => $now,
                'updated_at' => $now,
            ]);
            $log[] = "Администратор $email создан";
        } else {
            $conn->builder('celena_users')->where('id', '=', (int) $exists['id'])->update([
                'password_hash' => Password::hash($password),
                'role' => 'admin',
                'is_active' => true,
                'updated_at' => $now,
            ]);
            $log[] = "Пользователь $email уже существовал — пароль обновлён, роль admin";
        }

        // 6. Базовые настройки
        $options = new \Celena\Module\Settings\Options($conn);
        foreach ([
            'site_name' => $siteName,
            'theme' => 'default',
            'locale' => 'ru',
            'posts_per_page' => '10',
            'active_plugins' => '[]',
            'marketplace_url' => 'https://marketplace.celena.io',
            'seo_title' => $siteName,
            'seo_description' => '',
            'seo_keywords' => '',
        ] as $k => $v) {
            if ($options->get($k) === null || $options->get($k) === '') {
                $options->set($k, $v);
            }
        }
        $log[] = 'Базовые настройки сохранены';

        // 7. install.lock — атомарно, защищает от повторной установки
        $lock = $this->root . '/install.lock';
        if (@file_put_contents($lock, 'installed ' . $now . PHP_EOL, LOCK_EX) === false) {
            throw new RuntimeException('Не удалось записать install.lock — создайте файл вручную в корне сайта.');
        }
        $log[] = 'install.lock создан';

        return $log;
    }

    /**
     * Пишет .env атомарно (tmp + rename). Значения с спецсимволами — в кавычках.
     * @param array<string, string> $db
     */
    private function writeEnv(array $db, string $siteName, string $appUrl): void
    {
        $key = base64_encode(random_bytes(32));
        $lines = [
            'APP_NAME=' . $this->envQuote($siteName),
            'APP_ENV=production',
            'APP_DEBUG=false',
            'APP_URL=' . $this->envQuote($appUrl !== '' ? $appUrl : 'http://localhost'),
            'APP_KEY=' . $this->envQuote($key),
            '',
            'DB_DRIVER=' . $db['driver'],
            'DB_HOST=' . $this->envQuote($db['host']),
            'DB_PORT=' . $db['port'],
            'DB_NAME=' . $this->envQuote($db['name']),
            'DB_USER=' . $this->envQuote($db['user']),
            'DB_PASS=' . $this->envQuote($db['pass']),
            'DB_PREFIX=' . $db['prefix'],
            'DB_CHARSET=' . $db['charset'],
            '',
            'CACHE_DRIVER=file',
            'SESSION_DRIVER=file',
            'LOG_LEVEL=warning',
            '',
            'MARKETPLACE_URL=https://marketplace.celena.io',
            'MARKETPLACE_PUBKEY=',
            '',
        ];
        $content = implode("\n", $lines);
        $tmp = $this->root . '/.env.tmp-' . bin2hex(random_bytes(4));
        if (@file_put_contents($tmp, $content, LOCK_EX) === false) {
            throw new RuntimeException('Не удалось записать .env — проверьте права на корень сайта.');
        }
        @chmod($tmp, 0640);
        if (!@rename($tmp, $this->root . '/.env')) {
            @unlink($tmp);
            throw new RuntimeException('Не удалось сохранить .env (rename).');
        }
    }

    /**
     * Значение для .env: парсер Config снимает только окружающие кавычки,
     * эскейпов нет — поэтому кавычки внутри значения недопустимы.
     */
    private function envQuote(string $v): string
    {
        if (str_contains($v, "\n") || str_contains($v, "\r")) {
            throw new RuntimeException('Значения .env не могут содержать перенос строки.');
        }
        if ($v === '' || preg_match('/^[A-Za-z0-9_.\-:\/+=@]+$/', $v)) {
            return $v;
        }
        if (!str_contains($v, '"')) {
            return '"' . $v . '"';
        }
        if (!str_contains($v, "'")) {
            return "'" . $v . "'";
        }
        throw new RuntimeException('Значение не может содержать одновременно одинарные и двойные кавычки.');
    }
}
