<?php
declare(strict_types=1);

/**
 * Small, dependency-free helpers shared by the first-run installer and its tests.
 *
 * The installer intentionally keeps secrets out of return values, log messages,
 * and rendered HTML. Database credentials are used only for the connection and
 * are written to the application's PHP config only after the database work has
 * succeeded.
 */

function install_root_path(): string
{
    return dirname(__DIR__);
}

function install_escape(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

/**
 * @return array{path: string, fallback: bool}
 */
function install_lock_path(string $projectRoot, ?string $documentRoot = null): array
{
    $projectRoot = rtrim($projectRoot, "\\/");
    $documentRoot = $documentRoot !== null ? rtrim($documentRoot, "\\/") : null;
    $projectDirectoryWritable = is_dir($projectRoot) && is_writable($projectRoot);
    $projectIsPublic = $documentRoot !== null
        && install_path_is_within($projectRoot, $documentRoot)
        && install_path_is_within($documentRoot, $projectRoot);
    $publicDirectoryIsChild = $documentRoot !== null
        && install_path_is_within($documentRoot, $projectRoot)
        && $documentRoot !== $projectRoot;

    // The lock always stays inside the project root. Writing it to the parent
    // directory used to scatter a dotfile beside public_html on cPanel, and
    // falling back to install/ left it inside the document root when the
    // project root is also the document root -- protected only by .htaccess,
    // which nginx and LiteSpeed do not read.
    if ($projectDirectoryWritable) {
        return [
            'path' => $projectRoot . DIRECTORY_SEPARATOR . '.quaeriva-installed.lock',
            'fallback' => !$publicDirectoryIsChild && $projectIsPublic,
        ];
    }

    $configDirectory = $projectRoot . DIRECTORY_SEPARATOR . 'config';

    return [
        'path' => $configDirectory . DIRECTORY_SEPARATOR . '.quaeriva-installed.lock',
        'fallback' => true,
    ];
}

function install_path_is_within(string $path, string $parent): bool
{
    $path = install_normalize_path($path);
    $parent = rtrim(install_normalize_path($parent), '/') . '/';

    return str_starts_with($path . '/', $parent);
}

function install_normalize_path(string $path): string
{
    $real = realpath($path);

    if ($real !== false) {
        return rtrim(str_replace('\\', '/', $real), '/');
    }

    $path = str_replace('\\', '/', $path);

    return rtrim($path, '/');
}

/** @return 'missing'|'valid'|'invalid' */
function install_lock_status(string $lockPath): string
{
    if (!is_file($lockPath)) {
        return 'missing';
    }

    $contents = file_get_contents($lockPath);

    if ($contents === false) {
        return 'invalid';
    }

    try {
        $lock = json_decode($contents, true, 8, JSON_THROW_ON_ERROR);
    } catch (Throwable) {
        return 'invalid';
    }

    if (!is_array($lock) || ($lock['marker'] ?? null) !== 'quaeriva-installed-v1') {
        return 'invalid';
    }

    return is_string($lock['installed_at'] ?? null) && $lock['installed_at'] !== ''
        ? 'valid'
        : 'invalid';
}

function install_write_lock(string $lockPath): bool
{
    if (install_lock_status($lockPath) !== 'missing') {
        return false;
    }

    $directory = dirname($lockPath);

    if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
        return false;
    }

    $handle = @fopen($lockPath, 'x');

    if ($handle === false) {
        return false;
    }

    $payload = json_encode([
        'marker' => 'quaeriva-installed-v1',
        'installed_at' => gmdate('c'),
    ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . PHP_EOL;

    $written = fwrite($handle, $payload);
    fflush($handle);
    fclose($handle);

    if ($written !== strlen($payload)) {
        @unlink($lockPath);
        return false;
    }

    @chmod($lockPath, 0640);

    return true;
}

function install_begin_process_lock(string $lockPath): bool
{
    $path = $lockPath . '.installing';
    $directory = dirname($path);

    if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
        return false;
    }

    $handle = @fopen($path, 'x');

    if ($handle === false) {
        return false;
    }

    $payload = json_encode([
        'marker' => 'quaeriva-installing-v1',
        'started_at' => gmdate('c'),
    ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . PHP_EOL;
    $written = fwrite($handle, $payload);
    fflush($handle);
    fclose($handle);

    if ($written !== strlen($payload)) {
        @unlink($path);
        return false;
    }

    @chmod($path, 0640);

    return true;
}

function install_end_process_lock(string $lockPath): void
{
    @unlink($lockPath . '.installing');
}

/**
 * @param array<string, mixed> $input
 * @return array{errors: array<string, string>, values: array<string, string|int>}
 */
function install_validate_setup(array $input): array
{
    $errors = [];
    $values = [];
    $dbHost = trim((string) ($input['db_host'] ?? ''));
    $dbPort = trim((string) ($input['db_port'] ?? '3306'));
    $dbName = trim((string) ($input['db_name'] ?? ''));
    $dbUser = trim((string) ($input['db_user'] ?? ''));
    // An omitted site name is not worth blocking an install over; the form
    // pre-fills the default and it is editable in the admin panel afterwards.
    $siteName = trim((string) ($input['site_name'] ?? '')) ?: 'Quaeriva';
    $siteDescription = trim((string) ($input['site_description'] ?? ''));
    $appUrl = rtrim(trim((string) ($input['app_url'] ?? '')), '/');
    $adminPath = strtolower(trim((string) ($input['admin_path'] ?? 'admin'), "/ \t\n\r\0\x0B")) ?: 'admin';
    $adminName = trim((string) ($input['admin_name'] ?? ''));
    $adminEmail = strtolower(trim((string) ($input['admin_email'] ?? '')));
    $locale = strtolower(trim((string) ($input['locale'] ?? 'ar')));
    $adminPassword = (string) ($input['admin_password'] ?? '');

    if ($dbHost === '' || strlen($dbHost) > 253 || str_contains($dbHost, "\0")) {
        $errors['db_host'] = 'Database host is required.';
    }

    if (!ctype_digit($dbPort) || (int) $dbPort < 1 || (int) $dbPort > 65535) {
        $errors['db_port'] = 'Database port must be between 1 and 65535.';
    }

    if (!preg_match('/^[A-Za-z0-9_]{1,64}$/', $dbName)) {
        $errors['db_name'] = 'Database name may contain only letters, numbers, and underscores.';
    }

    if ($dbUser === '' || strlen($dbUser) > 191 || str_contains($dbUser, "\0")) {
        $errors['db_user'] = 'Database username is required.';
    }

    if (install_text_length($siteName) > 100) {
        $errors['site_name'] = 'Site name must be 100 characters or fewer.';
    }

    if (install_text_length($siteDescription) > 300) {
        $errors['site_description'] = 'Site description must be 300 characters or fewer.';
    }

    if (!install_valid_app_url($appUrl)) {
        $errors['app_url'] = 'Public site URL must be an HTTPS origin without a path, query, credentials, or fragment. HTTP is allowed only for localhost.';
    }

    // Rejected rather than silently corrected: a path that does not route is
    // the operator locked out of their own panel with no explanation.
    if (preg_match('/^[a-z0-9][a-z0-9_-]{0,31}$/', $adminPath) !== 1) {
        $errors['admin_path'] = 'Admin path may contain only letters, numbers, hyphens and underscores.';
    }

    if (in_array($adminPath, ['install', 'api', 'assets', 'public', 'login', 'register', 'logout'], true)) {
        $errors['admin_path'] = 'That admin path collides with an existing route. Choose another.';
    }

    if (install_text_length($adminName) < 1 || install_text_length($adminName) > 100) {
        $errors['admin_name'] = 'Admin name must be between 1 and 100 characters.';
    }

    if (!filter_var($adminEmail, FILTER_VALIDATE_EMAIL) || strlen($adminEmail) > 191) {
        $errors['admin_email'] = 'A valid admin email is required.';
    }

    if (strlen($adminPassword) < 12) {
        $errors['admin_password'] = 'Admin password must contain at least 12 characters.';
    }

    if (!in_array($locale, install_available_locales(), true)) {
        $errors['locale'] = 'Installer language is not available in the installed language packs.';
    }

    if ($errors === []) {
        $values = [
            'db_host' => $dbHost,
            'db_port' => (int) $dbPort,
            'db_name' => $dbName,
            'db_user' => $dbUser,
            'site_name' => $siteName,
            'site_description' => $siteDescription,
            'app_url' => $appUrl,
            'admin_path' => $adminPath,
            'admin_name' => $adminName,
            'admin_email' => $adminEmail,
            'locale' => $locale,
        ];
    }

    return [
        'errors' => $errors,
        'values' => $values,
    ];
}

/** @param array<string, mixed> $input @param array<string, string|int> $values @return array<string, mixed> */
function install_database_config_from_setup(array $input, array $values): array
{
    return [
        'driver' => 'mysql',
        'host' => $values['db_host'] ?? '',
        'port' => (int) ($values['db_port'] ?? 3306),
        'database' => $values['db_name'] ?? '',
        'username' => $values['db_user'] ?? '',
        'password' => (string) ($input['db_password'] ?? ''),
        'charset' => 'utf8mb4',
    ];
}

function install_text_length(string $value): int
{
    return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value);
}

function install_valid_app_url(string $url): bool
{
    if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) {
        return false;
    }

    $parts = parse_url($url);

    if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) {
        return false;
    }

    if (isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])) {
        return false;
    }

    if (isset($parts['path']) && $parts['path'] !== '' && $parts['path'] !== '/') {
        return false;
    }

    $scheme = strtolower((string) $parts['scheme']);
    $host = strtolower(trim((string) $parts['host'], '[]'));
    $localHost = in_array($host, ['localhost', '127.0.0.1', '::1'], true);

    return $scheme === 'https' || ($scheme === 'http' && $localHost);
}

