<?php

declare(strict_types=1);

const WEBINDIGO_RUNTIME_INSTALLER_VERSION = '2.3.6';
const WEBINDIGO_RUNTIME_PRODUCT = 'webindigo-cms';
const WEBINDIGO_RUNTIME_COMPONENT = 'runtime';
const WEBINDIGO_ACCOUNT_RUNTIME_INSTALLER_URL = 'https://services.webindigo.ru/api/account/runtime/installer';
const WEBINDIGO_RUNTIME_AUTHORIZATION_URL = 'https://webindigo.ru/platform/license/runtime/installer/authorize';
const WEBINDIGO_LICENSE_PUBLIC_KEY_URL = 'https://webindigo.ru/platform/license/public-key';
const WEBINDIGO_CLOUD_BOOTSTRAP_URL = 'https://cloud.webindigo.ru/api/runtime/v1/bootstrap';
const WEBINDIGO_CLOUD_REGISTRATION_URL = 'https://cloud.webindigo.ru/api/runtime/v1/register';
const WEBINDIGO_CLOUD_INSTALLATION_COMPLETE_URL = 'https://cloud.webindigo.ru/api/runtime/v1/install/complete';
const WEBINDIGO_CLOUD_INSTALLATION_COMPLETE_PATH = '/api/runtime/v1/install/complete';
const WEBINDIGO_CLOUD_URL = 'https://cloud.webindigo.ru';
const WEBINDIGO_INSTALLER_GRANT_TYPE = 'public';
const WEBINDIGO_INSTALLER_GRANT = 'public';
const WEBINDIGO_INSTALLER_EXPIRES_AT = 0;
const WEBINDIGO_INSTALLER_REFRESH_URL = 'https://webindigo.ru/download/install.php';
const WEBINDIGO_MAX_PACKAGE_BYTES = 536870912;
const WEBINDIGO_MAX_ARCHIVE_ENTRIES = 20000;
const WEBINDIGO_MAX_UNCOMPRESSED_BYTES = 2147483648;

if (PHP_VERSION_ID < 80200) {
    webindigo_render(
        'Сервер не готов к установке',
        'Для WebIndigo CMS требуется PHP 8.2 или выше. Сейчас используется PHP ' . PHP_VERSION . '.',
        [],
        false,
    );
}

