<?php

declare(strict_types=1);

/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

namespace TYPO3\CMS\Install\Controller;

use Doctrine\DBAL\DriverManager;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Routing\RouteRedirect;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Core\Authentication\CommandLineUserCreation;
use TYPO3\CMS\Core\Configuration\ConfigurationManager;
use TYPO3\CMS\Core\Core\BootService;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Schema\Exception\StatementException;
use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Imaging\IconRegistry;
use TYPO3\CMS\Core\Information\Typo3Version;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
use TYPO3\CMS\Core\Middleware\VerifyHostHeader;
use TYPO3\CMS\Core\Package\PackageManager;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Configuration\Behavior;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\ConsumableNonce;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\DirectiveHashCollection;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Middleware\PolicyBag;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Type\Map;
use TYPO3\CMS\Core\View\ViewInterface;
use TYPO3\CMS\Fluid\Core\Rendering\RenderingContextFactory;
use TYPO3\CMS\Fluid\View\FluidViewAdapter;
use TYPO3\CMS\Install\Factory\ImportMapFactory;
use TYPO3\CMS\Install\FolderStructure\DefaultFactory;
use TYPO3\CMS\Install\Service\EnableFileService;
use TYPO3\CMS\Install\Service\Exception\ConfigurationDirectoryDoesNotExistException;
use TYPO3\CMS\Install\Service\SetupDatabaseService;
use TYPO3\CMS\Install\Service\SetupService;
use TYPO3\CMS\Install\SystemEnvironment\Check;
use TYPO3\CMS\Install\SystemEnvironment\SetupCheck;
use TYPO3\CMS\Install\WebserverType;
use TYPO3Fluid\Fluid\View\TemplateView as FluidTemplateView;

/**
 * Install step controller, dispatcher class of step actions.
 *
 * @internal This class is a specific controller implementation and is not considered part of the Public TYPO3 API.
 * @phpstan-import-type Params from DriverManager
 */