/**
 * @param array<int, string> $migrationFiles
 * @param array<int, string> $applied
 * @return array<int, string>
 */
function install_pending_migrations(array $migrationFiles, array $applied): array
{
    $files = array_values(array_unique(array_map('basename', $migrationFiles)));
    $applied = array_values(array_unique(array_map('basename', $applied)));
    sort($files, SORT_STRING);
    $pending = array_values(array_diff($files, $applied));
    sort($pending, SORT_STRING);

    return $pending;
}

/**
 * @param array<string, mixed> $config
 */
function install_write_database_config(string $configPath, array $config): bool
{
    $directory = dirname($configPath);

    if (!is_dir($directory) || !is_writable($directory)) {
        return false;
    }

    $source = "<?php\ndeclare(strict_types=1);\n\nreturn "
        . var_export($config, true)
        . ";\n";
    $temporary = tempnam($directory, '.quaeriva-config-');

    if ($temporary === false) {
        return false;
    }

    try {
        if (file_put_contents($temporary, $source, LOCK_EX) !== strlen($source)) {
            return false;
        }

        @chmod($temporary, 0640);

        $replaced = DIRECTORY_SEPARATOR === '\\'
            ? @copy($temporary, $configPath)
            : @rename($temporary, $configPath);

        if (!$replaced) {
            return false;
        }

        return true;
    } finally {
        if (is_file($temporary)) {
            @unlink($temporary);
        }
    }
}