if (session_status() !== PHP_SESSION_ACTIVE) {
    session_name('webindigo_runtime_installer');
    session_set_cookie_params([
        'lifetime' => 0,
        'path' => '/',
        'secure' => webindigo_is_https(),
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
    session_start();
}

if (!is_string($_SESSION['webindigo_runtime_csrf'] ?? null)) {
    $_SESSION['webindigo_runtime_csrf'] = bin2hex(random_bytes(32));
}

$basePath = __DIR__;
$runtimeInstalled = webindigo_runtime_is_installed($basePath);
$installationComplete = is_file(webindigo_installation_completion_path($basePath));
$error = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST' && (!$runtimeInstalled || !$installationComplete)) {
    $downloadPath = null;
    $stagingPath = null;

    try {
        webindigo_verify_request();
        webindigo_assert_installer_access();

        if ($runtimeInstalled && !$installationComplete) {
            webindigo_assert_installation_browser($basePath);
            $completion = webindigo_resume_runtime_completion($basePath);
            webindigo_record_installation_completion($basePath, $completion);
            webindigo_finish_installer();
        }

        webindigo_assert_environment($basePath);
        webindigo_assert_target_directory($basePath);

        $domain = webindigo_current_domain();
        $runtimeUrl = (webindigo_is_https() ? 'https://' : 'http://') . $domain;
        $keys = webindigo_generate_installation_keypair();
        $installId = 'wi-runtime-' . substr($keys['fingerprint'], 0, 32);
        $installerGrant = WEBINDIGO_INSTALLER_GRANT;

        if (WEBINDIGO_INSTALLER_GRANT_TYPE === 'public') {
            $installerGrant = strtolower(trim((string) ($_POST['account_grant'] ?? '')));

            if (preg_match('/^[a-f0-9]{64}$/', $installerGrant) !== 1) {
                throw new RuntimeException(
                    'Сначала войдите в личный кабинет WebIndigo или создайте новый прямо в мастере установки.',
                );
            }
        }

        $domainProof = null;
        $domainProofState = null;

        if (WEBINDIGO_INSTALLER_GRANT_TYPE === 'public') {
            $domainProofState = webindigo_create_domain_proof(
                $basePath,
                $installId,
                $keys['fingerprint'],
            );
            $domainProof = $domainProofState['proof'];
        }

        try {
            $authorizationRequest = [
                'schema' => 2,
                'installer_version' => WEBINDIGO_RUNTIME_INSTALLER_VERSION,
                'product' => WEBINDIGO_RUNTIME_PRODUCT,
                'component' => WEBINDIGO_RUNTIME_COMPONENT,
                'grant_type' => WEBINDIGO_INSTALLER_GRANT_TYPE,
                'installer_grant' => $installerGrant,
                'domain' => $domain,
                'runtime_url' => $runtimeUrl,
                'install_id' => $installId,
                'domain_proof' => $domainProof,
            ];
            $authorizationRequest = webindigo_sign_installation_request(
                $authorizationRequest,
                $keys,
                '/api/license/runtime/installer/authorize',
            );

            $authorization = webindigo_http_json_post(
                WEBINDIGO_RUNTIME_AUTHORIZATION_URL,
                $authorizationRequest,
                1048576,
                120,
            );
        } finally {
            if (is_array($domainProofState)) {
                webindigo_remove_domain_proof($domainProofState);
            }
        }
        $licensingTrust = webindigo_verify_signed_license_response($authorization);
        $package = webindigo_authorized_runtime_package($authorization, $domain, $installId);
        $bootstrapAuthorization = webindigo_bootstrap_authorization($authorization);

        $bootstrap = webindigo_http_json_post(
            WEBINDIGO_CLOUD_BOOTSTRAP_URL,
            [
                'schema' => 1,
                'authorization' => $bootstrapAuthorization,
                'domain' => $domain,
                'runtime_url' => $runtimeUrl,
                'runtime_version' => (string) $package['version'],
                'install_id' => $installId,
                'public_key_fingerprint' => $keys['fingerprint'],
            ],
            1048576,
            60,
        );
        $bootstrap = webindigo_validate_cloud_bootstrap($bootstrap, $domain);

        $suffix = bin2hex(random_bytes(8));
        $downloadPath = $basePath . '/.webindigo-runtime-package-' . $suffix . '.zip';
        $stagingPath = $basePath . '/.webindigo-runtime-stage-' . $suffix;

        webindigo_download_package(
            (string) $package['url'],
            $downloadPath,
            (int) $package['size'],
        );
        webindigo_verify_downloaded_package($downloadPath, $package);
        webindigo_extract_package($downloadPath, $stagingPath);

        $payloadRoot = webindigo_payload_root($stagingPath);
        webindigo_validate_runtime_payload($payloadRoot, (string) $package['version']);

        $registration = webindigo_register_runtime(
            $bootstrap,
            $domain,
            $runtimeUrl,
            $installId,
            $keys,
            (string) $package['version'],
        );

        webindigo_prepare_runtime_state(
            $payloadRoot,
            $basePath,
            $bootstrap,
            $registration,
            $authorization,
            $package,
            $keys,
            $licensingTrust,
            $domain,
            $runtimeUrl,
        );
        webindigo_publish_payload($payloadRoot, $basePath);
        foreach (['index.html', 'robots.txt'] as $placeholder) {
            $placeholderPath = $basePath . '/' . $placeholder;
            if (is_file($placeholderPath) && !is_link($placeholderPath)) {
                @unlink($placeholderPath);
            }
        }

        $completion = webindigo_complete_runtime_installation(
            $installId,
            $keys['fingerprint'],
            $keys['private_key'],
        );
        webindigo_record_installation_completion($basePath, $completion);

        webindigo_remove_tree($stagingPath);
        @unlink($downloadPath);
        webindigo_finish_installer();
    } catch (Throwable $exception) {
        webindigo_remove_tree($stagingPath);
        if (is_string($downloadPath) && is_file($downloadPath)) {
            @unlink($downloadPath);
        }
        error_log('[WebIndigo CMS Installer] ' . $exception->getMessage());
        $error = webindigo_public_error_message($exception->getMessage());
    }
}

$runtimeInstalled = webindigo_runtime_is_installed($basePath);
$installationComplete = is_file(webindigo_installation_completion_path($basePath));
$completionPending = $runtimeInstalled && !$installationComplete;
$checks = webindigo_environment_checks($basePath);
webindigo_render(
    $completionPending
        ? 'Завершение установки WebIndigo CMS'
        : ($runtimeInstalled ? 'WebIndigo CMS уже установлена' : 'Установка WebIndigo CMS'),
    $error,
    $checks,
    !$runtimeInstalled,
    $runtimeInstalled && $installationComplete,
    $completionPending,
);


function webindigo_runtime_is_installed(string $basePath): bool
{
    return is_file($basePath . '/bootstrap/app.php')
        && is_file($basePath . '/vendor/autoload.php')
        && is_file($basePath . '/storage/app/private/runtime/identity.json')
        && is_file($basePath . '/storage/app/private/licensing/entitlement.json')
        && is_file($basePath . '/.env')
        && is_file($basePath . '/VERSION');
}

function webindigo_installation_completion_path(string $basePath): string
{
    return rtrim($basePath, '/\\') . '/storage/app/private/runtime/install-complete.json';
}

/** @return array<string,mixed> */
function webindigo_resume_runtime_completion(string $basePath): array
{
    $identityPath = $basePath . '/storage/app/private/runtime/identity.json';
    $privateKeyPath = $basePath . '/storage/app/private/runtime/installation-private.pem';

    $identityJson = is_file($identityPath) ? file_get_contents($identityPath) : false;
    $privateKeyPem = is_file($privateKeyPath) ? file_get_contents($privateKeyPath) : false;

    if (!is_string($identityJson) || !is_string($privateKeyPem) || trim($privateKeyPem) === '') {
        throw new RuntimeException('Не удалось восстановить защищённые данные установки WebIndigo CMS.');
    }

    try {
        $identity = json_decode($identityJson, true, 64, JSON_THROW_ON_ERROR);
    } catch (JsonException $exception) {
        throw new RuntimeException('Не удалось прочитать данные установки WebIndigo CMS.', previous: $exception);
    }

    if (!is_array($identity)) {
        throw new RuntimeException('Не удалось прочитать данные установки WebIndigo CMS.');
    }

    $installId = trim((string) ($identity['installation_id'] ?? ''));
    $fingerprint = strtolower(trim((string) ($identity['public_key_fingerprint'] ?? '')));

    if (
        preg_match('/^wi-runtime-[a-f0-9]{32}$/', $installId) !== 1
        || preg_match('/^[a-f0-9]{64}$/', $fingerprint) !== 1
    ) {
        throw new RuntimeException('Данные установленной WebIndigo CMS повреждены.');
    }

    try {
        $privateKey = openssl_pkey_get_private($privateKeyPem);
        $details = $privateKey !== false ? openssl_pkey_get_details($privateKey) : false;
        $publicKey = is_array($details) ? trim((string) ($details['key'] ?? '')) : '';
    } catch (Throwable $exception) {
        throw new RuntimeException('Не удалось проверить ключ установленной WebIndigo CMS.', previous: $exception);
    }

    if (
        $privateKey === false
        || $publicKey === ''
        || !hash_equals($fingerprint, hash('sha256', $publicKey))
        || !hash_equals($installId, 'wi-runtime-' . substr($fingerprint, 0, 32))
    ) {
        throw new RuntimeException('Ключ установленной WebIndigo CMS не соответствует её идентификатору.');
    }

    return webindigo_complete_runtime_installation($installId, $fingerprint, $privateKeyPem);
}

/** @return array<string,mixed> */
function webindigo_complete_runtime_installation(
    string $installId,
    string $fingerprint,
    string $privateKeyPem,
): array {
    $body = json_encode(
        ['schema' => 1],
        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR,
    );
    $timestamp = time();
    $nonce = bin2hex(random_bytes(24));
    $contentSha256 = hash('sha256', $body);
    $canonical = webindigo_canonical_json([
        'version' => 1,
        'method' => 'POST',
        'path' => WEBINDIGO_CLOUD_INSTALLATION_COMPLETE_PATH,
        'installation_id' => $installId,
        'timestamp' => $timestamp,
        'nonce' => $nonce,
        'content_sha256' => $contentSha256,
    ]);

    $signature = '';
    try {
        $signed = openssl_sign(
            $canonical,
            $signature,
            $privateKeyPem,
            OPENSSL_ALGO_SHA256,
        );
    } catch (Throwable $exception) {
        throw new RuntimeException('Не удалось подтвердить завершение установки WebIndigo CMS.', previous: $exception);
    }

    if ($signed !== true || $signature === '') {
        throw new RuntimeException('Не удалось подтвердить завершение установки WebIndigo CMS.');
    }

    $response = webindigo_http_signed_json_post(
        WEBINDIGO_CLOUD_INSTALLATION_COMPLETE_URL,
        $body,
        [
            'X-WebIndigo-Key-Id' => $fingerprint,
            'X-WebIndigo-Installation-Id' => $installId,
            'X-WebIndigo-Timestamp' => (string) $timestamp,
            'X-WebIndigo-Nonce' => $nonce,
            'X-WebIndigo-Content-SHA256' => $contentSha256,
            'X-WebIndigo-Signature' => base64_encode($signature),
        ],
        1048576,
        180,
    );

    if (($response['ok'] ?? false) !== true) {
        throw new RuntimeException('WebIndigo не подтвердил завершение установки.');
    }

    if (
        !hash_equals($installId, trim((string) ($response['installation_id'] ?? '')))
        || trim((string) ($response['revision'] ?? '')) === ''
    ) {
        throw new RuntimeException('WebIndigo вернул неполный результат завершения установки.');
    }

    return $response;
}

/** @param array<string,mixed> $completion */
function webindigo_record_installation_completion(string $basePath, array $completion): void
{
    $publicationNumber = filter_var($completion['publication_number'] ?? null, FILTER_VALIDATE_INT);
    $revision = trim((string) ($completion['revision'] ?? ''));

    if ($publicationNumber === false || $publicationNumber < 1 || $revision === '') {
        throw new RuntimeException('Не удалось сохранить результат завершения установки WebIndigo CMS.');
    }

    webindigo_write_json_file(webindigo_installation_completion_path($basePath), [
        'schema' => 1,
        'completed_at' => gmdate('c'),
        'publication_number' => $publicationNumber,
        'revision' => $revision,
    ]);
}

function webindigo_assert_installation_browser(string $basePath): void
{
    $path = rtrim($basePath, '/\\') . '/storage/app/private/runtime/installer-browser.json';
    $secret = (string) ($_SESSION['webindigo_installation_browser'] ?? '');
    $proof = is_file($path) && !is_link($path)
        ? json_decode((string) file_get_contents($path), true)
        : null;
    if (
        preg_match('/^[a-f0-9]{64}$/', $secret) !== 1
        || !is_array($proof)
        || !is_string($proof['secret_hash'] ?? null)
        || !hash_equals($proof['secret_hash'], hash('sha256', $secret))
    ) {
        throw new RuntimeException('Продолжите установку в том браузере, в котором она была начата.');
    }
    webindigo_write_json_file($path, array_replace($proof, ['expires_at' => time() + 1800]));
}


function webindigo_finish_installer(): never
{
    $secret = (string) ($_SESSION['webindigo_installation_browser'] ?? '');
    if (preg_match('/^[a-f0-9]{64}$/', $secret) === 1) {
        setcookie('wi_installer_browser', $secret, [
            'expires' => time() + 1800,
            'path' => '/admin',
            'secure' => webindigo_is_https(),
            'httponly' => true,
            'samesite' => 'Strict',
        ]);
    }
    $_SESSION = [];
    if (session_status() === PHP_SESSION_ACTIVE) {
        session_destroy();
    }

    @unlink(__FILE__);
    header('Location: /admin', true, 303);
    exit;
}


function webindigo_public_error_message(string $message): string
{
    $message = trim($message);

    $message = preg_replace('/\s+HTTP\s+\d{3}\.\s*$/i', '', $message) ?? $message;
    $message = trim($message);

    if ($message === '') {
        return 'Не удалось завершить установку WebIndigo CMS. Повторите попытку.';
    }

    if (preg_match('/runtime|cloud|bootstrap|identity|entitlement|signed\s+release|distribution|rsa/i', $message) === 1) {
        return 'Не удалось завершить установку WebIndigo CMS. Повторите попытку. Если ошибка сохраняется, скачайте актуальный install.php.';
    }

    return $message;
}

function webindigo_verify_request(): void
{
    $expected = (string) ($_SESSION['webindigo_runtime_csrf'] ?? '');
    $actual = (string) ($_POST['_token'] ?? '');

    if ($expected === '' || $actual === '' || !hash_equals($expected, $actual)) {
        throw new RuntimeException('Страница установки устарела. Обновите её и повторите попытку.');
    }
}

function webindigo_assert_installer_access(): void
{
    if (WEBINDIGO_INSTALLER_GRANT_TYPE === 'public') {
        if (WEBINDIGO_INSTALLER_GRANT !== 'public') {
            throw new RuntimeException('Публичный install.php повреждён. Скачайте актуальный установщик WebIndigo CMS.');
        }

        return;
    }

    if (!in_array(WEBINDIGO_INSTALLER_GRANT_TYPE, ['trial', 'commercial'], true)) {
        throw new RuntimeException('Этот install.php имеет неподдерживаемый режим установки.');
    }

    if (preg_match('/^[a-f0-9]{64}$/', WEBINDIGO_INSTALLER_GRANT) !== 1) {
        throw new RuntimeException('Персональное разрешение на установку отсутствует или повреждено. Скачайте актуальный install.php.');
    }

    if (WEBINDIGO_INSTALLER_EXPIRES_AT < 1 || time() >= WEBINDIGO_INSTALLER_EXPIRES_AT) {
        throw new RuntimeException('Срок действия персонального install.php истёк. Скачайте актуальный файл.');
    }
}

/** @return array{proof:string,file:string} */
function webindigo_create_domain_proof(
    string $basePath,
    string $installId,
    string $fingerprint,
): array {
    if (
        preg_match('/^wi-runtime-[a-f0-9]{32}$/', $installId) !== 1
        || preg_match('/^[a-f0-9]{64}$/', $fingerprint) !== 1
    ) {
        throw new RuntimeException('Не удалось подготовить подтверждение домена.');
    }

    $proof = bin2hex(random_bytes(32));
    $file = $basePath . '/webindigo-install-proof-' . $installId . '.txt';
    $content = "webindigo-install-proof\n"
        . $installId . "\n"
        . $fingerprint . "\n"
        . $proof . "\n";

    if (file_put_contents($file, $content, LOCK_EX) !== strlen($content)) {
        @unlink($file);
        throw new RuntimeException('Не удалось записать подтверждение домена.');
    }

    @chmod($file, 0644);

    return [
        'proof' => $proof,
        'file' => $file,
    ];
}

/** @param array{file?:string} $state */
function webindigo_remove_domain_proof(array $state): void
{
    $file = (string) ($state['file'] ?? '');

    if ($file !== '' && is_file($file) && !is_link($file)) {
        @unlink($file);
    }
}

function webindigo_assert_environment(string $basePath): void
{
    foreach (['ctype', 'curl', 'fileinfo', 'gd', 'mbstring', 'openssl', 'pdo', 'tokenizer', 'xml', 'zip'] as $extension) {
        if (!extension_loaded($extension)) {
            throw new RuntimeException('На сервере отсутствует обязательное PHP-расширение: ' . $extension . '.');
        }
    }

    if (!is_writable($basePath)) {
        throw new RuntimeException('Корневой каталог сайта недоступен для записи PHP-процессу.');
    }

    if (!webindigo_is_https() && webindigo_current_domain() !== 'localhost') {
        throw new RuntimeException('Установка WebIndigo CMS разрешена только по HTTPS.');
    }
}

function webindigo_assert_target_directory(string $basePath): void
{
    foreach (['bootstrap/app.php', 'vendor/autoload.php', 'storage/app/private/runtime/identity.json'] as $relative) {
        if (file_exists($basePath . '/' . $relative)) {
            throw new RuntimeException('В каталоге уже обнаружена установленная WebIndigo CMS.');
        }
    }

    $allowed = [
        '.', '..', 'install.php', '.well-known', 'cgi-bin', 'index.html', 'robots.txt', '.htaccess',
    ];
    $entries = scandir($basePath);

    if (!is_array($entries)) {
        throw new RuntimeException('Не удалось проверить содержимое корневого каталога сайта.');
    }

    foreach ($entries as $entry) {
        if (in_array($entry, $allowed, true) || str_starts_with($entry, '.webindigo-runtime-')) {
            continue;
        }

        throw new RuntimeException(
            'Каталог сайта не пуст. Для установки WebIndigo CMS используйте отдельный чистый корневой каталог сайта. Найден объект: ' . $entry . '.',
        );
    }
}

function webindigo_environment_checks(string $basePath): array
{
    $checks = [
        ['name' => 'PHP 8.2+', 'ok' => PHP_VERSION_ID >= 80200, 'value' => PHP_VERSION],
        ['name' => 'Запись в каталог сайта', 'ok' => is_writable($basePath), 'value' => is_writable($basePath) ? 'доступна' : 'нет'],
        ['name' => 'HTTPS', 'ok' => webindigo_is_https() || webindigo_current_domain_safe() === 'localhost', 'value' => webindigo_is_https() ? 'включён' : 'не обнаружен'],
    ];

    foreach (['ctype', 'curl', 'fileinfo', 'mbstring', 'openssl', 'pdo', 'tokenizer', 'xml', 'zip'] as $extension) {
        $checks[] = [
            'name' => 'PHP ' . $extension,
            'ok' => extension_loaded($extension),
            'value' => extension_loaded($extension) ? 'есть' : 'нет',
        ];
    }

    $publicInstaller = WEBINDIGO_INSTALLER_GRANT_TYPE === 'public'
        && WEBINDIGO_INSTALLER_GRANT === 'public';
    $personalized = in_array(WEBINDIGO_INSTALLER_GRANT_TYPE, ['trial', 'commercial'], true)
        && preg_match('/^[a-f0-9]{64}$/', WEBINDIGO_INSTALLER_GRANT) === 1
        && WEBINDIGO_INSTALLER_EXPIRES_AT > time();
    $installerReady = $publicInstaller || $personalized;

    $checks[] = [
        'name' => 'Установочный файл',
        'ok' => $installerReady,
        'value' => $publicInstaller
            ? 'готов'
            : ($personalized ? 'персональное разрешение действует' : 'требуется актуальный install.php'),
    ];

    return $checks;
}

/** @return array{private_key:string,public_key:string,fingerprint:string} */
function webindigo_generate_installation_keypair(): array
{
    $cached = $_SESSION['webindigo_runtime_installation_keys'] ?? null;

    if (is_array($cached)) {
        $privateKey = (string) ($cached['private_key'] ?? '');
        $publicKey = trim((string) ($cached['public_key'] ?? ''));
        $fingerprint = strtolower(trim((string) ($cached['fingerprint'] ?? '')));

        try {
            $private = $privateKey !== '' ? openssl_pkey_get_private($privateKey) : false;
            $details = $private !== false ? openssl_pkey_get_details($private) : false;
            $derivedPublicKey = is_array($details) ? trim((string) ($details['key'] ?? '')) : '';
        } catch (Throwable) {
            $derivedPublicKey = '';
        }

        if (
            $privateKey !== ''
            && $publicKey !== ''
            && preg_match('/^[a-f0-9]{64}$/', $fingerprint) === 1
            && $derivedPublicKey !== ''
            && hash_equals($publicKey, $derivedPublicKey)
            && hash_equals($fingerprint, hash('sha256', $publicKey))
        ) {
            return [
                'private_key' => $privateKey,
                'public_key' => $publicKey,
                'fingerprint' => $fingerprint,
            ];
        }

        unset($_SESSION['webindigo_runtime_installation_keys']);
    }

    $resource = openssl_pkey_new([
        'private_key_type' => OPENSSL_KEYTYPE_RSA,
        'private_key_bits' => 3072,
    ]);

    if ($resource === false) {
        throw new RuntimeException('Не удалось создать криптографический ключ Runtime.');
    }

    $privateKey = '';
    if (!openssl_pkey_export($resource, $privateKey)) {
        throw new RuntimeException('Не удалось экспортировать закрытый ключ Runtime.');
    }

    $details = openssl_pkey_get_details($resource);
    $publicKey = is_array($details) ? trim((string) ($details['key'] ?? '')) : '';

    if ($publicKey === '') {
        throw new RuntimeException('Не удалось получить открытый ключ Runtime.');
    }

    $keys = [
        'private_key' => $privateKey,
        'public_key' => $publicKey,
        'fingerprint' => hash('sha256', $publicKey),
    ];

    // Keep the installation identity stable across retries of the same
    // personalized installer. PHP session storage is outside the document root
    // on normal hosting configurations and is destroyed after successful setup.
    $_SESSION['webindigo_runtime_installation_keys'] = $keys;

    return $keys;
}

/** @param array<string,mixed> $payload @param array{private_key:string,public_key:string,fingerprint:string} $keys */
function webindigo_sign_installation_request(array $payload, array $keys, string $path): array
{
    $auth = [
        'algorithm' => 'RS256',
        'key_id' => 'wi-install-' . substr($keys['fingerprint'], 0, 16),
        'nonce' => bin2hex(random_bytes(32)),
        'timestamp' => time(),
        'method' => 'POST',
        'path' => $path,
        'public_key' => $keys['public_key'],
        'payload_hash' => null,
        'value' => null,
    ];
    $payload['request_auth'] = $auth;
    $canonical = webindigo_canonical_json($payload);
    $payload['request_auth']['payload_hash'] = hash('sha256', $canonical);

    $signature = '';
    if (!openssl_sign($canonical, $signature, $keys['private_key'], OPENSSL_ALGO_SHA256)) {
        throw new RuntimeException('Не удалось подписать запрос Runtime к серверу лицензирования.');
    }
    $payload['request_auth']['value'] = base64_encode($signature);

    return $payload;
}

/** @return array{key_id:string,public_key:string,fingerprint:string} */
function webindigo_verify_signed_license_response(array $payload): array
{
    $signature = is_array($payload['signature'] ?? null) ? $payload['signature'] : [];
    $algorithm = trim((string) ($signature['algorithm'] ?? ''));
    $keyId = trim((string) ($signature['key_id'] ?? ''));
    $payloadHash = strtolower(trim((string) ($signature['payload_hash'] ?? '')));
    $signatureValue = base64_decode((string) ($signature['value'] ?? ''), true);

    if (
        $algorithm !== 'RS256'
        || $keyId === ''
        || preg_match('/^[a-f0-9]{64}$/', $payloadHash) !== 1
        || !is_string($signatureValue)
        || $signatureValue === ''
    ) {
        throw new RuntimeException('Ответ сервера лицензирования не содержит корректной подписи WebIndigo.');
    }

    $keyResponse = json_decode(
        webindigo_http_string(WEBINDIGO_LICENSE_PUBLIC_KEY_URL, 1048576, 20),
        true,
        16,
        JSON_THROW_ON_ERROR,
    );

    if (
        !is_array($keyResponse)
        || !($keyResponse['ok'] ?? false)
        || trim((string) ($keyResponse['algorithm'] ?? '')) !== 'RS256'
        || !hash_equals($keyId, trim((string) ($keyResponse['key_id'] ?? '')))
    ) {
        throw new RuntimeException('Не удалось подтвердить публичный ключ WebIndigo Licensing.');
    }

    $publicKey = trim((string) ($keyResponse['public_key'] ?? ''));
    if ($publicKey === '' || openssl_pkey_get_public($publicKey) === false) {
        throw new RuntimeException('Публичный ключ WebIndigo Licensing повреждён.');
    }

    $unsigned = $payload;
    $unsigned['signature'] = null;
    $canonical = webindigo_canonical_json($unsigned);

    if (!hash_equals($payloadHash, hash('sha256', $canonical))) {
        throw new RuntimeException('Подписанный ответ WebIndigo был изменён.');
    }

    if (openssl_verify($canonical, $signatureValue, $publicKey, OPENSSL_ALGO_SHA256) !== 1) {
        throw new RuntimeException('Подпись WebIndigo Licensing не подтверждена.');
    }

    return [
        'key_id' => $keyId,
        'public_key' => $publicKey,
        'fingerprint' => hash('sha256', $publicKey),
    ];
}

/** @return array{url:string,sha256:string,size:int,version:string,distribution_id:string,release_trust:array} */
function webindigo_authorized_runtime_package(array $authorization, string $domain, string $installId): array
{
    if (!($authorization['ok'] ?? false)) {
        throw new RuntimeException(
            trim((string) ($authorization['message'] ?? 'WebIndigo Licensing не разрешил установку Runtime.')),
        );
    }

    if (
        ($authorization['schema'] ?? null) !== 2
        || trim((string) ($authorization['product'] ?? '')) !== WEBINDIGO_RUNTIME_PRODUCT
        || trim((string) ($authorization['component'] ?? '')) !== WEBINDIGO_RUNTIME_COMPONENT
    ) {
        throw new RuntimeException('Сервер вернул разрешение для другой поставки WebIndigo.');
    }

    $authorizedDomain = strtolower(trim((string) data_get_array($authorization, ['authorization', 'domain'], '')));
    $authorizedInstallId = trim((string) data_get_array($authorization, ['authorization', 'install_id'], ''));
    if (!hash_equals($domain, $authorizedDomain) || !hash_equals($installId, $authorizedInstallId)) {
        throw new RuntimeException('Разрешение на установку выдано для другого домена или Runtime identity.');
    }

    $package = is_array($authorization['package'] ?? null) ? $authorization['package'] : [];
    $distribution = is_array($authorization['distribution'] ?? null) ? $authorization['distribution'] : [];
    $release = is_array($authorization['release'] ?? null) ? $authorization['release'] : [];
    $url = trim((string) ($package['url'] ?? ''));
    $sha256 = strtolower(trim((string) ($package['sha256'] ?? '')));
    $size = filter_var($package['size'] ?? null, FILTER_VALIDATE_INT);
    $version = trim((string) ($package['version'] ?? ($release['version'] ?? '')));
    $distributionId = strtoupper(trim((string) ($distribution['id'] ?? '')));

    if (!webindigo_trusted_https_url($url, 'webindigo.ru')) {
        throw new RuntimeException('WebIndigo Licensing вернул недоверенный адрес Runtime-пакета.');
    }
    if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) {
        throw new RuntimeException('Runtime-пакет не содержит корректную SHA-256.');
    }
    if (!is_int($size) || $size < 1 || $size > WEBINDIGO_MAX_PACKAGE_BYTES) {
        throw new RuntimeException('Размер Runtime-пакета недопустим.');
    }
    if ($version === '' || preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $version) !== 1) {
        throw new RuntimeException('Версия Runtime-пакета недопустима.');
    }
    if (preg_match('/^WI-DIST-[A-F0-9]{32}$/', $distributionId) !== 1) {
        throw new RuntimeException('Licensing не вернул корректный Distribution ID установки.');
    }

    $releaseManifest = is_array($release['manifest'] ?? null) ? $release['manifest'] : [];
    $releaseTrust = is_array($release['trust'] ?? null) ? $release['trust'] : [];
    webindigo_verify_runtime_release_manifest(
        $releaseManifest,
        $releaseTrust,
        $version,
        $sha256,
        $size,
    );

    return [
        'url' => $url,
        'sha256' => $sha256,
        'size' => $size,
        'version' => $version,
        'distribution_id' => $distributionId,
        'release_trust' => $releaseTrust,
    ];
}

