<?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\Backend\Routing\Route;
use TYPO3\CMS\Backend\Routing\RouterConfigurationEvent;
use TYPO3\CMS\Core\Routing\RouteCollection;

/**
 * @internal Always use the ModuleProvider API to access modules
 */
final class ModuleRegistry
{
    /**
     * @var ModuleInterface[]
     */
    private array $modules = [];

    /**
     * This contains all available "aliases" as key, and the real module identifier as value
     * @var array<string, string>
     */
    private array $moduleAliases = [];

    /**
     * @param ModuleInterface[] $modules
     */
    public function __construct(array $modules)
    {
        array_walk($modules, $this->addModule(...));
        $this->modules = $this->applyHierarchy($this->modules);
        $this->populateAliasMapping();
    }

    private function addModule(ModuleInterface $module): void
    {
        $identifier = $module->getIdentifier();
        if (isset($this->modules[$identifier])) {
            throw new \LogicException(
                'A module with the identifier ' . $identifier . ' is already registered.',
                1642174843
            );
        }
        $this->modules[$module->getIdentifier()] = $module;
    }

    public function hasModule(string $identifier): bool
    {
        return isset($this->modules[$identifier]) || isset($this->moduleAliases[$identifier]);
    }

    public function getModule(string $identifier): ModuleInterface
    {
        if (!$this->hasModule($identifier)) {
            throw new \InvalidArgumentException(
                'Module with identifier ' . $identifier . ' does not exist.',
                1642375889
            );
        }

        // Resolve the alias to the real module
        if (isset($this->moduleAliases[$identifier])) {
            $identifier = $this->moduleAliases[$identifier];
        }
        return $this->modules[$identifier];
    }

    /**
     * @return ModuleInterface[]
     */
    public function getModules(): array
    {
        return $this->modules;
    }

    /**
     * Handle router configuration when the router is set up.
     *
     * The event registration is wired in `TYPO3\CMS\Backend\ServiceProvider::addEventListeners`
     * instead of using an `AsEventListener` because this service is not autowired, but has a
     * custom factory in `TYPO3\CMS\Backend\ServiceProvider`.
     */
    public function registerRoutesForModules(RouterConfigurationEvent $event): void
    {
        $router = $event->router;
        foreach ($this->modules as $module) {
            if (!$module->hasParentModule() && !$module->isStandalone()) {
                // Skip first level modules, which are not standalone
                continue;
            }
            $routeCollection = new RouteCollection();
            foreach ($module->getDefaultRouteOptions() as $routeIdentifier => $routeOptions) {
                $path = (string)(($routeOptions['path'] ?? false) ?: ('/' . $routeIdentifier));
                $methods = (array)($routeOptions['methods'] ?? []);
                unset($routeOptions['path'], $routeOptions['methods']);
                if ($routeIdentifier === '_default') {
                    // Add the first
                    $route = new Route($module->getPath(), $routeOptions);
                    if ($methods !== []) {
                        $route->setMethods($methods);
                    }
                    $router->addRoute($module->getIdentifier(), $route, $module->getAliases());
                } else {
                    $route = new Route($path, $routeOptions);
                    if ($methods !== []) {
                        $route->setMethods($methods);
                    }
                    $routeCollection->add($routeIdentifier, $route);
                }
            }
            $routeCollection->addNamePrefix($module->getIdentifier() . '.');
            $routeCollection->addPrefix($module->getPath());
            $router->addRouteCollection($routeCollection);
        }
    }

    /**
     * Applies sorting (based on the "position" configuration) to all
     * registered modules and resolves the hierarchy (creating relations
     * by attaching modules to the $parentModule and $subModules properties).
     *
     * @param ModuleInterface[] $modules
     * @return ModuleInterface[]
     */
    private function applyHierarchy(array $modules): array
    {
        // Fetch top-level (parent) modules and fill them with sorted sub modules
        $topLevelModules = [];
        foreach ($modules as $identifier => $module) {
            if ($module->getParentIdentifier() === '') {
                $topLevelModules[$identifier] = $module;
            }
            $subModules = array_filter(
                $modules,
                static fn(ModuleInterface $mod): bool => $mod->getParentIdentifier() === $identifier
            );
            if ($subModules === []) {
                continue;
            }
            // Sort sub modules and connect them with their parent module
            $subModules = $this->applySorting($subModules);
            foreach ($subModules as $subModule) {
                $module->addSubModule($subModule);
                $subModule->setParentModule($module);
            }
        }

        // Promote single submodules to standalone modules and rebuild top-level modules afterwards
        $topLevelModules = array_filter(
            $this->promoteSingleSubmodulesToStandalone($modules, $topLevelModules),
            static fn(ModuleInterface $module): bool => !$module->hasParentModule() || $module->isStandalone()
        );

        // Sort top level modules and return all modules (flat) with the correct sorting
        return $this->flattenModules($this->applySorting($topLevelModules));
    }