/**
 * @param array<string, mixed> $databaseConfig
 * @return array{ok: bool, checks: array<int, array{key: string, ok: bool, message: string, advisory?: bool}>}
 */
function install_check_requirements(
    array $databaseConfig,
    ?string $projectRoot = null,
    ?callable $connector = null
): array {
    $projectRoot ??= install_root_path();
    $checks = [];

    $checks[] = [
        'key' => 'php',
        'ok' => version_compare(PHP_VERSION, '8.1.0', '>='),
        'message' => version_compare(PHP_VERSION, '8.1.0', '>=')
            ? 'PHP 8.1 or newer is available.'
            : 'PHP 8.1 or newer is required.',
    ];

    foreach ([
        'pdo' => [extension_loaded('pdo'), 'PDO is available.'],
        'pdo_mysql' => [extension_loaded('pdo_mysql'), 'PDO MySQL is available.'],
        'json' => [function_exists('json_encode'), 'JSON support is available.'],
        'session' => [function_exists('session_start'), 'PHP sessions are available.'],
    ] as $key => [$ok, $message]) {
        $checks[] = [
            'key' => $key,
            'ok' => (bool) $ok,
            'message' => (bool) $ok ? $message : "Required PHP support is missing: {$key}.",
        ];
    }

    $checks[] = [
        'key' => 'mbstring',
        'ok' => extension_loaded('mbstring'),
        'advisory' => true,
        'message' => extension_loaded('mbstring')
            ? 'mbstring is available.'
            : 'mbstring is recommended; Quaeriva will use its built-in UTF-8 fallback when it is unavailable.',
    ];

    // Only directories the application actually writes to are checked. The
    // former storage/ check tested a directory that does not ship and that no
    // code path uses, so a failure there meant nothing.
    $configDirectory = $projectRoot . DIRECTORY_SEPARATOR . 'config';
    $checks[] = install_writable_check('config', $configDirectory, 'Configuration directory is writable.');
    $checks[] = install_writable_check('root', $projectRoot, 'Project directory is writable for the installation lock.');
    $checks[] = install_permissions_check($projectRoot);

    $databaseOk = false;

    try {
        if ($connector === null) {
            $bootstrap = $projectRoot . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'bootstrap.php';

            if (is_file($bootstrap)) {
                require_once $bootstrap;
                app_bootstrap();
            }

            $connector = static function (array $config): mixed {
                return \App\Database\Connection::connect($config);
            };
        }

        $databaseOk = (bool) $connector($databaseConfig);
    } catch (Throwable) {
        $databaseOk = false;
    }

    $checks[] = [
        'key' => 'database',
        'ok' => $databaseOk,
        'message' => $databaseOk
            ? 'Database connection succeeded.'
            : 'Database connection failed. Check the host, port, database, and username.',
    ];

    $ok = true;

    foreach ($checks as $check) {
        // Advisory checks report a condition the operator should fix but that
        // does not stop this server from running the site: Apache with mod_php
        // executes a group-writable script happily, LiteSpeed does not.
        // Blocking setup on it would lock out installs that work.
        if (!$check['ok'] && empty($check['advisory'])) {
            $ok = false;
            break;
        }
    }

    return [
        'ok' => $ok,
        'checks' => $checks,
    ];
}

