<?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\Backend\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Backend\Exception\ModuleAccessDeniedException;
use TYPO3\CMS\Backend\Exception\NoAccessibleModuleException;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\Module\ModuleInterface;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\Route;
use TYPO3\CMS\Backend\Routing\RouteRedirect;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Http\Response;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\MathUtility;

/**
 * Validates module access and extends the PSR-7 Request with the
 * resolved module object for the use in further components.
 *
 * @internal
 */
readonly class BackendModuleValidator implements MiddlewareInterface
{
    public function __construct(
        protected UriBuilder $uriBuilder,
        protected ModuleProvider $moduleProvider,
        protected FlashMessageService $flashMessageService,
        protected TcaSchemaFactory $tcaSchemaFactory,
    ) {}

    /**
     * In case the current route targets a TYPO3 backend module and the user
     * has necessary access permissions, add the module to the request.
     */
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        /** @var Route $route */
        $route = $request->getAttribute('route');
        $selectedSubModule = null;
        $inaccessibleSubModule = null;
        $ensureToPersistUserSettings = false;
        $backendUser = $GLOBALS['BE_USER'] ?? null;

        if (!$backendUser) {
            return $handler->handle($request);
        }

        // Exit if access to module was denied using module access inheritance check
        $inheritAccessFromModule = $route->getOption('inheritAccessFromModule');
        if ($inheritAccessFromModule !== null && !$this->moduleProvider->accessGranted($inheritAccessFromModule, $backendUser)) {
            return new Response(null, 403);
        }

        $module = $route->getOption('module');
        if (!$module instanceof ModuleInterface) {
            return $handler->handle($request);
        }

        // If on a second level module with further sub modules, jump to the third-level modules
        // (either the last used or the first in the list) and store this selection for the user.
        // Skip this automatic redirection if the module should show a submodule overview instead.
        if ($module->getParentModule() && $module->hasSubModules() && !$module->hasSubmoduleOverview()) {
            // Note: "action" is a special setting, which is evaluated here individually
            $subModuleIdentifier = (string)($backendUser->getModuleData($module->getIdentifier())['action'] ?? '');
            if ($module->hasSubModule($subModuleIdentifier)) {
                if ($this->moduleProvider->accessGranted($subModuleIdentifier, $backendUser)) {
                    // Use the selected sub module if user has access to it. By checking access here,
                    // we prevent that the user can no longer access the parent module, since it would
                    // always run into the ModuleAccessDeniedException.
                    $selectedSubModule = $module->getSubModule($subModuleIdentifier);
                } else {
                    // Stored sub module exists but is currently not accessible. Store the
                    // requested module to later inform the user about the forced redirect.
                    $inaccessibleSubModule = $module->getSubModule($subModuleIdentifier);
                }
            }
            if ($selectedSubModule === null) {
                // Try to fetch the first accessible sub module. We check access here to prevent
                // that the user can no longer access the parent module, since it would always run
                // into the ModuleAccessDeniedException.
                foreach ($module->getSubModules() as $subModule) {
                    if ($this->moduleProvider->accessGranted($subModule->getIdentifier(), $backendUser)) {
                        $selectedSubModule = $subModule;
                        break;
                    }
                }
            }
            if ($selectedSubModule !== null) {
                // Overwrite the requested module and the route target if an accessible sub module has been found
                $module = $selectedSubModule;
                $route->setOptions(array_replace_recursive($route->getOptions(), $module->getDefaultRouteOptions()['_default']));
            }
        } elseif (($routeIdentifier = $route->getOption('_identifier')) !== null
            && $routeIdentifier === $module->getParentModule()?->getIdentifier()
        ) {
            // In case the actually requested module is the parent of the actually resolved module,
            // the parent module does not define a route itself and uses the current third-level module
            // as fallback. Therefore, we have to check the special "action" key on the "inaccessible"
            // parent module to still allow rerouting to another (last used) third-level module.
            $inaccessibleParentModule = $module->getParentModule();
            $subModuleIdentifier = (string)($backendUser->getModuleData($inaccessibleParentModule->getIdentifier())['action'] ?? '');
            if ($inaccessibleParentModule->hasSubModule($subModuleIdentifier)) {
                $module = $inaccessibleParentModule->getSubModule($subModuleIdentifier);
                $route->setOptions(array_replace_recursive($route->getOptions(), $module->getDefaultRouteOptions()['_default']));
            }
        }

        // Validate the requested module
        try {
            $this->validateModuleAccess($request, $module);
            if ($selectedSubModule !== null && $inaccessibleSubModule !== null) {
                $this->enqueueRedirectMessage($inaccessibleSubModule, $selectedSubModule);
            }
        } catch (ModuleAccessDeniedException $e) {
            // Since the user might request a module which is just temporarily blocked, e.g. due to workspace
            // restrictions, do not throw an exception but redirect to the first accessible module - if any.
            if (($firstAccessibleModule = $this->moduleProvider->getFirstAccessibleModule($backendUser)) !== null) {
                $this->enqueueRedirectMessage($module, $firstAccessibleModule);
                return new RedirectResponse($this->uriBuilder->buildUriFromRoute($firstAccessibleModule->getIdentifier()));
            }
            // User does not have access to any module.. ¯\_(ツ)_/¯
            throw new NoAccessibleModuleException('You don\'t have access to any module.', 1702480600);
        }

        // This module request (which is usually opened inside the list_frame)
        // has been issued from a toplevel browser window (e.g. a link was opened in a new tab).
        // Redirect to open the module as frame inside the TYPO3 backend layout.
        // HEADS UP: This header will only be available in secure connections (https:// or .localhost TLD)
        if ($request->getHeaderLine('Sec-Fetch-Dest') === 'document') {
            return new RedirectResponse(
                $this->uriBuilder->buildUriWithRedirect(
                    'main',
                    [],
                    RouteRedirect::createFromRoute($route, $request->getQueryParams())
                )
            );
        }

        // Third-level module, make sure to remember the previously selected module in the parent module
        if ($module->getParentModule()?->getParentModule()) {
            $parentModuleData = $backendUser->getModuleData($module->getParentIdentifier());
            if (($parentModuleData['action'] ?? '') !== $module->getIdentifier()) {
                $parentModuleData['action'] = $module->getIdentifier();
                $backendUser->pushModuleData($module->getParentIdentifier(), $parentModuleData, true);
                $ensureToPersistUserSettings = true;
            }
        }

        // Check for module data, send via GET/POST parameters.
        // Only consider the configured keys from the module configuration.
        $requestModuleData = [];
        foreach (array_keys($module->getDefaultModuleData()) as $name) {
            $newValue = $request->getParsedBody()[$name] ?? $request->getQueryParams()[$name] ?? null;
            if ($newValue !== null) {
                $requestModuleData[$name] = $newValue;
            }
        }

        // Get stored module data
        if (!is_array(($persistedModuleData = $backendUser->getModuleData($module->getIdentifier())))) {
            $persistedModuleData = [];
        }

        // Settings were changed from the request, so they need to get persisted
        if ($requestModuleData !== []) {
            $moduleData = ModuleData::createFromModule($module, array_replace_recursive($persistedModuleData, $requestModuleData));
            $backendUser->pushModuleData($module->getIdentifier(), $moduleData->toArray(), true);
            $ensureToPersistUserSettings = true;
        } else {
            $moduleData = ModuleData::createFromModule($module, $persistedModuleData);
        }

        // Add validated module and its data to the current request
        $request = $request
            ->withAttribute('module', $module)
            ->withAttribute('moduleData', $moduleData);

        $response = $handler->handle($request);

        if ($ensureToPersistUserSettings) {
            $backendUser->writeUC();
        }

        return $response;
    }

    /**
     * Checks whether the current user is allowed to access the requested module. Does
     * also evaluate page access permissions, in case an "id" is given in the request.
     *
     * @throws ModuleAccessDeniedException
     * @throws \RuntimeException
     */
    protected function validateModuleAccess(ServerRequestInterface $request, ModuleInterface $module): void
    {
        $backendUserAuthentication = $GLOBALS['BE_USER'];
        if (!$this->moduleProvider->accessGranted($module->getIdentifier(), $backendUserAuthentication)) {
            throw new ModuleAccessDeniedException('You don\'t have access to this module.', 1642450334);
        }

        // @todo: This misuses 'id' as a broken convention for pages-uid. The filelist module for instance
        //        uses 'id' as "storage-uid:path", which is only mitigated here by testing the argument
        //        with MU:canBeInterpretedAsInteger().
        //        Also see a similar misuse in extbase BackendConfigurationManager, which does this as well
        //        to guess a pages-uid for TypoScript retrieval.
        $id = $request->getQueryParams()['id'] ?? $request->getParsedBody()['id'] ?? 0;
        if (MathUtility::canBeInterpretedAsInteger($id) && $id > 0) {
            $id = (int)$id;
            $permClause = $backendUserAuthentication->getPagePermsClause(Permission::PAGE_SHOW);
            // Check page access
            if (!is_array(BackendUtility::readPageAccess($id, $permClause))) {
                // Check if page has been deleted
                if (!$this->tcaSchemaFactory->has('pages')) {
                    throw new \RuntimeException('You don\'t have access to this page', 1289918924);
                }
                $schema = $this->tcaSchemaFactory->get('pages');
                if (!$schema->hasCapability(TcaSchemaCapability::SoftDelete)) {
                    throw new \RuntimeException('You don\'t have access to this page', 1289919924);
                }
                $deleteField = $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName();
                $pageInfo = BackendUtility::getRecord('pages', $id, $deleteField, $permClause ? ' AND ' . $permClause : '', false);
                if (!($pageInfo[$deleteField] ?? false)) {
                    throw new \RuntimeException('You don\'t have access to this page', 1289917924);
                }
            }
        }
    }

    protected function enqueueRedirectMessage(ModuleInterface $requestedModule, ModuleInterface $redirectedModule): void
    {
        $languageService = $this->getLanguageService();
        $this->flashMessageService
            ->getMessageQueueByIdentifier(FlashMessageQueue::NOTIFICATION_QUEUE)
            ->enqueue(
                new FlashMessage(
                    sprintf(
                        $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:module.noAccess.message'),
                        $languageService->sL($redirectedModule->getTitle()),
                        $languageService->sL($requestedModule->getTitle())
                    ),
                    $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:module.noAccess.title'),
                    ContextualFeedbackSeverity::INFO,
                    true
                )
            );
    }

    protected function getLanguageService(): LanguageService
    {
        return $GLOBALS['LANG'];
    }
}
