<?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\Module;

use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;

/**
 * This is the central point to retrieve modules from the ModuleRegistry, while
 * performing the necessary access checks, which ModuleRegistry does not deal with.
 */
readonly class ModuleProvider
{
    public function __construct(
        protected ModuleRegistry $moduleRegistry,
        protected ModuleAccessGateRegistry $gateRegistry,
    ) {}

    /**
     * Simple wrapper for the registry, which just checks if a
     * module is registered. Does NOT perform any access checks.
     */
    public function isModuleRegistered(string $identifier): bool
    {
        return $this->moduleRegistry->hasModule($identifier);
    }

    /**
     * Returns a Module for the given identifier. In case a user is given, also access checks are performed.
     */
    public function getModule(
        string $identifier,
        ?BackendUserAuthentication $user = null,
        bool $respectWorkspaceRestrictions = true
    ): ?ModuleInterface {
        if (($user === null && $this->moduleRegistry->hasModule($identifier))
            || $this->accessGranted($identifier, $user, $respectWorkspaceRestrictions)
        ) {
            $module = $this->moduleRegistry->getModule($identifier);
            if ($user !== null) {
                $this->filterInaccessibleSubModules($module, $user, $respectWorkspaceRestrictions);
            }
            return $module;
        }

        return null;
    }

    /**
     * Returns all modules either grouped by main modules or flat.
     * In case a user is given, also access checks are performed.
     *
     * @return ModuleInterface[]
     */
    public function getModules(
        ?BackendUserAuthentication $user = null,
        bool $respectWorkspaceRestrictions = true,
        bool $grouped = true
    ): array {
        if (!$grouped) {
            return array_filter(
                $this->moduleRegistry->getModules(),
                fn(ModuleInterface $module): bool => $user === null || $this->accessGranted($module->getIdentifier(), $user, $respectWorkspaceRestrictions)
            );
        }

        $availableModules = array_filter($this->moduleRegistry->getModules(), static fn(ModuleInterface $module): bool => !$module->hasParentModule());

        foreach ($availableModules as $identifier => $module) {
            if ($user !== null && !$this->accessGranted($identifier, $user, $respectWorkspaceRestrictions)) {
                unset($availableModules[$identifier]);
                continue;
            }
            if ($user !== null) {
                $this->filterInaccessibleSubModules($module, $user, $respectWorkspaceRestrictions);
            }
        }

        return $availableModules;
    }

    /**
     * Return the requested (main) module if exist and allowed, prepared
     * for menu generation or similar structured output (nested). Takes
     * TSConfig into account. Does not respect "appearance[renderInModuleMenu]".
     */
    public function getModuleForMenu(
        string $identifier,
        BackendUserAuthentication $user,
        bool $respectWorkspaceRestrictions = true
    ): ?MenuModule {
        $module = $this->getModule($identifier, $user, $respectWorkspaceRestrictions);
        if ($module === null) {
            return null;
        }
        // Before preparing the module for the menu, check if it is defined ad hidden in TSconfig
        $hideModules = GeneralUtility::trimExplode(',', $user->getTSConfig()['options.']['hideModules'] ?? '', true);
        if (in_array($identifier, $hideModules, true)) {
            return null;
        }
        $menuItem = new MenuModule(clone $module);
        if ($menuItem->isStandalone()) {
            return $menuItem;
        }
        $this->buildMenuModuleRecursively($menuItem, $module, $hideModules, $user, $respectWorkspaceRestrictions);
        if (!$menuItem->hasSubModules()) {
            // In case the main module does not have any submodules, unset it again
            return null;
        }
        return $menuItem;
    }

    /**
     * Returns all allowed modules for the current user, prepared
     * for module menu generation or similar structured output (nested).
     * Takes TSConfig and "appearance[renderInModuleMenu]" into account.
     *
     * @return MenuModule[]
     */
    public function getModulesForModuleMenu(
        BackendUserAuthentication $user,
        bool $respectWorkspaceRestrictions = true
    ): array {
        $moduleMenuItems = [];
        $moduleMenuState = $this->getModuleMenuState($user);

        // Before preparing the modules for the menu, check if we need to hide some of them (defined in TSconfig)
        $hideModules = GeneralUtility::trimExplode(',', $user->getTSConfig()['options.']['hideModules'] ?? '', true);
        foreach ($this->getModules($user, $respectWorkspaceRestrictions) as $identifier => $module) {
            if (in_array($identifier, $hideModules, true)
                || !($module->getAppearance()['renderInModuleMenu'] ?? true)
            ) {
                continue;
            }
            // Only use main modules for this first level
            if ($module->hasParentModule()) {
                continue;
            }
            $menuItem = new MenuModule(clone $module, isset($moduleMenuState[$identifier]));
            $moduleMenuItems[$identifier] = $menuItem;
            if ($menuItem->isStandalone()) {
                continue;
            }
            $this->buildMenuModuleRecursively($menuItem, $module, $hideModules, $user, $respectWorkspaceRestrictions, true);
            if (!$menuItem->hasSubModules()) {
                // In case the main module does not have any submodules, unset it again
                unset($moduleMenuItems[$identifier]);
            }
        }
        return $moduleMenuItems;
    }

    /**
     * Check access of a module for a given user
     */
    public function accessGranted(
        string $identifier,
        BackendUserAuthentication $user,
        bool $respectWorkspaceRestrictions = true
    ): bool {
        if (!$this->moduleRegistry->hasModule($identifier)) {
            return false;
        }

        $module = $this->moduleRegistry->getModule($identifier);

        if ($respectWorkspaceRestrictions && ExtensionManagementUtility::isLoaded('workspaces')) {
            $workspaceAccess = $module->getWorkspaceAccess();
            if ($workspaceAccess === '' && $module->hasParentModule()) {
                // In case workspace access is not set explicitly use the information
                // from the parent module as this access restriction is inherited.
                $workspaceAccess = $module->getParentModule()->getWorkspaceAccess();
            }
            if ($workspaceAccess !== '' && $workspaceAccess !== '*') {
                if (($workspaceAccess === 'live' && $user->workspace !== 0)
                    || ($workspaceAccess === 'offline' && $user->workspace === 0)
                ) {
                    return false;
                }
            } elseif ($user->workspace === -99 && $workspaceAccess !== '*') {
                return false;
            }
        }

        $moduleAccess = $module->getAccess();
        if ($moduleAccess === '') {
            // Early return since this module does not have any access permissions set
            return true;
        }

        if ($this->gateRegistry->has($moduleAccess)) {
            return $this->gateRegistry->get($moduleAccess)->decide($module, $user) === ModuleAccessResult::Granted;
        }

        return false;
    }

    /**
     * Returns the first module, accessible for the given user.
     * This will only return submodules or standalone modules.
     *
     * @internal not part of TYPO3's API. Only for use in TYPO3 Core.
     */
    public function getFirstAccessibleModule(BackendUserAuthentication $user): ?ModuleInterface
    {
        $modules = array_filter($this->moduleRegistry->getModules(), function (ModuleInterface $module) use ($user): bool {
            return $this->accessGranted($module->getIdentifier(), $user)
                && ($module->isStandalone() || $module->hasParentModule());
        });

        return reset($modules) ?: null;
    }

    /**
     * Get all modules with access=user, to be selected in the user/group records
     *
     * @return ModuleInterface[]
     * @internal not part of TYPO3's API. Only for use in TYPO3 Core.
     */
    public function getUserModules(): array
    {
        return array_filter($this->moduleRegistry->getModules(), static fn(ModuleInterface $module): bool => $module->getAccess() === 'user');
    }

    /**
     * Recursively removes inaccessible submodules from a module at any depth
     */
    protected function filterInaccessibleSubModules(
        ModuleInterface $module,
        BackendUserAuthentication $user,
        bool $respectWorkspaceRestrictions
    ): void {
        if (!$module->hasSubModules()) {
            return;
        }

        foreach ($module->getSubModules() as $subModuleIdentifier => $subModule) {
            if (!$this->accessGranted($subModuleIdentifier, $user, $respectWorkspaceRestrictions)) {
                $module->removeSubModule($subModuleIdentifier);
            } else {
                $this->filterInaccessibleSubModules($subModule, $user, $respectWorkspaceRestrictions);
            }
        }
    }

    /**
     * Recursively builds menu module structure, checking access, TSConfig hideModules,
     * and optionally renderInModuleMenu appearance setting at all nesting levels
     */
    protected function buildMenuModuleRecursively(
        MenuModule $menuItem,
        ModuleInterface $module,
        array $hideModules,
        BackendUserAuthentication $user,
        bool $respectWorkspaceRestrictions,
        bool $checkRenderInModuleMenu = false
    ): void {
        $moduleMenuState = $this->getModuleMenuState($user);
        foreach ($module->getSubModules() as $subModuleIdentifier => $subModule) {
            if (in_array($subModuleIdentifier, $hideModules, true)
                || ($checkRenderInModuleMenu && !($subModule->getAppearance()['renderInModuleMenu'] ?? true))
            ) {
                continue;
            }
            // Skip submodules that depend on their own submodules if they don't have any accessible ones
            if (($subModule->getAppearance()['dependsOnSubmodules'] ?? false)
                && !$this->hasAccessibleSubModules($subModule, $hideModules, $user, $respectWorkspaceRestrictions, $checkRenderInModuleMenu)
            ) {
                continue;
            }
            $subMenuItem = new MenuModule(clone $subModule, isset($moduleMenuState[$subModuleIdentifier]));
            $menuItem->addSubModule($subMenuItem);
            // Recursively build deeper levels
            if ($subModule->hasSubModules()) {
                $this->buildMenuModuleRecursively($subMenuItem, $subModule, $hideModules, $user, $respectWorkspaceRestrictions, $checkRenderInModuleMenu);
            }
        }
    }

    /**
     * Check if a module has at least one accessible submodule at any depth
     */
    protected function hasAccessibleSubModules(
        ModuleInterface $module,
        array $hideModules,
        BackendUserAuthentication $user,
        bool $respectWorkspaceRestrictions,
        bool $checkRenderInModuleMenu = false
    ): bool {
        foreach ($module->getSubModules() as $subModuleIdentifier => $subModule) {
            if (in_array($subModuleIdentifier, $hideModules, true)
                || ($checkRenderInModuleMenu && !($subModule->getAppearance()['renderInModuleMenu'] ?? true))
                || !$this->accessGranted($subModuleIdentifier, $user, $respectWorkspaceRestrictions)
            ) {
                continue;
            }
            // If this submodule is accessible, return true
            if (!($subModule->getAppearance()['dependsOnSubmodules'] ?? false)) {
                return true;
            }
            // If it depends on submodules, check recursively
            if ($this->hasAccessibleSubModules($subModule, $hideModules, $user, $respectWorkspaceRestrictions, $checkRenderInModuleMenu)) {
                return true;
            }
        }
        return false;
    }

    protected function getModuleMenuState(BackendUserAuthentication $user): array
    {
        return json_decode($user->uc['modulemenu'] ?? '{}', true);
    }
}
