<?php

declare(strict_types=1);
/**
 * Copyright 2022-2025 FOSSBilling
 * Copyright 2011-2021 BoxBilling, Inc.
 * SPDX-License-Identifier: Apache-2.0.
 *
 * @copyright FOSSBilling (https://www.fossbilling.org)
 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
 */

use Box\Mod\Email\Service;
use FOSSBilling\Environment;
use FOSSBilling\Http\RequestFactory;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Uid\Uuid;
use Twig\Loader\FilesystemLoader;

date_default_timezone_set('UTC');
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('display_startup_errors', 1);
ini_set('log_errors', '1');
ini_set('error_log', 'php_error.log');

// Define required paths.
define('PATH_ROOT', dirname(__DIR__));
define('PATH_LIBRARY', PATH_ROOT . DIRECTORY_SEPARATOR . 'library');
define('PATH_VENDOR', PATH_ROOT . DIRECTORY_SEPARATOR . 'vendor');

// Set the default include path to include the library directory.
set_include_path(get_include_path() . PATH_SEPARATOR . PATH_LIBRARY);

// Check vendor folder exists and load Composer autoloader.
if (!file_exists(PATH_VENDOR)) {
    throw new Exception('The composer packages are missing.', 1);
}
require PATH_VENDOR . DIRECTORY_SEPARATOR . 'autoload.php';

// Define global paths.
define('PATH_INSTALL_THEMES', Path::join(PATH_ROOT, 'install'));
define('PATH_THEMES', Path::join(PATH_ROOT, 'themes'));
define('PATH_LICENSE', Path::join(PATH_ROOT, 'LICENSE'));
define('PATH_SQL', Path::join(PATH_ROOT, 'install', 'sql', 'structure.sql'));
define('PATH_SQL_DATA', Path::join(PATH_ROOT, 'install', 'sql', 'content.sql'));
define('PATH_INSTALL', Path::join(PATH_ROOT, 'install'));
define('PATH_CONFIG', Path::join(PATH_ROOT, 'config.php'));
define('PATH_CONFIG_SAMPLE', Path::join(PATH_ROOT, 'config-sample.php'));
define('PATH_CRON', Path::join(PATH_ROOT, 'cron.php'));
define('PATH_LANGS', Path::join(PATH_ROOT, 'locale'));
define('PATH_MODS', Path::join(PATH_ROOT, 'modules'));
define('PATH_DATA', Path::join(PATH_ROOT, 'data'));
define('PATH_CACHE', Path::join(PATH_DATA, 'cache'));
define('PATH_LOG', Path::join(PATH_DATA, 'log'));
define('HURAGA_CONFIG', Path::join(PATH_THEMES, 'huraga', 'config', 'settings_data.json'));
define('HURAGA_CONFIG_TEMPLATE', Path::join(PATH_THEMES, 'huraga', 'config', 'settings_data.json.example'));
define('PATH_HTACCESS', Path::join(PATH_ROOT, '.htaccess'));
define('PAGE_INSTALL', Path::join('./assets', 'install.html.twig'));
define('PAGE_RESULT', Path::join('./assets', 'result.html.twig'));

// Some functions and classes reference this, so we define it here to avoid errors.
const DEBUG = false;

// Set default include path
set_include_path(implode(PATH_SEPARATOR, [
    PATH_LIBRARY,
    get_include_path(),
]));

// Set up custom autoloader.
require Path::join(PATH_LIBRARY, 'FOSSBilling', 'Autoloader.php');
$loader = new FOSSBilling\AutoLoader();
$loader->register();

$preConfigProxyCandidate = RequestFactory::getPreConfigProxyCandidate($_SERVER);
$request = RequestFactory::createFromGlobals();
$url = $request->getSchemeAndHttpHost() . $request->getRequestUri();
$current_url = Path::getDirectory($url);
$root_url = str_replace('/install', '', $current_url) . '/';
define('SYSTEM_URL', $root_url);
const URL_INSTALL = SYSTEM_URL . 'install/';
const URL_ADMIN = SYSTEM_URL . 'admin';