function webindigo_verify_runtime_release_manifest(
    array $manifest,
    array $trust,
    string $version,
    string $sha256,
    int $size,
): void {
    if (
        ($manifest['schema'] ?? null) !== 1
        || trim((string) ($manifest['type'] ?? '')) !== 'runtime_release_manifest'
        || trim((string) ($manifest['product'] ?? '')) !== WEBINDIGO_RUNTIME_PRODUCT
        || trim((string) ($manifest['component'] ?? '')) !== WEBINDIGO_RUNTIME_COMPONENT
        || !hash_equals($version, trim((string) ($manifest['version'] ?? '')))
        || !hash_equals($sha256, strtolower(trim((string) data_get_array($manifest, ['package', 'sha256'], ''))))
        || $size !== (int) data_get_array($manifest, ['package', 'size'], 0)
    ) {
        throw new RuntimeException('Signed Runtime Release manifest не соответствует разрешённому пакету.');
    }

    webindigo_validate_trust_key($trust);

    if (trim((string) ($trust['algorithm'] ?? '')) !== 'RS256') {
        throw new RuntimeException('WebIndigo Releases использует неподдерживаемый алгоритм подписи.');
    }

    $publicKey = trim((string) ($trust['public_key'] ?? ''));
    $key = openssl_pkey_get_public($publicKey);
    $details = $key !== false ? openssl_pkey_get_details($key) : false;
    if (!is_array($details) || ($details['type'] ?? null) !== OPENSSL_KEYTYPE_RSA || (int) ($details['bits'] ?? 0) < 3072) {
        throw new RuntimeException('Публичный ключ WebIndigo Releases должен быть RSA-3072 или сильнее.');
    }

    $signature = is_array($manifest['signature'] ?? null) ? $manifest['signature'] : [];
    $keyId = trim((string) ($signature['key_id'] ?? ''));
    $payloadHash = strtolower(trim((string) ($signature['payload_hash'] ?? '')));
    $value = base64_decode((string) ($signature['value'] ?? ''), true);

    if (
        trim((string) ($signature['algorithm'] ?? '')) !== 'RS256'
        || !hash_equals(trim((string) ($trust['key_id'] ?? '')), $keyId)
        || preg_match('/^[a-f0-9]{64}$/', $payloadHash) !== 1
        || !is_string($value)
        || $value === ''
    ) {
        throw new RuntimeException('Signed Runtime Release manifest содержит некорректную подпись.');
    }

    $unsigned = $manifest;
    $unsigned['signature'] = null;
    $canonical = webindigo_canonical_json($unsigned);

    if (!hash_equals($payloadHash, hash('sha256', $canonical))) {
        throw new RuntimeException('Signed Runtime Release manifest был изменён.');
    }

    if ($key === false || openssl_verify($canonical, $value, $key, OPENSSL_ALGO_SHA256) !== 1) {
        throw new RuntimeException('Подпись WebIndigo Releases не подтверждена.');
    }
}

function webindigo_bootstrap_authorization(array $authorization): array
{
    $bootstrap = $authorization['cloud_bootstrap'] ?? null;
    if (!is_array($bootstrap) || !is_array($bootstrap['signature'] ?? null)) {
        throw new RuntimeException('WebIndigo Licensing не выдал подписанное разрешение для Cloud bootstrap.');
    }

    return $bootstrap;
}

function webindigo_validate_cloud_bootstrap(array $payload, string $domain): array
{
    if (!($payload['ok'] ?? false)) {
        throw new RuntimeException(trim((string) ($payload['message'] ?? 'WebIndigo Cloud отклонил bootstrap Runtime.')));
    }

    $siteId = trim((string) ($payload['site_id'] ?? ''));
    $token = trim((string) ($payload['registration_token'] ?? ''));
    $responseDomain = strtolower(trim((string) ($payload['domain'] ?? '')));

    if ($siteId === '' || strlen($siteId) > 128 || $token === '' || strlen($token) > 256) {
        throw new RuntimeException('WebIndigo Cloud вернул неполные данные регистрации Runtime.');
    }
    if (!hash_equals($domain, $responseDomain)) {
        throw new RuntimeException('WebIndigo Cloud подготовил bootstrap для другого домена.');
    }

    foreach (['publication', 'control'] as $type) {
        $trust = data_get_array($payload, ['trust', $type], null);
        if (!is_array($trust)) {
            throw new RuntimeException('WebIndigo Cloud не передал доверенный публичный ключ ' . $type . '.');
        }
        webindigo_validate_trust_key($trust);
    }

    return $payload;
}

function webindigo_validate_trust_key(array $trust): void
{
    $keyId = trim((string) ($trust['key_id'] ?? ''));
    $publicKey = trim((string) ($trust['public_key'] ?? ''));
    $fingerprint = strtolower(trim((string) ($trust['fingerprint'] ?? '')));

    if (
        preg_match('/^[A-Za-z0-9._-]{1,128}$/', $keyId) !== 1
        || preg_match('/^[a-f0-9]{64}$/', $fingerprint) !== 1
        || $publicKey === ''
        || openssl_pkey_get_public($publicKey) === false
        || !hash_equals($fingerprint, hash('sha256', $publicKey))
    ) {
        throw new RuntimeException('WebIndigo передал некорректный доверенный публичный ключ.');
    }
}