#[Autoconfigure(public: true)]
final readonly class InstallerController
{
    use ControllerTrait;

    public function __construct(
        private BootService $bootService,
        private ConfigurationManager $configurationManager,
        private PackageManager $packageManager,
        private VerifyHostHeader $verifyHostHeader,
        private FormProtectionFactory $formProtectionFactory,
        private SetupService $setupService,
        private SetupDatabaseService $setupDatabaseService,
        private ImportMapFactory $importMapFactory,
        private HashService $hashService,
        private IconRegistry $iconRegistry,
        private DirectiveHashCollection $directiveHashCollection,
        private CommandLineUserCreation $commandLineUserCreation,
        private UriBuilder $uriBuilder,
        private RenderingContextFactory $renderingContextFactory,
        private ConnectionPool $connectionPool,
    ) {}

    /**
     * Init action loads <head> with JS initiating further stuff
     */
    public function initAction(ServerRequestInterface $request): ResponseInterface
    {
        $bust = $GLOBALS['EXEC_TIME'];
        if (!Environment::getContext()->isDevelopment()) {
            $bust = $this->hashService->hmac((new Typo3Version()) . Environment::getProjectPath(), self::class);
        }
        $sitePath = $request->getAttribute('normalizedParams')->getSitePath();
        $importMap = $this->importMapFactory->create($sitePath);
        $initModule = $importMap->resolveImport('@typo3/install/init-installer.js', true, $sitePath);
        $view = $this->initializeView($request);
        $view->assign('bust', $bust);
        $view->assign('initModule', $initModule);
        $view->assign('iconCacheIdentifier', sha1($this->iconRegistry->getBackendIconsCacheIdentifier()));
        $nonce = new ConsumableNonce();
        $view->assign('importmap', $importMap->render($sitePath, $nonce));

        return new HtmlResponse(
            $view->render('Installer/Init'),
            200,
            [
                'Content-Security-Policy' => $this->createContentSecurityPolicy()->compile(new PolicyBag(Scope::backend(), new Map(), new Behavior(), $nonce, $this->directiveHashCollection)),
                'Cache-Control' => 'no-cache, no-store',
                'Pragma' => 'no-cache',
            ]
        );
    }

    /**
     * Main layout with progress bar, header
     */
    public function mainLayoutAction(ServerRequestInterface $request): ResponseInterface
    {
        $view = $this->initializeView($request);
        return new JsonResponse([
            'success' => true,
            'html' => $view->render('Installer/MainLayout'),
        ]);
    }

    /**
     * Render "FIRST_INSTALL file need to exist" view
     */
    public function showInstallerNotAvailableAction(ServerRequestInterface $request): ResponseInterface
    {
        $view = $this->initializeView($request);
        return new JsonResponse([
            'success' => true,
            'html' => $view->render('Installer/ShowInstallerNotAvailable'),
        ]);
    }

    /**
     * Check if "environment and folders" should be shown
     */
    public function checkEnvironmentAndFoldersAction(): ResponseInterface
    {
        return new JsonResponse([
            'success' => @is_file($this->configurationManager->getSystemConfigurationFileLocation()),
        ]);
    }

    /**
     * Render "environment and folders"
     */
    public function showEnvironmentAndFoldersAction(ServerRequestInterface $request): ResponseInterface
    {
        $view = $this->initializeView($request);
        $systemCheckMessageQueue = new FlashMessageQueue('install');
        $checkMessages = (new Check())->getStatus();
        foreach ($checkMessages as $message) {
            $systemCheckMessageQueue->enqueue($message);
        }
        $setupCheckMessages = (new SetupCheck())->getStatus();
        foreach ($setupCheckMessages as $message) {
            $systemCheckMessageQueue->enqueue($message);
        }
        $folderStructureFactory = new DefaultFactory();
        $structureFacade = $folderStructureFactory->getStructure(WebserverType::fromRequest($request));
        $structureMessageQueue = $structureFacade->getStatus();
        return new JsonResponse([
            'success' => true,
            'html' => $view->render('Installer/ShowEnvironmentAndFolders'),
            'environmentStatusErrors' => $systemCheckMessageQueue->getAllMessages(ContextualFeedbackSeverity::ERROR),
            'environmentStatusWarnings' => $systemCheckMessageQueue->getAllMessages(ContextualFeedbackSeverity::WARNING),
            'structureErrors' => $structureMessageQueue->getAllMessages(ContextualFeedbackSeverity::ERROR),
        ]);
    }

    /**
     * Create main folder layout, LocalConfiguration, PackageStates
     */
    public function executeEnvironmentAndFoldersAction(ServerRequestInterface $request): ResponseInterface
    {
        $errorsFromStructure = $this->setupService->createDirectoryStructure(WebserverType::fromRequest($request));
        try {
            $this->setupService->prepareSystemSettings();
        } catch (ConfigurationDirectoryDoesNotExistException) {
            return new JsonResponse([
                'success' => false,
                'status' => $errorsFromStructure,
            ]);
        }

        return new JsonResponse([
            'success' => true,
        ]);
    }

    /**
     * Check if trusted hosts pattern needs to be adjusted
     */
    public function checkTrustedHostsPatternAction(ServerRequestInterface $request): ResponseInterface
    {
        $serverParams = $request->getServerParams();
        $host = $serverParams['HTTP_HOST'] ?? '';

        return new JsonResponse([
            'success' => $this->verifyHostHeader->isAllowedHostHeaderValue($host, $serverParams),
        ]);
    }

    /**
     * Adjust trusted hosts pattern to '.*' if it does not match yet
     */
    public function executeAdjustTrustedHostsPatternAction(ServerRequestInterface $request): ResponseInterface
    {
        $serverParams = $request->getServerParams();
        $host = $serverParams['HTTP_HOST'] ?? '';

        if (!$this->verifyHostHeader->isAllowedHostHeaderValue($host, $serverParams)) {
            $this->configurationManager->setLocalConfigurationValueByPath('SYS/trustedHostsPattern', '.*');
        }
        return new JsonResponse([
            'success' => true,
        ]);
    }

    /**
     * Check if database connect step needs to be shown
     */
    public function checkDatabaseConnectAction(): ResponseInterface
    {
        return new JsonResponse([
            'success' => $this->setupDatabaseService->isDatabaseConfigurationComplete() && $this->setupDatabaseService->isDatabaseConnectSuccessful(),
        ]);
    }

    /**
     * Show database connect step
     */
    public function showDatabaseConnectAction(ServerRequestInterface $request): ResponseInterface
    {
        $view = $this->initializeView($request);

        $driverOptions = $this->setupDatabaseService->getDriverOptions();
        $formProtection = $this->formProtectionFactory->createFromRequest($request);
        $driverOptions['executeDatabaseConnectToken'] = $formProtection->generateToken('installTool', 'executeDatabaseConnect');
        $view->assignMultiple($driverOptions);

        return new JsonResponse([
            'success' => true,
            'html' => $view->render('Installer/ShowDatabaseConnect'),
        ]);
    }

    /**
     * Test database connect data
     */
    public function executeDatabaseConnectAction(ServerRequestInterface $request): ResponseInterface
    {
        $postValues = $request->getParsedBody()['install']['values'];
        [$success, $messages] = $this->setupDatabaseService->setDefaultConnectionSettings($postValues);

        return new JsonResponse([
            'success' => $success,
            'status' => $messages,
        ]);
    }

    /**
     * Check if a database needs to be selected
     */
    public function checkDatabaseSelectAction(): ResponseInterface
    {
        return new JsonResponse([
            'success' => $this->setupDatabaseService->checkDatabaseSelect(),
        ]);
    }

    /**
     * Render "select a database"
     */
    public function showDatabaseSelectAction(ServerRequestInterface $request): ResponseInterface
    {
        $view = $this->initializeView($request);
        $formProtection = $this->formProtectionFactory->createFromRequest($request);
        $errors = [];
        try {
            $view->assign('databaseList', $this->setupDatabaseService->getDatabaseList());
        } catch (\Exception $exception) {
            $errors[] = $exception->getMessage();
        }
        $view->assignMultiple([
            'errors' => $errors,
            'executeDatabaseSelectToken' => $formProtection->generateToken('installTool', 'executeDatabaseSelect'),
            'executeCheckDatabaseRequirementsToken' => $formProtection->generateToken('installTool', 'checkDatabaseRequirements'),
        ]);
        return new JsonResponse([
            'success' => true,
            'html' => $view->render('Installer/ShowDatabaseSelect'),
        ]);
    }

    /**
     * Pre-check whether all requirements for the installed database driver and platform are fulfilled
     */
    public function checkDatabaseRequirementsAction(ServerRequestInterface $request): ResponseInterface
    {
        $success = true;
        $messages = [];
        $databaseDriverName = $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['driver'];

        $databaseName = $this->retrieveDatabaseNameFromRequest($request);
        if ($databaseName === '') {
            return new JsonResponse([
                'success' => false,
                'status' => [
                    new FlashMessage(
                        'You must select a database.',
                        'No Database selected',
                        ContextualFeedbackSeverity::ERROR
                    ),
                ],
            ]);
        }

        $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['dbname'] = $databaseName;

        foreach ($this->setupDatabaseService->checkDatabaseRequirementsForDriver($databaseDriverName) as $message) {
            if ($message->getSeverity() === ContextualFeedbackSeverity::ERROR) {
                $success = false;
                $messages[] = $message;
            }
        }

        // Check create and drop permissions
        $statusMessages = [];
        foreach ($this->setupDatabaseService->checkRequiredDatabasePermissions() as $checkRequiredPermission) {
            $statusMessages[] = new FlashMessage(
                $checkRequiredPermission,
                'Missing required permissions',
                ContextualFeedbackSeverity::ERROR
            );
        }
        if ($statusMessages !== []) {
            return new JsonResponse([
                'success' => false,
                'status' => $statusMessages,
            ]);
        }

        // if requirements are not fulfilled
        if ($success === false) {
            // remove the database again if we created it
            if ($request->getParsedBody()['install']['values']['type'] === 'new') {
                $connection = $this->connectionPool
                    ->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
                $connection
                    ->createSchemaManager()
                    ->dropDatabase($connection->quoteIdentifier($databaseName));
            }

            $this->configurationManager->removeLocalConfigurationKeysByPath(['DB/Connections/Default/dbname']);

            $message = new FlashMessage(
                sprintf(
                    'Database with name "%s" has been removed due to the following errors. '
                    . 'Please solve them first and try again. If you tried to create a new database make also sure, that the DBMS charset is to use UTF-8',
                    $databaseName
                ),
                '',
                ContextualFeedbackSeverity::INFO
            );
            array_unshift($messages, $message);
        }

        unset($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['dbname']);

        return new JsonResponse([
            'success' => $success,
            'status' => $messages,
        ]);
    }

    private function retrieveDatabaseNameFromRequest(ServerRequestInterface $request): string
    {
        $postValues = $request->getParsedBody()['install']['values'];
        if ($postValues['type'] === 'new') {
            return $postValues['new'];
        }

        if ($postValues['type'] === 'existing' && !empty($postValues['existing'])) {
            return $postValues['existing'];
        }
        return '';
    }

    /**
     * Select / create and test a database
     */
    public function executeDatabaseSelectAction(ServerRequestInterface $request): ResponseInterface
    {
        $databaseName = $this->retrieveDatabaseNameFromRequest($request);
        if ($databaseName === '') {
            return new JsonResponse([
                'success' => false,
                'status' => [
                    new FlashMessage(
                        'You must select a database.',
                        'No Database selected',
                        ContextualFeedbackSeverity::ERROR
                    ),
                ],
            ]);
        }

        $postValues = $request->getParsedBody()['install']['values'];
        if ($postValues['type'] === 'new') {
            $status = $this->setupDatabaseService->createNewDatabase($databaseName);
            if ($status->getSeverity() === ContextualFeedbackSeverity::ERROR) {
                return new JsonResponse([
                    'success' => false,
                    'status' => [$status],
                ]);
            }
        } elseif ($postValues['type'] === 'existing') {
            $status = $this->setupDatabaseService->checkExistingDatabase($databaseName);
            if ($status->getSeverity() === ContextualFeedbackSeverity::ERROR) {
                return new JsonResponse([
                    'success' => false,
                    'status' => [$status],
                ]);
            }
        }
        return new JsonResponse([
            'success' => true,
        ]);
    }

    /**
     * Check if initial data needs to be imported
     */
    public function checkDatabaseDataAction(): ResponseInterface
    {
        $existingTables = $this->connectionPool
            ->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME)
            ->createSchemaManager()
            ->listTableNames();
        return new JsonResponse([
            'success' => !empty($existingTables),
        ]);
    }

    /**
     * Render "import initial data"
     */
    public function showDatabaseDataAction(ServerRequestInterface $request): ResponseInterface
    {
        $view = $this->initializeView($request);
        $formProtection = $this->formProtectionFactory->createFromRequest($request);
        $view->assignMultiple([
            'executeDatabaseDataToken' => $formProtection->generateToken('installTool', 'executeDatabaseData'),
        ]);
        return new JsonResponse([
            'success' => true,
            'html' => $view->render('Installer/ShowDatabaseData'),
        ]);
    }

    /**
     * Create main db layout
     */
    public function executeDatabaseDataAction(ServerRequestInterface $request): ResponseInterface
    {
        $messages = [];
        $postValues = $request->getParsedBody()['install']['values'];
        $username = (string)$postValues['username'] !== '' ? $postValues['username'] : 'admin';
        // Check password and return early if not good enough
        $password = (string)($postValues['password'] ?? '');
        $email = $postValues['email'] ?? '';
        $passwordValidationErrors = $this->setupDatabaseService->getBackendUserPasswordValidationErrors($password);
        if (!empty($passwordValidationErrors)) {
            $messages[] = new FlashMessage(
                'Administrator password not secure enough!',
                '',
                ContextualFeedbackSeverity::ERROR
            );

            // Add all password validation errors to the messages array
            foreach ($passwordValidationErrors as $error) {
                $messages[] = new FlashMessage(
                    $error,
                    '',
                    ContextualFeedbackSeverity::ERROR
                );
            }

            return new JsonResponse([
                'success' => false,
                'status' => $messages,
            ]);
        }
        // Set site name
        if (!empty($postValues['sitename'])) {
            $this->setupService->setSiteName($postValues['sitename']);
        }
        try {
            $messages = $this->setupDatabaseService->importDatabaseData();
            if (!empty($messages)) {
                return new JsonResponse([
                    'success' => false,
                    'status' => $messages,
                ]);
            }
        } catch (StatementException $exception) {
            $messages[] = new FlashMessage(
                'Error detected in SQL statement:' . LF . $exception->getMessage(),
                'Import of database data could not be performed',
                ContextualFeedbackSeverity::ERROR
            );
            return new JsonResponse([
                'success' => false,
                'status' => $messages,
            ]);
        }

        $this->commandLineUserCreation->ensureCliUserExists();
        $this->setupService->createUser($username, $password, $email);
        $this->setupService->setInstallToolPassword($password);

        return new JsonResponse([
            'success' => true,
            'status' => $messages,
        ]);
    }

    /**
     * Show last "create site with theme / install distribution"
     */
    public function showDefaultConfigurationAction(ServerRequestInterface $request): ResponseInterface
    {
        $view = $this->initializeView($request);
        $formProtection = $this->formProtectionFactory->createFromRequest($request);
        $distributions = [];
        if ($this->packageManager->isPackageActive('impexp')) {
            $distributions = $this->setupService->getAvailableDistributions();
        }
        $view->assignMultiple([
            'composerMode' => Environment::isComposerMode(),
            'offerToCreateBasicSite' => $this->packageManager->isPackageActive('fluid_styled_content'),
            'distributions' => $distributions,
            'executeDefaultConfigurationToken' => $formProtection->generateToken('installTool', 'executeDefaultConfiguration'),
        ]);
        return new JsonResponse([
            'success' => true,
            'html' => $view->render('Installer/ShowDefaultConfiguration'),
        ]);
    }

    /**
     * Last step execution: clean up, remove FIRST_INSTALL file, ...
     */
    public function executeDefaultConfigurationAction(ServerRequestInterface $request): ResponseInterface
    {
        // Let the admin user redirect to the distributions page on first login
        $siteSetup = $request->getParsedBody()['install']['values']['sitesetup'] ?? '';
        $selectedDistribution = '';
        if (str_starts_with($siteSetup, 'createsite:')) {
            $selectedDistribution = substr($siteSetup, strlen('createsite:'));
            $siteSetup = 'activateDistribution';
        }
        // It is crucial to activate the package *before* loading the container
        if ($siteSetup === 'activateDistribution') {
            // Distribution handles all site creation (pages, content, site configuration)
            $this->setupService->activateDistributionPackage($selectedDistribution);
        }
        $nextStepUrl = $this->uriBuilder->buildUriFromRoute('login');

        if ($siteSetup === 'createsite') {
            $siteUrl = $request->getAttribute('normalizedParams')->getSiteUrl();
            $this->setupService->createSite('main', $siteUrl);
        } elseif ($siteSetup === 'loaddistribution'
            && !Environment::isComposerMode()
            && $this->packageManager->isPackageActive('extensionmanager')
        ) {
            // Update the URL to redirect after login to the extension manager distributions list
            $nextStepUrl = $this->uriBuilder->buildUriWithRedirect(
                'login',
                [],
                RouteRedirect::create(
                    'extensionmanager',
                    [
                        'action' => 'distributions',
                    ]
                )
            );
        }

        if (($request->getParsedBody()['install']['values']['backendgroups'] ?? '') === 'creategroups') {
            $this->setupService->createBackendUserGroups();
        }

        $this->bootService->unsetInternalContainerInstance();
        $container = $this->bootService->loadExtLocalconfDatabase(true);

        // Mark upgrade wizards as done
        $this->setupDatabaseService->markWizardsDone($container);

        // Set up all installed extensions
        // (includes e.g. publishing of assets, importing distribution data)
        $this->setupService->setupExtensions($container);

        $formProtection = $this->formProtectionFactory->createFromRequest($request);
        $formProtection->clean();

        EnableFileService::removeFirstInstallFile();

        return new JsonResponse([
            'success' => true,
            'redirect' => (string)$nextStepUrl,
        ]);
    }

    /**
     * Helper method to initialize a standalone view instance.
     */
    private function initializeView(ServerRequestInterface $request): ViewInterface
    {
        $templatePaths = [
            'templateRootPaths' => ['EXT:install/Resources/Private/Templates'],
        ];
        $renderingContext = $this->renderingContextFactory->create($templatePaths, $request);
        $fluidView = new FluidTemplateView($renderingContext);
        return new FluidViewAdapter($fluidView);
    }
}