// Load action and initialize the installer
$action = $request->query->get('a', 'index');
$action = $action !== '' ? $action : 'index';
$installer = new FOSSBilling_Installer($request, $preConfigProxyCandidate);

// Run the installer only in non-CLI mode
if (!Environment::isCLI()) {
    $installer->run($action)->send();
}

// Inline installer class.
final class FOSSBilling_Installer
{
    private readonly Session $session;
    private PDO $pdo;
    private bool $isDebug = false;
    private readonly Filesystem $filesystem;

    public function __construct(private readonly Request $request, private readonly array $preConfigProxyCandidate = [])
    {
        require_once 'session.php';
        $this->session = new Session();
        $this->filesystem = new Filesystem();
        $config = $this->getExistingConfig();
        if ($config !== null) {
            $this->isDebug = (bool) ($config['debug_and_monitoring']['debug'] ?? false);
        }

        $action = $this->request->query->get('a', 'index');
        if (getenv('IS_DDEV') === 'true' && $action === 'index') {
            $this->session->set('database_hostname', 'db');
            $this->session->set('database_name', 'db');
            $this->session->set('database_username', 'db');
            $this->session->set('database_password', 'db');
        }
    }

    /**
     * Action router.
     *
     * @param string $action
     */
    public function run($action): Response
    {
        switch ($action) {
            case 'install':
                // Make sure this is a POST request
                if (!$this->request->isMethod('POST')) {
                    return new RedirectResponse('./install.php');
                }

                // Installer validation
                try {
                    // Make sure we are not already installed. Prevents tampered requests from being able to trigger the installer.
                    if (!$this->isDebug && $this->isAlreadyInstalled()) {
                        throw new Exception('FOSSBilling is already installed.');
                    }

                    // Set if they've opted into error reporting
                    $this->session->set('error_reporting', $this->request->request->get('error_reporting'));

                    $this->session->set('system_url', $this->normalizeSystemUrl($this->request->request->get('system_url', SYSTEM_URL)));
                    $trustedProxyEnabled = $this->isChecked($this->request->request->get('trusted_proxy_enabled'));
                    $this->session->set('trusted_proxy_enabled', $trustedProxyEnabled);
                    if ($trustedProxyEnabled) {
                        $this->session->set('trusted_proxy_proxies', $this->normalizeTrustedProxyProxies($this->request->request->get('trusted_proxy_proxies')));
                        $this->session->set('trusted_proxy_headers', $this->normalizeTrustedProxyHeaders($this->request->request->get('trusted_proxy_headers')));
                    } else {
                        $this->session->set('trusted_proxy_proxies', []);
                        $this->session->set('trusted_proxy_headers', 'x_forwarded');
                    }

                    // Set up default currency before validation to preserve user selection if validation fails
                    $this->session->set('currency_code', $this->request->request->get('currency_code'));
                    // Handle database information
                    $this->session->set('database_hostname', $this->request->request->get('database_hostname'));
                    $databasePort = FOSSBilling\Tools::normalizePort($this->request->request->get('database_port'));
                    if ($databasePort === null) {
                        throw new Exception('Database port is invalid.');
                    }
                    $this->session->set('database_port', $databasePort);
                    $this->session->set('database_name', $this->request->request->get('database_name'));
                    $this->session->set('database_username', $this->request->request->get('database_username'));
                    $this->session->set('database_password', $this->request->request->get('database_password'));
                    $this->connectDatabase();

                    // Handle admin information
                    $this->session->set('admin_name', $this->request->request->get('admin_name'));
                    $this->session->set('admin_email', $this->request->request->get('admin_email'));
                    $this->session->set('admin_password', $this->request->request->get('admin_password'));

                    if (Environment::isTesting()) {
                        $this->session->set('admin_api_token', $this->request->request->get('admin_api_token'));
                    } else {
                        $this->session->set('admin_api_token', null);
                    }

                    $this->validateAdmin();
                    $selectedSystemUrl = $this->getSelectedSystemUrl();

                    // Attempt installation
                    $this->install();
                    $this->generateEmailTemplates();
                    session_destroy();

                    $result = $this->render(PAGE_RESULT, [
                        'success' => true,
                        'config_file_path' => PATH_CONFIG,
                        'cron_path' => PATH_CRON,
                        'install_module_path' => PATH_INSTALL,
                        'url_customer' => $selectedSystemUrl,
                        'url_admin' => rtrim($selectedSystemUrl, '/') . '/admin',
                    ]);

                    // Delete only the installer entry point if debug mode is NOT enabled, so completion page assets remain available.
                    try {
                        if (!$this->isDebug) {
                            $this->filesystem->remove(Path::join(PATH_INSTALL, 'install.php'));
                        }
                    } catch (Throwable) {
                        // Do nothing and fail silently. New warnings are presented on the installation completed page for a leftover install directory.
                    }

                    return new Response($result);
                } catch (Exception $e) {
                    // Route to result page with exception information
                    return new Response($this->render(PAGE_RESULT, [
                        'success' => false,
                        'message' => $e->getMessage(),
                    ]), 500);
                }
            case 'index':
            default:
                $requirements = new FOSSBilling\Requirements();
                $compatibility = $requirements->checkCompat();
                $vars = [
                    'compatibility' => $compatibility,
                    'os' => PHP_OS,
                    'os_ok' => (str_starts_with(strtoupper(PHP_OS), 'WIN')) ? false : true,
                    'is_subfolder' => $this->isSubfolder(),
                    'fossbilling_ver' => FOSSBilling\Version::VERSION,
                    'canInstall' => !$this->isSubfolder() && $compatibility['can_install'],
                    'alreadyInstalled' => $this->isAlreadyInstalled(),
                    'database_hostname' => $this->session->get('database_hostname'),
                    'database_name' => $this->session->get('database_name'),
                    'database_username' => $this->session->get('database_username'),
                    'database_password' => $this->session->get('database_password'),
                    'admin_name' => $this->session->get('admin_name'),
                    'admin_email' => $this->session->get('admin_email'),
                    'admin_password' => $this->session->get('admin_password'),
                    'currency_code' => $this->session->get('currency_code') ?: 'USD',
                    'install_module_path' => PATH_INSTALL,
                    'cron_path' => PATH_CRON,
                    'config_file_path' => PATH_CONFIG,
                    'system_url' => $this->session->get('system_url') ?: SYSTEM_URL,
                    'admin_site' => URL_ADMIN,
                    'domain' => SYSTEM_URL,
                    'proxy_candidate' => $this->preConfigProxyCandidate,
                    'trusted_proxy_enabled' => (bool) $this->session->get('trusted_proxy_enabled'),
                    'trusted_proxy_proxies' => implode(', ', $this->session->get('trusted_proxy_proxies') ?: ($this->preConfigProxyCandidate['proxies'] ?? [])),
                    'trusted_proxy_headers' => $this->session->get('trusted_proxy_headers') ?: ($this->preConfigProxyCandidate['headers'] ?? 'x_forwarded'),
                ];

                return new Response($this->render(PAGE_INSTALL, $vars));
        }
    }