/** @param array{private_key:string,public_key:string,fingerprint:string} $keys */
function webindigo_register_runtime(
    array $bootstrap,
    string $domain,
    string $runtimeUrl,
    string $installId,
    array $keys,
    string $runtimeVersion,
): array {
    $expectedInstallId = 'wi-runtime-' . substr($keys['fingerprint'], 0, 32);
    if (!hash_equals($expectedInstallId, $installId)) {
        throw new RuntimeException('Runtime Install ID не соответствует RSA-ключу установки.');
    }

    $payload = [
        'token' => (string) $bootstrap['registration_token'],
        'domain' => $domain,
        'install_id' => $installId,
        'runtime_url' => $runtimeUrl,
        'runtime_version' => $runtimeVersion,
        'protocol_version' => 1,
        'public_key_pem' => $keys['public_key'],
        'public_key_fingerprint' => $keys['fingerprint'],
        'timestamp' => time(),
        'nonce' => bin2hex(random_bytes(32)),
    ];
    $canonical = webindigo_canonical_json($payload);
    $signature = '';
    if (!openssl_sign($canonical, $signature, $keys['private_key'], OPENSSL_ALGO_SHA256)) {
        throw new RuntimeException('Не удалось подписать регистрацию Runtime в WebIndigo Cloud.');
    }
    $payload['proof'] = [
        'algorithm' => 'RS256',
        'value' => base64_encode($signature),
    ];

    $response = webindigo_http_json_post(
        WEBINDIGO_CLOUD_REGISTRATION_URL,
        $payload,
        1048576,
        60,
    );

    if (!($response['ok'] ?? false)) {
        throw new RuntimeException(trim((string) ($response['message'] ?? 'WebIndigo Cloud не зарегистрировал Runtime.')));
    }

    foreach (['site_id', 'installation_id', 'domain', 'runtime_url'] as $required) {
        if (trim((string) ($response[$required] ?? '')) === '') {
            throw new RuntimeException('WebIndigo Cloud вернул неполный результат регистрации Runtime.');
        }
    }

    if (!hash_equals($domain, strtolower(trim((string) $response['domain'])))) {
        throw new RuntimeException('Результат регистрации Runtime относится к другому домену.');
    }

    if (!hash_equals($installId, trim((string) $response['installation_id']))) {
        throw new RuntimeException('WebIndigo Cloud вернул другую identity Runtime-установки.');
    }

    return $response;
}

function webindigo_prepare_runtime_state(
    string $payloadRoot,
    string $finalRoot,
    array $bootstrap,
    array $registration,
    array $authorization,
    array $package,
    array $keys,
    array $licensingTrust,
    string $domain,
    string $runtimeUrl,
): void {
    $runtimePrivateDirectory = $payloadRoot . '/storage/app/private/runtime';
    $licensingDirectory = $payloadRoot . '/storage/app/private/licensing';
    $publicationKeysDirectory = $payloadRoot . '/resources/publication-keys';
    $runtimeKeysDirectory = $payloadRoot . '/resources/runtime-keys';
    $licensingKeysDirectory = $payloadRoot . '/resources/licensing-keys';
    $releaseKeysDirectory = $payloadRoot . '/resources/release-keys';

    foreach ([$runtimePrivateDirectory, $licensingDirectory, $publicationKeysDirectory, $runtimeKeysDirectory, $licensingKeysDirectory, $releaseKeysDirectory] as $directory) {
        if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
            throw new RuntimeException('Не удалось подготовить защищённый каталог Runtime.');
        }
    }

    // Секрет принадлежит только браузеру, прошедшему авторизованный мастер.
    $browserSecret = bin2hex(random_bytes(32));
    $_SESSION['webindigo_installation_browser'] = $browserSecret;
    webindigo_write_json_file($runtimePrivateDirectory . '/installer-browser.json', [
        'schema' => 1,
        'secret_hash' => hash('sha256', $browserSecret),
        'expires_at' => time() + 1800,
        'consumed_at' => null,
    ]);

    webindigo_write_private_file($runtimePrivateDirectory . '/installation-private.pem', $keys['private_key']);
    webindigo_write_private_file($runtimePrivateDirectory . '/installation-public.pem', $keys['public_key'] . PHP_EOL, 0644);

    $publicationTrust = data_get_array($bootstrap, ['trust', 'publication'], []);
    $controlTrust = data_get_array($bootstrap, ['trust', 'control'], []);
    webindigo_validate_trust_key($publicationTrust);
    webindigo_validate_trust_key($controlTrust);

    webindigo_validate_trust_key($licensingTrust);
    $releaseTrust = is_array($package['release_trust'] ?? null) ? $package['release_trust'] : [];
    webindigo_validate_trust_key($releaseTrust);

    $publicationKeyPath = $publicationKeysDirectory . '/' . $publicationTrust['key_id'] . '.pem';
    $controlKeyPath = $runtimeKeysDirectory . '/' . $controlTrust['key_id'] . '.pem';
    $licensingKeyPath = $licensingKeysDirectory . '/' . $licensingTrust['key_id'] . '.pem';
    $releaseKeyPath = $releaseKeysDirectory . '/' . $releaseTrust['key_id'] . '.pem';
    $finalPublicationKeyPath = $finalRoot . '/resources/publication-keys/' . $publicationTrust['key_id'] . '.pem';
    $finalControlKeyPath = $finalRoot . '/resources/runtime-keys/' . $controlTrust['key_id'] . '.pem';
    $finalLicensingKeyPath = $finalRoot . '/resources/licensing-keys/' . $licensingTrust['key_id'] . '.pem';
    $finalReleaseKeyPath = $finalRoot . '/resources/release-keys/' . $releaseTrust['key_id'] . '.pem';
    $finalEntitlementPath = $finalRoot . '/storage/app/private/licensing/entitlement.json';
    webindigo_write_private_file($publicationKeyPath, trim((string) $publicationTrust['public_key']) . PHP_EOL, 0644);
    webindigo_write_private_file($controlKeyPath, trim((string) $controlTrust['public_key']) . PHP_EOL, 0644);
    webindigo_write_private_file($licensingKeyPath, trim((string) $licensingTrust['public_key']) . PHP_EOL, 0644);
    webindigo_write_private_file($releaseKeyPath, trim((string) $releaseTrust['public_key']) . PHP_EOL, 0644);

    $identity = [
        'schema' => 1,
        'site_id' => (string) $registration['site_id'],
        'installation_id' => (string) $registration['installation_id'],
        'licensing_install_id' => (string) data_get_array($authorization, ['authorization', 'install_id'], ''),
        'first_domain' => webindigo_primary_domain($domain),
        'domain' => webindigo_primary_domain((string) data_get_array(
            $authorization,
            ['entitlement', 'binding', 'primary_domain'],
            $domain,
        )),
        'cloud_domain' => webindigo_primary_domain((string) data_get_array(
            $authorization,
            ['entitlement', 'binding', 'primary_domain'],
            $domain,
        )),
        'runtime_url' => $runtimeUrl,
        'cloud_url' => WEBINDIGO_CLOUD_URL,
        'protocol_version' => 1,
        'public_key_fingerprint' => $keys['fingerprint'],
        'distribution_id' => (string) $package['distribution_id'],
        'release_key_fingerprint' => (string) $releaseTrust['fingerprint'],
        'registered_at' => gmdate('c'),
    ];
    webindigo_write_json_file($runtimePrivateDirectory . '/identity.json', $identity);

    $entitlement = $authorization['entitlement'] ?? null;
    $entitlementPrimaryDomain = is_array($entitlement)
        ? webindigo_primary_domain((string) data_get_array(
            $entitlement,
            ['binding', 'primary_domain'],
            '',
        ))
        : '';
    if (
        !is_array($entitlement)
        || !is_array($entitlement['signature'] ?? null)
        || ($entitlement['schema'] ?? null) !== 2
        || trim((string) ($entitlement['type'] ?? '')) !== 'runtime_license_entitlement'
        || trim((string) ($entitlement['product'] ?? '')) !== WEBINDIGO_RUNTIME_PRODUCT
        || trim((string) ($entitlement['component'] ?? '')) !== WEBINDIGO_RUNTIME_COMPONENT
        || !hash_equals(
            (string) data_get_array($authorization, ['authorization', 'install_id'], ''),
            trim((string) ($entitlement['install_id'] ?? '')),
        )
        || !hash_equals(
            $keys['fingerprint'],
            strtolower(trim((string) data_get_array($entitlement, ['binding', 'installation_key_fingerprint'], ''))),
        )
        || $entitlementPrimaryDomain === ''
        || !hash_equals(webindigo_primary_domain($domain), $entitlementPrimaryDomain)
    ) {
        throw new RuntimeException('WebIndigo Licensing не выдал корректное локальное entitlement для Runtime.');
    }
    webindigo_write_json_file($licensingDirectory . '/entitlement.json', $entitlement);

    $appKey = 'base64:' . base64_encode(random_bytes(32));
    $env = [
        'APP_NAME' => 'WebIndigo Runtime',
        'APP_ENV' => 'production',
        'APP_KEY' => $appKey,
        'APP_DEBUG' => 'false',
        'APP_URL' => $runtimeUrl,
        'LOG_CHANNEL' => 'stack',
        'LOG_LEVEL' => 'warning',
        'CACHE_STORE' => 'file',
        'WEBINDIGO_SITE_ID' => (string) $registration['site_id'],
        'WEBINDIGO_INSTALLATION_ID' => (string) $registration['installation_id'],
        'WEBINDIGO_CLOUD_URL' => WEBINDIGO_CLOUD_URL,
        'WEBINDIGO_RUNTIME_TEMPLATE' => 'default',
        'WEBINDIGO_TEMPLATE_MAX_ARCHIVE_BYTES' => '104857600',
        'WEBINDIGO_TEMPLATE_MAX_EXTRACTED_BYTES' => '268435456',
        'WEBINDIGO_TEMPLATE_MAX_ENTRIES' => '5000',
        'WEBINDIGO_PUBLICATION_KEY_ID' => (string) $publicationTrust['key_id'],
        'WEBINDIGO_PUBLICATION_PUBLIC_KEY' => $finalPublicationKeyPath,
        'WEBINDIGO_PUBLICATION_PUBLIC_KEY_FINGERPRINT' => (string) $publicationTrust['fingerprint'],
        'WEBINDIGO_PUBLICATION_MAX_PAYLOAD_BYTES' => '1073741824',
        'WEBINDIGO_RUNTIME_CONTROL_KEY_ID' => (string) $controlTrust['key_id'],
        'WEBINDIGO_RUNTIME_CONTROL_PUBLIC_KEY' => $finalControlKeyPath,
        'WEBINDIGO_RUNTIME_CONTROL_PUBLIC_KEY_FINGERPRINT' => (string) $controlTrust['fingerprint'],
        'WEBINDIGO_RUNTIME_MAX_CLOCK_SKEW_SECONDS' => '300',
        'WEBINDIGO_RUNTIME_MAX_ARCHIVE_BYTES' => '1073741824',
        'WEBINDIGO_RUNTIME_MAX_ARCHIVE_ENTRIES' => '10000',
        'WEBINDIGO_LICENSE_KEY_ID' => (string) $licensingTrust['key_id'],
        'WEBINDIGO_LICENSE_PUBLIC_KEY_PATH' => $finalLicensingKeyPath,
        'WEBINDIGO_LICENSE_ENTITLEMENT_PATH' => $finalEntitlementPath,
        'WEBINDIGO_RELEASE_KEY_ID' => (string) $releaseTrust['key_id'],
        'WEBINDIGO_RELEASE_PUBLIC_KEY_PATH' => $finalReleaseKeyPath,
        'WEBINDIGO_RELEASE_PUBLIC_KEY_FINGERPRINT' => (string) $releaseTrust['fingerprint'],
    ];
    webindigo_write_env($payloadRoot . '/.env', $env);

    foreach ([
        $payloadRoot . '/bootstrap/cache',
        $payloadRoot . '/storage/framework/cache/data',
        $payloadRoot . '/storage/framework/sessions',
        $payloadRoot . '/storage/framework/views',
        $payloadRoot . '/storage/logs',
    ] as $directory) {
        if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
            throw new RuntimeException('Не удалось создать рабочий каталог Runtime: ' . basename($directory) . '.');
        }
        @chmod($directory, 0775);
    }
}