/**
 * LiteSpeed and suPHP refuse to execute a PHP file that is writable by group or
 * other, and answer 403 with nothing in the PHP error log -- an upload over FTP
 * or an archive carrying 0666 modes is the usual way to get there. Repair what
 * we can, because the operator may have no shell access at all, and report the
 * rest.
 *
 * @return array{key: string, ok: bool, message: string, advisory: bool}
 */
function install_permissions_check(string $projectRoot): array
{
    if (DIRECTORY_SEPARATOR === '\\') {
        // Windows has no Unix mode -- PHP reports 0666 for every file there --
        // and no Windows server runs LSAPI. Reporting a fault would be noise.
        return [
            'key' => 'permissions',
            'ok' => true,
            'advisory' => true,
            'message' => 'File permission check skipped: not applicable on Windows.',
        ];
    }

    $loose = [];

    foreach (['public/index.php', 'admin/index.php', 'install/index.php', '.htaccess'] as $relative) {
        $path = $projectRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);

        if (!is_file($path)) {
            continue;
        }

        $mode = fileperms($path);

        if ($mode === false || ($mode & 0022) === 0) {
            continue;
        }

        @chmod($path, 0644);
        clearstatcache(true, $path);
        $mode = fileperms($path);

        if ($mode !== false && ($mode & 0022) !== 0) {
            $loose[] = $relative . ' (' . decoct($mode & 0777) . ')';
        }
    }

    return [
        'key' => 'permissions',
        'ok' => $loose === [],
        'advisory' => true,
        'message' => $loose === []
            ? 'File permissions are safe: nothing is writable by group or other.'
            : 'Writable by group or other, which makes LiteSpeed answer 403: ' . implode(', ', $loose)
                . '. Set directories to 755 and files to 644.',
    ];
}