    /**
     * Render a page with Twig.
     *
     * @param string $name
     * @param array  $vars
     */
    private function render($name, $vars = []): string
    {
        $options = [
            'paths' => [PATH_INSTALL_THEMES],
            'debug' => true,
            'charset' => 'utf-8',
            'optimizations' => 1,
            'autoescape' => 'html',
            'auto_reload' => true,
            'cache' => false,
        ];
        $loader = new FilesystemLoader($options['paths']);
        $twig = new Twig\Environment($loader, $options);
        $twig->addGlobal('request', array_merge($this->request->query->all(), $this->request->request->all()));
        $twig->addGlobal('version', FOSSBilling\Version::VERSION);

        return $twig->render($name, $vars);
    }

    /**
     * Attempt to open the database connection.
     */
    private function connectDatabase(): void
    {
        $databaseName = $this->quoteMysqlIdentifier((string) $this->session->get('database_name'));

        // Open the connection
        $databasePort = FOSSBilling\Tools::normalizePort($this->session->get('database_port'), 3306);

        $this->pdo = new PDO('mysql:host=' . $this->session->get('database_hostname') . ';port=' . $databasePort,
            $this->session->get('database_username'),
            $this->session->get('database_password'),
            [
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ]
        );

        // Set required MySQL environment settings
        $this->pdo->exec('SET NAMES "utf8"');
        $this->pdo->exec('SET CHARACTER SET utf8');
        $this->pdo->exec('SET CHARACTER_SET_CONNECTION = utf8');
        $this->pdo->exec('SET character_set_results = utf8');
        $this->pdo->exec('SET character_set_server = utf8');
        $this->pdo->exec('SET SESSION interactive_timeout = 28800');
        $this->pdo->exec('SET SESSION wait_timeout = 28800');

        // Attempt to create the database.
        try {
            $this->pdo->exec('CREATE DATABASE ' . $databaseName . ' CHARACTER SET utf8 COLLATE utf8_general_ci;');
        } catch (PDOException) {
            // Silently fail if the database already exists.
        }

        // Select the database as default for future queries
        $this->pdo->query('USE ' . $databaseName . ';');
    }