function webindigo_download_package(string $url, string $target, int $expectedSize): void
{
    if (!webindigo_trusted_https_url($url, 'webindigo.ru')) {
        throw new RuntimeException('Адрес Runtime-пакета не принадлежит WebIndigo Services.');
    }

    $handle = fopen($target, 'xb');
    if (!is_resource($handle)) {
        throw new RuntimeException('Не удалось создать временный файл Runtime-пакета.');
    }

    $written = 0;
    $curl = curl_init($url);
    if ($curl === false) {
        fclose($handle);
        @unlink($target);
        throw new RuntimeException('Не удалось подготовить загрузку Runtime-пакета.');
    }

    curl_setopt_array($curl, [
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS => 3,
        CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
        CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
        CURLOPT_CONNECTTIMEOUT => 15,
        CURLOPT_TIMEOUT => 300,
        CURLOPT_USERAGENT => 'WebIndigo-Runtime-Installer/' . WEBINDIGO_RUNTIME_INSTALLER_VERSION,
        CURLOPT_WRITEFUNCTION => static function ($curlHandle, string $chunk) use ($handle, &$written): int {
            $length = strlen($chunk);
            $written += $length;
            if ($written > WEBINDIGO_MAX_PACKAGE_BYTES) {
                return 0;
            }
            $result = fwrite($handle, $chunk);
            return $result === false ? 0 : $result;
        },
    ]);

    try {
        $ok = curl_exec($curl);
        $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        if ($ok === false || $status < 200 || $status >= 300) {
            throw new RuntimeException('Не удалось скачать Runtime-пакет. HTTP ' . $status . '.');
        }
    } finally {
        curl_close($curl);
        fclose($handle);
    }

    if ($written !== $expectedSize) {
        @unlink($target);
        throw new RuntimeException('Размер загруженного Runtime-пакета не совпал с разрешённым.');
    }
}

function webindigo_verify_downloaded_package(string $path, array $package): void
{
    $size = filesize($path);
    $hash = hash_file('sha256', $path);

    if (!is_int($size) || $size !== (int) $package['size']) {
        throw new RuntimeException('Размер Runtime-пакета изменился при загрузке.');
    }
    if (!is_string($hash) || !hash_equals((string) $package['sha256'], strtolower($hash))) {
        throw new RuntimeException('SHA-256 Runtime-пакета не совпала. Установка остановлена.');
    }
}

function webindigo_extract_package(string $path, string $target): void
{
    if (!mkdir($target, 0700, true) && !is_dir($target)) {
        throw new RuntimeException('Не удалось создать временный каталог распаковки Runtime.');
    }

    $zip = new ZipArchive();
    if ($zip->open($path, ZipArchive::RDONLY) !== true) {
        throw new RuntimeException('Не удалось открыть Runtime-пакет.');
    }

    try {
        if ($zip->numFiles < 1 || $zip->numFiles > WEBINDIGO_MAX_ARCHIVE_ENTRIES) {
            throw new RuntimeException('Runtime-пакет содержит недопустимое количество файлов.');
        }

        $uncompressed = 0;
        for ($index = 0; $index < $zip->numFiles; $index++) {
            $stat = $zip->statIndex($index);
            if (!is_array($stat)) {
                throw new RuntimeException('Не удалось проверить содержимое Runtime-пакета.');
            }

            $name = str_replace('\\', '/', (string) ($stat['name'] ?? ''));
            webindigo_assert_archive_path($name);
            if (webindigo_zip_entry_is_symlink($zip, $index)) {
                throw new RuntimeException('Runtime-пакет содержит символическую ссылку.');
            }
            $uncompressed += max(0, (int) ($stat['size'] ?? 0));
            if ($uncompressed > WEBINDIGO_MAX_UNCOMPRESSED_BYTES) {
                throw new RuntimeException('Распакованный Runtime-пакет превышает допустимый размер.');
            }
        }

        if (!$zip->extractTo($target)) {
            throw new RuntimeException('Не удалось распаковать Runtime-пакет.');
        }
    } finally {
        $zip->close();
    }
}

function webindigo_payload_root(string $stagingPath): string
{
    foreach (['index.php', 'artisan', 'bootstrap/app.php', 'vendor/autoload.php', 'VERSION'] as $required) {
        if (!is_file($stagingPath . '/' . $required)) {
            $entries = array_values(array_filter(
                scandir($stagingPath) ?: [],
                static fn (string $entry): bool => !in_array($entry, ['.', '..', 'DISTRIBUTION_ID'], true),
            ));
            if (count($entries) !== 1 || !is_dir($stagingPath . '/' . $entries[0])) {
                throw new RuntimeException('В Runtime-пакете не найден однозначный корень приложения.');
            }
            return $stagingPath . '/' . $entries[0];
        }
    }

    return $stagingPath;
}

function webindigo_validate_runtime_payload(string $root, string $expectedVersion): void
{
    foreach (['index.php', 'artisan', 'bootstrap/app.php', 'vendor/autoload.php', 'composer.json', 'VERSION', '.htaccess'] as $required) {
        if (!is_file($root . '/' . $required)) {
            throw new RuntimeException('Runtime-пакет не содержит обязательный файл: ' . $required . '.');
        }
    }

    $version = trim((string) file_get_contents($root . '/VERSION'));
    if (!hash_equals($expectedVersion, $version)) {
        throw new RuntimeException('Версия внутри Runtime-пакета не совпадает с разрешённой версией.');
    }

    $composer = json_decode((string) file_get_contents($root . '/composer.json'), true);
    if (!is_array($composer) || trim((string) ($composer['name'] ?? '')) !== 'webindigo/runtime') {
        throw new RuntimeException('Установочный архив не является WebIndigo Runtime.');
    }
}

function webindigo_publish_payload(string $source, string $target): void
{
    $backupRoot = $target . '/.webindigo-runtime-backup-' . bin2hex(random_bytes(8));
    if (!mkdir($backupRoot, 0700, true) && !is_dir($backupRoot)) {
        throw new RuntimeException('Не удалось подготовить каталог отката Runtime.');
    }
    @chmod($backupRoot, 0700);

    $written = [];
    $createdDirectories = [];

    try {
        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::SELF_FIRST,
        );

        foreach ($iterator as $item) {
            $relative = substr($item->getPathname(), strlen($source) + 1);
            if ($relative === 'install.php') {
                continue;
            }

            $destination = $target . '/' . $relative;

            if ($item->isDir()) {
                if (!is_dir($destination)) {
                    $mode = fileperms($item->getPathname());
                    $mode = is_int($mode) ? ($mode & 0777) : 0775;
                    if (!mkdir($destination, $mode, true) && !is_dir($destination)) {
                        throw new RuntimeException('Не удалось создать каталог Runtime: ' . $relative . '.');
                    }
                    @chmod($destination, $mode);
                    $createdDirectories[] = $destination;
                }
                continue;
            }

            if ($item->isLink()) {
                throw new RuntimeException('Runtime package contains a symbolic link: ' . $relative . '.');
            }

            $parent = dirname($destination);
            if (!is_dir($parent)) {
                if (!mkdir($parent, 0775, true) && !is_dir($parent)) {
                    throw new RuntimeException('Не удалось создать каталог Runtime: ' . $parent . '.');
                }
                $createdDirectories[] = $parent;
            }

            if (is_file($destination)) {
                if (is_link($destination)) {
                    throw new RuntimeException('Установщик отказался перезаписывать symbolic link: ' . $relative . '.');
                }

                $backup = $backupRoot . '/' . $relative;
                $backupParent = dirname($backup);
                if (!is_dir($backupParent) && !mkdir($backupParent, 0700, true) && !is_dir($backupParent)) {
                    throw new RuntimeException('Не удалось сохранить резервную копию файла Runtime.');
                }
                if (!copy($destination, $backup)) {
                    throw new RuntimeException('Не удалось сохранить резервную копию файла Runtime: ' . $relative . '.');
                }
            }

            $temporary = $destination . '.part-' . bin2hex(random_bytes(4));
            if (!copy($item->getPathname(), $temporary)) {
                throw new RuntimeException('Не удалось записать файл Runtime: ' . $relative . '.');
            }

            $mode = fileperms($item->getPathname());
            if (is_int($mode)) {
                @chmod($temporary, $mode & 0777);
            }

            if (!rename($temporary, $destination)) {
                @unlink($temporary);
                throw new RuntimeException('Не удалось активировать файл Runtime: ' . $relative . '.');
            }

            $written[] = $relative;
        }

        webindigo_remove_tree($backupRoot);
    } catch (Throwable $exception) {
        foreach (array_reverse($written) as $relative) {
            $destination = $target . '/' . $relative;
            $backup = $backupRoot . '/' . $relative;

            if (is_file($backup)) {
                $temporary = $destination . '.rollback-' . bin2hex(random_bytes(4));
                if (copy($backup, $temporary)) {
                    $mode = fileperms($backup);
                    if (is_int($mode)) {
                        @chmod($temporary, $mode & 0777);
                    }
                    @rename($temporary, $destination);
                    @unlink($temporary);
                }
            } elseif (is_file($destination) && !is_link($destination)) {
                @unlink($destination);
            }
        }

        usort(
            $createdDirectories,
            static fn (string $left, string $right): int => strlen($right) <=> strlen($left),
        );
        foreach (array_unique($createdDirectories) as $directory) {
            @rmdir($directory);
        }

        webindigo_remove_tree($backupRoot);
        throw $exception;
    }
}

function webindigo_write_env(string $path, array $values): void
{
    $lines = [];
    foreach ($values as $key => $value) {
        if (preg_match('/^[A-Z0-9_]+$/', (string) $key) !== 1) {
            throw new RuntimeException('Некорректное имя параметра Runtime environment.');
        }
        $string = (string) $value;
        $string = str_replace(["\\", '"', "\r", "\n"], ["\\\\", '\\"', '', ''], $string);
        $lines[] = $key . '="' . $string . '"';
    }
    webindigo_write_private_file($path, implode(PHP_EOL, $lines) . PHP_EOL, 0600);
}

function webindigo_write_json_file(string $path, array $payload): void
{
    $json = json_encode(
        $payload,
        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
    ) . PHP_EOL;
    webindigo_write_private_file($path, $json, 0600);
}

function webindigo_write_private_file(string $path, string $contents, int $mode = 0600): void
{
    $directory = dirname($path);
    if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
        throw new RuntimeException('Не удалось создать каталог для защищённых данных Runtime.');
    }

    $temporary = $path . '.tmp-' . bin2hex(random_bytes(4));
    if (file_put_contents($temporary, $contents, LOCK_EX) === false) {
        throw new RuntimeException('Не удалось записать защищённый файл Runtime.');
    }
    @chmod($temporary, $mode);
    if (!rename($temporary, $path)) {
        @unlink($temporary);
        throw new RuntimeException('Не удалось активировать защищённый файл Runtime.');
    }
    @chmod($path, $mode);
}

/** @param array<string,string> $headers @return array<string,mixed> */
function webindigo_http_signed_json_post(
    string $url,
    string $json,
    array $headers,
    int $maxBytes,
    int $timeout,
): array {
    if (!webindigo_trusted_https_url($url, 'cloud.webindigo.ru')) {
        throw new RuntimeException('Установщик отказался обращаться к недоверенному адресу WebIndigo.');
    }

    $httpHeaders = ['Content-Type: application/json', 'Accept: application/json'];
    foreach ($headers as $name => $value) {
        if (preg_match('/^[A-Za-z0-9-]{1,128}$/', $name) !== 1 || preg_match('/[\\r\\n]/', $value) === 1) {
            throw new RuntimeException('Не удалось подготовить защищённый запрос WebIndigo.');
        }
        $httpHeaders[] = $name . ': ' . $value;
    }

    $curl = curl_init($url);
    if ($curl === false) {
        throw new RuntimeException('Не удалось подготовить HTTPS-запрос к WebIndigo.');
    }

    $body = '';
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $json,
        CURLOPT_HTTPHEADER => $httpHeaders,
        CURLOPT_RETURNTRANSFER => false,
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
        CURLOPT_CONNECTTIMEOUT => 15,
        CURLOPT_TIMEOUT => $timeout,
        CURLOPT_USERAGENT => 'WebIndigo-Runtime-Installer/' . WEBINDIGO_RUNTIME_INSTALLER_VERSION,
        CURLOPT_WRITEFUNCTION => static function ($curlHandle, string $chunk) use (&$body, $maxBytes): int {
            if (strlen($body) + strlen($chunk) > $maxBytes) {
                return 0;
            }
            $body .= $chunk;
            return strlen($chunk);
        },
    ]);

    try {
        $ok = curl_exec($curl);
        $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        if ($ok === false) {
            throw new RuntimeException('HTTPS-запрос к WebIndigo завершился ошибкой: ' . curl_error($curl));
        }
    } finally {
        curl_close($curl);
    }

    try {
        $decoded = json_decode($body, true, 64, JSON_THROW_ON_ERROR);
    } catch (JsonException $exception) {
        throw new RuntimeException('WebIndigo вернул некорректный JSON.', previous: $exception);
    }

    if (!is_array($decoded)) {
        throw new RuntimeException('WebIndigo вернул некорректный ответ.');
    }

    if ($status < 200 || $status >= 300) {
        $message = trim((string) ($decoded['message'] ?? 'Запрос WebIndigo отклонён.'));
        throw new RuntimeException($message . ' HTTP ' . $status . '.');
    }

    return $decoded;
}