    /**
     * Promotes submodules to standalone if their parent module has only one submodule and is not
     * standalone itself. This makes single submodules behave like standalone top-level modules.
     *
     * @param ModuleInterface[] $modules
     * @param ModuleInterface[] $topLevelModules
     * @return ModuleInterface[]
     */
    private function promoteSingleSubmodulesToStandalone(array $modules, array $topLevelModules): array
    {
        foreach ($topLevelModules as $parentIdentifier => $parentModule) {
            // Skip already standalone modules
            if ($parentModule->isStandalone()) {
                continue;
            }
            // Only promote if explicitly enabled via appearance setting
            if (!($parentModule->getAppearance()['promotesSingleSubmoduleToStandalone'] ?? false)) {
                continue;
            }
            $subModules = $parentModule->getSubModules();
            if (count($subModules) !== 1) {
                continue;
            }

            /** @var BaseModule $subModule */
            $subModule = reset($subModules);

            // Promote the submodule to standalone, inheriting parent properties
            $subModule->promoteToStandalone(
                $parentModule->getNavigationComponent(),
                $parentModule->getPosition(),
                $parentModule->getAliases()
            );

            // Remove parent module from registry
            unset($modules[$parentIdentifier]);
        }

        return $modules;
    }

    /**
     * Ensures that modules within one level of hierarchy are ordered properly the way
     * they were given respecting their "top", "bottom", "before" or "after" definition.
     *
     * @param ModuleInterface[] $modules
     * @return ModuleInterface[]
     */
    private function applySorting(array $modules): array
    {
        $modulePositionInformation = [];
        // First create a list of all needed data, that is the identifier, and its position
        foreach ($modules as $identifier => $module) {
            // @todo Should we enforce ['after' => '*'] in case ->getPosition() is empty?
            $modulePositionInformation[$identifier] = $module->getPosition();
            $modulePositionInformation[$identifier]['modulesToBeAddedDirectlyBefore'] = [];
            $modulePositionInformation[$identifier]['modulesToBeAddedDirectlyAfter'] = [];
        }

        // Identifiers of modules to be added at the top
        $highPriorityModules = [];
        // Identifiers of modules to be added at the bottom
        $lowPriorityModules = [];

        // Sort out the "top" and "bottom", and also build a graph of directly dependant (before/after) modules
        foreach ($modulePositionInformation as $identifier => $positionInformation) {
            if ($positionInformation['before'] ?? false) {
                if ($positionInformation['before'] === '*') {
                    // Module should be added on top
                    $highPriorityModules[] = $identifier;
                } elseif (isset($modules[$positionInformation['before']])) {
                    // Build the dependencies in case a valid module identifier is configured
                    $modulePositionInformation[$positionInformation['before']]['modulesToBeAddedDirectlyBefore'][] = $identifier;
                    $modulePositionInformation[$identifier]['modulesToBeAddedDirectlyAfter'][] = $positionInformation['before'];
                }
            } elseif ($positionInformation['after'] ?? false) {
                if ($positionInformation['after'] === '*') {
                    // Module should be added at the bottom
                    $lowPriorityModules[] = $identifier;
                } elseif (isset($modules[$positionInformation['after']])) {
                    // Build the dependencies in case a valid module identifier is configured
                    $modulePositionInformation[$identifier]['modulesToBeAddedDirectlyBefore'][] = $positionInformation['after'];
                    $modulePositionInformation[$positionInformation['after']]['modulesToBeAddedDirectlyAfter'][] = $identifier;
                }
            }
        }

        // First add the top items and their dependant modules
        $orderedModuleIdentifiers = $this->populateOrderingsForDependencies($highPriorityModules, $modulePositionInformation);
        // Now add the bottom items and their dependant modules
        // They will be cut out later, however, this is done now to also add their dependencies NOW (and not when looping over all items again)
        $orderedModuleIdentifiers = $this->populateOrderingsForDependencies($lowPriorityModules, $modulePositionInformation, $orderedModuleIdentifiers);
        $lastLowPriorityModule = end($orderedModuleIdentifiers);
        // Loop through all items and see which have not been added yet. Keep the original sorting.
        $orderedModuleIdentifiers = $this->populateOrderingsForDependencies(array_keys($modulePositionInformation), $modulePositionInformation, $orderedModuleIdentifiers);
        // Find the lowest priority module and move everything after that module to the very end
        if ($lowPriorityModules !== [] && $lastLowPriorityModule) {
            $firstLowPriorityModule = reset($lowPriorityModules);
            if ($firstLowPriorityModule !== $lastLowPriorityModule) {
                $firstPosition = array_search($firstLowPriorityModule, $orderedModuleIdentifiers, true);
                $lastPosition = array_search($lastLowPriorityModule, $orderedModuleIdentifiers, true);
                if ($firstPosition !== false && $lastPosition !== false) {
                    $extractedItems = array_slice($orderedModuleIdentifiers, $firstPosition, $lastPosition);
                    $orderedModuleIdentifiers = array_merge($orderedModuleIdentifiers, $extractedItems);
                }
            }
        }
        // Use the ordered list and replace all valid identifiers with the corresponding
        return array_replace(array_intersect_key(array_flip($orderedModuleIdentifiers), $modules), $modules);
    }