/** @return array{key: string, ok: bool, message: string} */
function install_writable_check(string $key, string $directory, string $successMessage): array
{
    $ok = is_dir($directory) ? is_writable($directory) : is_writable(dirname($directory));

    return [
        'key' => $key,
        'ok' => $ok,
        'message' => $ok ? $successMessage : "The {$key} directory is not writable.",
    ];
}

/**
 * Execute the complete first-run setup. The submitted password fields are
 * deliberately absent from both the return value and all error messages.
 *
 * @param array<string, mixed> $input
 * @param array<string, mixed> $options
 * @return array{ok: bool, errors: array<int|string, string>, migrations: array<int, string>, lock_path?: string, user_id?: int}
 */
function install_run_setup(array $input, array $options = []): array
{
    $validation = install_validate_setup($input);

    if ($validation['errors'] !== []) {
        return [
            'ok' => false,
            'errors' => $validation['errors'],
            'migrations' => [],
        ];
    }

    $root = (string) ($options['project_root'] ?? install_root_path());

    // The requirement checks were previously computed for display only, so a
    // host missing pdo_mysql or an unwritable config/ could still submit and
    // got an opaque exception instead of the failure it had already been
    // shown. The database check is skipped here because the real connection
    // attempt below reports that far more precisely.
    $environment = install_check_requirements(
        [],
        $root,
        static fn (array $config): bool => true
    );

    foreach ($environment['checks'] as $check) {
        if ($check['key'] !== 'database' && !$check['ok']) {
            return [
                'ok' => false,
                'errors' => [$check['message']],
                'migrations' => [],
            ];
        }
    }

    $documentRoot = isset($options['document_root']) ? (string) $options['document_root'] : null;
    $lockSelection = install_lock_path($root, $documentRoot);
    $lockStatus = install_lock_status($lockSelection['path']);

    if ($lockStatus !== 'missing') {
        return [
            'ok' => false,
            'errors' => [$lockStatus === 'valid'
                ? 'Installation is already locked.'
                : 'An invalid installation lock exists. Remove it manually only after inspection.'],
            'migrations' => [],
        ];
    }

    if (!install_begin_process_lock($lockSelection['path'])) {
        return [
            'ok' => false,
            'errors' => ['Installation is already in progress or needs manual lock inspection.'],
            'migrations' => [],
        ];
    }

    $databaseConfig = install_database_config_from_setup($input, $validation['values']);
    $connector = $options['connector'] ?? null;
    $pdo = null;
    $configWritten = false;
    $transactionCommitted = false;

    try {
        if ($connector !== null) {
            $pdo = $connector($databaseConfig);
        } else {
            $bootstrap = $root . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'bootstrap.php';
            require_once $bootstrap;
            app_bootstrap();
            $pdo = \App\Database\Connection::connect($databaseConfig);
        }

        if (!$pdo instanceof \PDO) {
            throw new RuntimeException('Database connection did not return PDO.');
        }

        $migrationDirectory = (string) ($options['migration_directory'] ?? $root . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'migrations');
        $migrator = new \App\Database\Migrator($pdo, $migrationDirectory);
        $migrator->migrate();

        // A successful install means a usable site, not just tables. These two are
        // what the appearance and widget screens read; without them the operator sees
        // nine "region unavailable" cards and no way to act on them.
        $health = [
            'layout' => (int) $pdo->query("SELECT COUNT(*) FROM layouts WHERE status='published'")->fetchColumn(),
            'sections' => (int) $pdo->query('SELECT COUNT(*) FROM layout_sections')->fetchColumn(),
        ];
        if ($health['layout'] < 1 || $health['sections'] < 1) {
            throw new RuntimeException('The database was created but the default page layout is missing.');
        }

        $existingUsers = (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();

        if ($existingUsers > 0) {
            throw new RuntimeException('An existing user record was found; installation will not overwrite it.');
        }

        $pdo->beginTransaction();
        $statement = $pdo->prepare(
            'INSERT INTO users (
                name, email, password_hash, role, status, email_verified_at,
                preferred_locale, created_at, updated_at
             ) VALUES (
                :name, :email, :password_hash, :role, :status, NOW(),
                :preferred_locale, NOW(), NOW()
             )'
        );
        $statement->execute([
            'name' => $validation['values']['admin_name'],
            'email' => $validation['values']['admin_email'],
            'password_hash' => password_hash((string) ($input['admin_password'] ?? ''), PASSWORD_DEFAULT),
            'role' => 'admin',
            'status' => 'active',
            'preferred_locale' => $validation['values']['locale'],
        ]);
        $userId = (int) $pdo->lastInsertId();

        // The site identifies itself in titles, feeds and Open Graph tags from
        // the first request, so asking here beats shipping every new site
        // named "Quaeriva" until someone finds the settings screen.
        install_put_setting($pdo, 'site.name', $validation['values']['site_name']);
        install_put_setting($pdo, 'site.description', $validation['values']['site_description']);

        // Without this the site defaulted to Arabic no matter which language
        // was chosen in the wizard, because site.default_locale was only ever
        // set from the admin panel.
        install_put_setting($pdo, 'site.default_locale', (string) $validation['values']['locale']);

        // Migration 020 backfills public handles, but it runs before this row
        // exists, so the first administrator was left with an empty username
        // and an unreachable profile page.
        $handle = $pdo->prepare('UPDATE users SET username = :username WHERE id = :id');
        $handle->execute(['username' => 'member-' . $userId, 'id' => $userId]);

        if (!empty($input['seed_starter_content'])) {
            install_seed_starter_categories($pdo, (string) $validation['values']['locale']);
        }

        $configPath = (string) ($options['database_config_path'] ?? $root . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'database.local.php');
        $previousConfig = install_capture_file($configPath);

        if (!install_write_database_config($configPath, $databaseConfig)) {
            throw new RuntimeException('Unable to write the database configuration.');
        }
        $configWritten = true;

        // Subdirectory installs need APP_BASE_URL or every generated link is
        // wrong. The field is pre-filled from SCRIPT_NAME so the common case
        // needs no thought from the operator.
        $baseUrl = app_base_path((string) ($input['base_url'] ?? ''));
        $envPath = $root . DIRECTORY_SEPARATOR . '.env';

        if (!install_write_base_url(
            $envPath,
            $baseUrl,
            (string) ($validation['values']['admin_path'] ?? 'admin'),
            (string) ($validation['values']['app_url'] ?? '')
        )) {
            throw new RuntimeException('Unable to write .env for the public URL and site paths. Make the project directory writable, or create .env yourself with APP_URL, APP_BASE_URL, and APP_ADMIN_PATH.');
        }

        $pdo->commit();
        $transactionCommitted = true;

        if (!install_write_lock($lockSelection['path'])) {
            throw new RuntimeException('Unable to create the installation lock.');
        }

        return [
            'ok' => true,
            'errors' => [],
            'migrations' => $migrator->applied(),
            'lock_path' => $lockSelection['path'],
            'user_id' => $userId,
        ];
    } catch (Throwable $exception) {
        if ($pdo instanceof \PDO && $pdo->inTransaction()) {
            $pdo->rollBack();
        }

        if ($configWritten && !$transactionCommitted) {
            install_restore_file($configPath, $previousConfig);
        }

        // A generic "could not be completed" leaves the operator with nothing
        // to act on, which is the difference between a five-minute fix and
        // giving up. The database driver's own errors are mapped to plain
        // instructions; anything unrecognised is passed through, because an
        // installer failure is not a place to withhold detail from the person
        // who owns the server.
        return [
            'ok' => false,
            'errors' => [install_explain_failure($exception)],
            'migrations' => [],
        ];
    } finally {
        install_end_process_lock($lockSelection['path']);
    }
}