    private function quoteMysqlIdentifier(string $identifier): string
    {
        if ($identifier === '' || str_contains($identifier, "\0")) {
            throw new InvalidArgumentException('The database name is invalid.');
        }

        return '`' . str_replace('`', '``', $identifier) . '`';
    }

    /**
     * Validate admin information meets all required parameters.
     */
    private function validateAdmin(): bool
    {
        if (!filter_var($this->session->get('admin_email'), FILTER_VALIDATE_EMAIL)) {
            throw new Exception('The admin email is not a valid address.');
        }

        if (strlen((string) $this->session->get('admin_password')) < 8) {
            throw new Exception('Minimum admin password length is 8 characters.');
        }

        if (!preg_match('#[0-9]+#', (string) $this->session->get('admin_password'))) {
            throw new Exception('Admin password must include at least one number.');
        }

        if (!preg_match('#[a-z]+#', (string) $this->session->get('admin_password'))) {
            throw new Exception('Admin password must include at least one lowercase letter.');
        }

        if (!preg_match('#[A-Z]+#', (string) $this->session->get('admin_password'))) {
            throw new Exception('Admin password must include at least one uppercase letter.');
        }

        if (empty($this->session->get('admin_name'))) {
            throw new Exception('You must enter an Admin Name.');
        }

        return true;
    }

    /**
     * Attempt to detect if the application is under a subfolder.
     */
    private function isSubfolder(): bool
    {
        return substr_count(URL_INSTALL, '/') > 4;
    }

    /**
     * Check if we are already installed.
     *
     * Any existing config file must block the public installer. If the file is
     * invalid, a server administrator must repair or remove it manually before
     * installation can proceed.
     */
    public function isAlreadyInstalled(): bool
    {
        return !$this->isDebug && $this->filesystem->exists(PATH_CONFIG);
    }

    private function getExistingConfig(): ?array
    {
        if (!$this->filesystem->exists(PATH_CONFIG)) {
            return null;
        }

        try {
            $config = require PATH_CONFIG;
        } catch (Throwable) {
            return null;
        }

        return is_array($config) ? $config : null;
    }

