<?php

declare(strict_types=1);

namespace PhpList\Core\Core;

use Doctrine\ORM\EntityManagerInterface;
use Exception;
use RuntimeException;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\ErrorHandler\ErrorHandler;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use UnexpectedValueException;

/**
 * This class bootstraps the phpList core system.
 *
 * Include it from the entry point and call Bootstrap::getInstance() to get an instance,
 * and $bootstrap->setEnvironment($environment) if you would like to run the application in
 * the development or testing environment. (For the production environment,
 * the setEnvironment call is not needed).
 *
 * After that, call $bootstrap->configure() and $bootstrap->dispatch().
 *
 * This class is the only "real" singleton in the system. All other singletons will use
 * Symfony dependency injection.
 *
 * @author Oliver Klee <oliver@phplist.com>
 */
class Bootstrap
{
    private static ?Bootstrap $instance = null;
    private bool $isConfigured = false;
    private string $environment = Environment::DEFAULT_ENVIRONMENT;
    private ?ApplicationKernel $applicationKernel = null;
    private ApplicationStructure $applicationStructure;
    private ErrorHandler $errorHandler;

    /**
     * Protected constructor to avoid direct instantiation of this class.
     *
     * Please use getInstance instead.
     */
    protected function __construct()
    {
        $this->applicationStructure = new ApplicationStructure();
        $this->errorHandler = new ErrorHandler();
    }

    /**
     * Disable direct cloning of this object.
     */
    protected function __clone()
    {
    }

    /**
     * Returns 'this' as singleton.
     *
     * @return Bootstrap
     */
    public static function getInstance(): Bootstrap
    {
        if (self::$instance === null) {
            self::$instance = new self;
        }

        return self::$instance;
    }

    /**
     * Purges the singleton instance.
     *
     * Note: This method is intended to be used for tests only.
     *
     * @return void
     */
    public static function purgeInstance(): void
    {
        self::$instance = null;
    }

    /**
     * @param string $environment must be one of the Environment::* constants
     *
     * @return Bootstrap fluent interface
     *
     * @throws UnexpectedValueException
     */
    public function setEnvironment(string $environment): Bootstrap
    {
        Environment::validateEnvironment($environment);
        $this->environment = $environment;

        return $this;
    }

    public function getEnvironment(): string
    {
        return $this->environment;
    }

    private function isSymfonyDebugModeEnabled(): bool
    {
        return $this->environment !== Environment::PRODUCTION;
    }

    private function isDebugEnabled(): bool
    {
        return $this->environment !== Environment::PRODUCTION;
    }

    /**
     * Checks that the application is running on a local testing machine or via CLI, not on a production server.
     *
     * If a production server is detected, a 403 header is sent and execution is stopped.
     *
     * @SuppressWarnings("PHPMD.ExitExpression")
     * @SuppressWarnings("PHPMD.Superglobals")
     *
     * @return Bootstrap fluent interface
     */
    public function ensureDevelopmentOrTestingEnvironment(): self
    {
        if (isset($_ENV['APP_ENV']) && $_ENV['APP_ENV'] === Environment::TESTING) {
            return $this;
        }
        $usesProxy = isset($_SERVER['HTTP_CLIENT_IP']);
        $isOnCli = PHP_SAPI === 'cli' || PHP_SAPI === 'cli-server';
        $isLocalRequest = isset($_SERVER['REMOTE_ADDR'])
            && in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'], true);
        if ($usesProxy || (!$isOnCli && !$isLocalRequest)) {
            header('HTTP/1.0 403 Forbidden');
            exit('You are not allowed to access this file.');
        }

        return $this;
    }

    /**
     * The main entry point called at every request usually from global scope. Checks if everything is correct
     * and loads the configuration.
     *
     * @return Bootstrap fluent interface
     */
    public function configure(): Bootstrap
    {
        $this->isConfigured = true;

        return $this->loadEnvironmentVariables()
            ->configureDebugging()
            ->configureApplicationKernel();
    }