/**
 * Turn an installation exception into something the operator can act on.
 */
/**
 * The public URL of the installed site, for the links shown after setup.
 * Falls back to detecting the path when the field was left empty.
 */
function install_site_url(string $baseUrl): string
{
    $base = trim($baseUrl);

    if ($base === '') {
        $base = install_detect_base_url();
    }

    $base = '/' . trim($base, '/');

    return $base === '/' ? '/' : $base . '/';
}

function install_put_setting(PDO $pdo, string $key, string $value): void
{
    $statement = $pdo->prepare(
        'INSERT INTO settings (`key`, value, type, created_at, updated_at)
         VALUES (:key, :value, \'string\', NOW(), NOW())
         ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = NOW()'
    );
    $statement->execute(['key' => $key, 'value' => $value]);
}

/**
 * Work out the URL path the site is served from, e.g. "/ask" for
 * https://example.com/ask/install/. Empty means the domain root.
 */
function install_detect_base_url(?string $scriptName = null): string
{
    $script = (string) ($scriptName ?? ($_SERVER['SCRIPT_NAME'] ?? ''));
    $script = str_replace('\\', '/', $script);
    // Strip the installer's own path back to the project root.
    // Strip the installer's own path, and any front controller the server put
    // in SCRIPT_NAME, back to the folder the site is served from.
    $path = preg_replace('#/install(?:/index\.php)?/?$#', '', $script);
    $path = preg_replace('#/index\.php$#', '', (string) $path);
    $path = rtrim((string) $path, '/');

    return $path === '' || $path === '/' ? '' : '/' . trim($path, '/');
}

/**
 * Persist the trusted public origin and URL paths in .env. Existing keys are preserved.
 */
function install_write_base_url(string $envPath, string $baseUrl, string $adminPath = '', string $appUrl = ''): bool
{
    return app_env_write($envPath, [
        'APP_URL' => $appUrl !== '' ? $appUrl : null,
        'APP_BASE_URL' => $baseUrl !== '' ? $baseUrl : null,
        // Only written when it differs from the default, so a stock install
        // leaves no line to explain.
        'APP_ADMIN_PATH' => ($adminPath !== '' && $adminPath !== 'admin') ? $adminPath : null,
    ]);
}

function install_explain_failure(Throwable $exception): string
{
    $message = $exception->getMessage();
    $lower = strtolower($message);

    // MySQL/MariaDB driver errors, in the order an operator meets them.
    if (str_contains($lower, 'access denied')) {
        return 'The database refused the username or password. Check the database user, its password, and that the user has been granted privileges on this database. (' . $message . ')';
    }
    if (str_contains($lower, 'unknown database')) {
        return 'The database does not exist. Create it in your hosting panel first, then run the installer again. On most shared hosts the real name is prefixed, for example "account_quaeriva". (' . $message . ')';
    }
    if (str_contains($lower, 'connection refused') || str_contains($lower, "can't connect") || str_contains($lower, 'no such host') || str_contains($lower, 'name or service not known')) {
        return 'Could not reach the database server. On most shared hosting the host is "localhost", not an IP address, and the port is the default 3306. Check both with your host. (' . $message . ')';
    }
    if (str_contains($lower, 'sqlstate[hy000] [2002]')) {
        return 'Could not reach the database server at that host and port. Try "localhost". (' . $message . ')';
    }
    if (str_contains($lower, 'command denied') || str_contains($lower, 'create command denied')) {
        return 'The database user cannot create tables. Grant it all privileges on this database in your hosting panel. (' . $message . ')';
    }
    if (str_contains($lower, 'unable to write the database configuration')) {
        return 'The config directory is not writable, so the database settings could not be saved. Set config/ to 755 (or 775) and run the installer again.';
    }
    if (str_contains($lower, 'unable to create the installation lock')) {
        return 'The project directory is not writable, so the installation lock could not be written. Set the project root to 755 and run the installer again.';
    }
    if (str_contains($lower, 'existing user record')) {
        return 'This database already contains a Quaeriva installation. Use an empty database, or remove the installation lock to re-enter an existing site.';
    }

    return 'Installation failed: ' . $message;
}

function install_seed_starter_categories(PDO $pdo, string $locale): void
{
    $starter = $locale === 'ar'
        ? [['أسئلة عامة', 'general-questions'], ['تقنية وبرمجة', 'technology-programming'], ['تعلم وتطوير', 'learning-development']]
        : [['General questions', 'general-questions'], ['Technology and programming', 'technology-programming'], ['Learning and development', 'learning-development']];
    $statement = $pdo->prepare('INSERT INTO categories (name, description, content_locale, slug, parent_id, sort_order, created_at, updated_at) VALUES (:name, NULL, :locale, :slug, NULL, :sort, NOW(), NOW())');
    foreach ($starter as $index => [$name, $slug]) {
        $exists = $pdo->prepare('SELECT 1 FROM categories WHERE slug = :slug AND content_locale = :locale LIMIT 1');
        $exists->execute(['slug' => $slug, 'locale' => $locale]);
        if ($exists->fetchColumn() === false) $statement->execute(['name' => $name, 'locale' => $locale, 'slug' => $slug, 'sort' => $index + 1]);
    }
}

/** @return array{exists: bool, contents: string, mode: int|null} */
function install_capture_file(string $path): array
{
    if (!is_file($path)) {
        return ['exists' => false, 'contents' => '', 'mode' => null];
    }

    return [
        'exists' => true,
        'contents' => (string) file_get_contents($path),
        'mode' => fileperms($path) !== false ? fileperms($path) & 0777 : null,
    ];
}

/** @param array{exists: bool, contents: string, mode: int|null} $backup */
function install_restore_file(string $path, array $backup): void
{
    if (!$backup['exists']) {
        if (is_file($path)) {
            @unlink($path);
        }

        return;
    }

    file_put_contents($path, $backup['contents'], LOCK_EX);

    if ($backup['mode'] !== null) {
        @chmod($path, $backup['mode']);
    }
}

/** @return list<string> */
function install_available_locales(): array
{
    $locales = [];
    foreach (glob(app_root_path('resources', 'lang', '*.php')) ?: [] as $file) {
        $code = strtolower((string) pathinfo($file, PATHINFO_FILENAME));
        if (preg_match('/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/', $code) === 1) {
            $locales[] = $code;
        }
    }
    $locales = array_values(array_unique($locales));
    sort($locales, SORT_STRING);
    return $locales;
}

/** @return array<string,mixed> */
function install_language_dictionary(string $locale): array
{
    $file = app_root_path('resources', 'lang', $locale . '.php');
    if (!is_file($file)) return [];
    $dictionary = require $file;
    return is_array($dictionary) ? $dictionary : [];
}

function install_locale_name(string $locale): string
{
    $dictionary = install_language_dictionary($locale);
    $pack = is_array($dictionary['install'] ?? null) ? $dictionary['install'] : [];
    return trim((string) ($pack['language_name'] ?? $locale)) ?: $locale;
}

/** @return array<string, string> */
function install_translations(string $locale): array
{
    $available = install_available_locales();
    if (!in_array($locale, $available, true)) $locale = $available[0] ?? 'en';
    $englishDictionary = install_language_dictionary('en');
    $selectedDictionary = install_language_dictionary($locale);
    $english = is_array($englishDictionary['install'] ?? null) ? $englishDictionary['install'] : [];
    $selected = is_array($selectedDictionary['install'] ?? null) ? $selectedDictionary['install'] : [];
    return array_replace($english, $selected);

}

function install_csrf_token(): string
{
    if (session_status() !== PHP_SESSION_ACTIVE) {
        $https = (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off')
            || (int) ($_SERVER['SERVER_PORT'] ?? 0) === 443;
        session_set_cookie_params([
            'httponly' => true,
            'secure' => $https,
            'samesite' => 'Lax',
            'path' => '/',
        ]);
        session_start();
    }

    if (!isset($_SESSION['quaeriva_install_csrf']) || !is_string($_SESSION['quaeriva_install_csrf'])) {
        $_SESSION['quaeriva_install_csrf'] = bin2hex(random_bytes(32));
    }

    return $_SESSION['quaeriva_install_csrf'];
}

function install_csrf_valid(string $token): bool
{
    return hash_equals(install_csrf_token(), $token);
}

function install_requested_locale(array $input = []): string
{
    // ponytail: English is the installer default because the archive ships
    // worldwide; Arabic stays one click away via ?lang=ar.
    $candidate = $input['locale'] ?? ($_GET['lang'] ?? getenv('APP_LOCALE') ?: 'en');
    $candidate = strtolower(str_replace('_', '-', trim((string) $candidate)));
    $available = install_available_locales();
    if (in_array($candidate, $available, true)) return $candidate;
    return in_array('en', $available, true) ? 'en' : ($available[0] ?? 'en');
}