    /**
     * Installation processor.
     */
    private function install(): bool
    {
        // Load database structure
        $sql = $this->filesystem->readFile(PATH_SQL);
        $sql_content = $this->filesystem->readFile(PATH_SQL_DATA);
        if (!$sql || !$sql_content) {
            throw new Exception('Could not read structure.sql file');
        }

        // Read content, parse queries into an array, then loop and execute each query
        $sql .= $sql_content;
        $sql = preg_split('/\;[\r]*\n/ism', $sql);
        $sql = array_map(trim(...), $sql);
        foreach ($sql as $query) {
            if (!trim($query)) {
                continue;
            }
            $this->pdo->query($query);
        }

        // Create default administrator
        $passwordObject = new FOSSBilling\PasswordManager();
        $stmt = $this->pdo->prepare("INSERT INTO admin (role, name, email, pass, protected, created_at, updated_at, api_token) VALUES('admin', :admin_name, :admin_email, :admin_password, 1, NOW(), NOW(), :api_token);");
        $stmt->execute([
            'admin_name' => $this->session->get('admin_name'),
            'admin_email' => $this->session->get('admin_email'),
            'admin_password' => $passwordObject->hashIt($this->session->get('admin_password')),
            'api_token' => $this->session->get('admin_api_token'),
        ]);

        // Delete default currency from content file and use currency passed in the installer
        $stmt = $this->pdo->prepare("DELETE FROM currency WHERE code='USD'");
        $stmt->execute();
        $stmt = $this->pdo->prepare('INSERT INTO currency (id, code, is_default, conversion_rate, created_at, updated_at) VALUES(1, :currency_code, 1, 1.000000, NOW(), NOW());');
        $stmt->execute([
            'currency_code' => $this->session->get('currency_code'),
        ]);

        $stmt = $this->pdo->prepare('INSERT INTO setting (param, value, created_at, updated_at) VALUES (:param, :value, NOW(), NOW())');
        $stmt->execute([
            ':param' => 'last_error_reporting_nudge',
            ':value' => FOSSBilling\Version::VERSION,
        ]);

        // Copy config templates when applicable
        if (!$this->filesystem->exists(HURAGA_CONFIG) && $this->filesystem->exists(HURAGA_CONFIG_TEMPLATE)) {
            $this->filesystem->copy(HURAGA_CONFIG_TEMPLATE, HURAGA_CONFIG); // Copy the file instead of renaming it. This allows local dev instances to not need to restore the original file manually.
        }

        // If .htaccess doesn't exist, fetch the latest from GitHub.
        if (!$this->filesystem->exists(PATH_HTACCESS)) {
            try {
                $client = HttpClient::create();
                $response = $client->request('GET', 'https://raw.githubusercontent.com/FOSSBilling/FOSSBilling/main/src/.htaccess');
                $this->filesystem->dumpFile(PATH_HTACCESS, $response->getContent());
            } catch (Exception $e) {
                throw new Exception('Unable to write required .htaccess file to ' . PATH_HTACCESS . '. Check file and folder permissions.', $e->getCode());
            }
        }

        // Create the configuration file
        $output = $this->getConfigOutput();

        try {
            $this->filesystem->dumpFile(PATH_CONFIG, $output);
        } catch (IOException) {
            throw new Exception('Configuration file is not writable or does not exist. Please create the file at ' . PATH_CONFIG . ' and make it writable', 101);
        }
        clearstatcache(true, PATH_CONFIG);
        if (function_exists('opcache_invalidate')) {
            opcache_invalidate(PATH_CONFIG, true);
        }
        (new FOSSBilling\UpdateFinalization())->writeCompleteState();

        // Installation completed successfully
        return true;
    }

    private function getSelectedSystemUrl(): string
    {
        $systemUrl = $this->session->get('system_url');
        if (is_string($systemUrl) && $systemUrl !== '') {
            return $systemUrl;
        }

        return $this->normalizeSystemUrl(SYSTEM_URL);
    }