    /**
     * Loads environment variables from the application's ".env" files (if present) using Symfony Dotenv,
     * following the standard ".env" -> ".env.local" -> ".env.$environment" -> ".env.$environment.local" cascade.
     *
     * ".env.dist" is a template only and must never be used to source real configuration: Symfony Dotenv
     * would otherwise silently load it (with its literal placeholder values) whenever ".env" is missing.
     *
     * @return Bootstrap fluent interface
     *
     * @throws RuntimeException if ".env" does not exist, or PHPLIST_SECRET was not resolved to a real value
     * @SuppressWarnings("PHPMD.Superglobals")
     */
    private function loadEnvironmentVariables(): Bootstrap
    {
        $applicationRoot = $this->applicationStructure->getApplicationRoot();
        $dotenvPath = $applicationRoot . '/.env';
        if (!file_exists($dotenvPath)) {
            throw new RuntimeException(
                'No ".env" file was found at "' . $dotenvPath . '". Run "composer install"/"composer update" ' .
                'to generate it from ".env.dist" (which is a template only and must not be used directly), ' .
                'or create ".env" manually with a real PHPLIST_SECRET.',
                1754766600
            );
        }

        (new Dotenv())->loadEnv($dotenvPath, 'APP_ENV', $this->environment);

        $secret = $_SERVER['PHPLIST_SECRET'] ?? $_ENV['PHPLIST_SECRET'] ?? '';
        if ($secret === '' || $secret === '%s') {
            throw new RuntimeException(
                'PHPLIST_SECRET in ".env" is missing or still set to the ".env.dist" template placeholder. ' .
                'Set it to a real, unique, freshly generated secret before starting the application.',
                1754766601
            );
        }

        return $this;
    }

    /**
     * Makes sure that configure has been called before.
     *
     * @return void
     *
     * @throws RuntimeException if configure has not been called before
     */
    private function assertConfigureHasBeenCalled(): void
    {
        if (!$this->isConfigured) {
            throw new RuntimeException('Please call configure() first.', 1501170550);
        }
    }

    /**
     * Dispatches the current HTTP request (if there is any).
     *
     * @return null
     *
     * @throws RuntimeException if configure has not been called before
     * @throws Exception
     */
    public function dispatch()
    {
        $this->assertConfigureHasBeenCalled();

        $request = Request::createFromGlobals();
        $response = $this->getApplicationKernel()->handle($request);
        $response->send();
        $this->getApplicationKernel()->terminate($request, $response);

        return null;
    }

    /**
     * @return Bootstrap fluent interface
     */
    private function configureDebugging(): Bootstrap
    {
        if ($this->isDebugEnabled()) {
            $this->errorHandler->register();
        }

        return $this;
    }

    /**
     * @return Bootstrap fluent interface
     */
    private function configureApplicationKernel(): Bootstrap
    {
        $this->applicationKernel = new ApplicationKernel(
            $this->getEnvironment(),
            $this->isSymfonyDebugModeEnabled()
        );

        return $this;
    }

    /**
     * @return ApplicationKernel
     *
     * @throws RuntimeException if configure has not been called before
     */
    public function getApplicationKernel(): ApplicationKernel
    {
        $this->assertConfigureHasBeenCalled();

        return $this->applicationKernel;
    }

    /**
     * Returns the Symfony DI container.
     *
     * @return ContainerInterface
     */
    public function getContainer(): ContainerInterface
    {
        $this->applicationKernel->boot();

        return $this->getApplicationKernel()->getContainer();
    }

    /**
     * @return EntityManagerInterface
     *
     * @throws RuntimeException if configure has not been called before
     */
    public function getEntityManager(): EntityManagerInterface
    {
        $this->assertConfigureHasBeenCalled();

        return $this->getContainer()->get('doctrine.orm.entity_manager');
    }

    /**
     * Returns the absolute path to the application root.
     *
     * When core is installed as a dependency (library) of an application, this method will return
     * the application's package path.
     *
     * When phpList4-core is installed stand-alone (i.e., as an application - usually only for testing),
     * this method will be the phpList4-core package path.
     *
     * @return string the absolute path without the trailing slash.
     *
     * @throws RuntimeException if there is no composer.json in the application root
     */
    public function getApplicationRoot(): string
    {
        return $this->applicationStructure->getApplicationRoot();
    }
}