function webindigo_http_json_post(string $url, array $payload, int $maxBytes, int $timeout): array
{
    if (!webindigo_trusted_https_url($url)) {
        throw new RuntimeException('Установщик отказался обращаться к недоверенному адресу WebIndigo.');
    }

    $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
    $curl = curl_init($url);
    if ($curl === false) {
        throw new RuntimeException('Не удалось подготовить HTTPS-запрос к WebIndigo.');
    }

    $body = '';
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $json,
        CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json'],
        CURLOPT_RETURNTRANSFER => false,
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
        CURLOPT_CONNECTTIMEOUT => 15,
        CURLOPT_TIMEOUT => $timeout,
        CURLOPT_USERAGENT => 'WebIndigo-Runtime-Installer/' . WEBINDIGO_RUNTIME_INSTALLER_VERSION,
        CURLOPT_WRITEFUNCTION => static function ($curlHandle, string $chunk) use (&$body, $maxBytes): int {
            if (strlen($body) + strlen($chunk) > $maxBytes) {
                return 0;
            }
            $body .= $chunk;
            return strlen($chunk);
        },
    ]);

    try {
        $ok = curl_exec($curl);
        $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        if ($ok === false) {
            throw new RuntimeException('HTTPS-запрос к WebIndigo завершился ошибкой: ' . curl_error($curl));
        }
    } finally {
        curl_close($curl);
    }

    try {
        $decoded = json_decode($body, true, 64, JSON_THROW_ON_ERROR);
    } catch (JsonException $exception) {
        throw new RuntimeException('WebIndigo вернул некорректный JSON.', previous: $exception);
    }

    if (!is_array($decoded)) {
        throw new RuntimeException('WebIndigo вернул некорректный ответ.');
    }

    if ($status < 200 || $status >= 300) {
        $message = trim((string) ($decoded['message'] ?? 'Запрос WebIndigo отклонён.'));
        throw new RuntimeException($message . ' HTTP ' . $status . '.');
    }

    return $decoded;
}

function webindigo_http_string(string $url, int $maxBytes, int $timeout): string
{
    if (!webindigo_trusted_https_url($url)) {
        throw new RuntimeException('Установщик отказался обращаться к недоверенному адресу WebIndigo.');
    }

    $curl = curl_init($url);
    if ($curl === false) {
        throw new RuntimeException('Не удалось подготовить HTTPS-запрос к WebIndigo.');
    }

    $body = '';
    curl_setopt_array($curl, [
        CURLOPT_HTTPGET => true,
        CURLOPT_RETURNTRANSFER => false,
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
        CURLOPT_CONNECTTIMEOUT => 15,
        CURLOPT_TIMEOUT => $timeout,
        CURLOPT_USERAGENT => 'WebIndigo-Runtime-Installer/' . WEBINDIGO_RUNTIME_INSTALLER_VERSION,
        CURLOPT_WRITEFUNCTION => static function ($curlHandle, string $chunk) use (&$body, $maxBytes): int {
            if (strlen($body) + strlen($chunk) > $maxBytes) {
                return 0;
            }
            $body .= $chunk;
            return strlen($chunk);
        },
    ]);

    try {
        $ok = curl_exec($curl);
        $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        if ($ok === false || $status < 200 || $status >= 300) {
            throw new RuntimeException('Не удалось получить доверенный ключ WebIndigo. HTTP ' . $status . '.');
        }
    } finally {
        curl_close($curl);
    }

    return $body;
}

function webindigo_trusted_https_url(string $url, ?string $requiredHost = null): bool
{
    $parts = parse_url(trim($url));
    if (!is_array($parts) || strtolower((string) ($parts['scheme'] ?? '')) !== 'https') {
        return false;
    }
    if (isset($parts['user']) || isset($parts['pass']) || isset($parts['fragment'])) {
        return false;
    }
    if (isset($parts['port']) && (int) $parts['port'] !== 443) {
        return false;
    }

    $host = strtolower((string) ($parts['host'] ?? ''));
    if ($host === '') {
        return false;
    }

    if ($requiredHost !== null) {
        return $host === strtolower($requiredHost);
    }

    return in_array($host, ['webindigo.ru', 'cloud.webindigo.ru'], true);
}

function webindigo_current_domain(): string
{
    $host = trim((string) ($_SERVER['HTTP_HOST'] ?? ''));
    if ($host === '') {
        throw new RuntimeException('Не удалось определить домен установки.');
    }

    $domain = strtolower(rtrim((string) parse_url('https://' . $host, PHP_URL_HOST), '.'));
    if (
        $domain === ''
        || strlen($domain) > 255
        || (
            $domain !== 'localhost'
            && filter_var($domain, FILTER_VALIDATE_IP) === false
            && preg_match('/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $domain) !== 1
        )
    ) {
        throw new RuntimeException('Домен установки имеет некорректный формат.');
    }

    return $domain;
}

function webindigo_current_domain_safe(): string
{
    try {
        return webindigo_current_domain();
    } catch (Throwable) {
        return '';
    }
}

function webindigo_primary_domain(string $domain): string
{
    $domain = strtolower(rtrim(trim($domain), '.'));

    return str_starts_with($domain, 'www.')
        ? substr($domain, 4)
        : $domain;
}


function webindigo_is_https(): bool
{
    if (strtolower((string) ($_SERVER['HTTPS'] ?? '')) === 'on') {
        return true;
    }
    if ((string) ($_SERVER['SERVER_PORT'] ?? '') === '443') {
        return true;
    }
    $forwarded = strtolower(trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0] ?? ''));
    return $forwarded === 'https';
}

function webindigo_canonical_json(mixed $value): string
{
    $normalized = webindigo_sort_recursively($value);
    $json = json_encode(
        $normalized,
        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
    );
    if (!is_string($json)) {
        throw new RuntimeException('Не удалось сформировать криптографически стабильный JSON.');
    }
    return $json;
}

function webindigo_sort_recursively(mixed $value): mixed
{
    if (!is_array($value)) {
        return $value;
    }
    if (array_is_list($value)) {
        return array_map(static fn (mixed $item): mixed => webindigo_sort_recursively($item), $value);
    }
    ksort($value);
    foreach ($value as $key => $item) {
        $value[$key] = webindigo_sort_recursively($item);
    }
    return $value;
}

function webindigo_assert_archive_path(string $path): void
{
    if (
        $path === ''
        || strlen($path) > 4096
        || str_contains($path, "\0")
        || str_starts_with($path, '/')
        || preg_match('/^[A-Za-z]:\//', $path) === 1
    ) {
        throw new RuntimeException('Runtime-пакет содержит небезопасный путь.');
    }
    foreach (explode('/', trim($path, '/')) as $segment) {
        if ($segment === '.' || $segment === '..') {
            throw new RuntimeException('Runtime-пакет пытается выйти за пределы каталога установки.');
        }
    }
}

function webindigo_zip_entry_is_symlink(ZipArchive $zip, int $index): bool
{
    $operationsSystem = 0;
    $attributes = 0;
    if (!$zip->getExternalAttributesIndex($index, $operationsSystem, $attributes)) {
        return false;
    }
    return $operationsSystem === ZipArchive::OPSYS_UNIX
        && (($attributes >> 16) & 0xF000) === 0xA000;
}

function webindigo_remove_tree(?string $path): void
{
    if (!is_string($path) || $path === '' || !is_dir($path)) {
        return;
    }

    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST,
    );
    foreach ($iterator as $item) {
        if ($item->isLink() || $item->isFile()) {
            @unlink($item->getPathname());
        } elseif ($item->isDir()) {
            @rmdir($item->getPathname());
        }
    }
    @rmdir($path);
}

function data_get_array(array $array, array $path, mixed $default = null): mixed
{
    $value = $array;
    foreach ($path as $segment) {
        if (!is_array($value) || !array_key_exists($segment, $value)) {
            return $default;
        }
        $value = $value[$segment];
    }
    return $value;
}