    private function normalizeSystemUrl(mixed $url): string
    {
        $url = trim((string) $url);
        if ($url === '') {
            throw new InvalidArgumentException('The FOSSBilling URL is required.');
        }

        if (!str_contains($url, '://')) {
            $url = 'https://' . $url;
        }

        $parts = parse_url($url);
        if (!is_array($parts) || !isset($parts['scheme'], $parts['host']) || !in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
            throw new InvalidArgumentException('The FOSSBilling URL must be a valid HTTP or HTTPS URL.');
        }

        $path = $parts['path'] ?? '/';
        if ($path === '') {
            $path = '/';
        }

        $normalizedUrl = strtolower($parts['scheme']) . '://' . $parts['host'];
        if (isset($parts['port'])) {
            $normalizedUrl .= ':' . $parts['port'];
        }

        return rtrim($normalizedUrl . '/' . trim($path, '/'), '/') . '/';
    }

    private function normalizeTrustedProxyProxies(mixed $proxies): array
    {
        $proxyList = preg_split('/[\s,]+/', trim((string) $proxies), -1, PREG_SPLIT_NO_EMPTY);
        if ($proxyList === false || $proxyList === []) {
            throw new InvalidArgumentException('At least one trusted proxy IP address is required when reverse proxy trust is enabled.');
        }

        return array_values(array_unique(array_map(trim(...), $proxyList)));
    }

    private function normalizeTrustedProxyHeaders(mixed $headers): string
    {
        $headers = trim((string) $headers);
        if (!in_array($headers, ['x_forwarded', 'forwarded', 'aws_elb', 'traefik'], true)) {
            throw new InvalidArgumentException('The trusted proxy header format is invalid.');
        }

        return $headers;
    }

    private function isChecked(mixed $value): bool
    {
        return in_array($value, ['1', 1, true, 'true', 'on', 'yes'], true);
    }

    /**
     * Generate the `config.php` file using the `config-sample.php` as a template.
     */
    private function getConfigOutput(): string
    {
        $updateBranch = FOSSBilling\Version::isPreviewVersion() ? 'preview' : 'release';

        // Load default sample config
        $data = require PATH_CONFIG_SAMPLE;
        $systemUrl = $this->getSelectedSystemUrl();

        // Handle dynamic configs
        $data['security']['force_https'] = str_starts_with($systemUrl, 'https://');
        if ($this->session->get('trusted_proxy_enabled') === true) {
            $data['security']['trusted_proxies'] = [
                'enabled' => true,
                'proxies' => $this->session->get('trusted_proxy_proxies') ?: [],
                'headers' => $this->session->get('trusted_proxy_headers') ?: 'x_forwarded',
            ];
        }
        $data['debug_and_monitoring']['report_errors'] = (bool) $this->session->get('error_reporting');
        $data['debug_and_monitoring']['debug'] = $this->isDebug;
        $data['update_branch'] = $updateBranch;
        $data['info']['instance_id'] = Uuid::v4()->toString();
        $data['url'] = str_replace(['https://', 'http://'], '', $systemUrl);
        $data['path_data'] = PATH_DATA;
        $data['db'] = [
            'driver' => 'pdo_mysql',
            'host' => $this->session->get('database_hostname'),
            'port' => FOSSBilling\Tools::normalizePort($this->session->get('database_port'), 3306),
            'name' => $this->session->get('database_name'),
            'user' => $this->session->get('database_username'),
            'password' => $this->session->get('database_password'),
        ];
        $data['twig']['cache'] = PATH_CACHE;
        $data['disable_auto_cron'] = !FOSSBilling\Version::isPreviewVersion() && !Environment::isDevelopment();

        // Build and return data
        $output = '<?php ' . PHP_EOL;

        return $output . ('return ' . var_export($data, true) . ';');
    }

    /**
     * Generate the default email templates.
     */
    private function generateEmailTemplates(): bool
    {
        $emailService = new Service();
        $di = include Path::join(PATH_ROOT, 'di.php');
        $di['translate']();
        $emailService->setDi($di);

        return $emailService->templateBatchGenerate();
    }
}