    /**
     * Given module identifiers are added based on their module position information to the
     * $alreadyOrderedModuleIdentifiers array (if not already present). In case the modules
     * to be added have dependencies to other modules and those modules exist, corresponding
     * modules are added to $alreadyOrderedModuleIdentifiers on the correct position as well.
     */
    private function populateOrderingsForDependencies(
        array $moduleIdentifiersToBeAdded,
        array $modulePositionInformation,
        array $alreadyOrderedModuleIdentifiers = []
    ): array {
        foreach ($moduleIdentifiersToBeAdded as $identifier) {
            // already placed somewhere
            if (in_array($identifier, $alreadyOrderedModuleIdentifiers, true)) {
                continue;
            }
            // Check if the current module has dependencies, which should be added BEFORE
            foreach ($modulePositionInformation[$identifier]['modulesToBeAddedDirectlyBefore'] ?? [] as $dependantIdentifier) {
                // already placed somewhere
                if (in_array($dependantIdentifier, $alreadyOrderedModuleIdentifiers, true)) {
                    continue;
                }
                // Check if the dependent module has dependencies, which should be added BEFORE
                foreach ($modulePositionInformation[$dependantIdentifier]['modulesToBeAddedDirectlyBefore'] ?? [] as $dependantDependantIdentifier) {
                    // already placed somewhere
                    if (in_array($dependantDependantIdentifier, $alreadyOrderedModuleIdentifiers, true)) {
                        continue;
                    }
                    // Add the sub dependency right away
                    $alreadyOrderedModuleIdentifiers[] = $dependantDependantIdentifier;
                }
                // Add the dependant module now
                $alreadyOrderedModuleIdentifiers[] = $dependantIdentifier;
            }
            // Add the actual module now
            $alreadyOrderedModuleIdentifiers[] = $identifier;
            // Check if the current module has dependencies, which should be added AFTER
            foreach ($modulePositionInformation[$identifier]['modulesToBeAddedDirectlyAfter'] ?? [] as $dependantIdentifier) {
                // already placed somewhere
                if (in_array($dependantIdentifier, $alreadyOrderedModuleIdentifiers, true)) {
                    continue;
                }
                // Add the dependant module right away
                $alreadyOrderedModuleIdentifiers[] = $dependantIdentifier;
                // Check if the dependent module has dependencies, which should be added AFTER
                foreach ($modulePositionInformation[$dependantIdentifier]['modulesToBeAddedDirectlyAfter'] ?? [] as $dependantDependantIdentifier) {
                    // already placed somewhere
                    if (in_array($dependantDependantIdentifier, $alreadyOrderedModuleIdentifiers, true)) {
                        continue;
                    }
                    // Add the sub dependency right away
                    $alreadyOrderedModuleIdentifiers[] = $dependantDependantIdentifier;
                }
            }
        }
        return $alreadyOrderedModuleIdentifiers;
    }

    /**
     * Create a flat modules array (looping through each level by calling "getSubmodules()" on the parent)
     */
    private function flattenModules(array $modules, $flatModules = []): array
    {
        foreach ($modules as $module) {
            $flatModules[$module->getIdentifier()] = $module;
            if ($module->hasSubmodules()) {
                $flatModules = $this->flattenModules($module->getSubmodules(), $flatModules);
            }
        }
        return $flatModules;
    }

    private function populateAliasMapping(): void
    {
        foreach ($this->modules as $moduleIdentifier => $module) {
            foreach ($module->getAliases() as $aliasIdentifier) {
                // Note: The last module defining the same alias wins in general
                $this->moduleAliases[$aliasIdentifier] = $moduleIdentifier;
            }
        }
    }

    public function getModuleAliases(): array
    {
        return $this->moduleAliases;
    }
}