function webindigo_render(
    string $title,
    ?string $error,
    array $checks,
    bool $showAction,
    bool $installed = false,
    bool $completionPending = false,
): never {
    $allPassed = $checks !== [] && !in_array(false, array_column($checks, 'ok'), true);
    $failedChecks = array_values(array_filter(
        $checks,
        static fn (array $check): bool => !((bool) ($check['ok'] ?? false)),
    ));
    $publicInstaller = WEBINDIGO_INSTALLER_GRANT_TYPE === 'public';
    $grantType = match (WEBINDIGO_INSTALLER_GRANT_TYPE) {
        'public' => 'Бесплатная пробная версия',
        'trial' => 'Демонстрационная лицензия',
        'commercial' => 'Коммерческая лицензия',
        default => 'WebIndigo CMS',
    };
    $grantDescription = $publicInstaller
        ? 'Учётная запись WebIndigo будет создана во время установки или будет использован уже существующий кабинет.'
        : (
            WEBINDIGO_INSTALLER_EXPIRES_AT > time()
                ? 'Персональный install.php действует до ' . date('d.m.Y H:i', WEBINDIGO_INSTALLER_EXPIRES_AT) . '.'
                : 'Персональное разрешение требует обновления.'
        );
    $csrf = htmlspecialchars(
        (string) ($_SESSION['webindigo_runtime_csrf'] ?? ''),
        ENT_QUOTES | ENT_SUBSTITUTE,
        'UTF-8',
    );
    $step = trim((string) ($_GET['step'] ?? 'welcome'));

    if ($error !== null) {
        $step = $publicInstaller ? 'account' : 'requirements';
    } elseif (!in_array($step, ['welcome', 'requirements', 'account', 'finish'], true)) {
        $step = 'welcome';
    }

    if (!$publicInstaller && $step === 'account') {
        $step = 'requirements';
    }

    if ($installed || $completionPending) {
        $step = 'finish';
    }

    $accountIdentity = null;
    $accountIdentityError = null;

    if ($publicInstaller && $step === 'account' && $allPassed && !$installed && !$completionPending) {
        try {
            $keys = webindigo_generate_installation_keypair();
            $accountIdentity = [
                'domain' => webindigo_current_domain(),
                'install_id' => 'wi-runtime-' . substr($keys['fingerprint'], 0, 32),
                'fingerprint' => $keys['fingerprint'],
            ];
        } catch (Throwable $exception) {
            error_log('[WebIndigo CMS Installer] ' . $exception->getMessage());
            $accountIdentityError = webindigo_public_error_message($exception->getMessage());
        }
    }

    $totalSteps = $publicInstaller ? 4 : 3;
    $finishStep = $totalSteps;
    ?>
<!doctype html>
<html lang="ru">
<head>
    <meta charset="utf-8">
    <title><?= htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="robots" content="noindex,nofollow,noarchive">
    <style>
        :root {
            --bg: #f8fafc;
            --panel: #ffffff;
            --panel-soft: #fff7ed;
            --line: #e2e8f0;
            --text: #0f172a;
            --muted: #64748b;
            --accent: #ea580c;
            --accent-dark: #c2410c;
            --danger: #dc2626;
            --danger-bg: #fef2f2;
            --ok: #15803d;
        }

        * { box-sizing: border-box; }
        body {
            margin: 0;
            font-family: Inter, Arial, sans-serif;
            color: var(--text);
            background: linear-gradient(180deg, #fff7ed 0%, #f8fafc 180px, #f8fafc 100%);
        }
        .shell {
            max-width: 1320px;
            margin: 0 auto;
            padding: 32px 20px 48px;
        }
        .grid {
            display: grid;
            grid-template-columns: 300px minmax(0, 1fr);
            gap: 24px;
            align-items: start;
        }
        .card {
            background: var(--panel);
            border: 1px solid var(--line);
            border-radius: 24px;
            box-shadow: 0 20px 60px rgba(15, 23, 42, .06);
        }
        .sidebar {
            padding: 24px;
            position: sticky;
            top: 24px;
        }
        .content { padding: 32px; }
        .brand {
            display: flex;
            align-items: center;
            gap: 14px;
            margin-bottom: 20px;
        }
        .brand-mark {
            width: 52px;
            height: 52px;
            border-radius: 16px;
            background: linear-gradient(135deg, #ea580c 0%, #f59e0b 100%);
            color: #fff;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            font-weight: 700;
            font-size: 18px;
            flex-shrink: 0;
        }
        .brand-title { font-size: 22px; font-weight: 700; line-height: 1.2; }
        .brand-subtitle { color: var(--muted); font-size: 14px; margin-top: 4px; }
        .lead { color: var(--muted); font-size: 14px; line-height: 1.6; margin-bottom: 24px; }
        .steps { display: grid; gap: 10px; }
        .step {
            display: flex;
            gap: 12px;
            align-items: flex-start;
            padding: 12px 14px;
            border-radius: 16px;
            border: 1px solid transparent;
            background: #fff;
        }
        .step.is-active { background: var(--panel-soft); border-color: #fdba74; }
        .step-num {
            width: 28px;
            height: 28px;
            border-radius: 999px;
            background: #e2e8f0;
            color: #334155;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            font-size: 13px;
            font-weight: 700;
            flex-shrink: 0;
        }
        .step.is-active .step-num { background: var(--accent); color: #fff; }
        .step-title { font-size: 14px; font-weight: 600; }
        .step-desc { font-size: 12px; color: var(--muted); margin-top: 3px; line-height: 1.45; }
        h2 { margin: 0 0 14px; font-size: 30px; line-height: 1.2; }
        h3 { margin: 0 0 12px; font-size: 18px; }
        .step-label {
            font-size: 12px;
            line-height: 1.4;
            letter-spacing: .12em;
            text-transform: uppercase;
            color: #94a3b8;
            margin-bottom: 10px;
        }
        .section-note {
            margin: 0 0 24px;
            color: #475569;
            font-size: 16px;
            line-height: 1.7;
        }
        .info-box, .success-box, .alert-danger {
            border-radius: 16px;
            padding: 16px 18px;
            margin-bottom: 24px;
            line-height: 1.7;
        }
        .info-box {
            border: 1px solid #fed7aa;
            background: #fff7ed;
            color: #9a3412;
        }
        .success-box {
            border: 1px solid #bbf7d0;
            background: #f0fdf4;
            color: #166534;
        }
        .alert-danger {
            border: 1px solid #fecaca;
            background: var(--danger-bg);
            color: #991b1b;
        }
        .group {
            padding: 20px;
            border: 1px solid var(--line);
            border-radius: 20px;
            background: #fff;
            margin-bottom: 18px;
        }
        .group ul {
            margin: 0;
            padding-left: 20px;
            color: #475569;
            line-height: 1.8;
        }
        .license-box {
            display: flex;
            justify-content: space-between;
            align-items: center;
            gap: 18px;
            padding: 18px 20px;
            border: 1px solid var(--line);
            border-radius: 18px;
            background: #fff;
            margin-bottom: 24px;
        }
        .license-box strong { display: block; font-size: 17px; margin-bottom: 4px; }
        .license-box span { color: var(--muted); font-size: 13px; }
        .license-state { color: var(--ok); font-size: 13px; font-weight: 700; white-space: nowrap; }
        .table-wrap {
            overflow: hidden;
            border: 1px solid var(--line);
            border-radius: 18px;
            margin-bottom: 28px;
            background: #fff;
        }
        table { width: 100%; border-collapse: collapse; }
        th {
            padding: 14px 16px;
            text-align: left;
            font-size: 13px;
            color: var(--muted);
            border-bottom: 1px solid var(--line);
            background: #f8fafc;
        }
        td {
            padding: 14px 16px;
            text-align: left;
            border-bottom: 1px solid var(--line);
        }
        tbody tr:last-child td { border-bottom: 0; }
        .status {
            display: inline-flex;
            align-items: center;
            min-height: 30px;
            padding: 0 10px;
            border-radius: 999px;
            font-size: 13px;
            font-weight: 700;
        }
        .status-ok { background: #f0fdf4; color: #166534; }
        .status-bad { background: #fef2f2; color: #991b1b; }
        .current { color: #475569; }
        .actions {
            display: flex;
            justify-content: space-between;
            gap: 12px;
            flex-wrap: wrap;
            margin-top: 24px;
        }
        .button, .button-secondary {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            min-height: 48px;
            padding: 0 20px;
            border-radius: 14px;
            text-decoration: none;
            cursor: pointer;
            font-size: 14px;
            font-weight: 700;
        }
        .button {
            border: 0;
            background: var(--accent);
            color: #fff;
        }
        .button:hover { background: var(--accent-dark); }
        .button:disabled { opacity: .5; cursor: not-allowed; }
        .button-secondary {
            background: #fff;
            color: var(--text);
            border: 1px solid var(--line);
        }
        .refresh {
            display: inline-flex;
            margin-top: 16px;
            color: var(--accent-dark);
            font-weight: 700;
            text-decoration: none;
        }
        .account-grid {
            display: grid;
            grid-template-columns: repeat(2, minmax(0, 1fr));
            gap: 16px;
        }
        .account-primary {
            margin-bottom: 22px;
            padding: 18px;
            border: 1px solid #fed7aa;
            border-radius: 18px;
            background: #fffaf5;
        }
        .account-primary-note {
            margin: 0 0 15px;
            color: #7c2d12;
            font-size: 13px;
            line-height: 1.5;
        }
        .account-optional-heading {
            display: flex;
            align-items: baseline;
            gap: 8px;
            margin: 0 0 12px;
            color: #334155;
            font-size: 14px;
            font-weight: 800;
        }
        .account-optional-heading span {
            color: var(--muted);
            font-size: 12px;
            font-weight: 600;
        }
        .field { display: grid; gap: 7px; }
        .field.full { grid-column: 1 / -1; }
        .field label {
            font-size: 13px;
            color: #334155;
            font-weight: 700;
        }
        .field-required {
            margin-left: 3px;
            color: #ea580c;
        }
        .field input {
            width: 100%;
            min-height: 48px;
            border: 1px solid #cbd5e1;
            border-radius: 14px;
            padding: 0 14px;
            font: inherit;
            color: var(--text);
            background: #fff;
            outline: none;
        }
        .account-primary .field input {
            border-color: #fdba74;
        }
        .field input:focus {
            border-color: #fb923c;
            box-shadow: 0 0 0 3px rgba(251, 146, 60, .16);
        }
        .account-ui-phone-control {
            display: flex;
            flex-direction: row;
            align-items: stretch;
            width: 100%;
            min-width: 0;
            min-height: 48px;
            overflow: hidden;
            border: 1px solid #cbd5e1;
            border-radius: 14px;
            background: #fff;
            transition: border-color .2s, box-shadow .2s;
        }
        .account-ui-phone-control:focus-within {
            border-color: #fb923c;
            box-shadow: 0 0 0 3px rgba(251, 146, 60, .16);
        }
        .account-ui-phone-control__prefix {
            display: flex;
            align-items: center;
            flex: 0 0 auto;
            padding: 0 0 0 14px;
            color: var(--text);
            font: inherit;
            font-weight: 700;
            line-height: 1;
            white-space: nowrap;
        }
        .field .account-ui-phone-control input[data-wi-phone-input] {
            display: block;
            flex: 1 1 auto;
            width: auto;
            min-width: 0;
            min-height: 46px;
            margin: 0;
            padding: 0 14px 0 8px;
            border: 0;
            border-radius: 0;
            outline: 0;
            background: transparent;
            box-shadow: none;
            color: var(--text);
            font: inherit;
        }
        .field .account-ui-phone-control input[data-wi-phone-input]:focus {
            border: 0;
            outline: 0;
            box-shadow: none;
        }
        .field small {
            color: var(--muted);
            line-height: 1.45;
        }
        .account-password-hint {
            display: block;
            margin-top: 9px;
            color: var(--muted);
            font-size: 12px;
            line-height: 1.45;
        }
        .password-input-wrap {
            position: relative;
        }
        .password-input-wrap input {
            padding-right: 92px;
        }
        .password-toggle {
            position: absolute;
            top: 50%;
            right: 8px;
            transform: translateY(-50%);
            border: 0;
            background: transparent;
            color: #9a3412;
            font: inherit;
            font-size: 12px;
            font-weight: 800;
            cursor: pointer;
            padding: 8px;
        }
        .consents {
            display: grid;
            gap: 10px;
            margin-top: 18px;
        }
        .consent {
            display: flex;
            gap: 10px;
            align-items: flex-start;
            color: #475569;
            font-size: 13px;
            line-height: 1.55;
        }
        .consent input { margin-top: 3px; }
        .consent a { color: var(--accent-dark); }
        .account-note {
            color: var(--muted);
            font-size: 13px;
            line-height: 1.6;
            margin: 0 0 18px;
        }
        @media (max-width: 980px) {
            .grid { grid-template-columns: 1fr; }
            .sidebar { position: static; }
        }
        @media (max-width: 720px) {
            .shell { padding: 14px 10px 28px; }
            .content, .sidebar { padding: 22px; }
            .license-box { align-items: flex-start; flex-direction: column; }
            .account-grid { grid-template-columns: 1fr; }
            .field.full { grid-column: auto; }
            th:nth-child(2), td:nth-child(2) { width: auto !important; }
        }
    </style>
</head>
<body>
<div class="shell">
    <div class="grid">
        <aside class="card sidebar">
            <div class="brand">
                <div class="brand-mark">WI</div>
                <div>
                    <div class="brand-title">WebIndigo CMS</div>
                    <div class="brand-subtitle">Мастер установки</div>
                </div>
            </div>

            <div class="lead">Мастер установки поможет подготовить WebIndigo CMS к работе.</div>

            <div class="steps">
                <div class="step <?= $step === 'welcome' ? 'is-active' : '' ?>">
                    <div class="step-num">1</div>
                    <div>
                        <div class="step-title">Начало установки</div>
                        <div class="step-desc">Вводная информация</div>
                    </div>
                </div>
                <div class="step <?= $step === 'requirements' ? 'is-active' : '' ?>">
                    <div class="step-num">2</div>
                    <div>
                        <div class="step-title">Проверка окружения</div>
                        <div class="step-desc">Расширения и права</div>
                    </div>
                </div>
                <?php if ($publicInstaller): ?>
                    <div class="step <?= $step === 'account' ? 'is-active' : '' ?>">
                        <div class="step-num">3</div>
                        <div>
                            <div class="step-title">Учётная запись</div>
                            <div class="step-desc">Владелец сайта</div>
                        </div>
                    </div>
                <?php endif; ?>
                <div class="step <?= $step === 'finish' ? 'is-active' : '' ?>">
                    <div class="step-num"><?= $finishStep ?></div>
                    <div>
                        <div class="step-title">Завершение</div>
                        <div class="step-desc">Готово к работе</div>
                    </div>
                </div>
            </div>
        </aside>

        <main class="card content">
            <?php if ($step === 'welcome'): ?>
                <div class="step-label">Шаг 1 из <?= $totalSteps ?></div>
                <h2>Добро пожаловать в установщик WebIndigo CMS</h2>
                <p class="section-note">
                    Мастер поможет установить WebIndigo CMS.
                    <?= $publicInstaller
                        ? 'Для бесплатной пробной версии потребуется учётная запись WebIndigo.'
                        : 'Установщик уже содержит персональное разрешение для вашей лицензии.' ?>
                </p>

                <div class="info-box">
                    Перед началом установки убедитесь, что домен сайта работает по HTTPS, а корневой каталог доступен для записи.
                </div>

                <div class="group">
                    <h3>Что будет проверено</h3>
                    <ul>
                        <li>версия PHP и обязательные расширения</li>
                        <li>доступ на запись в каталог сайта</li>
                        <li>защищённое HTTPS-соединение</li>
                        <li>готовность установочного файла</li>
                    </ul>
                </div>

                <div class="actions" style="justify-content:flex-end">
                    <a class="button" href="?step=requirements">Начать установку</a>
                </div>

            <?php elseif ($step === 'requirements'): ?>
                <div class="step-label">Шаг 2 из <?= $totalSteps ?></div>
                <h2>Проверка серверного окружения</h2>
                <p class="section-note">
                    Перед установкой WebIndigo CMS нужно убедиться, что сервер соответствует обязательным требованиям.
                    Если какая-либо проверка не пройдена, сначала исправьте окружение и затем повторите этот шаг.
                </p>

                <?php if ($error): ?>
                    <div class="alert-danger"><?= htmlspecialchars($error, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></div>
                <?php elseif ($allPassed): ?>
                    <div class="success-box">Все обязательные проверки успешно пройдены. Можно продолжать установку.</div>
                <?php else: ?>
                    <div class="alert-danger">Обнаружены ошибки окружения. Исправьте их и затем повторите проверку.</div>
                <?php endif; ?>

                <?php if ($showAction): ?>
                    <div class="license-box">
                        <div>
                            <strong><?= htmlspecialchars($grantType, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></strong>
                            <span><?= htmlspecialchars($grantDescription, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></span>
                        </div>
                        <div class="license-state"><?= $allPassed ? 'Готово к установке' : 'Требуется настройка' ?></div>
                    </div>
                <?php endif; ?>

                <?php if (!$allPassed && $failedChecks): ?>
                    <div class="table-wrap">
                        <table>
                            <thead>
                            <tr>
                                <th>Проверка</th>
                                <th style="width:160px">Статус</th>
                                <th>Текущее значение</th>
                            </tr>
                            </thead>
                            <tbody>
                            <?php foreach ($failedChecks as $check): ?>
                                <tr>
                                    <td><?= htmlspecialchars((string) $check['name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></td>
                                    <td><span class="status status-bad">Ошибка</span></td>
                                    <td class="current"><?= htmlspecialchars((string) $check['value'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></td>
                                </tr>
                            <?php endforeach; ?>
                            </tbody>
                        </table>
                    </div>
                <?php endif; ?>

                <div class="actions">
                    <a class="button-secondary" href="?step=welcome">Назад</a>

                    <?php if ($showAction && $allPassed && $publicInstaller): ?>
                        <a class="button" href="?step=account">Продолжить</a>
                    <?php elseif ($showAction && $allPassed): ?>
                        <form method="post" id="webindigo-install-form" style="margin:0">
                            <input type="hidden" name="_token" value="<?= $csrf ?>">
                            <button class="button" type="submit">Установить WebIndigo CMS</button>
                        </form>
                    <?php elseif (!$allPassed): ?>
                        <a class="button-secondary" href="?step=requirements">Проверить повторно</a>
                    <?php endif; ?>
                </div>

                <?php if ($showAction && !$allPassed && !$publicInstaller): ?>
                    <a class="refresh" href="<?= htmlspecialchars(WEBINDIGO_INSTALLER_REFRESH_URL, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">Скачать актуальный install.php</a>
                <?php endif; ?>

            <?php elseif ($step === 'account' && $publicInstaller): ?>
                <div class="step-label">Шаг 3 из 4</div>
                <h2>Учётная запись WebIndigo</h2>
                <p class="section-note">
                    Введите email и пароль. Если учётная запись с таким email уже существует, мы используем её.
                    Если нет — создадим новую автоматически.
                </p>

                <?php if ($error): ?>
                    <div class="alert-danger"><?= htmlspecialchars($error, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></div>
                <?php endif; ?>

                <?php if ($accountIdentityError): ?>
                    <div class="alert-danger"><?= htmlspecialchars($accountIdentityError, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></div>
                <?php elseif (!$allPassed): ?>
                    <div class="alert-danger">Серверное окружение изменилось. Вернитесь к проверке и повторите её.</div>
                <?php else: ?>
                    <div id="webindigo-account-error" class="alert-danger" style="display:none"></div>

                    <form method="post" id="webindigo-account-form">
                        <input type="hidden" name="_token" value="<?= $csrf ?>">
                        <input type="hidden" name="account_grant" id="webindigo-account-grant" value="">

                        <div class="group">
                            <div class="account-primary">
                                <p class="account-primary-note">Эти данные нужны для входа в WebIndigo CMS.</p>
                                <div class="account-grid">
                                    <div class="field">
                                        <label for="webindigo-account-email">Email<span class="field-required">*</span></label>
                                        <input id="webindigo-account-email" type="email" autocomplete="username" required autofocus>
                                    </div>

                                    <div class="field">
                                        <label for="webindigo-account-password">Пароль<span class="field-required">*</span></label>
                                        <div class="password-input-wrap">
                                            <input id="webindigo-account-password" type="password" autocomplete="current-password" required>
                                            <button class="password-toggle" id="webindigo-password-toggle" type="button" aria-label="Показать пароль">Показать</button>
                                        </div>
                                    </div>
                                </div>
                                <small class="account-password-hint">Для нового кабинета: минимум 8 символов, буквы и цифры.</small>
                            </div>

                            <div class="account-optional-heading">Дополнительные данные <span>необязательно</span></div>
                            <div class="account-grid">
                                <div class="field">
                                    <label for="webindigo-account-name">Имя</label>
                                    <input id="webindigo-account-name" type="text" autocomplete="name">
                                </div>

                                <div class="field">
                                    <label for="webindigo-account-phone">Телефон</label>
                                    <div class="account-ui-phone-control">
                                        <span class="account-ui-phone-control__prefix" aria-hidden="true">+7</span>
                                        <input
                                            id="webindigo-account-phone"
                                            type="tel"
                                            inputmode="numeric"
                                            autocomplete="tel"
                                            placeholder="912 345-67-89"
                                            maxlength="15"
                                            data-wi-phone-input
                                            data-no-phone-mask
                                        >
                                    </div>
                                </div>
                            </div>

                            <div class="consents">
                                <div class="account-note" style="margin:0">Согласия нужны только при создании новой учётной записи.</div>
                                <label class="consent">
                                    <input id="webindigo-account-terms" type="checkbox">
                                    <span>
                                        Принимаю
                                        <a href="https://webindigo.ru/legal/account-terms" target="_blank" rel="noopener">правила использования личного кабинета</a>.
                                    </span>
                                </label>
                                <label class="consent">
                                    <input id="webindigo-account-consent" type="checkbox">
                                    <span>
                                        Даю
                                        <a href="https://webindigo.ru/legal/personal-data-consent" target="_blank" rel="noopener">согласие на обработку персональных данных</a>
                                        и ознакомлен с
                                        <a href="https://webindigo.ru/legal/privacy" target="_blank" rel="noopener">политикой конфиденциальности</a>.
                                    </span>
                                </label>
                            </div>
                        </div>

                        <div class="actions">
                            <a class="button-secondary" href="?step=requirements">Назад</a>
                            <button class="button" type="submit" id="webindigo-account-submit">
                                Продолжить установку
                            </button>
                        </div>
                    </form>
                <?php endif; ?>

            <?php else: ?>
                <div class="step-label">Шаг <?= $finishStep ?> из <?= $totalSteps ?></div>
                <?php if ($completionPending): ?>
                    <h2>Завершение установки</h2>
                    <p class="section-note">Основные файлы WebIndigo CMS уже установлены. Осталось подготовить стартовую версию сайта.</p>
                    <?php if ($error): ?>
                        <div class="alert-danger"><?= htmlspecialchars($error, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></div>
                    <?php else: ?>
                        <div class="info-box">Нажмите кнопку ниже, чтобы завершить установку. Повторная попытка не создаст второй сайт или новую пробную лицензию.</div>
                    <?php endif; ?>
                    <form method="post" id="webindigo-install-form">
                        <input type="hidden" name="_token" value="<?= $csrf ?>">
                        <div class="actions" style="justify-content:flex-end">
                            <button class="button" type="submit">Завершить установку</button>
                        </div>
                    </form>
                <?php else: ?>
                    <h2>Установка завершена</h2>
                    <p class="section-note">WebIndigo CMS установлена и готова к работе.</p>
                    <div class="success-box">Установка успешно завершена.</div>
                    <div class="actions" style="justify-content:flex-end">
                        <a class="button" href="/admin">Перейти в административную панель</a>
                    </div>
                <?php endif; ?>
            <?php endif; ?>
        </main>
    </div>
</div>

<script>
document.getElementById('webindigo-install-form')?.addEventListener('submit', function () {
    const button = this.querySelector('button[type="submit"]');
    if (button) {
        button.disabled = true;
        button.textContent = 'Установка…';
    }
});

(() => {
    const form = document.getElementById('webindigo-account-form');
    if (!form) {
        return;
    }

    const button = document.getElementById('webindigo-account-submit');
    const errorBox = document.getElementById('webindigo-account-error');
    const grantInput = document.getElementById('webindigo-account-grant');
    const nameInput = document.getElementById('webindigo-account-name');
    const emailInput = document.getElementById('webindigo-account-email');
    const passwordInput = document.getElementById('webindigo-account-password');
    const passwordToggle = document.getElementById('webindigo-password-toggle');
    const phoneInput = document.getElementById('webindigo-account-phone');
    const termsInput = document.getElementById('webindigo-account-terms');
    const consentInput = document.getElementById('webindigo-account-consent');
    const accountIdentity = <?= json_encode(
        $accountIdentity,
        JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT,
    ) ?>;

    const getPhoneDigits = (value) => {
        let digits = String(value || '').replace(/\D/g, '');

        if (digits.length >= 11 && (digits.startsWith('7') || digits.startsWith('8'))) {
            digits = digits.slice(1);
        }

        if (digits === '7' || digits === '8') {
            return '';
        }

        return digits.slice(0, 10);
    };

    const formatPhone = (digits) => {
        if (digits.length === 0) {
            return '';
        }

        if (digits.length < 4) {
            return digits;
        }

        let result = '(' + digits.slice(0, 3) + ')';

        if (digits.length > 3) {
            result += ' ' + digits.slice(3, 6);
        }

        if (digits.length > 6) {
            result += '-' + digits.slice(6, 8);
        }

        if (digits.length > 8) {
            result += '-' + digits.slice(8, 10);
        }

        return result;
    };

    const applyPhoneMask = () => {
        if (!phoneInput) {
            return;
        }

        phoneInput.value = formatPhone(getPhoneDigits(phoneInput.value));
    };

    phoneInput?.addEventListener('input', applyPhoneMask);
    phoneInput?.addEventListener('change', applyPhoneMask);
    phoneInput?.addEventListener('paste', () => window.setTimeout(applyPhoneMask, 0));
    applyPhoneMask();

    passwordToggle?.addEventListener('click', () => {
        if (!passwordInput) {
            return;
        }

        const reveal = passwordInput.type === 'password';
        passwordInput.type = reveal ? 'text' : 'password';
        passwordToggle.textContent = reveal ? 'Скрыть' : 'Показать';
        passwordToggle.setAttribute('aria-label', reveal ? 'Скрыть пароль' : 'Показать пароль');
        passwordInput.focus();
    });

    const showError = (message) => {
        if (!errorBox) {
            return;
        }
        errorBox.textContent = message || 'Не удалось подтвердить учётную запись.';
        errorBox.style.display = 'block';
    };

    form.addEventListener('submit', async (event) => {
        event.preventDefault();

        if (!accountIdentity || !grantInput || !emailInput || !passwordInput || !button) {
            showError('Не удалось подготовить установку. Обновите страницу.');
            return;
        }

        if (errorBox) {
            errorBox.style.display = 'none';
            errorBox.textContent = '';
        }

        const email = emailInput.value.trim();
        const password = passwordInput.value;

        if (!email || !password) {
            showError('Укажите email и пароль.');
            return;
        }

        button.disabled = true;
        button.textContent = 'Проверяем учётную запись…';

        try {
            const body = new URLSearchParams();
            body.set('domain', accountIdentity.domain);
            body.set('install_id', accountIdentity.install_id);
            body.set('fingerprint', accountIdentity.fingerprint);
            body.set('name', nameInput?.value.trim() || '');
            body.set('email', email);
            body.set('password', password);
            body.set('phone', phoneInput?.value.trim() || '');
            if (termsInput?.checked) {
                body.set('account_terms', '1');
            }
            if (consentInput?.checked) {
                body.set('personal_data_consent', '1');
            }

            const response = await fetch(
                <?= json_encode(
                    WEBINDIGO_ACCOUNT_RUNTIME_INSTALLER_URL,
                    JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT,
                ) ?>,
                {
                    method: 'POST',
                    body,
                    headers: {
                        'Accept': 'application/json',
                    },
                    credentials: 'omit',
                    cache: 'no-store',
                },
            );
            const data = await response.json().catch(() => null);
            const installerGrant = String(data?.installer_grant || '').trim().toLowerCase();

            if (
                !response.ok
                || !data
                || data.ok !== true
                || !/^[a-f0-9]{64}$/.test(installerGrant)
            ) {
                showError(data?.message || 'Не удалось подтвердить учётную запись.');
                return;
            }

            grantInput.value = installerGrant;

            // Поля учётной записи намеренно не имеют name и никогда не
            // отправляются в локальный PHP установщика.
            button.textContent = 'Установка…';
            form.submit();
        } catch (error) {
            showError('Не удалось связаться с системой учётных записей WebIndigo.');
        } finally {
            if (!grantInput.value) {
                button.disabled = false;
                button.textContent = 'Продолжить установку';
            }
        }
    });
})();
</script>
</body>
</html>
<?php
    exit;
}
